Contributing
Thanks for your interest in contributing!
Picklock is a terminal client for PyMemoryEditor. If your change is about how memory is read, written or scanned, it probably belongs upstream in PyMemoryEditor; if it is about what you type and what you see, it belongs here. When in doubt, open an issue and it will be routed.
Development setup
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
make install-dev # pip install -e ".[dev]"
The Makefile is the single source of truth for the dev commands below — run
make help to see every target. The raw command each target wraps is shown in
parentheses if you would rather run it directly.
Running the test suite
make test # pytest tests -v
The suite has two halves.
Most of it never touches a process: parsing, formatting, dispatch, help, aliases, settings. Those run in about two seconds.
tests/test_end_to_end.py drives every command against a real process —
the test process itself, the same trick PyMemoryEditor’s own suite uses, which
is why it needs no privileges and no second program to launch. It puts known
values in memory with ctypes, types the command, and asserts on the table
that comes back. What is under test is the command, not the library: a failure
there means Picklock is wrong.
The scans walk a live address space, so they add about thirty seconds.
make test includes them and CI runs the lot, which is the point: a command
body is not covered until it has run against a real process. The target
fixture skips itself if the platform refuses to open its own process, so a
hostile runner degrades to the fast half rather than failing.
Linting and type checking
make lint # flake8 picklock tests
make type-check # mypy picklock
Before you push
make pre-commit # lint + type-check + test
CI runs the same three, plus a build, on Ubuntu, Windows and macOS across
Python 3.10–3.13. The matrix matters here: the shell has to start and dispatch
identically where readline is missing (Windows), and picklock -e "version"
is smoke-tested from the installed console script on every cell.
Project layout
picklock/
├── __init__.py # Version and the public re-exports
├── __main__.py # python -m picklock
├── cli.py # argparse front end: flags, batch mode, exit statuses
├── shell.py # The REPL: line splitting, dispatch, readline, history
├── session.py # Everything a session remembers: target, results, settings
├── addressing.py # The address expression language ([...], module+offset, #N)
├── valuetypes.py # The type vocabulary and the signed/unsigned bridge
├── output.py # Every byte Picklock prints: tables, hexdump, footers
├── processes.py # Cross-platform process enumeration
├── store.py # The JSON files Picklock remembers things in
├── errors.py # CommandError and friends
└── commands/ # One module per group; each registers with @command
Two rules keep the shape:
Commands never print directly. They go through
session.printer, which is what makes output testable against aStringIO.Anything the user got wrong raises
CommandError. The shell catches that one class, prints oneERROR:line and returns to the prompt. An exception that is not aCommandErroris a bug in Picklock and is allowed to escape with its traceback.
Adding a command
Pick the module in
picklock/commands/that matches the namespace.Register the handler. The name is a colon-separated path whose first segment is one of the groups in
NAMESPACES; the heading inhelpfollows from it, so there is nothing to keep in step. Do not give it a plain-word alias: two groups could each wantread, and a test enforces that namespaced commands have none.Note the word namespace is internal. To the reader there are only commands, some of which take a subcommand — a test sweeps every help page, topic and error to keep the word out of what they see.
A name with no colon is a top-level command, reserved for the shell’s own vocabulary (
help,set,exit). Anything that touches the target belongs in a namespace, and a test enforces that too.def _mycommand_parser() -> CommandParser: parser = CommandParser("memory:mycommand") parser.add_argument("address", help="what to act on") return parser @command( "memory:mycommand", parser=_mycommand_parser, summary="One line, sentence case, ending in a period.", details="The long help, printed by 'help memory:mycommand'.", examples=("memory:mycommand 0x1000",), ) def cmd_mycommand(session: Session, args: List[str]) -> None: options = _mycommand_parser().parse_args(args) process = session.require_process("memory:mycommand") address = parse_address(options.address, session) ...
There is no
usage=: the usage line is generated from the parser, so it always names every flag the command accepts. Give each argument ahelp=and a readablemetavar— those two are what the help is built from.If the command prints a table that can be longer than a screen, page it with the shared helpers rather than a
--limitof your own:def _mycommand_parser() -> CommandParser: return add_paging_arguments(CommandParser("memory:mycommand")) page = paginate( session, rows, command="memory:mycommand", limit=options.limit, page=options.page, show_all=options.all, ) session.printer.table(headers, page.rows, total=page.total, page=page.number, pages=page.count, next_page=page.next_page)
That gives the same three flags, the same wording, the same
page N of Mfooter and the sameNext page: ...line as every other listing — and a test enforces that the wording does not drift.Names go two levels at most —
scan:keep, neverscan:results:keep. The registry rejects a third level, and a test pins that down: a deeper name buys tidiness at the cost of a listing that has to be walked twice to be read once.<command>:helpis a dispatcher convention rather than a registered command, so it answers for every command at every depth —memory:help,memory:read:help,clear:help— without onehelpcommand per group cluttering the listings it exists to print. It keeps working, but the spelling the help advertises ishelp <command>: one form for everything, and it reads as a sentence.Use
CommandParser, not a bareArgumentParser: it raises instead of callingsys.exit, which would kill the shell on a typo.Take addresses through
parse_addressso your command speaks the same[game.exe+0x10]+0x8and#3language as every other one.
Pass every argument a help= string. help <command> (and <command> --help)
builds its Arguments and Options sections from the parser itself, so
the documentation cannot drift from what the command accepts — there is only
one definition of either.
help and picklock --help are likewise generated from the registry, so a
command cannot be added without also being documented. tests/test_commands.py
enforces all of it: every command must declare a parser, every argument must
carry help text, every flag must appear in the command’s help, and a usage line
may not advertise a flag the parser does not accept. A new command is covered
the moment it is registered.
Submitting changes
Open an issue first for bug reports or substantial features.
Branch from
main. Keep commits focused.Run
make pre-commitlocally before pushing.PR titles follow Conventional Commits (
feat:,fix:,docs:, …) — CI lints the title.Describe the change and how it was tested. For a command body, paste the session.
Reporting bugs
Please include:
The output of
picklock --version— it names Picklock, PyMemoryEditor, Python and the platform.The exact command you typed and the exact output you got.
Whether you were running elevated (
sudo/ Administrator).For Linux: whether
/proc/sys/kernel/yama/ptrace_scopeis0or1.
Security
If you find a security issue, please see SECURITY.md.
Do not report it via GitHub issues.