From 2ae7f57af2e6ac024e39972acb3ab40e14dd1746 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 00:22:49 -0300 Subject: [PATCH 01/82] feat: initial release of Peekmem, a terminal client for PyMemoryEditor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peekmem is a mysql-style interactive shell over PyMemoryEditor: ASCII result tables, a one-line prompt, no GUI toolkit and no colour beyond a single highlight for errors, so it runs on a desktop, a headless server or an SSH session alike. `pip install peekmem && peekmem` is the whole setup — PyMemoryEditor is a dependency, and the shell itself is stdlib only. The command set covers the library's surface: process discovery and attach (ps/open/close/status/info), address-space introspection (regions/modules/ threads), typed read/write/dump/watch, allocate/free, the full scan and refine cycle including AOB and regex scans, and pointer chains with scanning, saving, rescanning and cross-run intersection. Two things are Peekmem's own rather than thin wrappers. Addresses are expressions — `[[game.exe+0x1a2b3c]+0x10]+0x8`, or `#3` for a scan result — so a whole pointer chain fits on one line. And scans are driven a batch of regions at a time so the progress line advances whether or not anything is being found, and Ctrl+C stops a scan while keeping what it already found. The same vocabulary runs non-interactively via -e, -f, a trailing command or a pipe, with results on stdout, errors on stderr and a non-zero exit on failure. --- .flake8 | 12 + .github/workflows/publish.yml | 28 + .github/workflows/python-package.yml | 96 ++++ .gitignore | 23 + .tool-versions | 1 + LICENSE | 21 + README.md | 229 ++++++++ peekmem/__init__.py | 31 ++ peekmem/__main__.py | 10 + peekmem/addressing.py | 199 +++++++ peekmem/cli.py | 221 ++++++++ peekmem/commands/__init__.py | 149 ++++++ peekmem/commands/memory_commands.py | 556 +++++++++++++++++++ peekmem/commands/pointer_commands.py | 481 +++++++++++++++++ peekmem/commands/process_commands.py | 231 ++++++++ peekmem/commands/scan_commands.py | 762 +++++++++++++++++++++++++++ peekmem/commands/session_commands.py | 295 +++++++++++ peekmem/errors.py | 50 ++ peekmem/output.py | 284 ++++++++++ peekmem/processes.py | 107 ++++ peekmem/py.typed | 0 peekmem/session.py | 367 +++++++++++++ peekmem/shell.py | 259 +++++++++ peekmem/valuetypes.py | 303 +++++++++++ pyproject.toml | 122 +++++ tests/conftest.py | 55 ++ tests/test_addressing.py | 89 ++++ tests/test_cli.py | 94 ++++ tests/test_commands.py | 122 +++++ tests/test_output.py | 79 +++ tests/test_session.py | 75 +++ tests/test_shell.py | 120 +++++ tests/test_valuetypes.py | 93 ++++ 33 files changed, 5564 insertions(+) create mode 100644 .flake8 create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/python-package.yml create mode 100644 .gitignore create mode 100644 .tool-versions create mode 100644 LICENSE create mode 100644 README.md create mode 100644 peekmem/__init__.py create mode 100644 peekmem/__main__.py create mode 100644 peekmem/addressing.py create mode 100644 peekmem/cli.py create mode 100644 peekmem/commands/__init__.py create mode 100644 peekmem/commands/memory_commands.py create mode 100644 peekmem/commands/pointer_commands.py create mode 100644 peekmem/commands/process_commands.py create mode 100644 peekmem/commands/scan_commands.py create mode 100644 peekmem/commands/session_commands.py create mode 100644 peekmem/errors.py create mode 100644 peekmem/output.py create mode 100644 peekmem/processes.py create mode 100644 peekmem/py.typed create mode 100644 peekmem/session.py create mode 100644 peekmem/shell.py create mode 100644 peekmem/valuetypes.py create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_addressing.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_commands.py create mode 100644 tests/test_output.py create mode 100644 tests/test_session.py create mode 100644 tests/test_shell.py create mode 100644 tests/test_valuetypes.py diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..60c8ff4 --- /dev/null +++ b/.flake8 @@ -0,0 +1,12 @@ +[flake8] + +max-line-length = 130 +# E203: whitespace before ':' (black-compatible — black puts spaces around the +# colon in slices like data[i : i + n], which conflicts with PEP 8). +# E701: multiple statements on one line (colon) — used pervasively as a style choice. +# W503: line break before binary operator (black-compatible). +ignore = E203, E701, W503 + +per-file-ignores = + # __init__.py files are allowed to have unused imports and lines-too-long. + */__init__.py:F401, E501 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9b76d0f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,28 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # Required for trusted publishing (OIDC) + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build tools + run: python -m pip install --upgrade pip build + + - name: Build package + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml new file mode 100644 index 0000000..83c7219 --- /dev/null +++ b/.github/workflows/python-package.yml @@ -0,0 +1,96 @@ +# Lint, type-check and test Peekmem on every supported platform and Python. +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python Package + +on: + # `push` restricted to `main` so feature branches only run via `pull_request` + # — otherwise every push to a branch with an open PR runs the workflow twice. + push: + branches: [main] + pull_request: + workflow_dispatch: + schedule: + - cron: '0 0 */7 * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install lint deps + run: | + python -m pip install --upgrade pip + pip install flake8 + - name: Lint + run: flake8 peekmem tests + + type-check: + needs: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install dev deps + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run mypy + run: mypy peekmem + + test: + needs: lint + # Peekmem is a terminal program with three platform backends underneath it, + # so the matrix is the whole point: the shell has to start and dispatch + # identically on Windows (no readline), Linux and macOS. + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.10', '3.11', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run tests + run: pytest -q + - name: Check the console script starts + # The suite calls main() in-process; this proves the installed entry + # point resolves and that a batch run exits cleanly. + run: | + peekmem -e "version" + peekmem -e "help scan" + peekmem ps --limit 5 + + build: + needs: [type-check, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Build the distributions + run: | + python -m pip install --upgrade pip build twine + python -m build + twine check dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..763eca4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ + +# Tooling +.pytest_cache/ +.mypy_cache/ +.coverage +coverage.xml +htmlcov/ + +# OS +.DS_Store + +# Peekmem +.peekmem_history +*.peek.json diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..30b467f --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.11.0 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cfb0565 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jean Loui Bernard Silva de Jesus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0887bc6 --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +# Peekmem + +A **terminal client for [PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor)** — read, write and scan the memory of a running process from any shell, on any machine, over any SSH session. + +--- + +

+ Cheat Engine workflows, typed instead of clicked.
+ One pip install. No GUI toolkit. No X server. No compiler. +

+ +

+ Runs on 🪟 Windows · 🐧 Linux · 🍎 macOS — desktops, servers and containers alike. +

+ +

+ Python Package + PyPI + License + Python Version + Downloads +

+ +--- + +## Install + +```bash +pip install peekmem +peekmem +``` + +That is the whole setup. Peekmem's only dependency is PyMemoryEditor, which is +pure Python — so it installs on a bare server with no wheels to build, no Qt, +and no display. + +For faster scans on large targets, add the `speed` extra. It pulls in NumPy, +which PyMemoryEditor picks up automatically to vectorise the scan loop: + +```bash +pip install "peekmem[speed]" +``` + +## A session + +```console +$ peekmem +Welcome to Peekmem 0.1.0, a terminal client for PyMemoryEditor 2.2.0. +Commands end with a newline. Type 'help' for the command list, 'help scanning' for a walkthrough, 'exit' to quit. + +peekmem> ps game ++-------+----------+ +| PID | NAME | ++-------+----------+ +| 41902 | game.exe | ++-------+----------+ +1 row in set (0.01 sec) + +peekmem> open 41902 +Attached to game.exe (PID 41902, 64-bit). (0.00 sec) + +peekmem [game.exe:41902]> scan int32 100 --writable +Showing 20 of 3184 rows (1.42 sec) + +peekmem [game.exe:41902]> next 95 ++-----+--------------------+-------+ +| ROW | ADDRESS | VALUE | ++-----+--------------------+-------+ +| #1 | 0x00000201A4C0F118 | 95 | +| #2 | 0x00000201A51E7740 | 95 | ++-----+--------------------+-------+ +2 rows in set (0.02 sec) + +peekmem [game.exe:41902]> next decreased ++-----+--------------------+-------+ +| ROW | ADDRESS | VALUE | ++-----+--------------------+-------+ +| #1 | 0x00000201A4C0F118 | 80 | ++-----+--------------------+-------+ +1 row in set (0.01 sec) + +peekmem [game.exe:41902]> write #1 int32 9999 +Wrote 4 byte(s) to 0x00000201A4C0F118. (0.00 sec) +``` + +Found the address, but it moves every launch? Find the pointer path to it, and +keep it: + +```console +peekmem [game.exe:41902]> ptrscan #1 --depth 3 --max 100 ++-----+------------------+-------------+--------------------+ +| ROW | BASE | OFFSETS | TARGET | ++-----+------------------+-------------+--------------------+ +| #1 | game.exe+0x3BA228 | 0x3E8 | 0x00000201A4C0F118 | +| #2 | game.exe+0x3B9B70 | 0x310 0x168 | 0x00000201A4C0F118 | ++-----+------------------+-------------+--------------------+ +2 rows in set (6.18 sec) + +peekmem [game.exe:41902]> ptrsave health.json +Saved 2 path(s) to health.json. + +# ... restart the target, find the value again, then: +peekmem [game.exe:52771]> ptrrescan #1 health.json +1 path(s) still reach 0x000001F73C20E118. (0.03 sec) + +peekmem [game.exe:52771]> pointer game.exe+0x3BA228 0x3E8 --write 9999 +Wrote 4 byte(s) to 0x000001F73C20E118. (0.00 sec) +``` + +## Scriptable, too + +The same vocabulary works non-interactively, which is the point of a CLI on a +server: + +```bash +peekmem ps chrome # one command, then exit +peekmem -p 4242 -e "read game.exe+0x1234 int32" # attach, read, exit +peekmem -p 4242 -e "scan int32 100" -e "results" # several, in order +peekmem -f setup.peek # a file of commands +echo "ps" | peekmem # a pipe +``` + +Results go to stdout and errors to stderr, tables are plain ASCII, colour is +off whenever the output is not a terminal, and a failing command exits +non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave. + +## What it can do + +| Group | Commands | +| --- | --- | +| **Process** | `ps` · `open` · `close` · `status` · `info` | +| **Memory** | `regions` · `modules` · `threads` · `read` · `write` · `dump` · `watch` · `alloc` · `free` | +| **Scanning** | `scan` · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | +| **Pointers** | `deref` · `pointer` · `ptrscan` · `paths` · `ptrsave` · `ptrload` · `ptrrescan` · `ptrdiff` | +| **Session** | `help` · `set` · `source` · `version` · `exit` | + +`help ` documents each one in full, with examples. `help types`, +`help address` and `help scanning` cover what several commands share. + +Highlights: + +- **Every scan comparison PyMemoryEditor exposes** — exact, not-equal, greater, + smaller, and ranges — plus the refine-only ones that need no value at all: + `next changed`, `next unchanged`, `next increased`, `next decreased`, + `next increased-by N`. +- **AOB and regex scans.** `aob "48 8B ? ? 00"` finds a signature with + wildcards; `regex "Player[0-9]+"` finds text. +- **Thirteen value types** — `int8` … `int64`, `uint8` … `uint64`, `float`, + `double`, `bool`, `string`, `bytes` — with the aliases you would expect + (`dword`, `qword`, `short`, `f32`). +- **Pointer scanning and the full rescan workflow**, so an address survives a + restart. +- **`watch`**, which turns a terminal into a live cheat table: + `watch game.exe+0x1234 int32` prints a line every time the value changes. +- **Progress you can trust.** Long scans report a percentage that advances + whether or not anything is being found, and Ctrl+C stops a scan while keeping + what it already found. + +### Addresses are expressions + +Anywhere an address is taken: + +``` +0x7ffee3a01000 a literal (decimal works too) +game.exe+0x1234 a module base plus a static offset — survives ASLR +[game.exe+0x1234]+0x10 dereference, then add +[[base+0x8]+0x20]+0x4 nested as deeply as you like +#3 the address on row 3 of the last scan +``` + +So the whole chain fits on one line: `read [[game.exe+0x1a2b3c]+0x10]+0x8 float`. + +## Permissions + +Reading another process's memory is a privileged operation everywhere: + +- **Windows** — run your terminal as Administrator to touch processes you do + not own. +- **Linux** — `sudo peekmem`, or grant the capability once with + `sudo setcap cap_sys_ptrace+ep $(readlink -f $(which python3))`. Some + distributions also need `/proc/sys/kernel/yama/ptrace_scope` set to `0`. +- **macOS** — SIP blocks reading most processes. `sudo peekmem` works for + processes you own; anything else needs a signed binary carrying the debugger + entitlement. + +Peekmem says which of these applies when an `open` is refused. + +## Peekmem vs. the PyMemoryEditor app + +They are different front ends to the same library, and installing one does not +install the other: + +| | **Peekmem** | **PyMemoryEditor's app** | +| --- | --- | --- | +| Interface | terminal, ASCII | desktop GUI (Qt) | +| Install | `pip install peekmem` | `pip install "PyMemoryEditor[app]"` | +| Needs a display | no | yes | +| Scriptable | yes — `-e`, `-f`, pipes | no | +| Good for | servers, SSH, CI, automation | interactive exploration on a desktop | + +## Related + +Peekmem is a client. Every read, write, scan and pointer walk is performed by +**[PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor)** — the +cross-platform memory library it is built on. + +⭐ **If Peekmem is useful to you, star the repo — and +[star PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor) too.** +It is the engine underneath, and it is what makes any of this work on three +operating systems at once. + +## Contributing + +Issues and pull requests are welcome. To work on Peekmem: + +```bash +git clone https://github.com/JeanExtreme002/Peekmem +cd Peekmem +pip install -e ".[dev]" +pytest +flake8 peekmem tests +mypy peekmem +``` + +The test suite never attaches to another process, so it runs anywhere. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/peekmem/__init__.py b/peekmem/__init__.py new file mode 100644 index 0000000..d8c24f0 --- /dev/null +++ b/peekmem/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + +""" +Peekmem — a plain-text terminal client for PyMemoryEditor. + +Peekmem exposes PyMemoryEditor's process introspection, memory scanning and +read/write features through an interactive shell modelled on the ``mysql`` +command-line client: ASCII result tables, a one-line prompt, no curses, no +GUI toolkit, no colour beyond a single highlight for errors. It runs anywhere +Python does — a desktop, a headless server, an SSH session, a CI job. + +The package is a *client*: every memory operation is performed by +PyMemoryEditor, which Peekmem depends on but does not vendor. +""" + +__author__ = "Jean Loui Bernard Silva de Jesus" +__version__ = "0.1.0" + +from .errors import CommandError, NoProcessError, PeekmemError +from .session import Session +from .shell import Shell + +__all__ = ( + "CommandError", + "NoProcessError", + "PeekmemError", + "Session", + "Shell", + "__author__", + "__version__", +) diff --git a/peekmem/__main__.py b/peekmem/__main__.py new file mode 100644 index 0000000..b15f0c0 --- /dev/null +++ b/peekmem/__main__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +"""Entry point for ``python -m peekmem``.""" + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/peekmem/addressing.py b/peekmem/addressing.py new file mode 100644 index 0000000..e45ff73 --- /dev/null +++ b/peekmem/addressing.py @@ -0,0 +1,199 @@ +# -*- coding: utf-8 -*- + +""" +The little address language every command shares. + +Anywhere Peekmem takes an address it takes an *expression*, so the workflows +that matter can be typed on one line instead of copied between commands: + +=============================== ========================================= +``0x7ffee3a01000`` / ``140...`` a literal, hex or decimal +``game.exe+0x1234`` a module base plus a static offset (ASLR-proof) +``"libfoo-1.so"+0x20`` the same, quoted when the name has a ``-`` +``[game.exe+0x1234]+0x10`` read the pointer there, then add ``0x10`` +``[[base+0x8]+0x20]+0x4`` a pointer chain, nested as deep as you like +``#3`` the address on row 3 of the last scan +=============================== ========================================= + +The grammar is deliberately tiny — brackets, ``+``, ``-`` and the three kinds +of term above — because an address expression that needs its own manual page +has stopped being a convenience. +""" + +from typing import TYPE_CHECKING, Any, List, Optional, Tuple + +from .errors import CommandError + +if TYPE_CHECKING: # pragma: no cover - typing only + from .session import Session + +_IDENT_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_.") + +#: Token kinds produced by :func:`_tokenize`. +_NUMBER, _IDENT, _RESULT, _OPEN, _CLOSE, _PLUS, _MINUS = range(7) + + +def _tokenize(text: str) -> List[Tuple[int, Any]]: + tokens: List[Tuple[int, Any]] = [] + index = 0 + length = len(text) + + while index < length: + char = text[index] + + if char.isspace(): + index += 1 + continue + + if char == "[": + tokens.append((_OPEN, "[")) + index += 1 + continue + + if char == "]": + tokens.append((_CLOSE, "]")) + index += 1 + continue + + if char == "+": + tokens.append((_PLUS, "+")) + index += 1 + continue + + if char == "-": + tokens.append((_MINUS, "-")) + index += 1 + continue + + if char == "#": + index += 1 + start = index + while index < length and text[index].isdigit(): + index += 1 + if start == index: + raise CommandError("'#' must be followed by a result number, e.g. #3.") + tokens.append((_RESULT, int(text[start:index]))) + continue + + if char in ("'", '"'): + end = text.find(char, index + 1) + if end == -1: + raise CommandError(f"Unterminated {char} in address expression.") + tokens.append((_IDENT, text[index + 1 : end])) + index = end + 1 + continue + + if char.lower() == "0" and text[index : index + 2].lower() == "0x": + start = index + index += 2 + while index < length and text[index] in "0123456789abcdefABCDEF_": + index += 1 + try: + tokens.append((_NUMBER, int(text[start:index].replace("_", ""), 16))) + except ValueError: + raise CommandError(f"{text[start:index]!r} is not a hex number.") + continue + + if char in _IDENT_CHARS: + start = index + while index < length and text[index] in _IDENT_CHARS: + index += 1 + word = text[start:index] + # A run of digits is a decimal literal; anything else (including + # "game.exe" and "libc.so.6") is a module name. + if word.replace("_", "").isdigit(): + tokens.append((_NUMBER, int(word.replace("_", "")))) + else: + tokens.append((_IDENT, word)) + continue + + raise CommandError(f"Unexpected character {char!r} in address expression.") + + return tokens + + +class _Parser: + """Recursive-descent parser over the token list. One expression per call.""" + + def __init__(self, tokens: List[Tuple[int, Any]], session: "Session"): + self.tokens = tokens + self.session = session + self.position = 0 + + def peek(self) -> Optional[Tuple[int, Any]]: + if self.position < len(self.tokens): + return self.tokens[self.position] + return None + + def next(self) -> Tuple[int, Any]: + token = self.peek() + if token is None: + raise CommandError("Unexpected end of address expression.") + self.position += 1 + return token + + def parse_expression(self) -> int: + value = self.parse_term() + while True: + token = self.peek() + if token is None or token[0] not in (_PLUS, _MINUS): + return value + self.position += 1 + operand = self.parse_term() + value = value + operand if token[0] == _PLUS else value - operand + + def parse_term(self) -> int: + kind, value = self.next() + + if kind == _NUMBER: + return int(value) + + if kind == _RESULT: + return self.session.result_address(int(value)) + + if kind == _IDENT: + return self.session.module_base(str(value)) + + if kind == _OPEN: + inner = self.parse_expression() + closing = self.next() + if closing[0] != _CLOSE: + raise CommandError("Missing ']' in address expression.") + return self.session.read_pointer(inner) + + raise CommandError("Expected an address, a module name or '[' here.") + + +def parse_address(text: str, session: "Session") -> int: + """Evaluate an address expression against ``session``. + + :raises CommandError: on any syntax error, unknown module, out-of-range + result index or unreadable dereference — all of which are the user's + to correct, so the shell keeps running. + """ + tokens = _tokenize(text) + if not tokens: + raise CommandError("Empty address.") + + parser = _Parser(tokens, session) + address = parser.parse_expression() + + if parser.peek() is not None: + raise CommandError(f"Trailing characters in address {text!r}.") + if address < 0: + raise CommandError(f"Address expression {text!r} resolved below zero.") + return address + + +def parse_int(text: str, what: str = "value") -> int: + """Parse a plain integer argument (hex or decimal), not an address.""" + cleaned = text.strip().replace("_", "") + try: + if cleaned[:2].lower() == "0x": + return int(cleaned, 16) + return int(cleaned, 10) + except (ValueError, IndexError): + raise CommandError(f"{text!r} is not a valid {what}.") + + +__all__ = ("parse_address", "parse_int") diff --git a/peekmem/cli.py b/peekmem/cli.py new file mode 100644 index 0000000..56c4824 --- /dev/null +++ b/peekmem/cli.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- + +""" +The ``peekmem`` entry point. + +Run bare, it opens the interactive shell. Given commands — with ``-e``, as a +trailing command line, in a file, or on standard input — it runs them and +exits with a status, so the same vocabulary works inside a script, an SSH +session or a CI job: + + peekmem # the shell + peekmem ps chrome # one command, then exit + peekmem -p 4242 -e "read game.exe+0x10" # attach, read, exit + peekmem -f setup.peek # a file of commands + echo "ps" | peekmem # a pipe +""" + +import argparse +import shlex +import sys +from typing import List, Optional, Sequence + +import PyMemoryEditor + +from . import __version__ +from .commands import GROUPS, all_commands +from .errors import CommandError, PeekmemError +from .output import Printer +from .session import Session +from .shell import Shell + +_EPILOG_INTRO = "Commands (run 'peekmem -e \"help \"' for the details):" + + +def _format_commands() -> str: + """A compact command list for ``--help``, grouped like ``help`` is.""" + commands = all_commands() + width = max(len(entry.name) for entry in commands) + lines: List[str] = [_EPILOG_INTRO, ""] + for group in GROUPS: + in_group = [entry for entry in commands if entry.group == group] + if not in_group: + continue + lines.append(f" {group}") + for entry in in_group: + lines.append(f" {entry.name.ljust(width)} {entry.summary}") + lines.append("") + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="peekmem", + description=( + "A terminal client for PyMemoryEditor: read, write and scan the " + "memory of a running process from any shell, on Windows, Linux or " + "macOS." + ), + epilog=_format_commands(), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + target = parser.add_argument_group("target") + target.add_argument("-p", "--pid", type=int, help="attach to this PID at startup") + target.add_argument("-n", "--name", help="attach to this process name at startup") + target.add_argument( + "-i", + "--ignore-case", + action="store_true", + help="match --name regardless of case", + ) + target.add_argument( + "--partial", + action="store_true", + help="match --name as a substring ('chrome' finds 'chrome.exe')", + ) + + running = parser.add_argument_group("commands") + running.add_argument( + "-e", + "--execute", + action="append", + default=[], + metavar="COMMAND", + help="run a command and exit; repeatable, run in order", + ) + running.add_argument( + "-f", + "--file", + metavar="FILE", + help="run the commands in FILE and exit", + ) + + output = parser.add_argument_group("output") + output.add_argument( + "--no-color", action="store_true", help="never emit ANSI colour" + ) + output.add_argument( + "--no-timing", action="store_true", help="omit the elapsed-time footer" + ) + output.add_argument( + "--limit", + type=int, + metavar="N", + help="rows printed per result table (0 for no limit)", + ) + output.add_argument( + "-q", "--quiet", action="store_true", help="skip the welcome banner" + ) + parser.add_argument( + "-v", + "--version", + action="version", + version=f"peekmem {__version__} (PyMemoryEditor {PyMemoryEditor.__version__})", + ) + parser.add_argument( + "command", + nargs=argparse.REMAINDER, + help="a single command to run, e.g. 'peekmem ps chrome'", + ) + return parser + + +def _startup_lines(options: argparse.Namespace) -> List[str]: + """The commands implied by the target flags, run before anything else.""" + if options.pid is None and options.name is None: + return [] + if options.pid is not None and options.name is not None: + raise CommandError("Give --pid or --name, not both.") + + parts = ["open"] + if options.pid is not None: + parts += ["--pid", str(options.pid)] + else: + parts += ["--name", shlex.quote(options.name)] + if options.ignore_case: + parts.append("-i") + if options.partial: + parts.append("--partial") + return [" ".join(parts)] + + +def _batch_lines(options: argparse.Namespace, stdin) -> Optional[List[str]]: + """The commands to run non-interactively, or ``None`` for the shell. + + Standard input counts only when it is *not* a terminal: a pipe or a + redirect is someone scripting Peekmem, while a terminal is someone who + typed ``peekmem`` and wants the prompt. + """ + lines: List[str] = [] + + lines.extend(options.execute) + + if options.command: + lines.append(" ".join(shlex.quote(part) for part in options.command)) + + if options.file: + lines.append(f"source {shlex.quote(options.file)}") + + if lines: + return lines + + if not getattr(stdin, "isatty", lambda: True)(): + return stdin.read().splitlines() + + return None + + +def main(argv: Optional[Sequence[str]] = None) -> int: + """Run Peekmem. Returns the process exit status.""" + parser = build_parser() + options = parser.parse_args(argv) + + printer = Printer( + color=False if options.no_color else None, + timing=not options.no_timing, + ) + session = Session(printer) + shell = Shell(session, printer=printer) + + if options.limit is not None: + session.set_option("limit", str(options.limit)) + + try: + startup = _startup_lines(options) + except CommandError as error: + printer.error(str(error)) + return 2 + + batch = _batch_lines(options, sys.stdin) + interactive = batch is None + + try: + # A failed --pid/--name is fatal either way: the commands that follow + # were written for a target that is not there. + for line in startup: + if not shell.run_line(line, raise_errors=False): + return 1 + + if interactive: + return shell.interact(banner=not options.quiet) + + try: + return shell.run_lines(batch or [], raise_errors=True) + finally: + session.close() + + except PeekmemError as error: + printer.error(str(error)) + return 1 + except KeyboardInterrupt: + printer.clear_progress() + printer.write() + return 130 + except BrokenPipeError: # pragma: no cover - depends on the consumer + # 'peekmem ps | head' closes the pipe early; that is not an error. + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py new file mode 100644 index 0000000..0ac8448 --- /dev/null +++ b/peekmem/commands/__init__.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- + +""" +The command registry. + +Every Peekmem command is a plain function registered with :func:`command`. +The registry owns the name, the aliases, the one-line summary and the usage +string, which means ``help`` is generated from the same data the dispatcher +uses — a command cannot be added without also being documented. + +Importing this package imports every command module for its side effect of +registering; nothing else needs to know they exist. +""" + +import argparse +import difflib +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Sequence, Tuple + +from ..errors import CommandError + +#: Groups in the order ``help`` prints them. +GROUPS: Tuple[str, ...] = ("Process", "Memory", "Scanning", "Pointers", "Session") + +Handler = Callable[..., None] + + +@dataclass(frozen=True) +class Command: + """One registered command.""" + + name: str + handler: Handler + summary: str + usage: str + group: str + aliases: Tuple[str, ...] = () + details: str = "" + examples: Tuple[str, ...] = field(default=()) + + +_COMMANDS: Dict[str, Command] = {} +_ALIASES: Dict[str, str] = {} + + +def command( + name: str, + *, + summary: str, + usage: str, + group: str, + aliases: Sequence[str] = (), + details: str = "", + examples: Sequence[str] = (), +) -> Callable[[Handler], Handler]: + """Register a command handler. + + The handler is called as ``handler(session, args)`` where ``args`` is the + already-split argument list (the command word removed). + """ + + def decorator(handler: Handler) -> Handler: + if name in _COMMANDS or name in _ALIASES: + raise RuntimeError(f"Duplicate command name: {name}") + if group not in GROUPS: + raise RuntimeError(f"Unknown command group: {group}") + + entry = Command( + name=name, + handler=handler, + summary=summary, + usage=usage, + group=group, + aliases=tuple(aliases), + details=details, + examples=tuple(examples), + ) + _COMMANDS[name] = entry + for alias in entry.aliases: + if alias in _ALIASES or alias in _COMMANDS: + raise RuntimeError(f"Duplicate command alias: {alias}") + _ALIASES[alias] = name + return handler + + return decorator + + +def lookup(name: str) -> Command: + """Resolve a command word, suggesting a near miss when there is one.""" + key = name.strip().lower() + if key in _COMMANDS: + return _COMMANDS[key] + if key in _ALIASES: + return _COMMANDS[_ALIASES[key]] + + candidates = difflib.get_close_matches(key, list(_COMMANDS) + list(_ALIASES), 1) + hint = f" Did you mean {candidates[0]!r}?" if candidates else "" + raise CommandError( + f"Unknown command {name!r}.{hint} Type 'help' for the command list." + ) + + +def all_commands() -> List[Command]: + """Every registered command, sorted by group then name.""" + return sorted( + _COMMANDS.values(), key=lambda entry: (GROUPS.index(entry.group), entry.name) + ) + + +def command_words() -> List[str]: + """Every accepted command word, for tab completion.""" + return sorted(list(_COMMANDS) + list(_ALIASES)) + + +class CommandParser(argparse.ArgumentParser): + """An ``ArgumentParser`` that raises instead of killing the shell. + + ``argparse`` calls ``sys.exit`` on a usage error, which is right for a + program and fatal for a REPL. Every failure becomes a + :class:`~peekmem.errors.CommandError`, printed as one ``ERROR:`` line. + """ + + def __init__(self, prog: str, usage: Optional[str] = None): + super().__init__(prog=prog, usage=usage, add_help=False) + + def error(self, message: str) -> None: # type: ignore[override] + raise CommandError(f"{self.prog}: {message} (try 'help {self.prog}')") + + def exit(self, status: int = 0, message: Optional[str] = None) -> None: # type: ignore[override] + if message: + raise CommandError(message.strip()) + raise CommandError(f"{self.prog}: invalid arguments (try 'help {self.prog}')") + + +from . import memory_commands # noqa: E402,F401 (registration side effect) +from . import pointer_commands # noqa: E402,F401 +from . import process_commands # noqa: E402,F401 +from . import scan_commands # noqa: E402,F401 +from . import session_commands # noqa: E402,F401 + +__all__ = ( + "Command", + "CommandParser", + "GROUPS", + "all_commands", + "command", + "command_words", + "lookup", +) diff --git a/peekmem/commands/memory_commands.py b/peekmem/commands/memory_commands.py new file mode 100644 index 0000000..fed409a --- /dev/null +++ b/peekmem/commands/memory_commands.py @@ -0,0 +1,556 @@ +# -*- coding: utf-8 -*- + +""" +Looking at, and changing, the target's memory. + +``regions`` / ``modules`` / ``threads`` describe the address space; ``read`` / +``write`` / ``dump`` / ``watch`` work on a single address; ``alloc`` / ``free`` +hand the target new pages. +""" + +import time +from typing import Any, List, Optional + +from .. import valuetypes +from ..addressing import parse_address, parse_int +from ..errors import CommandError +from ..output import LEFT, RIGHT, Timer, format_address, format_size, render_hexdump +from ..session import Session +from ..valuetypes import ValueType +from . import CommandParser, command + + +def _resolve_type(name: Optional[str]) -> ValueType: + return valuetypes.DEFAULT_TYPE if name is None else valuetypes.resolve(name) + + +def _permissions(region) -> str: + """Render a region's access bits the way ``/proc/*/maps`` does.""" + return "".join( + ( + "r" if region.is_readable else "-", + "w" if region.is_writable else "-", + "x" if region.is_executable else "-", + "s" if region.is_shared else "p", + ) + ) + + +@command( + "regions", + summary="List the target's mapped memory regions.", + usage="regions [--writable] [--executable] [--path TEXT] [--at ADDRESS] [--limit N]", + group="Memory", + aliases=("maps",), + details=( + "The memory map is re-read on every call, so it reflects allocations " + "the target made since the last look.\n\n" + " --writable / --executable / --shared keep only regions with that bit\n" + " --path TEXT keep regions backed by a file whose path contains TEXT\n" + " --at ADDRESS show only the region containing ADDRESS\n\n" + "The PERMS column reads like /proc//maps: rwx plus 's' for a " + "shared/file-backed mapping or 'p' for a private one." + ), + examples=("regions --writable", "regions --at 0x7ffee3a01000", "regions --path libc"), +) +def cmd_regions(session: Session, args: List[str]) -> None: + parser = CommandParser("regions") + parser.add_argument("--writable", action="store_true") + parser.add_argument("--executable", action="store_true") + parser.add_argument("--shared", action="store_true") + parser.add_argument("--path", default=None) + parser.add_argument("--at", default=None) + parser.add_argument("--limit", type=int, default=None) + options = parser.parse_args(args) + + process = session.require_process("regions") + + with Timer() as timer: + regions = session.regions(refresh=True) + + if options.writable: + regions = [region for region in regions if region.is_writable] + if options.executable: + regions = [region for region in regions if region.is_executable] + if options.shared: + regions = [region for region in regions if region.is_shared] + if options.path: + needle = options.path.lower() + regions = [region for region in regions if needle in region.path.lower()] + if options.at is not None: + address = parse_address(options.at, session) + regions = [ + region + for region in regions + if region.address <= address < region.address + region.size + ] + + limit = session.display_limit(options.limit) + shown = regions[:limit] if limit else regions + pointer_size = process.pointer_size + + session.printer.table( + ("ADDRESS", "SIZE", "PERMS", "PATH"), + [ + ( + format_address(region.address, pointer_size), + format_size(region.size), + _permissions(region), + region.path, + ) + for region in shown + ], + (LEFT, RIGHT, LEFT, LEFT), + elapsed=timer.elapsed, + total=len(regions), + ) + + +@command( + "modules", + summary="List the modules loaded in the target.", + usage="modules [pattern] [--limit N]", + group="Memory", + details=( + "A module is the main executable or a shared library (.dll / .so / " + ".dylib). Its BASE moves on every launch under ASLR, which is why an " + "address is best written as 'module+offset' — see 'help address'.\n\n" + "Running this command refreshes the module table the address parser " + "uses, so run it after the target loads a library." + ), + examples=("modules", "modules libc"), +) +def cmd_modules(session: Session, args: List[str]) -> None: + parser = CommandParser("modules") + parser.add_argument("pattern", nargs="?", default=None) + parser.add_argument("--limit", type=int, default=None) + options = parser.parse_args(args) + + process = session.require_process("modules") + + with Timer() as timer: + modules = list(process.get_modules()) + session.invalidate() + session.modules(refresh=True) + + if options.pattern: + needle = options.pattern.lower() + modules = [ + module + for module in modules + if needle in module.name.lower() or needle in module.path.lower() + ] + + limit = session.display_limit(options.limit) + shown = modules[:limit] if limit else modules + pointer_size = process.pointer_size + + session.printer.table( + ("NAME", "BASE", "SIZE", "PATH"), + [ + ( + module.name, + format_address(module.base_address, pointer_size), + format_size(module.size) if module.size else "?", + module.path, + ) + for module in shown + ], + (LEFT, LEFT, RIGHT, LEFT), + elapsed=timer.elapsed, + total=len(modules), + ) + + +@command( + "threads", + summary="List the target's threads.", + usage="threads [--limit N]", + group="Memory", + details=( + "STATE and PRIORITY are filled in only where the platform exposes them " + "cheaply (Linux does; Windows and macOS leave them empty). The meaning " + "of TID is platform-specific: a POSIX task id on Linux, a kernel " + "thread id on Windows, a Mach port name on macOS." + ), +) +def cmd_threads(session: Session, args: List[str]) -> None: + parser = CommandParser("threads") + parser.add_argument("--limit", type=int, default=None) + options = parser.parse_args(args) + + process = session.require_process("threads") + + with Timer() as timer: + threads = list(process.get_threads()) + + limit = session.display_limit(options.limit) + shown = threads[:limit] if limit else threads + + session.printer.table( + ("TID", "STATE", "PRIORITY"), + [ + ( + thread.tid, + thread.state if thread.state is not None else "", + thread.priority if thread.priority is not None else "", + ) + for thread in shown + ], + (RIGHT, LEFT, RIGHT), + elapsed=timer.elapsed, + total=len(threads), + ) + + +@command( + "read", + summary="Read a typed value from an address.", + usage="read
[type] [length] [--count N] [--hex]", + group="Memory", + aliases=("peek",), + details=( + "The type defaults to int32. 'string' and 'bytes' need a length in " + "bytes; the fixed-width types ignore one.\n\n" + " --count N read N consecutive values, stepping by the type's width\n" + " --hex print integers in hexadecimal\n\n" + "The address is an expression — see 'help address' — so a pointer " + "chain can be read in one go." + ), + examples=( + "read 0x7ffee3a01000", + "read game.exe+0x1234 int32", + "read [game.exe+0x1a2b3c]+0x18 float", + "read 0x7ffee3a01000 string 32", + "read #1 int32 --count 8", + ), +) +def cmd_read(session: Session, args: List[str]) -> None: + parser = CommandParser("read") + parser.add_argument("address") + parser.add_argument("type", nargs="?", default=None) + parser.add_argument("length", nargs="?", type=int, default=None) + parser.add_argument("--count", type=int, default=1) + parser.add_argument("--hex", action="store_true") + options = parser.parse_args(args) + + process = session.require_process("read") + value_type = _resolve_type(options.type) + width = value_type.read_width(options.length) + + if options.count < 1: + raise CommandError("--count must be at least 1.") + + base = parse_address(options.address, session) + hex_output = options.hex or bool(session.option("hex")) + rows = [] + + with Timer() as timer: + for index in range(options.count): + address = base + index * width + try: + raw: Any = process.read_process_memory( + address, value_type.pytype, width + ) + except OSError as error: + raise CommandError(f"Cannot read 0x{address:X}: {error}") + rows.append( + ( + format_address(address, process.pointer_size), + value_type.name, + value_type.format(value_type.decode(raw), hex_output=hex_output), + ) + ) + + session.printer.table( + ("ADDRESS", "TYPE", "VALUE"), + rows, + (LEFT, LEFT, LEFT), + elapsed=timer.elapsed, + ) + + +@command( + "write", + summary="Write a typed value to an address.", + usage="write
[--length N] [--null-terminated]", + group="Memory", + aliases=("poke",), + details=( + "The value is parsed according to the type: integers accept 0x/0o/0b " + "prefixes, booleans accept true/false/on/off, 'bytes' takes hex " + "('DE AD BE EF') and 'string' takes the text verbatim.\n\n" + " --length N buffer width for string/bytes; defaults to the\n" + " natural width of the value given\n" + " --null-terminated append a NUL after a string (C-string writes)\n\n" + "There is no confirmation and no undo. Writing into a live process can " + "crash it — read the address first if you are not sure of it." + ), + examples=( + "write 0x7ffee3a01000 int32 100", + "write game.exe+0x1234 float 99.5", + "write #2 bytes 'DE AD BE EF'", + "write 0x7ffee3a01000 string Peekmem --null-terminated", + ), +) +def cmd_write(session: Session, args: List[str]) -> None: + parser = CommandParser("write") + parser.add_argument("address") + parser.add_argument("type") + parser.add_argument("value") + parser.add_argument("--length", type=int, default=None) + parser.add_argument("--null-terminated", action="store_true") + options = parser.parse_args(args) + + process = session.require_process("write") + value_type = valuetypes.resolve(options.type) + value = value_type.parse(options.value) + width = value_type.width_for(value, options.length) + address = parse_address(options.address, session) + + if options.null_terminated and value_type.pytype is not str: + raise CommandError("--null-terminated only applies to the 'string' type.") + + with Timer() as timer: + try: + if options.null_terminated: + process.write_string(address, value, null_terminator=True) + else: + process.write_process_memory( + address, value_type.pytype, width, value_type.encode(value) + ) + except OSError as error: + raise CommandError(f"Cannot write to 0x{address:X}: {error}") + + session.printer.ok( + f"Wrote {width} byte(s) to {format_address(address, process.pointer_size)}.", + elapsed=timer.elapsed, + ) + session.printer.write() + + +@command( + "dump", + summary="Hex-dump a range of memory.", + usage="dump
[length] [--width N]", + group="Memory", + aliases=("hexdump", "x"), + details=( + "Prints the classic three-column layout: absolute address, hex bytes, " + "printable ASCII. Length defaults to 256 bytes and the line width to " + "the 'dump_width' setting.\n\n" + "The read is a single call, so a range that crosses into an unmapped " + "page fails as a whole rather than returning half the bytes." + ), + examples=("dump 0x7ffee3a01000", "dump game.exe+0x1000 512", "dump #1 64 --width 8"), +) +def cmd_dump(session: Session, args: List[str]) -> None: + parser = CommandParser("dump") + parser.add_argument("address") + parser.add_argument("length", nargs="?", default="256") + parser.add_argument("--width", type=int, default=None) + options = parser.parse_args(args) + + process = session.require_process("dump") + address = parse_address(options.address, session) + length = parse_int(str(options.length), "length") + width = options.width if options.width else int(session.option("dump_width")) + + if length < 1: + raise CommandError("Length must be at least 1 byte.") + if width < 1: + raise CommandError("Line width must be at least 1 byte.") + + with Timer() as timer: + try: + data = process.read_bytes(address, length) + except OSError as error: + raise CommandError( + f"Cannot read {length} byte(s) at 0x{address:X}: {error}" + ) + + session.printer.write(render_hexdump(data, address, width)) + session.printer.write() + session.printer.ok(f"{len(data)} bytes", elapsed=timer.elapsed) + session.printer.write() + + +@command( + "watch", + summary="Poll an address and print it as it changes.", + usage="watch
[type] [length] [--interval S] [--count N] [--all]", + group="Memory", + details=( + "Reads the address on a timer and prints a line per sample. By default " + "only samples whose value differs from the previous one are printed, " + "which turns the terminal into a change log; --all prints every " + "sample.\n\n" + " --interval S seconds between reads (default: the 'watch_interval'\n" + " setting)\n" + " --count N stop after N samples; without it, watch runs until\n" + " Ctrl+C\n\n" + "This is the terminal answer to a cheat table: leave it running in one " + "window while the target does its thing." + ), + examples=( + "watch game.exe+0x1234 int32", + "watch [base+0x10]+0x8 float --interval 0.1", + "watch #1 int32 --count 20 --all", + ), +) +def cmd_watch(session: Session, args: List[str]) -> None: + parser = CommandParser("watch") + parser.add_argument("address") + parser.add_argument("type", nargs="?", default=None) + parser.add_argument("length", nargs="?", type=int, default=None) + parser.add_argument("--interval", type=float, default=None) + parser.add_argument("--count", type=int, default=0) + parser.add_argument("--all", action="store_true") + options = parser.parse_args(args) + + process = session.require_process("watch") + value_type = _resolve_type(options.type) + width = value_type.read_width(options.length) + address = parse_address(options.address, session) + interval = ( + options.interval + if options.interval is not None + else float(session.option("watch_interval")) + ) + if interval <= 0: + raise CommandError("--interval must be greater than zero.") + + hex_output = bool(session.option("hex")) + printer = session.printer + printer.write( + f"Watching {format_address(address, process.pointer_size)} as " + f"{value_type.name} every {interval:g}s. Press Ctrl+C to stop." + ) + + samples = 0 + printed = 0 + previous = object() # A sentinel no read can equal, so sample 1 always prints. + + try: + while not options.count or samples < options.count: + try: + value = value_type.decode( + process.read_process_memory(address, value_type.pytype, width) + ) + except OSError as error: + printer.error(f"Read failed at 0x{address:X}: {error}") + break + + samples += 1 + if options.all or value != previous: + stamp = time.strftime("%H:%M:%S") + printer.write( + f"{stamp} {value_type.format(value, hex_output=hex_output)}" + ) + printed += 1 + previous = value + + if options.count and samples >= options.count: + break + time.sleep(interval) + except KeyboardInterrupt: + # Ctrl+C is how a watch is meant to end, not an error. + printer.write() + + printer.ok(f"{samples} sample(s), {printed} printed.") + printer.write() + + +@command( + "alloc", + summary="Allocate memory inside the target.", + usage="alloc [--permission N]", + group="Memory", + details=( + "Reserves and commits SIZE bytes in the target's address space and " + "prints the base address. The region stays until 'free' releases it.\n\n" + " --permission N platform-specific protection: a PAGE_* value on\n" + " Windows (default PAGE_EXECUTE_READWRITE), a VM_PROT_*\n" + " bitmask on macOS.\n\n" + "Not available on Linux, which has no cross-process allocation syscall." + ), + examples=("alloc 4096", "alloc 0x1000"), +) +def cmd_alloc(session: Session, args: List[str]) -> None: + parser = CommandParser("alloc") + parser.add_argument("size") + parser.add_argument("--permission", default=None) + options = parser.parse_args(args) + + process = session.require_process("alloc") + size = parse_int(options.size, "size") + if size < 1: + raise CommandError("Size must be at least 1 byte.") + + permission = ( + parse_int(options.permission, "permission") + if options.permission is not None + else None + ) + + with Timer() as timer: + try: + address = process.allocate_memory(size, permission=permission) + except NotImplementedError: + raise CommandError( + "Allocating inside another process is not supported on this " + "platform (Linux has no cross-process allocation syscall)." + ) + except OSError as error: + raise CommandError(f"Allocation failed: {error}") + + session.invalidate() + session.printer.ok( + f"Allocated {format_size(size)} at " + f"{format_address(address, process.pointer_size)}.", + elapsed=timer.elapsed, + ) + session.printer.write() + + +@command( + "free", + summary="Release memory allocated with 'alloc'.", + usage="free
[size]", + group="Memory", + details=( + "The size may be omitted for a region this session allocated — " + "PyMemoryEditor remembers it. Give one only to free a region it did " + "not allocate." + ), + examples=("free 0x7ffee3a01000", "free 0x7ffee3a01000 4096"), +) +def cmd_free(session: Session, args: List[str]) -> None: + parser = CommandParser("free") + parser.add_argument("address") + parser.add_argument("size", nargs="?", default=None) + options = parser.parse_args(args) + + process = session.require_process("free") + address = parse_address(options.address, session) + size = parse_int(options.size, "size") if options.size is not None else 0 + + with Timer() as timer: + try: + freed = process.free_memory(address, size) + except NotImplementedError: + raise CommandError( + "Freeing memory in another process is not supported on Linux." + ) + except OSError as error: + raise CommandError(f"Free failed: {error}") + + if not freed: + raise CommandError(f"The kernel refused to free 0x{address:X}.") + + session.invalidate() + session.printer.ok(f"Freed 0x{address:X}.", elapsed=timer.elapsed) + session.printer.write() + + +__all__ = () diff --git a/peekmem/commands/pointer_commands.py b/peekmem/commands/pointer_commands.py new file mode 100644 index 0000000..fe024f0 --- /dev/null +++ b/peekmem/commands/pointer_commands.py @@ -0,0 +1,481 @@ +# -*- coding: utf-8 -*- + +""" +Pointer chains: following them, and finding them. + +An address found by a scan is only good until the target restarts — the +allocation that held it moves. What survives is the *path* to it: a static +base inside a module plus a list of offsets to dereference. ``deref`` and +``pointer`` walk a path you already know; ``ptrscan`` searches for the paths +that reach an address, and ``ptrsave`` / ``ptrload`` / ``ptrrescan`` / +``ptrdiff`` are the workflow that turns a pile of candidates into the one path +that holds across runs. +""" + +import os +from typing import Any, List, Optional, Sequence + +from PyMemoryEditor import PointerPath + +from .. import valuetypes +from ..addressing import parse_address, parse_int +from ..errors import CommandError +from ..output import LEFT, RIGHT, Timer, format_address +from ..session import Session +from . import CommandParser, command + + +def _parse_offsets(tokens: Sequence[str]) -> List[int]: + return [parse_int(token, "offset") for token in tokens] + + +def _describe_base(path: PointerPath, pointer_size: int) -> str: + """Render a path's base the portable way when we can, absolutely otherwise.""" + if path.module and path.module_offset is not None: + return f"{path.module}+0x{path.module_offset:X}" + return format_address(path.base_address, pointer_size) + + +def _describe_offsets(path: PointerPath) -> str: + return " ".join(f"0x{offset:X}" for offset in path.offsets) or "(none)" + + +def _print_paths( + session: Session, + paths: Sequence[PointerPath], + *, + limit: Optional[int], + elapsed: Optional[float] = None, +) -> None: + process = session.require_process() + pointer_size = process.pointer_size + shown = paths[:limit] if limit else paths + + rows = [] + for index, path in enumerate(shown): + try: + target = format_address(path.resolve(process), pointer_size) + except (OSError, ValueError): + # A path that no longer resolves is exactly what a rescan is for, + # so report it as a row rather than failing the whole listing. + target = "(unresolved)" + rows.append( + (f"#{index + 1}", _describe_base(path, pointer_size), _describe_offsets(path), target) + ) + + session.printer.table( + ("ROW", "BASE", "OFFSETS", "TARGET"), + rows, + (RIGHT, LEFT, LEFT, LEFT), + elapsed=elapsed, + total=len(paths), + ) + + +@command( + "deref", + summary="Walk a pointer chain and print the address it lands on.", + usage="deref [offset ...]", + group="Pointers", + aliases=("resolve",), + details=( + "Reads the pointer at BASE, adds the first offset, reads the pointer " + "there, and so on; the last offset is added without a final read — the " + "Cheat Engine convention, so a chain copied from a cheat table works " + "unchanged.\n\n" + "BASE is a full address expression, so 'deref game.exe+0x1a2b3c 0x10 " + "0x8' is the usual spelling." + ), + examples=("deref game.exe+0x1a2b3c 0x10 0x8", "deref 0x7ffee3a01000 0x18"), +) +def cmd_deref(session: Session, args: List[str]) -> None: + parser = CommandParser("deref") + parser.add_argument("base") + parser.add_argument("offsets", nargs="*") + options = parser.parse_args(args) + + process = session.require_process("deref") + base = parse_address(options.base, session) + offsets = _parse_offsets(options.offsets) + + with Timer() as timer: + try: + address = process.resolve_pointer_chain(base, offsets) + except OSError as error: + raise CommandError(f"The chain does not resolve: {error}") + + session.printer.table( + ("BASE", "OFFSETS", "TARGET"), + [ + ( + format_address(base, process.pointer_size), + " ".join(f"0x{offset:X}" for offset in offsets) or "(none)", + format_address(address, process.pointer_size), + ) + ], + (LEFT, LEFT, LEFT), + elapsed=timer.elapsed, + ) + + +@command( + "pointer", + summary="Read or write the value at the end of a pointer chain.", + usage="pointer [offset ...] [--type T] [--length N] [--write VALUE]", + group="Pointers", + aliases=("ptr",), + details=( + "Resolves the chain and then reads (or, with --write, writes) the " + "value there — the one-line form of 'deref' followed by 'read'.\n\n" + "The chain is re-walked on every call, which is the point: it keeps " + "working after the target reallocates whatever the last link pointed " + "at." + ), + examples=( + "pointer game.exe+0x1a2b3c 0x10 0x8 --type int32", + "pointer game.exe+0x1a2b3c 0x10 --write 999", + ), +) +def cmd_pointer(session: Session, args: List[str]) -> None: + parser = CommandParser("pointer") + parser.add_argument("base") + parser.add_argument("offsets", nargs="*") + parser.add_argument("--type", dest="value_type", default=None) + parser.add_argument("--length", type=int, default=None) + parser.add_argument("--write", default=None) + options = parser.parse_args(args) + + process = session.require_process("pointer") + value_type = ( + valuetypes.DEFAULT_TYPE + if options.value_type is None + else valuetypes.resolve(options.value_type) + ) + base = parse_address(options.base, session) + offsets = _parse_offsets(options.offsets) + + with Timer() as timer: + try: + if options.write is not None: + value = value_type.parse(options.write) + width = value_type.width_for(value, options.length) + remote = process.get_pointer( + base, offsets, pytype=value_type.pytype, bufflength=width + ) + address = remote.address + remote.write(value_type.encode(value)) + action = f"Wrote {width} byte(s) to" + else: + width = value_type.read_width(options.length) + remote = process.get_pointer( + base, offsets, pytype=value_type.pytype, bufflength=width + ) + address = remote.address + value = value_type.decode(remote.read()) + action = None + except OSError as error: + raise CommandError(f"The chain does not resolve: {error}") + + if action is not None: + session.printer.ok( + f"{action} {format_address(address, process.pointer_size)}.", + elapsed=timer.elapsed, + ) + session.printer.write() + return + + session.printer.table( + ("ADDRESS", "TYPE", "VALUE"), + [ + ( + format_address(address, process.pointer_size), + value_type.name, + value_type.format(value, hex_output=bool(session.option("hex"))), + ) + ], + (LEFT, LEFT, LEFT), + elapsed=timer.elapsed, + ) + + +@command( + "ptrscan", + summary="Find static pointer paths that reach an address.", + usage="ptrscan
[--depth N] [--max-offset N] [--max N] [--unaligned] [--all-regions]", + group="Pointers", + aliases=("pointerscan",), + details=( + "Builds a map of every pointer in the target and walks it backwards " + "from ADDRESS until it reaches a static base inside a module. The " + "paths found replace whatever 'paths' was showing.\n\n" + " --depth N maximum number of links (default 3). Each extra\n" + " level costs a lot of time and memory.\n" + " --max-offset N largest offset to consider (default 1024)\n" + " --max N stop after N paths\n" + " --unaligned also consider pointers not on a pointer-size\n" + " boundary (slower, rarely needed)\n" + " --all-regions include non-writable regions in the pointer map\n\n" + "This is the expensive command in Peekmem: minutes and hundreds of " + "megabytes on a large target. Ctrl+C stops it and keeps the paths " + "found so far.\n\n" + "A path is only worth trusting once it has survived a restart: save " + "the paths, restart the target, find the address again, and run " + "'ptrrescan' — see 'help ptrrescan'." + ), + examples=("ptrscan #1", "ptrscan 0x7ffee3a01000 --depth 4 --max 200"), +) +def cmd_ptrscan(session: Session, args: List[str]) -> None: + parser = CommandParser("ptrscan") + parser.add_argument("address") + parser.add_argument("--depth", type=int, default=3) + parser.add_argument("--max-offset", type=int, default=1024) + parser.add_argument("--max", type=int, default=None) + parser.add_argument("--unaligned", action="store_true") + parser.add_argument("--all-regions", action="store_true") + options = parser.parse_args(args) + + process = session.require_process("ptrscan") + target = parse_address(options.address, session) + + if options.depth < 1: + raise CommandError("--depth must be at least 1.") + if options.max_offset < 0: + raise CommandError("--max-offset cannot be negative.") + + printer = session.printer + show_progress = bool(session.option("progress")) + + def on_progress(fraction: float) -> None: + if show_progress: + printer.progress("Mapping pointers", fraction) + + paths: List[PointerPath] = [] + interrupted = False + + with Timer() as timer: + try: + for path in process.scan_pointer_paths( + target, + max_depth=options.depth, + max_offset=options.max_offset, + aligned=not options.unaligned, + writable_only=not options.all_regions, + max_results=options.max, + progress_callback=on_progress, + ): + paths.append(path) + except KeyboardInterrupt: + interrupted = True + finally: + printer.clear_progress() + + session.pointer_paths = paths + + if interrupted: + printer.note("Interrupted — showing the paths found so far.") + if not paths: + printer.note( + "No static path reaches that address. Try a greater --depth or a " + "larger --max-offset, or check that the address is still valid." + ) + + _print_paths(session, paths, limit=session.display_limit(), elapsed=timer.elapsed) + + +@command( + "paths", + summary="Show the pointer paths currently held.", + usage="paths [--limit N] [--all]", + group="Pointers", + details=( + "Lists the paths from the last 'ptrscan', 'ptrload', 'ptrrescan' or " + "'ptrdiff'. TARGET is where each one resolves right now, so a path " + "that has gone stale shows as '(unresolved)'." + ), +) +def cmd_paths(session: Session, args: List[str]) -> None: + parser = CommandParser("paths") + parser.add_argument("--limit", type=int, default=None) + parser.add_argument("--all", action="store_true") + options = parser.parse_args(args) + + session.require_process("paths") + if not session.pointer_paths: + raise CommandError('No pointer paths. Run "ptrscan
" first.') + + limit = None if options.all else session.display_limit(options.limit) + _print_paths(session, session.pointer_paths, limit=limit) + + +@command( + "ptrsave", + summary="Save the current pointer paths to a file.", + usage="ptrsave ", + group="Pointers", + details=( + "Writes the paths as JSON, keeping the module name and module-relative " + "offset of each base so the file survives ASLR and can be re-used " + "after the target restarts." + ), + examples=("ptrsave health.json",), +) +def cmd_ptrsave(session: Session, args: List[str]) -> None: + parser = CommandParser("ptrsave") + parser.add_argument("file") + options = parser.parse_args(args) + + process = session.require_process("ptrsave") + if not session.pointer_paths: + raise CommandError("No pointer paths to save.") + + with Timer() as timer: + try: + process.save_pointer_paths(session.pointer_paths, options.file) + except OSError as error: + raise CommandError(f"Cannot write {options.file!r}: {error}") + + session.printer.ok( + f"Saved {len(session.pointer_paths)} path(s) to {options.file}.", + elapsed=timer.elapsed, + ) + session.printer.write() + + +@command( + "ptrload", + summary="Load pointer paths from a file.", + usage="ptrload ", + group="Pointers", + details=( + "Replaces the paths currently held. Each base is rebased onto the " + "module addresses of the *running* target, so a file saved before a " + "restart resolves correctly after it." + ), + examples=("ptrload health.json",), +) +def cmd_ptrload(session: Session, args: List[str]) -> None: + parser = CommandParser("ptrload") + parser.add_argument("file") + options = parser.parse_args(args) + + process = session.require_process("ptrload") + if not os.path.exists(options.file): + raise CommandError(f"No such file: {options.file}") + + with Timer() as timer: + try: + loaded = process.load_pointer_paths(options.file) + except (OSError, ValueError) as error: + raise CommandError(f"Cannot read {options.file!r}: {error}") + + rebased: List[PointerPath] = [] + for path in loaded: + try: + rebased.append(path.rebase(process)) + except (ValueError, KeyError): + # The module is not loaded in this run; keep the absolute base + # rather than dropping a path the user may still want to see. + rebased.append(path) + + session.pointer_paths = rebased + session.printer.ok( + f"Loaded {len(rebased)} path(s) from {options.file}.", elapsed=timer.elapsed + ) + session.printer.write() + _print_paths(session, rebased, limit=session.display_limit()) + + +@command( + "ptrrescan", + summary="Keep only the paths that still reach an address.", + usage="ptrrescan
[file]", + group="Pointers", + details=( + "The step that separates a real pointer path from a coincidence. " + "Restart the target, find the value's new address, then rescan the " + "saved paths against it: the ones that still land on the address are " + "the ones that describe the structure rather than that one run.\n\n" + "Without FILE, the paths currently held are rescanned." + ), + examples=("ptrrescan #1", "ptrrescan 0x7ffee3a01000 health.json"), +) +def cmd_ptrrescan(session: Session, args: List[str]) -> None: + parser = CommandParser("ptrrescan") + parser.add_argument("address") + parser.add_argument("file", nargs="?", default=None) + options = parser.parse_args(args) + + process = session.require_process("ptrrescan") + target = parse_address(options.address, session) + + source: Any = options.file + if source is None: + if not session.pointer_paths: + raise CommandError("No pointer paths to rescan. Give a file, or run 'ptrscan'.") + source = session.pointer_paths + elif not os.path.exists(source): + raise CommandError(f"No such file: {source}") + + before = len(session.pointer_paths) if options.file is None else None + + with Timer() as timer: + try: + surviving = process.rescan_pointer_paths(source, target) + except (OSError, ValueError) as error: + raise CommandError(f"Rescan failed: {error}") + + session.pointer_paths = surviving + + detail = f" (of {before})" if before is not None else "" + session.printer.ok( + f"{len(surviving)} path(s){detail} still reach " + f"{format_address(target, process.pointer_size)}.", + elapsed=timer.elapsed, + ) + session.printer.write() + _print_paths(session, surviving, limit=session.display_limit()) + + +@command( + "ptrdiff", + summary="Intersect pointer-path files from several runs.", + usage="ptrdiff [file ...]", + group="Pointers", + details=( + "Keeps only the paths present in *every* file, compared by their " + "portable recipe (module, module offset, offsets) rather than by " + "absolute address. Two or three runs of the same target usually leave " + "a handful of paths standing, and those are the reliable ones.\n\n" + "The result replaces the paths currently held, so 'ptrsave' can write " + "it straight back out." + ), + examples=("ptrdiff run1.json run2.json", "ptrdiff run1.json run2.json run3.json"), +) +def cmd_ptrdiff(session: Session, args: List[str]) -> None: + parser = CommandParser("ptrdiff") + parser.add_argument("files", nargs="*") + options = parser.parse_args(args) + + process = session.require_process("ptrdiff") + if len(options.files) < 2: + raise CommandError("ptrdiff needs at least two files.") + for name in options.files: + if not os.path.exists(name): + raise CommandError(f"No such file: {name}") + + with Timer() as timer: + try: + common = process.compare_pointer_scans(*options.files) + except (OSError, ValueError) as error: + raise CommandError(f"Comparison failed: {error}") + + session.pointer_paths = common + session.printer.ok( + f"{len(common)} path(s) present in all {len(options.files)} file(s).", + elapsed=timer.elapsed, + ) + session.printer.write() + _print_paths(session, common, limit=session.display_limit()) + + +__all__ = () diff --git a/peekmem/commands/process_commands.py b/peekmem/commands/process_commands.py new file mode 100644 index 0000000..52918b8 --- /dev/null +++ b/peekmem/commands/process_commands.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- + +"""Finding a target and attaching to it: ``ps``, ``open``, ``close``, ``info``.""" + +import platform +from typing import List + +import PyMemoryEditor + +from .. import __version__, processes +from ..errors import CommandError +from ..output import LEFT, RIGHT, Timer, format_size, render_vertical +from ..session import Session +from . import CommandParser, command + + +@command( + "ps", + summary="List the processes visible to you.", + usage="ps [pattern] [--pid-sort] [--case-sensitive] [--limit N]", + group="Process", + aliases=("processes", "list"), + details=( + "With no pattern, every visible process is listed. A pattern matches " + "the process name as a case-insensitive substring, and also matches a " + "PID exactly when it is all digits.\n\n" + "Only processes your user can see are listed. Run Peekmem elevated to " + "see (and open) processes belonging to other users." + ), + examples=("ps", "ps chrome", "ps --pid-sort --limit 50"), +) +def cmd_ps(session: Session, args: List[str]) -> None: + parser = CommandParser("ps") + parser.add_argument("pattern", nargs="?", default=None) + parser.add_argument("--pid-sort", action="store_true", help="sort by PID") + parser.add_argument("--case-sensitive", action="store_true") + parser.add_argument("--limit", type=int, default=None) + options = parser.parse_args(args) + + with Timer() as timer: + entries = processes.list_processes( + options.pattern, + case_sensitive=options.case_sensitive, + sort_by="pid" if options.pid_sort else "name", + ) + + limit = session.display_limit(options.limit) + shown = entries[:limit] if limit else entries + + session.printer.table( + ("PID", "NAME"), + # A blank name means the OS would not tell us — macOS does that for + # some system processes. Print a placeholder so the column is never + # mistaken for an empty string the process actually has. + [(pid, name or "?") for pid, name in shown], + (RIGHT, LEFT), + elapsed=timer.elapsed, + total=len(entries), + ) + + +@command( + "open", + summary="Attach to a process by PID or name.", + usage="open [-i] [--partial] [--strict-bitness]", + group="Process", + aliases=("attach", "use"), + details=( + "An all-digits target is taken as a PID, anything else as a process " + "name; force either reading with --pid or --name.\n\n" + " -i / --ignore-case match the name regardless of case.\n" + " --partial match the name as a substring ('chrome' finds\n" + " 'chrome.exe'). Fails when more than one process\n" + " matches, listing the candidates.\n" + " --strict-bitness refuse to attach when the target's 32/64-bit\n" + " width cannot be determined, instead of guessing\n" + " it from this interpreter. Worth using before a\n" + " pointer scan, where a wrong width is silent.\n\n" + "Attaching replaces any previous target and clears the scan results." + ), + examples=("open 4242", "open notepad.exe", "open chrome --partial -i"), +) +def cmd_open(session: Session, args: List[str]) -> None: + parser = CommandParser("open") + parser.add_argument("target", nargs="?", default=None) + parser.add_argument("--pid", type=int, default=None) + parser.add_argument("--name", default=None) + parser.add_argument("-i", "--ignore-case", action="store_true") + parser.add_argument("--case-sensitive", action="store_true") + parser.add_argument("--partial", action="store_true") + parser.add_argument("--strict-bitness", action="store_true") + options = parser.parse_args(args) + + pid, name = options.pid, options.name + + if options.target is not None: + if pid is not None or name is not None: + raise CommandError("Give a target, or --pid/--name — not both.") + if options.target.isdigit(): + pid = int(options.target) + else: + name = options.target + + if pid is None and name is None: + raise CommandError("open needs a PID or a process name.") + + case_sensitive = None + if options.ignore_case: + case_sensitive = False + elif options.case_sensitive: + case_sensitive = True + + with Timer() as timer: + process = session.attach( + pid=pid, + name=name, + case_sensitive=case_sensitive, + exact_match=not options.partial, + strict_bitness=options.strict_bitness, + ) + + label = session.process_name or "?" + bitness = "64-bit" if process.is_64bit else "32-bit" + if not process.is_bitness_certain: + bitness += " (assumed)" + + session.printer.ok( + f"Attached to {label} (PID {process.pid}, {bitness}).", + elapsed=timer.elapsed, + ) + + +@command( + "close", + summary="Detach from the current process.", + usage="close", + group="Process", + aliases=("detach",), + details=( + "Closes the OS handle and drops the scan results, the pointer paths " + "and the cached memory map. The target itself is untouched — nothing " + "Peekmem wrote to it is undone." + ), +) +def cmd_close(session: Session, args: List[str]) -> None: + CommandParser("close").parse_args(args) + if not session.detach(): + raise CommandError("No process attached.") + session.printer.ok("Detached.") + + +@command( + "status", + summary="Show the session state and versions.", + usage="status", + group="Process", + aliases=("\\s",), + details="Cheap: reports what the session knows without touching the target.", +) +def cmd_status(session: Session, args: List[str]) -> None: + CommandParser("status").parse_args(args) + + rows = [ + ("Peekmem", __version__), + ("PyMemoryEditor", PyMemoryEditor.__version__), + ("Python", platform.python_version()), + ("Platform", f"{platform.system()} {platform.release()} ({platform.machine()})"), + ] + + if session.process is None: + rows.append(("Process", "(none attached)")) + else: + rows.append(("Process", f"{session.process_name or '?'} (PID {session.process.pid})")) + rows.append(("Architecture", "64-bit" if session.process.is_64bit else "32-bit")) + + if session.scan is not None: + rows.append( + ( + "Scan results", + f"{len(session.scan)} address(es) — {session.scan.description}", + ) + ) + if session.pointer_paths: + rows.append(("Pointer paths", str(len(session.pointer_paths)))) + + session.printer.write(render_vertical(rows)) + session.printer.write() + + +@command( + "info", + summary="Describe the attached process in detail.", + usage="info", + group="Process", + details=( + "Enumerates the memory map to report how much of the address space is " + "mapped, so it costs a little more than 'status'." + ), +) +def cmd_info(session: Session, args: List[str]) -> None: + CommandParser("info").parse_args(args) + process = session.require_process("info") + + with Timer() as timer: + regions = session.regions(refresh=True) + mapped = sum(region.size for region in regions) + writable = sum(region.size for region in regions if region.is_writable) + executable = sum(region.size for region in regions if region.is_executable) + + main_thread = process.main_thread + + rows = [ + ("PID", process.pid), + ("Name", session.process_name or "?"), + ("Architecture", "64-bit" if process.is_64bit else "32-bit"), + ("Bitness certain", "yes" if process.is_bitness_certain else "no (assumed)"), + ("Pointer size", f"{process.pointer_size} bytes"), + ("Regions", f"{len(regions)} ({format_size(mapped)} mapped)"), + ("Writable", format_size(writable)), + ("Executable", format_size(executable)), + ("Main thread", main_thread.tid if main_thread else "unknown"), + ] + + session.printer.write(render_vertical(rows)) + session.printer.write() + if session.printer.timing: + session.printer.ok(f"Memory map read in {timer.elapsed:.2f} sec.") + session.printer.write() + + +__all__ = () diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py new file mode 100644 index 0000000..f52b3a5 --- /dev/null +++ b/peekmem/commands/scan_commands.py @@ -0,0 +1,762 @@ +# -*- coding: utf-8 -*- + +""" +Finding an address: the scan / refine cycle. + +The workflow is Cheat Engine's, typed instead of clicked. ``scan`` searches +the whole address space for a value and keeps every hit; ``next`` narrows that +set by comparing each address against a new value or against what it held +before; ``results`` shows where you are. Two or three rounds usually take a +few thousand candidates down to one. + +Progress deserves a word. PyMemoryEditor reports scan progress alongside each +*match*, so a scan that finds nothing for ten seconds would report nothing at +all — indistinguishable from a hang in a terminal. Instead of relying on that, +the runner below feeds the library one batch of regions at a time and counts +the bytes itself, which gives a progress line that advances whether or not +anything is found, lets Ctrl+C interrupt a scan and keep the partial results, +and costs nothing: the library processes regions independently anyway, so +batching them changes no result. +""" + +from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple + +from PyMemoryEditor import MemoryRegion, ScanTypesEnum + +from .. import valuetypes +from ..addressing import parse_int +from ..errors import CommandError +from ..output import LEFT, RIGHT, Timer, format_address +from ..session import ScanState, Session +from ..valuetypes import ValueType +from . import CommandParser, command + +#: Bytes of address space handed to the library per call. Large enough that +#: per-call overhead is noise next to the scan itself, small enough that the +#: progress line moves and Ctrl+C lands promptly. +_BATCH_BYTES = 64 * 1024 * 1024 + +#: Command-line comparison names, and the symbols people type instead. +_SCAN_OPS = { + "eq": ScanTypesEnum.EXACT_VALUE, + "ne": ScanTypesEnum.NOT_EXACT_VALUE, + "gt": ScanTypesEnum.BIGGER_THAN, + "lt": ScanTypesEnum.SMALLER_THAN, + "ge": ScanTypesEnum.BIGGER_THAN_OR_EXACT_VALUE, + "le": ScanTypesEnum.SMALLER_THAN_OR_EXACT_VALUE, +} +_OP_SYMBOLS = { + "=": "eq", "==": "eq", "!=": "ne", "<>": "ne", + ">": "gt", "<": "lt", ">=": "ge", "<=": "le", +} + +#: Refine-only comparisons, which need the value recorded by the last scan. +_REFINE_OPS = ( + "changed", + "unchanged", + "increased", + "decreased", + "increased-by", + "decreased-by", + "between", +) + + +def _normalize_op(name: str) -> str: + key = name.strip().lower() + return _OP_SYMBOLS.get(key, key) + + +def _batch_regions( + regions: Sequence[MemoryRegion], budget: int = _BATCH_BYTES +) -> Iterable[Tuple[List[MemoryRegion], int]]: + """Group regions into roughly ``budget``-sized batches, in address order.""" + batch: List[MemoryRegion] = [] + size = 0 + for region in regions: + batch.append(region) + size += region.size + if size >= budget: + yield batch, size + batch, size = [], 0 + if batch: + yield batch, size + + +def _run_scan( + session: Session, + search: Callable[[List[MemoryRegion]], Iterable[Any]], + *, + label: str = "Scanning", +) -> Tuple[List[int], bool, bool]: + """Drive ``search`` over the target's regions. + + :param search: called with a batch of regions, yields matching addresses. + :return: ``(addresses, truncated, interrupted)`` — ``truncated`` when the + ``max_results`` cap stopped the scan, ``interrupted`` when Ctrl+C did. + Both keep whatever was found so far, because throwing away four + minutes of scanning to punish an impatient keystroke helps nobody. + """ + regions = session.scan_regions() + total = sum(region.size for region in regions) or 1 + max_results = int(session.option("max_results")) + show_progress = bool(session.option("progress")) + printer = session.printer + + addresses: List[int] = [] + scanned = 0 + truncated = False + interrupted = False + + try: + for batch, batch_size in _batch_regions(regions): + for address in search(batch): + addresses.append(address) + if max_results and len(addresses) >= max_results: + truncated = True + break + scanned += batch_size + if show_progress: + printer.progress(label, scanned / total) + if truncated: + break + except KeyboardInterrupt: + interrupted = True + finally: + printer.clear_progress() + + return addresses, truncated, interrupted + + +def _read_values( + session: Session, + value_type: ValueType, + width: int, + addresses: Sequence[int], +) -> List[Any]: + """Read the current value at each address, in one chunked pass. + + ``search_by_addresses`` walks the region snapshot once and slices every + requested address out of it, which is dramatically cheaper than one read + call per address when there are thousands of them. Addresses it cannot + read come back as ``None`` and stay ``None`` here — the caller decides + whether that drops the row. + """ + if not addresses: + return [] + process = session.require_process() + ordered = sorted(addresses) + found = dict( + process.search_by_addresses( + value_type.pytype, + width, + ordered, + memory_regions=session.regions(), + ) + ) + return [value_type.decode(found.get(address)) for address in addresses] + + +def _store( + session: Session, + value_type: ValueType, + width: int, + addresses: Sequence[int], + description: str, + *, + truncated: bool, +) -> ScanState: + values = _read_values(session, value_type, width, addresses) + return session.store_scan( + value_type, width, addresses, values, description, truncated=truncated + ) + + +def _report( + session: Session, + state: ScanState, + elapsed: float, + *, + interrupted: bool = False, + limit: Optional[int] = None, +) -> None: + """Print the outcome of a scan: a preview table plus the count.""" + printer = session.printer + + if interrupted: + printer.note("Interrupted — showing what had been found so far.") + if state.truncated: + printer.note( + f"Stopped at the max_results cap ({session.option('max_results')}). " + "Narrow the scan, or raise it with 'set max_results N'." + ) + + _print_results(session, state, limit=limit, elapsed=elapsed) + + +def _print_results( + session: Session, + state: ScanState, + *, + limit: Optional[int], + elapsed: Optional[float], + offset: int = 0, +) -> None: + process = session.require_process() + hex_output = bool(session.option("hex")) + display_limit = session.display_limit(limit) + + end = len(state.addresses) if display_limit is None else offset + display_limit + rows = [] + for index in range(offset, min(end, len(state.addresses))): + rows.append( + ( + f"#{index + 1}", + format_address(state.addresses[index], process.pointer_size), + state.value_type.format(state.values[index], hex_output=hex_output), + ) + ) + + session.printer.table( + ("ROW", "ADDRESS", "VALUE"), + rows, + (RIGHT, LEFT, LEFT), + elapsed=elapsed, + total=len(state.addresses), + ) + + +@command( + "scan", + summary="Search the whole address space for a value.", + usage="scan [--op eq|ne|gt|lt|ge|le] | scan --between A B", + group="Scanning", + aliases=("find", "search"), + details=( + "The first scan of a cycle. Every matching address is kept as the " + "result set that 'next', 'results' and the '#N' address form work " + "on.\n\n" + " --op OP comparison against the value; eq (the default), ne,\n" + " gt, lt, ge, le. The symbols =, !=, >, <, >=, <= work\n" + " too.\n" + " --between A B keep values inside the range, inclusive\n" + " --outside invert --between\n" + " --writable scan only writable regions — much faster, and where\n" + " a changing value almost always lives\n" + " --all-regions scan everything, overriding the writable_only setting\n" + " --length N buffer width for string/bytes scans\n" + " --max N stop after N hits (default: the max_results setting)\n\n" + "Ctrl+C stops a scan and keeps what it had already found." + ), + examples=( + "scan int32 100", + "scan float 99.5 --writable", + "scan int32 --between 100 200", + "scan string Peekmem", + "scan int32 1000 --op gt", + ), +) +def cmd_scan(session: Session, args: List[str]) -> None: + parser = CommandParser("scan") + parser.add_argument("type") + parser.add_argument("value", nargs="?", default=None) + parser.add_argument("--op", default="eq") + parser.add_argument("--between", nargs=2, metavar=("A", "B"), default=None) + parser.add_argument("--outside", action="store_true") + parser.add_argument("--writable", action="store_true") + parser.add_argument("--all-regions", action="store_true") + parser.add_argument("--length", type=int, default=None) + parser.add_argument("--max", type=int, default=None) + options = parser.parse_args(args) + + process = session.require_process("scan") + value_type = valuetypes.resolve(options.type) + + if options.writable and options.all_regions: + raise CommandError("--writable and --all-regions contradict each other.") + writable_only = ( + True if options.writable + else False if options.all_regions + else bool(session.option("writable_only")) + ) + if options.max is not None: + session.set_option("max_results", str(options.max)) + + # Refresh the map before a first scan: the target has probably allocated + # since the last one, and a stale snapshot would silently skip new regions. + session.regions(refresh=True) + + if options.between is not None: + if options.value is not None: + raise CommandError("Give a value or --between, not both.") + start = value_type.parse(options.between[0]) + end = value_type.parse(options.between[1]) + width = max( + value_type.width_for(start, options.length), + value_type.width_for(end, options.length), + ) + description = ( + f"{value_type.name} {'outside' if options.outside else 'between'} " + f"{options.between[0]} and {options.between[1]}" + ) + + def search(batch: List[MemoryRegion]) -> Iterable[Any]: + return process.search_by_value_between( + value_type.pytype, + width, + value_type.encode(start), + value_type.encode(end), + not_between=options.outside, + writeable_only=writable_only, + memory_regions=batch, + ) + + else: + if options.value is None: + raise CommandError("scan needs a value, or --between A B.") + operation = _normalize_op(options.op) + if operation not in _SCAN_OPS: + raise CommandError( + f"Unknown comparison {options.op!r}. Use one of: " + + ", ".join(_SCAN_OPS) + + "." + ) + value = value_type.parse(options.value) + width = value_type.width_for(value, options.length) + scan_type = _SCAN_OPS[operation] + description = f"{value_type.name} {operation} {options.value}" + + def search(batch: List[MemoryRegion]) -> Iterable[Any]: + return process.search_by_value( + value_type.pytype, + width, + value_type.encode(value), + scan_type, + writeable_only=writable_only, + memory_regions=batch, + ) + + with Timer() as timer: + addresses, truncated, interrupted = _run_scan(session, search) + state = _store( + session, value_type, width, addresses, description, truncated=truncated + ) + + _report(session, state, timer.elapsed, interrupted=interrupted) + + +@command( + "next", + summary="Narrow the results with another comparison.", + usage="next [op] [value] — op: eq ne gt lt ge le between changed unchanged increased decreased increased-by decreased-by", + group="Scanning", + aliases=("refine",), + details=( + "Re-reads every address in the result set and keeps the ones that " + "still match. Bare 'next 100' means 'next eq 100'.\n\n" + "Comparisons against a value you supply:\n" + " eq ne gt lt ge le VALUE the usual six\n" + " between A B inside the range, inclusive\n" + " increased-by N grew by exactly N since the last scan\n" + " decreased-by N shrank by exactly N since the last scan\n\n" + "Comparisons against the previous scan, for when you do not know the " + "value — the health bar moved, but to what?\n" + " changed / unchanged differs from / equals the last reading\n" + " increased / decreased moved in that direction\n\n" + "Addresses that have become unreadable (the target freed them) are " + "dropped." + ), + examples=("next 95", "next changed", "next decreased", "next gt 50", "next between 10 20"), +) +def cmd_next(session: Session, args: List[str]) -> None: + parser = CommandParser("next") + parser.add_argument("op", nargs="?", default=None) + parser.add_argument("value", nargs="*", default=[]) + options = parser.parse_args(args) + + state = session.require_scan() + session.require_process("next") + + operation = _normalize_op(options.op) if options.op else "eq" + operands = list(options.value) + + # "next 100" — no operation word, just a value. Recognised by the first + # word not naming a comparison, which is unambiguous: no comparison name + # is also a valid value spelling. + if operation not in _SCAN_OPS and operation not in _REFINE_OPS: + if options.op is None: + raise CommandError("next needs a comparison or a value.") + operands.insert(0, options.op) + operation = "eq" + + value_type = state.value_type + needs_value = operation in _SCAN_OPS or operation in ( + "increased-by", + "decreased-by", + ) + + if operation == "between": + if len(operands) != 2: + raise CommandError("'next between' takes two values: next between A B.") + low = value_type.parse(operands[0]) + high = value_type.parse(operands[1]) + elif needs_value: + if len(operands) != 1: + raise CommandError(f"'next {operation}' takes exactly one value.") + target = value_type.parse(operands[0]) + elif operands: + raise CommandError(f"'next {operation}' takes no value.") + + with Timer() as timer: + current = _read_values(session, value_type, state.width, state.addresses) + + kept_addresses: List[int] = [] + kept_values: List[Any] = [] + + for address, previous, now in zip(state.addresses, state.values, current): + if now is None: + continue # The address is gone; it cannot match anything. + try: + if operation == "eq": + keep = now == target + elif operation == "ne": + keep = now != target + elif operation == "gt": + keep = now > target + elif operation == "lt": + keep = now < target + elif operation == "ge": + keep = now >= target + elif operation == "le": + keep = now <= target + elif operation == "between": + keep = low <= now <= high + elif operation == "changed": + keep = now != previous + elif operation == "unchanged": + keep = now == previous + elif operation == "increased": + keep = previous is not None and now > previous + elif operation == "decreased": + keep = previous is not None and now < previous + elif operation == "increased-by": + keep = previous is not None and now == previous + target + else: # decreased-by + keep = previous is not None and now == previous - target + except TypeError: + # Ordering comparisons are meaningless for some type pairs + # (a string against a number); treat that as "does not match" + # rather than aborting a refine over one odd address. + keep = False + + if keep: + kept_addresses.append(address) + kept_values.append(now) + + description = f"{state.description} → {operation}" + if operation == "between": + description += f" {operands[0]} {operands[1]}" + elif needs_value: + description += f" {operands[0]}" + + new_state = session.store_scan( + value_type, + state.width, + kept_addresses, + kept_values, + description, + ) + + _print_results(session, new_state, limit=None, elapsed=timer.elapsed) + + +@command( + "aob", + summary="Scan for a byte pattern with wildcards (AOB).", + usage="aob [--max N]", + group="Scanning", + aliases=("pattern",), + details=( + "Takes an IDA-style signature: hex bytes separated by spaces, with '?' " + "or '??' standing for any single byte. Quote it, since it contains " + "spaces.\n\n" + "This is how you find code that moves between builds: the opcodes stay " + "put while the operands change, so you wildcard the operands. The " + "result set holds the address of each match and can be refined with " + "'next' or read with 'read #1'." + ), + examples=('aob "48 8B ? ? 00 00"', 'aob "DE AD BE EF"'), +) +def cmd_aob(session: Session, args: List[str]) -> None: + parser = CommandParser("aob") + parser.add_argument("pattern") + parser.add_argument("--max", type=int, default=None) + options = parser.parse_args(args) + + process = session.require_process("aob") + + from PyMemoryEditor.util.pattern import compile_pattern + + try: + _, width = compile_pattern(options.pattern) + except ValueError as error: + raise CommandError( + f"{error} Use IDA syntax: hex bytes separated by spaces, with '?' " + "as a one-byte wildcard, e.g. '48 8B ? ? 00'." + ) + + if options.max is not None: + session.set_option("max_results", str(options.max)) + session.regions(refresh=True) + + def search(batch: List[MemoryRegion]) -> Iterable[Any]: + return process.search_by_pattern(options.pattern, memory_regions=batch) + + value_type = valuetypes.resolve("bytes") + + with Timer() as timer: + addresses, truncated, interrupted = _run_scan(session, search, label="AOB scan") + state = _store( + session, + value_type, + width, + addresses, + f"aob {options.pattern}", + truncated=truncated, + ) + + _report(session, state, timer.elapsed, interrupted=interrupted) + + +@command( + "regex", + summary="Scan for text matching a regular expression.", + usage="regex [--length N] [--max N]", + group="Scanning", + details=( + "The pattern is a normal regex, UTF-8 encoded and matched against raw " + "memory. Because the match runs over *bytes*, a metacharacter spans " + "one byte: '.' matches any single byte and '\\d' is ASCII-only, so " + "quantify with care around non-ASCII text.\n\n" + " --length N the widest match to expect, in bytes (default 64). A\n" + " regex has no fixed width, and this is what lets a match\n" + " straddling an internal chunk boundary still be found —\n" + " and how many bytes are read back for the VALUE column." + ), + examples=('regex "Player[0-9]+"', 'regex "https?://[a-z.]+" --length 128'), +) +def cmd_regex(session: Session, args: List[str]) -> None: + parser = CommandParser("regex") + parser.add_argument("pattern") + parser.add_argument("--length", type=int, default=64) + parser.add_argument("--max", type=int, default=None) + options = parser.parse_args(args) + + process = session.require_process("regex") + + if options.length < 1: + raise CommandError("--length must be at least 1 byte.") + + import re + + pattern = options.pattern.encode("utf-8") + try: + re.compile(pattern, re.DOTALL) + except re.error as error: + raise CommandError(f"Invalid regex: {error}") + + if options.max is not None: + session.set_option("max_results", str(options.max)) + session.regions(refresh=True) + + def search(batch: List[MemoryRegion]) -> Iterable[Any]: + return process.search_by_pattern( + pattern, byte_length=options.length, memory_regions=batch + ) + + # The hits are text, so report them as a string of the requested width — + # a hex dump of a matched URL helps nobody. + value_type = valuetypes.resolve("string") + + with Timer() as timer: + addresses, truncated, interrupted = _run_scan( + session, search, label="Regex scan" + ) + state = _store( + session, + value_type, + options.length, + addresses, + f"regex {options.pattern}", + truncated=truncated, + ) + + _report(session, state, timer.elapsed, interrupted=interrupted) + + +@command( + "results", + summary="Show the current result set, re-read.", + usage="results [--limit N] [--offset N] [--all]", + group="Scanning", + aliases=("res",), + details=( + "Reads every address again, so the VALUE column is what the target " + "holds now, not what it held when the scan ran. The PREVIOUS column " + "shows the value the last scan recorded — the one 'next changed' and " + "friends compare against — and is filled in only where the two " + "differ.\n\n" + "Row numbers are what '#N' refers to in an address." + ), + examples=("results", "results --all", "results --offset 20 --limit 10"), +) +def cmd_results(session: Session, args: List[str]) -> None: + parser = CommandParser("results") + parser.add_argument("--limit", type=int, default=None) + parser.add_argument("--offset", type=int, default=0) + parser.add_argument("--all", action="store_true") + options = parser.parse_args(args) + + state = session.require_scan() + process = session.require_process("results") + hex_output = bool(session.option("hex")) + + if options.offset < 0: + raise CommandError("--offset cannot be negative.") + + limit = None if options.all else session.display_limit(options.limit) + end = len(state.addresses) if limit is None else options.offset + limit + window = list(range(options.offset, min(end, len(state.addresses)))) + + with Timer() as timer: + current = _read_values( + session, + state.value_type, + state.width, + [state.addresses[index] for index in window], + ) + + rows = [] + for position, index in enumerate(window): + now = current[position] + previous = state.values[index] + rows.append( + ( + f"#{index + 1}", + format_address(state.addresses[index], process.pointer_size), + state.value_type.format(now, hex_output=hex_output), + "" + if now == previous + else state.value_type.format(previous, hex_output=hex_output), + ) + ) + + session.printer.table( + ("ROW", "ADDRESS", "VALUE", "PREVIOUS"), + rows, + (RIGHT, LEFT, LEFT, LEFT), + elapsed=timer.elapsed, + total=len(state.addresses), + ) + + +def _parse_row_selection(tokens: Sequence[str], count: int) -> List[int]: + """Turn ``['1', '3-5', '#9']`` into zero-based row indexes.""" + selected: List[int] = [] + for token in tokens: + piece = token.strip().lstrip("#") + if "-" in piece: + low_text, _, high_text = piece.partition("-") + low = parse_int(low_text, "row number") + high = parse_int(high_text, "row number") + if low > high: + raise CommandError(f"Range {token!r} runs backwards.") + candidates = range(low, high + 1) + else: + number = parse_int(piece, "row number") + candidates = range(number, number + 1) + + for number in candidates: + if not 1 <= number <= count: + raise CommandError( + f"Row #{number} is out of range — there are {count} result(s)." + ) + selected.append(number - 1) + + if not selected: + raise CommandError("Name at least one row, e.g. 'keep 1 3-5'.") + return selected + + +@command( + "keep", + summary="Keep only the named result rows.", + usage="keep [row ...]", + group="Scanning", + details=( + "Rows may be given singly or as ranges: 'keep 1 4 7-9'. Use it when " + "you can see which candidates are real and would rather not invent a " + "comparison that happens to exclude the others." + ), + examples=("keep 1", "keep 1 3 7-9"), +) +def cmd_keep(session: Session, args: List[str]) -> None: + state = session.require_scan() + session.require_process("keep") + indexes = _parse_row_selection(args, len(state.addresses)) + ordered = sorted(set(indexes)) + + new_state = session.store_scan( + state.value_type, + state.width, + [state.addresses[index] for index in ordered], + [state.values[index] for index in ordered], + f"{state.description} → kept {len(ordered)} row(s)", + ) + _print_results(session, new_state, limit=None, elapsed=None) + + +@command( + "drop", + summary="Remove the named result rows.", + usage="drop [row ...]", + group="Scanning", + details="The inverse of 'keep'. Ranges work the same way.", + examples=("drop 2", "drop 5-12"), +) +def cmd_drop(session: Session, args: List[str]) -> None: + state = session.require_scan() + session.require_process("drop") + removed = set(_parse_row_selection(args, len(state.addresses))) + remaining = [index for index in range(len(state.addresses)) if index not in removed] + + new_state = session.store_scan( + state.value_type, + state.width, + [state.addresses[index] for index in remaining], + [state.values[index] for index in remaining], + f"{state.description} → dropped {len(removed)} row(s)", + ) + _print_results(session, new_state, limit=None, elapsed=None) + + +@command( + "reset", + summary="Discard the current scan results.", + usage="reset", + group="Scanning", + aliases=("unscan",), + details=( + "Clears the result set so the next 'scan' starts a fresh cycle. The " + "attached process is left alone." + ), +) +def cmd_reset(session: Session, args: List[str]) -> None: + CommandParser("reset").parse_args(args) + count = len(session.scan) if session.scan else 0 + session.scan = None + session.printer.ok(f"Discarded {count} result(s).") + session.printer.write() + + +__all__ = () diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py new file mode 100644 index 0000000..949b690 --- /dev/null +++ b/peekmem/commands/session_commands.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- + +"""Running the shell itself: ``help``, ``set``, ``source``, ``exit``.""" + +import os +import platform +from typing import List + +import PyMemoryEditor + +from .. import __version__, valuetypes +from ..errors import CommandError, ExitShell +from ..output import LEFT, RIGHT, render_table, render_vertical +from ..session import SETTINGS, Session +from . import GROUPS, CommandParser, all_commands, command, lookup + +_ADDRESS_TOPIC = """\ +Every command that takes an address takes an expression. + + 0x7ffee3a01000 a literal; decimal works too + game.exe+0x1234 a module's base plus a static offset + "libfoo-1.so"+0x20 quote a module name containing '-' or spaces + [game.exe+0x1234] the pointer stored there, dereferenced + [[base+0x8]+0x20]+0x4 nested as deeply as you like + #3 the address on row 3 of the last scan + +Module names are matched case-insensitively, and an unambiguous prefix is +enough: 'game' finds 'game.exe'. Run 'modules' to list them, and to refresh +the table after the target loads a library. + +'module+offset' is the form worth writing down: a module's base moves on every +launch under ASLR, but the offset inside it does not, so the expression keeps +working across restarts where a bare address does not.\ +""" + +_SCANNING_TOPIC = """\ +The scan / refine cycle, when you do not know the address: + + 1. scan int32 100 every address holding 100 right now + 2. (make the value change in the target) + 3. next 95 of those, the ones now holding 95 + +Repeat step 3 until a handful of rows remain. When you cannot see the value — +a health bar with no number — compare against the previous reading instead: + + next changed / next unchanged / next increased / next decreased + +Then read, write or watch a surviving row by number: + + read #1 int32 + write #1 int32 999 + watch #1 int32 + +An address found this way is good for this run only. To keep it, find the +pointer path that reaches it: 'ptrscan #1', then 'ptrsave', restart the +target, and 'ptrrescan' against the value's new address. See 'help ptrscan'.\ +""" + +_TOPICS = { + "address": ("Writing an address", _ADDRESS_TOPIC), + "addresses": ("Writing an address", _ADDRESS_TOPIC), + "scanning": ("The scan / refine cycle", _SCANNING_TOPIC), +} + + +def _format_setting(value: object) -> str: + """Print a setting the way it is typed: booleans as on/off, not True/False.""" + if isinstance(value, bool): + return "on" if value else "off" + return str(value) + + +def _print_types(session: Session) -> None: + rows = [ + ( + value_type.name, + "varies" if value_type.is_variable_width else f"{value_type.size}", + ", ".join(value_type.aliases) or "", + value_type.summary, + ) + for value_type in valuetypes.VALUE_TYPES + ] + session.printer.write( + render_table( + ("TYPE", "BYTES", "ALIASES", "DESCRIPTION"), + rows, + (LEFT, RIGHT, LEFT, LEFT), + ) + ) + session.printer.write() + session.printer.write( + "'string' and 'bytes' need a length: 'read
string 32'.\n" + "Byte-array values are written as hex: 'DE AD BE EF'." + ) + session.printer.write() + + +def _print_overview(session: Session) -> None: + printer = session.printer + printer.write(f"Peekmem {__version__} — a terminal client for PyMemoryEditor.") + printer.write() + + commands = all_commands() + width = max(len(entry.name) for entry in commands) + + for group in GROUPS: + in_group = [entry for entry in commands if entry.group == group] + if not in_group: + continue + printer.write(f"{group}") + for entry in in_group: + printer.write(f" {entry.name.ljust(width)} {entry.summary}") + printer.write() + + printer.write("Type 'help ' for the full description of one command.") + printer.write("Topics: 'help types', 'help address', 'help scanning'.") + printer.write("End the session with 'exit', Ctrl+D, or \\q.") + printer.write() + + +def _print_command_help(session: Session, name: str) -> None: + entry = lookup(name) + printer = session.printer + + printer.write(f"{entry.name} — {entry.summary}") + printer.write() + printer.write(f"Usage: {entry.usage}") + if entry.aliases: + printer.write(f"Aliases: {', '.join(entry.aliases)}") + printer.write() + if entry.details: + printer.write(entry.details) + printer.write() + if entry.examples: + printer.write("Examples:") + for example in entry.examples: + printer.write(f" {example}") + printer.write() + + +@command( + "help", + summary="List the commands, or describe one.", + usage="help [command|types|address|scanning]", + group="Session", + aliases=("?", "\\h"), + details=( + "With no argument, prints every command grouped by what it acts on. " + "With a command name, prints that command's usage, options and " + "examples. The topics 'types', 'address' and 'scanning' cover the " + "parts that several commands share." + ), + examples=("help", "help scan", "help address"), +) +def cmd_help(session: Session, args: List[str]) -> None: + if not args: + _print_overview(session) + return + if len(args) > 1: + raise CommandError("help takes one command or topic at a time.") + + topic = args[0].strip().lower() + + if topic in ("types", "type"): + _print_types(session) + return + + if topic in _TOPICS: + title, body = _TOPICS[topic] + session.printer.write(title) + session.printer.write() + session.printer.write(body) + session.printer.write() + return + + _print_command_help(session, topic) + + +@command( + "set", + summary="Show or change a session setting.", + usage="set [name [value]]", + group="Session", + details=( + "With no argument, prints every setting and its current value. " + "'set name value' and 'set name=value' both assign.\n\n" + "Settings live for the session only — Peekmem writes no config file, " + "so a fresh shell always starts from the documented defaults. Put the " + "'set' lines in a script and run it with 'source' to reuse a setup." + ), + examples=("set", "set limit 50", "set hex on", "set writable_only=true"), +) +def cmd_set(session: Session, args: List[str]) -> None: + if not args: + rows = [ + (setting.name, _format_setting(session.option(setting.name)), setting.summary) + for setting in SETTINGS + ] + session.printer.table( + ("SETTING", "VALUE", "DESCRIPTION"), rows, (LEFT, RIGHT, LEFT) + ) + return + + if len(args) == 1 and "=" in args[0]: + name, _, value = args[0].partition("=") + elif len(args) == 1: + name, value = args[0], None + elif len(args) == 2: + name, value = args[0], args[1] + else: + raise CommandError("Usage: set [name [value]]") + + if value is None: + setting = {item.name: item for item in SETTINGS}.get(name.lower()) + if setting is None: + raise CommandError(f"Unknown setting {name!r}.") + session.printer.write( + render_vertical([(setting.name, _format_setting(session.option(setting.name)))]) + ) + session.printer.write() + return + + applied = session.set_option(name, value) + session.printer.ok(f"{name.strip().lower()} = {_format_setting(applied)}") + session.printer.write() + + +@command( + "source", + summary="Run the commands in a file.", + usage="source ", + group="Session", + aliases=("\\.",), + details=( + "Reads the file and runs each line as if it had been typed. Blank " + "lines are skipped, and a line starting with '#' or '--' is a comment.\n\n" + "A failing line stops the script — a setup that half-ran is worse than " + "one that says where it stopped." + ), + examples=("source setup.peek",), +) +def cmd_source(session: Session, args: List[str]) -> None: + parser = CommandParser("source") + parser.add_argument("file") + options = parser.parse_args(args) + + if session.shell is None: + raise CommandError("'source' needs a shell to run the commands in.") + if not os.path.exists(options.file): + raise CommandError(f"No such file: {options.file}") + + try: + with open(options.file, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + except OSError as error: + raise CommandError(f"Cannot read {options.file!r}: {error}") + + for number, line in enumerate(lines, start=1): + try: + session.shell.run_line(line, raise_errors=True) + except CommandError as error: + raise CommandError(f"{options.file}:{number}: {error}") + + +@command( + "version", + summary="Print the Peekmem and PyMemoryEditor versions.", + usage="version", + group="Session", + details="The two lines to quote in a bug report, plus the platform.", +) +def cmd_version(session: Session, args: List[str]) -> None: + CommandParser("version").parse_args(args) + session.printer.write( + f"Peekmem {__version__} / PyMemoryEditor {PyMemoryEditor.__version__} " + f"/ Python {platform.python_version()} on {platform.system()} " + f"({platform.machine()})" + ) + session.printer.write() + + +@command( + "exit", + summary="Leave the shell.", + usage="exit", + group="Session", + aliases=("quit", "\\q"), + details="Detaches from the target first. Ctrl+D does the same thing.", +) +def cmd_exit(session: Session, args: List[str]) -> None: + CommandParser("exit").parse_args(args) + raise ExitShell(0) + + +__all__ = () diff --git a/peekmem/errors.py b/peekmem/errors.py new file mode 100644 index 0000000..b9d342a --- /dev/null +++ b/peekmem/errors.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- + +""" +Exception hierarchy for Peekmem. + +Every error a *command* can raise against the user's input derives from +:class:`CommandError`. The shell catches that one class, prints it as a single +``ERROR: ...`` line and returns to the prompt — an interactive session must +never die because an address was mistyped. Anything that is *not* a +``CommandError`` (a bug in Peekmem itself) propagates with its traceback, which +is what you want when reporting an issue. +""" + + +class PeekmemError(Exception): + """Base class for every Peekmem exception.""" + + +class CommandError(PeekmemError): + """A command was given input it cannot act on. + + Raised for unknown commands, malformed arguments, unreadable addresses and + any other condition that is the user's to fix. The shell prints the message + verbatim, so write it as a complete sentence that says what to do next. + """ + + +class NoProcessError(CommandError): + """A command needing an attached process was run without one.""" + + def __init__(self, command: str = ""): + detail = f" Command {command!r} needs a target." if command else "" + super().__init__( + "No process attached." + detail + ' Use "open " first.' + ) + + +class ExitShell(PeekmemError): + """Raised by ``exit`` / ``quit`` to unwind the shell loop cleanly. + + Not an error in the user-facing sense — the shell catches it before the + ``CommandError`` handler and returns the carried status code. + """ + + def __init__(self, status: int = 0): + super().__init__("exit") + self.status = status + + +__all__ = ("CommandError", "ExitShell", "NoProcessError", "PeekmemError") diff --git a/peekmem/output.py b/peekmem/output.py new file mode 100644 index 0000000..7c42119 --- /dev/null +++ b/peekmem/output.py @@ -0,0 +1,284 @@ +# -*- coding: utf-8 -*- + +""" +Everything Peekmem prints. + +The house style is the ``mysql`` client's: results in an ASCII box table, +a footer line counting rows and timing the command, and nothing else. Colour +is limited to a single highlight on ``ERROR`` and is dropped entirely when the +stream is not a terminal, when ``NO_COLOR`` is set, or when ``--no-color`` was +passed — so piping Peekmem into ``grep`` or a log file yields plain text. + +Keeping every byte of output behind this module is what makes the shell +testable: a test builds a :class:`Printer` over a ``StringIO`` and asserts on +the exact text a user would have seen. +""" + +import os +import sys +import time +from typing import Any, Iterable, List, Optional, Sequence, TextIO, Tuple + +#: Columns whose values are numbers are right-aligned, as in the mysql client. +RIGHT = "right" +LEFT = "left" + +_RED = "\033[31m" +_RESET = "\033[0m" + + +def supports_color(stream: TextIO) -> bool: + """True when it is polite to emit ANSI escapes on ``stream``.""" + if os.environ.get("NO_COLOR") is not None: + return False + if os.environ.get("TERM") == "dumb": + return False + return bool(getattr(stream, "isatty", lambda: False)()) + + +def format_address(address: int, pointer_size: int = 8) -> str: + """Render an address as fixed-width hex, e.g. ``0x00007FFEE3A01000``.""" + return "0x{:0{}X}".format(address, pointer_size * 2) + + +def format_size(size: int) -> str: + """Render a byte count as a short human-readable string.""" + if size < 1024: + return f"{size} B" + value = float(size) + for unit in ("KB", "MB", "GB", "TB"): + value /= 1024.0 + if value < 1024.0: + return f"{value:.1f} {unit}" + return f"{value:.1f} PB" + + +def format_duration(seconds: float) -> str: + """Render an elapsed time the way the mysql client does: ``0.01 sec``.""" + return f"{seconds:.2f} sec" + + +def _one_line(text: str) -> str: + """Collapse a cell's text to a single line of printable characters.""" + return "".join( + char if char == " " or char.isprintable() else "." for char in text + ) + + +def render_table( + headers: Sequence[str], + rows: Sequence[Sequence[Any]], + aligns: Optional[Sequence[str]] = None, +) -> str: + """Build a mysql-style box table. + + ``rows`` cells are stringified with ``str`` and truncated by nothing — + a long path is printed in full and the terminal wraps it, which beats + silently hiding the part that mattered. + """ + # A cell carrying a newline or a tab would break the box open, and cells + # can hold text read straight out of another process's memory. Flatten + # them here so no caller has to remember to. + text_rows: List[List[str]] = [ + ["" if cell is None else _one_line(str(cell)) for cell in row] for row in rows + ] + widths = [len(str(header)) for header in headers] + for row in text_rows: + for index, cell in enumerate(row): + if index < len(widths): + widths[index] = max(widths[index], len(cell)) + + if aligns is None: + aligns = [LEFT] * len(headers) + + rule = "+" + "+".join("-" * (width + 2) for width in widths) + "+" + + def line(cells: Sequence[str], pad_align: Sequence[str]) -> str: + parts = [] + for index, cell in enumerate(cells): + width = widths[index] + if pad_align[index] == RIGHT: + parts.append(" " + cell.rjust(width) + " ") + else: + parts.append(" " + cell.ljust(width) + " ") + return "|" + "|".join(parts) + "|" + + out = [rule, line([str(header) for header in headers], [LEFT] * len(headers)), rule] + out.extend(line(row, aligns) for row in text_rows) + out.append(rule) + return "\n".join(out) + + +def render_vertical(pairs: Iterable[Tuple[str, Any]]) -> str: + """Render key/value pairs as an aligned two-column block (``\\G`` style).""" + items = [(str(key), "" if value is None else str(value)) for key, value in pairs] + if not items: + return "" + width = max(len(key) for key, _ in items) + return "\n".join(f"{key.rjust(width)}: {value}" for key, value in items) + + +def render_hexdump(data: bytes, base_address: int = 0, width: int = 16) -> str: + """Classic ``hexdump -C`` layout: address, hex bytes, printable ASCII.""" + lines = [] + for offset in range(0, len(data), width): + chunk = data[offset : offset + width] + hex_part = " ".join(f"{byte:02X}" for byte in chunk) + hex_part = hex_part.ljust(width * 3 - 1) + ascii_part = "".join( + chr(byte) if 32 <= byte < 127 else "." for byte in chunk + ) + lines.append( + f"{base_address + offset:016X} {hex_part} |{ascii_part}|" + ) + return "\n".join(lines) + + +class Printer: + """The single writer every command prints through. + + :param stdout: stream for results. + :param stderr: stream for errors and for the transient progress line. + :param color: ``None`` auto-detects from ``stdout``; ``True``/``False`` + force it. + :param timing: print the mysql-style ``N rows in set (0.01 sec)`` footer. + """ + + def __init__( + self, + stdout: Optional[TextIO] = None, + stderr: Optional[TextIO] = None, + *, + color: Optional[bool] = None, + timing: bool = True, + ): + self.stdout = stdout if stdout is not None else sys.stdout + self.stderr = stderr if stderr is not None else sys.stderr + self.color = supports_color(self.stdout) if color is None else color + self.timing = timing + self._progress_active = False + + # -- primitives ------------------------------------------------------ + + def write(self, text: str = "") -> None: + self.clear_progress() + self.stdout.write(text + "\n") + self.stdout.flush() + + def error(self, message: str) -> None: + """Print a failed command's message. Goes to stderr, like mysql's.""" + self.clear_progress() + prefix = f"{_RED}ERROR{_RESET}" if self.color else "ERROR" + self.stderr.write(f"{prefix}: {message}\n") + self.stderr.flush() + + def note(self, message: str) -> None: + """Print an aside — a warning that did not stop the command.""" + self.clear_progress() + self.stdout.write(f"Note: {message}\n") + self.stdout.flush() + + # -- results --------------------------------------------------------- + + def table( + self, + headers: Sequence[str], + rows: Sequence[Sequence[Any]], + aligns: Optional[Sequence[str]] = None, + *, + elapsed: Optional[float] = None, + total: Optional[int] = None, + ) -> None: + """Print a result table plus its footer. + + ``total`` names the number of rows that *matched* when ``rows`` only + carries the ones that fit the display limit, so the footer can say + ``20 rows in set (of 1043)`` instead of pretending the rest do not + exist. + """ + self.clear_progress() + if rows: + self.write(render_table(headers, rows, aligns)) + self.footer(len(rows), elapsed=elapsed, total=total) + + def footer( + self, + count: int, + *, + elapsed: Optional[float] = None, + total: Optional[int] = None, + ) -> None: + """Print the ``N rows in set (0.01 sec)`` line.""" + if count == 0 and not total: + text = "Empty set" + elif total is not None and total != count: + # The table was cut to the display limit; say so plainly rather + # than reporting a row count that is not the answer to the query. + text = f"Showing {count} of {total} rows" + else: + text = f"{count} row{'' if count == 1 else 's'} in set" + if self.timing and elapsed is not None: + text += f" ({format_duration(elapsed)})" + self.write(text) + self.write() + + def ok(self, message: str, *, elapsed: Optional[float] = None) -> None: + """Print the acknowledgement of a command that changed something.""" + if self.timing and elapsed is not None: + message = f"{message} ({format_duration(elapsed)})" + self.write(message) + + # -- progress -------------------------------------------------------- + + def progress(self, label: str, fraction: float) -> None: + """Update the in-place progress line on stderr. + + Scans walk gigabytes and a silent terminal looks like a hang. The line + is written to stderr so a piped ``peekmem -e "scan ..."`` still yields + clean, parseable stdout, and is skipped entirely when stderr is not a + terminal so a log file does not fill with carriage returns. + """ + if not getattr(self.stderr, "isatty", lambda: False)(): + return + percent = max(0.0, min(1.0, fraction)) * 100.0 + self.stderr.write(f"\r{label} {percent:5.1f}%") + self.stderr.flush() + self._progress_active = True + + def clear_progress(self) -> None: + """Erase the progress line, if one is showing.""" + if not self._progress_active: + return + self.stderr.write("\r\033[K" if self.color else "\r" + " " * 40 + "\r") + self.stderr.flush() + self._progress_active = False + + +class Timer: + """Context manager measuring a command, for the mysql-style footer.""" + + def __init__(self) -> None: + self.elapsed = 0.0 + self._start = 0.0 + + def __enter__(self) -> "Timer": + self._start = time.perf_counter() + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.elapsed = time.perf_counter() - self._start + + +__all__ = ( + "LEFT", + "Printer", + "RIGHT", + "Timer", + "format_address", + "format_duration", + "format_size", + "render_hexdump", + "render_table", + "render_vertical", + "supports_color", +) diff --git a/peekmem/processes.py b/peekmem/processes.py new file mode 100644 index 0000000..c214ade --- /dev/null +++ b/peekmem/processes.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- + +""" +Listing the processes on this machine. + +PyMemoryEditor implements process enumeration natively per platform — via +``CreateToolhelp32Snapshot`` on Windows, ``/proc`` on Linux and ``libproc`` on +macOS — but only exposes it from the platform backend module, not from the +package root. Importing the backend directly is what keeps Peekmem's dependency +list at exactly one entry: the alternative is psutil, a compiled dependency +that would have to build or ship a wheel on every server Peekmem is meant to +run on, to answer a question PyMemoryEditor can already answer. + +The import is deliberately narrow (one function per platform) and guarded, so +a future rename in PyMemoryEditor surfaces here as a clear error rather than +as a mysterious traceback. +""" + +import sys +from typing import Callable, Generator, Iterator, List, Optional, Tuple + +from .errors import CommandError, PeekmemError + +#: ``(pid, name)`` as the platform backends yield it. +ProcessEntry = Tuple[int, str] + + +def _load_enumerator() -> Callable[[], Generator[ProcessEntry, None, None]]: + """Return the platform's process-enumeration generator function.""" + try: + if sys.platform == "win32": + from PyMemoryEditor.win32.functions import GetProcesses + + return GetProcesses + if sys.platform.startswith("linux"): + from PyMemoryEditor.linux.functions import get_processes + + return get_processes + if sys.platform == "darwin": + from PyMemoryEditor.macos.functions import get_processes + + return get_processes + except ImportError as error: # pragma: no cover - depends on the installed lib + raise PeekmemError( + "This PyMemoryEditor build does not expose process enumeration " + f"where Peekmem expects it ({error}). Upgrade PyMemoryEditor." + ) + + raise PeekmemError( + f"Unsupported platform {sys.platform!r}. Peekmem runs on Windows, " + "Linux and macOS." + ) + + +def iter_processes() -> Iterator[ProcessEntry]: + """Yield ``(pid, name)`` for every process visible to the current user.""" + yield from _load_enumerator()() + + +def list_processes( + pattern: Optional[str] = None, + *, + case_sensitive: bool = False, + sort_by: str = "name", +) -> List[ProcessEntry]: + """Return the visible processes, optionally filtered by a name substring. + + :param pattern: substring to match against the process name; a pattern that + is all digits also matches a PID exactly, so ``ps 4242`` finds the + process you meant even though the column is a number. + :param case_sensitive: match ``pattern`` case-sensitively. + :param sort_by: ``"name"`` (default) or ``"pid"``. + """ + entries = list(iter_processes()) + + if pattern: + needle = pattern if case_sensitive else pattern.lower() + pid_match = int(pattern) if pattern.isdigit() else None + + def matches(entry: ProcessEntry) -> bool: + pid, name = entry + if pid_match is not None and pid == pid_match: + return True + haystack = name if case_sensitive else name.lower() + return needle in haystack + + entries = [entry for entry in entries if matches(entry)] + + if sort_by == "pid": + entries.sort(key=lambda entry: entry[0]) + elif sort_by == "name": + entries.sort(key=lambda entry: (entry[1].lower(), entry[0])) + else: + raise CommandError("Sort key must be 'name' or 'pid'.") + + return entries + + +def process_name(pid: int) -> Optional[str]: + """Return the name of ``pid``, or ``None`` when it is not visible.""" + for entry_pid, name in iter_processes(): + if entry_pid == pid: + return name + return None + + +__all__ = ("ProcessEntry", "iter_processes", "list_processes", "process_name") diff --git a/peekmem/py.typed b/peekmem/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/peekmem/session.py b/peekmem/session.py new file mode 100644 index 0000000..d71b5f5 --- /dev/null +++ b/peekmem/session.py @@ -0,0 +1,367 @@ +# -*- coding: utf-8 -*- + +""" +The state one Peekmem session carries. + +A shell is only as useful as what it remembers between commands. A session +holds the attached process, the addresses the last scan found (so ``next`` +can narrow them and ``#3`` can name one), the paths the last pointer scan +found, the cached region snapshot that keeps an iterative scan from +re-enumerating the address space every time, and the handful of settings +``set`` exposes. + +Every command receives the session and touches the target only through it, so +"is a process attached?" and "did that address come from a stale scan?" are +answered in exactly one place. +""" + +import sys +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from PyMemoryEditor import ( + AbstractProcess, + AmbiguousProcessNameError, + MemoryRegion, + OpenProcess, + PointerPath, + ProcessIDNotExistsError, + ProcessNotFoundError, +) +from PyMemoryEditor.process.region import default_scan_filter + +from . import processes +from .errors import CommandError, NoProcessError +from .output import Printer +from .valuetypes import ValueType + + +@dataclass(frozen=True) +class Setting: + """One knob exposed by the ``set`` command.""" + + name: str + default: Any + kind: type + summary: str + + +#: Every session setting, in the order ``set`` prints them. +SETTINGS: Tuple[Setting, ...] = ( + Setting("limit", 20, int, "Rows printed per result table (0 = no limit)."), + Setting("max_results", 100000, int, "Scan hits kept in memory (0 = no cap)."), + Setting("hex", False, bool, "Print integer values in hexadecimal."), + Setting("timing", True, bool, "Print the elapsed time after each command."), + Setting("progress", True, bool, "Show a progress line while scanning."), + Setting("writable_only", False, bool, "Scan only writable regions (faster)."), + Setting("dump_width", 16, int, "Bytes per line in 'dump' output."), + Setting("watch_interval", 0.5, float, "Seconds between 'watch' samples."), +) + +_SETTINGS_BY_NAME = {setting.name: setting for setting in SETTINGS} + + +@dataclass +class ScanState: + """The result set of the last value scan, and what produced it. + + ``values`` runs parallel to ``addresses`` and holds what each address read + at the moment of the scan — the "previous value" the ``next`` comparisons + (increased / decreased / changed / unchanged) are defined against. + """ + + value_type: ValueType + width: int + addresses: List[int] = field(default_factory=list) + values: List[Any] = field(default_factory=list) + description: str = "" + truncated: bool = False + + def __len__(self) -> int: + return len(self.addresses) + + +class Session: + """One attached target and everything the shell remembers about it.""" + + def __init__(self, printer: Optional[Printer] = None): + self.printer = printer if printer is not None else Printer() + self.process: Optional[AbstractProcess] = None + self.process_name: str = "" + self.scan: Optional[ScanState] = None + self.pointer_paths: List[PointerPath] = [] + self.settings: Dict[str, Any] = { + setting.name: setting.default for setting in SETTINGS + } + self._regions: Optional[List[MemoryRegion]] = None + self._modules: Optional[Dict[str, int]] = None + # Set by the shell that owns this session, so 'source' can feed a + # script file back through the same dispatcher. None when the + # session is driven programmatically instead of by a shell. + self.shell: Optional[Any] = None + + # -- settings -------------------------------------------------------- + + def option(self, name: str) -> Any: + """Read a setting by name.""" + return self.settings[name] + + def set_option(self, name: str, text: str) -> Any: + """Assign a setting from its command-line spelling. + + :raises CommandError: for an unknown name or an unparseable value. + """ + setting = _SETTINGS_BY_NAME.get(name.strip().lower()) + if setting is None: + known = ", ".join(item.name for item in SETTINGS) + raise CommandError(f"Unknown setting {name!r}. Known settings: {known}.") + + raw = text.strip() + if setting.kind is bool: + if raw.lower() in ("1", "true", "on", "yes"): + value: Any = True + elif raw.lower() in ("0", "false", "off", "no"): + value = False + else: + raise CommandError(f"{setting.name} takes on/off, not {text!r}.") + elif setting.kind is int: + try: + value = int(raw, 0) if raw[:2].lower() == "0x" else int(raw) + except ValueError: + raise CommandError(f"{setting.name} takes an integer, not {text!r}.") + if value < 0: + raise CommandError(f"{setting.name} cannot be negative.") + else: + try: + value = float(raw) + except ValueError: + raise CommandError(f"{setting.name} takes a number, not {text!r}.") + if value <= 0: + raise CommandError(f"{setting.name} must be greater than zero.") + + self.settings[setting.name] = value + + # The printer mirrors two settings, because it is what actually prints. + if setting.name == "timing": + self.printer.timing = bool(value) + return value + + def display_limit(self, override: Optional[int] = None) -> Optional[int]: + """How many rows a table should print: ``None`` means all of them.""" + limit = self.option("limit") if override is None else override + return None if not limit else int(limit) + + # -- the target ------------------------------------------------------ + + @property + def is_attached(self) -> bool: + return self.process is not None + + def require_process(self, command: str = "") -> AbstractProcess: + """Return the attached process or explain that there is not one.""" + if self.process is None: + raise NoProcessError(command) + return self.process + + def attach( + self, + *, + pid: Optional[int] = None, + name: Optional[str] = None, + case_sensitive: Optional[bool] = None, + exact_match: bool = True, + strict_bitness: bool = False, + permission: Optional[Any] = None, + ) -> AbstractProcess: + """Open a target, replacing whatever was attached before. + + Errors PyMemoryEditor raises for a target the user named wrongly + (no such PID, no such name, an ambiguous name) are re-raised as + :class:`CommandError` so the shell reports them on one line and stays + alive; anything else propagates as the bug it probably is. + """ + kwargs: Dict[str, Any] = {"exact_match": exact_match, "strict_bitness": strict_bitness} + if pid is not None: + kwargs["pid"] = pid + if name is not None: + kwargs["name"] = name + # Left unset, each backend keeps its own default (Windows matches names + # case-insensitively, like the OS; Linux and macOS do not). + if case_sensitive is not None: + kwargs["case_sensitive"] = case_sensitive + if permission is not None: + kwargs["permission"] = permission + + try: + process = OpenProcess(**kwargs) + except ( + ProcessIDNotExistsError, + ProcessNotFoundError, + AmbiguousProcessNameError, + ) as error: + raise CommandError(str(error)) + except PermissionError as error: + raise CommandError( + f"{error} Peekmem needs permission to open the target — try " + "running it as an administrator (Windows), with sudo (Linux), " + "or with the debugger entitlement (macOS)." + ) + except OSError as error: + raise CommandError(f"Could not open the process: {error}") + + self.detach() + self.process = process + self.process_name = name or processes.process_name(process.pid) or "" + return process + + def detach(self) -> bool: + """Close the attached process and drop everything derived from it.""" + was_attached = self.process is not None + if self.process is not None: + try: + self.process.close() + except Exception: # noqa: BLE001 - a dead target cannot be closed + pass + self.process = None + self.process_name = "" + self.invalidate() + self.scan = None + self.pointer_paths = [] + return was_attached + + def invalidate(self) -> None: + """Drop the cached region snapshot and module table.""" + self._regions = None + self._modules = None + + # -- cached views of the target -------------------------------------- + + def regions(self, *, refresh: bool = False) -> List[MemoryRegion]: + """The target's memory map, cached for the duration of a workflow. + + A scan re-uses the snapshot instead of re-enumerating the address + space, which is most of what makes a ``scan`` / ``next`` cycle feel + immediate. The map does change as the target allocates, so ``refresh`` + (and the ``regions`` command) rebuild it. + """ + process = self.require_process() + if self._regions is None or refresh: + self._regions = list(process.snapshot_memory_regions()) + return self._regions + + def scan_regions(self, *, writable_only: Optional[bool] = None) -> List[MemoryRegion]: + """The regions a scan will actually walk. + + PyMemoryEditor applies ``default_scan_filter`` internally, so this is + purely so the progress line counts the same bytes the scan does. + """ + only_writable = ( + self.option("writable_only") if writable_only is None else writable_only + ) + return [ + region + for region in self.regions() + if default_scan_filter(region, writeable_only=only_writable) + ] + + def modules(self, *, refresh: bool = False) -> Dict[str, int]: + """Map lower-cased module name to base address, cached.""" + process = self.require_process() + if self._modules is None or refresh: + table: Dict[str, int] = {} + for module in process.get_modules(): + if module.name: + # First entry wins: the loader can map the same name twice + # (32/64-bit views on Windows), and the first is the one a + # static offset is normally taken against. + table.setdefault(module.name.lower(), module.base_address) + self._modules = table + return self._modules + + # -- hooks used by the address expression parser ---------------------- + + def module_base(self, name: str) -> int: + """Base address of a loaded module, matched by name then by prefix.""" + self.require_process() + table = self.modules() + key = name.lower() + + if key in table: + return table[key] + + # "game" should find "game.exe" — the extension is noise a user should + # not have to remember, and an unambiguous prefix is unambiguous. + matches = [ + module for module in table if module.startswith(key) or key in module + ] + if len(matches) == 1: + return table[matches[0]] + if len(matches) > 1: + listed = ", ".join(sorted(matches)[:6]) + raise CommandError(f"Module {name!r} is ambiguous: {listed}.") + + raise CommandError( + f"No loaded module matches {name!r}. Use 'modules' to list them." + ) + + def read_pointer(self, address: int) -> int: + """Dereference ``address``, for the ``[...]`` form of an expression.""" + process = self.require_process() + size = process.pointer_size + try: + data = process.read_bytes(address, size) + except OSError as error: + raise CommandError( + f"Cannot read the pointer at 0x{address:X}: {error}" + ) + return int.from_bytes(data, sys.byteorder) + + def result_address(self, index: int) -> int: + """The address on row ``index`` (1-based) of the last scan.""" + if self.scan is None or not self.scan.addresses: + raise CommandError( + "No scan results to refer to. Run 'scan' first, or give an " + "address instead of a '#' reference." + ) + if not 1 <= index <= len(self.scan.addresses): + raise CommandError( + f"#{index} is out of range — the last scan has " + f"{len(self.scan.addresses)} result(s)." + ) + return self.scan.addresses[index - 1] + + # -- scan results ----------------------------------------------------- + + def store_scan( + self, + value_type: ValueType, + width: int, + addresses: Sequence[int], + values: Sequence[Any], + description: str, + *, + truncated: bool = False, + ) -> ScanState: + """Replace the current result set.""" + self.scan = ScanState( + value_type=value_type, + width=width, + addresses=list(addresses), + values=list(values), + description=description, + truncated=truncated, + ) + return self.scan + + def require_scan(self) -> ScanState: + """Return the current result set or explain that there is not one.""" + if self.scan is None or not self.scan.addresses: + raise CommandError('No scan results. Run "scan " first.') + return self.scan + + def close(self) -> None: + """Release the target. Safe to call more than once.""" + self.detach() + + +__all__ = ("SETTINGS", "ScanState", "Session", "Setting") diff --git a/peekmem/shell.py b/peekmem/shell.py new file mode 100644 index 0000000..7e4434e --- /dev/null +++ b/peekmem/shell.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- + +""" +The read-eval-print loop. + +The shell is deliberately thin: it turns a line of text into a command word +plus arguments, hands them to the registry, and makes sure nothing a command +raises can end the session by accident. Everything else — what the commands +are, what they print — lives elsewhere. + +Line syntax is one command per line, arguments split with shell quoting rules, +and a trailing ``;`` politely ignored for the muscle memory of anyone arriving +from ``mysql``. Blank lines and lines starting with ``#`` or ``--`` are +comments, which is what makes a file of commands runnable with ``source``. +""" + +import os +import re +import shlex +import sys +from typing import Iterable, List, Optional, Sequence, TextIO, Tuple + +from PyMemoryEditor import PyMemoryEditorError + +from . import __version__, valuetypes +from .commands import all_commands, command_words, lookup +from .errors import CommandError, ExitShell +from .output import Printer +from .session import SETTINGS, Session + +#: Where the interactive shell remembers what you typed. +HISTORY_FILE = os.path.join(os.path.expanduser("~"), ".peekmem_history") +HISTORY_LENGTH = 1000 + +_LEADING_WORD = re.compile(r"\s*(\S+)\s*(.*)", re.DOTALL) + + +class Shell: + """Dispatches command lines against a :class:`~peekmem.session.Session`.""" + + def __init__( + self, + session: Optional[Session] = None, + *, + printer: Optional[Printer] = None, + stdin: Optional[TextIO] = None, + ): + self.printer = printer if printer is not None else Printer() + self.session = session if session is not None else Session(self.printer) + self.session.printer = self.printer + self.session.shell = self + self.stdin = stdin if stdin is not None else sys.stdin + self._history_loaded = False + + # -- parsing and dispatch --------------------------------------------- + + @staticmethod + def split(line: str) -> Optional[Tuple[str, List[str]]]: + """Split a line into ``(command, args)``, or ``None`` when it is blank. + + The command word is taken verbatim rather than through ``shlex`` so + the backslash aliases (``\\q``, ``\\s``, ``\\.``) survive: POSIX + quoting would eat the backslash and leave a command nobody registered. + """ + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("--"): + return None + + # A trailing ';' is habit, not syntax. Accept it and move on. + while stripped.endswith(";"): + stripped = stripped[:-1].rstrip() + if not stripped: + return None + + match = _LEADING_WORD.match(stripped) + if match is None: # pragma: no cover - a non-blank line always matches + return None + + word, remainder = match.group(1), match.group(2) + try: + args = shlex.split(remainder) + except ValueError as error: + raise CommandError(f"Cannot parse the arguments: {error}.") + return word, args + + def run_line(self, line: str, *, raise_errors: bool = False) -> bool: + """Run one line. Returns True when it succeeded. + + :param raise_errors: re-raise :class:`CommandError` instead of printing + it — used by ``source`` so a script stops at the failing line, and + by ``--execute`` so the process can exit non-zero. + """ + try: + parsed = self.split(line) + if parsed is None: + return True + word, args = parsed + entry = lookup(word) + entry.handler(self.session, args) + return True + + except ExitShell: + raise + except CommandError as error: + if raise_errors: + raise + self.printer.error(str(error)) + return False + except KeyboardInterrupt: + # A command that does not handle Ctrl+C itself: abandon it, keep + # the session. + self.printer.clear_progress() + self.printer.write("^C") + return False + except (PyMemoryEditorError, OSError, ValueError) as error: + # The target died, a page went away, a value did not fit. All of + # these are the day-to-day weather of poking at another process, + # and none of them should end the session. + if raise_errors: + raise CommandError(str(error)) + self.printer.error(str(error)) + return False + + def run_lines(self, lines: Iterable[str], *, raise_errors: bool = False) -> int: + """Run a sequence of lines, returning a process exit status.""" + for line in lines: + try: + if not self.run_line(line, raise_errors=raise_errors): + return 1 + except ExitShell as exit_request: + return exit_request.status + except CommandError as error: + self.printer.error(str(error)) + return 1 + return 0 + + # -- the interactive loop --------------------------------------------- + + def prompt(self) -> str: + """The prompt, naming the target so you cannot write to the wrong one.""" + if self.session.process is None: + return "peekmem> " + name = self.session.process_name or "?" + return f"peekmem [{name}:{self.session.process.pid}]> " + + def banner(self) -> str: + import PyMemoryEditor + + return ( + f"Welcome to Peekmem {__version__}, a terminal client for " + f"PyMemoryEditor {PyMemoryEditor.__version__}.\n" + "Commands end with a newline. Type 'help' for the command list, " + "'help scanning' for a walkthrough, 'exit' to quit.\n" + ) + + def interact(self, *, banner: bool = True) -> int: + """Run the shell until ``exit``, Ctrl+D, or the input runs out.""" + if banner: + self.printer.write(self.banner()) + + self._setup_readline() + status = 0 + + try: + while True: + try: + line = input(self.prompt()) + except KeyboardInterrupt: + # Ctrl+C at the prompt abandons the line, as in mysql. + self.printer.write("^C") + continue + except EOFError: + self.printer.write() + break + + try: + self.run_line(line) + except ExitShell as exit_request: + status = exit_request.status + break + finally: + self._save_history() + self.session.close() + + self.printer.write("Bye") + return status + + # -- readline ---------------------------------------------------------- + + def _setup_readline(self) -> None: + """Wire up history and tab completion when readline is available. + + readline is in the standard library on Linux and macOS but not on + Windows, where its absence simply means no history and no completion — + never a failure to start. + """ + try: + import readline + except ImportError: # pragma: no cover - Windows without pyreadline3 + return + + try: + readline.read_history_file(HISTORY_FILE) + except (OSError, ValueError): + pass # No history yet, or an unreadable one. Neither is fatal. + readline.set_history_length(HISTORY_LENGTH) + self._history_loaded = True + + readline.set_completer(self._complete) + readline.set_completer_delims(" \t\n") + # libedit (the readline stand-in shipped on macOS) spells the binding + # differently, and binding the wrong one is a silent no-op. + if "libedit" in (getattr(readline, "__doc__", "") or ""): + readline.parse_and_bind("bind ^I rl_complete") + else: + readline.parse_and_bind("tab: complete") + + def _save_history(self) -> None: + if not self._history_loaded: + return + try: + import readline + + readline.write_history_file(HISTORY_FILE) + except (ImportError, OSError): # pragma: no cover - read-only home + pass + + def _complete(self, text: str, state: int) -> Optional[str]: + """Tab completion over command words, type names and setting names.""" + try: + import readline + + buffer = readline.get_line_buffer()[: readline.get_endidx()] + except (ImportError, AttributeError): # pragma: no cover + buffer = text + + first_word = not buffer[: len(buffer) - len(text)].strip() + + if first_word: + candidates: Sequence[str] = command_words() + else: + head = buffer.strip().split()[0].lower() + if head == "set": + candidates = [setting.name for setting in SETTINGS] + elif head == "help": + candidates = command_words() + ["types", "address", "scanning"] + else: + candidates = valuetypes.type_names() + + matches = [item for item in candidates if item.startswith(text)] + return matches[state] if state < len(matches) else None + + +def command_summaries() -> List[Tuple[str, str]]: + """``(name, summary)`` for every command — used by the ``--help`` output.""" + return [(entry.name, entry.summary) for entry in all_commands()] + + +__all__ = ("HISTORY_FILE", "Shell", "command_summaries") diff --git a/peekmem/valuetypes.py b/peekmem/valuetypes.py new file mode 100644 index 0000000..eab4cba --- /dev/null +++ b/peekmem/valuetypes.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- + +""" +The value-type vocabulary the shell speaks. + +PyMemoryEditor's API takes a bare Python ``type`` (``bool``, ``int``, +``float``, ``str``, ``bytes``) plus an explicit byte width. A terminal user +types ``int32`` or ``float``, so this module owns the translation in both +directions: parsing what was typed into a value the library accepts, and +formatting what the library returns into a table cell. + +Unsigned integers deserve a note. The library maps ``int`` to the *signed* C +type of the requested width (``c_int32`` for 4 bytes, and so on), so there is +no unsigned pytype to ask for. The bytes in memory are identical either way — +only the interpretation differs — so an unsigned type here scans and writes the +two's-complement *signed* value with the same bit pattern and reinterprets the +result on the way back. ``uint32 4294967295`` therefore searches for the four +bytes ``FF FF FF FF``, exactly as a user expects, without the library needing +to know unsigned types exist. +""" + +import struct +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Tuple + +from .errors import CommandError + +# Types the shell will not let you read at a bare address without being told +# how many bytes to take. ``str`` and ``bytes`` have no natural width: the +# width is the question, not the answer. +VARIABLE_WIDTH = 0 + +_TRUE_WORDS = frozenset(("1", "true", "t", "yes", "y", "on")) +_FALSE_WORDS = frozenset(("0", "false", "f", "no", "n", "off")) + + +def _parse_int_text(text: str) -> int: + """Parse an integer literal, honouring 0x / 0o / 0b prefixes and ``_``.""" + cleaned = text.strip().replace("_", "") + if not cleaned: + raise CommandError("Empty integer value.") + + negative = cleaned.startswith("-") + if negative or cleaned.startswith("+"): + cleaned = cleaned[1:] + + try: + # int(x, 0) understands every prefix at once but rejects a bare "010", + # which a user typing a decimal with a leading zero would find absurd. + prefix = cleaned[:2].lower() + base = {"0x": 16, "0o": 8, "0b": 2}.get(prefix, 10) + value = int(cleaned, base) + except ValueError: + raise CommandError(f"{text!r} is not an integer.") + + return -value if negative else value + + +def printable(text: str) -> str: + """Make a string read from memory safe to put in a table cell. + + Two things happen to it. It is cut at the first NUL, because a fixed-width + read of a C string returns the string plus whatever bytes follow it, and + showing that tail helps nobody. Then every remaining control character + becomes a dot, the way a hex dump renders one — a raw newline or tab in a + cell would tear the ASCII table apart, which is a worse loss than the + exact bytes. + """ + text = text.split("\x00", 1)[0] + return "".join(char if char == " " or char.isprintable() else "." for char in text) + + +def _parse_hex_bytes(text: str) -> bytes: + """Parse ``"DE AD BE EF"`` (or ``de:ad-be:ef``) into raw bytes.""" + cleaned = text.strip() + for separator in (" ", "\t", ":", "-", ","): + cleaned = cleaned.replace(separator, "") + if cleaned.lower().startswith("0x"): + cleaned = cleaned[2:] + + if not cleaned: + raise CommandError("Empty byte array.") + if len(cleaned) % 2: + raise CommandError("A byte array needs an even number of hex digits.") + + try: + return bytes.fromhex(cleaned) + except ValueError: + raise CommandError(f"{text!r} is not a hex byte array.") + + +@dataclass(frozen=True) +class ValueType: + """One entry in the shell's type vocabulary. + + :param name: canonical name, as printed by ``help types``. + :param pytype: the Python type handed to PyMemoryEditor. + :param size: fixed width in bytes, or :data:`VARIABLE_WIDTH` when the + caller must supply one (``string`` and ``bytes``). + :param aliases: alternative spellings accepted on the command line. + :param unsigned: reinterpret the signed value the library reads/writes as + unsigned of the same width (see the module docstring). + :param summary: one-line description for ``help types``. + """ + + name: str + pytype: type + size: int + aliases: Tuple[str, ...] = () + unsigned: bool = False + summary: str = "" + struct_code: Optional[str] = field(default=None, compare=False) + + @property + def is_variable_width(self) -> bool: + return self.size == VARIABLE_WIDTH + + # -- parsing --------------------------------------------------------- + + def parse(self, text: str) -> Any: + """Turn command-line text into a value of this type.""" + if self.pytype is bool: + word = text.strip().lower() + if word in _TRUE_WORDS: + return True + if word in _FALSE_WORDS: + return False + raise CommandError(f"{text!r} is not a boolean (true/false, 1/0).") + + if self.pytype is int: + value = _parse_int_text(text) + self._check_int_range(value) + return value + + if self.pytype is float: + try: + return float(text.strip()) + except ValueError: + raise CommandError(f"{text!r} is not a number.") + + if self.pytype is bytes: + return _parse_hex_bytes(text) + + # str — taken verbatim. An empty string would size to a one-byte NUL + # buffer, which matches every zeroed byte in the target on a scan and + # writes nothing at all, so neither meaning is the one intended. + if not text: + raise CommandError("Empty string value.") + return text + + def _check_int_range(self, value: int) -> None: + bits = self.size * 8 + low, high = (0, (1 << bits) - 1) if self.unsigned else ( + -(1 << (bits - 1)), + (1 << (bits - 1)) - 1, + ) + if not low <= value <= high: + raise CommandError( + f"{value} is out of range for {self.name} ({low} to {high})." + ) + + # -- widths ---------------------------------------------------------- + + def width_for(self, value: Any, length: Optional[int] = None) -> int: + """Byte width to use for ``value``, honouring an explicit ``length``. + + Fixed-width types ignore ``length`` — a 4-byte int is four bytes + whatever the user says. Variable-width types default to the natural + width of the value: the encoded UTF-8 length for a string (counting + characters would silently truncate accented or CJK text), the array + length for bytes. + """ + if not self.is_variable_width: + return self.size + if length is not None: + if length < 1: + raise CommandError("Length must be at least 1 byte.") + return length + if isinstance(value, str): + return max(1, len(value.encode("utf-8"))) + if isinstance(value, (bytes, bytearray)): + return max(1, len(value)) + raise CommandError(f"Type {self.name} needs an explicit length.") + + def read_width(self, length: Optional[int] = None) -> int: + """Byte width to read at a bare address (no value to measure).""" + if not self.is_variable_width: + return self.size + if length is None: + raise CommandError( + f"Type {self.name} needs a length: e.g. 'read
" + f"{self.name} 32'." + ) + if length < 1: + raise CommandError("Length must be at least 1 byte.") + return length + + # -- the signed/unsigned bridge -------------------------------------- + + def encode(self, value: Any) -> Any: + """Convert a parsed value into what PyMemoryEditor should be given.""" + if self.unsigned and isinstance(value, int): + bits = self.size * 8 + # Same bit pattern, signed reading — c_int32(-1) and an unsigned + # 0xFFFFFFFF put identical bytes on the wire. + return value - (1 << bits) if value >= (1 << (bits - 1)) else value + return value + + def decode(self, value: Any) -> Any: + """Convert what PyMemoryEditor returned into the user's reading of it.""" + if value is None: + return None + if self.unsigned and isinstance(value, int): + bits = self.size * 8 + return value + (1 << bits) if value < 0 else value + return value + + # -- formatting ------------------------------------------------------ + + def format(self, value: Any, *, hex_output: bool = False) -> str: + """Render a value for a result table.""" + if value is None: + return "?" + if self.pytype is bool: + return "true" if value else "false" + if self.pytype is int: + return f"0x{value:X}" if hex_output and value >= 0 else str(value) + if self.pytype is float: + return f"{value:g}" + if self.pytype is bytes: + return " ".join(f"{byte:02X}" for byte in value) + return printable(str(value)) + + def to_bytes(self, value: Any, width: int) -> bytes: + """Best-effort byte image of ``value`` at ``width`` bytes. + + Used by the *_BY refine comparisons and by nothing that touches the + target, so an unrepresentable value is a programming error rather than + something to report to the user. + """ + wire = self.encode(value) + if self.struct_code: + return struct.pack("<" + self.struct_code, wire) + if isinstance(wire, str): + return wire.encode("utf-8")[:width].ljust(width, b"\x00") + return bytes(wire)[:width].ljust(width, b"\x00") + + +#: Every type the shell knows, in the order ``help types`` prints them. +VALUE_TYPES: Tuple[ValueType, ...] = ( + ValueType("int8", int, 1, ("i8", "char", "sbyte"), summary="signed 1-byte integer", struct_code="b"), + ValueType("int16", int, 2, ("i16", "short"), summary="signed 2-byte integer", struct_code="h"), + ValueType("int32", int, 4, ("i32", "int"), summary="signed 4-byte integer (the usual default)", struct_code="i"), + ValueType("int64", int, 8, ("i64", "long", "longlong"), summary="signed 8-byte integer", struct_code="q"), + ValueType("uint8", int, 1, ("u8", "byte", "ubyte"), unsigned=True, summary="unsigned 1-byte integer", struct_code="B"), + ValueType("uint16", int, 2, ("u16", "ushort", "word"), unsigned=True, summary="unsigned 2-byte integer", struct_code="H"), + ValueType("uint32", int, 4, ("u32", "uint", "dword"), unsigned=True, summary="unsigned 4-byte integer", struct_code="I"), + ValueType("uint64", int, 8, ("u64", "ulong", "qword"), unsigned=True, summary="unsigned 8-byte integer", struct_code="Q"), + ValueType("float", float, 4, ("f32", "single"), summary="4-byte IEEE float", struct_code="f"), + ValueType("double", float, 8, ("f64",), summary="8-byte IEEE float", struct_code="d"), + ValueType("bool", bool, 1, ("boolean",), summary="1-byte boolean", struct_code="?"), + ValueType("string", str, VARIABLE_WIDTH, ("str", "utf8", "text"), summary="UTF-8 text; give a length in bytes"), + ValueType("bytes", bytes, VARIABLE_WIDTH, ("hex", "bytearray", "aob"), summary="raw bytes, written as hex ('DE AD BE EF')"), +) + +_BY_NAME: Dict[str, ValueType] = {} +for _value_type in VALUE_TYPES: + _BY_NAME[_value_type.name] = _value_type + for _alias in _value_type.aliases: + _BY_NAME[_alias] = _value_type + +#: Default type when a command takes one but the user did not say which. +DEFAULT_TYPE = _BY_NAME["int32"] + + +def resolve(name: str) -> ValueType: + """Look up a type by canonical name or alias. + + :raises CommandError: when the name is unknown, listing what is valid — + a shell that answers "no such type" without saying which types exist + makes the user go and read a manual. + """ + value_type = _BY_NAME.get(name.strip().lower()) + if value_type is None: + known = ", ".join(item.name for item in VALUE_TYPES) + raise CommandError(f"Unknown type {name!r}. Known types: {known}.") + return value_type + + +def type_names() -> Tuple[str, ...]: + """Every accepted spelling, for tab completion.""" + return tuple(sorted(_BY_NAME)) + + +__all__ = ( + "DEFAULT_TYPE", + "VALUE_TYPES", + "VARIABLE_WIDTH", + "ValueType", + "printable", + "resolve", + "type_names", +) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..394dad5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,122 @@ +[project] +name = "peekmem" +dynamic = ["version"] +description = "A terminal client for PyMemoryEditor — read, write and scan process memory from any shell on Windows, Linux and macOS." +authors = [ + { name = "Jean Loui Bernard Silva de Jesus", email = "contact@jeanloui.dev" }, +] +maintainers = [ + { name = "Jean Loui Bernard Silva de Jesus", email = "contact@jeanloui.dev" }, +] +license = "MIT" +license-files = ["LICENSE*"] +readme = "README.md" +keywords = [ + "cli", + "shell", + "repl", + "terminal", + "memory-scanner", + "memory-editor", + "process-memory", + "cheat-engine", + "pointer-scan", + "aob-scan", + "game-hacking", + "reverse-engineering", + "debugging", +] + +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Security", + "Topic :: Software Development :: Debuggers", + "Topic :: System :: Monitoring", + "Topic :: Utilities", + "Typing :: Typed", +] +requires-python = ">=3.10" + +# Peekmem is a client, not a reimplementation: every memory operation is +# PyMemoryEditor's. The floor is 2.2.0 because Peekmem uses the region +# snapshot, the module/thread descriptors and the pointer-scan file format +# introduced up to that release. +# +# Nothing else is needed. The shell is stdlib only (argparse, shlex, readline +# where the platform has it), which is what lets `pip install peekmem` work on +# a bare server with no compiler and no wheels to build. +dependencies = [ + "PyMemoryEditor>=2.2.0", +] + +[project.optional-dependencies] +# Vectorised scan acceleration. NumPy lights up the fast path inside +# PyMemoryEditor automatically — Peekmem needs no code change and behaves +# identically without it, only slower on large regions. +speed = [ + "PyMemoryEditor[speed]>=2.2.0", +] +dev = [ + "pytest", + "pytest-cov", + "flake8", + "mypy", + "build", + "twine", +] + +[project.scripts] +peekmem = "peekmem.cli:main" + +[project.urls] +Homepage = "https://github.com/JeanExtreme002/Peekmem" +Repository = "https://github.com/JeanExtreme002/Peekmem" +Issues = "https://github.com/JeanExtreme002/Peekmem/issues" +Changelog = "https://github.com/JeanExtreme002/Peekmem/releases" +"PyMemoryEditor" = "https://github.com/JeanExtreme002/PyMemoryEditor" +Funding = "https://github.com/sponsors/JeanExtreme002" + +[tool.hatch.version] +path = "peekmem/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["peekmem"] + +[tool.hatch.build.targets.wheel.force-include] +"peekmem/py.typed" = "peekmem/py.typed" + +[tool.hatch.build.targets.sdist] +exclude = ["/.github"] + +[tool.mypy] +ignore_missing_imports = true +warn_unused_ignores = true + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.coverage.run] +source = ["peekmem"] + +[tool.coverage.report] +show_missing = true +skip_empty = true + +# hatchling>=1.27 understands the PEP 639 `license = "MIT"` expression and the +# `license-files` glob above; older versions fail with a confusing error. +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d74f7c1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +"""Shared fixtures. Nothing here attaches to a process. + +The suite is deliberately target-free: it covers the parts of Peekmem that are +its own — parsing, formatting, dispatch, help — and leaves reading another +process's memory to PyMemoryEditor's own tests. That keeps the suite runnable +on any CI machine, where opening a second process is usually not permitted. +""" + +import io + +import pytest + +from peekmem.output import Printer +from peekmem.session import Session +from peekmem.shell import Shell + + +class Capture: + """A printer writing to strings, plus helpers to read them back.""" + + def __init__(self) -> None: + self.stdout = io.StringIO() + self.stderr = io.StringIO() + self.printer = Printer(self.stdout, self.stderr, color=False, timing=False) + + @property + def out(self) -> str: + return self.stdout.getvalue() + + @property + def err(self) -> str: + return self.stderr.getvalue() + + def reset(self) -> None: + self.stdout.seek(0) + self.stdout.truncate() + self.stderr.seek(0) + self.stderr.truncate() + + +@pytest.fixture +def capture() -> Capture: + return Capture() + + +@pytest.fixture +def session(capture: Capture) -> Session: + return Session(capture.printer) + + +@pytest.fixture +def shell(session: Session, capture: Capture) -> Shell: + return Shell(session, printer=capture.printer) diff --git a/tests/test_addressing.py b/tests/test_addressing.py new file mode 100644 index 0000000..4d39a69 --- /dev/null +++ b/tests/test_addressing.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +"""The address expression language.""" + +import pytest + +from peekmem.addressing import parse_address, parse_int +from peekmem.errors import CommandError + + +class FakeSession: + """Stands in for a Session, answering the three hooks the parser uses.""" + + def __init__(self): + self.pointers = {0x1000: 0x2000, 0x2010: 0x3000} + self.reads = [] + + def module_base(self, name): + bases = {"game.exe": 0x400000, "libfoo-1.so": 0x500000} + try: + return bases[name.lower()] + except KeyError: + raise CommandError(f"No loaded module matches {name!r}.") + + def read_pointer(self, address): + self.reads.append(address) + if address not in self.pointers: + raise CommandError(f"Cannot read the pointer at 0x{address:X}.") + return self.pointers[address] + + def result_address(self, index): + if index != 3: + raise CommandError(f"#{index} is out of range.") + return 0xDEAD0000 + + +@pytest.fixture +def fake(): + return FakeSession() + + +@pytest.mark.parametrize( + "text,expected", + [ + ("0x1000", 0x1000), + ("4096", 4096), + ("0x1000+0x10", 0x1010), + ("0x1000 - 0x10", 0xFF0), + ("game.exe+0x1234", 0x401234), + ("'libfoo-1.so'+0x20", 0x500020), + ("#3", 0xDEAD0000), + ("#3+8", 0xDEAD0008), + ], +) +def test_expressions(fake, text, expected): + assert parse_address(text, fake) == expected + + +def test_dereference_reads_through_the_pointer(fake): + assert parse_address("[0x1000]", fake) == 0x2000 + assert parse_address("[0x1000]+0x10", fake) == 0x2010 + + +def test_nested_dereference(fake): + assert parse_address("[[0x1000]+0x10]", fake) == 0x3000 + assert fake.reads == [0x1000, 0x2010] + + +def test_unknown_module_is_a_command_error(fake): + with pytest.raises(CommandError): + parse_address("nosuch.dll+0x10", fake) + + +@pytest.mark.parametrize("text", ["", "[0x1000", "0x1000+", "0x1000 0x10", "@", "#"]) +def test_malformed_expressions_are_reported(fake, text): + with pytest.raises(CommandError): + parse_address(text, fake) + + +def test_negative_result_is_rejected(fake): + with pytest.raises(CommandError): + parse_address("0x10-0x20", fake) + + +def test_parse_int_accepts_hex_and_decimal(): + assert parse_int("0x10") == 16 + assert parse_int("16") == 16 + with pytest.raises(CommandError): + parse_int("sixteen") diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8a25ba9 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- + +"""The command-line front end: flags, batch mode and exit statuses.""" + +import io + +import pytest + +from peekmem.cli import build_parser, main + + +def run(argv, stdin_text=None, monkeypatch=None): + """Run main() with stdin replaced, returning (status, stdout, stderr).""" + import sys + + stdout, stderr = io.StringIO(), io.StringIO() + stdin = io.StringIO(stdin_text or "") + # An explicit isatty: a StringIO has none, and _batch_lines asks. + stdin.isatty = lambda: stdin_text is None # type: ignore[method-assign] + + old = sys.stdout, sys.stderr, sys.stdin + sys.stdout, sys.stderr, sys.stdin = stdout, stderr, stdin + try: + status = main(argv) + finally: + sys.stdout, sys.stderr, sys.stdin = old + return status, stdout.getvalue(), stderr.getvalue() + + +def test_execute_runs_a_command_and_exits(): + status, out, _ = run(["-e", "version"]) + assert status == 0 + assert "Peekmem" in out + + +def test_execute_flags_run_in_order(): + status, out, _ = run(["-e", "set limit 3", "-e", "set"]) + assert status == 0 + assert out.index("limit = 3") < out.index("SETTING") + + +def test_a_trailing_command_works_like_execute(): + status, out, _ = run(["ps", "--limit", "1"]) + assert status == 0 + assert "PID" in out + + +def test_a_failing_command_exits_non_zero(): + status, _, err = run(["-e", "read 0x10"]) + assert status == 1 + assert "No process attached" in err + + +def test_commands_after_a_failure_do_not_run(): + status, out, _ = run(["-e", "nosuchcommand", "-e", "version"]) + assert status == 1 + assert "Peekmem" not in out + + +def test_commands_are_read_from_a_pipe(): + status, out, _ = run([], stdin_text="version\n# comment\n") + assert status == 0 + assert out.count("Peekmem") == 1 + + +def test_pid_and_name_together_are_rejected(): + status, _, err = run(["-p", "1", "-n", "init", "-e", "version"]) + assert status == 2 + assert "not both" in err + + +def test_a_bad_pid_stops_before_the_commands(): + status, out, err = run(["-p", "2147483646", "-e", "version"]) + assert status == 1 + assert "Peekmem" not in out + + +def test_limit_flag_reaches_the_session(): + status, out, _ = run(["--limit", "1", "-e", "ps"]) + assert status == 0 + assert "Showing 1 of" in out + + +def test_version_flag(): + parser = build_parser() + with pytest.raises(SystemExit) as exit_info: + parser.parse_args(["--version"]) + assert exit_info.value.code == 0 + + +def test_help_lists_the_commands(capsys): + parser = build_parser() + text = parser.format_help() + assert "scan" in text and "ptrscan" in text and "Pointers" in text diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..95adf2b --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- + +"""Properties every command must hold, and the ones that need no target.""" + +import pytest + +from peekmem.commands import GROUPS, all_commands, command_words, lookup +from peekmem.errors import CommandError, NoProcessError + + +COMMANDS = all_commands() + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_every_command_is_documented(entry): + assert entry.summary and entry.summary[0].isupper() and entry.summary.endswith(".") + assert entry.usage.startswith(entry.name) + assert entry.group in GROUPS + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_every_command_has_help(entry, shell, capture): + """'help ' must work for every command, alias included.""" + shell.run_line(f"help {entry.name}") + assert entry.summary in capture.out + for alias in entry.aliases: + assert lookup(alias).name == entry.name + + +def test_command_words_are_unique(): + words = command_words() + assert len(words) == len(set(words)) + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_examples_parse_as_commands(entry, shell): + """A documented example that cannot even be split is a broken example.""" + for example in entry.examples: + parsed = shell.split(example) + assert parsed is not None + assert lookup(parsed[0]).name == entry.name + + +@pytest.mark.parametrize( + "line", + [ + "read 0x10", + "write 0x10 int32 1", + "dump 0x10", + "regions", + "modules", + "threads", + "scan int32 1", + "aob 'DE AD'", + "regex abc", + "deref 0x10", + "pointer 0x10", + "ptrscan 0x10", + "alloc 16", + "free 0x10", + "watch 0x10", + "info", + ], +) +def test_commands_needing_a_target_refuse_without_one(shell, line): + with pytest.raises(NoProcessError): + shell.run_line(line, raise_errors=True) + + +@pytest.mark.parametrize( + "line", + ["next 1", "results", "keep 1", "drop 1", "paths", "ptrsave out.json"], +) +def test_commands_needing_results_refuse_without_them(shell, line): + with pytest.raises(CommandError): + shell.run_line(line, raise_errors=True) + + +def test_close_without_a_target_is_an_error(shell): + with pytest.raises(CommandError): + shell.run_line("close", raise_errors=True) + + +def test_status_works_with_no_target(shell, capture): + shell.run_line("status") + assert "(none attached)" in capture.out + + +def test_ps_lists_this_process(shell, capture): + """The one command that talks to the OS without attaching to anything.""" + import os + + shell.run_line("set limit 0") + shell.run_line("ps") + assert str(os.getpid()) in capture.out + + +def test_set_prints_booleans_the_way_they_are_typed(shell, capture): + shell.run_line("set") + assert "| off" in capture.out or "off " in capture.out + capture.reset() + shell.run_line("set hex on") + assert "hex = on" in capture.out + + +def test_set_accepts_the_equals_form(shell): + shell.run_line("set limit=42") + assert shell.session.option("limit") == 42 + + +def test_unknown_option_is_reported_not_swallowed(shell): + with pytest.raises(CommandError): + shell.run_line("ps --nosuchflag", raise_errors=True) + + +def test_reset_reports_what_it_discarded(shell, capture): + from peekmem import valuetypes + + shell.session.store_scan(valuetypes.resolve("int32"), 4, [1, 2], [0, 0], "t") + shell.run_line("reset") + assert "Discarded 2 result(s)." in capture.out + assert shell.session.scan is None diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..3a3aaae --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- + +"""Table, hexdump and footer rendering.""" + +from peekmem.output import ( + LEFT, + RIGHT, + format_address, + format_size, + render_hexdump, + render_table, + render_vertical, +) + + +def test_table_is_a_closed_box_with_aligned_columns(): + text = render_table(("PID", "NAME"), [(7, "init"), (4242, "a")], (RIGHT, LEFT)) + lines = text.splitlines() + assert lines[0] == lines[2] == lines[-1] + assert set(len(line) for line in lines) == {len(lines[0])} + assert "| 7 | init |" in text + + +def test_table_cells_never_break_the_box(): + """A value read out of another process can contain anything at all.""" + text = render_table(("V",), [("a\nb\tc",)], (LEFT,)) + assert len(text.splitlines()) == 5 + assert "a.b.c" in text + + +def test_footer_counts_rows_and_says_when_it_is_showing_fewer(capture): + printer = capture.printer + printer.footer(3) + printer.footer(0) + printer.footer(3, total=90) + lines = [line for line in capture.out.splitlines() if line] + assert lines == ["3 rows in set", "Empty set", "Showing 3 of 90 rows"] + + +def test_footer_reports_one_row_in_the_singular(capture): + capture.printer.footer(1) + assert "1 row in set" in capture.out + + +def test_timing_is_printed_only_when_enabled(capture): + capture.printer.timing = True + capture.printer.footer(1, elapsed=0.125) + assert "(0.12 sec)" in capture.out + + +def test_errors_go_to_stderr(capture): + capture.printer.error("boom") + assert capture.err.strip() == "ERROR: boom" + assert capture.out == "" + + +def test_hexdump_layout(): + text = render_hexdump(b"AB\x00\xff", 0x1000, width=4) + assert text == "0000000000001000 41 42 00 FF |AB..|" + + +def test_addresses_are_padded_to_the_pointer_width(): + assert format_address(0x1000, 8) == "0x0000000000001000" + assert format_address(0x1000, 4) == "0x00001000" + + +def test_sizes_are_human_readable(): + assert format_size(512) == "512 B" + assert format_size(2048) == "2.0 KB" + + +def test_vertical_block_aligns_the_keys(): + text = render_vertical([("PID", 1), ("Name", "init")]) + assert text == " PID: 1\nName: init" + + +def test_progress_is_silent_when_stderr_is_not_a_terminal(capture): + capture.printer.progress("Scanning", 0.5) + assert capture.err == "" diff --git a/tests/test_session.py b/tests/test_session.py new file mode 100644 index 0000000..21cb569 --- /dev/null +++ b/tests/test_session.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- + +"""Session state: settings, scan results and the '#N' reference.""" + +import pytest + +from peekmem import valuetypes +from peekmem.errors import CommandError, NoProcessError +from peekmem.session import SETTINGS, Session + + +def test_defaults_match_the_documented_settings(session: Session): + for setting in SETTINGS: + assert session.option(setting.name) == setting.default + + +@pytest.mark.parametrize("text,expected", [("on", True), ("off", False), ("TRUE", True)]) +def test_boolean_settings(session: Session, text, expected): + assert session.set_option("hex", text) is expected + + +def test_integer_settings_reject_nonsense(session: Session): + assert session.set_option("limit", "50") == 50 + with pytest.raises(CommandError): + session.set_option("limit", "many") + with pytest.raises(CommandError): + session.set_option("limit", "-1") + + +def test_float_settings_must_be_positive(session: Session): + assert session.set_option("watch_interval", "0.25") == 0.25 + with pytest.raises(CommandError): + session.set_option("watch_interval", "0") + + +def test_unknown_setting_lists_the_known_ones(session: Session): + with pytest.raises(CommandError) as error: + session.set_option("colour", "on") + assert "limit" in str(error.value) + + +def test_timing_setting_reaches_the_printer(session: Session): + session.set_option("timing", "off") + assert session.printer.timing is False + + +def test_display_limit_of_zero_means_everything(session: Session): + session.set_option("limit", "0") + assert session.display_limit() is None + assert session.display_limit(5) == 5 + + +def test_commands_needing_a_target_say_so(session: Session): + with pytest.raises(NoProcessError) as error: + session.require_process("read") + assert "open" in str(error.value) + + +def test_result_reference_needs_a_scan(session: Session): + with pytest.raises(CommandError): + session.result_address(1) + + +def test_result_reference_is_one_based(session: Session): + session.store_scan(valuetypes.resolve("int32"), 4, [0x10, 0x20], [1, 2], "test") + assert session.result_address(1) == 0x10 + assert session.result_address(2) == 0x20 + with pytest.raises(CommandError): + session.result_address(3) + with pytest.raises(CommandError): + session.result_address(0) + + +def test_detaching_without_a_target_is_not_an_error(session: Session): + assert session.detach() is False diff --git a/tests/test_shell.py b/tests/test_shell.py new file mode 100644 index 0000000..4b03ce1 --- /dev/null +++ b/tests/test_shell.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +"""Line parsing and dispatch.""" + +import pytest + +from peekmem.errors import CommandError, ExitShell +from peekmem.shell import Shell + + +@pytest.mark.parametrize( + "line,expected", + [ + ("ps", ("ps", [])), + (" ps chrome ", ("ps", ["chrome"])), + ("ps chrome;", ("ps", ["chrome"])), + ("ps chrome ;;", ("ps", ["chrome"])), + ("write 0x10 bytes 'DE AD'", ("write", ["0x10", "bytes", "DE AD"])), + ("\\q", ("\\q", [])), + ("source \\.", ("source", ["."])), + ], +) +def test_split(line, expected): + assert Shell.split(line) == expected + + +@pytest.mark.parametrize("line", ["", " ", "# a comment", "-- also a comment"]) +def test_blank_and_comment_lines_are_skipped(line): + assert Shell.split(line) is None + + +def test_unbalanced_quotes_are_reported(): + with pytest.raises(CommandError): + Shell.split("scan string 'unclosed") + + +def test_unknown_command_suggests_a_near_miss(shell, capture): + assert shell.run_line("scna int32 1") is False + assert "Did you mean 'scan'" in capture.err + + +def test_a_failing_command_does_not_end_the_session(shell, capture): + assert shell.run_line("read 0x10") is False + assert shell.run_line("version") is True + assert "Peekmem" in capture.out + + +def test_errors_can_be_raised_instead_of_printed(shell): + with pytest.raises(CommandError): + shell.run_line("read 0x10", raise_errors=True) + + +def test_exit_unwinds_the_loop(shell): + with pytest.raises(ExitShell): + shell.run_line("exit") + + +def test_run_lines_stops_at_the_first_failure(shell, capture): + status = shell.run_lines(["version", "nosuchcommand", "version"], raise_errors=True) + assert status == 1 + assert capture.out.count("Peekmem") == 1 + + +def test_run_lines_returns_the_exit_status(shell): + assert shell.run_lines(["version", "exit"]) == 0 + + +def test_prompt_names_the_target(shell): + assert shell.prompt() == "peekmem> " + + +def test_help_lists_every_group(shell, capture): + shell.run_line("help") + for group in ("Process", "Memory", "Scanning", "Pointers", "Session"): + assert group in capture.out + + +def test_help_topics_are_reachable(shell, capture): + shell.run_line("help address") + assert "module+offset" in capture.out + capture.reset() + shell.run_line("help scanning") + assert "next changed" in capture.out + + +def test_source_runs_a_file(shell, capture, tmp_path): + script = tmp_path / "setup.peek" + script.write_text("# a comment\nset limit 7\n\nset hex on\n") + shell.run_line(f"source {script}") + assert shell.session.option("limit") == 7 + assert shell.session.option("hex") is True + + +def test_source_stops_at_the_failing_line(shell, capture, tmp_path): + script = tmp_path / "bad.peek" + script.write_text("set limit 7\nnosuchcommand\nset limit 9\n") + shell.run_line(f"source {script}") + assert "bad.peek:2" in capture.err + assert shell.session.option("limit") == 7 + + +def test_interactive_loop_reads_until_eof(shell, capture, monkeypatch): + lines = iter(["version", "exit"]) + monkeypatch.setattr("builtins.input", lambda prompt="": next(lines)) + assert shell.interact(banner=False) == 0 + assert "Bye" in capture.out + + +def test_ctrl_c_at_the_prompt_does_not_quit(shell, capture, monkeypatch): + answers = iter([KeyboardInterrupt, "exit"]) + + def fake_input(prompt=""): + value = next(answers) + if value is KeyboardInterrupt: + raise KeyboardInterrupt + return value + + monkeypatch.setattr("builtins.input", fake_input) + assert shell.interact(banner=False) == 0 + assert "^C" in capture.out diff --git a/tests/test_valuetypes.py b/tests/test_valuetypes.py new file mode 100644 index 0000000..1f16a6d --- /dev/null +++ b/tests/test_valuetypes.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +"""The type vocabulary: parsing, widths and the unsigned bridge.""" + +import pytest + +from peekmem import valuetypes +from peekmem.errors import CommandError + + +def test_aliases_resolve_to_the_same_type(): + assert valuetypes.resolve("int32") is valuetypes.resolve("i32") + assert valuetypes.resolve("INT") is valuetypes.resolve("int32") + assert valuetypes.resolve("dword") is valuetypes.resolve("uint32") + + +def test_unknown_type_lists_the_known_ones(): + with pytest.raises(CommandError) as error: + valuetypes.resolve("int37") + assert "int32" in str(error.value) + + +@pytest.mark.parametrize( + "text,expected", + [("10", 10), ("0x10", 16), ("0b1010", 10), ("0o17", 15), ("-5", -5), ("1_000", 1000)], +) +def test_integer_literals(text, expected): + assert valuetypes.resolve("int32").parse(text) == expected + + +def test_integer_range_is_checked_per_width(): + with pytest.raises(CommandError): + valuetypes.resolve("int8").parse("200") + assert valuetypes.resolve("uint8").parse("200") == 200 + with pytest.raises(CommandError): + valuetypes.resolve("uint8").parse("-1") + + +def test_unsigned_values_travel_as_the_same_bits(): + """An unsigned value must scan for the byte pattern the user expects.""" + uint32 = valuetypes.resolve("uint32") + assert uint32.encode(4294967295) == -1 + assert uint32.decode(-1) == 4294967295 + # Values below the signed ceiling are untouched in both directions. + assert uint32.encode(7) == 7 + assert uint32.decode(7) == 7 + + +def test_signed_types_are_left_alone(): + int32 = valuetypes.resolve("int32") + assert int32.encode(-1) == -1 + assert int32.decode(-1) == -1 + + +@pytest.mark.parametrize("text,expected", [("true", True), ("off", False), ("1", True)]) +def test_boolean_words(text, expected): + assert valuetypes.resolve("bool").parse(text) is expected + + +def test_byte_arrays_accept_the_usual_separators(): + parse = valuetypes.resolve("bytes").parse + assert parse("DE AD BE EF") == b"\xde\xad\xbe\xef" + assert parse("de:ad-be:ef") == b"\xde\xad\xbe\xef" + with pytest.raises(CommandError): + parse("DEA") + + +def test_string_width_counts_encoded_bytes(): + """Counting characters would silently truncate accented text.""" + string = valuetypes.resolve("string") + assert string.width_for("abc") == 3 + assert string.width_for("ábc") == 4 + assert string.width_for("abc", 16) == 16 + + +def test_variable_width_types_demand_a_length_at_a_bare_address(): + with pytest.raises(CommandError) as error: + valuetypes.resolve("string").read_width(None) + assert "length" in str(error.value) + assert valuetypes.resolve("int32").read_width(None) == 4 + + +def test_printable_cuts_at_nul_and_dots_control_characters(): + assert valuetypes.printable("name\x00garbage") == "name" + assert valuetypes.printable("a\nb\tc") == "a.b.c" + + +def test_format_renders_each_type_readably(): + assert valuetypes.resolve("bool").format(True) == "true" + assert valuetypes.resolve("bytes").format(b"\xde\xad") == "DE AD" + assert valuetypes.resolve("int32").format(255, hex_output=True) == "0xFF" + assert valuetypes.resolve("float").format(1.5) == "1.5" + assert valuetypes.resolve("int32").format(None) == "?" From 51dbf1abace6a16a10a55a3ce33c104a51367b3c Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 00:30:59 -0300 Subject: [PATCH 02/82] chore: add the project's community and tooling files Adds the standard repository furniture, adapted to a CLI client rather than copied from PyMemoryEditor verbatim: - Makefile with the usual install/test/lint/build/publish targets, plus `run` to launch the shell and `smoke` to drive the CLI as a real process. - CONTRIBUTING.md documenting the project layout, the two rules that keep its shape (commands print through session.printer; anything the user got wrong raises CommandError), and how to add a command. - SECURITY.md scoped to the client: command and expression parsing, `source` scripts, `ptrload` files, writes landing where they were reported. Memory operations themselves are routed upstream. - CODE_OF_CONDUCT.md (Contributor Covenant) with the enforcement contact filled in. - Issue templates that ask for `peekmem -e "version"` and whether the session was elevated, a PR template, path labels, dependabot and funding config. - Workflows for PR labelling, conventional-commit title linting and head branch cleanup, matching the ones PyMemoryEditor runs. CI now measures coverage with a `--cov-fail-under=55` gate (the suite sits at ~60%; the gap is command bodies that need a live target it deliberately never attaches to) and uploads to Codecov as informational only, per codecov.yml. --- .github/FUNDING.yml | 3 + .github/ISSUE_TEMPLATE/bug_report.md | 41 +++ .github/ISSUE_TEMPLATE/feature_request.md | 35 +++ .github/ISSUE_TEMPLATE/questioning.md | 34 +++ .github/dependabot.yml | 21 ++ .github/labeler.yml | 48 ++++ .github/pull_request_template.md | 27 ++ .github/workflows/delete-pr-branch.yml | 41 +++ .github/workflows/labeler.yml | 27 ++ .github/workflows/lint-pr-title.yml | 46 ++++ .github/workflows/python-package.yml | 19 +- CODE_OF_CONDUCT.md | 128 +++++++++ CONTRIBUTING.md | 143 ++++++++++ Makefile | 304 ++++++++++++++++++++++ README.md | 21 +- SECURITY.md | 67 +++++ codecov.yml | 25 ++ 17 files changed, 1023 insertions(+), 7 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/questioning.md create mode 100644 .github/dependabot.yml create mode 100644 .github/labeler.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/delete-pr-branch.yml create mode 100644 .github/workflows/labeler.yml create mode 100644 .github/workflows/lint-pr-title.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 SECURITY.md create mode 100644 codecov.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..6f9e1e6 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: JeanExtreme002 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..0939c92 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +The exact commands you ran, and what happened: + +```console +$ peekmem +peekmem> open 1234 +peekmem> ... +``` + +**Expected behavior** +A clear and concise description of what you expected to happen instead. + +**Versions** +Paste the output of `peekmem -e "version"` — it covers Peekmem, PyMemoryEditor, +Python and the platform in one line: + +``` +Peekmem 0.1.0 / PyMemoryEditor 2.2.0 / Python 3.12.0 on Linux (x86_64) +``` + +**Environment** +- Were you running elevated (`sudo` / Administrator)? [yes / no] +- Terminal (e.g. Windows Terminal, iTerm2, GNOME Terminal, plain SSH): +- On Linux, the value of `/proc/sys/kernel/yama/ptrace_scope`: +- Target process (e.g. a game, another Python script), if that matters: + +**Additional context** +Add any other context about the problem here. If the target was a process you +cannot share, a minimal script that reproduces the same behaviour helps a lot. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..00f0aba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,35 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +- A clear and concise description of what the problem is. Ex. I always have to + type [...] to do [...] +- A clear and concise description of what you want to happen. +- Any alternative commands or workflows you have considered. + +**Is not your feature request related to a problem? Please describe** +A clear and concise description of how your feature can positively impact the +project. + +**What would it look like?** +If it is a new command or a new flag, sketch the session: + +```console +peekmem> mycommand 0x1000 --flag +``` + +**Is this Peekmem or PyMemoryEditor?** +Peekmem is a client — it parses commands and formats results, while +[PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor) performs +every read, write and scan. If the feature needs a memory capability that does +not exist yet, it may belong upstream. Say which you think it is; being wrong +is fine, the issue will be routed. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/questioning.md b/.github/ISSUE_TEMPLATE/questioning.md new file mode 100644 index 0000000..f3c8318 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/questioning.md @@ -0,0 +1,34 @@ +--- +name: Questioning +about: Ask a question about the project +title: '' +labels: question +assignees: '' + +--- + +**Did you check the built-in help? If so, please describe** +Peekmem documents itself: `help` lists every command, `help ` explains +one in full, and `help types`, `help address` and `help scanning` cover what +several commands share. If one of those was confusing, say which and how — that +is a documentation bug worth fixing. + +**Describe your question** +A clear and concise description of what you are trying to do. + +**What did you try?** +The commands you ran and the output you got: + +```console +peekmem> ... +``` + +**Versions** +Paste the output of `peekmem -e "version"`, if applicable. + +**Environment** +- Were you running elevated (`sudo` / Administrator)? [yes / no] +- On Linux, the value of `/proc/sys/kernel/yama/ptrace_scope`: + +**Additional context** +Add any other context about your question here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..8021f30 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +# +# `open-pull-requests-limit: 0` disables routine version-update PRs (no weekly +# bump churn). Dependabot security advisories still surface via the Security +# tab and security-update PRs are unaffected by this limit, so a vulnerability +# in PyMemoryEditor or an action remains visible. + +version: 2 + +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..0fc4c1e --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,48 @@ +# Path-based labels applied to PRs by .github/workflows/labeler.yml. +# Uses actions/labeler@v5 match rules. + +# The command layer — where a new command or a change to an existing one lands. +commands: + - changed-files: + - any-glob-to-any-file: + - "peekmem/commands/**" + +# The shell itself: line parsing, dispatch, readline, the CLI front end. +shell: + - changed-files: + - any-glob-to-any-file: + - "peekmem/shell.py" + - "peekmem/cli.py" + +# Everything that decides how a result looks on screen. +output: + - changed-files: + - any-glob-to-any-file: + - "peekmem/output.py" + - "peekmem/valuetypes.py" + +# Any change inside the package. +core: + - changed-files: + - any-glob-to-any-file: + - "peekmem/**" + +tests: + - changed-files: + - any-glob-to-any-file: + - "tests/**" + +docs: + - changed-files: + - any-glob-to-any-file: + - "README.md" + - "CONTRIBUTING.md" + - "SECURITY.md" + - "CODE_OF_CONDUCT.md" + +ci: + - changed-files: + - any-glob-to-any-file: + - ".github/**" + - "Makefile" + - "codecov.yml" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..090686c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +**Why is this PR necessary, what does it do?** + + + +**Checklist (complete all items)**: + +- [ ] Added tests as necessary. +- [ ] There is no breaking change for existing features. + +**References:** + + + +No references to be shared. + +**Notes:** + + + +No notes to be shared. \ No newline at end of file diff --git a/.github/workflows/delete-pr-branch.yml b/.github/workflows/delete-pr-branch.yml new file mode 100644 index 0000000..0d05c11 --- /dev/null +++ b/.github/workflows/delete-pr-branch.yml @@ -0,0 +1,41 @@ +name: Delete PR branch + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + delete: + name: Delete head branch after PR close + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const ref = pr.head.ref; + + const protectedBranches = new Set(["main", "gh-pages"]); + if (protectedBranches.has(ref)) { + core.info(`Refusing to delete protected branch: ${ref}`); + return; + } + + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${ref}`, + }); + core.info(`Deleted branch: ${ref}`); + } catch (err) { + if (err.status === 422 || err.status === 404) { + core.info(`Branch already gone: ${ref}`); + return; + } + throw err; + } \ No newline at end of file diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..3f89597 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,27 @@ +name: Labeler + +on: + pull_request_target: + types: + - opened + - synchronize + - reopened + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + label: + name: Label PR by changed files + runs-on: ubuntu-latest + steps: + - name: Apply labels + uses: actions/labeler@v5 + with: + configuration-path: .github/labeler.yml + sync-labels: true diff --git a/.github/workflows/lint-pr-title.yml b/.github/workflows/lint-pr-title.yml new file mode 100644 index 0000000..b89d858 --- /dev/null +++ b/.github/workflows/lint-pr-title.yml @@ -0,0 +1,46 @@ +name: Lint PR title + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + - reopened + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: + pull-requests: read + +jobs: + lint: + name: Conventional commit title + runs-on: ubuntu-latest + steps: + - name: Lint PR title + uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Conventional commit types accepted in titles. Mirrors the set the + # PR labeler recognizes in .github/workflows/labeler.yml. + types: | + feat + fix + perf + refactor + revert + docs + ci + build + chore + test + style + # Subject must start lowercase and not end with a period. + subjectPattern: ^(?![A-Z])(?!.*\.$).+$ + subjectPatternError: | + The subject "{subject}" found in "{title}" must start with a + lowercase letter and must not end with a period. \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 83c7219..96ff544 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -71,7 +71,24 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" - name: Run tests - run: pytest -q + # --cov-fail-under is the real, in-repo coverage gate: deterministic and + # independent of any external service. It is a conservative floor — the + # suite measures ~60% today, and the gap is almost entirely command + # bodies that need a live target the suite deliberately never attaches + # to. Ratchet it up as fake-target coverage grows. + run: | + pytest -q --cov=peekmem --cov-report=term --cov-report=xml --cov-fail-under=55 + - name: Upload coverage to Codecov + # Informational only — see codecov.yml — so a flaky upload never blocks + # the merge; the hard gate is --cov-fail-under above. Runs even when the + # tests fail so partial coverage stays visible. + if: always() + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + flags: ${{ runner.os }}-py${{ matrix.python-version }} + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false - name: Check the console script starts # The suite calls main() in-process; this proves the installed entry # point resolves and that a batch run exits cleanly. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..939d553 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +`contact@jeanloui.dev`. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b249823 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,143 @@ +# Contributing to Peekmem + +Thanks for your interest in contributing! + +Peekmem is a terminal client for [PyMemoryEditor][pyme]. 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 + +```bash +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 + +```bash +make test # pytest tests -v +``` + +The suite never attaches to a process. It covers parsing, formatting, dispatch +and help — the parts that are Peekmem's own — and leaves reading another +process's memory to PyMemoryEditor's tests. That is deliberate: it means the +suite runs identically on any machine, including CI runners where opening a +second process is not permitted. + +The consequence is that a change to a command *body* is not covered by the +suite. Exercise it by hand against a real target and say so in the PR — a +transcript of the session is the ideal evidence. + +## Linting and type checking + +```bash +make lint # flake8 peekmem tests +make type-check # mypy peekmem +``` + +## Before you push + +```bash +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 `peekmem -e "version"` +is smoke-tested from the installed console script on every cell. + +## Project layout + +``` +peekmem/ +├── __init__.py # Version and the public re-exports +├── __main__.py # python -m peekmem +├── 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 Peekmem prints: tables, hexdump, footers +├── processes.py # Cross-platform process enumeration +├── 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 a `StringIO`. +- **Anything the user got wrong raises `CommandError`.** The shell catches that + one class, prints one `ERROR:` line and returns to the prompt. An exception + that is *not* a `CommandError` is a bug in Peekmem and is allowed to escape + with its traceback. + +## Adding a command + +1. Pick the module in `peekmem/commands/` that matches the group. +2. Register the handler: + + ```python + @command( + "mycommand", + summary="One line, sentence case, ending in a period.", + usage="mycommand
[--flag]", + group="Memory", + aliases=("mycmd",), + details="The long help, printed by 'help mycommand'.", + examples=("mycommand 0x1000",), + ) + def cmd_mycommand(session: Session, args: List[str]) -> None: + parser = CommandParser("mycommand") + parser.add_argument("address") + options = parser.parse_args(args) + + process = session.require_process("mycommand") + address = parse_address(options.address, session) + ... + ``` + +3. Use `CommandParser`, not a bare `ArgumentParser`: it raises instead of + calling `sys.exit`, which would kill the shell on a typo. +4. Take addresses through `parse_address` so your command speaks the same + `[game.exe+0x10]+0x8` and `#3` language as every other one. + +`help` and `peekmem --help` are generated from the registry, so a command +cannot be added without also being documented. `tests/test_commands.py` +enforces that: it checks every command's summary, usage and examples, and that +`help ` works — so a new command is covered the moment it is registered. + +## Submitting changes + +1. Open an issue first for bug reports or substantial features. +2. Branch from `main`. Keep commits focused. +3. Run `make pre-commit` locally before pushing. +4. PR titles follow [Conventional Commits][cc] (`feat:`, `fix:`, `docs:`, …) — + CI lints the title. +5. Describe the change and how it was tested. For a command body, paste the + session. + +## Reporting bugs + +Please include: + +- The output of `peekmem -e "version"` — it names Peekmem, PyMemoryEditor, + Python and the platform in one line. +- 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_scope` is `0` or `1`. + +## Security + +If you find a security issue, please see [`SECURITY.md`](SECURITY.md). +**Do not** report it via GitHub issues. + +[pyme]: https://github.com/JeanExtreme002/PyMemoryEditor +[cc]: https://www.conventionalcommits.org/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..49833dc --- /dev/null +++ b/Makefile @@ -0,0 +1,304 @@ +# Makefile for the Peekmem Python package + +# Variables +PACKAGE_NAME = peekmem +PYTHON = python3 +PIP = pip3 +BUILD_DIR = build +DIST_DIR = dist +EGG_INFO = $(PACKAGE_NAME).egg-info +VENV_DIR = venv +TEST_DIR = tests + +# Colors for output +GREEN = \033[0;32m +YELLOW = \033[0;33m +RED = \033[0;31m +BLUE = \033[0;34m +NC = \033[0m # No Color + +# Default target +.PHONY: help +help: + @echo "$(GREEN)Peekmem Python package Makefile$(NC)" + @echo "" + @echo "Available targets:" + @echo " $(YELLOW)install$(NC) - Install package in development mode" + @echo " $(YELLOW)install-deps$(NC) - Install runtime dependencies" + @echo " $(YELLOW)install-dev$(NC) - Install development dependencies" + @echo " $(YELLOW)install-speed$(NC) - Install with the NumPy scan accelerator" + @echo " $(YELLOW)run$(NC) - Launch the Peekmem shell" + @echo " $(YELLOW)test$(NC) - Run tests" + @echo " $(YELLOW)test-verbose$(NC) - Run tests with verbose output" + @echo " $(YELLOW)test-coverage$(NC) - Run tests with coverage report" + @echo " $(YELLOW)lint$(NC) - Run linter (flake8)" + @echo " $(YELLOW)type-check$(NC) - Run type checker (mypy)" + @echo " $(YELLOW)smoke$(NC) - Check the installed console script starts" + @echo " $(YELLOW)clean$(NC) - Clean build artifacts" + @echo " $(YELLOW)build$(NC) - Build package" + @echo " $(YELLOW)build-wheel$(NC) - Build wheel package" + @echo " $(YELLOW)build-sdist$(NC) - Build source distribution" + @echo " $(YELLOW)validate$(NC) - Validate package" + @echo " $(YELLOW)publish$(NC) - Publish to PyPI" + @echo " $(YELLOW)publish-test$(NC) - Publish to Test PyPI" + @echo " $(YELLOW)version$(NC) - Show current version" + @echo " $(YELLOW)check-deps$(NC) - Check for outdated dependencies" + @echo " $(YELLOW)update-deps$(NC) - Update dependencies" + @echo " $(YELLOW)security$(NC) - Run security audit" + @echo " $(YELLOW)venv$(NC) - Create virtual environment" + @echo " $(YELLOW)venv-activate$(NC) - Show command to activate venv" + @echo " $(YELLOW)all$(NC) - Run full pipeline (install, lint, test, build)" + +# Create virtual environment +.PHONY: venv +venv: + @echo "$(GREEN)Creating virtual environment...$(NC)" + $(PYTHON) -m venv $(VENV_DIR) + @echo "$(GREEN)Virtual environment created in $(VENV_DIR)$(NC)" + @echo "$(YELLOW)To activate: source $(VENV_DIR)/bin/activate$(NC)" + +# Show activation command +.PHONY: venv-activate +venv-activate: + @echo "$(YELLOW)To activate virtual environment run:$(NC)" + @echo "source $(VENV_DIR)/bin/activate" + +# Install runtime dependencies (just PyMemoryEditor — see pyproject.toml) +.PHONY: install-deps +install-deps: + @echo "$(GREEN)Installing runtime dependencies...$(NC)" + $(PIP) install -e . + @echo "$(GREEN)Dependencies installed successfully!$(NC)" + +# Install development dependencies +.PHONY: install-dev +install-dev: + @echo "$(GREEN)Installing development dependencies...$(NC)" + $(PIP) install -e ".[dev]" + @echo "$(GREEN)Development dependencies installed successfully!$(NC)" + +# Install with the NumPy-accelerated scan path +.PHONY: install-speed +install-speed: + @echo "$(GREEN)Installing with the NumPy scan accelerator...$(NC)" + $(PIP) install -e ".[speed]" + @echo "$(GREEN)Speed extra installed successfully!$(NC)" + +# Install package in development mode +.PHONY: install +install: + @echo "$(GREEN)Installing package in development mode...$(NC)" + $(PIP) install -e . + @echo "$(GREEN)Package installed successfully!$(NC)" + +# Launch the shell straight from the working tree +.PHONY: run +run: + @echo "$(GREEN)Starting the Peekmem shell...$(NC)" + $(PYTHON) -m $(PACKAGE_NAME) + +# Run tests +.PHONY: test +test: + @echo "$(GREEN)Running tests...$(NC)" + $(PYTHON) -m pytest $(TEST_DIR) -v + @echo "$(GREEN)Tests completed!$(NC)" + +# Run tests with verbose output +.PHONY: test-verbose +test-verbose: + @echo "$(GREEN)Running tests with verbose output...$(NC)" + $(PYTHON) -m pytest $(TEST_DIR) -v -s + @echo "$(GREEN)Verbose tests completed!$(NC)" + +# Run tests with coverage +.PHONY: test-coverage +test-coverage: + @echo "$(GREEN)Running tests with coverage...$(NC)" + $(PYTHON) -m pytest $(TEST_DIR) --cov=$(PACKAGE_NAME) --cov-report=html --cov-report=term + @echo "$(GREEN)Coverage report generated!$(NC)" + @echo "$(YELLOW)HTML report available at htmlcov/index.html$(NC)" + +# Run linter +.PHONY: lint +lint: + @echo "$(GREEN)Running linter (flake8)...$(NC)" + $(PYTHON) -m flake8 $(PACKAGE_NAME) $(TEST_DIR) + @echo "$(GREEN)Linting completed!$(NC)" + +# Run type checker (config in pyproject.toml) +.PHONY: type-check +type-check: + @echo "$(GREEN)Running type checker (mypy)...$(NC)" + $(PYTHON) -m mypy $(PACKAGE_NAME) + @echo "$(GREEN)Type checking completed!$(NC)" + +# Check that the CLI starts and dispatches end to end. The test suite calls +# main() in-process; this drives it as a real process, so argv parsing, batch +# mode and the exit status are all exercised. CI additionally runs the +# installed `peekmem` console script to prove the entry point resolves. +.PHONY: smoke +smoke: + @echo "$(GREEN)Checking the console script...$(NC)" + $(PYTHON) -m $(PACKAGE_NAME) -e "version" + $(PYTHON) -m $(PACKAGE_NAME) -e "help scan" > /dev/null + $(PYTHON) -m $(PACKAGE_NAME) ps --limit 5 > /dev/null + @echo "$(GREEN)Console script works!$(NC)" + +# Clean build artifacts +.PHONY: clean +clean: + @echo "$(GREEN)Cleaning build artifacts...$(NC)" + rm -rf $(BUILD_DIR) + rm -rf $(DIST_DIR) + rm -rf $(EGG_INFO) + rm -rf .pytest_cache + rm -rf htmlcov + rm -rf .coverage + rm -rf coverage.xml + rm -rf .mypy_cache + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name "*.pyd" -delete + find . -type f -name ".coverage" -delete + @echo "$(GREEN)Cleanup completed!$(NC)" + +# Build package +.PHONY: build +build: clean + @echo "$(GREEN)Building package...$(NC)" + $(PYTHON) -m build + @echo "$(GREEN)Package built successfully!$(NC)" + +# Build wheel package only +.PHONY: build-wheel +build-wheel: clean + @echo "$(GREEN)Building wheel package...$(NC)" + $(PYTHON) -m build --wheel + @echo "$(GREEN)Wheel package built successfully!$(NC)" + +# Build source distribution only +.PHONY: build-sdist +build-sdist: clean + @echo "$(GREEN)Building source distribution...$(NC)" + $(PYTHON) -m build --sdist + @echo "$(GREEN)Source distribution built successfully!$(NC)" + +# Validate package +.PHONY: validate +validate: build + @echo "$(GREEN)Validating package...$(NC)" + $(PYTHON) -m twine check $(DIST_DIR)/* + @echo "$(GREEN)Package validation completed!$(NC)" + +# Publish to PyPI +.PHONY: publish +publish: validate + @echo "$(YELLOW)Are you sure you want to publish to PyPI? [y/N]$(NC)" && read ans && [ $${ans:-N} = y ] + @echo "$(GREEN)Publishing to PyPI...$(NC)" + $(PYTHON) -m twine upload $(DIST_DIR)/* + @echo "$(GREEN)Package published successfully to PyPI!$(NC)" + +# Publish to Test PyPI +.PHONY: publish-test +publish-test: validate + @echo "$(GREEN)Publishing to Test PyPI...$(NC)" + $(PYTHON) -m twine upload --repository testpypi $(DIST_DIR)/* + @echo "$(GREEN)Package published successfully to Test PyPI!$(NC)" + +# Show current version +.PHONY: version +version: + @echo "$(GREEN)Current package version:$(NC)" + @$(PYTHON) -c "import $(PACKAGE_NAME); print($(PACKAGE_NAME).__version__)" + +# Check for outdated dependencies +.PHONY: check-deps +check-deps: + @echo "$(GREEN)Checking for outdated dependencies...$(NC)" + $(PIP) list --outdated + +# Update dependencies +.PHONY: update-deps +update-deps: + @echo "$(GREEN)Updating dependencies...$(NC)" + $(PIP) install --upgrade -e ".[dev]" + @echo "$(GREEN)Dependencies updated!$(NC)" + +# Security audit — uses pip-audit (PyPA-maintained) which works without a +# paid account, unlike the older `safety` tool. +.PHONY: security +security: + @echo "$(GREEN)Running security audit (pip-audit)...$(NC)" + $(PIP) install pip-audit + pip-audit + @echo "$(GREEN)Security audit completed!$(NC)" + +# Full development pipeline +.PHONY: all +all: install lint type-check test build validate + @echo "$(GREEN)Full pipeline completed successfully!$(NC)" + +# Development workflow targets +.PHONY: dev-setup +dev-setup: venv install-dev install + @echo "$(GREEN)Development environment setup completed!$(NC)" + @echo "$(YELLOW)Don't forget to activate the virtual environment:$(NC)" + @echo "source $(VENV_DIR)/bin/activate" + +.PHONY: pre-commit +pre-commit: lint type-check test + @echo "$(GREEN)Pre-commit checks passed!$(NC)" + +.PHONY: pre-publish +pre-publish: all security + @echo "$(GREEN)Pre-publish checks completed!$(NC)" + +# CI/CD targets +.PHONY: ci +ci: install-dev install lint type-check test smoke build validate + @echo "$(GREEN)CI pipeline completed!$(NC)" + +# Show package info +.PHONY: info +info: + @echo "$(GREEN)Package Information:$(NC)" + @echo "Name: $(PACKAGE_NAME)" + @$(PYTHON) -c "import $(PACKAGE_NAME); print('Version:', $(PACKAGE_NAME).__version__)" 2>/dev/null || echo "Version: Not installed" + @$(PYTHON) -c "import PyMemoryEditor; print('PyMemoryEditor:', PyMemoryEditor.__version__)" 2>/dev/null || echo "PyMemoryEditor: Not installed" + @echo "Python: $(shell $(PYTHON) --version)" + @echo "Pip: $(shell $(PIP) --version)" + @echo "" + @echo "$(GREEN)Installed packages:$(NC)" + @$(PIP) list | grep -E "($(PACKAGE_NAME)|PyMemoryEditor|pytest|flake8|mypy|twine|build)" + +# Quick release workflow +.PHONY: release +release: pre-publish publish + @echo "$(GREEN)Release completed!$(NC)" + +.PHONY: release-test +release-test: pre-publish publish-test + @echo "$(GREEN)Test release completed!$(NC)" + +# Install from PyPI (for testing) +.PHONY: install-from-pypi +install-from-pypi: + @echo "$(GREEN)Installing from PyPI...$(NC)" + $(PIP) install $(PACKAGE_NAME) + @echo "$(GREEN)Package installed from PyPI!$(NC)" + +# Install from Test PyPI (for testing) +.PHONY: install-from-test-pypi +install-from-test-pypi: + @echo "$(GREEN)Installing from Test PyPI...$(NC)" + $(PIP) install --index-url https://test.pypi.org/simple/ $(PACKAGE_NAME) + @echo "$(GREEN)Package installed from Test PyPI!$(NC)" + +# Uninstall package +.PHONY: uninstall +uninstall: + @echo "$(GREEN)Uninstalling package...$(NC)" + $(PIP) uninstall $(PACKAGE_NAME) -y + @echo "$(GREEN)Package uninstalled!$(NC)" diff --git a/README.md b/README.md index 0887bc6..949750b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A **terminal client for [PyMemoryEditor](https://github.com/JeanExtreme002/PyMem PyPI License Python Version + Coverage Downloads

@@ -211,18 +212,26 @@ operating systems at once. ## Contributing -Issues and pull requests are welcome. To work on Peekmem: +Issues and pull requests are welcome. ```bash git clone https://github.com/JeanExtreme002/Peekmem cd Peekmem -pip install -e ".[dev]" -pytest -flake8 peekmem tests -mypy peekmem +make install-dev # pip install -e ".[dev]" +make pre-commit # lint + type-check + tests ``` -The test suite never attaches to another process, so it runs anywhere. +`make help` lists every target. The test suite never attaches to another +process, so it runs anywhere — including CI runners that would refuse. + +[**CONTRIBUTING.md**](CONTRIBUTING.md) covers the project layout, the two rules +that keep its shape, and how to add a command (it is one decorator, and `help` +plus the tests come along for free). + +- 🐛 [Report a bug](https://github.com/JeanExtreme002/Peekmem/issues/new?template=bug_report.md) +- 💡 [Request a feature](https://github.com/JeanExtreme002/Peekmem/issues/new?template=feature_request.md) +- 🔒 [Security policy](SECURITY.md) — please do **not** open a public issue +- 🤝 [Code of Conduct](CODE_OF_CONDUCT.md) ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..59bc9ed --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,67 @@ +# Security Policy + +## Reporting a Vulnerability + +**Please do not open a public issue for a suspected vulnerability.** Use one +of the channels below instead so the impact can be assessed and a fix +prepared before details become public. + +- **Preferred:** open a [private security advisory] on GitHub. This creates a + private thread visible only to the maintainers and the reporter, supports + CVE assignment, and lets us coordinate a disclosure timeline. +- **Alternative:** email `contact@jeanloui.dev` with subject + `[Peekmem security]`. + +When reporting, please include: + +- Affected version(s) — the output of `peekmem -e "version"` covers Peekmem, + PyMemoryEditor, Python and the platform in one line. +- The exact command line or shell session that triggers it. +- A minimal reproducer, and the impact you observed. +- Any prerequisites (privileges, `ptrace_scope`, target process attributes). + +## Scope + +Peekmem is a *client*. It parses commands, formats results, and calls +[PyMemoryEditor], which performs every read, write and scan through OS-level +APIs. Vulnerabilities in the memory operations themselves therefore belong to +PyMemoryEditor — see [its security policy][pyme-security] — while everything +between the keyboard and that call belongs here. + +That Peekmem needs elevated privileges, a debugger entitlement or a relaxed +`ptrace_scope` to attach to a process is documented in the README; those +requirements are not defects. + +In scope: + +- Command injection or unintended code execution from anything Peekmem parses: + a command line, an address expression, a `source` script, a pointer-path + file loaded with `ptrload`. +- A command writing to an address other than the one it reported, or reporting + a write that did not happen (and the reverse). +- Path traversal or unintended file writes from `ptrsave` and friends. +- Leaking target memory contents into a place the user did not ask for — + history files, logs, error messages. +- Crashes in the shell that leave a target process attached, modified or in a + more permissive state than it started. + +Out of scope: + +- Using Peekmem against a target you are not authorized to inspect. That is a + misuse question, not a defect. +- Anti-cheat evasion or cheating-detection bypass requests. +- Peekmem being able to read and write another process's memory *at all* — + that is the entire purpose of the tool, and the OS is what gates it. +- Bugs in PyMemoryEditor's platform backends. Report those [upstream][pyme-security]; + if you are unsure which side a bug is on, report it here and it will be + routed. + +## Supported versions + +Fixes land on the latest release. Peekmem follows the version of +PyMemoryEditor it depends on rather than pinning to an old one, so please +reproduce on the current release of both before reporting. + +[private security advisory]: https://github.com/JeanExtreme002/Peekmem/security/advisories/new +[PyMemoryEditor]: https://github.com/JeanExtreme002/PyMemoryEditor +[pyme-security]: https://github.com/JeanExtreme002/PyMemoryEditor/blob/main/SECURITY.md diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..3e4cdb0 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,25 @@ +# Codecov is used for *visibility* only — the enforced coverage gate lives in +# CI as pytest's `--cov-fail-under` (see .github/workflows/python-package.yml), +# which is deterministic and needs no external service. Marking Codecov's +# status checks informational keeps a flaky upload or a transient dip from +# blocking the merge button while still surfacing the trend and per-PR diff +# coverage. +# +# Coverage is uploaded from every OS/Python cell of the matrix. Peekmem's own +# code is platform-independent, but the shell behaves differently where +# readline is missing (Windows), so the merged view is what shows those +# branches covered. +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true + +# Group the per-cell uploads (flagged by OS + Python version) so the combined +# report reflects the whole matrix rather than the last upload to land. +comment: + layout: "reach, diff, flags, files" + require_changes: false From 6adeac343a3de7f0550bffc07051e6f9c1dc8d97 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 00:43:30 -0300 Subject: [PATCH 03/82] feat(help): generate each command's argument list from its own parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command's flags lived only in hand-written prose, and half the commands had none at all — so the only way to learn that `scan` takes `--writable` was to read the source. `help ` now prints Arguments and Options sections built from the very parser the command parses with, which means the two cannot disagree: adding a flag documents it, and renaming one renames it in both places at once. Each command now declares a parser factory alongside its handler, and every argument carries a `help=` string. `CommandParser` records its actions so the help can be rendered without reaching into argparse's internals, and the prose `details` keep only what a flag list cannot say. ` --help` and `-h` do the same thing as `help `, both in the shell and from the command line (`peekmem scan --help`) — asking a command for its own help is a reflex worth honouring rather than a spelling to learn. Tab completion offers a command's real flags after a `-`, and `--help` output wraps prose to 78 columns while leaving hand-aligned blocks alone. Four new invariants are enforced in tests: every command declares a parser, every argument carries help text, every flag appears in that command's help, and a usage line may not advertise a flag the parser does not accept. --- CONTRIBUTING.md | 15 +- README.md | 24 ++- peekmem/cli.py | 5 +- peekmem/commands/__init__.py | 128 ++++++++++-- peekmem/commands/memory_commands.py | 293 ++++++++++++++++++++------- peekmem/commands/pointer_commands.py | 210 ++++++++++++++----- peekmem/commands/process_commands.py | 136 ++++++++++--- peekmem/commands/scan_commands.py | 229 +++++++++++++++------ peekmem/commands/session_commands.py | 161 ++++++++++++--- peekmem/output.py | 60 ++++++ peekmem/shell.py | 15 +- tests/test_commands.py | 71 ++++++- 12 files changed, 1070 insertions(+), 277 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b249823..595f5f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,10 +109,17 @@ Two rules keep the shape: 4. Take addresses through `parse_address` so your command speaks the same `[game.exe+0x10]+0x8` and `#3` language as every other one. -`help` and `peekmem --help` are generated from the registry, so a command -cannot be added without also being documented. `tests/test_commands.py` -enforces that: it checks every command's summary, usage and examples, and that -`help ` works — so a new command is covered the moment it is registered. +Pass every argument a `help=` string. `help ` (and ` --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 `peekmem --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 diff --git a/README.md b/README.md index 949750b..c0c7d95 100644 --- a/README.md +++ b/README.md @@ -135,8 +135,28 @@ non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave | **Pointers** | `deref` · `pointer` · `ptrscan` · `paths` · `ptrsave` · `ptrload` · `ptrrescan` · `ptrdiff` | | **Session** | `help` · `set` · `source` · `version` · `exit` | -`help ` documents each one in full, with examples. `help types`, -`help address` and `help scanning` cover what several commands share. +`help ` — or ` --help` — documents each one in full: every +argument, every flag, and examples. That list is generated from the command's +own parser, so it is always exactly what the command accepts: + +```console +peekmem> help dump +dump — Hex-dump a range of memory. + +Usage: dump
[length] [--width N] +Aliases: hexdump, x + +Arguments: + address address expression: a literal, module+offset, [pointer] or #N — + see 'help address' + [length] number of bytes to read (default 256); hex accepted + +Options: + --width N bytes per line, overriding the 'dump_width' setting +``` + +`help types`, `help address` and `help scanning` cover what several commands +share. Highlights: diff --git a/peekmem/cli.py b/peekmem/cli.py index 56c4824..09f3c4e 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -29,7 +29,10 @@ from .session import Session from .shell import Shell -_EPILOG_INTRO = "Commands (run 'peekmem -e \"help \"' for the details):" +_EPILOG_INTRO = ( + "Commands — run 'peekmem --help' for one command's arguments,\n" + "or 'peekmem help' for the topics:" +) def _format_commands() -> str: diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index 0ac8448..a10719d 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -4,9 +4,11 @@ The command registry. Every Peekmem command is a plain function registered with :func:`command`. -The registry owns the name, the aliases, the one-line summary and the usage -string, which means ``help`` is generated from the same data the dispatcher -uses — a command cannot be added without also being documented. +The registry owns the name, the aliases, the one-line summary, the usage line +and — through :class:`CommandParser` — the full argument list, which means +``help`` is generated from the very definitions the dispatcher runs. A flag +cannot be added without being documented, and documentation cannot drift from +the parser, because there is only one of them. Importing this package imports every command module for its side effect of registering; nothing else needs to know they exist. @@ -24,6 +26,85 @@ Handler = Callable[..., None] +#: A zero-argument factory returning the command's configured parser. It is a +#: factory rather than a shared instance because ``help`` and a running command +#: both want one, and an ``ArgumentParser`` accumulates state as it parses. +ParserFactory = Callable[[], "CommandParser"] + + +class CommandParser(argparse.ArgumentParser): + """An ``ArgumentParser`` that raises instead of killing the shell. + + ``argparse`` calls ``sys.exit`` on a usage error, which is right for a + program and fatal for a REPL. Every failure becomes a + :class:`~peekmem.errors.CommandError`, printed as one ``ERROR:`` line. + + It also keeps the actions it was given, in the order they were declared, so + ``help`` can list a command's arguments without reaching into argparse's + internals. ``Action.option_strings``, ``.dest``, ``.nargs``, ``.metavar``, + ``.choices`` and ``.help`` are all documented attributes. + """ + + def __init__(self, prog: str, usage: Optional[str] = None): + super().__init__(prog=prog, usage=usage, add_help=False) + self.arguments: List[argparse.Action] = [] + + def add_argument(self, *args, **kwargs): + action = super().add_argument(*args, **kwargs) + self.arguments.append(action) + return action + + def error(self, message: str) -> None: # type: ignore[override] + raise CommandError(f"{self.prog}: {message} (try 'help {self.prog}')") + + def exit(self, status: int = 0, message: Optional[str] = None) -> None: # type: ignore[override] + if message: + raise CommandError(message.strip()) + raise CommandError(f"{self.prog}: invalid arguments (try 'help {self.prog}')") + + +def describe_action(action: argparse.Action) -> str: + """Render one argument the way it is typed on the command line. + + Positionals come out as ``address``, ``[length]`` or ``[offset ...]`` + depending on how many are accepted; options come out as + ``-i, --ignore-case`` or ``--between A B``. The point is that the label in + the help is something the reader can copy. + """ + value = _value_placeholder(action) + + if not action.option_strings: + name = value or (action.metavar or action.dest) + if action.nargs == "?": + return f"[{name}]" + if action.nargs == "*": + return f"[{name} ...]" + if action.nargs == "+": + return f"{name} [{name} ...]" + return str(name) + + flags = ", ".join(action.option_strings) + return f"{flags} {value}" if value else flags + + +def _value_placeholder(action: argparse.Action) -> str: + """The ``VALUE`` part of ``--flag VALUE``, or '' for a flag that takes none.""" + if action.nargs == 0: # store_true / store_false + return "" + + if action.metavar is not None: + if isinstance(action.metavar, tuple): + return " ".join(action.metavar) + return action.metavar + + if action.choices: + return "|".join(str(choice) for choice in action.choices) + + name = action.dest if not action.option_strings else action.dest.upper() + if isinstance(action.nargs, int): + return " ".join([name] * action.nargs) + return name + @dataclass(frozen=True) class Command: @@ -34,10 +115,15 @@ class Command: summary: str usage: str group: str + parser: Optional[ParserFactory] = None aliases: Tuple[str, ...] = () details: str = "" examples: Tuple[str, ...] = field(default=()) + def arguments(self) -> List[argparse.Action]: + """Every argument this command accepts, in declaration order.""" + return list(self.parser().arguments) if self.parser is not None else [] + _COMMANDS: Dict[str, Command] = {} _ALIASES: Dict[str, str] = {} @@ -49,6 +135,7 @@ def command( summary: str, usage: str, group: str, + parser: Optional[ParserFactory] = None, aliases: Sequence[str] = (), details: str = "", examples: Sequence[str] = (), @@ -57,6 +144,11 @@ def command( The handler is called as ``handler(session, args)`` where ``args`` is the already-split argument list (the command word removed). + + ``parser`` is the factory returning the same :class:`CommandParser` the + handler parses with. Passing it is what puts the command's arguments in + ``help``, so every command has one — even the few whose parser only + declares that they take nothing. """ def decorator(handler: Handler) -> Handler: @@ -71,6 +163,7 @@ def decorator(handler: Handler) -> Handler: summary=summary, usage=usage, group=group, + parser=parser, aliases=tuple(aliases), details=details, examples=tuple(examples), @@ -112,24 +205,15 @@ def command_words() -> List[str]: return sorted(list(_COMMANDS) + list(_ALIASES)) -class CommandParser(argparse.ArgumentParser): - """An ``ArgumentParser`` that raises instead of killing the shell. - - ``argparse`` calls ``sys.exit`` on a usage error, which is right for a - program and fatal for a REPL. Every failure becomes a - :class:`~peekmem.errors.CommandError`, printed as one ``ERROR:`` line. - """ - - def __init__(self, prog: str, usage: Optional[str] = None): - super().__init__(prog=prog, usage=usage, add_help=False) - - def error(self, message: str) -> None: # type: ignore[override] - raise CommandError(f"{self.prog}: {message} (try 'help {self.prog}')") - - def exit(self, status: int = 0, message: Optional[str] = None) -> None: # type: ignore[override] - if message: - raise CommandError(message.strip()) - raise CommandError(f"{self.prog}: invalid arguments (try 'help {self.prog}')") +def option_words(name: str) -> List[str]: + """Every option flag a command accepts, for tab completion.""" + try: + entry = lookup(name) + except CommandError: + return [] + return sorted( + flag for action in entry.arguments() for flag in action.option_strings + ) from . import memory_commands # noqa: E402,F401 (registration side effect) @@ -145,5 +229,7 @@ def exit(self, status: int = 0, message: Optional[str] = None) -> None: # type: "all_commands", "command", "command_words", + "describe_action", "lookup", + "option_words", ) diff --git a/peekmem/commands/memory_commands.py b/peekmem/commands/memory_commands.py index fed409a..0bf0556 100644 --- a/peekmem/commands/memory_commands.py +++ b/peekmem/commands/memory_commands.py @@ -19,6 +19,18 @@ from ..valuetypes import ValueType from . import CommandParser, command +#: The help text shared by every argument that takes an address expression. +_ADDRESS_HELP = ( + "address expression: a literal, module+offset, [pointer] or #N — " + "see 'help address'" +) + +#: The help text shared by every optional type argument. +_TYPE_HELP = "value type; defaults to int32 (see 'help types')" + +#: The help text shared by the length argument of the variable-width types. +_LENGTH_HELP = "byte width, required for the 'string' and 'bytes' types" + def _resolve_type(name: Optional[str]) -> ValueType: return valuetypes.DEFAULT_TYPE if name is None else valuetypes.resolve(name) @@ -36,8 +48,44 @@ def _permissions(region) -> str: ) +def _regions_parser() -> CommandParser: + parser = CommandParser("regions") + parser.add_argument( + "--writable", action="store_true", help="keep only writable regions" + ) + parser.add_argument( + "--executable", action="store_true", help="keep only executable regions" + ) + parser.add_argument( + "--shared", + action="store_true", + help="keep only shared or file-backed mappings", + ) + parser.add_argument( + "--path", + default=None, + metavar="TEXT", + help="keep regions whose backing file path contains TEXT", + ) + parser.add_argument( + "--at", + default=None, + metavar="ADDRESS", + help="show only the region containing this address", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + metavar="N", + help="print at most N rows, overriding the 'limit' setting", + ) + return parser + + @command( "regions", + parser=_regions_parser, summary="List the target's mapped memory regions.", usage="regions [--writable] [--executable] [--path TEXT] [--at ADDRESS] [--limit N]", group="Memory", @@ -45,23 +93,13 @@ def _permissions(region) -> str: details=( "The memory map is re-read on every call, so it reflects allocations " "the target made since the last look.\n\n" - " --writable / --executable / --shared keep only regions with that bit\n" - " --path TEXT keep regions backed by a file whose path contains TEXT\n" - " --at ADDRESS show only the region containing ADDRESS\n\n" "The PERMS column reads like /proc//maps: rwx plus 's' for a " "shared/file-backed mapping or 'p' for a private one." ), examples=("regions --writable", "regions --at 0x7ffee3a01000", "regions --path libc"), ) def cmd_regions(session: Session, args: List[str]) -> None: - parser = CommandParser("regions") - parser.add_argument("--writable", action="store_true") - parser.add_argument("--executable", action="store_true") - parser.add_argument("--shared", action="store_true") - parser.add_argument("--path", default=None) - parser.add_argument("--at", default=None) - parser.add_argument("--limit", type=int, default=None) - options = parser.parse_args(args) + options = _regions_parser().parse_args(args) process = session.require_process("regions") @@ -106,8 +144,27 @@ def cmd_regions(session: Session, args: List[str]) -> None: ) +def _modules_parser() -> CommandParser: + parser = CommandParser("modules") + parser.add_argument( + "pattern", + nargs="?", + default=None, + help="keep modules whose name or path contains this text", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + metavar="N", + help="print at most N rows, overriding the 'limit' setting", + ) + return parser + + @command( "modules", + parser=_modules_parser, summary="List the modules loaded in the target.", usage="modules [pattern] [--limit N]", group="Memory", @@ -121,10 +178,7 @@ def cmd_regions(session: Session, args: List[str]) -> None: examples=("modules", "modules libc"), ) def cmd_modules(session: Session, args: List[str]) -> None: - parser = CommandParser("modules") - parser.add_argument("pattern", nargs="?", default=None) - parser.add_argument("--limit", type=int, default=None) - options = parser.parse_args(args) + options = _modules_parser().parse_args(args) process = session.require_process("modules") @@ -162,8 +216,21 @@ def cmd_modules(session: Session, args: List[str]) -> None: ) +def _threads_parser() -> CommandParser: + parser = CommandParser("threads") + parser.add_argument( + "--limit", + type=int, + default=None, + metavar="N", + help="print at most N rows, overriding the 'limit' setting", + ) + return parser + + @command( "threads", + parser=_threads_parser, summary="List the target's threads.", usage="threads [--limit N]", group="Memory", @@ -175,9 +242,7 @@ def cmd_modules(session: Session, args: List[str]) -> None: ), ) def cmd_threads(session: Session, args: List[str]) -> None: - parser = CommandParser("threads") - parser.add_argument("--limit", type=int, default=None) - options = parser.parse_args(args) + options = _threads_parser().parse_args(args) process = session.require_process("threads") @@ -203,8 +268,27 @@ def cmd_threads(session: Session, args: List[str]) -> None: ) +def _read_parser() -> CommandParser: + parser = CommandParser("read") + parser.add_argument("address", help=_ADDRESS_HELP) + parser.add_argument("type", nargs="?", default=None, help=_TYPE_HELP) + parser.add_argument("length", nargs="?", type=int, default=None, help=_LENGTH_HELP) + parser.add_argument( + "--count", + type=int, + default=1, + metavar="N", + help="read N consecutive values, stepping by the type's width", + ) + parser.add_argument( + "--hex", action="store_true", help="print integers in hexadecimal" + ) + return parser + + @command( "read", + parser=_read_parser, summary="Read a typed value from an address.", usage="read
[type] [length] [--count N] [--hex]", group="Memory", @@ -212,8 +296,6 @@ def cmd_threads(session: Session, args: List[str]) -> None: details=( "The type defaults to int32. 'string' and 'bytes' need a length in " "bytes; the fixed-width types ignore one.\n\n" - " --count N read N consecutive values, stepping by the type's width\n" - " --hex print integers in hexadecimal\n\n" "The address is an expression — see 'help address' — so a pointer " "chain can be read in one go." ), @@ -226,13 +308,7 @@ def cmd_threads(session: Session, args: List[str]) -> None: ), ) def cmd_read(session: Session, args: List[str]) -> None: - parser = CommandParser("read") - parser.add_argument("address") - parser.add_argument("type", nargs="?", default=None) - parser.add_argument("length", nargs="?", type=int, default=None) - parser.add_argument("--count", type=int, default=1) - parser.add_argument("--hex", action="store_true") - options = parser.parse_args(args) + options = _read_parser().parse_args(args) process = session.require_process("read") value_type = _resolve_type(options.type) @@ -270,19 +346,40 @@ def cmd_read(session: Session, args: List[str]) -> None: ) +def _write_parser() -> CommandParser: + parser = CommandParser("write") + parser.add_argument("address", help=_ADDRESS_HELP) + parser.add_argument("type", help="value type (see 'help types')") + parser.add_argument( + "value", + help="the value to write, parsed according to the type: integers " + "accept 0x/0o/0b prefixes, booleans accept true/false/on/off, 'bytes' " + "takes hex ('DE AD BE EF') and 'string' takes the text verbatim", + ) + parser.add_argument( + "--length", + type=int, + default=None, + metavar="N", + help="buffer width for string/bytes; defaults to the natural width " + "of the value given", + ) + parser.add_argument( + "--null-terminated", + action="store_true", + help="append a NUL after a string, for C-string writes", + ) + return parser + + @command( "write", + parser=_write_parser, summary="Write a typed value to an address.", usage="write
[--length N] [--null-terminated]", group="Memory", aliases=("poke",), details=( - "The value is parsed according to the type: integers accept 0x/0o/0b " - "prefixes, booleans accept true/false/on/off, 'bytes' takes hex " - "('DE AD BE EF') and 'string' takes the text verbatim.\n\n" - " --length N buffer width for string/bytes; defaults to the\n" - " natural width of the value given\n" - " --null-terminated append a NUL after a string (C-string writes)\n\n" "There is no confirmation and no undo. Writing into a live process can " "crash it — read the address first if you are not sure of it." ), @@ -294,13 +391,7 @@ def cmd_read(session: Session, args: List[str]) -> None: ), ) def cmd_write(session: Session, args: List[str]) -> None: - parser = CommandParser("write") - parser.add_argument("address") - parser.add_argument("type") - parser.add_argument("value") - parser.add_argument("--length", type=int, default=None) - parser.add_argument("--null-terminated", action="store_true") - options = parser.parse_args(args) + options = _write_parser().parse_args(args) process = session.require_process("write") value_type = valuetypes.resolve(options.type) @@ -329,27 +420,42 @@ def cmd_write(session: Session, args: List[str]) -> None: session.printer.write() +def _dump_parser() -> CommandParser: + parser = CommandParser("dump") + parser.add_argument("address", help=_ADDRESS_HELP) + parser.add_argument( + "length", + nargs="?", + default="256", + help="number of bytes to read (default 256); hex accepted", + ) + parser.add_argument( + "--width", + type=int, + default=None, + metavar="N", + help="bytes per line, overriding the 'dump_width' setting", + ) + return parser + + @command( "dump", + parser=_dump_parser, summary="Hex-dump a range of memory.", usage="dump
[length] [--width N]", group="Memory", aliases=("hexdump", "x"), details=( "Prints the classic three-column layout: absolute address, hex bytes, " - "printable ASCII. Length defaults to 256 bytes and the line width to " - "the 'dump_width' setting.\n\n" + "printable ASCII.\n\n" "The read is a single call, so a range that crosses into an unmapped " "page fails as a whole rather than returning half the bytes." ), examples=("dump 0x7ffee3a01000", "dump game.exe+0x1000 512", "dump #1 64 --width 8"), ) def cmd_dump(session: Session, args: List[str]) -> None: - parser = CommandParser("dump") - parser.add_argument("address") - parser.add_argument("length", nargs="?", default="256") - parser.add_argument("--width", type=int, default=None) - options = parser.parse_args(args) + options = _dump_parser().parse_args(args) process = session.require_process("dump") address = parse_address(options.address, session) @@ -375,20 +481,43 @@ def cmd_dump(session: Session, args: List[str]) -> None: session.printer.write() +def _watch_parser() -> CommandParser: + parser = CommandParser("watch") + parser.add_argument("address", help=_ADDRESS_HELP) + parser.add_argument("type", nargs="?", default=None, help=_TYPE_HELP) + parser.add_argument("length", nargs="?", type=int, default=None, help=_LENGTH_HELP) + parser.add_argument( + "--interval", + type=float, + default=None, + metavar="S", + help="seconds between reads, overriding the 'watch_interval' setting", + ) + parser.add_argument( + "--count", + type=int, + default=0, + metavar="N", + help="stop after N samples; without it, watch runs until Ctrl+C", + ) + parser.add_argument( + "--all", + action="store_true", + help="print every sample, not only the ones whose value changed", + ) + return parser + + @command( "watch", + parser=_watch_parser, summary="Poll an address and print it as it changes.", usage="watch
[type] [length] [--interval S] [--count N] [--all]", group="Memory", details=( "Reads the address on a timer and prints a line per sample. By default " "only samples whose value differs from the previous one are printed, " - "which turns the terminal into a change log; --all prints every " - "sample.\n\n" - " --interval S seconds between reads (default: the 'watch_interval'\n" - " setting)\n" - " --count N stop after N samples; without it, watch runs until\n" - " Ctrl+C\n\n" + "which turns the terminal into a change log.\n\n" "This is the terminal answer to a cheat table: leave it running in one " "window while the target does its thing." ), @@ -399,14 +528,7 @@ def cmd_dump(session: Session, args: List[str]) -> None: ), ) def cmd_watch(session: Session, args: List[str]) -> None: - parser = CommandParser("watch") - parser.add_argument("address") - parser.add_argument("type", nargs="?", default=None) - parser.add_argument("length", nargs="?", type=int, default=None) - parser.add_argument("--interval", type=float, default=None) - parser.add_argument("--count", type=int, default=0) - parser.add_argument("--all", action="store_true") - options = parser.parse_args(args) + options = _watch_parser().parse_args(args) process = session.require_process("watch") value_type = _resolve_type(options.type) @@ -461,26 +583,36 @@ def cmd_watch(session: Session, args: List[str]) -> None: printer.write() +def _alloc_parser() -> CommandParser: + parser = CommandParser("alloc") + parser.add_argument( + "size", help="number of bytes to allocate; hex accepted (e.g. 0x1000)" + ) + parser.add_argument( + "--permission", + default=None, + metavar="N", + help="platform-specific protection: a PAGE_* value on Windows " + "(default PAGE_EXECUTE_READWRITE), a VM_PROT_* bitmask on macOS", + ) + return parser + + @command( "alloc", + parser=_alloc_parser, summary="Allocate memory inside the target.", usage="alloc [--permission N]", group="Memory", details=( "Reserves and commits SIZE bytes in the target's address space and " "prints the base address. The region stays until 'free' releases it.\n\n" - " --permission N platform-specific protection: a PAGE_* value on\n" - " Windows (default PAGE_EXECUTE_READWRITE), a VM_PROT_*\n" - " bitmask on macOS.\n\n" "Not available on Linux, which has no cross-process allocation syscall." ), examples=("alloc 4096", "alloc 0x1000"), ) def cmd_alloc(session: Session, args: List[str]) -> None: - parser = CommandParser("alloc") - parser.add_argument("size") - parser.add_argument("--permission", default=None) - options = parser.parse_args(args) + options = _alloc_parser().parse_args(args) process = session.require_process("alloc") size = parse_int(options.size, "size") @@ -513,23 +645,30 @@ def cmd_alloc(session: Session, args: List[str]) -> None: session.printer.write() +def _free_parser() -> CommandParser: + parser = CommandParser("free") + parser.add_argument("address", help="base address returned by 'alloc'") + parser.add_argument( + "size", + nargs="?", + default=None, + help="region size; only needed to free a region this session did not " + "allocate, since PyMemoryEditor remembers its own", + ) + return parser + + @command( "free", + parser=_free_parser, summary="Release memory allocated with 'alloc'.", usage="free
[size]", group="Memory", - details=( - "The size may be omitted for a region this session allocated — " - "PyMemoryEditor remembers it. Give one only to free a region it did " - "not allocate." - ), + details="Not available on Linux, for the same reason as 'alloc'.", examples=("free 0x7ffee3a01000", "free 0x7ffee3a01000 4096"), ) def cmd_free(session: Session, args: List[str]) -> None: - parser = CommandParser("free") - parser.add_argument("address") - parser.add_argument("size", nargs="?", default=None) - options = parser.parse_args(args) + options = _free_parser().parse_args(args) process = session.require_process("free") address = parse_address(options.address, session) diff --git a/peekmem/commands/pointer_commands.py b/peekmem/commands/pointer_commands.py index fe024f0..07fa2ee 100644 --- a/peekmem/commands/pointer_commands.py +++ b/peekmem/commands/pointer_commands.py @@ -24,6 +24,15 @@ from ..session import Session from . import CommandParser, command +_BASE_HELP = ( + "the static base of the chain, as an address expression — usually " + "module+offset (see 'help address')" +) +_OFFSETS_HELP = ( + "offsets to walk, in order; hex accepted. The last one is added without a " + "final read, matching the Cheat Engine convention" +) + def _parse_offsets(tokens: Sequence[str]) -> List[int]: return [parse_int(token, "offset") for token in tokens] @@ -72,8 +81,16 @@ def _print_paths( ) +def _deref_parser() -> CommandParser: + parser = CommandParser("deref") + parser.add_argument("base", help=_BASE_HELP) + parser.add_argument("offsets", nargs="*", help=_OFFSETS_HELP) + return parser + + @command( "deref", + parser=_deref_parser, summary="Walk a pointer chain and print the address it lands on.", usage="deref [offset ...]", group="Pointers", @@ -82,17 +99,12 @@ def _print_paths( "Reads the pointer at BASE, adds the first offset, reads the pointer " "there, and so on; the last offset is added without a final read — the " "Cheat Engine convention, so a chain copied from a cheat table works " - "unchanged.\n\n" - "BASE is a full address expression, so 'deref game.exe+0x1a2b3c 0x10 " - "0x8' is the usual spelling." + "unchanged." ), examples=("deref game.exe+0x1a2b3c 0x10 0x8", "deref 0x7ffee3a01000 0x18"), ) def cmd_deref(session: Session, args: List[str]) -> None: - parser = CommandParser("deref") - parser.add_argument("base") - parser.add_argument("offsets", nargs="*") - options = parser.parse_args(args) + options = _deref_parser().parse_args(args) process = session.require_process("deref") base = parse_address(options.base, session) @@ -118,15 +130,42 @@ def cmd_deref(session: Session, args: List[str]) -> None: ) +def _pointer_parser() -> CommandParser: + parser = CommandParser("pointer") + parser.add_argument("base", help=_BASE_HELP) + parser.add_argument("offsets", nargs="*", help=_OFFSETS_HELP) + parser.add_argument( + "--type", + dest="value_type", + default=None, + metavar="TYPE", + help="value type at the end of the chain; defaults to int32", + ) + parser.add_argument( + "--length", + type=int, + default=None, + metavar="N", + help="byte width, required for the 'string' and 'bytes' types", + ) + parser.add_argument( + "--write", + default=None, + metavar="VALUE", + help="write this value at the end of the chain instead of reading it", + ) + return parser + + @command( "pointer", + parser=_pointer_parser, summary="Read or write the value at the end of a pointer chain.", usage="pointer [offset ...] [--type T] [--length N] [--write VALUE]", group="Pointers", aliases=("ptr",), details=( - "Resolves the chain and then reads (or, with --write, writes) the " - "value there — the one-line form of 'deref' followed by 'read'.\n\n" + "The one-line form of 'deref' followed by 'read'.\n\n" "The chain is re-walked on every call, which is the point: it keeps " "working after the target reallocates whatever the last link pointed " "at." @@ -137,13 +176,7 @@ def cmd_deref(session: Session, args: List[str]) -> None: ), ) def cmd_pointer(session: Session, args: List[str]) -> None: - parser = CommandParser("pointer") - parser.add_argument("base") - parser.add_argument("offsets", nargs="*") - parser.add_argument("--type", dest="value_type", default=None) - parser.add_argument("--length", type=int, default=None) - parser.add_argument("--write", default=None) - options = parser.parse_args(args) + options = _pointer_parser().parse_args(args) process = session.require_process("pointer") value_type = ( @@ -198,8 +231,52 @@ def cmd_pointer(session: Session, args: List[str]) -> None: ) +def _ptrscan_parser() -> CommandParser: + parser = CommandParser("ptrscan") + parser.add_argument( + "address", + help="the address to find paths to, as an address expression — " + "usually '#1' straight from a scan", + ) + parser.add_argument( + "--depth", + type=int, + default=3, + metavar="N", + help="maximum number of links in a chain (default 3). Each extra " + "level costs a lot of time and memory", + ) + parser.add_argument( + "--max-offset", + type=int, + default=1024, + metavar="N", + help="largest offset to consider (default 1024)", + ) + parser.add_argument( + "--max", + type=int, + default=None, + metavar="N", + help="stop after N paths", + ) + parser.add_argument( + "--unaligned", + action="store_true", + help="also consider pointers not on a pointer-size boundary — slower, " + "and rarely needed", + ) + parser.add_argument( + "--all-regions", + action="store_true", + help="include non-writable regions in the pointer map", + ) + return parser + + @command( "ptrscan", + parser=_ptrscan_parser, summary="Find static pointer paths that reach an address.", usage="ptrscan
[--depth N] [--max-offset N] [--max N] [--unaligned] [--all-regions]", group="Pointers", @@ -208,13 +285,6 @@ def cmd_pointer(session: Session, args: List[str]) -> None: "Builds a map of every pointer in the target and walks it backwards " "from ADDRESS until it reaches a static base inside a module. The " "paths found replace whatever 'paths' was showing.\n\n" - " --depth N maximum number of links (default 3). Each extra\n" - " level costs a lot of time and memory.\n" - " --max-offset N largest offset to consider (default 1024)\n" - " --max N stop after N paths\n" - " --unaligned also consider pointers not on a pointer-size\n" - " boundary (slower, rarely needed)\n" - " --all-regions include non-writable regions in the pointer map\n\n" "This is the expensive command in Peekmem: minutes and hundreds of " "megabytes on a large target. Ctrl+C stops it and keeps the paths " "found so far.\n\n" @@ -225,14 +295,7 @@ def cmd_pointer(session: Session, args: List[str]) -> None: examples=("ptrscan #1", "ptrscan 0x7ffee3a01000 --depth 4 --max 200"), ) def cmd_ptrscan(session: Session, args: List[str]) -> None: - parser = CommandParser("ptrscan") - parser.add_argument("address") - parser.add_argument("--depth", type=int, default=3) - parser.add_argument("--max-offset", type=int, default=1024) - parser.add_argument("--max", type=int, default=None) - parser.add_argument("--unaligned", action="store_true") - parser.add_argument("--all-regions", action="store_true") - options = parser.parse_args(args) + options = _ptrscan_parser().parse_args(args) process = session.require_process("ptrscan") target = parse_address(options.address, session) @@ -282,8 +345,24 @@ def on_progress(fraction: float) -> None: _print_paths(session, paths, limit=session.display_limit(), elapsed=timer.elapsed) +def _paths_parser() -> CommandParser: + parser = CommandParser("paths") + parser.add_argument( + "--limit", + type=int, + default=None, + metavar="N", + help="print at most N rows, overriding the 'limit' setting", + ) + parser.add_argument( + "--all", action="store_true", help="print every path, ignoring the limit" + ) + return parser + + @command( "paths", + parser=_paths_parser, summary="Show the pointer paths currently held.", usage="paths [--limit N] [--all]", group="Pointers", @@ -294,10 +373,7 @@ def on_progress(fraction: float) -> None: ), ) def cmd_paths(session: Session, args: List[str]) -> None: - parser = CommandParser("paths") - parser.add_argument("--limit", type=int, default=None) - parser.add_argument("--all", action="store_true") - options = parser.parse_args(args) + options = _paths_parser().parse_args(args) session.require_process("paths") if not session.pointer_paths: @@ -307,8 +383,15 @@ def cmd_paths(session: Session, args: List[str]) -> None: _print_paths(session, session.pointer_paths, limit=limit) +def _ptrsave_parser() -> CommandParser: + parser = CommandParser("ptrsave") + parser.add_argument("file", help="path of the JSON file to write") + return parser + + @command( "ptrsave", + parser=_ptrsave_parser, summary="Save the current pointer paths to a file.", usage="ptrsave ", group="Pointers", @@ -320,9 +403,7 @@ def cmd_paths(session: Session, args: List[str]) -> None: examples=("ptrsave health.json",), ) def cmd_ptrsave(session: Session, args: List[str]) -> None: - parser = CommandParser("ptrsave") - parser.add_argument("file") - options = parser.parse_args(args) + options = _ptrsave_parser().parse_args(args) process = session.require_process("ptrsave") if not session.pointer_paths: @@ -341,8 +422,15 @@ def cmd_ptrsave(session: Session, args: List[str]) -> None: session.printer.write() +def _ptrload_parser() -> CommandParser: + parser = CommandParser("ptrload") + parser.add_argument("file", help="path of a JSON file written by 'ptrsave'") + return parser + + @command( "ptrload", + parser=_ptrload_parser, summary="Load pointer paths from a file.", usage="ptrload ", group="Pointers", @@ -354,9 +442,7 @@ def cmd_ptrsave(session: Session, args: List[str]) -> None: examples=("ptrload health.json",), ) def cmd_ptrload(session: Session, args: List[str]) -> None: - parser = CommandParser("ptrload") - parser.add_argument("file") - options = parser.parse_args(args) + options = _ptrload_parser().parse_args(args) process = session.require_process("ptrload") if not os.path.exists(options.file): @@ -385,8 +471,26 @@ def cmd_ptrload(session: Session, args: List[str]) -> None: _print_paths(session, rebased, limit=session.display_limit()) +def _ptrrescan_parser() -> CommandParser: + parser = CommandParser("ptrrescan") + parser.add_argument( + "address", + help="the address the surviving paths must reach, as an address " + "expression — usually '#1' from the scan that found it again", + ) + parser.add_argument( + "file", + nargs="?", + default=None, + help="rescan the paths in this file; without it, the paths currently " + "held are rescanned", + ) + return parser + + @command( "ptrrescan", + parser=_ptrrescan_parser, summary="Keep only the paths that still reach an address.", usage="ptrrescan
[file]", group="Pointers", @@ -394,16 +498,12 @@ def cmd_ptrload(session: Session, args: List[str]) -> None: "The step that separates a real pointer path from a coincidence. " "Restart the target, find the value's new address, then rescan the " "saved paths against it: the ones that still land on the address are " - "the ones that describe the structure rather than that one run.\n\n" - "Without FILE, the paths currently held are rescanned." + "the ones that describe the structure rather than that one run." ), examples=("ptrrescan #1", "ptrrescan 0x7ffee3a01000 health.json"), ) def cmd_ptrrescan(session: Session, args: List[str]) -> None: - parser = CommandParser("ptrrescan") - parser.add_argument("address") - parser.add_argument("file", nargs="?", default=None) - options = parser.parse_args(args) + options = _ptrrescan_parser().parse_args(args) process = session.require_process("ptrrescan") target = parse_address(options.address, session) @@ -436,8 +536,20 @@ def cmd_ptrrescan(session: Session, args: List[str]) -> None: _print_paths(session, surviving, limit=session.display_limit()) +def _ptrdiff_parser() -> CommandParser: + parser = CommandParser("ptrdiff") + parser.add_argument( + "files", + nargs="*", + help="two or more JSON files written by 'ptrsave', one per run of the " + "target", + ) + return parser + + @command( "ptrdiff", + parser=_ptrdiff_parser, summary="Intersect pointer-path files from several runs.", usage="ptrdiff [file ...]", group="Pointers", @@ -452,9 +564,7 @@ def cmd_ptrrescan(session: Session, args: List[str]) -> None: examples=("ptrdiff run1.json run2.json", "ptrdiff run1.json run2.json run3.json"), ) def cmd_ptrdiff(session: Session, args: List[str]) -> None: - parser = CommandParser("ptrdiff") - parser.add_argument("files", nargs="*") - options = parser.parse_args(args) + options = _ptrdiff_parser().parse_args(args) process = session.require_process("ptrdiff") if len(options.files) < 2: diff --git a/peekmem/commands/process_commands.py b/peekmem/commands/process_commands.py index 52918b8..4e858fa 100644 --- a/peekmem/commands/process_commands.py +++ b/peekmem/commands/process_commands.py @@ -14,28 +14,50 @@ from . import CommandParser, command +def _ps_parser() -> CommandParser: + parser = CommandParser("ps") + parser.add_argument( + "pattern", + nargs="?", + default=None, + help="keep processes whose name contains this text; an all-digit " + "pattern also matches that PID exactly", + ) + parser.add_argument( + "--pid-sort", + action="store_true", + help="sort by PID instead of by name", + ) + parser.add_argument( + "--case-sensitive", + action="store_true", + help="match the pattern case-sensitively", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + metavar="N", + help="print at most N rows, overriding the 'limit' setting", + ) + return parser + + @command( "ps", + parser=_ps_parser, summary="List the processes visible to you.", usage="ps [pattern] [--pid-sort] [--case-sensitive] [--limit N]", group="Process", aliases=("processes", "list"), details=( - "With no pattern, every visible process is listed. A pattern matches " - "the process name as a case-insensitive substring, and also matches a " - "PID exactly when it is all digits.\n\n" "Only processes your user can see are listed. Run Peekmem elevated to " "see (and open) processes belonging to other users." ), examples=("ps", "ps chrome", "ps --pid-sort --limit 50"), ) def cmd_ps(session: Session, args: List[str]) -> None: - parser = CommandParser("ps") - parser.add_argument("pattern", nargs="?", default=None) - parser.add_argument("--pid-sort", action="store_true", help="sort by PID") - parser.add_argument("--case-sensitive", action="store_true") - parser.add_argument("--limit", type=int, default=None) - options = parser.parse_args(args) + options = _ps_parser().parse_args(args) with Timer() as timer: entries = processes.list_processes( @@ -59,8 +81,56 @@ def cmd_ps(session: Session, args: List[str]) -> None: ) +def _open_parser() -> CommandParser: + parser = CommandParser("open") + parser.add_argument( + "target", + nargs="?", + default=None, + help="the PID (all digits) or the process name to attach to", + ) + parser.add_argument( + "--pid", + type=int, + default=None, + metavar="PID", + help="attach by PID, when the target could be read either way", + ) + parser.add_argument( + "--name", + default=None, + metavar="NAME", + help="attach by process name, when the name is all digits", + ) + parser.add_argument( + "-i", + "--ignore-case", + action="store_true", + help="match the name regardless of case", + ) + parser.add_argument( + "--case-sensitive", + action="store_true", + help="match the name case-sensitively (the default on Linux and macOS)", + ) + parser.add_argument( + "--partial", + action="store_true", + help="match the name as a substring ('chrome' finds 'chrome.exe'); " + "fails when more than one process matches, listing the candidates", + ) + parser.add_argument( + "--strict-bitness", + action="store_true", + help="refuse to attach when the target's 32/64-bit width cannot be " + "determined, instead of guessing it from this interpreter", + ) + return parser + + @command( "open", + parser=_open_parser, summary="Attach to a process by PID or name.", usage="open [-i] [--partial] [--strict-bitness]", group="Process", @@ -68,28 +138,14 @@ def cmd_ps(session: Session, args: List[str]) -> None: details=( "An all-digits target is taken as a PID, anything else as a process " "name; force either reading with --pid or --name.\n\n" - " -i / --ignore-case match the name regardless of case.\n" - " --partial match the name as a substring ('chrome' finds\n" - " 'chrome.exe'). Fails when more than one process\n" - " matches, listing the candidates.\n" - " --strict-bitness refuse to attach when the target's 32/64-bit\n" - " width cannot be determined, instead of guessing\n" - " it from this interpreter. Worth using before a\n" - " pointer scan, where a wrong width is silent.\n\n" + "--strict-bitness is worth using before a pointer scan, where a wrong " + "pointer width is silent rather than loud.\n\n" "Attaching replaces any previous target and clears the scan results." ), examples=("open 4242", "open notepad.exe", "open chrome --partial -i"), ) def cmd_open(session: Session, args: List[str]) -> None: - parser = CommandParser("open") - parser.add_argument("target", nargs="?", default=None) - parser.add_argument("--pid", type=int, default=None) - parser.add_argument("--name", default=None) - parser.add_argument("-i", "--ignore-case", action="store_true") - parser.add_argument("--case-sensitive", action="store_true") - parser.add_argument("--partial", action="store_true") - parser.add_argument("--strict-bitness", action="store_true") - options = parser.parse_args(args) + options = _open_parser().parse_args(args) pid, name = options.pid, options.name @@ -130,35 +186,49 @@ def cmd_open(session: Session, args: List[str]) -> None: ) +def _close_parser() -> CommandParser: + return CommandParser("close") + + @command( "close", + parser=_close_parser, summary="Detach from the current process.", usage="close", group="Process", aliases=("detach",), details=( + "Takes no arguments.\n\n" "Closes the OS handle and drops the scan results, the pointer paths " "and the cached memory map. The target itself is untouched — nothing " "Peekmem wrote to it is undone." ), ) def cmd_close(session: Session, args: List[str]) -> None: - CommandParser("close").parse_args(args) + _close_parser().parse_args(args) if not session.detach(): raise CommandError("No process attached.") session.printer.ok("Detached.") +def _status_parser() -> CommandParser: + return CommandParser("status") + + @command( "status", + parser=_status_parser, summary="Show the session state and versions.", usage="status", group="Process", aliases=("\\s",), - details="Cheap: reports what the session knows without touching the target.", + details=( + "Takes no arguments.\n\n" + "Cheap: reports what the session knows without touching the target." + ), ) def cmd_status(session: Session, args: List[str]) -> None: - CommandParser("status").parse_args(args) + _status_parser().parse_args(args) rows = [ ("Peekmem", __version__), @@ -187,18 +257,24 @@ def cmd_status(session: Session, args: List[str]) -> None: session.printer.write() +def _info_parser() -> CommandParser: + return CommandParser("info") + + @command( "info", + parser=_info_parser, summary="Describe the attached process in detail.", usage="info", group="Process", details=( + "Takes no arguments.\n\n" "Enumerates the memory map to report how much of the address space is " "mapped, so it costs a little more than 'status'." ), ) def cmd_info(session: Session, args: List[str]) -> None: - CommandParser("info").parse_args(args) + _info_parser().parse_args(args) process = session.require_process("info") with Timer() as timer: diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index f52b3a5..f8ef4f1 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -61,6 +61,8 @@ "between", ) +_MAX_HELP = "stop after N hits, overriding the 'max_results' setting" + def _normalize_op(name: str) -> str: key = name.strip().lower() @@ -226,8 +228,60 @@ def _print_results( ) +def _scan_parser() -> CommandParser: + parser = CommandParser("scan") + parser.add_argument("type", help="value type to search for (see 'help types')") + parser.add_argument( + "value", + nargs="?", + default=None, + help="the value to search for; omit it when using --between", + ) + # Deliberately no `choices=`: argparse would reject the symbol spellings + # ('>' and friends) before _normalize_op ever sees them, and those are the + # ones people reach for first. The check below reports them itself. + parser.add_argument( + "--op", + default="eq", + metavar="OP", + help="comparison against the value: eq (default), ne, gt, lt, ge, le. " + "The symbols =, !=, >, <, >=, <= are accepted too", + ) + parser.add_argument( + "--between", + nargs=2, + metavar=("A", "B"), + default=None, + help="keep values inside the range A..B, inclusive", + ) + parser.add_argument( + "--outside", action="store_true", help="invert --between" + ) + parser.add_argument( + "--writable", + action="store_true", + help="scan only writable regions — much faster, and where a changing " + "value almost always lives", + ) + parser.add_argument( + "--all-regions", + action="store_true", + help="scan everything, overriding the 'writable_only' setting", + ) + parser.add_argument( + "--length", + type=int, + default=None, + metavar="N", + help="buffer width for string/bytes scans", + ) + parser.add_argument("--max", type=int, default=None, metavar="N", help=_MAX_HELP) + return parser + + @command( "scan", + parser=_scan_parser, summary="Search the whole address space for a value.", usage="scan [--op eq|ne|gt|lt|ge|le] | scan --between A B", group="Scanning", @@ -236,16 +290,6 @@ def _print_results( "The first scan of a cycle. Every matching address is kept as the " "result set that 'next', 'results' and the '#N' address form work " "on.\n\n" - " --op OP comparison against the value; eq (the default), ne,\n" - " gt, lt, ge, le. The symbols =, !=, >, <, >=, <= work\n" - " too.\n" - " --between A B keep values inside the range, inclusive\n" - " --outside invert --between\n" - " --writable scan only writable regions — much faster, and where\n" - " a changing value almost always lives\n" - " --all-regions scan everything, overriding the writable_only setting\n" - " --length N buffer width for string/bytes scans\n" - " --max N stop after N hits (default: the max_results setting)\n\n" "Ctrl+C stops a scan and keeps what it had already found." ), examples=( @@ -257,17 +301,7 @@ def _print_results( ), ) def cmd_scan(session: Session, args: List[str]) -> None: - parser = CommandParser("scan") - parser.add_argument("type") - parser.add_argument("value", nargs="?", default=None) - parser.add_argument("--op", default="eq") - parser.add_argument("--between", nargs=2, metavar=("A", "B"), default=None) - parser.add_argument("--outside", action="store_true") - parser.add_argument("--writable", action="store_true") - parser.add_argument("--all-regions", action="store_true") - parser.add_argument("--length", type=int, default=None) - parser.add_argument("--max", type=int, default=None) - options = parser.parse_args(args) + options = _scan_parser().parse_args(args) process = session.require_process("scan") value_type = valuetypes.resolve(options.type) @@ -345,8 +379,29 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: _report(session, state, timer.elapsed, interrupted=interrupted) +def _next_parser() -> CommandParser: + parser = CommandParser("next") + parser.add_argument( + "op", + nargs="?", + default=None, + help="the comparison to apply: eq, ne, gt, lt, ge, le, between, " + "changed, unchanged, increased, decreased, increased-by, " + "decreased-by. Omit it to mean eq", + ) + parser.add_argument( + "value", + nargs="*", + default=[], + help="the value the comparison needs — two for 'between', one for the " + "six ordinary comparisons and the *-by pair, none for the rest", + ) + return parser + + @command( "next", + parser=_next_parser, summary="Narrow the results with another comparison.", usage="next [op] [value] — op: eq ne gt lt ge le between changed unchanged increased decreased increased-by decreased-by", group="Scanning", @@ -354,13 +409,13 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: details=( "Re-reads every address in the result set and keeps the ones that " "still match. Bare 'next 100' means 'next eq 100'.\n\n" - "Comparisons against a value you supply:\n" + "Comparisons against a value you supply:\n\n" " eq ne gt lt ge le VALUE the usual six\n" " between A B inside the range, inclusive\n" " increased-by N grew by exactly N since the last scan\n" " decreased-by N shrank by exactly N since the last scan\n\n" "Comparisons against the previous scan, for when you do not know the " - "value — the health bar moved, but to what?\n" + "value — the health bar moved, but to what?\n\n" " changed / unchanged differs from / equals the last reading\n" " increased / decreased moved in that direction\n\n" "Addresses that have become unreadable (the target freed them) are " @@ -369,10 +424,7 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: examples=("next 95", "next changed", "next decreased", "next gt 50", "next between 10 20"), ) def cmd_next(session: Session, args: List[str]) -> None: - parser = CommandParser("next") - parser.add_argument("op", nargs="?", default=None) - parser.add_argument("value", nargs="*", default=[]) - options = parser.parse_args(args) + options = _next_parser().parse_args(args) state = session.require_scan() session.require_process("next") @@ -470,16 +522,25 @@ def cmd_next(session: Session, args: List[str]) -> None: _print_results(session, new_state, limit=None, elapsed=timer.elapsed) +def _aob_parser() -> CommandParser: + parser = CommandParser("aob") + parser.add_argument( + "pattern", + help="IDA-style signature: hex bytes separated by spaces, with '?' or " + "'??' for any single byte. Quote it, since it contains spaces", + ) + parser.add_argument("--max", type=int, default=None, metavar="N", help=_MAX_HELP) + return parser + + @command( "aob", + parser=_aob_parser, summary="Scan for a byte pattern with wildcards (AOB).", usage="aob [--max N]", group="Scanning", aliases=("pattern",), details=( - "Takes an IDA-style signature: hex bytes separated by spaces, with '?' " - "or '??' standing for any single byte. Quote it, since it contains " - "spaces.\n\n" "This is how you find code that moves between builds: the opcodes stay " "put while the operands change, so you wildcard the operands. The " "result set holds the address of each match and can be refined with " @@ -488,10 +549,7 @@ def cmd_next(session: Session, args: List[str]) -> None: examples=('aob "48 8B ? ? 00 00"', 'aob "DE AD BE EF"'), ) def cmd_aob(session: Session, args: List[str]) -> None: - parser = CommandParser("aob") - parser.add_argument("pattern") - parser.add_argument("--max", type=int, default=None) - options = parser.parse_args(args) + options = _aob_parser().parse_args(args) process = session.require_process("aob") @@ -528,29 +586,41 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: _report(session, state, timer.elapsed, interrupted=interrupted) +def _regex_parser() -> CommandParser: + parser = CommandParser("regex") + parser.add_argument( + "pattern", + help="a regular expression, UTF-8 encoded and matched against raw memory", + ) + parser.add_argument( + "--length", + type=int, + default=64, + metavar="N", + help="the widest match to expect, in bytes (default 64); also how " + "many bytes are read back for the VALUE column", + ) + parser.add_argument("--max", type=int, default=None, metavar="N", help=_MAX_HELP) + return parser + + @command( "regex", + parser=_regex_parser, summary="Scan for text matching a regular expression.", usage="regex [--length N] [--max N]", group="Scanning", details=( - "The pattern is a normal regex, UTF-8 encoded and matched against raw " - "memory. Because the match runs over *bytes*, a metacharacter spans " - "one byte: '.' matches any single byte and '\\d' is ASCII-only, so " - "quantify with care around non-ASCII text.\n\n" - " --length N the widest match to expect, in bytes (default 64). A\n" - " regex has no fixed width, and this is what lets a match\n" - " straddling an internal chunk boundary still be found —\n" - " and how many bytes are read back for the VALUE column." + "Because the match runs over *bytes*, a metacharacter spans one byte: " + "'.' matches any single byte and '\\d' is ASCII-only, so quantify with " + "care around non-ASCII text.\n\n" + "A regex has no fixed width, which is why --length matters: it is what " + "lets a match straddling an internal chunk boundary still be found." ), examples=('regex "Player[0-9]+"', 'regex "https?://[a-z.]+" --length 128'), ) def cmd_regex(session: Session, args: List[str]) -> None: - parser = CommandParser("regex") - parser.add_argument("pattern") - parser.add_argument("--length", type=int, default=64) - parser.add_argument("--max", type=int, default=None) - options = parser.parse_args(args) + options = _regex_parser().parse_args(args) process = session.require_process("regex") @@ -594,8 +664,31 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: _report(session, state, timer.elapsed, interrupted=interrupted) +def _results_parser() -> CommandParser: + parser = CommandParser("results") + parser.add_argument( + "--limit", + type=int, + default=None, + metavar="N", + help="print at most N rows, overriding the 'limit' setting", + ) + parser.add_argument( + "--offset", + type=int, + default=0, + metavar="N", + help="start at row N+1, for paging through a long result set", + ) + parser.add_argument( + "--all", action="store_true", help="print every row, ignoring the limit" + ) + return parser + + @command( "results", + parser=_results_parser, summary="Show the current result set, re-read.", usage="results [--limit N] [--offset N] [--all]", group="Scanning", @@ -611,11 +704,7 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: examples=("results", "results --all", "results --offset 20 --limit 10"), ) def cmd_results(session: Session, args: List[str]) -> None: - parser = CommandParser("results") - parser.add_argument("--limit", type=int, default=None) - parser.add_argument("--offset", type=int, default=0) - parser.add_argument("--all", action="store_true") - options = parser.parse_args(args) + options = _results_parser().parse_args(args) state = session.require_scan() process = session.require_process("results") @@ -688,22 +777,32 @@ def _parse_row_selection(tokens: Sequence[str], count: int) -> List[int]: return selected +_ROWS_HELP = "row numbers from 'results', singly or as ranges: 1 4 7-9 (a '#' prefix is optional)" + + +def _keep_parser() -> CommandParser: + parser = CommandParser("keep") + parser.add_argument("rows", nargs="+", help=_ROWS_HELP) + return parser + + @command( "keep", + parser=_keep_parser, summary="Keep only the named result rows.", usage="keep [row ...]", group="Scanning", details=( - "Rows may be given singly or as ranges: 'keep 1 4 7-9'. Use it when " - "you can see which candidates are real and would rather not invent a " - "comparison that happens to exclude the others." + "Use it when you can see which candidates are real and would rather " + "not invent a comparison that happens to exclude the others." ), examples=("keep 1", "keep 1 3 7-9"), ) def cmd_keep(session: Session, args: List[str]) -> None: + options = _keep_parser().parse_args(args) state = session.require_scan() session.require_process("keep") - indexes = _parse_row_selection(args, len(state.addresses)) + indexes = _parse_row_selection(options.rows, len(state.addresses)) ordered = sorted(set(indexes)) new_state = session.store_scan( @@ -716,8 +815,15 @@ def cmd_keep(session: Session, args: List[str]) -> None: _print_results(session, new_state, limit=None, elapsed=None) +def _drop_parser() -> CommandParser: + parser = CommandParser("drop") + parser.add_argument("rows", nargs="+", help=_ROWS_HELP) + return parser + + @command( "drop", + parser=_drop_parser, summary="Remove the named result rows.", usage="drop [row ...]", group="Scanning", @@ -725,9 +831,10 @@ def cmd_keep(session: Session, args: List[str]) -> None: examples=("drop 2", "drop 5-12"), ) def cmd_drop(session: Session, args: List[str]) -> None: + options = _drop_parser().parse_args(args) state = session.require_scan() session.require_process("drop") - removed = set(_parse_row_selection(args, len(state.addresses))) + removed = set(_parse_row_selection(options.rows, len(state.addresses))) remaining = [index for index in range(len(state.addresses)) if index not in removed] new_state = session.store_scan( @@ -740,19 +847,25 @@ def cmd_drop(session: Session, args: List[str]) -> None: _print_results(session, new_state, limit=None, elapsed=None) +def _reset_parser() -> CommandParser: + return CommandParser("reset") + + @command( "reset", + parser=_reset_parser, summary="Discard the current scan results.", usage="reset", group="Scanning", aliases=("unscan",), details=( + "Takes no arguments.\n\n" "Clears the result set so the next 'scan' starts a fresh cycle. The " "attached process is left alone." ), ) def cmd_reset(session: Session, args: List[str]) -> None: - CommandParser("reset").parse_args(args) + _reset_parser().parse_args(args) count = len(session.scan) if session.scan else 0 session.scan = None session.printer.ok(f"Discarded {count} result(s).") diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 949b690..c08ca22 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -4,15 +4,22 @@ import os import platform -from typing import List +from typing import List, Tuple import PyMemoryEditor from .. import __version__, valuetypes from ..errors import CommandError, ExitShell -from ..output import LEFT, RIGHT, render_table, render_vertical +from ..output import ( + LEFT, + RIGHT, + render_definitions, + render_paragraphs, + render_table, + render_vertical, +) from ..session import SETTINGS, Session -from . import GROUPS, CommandParser, all_commands, command, lookup +from . import GROUPS, Command, CommandParser, all_commands, command, describe_action, lookup _ADDRESS_TOPIC = """\ Every command that takes an address takes an expression. @@ -112,12 +119,41 @@ def _print_overview(session: Session) -> None: printer.write(f" {entry.name.ljust(width)} {entry.summary}") printer.write() - printer.write("Type 'help ' for the full description of one command.") + printer.write( + "Type 'help ' — or ' --help' — for a command's full\n" + "description, including every argument and flag it accepts." + ) printer.write("Topics: 'help types', 'help address', 'help scanning'.") printer.write("End the session with 'exit', Ctrl+D, or \\q.") printer.write() +def _argument_sections(entry: Command) -> List[Tuple[str, List[Tuple[str, str]]]]: + """Split a command's arguments into the sections ``help`` prints. + + Built from the very parser the command parses with, so the two cannot + disagree: adding a flag adds it to the help, and changing what a flag is + called changes it in both places at once. + """ + positionals: List[Tuple[str, str]] = [] + options: List[Tuple[str, str]] = [] + + for action in entry.arguments(): + label = describe_action(action) + text = action.help or "" + if action.option_strings: + options.append((label, text)) + else: + positionals.append((label, text)) + + sections = [] + if positionals: + sections.append(("Arguments", positionals)) + if options: + sections.append(("Options", options)) + return sections + + def _print_command_help(session: Session, name: str) -> None: entry = lookup(name) printer = session.printer @@ -128,8 +164,14 @@ def _print_command_help(session: Session, name: str) -> None: if entry.aliases: printer.write(f"Aliases: {', '.join(entry.aliases)}") printer.write() + + for title, items in _argument_sections(entry): + printer.write(f"{title}:") + printer.write(render_definitions(items)) + printer.write() + if entry.details: - printer.write(entry.details) + printer.write(render_paragraphs(entry.details)) printer.write() if entry.examples: printer.write("Examples:") @@ -138,28 +180,42 @@ def _print_command_help(session: Session, name: str) -> None: printer.write() +def _help_parser() -> CommandParser: + parser = CommandParser("help") + parser.add_argument( + "topic", + nargs="?", + default=None, + help="a command name, or one of the topics 'types', 'address' and " + "'scanning'. Omit it to list every command", + ) + return parser + + @command( "help", + parser=_help_parser, summary="List the commands, or describe one.", usage="help [command|types|address|scanning]", group="Session", aliases=("?", "\\h"), details=( - "With no argument, prints every command grouped by what it acts on. " - "With a command name, prints that command's usage, options and " - "examples. The topics 'types', 'address' and 'scanning' cover the " - "parts that several commands share." + "With a command name, prints that command's usage, every argument and " + "flag it accepts, and examples. Typing ' --help' does the " + "same thing.\n\n" + "The argument list is generated from the command's own parser, so it " + "is always what the command actually accepts." ), examples=("help", "help scan", "help address"), ) def cmd_help(session: Session, args: List[str]) -> None: - if not args: + options = _help_parser().parse_args(args) + + if options.topic is None: _print_overview(session) return - if len(args) > 1: - raise CommandError("help takes one command or topic at a time.") - topic = args[0].strip().lower() + topic = options.topic.strip().lower() if topic in ("types", "type"): _print_types(session) @@ -176,22 +232,38 @@ def cmd_help(session: Session, args: List[str]) -> None: _print_command_help(session, topic) +def _set_parser() -> CommandParser: + parser = CommandParser("set") + parser.add_argument( + "assignment", + nargs="*", + default=[], + help="'name value', 'name=value', or a bare 'name' to read one back. " + "Omit it to print every setting", + ) + return parser + + @command( "set", + parser=_set_parser, summary="Show or change a session setting.", usage="set [name [value]]", group="Session", details=( - "With no argument, prints every setting and its current value. " - "'set name value' and 'set name=value' both assign.\n\n" "Settings live for the session only — Peekmem writes no config file, " "so a fresh shell always starts from the documented defaults. Put the " - "'set' lines in a script and run it with 'source' to reuse a setup." + "'set' lines in a script and run it with 'source' to reuse a setup.\n\n" + "Run 'set' with no argument to see every setting, its current value " + "and what it does." ), examples=("set", "set limit 50", "set hex on", "set writable_only=true"), ) def cmd_set(session: Session, args: List[str]) -> None: - if not args: + options = _set_parser().parse_args(args) + assignment = options.assignment + + if not assignment: rows = [ (setting.name, _format_setting(session.option(setting.name)), setting.summary) for setting in SETTINGS @@ -201,12 +273,12 @@ def cmd_set(session: Session, args: List[str]) -> None: ) return - if len(args) == 1 and "=" in args[0]: - name, _, value = args[0].partition("=") - elif len(args) == 1: - name, value = args[0], None - elif len(args) == 2: - name, value = args[0], args[1] + if len(assignment) == 1 and "=" in assignment[0]: + name, _, value = assignment[0].partition("=") + elif len(assignment) == 1: + name, value = assignment[0], None + elif len(assignment) == 2: + name, value = assignment[0], assignment[1] else: raise CommandError("Usage: set [name [value]]") @@ -225,24 +297,32 @@ def cmd_set(session: Session, args: List[str]) -> None: session.printer.write() +def _source_parser() -> CommandParser: + parser = CommandParser("source") + parser.add_argument( + "file", + help="a text file of commands, one per line; blank lines and lines " + "starting with '#' or '--' are ignored", + ) + return parser + + @command( "source", + parser=_source_parser, summary="Run the commands in a file.", usage="source ", group="Session", aliases=("\\.",), details=( - "Reads the file and runs each line as if it had been typed. Blank " - "lines are skipped, and a line starting with '#' or '--' is a comment.\n\n" + "Reads the file and runs each line as if it had been typed.\n\n" "A failing line stops the script — a setup that half-ran is worse than " "one that says where it stopped." ), examples=("source setup.peek",), ) def cmd_source(session: Session, args: List[str]) -> None: - parser = CommandParser("source") - parser.add_argument("file") - options = parser.parse_args(args) + options = _source_parser().parse_args(args) if session.shell is None: raise CommandError("'source' needs a shell to run the commands in.") @@ -262,15 +342,24 @@ def cmd_source(session: Session, args: List[str]) -> None: raise CommandError(f"{options.file}:{number}: {error}") +def _version_parser() -> CommandParser: + return CommandParser("version") + + @command( "version", + parser=_version_parser, summary="Print the Peekmem and PyMemoryEditor versions.", usage="version", group="Session", - details="The two lines to quote in a bug report, plus the platform.", + details=( + "Takes no arguments.\n\n" + "The one line to quote in a bug report: it names Peekmem, " + "PyMemoryEditor, Python and the platform." + ), ) def cmd_version(session: Session, args: List[str]) -> None: - CommandParser("version").parse_args(args) + _version_parser().parse_args(args) session.printer.write( f"Peekmem {__version__} / PyMemoryEditor {PyMemoryEditor.__version__} " f"/ Python {platform.python_version()} on {platform.system()} " @@ -279,16 +368,24 @@ def cmd_version(session: Session, args: List[str]) -> None: session.printer.write() +def _exit_parser() -> CommandParser: + return CommandParser("exit") + + @command( "exit", + parser=_exit_parser, summary="Leave the shell.", usage="exit", group="Session", aliases=("quit", "\\q"), - details="Detaches from the target first. Ctrl+D does the same thing.", + details=( + "Takes no arguments.\n\n" + "Detaches from the target first. Ctrl+D does the same thing." + ), ) def cmd_exit(session: Session, args: List[str]) -> None: - CommandParser("exit").parse_args(args) + _exit_parser().parse_args(args) raise ExitShell(0) diff --git a/peekmem/output.py b/peekmem/output.py index 7c42119..0fceea6 100644 --- a/peekmem/output.py +++ b/peekmem/output.py @@ -16,6 +16,7 @@ import os import sys +import textwrap import time from typing import Any, Iterable, List, Optional, Sequence, TextIO, Tuple @@ -118,6 +119,63 @@ def render_vertical(pairs: Iterable[Tuple[str, Any]]) -> str: return "\n".join(f"{key.rjust(width)}: {value}" for key, value in items) +def render_paragraphs(text: str, width: int = 78) -> str: + """Wrap prose to ``width``, leaving hand-laid-out blocks alone. + + A command's long description is written as ordinary paragraphs, which + should reflow; but some of them contain small aligned tables (the list of + 'next' comparisons, say) whose whole value is the alignment. A paragraph + with an indented line is taken to be one of those and is printed verbatim. + """ + blocks = [] + for block in text.split("\n\n"): + if not block.strip(): + continue + if any(line.startswith((" ", "\t")) for line in block.splitlines()): + blocks.append(block) + else: + blocks.append(textwrap.fill(" ".join(block.split()), width)) + return "\n\n".join(blocks) + + +def render_definitions( + items: Sequence[Tuple[str, str]], + *, + indent: int = 2, + label_width: int = 22, + total_width: int = 78, +) -> str: + """Render label/description pairs as an aligned, wrapped block. + + This is the shape ``help`` uses for a command's arguments — the layout + every command-line tool's ``--help`` has, so it needs no explaining. A + label longer than ``label_width`` takes a line of its own rather than + pushing every description out of alignment. + """ + if not items: + return "" + + width = min(max(len(label) for label, _ in items), label_width) + pad = " " * indent + continuation = pad + " " * (width + 2) + text_width = max(24, total_width - len(continuation)) + lines: List[str] = [] + + for label, description in items: + wrapped = textwrap.wrap(description, text_width) if description else [] + if not wrapped: + lines.append(pad + label) + continue + if len(label) <= width: + lines.append(f"{pad}{label.ljust(width)} {wrapped[0]}") + else: + lines.append(pad + label) + lines.append(continuation + wrapped[0]) + lines.extend(continuation + extra for extra in wrapped[1:]) + + return "\n".join(lines) + + def render_hexdump(data: bytes, base_address: int = 0, width: int = 16) -> str: """Classic ``hexdump -C`` layout: address, hex bytes, printable ASCII.""" lines = [] @@ -277,7 +335,9 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: "format_address", "format_duration", "format_size", + "render_definitions", "render_hexdump", + "render_paragraphs", "render_table", "render_vertical", "supports_color", diff --git a/peekmem/shell.py b/peekmem/shell.py index 7e4434e..b89c927 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -23,7 +23,7 @@ from PyMemoryEditor import PyMemoryEditorError from . import __version__, valuetypes -from .commands import all_commands, command_words, lookup +from .commands import all_commands, command_words, lookup, option_words from .errors import CommandError, ExitShell from .output import Printer from .session import SETTINGS, Session @@ -34,6 +34,10 @@ _LEADING_WORD = re.compile(r"\s*(\S+)\s*(.*)", re.DOTALL) +#: Asking a command for its own help is a reflex worth honouring — nobody +#: should have to learn that this shell spells it 'help '. +_HELP_FLAGS = frozenset(("-h", "--help", "-?")) + class Shell: """Dispatches command lines against a :class:`~peekmem.session.Session`.""" @@ -96,6 +100,11 @@ def run_line(self, line: str, *, raise_errors: bool = False) -> bool: return True word, args = parsed entry = lookup(word) + + if any(argument in _HELP_FLAGS for argument in args): + lookup("help").handler(self.session, [entry.name]) + return True + entry.handler(self.session, args) return True @@ -244,6 +253,10 @@ def _complete(self, text: str, state: int) -> Optional[str]: candidates = [setting.name for setting in SETTINGS] elif head == "help": candidates = command_words() + ["types", "address", "scanning"] + elif text.startswith("-"): + # Completing a flag: offer exactly the ones this command + # declares, which is the same list 'help ' prints. + candidates = option_words(head) else: candidates = valuetypes.type_names() diff --git a/tests/test_commands.py b/tests/test_commands.py index 95adf2b..fe123a7 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -4,7 +4,13 @@ import pytest -from peekmem.commands import GROUPS, all_commands, command_words, lookup +from peekmem.commands import ( + GROUPS, + all_commands, + command_words, + describe_action, + lookup, +) from peekmem.errors import CommandError, NoProcessError @@ -27,6 +33,69 @@ def test_every_command_has_help(entry, shell, capture): assert lookup(alias).name == entry.name +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_every_command_declares_a_parser(entry): + """Without one, 'help' cannot list what the command accepts.""" + assert entry.parser is not None + assert entry.parser().prog == entry.name + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_every_argument_is_documented(entry): + """An undocumented flag is a flag nobody can discover.""" + for action in entry.arguments(): + label = describe_action(action) + assert action.help, f"{entry.name}: {label} has no help text" + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_help_lists_every_flag(entry, shell, capture): + """Each option a command accepts must appear in its help output.""" + shell.run_line(f"help {entry.name}") + for action in entry.arguments(): + for flag in action.option_strings: + assert flag in capture.out, f"{entry.name}: {flag} missing from help" + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_usage_line_advertises_only_real_flags(entry): + """The usage line is hand-written; a flag it names must still exist. + + The other direction is deliberately not checked: a usage line is a summary + for a human, so it is free to leave a rarely-used flag to the generated + Options section below it. + """ + declared = { + flag for action in entry.arguments() for flag in action.option_strings + } + for word in entry.usage.replace("[", " ").replace("]", " ").split(): + if word.startswith("--") and len(word) > 2: + assert word in declared, ( + f"{entry.name}: usage names {word}, which the parser does not accept" + ) + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_a_command_can_be_asked_for_its_own_help(shell, capture, flag): + shell.run_line(f"scan {flag}") + assert "Search the whole address space" in capture.out + assert "--between A B" in capture.out + + +def test_help_flag_wins_over_a_bad_argument(shell, capture): + """'scan --help' must explain, not complain about the missing value.""" + assert shell.run_line("scan --help") is True + assert capture.err == "" + + +def test_option_words_come_from_the_parser(): + from peekmem.commands import option_words + + assert "--writable" in option_words("scan") + assert "--between" in option_words("find") # an alias resolves too + assert option_words("nosuchcommand") == [] + + def test_command_words_are_unique(): words = command_words() assert len(words) == len(set(words)) From 863050c2c760fc87b35d5ca4c410d5453b9f3515 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 00:50:13 -0300 Subject: [PATCH 04/82] fix(scan): survive a region the target refuses to read, and pin the floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scan string utf-8` died with `mach_vm_read_overwrite failed: (os/kern) memory error (kr=10)`, losing the whole scan. The cause was the PyMemoryEditor underneath: 2.1.0 does not classify KERN_MEMORY_ERROR as transient, so a file-backed page whose pager declines to produce data aborts the scan instead of skipping the chunk. PyMemoryEditor 2.2.0 fixed that (its #88), and Peekmem already declares `>=2.2.0` — but pip only enforces a floor on an install, not on a source tree that happens to have an older one importable. Two changes, because either alone leaves a hole. A version check at startup turns "a Mach call failed somewhere inside a scan" into a sentence naming the version installed, the version needed, and the pip command that fixes it. It is deliberately lenient about unparseable versions: a fork or a locally patched build should not be refused over a version string. The scan runner no longer treats a read failure as fatal. A batch the backend gives up on is skipped, counted, and reported in a note; the rest of the address space is still walked, and the matches found before the failure are kept. This mirrors what Ctrl+C already did, for the same reason: a page that cannot be read says nothing about the thousands of regions behind it, and a scan that discards four minutes of work over one of them is answering a question nobody asked. With this, the reported command completes even on the older library — with a note, and with the coverage gap stated rather than hidden. --- peekmem/cli.py | 10 ++- peekmem/commands/scan_commands.py | 106 +++++++++++++++++-------- peekmem/dependencies.py | 67 ++++++++++++++++ tests/test_cli.py | 11 +++ tests/test_dependencies.py | 44 +++++++++++ tests/test_scanning.py | 124 ++++++++++++++++++++++++++++++ 6 files changed, 329 insertions(+), 33 deletions(-) create mode 100644 peekmem/dependencies.py create mode 100644 tests/test_dependencies.py create mode 100644 tests/test_scanning.py diff --git a/peekmem/cli.py b/peekmem/cli.py index 09f3c4e..45478a7 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -22,7 +22,7 @@ import PyMemoryEditor -from . import __version__ +from . import __version__, dependencies from .commands import GROUPS, all_commands from .errors import CommandError, PeekmemError from .output import Printer @@ -178,6 +178,14 @@ def main(argv: Optional[Sequence[str]] = None) -> int: color=False if options.no_color else None, timing=not options.no_timing, ) + + # Before anything touches a process: a PyMemoryEditor below the declared + # floor fails later, deep inside a scan, with an error that names a Mach + # call rather than the cause. + outdated = dependencies.check() + if outdated is not None: + printer.error(outdated) + return 2 session = Session(printer) shell = Shell(session, printer=printer) diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index f8ef4f1..9319a9e 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -19,6 +19,7 @@ batching them changes no result. """ +from dataclasses import dataclass, field from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple from PyMemoryEditor import MemoryRegion, ScanTypesEnum @@ -85,19 +86,45 @@ def _batch_regions( yield batch, size +@dataclass +class ScanOutcome: + """What a scan found, and everything that got in its way. + + A scan is long and the address space is a moving target, so "it worked" and + "it failed" are not the only two answers. Each of these flags means the + results are real but partial, and every one of them is reported to the user + rather than quietly folded into the row count. + """ + + addresses: List[int] = field(default_factory=list) + #: The ``max_results`` cap stopped the scan early. + truncated: bool = False + #: Ctrl+C stopped it. + interrupted: bool = False + #: Batches of regions the backend refused to read. + skipped: int = 0 + #: The last such refusal, for the note that reports them. + last_error: Optional[BaseException] = None + + def _run_scan( session: Session, search: Callable[[List[MemoryRegion]], Iterable[Any]], *, label: str = "Scanning", -) -> Tuple[List[int], bool, bool]: +) -> ScanOutcome: """Drive ``search`` over the target's regions. :param search: called with a batch of regions, yields matching addresses. - :return: ``(addresses, truncated, interrupted)`` — ``truncated`` when the - ``max_results`` cap stopped the scan, ``interrupted`` when Ctrl+C did. - Both keep whatever was found so far, because throwing away four - minutes of scanning to punish an impatient keystroke helps nobody. + + Nothing here throws away results it already has. Ctrl+C stops the scan and + keeps them, because punishing an impatient keystroke by discarding four + minutes of scanning helps nobody. A read failure skips the rest of that + batch and moves to the next one, for the same reason and one more: a page + that cannot be read is ordinary weather in another process's address + space — it may have been unmapped a microsecond ago, or be file-backed and + declined by its pager — and it says nothing about the thousands of regions + behind it. Both are reported; neither is silent. """ regions = session.scan_regions() total = sum(region.size for region in regions) or 1 @@ -105,29 +132,35 @@ def _run_scan( show_progress = bool(session.option("progress")) printer = session.printer - addresses: List[int] = [] + outcome = ScanOutcome() scanned = 0 - truncated = False - interrupted = False try: for batch, batch_size in _batch_regions(regions): - for address in search(batch): - addresses.append(address) - if max_results and len(addresses) >= max_results: - truncated = True - break + try: + for address in search(batch): + outcome.addresses.append(address) + if max_results and len(outcome.addresses) >= max_results: + outcome.truncated = True + break + except OSError as error: + # The backend gave up on this batch. Matches it had already + # yielded are kept; the rest of the address space is still + # worth walking. + outcome.skipped += 1 + outcome.last_error = error + scanned += batch_size if show_progress: printer.progress(label, scanned / total) - if truncated: + if outcome.truncated: break except KeyboardInterrupt: - interrupted = True + outcome.interrupted = True finally: printer.clear_progress() - return addresses, truncated, interrupted + return outcome def _read_values( @@ -178,20 +211,26 @@ def _report( session: Session, state: ScanState, elapsed: float, + outcome: ScanOutcome, *, - interrupted: bool = False, limit: Optional[int] = None, ) -> None: - """Print the outcome of a scan: a preview table plus the count.""" + """Print the outcome of a scan: any caveats, then a preview table.""" printer = session.printer - if interrupted: + if outcome.interrupted: printer.note("Interrupted — showing what had been found so far.") if state.truncated: printer.note( f"Stopped at the max_results cap ({session.option('max_results')}). " "Narrow the scan, or raise it with 'set max_results N'." ) + if outcome.skipped: + printer.note( + f"Skipped {outcome.skipped} batch(es) of regions the target would " + f"not let us read — the last failure was: {outcome.last_error}. " + "The rest of the address space was scanned normally." + ) _print_results(session, state, limit=limit, elapsed=elapsed) @@ -371,12 +410,17 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: ) with Timer() as timer: - addresses, truncated, interrupted = _run_scan(session, search) + outcome = _run_scan(session, search) state = _store( - session, value_type, width, addresses, description, truncated=truncated + session, + value_type, + width, + outcome.addresses, + description, + truncated=outcome.truncated, ) - _report(session, state, timer.elapsed, interrupted=interrupted) + _report(session, state, timer.elapsed, outcome) def _next_parser() -> CommandParser: @@ -573,17 +617,17 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: value_type = valuetypes.resolve("bytes") with Timer() as timer: - addresses, truncated, interrupted = _run_scan(session, search, label="AOB scan") + outcome = _run_scan(session, search, label="AOB scan") state = _store( session, value_type, width, - addresses, + outcome.addresses, f"aob {options.pattern}", - truncated=truncated, + truncated=outcome.truncated, ) - _report(session, state, timer.elapsed, interrupted=interrupted) + _report(session, state, timer.elapsed, outcome) def _regex_parser() -> CommandParser: @@ -649,19 +693,17 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: value_type = valuetypes.resolve("string") with Timer() as timer: - addresses, truncated, interrupted = _run_scan( - session, search, label="Regex scan" - ) + outcome = _run_scan(session, search, label="Regex scan") state = _store( session, value_type, options.length, - addresses, + outcome.addresses, f"regex {options.pattern}", - truncated=truncated, + truncated=outcome.truncated, ) - _report(session, state, timer.elapsed, interrupted=interrupted) + _report(session, state, timer.elapsed, outcome) def _results_parser() -> CommandParser: diff --git a/peekmem/dependencies.py b/peekmem/dependencies.py new file mode 100644 index 0000000..5939f20 --- /dev/null +++ b/peekmem/dependencies.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- + +""" +Checking that the PyMemoryEditor underneath is new enough. + +``pyproject.toml`` declares the floor, and pip enforces it on a normal +install — but not for anyone running Peekmem out of a source tree, or in an +environment where an older PyMemoryEditor was already present. Those setups +used to fail much later and much more cryptically: an older backend aborts a +whole macOS scan on the first file-backed page whose pager declines to read, +so ``scan string utf-8`` came back as +``mach_vm_read_overwrite failed: (os/kern) memory error (kr=10)`` with nothing +pointing at the real cause. + +One version comparison at startup turns that into a sentence naming the +problem and the command that fixes it. +""" + +from typing import Optional, Tuple + +import PyMemoryEditor + +#: The oldest PyMemoryEditor Peekmem supports. Keep in step with the floor in +#: pyproject.toml — the two say the same thing to different audiences. +REQUIRED_VERSION: Tuple[int, ...] = (2, 2, 0) + + +def parse_version(text: str) -> Tuple[int, ...]: + """Parse the leading numeric components of a version string. + + Stops at the first component that is not a plain number, so a development + or pre-release suffix (``2.2.0rc1``, ``2.3.0.dev0``) compares as the + release it is heading for rather than crashing the check. + """ + parts = [] + for piece in text.split("."): + digits = "" + for character in piece: + if not character.isdigit(): + break + digits += character + if not digits: + break + parts.append(int(digits)) + return tuple(parts) + + +def check() -> Optional[str]: + """Return an explanation when PyMemoryEditor is too old, else ``None``.""" + installed = getattr(PyMemoryEditor, "__version__", "") + parsed = parse_version(installed) + + # An unparseable version is not evidence of an old one — a fork or a + # locally patched build should not be blocked over its version string. + if not parsed or parsed >= REQUIRED_VERSION: + return None + + required = ".".join(str(part) for part in REQUIRED_VERSION) + return ( + f"Peekmem needs PyMemoryEditor {required} or newer, but " + f"{installed} is installed. Older versions abort a whole scan on the " + "first page they cannot read, among other differences.\n" + f'Upgrade with: pip install -U "PyMemoryEditor>={required}"' + ) + + +__all__ = ("REQUIRED_VERSION", "check", "parse_version") diff --git a/tests/test_cli.py b/tests/test_cli.py index 8a25ba9..420f43a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -81,6 +81,17 @@ def test_limit_flag_reaches_the_session(): assert "Showing 1 of" in out +def test_an_outdated_pymemoryeditor_stops_the_run(monkeypatch): + """The check must land before any command touches a process.""" + from peekmem import dependencies + + monkeypatch.setattr(dependencies.PyMemoryEditor, "__version__", "2.1.0") + status, out, err = run(["-e", "version"]) + assert status == 2 + assert "PyMemoryEditor 2.2.0 or newer" in err + assert out == "" + + def test_version_flag(): parser = build_parser() with pytest.raises(SystemExit) as exit_info: diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..d349675 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- + +"""The PyMemoryEditor version floor.""" + +import pytest + +from peekmem import dependencies + + +@pytest.mark.parametrize( + "text,expected", + [ + ("2.2.0", (2, 2, 0)), + ("2.10.3", (2, 10, 3)), + ("2.2.0rc1", (2, 2, 0)), + ("2.3.0.dev0", (2, 3, 0)), + ("2.2", (2, 2)), + ("", ()), + ("unknown", ()), + ], +) +def test_parse_version(text, expected): + assert dependencies.parse_version(text) == expected + + +def test_a_new_enough_version_passes(monkeypatch): + monkeypatch.setattr(dependencies.PyMemoryEditor, "__version__", "2.2.0") + assert dependencies.check() is None + monkeypatch.setattr(dependencies.PyMemoryEditor, "__version__", "3.0.0") + assert dependencies.check() is None + + +def test_an_old_version_is_reported_with_the_fix(monkeypatch): + monkeypatch.setattr(dependencies.PyMemoryEditor, "__version__", "2.1.0") + message = dependencies.check() + assert message is not None + assert "2.1.0 is installed" in message + assert 'pip install -U "PyMemoryEditor>=2.2.0"' in message + + +def test_an_unparseable_version_is_not_blocked(monkeypatch): + """A fork or a locally patched build should not be refused over a string.""" + monkeypatch.setattr(dependencies.PyMemoryEditor, "__version__", "custom-build") + assert dependencies.check() is None diff --git a/tests/test_scanning.py b/tests/test_scanning.py new file mode 100644 index 0000000..7558ee0 --- /dev/null +++ b/tests/test_scanning.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- + +"""The scan runner: batching, caps, interruption and unreadable regions. + +These exercise ``_run_scan`` directly with a stand-in search callable, so the +partial-result rules are covered without attaching to a process. +""" + +import pytest + +from PyMemoryEditor import MemoryRegion + +from peekmem.commands.scan_commands import _BATCH_BYTES, _batch_regions, _run_scan +from peekmem.session import Session + + +def make_regions(count: int, size: int = _BATCH_BYTES): + return [ + MemoryRegion(address=(index + 1) * size, size=size, is_readable=True) + for index in range(count) + ] + + +@pytest.fixture +def scannable(session: Session, monkeypatch): + """A session whose scan regions are three separate batches.""" + regions = make_regions(3) + monkeypatch.setattr(session, "scan_regions", lambda **kwargs: regions) + monkeypatch.setattr(session, "regions", lambda **kwargs: regions) + return session + + +def test_regions_are_batched_by_byte_budget(): + batches = list(_batch_regions(make_regions(3))) + assert len(batches) == 3 + small = list(_batch_regions(make_regions(4, size=16), budget=64)) + assert [len(batch) for batch, _ in small] == [4] + + +def test_every_batch_is_searched(scannable): + seen = [] + + def search(batch): + seen.append(batch[0].address) + return iter(()) + + outcome = _run_scan(scannable, search) + assert len(seen) == 3 + assert outcome.addresses == [] + assert outcome.skipped == 0 + + +def test_an_unreadable_batch_is_skipped_not_fatal(scannable): + """One page the target will not hand over must not lose the whole scan. + + This is the macOS 'mach_vm_read_overwrite failed: (os/kern) memory error' + case: a file-backed page whose pager declines to produce data, sitting in + the middle of an address space that is otherwise perfectly readable. + """ + calls = [] + + def search(batch): + calls.append(batch) + if len(calls) == 2: + raise OSError("mach_vm_read_overwrite failed: (os/kern) memory error") + yield batch[0].address + 8 + + outcome = _run_scan(scannable, search) + + assert len(calls) == 3, "the scan must carry on past the failing batch" + assert outcome.skipped == 1 + assert outcome.last_error is not None + assert len(outcome.addresses) == 2, "the readable batches still contribute" + + +def test_results_found_before_a_failure_are_kept(scannable): + def search(batch): + yield batch[0].address + raise OSError("page vanished mid-batch") + + outcome = _run_scan(scannable, search) + assert len(outcome.addresses) == 3 + assert outcome.skipped == 3 + + +def test_interrupting_keeps_what_was_found(scannable): + calls = [] + + def search(batch): + calls.append(batch) + if len(calls) == 3: + raise KeyboardInterrupt + yield batch[0].address + + outcome = _run_scan(scannable, search) + assert outcome.interrupted is True + assert len(outcome.addresses) == 2 + + +def test_the_max_results_cap_stops_the_scan(scannable): + scannable.set_option("max_results", "2") + calls = [] + + def search(batch): + calls.append(batch) + yield batch[0].address + yield batch[0].address + 4 + yield batch[0].address + 8 + + outcome = _run_scan(scannable, search) + assert outcome.truncated is True + assert len(outcome.addresses) == 2 + assert len(calls) == 1, "the cap must stop the scan, not just trim the output" + + +def test_a_cap_of_zero_means_no_cap(scannable): + scannable.set_option("max_results", "0") + + def search(batch): + yield batch[0].address + + outcome = _run_scan(scannable, search) + assert outcome.truncated is False + assert len(outcome.addresses) == 3 From e590c74934f9f00cb27c6a446a31bd0227fb660c Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 01:02:39 -0300 Subject: [PATCH 05/82] feat(shell): quit on Ctrl+C at the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+C at the prompt abandoned the line and carried on, leaving Ctrl+D or 'exit' as the only ways out — which is the Python REPL's habit, not the mysql client's, and not what the keystroke is expected to do. It now ends the session, exiting 130: the conventional "terminated by SIGINT" status, as against 0 for 'exit' and Ctrl+D. Ctrl+C *during* a command keeps its existing meaning — abandon the command, return to the prompt, keep whatever a scan had already found. Conflating the two would mean the keystroke that stops a four-minute scan also throws away its results and the shell holding them. So interrupting costs one keystroke and leaving costs two. Verified against a real SIGINT delivered to the process under a pty, in both states. The banner, 'help', 'help exit' and the README all say so now. --- README.md | 6 +++- peekmem/commands/session_commands.py | 11 ++++-- peekmem/shell.py | 18 +++++++--- tests/test_shell.py | 54 ++++++++++++++++++++++++---- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c0c7d95..f6166c1 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ pip install "peekmem[speed]" ```console $ peekmem Welcome to Peekmem 0.1.0, a terminal client for PyMemoryEditor 2.2.0. -Commands end with a newline. Type 'help' for the command list, 'help scanning' for a walkthrough, 'exit' to quit. +Commands end with a newline. Type 'help' for the command list, 'help scanning' for a walkthrough, 'exit' or Ctrl+C to quit. peekmem> ps game +-------+----------+ @@ -176,6 +176,10 @@ Highlights: - **Progress you can trust.** Long scans report a percentage that advances whether or not anything is being found, and Ctrl+C stops a scan while keeping what it already found. +- **Ctrl+C means the obvious thing.** During a command it abandons that command + and returns to the prompt; at the prompt it quits. So stopping a scan costs + one keystroke and leaving costs two, and neither one loses your results by + surprise. Ctrl+D and `exit` quit too. ### Addresses are expressions diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index c08ca22..626fcdc 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -124,7 +124,7 @@ def _print_overview(session: Session) -> None: "description, including every argument and flag it accepts." ) printer.write("Topics: 'help types', 'help address', 'help scanning'.") - printer.write("End the session with 'exit', Ctrl+D, or \\q.") + printer.write("End the session with 'exit', Ctrl+C, Ctrl+D, or \\q.") printer.write() @@ -381,7 +381,14 @@ def _exit_parser() -> CommandParser: aliases=("quit", "\\q"), details=( "Takes no arguments.\n\n" - "Detaches from the target first. Ctrl+D does the same thing." + "Detaches from the target first. Ctrl+C and Ctrl+D at the prompt do " + "the same thing, except for the status they exit with: 130 for " + "Ctrl+C, the conventional 'interrupted' value, and 0 for the other " + "two.\n\n" + "Ctrl+C means something different *during* a command — it abandons " + "that command and returns to the prompt, keeping whatever a scan had " + "already found. So interrupting a scan costs one keystroke and " + "leaving costs two." ), ) def cmd_exit(session: Session, args: List[str]) -> None: diff --git a/peekmem/shell.py b/peekmem/shell.py index b89c927..e2dbacf 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -159,11 +159,16 @@ def banner(self) -> str: f"Welcome to Peekmem {__version__}, a terminal client for " f"PyMemoryEditor {PyMemoryEditor.__version__}.\n" "Commands end with a newline. Type 'help' for the command list, " - "'help scanning' for a walkthrough, 'exit' to quit.\n" + "'help scanning' for a walkthrough, 'exit' or Ctrl+C to quit.\n" ) def interact(self, *, banner: bool = True) -> int: - """Run the shell until ``exit``, Ctrl+D, or the input runs out.""" + """Run the shell until ``exit``, Ctrl+C, Ctrl+D, or the input runs out. + + Ctrl+C returns 130 — the conventional "terminated by SIGINT" status — + while ``exit`` and Ctrl+D return 0. All three are ordinary ways to + leave; only the status distinguishes them. + """ if banner: self.printer.write(self.banner()) @@ -175,9 +180,14 @@ def interact(self, *, banner: bool = True) -> int: try: line = input(self.prompt()) except KeyboardInterrupt: - # Ctrl+C at the prompt abandons the line, as in mysql. + # Ctrl+C at the prompt quits, as it does in the mysql + # client. During a *command* it means something else — + # abandon that command and come back here (see run_line) — + # so interrupting a long scan still costs one keystroke + # and leaving costs two. self.printer.write("^C") - continue + status = 130 + break except EOFError: self.printer.write() break diff --git a/tests/test_shell.py b/tests/test_shell.py index 4b03ce1..6a5e50d 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -106,15 +106,57 @@ def test_interactive_loop_reads_until_eof(shell, capture, monkeypatch): assert "Bye" in capture.out -def test_ctrl_c_at_the_prompt_does_not_quit(shell, capture, monkeypatch): - answers = iter([KeyboardInterrupt, "exit"]) +def test_ctrl_c_at_the_prompt_quits(shell, capture, monkeypatch): + """As in the mysql client: an interrupt at the prompt ends the session.""" def fake_input(prompt=""): - value = next(answers) - if value is KeyboardInterrupt: - raise KeyboardInterrupt - return value + raise KeyboardInterrupt monkeypatch.setattr("builtins.input", fake_input) + assert shell.interact(banner=False) == 130 + assert "^C" in capture.out + assert "Bye" in capture.out + + +def test_ctrl_d_quits_with_a_zero_status(shell, capture, monkeypatch): + def fake_input(prompt=""): + raise EOFError + + monkeypatch.setattr("builtins.input", fake_input) + assert shell.interact(banner=False) == 0 + assert "Bye" in capture.out + + +def test_ctrl_c_during_a_command_returns_to_the_prompt(shell, capture, monkeypatch): + """Interrupting a long scan must not also end the session. + + One keystroke abandons the command; a second one, now at the prompt, is + what leaves. Losing the shell — and the scan results in it — on the + keystroke that stops a scan would make the results unreachable. + """ + from peekmem.commands import Command, lookup + + def interrupted(session, args): + raise KeyboardInterrupt + + real_lookup = lookup + + def fake_lookup(name): + entry = real_lookup(name) + if entry.name == "version": + return Command( + name=entry.name, + handler=interrupted, + summary=entry.summary, + usage=entry.usage, + group=entry.group, + ) + return entry + + monkeypatch.setattr("peekmem.shell.lookup", fake_lookup) + + lines = iter(["version", "exit"]) + monkeypatch.setattr("builtins.input", lambda prompt="": next(lines)) + assert shell.interact(banner=False) == 0 assert "^C" in capture.out From 958f0795c76376c85b4ca58c259b528f83427043 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 02:53:09 -0300 Subject: [PATCH 06/82] refactor(commands): organise the 34 commands into a namespace hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One flat list of 34 words made related commands impossible to find: 'ptrdiff' and 'paths' sat between 'ps' and 'read' with nothing saying they belong together. Commands are now colon-separated paths — memory:read, scan:value, scan:results:keep, pointer:paths:save — so a name says what it acts on and siblings group themselves. Nothing costs more to type. Every command keeps a plain-word alias, and the old name is that alias: 'read', 'scan', 'keep', 'ptrsave' all still work, and a test enforces that every command has one. The help lists both spellings in aligned columns, so the alias is discoverable rather than folklore. A command's group in the help now derives from the first segment of its name instead of a separate `group=` argument, which removes a field that could disagree with the naming. Typing a namespace alone — 'memory', 'pointer:' — lists what is in it, and a parent's help gains a Subcommands section. 'memory read 0x10' is answered by naming the fix ("the command is spelled 'memory:read', with a colon") rather than a bare "unknown command". Tab completion offers namespaces before it offers forty commands, and the two namespaces shadowed by an alias ('scan', 'pointer') say so at the end of their help. Six new invariants in tests: every command is namespaced, in a declared namespace, with a plain-word alias that resolves back to it; children share their parent's prefix and namespace; no namespace is empty; and the help sections come out in the declared order. --- CONTRIBUTING.md | 33 ++++-- README.md | 16 +-- peekmem/cli.py | 14 ++- peekmem/commands/__init__.py | 87 ++++++++++++++-- peekmem/commands/memory_commands.py | 94 ++++++++--------- peekmem/commands/pointer_commands.py | 83 +++++++-------- peekmem/commands/process_commands.py | 46 ++++----- peekmem/commands/scan_commands.py | 84 +++++++-------- peekmem/commands/session_commands.py | 149 +++++++++++++++++++++------ peekmem/shell.py | 57 +++++++++- tests/test_commands.py | 86 +++++++++++++++- tests/test_shell.py | 3 +- 12 files changed, 521 insertions(+), 231 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 595f5f5..015299d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,29 +81,40 @@ Two rules keep the shape: ## Adding a command -1. Pick the module in `peekmem/commands/` that matches the group. -2. Register the handler: +1. Pick the module in `peekmem/commands/` that matches the namespace. +2. Register the handler. The name is a colon-separated path whose first + segment is one of the namespaces in `NAMESPACES`; the group in `help` + follows from it, so there is nothing to keep in step. Give it a plain-word + alias too — that is what people type, and a test enforces that every + command has one: ```python + def _mycommand_parser() -> CommandParser: + parser = CommandParser("memory:mycommand") + parser.add_argument("address", help="what to act on") + return parser + + @command( - "mycommand", + "memory:mycommand", + parser=_mycommand_parser, summary="One line, sentence case, ending in a period.", - usage="mycommand
[--flag]", - group="Memory", - aliases=("mycmd",), - details="The long help, printed by 'help mycommand'.", + usage="memory:mycommand
[--flag]", + aliases=("mycommand",), + details="The long help, printed by 'help memory:mycommand'.", examples=("mycommand 0x1000",), ) def cmd_mycommand(session: Session, args: List[str]) -> None: - parser = CommandParser("mycommand") - parser.add_argument("address") - options = parser.parse_args(args) + options = _mycommand_parser().parse_args(args) - process = session.require_process("mycommand") + process = session.require_process("memory:mycommand") address = parse_address(options.address, session) ... ``` + A name with a third segment (`scan:results:keep`) is fine and shows up as a + **Subcommands** section under its parent's help. + 3. Use `CommandParser`, not a bare `ArgumentParser`: it raises instead of calling `sys.exit`, which would kill the shell on a typo. 4. Take addresses through `parse_address` so your command speaks the same diff --git a/README.md b/README.md index f6166c1..683742f 100644 --- a/README.md +++ b/README.md @@ -127,13 +127,17 @@ non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave ## What it can do -| Group | Commands | +Commands are namespaced, and every one has a short alias — `memory:read` and +`read` are the same command, so the hierarchy costs nothing at the keyboard. +Type a namespace alone (`memory`, `pointer:`) to list what is in it. + +| Namespace | Commands (short alias) | | --- | --- | -| **Process** | `ps` · `open` · `close` · `status` · `info` | -| **Memory** | `regions` · `modules` · `threads` · `read` · `write` · `dump` · `watch` · `alloc` · `free` | -| **Scanning** | `scan` · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | -| **Pointers** | `deref` · `pointer` · `ptrscan` · `paths` · `ptrsave` · `ptrload` · `ptrrescan` · `ptrdiff` | -| **Session** | `help` · `set` · `source` · `version` · `exit` | +| **`process:`** | `list` (ps) · `open` · `close` · `info` | +| **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | +| **`scan:`** | `value` (scan) · `next` · `aob` · `regex` · `results` · `results:keep` (keep) · `results:drop` (drop) · `results:clear` (reset) | +| **`pointer:`** | `deref` · `read` (pointer) · `scan` (ptrscan) · `rescan` (ptrrescan) · `paths` · `paths:save` (ptrsave) · `paths:load` (ptrload) · `paths:diff` (ptrdiff) | +| **`session:`** | `help` · `set` · `source` · `status` · `version` · `exit` | `help ` — or ` --help` — documents each one in full: every argument, every flag, and examples. That list is generated from the command's diff --git a/peekmem/cli.py b/peekmem/cli.py index 45478a7..bcf6602 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -23,7 +23,7 @@ import PyMemoryEditor from . import __version__, dependencies -from .commands import GROUPS, all_commands +from .commands import NAMESPACES, all_commands from .errors import CommandError, PeekmemError from .output import Printer from .session import Session @@ -40,13 +40,17 @@ def _format_commands() -> str: commands = all_commands() width = max(len(entry.name) for entry in commands) lines: List[str] = [_EPILOG_INTRO, ""] - for group in GROUPS: - in_group = [entry for entry in commands if entry.group == group] + for namespace, title in NAMESPACES: + in_group = [entry for entry in commands if entry.namespace == namespace] if not in_group: continue - lines.append(f" {group}") + lines.append(f" {title}") for entry in in_group: - lines.append(f" {entry.name.ljust(width)} {entry.summary}") + # Full name, then the alias people actually type, in aligned + # columns — the same shape 'help' uses inside the shell. + lines.append( + f" {entry.name.ljust(width)} {entry.short.ljust(9)} {entry.summary}" + ) lines.append("") return "\n".join(lines) diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index a10719d..43e923f 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -21,8 +21,22 @@ from ..errors import CommandError -#: Groups in the order ``help`` prints them. -GROUPS: Tuple[str, ...] = ("Process", "Memory", "Scanning", "Pointers", "Session") +#: Namespaces in the order ``help`` prints them, with the heading each one +#: gets. A command's namespace is the part of its name before the first colon, +#: so the grouping cannot drift from the naming — there is nothing to keep in +#: step. +NAMESPACES: Tuple[Tuple[str, str], ...] = ( + ("process", "Process"), + ("memory", "Memory"), + ("scan", "Scanning"), + ("pointer", "Pointers"), + ("session", "Session"), +) + +_NAMESPACE_TITLES: Dict[str, str] = dict(NAMESPACES) +_NAMESPACE_ORDER: Dict[str, int] = { + name: index for index, (name, _) in enumerate(NAMESPACES) +} Handler = Callable[..., None] @@ -108,18 +122,48 @@ def _value_placeholder(action: argparse.Action) -> str: @dataclass(frozen=True) class Command: - """One registered command.""" + """One registered command. + + The ``name`` is a colon-separated path — ``memory:read``, + ``scan:results:keep`` — so related commands sort and group together and a + name says what it acts on. The short spellings people actually type + (``read``, ``keep``) are registered as aliases, which is why the hierarchy + costs nothing at the keyboard. + """ name: str handler: Handler summary: str usage: str - group: str parser: Optional[ParserFactory] = None aliases: Tuple[str, ...] = () details: str = "" examples: Tuple[str, ...] = field(default=()) + @property + def namespace(self) -> str: + """The first segment of the name — the group this command belongs to.""" + return self.name.split(":", 1)[0] + + @property + def group(self) -> str: + """The heading ``help`` files this command under.""" + return _NAMESPACE_TITLES.get(self.namespace, self.namespace.capitalize()) + + @property + def short(self) -> str: + """The alias worth advertising — the first plain-word one. + + First, not shortest: the alias list is written most-natural-first, and + the shortest is often the cryptic one (``x`` for ``memory:dump``, + ``ptr`` for ``pointer:read``). Backslash aliases like ``\\q`` are + skipped; they are shortcuts, not names. + """ + for alias in self.aliases: + if alias.isalnum(): + return alias + return "" + def arguments(self) -> List[argparse.Action]: """Every argument this command accepts, in declaration order.""" return list(self.parser().arguments) if self.parser is not None else [] @@ -134,7 +178,6 @@ def command( *, summary: str, usage: str, - group: str, parser: Optional[ParserFactory] = None, aliases: Sequence[str] = (), details: str = "", @@ -154,15 +197,14 @@ def command( def decorator(handler: Handler) -> Handler: if name in _COMMANDS or name in _ALIASES: raise RuntimeError(f"Duplicate command name: {name}") - if group not in GROUPS: - raise RuntimeError(f"Unknown command group: {group}") + if name.split(":", 1)[0] not in _NAMESPACE_TITLES: + raise RuntimeError(f"Unknown namespace in command name: {name}") entry = Command( name=name, handler=handler, summary=summary, usage=usage, - group=group, parser=parser, aliases=tuple(aliases), details=details, @@ -194,12 +236,33 @@ def lookup(name: str) -> Command: def all_commands() -> List[Command]: - """Every registered command, sorted by group then name.""" + """Every registered command, in namespace order then alphabetically.""" return sorted( - _COMMANDS.values(), key=lambda entry: (GROUPS.index(entry.group), entry.name) + _COMMANDS.values(), + key=lambda entry: (_NAMESPACE_ORDER.get(entry.namespace, 99), entry.name), ) +def children(prefix: str) -> List[Command]: + """Commands sitting under ``prefix`` in the hierarchy. + + Works for a namespace (``memory`` yields every ``memory:*``) and for a + command that has commands beneath it (``scan:results`` yields + ``scan:results:keep`` and its siblings), which is the same question in both + cases: what can follow this word? + """ + head = prefix.strip().lower().rstrip(":") + if not head: + return [] + return [entry for entry in all_commands() if entry.name.startswith(head + ":")] + + +def namespaces() -> List[str]: + """Every namespace that has at least one command registered in it.""" + known = {entry.namespace for entry in _COMMANDS.values()} + return [name for name, _ in NAMESPACES if name in known] + + def command_words() -> List[str]: """Every accepted command word, for tab completion.""" return sorted(list(_COMMANDS) + list(_ALIASES)) @@ -225,11 +288,13 @@ def option_words(name: str) -> List[str]: __all__ = ( "Command", "CommandParser", - "GROUPS", + "NAMESPACES", "all_commands", + "children", "command", "command_words", "describe_action", "lookup", + "namespaces", "option_words", ) diff --git a/peekmem/commands/memory_commands.py b/peekmem/commands/memory_commands.py index 0bf0556..20133f9 100644 --- a/peekmem/commands/memory_commands.py +++ b/peekmem/commands/memory_commands.py @@ -49,7 +49,7 @@ def _permissions(region) -> str: def _regions_parser() -> CommandParser: - parser = CommandParser("regions") + parser = CommandParser("memory:regions") parser.add_argument( "--writable", action="store_true", help="keep only writable regions" ) @@ -84,12 +84,11 @@ def _regions_parser() -> CommandParser: @command( - "regions", + "memory:regions", parser=_regions_parser, summary="List the target's mapped memory regions.", - usage="regions [--writable] [--executable] [--path TEXT] [--at ADDRESS] [--limit N]", - group="Memory", - aliases=("maps",), + usage="memory:regions [--writable] [--executable] [--path TEXT] [--at ADDRESS] [--limit N]", + aliases=("regions", "maps"), details=( "The memory map is re-read on every call, so it reflects allocations " "the target made since the last look.\n\n" @@ -101,7 +100,7 @@ def _regions_parser() -> CommandParser: def cmd_regions(session: Session, args: List[str]) -> None: options = _regions_parser().parse_args(args) - process = session.require_process("regions") + process = session.require_process("memory:regions") with Timer() as timer: regions = session.regions(refresh=True) @@ -145,7 +144,7 @@ def cmd_regions(session: Session, args: List[str]) -> None: def _modules_parser() -> CommandParser: - parser = CommandParser("modules") + parser = CommandParser("memory:modules") parser.add_argument( "pattern", nargs="?", @@ -163,11 +162,11 @@ def _modules_parser() -> CommandParser: @command( - "modules", + "memory:modules", parser=_modules_parser, summary="List the modules loaded in the target.", - usage="modules [pattern] [--limit N]", - group="Memory", + usage="memory:modules [pattern] [--limit N]", + aliases=("modules",), details=( "A module is the main executable or a shared library (.dll / .so / " ".dylib). Its BASE moves on every launch under ASLR, which is why an " @@ -180,7 +179,7 @@ def _modules_parser() -> CommandParser: def cmd_modules(session: Session, args: List[str]) -> None: options = _modules_parser().parse_args(args) - process = session.require_process("modules") + process = session.require_process("memory:modules") with Timer() as timer: modules = list(process.get_modules()) @@ -217,7 +216,7 @@ def cmd_modules(session: Session, args: List[str]) -> None: def _threads_parser() -> CommandParser: - parser = CommandParser("threads") + parser = CommandParser("memory:threads") parser.add_argument( "--limit", type=int, @@ -229,11 +228,11 @@ def _threads_parser() -> CommandParser: @command( - "threads", + "memory:threads", parser=_threads_parser, summary="List the target's threads.", - usage="threads [--limit N]", - group="Memory", + usage="memory:threads [--limit N]", + aliases=("threads",), details=( "STATE and PRIORITY are filled in only where the platform exposes them " "cheaply (Linux does; Windows and macOS leave them empty). The meaning " @@ -244,7 +243,7 @@ def _threads_parser() -> CommandParser: def cmd_threads(session: Session, args: List[str]) -> None: options = _threads_parser().parse_args(args) - process = session.require_process("threads") + process = session.require_process("memory:threads") with Timer() as timer: threads = list(process.get_threads()) @@ -269,7 +268,7 @@ def cmd_threads(session: Session, args: List[str]) -> None: def _read_parser() -> CommandParser: - parser = CommandParser("read") + parser = CommandParser("memory:read") parser.add_argument("address", help=_ADDRESS_HELP) parser.add_argument("type", nargs="?", default=None, help=_TYPE_HELP) parser.add_argument("length", nargs="?", type=int, default=None, help=_LENGTH_HELP) @@ -287,12 +286,11 @@ def _read_parser() -> CommandParser: @command( - "read", + "memory:read", parser=_read_parser, summary="Read a typed value from an address.", - usage="read
[type] [length] [--count N] [--hex]", - group="Memory", - aliases=("peek",), + usage="memory:read
[type] [length] [--count N] [--hex]", + aliases=("read", "peek"), details=( "The type defaults to int32. 'string' and 'bytes' need a length in " "bytes; the fixed-width types ignore one.\n\n" @@ -310,7 +308,7 @@ def _read_parser() -> CommandParser: def cmd_read(session: Session, args: List[str]) -> None: options = _read_parser().parse_args(args) - process = session.require_process("read") + process = session.require_process("memory:read") value_type = _resolve_type(options.type) width = value_type.read_width(options.length) @@ -347,7 +345,7 @@ def cmd_read(session: Session, args: List[str]) -> None: def _write_parser() -> CommandParser: - parser = CommandParser("write") + parser = CommandParser("memory:write") parser.add_argument("address", help=_ADDRESS_HELP) parser.add_argument("type", help="value type (see 'help types')") parser.add_argument( @@ -373,12 +371,11 @@ def _write_parser() -> CommandParser: @command( - "write", + "memory:write", parser=_write_parser, summary="Write a typed value to an address.", - usage="write
[--length N] [--null-terminated]", - group="Memory", - aliases=("poke",), + usage="memory:write
[--length N] [--null-terminated]", + aliases=("write", "poke"), details=( "There is no confirmation and no undo. Writing into a live process can " "crash it — read the address first if you are not sure of it." @@ -393,7 +390,7 @@ def _write_parser() -> CommandParser: def cmd_write(session: Session, args: List[str]) -> None: options = _write_parser().parse_args(args) - process = session.require_process("write") + process = session.require_process("memory:write") value_type = valuetypes.resolve(options.type) value = value_type.parse(options.value) width = value_type.width_for(value, options.length) @@ -421,7 +418,7 @@ def cmd_write(session: Session, args: List[str]) -> None: def _dump_parser() -> CommandParser: - parser = CommandParser("dump") + parser = CommandParser("memory:dump") parser.add_argument("address", help=_ADDRESS_HELP) parser.add_argument( "length", @@ -440,12 +437,11 @@ def _dump_parser() -> CommandParser: @command( - "dump", + "memory:dump", parser=_dump_parser, summary="Hex-dump a range of memory.", - usage="dump
[length] [--width N]", - group="Memory", - aliases=("hexdump", "x"), + usage="memory:dump
[length] [--width N]", + aliases=("dump", "hexdump", "x"), details=( "Prints the classic three-column layout: absolute address, hex bytes, " "printable ASCII.\n\n" @@ -457,7 +453,7 @@ def _dump_parser() -> CommandParser: def cmd_dump(session: Session, args: List[str]) -> None: options = _dump_parser().parse_args(args) - process = session.require_process("dump") + process = session.require_process("memory:dump") address = parse_address(options.address, session) length = parse_int(str(options.length), "length") width = options.width if options.width else int(session.option("dump_width")) @@ -482,7 +478,7 @@ def cmd_dump(session: Session, args: List[str]) -> None: def _watch_parser() -> CommandParser: - parser = CommandParser("watch") + parser = CommandParser("memory:watch") parser.add_argument("address", help=_ADDRESS_HELP) parser.add_argument("type", nargs="?", default=None, help=_TYPE_HELP) parser.add_argument("length", nargs="?", type=int, default=None, help=_LENGTH_HELP) @@ -509,11 +505,11 @@ def _watch_parser() -> CommandParser: @command( - "watch", + "memory:watch", parser=_watch_parser, summary="Poll an address and print it as it changes.", - usage="watch
[type] [length] [--interval S] [--count N] [--all]", - group="Memory", + usage="memory:watch
[type] [length] [--interval S] [--count N] [--all]", + aliases=("watch",), details=( "Reads the address on a timer and prints a line per sample. By default " "only samples whose value differs from the previous one are printed, " @@ -530,7 +526,7 @@ def _watch_parser() -> CommandParser: def cmd_watch(session: Session, args: List[str]) -> None: options = _watch_parser().parse_args(args) - process = session.require_process("watch") + process = session.require_process("memory:watch") value_type = _resolve_type(options.type) width = value_type.read_width(options.length) address = parse_address(options.address, session) @@ -584,7 +580,7 @@ def cmd_watch(session: Session, args: List[str]) -> None: def _alloc_parser() -> CommandParser: - parser = CommandParser("alloc") + parser = CommandParser("memory:alloc") parser.add_argument( "size", help="number of bytes to allocate; hex accepted (e.g. 0x1000)" ) @@ -599,11 +595,11 @@ def _alloc_parser() -> CommandParser: @command( - "alloc", + "memory:alloc", parser=_alloc_parser, summary="Allocate memory inside the target.", - usage="alloc [--permission N]", - group="Memory", + usage="memory:alloc [--permission N]", + aliases=("alloc",), details=( "Reserves and commits SIZE bytes in the target's address space and " "prints the base address. The region stays until 'free' releases it.\n\n" @@ -614,7 +610,7 @@ def _alloc_parser() -> CommandParser: def cmd_alloc(session: Session, args: List[str]) -> None: options = _alloc_parser().parse_args(args) - process = session.require_process("alloc") + process = session.require_process("memory:alloc") size = parse_int(options.size, "size") if size < 1: raise CommandError("Size must be at least 1 byte.") @@ -646,7 +642,7 @@ def cmd_alloc(session: Session, args: List[str]) -> None: def _free_parser() -> CommandParser: - parser = CommandParser("free") + parser = CommandParser("memory:free") parser.add_argument("address", help="base address returned by 'alloc'") parser.add_argument( "size", @@ -659,18 +655,18 @@ def _free_parser() -> CommandParser: @command( - "free", + "memory:free", parser=_free_parser, summary="Release memory allocated with 'alloc'.", - usage="free
[size]", - group="Memory", + usage="memory:free
[size]", + aliases=("free",), details="Not available on Linux, for the same reason as 'alloc'.", examples=("free 0x7ffee3a01000", "free 0x7ffee3a01000 4096"), ) def cmd_free(session: Session, args: List[str]) -> None: options = _free_parser().parse_args(args) - process = session.require_process("free") + process = session.require_process("memory:free") address = parse_address(options.address, session) size = parse_int(options.size, "size") if options.size is not None else 0 diff --git a/peekmem/commands/pointer_commands.py b/peekmem/commands/pointer_commands.py index 07fa2ee..c9d890b 100644 --- a/peekmem/commands/pointer_commands.py +++ b/peekmem/commands/pointer_commands.py @@ -82,19 +82,18 @@ def _print_paths( def _deref_parser() -> CommandParser: - parser = CommandParser("deref") + parser = CommandParser("pointer:deref") parser.add_argument("base", help=_BASE_HELP) parser.add_argument("offsets", nargs="*", help=_OFFSETS_HELP) return parser @command( - "deref", + "pointer:deref", parser=_deref_parser, summary="Walk a pointer chain and print the address it lands on.", - usage="deref [offset ...]", - group="Pointers", - aliases=("resolve",), + usage="pointer:deref [offset ...]", + aliases=("deref", "resolve"), details=( "Reads the pointer at BASE, adds the first offset, reads the pointer " "there, and so on; the last offset is added without a final read — the " @@ -106,7 +105,7 @@ def _deref_parser() -> CommandParser: def cmd_deref(session: Session, args: List[str]) -> None: options = _deref_parser().parse_args(args) - process = session.require_process("deref") + process = session.require_process("pointer:deref") base = parse_address(options.base, session) offsets = _parse_offsets(options.offsets) @@ -131,7 +130,7 @@ def cmd_deref(session: Session, args: List[str]) -> None: def _pointer_parser() -> CommandParser: - parser = CommandParser("pointer") + parser = CommandParser("pointer:read") parser.add_argument("base", help=_BASE_HELP) parser.add_argument("offsets", nargs="*", help=_OFFSETS_HELP) parser.add_argument( @@ -158,12 +157,11 @@ def _pointer_parser() -> CommandParser: @command( - "pointer", + "pointer:read", parser=_pointer_parser, summary="Read or write the value at the end of a pointer chain.", - usage="pointer [offset ...] [--type T] [--length N] [--write VALUE]", - group="Pointers", - aliases=("ptr",), + usage="pointer:read [offset ...] [--type T] [--length N] [--write VALUE]", + aliases=("pointer", "ptr"), details=( "The one-line form of 'deref' followed by 'read'.\n\n" "The chain is re-walked on every call, which is the point: it keeps " @@ -178,7 +176,7 @@ def _pointer_parser() -> CommandParser: def cmd_pointer(session: Session, args: List[str]) -> None: options = _pointer_parser().parse_args(args) - process = session.require_process("pointer") + process = session.require_process("pointer:read") value_type = ( valuetypes.DEFAULT_TYPE if options.value_type is None @@ -232,7 +230,7 @@ def cmd_pointer(session: Session, args: List[str]) -> None: def _ptrscan_parser() -> CommandParser: - parser = CommandParser("ptrscan") + parser = CommandParser("pointer:scan") parser.add_argument( "address", help="the address to find paths to, as an address expression — " @@ -275,12 +273,11 @@ def _ptrscan_parser() -> CommandParser: @command( - "ptrscan", + "pointer:scan", parser=_ptrscan_parser, summary="Find static pointer paths that reach an address.", - usage="ptrscan
[--depth N] [--max-offset N] [--max N] [--unaligned] [--all-regions]", - group="Pointers", - aliases=("pointerscan",), + usage="pointer:scan
[--depth N] [--max-offset N] [--max N] [--unaligned] [--all-regions]", + aliases=("ptrscan", "pointerscan"), details=( "Builds a map of every pointer in the target and walks it backwards " "from ADDRESS until it reaches a static base inside a module. The " @@ -297,7 +294,7 @@ def _ptrscan_parser() -> CommandParser: def cmd_ptrscan(session: Session, args: List[str]) -> None: options = _ptrscan_parser().parse_args(args) - process = session.require_process("ptrscan") + process = session.require_process("pointer:scan") target = parse_address(options.address, session) if options.depth < 1: @@ -346,7 +343,7 @@ def on_progress(fraction: float) -> None: def _paths_parser() -> CommandParser: - parser = CommandParser("paths") + parser = CommandParser("pointer:paths") parser.add_argument( "--limit", type=int, @@ -361,11 +358,11 @@ def _paths_parser() -> CommandParser: @command( - "paths", + "pointer:paths", parser=_paths_parser, summary="Show the pointer paths currently held.", - usage="paths [--limit N] [--all]", - group="Pointers", + usage="pointer:paths [--limit N] [--all]", + aliases=("paths",), details=( "Lists the paths from the last 'ptrscan', 'ptrload', 'ptrrescan' or " "'ptrdiff'. TARGET is where each one resolves right now, so a path " @@ -375,7 +372,7 @@ def _paths_parser() -> CommandParser: def cmd_paths(session: Session, args: List[str]) -> None: options = _paths_parser().parse_args(args) - session.require_process("paths") + session.require_process("pointer:paths") if not session.pointer_paths: raise CommandError('No pointer paths. Run "ptrscan
" first.') @@ -384,17 +381,17 @@ def cmd_paths(session: Session, args: List[str]) -> None: def _ptrsave_parser() -> CommandParser: - parser = CommandParser("ptrsave") + parser = CommandParser("pointer:paths:save") parser.add_argument("file", help="path of the JSON file to write") return parser @command( - "ptrsave", + "pointer:paths:save", parser=_ptrsave_parser, summary="Save the current pointer paths to a file.", - usage="ptrsave ", - group="Pointers", + usage="pointer:paths:save ", + aliases=("ptrsave",), details=( "Writes the paths as JSON, keeping the module name and module-relative " "offset of each base so the file survives ASLR and can be re-used " @@ -405,7 +402,7 @@ def _ptrsave_parser() -> CommandParser: def cmd_ptrsave(session: Session, args: List[str]) -> None: options = _ptrsave_parser().parse_args(args) - process = session.require_process("ptrsave") + process = session.require_process("pointer:paths:save") if not session.pointer_paths: raise CommandError("No pointer paths to save.") @@ -423,17 +420,17 @@ def cmd_ptrsave(session: Session, args: List[str]) -> None: def _ptrload_parser() -> CommandParser: - parser = CommandParser("ptrload") + parser = CommandParser("pointer:paths:load") parser.add_argument("file", help="path of a JSON file written by 'ptrsave'") return parser @command( - "ptrload", + "pointer:paths:load", parser=_ptrload_parser, summary="Load pointer paths from a file.", - usage="ptrload ", - group="Pointers", + usage="pointer:paths:load ", + aliases=("ptrload",), details=( "Replaces the paths currently held. Each base is rebased onto the " "module addresses of the *running* target, so a file saved before a " @@ -444,7 +441,7 @@ def _ptrload_parser() -> CommandParser: def cmd_ptrload(session: Session, args: List[str]) -> None: options = _ptrload_parser().parse_args(args) - process = session.require_process("ptrload") + process = session.require_process("pointer:paths:load") if not os.path.exists(options.file): raise CommandError(f"No such file: {options.file}") @@ -472,7 +469,7 @@ def cmd_ptrload(session: Session, args: List[str]) -> None: def _ptrrescan_parser() -> CommandParser: - parser = CommandParser("ptrrescan") + parser = CommandParser("pointer:rescan") parser.add_argument( "address", help="the address the surviving paths must reach, as an address " @@ -489,11 +486,11 @@ def _ptrrescan_parser() -> CommandParser: @command( - "ptrrescan", + "pointer:rescan", parser=_ptrrescan_parser, summary="Keep only the paths that still reach an address.", - usage="ptrrescan
[file]", - group="Pointers", + usage="pointer:rescan
[file]", + aliases=("ptrrescan",), details=( "The step that separates a real pointer path from a coincidence. " "Restart the target, find the value's new address, then rescan the " @@ -505,7 +502,7 @@ def _ptrrescan_parser() -> CommandParser: def cmd_ptrrescan(session: Session, args: List[str]) -> None: options = _ptrrescan_parser().parse_args(args) - process = session.require_process("ptrrescan") + process = session.require_process("pointer:rescan") target = parse_address(options.address, session) source: Any = options.file @@ -537,7 +534,7 @@ def cmd_ptrrescan(session: Session, args: List[str]) -> None: def _ptrdiff_parser() -> CommandParser: - parser = CommandParser("ptrdiff") + parser = CommandParser("pointer:paths:diff") parser.add_argument( "files", nargs="*", @@ -548,11 +545,11 @@ def _ptrdiff_parser() -> CommandParser: @command( - "ptrdiff", + "pointer:paths:diff", parser=_ptrdiff_parser, summary="Intersect pointer-path files from several runs.", - usage="ptrdiff [file ...]", - group="Pointers", + usage="pointer:paths:diff [file ...]", + aliases=("ptrdiff",), details=( "Keeps only the paths present in *every* file, compared by their " "portable recipe (module, module offset, offsets) rather than by " @@ -566,7 +563,7 @@ def _ptrdiff_parser() -> CommandParser: def cmd_ptrdiff(session: Session, args: List[str]) -> None: options = _ptrdiff_parser().parse_args(args) - process = session.require_process("ptrdiff") + process = session.require_process("pointer:paths:diff") if len(options.files) < 2: raise CommandError("ptrdiff needs at least two files.") for name in options.files: diff --git a/peekmem/commands/process_commands.py b/peekmem/commands/process_commands.py index 4e858fa..d637d47 100644 --- a/peekmem/commands/process_commands.py +++ b/peekmem/commands/process_commands.py @@ -15,7 +15,7 @@ def _ps_parser() -> CommandParser: - parser = CommandParser("ps") + parser = CommandParser("process:list") parser.add_argument( "pattern", nargs="?", @@ -44,12 +44,11 @@ def _ps_parser() -> CommandParser: @command( - "ps", + "process:list", parser=_ps_parser, summary="List the processes visible to you.", - usage="ps [pattern] [--pid-sort] [--case-sensitive] [--limit N]", - group="Process", - aliases=("processes", "list"), + usage="process:list [pattern] [--pid-sort] [--case-sensitive] [--limit N]", + aliases=("ps", "processes"), details=( "Only processes your user can see are listed. Run Peekmem elevated to " "see (and open) processes belonging to other users." @@ -82,7 +81,7 @@ def cmd_ps(session: Session, args: List[str]) -> None: def _open_parser() -> CommandParser: - parser = CommandParser("open") + parser = CommandParser("process:open") parser.add_argument( "target", nargs="?", @@ -129,12 +128,11 @@ def _open_parser() -> CommandParser: @command( - "open", + "process:open", parser=_open_parser, summary="Attach to a process by PID or name.", - usage="open [-i] [--partial] [--strict-bitness]", - group="Process", - aliases=("attach", "use"), + usage="process:open [-i] [--partial] [--strict-bitness]", + aliases=("open", "attach", "use"), details=( "An all-digits target is taken as a PID, anything else as a process " "name; force either reading with --pid or --name.\n\n" @@ -187,16 +185,15 @@ def cmd_open(session: Session, args: List[str]) -> None: def _close_parser() -> CommandParser: - return CommandParser("close") + return CommandParser("process:close") @command( - "close", + "process:close", parser=_close_parser, summary="Detach from the current process.", - usage="close", - group="Process", - aliases=("detach",), + usage="process:close", + aliases=("close", "detach"), details=( "Takes no arguments.\n\n" "Closes the OS handle and drops the scan results, the pointer paths " @@ -212,16 +209,15 @@ def cmd_close(session: Session, args: List[str]) -> None: def _status_parser() -> CommandParser: - return CommandParser("status") + return CommandParser("session:status") @command( - "status", + "session:status", parser=_status_parser, summary="Show the session state and versions.", - usage="status", - group="Process", - aliases=("\\s",), + usage="session:status", + aliases=("status", "\\s"), details=( "Takes no arguments.\n\n" "Cheap: reports what the session knows without touching the target." @@ -258,15 +254,15 @@ def cmd_status(session: Session, args: List[str]) -> None: def _info_parser() -> CommandParser: - return CommandParser("info") + return CommandParser("process:info") @command( - "info", + "process:info", parser=_info_parser, summary="Describe the attached process in detail.", - usage="info", - group="Process", + usage="process:info", + aliases=("info",), details=( "Takes no arguments.\n\n" "Enumerates the memory map to report how much of the address space is " @@ -275,7 +271,7 @@ def _info_parser() -> CommandParser: ) def cmd_info(session: Session, args: List[str]) -> None: _info_parser().parse_args(args) - process = session.require_process("info") + process = session.require_process("process:info") with Timer() as timer: regions = session.regions(refresh=True) diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index 9319a9e..b77f1ac 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -268,7 +268,7 @@ def _print_results( def _scan_parser() -> CommandParser: - parser = CommandParser("scan") + parser = CommandParser("scan:value") parser.add_argument("type", help="value type to search for (see 'help types')") parser.add_argument( "value", @@ -319,12 +319,11 @@ def _scan_parser() -> CommandParser: @command( - "scan", + "scan:value", parser=_scan_parser, summary="Search the whole address space for a value.", - usage="scan [--op eq|ne|gt|lt|ge|le] | scan --between A B", - group="Scanning", - aliases=("find", "search"), + usage="scan:value [--op eq|ne|gt|lt|ge|le] | scan:value --between A B", + aliases=("scan", "find", "search"), details=( "The first scan of a cycle. Every matching address is kept as the " "result set that 'next', 'results' and the '#N' address form work " @@ -342,7 +341,7 @@ def _scan_parser() -> CommandParser: def cmd_scan(session: Session, args: List[str]) -> None: options = _scan_parser().parse_args(args) - process = session.require_process("scan") + process = session.require_process("scan:value") value_type = valuetypes.resolve(options.type) if options.writable and options.all_regions: @@ -424,7 +423,7 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: def _next_parser() -> CommandParser: - parser = CommandParser("next") + parser = CommandParser("scan:next") parser.add_argument( "op", nargs="?", @@ -444,12 +443,12 @@ def _next_parser() -> CommandParser: @command( - "next", + "scan:next", parser=_next_parser, summary="Narrow the results with another comparison.", - usage="next [op] [value] — op: eq ne gt lt ge le between changed unchanged increased decreased increased-by decreased-by", - group="Scanning", - aliases=("refine",), + usage="scan:next [op] [value] (op: eq ne gt lt ge le between changed " + "unchanged increased decreased increased-by decreased-by)", + aliases=("next", "refine"), details=( "Re-reads every address in the result set and keeps the ones that " "still match. Bare 'next 100' means 'next eq 100'.\n\n" @@ -471,7 +470,7 @@ def cmd_next(session: Session, args: List[str]) -> None: options = _next_parser().parse_args(args) state = session.require_scan() - session.require_process("next") + session.require_process("scan:next") operation = _normalize_op(options.op) if options.op else "eq" operands = list(options.value) @@ -567,7 +566,7 @@ def cmd_next(session: Session, args: List[str]) -> None: def _aob_parser() -> CommandParser: - parser = CommandParser("aob") + parser = CommandParser("scan:aob") parser.add_argument( "pattern", help="IDA-style signature: hex bytes separated by spaces, with '?' or " @@ -578,12 +577,11 @@ def _aob_parser() -> CommandParser: @command( - "aob", + "scan:aob", parser=_aob_parser, summary="Scan for a byte pattern with wildcards (AOB).", - usage="aob [--max N]", - group="Scanning", - aliases=("pattern",), + usage="scan:aob [--max N]", + aliases=("aob", "pattern"), details=( "This is how you find code that moves between builds: the opcodes stay " "put while the operands change, so you wildcard the operands. The " @@ -595,7 +593,7 @@ def _aob_parser() -> CommandParser: def cmd_aob(session: Session, args: List[str]) -> None: options = _aob_parser().parse_args(args) - process = session.require_process("aob") + process = session.require_process("scan:aob") from PyMemoryEditor.util.pattern import compile_pattern @@ -631,7 +629,7 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: def _regex_parser() -> CommandParser: - parser = CommandParser("regex") + parser = CommandParser("scan:regex") parser.add_argument( "pattern", help="a regular expression, UTF-8 encoded and matched against raw memory", @@ -649,11 +647,11 @@ def _regex_parser() -> CommandParser: @command( - "regex", + "scan:regex", parser=_regex_parser, summary="Scan for text matching a regular expression.", - usage="regex [--length N] [--max N]", - group="Scanning", + usage="scan:regex [--length N] [--max N]", + aliases=("regex",), details=( "Because the match runs over *bytes*, a metacharacter spans one byte: " "'.' matches any single byte and '\\d' is ASCII-only, so quantify with " @@ -666,7 +664,7 @@ def _regex_parser() -> CommandParser: def cmd_regex(session: Session, args: List[str]) -> None: options = _regex_parser().parse_args(args) - process = session.require_process("regex") + process = session.require_process("scan:regex") if options.length < 1: raise CommandError("--length must be at least 1 byte.") @@ -707,7 +705,7 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: def _results_parser() -> CommandParser: - parser = CommandParser("results") + parser = CommandParser("scan:results") parser.add_argument( "--limit", type=int, @@ -729,12 +727,11 @@ def _results_parser() -> CommandParser: @command( - "results", + "scan:results", parser=_results_parser, summary="Show the current result set, re-read.", - usage="results [--limit N] [--offset N] [--all]", - group="Scanning", - aliases=("res",), + usage="scan:results [--limit N] [--offset N] [--all]", + aliases=("results", "res"), details=( "Reads every address again, so the VALUE column is what the target " "holds now, not what it held when the scan ran. The PREVIOUS column " @@ -749,7 +746,7 @@ def cmd_results(session: Session, args: List[str]) -> None: options = _results_parser().parse_args(args) state = session.require_scan() - process = session.require_process("results") + process = session.require_process("scan:results") hex_output = bool(session.option("hex")) if options.offset < 0: @@ -823,17 +820,17 @@ def _parse_row_selection(tokens: Sequence[str], count: int) -> List[int]: def _keep_parser() -> CommandParser: - parser = CommandParser("keep") + parser = CommandParser("scan:results:keep") parser.add_argument("rows", nargs="+", help=_ROWS_HELP) return parser @command( - "keep", + "scan:results:keep", parser=_keep_parser, summary="Keep only the named result rows.", - usage="keep [row ...]", - group="Scanning", + usage="scan:results:keep [row ...]", + aliases=("keep",), details=( "Use it when you can see which candidates are real and would rather " "not invent a comparison that happens to exclude the others." @@ -843,7 +840,7 @@ def _keep_parser() -> CommandParser: def cmd_keep(session: Session, args: List[str]) -> None: options = _keep_parser().parse_args(args) state = session.require_scan() - session.require_process("keep") + session.require_process("scan:results:keep") indexes = _parse_row_selection(options.rows, len(state.addresses)) ordered = sorted(set(indexes)) @@ -858,24 +855,24 @@ def cmd_keep(session: Session, args: List[str]) -> None: def _drop_parser() -> CommandParser: - parser = CommandParser("drop") + parser = CommandParser("scan:results:drop") parser.add_argument("rows", nargs="+", help=_ROWS_HELP) return parser @command( - "drop", + "scan:results:drop", parser=_drop_parser, summary="Remove the named result rows.", - usage="drop [row ...]", - group="Scanning", + usage="scan:results:drop [row ...]", + aliases=("drop",), details="The inverse of 'keep'. Ranges work the same way.", examples=("drop 2", "drop 5-12"), ) def cmd_drop(session: Session, args: List[str]) -> None: options = _drop_parser().parse_args(args) state = session.require_scan() - session.require_process("drop") + session.require_process("scan:results:drop") removed = set(_parse_row_selection(options.rows, len(state.addresses))) remaining = [index for index in range(len(state.addresses)) if index not in removed] @@ -890,16 +887,15 @@ def cmd_drop(session: Session, args: List[str]) -> None: def _reset_parser() -> CommandParser: - return CommandParser("reset") + return CommandParser("scan:results:clear") @command( - "reset", + "scan:results:clear", parser=_reset_parser, summary="Discard the current scan results.", - usage="reset", - group="Scanning", - aliases=("unscan",), + usage="scan:results:clear", + aliases=("reset", "unscan"), details=( "Takes no arguments.\n\n" "Clears the result set so the next 'scan' starts a fresh cycle. The " diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 626fcdc..388c5e4 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -19,7 +19,17 @@ render_vertical, ) from ..session import SETTINGS, Session -from . import GROUPS, Command, CommandParser, all_commands, command, describe_action, lookup +from . import ( + NAMESPACES, + Command, + CommandParser, + all_commands, + children, + command, + describe_action, + lookup, + namespaces, +) _ADDRESS_TOPIC = """\ Every command that takes an address takes an expression. @@ -102,32 +112,80 @@ def _print_types(session: Session) -> None: session.printer.write() +#: Width of the canonical-name column in a listing, so the alias column lines +#: up across every section rather than jumping about per namespace. +_NAME_WIDTH = 18 + +#: Width of the alias column, padded so every section of the overview lines up +#: as one grid instead of re-aligning per namespace. +_ALIAS_WIDTH = 9 + + +def _command_rows(commands) -> List[Tuple[str, str]]: + """Label/summary pairs for a command listing. + + The label carries both spellings in two aligned columns — the full name + says where the command lives, the alias is what anybody actually types. + """ + return [ + ( + f"{entry.name.ljust(_NAME_WIDTH)} {entry.short.ljust(_ALIAS_WIDTH)}", + entry.summary, + ) + for entry in commands + ] + + def _print_overview(session: Session) -> None: printer = session.printer printer.write(f"Peekmem {__version__} — a terminal client for PyMemoryEditor.") printer.write() commands = all_commands() - width = max(len(entry.name) for entry in commands) - for group in GROUPS: - in_group = [entry for entry in commands if entry.group == group] + for namespace, title in NAMESPACES: + in_group = [entry for entry in commands if entry.namespace == namespace] if not in_group: continue - printer.write(f"{group}") - for entry in in_group: - printer.write(f" {entry.name.ljust(width)} {entry.summary}") + printer.write(f"{title} ({namespace}:)") + printer.write(render_definitions(_command_rows(in_group), label_width=30)) printer.write() + printer.write( + "Every command has a full name and a short alias: 'memory:read' and\n" + "'read' are the same command. Type either." + ) printer.write( "Type 'help ' — or ' --help' — for a command's full\n" "description, including every argument and flag it accepts." ) + printer.write( + "Type a namespace alone — 'memory', 'pointer' — to list what is in it." + ) printer.write("Topics: 'help types', 'help address', 'help scanning'.") printer.write("End the session with 'exit', Ctrl+C, Ctrl+D, or \\q.") printer.write() +def print_namespace(session: Session, prefix: str) -> bool: + """List the commands under ``prefix``. False when there are none. + + Used both by ``help memory`` and by typing ``memory`` at the prompt: in a + namespaced shell, naming a group and being shown its contents is the + obvious thing for that word to do. + """ + entries = children(prefix) + if not entries: + return False + + head = prefix.strip().lower().rstrip(":") + session.printer.write(f"Commands under '{head}:'") + session.printer.write() + session.printer.write(render_definitions(_command_rows(entries), label_width=30)) + session.printer.write() + return True + + def _argument_sections(entry: Command) -> List[Tuple[str, List[Tuple[str, str]]]]: """Split a command's arguments into the sections ``help`` prints. @@ -170,6 +228,12 @@ def _print_command_help(session: Session, name: str) -> None: printer.write(render_definitions(items)) printer.write() + subcommands = children(entry.name) + if subcommands: + printer.write("Subcommands:") + printer.write(render_definitions(_command_rows(subcommands), label_width=30)) + printer.write() + if entry.details: printer.write(render_paragraphs(entry.details)) printer.write() @@ -181,7 +245,7 @@ def _print_command_help(session: Session, name: str) -> None: def _help_parser() -> CommandParser: - parser = CommandParser("help") + parser = CommandParser("session:help") parser.add_argument( "topic", nargs="?", @@ -193,12 +257,11 @@ def _help_parser() -> CommandParser: @command( - "help", + "session:help", parser=_help_parser, summary="List the commands, or describe one.", - usage="help [command|types|address|scanning]", - group="Session", - aliases=("?", "\\h"), + usage="session:help [command|types|address|scanning]", + aliases=("help", "?", "\\h"), details=( "With a command name, prints that command's usage, every argument and " "flag it accepts, and examples. Typing ' --help' does the " @@ -217,6 +280,11 @@ def cmd_help(session: Session, args: List[str]) -> None: topic = options.topic.strip().lower() + # 'help pointer:' — the trailing colon asks for the namespace explicitly, + # which matters for the two namespaces that are also command aliases. + if topic.endswith(":") and print_namespace(session, topic): + return + if topic in ("types", "type"): _print_types(session) return @@ -229,11 +297,34 @@ def cmd_help(session: Session, args: List[str]) -> None: session.printer.write() return + # A bare namespace is not a command, so describe what is in it instead of + # reporting that it does not exist. + if topic in namespaces() and topic not in command_words_set(): + print_namespace(session, topic) + return + _print_command_help(session, topic) + # 'scan' and 'pointer' are aliases *and* namespaces. The alias wins, so + # say out loud that there is more under the same word. + if topic in namespaces(): + count = len(children(topic)) + session.printer.write( + f"'{topic}' is also a namespace holding {count} commands. " + f"Type '{topic}:' to list them." + ) + session.printer.write() + + +def command_words_set() -> set: + """Every word that resolves to a command, for the namespace check above.""" + from . import command_words + + return set(command_words()) + def _set_parser() -> CommandParser: - parser = CommandParser("set") + parser = CommandParser("session:set") parser.add_argument( "assignment", nargs="*", @@ -245,11 +336,11 @@ def _set_parser() -> CommandParser: @command( - "set", + "session:set", parser=_set_parser, summary="Show or change a session setting.", - usage="set [name [value]]", - group="Session", + usage="session:set [name [value]]", + aliases=("set",), details=( "Settings live for the session only — Peekmem writes no config file, " "so a fresh shell always starts from the documented defaults. Put the " @@ -298,7 +389,7 @@ def cmd_set(session: Session, args: List[str]) -> None: def _source_parser() -> CommandParser: - parser = CommandParser("source") + parser = CommandParser("session:source") parser.add_argument( "file", help="a text file of commands, one per line; blank lines and lines " @@ -308,12 +399,11 @@ def _source_parser() -> CommandParser: @command( - "source", + "session:source", parser=_source_parser, summary="Run the commands in a file.", - usage="source ", - group="Session", - aliases=("\\.",), + usage="session:source ", + aliases=("source", "\\."), details=( "Reads the file and runs each line as if it had been typed.\n\n" "A failing line stops the script — a setup that half-ran is worse than " @@ -343,15 +433,15 @@ def cmd_source(session: Session, args: List[str]) -> None: def _version_parser() -> CommandParser: - return CommandParser("version") + return CommandParser("session:version") @command( - "version", + "session:version", parser=_version_parser, summary="Print the Peekmem and PyMemoryEditor versions.", - usage="version", - group="Session", + usage="session:version", + aliases=("version",), details=( "Takes no arguments.\n\n" "The one line to quote in a bug report: it names Peekmem, " @@ -369,16 +459,15 @@ def cmd_version(session: Session, args: List[str]) -> None: def _exit_parser() -> CommandParser: - return CommandParser("exit") + return CommandParser("session:exit") @command( - "exit", + "session:exit", parser=_exit_parser, summary="Leave the shell.", - usage="exit", - group="Session", - aliases=("quit", "\\q"), + usage="session:exit", + aliases=("exit", "quit", "\\q"), details=( "Takes no arguments.\n\n" "Detaches from the target first. Ctrl+C and Ctrl+D at the prompt do " diff --git a/peekmem/shell.py b/peekmem/shell.py index e2dbacf..23859c9 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -23,7 +23,14 @@ from PyMemoryEditor import PyMemoryEditorError from . import __version__, valuetypes -from .commands import all_commands, command_words, lookup, option_words +from .commands import ( + all_commands, + children, + command_words, + lookup, + namespaces, + option_words, +) from .errors import CommandError, ExitShell from .output import Printer from .session import SETTINGS, Session @@ -39,6 +46,10 @@ _HELP_FLAGS = frozenset(("-h", "--help", "-?")) +class _Handled(Exception): + """Internal: the line was dealt with and needs no further dispatch.""" + + class Shell: """Dispatches command lines against a :class:`~peekmem.session.Session`.""" @@ -99,7 +110,7 @@ def run_line(self, line: str, *, raise_errors: bool = False) -> bool: if parsed is None: return True word, args = parsed - entry = lookup(word) + entry = self._resolve(word, args) if any(argument in _HELP_FLAGS for argument in args): lookup("help").handler(self.session, [entry.name]) @@ -110,6 +121,8 @@ def run_line(self, line: str, *, raise_errors: bool = False) -> bool: except ExitShell: raise + except _Handled: + return True except CommandError as error: if raise_errors: raise @@ -130,6 +143,40 @@ def run_line(self, line: str, *, raise_errors: bool = False) -> bool: self.printer.error(str(error)) return False + def _resolve(self, word: str, args: Sequence[str]): + """Resolve a command word, treating a bare namespace as a request to list it. + + ``memory`` is not a command, but in a namespaced shell it is an obvious + thing to type — so it prints what lives under it rather than a "no such + command". With arguments it is a mistake worth naming precisely: the + user almost always meant the colon. + """ + try: + return lookup(word) + except CommandError: + head = word.strip().lower() + if not children(head): + raise + + if not args: + from .commands.session_commands import print_namespace + + print_namespace(self.session, head) + raise _Handled() + + candidate = f"{head}:{args[0]}" + try: + lookup(candidate) + except CommandError: + raise CommandError( + f"{head!r} is a namespace, not a command. " + f"Type 'help {head}' to see what is in it." + ) + raise CommandError( + f"{head!r} is a namespace: the command is spelled " + f"{candidate!r}, with a colon." + ) + def run_lines(self, lines: Iterable[str], *, raise_errors: bool = False) -> int: """Run a sequence of lines, returning a process exit status.""" for line in lines: @@ -256,7 +303,11 @@ def _complete(self, text: str, state: int) -> Optional[str]: first_word = not buffer[: len(buffer) - len(text)].strip() if first_word: - candidates: Sequence[str] = command_words() + # Namespaces complete too, so tabbing from nothing shows the five + # groups before it shows forty commands. + candidates: Sequence[str] = command_words() + [ + name + ":" for name in namespaces() + ] else: head = buffer.strip().split()[0].lower() if head == "set": diff --git a/tests/test_commands.py b/tests/test_commands.py index fe123a7..9029f49 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -5,11 +5,13 @@ import pytest from peekmem.commands import ( - GROUPS, + NAMESPACES, all_commands, + children, command_words, describe_action, lookup, + namespaces, ) from peekmem.errors import CommandError, NoProcessError @@ -21,7 +23,87 @@ def test_every_command_is_documented(entry): assert entry.summary and entry.summary[0].isupper() and entry.summary.endswith(".") assert entry.usage.startswith(entry.name) - assert entry.group in GROUPS + assert entry.namespace in dict(NAMESPACES) + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_every_command_lives_in_a_namespace(entry): + """A bare name would sit outside the hierarchy the help is built from.""" + assert ":" in entry.name + assert entry.namespace in namespaces() + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_every_command_keeps_a_short_alias(entry): + """The hierarchy must cost nothing at the keyboard. + + Every command has to stay reachable by a plain word — 'read', not + 'memory:read' — or the namespacing would have made the shell worse to use. + """ + assert entry.short, f"{entry.name} has no plain-word alias" + assert lookup(entry.short).name == entry.name + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_a_parent_name_is_a_prefix_of_its_children(entry): + for child in children(entry.name): + assert child.name.startswith(entry.name + ":") + assert child.namespace == entry.namespace + + +def test_namespaces_are_listed_in_the_declared_order(): + order = [name for name, _ in NAMESPACES] + seen = [entry.namespace for entry in COMMANDS] + positions = [order.index(name) for name in seen] + assert positions == sorted(positions) + + +@pytest.mark.parametrize("namespace", [name for name, _ in NAMESPACES]) +def test_every_namespace_has_commands(namespace): + assert children(namespace), f"namespace {namespace!r} is empty" + + +def test_typing_a_namespace_lists_it(shell, capture): + shell.run_line("memory") + assert "Commands under 'memory:'" in capture.out + assert "memory:read" in capture.out + assert capture.err == "" + + +def test_a_namespace_with_arguments_points_at_the_colon(shell, capture): + """'memory read 0x10' is the likely typo; name the fix precisely.""" + assert shell.run_line("memory read 0x10") is False + assert "'memory:read'" in capture.err + + +def test_a_namespace_with_nonsense_arguments_is_still_explained(shell, capture): + assert shell.run_line("memory nonsense") is False + assert "namespace" in capture.err + assert "help memory" in capture.err + + +def test_help_on_a_namespace_lists_it(shell, capture): + shell.run_line("help memory") + assert "memory:read" in capture.out + + +def test_a_namespace_shadowed_by_an_alias_still_announces_itself(shell, capture): + """'pointer' is both an alias and a namespace; the alias wins, loudly.""" + shell.run_line("help pointer") + assert "pointer:read" in capture.out, "the alias resolves to the command" + assert "is also a namespace" in capture.out + + +@pytest.mark.parametrize("topic", ["pointer:", "scan:", "memory:"]) +def test_a_trailing_colon_asks_for_the_namespace(shell, capture, topic): + shell.run_line(f"help {topic}") + assert f"Commands under '{topic.rstrip(':')}:'" in capture.out + + +def test_help_on_a_parent_command_lists_its_subcommands(shell, capture): + shell.run_line("help scan:results") + assert "Subcommands:" in capture.out + assert "scan:results:keep" in capture.out @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) diff --git a/tests/test_shell.py b/tests/test_shell.py index 6a5e50d..587f803 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -143,13 +143,12 @@ def interrupted(session, args): def fake_lookup(name): entry = real_lookup(name) - if entry.name == "version": + if entry.name == "session:version": return Command( name=entry.name, handler=interrupted, summary=entry.summary, usage=entry.usage, - group=entry.group, ) return entry From 081a86a047ff6ab505ea0dba0aeb6fbef51b0721 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 03:53:58 -0300 Subject: [PATCH 07/82] chore: stop tracking .tool-versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file pins the local toolchain for asdf, which is a per-machine choice rather than a property of the project — a contributor on pyenv, or on a different patch release of 3.11, has no use for this repo's copy. It stays on disk (asdf needs it to resolve `python3` here at all) and is now ignored, matching how PyMemoryEditor treats it. The .gitignore it is ignored by is the fuller one carried over from PyMemoryEditor, committed here alongside the untracking since the two changes are the same decision. --- .gitignore | 82 +++++++++++++++++++++++++++++++++++++++++++------- .tool-versions | 1 - 2 files changed, 71 insertions(+), 12 deletions(-) delete mode 100644 .tool-versions diff --git a/.gitignore b/.gitignore index 763eca4..ea2a776 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,83 @@ -# Python +# Byte-compiled / cached __pycache__/ *.py[cod] -*.egg-info/ -.eggs/ +*$py.class + +# Build / packaging build/ +.build/ dist/ -.venv/ +*.egg-info/ +*.egg +.eggs/ +*.whl +*.tar.gz +pip-log.txt +pip-delete-this-directory.txt +MANIFEST + +# Virtual environments venv/ +.venv/ +env/ +ENV/ -# Tooling +# Testing & coverage .pytest_cache/ -.mypy_cache/ +*.pytest_cache .coverage -coverage.xml +.coverage.* htmlcov/ +coverage.xml +*.cover +.tox/ +.nox/ +.hypothesis/ + +# Type checkers & linters +.mypy_cache/ +.ruff_cache/ +.pyre/ +.pytype/ + +# IDEs / editors +.idea/ +.vscode/ +*.code-workspace +*.sublime-* +.spyderproject +.spyproject + +# Editor swap / backup files +*.swp +*.swo +*~ +.\#* +\#*\# -# OS +# OS-specific cruft .DS_Store +.AppleDouble +.LSOverride +Thumbs.db +Desktop.ini +.directory + +# Toolchain / version pinning state +.tool-versions +.python-version + +# Local environment files (never commit secrets) +.env +.env.local +.env.*.local + +# Logs / temporary +*.log +*.tmp + +# Sphinx / docs build output +docs/_build/ -# Peekmem -.peekmem_history -*.peek.json +# Project-local +.claude/ diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 30b467f..0000000 --- a/.tool-versions +++ /dev/null @@ -1 +0,0 @@ -python 3.11.0 \ No newline at end of file From d0719a551db2c028b2d6bde29f72a2c301300a57 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 13:47:21 -0300 Subject: [PATCH 08/82] feat(help): make the help layered, and lift the shell's own commands out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Namespacing the commands organised the names but not the help, which still printed all thirty-four at once — a wall to read past rather than an answer. 'help' now shows one layer: the four subject namespaces with a line each, and the handful of commands that drive the shell. ':help' opens the next layer down, at any depth — 'scan:help', then 'scan:results:help' — and a listing points at the layers beneath it instead of printing them. ':help' is a dispatcher convention rather than a registered command, so it works for every prefix that exists or ever will, without one 'help' command per namespace cluttering the very listings it exists to print. The three spellings that reach a listing ('memory', 'memory:help', 'help memory') all do the same thing; only the second is advertised. The shell's own commands leave the session namespace and go back to being bare words: help, set, source, status, version, exit. They are not a subject you go looking through — they are what you type between doing real work, and burying them a level down cost more than the tidiness was worth. The registry now allows a name without a colon, and a test pins down that only those six are top-level: anything touching the target belongs to a namespace. `children()` returns one level rather than every descendant, which is what makes a listing a screen instead of a tree. --- CONTRIBUTING.md | 13 +++- README.md | 31 +++++++-- peekmem/cli.py | 44 ++++++------- peekmem/commands/__init__.py | 96 ++++++++++++++++++++-------- peekmem/commands/process_commands.py | 8 +-- peekmem/commands/session_commands.py | 90 +++++++++++++++----------- peekmem/shell.py | 15 ++++- tests/test_cli.py | 13 ++-- tests/test_commands.py | 86 ++++++++++++++++++++++--- tests/test_shell.py | 8 +-- 10 files changed, 292 insertions(+), 112 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 015299d..c91e847 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,11 @@ Two rules keep the shape: segment is one of the namespaces in `NAMESPACES`; the group in `help` follows from it, so there is nothing to keep in step. Give it a plain-word alias too — that is what people type, and a test enforces that every - command has one: + command has one. + + 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. ```python def _mycommand_parser() -> CommandParser: @@ -112,8 +116,11 @@ Two rules keep the shape: ... ``` - A name with a third segment (`scan:results:keep`) is fine and shows up as a - **Subcommands** section under its parent's help. + A name with a third segment (`scan:results:keep`) is fine: it shows up as a + **Subcommands** section under its parent's help, and `scan:results:help` + lists that layer. The `:help` form is a dispatcher convention rather + than a registered command, so it works at any depth without one `help` + command per namespace cluttering the listings it exists to print. 3. Use `CommandParser`, not a bare `ArgumentParser`: it raises instead of calling `sys.exit`, which would kill the shell on a typo. diff --git a/README.md b/README.md index 683742f..9f00a21 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,32 @@ non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave ## What it can do -Commands are namespaced, and every one has a short alias — `memory:read` and -`read` are the same command, so the hierarchy costs nothing at the keyboard. -Type a namespace alone (`memory`, `pointer:`) to list what is in it. +The help is layered. `help` shows four namespaces and the handful of commands +that drive the shell — not a wall of forty: + +```console +peekmem> help +Namespaces — type ':help' to list what is in one: + process Find a target process and attach to it. + memory Read, write and inspect the target's memory. + scan Search memory for a value, then narrow what you found. + pointer Follow pointer chains, and find ones that survive a restart. + +Commands: + exit Leave the shell. + help List the commands, or describe one. + set Show or change a session setting. + source Run the commands in a file. + status Show the session state and versions. + version Print the Peekmem and PyMemoryEditor versions. +``` + +`scan:help` opens the next layer, `scan:results:help` the one below that. Each +listing shows one level and points at the next, so you never read past what you +came for. + +Every namespaced command also has a short alias — `memory:read` and `read` are +the same command, so the hierarchy costs nothing at the keyboard. | Namespace | Commands (short alias) | | --- | --- | @@ -137,7 +160,7 @@ Type a namespace alone (`memory`, `pointer:`) to list what is in it. | **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | | **`scan:`** | `value` (scan) · `next` · `aob` · `regex` · `results` · `results:keep` (keep) · `results:drop` (drop) · `results:clear` (reset) | | **`pointer:`** | `deref` · `read` (pointer) · `scan` (ptrscan) · `rescan` (ptrrescan) · `paths` · `paths:save` (ptrsave) · `paths:load` (ptrload) · `paths:diff` (ptrdiff) | -| **`session:`** | `help` · `set` · `source` · `status` · `version` · `exit` | +| Top level | `help` · `set` · `source` · `status` · `version` · `exit` | `help ` — or ` --help` — documents each one in full: every argument, every flag, and examples. That list is generated from the command's diff --git a/peekmem/cli.py b/peekmem/cli.py index bcf6602..b674df3 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -23,35 +23,35 @@ import PyMemoryEditor from . import __version__, dependencies -from .commands import NAMESPACES, all_commands +from .commands import namespace_summary, namespaces, top_level from .errors import CommandError, PeekmemError from .output import Printer from .session import Session from .shell import Shell -_EPILOG_INTRO = ( - "Commands — run 'peekmem --help' for one command's arguments,\n" - "or 'peekmem help' for the topics:" -) - def _format_commands() -> str: - """A compact command list for ``--help``, grouped like ``help`` is.""" - commands = all_commands() - width = max(len(entry.name) for entry in commands) - lines: List[str] = [_EPILOG_INTRO, ""] - for namespace, title in NAMESPACES: - in_group = [entry for entry in commands if entry.namespace == namespace] - if not in_group: - continue - lines.append(f" {title}") - for entry in in_group: - # Full name, then the alias people actually type, in aligned - # columns — the same shape 'help' uses inside the shell. - lines.append( - f" {entry.name.ljust(width)} {entry.short.ljust(9)} {entry.summary}" - ) - lines.append("") + """The same layered summary the shell's own ``help`` prints. + + Namespaces and the shell's own commands, with one move to go deeper — + rather than every command at once, which is a wall rather than an answer. + """ + lines: List[str] = [ + "Namespaces — run 'peekmem :help' to list what is in one:", + "", + ] + for name in namespaces(): + lines.append(f" {name.ljust(10)} {namespace_summary(name)}") + + lines += ["", "Commands:", ""] + for entry in top_level(): + lines.append(f" {entry.name.ljust(10)} {entry.summary}") + + lines += [ + "", + "Run 'peekmem --help' for one command's arguments, or", + "'peekmem help' for the topics ('types', 'address', 'scanning').", + ] return "\n".join(lines) diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index 43e923f..26b010c 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -21,21 +21,32 @@ from ..errors import CommandError -#: Namespaces in the order ``help`` prints them, with the heading each one -#: gets. A command's namespace is the part of its name before the first colon, -#: so the grouping cannot drift from the naming — there is nothing to keep in -#: step. -NAMESPACES: Tuple[Tuple[str, str], ...] = ( - ("process", "Process"), - ("memory", "Memory"), - ("scan", "Scanning"), - ("pointer", "Pointers"), - ("session", "Session"), +#: Namespaces in the order ``help`` prints them, each with the heading it gets +#: and the one line that says what it is for. A command's namespace is the part +#: of its name before the first colon, so the grouping cannot drift from the +#: naming — there is nothing to keep in step. +#: +#: Commands about the shell itself (``help``, ``set``, ``exit`` …) deliberately +#: have no namespace. They are not a subject you go looking through; they are +#: the handful of words you type between doing real work, and burying them one +#: level down would cost more than the tidiness is worth. +NAMESPACES: Tuple[Tuple[str, str, str], ...] = ( + ("process", "Process", "Find a target process and attach to it."), + ("memory", "Memory", "Read, write and inspect the target's memory."), + ("scan", "Scanning", "Search memory for a value, then narrow what you found."), + ( + "pointer", + "Pointers", + "Follow pointer chains, and find ones that survive a restart.", + ), ) -_NAMESPACE_TITLES: Dict[str, str] = dict(NAMESPACES) +_NAMESPACE_TITLES: Dict[str, str] = {name: title for name, title, _ in NAMESPACES} +_NAMESPACE_SUMMARIES: Dict[str, str] = { + name: summary for name, _, summary in NAMESPACES +} _NAMESPACE_ORDER: Dict[str, int] = { - name: index for index, (name, _) in enumerate(NAMESPACES) + name: index for index, (name, _, _) in enumerate(NAMESPACES) } Handler = Callable[..., None] @@ -142,23 +153,33 @@ class Command: @property def namespace(self) -> str: - """The first segment of the name — the group this command belongs to.""" - return self.name.split(":", 1)[0] + """The first segment of the name, or ``""`` for a top-level command.""" + return self.name.split(":", 1)[0] if ":" in self.name else "" + + @property + def is_top_level(self) -> bool: + """True for the shell's own commands, which live outside any namespace.""" + return ":" not in self.name @property def group(self) -> str: """The heading ``help`` files this command under.""" + if self.is_top_level: + return "Commands" return _NAMESPACE_TITLES.get(self.namespace, self.namespace.capitalize()) @property def short(self) -> str: - """The alias worth advertising — the first plain-word one. + """The spelling worth advertising — the first plain-word alias. First, not shortest: the alias list is written most-natural-first, and the shortest is often the cryptic one (``x`` for ``memory:dump``, ``ptr`` for ``pointer:read``). Backslash aliases like ``\\q`` are - skipped; they are shortcuts, not names. + skipped; they are shortcuts, not names. A top-level command is already + the short spelling of itself. """ + if self.is_top_level: + return self.name for alias in self.aliases: if alias.isalnum(): return alias @@ -197,7 +218,7 @@ def command( def decorator(handler: Handler) -> Handler: if name in _COMMANDS or name in _ALIASES: raise RuntimeError(f"Duplicate command name: {name}") - if name.split(":", 1)[0] not in _NAMESPACE_TITLES: + if ":" in name and name.split(":", 1)[0] not in _NAMESPACE_TITLES: raise RuntimeError(f"Unknown namespace in command name: {name}") entry = Command( @@ -236,31 +257,54 @@ def lookup(name: str) -> Command: def all_commands() -> List[Command]: - """Every registered command, in namespace order then alphabetically.""" + """Every registered command, in namespace order then alphabetically. + + Top-level commands sort last, because that is where ``help`` prints them: + after the subjects, as the short list of words for driving the shell. + """ return sorted( _COMMANDS.values(), key=lambda entry: (_NAMESPACE_ORDER.get(entry.namespace, 99), entry.name), ) +def top_level() -> List[Command]: + """The commands that live outside any namespace.""" + return [entry for entry in all_commands() if entry.is_top_level] + + +def namespace_summary(name: str) -> str: + """The one line describing a namespace, for the top-level help.""" + return _NAMESPACE_SUMMARIES.get(name.strip().lower().rstrip(":"), "") + + def children(prefix: str) -> List[Command]: - """Commands sitting under ``prefix`` in the hierarchy. + """The commands one level below ``prefix``. + + One level, not all of them: ``children("scan")`` yields ``scan:results`` + but not ``scan:results:keep``. That is what makes the help layered — each + listing shows a screen you can take in, and points at the next level down + rather than dumping it. - Works for a namespace (``memory`` yields every ``memory:*``) and for a - command that has commands beneath it (``scan:results`` yields - ``scan:results:keep`` and its siblings), which is the same question in both - cases: what can follow this word? + Works the same for a namespace (``memory``) and for a command that has + commands beneath it (``scan:results``), because it is the same question in + both cases: what can follow this word? """ head = prefix.strip().lower().rstrip(":") if not head: return [] - return [entry for entry in all_commands() if entry.name.startswith(head + ":")] + depth = head.count(":") + 1 + return [ + entry + for entry in all_commands() + if entry.name.startswith(head + ":") and entry.name.count(":") == depth + ] def namespaces() -> List[str]: """Every namespace that has at least one command registered in it.""" known = {entry.namespace for entry in _COMMANDS.values()} - return [name for name, _ in NAMESPACES if name in known] + return [name for name, _, _ in NAMESPACES if name in known] def command_words() -> List[str]: @@ -295,6 +339,8 @@ def option_words(name: str) -> List[str]: "command_words", "describe_action", "lookup", + "namespace_summary", "namespaces", "option_words", + "top_level", ) diff --git a/peekmem/commands/process_commands.py b/peekmem/commands/process_commands.py index d637d47..30d0e37 100644 --- a/peekmem/commands/process_commands.py +++ b/peekmem/commands/process_commands.py @@ -209,15 +209,15 @@ def cmd_close(session: Session, args: List[str]) -> None: def _status_parser() -> CommandParser: - return CommandParser("session:status") + return CommandParser("status") @command( - "session:status", + "status", parser=_status_parser, summary="Show the session state and versions.", - usage="session:status", - aliases=("status", "\\s"), + usage="status", + aliases=("\\s",), details=( "Takes no arguments.\n\n" "Cheap: reports what the session knows without touching the target." diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 388c5e4..c62daf3 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -20,15 +20,15 @@ ) from ..session import SETTINGS, Session from . import ( - NAMESPACES, Command, CommandParser, - all_commands, children, command, describe_action, lookup, + namespace_summary, namespaces, + top_level, ) _ADDRESS_TOPIC = """\ @@ -137,30 +137,40 @@ def _command_rows(commands) -> List[Tuple[str, str]]: def _print_overview(session: Session) -> None: + """The top layer: the four subjects, and the words that drive the shell. + + Deliberately not a list of every command. Thirty-four lines is a wall to + read past, not an answer; four namespaces and six commands is something you + can take in, with one obvious move to get deeper. + """ printer = session.printer printer.write(f"Peekmem {__version__} — a terminal client for PyMemoryEditor.") printer.write() - commands = all_commands() - - for namespace, title in NAMESPACES: - in_group = [entry for entry in commands if entry.namespace == namespace] - if not in_group: - continue - printer.write(f"{title} ({namespace}:)") - printer.write(render_definitions(_command_rows(in_group), label_width=30)) - printer.write() + printer.write("Namespaces — type ':help' to list what is in one:") + printer.write( + render_definitions( + [(name, namespace_summary(name)) for name in namespaces()], + label_width=10, + ) + ) + printer.write() + printer.write("Commands:") printer.write( - "Every command has a full name and a short alias: 'memory:read' and\n" - "'read' are the same command. Type either." + render_definitions( + [(entry.name, entry.summary) for entry in top_level()], label_width=10 + ) ) + printer.write() + printer.write( - "Type 'help ' — or ' --help' — for a command's full\n" - "description, including every argument and flag it accepts." + "Every command in a namespace also has a short alias: 'memory:read' " + "and 'read'\nare the same command." ) printer.write( - "Type a namespace alone — 'memory', 'pointer' — to list what is in it." + "Type 'help ' — or ' --help' — for a command's " + "arguments." ) printer.write("Topics: 'help types', 'help address', 'help scanning'.") printer.write("End the session with 'exit', Ctrl+C, Ctrl+D, or \\q.") @@ -183,6 +193,16 @@ def print_namespace(session: Session, prefix: str) -> bool: session.printer.write() session.printer.write(render_definitions(_command_rows(entries), label_width=30)) session.printer.write() + + # Point at the next layer down rather than printing it here. + for entry in entries: + deeper = children(entry.name) + if deeper: + session.printer.write( + f"'{entry.name}' has {len(deeper)} subcommands of its own — " + f"type '{entry.name}:help'." + ) + session.printer.write() return True @@ -245,7 +265,7 @@ def _print_command_help(session: Session, name: str) -> None: def _help_parser() -> CommandParser: - parser = CommandParser("session:help") + parser = CommandParser("help") parser.add_argument( "topic", nargs="?", @@ -257,11 +277,11 @@ def _help_parser() -> CommandParser: @command( - "session:help", + "help", parser=_help_parser, summary="List the commands, or describe one.", - usage="session:help [command|types|address|scanning]", - aliases=("help", "?", "\\h"), + usage="help [command|types|address|scanning]", + aliases=("?", "\\h"), details=( "With a command name, prints that command's usage, every argument and " "flag it accepts, and examples. Typing ' --help' does the " @@ -324,7 +344,7 @@ def command_words_set() -> set: def _set_parser() -> CommandParser: - parser = CommandParser("session:set") + parser = CommandParser("set") parser.add_argument( "assignment", nargs="*", @@ -336,11 +356,10 @@ def _set_parser() -> CommandParser: @command( - "session:set", + "set", parser=_set_parser, summary="Show or change a session setting.", - usage="session:set [name [value]]", - aliases=("set",), + usage="set [name [value]]", details=( "Settings live for the session only — Peekmem writes no config file, " "so a fresh shell always starts from the documented defaults. Put the " @@ -389,7 +408,7 @@ def cmd_set(session: Session, args: List[str]) -> None: def _source_parser() -> CommandParser: - parser = CommandParser("session:source") + parser = CommandParser("source") parser.add_argument( "file", help="a text file of commands, one per line; blank lines and lines " @@ -399,11 +418,11 @@ def _source_parser() -> CommandParser: @command( - "session:source", + "source", parser=_source_parser, summary="Run the commands in a file.", - usage="session:source ", - aliases=("source", "\\."), + usage="source ", + aliases=("\\.",), details=( "Reads the file and runs each line as if it had been typed.\n\n" "A failing line stops the script — a setup that half-ran is worse than " @@ -433,15 +452,14 @@ def cmd_source(session: Session, args: List[str]) -> None: def _version_parser() -> CommandParser: - return CommandParser("session:version") + return CommandParser("version") @command( - "session:version", + "version", parser=_version_parser, summary="Print the Peekmem and PyMemoryEditor versions.", - usage="session:version", - aliases=("version",), + usage="version", details=( "Takes no arguments.\n\n" "The one line to quote in a bug report: it names Peekmem, " @@ -459,15 +477,15 @@ def cmd_version(session: Session, args: List[str]) -> None: def _exit_parser() -> CommandParser: - return CommandParser("session:exit") + return CommandParser("exit") @command( - "session:exit", + "exit", parser=_exit_parser, summary="Leave the shell.", - usage="session:exit", - aliases=("exit", "quit", "\\q"), + usage="exit", + aliases=("quit", "\\q"), details=( "Takes no arguments.\n\n" "Detaches from the target first. Ctrl+C and Ctrl+D at the prompt do " diff --git a/peekmem/shell.py b/peekmem/shell.py index 23859c9..c7e943e 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -148,13 +148,24 @@ def _resolve(self, word: str, args: Sequence[str]): ``memory`` is not a command, but in a namespaced shell it is an obvious thing to type — so it prints what lives under it rather than a "no such - command". With arguments it is a mistake worth naming precisely: the - user almost always meant the colon. + command", exactly as ``memory:help`` does. With arguments it is a + mistake worth naming precisely: the user almost always meant the colon. """ try: return lookup(word) except CommandError: head = word.strip().lower() + + # ':help' — the layered way down. It is a convention rather + # than a registered command so it works at every depth, present and + # future, without one 'help' command per namespace cluttering the + # very listings it exists to print. + if head.endswith(":help"): + from .commands.session_commands import print_namespace + + if print_namespace(self.session, head[: -len(":help")]): + raise _Handled() + if not children(head): raise diff --git a/tests/test_cli.py b/tests/test_cli.py index 420f43a..3000099 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -99,7 +99,12 @@ def test_version_flag(): assert exit_info.value.code == 0 -def test_help_lists_the_commands(capsys): - parser = build_parser() - text = parser.format_help() - assert "scan" in text and "ptrscan" in text and "Pointers" in text +def test_help_lists_the_layers_not_every_command(capsys): + """--help mirrors the shell's own overview: namespaces, then shell commands.""" + text = build_parser().format_help() + for namespace in ("process", "memory", "scan", "pointer"): + assert namespace in text + for name in ("help", "set", "version", "exit"): + assert name in text + assert ":help" in text + assert "ptrscan" not in text, "the deeper layers are reached, not dumped" diff --git a/tests/test_commands.py b/tests/test_commands.py index 9029f49..36e9b87 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -12,6 +12,7 @@ describe_action, lookup, namespaces, + top_level, ) from peekmem.errors import CommandError, NoProcessError @@ -23,14 +24,28 @@ def test_every_command_is_documented(entry): assert entry.summary and entry.summary[0].isupper() and entry.summary.endswith(".") assert entry.usage.startswith(entry.name) - assert entry.namespace in dict(NAMESPACES) + assert entry.is_top_level or entry.namespace in namespaces() @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) -def test_every_command_lives_in_a_namespace(entry): - """A bare name would sit outside the hierarchy the help is built from.""" - assert ":" in entry.name - assert entry.namespace in namespaces() +def test_every_command_is_placed(entry): + """Either in a declared namespace, or deliberately top-level.""" + if entry.is_top_level: + assert ":" not in entry.name + else: + assert entry.namespace in namespaces() + + +def test_only_shell_commands_are_top_level(): + """Anything that touches the target belongs to a subject namespace.""" + assert sorted(entry.name for entry in top_level()) == [ + "exit", + "help", + "set", + "source", + "status", + "version", + ] @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) @@ -52,13 +67,68 @@ def test_a_parent_name_is_a_prefix_of_its_children(entry): def test_namespaces_are_listed_in_the_declared_order(): - order = [name for name, _ in NAMESPACES] - seen = [entry.namespace for entry in COMMANDS] + order = [name for name, _, _ in NAMESPACES] + seen = [entry.namespace for entry in COMMANDS if not entry.is_top_level] positions = [order.index(name) for name in seen] assert positions == sorted(positions) -@pytest.mark.parametrize("namespace", [name for name, _ in NAMESPACES]) +def test_top_level_commands_sort_last(): + """help prints the subjects first, then the words that drive the shell.""" + names = [entry.is_top_level for entry in all_commands()] + assert names == sorted(names), "a namespaced command came after a top-level one" + + +def test_children_are_one_level_deep(): + """The layering depends on this: a listing shows a screen, not a tree.""" + names = [entry.name for entry in children("scan")] + assert "scan:results" in names + assert "scan:results:keep" not in names + assert [entry.name for entry in children("scan:results")] == [ + "scan:results:clear", + "scan:results:drop", + "scan:results:keep", + ] + + +def test_the_overview_shows_layers_not_every_command(shell, capture): + shell.run_line("help") + out = capture.out + for namespace in namespaces(): + assert namespace in out + for entry in top_level(): + assert entry.name in out + # The point of the layering: the forty-odd namespaced commands are not + # listed here, only pointed at. Checked line-first, because the prose does + # name one of them as an example of the alias rule. + listed = {line.strip().split()[0] for line in out.splitlines() if line.startswith(" ")} + assert not any(":" in item for item in listed), f"a namespaced command is listed: {listed}" + assert ":help" in out + + +@pytest.mark.parametrize("namespace", [name for name, _, _ in NAMESPACES]) +def test_namespace_help_lists_that_layer(shell, capture, namespace): + shell.run_line(f"{namespace}:help") + assert f"Commands under '{namespace}:'" in capture.out + for entry in children(namespace): + assert entry.name in capture.out + assert capture.err == "" + + +def test_a_deeper_help_lists_the_third_layer(shell, capture): + shell.run_line("scan:results:help") + assert "Commands under 'scan:results:'" in capture.out + assert "scan:results:keep" in capture.out + + +def test_a_listing_points_at_the_layer_below_it(shell, capture): + shell.run_line("scan:help") + assert "scan:results" in capture.out + assert "scan:results:keep" not in capture.out, "that is the next layer down" + assert "type 'scan:results:help'" in capture.out + + +@pytest.mark.parametrize("namespace", [name for name, _, _ in NAMESPACES]) def test_every_namespace_has_commands(namespace): assert children(namespace), f"namespace {namespace!r} is empty" diff --git a/tests/test_shell.py b/tests/test_shell.py index 587f803..9f8d9cb 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -69,10 +69,10 @@ def test_prompt_names_the_target(shell): assert shell.prompt() == "peekmem> " -def test_help_lists_every_group(shell, capture): +def test_help_lists_every_namespace(shell, capture): shell.run_line("help") - for group in ("Process", "Memory", "Scanning", "Pointers", "Session"): - assert group in capture.out + for namespace in ("process", "memory", "scan", "pointer"): + assert namespace in capture.out def test_help_topics_are_reachable(shell, capture): @@ -143,7 +143,7 @@ def interrupted(session, args): def fake_lookup(name): entry = real_lookup(name) - if entry.name == "session:version": + if entry.name == "version": return Command( name=entry.name, handler=interrupted, From 47a0c8017c102d80f3f5c6f6cf4beb0341c7b45b Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 14:07:35 -0300 Subject: [PATCH 09/82] feat(shell): list a namespace from its bare name, and add 'clear' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a bare namespace already listed it, but only for 'process' and 'memory'. 'scan' and 'pointer' are command aliases as well, so the alias won and the answer was "the following arguments are required" — a dead end where the other two were helpful. A bare namespace now lists it in every case. Nothing is lost by that: both shadowed commands require arguments, so the bare word could never do anything but fail. The rule applies to the bare word only, so 'scan int32 100' and 'pointer game.exe+0x10 0x8' are untouched, and 'scan --help' still describes the command rather than the namespace. 'clear' wipes the screen and the scrollback, as the shell's own clear does. It is deliberately not a session command: the process stays attached and the scan results survive, because a cleared terminal is not a reset — and 'reset' is the command for that, said in as many words in the help. It is a no-op when stdout is redirected, since escape codes in a log file are vandalism rather than tidying, and it goes through the printer like every other byte Peekmem writes. Verified in a pty that the real escape sequences reach a real terminal and that the session survives them. --- README.md | 9 +++++---- peekmem/commands/session_commands.py | 26 ++++++++++++++++++++++++++ peekmem/output.py | 22 ++++++++++++++++++++++ peekmem/shell.py | 14 +++++++++++++- tests/test_commands.py | 26 ++++++++++++++++++++++++++ tests/test_output.py | 13 +++++++++++++ 6 files changed, 105 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9f00a21..8cfa474 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ Namespaces — type ':help' to list what is in one: pointer Follow pointer chains, and find ones that survive a restart. Commands: + clear Clear the terminal. exit Leave the shell. help List the commands, or describe one. set Show or change a session setting. @@ -147,9 +148,9 @@ Commands: version Print the Peekmem and PyMemoryEditor versions. ``` -`scan:help` opens the next layer, `scan:results:help` the one below that. Each -listing shows one level and points at the next, so you never read past what you -came for. +`scan:help` opens the next layer, `scan:results:help` the one below that — or +just type the namespace, `scan`. Each listing shows one level and points at the +next, so you never read past what you came for. Every namespaced command also has a short alias — `memory:read` and `read` are the same command, so the hierarchy costs nothing at the keyboard. @@ -160,7 +161,7 @@ the same command, so the hierarchy costs nothing at the keyboard. | **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | | **`scan:`** | `value` (scan) · `next` · `aob` · `regex` · `results` · `results:keep` (keep) · `results:drop` (drop) · `results:clear` (reset) | | **`pointer:`** | `deref` · `read` (pointer) · `scan` (ptrscan) · `rescan` (ptrrescan) · `paths` · `paths:save` (ptrsave) · `paths:load` (ptrload) · `paths:diff` (ptrdiff) | -| Top level | `help` · `set` · `source` · `status` · `version` · `exit` | +| Top level | `help` · `set` · `source` · `status` · `version` · `clear` · `exit` | `help ` — or ` --help` — documents each one in full: every argument, every flag, and examples. That list is generated from the command's diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index c62daf3..3d87746 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -451,6 +451,32 @@ def cmd_source(session: Session, args: List[str]) -> None: raise CommandError(f"{options.file}:{number}: {error}") +def _clear_parser() -> CommandParser: + return CommandParser("clear") + + +@command( + "clear", + parser=_clear_parser, + summary="Clear the terminal.", + usage="clear", + aliases=("cls",), + details=( + "Takes no arguments.\n\n" + "Wipes the screen and the scrollback, the way the shell's own 'clear' " + "does. Nothing about the session changes: the process stays attached, " + "the scan results and pointer paths are all still there.\n\n" + "To discard the scan results instead, that is 'reset' " + "(scan:results:clear).\n\n" + "Does nothing when the output is redirected — escape codes in a log " + "file would be vandalism rather than tidying." + ), +) +def cmd_clear(session: Session, args: List[str]) -> None: + _clear_parser().parse_args(args) + session.printer.clear_screen() + + def _version_parser() -> CommandParser: return CommandParser("version") diff --git a/peekmem/output.py b/peekmem/output.py index 0fceea6..387eea5 100644 --- a/peekmem/output.py +++ b/peekmem/output.py @@ -230,6 +230,28 @@ def error(self, message: str) -> None: self.stderr.write(f"{prefix}: {message}\n") self.stderr.flush() + def clear_screen(self) -> bool: + """Wipe the terminal. False when there is no terminal to wipe. + + A no-op when stdout is redirected: clearing is a courtesy to a human + looking at a screen, and emitting escape codes into a pipe or a log + file would be vandalism rather than tidying. + """ + self.clear_progress() + if not getattr(self.stdout, "isatty", lambda: False)(): + return False + + if sys.platform == "win32": # pragma: no cover - Windows only + # Not every Windows console has VT processing enabled, so the + # escape sequence below cannot be relied on. `cls` always works. + os.system("cls") + else: + # 2J wipes the screen, 3J the scrollback (so the shell matches what + # `clear` does), H parks the cursor at the top. + self.stdout.write("\033[2J\033[3J\033[H") + self.stdout.flush() + return True + def note(self, message: str) -> None: """Print an aside — a warning that did not stop the command.""" self.clear_progress() diff --git a/peekmem/shell.py b/peekmem/shell.py index c7e943e..e475d54 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -151,10 +151,22 @@ def _resolve(self, word: str, args: Sequence[str]): command", exactly as ``memory:help`` does. With arguments it is a mistake worth naming precisely: the user almost always meant the colon. """ + head = word.strip().lower() + + # A bare namespace lists what is in it even when the word is *also* a + # command alias, which 'scan' and 'pointer' are. Nothing is lost: both + # of those commands require arguments, so a bare 'scan' could only ever + # have produced "the following arguments are required". With arguments + # the alias still wins, so 'scan int32 100' is untouched. + if not args and head in namespaces(): + from .commands.session_commands import print_namespace + + print_namespace(self.session, head) + raise _Handled() + try: return lookup(word) except CommandError: - head = word.strip().lower() # ':help' — the layered way down. It is a convention rather # than a registered command so it works at every depth, present and diff --git a/tests/test_commands.py b/tests/test_commands.py index 36e9b87..393a8ea 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -39,6 +39,7 @@ def test_every_command_is_placed(entry): def test_only_shell_commands_are_top_level(): """Anything that touches the target belongs to a subject namespace.""" assert sorted(entry.name for entry in top_level()) == [ + "clear", "exit", "help", "set", @@ -48,6 +49,31 @@ def test_only_shell_commands_are_top_level(): ] +@pytest.mark.parametrize("namespace", [name for name, _, _ in NAMESPACES]) +def test_a_bare_namespace_lists_it(shell, capture, namespace): + """Including 'scan' and 'pointer', which are command aliases as well.""" + shell.run_line(namespace) + assert f"Commands under '{namespace}:'" in capture.out + assert capture.err == "" + + +@pytest.mark.parametrize("line", ["scan int32 100", "pointer 0x10"]) +def test_an_alias_that_shadows_a_namespace_still_runs_with_arguments(shell, line): + """The listing rule applies to the bare word only; the command is intact.""" + with pytest.raises(NoProcessError): + shell.run_line(line, raise_errors=True) + + +def test_clear_leaves_the_session_alone(shell, capture): + """It wipes the screen, not the work: a cleared terminal is not a reset.""" + from peekmem import valuetypes + + shell.session.store_scan(valuetypes.resolve("int32"), 4, [0x10], [1], "t") + shell.run_line("clear") + assert shell.session.scan is not None + assert capture.err == "" + + @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) def test_every_command_keeps_a_short_alias(entry): """The hierarchy must cost nothing at the keyboard. diff --git a/tests/test_output.py b/tests/test_output.py index 3a3aaae..254de99 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -74,6 +74,19 @@ def test_vertical_block_aligns_the_keys(): assert text == " PID: 1\nName: init" +def test_clear_screen_is_a_no_op_without_a_terminal(capture): + """Escape codes in a pipe or a log file are vandalism, not tidying.""" + assert capture.printer.clear_screen() is False + assert capture.out == "" + + +def test_clear_screen_wipes_screen_and_scrollback(capture): + capture.printer.stdout.isatty = lambda: True # type: ignore[method-assign] + assert capture.printer.clear_screen() is True + # 2J the screen, 3J the scrollback, H the cursor — what `clear` itself does. + assert capture.out == "\033[2J\033[3J\033[H" + + def test_progress_is_silent_when_stderr_is_not_a_terminal(capture): capture.printer.progress("Scanning", 0.5) assert capture.err == "" From c65a7cf4f3da53ad49955f9d4a57fe9857eda087 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 17:51:11 -0300 Subject: [PATCH 10/82] feat(help): give each namespace a dokku-style help page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A namespace listing was a bare table of names and summaries. It now reads like dokku's plugin help, which packs more into the same screen: a usage line, the one sentence saying what the namespace is for, a worked example showing real input and real output, and then the commands — each with the *arguments it takes*, not just its name. The signature comes from the usage string the command already declares, so it cannot drift from the parser; a long one is cut at a token boundary with an ellipsis rather than pushing every summary off the screen, the full list being one 'help ' away. Sub-namespaces (scan:results, pointer:paths) get the same page, taking their description and example from the parent command. 'scan:help aob' now describes one command, which is exactly what the listing header advertises — it prints that command's help rather than running it. The top-level 'help' takes the same shape: usage line, example, namespaces, then the shell's own commands. NAMESPACES becomes a dataclass so a namespace can carry its example alongside its name and summary, and two usage strings that had grown into prose ('scan:value', 'scan:next') are back to being signatures. --- README.md | 53 ++++++---- peekmem/commands/__init__.py | 88 ++++++++++++++--- peekmem/commands/scan_commands.py | 5 +- peekmem/commands/session_commands.py | 138 ++++++++++++++++++++------- peekmem/shell.py | 12 ++- tests/test_commands.py | 72 +++++++++++--- 6 files changed, 285 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 8cfa474..4d41d66 100644 --- a/README.md +++ b/README.md @@ -128,29 +128,44 @@ non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave ## What it can do The help is layered. `help` shows four namespaces and the handful of commands -that drive the shell — not a wall of forty: +that drive the shell — not a wall of forty. Each namespace then documents +itself: a usage line, a worked example, and its commands with the arguments +they take. ```console -peekmem> help -Namespaces — type ':help' to list what is in one: - process Find a target process and attach to it. - memory Read, write and inspect the target's memory. - scan Search memory for a value, then narrow what you found. - pointer Follow pointer chains, and find ones that survive a restart. - -Commands: - clear Clear the terminal. - exit Leave the shell. - help List the commands, or describe one. - set Show or change a session setting. - source Run the commands in a file. - status Show the session state and versions. - version Print the Peekmem and PyMemoryEditor versions. +peekmem> scan:help +usage: scan[:COMMAND] + +Search memory for a value, then narrow what you found. + +Example: + + peekmem> scan:value int32 100 --writable + Showing 20 of 3184 rows (1.42 sec) + + peekmem> scan:next 95 + +-----+--------------------+-------+ + | ROW | ADDRESS | VALUE | + +-----+--------------------+-------+ + | #1 | 0x00000201A4C0F118 | 95 | + +-----+--------------------+-------+ + 1 row in set (0.02 sec) + +scan commands: (get help with scan:help SUBCOMMAND) + + scan:aob [--max N] Scan for a byte pattern with wildcards (AOB). + scan:next [op] [value] Narrow the results with another comparison. + scan:regex [--length N] [--max N] Scan for text matching a regular expression. + scan:results [--limit N] [--offset N]... Show the current result set, re-read. + scan:value [value] [--op OP]... Search the whole address space for a value. + +'scan:results' has 3 subcommands of its own — type 'scan:results:help'. ``` -`scan:help` opens the next layer, `scan:results:help` the one below that — or -just type the namespace, `scan`. Each listing shows one level and points at the -next, so you never read past what you came for. +`scan:results:help` opens the layer below that; `scan:help aob` describes one +command. Typing the namespace alone — `scan` — does the same as `scan:help`. +Each listing shows one level and points at the next, so you never read past +what you came for. Every namespaced command also has a short alias — `memory:read` and `read` are the same command, so the hierarchy costs nothing at the keyboard. diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index 26b010c..6f7eef7 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -21,6 +21,19 @@ from ..errors import CommandError + +@dataclass(frozen=True) +class Namespace: + """One subject the commands are grouped under.""" + + name: str + title: str + summary: str + #: A worked example for the namespace's help — a line someone would type + #: and what comes back. Indented by the renderer, so write it flush left. + example: str = "" + + #: Namespaces in the order ``help`` prints them, each with the heading it gets #: and the one line that says what it is for. A command's namespace is the part #: of its name before the first colon, so the grouping cannot drift from the @@ -30,23 +43,66 @@ #: have no namespace. They are not a subject you go looking through; they are #: the handful of words you type between doing real work, and burying them one #: level down would cost more than the tidiness is worth. -NAMESPACES: Tuple[Tuple[str, str, str], ...] = ( - ("process", "Process", "Find a target process and attach to it."), - ("memory", "Memory", "Read, write and inspect the target's memory."), - ("scan", "Scanning", "Search memory for a value, then narrow what you found."), - ( +NAMESPACES: Tuple[Namespace, ...] = ( + Namespace( + "process", + "Process", + "Find a target process and attach to it.", + "peekmem> process:list chrome\n" + "\n" + "+-------+------------+\n" + "| PID | NAME |\n" + "+-------+------------+\n" + "| 41902 | chrome.exe |\n" + "+-------+------------+\n" + "1 row in set (0.01 sec)", + ), + Namespace( + "memory", + "Memory", + "Read, write and inspect the target's memory.", + "peekmem> memory:read game.exe+0x1234 int32\n" + "\n" + "+--------------------+-------+-------+\n" + "| ADDRESS | TYPE | VALUE |\n" + "+--------------------+-------+-------+\n" + "| 0x00007FF6A41B1234 | int32 | 100 |\n" + "+--------------------+-------+-------+\n" + "1 row in set (0.00 sec)", + ), + Namespace( + "scan", + "Scanning", + "Search memory for a value, then narrow what you found.", + "peekmem> scan:value int32 100 --writable\n" + "Showing 20 of 3184 rows (1.42 sec)\n" + "\n" + "peekmem> scan:next 95\n" + "+-----+--------------------+-------+\n" + "| ROW | ADDRESS | VALUE |\n" + "+-----+--------------------+-------+\n" + "| #1 | 0x00000201A4C0F118 | 95 |\n" + "+-----+--------------------+-------+\n" + "1 row in set (0.02 sec)", + ), + Namespace( "pointer", "Pointers", "Follow pointer chains, and find ones that survive a restart.", + "peekmem> pointer:scan #1 --depth 3\n" + "+-----+-------------------+---------+--------------------+\n" + "| ROW | BASE | OFFSETS | TARGET |\n" + "+-----+-------------------+---------+--------------------+\n" + "| #1 | game.exe+0x3BA228 | 0x3E8 | 0x00000201A4C0F118 |\n" + "+-----+-------------------+---------+--------------------+\n" + "1 row in set (6.18 sec)", ), ) -_NAMESPACE_TITLES: Dict[str, str] = {name: title for name, title, _ in NAMESPACES} -_NAMESPACE_SUMMARIES: Dict[str, str] = { - name: summary for name, _, summary in NAMESPACES -} +_NAMESPACES_BY_NAME: Dict[str, Namespace] = {item.name: item for item in NAMESPACES} +_NAMESPACE_TITLES: Dict[str, str] = {item.name: item.title for item in NAMESPACES} _NAMESPACE_ORDER: Dict[str, int] = { - name: index for index, (name, _, _) in enumerate(NAMESPACES) + item.name: index for index, item in enumerate(NAMESPACES) } Handler = Callable[..., None] @@ -273,9 +329,15 @@ def top_level() -> List[Command]: return [entry for entry in all_commands() if entry.is_top_level] +def namespace(name: str) -> Optional[Namespace]: + """The declared namespace called ``name``, if there is one.""" + return _NAMESPACES_BY_NAME.get(name.strip().lower().rstrip(":")) + + def namespace_summary(name: str) -> str: """The one line describing a namespace, for the top-level help.""" - return _NAMESPACE_SUMMARIES.get(name.strip().lower().rstrip(":"), "") + entry = namespace(name) + return entry.summary if entry else "" def children(prefix: str) -> List[Command]: @@ -304,7 +366,7 @@ def children(prefix: str) -> List[Command]: def namespaces() -> List[str]: """Every namespace that has at least one command registered in it.""" known = {entry.namespace for entry in _COMMANDS.values()} - return [name for name, _, _ in NAMESPACES if name in known] + return [item.name for item in NAMESPACES if item.name in known] def command_words() -> List[str]: @@ -333,12 +395,14 @@ def option_words(name: str) -> List[str]: "Command", "CommandParser", "NAMESPACES", + "Namespace", "all_commands", "children", "command", "command_words", "describe_action", "lookup", + "namespace", "namespace_summary", "namespaces", "option_words", diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index b77f1ac..b8e68c7 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -322,7 +322,7 @@ def _scan_parser() -> CommandParser: "scan:value", parser=_scan_parser, summary="Search the whole address space for a value.", - usage="scan:value [--op eq|ne|gt|lt|ge|le] | scan:value --between A B", + usage="scan:value [value] [--op OP] [--between A B] [--writable] [--max N]", aliases=("scan", "find", "search"), details=( "The first scan of a cycle. Every matching address is kept as the " @@ -446,8 +446,7 @@ def _next_parser() -> CommandParser: "scan:next", parser=_next_parser, summary="Narrow the results with another comparison.", - usage="scan:next [op] [value] (op: eq ne gt lt ge le between changed " - "unchanged increased decreased increased-by decreased-by)", + usage="scan:next [op] [value]", aliases=("next", "refine"), details=( "Re-reads every address in the result set and keeps the ones that " diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 3d87746..7f76974 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -26,6 +26,7 @@ command, describe_action, lookup, + namespace, namespace_summary, namespaces, top_level, @@ -112,54 +113,97 @@ def _print_types(session: Session) -> None: session.printer.write() -#: Width of the canonical-name column in a listing, so the alias column lines -#: up across every section rather than jumping about per namespace. -_NAME_WIDTH = 18 +#: Widest an argument signature gets in a listing before it is cut short. The +#: listing exists to show you what a command is *called* and roughly what it +#: takes; the full argument list is one 'help ' away, so a signature +#: long enough to push every summary off the screen has stopped helping. +_SIGNATURE_WIDTH = 44 -#: Width of the alias column, padded so every section of the overview lines up -#: as one grid instead of re-aligning per namespace. -_ALIAS_WIDTH = 9 +#: Overall width these listings wrap to. Wider than the 78 used elsewhere +#: because a signature plus a summary genuinely needs the room. +_LISTING_WIDTH = 106 + + +def _signature(entry: Command, limit: int = _SIGNATURE_WIDTH) -> str: + """The command's usage line, cut at a token boundary when it runs long.""" + usage = " ".join(entry.usage.split()) + if len(usage) <= limit: + return usage + + kept: List[str] = [] + length = 0 + for token in usage.split(" "): + if kept and length + 1 + len(token) > limit - 3: + break + length += (1 if kept else 0) + len(token) + kept.append(token) + return " ".join(kept) + "..." def _command_rows(commands) -> List[Tuple[str, str]]: - """Label/summary pairs for a command listing. + """Signature/summary pairs for a command listing. - The label carries both spellings in two aligned columns — the full name - says where the command lives, the alias is what anybody actually types. + The signature rather than the bare name, so the listing answers "what does + this take?" at the same time as "what is it called" — the shape dokku's + plugin help uses, and the reason its listings are worth reading straight + through. """ - return [ - ( - f"{entry.name.ljust(_NAME_WIDTH)} {entry.short.ljust(_ALIAS_WIDTH)}", - entry.summary, - ) - for entry in commands - ] + return [(_signature(entry), entry.summary) for entry in commands] + + +def _print_example(session: Session, example: str) -> None: + """Print an indented ``Example:`` block, verbatim.""" + if not example: + return + session.printer.write("Example:") + session.printer.write() + for line in example.splitlines(): + session.printer.write(f" {line}" if line else "") + session.printer.write() def _print_overview(session: Session) -> None: """The top layer: the four subjects, and the words that drive the shell. - Deliberately not a list of every command. Thirty-four lines is a wall to - read past, not an answer; four namespaces and six commands is something you - can take in, with one obvious move to get deeper. + Deliberately not a list of every command. Thirty-five lines is a wall to + read past, not an answer; four namespaces and seven commands is something + you can take in, with one obvious move to get deeper. """ printer = session.printer + + printer.write("usage: COMMAND[:SUBCOMMAND] [arguments]") + printer.write() printer.write(f"Peekmem {__version__} — a terminal client for PyMemoryEditor.") printer.write() - printer.write("Namespaces — type ':help' to list what is in one:") + _print_example( + session, + "peekmem> process:open 4242\n" + "Attached to game.exe (PID 4242, 64-bit). (0.00 sec)\n" + "\n" + "peekmem> memory:read game.exe+0x1234 int32", + ) + + printer.write("peekmem namespaces: (get help with :help)") + printer.write() printer.write( render_definitions( [(name, namespace_summary(name)) for name in namespaces()], - label_width=10, + indent=4, + label_width=12, + total_width=_LISTING_WIDTH, ) ) printer.write() - printer.write("Commands:") + printer.write("peekmem commands: (get help with help COMMAND)") + printer.write() printer.write( render_definitions( - [(entry.name, entry.summary) for entry in top_level()], label_width=10 + [(entry.name, entry.summary) for entry in top_level()], + indent=4, + label_width=12, + total_width=_LISTING_WIDTH, ) ) printer.write() @@ -168,10 +212,6 @@ def _print_overview(session: Session) -> None: "Every command in a namespace also has a short alias: 'memory:read' " "and 'read'\nare the same command." ) - printer.write( - "Type 'help ' — or ' --help' — for a command's " - "arguments." - ) printer.write("Topics: 'help types', 'help address', 'help scanning'.") printer.write("End the session with 'exit', Ctrl+C, Ctrl+D, or \\q.") printer.write() @@ -189,20 +229,52 @@ def print_namespace(session: Session, prefix: str) -> bool: return False head = prefix.strip().lower().rstrip(":") - session.printer.write(f"Commands under '{head}:'") - session.printer.write() - session.printer.write(render_definitions(_command_rows(entries), label_width=30)) - session.printer.write() + printer = session.printer + + printer.write(f"usage: {head}[:COMMAND]") + printer.write() + + # The description and example come from the declared namespace, or — for a + # prefix that is itself a command, like 'scan:results' — from that command. + declared = namespace(head) + parent = None + if declared is None: + try: + parent = lookup(head) + except CommandError: + parent = None + + description = declared.summary if declared else (parent.summary if parent else "") + if description: + printer.write(description) + printer.write() + + if declared is not None: + _print_example(session, declared.example) + elif parent is not None and parent.examples: + _print_example(session, "peekmem> " + parent.examples[0]) + + printer.write(f"{head} commands: (get help with {head}:help SUBCOMMAND)") + printer.write() + printer.write( + render_definitions( + _command_rows(entries), + indent=4, + label_width=_SIGNATURE_WIDTH, + total_width=_LISTING_WIDTH, + ) + ) + printer.write() # Point at the next layer down rather than printing it here. for entry in entries: deeper = children(entry.name) if deeper: - session.printer.write( + printer.write( f"'{entry.name}' has {len(deeper)} subcommands of its own — " f"type '{entry.name}:help'." ) - session.printer.write() + printer.write() return True diff --git a/peekmem/shell.py b/peekmem/shell.py index e475d54..97aee48 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -173,9 +173,19 @@ def _resolve(self, word: str, args: Sequence[str]): # future, without one 'help' command per namespace cluttering the # very listings it exists to print. if head.endswith(":help"): + prefix = head[: -len(":help")] + + # 'scan:help aob' — describe one command in the namespace, + # which is what the listing header tells the reader to type. + # It prints help; it must not run the command it names. + if args: + target = lookup(f"{prefix}:{args[0]}") + lookup("help").handler(self.session, [target.name]) + raise _Handled() + from .commands.session_commands import print_namespace - if print_namespace(self.session, head[: -len(":help")]): + if print_namespace(self.session, prefix): raise _Handled() if not children(head): diff --git a/tests/test_commands.py b/tests/test_commands.py index 393a8ea..a5b2890 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -49,11 +49,12 @@ def test_only_shell_commands_are_top_level(): ] -@pytest.mark.parametrize("namespace", [name for name, _, _ in NAMESPACES]) +@pytest.mark.parametrize("namespace", [item.name for item in NAMESPACES]) def test_a_bare_namespace_lists_it(shell, capture, namespace): """Including 'scan' and 'pointer', which are command aliases as well.""" shell.run_line(namespace) - assert f"Commands under '{namespace}:'" in capture.out + assert f"usage: {namespace}[:COMMAND]" in capture.out + assert f"{namespace} commands:" in capture.out assert capture.err == "" @@ -93,7 +94,7 @@ def test_a_parent_name_is_a_prefix_of_its_children(entry): def test_namespaces_are_listed_in_the_declared_order(): - order = [name for name, _, _ in NAMESPACES] + order = [item.name for item in NAMESPACES] seen = [entry.namespace for entry in COMMANDS if not entry.is_top_level] positions = [order.index(name) for name in seen] assert positions == sorted(positions) @@ -124,18 +125,18 @@ def test_the_overview_shows_layers_not_every_command(shell, capture): assert namespace in out for entry in top_level(): assert entry.name in out - # The point of the layering: the forty-odd namespaced commands are not - # listed here, only pointed at. Checked line-first, because the prose does - # name one of them as an example of the alias rule. - listed = {line.strip().split()[0] for line in out.splitlines() if line.startswith(" ")} - assert not any(":" in item for item in listed), f"a namespaced command is listed: {listed}" - assert ":help" in out + # The point of the layering: the deeper commands are pointed at, not + # listed. A couple of them appear in the worked example, which is why this + # names ones that do not. + for hidden in ("memory:regions", "scan:results:keep", "pointer:paths:save"): + assert hidden not in out + assert ":help" in out -@pytest.mark.parametrize("namespace", [name for name, _, _ in NAMESPACES]) +@pytest.mark.parametrize("namespace", [item.name for item in NAMESPACES]) def test_namespace_help_lists_that_layer(shell, capture, namespace): shell.run_line(f"{namespace}:help") - assert f"Commands under '{namespace}:'" in capture.out + assert f"{namespace} commands:" in capture.out for entry in children(namespace): assert entry.name in capture.out assert capture.err == "" @@ -143,7 +144,7 @@ def test_namespace_help_lists_that_layer(shell, capture, namespace): def test_a_deeper_help_lists_the_third_layer(shell, capture): shell.run_line("scan:results:help") - assert "Commands under 'scan:results:'" in capture.out + assert "scan:results commands:" in capture.out assert "scan:results:keep" in capture.out @@ -154,14 +155,15 @@ def test_a_listing_points_at_the_layer_below_it(shell, capture): assert "type 'scan:results:help'" in capture.out -@pytest.mark.parametrize("namespace", [name for name, _, _ in NAMESPACES]) +@pytest.mark.parametrize("namespace", [item.name for item in NAMESPACES]) def test_every_namespace_has_commands(namespace): assert children(namespace), f"namespace {namespace!r} is empty" def test_typing_a_namespace_lists_it(shell, capture): shell.run_line("memory") - assert "Commands under 'memory:'" in capture.out + assert "usage: memory[:COMMAND]" in capture.out + assert "memory commands:" in capture.out assert "memory:read" in capture.out assert capture.err == "" @@ -180,9 +182,49 @@ def test_a_namespace_with_nonsense_arguments_is_still_explained(shell, capture): def test_help_on_a_namespace_lists_it(shell, capture): shell.run_line("help memory") + assert "memory commands:" in capture.out assert "memory:read" in capture.out +def test_a_namespace_listing_shows_argument_signatures(shell, capture): + """The dokku shape: what it is called and what it takes, in one line.""" + shell.run_line("memory:help") + assert "memory:dump
[length]" in capture.out + assert "memory:read
[type] [length]" in capture.out + + +def test_a_namespace_listing_carries_a_worked_example(shell, capture): + shell.run_line("process:help") + assert "Example:" in capture.out + assert "peekmem> process:list chrome" in capture.out + + +def test_a_long_signature_is_cut_at_a_token_boundary(shell, capture): + shell.run_line("pointer:help") + # The listing row, not the worked example above it. + line = next( + line + for line in capture.out.splitlines() + if line.strip().startswith("pointer:scan") + ) + signature = line.strip().split(" ")[0] + assert signature.endswith("...") + assert len(signature) <= 44 + assert " " not in signature.strip(), "cut mid-token" + + +def test_namespace_help_can_describe_one_subcommand(shell, capture): + """'scan:help aob' — exactly what the listing header advertises.""" + shell.run_line("scan:help aob") + assert "scan:aob — Scan for a byte pattern" in capture.out + assert "Usage: scan:aob" in capture.out + + +def test_namespace_help_with_a_bad_subcommand_is_reported(shell, capture): + assert shell.run_line("scan:help nosuch") is False + assert "Unknown command" in capture.err + + def test_a_namespace_shadowed_by_an_alias_still_announces_itself(shell, capture): """'pointer' is both an alias and a namespace; the alias wins, loudly.""" shell.run_line("help pointer") @@ -193,7 +235,7 @@ def test_a_namespace_shadowed_by_an_alias_still_announces_itself(shell, capture) @pytest.mark.parametrize("topic", ["pointer:", "scan:", "memory:"]) def test_a_trailing_colon_asks_for_the_namespace(shell, capture, topic): shell.run_line(f"help {topic}") - assert f"Commands under '{topic.rstrip(':')}:'" in capture.out + assert f"{topic.rstrip(':')} commands:" in capture.out def test_help_on_a_parent_command_lists_its_subcommands(shell, capture): From 537e03ff10039294bc3401d092236ceee16c02b6 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 18:04:08 -0300 Subject: [PATCH 11/82] refactor(commands): cap the hierarchy at two levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six commands lived a level deeper than the rest — scan:results:keep, pointer:paths:save and their siblings — which meant a listing had to be walked twice to be read once, and every rule about the help needed a paragraph about the third layer. They are now scan:keep, scan:drop, scan:reset, pointer:save, pointer:load and pointer:diff, so every command is exactly namespace:command. The short aliases people actually type (keep, drop, reset, ptrsave, ptrload, ptrdiff) are unchanged. The registry now refuses a name with two colons, with a test to hold it there, and the machinery the third level needed goes with it: the Subcommands section in a command's help, the "has N subcommands of its own" pointer under a listing, and the branch in the namespace renderer that fell back to a parent command for its description and example. `children()` is once again the plain question it should have been — what is in this namespace. scan:results:clear becomes scan:reset rather than scan:clear, which also ends the near-collision with the top-level 'clear' that wipes the terminal. --- CONTRIBUTING.md | 13 +++--- README.md | 16 +++---- peekmem/commands/__init__.py | 28 ++++++------ peekmem/commands/pointer_commands.py | 24 +++++------ peekmem/commands/scan_commands.py | 22 +++++----- peekmem/commands/session_commands.py | 36 ++-------------- tests/test_commands.py | 64 +++++++++++++++------------- 7 files changed, 89 insertions(+), 114 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c91e847..fb1a747 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,11 +116,14 @@ Two rules keep the shape: ... ``` - A name with a third segment (`scan:results:keep`) is fine: it shows up as a - **Subcommands** section under its parent's help, and `scan:results:help` - lists that layer. The `:help` form is a dispatcher convention rather - than a registered command, so it works at any depth without one `help` - command per namespace cluttering the listings it exists to print. + Names go **two levels at most** — `scan:keep`, never `scan: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. + + `:help` is a dispatcher convention rather than a registered + command, so it works for every namespace without one `help` command per + namespace cluttering the listings it exists to print. 3. Use `CommandParser`, not a bare `ArgumentParser`: it raises instead of calling `sys.exit`, which would kill the shell on a typo. diff --git a/README.md b/README.md index 4d41d66..0986b78 100644 --- a/README.md +++ b/README.md @@ -154,18 +154,18 @@ Example: scan commands: (get help with scan:help SUBCOMMAND) scan:aob [--max N] Scan for a byte pattern with wildcards (AOB). + scan:drop [row ...] Remove the named result rows. + scan:keep [row ...] Keep only the named result rows. scan:next [op] [value] Narrow the results with another comparison. scan:regex [--length N] [--max N] Scan for text matching a regular expression. + scan:reset Discard the current scan results. scan:results [--limit N] [--offset N]... Show the current result set, re-read. scan:value [value] [--op OP]... Search the whole address space for a value. - -'scan:results' has 3 subcommands of its own — type 'scan:results:help'. ``` -`scan:results:help` opens the layer below that; `scan:help aob` describes one -command. Typing the namespace alone — `scan` — does the same as `scan:help`. -Each listing shows one level and points at the next, so you never read past -what you came for. +`scan:help aob` describes one command. Typing the namespace alone — `scan` — +does the same as `scan:help`. Names go two levels at most, so there is never a +third listing to walk. Every namespaced command also has a short alias — `memory:read` and `read` are the same command, so the hierarchy costs nothing at the keyboard. @@ -174,8 +174,8 @@ the same command, so the hierarchy costs nothing at the keyboard. | --- | --- | | **`process:`** | `list` (ps) · `open` · `close` · `info` | | **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | -| **`scan:`** | `value` (scan) · `next` · `aob` · `regex` · `results` · `results:keep` (keep) · `results:drop` (drop) · `results:clear` (reset) | -| **`pointer:`** | `deref` · `read` (pointer) · `scan` (ptrscan) · `rescan` (ptrrescan) · `paths` · `paths:save` (ptrsave) · `paths:load` (ptrload) · `paths:diff` (ptrdiff) | +| **`scan:`** | `value` (scan) · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | +| **`pointer:`** | `deref` · `read` (pointer) · `scan` (ptrscan) · `rescan` (ptrrescan) · `paths` · `save` (ptrsave) · `load` (ptrload) · `diff` (ptrdiff) | | Top level | `help` · `set` · `source` · `status` · `version` · `clear` · `exit` | `help ` — or ` --help` — documents each one in full: every diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index 6f7eef7..24ed071 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -192,7 +192,7 @@ class Command: """One registered command. The ``name`` is a colon-separated path — ``memory:read``, - ``scan:results:keep`` — so related commands sort and group together and a + ``scan:keep`` — so related commands sort and group together and a name says what it acts on. The short spellings people actually type (``read``, ``keep``) are registered as aliases, which is why the hierarchy costs nothing at the keyboard. @@ -276,6 +276,13 @@ def decorator(handler: Handler) -> Handler: raise RuntimeError(f"Duplicate command name: {name}") if ":" in name and name.split(":", 1)[0] not in _NAMESPACE_TITLES: raise RuntimeError(f"Unknown namespace in command name: {name}") + # Two levels, deliberately. A third — 'scan:results:keep' — buys a + # tidier name at the cost of a listing that has to be walked twice to + # be read once, which is a bad trade for six commands. + if name.count(":") > 1: + raise RuntimeError( + f"Command names go at most one level deep: {name}" + ) entry = Command( name=name, @@ -341,26 +348,15 @@ def namespace_summary(name: str) -> str: def children(prefix: str) -> List[Command]: - """The commands one level below ``prefix``. + """The commands in the namespace ``prefix``. - One level, not all of them: ``children("scan")`` yields ``scan:results`` - but not ``scan:results:keep``. That is what makes the help layered — each - listing shows a screen you can take in, and points at the next level down - rather than dumping it. - - Works the same for a namespace (``memory``) and for a command that has - commands beneath it (``scan:results``), because it is the same question in - both cases: what can follow this word? + Names go two levels deep at most, so this is always "the commands in a + namespace" — there is no deeper layer to walk. """ head = prefix.strip().lower().rstrip(":") if not head: return [] - depth = head.count(":") + 1 - return [ - entry - for entry in all_commands() - if entry.name.startswith(head + ":") and entry.name.count(":") == depth - ] + return [entry for entry in all_commands() if entry.namespace == head] def namespaces() -> List[str]: diff --git a/peekmem/commands/pointer_commands.py b/peekmem/commands/pointer_commands.py index c9d890b..e672168 100644 --- a/peekmem/commands/pointer_commands.py +++ b/peekmem/commands/pointer_commands.py @@ -381,16 +381,16 @@ def cmd_paths(session: Session, args: List[str]) -> None: def _ptrsave_parser() -> CommandParser: - parser = CommandParser("pointer:paths:save") + parser = CommandParser("pointer:save") parser.add_argument("file", help="path of the JSON file to write") return parser @command( - "pointer:paths:save", + "pointer:save", parser=_ptrsave_parser, summary="Save the current pointer paths to a file.", - usage="pointer:paths:save ", + usage="pointer:save ", aliases=("ptrsave",), details=( "Writes the paths as JSON, keeping the module name and module-relative " @@ -402,7 +402,7 @@ def _ptrsave_parser() -> CommandParser: def cmd_ptrsave(session: Session, args: List[str]) -> None: options = _ptrsave_parser().parse_args(args) - process = session.require_process("pointer:paths:save") + process = session.require_process("pointer:save") if not session.pointer_paths: raise CommandError("No pointer paths to save.") @@ -420,16 +420,16 @@ def cmd_ptrsave(session: Session, args: List[str]) -> None: def _ptrload_parser() -> CommandParser: - parser = CommandParser("pointer:paths:load") + parser = CommandParser("pointer:load") parser.add_argument("file", help="path of a JSON file written by 'ptrsave'") return parser @command( - "pointer:paths:load", + "pointer:load", parser=_ptrload_parser, summary="Load pointer paths from a file.", - usage="pointer:paths:load ", + usage="pointer:load ", aliases=("ptrload",), details=( "Replaces the paths currently held. Each base is rebased onto the " @@ -441,7 +441,7 @@ def _ptrload_parser() -> CommandParser: def cmd_ptrload(session: Session, args: List[str]) -> None: options = _ptrload_parser().parse_args(args) - process = session.require_process("pointer:paths:load") + process = session.require_process("pointer:load") if not os.path.exists(options.file): raise CommandError(f"No such file: {options.file}") @@ -534,7 +534,7 @@ def cmd_ptrrescan(session: Session, args: List[str]) -> None: def _ptrdiff_parser() -> CommandParser: - parser = CommandParser("pointer:paths:diff") + parser = CommandParser("pointer:diff") parser.add_argument( "files", nargs="*", @@ -545,10 +545,10 @@ def _ptrdiff_parser() -> CommandParser: @command( - "pointer:paths:diff", + "pointer:diff", parser=_ptrdiff_parser, summary="Intersect pointer-path files from several runs.", - usage="pointer:paths:diff [file ...]", + usage="pointer:diff [file ...]", aliases=("ptrdiff",), details=( "Keeps only the paths present in *every* file, compared by their " @@ -563,7 +563,7 @@ def _ptrdiff_parser() -> CommandParser: def cmd_ptrdiff(session: Session, args: List[str]) -> None: options = _ptrdiff_parser().parse_args(args) - process = session.require_process("pointer:paths:diff") + process = session.require_process("pointer:diff") if len(options.files) < 2: raise CommandError("ptrdiff needs at least two files.") for name in options.files: diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index b8e68c7..3de029b 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -819,16 +819,16 @@ def _parse_row_selection(tokens: Sequence[str], count: int) -> List[int]: def _keep_parser() -> CommandParser: - parser = CommandParser("scan:results:keep") + parser = CommandParser("scan:keep") parser.add_argument("rows", nargs="+", help=_ROWS_HELP) return parser @command( - "scan:results:keep", + "scan:keep", parser=_keep_parser, summary="Keep only the named result rows.", - usage="scan:results:keep [row ...]", + usage="scan:keep [row ...]", aliases=("keep",), details=( "Use it when you can see which candidates are real and would rather " @@ -839,7 +839,7 @@ def _keep_parser() -> CommandParser: def cmd_keep(session: Session, args: List[str]) -> None: options = _keep_parser().parse_args(args) state = session.require_scan() - session.require_process("scan:results:keep") + session.require_process("scan:keep") indexes = _parse_row_selection(options.rows, len(state.addresses)) ordered = sorted(set(indexes)) @@ -854,16 +854,16 @@ def cmd_keep(session: Session, args: List[str]) -> None: def _drop_parser() -> CommandParser: - parser = CommandParser("scan:results:drop") + parser = CommandParser("scan:drop") parser.add_argument("rows", nargs="+", help=_ROWS_HELP) return parser @command( - "scan:results:drop", + "scan:drop", parser=_drop_parser, summary="Remove the named result rows.", - usage="scan:results:drop [row ...]", + usage="scan:drop [row ...]", aliases=("drop",), details="The inverse of 'keep'. Ranges work the same way.", examples=("drop 2", "drop 5-12"), @@ -871,7 +871,7 @@ def _drop_parser() -> CommandParser: def cmd_drop(session: Session, args: List[str]) -> None: options = _drop_parser().parse_args(args) state = session.require_scan() - session.require_process("scan:results:drop") + session.require_process("scan:drop") removed = set(_parse_row_selection(options.rows, len(state.addresses))) remaining = [index for index in range(len(state.addresses)) if index not in removed] @@ -886,14 +886,14 @@ def cmd_drop(session: Session, args: List[str]) -> None: def _reset_parser() -> CommandParser: - return CommandParser("scan:results:clear") + return CommandParser("scan:reset") @command( - "scan:results:clear", + "scan:reset", parser=_reset_parser, summary="Discard the current scan results.", - usage="scan:results:clear", + usage="scan:reset", aliases=("reset", "unscan"), details=( "Takes no arguments.\n\n" diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 7f76974..3e5dce6 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -234,25 +234,11 @@ def print_namespace(session: Session, prefix: str) -> bool: printer.write(f"usage: {head}[:COMMAND]") printer.write() - # The description and example come from the declared namespace, or — for a - # prefix that is itself a command, like 'scan:results' — from that command. declared = namespace(head) - parent = None - if declared is None: - try: - parent = lookup(head) - except CommandError: - parent = None - - description = declared.summary if declared else (parent.summary if parent else "") - if description: - printer.write(description) - printer.write() - if declared is not None: + printer.write(declared.summary) + printer.write() _print_example(session, declared.example) - elif parent is not None and parent.examples: - _print_example(session, "peekmem> " + parent.examples[0]) printer.write(f"{head} commands: (get help with {head}:help SUBCOMMAND)") printer.write() @@ -265,16 +251,6 @@ def print_namespace(session: Session, prefix: str) -> bool: ) ) printer.write() - - # Point at the next layer down rather than printing it here. - for entry in entries: - deeper = children(entry.name) - if deeper: - printer.write( - f"'{entry.name}' has {len(deeper)} subcommands of its own — " - f"type '{entry.name}:help'." - ) - printer.write() return True @@ -320,12 +296,6 @@ def _print_command_help(session: Session, name: str) -> None: printer.write(render_definitions(items)) printer.write() - subcommands = children(entry.name) - if subcommands: - printer.write("Subcommands:") - printer.write(render_definitions(_command_rows(subcommands), label_width=30)) - printer.write() - if entry.details: printer.write(render_paragraphs(entry.details)) printer.write() @@ -539,7 +509,7 @@ def _clear_parser() -> CommandParser: "does. Nothing about the session changes: the process stays attached, " "the scan results and pointer paths are all still there.\n\n" "To discard the scan results instead, that is 'reset' " - "(scan:results:clear).\n\n" + "(scan:reset).\n\n" "Does nothing when the output is redirected — escape codes in a log " "file would be vandalism rather than tidying." ), diff --git a/tests/test_commands.py b/tests/test_commands.py index a5b2890..580786c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -87,10 +87,25 @@ def test_every_command_keeps_a_short_alias(entry): @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) -def test_a_parent_name_is_a_prefix_of_its_children(entry): - for child in children(entry.name): - assert child.name.startswith(entry.name + ":") - assert child.namespace == entry.namespace +def test_names_go_at_most_one_level_deep(entry): + """Two levels is the ceiling: 'scan:keep', never 'scan:results:keep'. + + A third level buys a tidier name at the cost of a listing that has to be + walked twice to be read once. + """ + assert entry.name.count(":") <= 1 + + +def test_the_registry_refuses_a_third_level(): + from peekmem.commands import CommandParser, command + + with pytest.raises(RuntimeError, match="one level deep"): + command( + "scan:results:keep", + parser=lambda: CommandParser("scan:results:keep"), + summary="Never registered.", + usage="scan:results:keep", + )(lambda session, args: None) def test_namespaces_are_listed_in_the_declared_order(): @@ -106,16 +121,19 @@ def test_top_level_commands_sort_last(): assert names == sorted(names), "a namespaced command came after a top-level one" -def test_children_are_one_level_deep(): - """The layering depends on this: a listing shows a screen, not a tree.""" +def test_children_are_the_commands_of_a_namespace(): names = [entry.name for entry in children("scan")] - assert "scan:results" in names - assert "scan:results:keep" not in names - assert [entry.name for entry in children("scan:results")] == [ - "scan:results:clear", - "scan:results:drop", - "scan:results:keep", + assert names == [ + "scan:aob", + "scan:drop", + "scan:keep", + "scan:next", + "scan:regex", + "scan:reset", + "scan:results", + "scan:value", ] + assert children("scan:results") == [], "a command has no commands under it" def test_the_overview_shows_layers_not_every_command(shell, capture): @@ -128,7 +146,7 @@ def test_the_overview_shows_layers_not_every_command(shell, capture): # The point of the layering: the deeper commands are pointed at, not # listed. A couple of them appear in the worked example, which is why this # names ones that do not. - for hidden in ("memory:regions", "scan:results:keep", "pointer:paths:save"): + for hidden in ("memory:regions", "scan:keep", "pointer:save"): assert hidden not in out assert ":help" in out @@ -142,17 +160,11 @@ def test_namespace_help_lists_that_layer(shell, capture, namespace): assert capture.err == "" -def test_a_deeper_help_lists_the_third_layer(shell, capture): - shell.run_line("scan:results:help") - assert "scan:results commands:" in capture.out - assert "scan:results:keep" in capture.out - - -def test_a_listing_points_at_the_layer_below_it(shell, capture): +def test_a_namespace_listing_is_the_whole_namespace(shell, capture): + """With two levels there is nowhere deeper to point at.""" shell.run_line("scan:help") - assert "scan:results" in capture.out - assert "scan:results:keep" not in capture.out, "that is the next layer down" - assert "type 'scan:results:help'" in capture.out + for entry in children("scan"): + assert entry.name in capture.out @pytest.mark.parametrize("namespace", [item.name for item in NAMESPACES]) @@ -238,12 +250,6 @@ def test_a_trailing_colon_asks_for_the_namespace(shell, capture, topic): assert f"{topic.rstrip(':')} commands:" in capture.out -def test_help_on_a_parent_command_lists_its_subcommands(shell, capture): - shell.run_line("help scan:results") - assert "Subcommands:" in capture.out - assert "scan:results:keep" in capture.out - - @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) def test_every_command_has_help(entry, shell, capture): """'help ' must work for every command, alias included.""" From 15eaf39eb6b647291a341d55881dcbea2be51da2 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 18:23:05 -0300 Subject: [PATCH 12/82] refactor(commands): drop the short aliases, and make a namespace only ever help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The short aliases had to go: with 'read' meaning memory:read, a second namespace could never have a 'read' of its own, so the flat vocabulary the namespacing was meant to end was quietly still there, waiting for the first collision. Namespaced commands now answer to their full name alone; the shell's own top-level commands keep their shortcuts (\q, cls, \s), which are abbreviations rather than second names for something else. That also settles what a bare namespace does. 'scan' and 'pointer' were aliases as well as namespaces, so the alias won and 'scan' ran a command. A namespace now names a subject and never an action: 'scan', 'scan --help', 'scan:help' and 'help scan' all print the same page, byte for byte, and 'scan int32 100' is an error naming the spelling that was meant. In the top-level help the worked example moves inside the namespaces section, where it illustrates the thing above it instead of floating over the page. The sentence about short aliases goes with the aliases, and the line about how to leave goes too — 'exit' is in the list right above it. Everything user-facing that named a command by its short spelling now names it in full: every example, the address and scanning topics, the argument help, and the errors that tell you which command to run next. --- CONTRIBUTING.md | 9 ++- README.md | 62 +++++++++--------- peekmem/cli.py | 10 +-- peekmem/commands/__init__.py | 17 ----- peekmem/commands/memory_commands.py | 52 +++++++-------- peekmem/commands/pointer_commands.py | 47 ++++++-------- peekmem/commands/process_commands.py | 8 +-- peekmem/commands/scan_commands.py | 52 ++++++++------- peekmem/commands/session_commands.py | 58 +++++++++-------- peekmem/errors.py | 2 +- peekmem/session.py | 10 +-- peekmem/shell.py | 79 +++++++++-------------- tests/test_cli.py | 6 +- tests/test_commands.py | 96 ++++++++++++++++------------ tests/test_shell.py | 23 ++++--- 15 files changed, 247 insertions(+), 284 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb1a747..0bb050f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -84,9 +84,9 @@ Two rules keep the shape: 1. Pick the module in `peekmem/commands/` that matches the namespace. 2. Register the handler. The name is a colon-separated path whose first segment is one of the namespaces in `NAMESPACES`; the group in `help` - follows from it, so there is nothing to keep in step. Give it a plain-word - alias too — that is what people type, and a test enforces that every - command has one. + follows from it, so there is nothing to keep in step. Do **not** give it a + plain-word alias: two namespaces could each want `read`, and a test enforces + that namespaced commands have none. 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 @@ -104,9 +104,8 @@ Two rules keep the shape: parser=_mycommand_parser, summary="One line, sentence case, ending in a period.", usage="memory:mycommand
[--flag]", - aliases=("mycommand",), details="The long help, printed by 'help memory:mycommand'.", - examples=("mycommand 0x1000",), + examples=("memory:mycommand 0x1000",), ) def cmd_mycommand(session: Session, args: List[str]) -> None: options = _mycommand_parser().parse_args(args) diff --git a/README.md b/README.md index 0986b78..9874892 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ $ peekmem Welcome to Peekmem 0.1.0, a terminal client for PyMemoryEditor 2.2.0. Commands end with a newline. Type 'help' for the command list, 'help scanning' for a walkthrough, 'exit' or Ctrl+C to quit. -peekmem> ps game +peekmem> process:list game +-------+----------+ | PID | NAME | +-------+----------+ @@ -57,13 +57,13 @@ peekmem> ps game +-------+----------+ 1 row in set (0.01 sec) -peekmem> open 41902 +peekmem> process:open 41902 Attached to game.exe (PID 41902, 64-bit). (0.00 sec) -peekmem [game.exe:41902]> scan int32 100 --writable +peekmem [game.exe:41902]> scan:value int32 100 --writable Showing 20 of 3184 rows (1.42 sec) -peekmem [game.exe:41902]> next 95 +peekmem [game.exe:41902]> scan:next 95 +-----+--------------------+-------+ | ROW | ADDRESS | VALUE | +-----+--------------------+-------+ @@ -72,7 +72,7 @@ peekmem [game.exe:41902]> next 95 +-----+--------------------+-------+ 2 rows in set (0.02 sec) -peekmem [game.exe:41902]> next decreased +peekmem [game.exe:41902]> scan:next decreased +-----+--------------------+-------+ | ROW | ADDRESS | VALUE | +-----+--------------------+-------+ @@ -80,7 +80,7 @@ peekmem [game.exe:41902]> next decreased +-----+--------------------+-------+ 1 row in set (0.01 sec) -peekmem [game.exe:41902]> write #1 int32 9999 +peekmem [game.exe:41902]> memory:write #1 int32 9999 Wrote 4 byte(s) to 0x00000201A4C0F118. (0.00 sec) ``` @@ -88,7 +88,7 @@ Found the address, but it moves every launch? Find the pointer path to it, and keep it: ```console -peekmem [game.exe:41902]> ptrscan #1 --depth 3 --max 100 +peekmem [game.exe:41902]> pointer:scan #1 --depth 3 --max 100 +-----+------------------+-------------+--------------------+ | ROW | BASE | OFFSETS | TARGET | +-----+------------------+-------------+--------------------+ @@ -97,14 +97,14 @@ peekmem [game.exe:41902]> ptrscan #1 --depth 3 --max 100 +-----+------------------+-------------+--------------------+ 2 rows in set (6.18 sec) -peekmem [game.exe:41902]> ptrsave health.json +peekmem [game.exe:41902]> pointer:save health.json Saved 2 path(s) to health.json. # ... restart the target, find the value again, then: -peekmem [game.exe:52771]> ptrrescan #1 health.json +peekmem [game.exe:52771]> pointer:rescan #1 health.json 1 path(s) still reach 0x000001F73C20E118. (0.03 sec) -peekmem [game.exe:52771]> pointer game.exe+0x3BA228 0x3E8 --write 9999 +peekmem [game.exe:52771]> pointer:read game.exe+0x3BA228 0x3E8 --write 9999 Wrote 4 byte(s) to 0x000001F73C20E118. (0.00 sec) ``` @@ -114,11 +114,11 @@ The same vocabulary works non-interactively, which is the point of a CLI on a server: ```bash -peekmem ps chrome # one command, then exit -peekmem -p 4242 -e "read game.exe+0x1234 int32" # attach, read, exit -peekmem -p 4242 -e "scan int32 100" -e "results" # several, in order +peekmem process:list chrome # one command, then exit +peekmem -p 4242 -e "memory:read game.exe+0x1234" # attach, read, exit +peekmem -p 4242 -e "scan:value int32 100" -e "scan:results" # several, in order peekmem -f setup.peek # a file of commands -echo "ps" | peekmem # a pipe +echo "process:list" | peekmem # a pipe ``` Results go to stdout and errors to stderr, tables are plain ASCII, colour is @@ -163,19 +163,19 @@ scan commands: (get help with scan:help SUBCOMMAND) scan:value [value] [--op OP]... Search the whole address space for a value. ``` -`scan:help aob` describes one command. Typing the namespace alone — `scan` — -does the same as `scan:help`. Names go two levels at most, so there is never a -third listing to walk. +`scan:help aob` describes one command. Names go two levels at most, so there +is never a third listing to walk. -Every namespaced command also has a short alias — `memory:read` and `read` are -the same command, so the hierarchy costs nothing at the keyboard. +A namespace is never a command: typing `scan` prints its page and runs +nothing, whichever way you ask — `scan`, `scan --help`, `scan:help` and +`help scan` all produce the same output. -| Namespace | Commands (short alias) | +| Namespace | Commands | | --- | --- | -| **`process:`** | `list` (ps) · `open` · `close` · `info` | +| **`process:`** | `list` · `open` · `close` · `info` | | **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | -| **`scan:`** | `value` (scan) · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | -| **`pointer:`** | `deref` · `read` (pointer) · `scan` (ptrscan) · `rescan` (ptrrescan) · `paths` · `save` (ptrsave) · `load` (ptrload) · `diff` (ptrdiff) | +| **`scan:`** | `value` · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | +| **`pointer:`** | `deref` · `read` · `scan` · `rescan` · `paths` · `save` · `load` · `diff` | | Top level | `help` · `set` · `source` · `status` · `version` · `clear` · `exit` | `help ` — or ` --help` — documents each one in full: every @@ -205,17 +205,18 @@ Highlights: - **Every scan comparison PyMemoryEditor exposes** — exact, not-equal, greater, smaller, and ranges — plus the refine-only ones that need no value at all: - `next changed`, `next unchanged`, `next increased`, `next decreased`, - `next increased-by N`. -- **AOB and regex scans.** `aob "48 8B ? ? 00"` finds a signature with - wildcards; `regex "Player[0-9]+"` finds text. + `scan:next changed`, `scan:next unchanged`, `scan:next increased`, + `scan:next decreased`, `scan:next increased-by N`. +- **AOB and regex scans.** `scan:aob "48 8B ? ? 00"` finds a signature + with wildcards; `scan:regex "Player[0-9]+"` finds text. - **Thirteen value types** — `int8` … `int64`, `uint8` … `uint64`, `float`, `double`, `bool`, `string`, `bytes` — with the aliases you would expect (`dword`, `qword`, `short`, `f32`). - **Pointer scanning and the full rescan workflow**, so an address survives a restart. -- **`watch`**, which turns a terminal into a live cheat table: - `watch game.exe+0x1234 int32` prints a line every time the value changes. +- **`memory:watch`**, which turns a terminal into a live cheat table: + `memory:watch game.exe+0x1234 int32` prints a line every time the value + changes. - **Progress you can trust.** Long scans report a percentage that advances whether or not anything is being found, and Ctrl+C stops a scan while keeping what it already found. @@ -236,7 +237,8 @@ game.exe+0x1234 a module base plus a static offset — survives ASLR #3 the address on row 3 of the last scan ``` -So the whole chain fits on one line: `read [[game.exe+0x1a2b3c]+0x10]+0x8 float`. +So the whole chain fits on one line: +`memory:read [[game.exe+0x1a2b3c]+0x10]+0x8 float`. ## Permissions diff --git a/peekmem/cli.py b/peekmem/cli.py index b674df3..d39c14e 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -9,10 +9,10 @@ session or a CI job: peekmem # the shell - peekmem ps chrome # one command, then exit - peekmem -p 4242 -e "read game.exe+0x10" # attach, read, exit - peekmem -f setup.peek # a file of commands - echo "ps" | peekmem # a pipe + peekmem process:list chrome # one command, then exit + peekmem -p 4242 -e "memory:read game.exe+0x10" # attach, read, exit + peekmem -f setup.peek # a file of commands + echo "process:list" | peekmem # a pipe """ import argparse @@ -123,7 +123,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "command", nargs=argparse.REMAINDER, - help="a single command to run, e.g. 'peekmem ps chrome'", + help="a single command to run, e.g. 'peekmem process:list chrome'", ) return parser diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index 24ed071..5db595b 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -224,23 +224,6 @@ def group(self) -> str: return "Commands" return _NAMESPACE_TITLES.get(self.namespace, self.namespace.capitalize()) - @property - def short(self) -> str: - """The spelling worth advertising — the first plain-word alias. - - First, not shortest: the alias list is written most-natural-first, and - the shortest is often the cryptic one (``x`` for ``memory:dump``, - ``ptr`` for ``pointer:read``). Backslash aliases like ``\\q`` are - skipped; they are shortcuts, not names. A top-level command is already - the short spelling of itself. - """ - if self.is_top_level: - return self.name - for alias in self.aliases: - if alias.isalnum(): - return alias - return "" - def arguments(self) -> List[argparse.Action]: """Every argument this command accepts, in declaration order.""" return list(self.parser().arguments) if self.parser is not None else [] diff --git a/peekmem/commands/memory_commands.py b/peekmem/commands/memory_commands.py index 20133f9..7468073 100644 --- a/peekmem/commands/memory_commands.py +++ b/peekmem/commands/memory_commands.py @@ -88,14 +88,13 @@ def _regions_parser() -> CommandParser: parser=_regions_parser, summary="List the target's mapped memory regions.", usage="memory:regions [--writable] [--executable] [--path TEXT] [--at ADDRESS] [--limit N]", - aliases=("regions", "maps"), details=( "The memory map is re-read on every call, so it reflects allocations " "the target made since the last look.\n\n" "The PERMS column reads like /proc//maps: rwx plus 's' for a " "shared/file-backed mapping or 'p' for a private one." ), - examples=("regions --writable", "regions --at 0x7ffee3a01000", "regions --path libc"), + examples=("memory:regions --writable", "memory:regions --at 0x7ffee3a01000", "memory:regions --path libc"), ) def cmd_regions(session: Session, args: List[str]) -> None: options = _regions_parser().parse_args(args) @@ -166,7 +165,6 @@ def _modules_parser() -> CommandParser: parser=_modules_parser, summary="List the modules loaded in the target.", usage="memory:modules [pattern] [--limit N]", - aliases=("modules",), details=( "A module is the main executable or a shared library (.dll / .so / " ".dylib). Its BASE moves on every launch under ASLR, which is why an " @@ -174,7 +172,7 @@ def _modules_parser() -> CommandParser: "Running this command refreshes the module table the address parser " "uses, so run it after the target loads a library." ), - examples=("modules", "modules libc"), + examples=("memory:modules", "memory:modules libc"), ) def cmd_modules(session: Session, args: List[str]) -> None: options = _modules_parser().parse_args(args) @@ -232,7 +230,6 @@ def _threads_parser() -> CommandParser: parser=_threads_parser, summary="List the target's threads.", usage="memory:threads [--limit N]", - aliases=("threads",), details=( "STATE and PRIORITY are filled in only where the platform exposes them " "cheaply (Linux does; Windows and macOS leave them empty). The meaning " @@ -290,7 +287,6 @@ def _read_parser() -> CommandParser: parser=_read_parser, summary="Read a typed value from an address.", usage="memory:read
[type] [length] [--count N] [--hex]", - aliases=("read", "peek"), details=( "The type defaults to int32. 'string' and 'bytes' need a length in " "bytes; the fixed-width types ignore one.\n\n" @@ -298,11 +294,11 @@ def _read_parser() -> CommandParser: "chain can be read in one go." ), examples=( - "read 0x7ffee3a01000", - "read game.exe+0x1234 int32", - "read [game.exe+0x1a2b3c]+0x18 float", - "read 0x7ffee3a01000 string 32", - "read #1 int32 --count 8", + "memory:read 0x7ffee3a01000", + "memory:read game.exe+0x1234 int32", + "memory:read [game.exe+0x1a2b3c]+0x18 float", + "memory:read 0x7ffee3a01000 string 32", + "memory:read #1 int32 --count 8", ), ) def cmd_read(session: Session, args: List[str]) -> None: @@ -375,16 +371,15 @@ def _write_parser() -> CommandParser: parser=_write_parser, summary="Write a typed value to an address.", usage="memory:write
[--length N] [--null-terminated]", - aliases=("write", "poke"), details=( "There is no confirmation and no undo. Writing into a live process can " "crash it — read the address first if you are not sure of it." ), examples=( - "write 0x7ffee3a01000 int32 100", - "write game.exe+0x1234 float 99.5", - "write #2 bytes 'DE AD BE EF'", - "write 0x7ffee3a01000 string Peekmem --null-terminated", + "memory:write 0x7ffee3a01000 int32 100", + "memory:write game.exe+0x1234 float 99.5", + "memory:write #2 bytes 'DE AD BE EF'", + "memory:write 0x7ffee3a01000 string Peekmem --null-terminated", ), ) def cmd_write(session: Session, args: List[str]) -> None: @@ -441,14 +436,13 @@ def _dump_parser() -> CommandParser: parser=_dump_parser, summary="Hex-dump a range of memory.", usage="memory:dump
[length] [--width N]", - aliases=("dump", "hexdump", "x"), details=( "Prints the classic three-column layout: absolute address, hex bytes, " "printable ASCII.\n\n" "The read is a single call, so a range that crosses into an unmapped " "page fails as a whole rather than returning half the bytes." ), - examples=("dump 0x7ffee3a01000", "dump game.exe+0x1000 512", "dump #1 64 --width 8"), + examples=("memory:dump 0x7ffee3a01000", "memory:dump game.exe+0x1000 512", "memory:dump #1 64 --width 8"), ) def cmd_dump(session: Session, args: List[str]) -> None: options = _dump_parser().parse_args(args) @@ -509,7 +503,6 @@ def _watch_parser() -> CommandParser: parser=_watch_parser, summary="Poll an address and print it as it changes.", usage="memory:watch
[type] [length] [--interval S] [--count N] [--all]", - aliases=("watch",), details=( "Reads the address on a timer and prints a line per sample. By default " "only samples whose value differs from the previous one are printed, " @@ -518,9 +511,9 @@ def _watch_parser() -> CommandParser: "window while the target does its thing." ), examples=( - "watch game.exe+0x1234 int32", - "watch [base+0x10]+0x8 float --interval 0.1", - "watch #1 int32 --count 20 --all", + "memory:watch game.exe+0x1234 int32", + "memory:watch [base+0x10]+0x8 float --interval 0.1", + "memory:watch #1 int32 --count 20 --all", ), ) def cmd_watch(session: Session, args: List[str]) -> None: @@ -599,13 +592,13 @@ def _alloc_parser() -> CommandParser: parser=_alloc_parser, summary="Allocate memory inside the target.", usage="memory:alloc [--permission N]", - aliases=("alloc",), details=( "Reserves and commits SIZE bytes in the target's address space and " - "prints the base address. The region stays until 'free' releases it.\n\n" + "prints the base address. The region stays until 'memory:free' releases " + "it.\n\n" "Not available on Linux, which has no cross-process allocation syscall." ), - examples=("alloc 4096", "alloc 0x1000"), + examples=("memory:alloc 4096", "memory:alloc 0x1000"), ) def cmd_alloc(session: Session, args: List[str]) -> None: options = _alloc_parser().parse_args(args) @@ -643,7 +636,7 @@ def cmd_alloc(session: Session, args: List[str]) -> None: def _free_parser() -> CommandParser: parser = CommandParser("memory:free") - parser.add_argument("address", help="base address returned by 'alloc'") + parser.add_argument("address", help="base address returned by 'memory:alloc'") parser.add_argument( "size", nargs="?", @@ -657,11 +650,10 @@ def _free_parser() -> CommandParser: @command( "memory:free", parser=_free_parser, - summary="Release memory allocated with 'alloc'.", + summary="Release memory allocated with 'memory:alloc'.", usage="memory:free
[size]", - aliases=("free",), - details="Not available on Linux, for the same reason as 'alloc'.", - examples=("free 0x7ffee3a01000", "free 0x7ffee3a01000 4096"), + details="Not available on Linux, for the same reason as 'memory:alloc'.", + examples=("memory:free 0x7ffee3a01000", "memory:free 0x7ffee3a01000 4096"), ) def cmd_free(session: Session, args: List[str]) -> None: options = _free_parser().parse_args(args) diff --git a/peekmem/commands/pointer_commands.py b/peekmem/commands/pointer_commands.py index e672168..592642a 100644 --- a/peekmem/commands/pointer_commands.py +++ b/peekmem/commands/pointer_commands.py @@ -93,14 +93,13 @@ def _deref_parser() -> CommandParser: parser=_deref_parser, summary="Walk a pointer chain and print the address it lands on.", usage="pointer:deref [offset ...]", - aliases=("deref", "resolve"), details=( "Reads the pointer at BASE, adds the first offset, reads the pointer " "there, and so on; the last offset is added without a final read — the " "Cheat Engine convention, so a chain copied from a cheat table works " "unchanged." ), - examples=("deref game.exe+0x1a2b3c 0x10 0x8", "deref 0x7ffee3a01000 0x18"), + examples=("pointer:deref game.exe+0x1a2b3c 0x10 0x8", "pointer:deref 0x7ffee3a01000 0x18"), ) def cmd_deref(session: Session, args: List[str]) -> None: options = _deref_parser().parse_args(args) @@ -161,16 +160,15 @@ def _pointer_parser() -> CommandParser: parser=_pointer_parser, summary="Read or write the value at the end of a pointer chain.", usage="pointer:read [offset ...] [--type T] [--length N] [--write VALUE]", - aliases=("pointer", "ptr"), details=( - "The one-line form of 'deref' followed by 'read'.\n\n" + "The one-line form of 'pointer:deref' followed by 'memory:read'.\n\n" "The chain is re-walked on every call, which is the point: it keeps " "working after the target reallocates whatever the last link pointed " "at." ), examples=( - "pointer game.exe+0x1a2b3c 0x10 0x8 --type int32", - "pointer game.exe+0x1a2b3c 0x10 --write 999", + "pointer:read game.exe+0x1a2b3c 0x10 0x8 --type int32", + "pointer:read game.exe+0x1a2b3c 0x10 --write 999", ), ) def cmd_pointer(session: Session, args: List[str]) -> None: @@ -277,19 +275,18 @@ def _ptrscan_parser() -> CommandParser: parser=_ptrscan_parser, summary="Find static pointer paths that reach an address.", usage="pointer:scan
[--depth N] [--max-offset N] [--max N] [--unaligned] [--all-regions]", - aliases=("ptrscan", "pointerscan"), details=( "Builds a map of every pointer in the target and walks it backwards " "from ADDRESS until it reaches a static base inside a module. The " - "paths found replace whatever 'paths' was showing.\n\n" + "paths found replace whatever 'pointer:paths' was showing.\n\n" "This is the expensive command in Peekmem: minutes and hundreds of " "megabytes on a large target. Ctrl+C stops it and keeps the paths " "found so far.\n\n" "A path is only worth trusting once it has survived a restart: save " "the paths, restart the target, find the address again, and run " - "'ptrrescan' — see 'help ptrrescan'." + "'pointer:rescan' — see 'help pointer:rescan'." ), - examples=("ptrscan #1", "ptrscan 0x7ffee3a01000 --depth 4 --max 200"), + examples=("pointer:scan #1", "pointer:scan 0x7ffee3a01000 --depth 4 --max 200"), ) def cmd_ptrscan(session: Session, args: List[str]) -> None: options = _ptrscan_parser().parse_args(args) @@ -362,10 +359,10 @@ def _paths_parser() -> CommandParser: parser=_paths_parser, summary="Show the pointer paths currently held.", usage="pointer:paths [--limit N] [--all]", - aliases=("paths",), details=( - "Lists the paths from the last 'ptrscan', 'ptrload', 'ptrrescan' or " - "'ptrdiff'. TARGET is where each one resolves right now, so a path " + "Lists the paths from the last 'pointer:scan', 'pointer:load', " + "'pointer:rescan' or 'pointer:diff'. TARGET is where each one resolves " + "right now, so a path " "that has gone stale shows as '(unresolved)'." ), ) @@ -374,7 +371,7 @@ def cmd_paths(session: Session, args: List[str]) -> None: session.require_process("pointer:paths") if not session.pointer_paths: - raise CommandError('No pointer paths. Run "ptrscan
" first.') + raise CommandError('No pointer paths. Run "pointer:scan
" first.') limit = None if options.all else session.display_limit(options.limit) _print_paths(session, session.pointer_paths, limit=limit) @@ -391,13 +388,12 @@ def _ptrsave_parser() -> CommandParser: parser=_ptrsave_parser, summary="Save the current pointer paths to a file.", usage="pointer:save ", - aliases=("ptrsave",), details=( "Writes the paths as JSON, keeping the module name and module-relative " "offset of each base so the file survives ASLR and can be re-used " "after the target restarts." ), - examples=("ptrsave health.json",), + examples=("pointer:save health.json",), ) def cmd_ptrsave(session: Session, args: List[str]) -> None: options = _ptrsave_parser().parse_args(args) @@ -421,7 +417,7 @@ def cmd_ptrsave(session: Session, args: List[str]) -> None: def _ptrload_parser() -> CommandParser: parser = CommandParser("pointer:load") - parser.add_argument("file", help="path of a JSON file written by 'ptrsave'") + parser.add_argument("file", help="path of a JSON file written by 'pointer:save'") return parser @@ -430,13 +426,12 @@ def _ptrload_parser() -> CommandParser: parser=_ptrload_parser, summary="Load pointer paths from a file.", usage="pointer:load ", - aliases=("ptrload",), details=( "Replaces the paths currently held. Each base is rebased onto the " "module addresses of the *running* target, so a file saved before a " "restart resolves correctly after it." ), - examples=("ptrload health.json",), + examples=("pointer:load health.json",), ) def cmd_ptrload(session: Session, args: List[str]) -> None: options = _ptrload_parser().parse_args(args) @@ -490,14 +485,13 @@ def _ptrrescan_parser() -> CommandParser: parser=_ptrrescan_parser, summary="Keep only the paths that still reach an address.", usage="pointer:rescan
[file]", - aliases=("ptrrescan",), details=( "The step that separates a real pointer path from a coincidence. " "Restart the target, find the value's new address, then rescan the " "saved paths against it: the ones that still land on the address are " "the ones that describe the structure rather than that one run." ), - examples=("ptrrescan #1", "ptrrescan 0x7ffee3a01000 health.json"), + examples=("pointer:rescan #1", "pointer:rescan 0x7ffee3a01000 health.json"), ) def cmd_ptrrescan(session: Session, args: List[str]) -> None: options = _ptrrescan_parser().parse_args(args) @@ -508,7 +502,9 @@ def cmd_ptrrescan(session: Session, args: List[str]) -> None: source: Any = options.file if source is None: if not session.pointer_paths: - raise CommandError("No pointer paths to rescan. Give a file, or run 'ptrscan'.") + raise CommandError( + "No pointer paths to rescan. Give a file, or run 'pointer:scan'." + ) source = session.pointer_paths elif not os.path.exists(source): raise CommandError(f"No such file: {source}") @@ -538,7 +534,7 @@ def _ptrdiff_parser() -> CommandParser: parser.add_argument( "files", nargs="*", - help="two or more JSON files written by 'ptrsave', one per run of the " + help="two or more JSON files written by 'pointer:save', one per run of the " "target", ) return parser @@ -549,16 +545,15 @@ def _ptrdiff_parser() -> CommandParser: parser=_ptrdiff_parser, summary="Intersect pointer-path files from several runs.", usage="pointer:diff [file ...]", - aliases=("ptrdiff",), details=( "Keeps only the paths present in *every* file, compared by their " "portable recipe (module, module offset, offsets) rather than by " "absolute address. Two or three runs of the same target usually leave " "a handful of paths standing, and those are the reliable ones.\n\n" - "The result replaces the paths currently held, so 'ptrsave' can write " + "The result replaces the paths currently held, so 'pointer:save' can write " "it straight back out." ), - examples=("ptrdiff run1.json run2.json", "ptrdiff run1.json run2.json run3.json"), + examples=("pointer:diff run1.json run2.json", "pointer:diff run1.json run2.json run3.json"), ) def cmd_ptrdiff(session: Session, args: List[str]) -> None: options = _ptrdiff_parser().parse_args(args) diff --git a/peekmem/commands/process_commands.py b/peekmem/commands/process_commands.py index 30d0e37..63754ed 100644 --- a/peekmem/commands/process_commands.py +++ b/peekmem/commands/process_commands.py @@ -48,12 +48,11 @@ def _ps_parser() -> CommandParser: parser=_ps_parser, summary="List the processes visible to you.", usage="process:list [pattern] [--pid-sort] [--case-sensitive] [--limit N]", - aliases=("ps", "processes"), details=( "Only processes your user can see are listed. Run Peekmem elevated to " "see (and open) processes belonging to other users." ), - examples=("ps", "ps chrome", "ps --pid-sort --limit 50"), + examples=("process:list", "process:list chrome", "process:list --pid-sort --limit 50"), ) def cmd_ps(session: Session, args: List[str]) -> None: options = _ps_parser().parse_args(args) @@ -132,7 +131,6 @@ def _open_parser() -> CommandParser: parser=_open_parser, summary="Attach to a process by PID or name.", usage="process:open [-i] [--partial] [--strict-bitness]", - aliases=("open", "attach", "use"), details=( "An all-digits target is taken as a PID, anything else as a process " "name; force either reading with --pid or --name.\n\n" @@ -140,7 +138,7 @@ def _open_parser() -> CommandParser: "pointer width is silent rather than loud.\n\n" "Attaching replaces any previous target and clears the scan results." ), - examples=("open 4242", "open notepad.exe", "open chrome --partial -i"), + examples=("process:open 4242", "process:open notepad.exe", "process:open chrome --partial -i"), ) def cmd_open(session: Session, args: List[str]) -> None: options = _open_parser().parse_args(args) @@ -193,7 +191,6 @@ def _close_parser() -> CommandParser: parser=_close_parser, summary="Detach from the current process.", usage="process:close", - aliases=("close", "detach"), details=( "Takes no arguments.\n\n" "Closes the OS handle and drops the scan results, the pointer paths " @@ -262,7 +259,6 @@ def _info_parser() -> CommandParser: parser=_info_parser, summary="Describe the attached process in detail.", usage="process:info", - aliases=("info",), details=( "Takes no arguments.\n\n" "Enumerates the memory map to report how much of the address space is " diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index 3de029b..e31d377 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -323,19 +323,19 @@ def _scan_parser() -> CommandParser: parser=_scan_parser, summary="Search the whole address space for a value.", usage="scan:value [value] [--op OP] [--between A B] [--writable] [--max N]", - aliases=("scan", "find", "search"), details=( "The first scan of a cycle. Every matching address is kept as the " - "result set that 'next', 'results' and the '#N' address form work " + "result set that 'scan:next', 'scan:results' and the '#N' address form " + "work " "on.\n\n" "Ctrl+C stops a scan and keeps what it had already found." ), examples=( - "scan int32 100", - "scan float 99.5 --writable", - "scan int32 --between 100 200", - "scan string Peekmem", - "scan int32 1000 --op gt", + "scan:value int32 100", + "scan:value float 99.5 --writable", + "scan:value int32 --between 100 200", + "scan:value string Peekmem", + "scan:value int32 1000 --op gt", ), ) def cmd_scan(session: Session, args: List[str]) -> None: @@ -447,10 +447,9 @@ def _next_parser() -> CommandParser: parser=_next_parser, summary="Narrow the results with another comparison.", usage="scan:next [op] [value]", - aliases=("next", "refine"), details=( "Re-reads every address in the result set and keeps the ones that " - "still match. Bare 'next 100' means 'next eq 100'.\n\n" + "still match. Bare 'scan:next 100' means 'scan:next eq 100'.\n\n" "Comparisons against a value you supply:\n\n" " eq ne gt lt ge le VALUE the usual six\n" " between A B inside the range, inclusive\n" @@ -463,7 +462,7 @@ def _next_parser() -> CommandParser: "Addresses that have become unreadable (the target freed them) are " "dropped." ), - examples=("next 95", "next changed", "next decreased", "next gt 50", "next between 10 20"), + examples=("scan:next 95", "scan:next changed", "scan:next decreased", "scan:next gt 50", "scan:next between 10 20"), ) def cmd_next(session: Session, args: List[str]) -> None: options = _next_parser().parse_args(args) @@ -491,15 +490,17 @@ def cmd_next(session: Session, args: List[str]) -> None: if operation == "between": if len(operands) != 2: - raise CommandError("'next between' takes two values: next between A B.") + raise CommandError( + "'scan:next between' takes two values: scan:next between A B." + ) low = value_type.parse(operands[0]) high = value_type.parse(operands[1]) elif needs_value: if len(operands) != 1: - raise CommandError(f"'next {operation}' takes exactly one value.") + raise CommandError(f"'scan:next {operation}' takes exactly one value.") target = value_type.parse(operands[0]) elif operands: - raise CommandError(f"'next {operation}' takes no value.") + raise CommandError(f"'scan:next {operation}' takes no value.") with Timer() as timer: current = _read_values(session, value_type, state.width, state.addresses) @@ -580,14 +581,13 @@ def _aob_parser() -> CommandParser: parser=_aob_parser, summary="Scan for a byte pattern with wildcards (AOB).", usage="scan:aob [--max N]", - aliases=("aob", "pattern"), details=( "This is how you find code that moves between builds: the opcodes stay " "put while the operands change, so you wildcard the operands. The " "result set holds the address of each match and can be refined with " - "'next' or read with 'read #1'." + "'scan:next' or read with 'memory:read #1'." ), - examples=('aob "48 8B ? ? 00 00"', 'aob "DE AD BE EF"'), + examples=('scan:aob "48 8B ? ? 00 00"', 'scan:aob "DE AD BE EF"'), ) def cmd_aob(session: Session, args: List[str]) -> None: options = _aob_parser().parse_args(args) @@ -650,7 +650,6 @@ def _regex_parser() -> CommandParser: parser=_regex_parser, summary="Scan for text matching a regular expression.", usage="scan:regex [--length N] [--max N]", - aliases=("regex",), details=( "Because the match runs over *bytes*, a metacharacter spans one byte: " "'.' matches any single byte and '\\d' is ASCII-only, so quantify with " @@ -658,7 +657,10 @@ def _regex_parser() -> CommandParser: "A regex has no fixed width, which is why --length matters: it is what " "lets a match straddling an internal chunk boundary still be found." ), - examples=('regex "Player[0-9]+"', 'regex "https?://[a-z.]+" --length 128'), + examples=( + 'scan:regex "Player[0-9]+"', + 'scan:regex "https?://[a-z.]+" --length 128', + ), ) def cmd_regex(session: Session, args: List[str]) -> None: options = _regex_parser().parse_args(args) @@ -730,16 +732,15 @@ def _results_parser() -> CommandParser: parser=_results_parser, summary="Show the current result set, re-read.", usage="scan:results [--limit N] [--offset N] [--all]", - aliases=("results", "res"), details=( "Reads every address again, so the VALUE column is what the target " "holds now, not what it held when the scan ran. The PREVIOUS column " - "shows the value the last scan recorded — the one 'next changed' and " + "shows the value the last scan recorded — the one 'scan:next changed' and " "friends compare against — and is filled in only where the two " "differ.\n\n" "Row numbers are what '#N' refers to in an address." ), - examples=("results", "results --all", "results --offset 20 --limit 10"), + examples=("scan:results", "scan:results --all", "scan:results --offset 20 --limit 10"), ) def cmd_results(session: Session, args: List[str]) -> None: options = _results_parser().parse_args(args) @@ -811,7 +812,7 @@ def _parse_row_selection(tokens: Sequence[str], count: int) -> List[int]: selected.append(number - 1) if not selected: - raise CommandError("Name at least one row, e.g. 'keep 1 3-5'.") + raise CommandError("Name at least one row, e.g. 'scan:keep 1 3-5'.") return selected @@ -829,12 +830,11 @@ def _keep_parser() -> CommandParser: parser=_keep_parser, summary="Keep only the named result rows.", usage="scan:keep [row ...]", - aliases=("keep",), details=( "Use it when you can see which candidates are real and would rather " "not invent a comparison that happens to exclude the others." ), - examples=("keep 1", "keep 1 3 7-9"), + examples=("scan:keep 1", "scan:keep 1 3 7-9"), ) def cmd_keep(session: Session, args: List[str]) -> None: options = _keep_parser().parse_args(args) @@ -864,9 +864,8 @@ def _drop_parser() -> CommandParser: parser=_drop_parser, summary="Remove the named result rows.", usage="scan:drop [row ...]", - aliases=("drop",), details="The inverse of 'keep'. Ranges work the same way.", - examples=("drop 2", "drop 5-12"), + examples=("scan:drop 2", "scan:drop 5-12"), ) def cmd_drop(session: Session, args: List[str]) -> None: options = _drop_parser().parse_args(args) @@ -894,7 +893,6 @@ def _reset_parser() -> CommandParser: parser=_reset_parser, summary="Discard the current scan results.", usage="scan:reset", - aliases=("reset", "unscan"), details=( "Takes no arguments.\n\n" "Clears the result set so the next 'scan' starts a fresh cycle. The " diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 3e5dce6..4af3a57 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -43,8 +43,8 @@ #3 the address on row 3 of the last scan Module names are matched case-insensitively, and an unambiguous prefix is -enough: 'game' finds 'game.exe'. Run 'modules' to list them, and to refresh -the table after the target loads a library. +enough: 'game' finds 'game.exe'. Run 'memory:modules' to list them, and to +refresh the table after the target loads a library. 'module+offset' is the form worth writing down: a module's base moves on every launch under ASLR, but the offset inside it does not, so the expression keeps @@ -54,24 +54,26 @@ _SCANNING_TOPIC = """\ The scan / refine cycle, when you do not know the address: - 1. scan int32 100 every address holding 100 right now + 1. scan:value int32 100 every address holding 100 right now 2. (make the value change in the target) - 3. next 95 of those, the ones now holding 95 + 3. scan:next 95 of those, the ones now holding 95 Repeat step 3 until a handful of rows remain. When you cannot see the value — a health bar with no number — compare against the previous reading instead: - next changed / next unchanged / next increased / next decreased + scan:next changed / scan:next unchanged + scan:next increased / scan:next decreased Then read, write or watch a surviving row by number: - read #1 int32 - write #1 int32 999 - watch #1 int32 + memory:read #1 int32 + memory:write #1 int32 999 + memory:watch #1 int32 An address found this way is good for this run only. To keep it, find the -pointer path that reaches it: 'ptrscan #1', then 'ptrsave', restart the -target, and 'ptrrescan' against the value's new address. See 'help ptrscan'.\ +pointer path that reaches it: 'pointer:scan #1', then 'pointer:save', restart +the target, and 'pointer:rescan' against the value's new address. See +'help pointer:scan'.\ """ _TOPICS = { @@ -151,14 +153,18 @@ def _command_rows(commands) -> List[Tuple[str, str]]: return [(_signature(entry), entry.summary) for entry in commands] -def _print_example(session: Session, example: str) -> None: - """Print an indented ``Example:`` block, verbatim.""" +def _print_example(session: Session, example: str, *, indent: int = 0) -> None: + """Print an indented ``Example:`` block, verbatim. + + ``indent`` nests the whole block, which is how the top-level help tucks its + example inside the namespaces section rather than floating it above. + """ if not example: return - session.printer.write("Example:") - session.printer.write() + pad = " " * indent + session.printer.write(f"{pad}Example:") for line in example.splitlines(): - session.printer.write(f" {line}" if line else "") + session.printer.write(f"{pad} {line}" if line else "") session.printer.write() @@ -176,14 +182,6 @@ def _print_overview(session: Session) -> None: printer.write(f"Peekmem {__version__} — a terminal client for PyMemoryEditor.") printer.write() - _print_example( - session, - "peekmem> process:open 4242\n" - "Attached to game.exe (PID 4242, 64-bit). (0.00 sec)\n" - "\n" - "peekmem> memory:read game.exe+0x1234 int32", - ) - printer.write("peekmem namespaces: (get help with :help)") printer.write() printer.write( @@ -196,6 +194,15 @@ def _print_overview(session: Session) -> None: ) printer.write() + # The example belongs *inside* the namespaces block: it is what typing one + # of those namespaced commands looks like, not a preamble to the page. + _print_example( + session, + "peekmem> process:open 4242\n" + "Attached to game.exe (PID 4242, 64-bit). (0.00 sec)", + indent=4, + ) + printer.write("peekmem commands: (get help with help COMMAND)") printer.write() printer.write( @@ -208,12 +215,7 @@ def _print_overview(session: Session) -> None: ) printer.write() - printer.write( - "Every command in a namespace also has a short alias: 'memory:read' " - "and 'read'\nare the same command." - ) printer.write("Topics: 'help types', 'help address', 'help scanning'.") - printer.write("End the session with 'exit', Ctrl+C, Ctrl+D, or \\q.") printer.write() diff --git a/peekmem/errors.py b/peekmem/errors.py index b9d342a..6b0de02 100644 --- a/peekmem/errors.py +++ b/peekmem/errors.py @@ -31,7 +31,7 @@ class NoProcessError(CommandError): def __init__(self, command: str = ""): detail = f" Command {command!r} needs a target." if command else "" super().__init__( - "No process attached." + detail + ' Use "open " first.' + "No process attached." + detail + ' Use "process:open " first.' ) diff --git a/peekmem/session.py b/peekmem/session.py index d71b5f5..d0e5faa 100644 --- a/peekmem/session.py +++ b/peekmem/session.py @@ -54,8 +54,8 @@ class Setting: Setting("timing", True, bool, "Print the elapsed time after each command."), Setting("progress", True, bool, "Show a progress line while scanning."), Setting("writable_only", False, bool, "Scan only writable regions (faster)."), - Setting("dump_width", 16, int, "Bytes per line in 'dump' output."), - Setting("watch_interval", 0.5, float, "Seconds between 'watch' samples."), + Setting("dump_width", 16, int, "Bytes per line in 'memory:dump' output."), + Setting("watch_interval", 0.5, float, "Seconds between 'memory:watch' samples."), ) _SETTINGS_BY_NAME = {setting.name: setting for setting in SETTINGS} @@ -301,7 +301,7 @@ def module_base(self, name: str) -> int: raise CommandError(f"Module {name!r} is ambiguous: {listed}.") raise CommandError( - f"No loaded module matches {name!r}. Use 'modules' to list them." + f"No loaded module matches {name!r}. Use 'memory:modules' to list them." ) def read_pointer(self, address: int) -> int: @@ -320,7 +320,7 @@ def result_address(self, index: int) -> int: """The address on row ``index`` (1-based) of the last scan.""" if self.scan is None or not self.scan.addresses: raise CommandError( - "No scan results to refer to. Run 'scan' first, or give an " + "No scan results to refer to. Run 'scan:value' first, or give an " "address instead of a '#' reference." ) if not 1 <= index <= len(self.scan.addresses): @@ -356,7 +356,7 @@ def store_scan( def require_scan(self) -> ScanState: """Return the current result set or explain that there is not one.""" if self.scan is None or not self.scan.addresses: - raise CommandError('No scan results. Run "scan " first.') + raise CommandError('No scan results. Run "scan:value " first.') return self.scan def close(self) -> None: diff --git a/peekmem/shell.py b/peekmem/shell.py index 97aee48..29fe425 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -25,7 +25,6 @@ from . import __version__, valuetypes from .commands import ( all_commands, - children, command_words, lookup, namespaces, @@ -144,72 +143,52 @@ def run_line(self, line: str, *, raise_errors: bool = False) -> bool: return False def _resolve(self, word: str, args: Sequence[str]): - """Resolve a command word, treating a bare namespace as a request to list it. + """Resolve a command word, answering every namespace question the same way. - ``memory`` is not a command, but in a namespaced shell it is an obvious - thing to type — so it prints what lives under it rather than a "no such - command", exactly as ``memory:help`` does. With arguments it is a - mistake worth naming precisely: the user almost always meant the colon. + A namespace is not a command and never runs anything. Naming one — + ``scan``, ``scan --help``, ``scan:help``, or ``help scan`` — prints its + page, all four spellings producing the same output, because a reader + who tries one of them has already told you what they want. + + With arguments that are not a help flag it is a mistake worth naming + precisely: the user almost always meant the colon. """ head = word.strip().lower() + wants_help = bool(args) and all(item in _HELP_FLAGS for item in args) + + # ':help [command]' — the form the listings advertise. + if head.endswith(":help") and head[: -len(":help")] in namespaces(): + prefix = head[: -len(":help")] + if args and not wants_help: + # 'scan:help aob' describes one command; it must not run it. + target = lookup(f"{prefix}:{args[0]}") + lookup("help").handler(self.session, [target.name]) + raise _Handled() + head, args = prefix, () - # A bare namespace lists what is in it even when the word is *also* a - # command alias, which 'scan' and 'pointer' are. Nothing is lost: both - # of those commands require arguments, so a bare 'scan' could only ever - # have produced "the following arguments are required". With arguments - # the alias still wins, so 'scan int32 100' is untouched. - if not args and head in namespaces(): - from .commands.session_commands import print_namespace - - print_namespace(self.session, head) - raise _Handled() - - try: - return lookup(word) - except CommandError: - - # ':help' — the layered way down. It is a convention rather - # than a registered command so it works at every depth, present and - # future, without one 'help' command per namespace cluttering the - # very listings it exists to print. - if head.endswith(":help"): - prefix = head[: -len(":help")] - - # 'scan:help aob' — describe one command in the namespace, - # which is what the listing header tells the reader to type. - # It prints help; it must not run the command it names. - if args: - target = lookup(f"{prefix}:{args[0]}") - lookup("help").handler(self.session, [target.name]) - raise _Handled() - - from .commands.session_commands import print_namespace - - if print_namespace(self.session, prefix): - raise _Handled() - - if not children(head): - raise - - if not args: + if head.rstrip(":") in namespaces(): + namespace_name = head.rstrip(":") + if not args or wants_help: from .commands.session_commands import print_namespace - print_namespace(self.session, head) + print_namespace(self.session, namespace_name) raise _Handled() - candidate = f"{head}:{args[0]}" + candidate = f"{namespace_name}:{args[0]}" try: lookup(candidate) except CommandError: raise CommandError( - f"{head!r} is a namespace, not a command. " - f"Type 'help {head}' to see what is in it." + f"{namespace_name!r} is a namespace, not a command. " + f"Type '{namespace_name}:help' to see what is in it." ) raise CommandError( - f"{head!r} is a namespace: the command is spelled " + f"{namespace_name!r} is a namespace: the command is spelled " f"{candidate!r}, with a colon." ) + return lookup(word) + def run_lines(self, lines: Iterable[str], *, raise_errors: bool = False) -> int: """Run a sequence of lines, returning a process exit status.""" for line in lines: diff --git a/tests/test_cli.py b/tests/test_cli.py index 3000099..2b48a36 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,13 +40,13 @@ def test_execute_flags_run_in_order(): def test_a_trailing_command_works_like_execute(): - status, out, _ = run(["ps", "--limit", "1"]) + status, out, _ = run(["process:list", "--limit", "1"]) assert status == 0 assert "PID" in out def test_a_failing_command_exits_non_zero(): - status, _, err = run(["-e", "read 0x10"]) + status, _, err = run(["-e", "memory:read 0x10"]) assert status == 1 assert "No process attached" in err @@ -76,7 +76,7 @@ def test_a_bad_pid_stops_before_the_commands(): def test_limit_flag_reaches_the_session(): - status, out, _ = run(["--limit", "1", "-e", "ps"]) + status, out, _ = run(["--limit", "1", "-e", "process:list"]) assert status == 0 assert "Showing 1 of" in out diff --git a/tests/test_commands.py b/tests/test_commands.py index 580786c..005ede1 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -59,10 +59,11 @@ def test_a_bare_namespace_lists_it(shell, capture, namespace): @pytest.mark.parametrize("line", ["scan int32 100", "pointer 0x10"]) -def test_an_alias_that_shadows_a_namespace_still_runs_with_arguments(shell, line): - """The listing rule applies to the bare word only; the command is intact.""" - with pytest.raises(NoProcessError): +def test_a_namespace_never_runs_anything(shell, line): + """A namespace names a subject, not an action — with or without arguments.""" + with pytest.raises(CommandError) as error: shell.run_line(line, raise_errors=True) + assert "is a namespace" in str(error.value) def test_clear_leaves_the_session_alone(shell, capture): @@ -76,14 +77,15 @@ def test_clear_leaves_the_session_alone(shell, capture): @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) -def test_every_command_keeps_a_short_alias(entry): - """The hierarchy must cost nothing at the keyboard. +def test_a_namespaced_command_has_no_plain_word_alias(entry): + """One namespace's 'read' must not claim the word from another's. - Every command has to stay reachable by a plain word — 'read', not - 'memory:read' — or the namespacing would have made the shell worse to use. + Only the shell's own top-level commands keep short spellings, and those are + shortcuts (\\q, cls) rather than second names for a namespaced command. """ - assert entry.short, f"{entry.name} has no plain-word alias" - assert lookup(entry.short).name == entry.name + if entry.is_top_level: + return + assert entry.aliases == (), f"{entry.name} still answers to {entry.aliases}" @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) @@ -189,7 +191,7 @@ def test_a_namespace_with_arguments_points_at_the_colon(shell, capture): def test_a_namespace_with_nonsense_arguments_is_still_explained(shell, capture): assert shell.run_line("memory nonsense") is False assert "namespace" in capture.err - assert "help memory" in capture.err + assert "memory:help" in capture.err def test_help_on_a_namespace_lists_it(shell, capture): @@ -237,11 +239,15 @@ def test_namespace_help_with_a_bad_subcommand_is_reported(shell, capture): assert "Unknown command" in capture.err -def test_a_namespace_shadowed_by_an_alias_still_announces_itself(shell, capture): - """'pointer' is both an alias and a namespace; the alias wins, loudly.""" - shell.run_line("help pointer") - assert "pointer:read" in capture.out, "the alias resolves to the command" - assert "is also a namespace" in capture.out +@pytest.mark.parametrize( + "line", ["scan", "scan --help", "scan -h", "scan:help", "help scan"] +) +def test_every_way_of_asking_about_a_namespace_agrees(shell, capture, line): + """Five spellings, one page — whichever a reader reaches for.""" + shell.run_line(line) + assert capture.out.startswith("usage: scan[:COMMAND]") + assert "scan commands:" in capture.out + assert capture.err == "" @pytest.mark.parametrize("topic", ["pointer:", "scan:", "memory:"]) @@ -303,23 +309,24 @@ def test_usage_line_advertises_only_real_flags(entry): @pytest.mark.parametrize("flag", ["--help", "-h"]) def test_a_command_can_be_asked_for_its_own_help(shell, capture, flag): - shell.run_line(f"scan {flag}") + shell.run_line(f"scan:value {flag}") assert "Search the whole address space" in capture.out assert "--between A B" in capture.out def test_help_flag_wins_over_a_bad_argument(shell, capture): - """'scan --help' must explain, not complain about the missing value.""" - assert shell.run_line("scan --help") is True + """'scan:value --help' must explain, not complain about the missing value.""" + assert shell.run_line("scan:value --help") is True assert capture.err == "" def test_option_words_come_from_the_parser(): from peekmem.commands import option_words - assert "--writable" in option_words("scan") - assert "--between" in option_words("find") # an alias resolves too + assert "--writable" in option_words("scan:value") + assert "--between" in option_words("scan:value") assert option_words("nosuchcommand") == [] + assert option_words("scan") == [], "a namespace has no options of its own" def test_command_words_are_unique(): @@ -339,22 +346,22 @@ def test_examples_parse_as_commands(entry, shell): @pytest.mark.parametrize( "line", [ - "read 0x10", - "write 0x10 int32 1", - "dump 0x10", - "regions", - "modules", - "threads", - "scan int32 1", - "aob 'DE AD'", - "regex abc", - "deref 0x10", - "pointer 0x10", - "ptrscan 0x10", - "alloc 16", - "free 0x10", - "watch 0x10", - "info", + "memory:read 0x10", + "memory:write 0x10 int32 1", + "memory:dump 0x10", + "memory:regions", + "memory:modules", + "memory:threads", + "scan:value int32 1", + "scan:aob 'DE AD'", + "scan:regex abc", + "pointer:deref 0x10", + "pointer:read 0x10", + "pointer:scan 0x10", + "memory:alloc 16", + "memory:free 0x10", + "memory:watch 0x10", + "process:info", ], ) def test_commands_needing_a_target_refuse_without_one(shell, line): @@ -364,7 +371,14 @@ def test_commands_needing_a_target_refuse_without_one(shell, line): @pytest.mark.parametrize( "line", - ["next 1", "results", "keep 1", "drop 1", "paths", "ptrsave out.json"], + [ + "scan:next 1", + "scan:results", + "scan:keep 1", + "scan:drop 1", + "pointer:paths", + "pointer:save out.json", + ], ) def test_commands_needing_results_refuse_without_them(shell, line): with pytest.raises(CommandError): @@ -373,7 +387,7 @@ def test_commands_needing_results_refuse_without_them(shell, line): def test_close_without_a_target_is_an_error(shell): with pytest.raises(CommandError): - shell.run_line("close", raise_errors=True) + shell.run_line("process:close", raise_errors=True) def test_status_works_with_no_target(shell, capture): @@ -386,7 +400,7 @@ def test_ps_lists_this_process(shell, capture): import os shell.run_line("set limit 0") - shell.run_line("ps") + shell.run_line("process:list") assert str(os.getpid()) in capture.out @@ -405,13 +419,13 @@ def test_set_accepts_the_equals_form(shell): def test_unknown_option_is_reported_not_swallowed(shell): with pytest.raises(CommandError): - shell.run_line("ps --nosuchflag", raise_errors=True) + shell.run_line("process:list --nosuchflag", raise_errors=True) def test_reset_reports_what_it_discarded(shell, capture): from peekmem import valuetypes shell.session.store_scan(valuetypes.resolve("int32"), 4, [1, 2], [0, 0], "t") - shell.run_line("reset") + shell.run_line("scan:reset") assert "Discarded 2 result(s)." in capture.out assert shell.session.scan is None diff --git a/tests/test_shell.py b/tests/test_shell.py index 9f8d9cb..9b5c25d 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -11,11 +11,14 @@ @pytest.mark.parametrize( "line,expected", [ - ("ps", ("ps", [])), - (" ps chrome ", ("ps", ["chrome"])), - ("ps chrome;", ("ps", ["chrome"])), - ("ps chrome ;;", ("ps", ["chrome"])), - ("write 0x10 bytes 'DE AD'", ("write", ["0x10", "bytes", "DE AD"])), + ("process:list", ("process:list", [])), + (" process:list chrome ", ("process:list", ["chrome"])), + ("process:list chrome;", ("process:list", ["chrome"])), + ("process:list chrome ;;", ("process:list", ["chrome"])), + ( + "memory:write 0x10 bytes 'DE AD'", + ("memory:write", ["0x10", "bytes", "DE AD"]), + ), ("\\q", ("\\q", [])), ("source \\.", ("source", ["."])), ], @@ -31,23 +34,23 @@ def test_blank_and_comment_lines_are_skipped(line): def test_unbalanced_quotes_are_reported(): with pytest.raises(CommandError): - Shell.split("scan string 'unclosed") + Shell.split("scan:value string 'unclosed") def test_unknown_command_suggests_a_near_miss(shell, capture): - assert shell.run_line("scna int32 1") is False - assert "Did you mean 'scan'" in capture.err + assert shell.run_line("memory:raed 0x10") is False + assert "Did you mean 'memory:read'" in capture.err def test_a_failing_command_does_not_end_the_session(shell, capture): - assert shell.run_line("read 0x10") is False + assert shell.run_line("memory:read 0x10") is False assert shell.run_line("version") is True assert "Peekmem" in capture.out def test_errors_can_be_raised_instead_of_printed(shell): with pytest.raises(CommandError): - shell.run_line("read 0x10", raise_errors=True) + shell.run_line("memory:read 0x10", raise_errors=True) def test_exit_unwinds_the_loop(shell): From 2a8278be6fa4afd113847c80bef392bd4ffbab64 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 18:42:31 -0300 Subject: [PATCH 13/82] feat(commands): page every listing, and generate the usage line from the parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems with one cause. Three commands accepted flags their usage line never mentioned — memory:regions had --shared, scan:value had --outside, --all-regions and --length, process:open had four — because the line was written by hand next to a parser that kept growing. It is generated from the parser now, so a flag that exists is a flag that shows, and a test asserts the two agree in both directions. The hand-written wording survives where it was carrying real information, moved into metavars: 'process:open ', 'scan:keep [row ...]'. The listings that report "Showing n of m rows" could only ever show the first page. process:list, memory:regions, memory:modules, memory:threads and pointer:paths now take --limit, --offset and --all like scan:results already did — declared once in add_paging_arguments so the three flags cannot drift apart in wording or behaviour, with a test pinning that down. A truncated table now ends with the command for the next page, spelled out: Showing 2 of 327 rows (0.01 sec) Next page: memory:modules --offset 2 --limit 2 The preview printed after a scan names 'scan:results --offset N' rather than itself, because re-running a scan to see its second page would be absurd. --- CONTRIBUTING.md | 25 +++++- README.md | 6 +- peekmem/commands/__init__.py | 114 ++++++++++++++++++++++++++- peekmem/commands/memory_commands.py | 83 +++++++++---------- peekmem/commands/pointer_commands.py | 63 +++++++-------- peekmem/commands/process_commands.py | 32 ++++---- peekmem/commands/scan_commands.py | 91 +++++++++------------ peekmem/commands/session_commands.py | 20 +++-- peekmem/output.py | 16 ++-- peekmem/shell.py | 2 +- tests/test_commands.py | 105 +++++++++++++++++++++--- tests/test_shell.py | 1 - 12 files changed, 376 insertions(+), 182 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0bb050f..b827123 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,7 +103,6 @@ Two rules keep the shape: "memory:mycommand", parser=_mycommand_parser, summary="One line, sentence case, ending in a period.", - usage="memory:mycommand
[--flag]", details="The long help, printed by 'help memory:mycommand'.", examples=("memory:mycommand 0x1000",), ) @@ -115,6 +114,30 @@ Two rules keep the shape: ... ``` + There is no `usage=`: the usage line is generated from the parser, so it + always names every flag the command accepts. Give each argument a `help=` + and a readable `metavar` — 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 `--limit` of your own: + + ```python + def _mycommand_parser() -> CommandParser: + return add_paging_arguments(CommandParser("memory:mycommand")) + + + page = paginate( + session, rows, command="memory:mycommand", + limit=options.limit, offset=options.offset, show_all=options.all, + ) + session.printer.table(headers, page.rows, total=page.total, + next_page=page.next_page) + ``` + + That gives the same three flags, the same wording and the same + `Next page: ...` footer as every other listing — and a test enforces that + the wording does not drift. + Names go **two levels at most** — `scan:keep`, never `scan: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 diff --git a/README.md b/README.md index 9874892..b991933 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ pip install "peekmem[speed]" ```console $ peekmem Welcome to Peekmem 0.1.0, a terminal client for PyMemoryEditor 2.2.0. -Commands end with a newline. Type 'help' for the command list, 'help scanning' for a walkthrough, 'exit' or Ctrl+C to quit. +Type 'help' for the command list, 'help scanning' for a walkthrough, 'exit' or Ctrl+C to quit. peekmem> process:list game +-------+----------+ @@ -220,6 +220,10 @@ Highlights: - **Progress you can trust.** Long scans report a percentage that advances whether or not anything is being found, and Ctrl+C stops a scan while keeping what it already found. +- **Every listing pages the same way.** `--limit`, `--offset` and `--all` on + each of them, and a truncated table ends with the command that shows the + next page — `Next page: memory:regions --offset 20` — so it is a copy-paste, + not a puzzle. - **Ctrl+C means the obvious thing.** During a command it abandons that command and returns to the prompt; at the prompt it quits. So stopping a scan costs one keystroke and leaving costs two, and neither one loses your results by diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index 5db595b..d79cb4d 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -17,7 +17,7 @@ import argparse import difflib from dataclasses import dataclass, field -from typing import Callable, Dict, List, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple from ..errors import CommandError @@ -168,6 +168,34 @@ def describe_action(action: argparse.Action) -> str: return f"{flags} {value}" if value else flags +def usage_token(action: argparse.Action) -> str: + """Render one argument as it appears in a ``usage:`` line. + + Positionals come out as ``
`` or ``[length]``, options as + ``[--limit N]``. Long flags win over short ones: a usage line is read, not + typed, and ``--ignore-case`` says what it does where ``-i`` does not. + """ + value = _value_placeholder(action) + + if not action.option_strings: + name = action.metavar or action.dest + if isinstance(name, tuple): + name = " ".join(name) + if action.nargs == "?": + return f"[{name}]" + if action.nargs == "*": + return f"[{name} ...]" + if action.nargs == "+": + return f"<{name}> [{name} ...]" + return f"<{name}>" + + flag = next( + (item for item in action.option_strings if item.startswith("--")), + action.option_strings[0], + ) + return f"[{flag} {value}]" if value else f"[{flag}]" + + def _value_placeholder(action: argparse.Action) -> str: """The ``VALUE`` part of ``--flag VALUE``, or '' for a flag that takes none.""" if action.nargs == 0: # store_true / store_false @@ -201,12 +229,21 @@ class Command: name: str handler: Handler summary: str - usage: str parser: Optional[ParserFactory] = None aliases: Tuple[str, ...] = () details: str = "" examples: Tuple[str, ...] = field(default=()) + @property + def usage(self) -> str: + """The usage line, built from the parser. + + Generated rather than written by hand, because a hand-written one drifts: + three commands had grown flags their usage line never mentioned. There is + now nothing to keep in step — a flag that exists is a flag that shows. + """ + return " ".join([self.name] + [usage_token(a) for a in self.arguments()]) + @property def namespace(self) -> str: """The first segment of the name, or ``""`` for a top-level command.""" @@ -237,7 +274,6 @@ def command( name: str, *, summary: str, - usage: str, parser: Optional[ParserFactory] = None, aliases: Sequence[str] = (), details: str = "", @@ -271,7 +307,6 @@ def decorator(handler: Handler) -> Handler: name=name, handler=handler, summary=summary, - usage=usage, parser=parser, aliases=tuple(aliases), details=details, @@ -364,6 +399,73 @@ def option_words(name: str) -> List[str]: ) +@dataclass(frozen=True) +class Page: + """One screen of a longer listing, plus the way to ask for the next.""" + + rows: List[Any] + total: int + #: The command line that shows the following page, or ``None`` at the end. + next_page: Optional[str] = None + + +def add_paging_arguments(parser: CommandParser) -> CommandParser: + """Give a listing command the same three paging flags as every other one. + + Declared in one place so the wording, the behaviour and the help text + cannot drift between commands — a listing that pages differently from its + neighbour is a listing you have to learn twice. + """ + parser.add_argument( + "--limit", + type=int, + default=None, + metavar="N", + help="rows per page, overriding the 'limit' setting", + ) + parser.add_argument( + "--offset", + type=int, + default=0, + metavar="N", + help="skip the first N rows — how you reach the second page", + ) + parser.add_argument( + "--all", action="store_true", help="print every row, ignoring the limit" + ) + return parser + + +def paginate( + session: Any, + entries: Sequence[Any], + *, + command: str, + limit: Optional[int] = None, + offset: int = 0, + show_all: bool = False, +) -> Page: + """Cut ``entries`` down to one page, and name the command for the next one. + + ``command`` is what the reader would type to page on; it is spelled out in + the footer so the next page is a copy-paste rather than a puzzle. + """ + if offset < 0: + raise CommandError("--offset cannot be negative.") + + total = len(entries) + size = None if show_all else session.display_limit(limit) + if size is None: + return Page(list(entries[offset:]), total) + + window = list(entries[offset : offset + size]) + following = offset + size + next_page = f"{command} --offset {following}" if following < total else None + if next_page is not None and limit is not None: + next_page += f" --limit {limit}" + return Page(window, total, next_page) + + from . import memory_commands # noqa: E402,F401 (registration side effect) from . import pointer_commands # noqa: E402,F401 from . import process_commands # noqa: E402,F401 @@ -375,6 +477,8 @@ def option_words(name: str) -> List[str]: "CommandParser", "NAMESPACES", "Namespace", + "Page", + "add_paging_arguments", "all_commands", "children", "command", @@ -385,5 +489,7 @@ def option_words(name: str) -> List[str]: "namespace_summary", "namespaces", "option_words", + "paginate", + "usage_token", "top_level", ) diff --git a/peekmem/commands/memory_commands.py b/peekmem/commands/memory_commands.py index 7468073..d1d1734 100644 --- a/peekmem/commands/memory_commands.py +++ b/peekmem/commands/memory_commands.py @@ -17,7 +17,7 @@ from ..output import LEFT, RIGHT, Timer, format_address, format_size, render_hexdump from ..session import Session from ..valuetypes import ValueType -from . import CommandParser, command +from . import CommandParser, add_paging_arguments, command, paginate #: The help text shared by every argument that takes an address expression. _ADDRESS_HELP = ( @@ -73,21 +73,13 @@ def _regions_parser() -> CommandParser: metavar="ADDRESS", help="show only the region containing this address", ) - parser.add_argument( - "--limit", - type=int, - default=None, - metavar="N", - help="print at most N rows, overriding the 'limit' setting", - ) - return parser + return add_paging_arguments(parser) @command( "memory:regions", parser=_regions_parser, summary="List the target's mapped memory regions.", - usage="memory:regions [--writable] [--executable] [--path TEXT] [--at ADDRESS] [--limit N]", details=( "The memory map is re-read on every call, so it reflects allocations " "the target made since the last look.\n\n" @@ -121,8 +113,14 @@ def cmd_regions(session: Session, args: List[str]) -> None: if region.address <= address < region.address + region.size ] - limit = session.display_limit(options.limit) - shown = regions[:limit] if limit else regions + page = paginate( + session, + regions, + command="memory:regions", + limit=options.limit, + offset=options.offset, + show_all=options.all, + ) pointer_size = process.pointer_size session.printer.table( @@ -134,11 +132,12 @@ def cmd_regions(session: Session, args: List[str]) -> None: _permissions(region), region.path, ) - for region in shown + for region in page.rows ], (LEFT, RIGHT, LEFT, LEFT), elapsed=timer.elapsed, - total=len(regions), + total=page.total, + next_page=page.next_page, ) @@ -150,21 +149,13 @@ def _modules_parser() -> CommandParser: default=None, help="keep modules whose name or path contains this text", ) - parser.add_argument( - "--limit", - type=int, - default=None, - metavar="N", - help="print at most N rows, overriding the 'limit' setting", - ) - return parser + return add_paging_arguments(parser) @command( "memory:modules", parser=_modules_parser, summary="List the modules loaded in the target.", - usage="memory:modules [pattern] [--limit N]", details=( "A module is the main executable or a shared library (.dll / .so / " ".dylib). Its BASE moves on every launch under ASLR, which is why an " @@ -192,8 +183,14 @@ def cmd_modules(session: Session, args: List[str]) -> None: if needle in module.name.lower() or needle in module.path.lower() ] - limit = session.display_limit(options.limit) - shown = modules[:limit] if limit else modules + page = paginate( + session, + modules, + command="memory:modules", + limit=options.limit, + offset=options.offset, + show_all=options.all, + ) pointer_size = process.pointer_size session.printer.table( @@ -205,31 +202,24 @@ def cmd_modules(session: Session, args: List[str]) -> None: format_size(module.size) if module.size else "?", module.path, ) - for module in shown + for module in page.rows ], (LEFT, LEFT, RIGHT, LEFT), elapsed=timer.elapsed, - total=len(modules), + total=page.total, + next_page=page.next_page, ) def _threads_parser() -> CommandParser: parser = CommandParser("memory:threads") - parser.add_argument( - "--limit", - type=int, - default=None, - metavar="N", - help="print at most N rows, overriding the 'limit' setting", - ) - return parser + return add_paging_arguments(parser) @command( "memory:threads", parser=_threads_parser, summary="List the target's threads.", - usage="memory:threads [--limit N]", details=( "STATE and PRIORITY are filled in only where the platform exposes them " "cheaply (Linux does; Windows and macOS leave them empty). The meaning " @@ -245,8 +235,14 @@ def cmd_threads(session: Session, args: List[str]) -> None: with Timer() as timer: threads = list(process.get_threads()) - limit = session.display_limit(options.limit) - shown = threads[:limit] if limit else threads + page = paginate( + session, + threads, + command="memory:threads", + limit=options.limit, + offset=options.offset, + show_all=options.all, + ) session.printer.table( ("TID", "STATE", "PRIORITY"), @@ -256,11 +252,12 @@ def cmd_threads(session: Session, args: List[str]) -> None: thread.state if thread.state is not None else "", thread.priority if thread.priority is not None else "", ) - for thread in shown + for thread in page.rows ], (RIGHT, LEFT, RIGHT), elapsed=timer.elapsed, - total=len(threads), + total=page.total, + next_page=page.next_page, ) @@ -286,7 +283,6 @@ def _read_parser() -> CommandParser: "memory:read", parser=_read_parser, summary="Read a typed value from an address.", - usage="memory:read
[type] [length] [--count N] [--hex]", details=( "The type defaults to int32. 'string' and 'bytes' need a length in " "bytes; the fixed-width types ignore one.\n\n" @@ -370,7 +366,6 @@ def _write_parser() -> CommandParser: "memory:write", parser=_write_parser, summary="Write a typed value to an address.", - usage="memory:write
[--length N] [--null-terminated]", details=( "There is no confirmation and no undo. Writing into a live process can " "crash it — read the address first if you are not sure of it." @@ -435,7 +430,6 @@ def _dump_parser() -> CommandParser: "memory:dump", parser=_dump_parser, summary="Hex-dump a range of memory.", - usage="memory:dump
[length] [--width N]", details=( "Prints the classic three-column layout: absolute address, hex bytes, " "printable ASCII.\n\n" @@ -502,7 +496,6 @@ def _watch_parser() -> CommandParser: "memory:watch", parser=_watch_parser, summary="Poll an address and print it as it changes.", - usage="memory:watch
[type] [length] [--interval S] [--count N] [--all]", details=( "Reads the address on a timer and prints a line per sample. By default " "only samples whose value differs from the previous one are printed, " @@ -591,7 +584,6 @@ def _alloc_parser() -> CommandParser: "memory:alloc", parser=_alloc_parser, summary="Allocate memory inside the target.", - usage="memory:alloc [--permission N]", details=( "Reserves and commits SIZE bytes in the target's address space and " "prints the base address. The region stays until 'memory:free' releases " @@ -651,7 +643,6 @@ def _free_parser() -> CommandParser: "memory:free", parser=_free_parser, summary="Release memory allocated with 'memory:alloc'.", - usage="memory:free
[size]", details="Not available on Linux, for the same reason as 'memory:alloc'.", examples=("memory:free 0x7ffee3a01000", "memory:free 0x7ffee3a01000 4096"), ) diff --git a/peekmem/commands/pointer_commands.py b/peekmem/commands/pointer_commands.py index 592642a..51cdc90 100644 --- a/peekmem/commands/pointer_commands.py +++ b/peekmem/commands/pointer_commands.py @@ -22,7 +22,7 @@ from ..errors import CommandError from ..output import LEFT, RIGHT, Timer, format_address from ..session import Session -from . import CommandParser, command +from . import CommandParser, add_paging_arguments, command, paginate _BASE_HELP = ( "the static base of the chain, as an address expression — usually " @@ -53,15 +53,24 @@ def _print_paths( session: Session, paths: Sequence[PointerPath], *, - limit: Optional[int], + limit: Optional[int] = None, + offset: int = 0, + show_all: bool = False, elapsed: Optional[float] = None, ) -> None: process = session.require_process() pointer_size = process.pointer_size - shown = paths[:limit] if limit else paths + page = paginate( + session, + paths, + command="pointer:paths", + limit=limit, + offset=offset, + show_all=show_all, + ) rows = [] - for index, path in enumerate(shown): + for index, path in enumerate(page.rows, start=offset): try: target = format_address(path.resolve(process), pointer_size) except (OSError, ValueError): @@ -77,14 +86,15 @@ def _print_paths( rows, (RIGHT, LEFT, LEFT, LEFT), elapsed=elapsed, - total=len(paths), + total=page.total, + next_page=page.next_page, ) def _deref_parser() -> CommandParser: parser = CommandParser("pointer:deref") parser.add_argument("base", help=_BASE_HELP) - parser.add_argument("offsets", nargs="*", help=_OFFSETS_HELP) + parser.add_argument("offsets", nargs="*", metavar="offset", help=_OFFSETS_HELP) return parser @@ -92,7 +102,6 @@ def _deref_parser() -> CommandParser: "pointer:deref", parser=_deref_parser, summary="Walk a pointer chain and print the address it lands on.", - usage="pointer:deref [offset ...]", details=( "Reads the pointer at BASE, adds the first offset, reads the pointer " "there, and so on; the last offset is added without a final read — the " @@ -131,7 +140,7 @@ def cmd_deref(session: Session, args: List[str]) -> None: def _pointer_parser() -> CommandParser: parser = CommandParser("pointer:read") parser.add_argument("base", help=_BASE_HELP) - parser.add_argument("offsets", nargs="*", help=_OFFSETS_HELP) + parser.add_argument("offsets", nargs="*", metavar="offset", help=_OFFSETS_HELP) parser.add_argument( "--type", dest="value_type", @@ -159,7 +168,6 @@ def _pointer_parser() -> CommandParser: "pointer:read", parser=_pointer_parser, summary="Read or write the value at the end of a pointer chain.", - usage="pointer:read [offset ...] [--type T] [--length N] [--write VALUE]", details=( "The one-line form of 'pointer:deref' followed by 'memory:read'.\n\n" "The chain is re-walked on every call, which is the point: it keeps " @@ -274,7 +282,6 @@ def _ptrscan_parser() -> CommandParser: "pointer:scan", parser=_ptrscan_parser, summary="Find static pointer paths that reach an address.", - usage="pointer:scan
[--depth N] [--max-offset N] [--max N] [--unaligned] [--all-regions]", details=( "Builds a map of every pointer in the target and walks it backwards " "from ADDRESS until it reaches a static base inside a module. The " @@ -336,29 +343,17 @@ def on_progress(fraction: float) -> None: "larger --max-offset, or check that the address is still valid." ) - _print_paths(session, paths, limit=session.display_limit(), elapsed=timer.elapsed) + _print_paths(session, paths, elapsed=timer.elapsed) def _paths_parser() -> CommandParser: - parser = CommandParser("pointer:paths") - parser.add_argument( - "--limit", - type=int, - default=None, - metavar="N", - help="print at most N rows, overriding the 'limit' setting", - ) - parser.add_argument( - "--all", action="store_true", help="print every path, ignoring the limit" - ) - return parser + return add_paging_arguments(CommandParser("pointer:paths")) @command( "pointer:paths", parser=_paths_parser, summary="Show the pointer paths currently held.", - usage="pointer:paths [--limit N] [--all]", details=( "Lists the paths from the last 'pointer:scan', 'pointer:load', " "'pointer:rescan' or 'pointer:diff'. TARGET is where each one resolves " @@ -373,8 +368,13 @@ def cmd_paths(session: Session, args: List[str]) -> None: if not session.pointer_paths: raise CommandError('No pointer paths. Run "pointer:scan
" first.') - limit = None if options.all else session.display_limit(options.limit) - _print_paths(session, session.pointer_paths, limit=limit) + _print_paths( + session, + session.pointer_paths, + limit=options.limit, + offset=options.offset, + show_all=options.all, + ) def _ptrsave_parser() -> CommandParser: @@ -387,7 +387,6 @@ def _ptrsave_parser() -> CommandParser: "pointer:save", parser=_ptrsave_parser, summary="Save the current pointer paths to a file.", - usage="pointer:save ", details=( "Writes the paths as JSON, keeping the module name and module-relative " "offset of each base so the file survives ASLR and can be re-used " @@ -425,7 +424,6 @@ def _ptrload_parser() -> CommandParser: "pointer:load", parser=_ptrload_parser, summary="Load pointer paths from a file.", - usage="pointer:load ", details=( "Replaces the paths currently held. Each base is rebased onto the " "module addresses of the *running* target, so a file saved before a " @@ -460,7 +458,7 @@ def cmd_ptrload(session: Session, args: List[str]) -> None: f"Loaded {len(rebased)} path(s) from {options.file}.", elapsed=timer.elapsed ) session.printer.write() - _print_paths(session, rebased, limit=session.display_limit()) + _print_paths(session, rebased) def _ptrrescan_parser() -> CommandParser: @@ -484,7 +482,6 @@ def _ptrrescan_parser() -> CommandParser: "pointer:rescan", parser=_ptrrescan_parser, summary="Keep only the paths that still reach an address.", - usage="pointer:rescan
[file]", details=( "The step that separates a real pointer path from a coincidence. " "Restart the target, find the value's new address, then rescan the " @@ -526,7 +523,7 @@ def cmd_ptrrescan(session: Session, args: List[str]) -> None: elapsed=timer.elapsed, ) session.printer.write() - _print_paths(session, surviving, limit=session.display_limit()) + _print_paths(session, surviving) def _ptrdiff_parser() -> CommandParser: @@ -534,6 +531,7 @@ def _ptrdiff_parser() -> CommandParser: parser.add_argument( "files", nargs="*", + metavar="file", help="two or more JSON files written by 'pointer:save', one per run of the " "target", ) @@ -544,7 +542,6 @@ def _ptrdiff_parser() -> CommandParser: "pointer:diff", parser=_ptrdiff_parser, summary="Intersect pointer-path files from several runs.", - usage="pointer:diff [file ...]", details=( "Keeps only the paths present in *every* file, compared by their " "portable recipe (module, module offset, offsets) rather than by " @@ -577,7 +574,7 @@ def cmd_ptrdiff(session: Session, args: List[str]) -> None: elapsed=timer.elapsed, ) session.printer.write() - _print_paths(session, common, limit=session.display_limit()) + _print_paths(session, common) __all__ = () diff --git a/peekmem/commands/process_commands.py b/peekmem/commands/process_commands.py index 63754ed..68ee49e 100644 --- a/peekmem/commands/process_commands.py +++ b/peekmem/commands/process_commands.py @@ -11,7 +11,7 @@ from ..errors import CommandError from ..output import LEFT, RIGHT, Timer, format_size, render_vertical from ..session import Session -from . import CommandParser, command +from . import CommandParser, add_paging_arguments, command, paginate def _ps_parser() -> CommandParser: @@ -33,21 +33,13 @@ def _ps_parser() -> CommandParser: action="store_true", help="match the pattern case-sensitively", ) - parser.add_argument( - "--limit", - type=int, - default=None, - metavar="N", - help="print at most N rows, overriding the 'limit' setting", - ) - return parser + return add_paging_arguments(parser) @command( "process:list", parser=_ps_parser, summary="List the processes visible to you.", - usage="process:list [pattern] [--pid-sort] [--case-sensitive] [--limit N]", details=( "Only processes your user can see are listed. Run Peekmem elevated to " "see (and open) processes belonging to other users." @@ -64,18 +56,25 @@ def cmd_ps(session: Session, args: List[str]) -> None: sort_by="pid" if options.pid_sort else "name", ) - limit = session.display_limit(options.limit) - shown = entries[:limit] if limit else entries + page = paginate( + session, + entries, + command="process:list", + limit=options.limit, + offset=options.offset, + show_all=options.all, + ) session.printer.table( ("PID", "NAME"), # A blank name means the OS would not tell us — macOS does that for # some system processes. Print a placeholder so the column is never # mistaken for an empty string the process actually has. - [(pid, name or "?") for pid, name in shown], + [(pid, name or "?") for pid, name in page.rows], (RIGHT, LEFT), elapsed=timer.elapsed, - total=len(entries), + total=page.total, + next_page=page.next_page, ) @@ -85,6 +84,7 @@ def _open_parser() -> CommandParser: "target", nargs="?", default=None, + metavar="pid|name", help="the PID (all digits) or the process name to attach to", ) parser.add_argument( @@ -130,7 +130,6 @@ def _open_parser() -> CommandParser: "process:open", parser=_open_parser, summary="Attach to a process by PID or name.", - usage="process:open [-i] [--partial] [--strict-bitness]", details=( "An all-digits target is taken as a PID, anything else as a process " "name; force either reading with --pid or --name.\n\n" @@ -190,7 +189,6 @@ def _close_parser() -> CommandParser: "process:close", parser=_close_parser, summary="Detach from the current process.", - usage="process:close", details=( "Takes no arguments.\n\n" "Closes the OS handle and drops the scan results, the pointer paths " @@ -213,7 +211,6 @@ def _status_parser() -> CommandParser: "status", parser=_status_parser, summary="Show the session state and versions.", - usage="status", aliases=("\\s",), details=( "Takes no arguments.\n\n" @@ -258,7 +255,6 @@ def _info_parser() -> CommandParser: "process:info", parser=_info_parser, summary="Describe the attached process in detail.", - usage="process:info", details=( "Takes no arguments.\n\n" "Enumerates the memory map to report how much of the address space is " diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index e31d377..ae75b66 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -30,7 +30,7 @@ from ..output import LEFT, RIGHT, Timer, format_address from ..session import ScanState, Session from ..valuetypes import ValueType -from . import CommandParser, command +from . import CommandParser, add_paging_arguments, command, paginate #: Bytes of address space handed to the library per call. Large enough that #: per-call overhead is noise next to the scan itself, small enough that the @@ -239,31 +239,38 @@ def _print_results( session: Session, state: ScanState, *, - limit: Optional[int], - elapsed: Optional[float], + limit: Optional[int] = None, + elapsed: Optional[float] = None, offset: int = 0, ) -> None: + """Print the result set, one page at a time. + + The next page is fetched with ``scan:results``, not by re-running the scan + — which is why the hint names that command whatever produced the rows. + """ process = session.require_process() hex_output = bool(session.option("hex")) - display_limit = session.display_limit(limit) + indexes = range(len(state.addresses)) + page = paginate( + session, indexes, command="scan:results", limit=limit, offset=offset + ) - end = len(state.addresses) if display_limit is None else offset + display_limit - rows = [] - for index in range(offset, min(end, len(state.addresses))): - rows.append( - ( - f"#{index + 1}", - format_address(state.addresses[index], process.pointer_size), - state.value_type.format(state.values[index], hex_output=hex_output), - ) + rows = [ + ( + f"#{index + 1}", + format_address(state.addresses[index], process.pointer_size), + state.value_type.format(state.values[index], hex_output=hex_output), ) + for index in page.rows + ] session.printer.table( ("ROW", "ADDRESS", "VALUE"), rows, (RIGHT, LEFT, LEFT), elapsed=elapsed, - total=len(state.addresses), + total=page.total, + next_page=page.next_page, ) @@ -322,7 +329,6 @@ def _scan_parser() -> CommandParser: "scan:value", parser=_scan_parser, summary="Search the whole address space for a value.", - usage="scan:value [value] [--op OP] [--between A B] [--writable] [--max N]", details=( "The first scan of a cycle. Every matching address is kept as the " "result set that 'scan:next', 'scan:results' and the '#N' address form " @@ -446,7 +452,6 @@ def _next_parser() -> CommandParser: "scan:next", parser=_next_parser, summary="Narrow the results with another comparison.", - usage="scan:next [op] [value]", details=( "Re-reads every address in the result set and keeps the ones that " "still match. Bare 'scan:next 100' means 'scan:next eq 100'.\n\n" @@ -562,7 +567,7 @@ def cmd_next(session: Session, args: List[str]) -> None: description, ) - _print_results(session, new_state, limit=None, elapsed=timer.elapsed) + _print_results(session, new_state, elapsed=timer.elapsed) def _aob_parser() -> CommandParser: @@ -580,7 +585,6 @@ def _aob_parser() -> CommandParser: "scan:aob", parser=_aob_parser, summary="Scan for a byte pattern with wildcards (AOB).", - usage="scan:aob [--max N]", details=( "This is how you find code that moves between builds: the opcodes stay " "put while the operands change, so you wildcard the operands. The " @@ -649,7 +653,6 @@ def _regex_parser() -> CommandParser: "scan:regex", parser=_regex_parser, summary="Scan for text matching a regular expression.", - usage="scan:regex [--length N] [--max N]", details=( "Because the match runs over *bytes*, a metacharacter spans one byte: " "'.' matches any single byte and '\\d' is ASCII-only, so quantify with " @@ -706,32 +709,13 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: def _results_parser() -> CommandParser: - parser = CommandParser("scan:results") - parser.add_argument( - "--limit", - type=int, - default=None, - metavar="N", - help="print at most N rows, overriding the 'limit' setting", - ) - parser.add_argument( - "--offset", - type=int, - default=0, - metavar="N", - help="start at row N+1, for paging through a long result set", - ) - parser.add_argument( - "--all", action="store_true", help="print every row, ignoring the limit" - ) - return parser + return add_paging_arguments(CommandParser("scan:results")) @command( "scan:results", parser=_results_parser, summary="Show the current result set, re-read.", - usage="scan:results [--limit N] [--offset N] [--all]", details=( "Reads every address again, so the VALUE column is what the target " "holds now, not what it held when the scan ran. The PREVIOUS column " @@ -749,12 +733,15 @@ def cmd_results(session: Session, args: List[str]) -> None: process = session.require_process("scan:results") hex_output = bool(session.option("hex")) - if options.offset < 0: - raise CommandError("--offset cannot be negative.") - - limit = None if options.all else session.display_limit(options.limit) - end = len(state.addresses) if limit is None else options.offset + limit - window = list(range(options.offset, min(end, len(state.addresses)))) + page = paginate( + session, + range(len(state.addresses)), + command="scan:results", + limit=options.limit, + offset=options.offset, + show_all=options.all, + ) + window = list(page.rows) with Timer() as timer: current = _read_values( @@ -784,7 +771,8 @@ def cmd_results(session: Session, args: List[str]) -> None: rows, (RIGHT, LEFT, LEFT, LEFT), elapsed=timer.elapsed, - total=len(state.addresses), + total=page.total, + next_page=page.next_page, ) @@ -821,7 +809,7 @@ def _parse_row_selection(tokens: Sequence[str], count: int) -> List[int]: def _keep_parser() -> CommandParser: parser = CommandParser("scan:keep") - parser.add_argument("rows", nargs="+", help=_ROWS_HELP) + parser.add_argument("rows", nargs="+", metavar="row", help=_ROWS_HELP) return parser @@ -829,7 +817,6 @@ def _keep_parser() -> CommandParser: "scan:keep", parser=_keep_parser, summary="Keep only the named result rows.", - usage="scan:keep [row ...]", details=( "Use it when you can see which candidates are real and would rather " "not invent a comparison that happens to exclude the others." @@ -850,12 +837,12 @@ def cmd_keep(session: Session, args: List[str]) -> None: [state.values[index] for index in ordered], f"{state.description} → kept {len(ordered)} row(s)", ) - _print_results(session, new_state, limit=None, elapsed=None) + _print_results(session, new_state) def _drop_parser() -> CommandParser: parser = CommandParser("scan:drop") - parser.add_argument("rows", nargs="+", help=_ROWS_HELP) + parser.add_argument("rows", nargs="+", metavar="row", help=_ROWS_HELP) return parser @@ -863,7 +850,6 @@ def _drop_parser() -> CommandParser: "scan:drop", parser=_drop_parser, summary="Remove the named result rows.", - usage="scan:drop [row ...]", details="The inverse of 'keep'. Ranges work the same way.", examples=("scan:drop 2", "scan:drop 5-12"), ) @@ -881,7 +867,7 @@ def cmd_drop(session: Session, args: List[str]) -> None: [state.values[index] for index in remaining], f"{state.description} → dropped {len(removed)} row(s)", ) - _print_results(session, new_state, limit=None, elapsed=None) + _print_results(session, new_state) def _reset_parser() -> CommandParser: @@ -892,7 +878,6 @@ def _reset_parser() -> CommandParser: "scan:reset", parser=_reset_parser, summary="Discard the current scan results.", - usage="scan:reset", details=( "Takes no arguments.\n\n" "Clears the result set so the next 'scan' starts a fresh cycle. The " diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 4af3a57..40a0358 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -4,6 +4,7 @@ import os import platform +import textwrap from typing import List, Tuple import PyMemoryEditor @@ -288,7 +289,18 @@ def _print_command_help(session: Session, name: str) -> None: printer.write(f"{entry.name} — {entry.summary}") printer.write() - printer.write(f"Usage: {entry.usage}") + # A generated usage line lists every flag, so it can outrun the terminal. + # Wrap it under a hanging indent rather than letting the terminal fold it + # at an arbitrary column. + printer.write( + textwrap.fill( + f"Usage: {entry.usage}", + width=_LISTING_WIDTH, + subsequent_indent=" ", + break_long_words=False, + break_on_hyphens=False, + ) + ) if entry.aliases: printer.write(f"Aliases: {', '.join(entry.aliases)}") printer.write() @@ -324,7 +336,6 @@ def _help_parser() -> CommandParser: "help", parser=_help_parser, summary="List the commands, or describe one.", - usage="help [command|types|address|scanning]", aliases=("?", "\\h"), details=( "With a command name, prints that command's usage, every argument and " @@ -403,7 +414,6 @@ def _set_parser() -> CommandParser: "set", parser=_set_parser, summary="Show or change a session setting.", - usage="set [name [value]]", details=( "Settings live for the session only — Peekmem writes no config file, " "so a fresh shell always starts from the documented defaults. Put the " @@ -465,7 +475,6 @@ def _source_parser() -> CommandParser: "source", parser=_source_parser, summary="Run the commands in a file.", - usage="source ", aliases=("\\.",), details=( "Reads the file and runs each line as if it had been typed.\n\n" @@ -503,7 +512,6 @@ def _clear_parser() -> CommandParser: "clear", parser=_clear_parser, summary="Clear the terminal.", - usage="clear", aliases=("cls",), details=( "Takes no arguments.\n\n" @@ -529,7 +537,6 @@ def _version_parser() -> CommandParser: "version", parser=_version_parser, summary="Print the Peekmem and PyMemoryEditor versions.", - usage="version", details=( "Takes no arguments.\n\n" "The one line to quote in a bug report: it names Peekmem, " @@ -554,7 +561,6 @@ def _exit_parser() -> CommandParser: "exit", parser=_exit_parser, summary="Leave the shell.", - usage="exit", aliases=("quit", "\\q"), details=( "Takes no arguments.\n\n" diff --git a/peekmem/output.py b/peekmem/output.py index 387eea5..697b01f 100644 --- a/peekmem/output.py +++ b/peekmem/output.py @@ -268,18 +268,19 @@ def table( *, elapsed: Optional[float] = None, total: Optional[int] = None, + next_page: Optional[str] = None, ) -> None: """Print a result table plus its footer. ``total`` names the number of rows that *matched* when ``rows`` only - carries the ones that fit the display limit, so the footer can say - ``20 rows in set (of 1043)`` instead of pretending the rest do not - exist. + carries the ones that fit the display limit, so the footer can say how + many were left out instead of pretending they do not exist, and + ``next_page`` is the command that shows them. """ self.clear_progress() if rows: self.write(render_table(headers, rows, aligns)) - self.footer(len(rows), elapsed=elapsed, total=total) + self.footer(len(rows), elapsed=elapsed, total=total, next_page=next_page) def footer( self, @@ -287,8 +288,9 @@ def footer( *, elapsed: Optional[float] = None, total: Optional[int] = None, + next_page: Optional[str] = None, ) -> None: - """Print the ``N rows in set (0.01 sec)`` line.""" + """Print the ``N rows in set (0.01 sec)`` line, and how to see more.""" if count == 0 and not total: text = "Empty set" elif total is not None and total != count: @@ -300,6 +302,10 @@ def footer( if self.timing and elapsed is not None: text += f" ({format_duration(elapsed)})" self.write(text) + if next_page: + # A line the reader can copy, rather than a hint they have to + # translate into flags. + self.write(f"Next page: {next_page}") self.write() def ok(self, message: str, *, elapsed: Optional[float] = None) -> None: diff --git a/peekmem/shell.py b/peekmem/shell.py index 29fe425..5b491a9 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -217,7 +217,7 @@ def banner(self) -> str: return ( f"Welcome to Peekmem {__version__}, a terminal client for " f"PyMemoryEditor {PyMemoryEditor.__version__}.\n" - "Commands end with a newline. Type 'help' for the command list, " + "Type 'help' for the command list, " "'help scanning' for a walkthrough, 'exit' or Ctrl+C to quit.\n" ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 005ede1..5a0e793 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -106,7 +106,6 @@ def test_the_registry_refuses_a_third_level(): "scan:results:keep", parser=lambda: CommandParser("scan:results:keep"), summary="Never registered.", - usage="scan:results:keep", )(lambda session, args: None) @@ -290,21 +289,30 @@ def test_help_lists_every_flag(entry, shell, capture): @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) -def test_usage_line_advertises_only_real_flags(entry): - """The usage line is hand-written; a flag it names must still exist. +def test_usage_line_names_every_flag(entry): + """A flag the command accepts is a flag its usage line shows. - The other direction is deliberately not checked: a usage line is a summary - for a human, so it is free to leave a rarely-used flag to the generated - Options section below it. + Three commands had grown options their hand-written usage never mentioned, + which is why the line is generated from the parser now. This pins the + property down in both directions at once: what is listed is what exists. """ declared = { - flag for action in entry.arguments() for flag in action.option_strings + flag + for action in entry.arguments() + for flag in action.option_strings + if flag.startswith("--") } - for word in entry.usage.replace("[", " ").replace("]", " ").split(): - if word.startswith("--") and len(word) > 2: - assert word in declared, ( - f"{entry.name}: usage names {word}, which the parser does not accept" - ) + shown = { + word.strip("[]") + for word in entry.usage.replace("[", " [").split() + if word.strip("[]").startswith("--") + } + assert declared == shown, f"{entry.name}: usage and parser disagree" + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_usage_line_starts_with_the_command(entry): + assert entry.usage == entry.name or entry.usage.startswith(entry.name + " ") @pytest.mark.parametrize("flag", ["--help", "-h"]) @@ -429,3 +437,76 @@ def test_reset_reports_what_it_discarded(shell, capture): shell.run_line("scan:reset") assert "Discarded 2 result(s)." in capture.out assert shell.session.scan is None + + +#: Every command that reports "Showing n of m rows" must offer a way to see +#: the rest. Kept as a list so a new listing command has to join it. +PAGED = [ + "process:list", + "memory:regions", + "memory:modules", + "memory:threads", + "scan:results", + "pointer:paths", +] + + +@pytest.mark.parametrize("name", PAGED) +def test_every_listing_command_pages_the_same_way(name): + """One set of flags, one wording — a listing you learn once.""" + flags = { + flag for action in lookup(name).arguments() for flag in action.option_strings + } + assert {"--limit", "--offset", "--all"} <= flags + + +@pytest.mark.parametrize("name", PAGED) +def test_paging_flags_are_documented_identically(name): + """The shared helper is the point: the help text must not drift per command.""" + reference = { + action.dest: action.help + for action in lookup("scan:results").arguments() + if action.dest in ("limit", "offset", "all") + } + actual = { + action.dest: action.help + for action in lookup(name).arguments() + if action.dest in ("limit", "offset", "all") + } + assert actual == reference + + +def test_a_truncated_listing_names_the_next_page(shell, capture): + shell.run_line("process:list --limit 2") + assert "Showing 2 of" in capture.out + assert "Next page: process:list --offset 2 --limit 2" in capture.out + + +def test_the_last_page_offers_no_next(shell, capture): + shell.run_line("process:list --all") + assert "Next page:" not in capture.out + + +def test_offset_cannot_be_negative(shell): + with pytest.raises(CommandError, match="offset"): + shell.run_line("process:list --offset -1", raise_errors=True) + + +def test_a_scan_preview_pages_through_scan_results(session, capture): + """Re-running a scan to see page two would be absurd; scan:results is the pager.""" + from peekmem import valuetypes + from peekmem.commands.scan_commands import _print_results + + session.set_option("limit", "2") + state = session.store_scan( + valuetypes.resolve("int32"), 4, [0x10, 0x20, 0x30], [1, 2, 3], "test" + ) + + class FakeProcess: + pointer_size = 8 + + session.process = FakeProcess() # type: ignore[assignment] + _print_results(session, state) + + assert "Showing 2 of 3 rows" in capture.out + assert "Next page: scan:results --offset 2" in capture.out diff --git a/tests/test_shell.py b/tests/test_shell.py index 9b5c25d..6f66963 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -151,7 +151,6 @@ def fake_lookup(name): name=entry.name, handler=interrupted, summary=entry.summary, - usage=entry.usage, ) return entry From 07099fe6e492964dc3d8d8436622e2c16c472fdd Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 18:56:17 -0300 Subject: [PATCH 14/82] refactor(commands): fold status into version, rename process: to ps: and set to config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'status' and 'version' were two commands answering nearly the same question, so 'version' absorbs the part worth keeping — Peekmem, PyMemoryEditor, Python and the platform, in the aligned block 'status' used to print. The session state 'status' also carried is not lost: the prompt already names the attached process, and 'ps:info' describes it properly. The 'process:' namespace becomes 'ps:', matching the name of the tool everyone already types to list processes, and 'set' becomes 'config', which says which kind of setting it means. The module moves to ps_commands.py so namespace and file still line up. The shell no longer says "Bye" on the way out, and '\q' is gone; 'exit' and 'quit' remain. Found while renaming: --pid and --name still built an 'open' command line, which stopped existing when the short aliases went. `peekmem -p 1234` had been failing with "Unknown command 'open'" — with the right exit status, which is why the CLI test never noticed. That test now asserts the error is about the PID, and a new one parses whatever --pid/--name generate through the registry so a command line that names nothing real fails loudly. A second test pins the retired spellings — status, \q, \s, set, process:list — as gone for good. --- .github/ISSUE_TEMPLATE/bug_report.md | 9 ++- CONTRIBUTING.md | 2 +- README.md | 14 ++-- SECURITY.md | 2 +- peekmem/cli.py | 8 +- peekmem/commands/__init__.py | 6 +- .../{process_commands.py => ps_commands.py} | 77 ++++--------------- peekmem/commands/scan_commands.py | 2 +- peekmem/commands/session_commands.py | 53 ++++++++----- peekmem/errors.py | 2 +- peekmem/shell.py | 5 +- tests/test_cli.py | 23 +++++- tests/test_commands.py | 52 +++++++------ tests/test_shell.py | 20 ++--- 14 files changed, 136 insertions(+), 139 deletions(-) rename peekmem/commands/{process_commands.py => ps_commands.py} (77%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0939c92..b90f46b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -15,7 +15,7 @@ The exact commands you ran, and what happened: ```console $ peekmem -peekmem> open 1234 +peekmem> ps:open 1234 peekmem> ... ``` @@ -24,10 +24,13 @@ A clear and concise description of what you expected to happen instead. **Versions** Paste the output of `peekmem -e "version"` — it covers Peekmem, PyMemoryEditor, -Python and the platform in one line: +Python and the platform: ``` -Peekmem 0.1.0 / PyMemoryEditor 2.2.0 / Python 3.12.0 on Linux (x86_64) + Peekmem: 0.1.0 +PyMemoryEditor: 2.2.0 + Python: 3.12.0 + Platform: Linux 6.8.0 (x86_64) ``` **Environment** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b827123..da1b900 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -179,7 +179,7 @@ the moment it is registered. Please include: - The output of `peekmem -e "version"` — it names Peekmem, PyMemoryEditor, - Python and the platform in one line. + 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_scope` is `0` or `1`. diff --git a/README.md b/README.md index b991933..61a2812 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ $ peekmem Welcome to Peekmem 0.1.0, a terminal client for PyMemoryEditor 2.2.0. Type 'help' for the command list, 'help scanning' for a walkthrough, 'exit' or Ctrl+C to quit. -peekmem> process:list game +peekmem> ps:list game +-------+----------+ | PID | NAME | +-------+----------+ @@ -57,7 +57,7 @@ peekmem> process:list game +-------+----------+ 1 row in set (0.01 sec) -peekmem> process:open 41902 +peekmem> ps:open 41902 Attached to game.exe (PID 41902, 64-bit). (0.00 sec) peekmem [game.exe:41902]> scan:value int32 100 --writable @@ -114,11 +114,11 @@ The same vocabulary works non-interactively, which is the point of a CLI on a server: ```bash -peekmem process:list chrome # one command, then exit +peekmem ps:list chrome # one command, then exit peekmem -p 4242 -e "memory:read game.exe+0x1234" # attach, read, exit peekmem -p 4242 -e "scan:value int32 100" -e "scan:results" # several, in order peekmem -f setup.peek # a file of commands -echo "process:list" | peekmem # a pipe +echo "ps:list" | peekmem # a pipe ``` Results go to stdout and errors to stderr, tables are plain ASCII, colour is @@ -156,7 +156,7 @@ scan commands: (get help with scan:help SUBCOMMAND) scan:aob [--max N] Scan for a byte pattern with wildcards (AOB). scan:drop [row ...] Remove the named result rows. scan:keep [row ...] Keep only the named result rows. - scan:next [op] [value] Narrow the results with another comparison. + scan:next [op] [value ...] Narrow the results with another comparison. scan:regex [--length N] [--max N] Scan for text matching a regular expression. scan:reset Discard the current scan results. scan:results [--limit N] [--offset N]... Show the current result set, re-read. @@ -172,11 +172,11 @@ nothing, whichever way you ask — `scan`, `scan --help`, `scan:help` and | Namespace | Commands | | --- | --- | -| **`process:`** | `list` · `open` · `close` · `info` | +| **`ps:`** | `list` · `open` · `close` · `info` | | **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | | **`scan:`** | `value` · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | | **`pointer:`** | `deref` · `read` · `scan` · `rescan` · `paths` · `save` · `load` · `diff` | -| Top level | `help` · `set` · `source` · `status` · `version` · `clear` · `exit` | +| Top level | `help` · `config` · `source` · `version` · `clear` · `exit` | `help ` — or ` --help` — documents each one in full: every argument, every flag, and examples. That list is generated from the command's diff --git a/SECURITY.md b/SECURITY.md index 59bc9ed..2f6bd61 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,7 +15,7 @@ prepared before details become public. When reporting, please include: - Affected version(s) — the output of `peekmem -e "version"` covers Peekmem, - PyMemoryEditor, Python and the platform in one line. + PyMemoryEditor, Python and the platform. - The exact command line or shell session that triggers it. - A minimal reproducer, and the impact you observed. - Any prerequisites (privileges, `ptrace_scope`, target process attributes). diff --git a/peekmem/cli.py b/peekmem/cli.py index d39c14e..a60dba0 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -9,10 +9,10 @@ session or a CI job: peekmem # the shell - peekmem process:list chrome # one command, then exit + peekmem ps:list chrome # one command, then exit peekmem -p 4242 -e "memory:read game.exe+0x10" # attach, read, exit peekmem -f setup.peek # a file of commands - echo "process:list" | peekmem # a pipe + echo "ps:list" | peekmem # a pipe """ import argparse @@ -123,7 +123,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "command", nargs=argparse.REMAINDER, - help="a single command to run, e.g. 'peekmem process:list chrome'", + help="a single command to run, e.g. 'peekmem ps:list chrome'", ) return parser @@ -135,7 +135,7 @@ def _startup_lines(options: argparse.Namespace) -> List[str]: if options.pid is not None and options.name is not None: raise CommandError("Give --pid or --name, not both.") - parts = ["open"] + parts = ["ps:open"] if options.pid is not None: parts += ["--pid", str(options.pid)] else: diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index d79cb4d..d6e6a5e 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -45,10 +45,10 @@ class Namespace: #: level down would cost more than the tidiness is worth. NAMESPACES: Tuple[Namespace, ...] = ( Namespace( - "process", + "ps", "Process", "Find a target process and attach to it.", - "peekmem> process:list chrome\n" + "peekmem> ps:list chrome\n" "\n" "+-------+------------+\n" "| PID | NAME |\n" @@ -468,7 +468,7 @@ def paginate( from . import memory_commands # noqa: E402,F401 (registration side effect) from . import pointer_commands # noqa: E402,F401 -from . import process_commands # noqa: E402,F401 +from . import ps_commands # noqa: E402,F401 from . import scan_commands # noqa: E402,F401 from . import session_commands # noqa: E402,F401 diff --git a/peekmem/commands/process_commands.py b/peekmem/commands/ps_commands.py similarity index 77% rename from peekmem/commands/process_commands.py rename to peekmem/commands/ps_commands.py index 68ee49e..2a53282 100644 --- a/peekmem/commands/process_commands.py +++ b/peekmem/commands/ps_commands.py @@ -1,13 +1,10 @@ # -*- coding: utf-8 -*- -"""Finding a target and attaching to it: ``ps``, ``open``, ``close``, ``info``.""" +"""The ``ps:`` namespace — finding a target process and attaching to it.""" -import platform from typing import List -import PyMemoryEditor - -from .. import __version__, processes +from .. import processes from ..errors import CommandError from ..output import LEFT, RIGHT, Timer, format_size, render_vertical from ..session import Session @@ -15,7 +12,7 @@ def _ps_parser() -> CommandParser: - parser = CommandParser("process:list") + parser = CommandParser("ps:list") parser.add_argument( "pattern", nargs="?", @@ -37,14 +34,14 @@ def _ps_parser() -> CommandParser: @command( - "process:list", + "ps:list", parser=_ps_parser, summary="List the processes visible to you.", details=( "Only processes your user can see are listed. Run Peekmem elevated to " "see (and open) processes belonging to other users." ), - examples=("process:list", "process:list chrome", "process:list --pid-sort --limit 50"), + examples=("ps:list", "ps:list chrome", "ps:list --pid-sort --limit 50"), ) def cmd_ps(session: Session, args: List[str]) -> None: options = _ps_parser().parse_args(args) @@ -59,7 +56,7 @@ def cmd_ps(session: Session, args: List[str]) -> None: page = paginate( session, entries, - command="process:list", + command="ps:list", limit=options.limit, offset=options.offset, show_all=options.all, @@ -79,7 +76,7 @@ def cmd_ps(session: Session, args: List[str]) -> None: def _open_parser() -> CommandParser: - parser = CommandParser("process:open") + parser = CommandParser("ps:open") parser.add_argument( "target", nargs="?", @@ -127,7 +124,7 @@ def _open_parser() -> CommandParser: @command( - "process:open", + "ps:open", parser=_open_parser, summary="Attach to a process by PID or name.", details=( @@ -137,7 +134,7 @@ def _open_parser() -> CommandParser: "pointer width is silent rather than loud.\n\n" "Attaching replaces any previous target and clears the scan results." ), - examples=("process:open 4242", "process:open notepad.exe", "process:open chrome --partial -i"), + examples=("ps:open 4242", "ps:open notepad.exe", "ps:open chrome --partial -i"), ) def cmd_open(session: Session, args: List[str]) -> None: options = _open_parser().parse_args(args) @@ -182,11 +179,11 @@ def cmd_open(session: Session, args: List[str]) -> None: def _close_parser() -> CommandParser: - return CommandParser("process:close") + return CommandParser("ps:close") @command( - "process:close", + "ps:close", parser=_close_parser, summary="Detach from the current process.", details=( @@ -203,67 +200,23 @@ def cmd_close(session: Session, args: List[str]) -> None: session.printer.ok("Detached.") -def _status_parser() -> CommandParser: - return CommandParser("status") - - -@command( - "status", - parser=_status_parser, - summary="Show the session state and versions.", - aliases=("\\s",), - details=( - "Takes no arguments.\n\n" - "Cheap: reports what the session knows without touching the target." - ), -) -def cmd_status(session: Session, args: List[str]) -> None: - _status_parser().parse_args(args) - - rows = [ - ("Peekmem", __version__), - ("PyMemoryEditor", PyMemoryEditor.__version__), - ("Python", platform.python_version()), - ("Platform", f"{platform.system()} {platform.release()} ({platform.machine()})"), - ] - - if session.process is None: - rows.append(("Process", "(none attached)")) - else: - rows.append(("Process", f"{session.process_name or '?'} (PID {session.process.pid})")) - rows.append(("Architecture", "64-bit" if session.process.is_64bit else "32-bit")) - - if session.scan is not None: - rows.append( - ( - "Scan results", - f"{len(session.scan)} address(es) — {session.scan.description}", - ) - ) - if session.pointer_paths: - rows.append(("Pointer paths", str(len(session.pointer_paths)))) - - session.printer.write(render_vertical(rows)) - session.printer.write() - - def _info_parser() -> CommandParser: - return CommandParser("process:info") + return CommandParser("ps:info") @command( - "process:info", + "ps:info", parser=_info_parser, summary="Describe the attached process in detail.", details=( "Takes no arguments.\n\n" "Enumerates the memory map to report how much of the address space is " - "mapped, so it costs a little more than 'status'." + "mapped, so it is the slower of the two ways to look at a target." ), ) def cmd_info(session: Session, args: List[str]) -> None: _info_parser().parse_args(args) - process = session.require_process("process:info") + process = session.require_process("ps:info") with Timer() as timer: regions = session.regions(refresh=True) diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index ae75b66..e1eebb5 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -223,7 +223,7 @@ def _report( if state.truncated: printer.note( f"Stopped at the max_results cap ({session.option('max_results')}). " - "Narrow the scan, or raise it with 'set max_results N'." + "Narrow the scan, or raise it with 'config max_results N'." ) if outcome.skipped: printer.note( diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 40a0358..e8f02bc 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -164,6 +164,7 @@ def _print_example(session: Session, example: str, *, indent: int = 0) -> None: return pad = " " * indent session.printer.write(f"{pad}Example:") + session.printer.write() for line in example.splitlines(): session.printer.write(f"{pad} {line}" if line else "") session.printer.write() @@ -199,7 +200,7 @@ def _print_overview(session: Session) -> None: # of those namespaced commands looks like, not a preamble to the page. _print_example( session, - "peekmem> process:open 4242\n" + "peekmem> ps:open 4242\n" "Attached to game.exe (PID 4242, 64-bit). (0.00 sec)", indent=4, ) @@ -398,8 +399,8 @@ def command_words_set() -> set: return set(command_words()) -def _set_parser() -> CommandParser: - parser = CommandParser("set") +def _config_parser() -> CommandParser: + parser = CommandParser("config") parser.add_argument( "assignment", nargs="*", @@ -411,20 +412,26 @@ def _set_parser() -> CommandParser: @command( - "set", - parser=_set_parser, + "config", + parser=_config_parser, summary="Show or change a session setting.", details=( "Settings live for the session only — Peekmem writes no config file, " "so a fresh shell always starts from the documented defaults. Put the " - "'set' lines in a script and run it with 'source' to reuse a setup.\n\n" - "Run 'set' with no argument to see every setting, its current value " + "'config' lines in a script and run it with 'source' to reuse a " + "setup.\n\n" + "Run 'config' with no argument to see every setting, its current value " "and what it does." ), - examples=("set", "set limit 50", "set hex on", "set writable_only=true"), + examples=( + "config", + "config limit 50", + "config hex on", + "config writable_only=true", + ), ) -def cmd_set(session: Session, args: List[str]) -> None: - options = _set_parser().parse_args(args) +def cmd_config(session: Session, args: List[str]) -> None: + options = _config_parser().parse_args(args) assignment = options.assignment if not assignment: @@ -444,7 +451,7 @@ def cmd_set(session: Session, args: List[str]) -> None: elif len(assignment) == 2: name, value = assignment[0], assignment[1] else: - raise CommandError("Usage: set [name [value]]") + raise CommandError("Usage: config [name [value]]") if value is None: setting = {item.name: item for item in SETTINGS}.get(name.lower()) @@ -536,19 +543,29 @@ def _version_parser() -> CommandParser: @command( "version", parser=_version_parser, - summary="Print the Peekmem and PyMemoryEditor versions.", + summary="Print the Peekmem, PyMemoryEditor, Python and platform versions.", details=( "Takes no arguments.\n\n" - "The one line to quote in a bug report: it names Peekmem, " - "PyMemoryEditor, Python and the platform." + "The four lines to quote in a bug report. Peekmem is a client, so which " + "PyMemoryEditor is underneath matters as much as which Peekmem is on " + "top — the two move independently." ), ) def cmd_version(session: Session, args: List[str]) -> None: _version_parser().parse_args(args) session.printer.write( - f"Peekmem {__version__} / PyMemoryEditor {PyMemoryEditor.__version__} " - f"/ Python {platform.python_version()} on {platform.system()} " - f"({platform.machine()})" + render_vertical( + [ + ("Peekmem", __version__), + ("PyMemoryEditor", PyMemoryEditor.__version__), + ("Python", platform.python_version()), + ( + "Platform", + f"{platform.system()} {platform.release()} " + f"({platform.machine()})", + ), + ] + ) ) session.printer.write() @@ -561,7 +578,7 @@ def _exit_parser() -> CommandParser: "exit", parser=_exit_parser, summary="Leave the shell.", - aliases=("quit", "\\q"), + aliases=("quit",), details=( "Takes no arguments.\n\n" "Detaches from the target first. Ctrl+C and Ctrl+D at the prompt do " diff --git a/peekmem/errors.py b/peekmem/errors.py index 6b0de02..c7aac73 100644 --- a/peekmem/errors.py +++ b/peekmem/errors.py @@ -31,7 +31,7 @@ class NoProcessError(CommandError): def __init__(self, command: str = ""): detail = f" Command {command!r} needs a target." if command else "" super().__init__( - "No process attached." + detail + ' Use "process:open " first.' + "No process attached." + detail + ' Use "ps:open " first.' ) diff --git a/peekmem/shell.py b/peekmem/shell.py index 5b491a9..c1bcc38 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -73,7 +73,7 @@ def split(line: str) -> Optional[Tuple[str, List[str]]]: """Split a line into ``(command, args)``, or ``None`` when it is blank. The command word is taken verbatim rather than through ``shlex`` so - the backslash aliases (``\\q``, ``\\s``, ``\\.``) survive: POSIX + the backslash aliases (``\\h``, ``\\.``) survive: POSIX quoting would eat the backslash and leave a command nobody registered. """ stripped = line.strip() @@ -260,7 +260,6 @@ def interact(self, *, banner: bool = True) -> int: self._save_history() self.session.close() - self.printer.write("Bye") return status # -- readline ---------------------------------------------------------- @@ -322,7 +321,7 @@ def _complete(self, text: str, state: int) -> Optional[str]: ] else: head = buffer.strip().split()[0].lower() - if head == "set": + if head == "config": candidates = [setting.name for setting in SETTINGS] elif head == "help": candidates = command_words() + ["types", "address", "scanning"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b48a36..836894d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,13 +34,13 @@ def test_execute_runs_a_command_and_exits(): def test_execute_flags_run_in_order(): - status, out, _ = run(["-e", "set limit 3", "-e", "set"]) + status, out, _ = run(["-e", "config limit 3", "-e", "config"]) assert status == 0 assert out.index("limit = 3") < out.index("SETTING") def test_a_trailing_command_works_like_execute(): - status, out, _ = run(["process:list", "--limit", "1"]) + status, out, _ = run(["ps:list", "--limit", "1"]) assert status == 0 assert "PID" in out @@ -73,10 +73,27 @@ def test_a_bad_pid_stops_before_the_commands(): status, out, err = run(["-p", "2147483646", "-e", "version"]) assert status == 1 assert "Peekmem" not in out + # Specifically the PID's fault. Asserting only on the status let a real + # bug hide here once: --pid built a command that no longer existed, so the + # run failed for the right code and entirely the wrong reason. + assert "2147483646" in err + + +def test_the_target_flags_build_a_real_command(): + """--pid and --name are spelled as a command line; it has to be one.""" + from peekmem.cli import _startup_lines + from peekmem.commands import lookup + + for argv in (["-p", "42"], ["-n", "game.exe", "-i", "--partial"]): + options = build_parser().parse_args(argv + ["-e", "version"]) + for line in _startup_lines(options): + word, _, rest = line.partition(" ") + entry = lookup(word) # raises if the command does not exist + entry.parser().parse_args(rest.split()) def test_limit_flag_reaches_the_session(): - status, out, _ = run(["--limit", "1", "-e", "process:list"]) + status, out, _ = run(["--limit", "1", "-e", "ps:list"]) assert status == 0 assert "Showing 1 of" in out diff --git a/tests/test_commands.py b/tests/test_commands.py index 5a0e793..78adab3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -40,11 +40,10 @@ def test_only_shell_commands_are_top_level(): """Anything that touches the target belongs to a subject namespace.""" assert sorted(entry.name for entry in top_level()) == [ "clear", + "config", "exit", "help", - "set", "source", - "status", "version", ] @@ -207,9 +206,9 @@ def test_a_namespace_listing_shows_argument_signatures(shell, capture): def test_a_namespace_listing_carries_a_worked_example(shell, capture): - shell.run_line("process:help") + shell.run_line("ps:help") assert "Example:" in capture.out - assert "peekmem> process:list chrome" in capture.out + assert "peekmem> ps:list chrome" in capture.out def test_a_long_signature_is_cut_at_a_token_boundary(shell, capture): @@ -337,6 +336,13 @@ def test_option_words_come_from_the_parser(): assert option_words("scan") == [], "a namespace has no options of its own" +@pytest.mark.parametrize("word", ["status", "\\q", "\\s", "set", "process:list"]) +def test_retired_spellings_stay_retired(word): + """Names that were removed must not quietly come back as an alias.""" + with pytest.raises(CommandError): + lookup(word) + + def test_command_words_are_unique(): words = command_words() assert len(words) == len(set(words)) @@ -369,7 +375,7 @@ def test_examples_parse_as_commands(entry, shell): "memory:alloc 16", "memory:free 0x10", "memory:watch 0x10", - "process:info", + "ps:info", ], ) def test_commands_needing_a_target_refuse_without_one(shell, line): @@ -395,39 +401,41 @@ def test_commands_needing_results_refuse_without_them(shell, line): def test_close_without_a_target_is_an_error(shell): with pytest.raises(CommandError): - shell.run_line("process:close", raise_errors=True) + shell.run_line("ps:close", raise_errors=True) -def test_status_works_with_no_target(shell, capture): - shell.run_line("status") - assert "(none attached)" in capture.out +def test_version_reports_both_halves(shell, capture): + """Peekmem is a client: which PyMemoryEditor is underneath is half the answer.""" + shell.run_line("version") + for label in ("Peekmem", "PyMemoryEditor", "Python", "Platform"): + assert f"{label}:" in capture.out def test_ps_lists_this_process(shell, capture): """The one command that talks to the OS without attaching to anything.""" import os - shell.run_line("set limit 0") - shell.run_line("process:list") + shell.run_line("config limit 0") + shell.run_line("ps:list") assert str(os.getpid()) in capture.out -def test_set_prints_booleans_the_way_they_are_typed(shell, capture): - shell.run_line("set") +def test_config_prints_booleans_the_way_they_are_typed(shell, capture): + shell.run_line("config") assert "| off" in capture.out or "off " in capture.out capture.reset() - shell.run_line("set hex on") + shell.run_line("config hex on") assert "hex = on" in capture.out -def test_set_accepts_the_equals_form(shell): - shell.run_line("set limit=42") +def test_config_accepts_the_equals_form(shell): + shell.run_line("config limit=42") assert shell.session.option("limit") == 42 def test_unknown_option_is_reported_not_swallowed(shell): with pytest.raises(CommandError): - shell.run_line("process:list --nosuchflag", raise_errors=True) + shell.run_line("ps:list --nosuchflag", raise_errors=True) def test_reset_reports_what_it_discarded(shell, capture): @@ -442,7 +450,7 @@ def test_reset_reports_what_it_discarded(shell, capture): #: Every command that reports "Showing n of m rows" must offer a way to see #: the rest. Kept as a list so a new listing command has to join it. PAGED = [ - "process:list", + "ps:list", "memory:regions", "memory:modules", "memory:threads", @@ -477,19 +485,19 @@ def test_paging_flags_are_documented_identically(name): def test_a_truncated_listing_names_the_next_page(shell, capture): - shell.run_line("process:list --limit 2") + shell.run_line("ps:list --limit 2") assert "Showing 2 of" in capture.out - assert "Next page: process:list --offset 2 --limit 2" in capture.out + assert "Next page: ps:list --offset 2 --limit 2" in capture.out def test_the_last_page_offers_no_next(shell, capture): - shell.run_line("process:list --all") + shell.run_line("ps:list --all") assert "Next page:" not in capture.out def test_offset_cannot_be_negative(shell): with pytest.raises(CommandError, match="offset"): - shell.run_line("process:list --offset -1", raise_errors=True) + shell.run_line("ps:list --offset -1", raise_errors=True) def test_a_scan_preview_pages_through_scan_results(session, capture): diff --git a/tests/test_shell.py b/tests/test_shell.py index 6f66963..c8d14cc 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -11,15 +11,15 @@ @pytest.mark.parametrize( "line,expected", [ - ("process:list", ("process:list", [])), - (" process:list chrome ", ("process:list", ["chrome"])), - ("process:list chrome;", ("process:list", ["chrome"])), - ("process:list chrome ;;", ("process:list", ["chrome"])), + ("ps:list", ("ps:list", [])), + (" ps:list chrome ", ("ps:list", ["chrome"])), + ("ps:list chrome;", ("ps:list", ["chrome"])), + ("ps:list chrome ;;", ("ps:list", ["chrome"])), ( "memory:write 0x10 bytes 'DE AD'", ("memory:write", ["0x10", "bytes", "DE AD"]), ), - ("\\q", ("\\q", [])), + ("\\h", ("\\h", [])), ("source \\.", ("source", ["."])), ], ) @@ -88,7 +88,7 @@ def test_help_topics_are_reachable(shell, capture): def test_source_runs_a_file(shell, capture, tmp_path): script = tmp_path / "setup.peek" - script.write_text("# a comment\nset limit 7\n\nset hex on\n") + script.write_text("# a comment\nconfig limit 7\n\nconfig hex on\n") shell.run_line(f"source {script}") assert shell.session.option("limit") == 7 assert shell.session.option("hex") is True @@ -96,7 +96,7 @@ def test_source_runs_a_file(shell, capture, tmp_path): def test_source_stops_at_the_failing_line(shell, capture, tmp_path): script = tmp_path / "bad.peek" - script.write_text("set limit 7\nnosuchcommand\nset limit 9\n") + script.write_text("config limit 7\nnosuchcommand\nconfig limit 9\n") shell.run_line(f"source {script}") assert "bad.peek:2" in capture.err assert shell.session.option("limit") == 7 @@ -106,7 +106,7 @@ def test_interactive_loop_reads_until_eof(shell, capture, monkeypatch): lines = iter(["version", "exit"]) monkeypatch.setattr("builtins.input", lambda prompt="": next(lines)) assert shell.interact(banner=False) == 0 - assert "Bye" in capture.out + assert capture.err == "" def test_ctrl_c_at_the_prompt_quits(shell, capture, monkeypatch): @@ -118,7 +118,7 @@ def fake_input(prompt=""): monkeypatch.setattr("builtins.input", fake_input) assert shell.interact(banner=False) == 130 assert "^C" in capture.out - assert "Bye" in capture.out + assert capture.err == "" def test_ctrl_d_quits_with_a_zero_status(shell, capture, monkeypatch): @@ -127,7 +127,7 @@ def fake_input(prompt=""): monkeypatch.setattr("builtins.input", fake_input) assert shell.interact(banner=False) == 0 - assert "Bye" in capture.out + assert capture.err == "" def test_ctrl_c_during_a_command_returns_to_the_prompt(shell, capture, monkeypatch): From 4e4e8bba5d67e5412a7a25b14ba180c5c9416f93 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 20:33:58 -0300 Subject: [PATCH 15/82] docs(shell): drop the exit instruction from the welcome banner The banner told you how to leave before you had done anything. 'exit' is in the command list one keystroke away, and the two lines it cost were spent on the least interesting thing the shell does. --- README.md | 2 +- peekmem/shell.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 61a2812..ff58e5a 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ pip install "peekmem[speed]" ```console $ peekmem Welcome to Peekmem 0.1.0, a terminal client for PyMemoryEditor 2.2.0. -Type 'help' for the command list, 'help scanning' for a walkthrough, 'exit' or Ctrl+C to quit. +Type 'help' for the command list, or 'help scanning' for a walkthrough. peekmem> ps:list game +-------+----------+ diff --git a/peekmem/shell.py b/peekmem/shell.py index c1bcc38..42e9226 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -217,8 +217,8 @@ def banner(self) -> str: return ( f"Welcome to Peekmem {__version__}, a terminal client for " f"PyMemoryEditor {PyMemoryEditor.__version__}.\n" - "Type 'help' for the command list, " - "'help scanning' for a walkthrough, 'exit' or Ctrl+C to quit.\n" + "Type 'help' for the command list, or 'help scanning' for a " + "walkthrough.\n" ) def interact(self, *, banner: bool = True) -> int: From 4f562460b84c38a7d264816ddd3c13fa78e6885f Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 20:48:38 -0300 Subject: [PATCH 16/82] refactor(help): present everything as a command, with ':help' on all of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader had to carry a distinction the code needs and they do not: 'memory' was a namespace, 'clear' a command, and the help had a section for each. Now there is one list of the words you can type first, and the only thing telling them apart is the signature — 'memory:COMMAND' says it takes a subcommand where 'clear' does not, without naming a concept to explain it. ':help' answers for anything at any depth: 'memory:help' lists what memory takes, 'memory:read:help' describes that one, 'clear:help' and 'version:help' describe themselves. One rule, no exceptions, and nothing to learn about which words are which kind. The errors follow: 'memory' now "takes a subcommand" rather than being "a namespace, not a command". The word survives in the source, where it is accurate — a name prefix is a namespace. A test sweeps every help page, every topic, every command's page and the errors to keep it out of what the reader sees, because a word like that leaks back one string at a time. --- CONTRIBUTING.md | 17 +++++--- README.md | 24 +++++----- peekmem/cli.py | 22 ++++------ peekmem/commands/__init__.py | 14 ++++++ peekmem/commands/session_commands.py | 36 +++++---------- peekmem/shell.py | 65 ++++++++++++++++++---------- tests/test_cli.py | 13 +++--- tests/test_commands.py | 60 ++++++++++++++++++++++--- 8 files changed, 161 insertions(+), 90 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da1b900..966df73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,11 +83,15 @@ Two rules keep the shape: 1. Pick the module in `peekmem/commands/` that matches the namespace. 2. Register the handler. The name is a colon-separated path whose first - segment is one of the namespaces in `NAMESPACES`; the group in `help` - follows from it, so there is nothing to keep in step. Do **not** give it a - plain-word alias: two namespaces could each want `read`, and a test enforces + segment is one of the groups in `NAMESPACES`; the heading in `help` follows + from it, so there is nothing to keep in step. Do **not** give it a + plain-word alias: two groups could each want `read`, 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. @@ -143,9 +147,10 @@ Two rules keep the shape: buys tidiness at the cost of a listing that has to be walked twice to be read once. - `:help` is a dispatcher convention rather than a registered - command, so it works for every namespace without one `help` command per - namespace cluttering the listings it exists to print. + `:help` is 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 one `help` command per group + cluttering the listings it exists to print. 3. Use `CommandParser`, not a bare `ArgumentParser`: it raises instead of calling `sys.exit`, which would kill the shell on a typo. diff --git a/README.md b/README.md index ff58e5a..bf9b37c 100644 --- a/README.md +++ b/README.md @@ -127,10 +127,11 @@ non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave ## What it can do -The help is layered. `help` shows four namespaces and the handful of commands -that drive the shell — not a wall of forty. Each namespace then documents -itself: a usage line, a worked example, and its commands with the arguments -they take. +The help is layered. `help` shows the words you can type first — ten lines, +not a wall of forty — and `:help` opens any of them. A command that +takes a subcommand says so in its signature (`scan:COMMAND`) and documents +itself with a usage line, a worked example, and its commands with the +arguments they take. ```console peekmem> scan:help @@ -151,7 +152,7 @@ Example: +-----+--------------------+-------+ 1 row in set (0.02 sec) -scan commands: (get help with scan:help SUBCOMMAND) +scan commands: (get help with scan:COMMAND:help) scan:aob [--max N] Scan for a byte pattern with wildcards (AOB). scan:drop [row ...] Remove the named result rows. @@ -163,14 +164,15 @@ scan commands: (get help with scan:help SUBCOMMAND) scan:value [value] [--op OP]... Search the whole address space for a value. ``` -`scan:help aob` describes one command. Names go two levels at most, so there -is never a third listing to walk. +Every command answers `:help`, at any depth — `scan:help`, `scan:aob:help`, +`clear:help` — so there is one rule and nothing to learn about which words are +which. Names go two levels at most, so there is never a third listing to walk. -A namespace is never a command: typing `scan` prints its page and runs -nothing, whichever way you ask — `scan`, `scan --help`, `scan:help` and -`help scan` all produce the same output. +A command that takes a subcommand never runs anything itself: typing `scan` +prints its page, whichever way you ask — `scan`, `scan --help`, `scan:help` +and `help scan` all produce the same output. -| Namespace | Commands | +| Command | Subcommands | | --- | --- | | **`ps:`** | `list` · `open` · `close` · `info` | | **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | diff --git a/peekmem/cli.py b/peekmem/cli.py index a60dba0..240231c 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -23,7 +23,7 @@ import PyMemoryEditor from . import __version__, dependencies -from .commands import namespace_summary, namespaces, top_level +from .commands import top_level_listing from .errors import CommandError, PeekmemError from .output import Printer from .session import Session @@ -33,23 +33,17 @@ def _format_commands() -> str: """The same layered summary the shell's own ``help`` prints. - Namespaces and the shell's own commands, with one move to go deeper — - rather than every command at once, which is a wall rather than an answer. + Every word you can type first, and nothing below it — rather than all + thirty-odd commands at once, which is a wall rather than an answer. """ - lines: List[str] = [ - "Namespaces — run 'peekmem :help' to list what is in one:", - "", - ] - for name in namespaces(): - lines.append(f" {name.ljust(10)} {namespace_summary(name)}") - - lines += ["", "Commands:", ""] - for entry in top_level(): - lines.append(f" {entry.name.ljust(10)} {entry.summary}") + rows = top_level_listing() + width = max(len(signature) for signature, _ in rows) + lines: List[str] = ["peekmem commands:", ""] + lines += [f" {signature.ljust(width)} {summary}" for signature, summary in rows] lines += [ "", - "Run 'peekmem --help' for one command's arguments, or", + "Run 'peekmem :help' for what a command takes, or", "'peekmem help' for the topics ('types', 'address', 'scanning').", ] return "\n".join(lines) diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index d6e6a5e..9d38467 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -354,6 +354,19 @@ def top_level() -> List[Command]: return [entry for entry in all_commands() if entry.is_top_level] +def top_level_listing() -> List[Tuple[str, str]]: + """Every word you can type first, as ``(signature, summary)`` pairs. + + The distinction the code keeps — a namespace is a prefix, a command is a + thing that runs — is not one the reader has to carry. To them ``ps`` and + ``clear`` are both commands; one happens to take a subcommand, which the + ``ps:COMMAND`` signature says without naming a concept. + """ + rows = [(f"{name}:COMMAND", namespace_summary(name)) for name in namespaces()] + rows += [(entry.usage, entry.summary) for entry in top_level()] + return sorted(rows) + + def namespace(name: str) -> Optional[Namespace]: """The declared namespace called ``name``, if there is one.""" return _NAMESPACES_BY_NAME.get(name.strip().lower().rstrip(":")) @@ -492,4 +505,5 @@ def paginate( "paginate", "usage_token", "top_level", + "top_level_listing", ) diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index e8f02bc..5067be8 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -28,9 +28,8 @@ describe_action, lookup, namespace, - namespace_summary, namespaces, - top_level, + top_level_listing, ) _ADDRESS_TOPIC = """\ @@ -171,11 +170,12 @@ def _print_example(session: Session, example: str, *, indent: int = 0) -> None: def _print_overview(session: Session) -> None: - """The top layer: the four subjects, and the words that drive the shell. + """The top layer: every word you can type first, and nothing below it. - Deliberately not a list of every command. Thirty-five lines is a wall to - read past, not an answer; four namespaces and seven commands is something - you can take in, with one obvious move to get deeper. + Deliberately not a list of all thirty-odd commands. A wall is not an + answer; ten lines is something you can take in, with one obvious move to + get deeper — and a command that takes a subcommand says so in its + signature rather than in a paragraph about namespaces. """ printer = session.printer @@ -184,20 +184,20 @@ def _print_overview(session: Session) -> None: printer.write(f"Peekmem {__version__} — a terminal client for PyMemoryEditor.") printer.write() - printer.write("peekmem namespaces: (get help with :help)") + printer.write("peekmem commands: (get help with :help)") printer.write() printer.write( render_definitions( - [(name, namespace_summary(name)) for name in namespaces()], + top_level_listing(), indent=4, - label_width=12, + label_width=24, total_width=_LISTING_WIDTH, ) ) printer.write() - # The example belongs *inside* the namespaces block: it is what typing one - # of those namespaced commands looks like, not a preamble to the page. + # The example sits inside the listing: it is what typing one of these + # looks like, not a preamble to the page. _print_example( session, "peekmem> ps:open 4242\n" @@ -205,18 +205,6 @@ def _print_overview(session: Session) -> None: indent=4, ) - printer.write("peekmem commands: (get help with help COMMAND)") - printer.write() - printer.write( - render_definitions( - [(entry.name, entry.summary) for entry in top_level()], - indent=4, - label_width=12, - total_width=_LISTING_WIDTH, - ) - ) - printer.write() - printer.write("Topics: 'help types', 'help address', 'help scanning'.") printer.write() @@ -244,7 +232,7 @@ def print_namespace(session: Session, prefix: str) -> bool: printer.write() _print_example(session, declared.example) - printer.write(f"{head} commands: (get help with {head}:help SUBCOMMAND)") + printer.write(f"{head} commands: (get help with {head}:COMMAND:help)") printer.write() printer.write( render_definitions( diff --git a/peekmem/shell.py b/peekmem/shell.py index 42e9226..d33d5eb 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -143,52 +143,71 @@ def run_line(self, line: str, *, raise_errors: bool = False) -> bool: return False def _resolve(self, word: str, args: Sequence[str]): - """Resolve a command word, answering every namespace question the same way. + """Resolve a command word, answering ``:help`` for anything at all. - A namespace is not a command and never runs anything. Naming one — - ``scan``, ``scan --help``, ``scan:help``, or ``help scan`` — prints its - page, all four spellings producing the same output, because a reader - who tries one of them has already told you what they want. + Every command answers ``:help`` — ``memory:help`` lists what + ``memory`` takes, ``memory:read:help`` describes that one, ``clear:help`` + describes ``clear``. One rule, no exceptions to learn, and no need for + the reader to know that some of those words are prefixes rather than + actions. - With arguments that are not a help flag it is a mistake worth naming - precisely: the user almost always meant the colon. + A word that only takes a subcommand prints its listing when typed alone + (``memory``, ``memory --help``) and never runs anything. With other + arguments it is a mistake worth naming precisely: the colon was meant. """ head = word.strip().lower() wants_help = bool(args) and all(item in _HELP_FLAGS for item in args) - # ':help [command]' — the form the listings advertise. - if head.endswith(":help") and head[: -len(":help")] in namespaces(): - prefix = head[: -len(":help")] - if args and not wants_help: - # 'scan:help aob' describes one command; it must not run it. - target = lookup(f"{prefix}:{args[0]}") - lookup("help").handler(self.session, [target.name]) + if head.endswith(":help"): + subject = head[: -len(":help")] + + if subject in namespaces(): + # 'memory:help read' describes one of them, as the listing says. + if args and not wants_help: + self._show_help(lookup(f"{subject}:{args[0]}")) + raise _Handled() + self._show_listing(subject) + raise _Handled() + + try: + target = lookup(subject) + except CommandError: + pass + else: + self._show_help(target) raise _Handled() - head, args = prefix, () if head.rstrip(":") in namespaces(): - namespace_name = head.rstrip(":") + parent = head.rstrip(":") if not args or wants_help: - from .commands.session_commands import print_namespace - - print_namespace(self.session, namespace_name) + self._show_listing(parent) raise _Handled() - candidate = f"{namespace_name}:{args[0]}" + candidate = f"{parent}:{args[0]}" try: lookup(candidate) except CommandError: raise CommandError( - f"{namespace_name!r} is a namespace, not a command. " - f"Type '{namespace_name}:help' to see what is in it." + f"{parent!r} takes a subcommand. " + f"Type '{parent}:help' to see them." ) raise CommandError( - f"{namespace_name!r} is a namespace: the command is spelled " + f"{parent!r} takes a subcommand: the command is spelled " f"{candidate!r}, with a colon." ) return lookup(word) + def _show_help(self, entry) -> None: + """Print one command's help page.""" + lookup("help").handler(self.session, [entry.name]) + + def _show_listing(self, prefix: str) -> None: + """Print the commands a prefix takes.""" + from .commands.session_commands import print_namespace + + print_namespace(self.session, prefix) + def run_lines(self, lines: Iterable[str], *, raise_errors: bool = False) -> int: """Run a sequence of lines, returning a process exit status.""" for line in lines: diff --git a/tests/test_cli.py b/tests/test_cli.py index 836894d..daeb360 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -117,11 +117,12 @@ def test_version_flag(): def test_help_lists_the_layers_not_every_command(capsys): - """--help mirrors the shell's own overview: namespaces, then shell commands.""" + """--help mirrors the shell's own overview: one list of first words.""" text = build_parser().format_help() - for namespace in ("process", "memory", "scan", "pointer"): - assert namespace in text - for name in ("help", "set", "version", "exit"): + for signature in ("ps:COMMAND", "memory:COMMAND", "scan:COMMAND", "pointer:COMMAND"): + assert signature in text + for name in ("help", "config", "version", "exit"): assert name in text - assert ":help" in text - assert "ptrscan" not in text, "the deeper layers are reached, not dumped" + assert ":help" in text + assert "memory:read" not in text, "the deeper layer is reached, not dumped" + assert "namespace" not in text.lower() diff --git a/tests/test_commands.py b/tests/test_commands.py index 78adab3..96b26d0 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -58,11 +58,11 @@ def test_a_bare_namespace_lists_it(shell, capture, namespace): @pytest.mark.parametrize("line", ["scan int32 100", "pointer 0x10"]) -def test_a_namespace_never_runs_anything(shell, line): - """A namespace names a subject, not an action — with or without arguments.""" +def test_a_parent_command_never_runs_anything(shell, line): + """A word that takes a subcommand is not itself an action.""" with pytest.raises(CommandError) as error: shell.run_line(line, raise_errors=True) - assert "is a namespace" in str(error.value) + assert "takes a subcommand" in str(error.value) def test_clear_leaves_the_session_alone(shell, capture): @@ -148,7 +148,10 @@ def test_the_overview_shows_layers_not_every_command(shell, capture): # names ones that do not. for hidden in ("memory:regions", "scan:keep", "pointer:save"): assert hidden not in out - assert ":help" in out + assert ":help" in out + # Parents and leaves sit in one list; only the signature tells them apart. + assert "memory:COMMAND" in out + assert "clear" in out @pytest.mark.parametrize("namespace", [item.name for item in NAMESPACES]) @@ -186,9 +189,9 @@ def test_a_namespace_with_arguments_points_at_the_colon(shell, capture): assert "'memory:read'" in capture.err -def test_a_namespace_with_nonsense_arguments_is_still_explained(shell, capture): +def test_a_parent_with_nonsense_arguments_is_still_explained(shell, capture): assert shell.run_line("memory nonsense") is False - assert "namespace" in capture.err + assert "takes a subcommand" in capture.err assert "memory:help" in capture.err @@ -518,3 +521,48 @@ class FakeProcess: assert "Showing 2 of 3 rows" in capture.out assert "Next page: scan:results --offset 2" in capture.out + + +def test_the_word_namespace_never_reaches_the_reader(shell, capture): + """It is an implementation detail, not something to teach. + + Everything the shell prints — the overview, the topics, every command's + page, the errors — talks about commands and subcommands only. Swept over + all of it rather than a sample, because the word leaks back one string at + a time. + """ + lines = ["help", "help types", "help address", "help scanning"] + lines += [f"{name}:help" for name in namespaces()] + lines += [f"{entry.name}:help" for entry in COMMANDS] + lines += ["memory nonsense", "memory read 0x10"] + + for line in lines: + capture.reset() + shell.run_line(line) + combined = (capture.out + capture.err).lower() + assert "namespace" not in combined, f"{line!r} says 'namespace'" + + +@pytest.mark.parametrize( + "line,expected", + [ + ("memory:help", "usage: memory[:COMMAND]"), + ("memory:read:help", "memory:read — Read a typed value"), + ("scan:results:help", "scan:results — Show the current result set"), + ("clear:help", "clear — Clear the terminal"), + ("version:help", "version — Print the Peekmem"), + ("help:help", "help — List the commands"), + ], +) +def test_every_command_answers_colon_help(shell, capture, line, expected): + """One rule for asking about anything, with no exceptions to learn.""" + shell.run_line(line) + assert capture.out.startswith(expected) + assert capture.err == "" + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_colon_help_works_for_every_registered_command(entry, shell, capture): + shell.run_line(f"{entry.name}:help") + assert entry.summary in capture.out + assert capture.err == "" From 62b40aeefc4b153ee5eee15a168b77dd482834bd Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 20:53:46 -0300 Subject: [PATCH 17/82] docs(help): list every command by its plain name in the overview The ':COMMAND' suffix on 'memory' and friends was there to hint that they take one, but it read like part of the name and made four of the ten lines look different for a reason nobody asked about. They are listed plainly now; typing one is how you find out there is more underneath, which is a keystroke rather than a footnote. --- README.md | 5 ++--- peekmem/commands/__init__.py | 6 +++--- tests/test_cli.py | 4 ++-- tests/test_commands.py | 6 +++--- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bf9b37c..a7312b6 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,8 @@ non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave The help is layered. `help` shows the words you can type first — ten lines, not a wall of forty — and `:help` opens any of them. A command that -takes a subcommand says so in its signature (`scan:COMMAND`) and documents -itself with a usage line, a worked example, and its commands with the -arguments they take. +takes a subcommand documents itself with a usage line, a worked example, and +its commands with the arguments they take. ```console peekmem> scan:help diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index 9d38467..c6da776 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -359,10 +359,10 @@ def top_level_listing() -> List[Tuple[str, str]]: The distinction the code keeps — a namespace is a prefix, a command is a thing that runs — is not one the reader has to carry. To them ``ps`` and - ``clear`` are both commands; one happens to take a subcommand, which the - ``ps:COMMAND`` signature says without naming a concept. + ``clear`` are both commands, listed side by side under one heading; typing + either one is how you find out that the first has more underneath. """ - rows = [(f"{name}:COMMAND", namespace_summary(name)) for name in namespaces()] + rows = [(name, namespace_summary(name)) for name in namespaces()] rows += [(entry.usage, entry.summary) for entry in top_level()] return sorted(rows) diff --git a/tests/test_cli.py b/tests/test_cli.py index daeb360..fa5387c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -119,8 +119,8 @@ def test_version_flag(): def test_help_lists_the_layers_not_every_command(capsys): """--help mirrors the shell's own overview: one list of first words.""" text = build_parser().format_help() - for signature in ("ps:COMMAND", "memory:COMMAND", "scan:COMMAND", "pointer:COMMAND"): - assert signature in text + for name in ("ps", "memory", "scan", "pointer"): + assert name in text for name in ("help", "config", "version", "exit"): assert name in text assert ":help" in text diff --git a/tests/test_commands.py b/tests/test_commands.py index 96b26d0..12d80b2 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -149,9 +149,9 @@ def test_the_overview_shows_layers_not_every_command(shell, capture): for hidden in ("memory:regions", "scan:keep", "pointer:save"): assert hidden not in out assert ":help" in out - # Parents and leaves sit in one list; only the signature tells them apart. - assert "memory:COMMAND" in out - assert "clear" in out + # Parents and leaves sit in one list, spelled the same way. + for word in ("memory", "pointer", "ps", "scan", "clear", "version"): + assert word in out @pytest.mark.parametrize("namespace", [item.name for item in NAMESPACES]) From de9f5582d0935282fc82254505db54e12451512f Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 20:57:11 -0300 Subject: [PATCH 18/82] docs(help): show names only in the top-level listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview answers "what is there?", and that answer is not improved by also answering "and what does each one take?" — which is what :help is for, one keystroke away. The arguments and flags come off, leaving ten names and ten sentences. The per-command listings keep their signatures: by the time you have asked about 'scan' specifically, what its commands take is the next thing you want. --- peekmem/commands/__init__.py | 8 ++++++-- peekmem/commands/session_commands.py | 2 +- tests/test_commands.py | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index c6da776..a0f7bc0 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -355,7 +355,11 @@ def top_level() -> List[Command]: def top_level_listing() -> List[Tuple[str, str]]: - """Every word you can type first, as ``(signature, summary)`` pairs. + """Every word you can type first, as ``(name, summary)`` pairs. + + Names only — no arguments, no flags. This listing answers "what is there?", + and an answer to that question is not improved by also answering "and what + does each one take?", which is what ``:help`` is for. The distinction the code keeps — a namespace is a prefix, a command is a thing that runs — is not one the reader has to carry. To them ``ps`` and @@ -363,7 +367,7 @@ def top_level_listing() -> List[Tuple[str, str]]: either one is how you find out that the first has more underneath. """ rows = [(name, namespace_summary(name)) for name in namespaces()] - rows += [(entry.usage, entry.summary) for entry in top_level()] + rows += [(entry.name, entry.summary) for entry in top_level()] return sorted(rows) diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 5067be8..4eab312 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -190,7 +190,7 @@ def _print_overview(session: Session) -> None: render_definitions( top_level_listing(), indent=4, - label_width=24, + label_width=12, total_width=_LISTING_WIDTH, ) ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 12d80b2..008c3de 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -154,6 +154,22 @@ def test_the_overview_shows_layers_not_every_command(shell, capture): assert word in out +def test_the_overview_lists_names_only(shell, capture): + """It answers "what is there?" — arguments are what :help is for.""" + shell.run_line("help") + listing = [ + line + for line in capture.out.splitlines() + if line.startswith(" ") and not line.startswith(" ") + ] + assert listing, "the command listing went missing" + for line in listing: + label = line.strip().split(" ")[0] + assert not any( + mark in label for mark in ("[", "<", "--") + ), f"the overview shows arguments: {label!r}" + + @pytest.mark.parametrize("namespace", [item.name for item in NAMESPACES]) def test_namespace_help_lists_that_layer(shell, capture, namespace): shell.run_line(f"{namespace}:help") From f21801cc12475c5ec3065c284b6ef520a1c90577 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:00:18 -0300 Subject: [PATCH 19/82] docs(help): advertise 'help ' in the overview The listing pointed at ':help'. Both spellings work and always will, but the one named next to a list of bare names should be the one that reads as a sentence. --- peekmem/commands/session_commands.py | 2 +- tests/test_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 4eab312..bb4b613 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -184,7 +184,7 @@ def _print_overview(session: Session) -> None: printer.write(f"Peekmem {__version__} — a terminal client for PyMemoryEditor.") printer.write() - printer.write("peekmem commands: (get help with :help)") + printer.write('peekmem commands: (get help with "help ")') printer.write() printer.write( render_definitions( diff --git a/tests/test_commands.py b/tests/test_commands.py index 008c3de..ee06c1e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -148,7 +148,7 @@ def test_the_overview_shows_layers_not_every_command(shell, capture): # names ones that do not. for hidden in ("memory:regions", "scan:keep", "pointer:save"): assert hidden not in out - assert ":help" in out + assert 'get help with "help "' in out # Parents and leaves sit in one list, spelled the same way. for word in ("memory", "pointer", "ps", "scan", "clear", "version"): assert word in out From b02212d43fb8b0f4fc667948a27037c4fa3e2c76 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:07:02 -0300 Subject: [PATCH 20/82] =?UTF-8?q?docs(help):=20teach=20one=20spelling=20?= =?UTF-8?q?=E2=80=94=20'help=20'=20=E2=80=94=20and=20say=20subcom?= =?UTF-8?q?mand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every listing now advertises the same form: the overview, each command's page and 'peekmem --help' all say 'help '. The ':help' suffix keeps working at every depth, but a reader should not have to notice there are two ways to ask. The placeholder for the second segment is SUBCOMMAND, not COMMAND, and a listing of them is headed "memory subcommands:" — the distinction is real, and using the same word for both halves was the thing that made it look like one. Two tests hold it: 'help ' resolves for every registered command, and every "get help with" line in the shell names that spelling and no other. --- CONTRIBUTING.md | 4 ++- README.md | 4 +-- peekmem/cli.py | 2 +- peekmem/commands/session_commands.py | 4 +-- tests/test_cli.py | 2 +- tests/test_commands.py | 39 +++++++++++++++++++++------- 6 files changed, 38 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 966df73..8dc4c37 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,7 +150,9 @@ Two rules keep the shape: `:help` is 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 one `help` command per group - cluttering the listings it exists to print. + cluttering the listings it exists to print. It keeps working, but the + spelling the help *advertises* is `help `: one form for everything, + and it reads as a sentence. 3. Use `CommandParser`, not a bare `ArgumentParser`: it raises instead of calling `sys.exit`, which would kill the shell on a typo. diff --git a/README.md b/README.md index a7312b6..1a07379 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ its commands with the arguments they take. ```console peekmem> scan:help -usage: scan[:COMMAND] +usage: scan[:SUBCOMMAND] Search memory for a value, then narrow what you found. @@ -151,7 +151,7 @@ Example: +-----+--------------------+-------+ 1 row in set (0.02 sec) -scan commands: (get help with scan:COMMAND:help) +scan subcommands: (get help with "help scan:SUBCOMMAND") scan:aob [--max N] Scan for a byte pattern with wildcards (AOB). scan:drop [row ...] Remove the named result rows. diff --git a/peekmem/cli.py b/peekmem/cli.py index 240231c..09713b8 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -43,7 +43,7 @@ def _format_commands() -> str: lines += [f" {signature.ljust(width)} {summary}" for signature, summary in rows] lines += [ "", - "Run 'peekmem :help' for what a command takes, or", + "Run 'peekmem help ' for what a command takes, or", "'peekmem help' for the topics ('types', 'address', 'scanning').", ] return "\n".join(lines) diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index bb4b613..0fda83c 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -223,7 +223,7 @@ def print_namespace(session: Session, prefix: str) -> bool: head = prefix.strip().lower().rstrip(":") printer = session.printer - printer.write(f"usage: {head}[:COMMAND]") + printer.write(f"usage: {head}[:SUBCOMMAND]") printer.write() declared = namespace(head) @@ -232,7 +232,7 @@ def print_namespace(session: Session, prefix: str) -> bool: printer.write() _print_example(session, declared.example) - printer.write(f"{head} commands: (get help with {head}:COMMAND:help)") + printer.write(f'{head} subcommands: (get help with "help {head}:SUBCOMMAND")') printer.write() printer.write( render_definitions( diff --git a/tests/test_cli.py b/tests/test_cli.py index fa5387c..1a47a32 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -123,6 +123,6 @@ def test_help_lists_the_layers_not_every_command(capsys): assert name in text for name in ("help", "config", "version", "exit"): assert name in text - assert ":help" in text + assert "peekmem help " in text assert "memory:read" not in text, "the deeper layer is reached, not dumped" assert "namespace" not in text.lower() diff --git a/tests/test_commands.py b/tests/test_commands.py index ee06c1e..ae4ccfa 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -52,8 +52,8 @@ def test_only_shell_commands_are_top_level(): def test_a_bare_namespace_lists_it(shell, capture, namespace): """Including 'scan' and 'pointer', which are command aliases as well.""" shell.run_line(namespace) - assert f"usage: {namespace}[:COMMAND]" in capture.out - assert f"{namespace} commands:" in capture.out + assert f"usage: {namespace}[:SUBCOMMAND]" in capture.out + assert f"{namespace} subcommands:" in capture.out assert capture.err == "" @@ -173,7 +173,7 @@ def test_the_overview_lists_names_only(shell, capture): @pytest.mark.parametrize("namespace", [item.name for item in NAMESPACES]) def test_namespace_help_lists_that_layer(shell, capture, namespace): shell.run_line(f"{namespace}:help") - assert f"{namespace} commands:" in capture.out + assert f"{namespace} subcommands:" in capture.out for entry in children(namespace): assert entry.name in capture.out assert capture.err == "" @@ -193,8 +193,8 @@ def test_every_namespace_has_commands(namespace): def test_typing_a_namespace_lists_it(shell, capture): shell.run_line("memory") - assert "usage: memory[:COMMAND]" in capture.out - assert "memory commands:" in capture.out + assert "usage: memory[:SUBCOMMAND]" in capture.out + assert "memory subcommands:" in capture.out assert "memory:read" in capture.out assert capture.err == "" @@ -213,7 +213,7 @@ def test_a_parent_with_nonsense_arguments_is_still_explained(shell, capture): def test_help_on_a_namespace_lists_it(shell, capture): shell.run_line("help memory") - assert "memory commands:" in capture.out + assert "memory subcommands:" in capture.out assert "memory:read" in capture.out @@ -262,15 +262,15 @@ def test_namespace_help_with_a_bad_subcommand_is_reported(shell, capture): def test_every_way_of_asking_about_a_namespace_agrees(shell, capture, line): """Five spellings, one page — whichever a reader reaches for.""" shell.run_line(line) - assert capture.out.startswith("usage: scan[:COMMAND]") - assert "scan commands:" in capture.out + assert capture.out.startswith("usage: scan[:SUBCOMMAND]") + assert "scan subcommands:" in capture.out assert capture.err == "" @pytest.mark.parametrize("topic", ["pointer:", "scan:", "memory:"]) def test_a_trailing_colon_asks_for_the_namespace(shell, capture, topic): shell.run_line(f"help {topic}") - assert f"{topic.rstrip(':')} commands:" in capture.out + assert f"{topic.rstrip(':')} subcommands:" in capture.out @pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) @@ -562,7 +562,7 @@ def test_the_word_namespace_never_reaches_the_reader(shell, capture): @pytest.mark.parametrize( "line,expected", [ - ("memory:help", "usage: memory[:COMMAND]"), + ("memory:help", "usage: memory[:SUBCOMMAND]"), ("memory:read:help", "memory:read — Read a typed value"), ("scan:results:help", "scan:results — Show the current result set"), ("clear:help", "clear — Clear the terminal"), @@ -582,3 +582,22 @@ def test_colon_help_works_for_every_registered_command(entry, shell, capture): shell.run_line(f"{entry.name}:help") assert entry.summary in capture.out assert capture.err == "" + + +@pytest.mark.parametrize("entry", COMMANDS, ids=lambda entry: entry.name) +def test_the_advertised_help_form_works_for_every_command(entry, shell, capture): + """'help ' is the one spelling the help names, so it must be total.""" + shell.run_line(f"help {entry.name}") + assert entry.summary in capture.out + assert capture.err == "" + + +def test_the_help_only_ever_advertises_one_spelling(shell, capture): + """Both forms work; only 'help ' is taught, everywhere.""" + for line in ["help", "memory:help", "scan:help", "ps:help", "pointer:help"]: + capture.reset() + shell.run_line(line) + hint = next( + row for row in capture.out.splitlines() if "get help with" in row + ) + assert '"help ' in hint, f"{line!r} advertises something else: {hint!r}" From 1b98d95b47b773c6ee17afdb59b576501e1017f6 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:14:22 -0300 Subject: [PATCH 21/82] feat(commands): page listings by page number, not by offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --offset made the reader do arithmetic to move on and told them nothing about where they were. Listings now take --page, counting from 1, and the footer says which page it is: Showing 10 of 25 rows — page 1 of 3 (0.00 sec) Next page: scan:results --page 2 "page 1 of 3" is a place you can hold in your head; "--offset 20" is a sum. The two flags do the same job, so --offset is gone rather than kept beside it. Asking for a page that does not exist now says how many there are, which beats an empty table that reads like "no results", and --page 0 is refused with the rule rather than silently meaning the last page. Result rows keep their absolute numbers across pages, so '#21' at the top of page 2 is still the row 'memory:read #21' reaches. --- CONTRIBUTING.md | 9 ++--- README.md | 8 ++--- peekmem/commands/__init__.py | 54 +++++++++++++++++++--------- peekmem/commands/memory_commands.py | 12 +++++-- peekmem/commands/pointer_commands.py | 10 +++--- peekmem/commands/ps_commands.py | 4 ++- peekmem/commands/scan_commands.py | 19 +++++++--- peekmem/output.py | 21 +++++++++-- tests/test_commands.py | 27 +++++++++----- 9 files changed, 115 insertions(+), 49 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8dc4c37..0077cc9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,15 +132,16 @@ Two rules keep the shape: page = paginate( session, rows, command="memory:mycommand", - limit=options.limit, offset=options.offset, show_all=options.all, + 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 and the same - `Next page: ...` footer as every other listing — and a test enforces that - the wording does not drift. + That gives the same three flags, the same wording, the same + `page N of M` footer and the same `Next page: ...` line as every other + listing — and a test enforces that the wording does not drift. Names go **two levels at most** — `scan:keep`, never `scan:results:keep`. The registry rejects a third level, and a test pins that down: a deeper name diff --git a/README.md b/README.md index 1a07379..e6708aa 100644 --- a/README.md +++ b/README.md @@ -221,10 +221,10 @@ Highlights: - **Progress you can trust.** Long scans report a percentage that advances whether or not anything is being found, and Ctrl+C stops a scan while keeping what it already found. -- **Every listing pages the same way.** `--limit`, `--offset` and `--all` on - each of them, and a truncated table ends with the command that shows the - next page — `Next page: memory:regions --offset 20` — so it is a copy-paste, - not a puzzle. +- **Every listing pages the same way.** `--limit`, `--page` and `--all` on each + of them, a footer that says where you are — `Showing 20 of 3184 rows — page + 1 of 160` — and the command for the next page spelled out underneath, so it + is a copy-paste rather than a puzzle. - **Ctrl+C means the obvious thing.** During a command it abandons that command and returns to the prompt; at the prompt it quits. So stopping a scan costs one keystroke and leaving costs two, and neither one loses your results by diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index a0f7bc0..47991fb 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -418,10 +418,17 @@ def option_words(name: str) -> List[str]: @dataclass(frozen=True) class Page: - """One screen of a longer listing, plus the way to ask for the next.""" + """One page of a longer listing, and where it sits in the whole.""" rows: List[Any] + #: Rows in the full listing, not just this page. total: int + #: Which page this is, counting from 1. + number: int = 1 + #: How many pages the listing has in total. + count: int = 1 + #: Index of the first row on this page, for numbering result rows. + offset: int = 0 #: The command line that shows the following page, or ``None`` at the end. next_page: Optional[str] = None @@ -441,11 +448,11 @@ def add_paging_arguments(parser: CommandParser) -> CommandParser: help="rows per page, overriding the 'limit' setting", ) parser.add_argument( - "--offset", + "--page", type=int, - default=0, + default=1, metavar="N", - help="skip the first N rows — how you reach the second page", + help="which page to show, counting from 1", ) parser.add_argument( "--all", action="store_true", help="print every row, ignoring the limit" @@ -459,28 +466,41 @@ def paginate( *, command: str, limit: Optional[int] = None, - offset: int = 0, + page: int = 1, show_all: bool = False, ) -> Page: - """Cut ``entries`` down to one page, and name the command for the next one. + """Cut ``entries`` down to one page, and say where that page sits. - ``command`` is what the reader would type to page on; it is spelled out in - the footer so the next page is a copy-paste rather than a puzzle. + Pages rather than offsets, because "page 3 of 12" is a place a reader can + hold in their head and ``--offset 40`` is arithmetic they have to do. + ``command`` is what they would type to move on; it is spelled out in the + footer so the next page is a copy-paste rather than a puzzle. """ - if offset < 0: - raise CommandError("--offset cannot be negative.") - total = len(entries) size = None if show_all else session.display_limit(limit) + if size is None: - return Page(list(entries[offset:]), total) + return Page(list(entries), total, 1, 1, 0) + + pages = max(1, -(-total // size)) # Ceiling division: a part page counts. + if page < 1: + raise CommandError("--page counts from 1.") + if page > pages: + raise CommandError( + f"Page {page} does not exist — this listing has " + f"{pages} page{'' if pages == 1 else 's'}." + ) + offset = (page - 1) * size window = list(entries[offset : offset + size]) - following = offset + size - next_page = f"{command} --offset {following}" if following < total else None - if next_page is not None and limit is not None: - next_page += f" --limit {limit}" - return Page(window, total, next_page) + + next_page = None + if page < pages: + next_page = f"{command} --page {page + 1}" + if limit is not None: + next_page += f" --limit {limit}" + + return Page(window, total, page, pages, offset, next_page) from . import memory_commands # noqa: E402,F401 (registration side effect) diff --git a/peekmem/commands/memory_commands.py b/peekmem/commands/memory_commands.py index d1d1734..9540217 100644 --- a/peekmem/commands/memory_commands.py +++ b/peekmem/commands/memory_commands.py @@ -118,7 +118,7 @@ def cmd_regions(session: Session, args: List[str]) -> None: regions, command="memory:regions", limit=options.limit, - offset=options.offset, + page=options.page, show_all=options.all, ) pointer_size = process.pointer_size @@ -137,6 +137,8 @@ def cmd_regions(session: Session, args: List[str]) -> None: (LEFT, RIGHT, LEFT, LEFT), elapsed=timer.elapsed, total=page.total, + page=page.number, + pages=page.count, next_page=page.next_page, ) @@ -188,7 +190,7 @@ def cmd_modules(session: Session, args: List[str]) -> None: modules, command="memory:modules", limit=options.limit, - offset=options.offset, + page=options.page, show_all=options.all, ) pointer_size = process.pointer_size @@ -207,6 +209,8 @@ def cmd_modules(session: Session, args: List[str]) -> None: (LEFT, LEFT, RIGHT, LEFT), elapsed=timer.elapsed, total=page.total, + page=page.number, + pages=page.count, next_page=page.next_page, ) @@ -240,7 +244,7 @@ def cmd_threads(session: Session, args: List[str]) -> None: threads, command="memory:threads", limit=options.limit, - offset=options.offset, + page=options.page, show_all=options.all, ) @@ -257,6 +261,8 @@ def cmd_threads(session: Session, args: List[str]) -> None: (RIGHT, LEFT, RIGHT), elapsed=timer.elapsed, total=page.total, + page=page.number, + pages=page.count, next_page=page.next_page, ) diff --git a/peekmem/commands/pointer_commands.py b/peekmem/commands/pointer_commands.py index 51cdc90..0865ec8 100644 --- a/peekmem/commands/pointer_commands.py +++ b/peekmem/commands/pointer_commands.py @@ -54,7 +54,7 @@ def _print_paths( paths: Sequence[PointerPath], *, limit: Optional[int] = None, - offset: int = 0, + number: int = 1, show_all: bool = False, elapsed: Optional[float] = None, ) -> None: @@ -65,12 +65,12 @@ def _print_paths( paths, command="pointer:paths", limit=limit, - offset=offset, + page=number, show_all=show_all, ) rows = [] - for index, path in enumerate(page.rows, start=offset): + for index, path in enumerate(page.rows, start=page.offset): try: target = format_address(path.resolve(process), pointer_size) except (OSError, ValueError): @@ -87,6 +87,8 @@ def _print_paths( (RIGHT, LEFT, LEFT, LEFT), elapsed=elapsed, total=page.total, + page=page.number, + pages=page.count, next_page=page.next_page, ) @@ -372,7 +374,7 @@ def cmd_paths(session: Session, args: List[str]) -> None: session, session.pointer_paths, limit=options.limit, - offset=options.offset, + number=options.page, show_all=options.all, ) diff --git a/peekmem/commands/ps_commands.py b/peekmem/commands/ps_commands.py index 2a53282..57e1ab1 100644 --- a/peekmem/commands/ps_commands.py +++ b/peekmem/commands/ps_commands.py @@ -58,7 +58,7 @@ def cmd_ps(session: Session, args: List[str]) -> None: entries, command="ps:list", limit=options.limit, - offset=options.offset, + page=options.page, show_all=options.all, ) @@ -71,6 +71,8 @@ def cmd_ps(session: Session, args: List[str]) -> None: (RIGHT, LEFT), elapsed=timer.elapsed, total=page.total, + page=page.number, + pages=page.count, next_page=page.next_page, ) diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index e1eebb5..aea7bb4 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -241,7 +241,7 @@ def _print_results( *, limit: Optional[int] = None, elapsed: Optional[float] = None, - offset: int = 0, + number: int = 1, ) -> None: """Print the result set, one page at a time. @@ -252,7 +252,7 @@ def _print_results( hex_output = bool(session.option("hex")) indexes = range(len(state.addresses)) page = paginate( - session, indexes, command="scan:results", limit=limit, offset=offset + session, indexes, command="scan:results", limit=limit, page=number ) rows = [ @@ -270,6 +270,8 @@ def _print_results( (RIGHT, LEFT, LEFT), elapsed=elapsed, total=page.total, + page=page.number, + pages=page.count, next_page=page.next_page, ) @@ -722,9 +724,14 @@ def _results_parser() -> CommandParser: "shows the value the last scan recorded — the one 'scan:next changed' and " "friends compare against — and is filled in only where the two " "differ.\n\n" - "Row numbers are what '#N' refers to in an address." + "Row numbers are what '#N' refers to in an address, and they keep " + "counting across pages: row #21 is the first on page 2 of twenty." + ), + examples=( + "scan:results", + "scan:results --all", + "scan:results --page 3 --limit 10", ), - examples=("scan:results", "scan:results --all", "scan:results --offset 20 --limit 10"), ) def cmd_results(session: Session, args: List[str]) -> None: options = _results_parser().parse_args(args) @@ -738,7 +745,7 @@ def cmd_results(session: Session, args: List[str]) -> None: range(len(state.addresses)), command="scan:results", limit=options.limit, - offset=options.offset, + page=options.page, show_all=options.all, ) window = list(page.rows) @@ -772,6 +779,8 @@ def cmd_results(session: Session, args: List[str]) -> None: (RIGHT, LEFT, LEFT, LEFT), elapsed=timer.elapsed, total=page.total, + page=page.number, + pages=page.count, next_page=page.next_page, ) diff --git a/peekmem/output.py b/peekmem/output.py index 697b01f..6faec5c 100644 --- a/peekmem/output.py +++ b/peekmem/output.py @@ -268,6 +268,8 @@ def table( *, elapsed: Optional[float] = None, total: Optional[int] = None, + page: Optional[int] = None, + pages: Optional[int] = None, next_page: Optional[str] = None, ) -> None: """Print a result table plus its footer. @@ -280,7 +282,14 @@ def table( self.clear_progress() if rows: self.write(render_table(headers, rows, aligns)) - self.footer(len(rows), elapsed=elapsed, total=total, next_page=next_page) + self.footer( + len(rows), + elapsed=elapsed, + total=total, + page=page, + pages=pages, + next_page=next_page, + ) def footer( self, @@ -288,15 +297,21 @@ def footer( *, elapsed: Optional[float] = None, total: Optional[int] = None, + page: Optional[int] = None, + pages: Optional[int] = None, next_page: Optional[str] = None, ) -> None: """Print the ``N rows in set (0.01 sec)`` line, and how to see more.""" if count == 0 and not total: text = "Empty set" elif total is not None and total != count: - # The table was cut to the display limit; say so plainly rather - # than reporting a row count that is not the answer to the query. + # The table was cut to one page; say so plainly rather than + # reporting a row count that is not the answer to the query, and + # say which page it is — "page 3 of 12" is a place you can hold in + # your head. text = f"Showing {count} of {total} rows" + if pages and pages > 1: + text += f" — page {page} of {pages}" else: text = f"{count} row{'' if count == 1 else 's'} in set" if self.timing and elapsed is not None: diff --git a/tests/test_commands.py b/tests/test_commands.py index ae4ccfa..3f7d588 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -484,7 +484,7 @@ def test_every_listing_command_pages_the_same_way(name): flags = { flag for action in lookup(name).arguments() for flag in action.option_strings } - assert {"--limit", "--offset", "--all"} <= flags + assert {"--limit", "--page", "--all"} <= flags @pytest.mark.parametrize("name", PAGED) @@ -493,12 +493,12 @@ def test_paging_flags_are_documented_identically(name): reference = { action.dest: action.help for action in lookup("scan:results").arguments() - if action.dest in ("limit", "offset", "all") + if action.dest in ("limit", "page", "all") } actual = { action.dest: action.help for action in lookup(name).arguments() - if action.dest in ("limit", "offset", "all") + if action.dest in ("limit", "page", "all") } assert actual == reference @@ -506,7 +506,7 @@ def test_paging_flags_are_documented_identically(name): def test_a_truncated_listing_names_the_next_page(shell, capture): shell.run_line("ps:list --limit 2") assert "Showing 2 of" in capture.out - assert "Next page: ps:list --offset 2 --limit 2" in capture.out + assert "Next page: ps:list --page 2 --limit 2" in capture.out def test_the_last_page_offers_no_next(shell, capture): @@ -514,9 +514,20 @@ def test_the_last_page_offers_no_next(shell, capture): assert "Next page:" not in capture.out -def test_offset_cannot_be_negative(shell): - with pytest.raises(CommandError, match="offset"): - shell.run_line("ps:list --offset -1", raise_errors=True) +def test_pages_count_from_one(shell): + with pytest.raises(CommandError, match="counts from 1"): + shell.run_line("ps:list --page 0", raise_errors=True) + + +def test_a_page_past_the_end_says_how_many_there_are(shell): + """Better than an empty table, which reads like "no results".""" + with pytest.raises(CommandError, match="does not exist"): + shell.run_line("ps:list --limit 5 --page 99999", raise_errors=True) + + +def test_a_truncated_listing_says_which_page_it_is(shell, capture): + shell.run_line("ps:list --limit 2 --page 2") + assert "— page 2 of " in capture.out def test_a_scan_preview_pages_through_scan_results(session, capture): @@ -536,7 +547,7 @@ class FakeProcess: _print_results(session, state) assert "Showing 2 of 3 rows" in capture.out - assert "Next page: scan:results --offset 2" in capture.out + assert "Next page: scan:results --page 2" in capture.out def test_the_word_namespace_never_reaches_the_reader(shell, capture): From cac7b3d20b6b7a2973f07fb1eb757898cbf6942d Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:24:01 -0300 Subject: [PATCH 22/82] feat(shell): dim the attached target in the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'peekmem [game.exe:4242]>' now shows the bracketed part faintly, so a glance tells you writes are going somewhere without the prompt competing with the output above it. Faint rather than a colour: it derives from whatever foreground the terminal already uses, so it reads as quieter on a light theme and a dark one alike, and a terminal that does not implement it shows ordinary text — the worst case is no emphasis rather than an unreadable one. It follows the same switch as the red ERROR, so a redirected or NO_COLOR session sees plain text. The escapes are bracketed in \\001/\\002 when readline is driving the line. Without that, readline counts them toward the prompt's width and puts the cursor in the wrong column the moment the line wraps or history is recalled — the classic way a coloured prompt breaks editing. Verified in a pty that the markers reach readline and not the screen. --- README.md | 5 +++-- peekmem/output.py | 29 +++++++++++++++++++++++++++++ peekmem/shell.py | 15 +++++++++++++-- tests/test_output.py | 25 +++++++++++++++++++++++++ tests/test_shell.py | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e6708aa..1c29edf 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,9 @@ echo "ps:list" | peekmem # a pipe ``` Results go to stdout and errors to stderr, tables are plain ASCII, colour is -off whenever the output is not a terminal, and a failing command exits -non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave. +off whenever the output is not a terminal — in a terminal it amounts to a red +`ERROR` and a dimmed target in the prompt, and nothing else — and a failing +command exits non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave. ## What it can do diff --git a/peekmem/output.py b/peekmem/output.py index 6faec5c..5dc28a9 100644 --- a/peekmem/output.py +++ b/peekmem/output.py @@ -25,8 +25,20 @@ LEFT = "left" _RED = "\033[31m" +#: Faint, rather than any actual colour. It is derived from whatever the +#: terminal's foreground already is, so it reads as "quieter" on a light theme +#: and a dark one alike, and a terminal that does not implement it simply shows +#: ordinary text — the worst case is no emphasis, never an unreadable one. +_DIM = "\033[2m" _RESET = "\033[0m" +#: readline measures a prompt to know where the cursor is. Escapes bracketed +#: by these are excluded from that measurement; without them, a coloured prompt +#: makes editing land in the wrong column as soon as the line wraps or history +#: is recalled. +_RL_IGNORE_START = "\001" +_RL_IGNORE_END = "\002" + def supports_color(stream: TextIO) -> bool: """True when it is polite to emit ANSI escapes on ``stream``.""" @@ -252,6 +264,23 @@ def clear_screen(self) -> bool: self.stdout.flush() return True + def dim(self, text: str, *, in_prompt: bool = False) -> str: + """Return ``text`` faintly styled, or unchanged when colour is off. + + :param in_prompt: bracket the escapes for readline. Pass it only when + readline is actually handling the line — the markers are invisible + to readline and literal control characters to anything else. + """ + if not self.color or not text: + return text + if in_prompt: + return ( + f"{_RL_IGNORE_START}{_DIM}{_RL_IGNORE_END}" + f"{text}" + f"{_RL_IGNORE_START}{_RESET}{_RL_IGNORE_END}" + ) + return f"{_DIM}{text}{_RESET}" + def note(self, message: str) -> None: """Print an aside — a warning that did not stop the command.""" self.clear_progress() diff --git a/peekmem/shell.py b/peekmem/shell.py index d33d5eb..b48e1d7 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -65,6 +65,9 @@ def __init__( self.session.shell = self self.stdin = stdin if stdin is not None else sys.stdin self._history_loaded = False + # True once readline is driving input(), which decides whether the + # prompt's escapes need its width-ignoring brackets. + self._readline = False # -- parsing and dispatch --------------------------------------------- @@ -224,11 +227,17 @@ def run_lines(self, lines: Iterable[str], *, raise_errors: bool = False) -> int: # -- the interactive loop --------------------------------------------- def prompt(self) -> str: - """The prompt, naming the target so you cannot write to the wrong one.""" + """The prompt, naming the target so you cannot write to the wrong one. + + The target is dimmed rather than coloured: it is there to be noticed + out of the corner of your eye — a reminder that writes are going + somewhere — not to compete with the output above it. + """ if self.session.process is None: return "peekmem> " name = self.session.process_name or "?" - return f"peekmem [{name}:{self.session.process.pid}]> " + target = f"[{name}:{self.session.process.pid}]" + return f"peekmem {self.printer.dim(target, in_prompt=self._readline)}> " def banner(self) -> str: import PyMemoryEditor @@ -295,6 +304,8 @@ def _setup_readline(self) -> None: except ImportError: # pragma: no cover - Windows without pyreadline3 return + self._readline = True + try: readline.read_history_file(HISTORY_FILE) except (OSError, ValueError): diff --git a/tests/test_output.py b/tests/test_output.py index 254de99..406e47c 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -90,3 +90,28 @@ def test_clear_screen_wipes_screen_and_scrollback(capture): def test_progress_is_silent_when_stderr_is_not_a_terminal(capture): capture.printer.progress("Scanning", 0.5) assert capture.err == "" + + +def test_dim_is_a_no_op_when_colour_is_off(capture): + assert capture.printer.dim("[game.exe:42]") == "[game.exe:42]" + + +def test_dim_uses_the_faint_attribute_not_a_colour(capture): + """Faint follows the terminal's own foreground, so it reads on any theme.""" + capture.printer.color = True + assert capture.printer.dim("x") == "\033[2mx\033[0m" + + +def test_dim_brackets_its_escapes_for_readline(capture): + """Unbracketed escapes make readline miscount the prompt and misplace the + cursor as soon as the line wraps.""" + capture.printer.color = True + styled = capture.printer.dim("x", in_prompt=True) + assert styled == "\001\033[2m\002x\001\033[0m\002" + # Every escape sits inside a pair of markers. + assert styled.count("\001") == styled.count("\002") == 2 + + +def test_dim_leaves_empty_text_alone(capture): + capture.printer.color = True + assert capture.printer.dim("") == "" diff --git a/tests/test_shell.py b/tests/test_shell.py index c8d14cc..cacc487 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -68,8 +68,43 @@ def test_run_lines_returns_the_exit_status(shell): assert shell.run_lines(["version", "exit"]) == 0 +class _FakeProcess: + pid = 4242 + + +def _attach(shell): + shell.session.process = _FakeProcess() # type: ignore[assignment] + shell.session.process_name = "game.exe" + + def test_prompt_names_the_target(shell): assert shell.prompt() == "peekmem> " + _attach(shell) + assert shell.prompt() == "peekmem [game.exe:4242]> " + + +def test_the_target_is_dimmed_when_colour_is_on(shell, capture): + """A reminder that writes are going somewhere, not a thing to look at.""" + capture.printer.color = True + _attach(shell) + assert shell.prompt() == "peekmem \033[2m[game.exe:4242]\033[0m> " + + +def test_an_empty_prompt_is_never_styled(shell, capture): + """Nothing is attached, so there is nothing to point at.""" + capture.printer.color = True + assert shell.prompt() == "peekmem> " + assert "\033" not in shell.prompt() + + +def test_the_prompt_brackets_its_escapes_only_under_readline(shell, capture): + capture.printer.color = True + _attach(shell) + assert "\001" not in shell.prompt() + + shell._readline = True + assert "\001" in shell.prompt() + assert shell.prompt().count("\001") == shell.prompt().count("\002") def test_help_lists_every_namespace(shell, capture): From f05242066c4e83e80bd2534980cf253a3dfc3f0e Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:30:46 -0300 Subject: [PATCH 23/82] feat(config): split config into config:list and config:set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'config' was doing three jobs behind one word — print everything, read one back, assign — told apart by counting arguments, which is how 'config limit' came to mean "read" while 'config limit 50' meant "write". It is a parent command now, like the others: bare 'config' prints its page and runs nothing, 'config:list [name]' shows the settings, 'config:set ' changes one. 'config:set name=value' still works as one word. Reading and writing being different commands lets each say something useful when it goes wrong: 'config:set limit' now answers "needs a value — to read one back, use 'config:list limit'" instead of quietly printing the value, and an unknown name lists the real ones from either side. --- README.md | 3 +- peekmem/commands/__init__.py | 11 +++ peekmem/commands/scan_commands.py | 2 +- peekmem/commands/session_commands.py | 124 +++++++++++++++++---------- peekmem/shell.py | 2 +- tests/test_cli.py | 2 +- tests/test_commands.py | 27 ++++-- tests/test_shell.py | 4 +- 8 files changed, 117 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 1c29edf..3166eeb 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,8 @@ and `help scan` all produce the same output. | **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | | **`scan:`** | `value` · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | | **`pointer:`** | `deref` · `read` · `scan` · `rescan` · `paths` · `save` · `load` · `diff` | -| Top level | `help` · `config` · `source` · `version` · `clear` · `exit` | +| **`config:`** | `list` · `set` | +| Top level | `help` · `source` · `version` · `clear` · `exit` | `help ` — or ` --help` — documents each one in full: every argument, every flag, and examples. That list is generated from the command's diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index 47991fb..71bee29 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -85,6 +85,17 @@ class Namespace: "+-----+--------------------+-------+\n" "1 row in set (0.02 sec)", ), + Namespace( + "config", + "Configuration", + "Show or change the session's settings.", + "peekmem> config:set writable_only on\n" + "writable_only = on\n" + "\n" + "peekmem> config:list limit\n" + "\n" + "limit: 20", + ), Namespace( "pointer", "Pointers", diff --git a/peekmem/commands/scan_commands.py b/peekmem/commands/scan_commands.py index aea7bb4..7bcbc41 100644 --- a/peekmem/commands/scan_commands.py +++ b/peekmem/commands/scan_commands.py @@ -223,7 +223,7 @@ def _report( if state.truncated: printer.note( f"Stopped at the max_results cap ({session.option('max_results')}). " - "Narrow the scan, or raise it with 'config max_results N'." + "Narrow the scan, or raise it with 'config:set max_results N'." ) if outcome.skipped: printer.note( diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 0fda83c..48e06e3 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -83,6 +83,15 @@ } +def _find_setting(name: str): + """Look up a setting by name, listing the real ones when it is not one.""" + setting = {item.name: item for item in SETTINGS}.get(name.strip().lower()) + if setting is None: + known = ", ".join(item.name for item in SETTINGS) + raise CommandError(f"Unknown setting {name!r}. Known settings: {known}.") + return setting + + def _format_setting(value: object) -> str: """Print a setting the way it is typed: booleans as on/off, not True/False.""" if isinstance(value, bool): @@ -387,70 +396,91 @@ def command_words_set() -> set: return set(command_words()) -def _config_parser() -> CommandParser: - parser = CommandParser("config") +def _config_list_parser() -> CommandParser: + parser = CommandParser("config:list") parser.add_argument( - "assignment", - nargs="*", - default=[], - help="'name value', 'name=value', or a bare 'name' to read one back. " - "Omit it to print every setting", + "name", + nargs="?", + default=None, + help="show just this one setting; omit it for all of them", ) return parser @command( - "config", - parser=_config_parser, - summary="Show or change a session setting.", + "config:list", + parser=_config_list_parser, + summary="Show the session's settings and their current values.", details=( "Settings live for the session only — Peekmem writes no config file, " "so a fresh shell always starts from the documented defaults. Put the " - "'config' lines in a script and run it with 'source' to reuse a " - "setup.\n\n" - "Run 'config' with no argument to see every setting, its current value " - "and what it does." - ), - examples=( - "config", - "config limit 50", - "config hex on", - "config writable_only=true", + "'config:set' lines in a script and run it with 'source' to reuse a " + "setup." ), + examples=("config:list", "config:list limit"), ) -def cmd_config(session: Session, args: List[str]) -> None: - options = _config_parser().parse_args(args) - assignment = options.assignment - - if not assignment: - rows = [ - (setting.name, _format_setting(session.option(setting.name)), setting.summary) - for setting in SETTINGS - ] - session.printer.table( - ("SETTING", "VALUE", "DESCRIPTION"), rows, (LEFT, RIGHT, LEFT) - ) - return +def cmd_config_list(session: Session, args: List[str]) -> None: + options = _config_list_parser().parse_args(args) - if len(assignment) == 1 and "=" in assignment[0]: - name, _, value = assignment[0].partition("=") - elif len(assignment) == 1: - name, value = assignment[0], None - elif len(assignment) == 2: - name, value = assignment[0], assignment[1] - else: - raise CommandError("Usage: config [name [value]]") - - if value is None: - setting = {item.name: item for item in SETTINGS}.get(name.lower()) - if setting is None: - raise CommandError(f"Unknown setting {name!r}.") + if options.name is not None: + setting = _find_setting(options.name) session.printer.write( - render_vertical([(setting.name, _format_setting(session.option(setting.name)))]) + render_vertical( + [(setting.name, _format_setting(session.option(setting.name)))] + ) ) session.printer.write() return + rows = [ + (setting.name, _format_setting(session.option(setting.name)), setting.summary) + for setting in SETTINGS + ] + session.printer.table(("SETTING", "VALUE", "DESCRIPTION"), rows, (LEFT, RIGHT, LEFT)) + + +def _config_set_parser() -> CommandParser: + parser = CommandParser("config:set") + parser.add_argument( + "name", help="the setting to change; 'name=value' in one word also works" + ) + parser.add_argument( + "value", + nargs="?", + default=None, + help="its new value — on/off for a switch, a number otherwise", + ) + return parser + + +@command( + "config:set", + parser=_config_set_parser, + summary="Change one of the session's settings.", + details=( + "'config:set limit 50' and 'config:set limit=50' do the same thing.\n\n" + "The change lasts for the session and no longer. Run 'config:list' to " + "see what can be set, and what each one does." + ), + examples=( + "config:set limit 50", + "config:set hex on", + "config:set writable_only=true", + ), +) +def cmd_config_set(session: Session, args: List[str]) -> None: + options = _config_set_parser().parse_args(args) + + name, value = options.name, options.value + if value is None: + if "=" not in name: + raise CommandError( + f"config:set needs a value: 'config:set {name} '. " + f"To read one back, use 'config:list {name}'." + ) + name, _, value = name.partition("=") + + _find_setting(name) # Reject an unknown name before parsing its value. applied = session.set_option(name, value) session.printer.ok(f"{name.strip().lower()} = {_format_setting(applied)}") session.printer.write() diff --git a/peekmem/shell.py b/peekmem/shell.py index b48e1d7..b0ddb5b 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -351,7 +351,7 @@ def _complete(self, text: str, state: int) -> Optional[str]: ] else: head = buffer.strip().split()[0].lower() - if head == "config": + if head in ("config:set", "config:list"): candidates = [setting.name for setting in SETTINGS] elif head == "help": candidates = command_words() + ["types", "address", "scanning"] diff --git a/tests/test_cli.py b/tests/test_cli.py index 1a47a32..affe1d6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,7 +34,7 @@ def test_execute_runs_a_command_and_exits(): def test_execute_flags_run_in_order(): - status, out, _ = run(["-e", "config limit 3", "-e", "config"]) + status, out, _ = run(["-e", "config:set limit 3", "-e", "config:list"]) assert status == 0 assert out.index("limit = 3") < out.index("SETTING") diff --git a/tests/test_commands.py b/tests/test_commands.py index 3f7d588..c7a3196 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -40,7 +40,6 @@ def test_only_shell_commands_are_top_level(): """Anything that touches the target belongs to a subject namespace.""" assert sorted(entry.name for entry in top_level()) == [ "clear", - "config", "exit", "help", "source", @@ -434,21 +433,21 @@ def test_ps_lists_this_process(shell, capture): """The one command that talks to the OS without attaching to anything.""" import os - shell.run_line("config limit 0") + shell.run_line("config:set limit 0") shell.run_line("ps:list") assert str(os.getpid()) in capture.out def test_config_prints_booleans_the_way_they_are_typed(shell, capture): - shell.run_line("config") + shell.run_line("config:list") assert "| off" in capture.out or "off " in capture.out capture.reset() - shell.run_line("config hex on") + shell.run_line("config:set hex on") assert "hex = on" in capture.out def test_config_accepts_the_equals_form(shell): - shell.run_line("config limit=42") + shell.run_line("config:set limit=42") assert shell.session.option("limit") == 42 @@ -612,3 +611,21 @@ def test_the_help_only_ever_advertises_one_spelling(shell, capture): row for row in capture.out.splitlines() if "get help with" in row ) assert '"help ' in hint, f"{line!r} advertises something else: {hint!r}" + + +def test_config_reads_one_setting_back(shell, capture): + shell.run_line("config:list limit") + assert "limit: 20" in capture.out + + +def test_config_set_without_a_value_points_at_the_reader(shell, capture): + """Setting and reading are different commands now; say which is which.""" + assert shell.run_line("config:set limit") is False + assert "config:list limit" in capture.err + + +@pytest.mark.parametrize("line", ["config:list nosuch", "config:set nosuch on"]) +def test_an_unknown_setting_lists_the_real_ones(shell, capture, line): + assert shell.run_line(line) is False + assert "Unknown setting" in capture.err + assert "max_results" in capture.err diff --git a/tests/test_shell.py b/tests/test_shell.py index cacc487..0dd6d41 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -123,7 +123,7 @@ def test_help_topics_are_reachable(shell, capture): def test_source_runs_a_file(shell, capture, tmp_path): script = tmp_path / "setup.peek" - script.write_text("# a comment\nconfig limit 7\n\nconfig hex on\n") + script.write_text("# a comment\nconfig:set limit 7\n\nconfig:set hex on\n") shell.run_line(f"source {script}") assert shell.session.option("limit") == 7 assert shell.session.option("hex") is True @@ -131,7 +131,7 @@ def test_source_runs_a_file(shell, capture, tmp_path): def test_source_stops_at_the_failing_line(shell, capture, tmp_path): script = tmp_path / "bad.peek" - script.write_text("config limit 7\nnosuchcommand\nconfig limit 9\n") + script.write_text("config:set limit 7\nnosuchcommand\nconfig:set limit 9\n") shell.run_line(f"source {script}") assert "bad.peek:2" in capture.err assert shell.session.option("limit") == 7 From 79410242f56efc9dc5239afabeb394d22db17bc4 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:34:52 -0300 Subject: [PATCH 24/82] feat(help): dim the contents of every example block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transcript sitting inside a help page is there to be recognised as a transcript at a glance — the shape of it is the information, not the words. Dimming the contents separates it from the prose above and the listing below without adding a rule or a box. The same faint attribute as the prompt's target, so it stays legible on a light theme and a dark one, and it follows the same switch: redirected output carries no escapes at all. The 'Example:' and 'Examples:' labels stay at full strength, so the block is still findable when skimming. Dimmed a line at a time rather than one escape around the block: a single span survives neither a pager nor a terminal that reflows it. --- peekmem/commands/session_commands.py | 22 +++++++---- tests/test_commands.py | 57 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 48e06e3..b92983a 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -163,19 +163,27 @@ def _command_rows(commands) -> List[Tuple[str, str]]: def _print_example(session: Session, example: str, *, indent: int = 0) -> None: - """Print an indented ``Example:`` block, verbatim. + """Print an indented ``Example:`` block, verbatim but dimmed. + + The label stays at full strength so the block is findable when skimming; + its contents are dimmed, because a transcript sitting inside a help page is + there to be recognised as a transcript at a glance, not read word by word + on the way past. ``indent`` nests the whole block, which is how the top-level help tucks its - example inside the namespaces section rather than floating it above. + example inside the command listing rather than floating it above. """ if not example: return + printer = session.printer pad = " " * indent - session.printer.write(f"{pad}Example:") - session.printer.write() + printer.write(f"{pad}Example:") + printer.write() for line in example.splitlines(): - session.printer.write(f"{pad} {line}" if line else "") - session.printer.write() + # Dimmed a line at a time: one escape spanning a whole block survives + # neither a pager nor a terminal that reflows it. + printer.write(f"{pad} {printer.dim(line)}" if line else "") + printer.write() def _print_overview(session: Session) -> None: @@ -314,7 +322,7 @@ def _print_command_help(session: Session, name: str) -> None: if entry.examples: printer.write("Examples:") for example in entry.examples: - printer.write(f" {example}") + printer.write(f" {printer.dim(example)}") printer.write() diff --git a/tests/test_commands.py b/tests/test_commands.py index c7a3196..739ccbb 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -629,3 +629,60 @@ def test_an_unknown_setting_lists_the_real_ones(shell, capture, line): assert shell.run_line(line) is False assert "Unknown setting" in capture.err assert "max_results" in capture.err + + +def _coloured_shell(capture): + """A shell whose printer emits colour, as a terminal session would.""" + capture.printer.color = True + from peekmem.session import Session + from peekmem.shell import Shell + + return Shell(Session(capture.printer), printer=capture.printer) + + +@pytest.mark.parametrize("line", ["help", "config:help", "scan:help", "ps:help"]) +def test_example_blocks_are_dimmed(capture, line): + """A transcript inside a help page should read as a transcript at a glance.""" + shell = _coloured_shell(capture) + shell.run_line(line) + + body = [ + row + for row in capture.out.splitlines() + if row.strip() and "Example:" not in row and row.startswith(" ") + ] + transcript = [row for row in body if "peekmem>" in row] + assert transcript, f"{line!r} printed no example" + for row in transcript: + assert "\033[2m" in row and "\033[0m" in row + + +def test_the_example_label_is_not_dimmed(capture): + """It stays at full strength so the block is findable when skimming.""" + shell = _coloured_shell(capture) + shell.run_line("scan:help") + label = next( + row for row in capture.out.splitlines() if row.strip() == "Example:" + ) + assert "\033" not in label + + +def test_a_commands_own_examples_are_dimmed_too(capture): + shell = _coloured_shell(capture) + shell.run_line("help memory:read") + # The escape comes before the text, so match on the content, not the start. + listed = [ + row + for row in capture.out.splitlines() + if row.startswith(" ") and "memory:read 0x" in row + ] + assert listed + for row in listed: + assert "\033[2m" in row + + +@pytest.mark.parametrize("line", ["help", "scan:help", "help memory:read"]) +def test_examples_stay_plain_when_colour_is_off(shell, capture, line): + """Redirected output must not carry escapes into a file.""" + shell.run_line(line) + assert "\033" not in capture.out From 5935df6c99412e30468226a106fdf89f55c5d6c3 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:38:17 -0300 Subject: [PATCH 25/82] feat(help): grey the example blocks, a shade lighter than the prompt Faint made a transcript hard to read line after line, which is the wrong trade for a block that exists to be read. Example blocks are grey (bright black, the conventional secondary-text colour); the prompt's target keeps the fainter shade, because it only has to be noticed. Both go through one styling helper, so the readline bracketing and the colour-off switch stay in a single place. A caveat worth writing down: grey is an actual colour where faint is derived from the terminal's own foreground, so on a light-background theme it has less contrast than faint would. Terminals overwhelmingly default to a dark background and every other tool greys its secondary text the same way, so this takes that bet knowingly. --- peekmem/commands/session_commands.py | 15 +++++------ peekmem/output.py | 37 +++++++++++++++++++++------- tests/test_commands.py | 18 ++++++++++---- tests/test_output.py | 9 +++++++ 4 files changed, 58 insertions(+), 21 deletions(-) diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index b92983a..e8eb5f3 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -163,12 +163,13 @@ def _command_rows(commands) -> List[Tuple[str, str]]: def _print_example(session: Session, example: str, *, indent: int = 0) -> None: - """Print an indented ``Example:`` block, verbatim but dimmed. + """Print an indented ``Example:`` block, verbatim but greyed. The label stays at full strength so the block is findable when skimming; - its contents are dimmed, because a transcript sitting inside a help page is - there to be recognised as a transcript at a glance, not read word by word - on the way past. + its contents are greyed, because a transcript sitting inside a help page is + there to be recognised as a transcript at a glance — set apart from the + prose, but still readable line after line, which is why it is grey rather + than the fainter shade the prompt uses. ``indent`` nests the whole block, which is how the top-level help tucks its example inside the command listing rather than floating it above. @@ -180,9 +181,9 @@ def _print_example(session: Session, example: str, *, indent: int = 0) -> None: printer.write(f"{pad}Example:") printer.write() for line in example.splitlines(): - # Dimmed a line at a time: one escape spanning a whole block survives + # Styled a line at a time: one escape spanning a whole block survives # neither a pager nor a terminal that reflows it. - printer.write(f"{pad} {printer.dim(line)}" if line else "") + printer.write(f"{pad} {printer.grey(line)}" if line else "") printer.write() @@ -322,7 +323,7 @@ def _print_command_help(session: Session, name: str) -> None: if entry.examples: printer.write("Examples:") for example in entry.examples: - printer.write(f" {printer.dim(example)}") + printer.write(f" {printer.grey(example)}") printer.write() diff --git a/peekmem/output.py b/peekmem/output.py index 5dc28a9..98b42d3 100644 --- a/peekmem/output.py +++ b/peekmem/output.py @@ -30,6 +30,13 @@ #: and a dark one alike, and a terminal that does not implement it simply shows #: ordinary text — the worst case is no emphasis, never an unreadable one. _DIM = "\033[2m" + +#: Grey — bright black, the conventional "secondary text" colour. A shade +#: lighter than faint on a dark terminal, which is what an example block wants: +#: set apart from the prose around it, but a whole transcript at a time still +#: has to be readable, where a one-line prompt only has to be noticed. +_GREY = "\033[90m" + _RESET = "\033[0m" #: readline measures a prompt to know where the cursor is. Escapes bracketed @@ -264,22 +271,34 @@ def clear_screen(self) -> bool: self.stdout.flush() return True - def dim(self, text: str, *, in_prompt: bool = False) -> str: - """Return ``text`` faintly styled, or unchanged when colour is off. - - :param in_prompt: bracket the escapes for readline. Pass it only when - readline is actually handling the line — the markers are invisible - to readline and literal control characters to anything else. - """ + def _style(self, text: str, escape: str, *, in_prompt: bool = False) -> str: + """Wrap ``text`` in ``escape``, or return it unchanged when colour is off.""" if not self.color or not text: return text if in_prompt: return ( - f"{_RL_IGNORE_START}{_DIM}{_RL_IGNORE_END}" + f"{_RL_IGNORE_START}{escape}{_RL_IGNORE_END}" f"{text}" f"{_RL_IGNORE_START}{_RESET}{_RL_IGNORE_END}" ) - return f"{_DIM}{text}{_RESET}" + return f"{escape}{text}{_RESET}" + + def dim(self, text: str, *, in_prompt: bool = False) -> str: + """Return ``text`` faintly styled — for something to notice, not read. + + :param in_prompt: bracket the escapes for readline. Pass it only when + readline is actually handling the line — the markers are invisible + to readline and literal control characters to anything else. + """ + return self._style(text, _DIM, in_prompt=in_prompt) + + def grey(self, text: str) -> str: + """Return ``text`` in grey — for a block that is set apart but read. + + A shade lighter than :meth:`dim`, because a transcript has to stay + legible line after line where a prompt only has to catch the eye. + """ + return self._style(text, _GREY) def note(self, message: str) -> None: """Print an aside — a warning that did not stop the command.""" diff --git a/tests/test_commands.py b/tests/test_commands.py index 739ccbb..16d6beb 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -641,7 +641,7 @@ def _coloured_shell(capture): @pytest.mark.parametrize("line", ["help", "config:help", "scan:help", "ps:help"]) -def test_example_blocks_are_dimmed(capture, line): +def test_example_blocks_are_greyed(capture, line): """A transcript inside a help page should read as a transcript at a glance.""" shell = _coloured_shell(capture) shell.run_line(line) @@ -654,10 +654,10 @@ def test_example_blocks_are_dimmed(capture, line): transcript = [row for row in body if "peekmem>" in row] assert transcript, f"{line!r} printed no example" for row in transcript: - assert "\033[2m" in row and "\033[0m" in row + assert "\033[90m" in row and "\033[0m" in row -def test_the_example_label_is_not_dimmed(capture): +def test_the_example_label_is_not_styled(capture): """It stays at full strength so the block is findable when skimming.""" shell = _coloured_shell(capture) shell.run_line("scan:help") @@ -667,7 +667,7 @@ def test_the_example_label_is_not_dimmed(capture): assert "\033" not in label -def test_a_commands_own_examples_are_dimmed_too(capture): +def test_a_commands_own_examples_are_greyed_too(capture): shell = _coloured_shell(capture) shell.run_line("help memory:read") # The escape comes before the text, so match on the content, not the start. @@ -678,7 +678,7 @@ def test_a_commands_own_examples_are_dimmed_too(capture): ] assert listed for row in listed: - assert "\033[2m" in row + assert "\033[90m" in row @pytest.mark.parametrize("line", ["help", "scan:help", "help memory:read"]) @@ -686,3 +686,11 @@ def test_examples_stay_plain_when_colour_is_off(shell, capture, line): """Redirected output must not carry escapes into a file.""" shell.run_line(line) assert "\033" not in capture.out + + +def test_examples_and_the_prompt_use_different_shades(capture): + """A transcript is read; the prompt's target is only noticed.""" + capture.printer.color = True + assert capture.printer.grey("x") != capture.printer.dim("x") + assert "\033[90m" in capture.printer.grey("x") + assert "\033[2m" in capture.printer.dim("x") diff --git a/tests/test_output.py b/tests/test_output.py index 406e47c..7ca471a 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -115,3 +115,12 @@ def test_dim_brackets_its_escapes_for_readline(capture): def test_dim_leaves_empty_text_alone(capture): capture.printer.color = True assert capture.printer.dim("") == "" + + +def test_grey_is_a_no_op_when_colour_is_off(capture): + assert capture.printer.grey("peekmem> help") == "peekmem> help" + + +def test_grey_is_bright_black(capture): + capture.printer.color = True + assert capture.printer.grey("x") == "\033[90mx\033[0m" From e1210a61d6ddaf92abdf81b3cc4fd699316624ee Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:40:36 -0300 Subject: [PATCH 26/82] feat(output): lighten both greys, and pick them explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt's target moves off the faint attribute and the example blocks off bright black; both are now explicit 256-colour greys, 247 for the prompt and 252 for examples — a step lighter each, and still a step apart from each other. Explicit shades rather than the basic codes, because the basic ones offer no choice: faint is whatever the terminal makes of the foreground, bright black is one fixed grey, and "white" is often *exactly* the default foreground, which would have left the text looking unstyled. Naming the values keeps the two shades distinct from each other and from ordinary text whatever the theme does. A terminal without 256-colour support ignores the parameter and prints plain text — no emphasis, never a mess — and redirected output still carries no escapes at all. --- peekmem/output.py | 31 ++++++++++++++++++------------- tests/test_commands.py | 8 ++++---- tests/test_output.py | 12 ++++++------ tests/test_shell.py | 2 +- 4 files changed, 29 insertions(+), 24 deletions(-) diff --git a/peekmem/output.py b/peekmem/output.py index 98b42d3..67fcaf2 100644 --- a/peekmem/output.py +++ b/peekmem/output.py @@ -25,17 +25,22 @@ LEFT = "left" _RED = "\033[31m" -#: Faint, rather than any actual colour. It is derived from whatever the -#: terminal's foreground already is, so it reads as "quieter" on a light theme -#: and a dark one alike, and a terminal that does not implement it simply shows -#: ordinary text — the worst case is no emphasis, never an unreadable one. -_DIM = "\033[2m" - -#: Grey — bright black, the conventional "secondary text" colour. A shade -#: lighter than faint on a dark terminal, which is what an example block wants: -#: set apart from the prose around it, but a whole transcript at a time still -#: has to be readable, where a one-line prompt only has to be noticed. -_GREY = "\033[90m" +# Two greys, named for what they mark rather than for their number, and picked +# from the 256-colour cube rather than the 16-colour palette. The basic codes +# do not offer a choice of shade — bright black is one fixed grey, and "white" +# is often exactly the terminal's default foreground, which would leave the +# text looking unstyled. These are explicit values, so the two shades stay +# distinct from each other and from ordinary text whatever the theme does. +# +# A terminal without 256-colour support ignores the parameter and prints plain +# text: no emphasis, never a mess. + +#: The quieter of the two — the prompt's target, which only has to be noticed. +_DIM = "\033[38;5;247m" + +#: The lighter one — example blocks, which have to stay readable line after +#: line, so they sit closer to ordinary text than the prompt does. +_GREY = "\033[38;5;252m" _RESET = "\033[0m" @@ -284,7 +289,7 @@ def _style(self, text: str, escape: str, *, in_prompt: bool = False) -> str: return f"{escape}{text}{_RESET}" def dim(self, text: str, *, in_prompt: bool = False) -> str: - """Return ``text`` faintly styled — for something to notice, not read. + """Return ``text`` in the quieter grey — for something to notice, not read. :param in_prompt: bracket the escapes for readline. Pass it only when readline is actually handling the line — the markers are invisible @@ -293,7 +298,7 @@ def dim(self, text: str, *, in_prompt: bool = False) -> str: return self._style(text, _DIM, in_prompt=in_prompt) def grey(self, text: str) -> str: - """Return ``text`` in grey — for a block that is set apart but read. + """Return ``text`` in the lighter grey — for a block set apart but read. A shade lighter than :meth:`dim`, because a transcript has to stay legible line after line where a prompt only has to catch the eye. diff --git a/tests/test_commands.py b/tests/test_commands.py index 16d6beb..27aa99e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -654,7 +654,7 @@ def test_example_blocks_are_greyed(capture, line): transcript = [row for row in body if "peekmem>" in row] assert transcript, f"{line!r} printed no example" for row in transcript: - assert "\033[90m" in row and "\033[0m" in row + assert "\033[38;5;252m" in row and "\033[0m" in row def test_the_example_label_is_not_styled(capture): @@ -678,7 +678,7 @@ def test_a_commands_own_examples_are_greyed_too(capture): ] assert listed for row in listed: - assert "\033[90m" in row + assert "\033[38;5;252m" in row @pytest.mark.parametrize("line", ["help", "scan:help", "help memory:read"]) @@ -692,5 +692,5 @@ def test_examples_and_the_prompt_use_different_shades(capture): """A transcript is read; the prompt's target is only noticed.""" capture.printer.color = True assert capture.printer.grey("x") != capture.printer.dim("x") - assert "\033[90m" in capture.printer.grey("x") - assert "\033[2m" in capture.printer.dim("x") + assert "\033[38;5;252m" in capture.printer.grey("x") + assert "\033[38;5;247m" in capture.printer.dim("x") diff --git a/tests/test_output.py b/tests/test_output.py index 7ca471a..bff3d6e 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -96,10 +96,10 @@ def test_dim_is_a_no_op_when_colour_is_off(capture): assert capture.printer.dim("[game.exe:42]") == "[game.exe:42]" -def test_dim_uses_the_faint_attribute_not_a_colour(capture): - """Faint follows the terminal's own foreground, so it reads on any theme.""" +def test_dim_uses_the_quieter_grey(capture): + """An explicit shade, so it never lands on the terminal's default foreground.""" capture.printer.color = True - assert capture.printer.dim("x") == "\033[2mx\033[0m" + assert capture.printer.dim("x") == "\033[38;5;247mx\033[0m" def test_dim_brackets_its_escapes_for_readline(capture): @@ -107,7 +107,7 @@ def test_dim_brackets_its_escapes_for_readline(capture): cursor as soon as the line wraps.""" capture.printer.color = True styled = capture.printer.dim("x", in_prompt=True) - assert styled == "\001\033[2m\002x\001\033[0m\002" + assert styled == "\001\033[38;5;247m\002x\001\033[0m\002" # Every escape sits inside a pair of markers. assert styled.count("\001") == styled.count("\002") == 2 @@ -121,6 +121,6 @@ def test_grey_is_a_no_op_when_colour_is_off(capture): assert capture.printer.grey("peekmem> help") == "peekmem> help" -def test_grey_is_bright_black(capture): +def test_grey_is_the_lighter_shade(capture): capture.printer.color = True - assert capture.printer.grey("x") == "\033[90mx\033[0m" + assert capture.printer.grey("x") == "\033[38;5;252mx\033[0m" diff --git a/tests/test_shell.py b/tests/test_shell.py index 0dd6d41..07b1edb 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -87,7 +87,7 @@ def test_the_target_is_dimmed_when_colour_is_on(shell, capture): """A reminder that writes are going somewhere, not a thing to look at.""" capture.printer.color = True _attach(shell) - assert shell.prompt() == "peekmem \033[2m[game.exe:4242]\033[0m> " + assert shell.prompt() == "peekmem \033[38;5;247m[game.exe:4242]\033[0m> " def test_an_empty_prompt_is_never_styled(shell, capture): From e84b7624de123bde787f8a38913c77560ab1677a Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:44:29 -0300 Subject: [PATCH 27/82] refactor(output): one shade for everything set apart from the output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Example blocks take the prompt target's colour. They mean the same thing — context around the output rather than output itself — and two shades implied a difference that was not there. So the two styling methods collapse into one. Keeping 'grey' and 'dim' as separate names for the same escape would have left the distinction in the code after it stopped existing anywhere else. --- peekmem/commands/session_commands.py | 12 ++++----- peekmem/output.py | 37 ++++++++++------------------ tests/test_commands.py | 20 ++++++++------- tests/test_output.py | 13 ++-------- 4 files changed, 31 insertions(+), 51 deletions(-) diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index e8eb5f3..ea5d694 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -163,13 +163,11 @@ def _command_rows(commands) -> List[Tuple[str, str]]: def _print_example(session: Session, example: str, *, indent: int = 0) -> None: - """Print an indented ``Example:`` block, verbatim but greyed. + """Print an indented ``Example:`` block, verbatim but dimmed. The label stays at full strength so the block is findable when skimming; - its contents are greyed, because a transcript sitting inside a help page is - there to be recognised as a transcript at a glance — set apart from the - prose, but still readable line after line, which is why it is grey rather - than the fainter shade the prompt uses. + its contents take the same shade as the prompt's target, because they mean + the same thing — context around the output rather than output itself. ``indent`` nests the whole block, which is how the top-level help tucks its example inside the command listing rather than floating it above. @@ -183,7 +181,7 @@ def _print_example(session: Session, example: str, *, indent: int = 0) -> None: for line in example.splitlines(): # Styled a line at a time: one escape spanning a whole block survives # neither a pager nor a terminal that reflows it. - printer.write(f"{pad} {printer.grey(line)}" if line else "") + printer.write(f"{pad} {printer.dim(line)}" if line else "") printer.write() @@ -323,7 +321,7 @@ def _print_command_help(session: Session, name: str) -> None: if entry.examples: printer.write("Examples:") for example in entry.examples: - printer.write(f" {printer.grey(example)}") + printer.write(f" {printer.dim(example)}") printer.write() diff --git a/peekmem/output.py b/peekmem/output.py index 67fcaf2..55c5a27 100644 --- a/peekmem/output.py +++ b/peekmem/output.py @@ -25,23 +25,20 @@ LEFT = "left" _RED = "\033[31m" -# Two greys, named for what they mark rather than for their number, and picked -# from the 256-colour cube rather than the 16-colour palette. The basic codes -# do not offer a choice of shade — bright black is one fixed grey, and "white" -# is often exactly the terminal's default foreground, which would leave the -# text looking unstyled. These are explicit values, so the two shades stay -# distinct from each other and from ordinary text whatever the theme does. -# -# A terminal without 256-colour support ignores the parameter and prints plain -# text: no emphasis, never a mess. - -#: The quieter of the two — the prompt's target, which only has to be noticed. +#: The one shade Peekmem uses for text that is set apart from the rest: the +#: target in the prompt, and the contents of an example block. One shade +#: because they mean the same thing — this is context, not output — and using +#: two would have implied a difference that is not there. +#: +#: Picked from the 256-colour cube rather than the 16-colour palette, which +#: offers no choice of shade: faint is whatever the terminal makes of the +#: foreground, bright black is one fixed grey, and "white" is often exactly the +#: default foreground, which would leave the text looking unstyled. An explicit +#: value stays distinct from ordinary text whatever the theme does, and a +#: terminal without 256-colour support ignores the parameter and prints plain +#: text — no emphasis, never a mess. _DIM = "\033[38;5;247m" -#: The lighter one — example blocks, which have to stay readable line after -#: line, so they sit closer to ordinary text than the prompt does. -_GREY = "\033[38;5;252m" - _RESET = "\033[0m" #: readline measures a prompt to know where the cursor is. Escapes bracketed @@ -289,7 +286,7 @@ def _style(self, text: str, escape: str, *, in_prompt: bool = False) -> str: return f"{escape}{text}{_RESET}" def dim(self, text: str, *, in_prompt: bool = False) -> str: - """Return ``text`` in the quieter grey — for something to notice, not read. + """Return ``text`` set apart from ordinary output, or plain when colour is off. :param in_prompt: bracket the escapes for readline. Pass it only when readline is actually handling the line — the markers are invisible @@ -297,14 +294,6 @@ def dim(self, text: str, *, in_prompt: bool = False) -> str: """ return self._style(text, _DIM, in_prompt=in_prompt) - def grey(self, text: str) -> str: - """Return ``text`` in the lighter grey — for a block set apart but read. - - A shade lighter than :meth:`dim`, because a transcript has to stay - legible line after line where a prompt only has to catch the eye. - """ - return self._style(text, _GREY) - def note(self, message: str) -> None: """Print an aside — a warning that did not stop the command.""" self.clear_progress() diff --git a/tests/test_commands.py b/tests/test_commands.py index 27aa99e..dd69531 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -641,7 +641,7 @@ def _coloured_shell(capture): @pytest.mark.parametrize("line", ["help", "config:help", "scan:help", "ps:help"]) -def test_example_blocks_are_greyed(capture, line): +def test_example_blocks_are_dimmed(capture, line): """A transcript inside a help page should read as a transcript at a glance.""" shell = _coloured_shell(capture) shell.run_line(line) @@ -654,7 +654,7 @@ def test_example_blocks_are_greyed(capture, line): transcript = [row for row in body if "peekmem>" in row] assert transcript, f"{line!r} printed no example" for row in transcript: - assert "\033[38;5;252m" in row and "\033[0m" in row + assert "\033[38;5;247m" in row and "\033[0m" in row def test_the_example_label_is_not_styled(capture): @@ -667,7 +667,7 @@ def test_the_example_label_is_not_styled(capture): assert "\033" not in label -def test_a_commands_own_examples_are_greyed_too(capture): +def test_a_commands_own_examples_are_dimmed_too(capture): shell = _coloured_shell(capture) shell.run_line("help memory:read") # The escape comes before the text, so match on the content, not the start. @@ -678,7 +678,7 @@ def test_a_commands_own_examples_are_greyed_too(capture): ] assert listed for row in listed: - assert "\033[38;5;252m" in row + assert "\033[38;5;247m" in row @pytest.mark.parametrize("line", ["help", "scan:help", "help memory:read"]) @@ -688,9 +688,11 @@ def test_examples_stay_plain_when_colour_is_off(shell, capture, line): assert "\033" not in capture.out -def test_examples_and_the_prompt_use_different_shades(capture): - """A transcript is read; the prompt's target is only noticed.""" +def test_examples_and_the_prompt_share_one_shade(capture, shell): + """Both mean the same thing — context around the output, not output.""" capture.printer.color = True - assert capture.printer.grey("x") != capture.printer.dim("x") - assert "\033[38;5;252m" in capture.printer.grey("x") - assert "\033[38;5;247m" in capture.printer.dim("x") + shell.run_line("scan:help") + transcript = next( + row for row in capture.out.splitlines() if "peekmem>" in row + ) + assert capture.printer.dim("peekmem>").split("peekmem>")[0] in transcript diff --git a/tests/test_output.py b/tests/test_output.py index bff3d6e..bb4f6e0 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -96,8 +96,8 @@ def test_dim_is_a_no_op_when_colour_is_off(capture): assert capture.printer.dim("[game.exe:42]") == "[game.exe:42]" -def test_dim_uses_the_quieter_grey(capture): - """An explicit shade, so it never lands on the terminal's default foreground.""" +def test_dim_uses_an_explicit_shade(capture): + """Explicit, so it never lands on the terminal's own default foreground.""" capture.printer.color = True assert capture.printer.dim("x") == "\033[38;5;247mx\033[0m" @@ -115,12 +115,3 @@ def test_dim_brackets_its_escapes_for_readline(capture): def test_dim_leaves_empty_text_alone(capture): capture.printer.color = True assert capture.printer.dim("") == "" - - -def test_grey_is_a_no_op_when_colour_is_off(capture): - assert capture.printer.grey("peekmem> help") == "peekmem> help" - - -def test_grey_is_the_lighter_shade(capture): - capture.printer.color = True - assert capture.printer.grey("x") == "\033[38;5;252mx\033[0m" From a38004d2df0bdedaac6808259df085b00bbd4024 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 21:49:30 -0300 Subject: [PATCH 28/82] style(help): widen the gap between a command and its description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two spaces read as one column; a listing of commands is scanned down its left edge before any of it is read, and the gap is what stops the two halves running together into a sentence. Four now, in the top-level listing, in every subcommand listing, and in 'peekmem --help'. render_definitions takes the gap as a parameter rather than hardcoding it, which also moves the continuation indent — a wider gap with the old indent would leave wrapped descriptions no longer lining up under the first line. Argument and option lists keep two: there the two columns are read together, and they never share a page with a command listing. --- README.md | 16 +++++++------- peekmem/cli.py | 3 ++- peekmem/commands/session_commands.py | 7 +++++++ peekmem/output.py | 9 ++++++-- tests/test_commands.py | 31 ++++++++++++++++++++++++++++ tests/test_output.py | 20 ++++++++++++++++++ 6 files changed, 75 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3166eeb..61fb760 100644 --- a/README.md +++ b/README.md @@ -154,14 +154,14 @@ Example: scan subcommands: (get help with "help scan:SUBCOMMAND") - scan:aob [--max N] Scan for a byte pattern with wildcards (AOB). - scan:drop [row ...] Remove the named result rows. - scan:keep [row ...] Keep only the named result rows. - scan:next [op] [value ...] Narrow the results with another comparison. - scan:regex [--length N] [--max N] Scan for text matching a regular expression. - scan:reset Discard the current scan results. - scan:results [--limit N] [--offset N]... Show the current result set, re-read. - scan:value [value] [--op OP]... Search the whole address space for a value. + scan:aob [--max N] Scan for a byte pattern with wildcards (AOB). + scan:drop [row ...] Remove the named result rows. + scan:keep [row ...] Keep only the named result rows. + scan:next [op] [value ...] Narrow the results with another comparison. + scan:regex [--length N] [--max N] Scan for text matching a regular expression. + scan:reset Discard the current scan results. + scan:results [--limit N] [--page N] [--all] Show the current result set, re-read. + scan:value [value] [--op OP]... Search the whole address space for a value. ``` Every command answers `:help`, at any depth — `scan:help`, `scan:aob:help`, diff --git a/peekmem/cli.py b/peekmem/cli.py index 09713b8..9d77df1 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -40,7 +40,8 @@ def _format_commands() -> str: width = max(len(signature) for signature, _ in rows) lines: List[str] = ["peekmem commands:", ""] - lines += [f" {signature.ljust(width)} {summary}" for signature, summary in rows] + # Four spaces between the columns, as the shell's own listings use. + lines += [f" {signature.ljust(width)} {summary}" for signature, summary in rows] lines += [ "", "Run 'peekmem help ' for what a command takes, or", diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index ea5d694..f1034da 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -134,6 +134,11 @@ def _print_types(session: Session) -> None: #: because a signature plus a summary genuinely needs the room. _LISTING_WIDTH = 106 +#: Spaces between a command and its description. Wider than the two an +#: argument list uses: a listing of commands is scanned down its left edge +#: first, and the gap is what stops the two columns reading as one sentence. +_LISTING_GAP = 4 + def _signature(entry: Command, limit: int = _SIGNATURE_WIDTH) -> str: """The command's usage line, cut at a token boundary when it runs long.""" @@ -208,6 +213,7 @@ def _print_overview(session: Session) -> None: indent=4, label_width=12, total_width=_LISTING_WIDTH, + gap=_LISTING_GAP, ) ) printer.write() @@ -256,6 +262,7 @@ def print_namespace(session: Session, prefix: str) -> bool: indent=4, label_width=_SIGNATURE_WIDTH, total_width=_LISTING_WIDTH, + gap=_LISTING_GAP, ) ) printer.write() diff --git a/peekmem/output.py b/peekmem/output.py index 55c5a27..c05246e 100644 --- a/peekmem/output.py +++ b/peekmem/output.py @@ -165,6 +165,7 @@ def render_definitions( indent: int = 2, label_width: int = 22, total_width: int = 78, + gap: int = 2, ) -> str: """Render label/description pairs as an aligned, wrapped block. @@ -172,13 +173,17 @@ def render_definitions( every command-line tool's ``--help`` has, so it needs no explaining. A label longer than ``label_width`` takes a line of its own rather than pushing every description out of alignment. + + :param gap: spaces between the two columns. A listing of command names is + read down the left edge first, so it is given more room than an + argument list, where the two columns are read together. """ if not items: return "" width = min(max(len(label) for label, _ in items), label_width) pad = " " * indent - continuation = pad + " " * (width + 2) + continuation = pad + " " * (width + gap) text_width = max(24, total_width - len(continuation)) lines: List[str] = [] @@ -188,7 +193,7 @@ def render_definitions( lines.append(pad + label) continue if len(label) <= width: - lines.append(f"{pad}{label.ljust(width)} {wrapped[0]}") + lines.append(f"{pad}{label.ljust(width)}{' ' * gap}{wrapped[0]}") else: lines.append(pad + label) lines.append(continuation + wrapped[0]) diff --git a/tests/test_commands.py b/tests/test_commands.py index dd69531..e217d38 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -696,3 +696,34 @@ def test_examples_and_the_prompt_share_one_shade(capture, shell): row for row in capture.out.splitlines() if "peekmem>" in row ) assert capture.printer.dim("peekmem>").split("peekmem>")[0] in transcript + + +@pytest.mark.parametrize("line", ["help", "scan:help", "memory:help"]) +def test_command_listings_breathe(shell, capture, line): + """Four spaces between the columns, not two. + + A listing of commands is scanned down its left edge before any of it is + read, and the gap is what stops the two columns running together into one + sentence. + """ + import re + + shell.run_line(line) + + # Listing rows only: the indented lines whose first word names a command. + # The example block sits at the same indent and must not be measured. + known = set(command_words()) | set(namespaces()) + rows = [ + row[4:] + for row in capture.out.splitlines() + if row.startswith(" ") + and not row.startswith(" ") + and row.split() + and row.split()[0] in known + ] + assert rows, f"{line!r} listed nothing" + + for row in rows: + gap = re.search(r"\S(\s{2,})\S", row) + assert gap, f"no column separator in {row!r}" + assert len(gap.group(1)) >= 4, f"only {len(gap.group(1))} spaces in {row!r}" diff --git a/tests/test_output.py b/tests/test_output.py index bb4f6e0..ecf1be9 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,6 +5,7 @@ from peekmem.output import ( LEFT, RIGHT, + render_definitions, format_address, format_size, render_hexdump, @@ -115,3 +116,22 @@ def test_dim_brackets_its_escapes_for_readline(capture): def test_dim_leaves_empty_text_alone(capture): capture.printer.color = True assert capture.printer.dim("") == "" + + +def test_definitions_default_to_a_two_space_gap(capture): + text = render_definitions([("name", "what it does")]) + assert text == " name what it does" + + +def test_the_gap_widens_both_the_first_line_and_the_wrapping(capture): + """A wider gap has to move the continuation indent too, or the wrapped + lines stop lining up under the first.""" + text = render_definitions( + [("name", "a description long enough that it has to wrap somewhere")], + gap=4, + total_width=40, + ) + first, second = text.splitlines() + assert first.startswith(" name a description") + # The continuation sits under the description, not under the label. + assert second.index(second.strip()[0]) == first.index("a description") From b79baab7d34ae87abe64b79db2f73c424b782427 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 22:04:12 -0300 Subject: [PATCH 29/82] feat(alias): add an alias command for naming commands yourself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'alias:add r memory:read' makes 'r' do the same thing, and an alias can carry arguments: with 'find-text' set to 'scan:value string', 'find-text Peekmem' runs 'scan:value string Peekmem'. 'alias:list' and 'alias:remove' round it out. A name already answered to by a command, by one of a command's own shortcuts, or by another alias is refused rather than shadowing it — nothing you could type before stops working because of something you added. The target is checked when the alias is created, not when it is used. That catches a typo while you still remember what you meant, and it makes chains impossible by construction: an alias can only point at something the registry knows, so no alias can point at another. Expansion is therefore a single pass that always lands on a real command, with no depth limit or cycle check to get wrong. Substitution happens before anything else reads the line, so '--help' on an alias describes what it stands for; 'help ' says what it stands for first and then prints that command's page. Aliases live for the session, like the settings — put the lines in a script and 'source' it to get the shell back. --- README.md | 5 + peekmem/commands/__init__.py | 16 ++- peekmem/commands/alias_commands.py | 183 +++++++++++++++++++++++++++ peekmem/commands/session_commands.py | 11 ++ peekmem/session.py | 20 +++ peekmem/shell.py | 14 +- tests/test_aliases.py | 153 ++++++++++++++++++++++ 7 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 peekmem/commands/alias_commands.py create mode 100644 tests/test_aliases.py diff --git a/README.md b/README.md index 61fb760..863bd30 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ and `help scan` all produce the same output. | **`scan:`** | `value` · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | | **`pointer:`** | `deref` · `read` · `scan` · `rescan` · `paths` · `save` · `load` · `diff` | | **`config:`** | `list` · `set` | +| **`alias:`** | `add` · `list` · `remove` | | Top level | `help` · `source` · `version` · `clear` · `exit` | `help ` — or ` --help` — documents each one in full: every @@ -223,6 +224,10 @@ Highlights: - **Progress you can trust.** Long scans report a percentage that advances whether or not anything is being found, and Ctrl+C stops a scan while keeping what it already found. +- **Names of your own.** `alias:add r memory:read` makes `r` do the same + thing; `alias:add find-text scan:value string` carries arguments along, so + `find-text Peekmem` runs `scan:value string Peekmem`. A name already taken + by a command is refused rather than shadowing it. - **Every listing pages the same way.** `--limit`, `--page` and `--all` on each of them, a footer that says where you are — `Showing 20 of 3184 rows — page 1 of 160` — and the command for the next page spelled out underneath, so it diff --git a/peekmem/commands/__init__.py b/peekmem/commands/__init__.py index 71bee29..6546e3a 100644 --- a/peekmem/commands/__init__.py +++ b/peekmem/commands/__init__.py @@ -85,6 +85,19 @@ class Namespace: "+-----+--------------------+-------+\n" "1 row in set (0.02 sec)", ), + Namespace( + "alias", + "Aliases", + "Give a command a shorter name of your own.", + "peekmem> alias:add r memory:read\n" + "r = memory:read\n" + "\n" + "peekmem> alias:add find-text scan:value string\n" + "find-text = scan:value string\n" + "\n" + "peekmem> find-text Peekmem\n" + "(runs 'scan:value string Peekmem')", + ), Namespace( "config", "Configuration", @@ -514,7 +527,8 @@ def paginate( return Page(window, total, page, pages, offset, next_page) -from . import memory_commands # noqa: E402,F401 (registration side effect) +from . import alias_commands # noqa: E402,F401 (registration side effect) +from . import memory_commands # noqa: E402,F401 from . import pointer_commands # noqa: E402,F401 from . import ps_commands # noqa: E402,F401 from . import scan_commands # noqa: E402,F401 diff --git a/peekmem/commands/alias_commands.py b/peekmem/commands/alias_commands.py new file mode 100644 index 0000000..344ebd6 --- /dev/null +++ b/peekmem/commands/alias_commands.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- + +""" +The ``alias:`` namespace — giving a command a shorter name of your own. + +An alias stands for the first word of a line and, optionally, some words after +it: ``r`` for ``memory:read``, or ``find-text`` for ``scan:value string``. When +one is used, its words replace the alias and whatever else was typed follows +them, so ``find-text Peekmem`` runs ``scan:value string Peekmem``. + +Aliases live for the session, like the settings — Peekmem writes no config +file. Put the ``alias:add`` lines in a script and run it with ``source`` to +get the same shell back. +""" + +from typing import List + +from ..errors import CommandError +from ..output import LEFT, render_vertical +from ..session import Session +from . import CommandParser, command, command_words, lookup, namespaces + +#: Characters that would make an alias unusable or ambiguous. A colon is the +#: separator the command hierarchy is built on, and a leading dash would read +#: as a flag at the point the line is split. +_FORBIDDEN = (":", " ", "\t") + + +def _validate_name(session: Session, name: str) -> str: + """Check that ``name`` is free to use, or explain why it is not.""" + if not name: + raise CommandError("An alias needs a name.") + if any(character in name for character in _FORBIDDEN): + raise CommandError( + f"{name!r} cannot be an alias: a name has no ':' or spaces in it." + ) + if name.startswith("-"): + raise CommandError(f"{name!r} cannot be an alias: it would read as a flag.") + + if name in command_words() or name in namespaces(): + raise CommandError( + f"{name!r} is already a command. Pick a name that is not taken — " + "'alias:list' shows the ones you have." + ) + if name in session.aliases: + stands_for = " ".join(session.aliases[name]) + raise CommandError( + f"{name!r} is already an alias for {stands_for!r}. " + f"Remove it first with 'alias:remove {name}'." + ) + return name + + +def _validate_target(words: List[str]) -> List[str]: + """Check that the alias points at something that exists. + + Checked here rather than when the alias is used, so a typo is caught while + you still remember what you meant — and so expansion can be a single pass + that always lands on a real command, with no chains to follow or cycles to + guard against. + """ + if not words: + raise CommandError("An alias needs a command to stand for.") + + head = words[0] + if head in namespaces(): + return words + try: + lookup(head) + except CommandError: + raise CommandError( + f"{head!r} is not a command, so nothing can be an alias for it. " + "Type 'help' for the command list." + ) + return words + + +def _alias_add_parser() -> CommandParser: + parser = CommandParser("alias:add") + parser.add_argument("name", help="the word you want to type") + parser.add_argument( + "words", + nargs="+", + metavar="command", + help="the command it stands for, and any arguments that always go " + "with it", + ) + return parser + + +@command( + "alias:add", + parser=_alias_add_parser, + summary="Give a command a shorter name.", + details=( + "The alias replaces the first word of a line, and anything else you " + "type follows what it stands for — so with 'find-text' set to " + "'scan:value string', typing 'find-text Peekmem' runs " + "'scan:value string Peekmem'.\n\n" + "A name already taken by a command or another alias is refused rather " + "than shadowing it, and the command an alias points at has to exist, " + "so a typo is caught here rather than the next time you use it.\n\n" + "Aliases last for the session. Put these lines in a script and run it " + "with 'source' to get the same shell back." + ), + examples=( + "alias:add r memory:read", + "alias:add find-text scan:value string", + "alias:add w memory:write", + ), +) +def cmd_alias_add(session: Session, args: List[str]) -> None: + options = _alias_add_parser().parse_args(args) + + name = _validate_name(session, options.name.strip().lower()) + words = _validate_target(list(options.words)) + + session.aliases[name] = words + session.printer.ok(f"{name} = {' '.join(words)}") + session.printer.write() + + +def _alias_list_parser() -> CommandParser: + return CommandParser("alias:list") + + +@command( + "alias:list", + parser=_alias_list_parser, + summary="Show the aliases defined in this session.", + details=( + "Takes no arguments.\n\n" + "Only the ones you have added. The shell's own shortcuts — 'quit', " + "'cls', '\\\\h', '\\\\.' — are part of the commands themselves and are " + "listed with them, in 'help '." + ), +) +def cmd_alias_list(session: Session, args: List[str]) -> None: + _alias_list_parser().parse_args(args) + + if not session.aliases: + session.printer.write( + "No aliases. Add one with 'alias:add '." + ) + session.printer.write() + return + + rows = [ + (name, " ".join(words)) for name, words in sorted(session.aliases.items()) + ] + session.printer.table(("ALIAS", "STANDS FOR"), rows, (LEFT, LEFT)) + + +def _alias_remove_parser() -> CommandParser: + parser = CommandParser("alias:remove") + parser.add_argument("name", help="the alias to forget") + return parser + + +@command( + "alias:remove", + parser=_alias_remove_parser, + summary="Forget an alias.", + details=( + "Only aliases you added can be removed; the shell's own shortcuts are " + "part of their commands." + ), + examples=("alias:remove r",), +) +def cmd_alias_remove(session: Session, args: List[str]) -> None: + options = _alias_remove_parser().parse_args(args) + + name = options.name.strip().lower() + if name not in session.aliases: + known = ", ".join(sorted(session.aliases)) or "none" + raise CommandError(f"No alias called {name!r}. Defined: {known}.") + + words = session.aliases.pop(name) + session.printer.write(render_vertical([("removed", f"{name} = {' '.join(words)}")])) + session.printer.write() + + +__all__ = () diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index f1034da..7c22d48 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -390,6 +390,17 @@ def cmd_help(session: Session, args: List[str]) -> None: print_namespace(session, topic) return + # 'help r' where r is an alias: describe what it stands for. Anyone who + # named a command is entitled to ask about it by the name they gave it. + if topic in session.aliases: + stands_for = session.aliases[topic] + session.printer.write( + f"{topic} is an alias for '{' '.join(stands_for)}'." + ) + session.printer.write() + _print_command_help(session, stands_for[0]) + return + _print_command_help(session, topic) # 'scan' and 'pointer' are aliases *and* namespaces. The alias wins, so diff --git a/peekmem/session.py b/peekmem/session.py index d0e5faa..174ced4 100644 --- a/peekmem/session.py +++ b/peekmem/session.py @@ -93,6 +93,11 @@ def __init__(self, printer: Optional[Printer] = None): self.settings: Dict[str, Any] = { setting.name: setting.default for setting in SETTINGS } + #: User-defined aliases: the word typed, mapped to the words it stands + #: for. Kept here rather than in the command registry because they + #: belong to this session and die with it, exactly like the settings — + #: Peekmem writes no config file. + self.aliases: Dict[str, List[str]] = {} self._regions: Optional[List[MemoryRegion]] = None self._modules: Optional[Dict[str, int]] = None # Set by the shell that owns this session, so 'source' can feed a @@ -278,6 +283,21 @@ def modules(self, *, refresh: bool = False) -> Dict[str, int]: self._modules = table return self._modules + # -- aliases ----------------------------------------------------------- + + def expand_alias(self, word: str, args: Sequence[str]) -> Tuple[str, List[str]]: + """Substitute an alias, returning the command word and its arguments. + + Expanded once, never repeatedly: an alias is checked when it is + created, so its target is always a real command and a chain cannot + form. One pass therefore always lands somewhere real, and no depth + limit or cycle check is needed to promise it. + """ + tokens = self.aliases.get(word.strip().lower()) + if not tokens: + return word, list(args) + return tokens[0], list(tokens[1:]) + list(args) + # -- hooks used by the address expression parser ---------------------- def module_base(self, name: str) -> int: diff --git a/peekmem/shell.py b/peekmem/shell.py index b0ddb5b..90f470f 100644 --- a/peekmem/shell.py +++ b/peekmem/shell.py @@ -112,6 +112,12 @@ def run_line(self, line: str, *, raise_errors: bool = False) -> bool: if parsed is None: return True word, args = parsed + + # An alias stands for the first word, so it is substituted before + # anything else looks at the line: what follows sees only real + # commands, and '--help' on an alias describes what it stands for. + word, args = self.session.expand_alias(word, args) + entry = self._resolve(word, args) if any(argument in _HELP_FLAGS for argument in args): @@ -346,9 +352,11 @@ def _complete(self, text: str, state: int) -> Optional[str]: if first_word: # Namespaces complete too, so tabbing from nothing shows the five # groups before it shows forty commands. - candidates: Sequence[str] = command_words() + [ - name + ":" for name in namespaces() - ] + candidates: Sequence[str] = ( + command_words() + + [name + ":" for name in namespaces()] + + list(self.session.aliases) + ) else: head = buffer.strip().split()[0].lower() if head in ("config:set", "config:list"): diff --git a/tests/test_aliases.py b/tests/test_aliases.py new file mode 100644 index 0000000..9335123 --- /dev/null +++ b/tests/test_aliases.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- + +"""User-defined aliases: creating them, using them, and refusing bad ones.""" + +import pytest + +from peekmem.errors import CommandError +from peekmem.session import Session + + +def test_an_alias_stands_for_a_command(shell, capture): + shell.run_line("alias:add r memory:read") + assert shell.session.aliases == {"r": ["memory:read"]} + assert "r = memory:read" in capture.out + + +def test_an_alias_can_carry_arguments_of_its_own(shell): + """'find-text Peekmem' has to run 'scan:value string Peekmem'.""" + shell.run_line("alias:add find-text scan:value string") + word, args = shell.session.expand_alias("find-text", ["Peekmem"]) + assert (word, args) == ("scan:value", ["string", "Peekmem"]) + + +def test_an_unknown_word_expands_to_itself(shell): + assert shell.session.expand_alias("memory:read", ["0x10"]) == ( + "memory:read", + ["0x10"], + ) + + +def test_using_an_alias_runs_the_command(shell, capture): + """The whole point: it has to reach the real command, arguments and all.""" + shell.run_line("alias:add r memory:read") + capture.reset() + assert shell.run_line("r 0x10") is False # no target attached + assert "No process attached" in capture.err + assert "memory:read" in capture.err, "the error names the real command" + + +def test_listing_is_empty_until_something_is_added(shell, capture): + shell.run_line("alias:list") + assert "No aliases" in capture.out + + +def test_listing_shows_what_each_stands_for(shell, capture): + shell.run_line("alias:add r memory:read") + shell.run_line("alias:add find-text scan:value string") + capture.reset() + shell.run_line("alias:list") + assert "memory:read" in capture.out + assert "scan:value string" in capture.out + + +def test_removing_forgets_it(shell, capture): + shell.run_line("alias:add r memory:read") + shell.run_line("alias:remove r") + assert shell.session.aliases == {} + assert "removed" in capture.out + + +def test_removing_something_that_is_not_an_alias_lists_the_real_ones(shell, capture): + shell.run_line("alias:add r memory:read") + assert shell.run_line("alias:remove nope") is False + assert "No alias called 'nope'" in capture.err + assert "r" in capture.err + + +@pytest.mark.parametrize( + "name", + [ + "memory", # a command that takes subcommands + "quit", # a command's own shortcut + "help", # a top-level command + "cls", # another shortcut + ], +) +def test_a_taken_name_is_refused(shell, name): + """An alias must never shadow something that already answers to that word.""" + with pytest.raises(CommandError, match="already a command"): + shell.run_line(f"alias:add {name} memory:read", raise_errors=True) + + +def test_an_existing_alias_is_not_silently_replaced(shell): + shell.run_line("alias:add r memory:read") + with pytest.raises(CommandError, match="already an alias"): + shell.run_line("alias:add r memory:write", raise_errors=True) + assert shell.session.aliases["r"] == ["memory:read"] + + +@pytest.mark.parametrize("name", ["mem:r", "a:b:c", "memory:read"]) +def test_a_name_with_a_colon_is_refused(shell, name): + """The colon is what the command hierarchy is built on. + + That covers 'memory:read' too: it is both taken and unspellable, and the + shape of the name is the more useful thing to say about it. + """ + with pytest.raises(CommandError, match="no ':' or spaces"): + shell.run_line(f"alias:add {name} memory:read", raise_errors=True) + + +def test_a_name_that_looks_like_a_flag_is_refused(shell): + with pytest.raises(CommandError, match="read as a flag"): + shell.run_line("alias:add -- -x memory:read", raise_errors=True) + + +def test_the_target_has_to_exist(shell): + """Caught now, while you still remember what you meant.""" + with pytest.raises(CommandError, match="not a command"): + shell.run_line("alias:add r nosuch:command", raise_errors=True) + + +def test_an_alias_may_point_at_a_command_that_takes_subcommands(shell, capture): + shell.run_line("alias:add m memory") + capture.reset() + shell.run_line("m") + assert "memory subcommands:" in capture.out + + +def test_aliases_cannot_chain(shell): + """One expansion always lands on a real command, by construction. + + An alias may only point at something the registry knows, so a second alias + can never point at the first — which is what makes a single pass safe, with + no cycle to detect. + """ + shell.run_line("alias:add r memory:read") + with pytest.raises(CommandError, match="not a command"): + shell.run_line("alias:add rr r", raise_errors=True) + + +def test_help_answers_for_an_alias(shell, capture): + shell.run_line("alias:add r memory:read") + capture.reset() + shell.run_line("help r") + assert "r is an alias for 'memory:read'." in capture.out + assert "Read a typed value from an address" in capture.out + + +def test_the_help_flag_works_through_an_alias(shell, capture): + shell.run_line("alias:add r memory:read") + capture.reset() + shell.run_line("r --help") + assert "memory:read — Read a typed value" in capture.out + + +def test_aliases_belong_to_the_session(capture): + """They die with the shell, like the settings — there is no config file.""" + assert Session(capture.printer).aliases == {} + + +def test_aliases_complete(shell): + shell.run_line("alias:add find-text scan:value string") + assert "find-text" in shell.session.aliases From fd1baeb736a06531615587565ec96df74b8a8b41 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 22:11:49 -0300 Subject: [PATCH 30/82] feat(alias): remember aliases between runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An alias you have to define again every session is not worth defining. They are now written to disk the moment they change and loaded at startup — the one thing Peekmem stores, in $XDG_CONFIG_HOME/peekmem/aliases.json (%APPDATA% on Windows), with PEEKMEM_CONFIG_DIR to move it and 'alias:list' printing the path. Settings deliberately still do not persist. They tune one session's output and a stale one would be a surprise on the next run; a name you chose is the opposite. The help now says which is which rather than claiming, as it did, that Peekmem writes no config file at all. Loading happens in the CLI, not in Session, so a Session built in a test or a script touches no files unless it asks to — and the suite pins that down with an autouse fixture that points every test at a throwaway directory, because remembering to opt in per test is the kind of thing that gets forgotten once and reads someone's real config. The file is replaced atomically, so an interrupted write cannot leave a half-file that the next run would find malformed and drop wholesale. A malformed or unreadable one loses the aliases and nothing else: refusing to start over a stray character would be the worse bug. An alias whose command no longer exists — renamed between releases — is dropped with a line saying so rather than left to fail later, and a home directory that cannot be written to is reported without refusing the alias, which still works for that session. --- .gitignore | 3 + README.md | 21 ++++- peekmem/aliases.py | 117 +++++++++++++++++++++++++++ peekmem/cli.py | 15 ++++ peekmem/commands/alias_commands.py | 57 +++++++++++-- peekmem/commands/session_commands.py | 9 ++- peekmem/session.py | 7 +- tests/conftest.py | 12 +++ tests/test_aliases.py | 102 ++++++++++++++++++++++- 9 files changed, 324 insertions(+), 19 deletions(-) create mode 100644 peekmem/aliases.py diff --git a/.gitignore b/.gitignore index ea2a776..c5cdb69 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,6 @@ docs/_build/ # Project-local .claude/ + +# A throwaway alias store, if one is pointed here +aliases.json diff --git a/README.md b/README.md index 863bd30..873953a 100644 --- a/README.md +++ b/README.md @@ -224,10 +224,11 @@ Highlights: - **Progress you can trust.** Long scans report a percentage that advances whether or not anything is being found, and Ctrl+C stops a scan while keeping what it already found. -- **Names of your own.** `alias:add r memory:read` makes `r` do the same - thing; `alias:add find-text scan:value string` carries arguments along, so - `find-text Peekmem` runs `scan:value string Peekmem`. A name already taken - by a command is refused rather than shadowing it. +- **Names of your own, remembered.** `alias:add r memory:read` makes `r` do + the same thing; `alias:add find-text scan:value string` carries arguments + along, so `find-text Peekmem` runs `scan:value string Peekmem`. A name + already taken by a command is refused rather than shadowing it, and the + aliases are still there next time you open a terminal. - **Every listing pages the same way.** `--limit`, `--page` and `--all` on each of them, a footer that says where you are — `Showing 20 of 3184 rows — page 1 of 160` — and the command for the next page spelled out underneath, so it @@ -252,6 +253,18 @@ game.exe+0x1234 a module base plus a static offset — survives ASLR So the whole chain fits on one line: `memory:read [[game.exe+0x1a2b3c]+0x10]+0x8 float`. +### Where things are kept + +Aliases are the one thing Peekmem stores between runs — a name you chose would +be pointless if you had to choose it again every session. They live in +`$XDG_CONFIG_HOME/peekmem/aliases.json` (`~/.config/peekmem/aliases.json` by +default, `%APPDATA%\peekmem` on Windows); `alias:list` prints the path, and +`PEEKMEM_CONFIG_DIR` moves it. + +Settings do not persist, on purpose: they tune one session's output, and a +stale one would be a surprise on the next run. Put `config:set` lines in a file +and `source` it to reuse a setup. + ## Permissions Reading another process's memory is a privileged operation everywhere: diff --git a/peekmem/aliases.py b/peekmem/aliases.py new file mode 100644 index 0000000..085bf30 --- /dev/null +++ b/peekmem/aliases.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- + +""" +Where the aliases are kept between sessions. + +This is the only file Peekmem writes. Settings deliberately do not persist — +they tune one session's output and a stale one would be a surprise on the next +run — but an alias is a name you chose, and having to choose it again every +time would make the feature pointless. + +The location follows the usual convention for the platform: +``$XDG_CONFIG_HOME/peekmem`` (or ``~/.config/peekmem``) on Linux and macOS, +``%APPDATA%\\peekmem`` on Windows. The readline history stays a dotfile in the +home directory, where readline's own convention puts it — that is an artefact +of the line editor rather than configuration. + +Nothing here validates what it reads: a name that no longer points at a real +command is the caller's problem to report, not this module's to silently fix. +""" + +import json +import os +import sys +import tempfile +from typing import Dict, List + +#: Overridable so a test — or a throwaway session — can use its own file. +ENV_DIR = "PEEKMEM_CONFIG_DIR" + +_FILENAME = "aliases.json" + +Aliases = Dict[str, List[str]] + + +def directory() -> str: + """The directory Peekmem keeps its configuration in.""" + override = os.environ.get(ENV_DIR) + if override: + return override + + if sys.platform == "win32": # pragma: no cover - Windows only + base = os.environ.get("APPDATA") or os.path.expanduser("~") + else: + base = os.environ.get("XDG_CONFIG_HOME") or os.path.join( + os.path.expanduser("~"), ".config" + ) + return os.path.join(base, "peekmem") + + +def path() -> str: + """The alias file itself.""" + return os.path.join(directory(), _FILENAME) + + +def load() -> Aliases: + """Read the stored aliases, or return none. + + A missing file is the ordinary case on a first run. An unreadable or + malformed one returns nothing as well: a shell that refuses to start + because of a stray character in a convenience file would be a worse bug + than the one it is reporting. + """ + try: + with open(path(), "r", encoding="utf-8") as handle: + stored = json.load(handle) + except (OSError, ValueError): + return {} + + if not isinstance(stored, dict): + return {} + + aliases: Aliases = {} + for name, words in stored.items(): + if not isinstance(name, str): + continue + if isinstance(words, str): # Tolerate a hand-edited single string. + words = words.split() + if isinstance(words, list) and words and all(isinstance(w, str) for w in words): + aliases[name] = list(words) + return aliases + + +def save(aliases: Aliases) -> None: + """Write the aliases, replacing whatever was there. + + Written to a temporary file in the same directory and moved into place, so + an interrupted write cannot leave a half-file behind — the next run would + read it, find it malformed, and quietly drop every alias at once. + + :raises OSError: when the file cannot be written. The caller decides + whether that is worth interrupting them over. + """ + target = path() + os.makedirs(os.path.dirname(target), exist_ok=True) + + handle = tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=os.path.dirname(target), + prefix=_FILENAME, + suffix=".tmp", + delete=False, + ) + try: + with handle: + json.dump(aliases, handle, indent=2, sort_keys=True) + handle.write("\n") + os.replace(handle.name, target) + except BaseException: + try: + os.unlink(handle.name) + except OSError: + pass + raise + + +__all__ = ("ENV_DIR", "Aliases", "directory", "load", "path", "save") diff --git a/peekmem/cli.py b/peekmem/cli.py index 9d77df1..01c93b2 100644 --- a/peekmem/cli.py +++ b/peekmem/cli.py @@ -24,6 +24,7 @@ from . import __version__, dependencies from .commands import top_level_listing +from .commands.alias_commands import restore as restore_aliases from .errors import CommandError, PeekmemError from .output import Printer from .session import Session @@ -185,9 +186,23 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if outdated is not None: printer.error(outdated) return 2 + session = Session(printer) shell = Shell(session, printer=printer) + # The aliases the user defined in an earlier run. Loaded here rather than + # in Session, so a Session built in a test or a script touches no files + # unless it asks to. + dropped = restore_aliases(session) + if dropped: + printer.note( + "Dropped %s, whose command no longer exists: %s." + % ( + "an alias" if len(dropped) == 1 else "some aliases", + ", ".join(sorted(dropped)), + ) + ) + if options.limit is not None: session.set_option("limit", str(options.limit)) diff --git a/peekmem/commands/alias_commands.py b/peekmem/commands/alias_commands.py index 344ebd6..27160bc 100644 --- a/peekmem/commands/alias_commands.py +++ b/peekmem/commands/alias_commands.py @@ -8,13 +8,14 @@ one is used, its words replace the alias and whatever else was typed follows them, so ``find-text Peekmem`` runs ``scan:value string Peekmem``. -Aliases live for the session, like the settings — Peekmem writes no config -file. Put the ``alias:add`` lines in a script and run it with ``source`` to -get the same shell back. +Aliases persist. They are the one thing Peekmem stores between runs — a name +you chose would be pointless if you had to choose it again every session — +and they are written to :mod:`peekmem.aliases`'s file the moment they change. """ from typing import List +from .. import aliases as storage from ..errors import CommandError from ..output import LEFT, render_vertical from ..session import Session @@ -75,6 +76,43 @@ def _validate_target(words: List[str]) -> List[str]: return words +def restore(session: Session) -> List[str]: + """Load the stored aliases into ``session``; return the ones dropped. + + An alias whose command no longer exists is left out rather than kept: the + shell promises that expanding an alias lands on a real command, and a name + that quietly stopped working is worth one line of explanation the next time + you start up. + """ + dropped = [] + for name, words in sorted(storage.load().items()): + if words[0] in namespaces(): + session.aliases[name] = words + continue + try: + lookup(words[0]) + except CommandError: + dropped.append(name) + else: + session.aliases[name] = words + return dropped + + +def _persist(session: Session) -> None: + """Write the aliases out, reporting a failure without raising. + + A home directory that cannot be written to is a reason to say so, not a + reason to refuse the alias: it still works for this session. + """ + try: + storage.save(session.aliases) + except OSError as error: + session.printer.note( + f"Could not save to {storage.path()}: {error}. " + "The alias works for this session only." + ) + + def _alias_add_parser() -> CommandParser: parser = CommandParser("alias:add") parser.add_argument("name", help="the word you want to type") @@ -100,8 +138,8 @@ def _alias_add_parser() -> CommandParser: "A name already taken by a command or another alias is refused rather " "than shadowing it, and the command an alias points at has to exist, " "so a typo is caught here rather than the next time you use it.\n\n" - "Aliases last for the session. Put these lines in a script and run it " - "with 'source' to get the same shell back." + "Aliases are remembered between runs — they are the one thing Peekmem " + "stores on disk. 'alias:list' says where." ), examples=( "alias:add r memory:read", @@ -116,6 +154,7 @@ def cmd_alias_add(session: Session, args: List[str]) -> None: words = _validate_target(list(options.words)) session.aliases[name] = words + _persist(session) session.printer.ok(f"{name} = {' '.join(words)}") session.printer.write() @@ -132,7 +171,10 @@ def _alias_list_parser() -> CommandParser: "Takes no arguments.\n\n" "Only the ones you have added. The shell's own shortcuts — 'quit', " "'cls', '\\\\h', '\\\\.' — are part of the commands themselves and are " - "listed with them, in 'help '." + "listed with them, in 'help '.\n\n" + "They are stored in a file, whose path is printed under the table. " + "Setting PEEKMEM_CONFIG_DIR moves it — useful for keeping a throwaway " + "set apart from the one you rely on." ), ) def cmd_alias_list(session: Session, args: List[str]) -> None: @@ -149,6 +191,8 @@ def cmd_alias_list(session: Session, args: List[str]) -> None: (name, " ".join(words)) for name, words in sorted(session.aliases.items()) ] session.printer.table(("ALIAS", "STANDS FOR"), rows, (LEFT, LEFT)) + session.printer.write(f"Stored in {storage.path()}") + session.printer.write() def _alias_remove_parser() -> CommandParser: @@ -176,6 +220,7 @@ def cmd_alias_remove(session: Session, args: List[str]) -> None: raise CommandError(f"No alias called {name!r}. Defined: {known}.") words = session.aliases.pop(name) + _persist(session) session.printer.write(render_vertical([("removed", f"{name} = {' '.join(words)}")])) session.printer.write() diff --git a/peekmem/commands/session_commands.py b/peekmem/commands/session_commands.py index 7c22d48..098a93a 100644 --- a/peekmem/commands/session_commands.py +++ b/peekmem/commands/session_commands.py @@ -437,10 +437,11 @@ def _config_list_parser() -> CommandParser: parser=_config_list_parser, summary="Show the session's settings and their current values.", details=( - "Settings live for the session only — Peekmem writes no config file, " - "so a fresh shell always starts from the documented defaults. Put the " - "'config:set' lines in a script and run it with 'source' to reuse a " - "setup." + "Settings live for the session only. They tune one session's output, " + "and a stale one would be a surprise on the next run, so a fresh shell " + "always starts from the documented defaults — unlike aliases, which " + "are remembered. Put the 'config:set' lines in a script and run it " + "with 'source' to reuse a setup." ), examples=("config:list", "config:list limit"), ) diff --git a/peekmem/session.py b/peekmem/session.py index 174ced4..03edfaa 100644 --- a/peekmem/session.py +++ b/peekmem/session.py @@ -94,9 +94,10 @@ def __init__(self, printer: Optional[Printer] = None): setting.name: setting.default for setting in SETTINGS } #: User-defined aliases: the word typed, mapped to the words it stands - #: for. Kept here rather than in the command registry because they - #: belong to this session and die with it, exactly like the settings — - #: Peekmem writes no config file. + #: for. Kept here rather than in the command registry because they are + #: the user's, not the program's. A shell loads them from disk at + #: startup (see peekmem.aliases); a Session on its own starts with + #: none, so nothing built in a test or a script touches a file. self.aliases: Dict[str, List[str]] = {} self._regions: Optional[List[MemoryRegion]] = None self._modules: Optional[Dict[str, int]] = None diff --git a/tests/conftest.py b/tests/conftest.py index d74f7c1..99daad3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,7 @@ import pytest +from peekmem import aliases from peekmem.output import Printer from peekmem.session import Session from peekmem.shell import Shell @@ -40,6 +41,17 @@ def reset(self) -> None: self.stderr.truncate() +@pytest.fixture(autouse=True) +def isolated_config(tmp_path, monkeypatch): + """Point the alias file at a throwaway directory, for every test. + + Autouse and unconditional: the suite must never read or write the config + of whoever is running it, and remembering to opt in per test is exactly + the kind of thing that gets forgotten once. + """ + monkeypatch.setenv(aliases.ENV_DIR, str(tmp_path / "config")) + + @pytest.fixture def capture() -> Capture: return Capture() diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 9335123..93b73d1 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -2,8 +2,13 @@ """User-defined aliases: creating them, using them, and refusing bad ones.""" +import pathlib +import sys + import pytest +from peekmem import aliases as storage +from peekmem.commands.alias_commands import restore from peekmem.errors import CommandError from peekmem.session import Session @@ -143,11 +148,104 @@ def test_the_help_flag_works_through_an_alias(shell, capture): assert "memory:read — Read a typed value" in capture.out -def test_aliases_belong_to_the_session(capture): - """They die with the shell, like the settings — there is no config file.""" +def test_a_bare_session_touches_no_file(capture): + """Loading is the shell's job, so a Session in a test or a script is inert.""" assert Session(capture.printer).aliases == {} def test_aliases_complete(shell): shell.run_line("alias:add find-text scan:value string") assert "find-text" in shell.session.aliases + + +# -- persistence --------------------------------------------------------- + + +def test_adding_writes_the_file(shell): + shell.run_line("alias:add r memory:read") + assert storage.load() == {"r": ["memory:read"]} + + +def test_removing_rewrites_the_file(shell): + shell.run_line("alias:add r memory:read") + shell.run_line("alias:add w memory:write") + shell.run_line("alias:remove r") + assert storage.load() == {"w": ["memory:write"]} + + +def test_a_new_session_gets_them_back(shell, capture): + """The whole point: close the terminal, open it again, the name is there.""" + shell.run_line("alias:add find-text scan:value string") + + fresh = Session(capture.printer) + assert restore(fresh) == [] + assert fresh.aliases == {"find-text": ["scan:value", "string"]} + + +def test_restoring_drops_an_alias_whose_command_is_gone(capture): + """A command can be renamed between releases; the name should not linger.""" + storage.save({"ok": ["memory:read"], "stale": ["memory:teleport"]}) + + session = Session(capture.printer) + assert restore(session) == ["stale"] + assert session.aliases == {"ok": ["memory:read"]} + + +def test_a_missing_file_is_the_ordinary_first_run(capture): + session = Session(capture.printer) + assert restore(session) == [] + assert session.aliases == {} + + +@pytest.mark.parametrize("content", ["not json at all", "[]", '{"r": 7}', '{"r": []}']) +def test_a_malformed_file_loses_the_aliases_but_not_the_shell(content, capture): + """Refusing to start over a stray character would be the worse bug.""" + path = pathlib.Path(storage.path()) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + session = Session(capture.printer) + assert restore(session) == [] + assert session.aliases == {} + + +def test_a_hand_written_string_is_tolerated(capture): + """Someone will edit this file by hand; accept the obvious spelling.""" + path = pathlib.Path(storage.path()) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('{"f": "scan:value string"}', encoding="utf-8") + + session = Session(capture.printer) + restore(session) + assert session.aliases == {"f": ["scan:value", "string"]} + + +def test_a_write_failure_is_reported_but_not_fatal(shell, capture, monkeypatch): + """A read-only home is a reason to say so, not to refuse the alias.""" + + def refuse(_aliases): + raise OSError("read-only file system") + + monkeypatch.setattr(storage, "save", refuse) + shell.run_line("alias:add r memory:read") + + assert shell.session.aliases == {"r": ["memory:read"]} + assert "Could not save" in capture.out + assert "this session only" in capture.out + + +def test_the_file_is_replaced_atomically(shell): + """An interrupted write must not leave a half-file for the next run.""" + shell.run_line("alias:add r memory:read") + directory = pathlib.Path(storage.directory()) + assert [item.name for item in directory.iterdir()] == ["aliases.json"] + + +def test_the_location_follows_the_environment(monkeypatch, tmp_path): + monkeypatch.setenv(storage.ENV_DIR, str(tmp_path / "explicit")) + assert storage.directory() == str(tmp_path / "explicit") + + monkeypatch.delenv(storage.ENV_DIR) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + if sys.platform != "win32": + assert storage.directory() == str(tmp_path / "xdg" / "peekmem") From 1a952af785ea7fc0a5f7f32ce2521cf6ebebc4be Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 22:14:16 -0300 Subject: [PATCH 31/82] chore: rename the project from Peekmem to Picklock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PyPI package becomes `picklock`, the console script and the prompt become `picklock`, the history file becomes `~/.picklock_history`, the alias store moves to `~/.config/picklock/aliases.json`, and the environment variable that relocates it becomes PICKLOCK_CONFIG_DIR. Nothing carries over from the old names: an existing `~/.peekmem_history` and any `~/.config/peekmem/aliases.json` are left where they are rather than moved, since they are the user's files to keep or delete. `picklock` was free on PyPI. The name is used by a couple of small unrelated projects on GitHub — a Nintendo RCM payload tool and a Java reflection helper — neither in this domain and neither large enough to compete with for the term. --- .github/ISSUE_TEMPLATE/bug_report.md | 10 +- .github/ISSUE_TEMPLATE/feature_request.md | 6 +- .github/ISSUE_TEMPLATE/questioning.md | 6 +- .github/labeler.yml | 12 +-- .github/workflows/python-package.yml | 16 +-- CONTRIBUTING.md | 26 ++--- Makefile | 12 +-- README.md | 100 +++++++++--------- SECURITY.md | 18 ++-- codecov.yml | 2 +- {peekmem => picklock}/__init__.py | 10 +- {peekmem => picklock}/__main__.py | 2 +- {peekmem => picklock}/addressing.py | 2 +- {peekmem => picklock}/aliases.py | 12 +-- {peekmem => picklock}/cli.py | 36 +++---- {peekmem => picklock}/commands/__init__.py | 26 ++--- .../commands/alias_commands.py | 14 +-- .../commands/memory_commands.py | 2 +- .../commands/pointer_commands.py | 2 +- {peekmem => picklock}/commands/ps_commands.py | 4 +- .../commands/scan_commands.py | 2 +- .../commands/session_commands.py | 14 +-- {peekmem => picklock}/dependencies.py | 6 +- {peekmem => picklock}/errors.py | 14 +-- {peekmem => picklock}/output.py | 8 +- {peekmem => picklock}/processes.py | 14 +-- {peekmem => picklock}/py.typed | 0 {peekmem => picklock}/session.py | 6 +- {peekmem => picklock}/shell.py | 10 +- {peekmem => picklock}/valuetypes.py | 0 pyproject.toml | 28 ++--- tests/conftest.py | 10 +- tests/test_addressing.py | 4 +- tests/test_aliases.py | 16 +-- tests/test_cli.py | 18 ++-- tests/test_commands.py | 34 +++--- tests/test_dependencies.py | 2 +- tests/test_output.py | 2 +- tests/test_scanning.py | 4 +- tests/test_session.py | 6 +- tests/test_shell.py | 20 ++-- tests/test_valuetypes.py | 4 +- 42 files changed, 270 insertions(+), 270 deletions(-) rename {peekmem => picklock}/__init__.py (69%) rename {peekmem => picklock}/__main__.py (70%) rename {peekmem => picklock}/addressing.py (98%) rename {peekmem => picklock}/aliases.py (89%) rename {peekmem => picklock}/cli.py (85%) rename {peekmem => picklock}/commands/__init__.py (96%) rename {peekmem => picklock}/commands/alias_commands.py (94%) rename {peekmem => picklock}/commands/memory_commands.py (99%) rename {peekmem => picklock}/commands/pointer_commands.py (99%) rename {peekmem => picklock}/commands/ps_commands.py (98%) rename {peekmem => picklock}/commands/scan_commands.py (99%) rename {peekmem => picklock}/commands/session_commands.py (98%) rename {peekmem => picklock}/dependencies.py (90%) rename {peekmem => picklock}/errors.py (79%) rename {peekmem => picklock}/output.py (98%) rename {peekmem => picklock}/processes.py (89%) rename {peekmem => picklock}/py.typed (100%) rename {peekmem => picklock}/session.py (98%) rename {peekmem => picklock}/shell.py (97%) rename {peekmem => picklock}/valuetypes.py (100%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b90f46b..a9ee868 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -14,20 +14,20 @@ A clear and concise description of what the bug is. The exact commands you ran, and what happened: ```console -$ peekmem -peekmem> ps:open 1234 -peekmem> ... +$ picklock +picklock> ps:open 1234 +picklock> ... ``` **Expected behavior** A clear and concise description of what you expected to happen instead. **Versions** -Paste the output of `peekmem -e "version"` — it covers Peekmem, PyMemoryEditor, +Paste the output of `picklock -e "version"` — it covers Picklock, PyMemoryEditor, Python and the platform: ``` - Peekmem: 0.1.0 + Picklock: 0.1.0 PyMemoryEditor: 2.2.0 Python: 3.12.0 Platform: Linux 6.8.0 (x86_64) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 00f0aba..050de46 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -21,11 +21,11 @@ project. If it is a new command or a new flag, sketch the session: ```console -peekmem> mycommand 0x1000 --flag +picklock> mycommand 0x1000 --flag ``` -**Is this Peekmem or PyMemoryEditor?** -Peekmem is a client — it parses commands and formats results, while +**Is this Picklock or PyMemoryEditor?** +Picklock is a client — it parses commands and formats results, while [PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor) performs every read, write and scan. If the feature needs a memory capability that does not exist yet, it may belong upstream. Say which you think it is; being wrong diff --git a/.github/ISSUE_TEMPLATE/questioning.md b/.github/ISSUE_TEMPLATE/questioning.md index f3c8318..cbeb10e 100644 --- a/.github/ISSUE_TEMPLATE/questioning.md +++ b/.github/ISSUE_TEMPLATE/questioning.md @@ -8,7 +8,7 @@ assignees: '' --- **Did you check the built-in help? If so, please describe** -Peekmem documents itself: `help` lists every command, `help ` explains +Picklock documents itself: `help` lists every command, `help ` explains one in full, and `help types`, `help address` and `help scanning` cover what several commands share. If one of those was confusing, say which and how — that is a documentation bug worth fixing. @@ -20,11 +20,11 @@ A clear and concise description of what you are trying to do. The commands you ran and the output you got: ```console -peekmem> ... +picklock> ... ``` **Versions** -Paste the output of `peekmem -e "version"`, if applicable. +Paste the output of `picklock -e "version"`, if applicable. **Environment** - Were you running elevated (`sudo` / Administrator)? [yes / no] diff --git a/.github/labeler.yml b/.github/labeler.yml index 0fc4c1e..4c7e193 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -5,27 +5,27 @@ commands: - changed-files: - any-glob-to-any-file: - - "peekmem/commands/**" + - "picklock/commands/**" # The shell itself: line parsing, dispatch, readline, the CLI front end. shell: - changed-files: - any-glob-to-any-file: - - "peekmem/shell.py" - - "peekmem/cli.py" + - "picklock/shell.py" + - "picklock/cli.py" # Everything that decides how a result looks on screen. output: - changed-files: - any-glob-to-any-file: - - "peekmem/output.py" - - "peekmem/valuetypes.py" + - "picklock/output.py" + - "picklock/valuetypes.py" # Any change inside the package. core: - changed-files: - any-glob-to-any-file: - - "peekmem/**" + - "picklock/**" tests: - changed-files: diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 96ff544..479afd2 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -1,4 +1,4 @@ -# Lint, type-check and test Peekmem on every supported platform and Python. +# Lint, type-check and test Picklock on every supported platform and Python. # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Python Package @@ -31,7 +31,7 @@ jobs: python -m pip install --upgrade pip pip install flake8 - name: Lint - run: flake8 peekmem tests + run: flake8 picklock tests type-check: needs: lint @@ -47,11 +47,11 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" - name: Run mypy - run: mypy peekmem + run: mypy picklock test: needs: lint - # Peekmem is a terminal program with three platform backends underneath it, + # Picklock is a terminal program with three platform backends underneath it, # so the matrix is the whole point: the shell has to start and dispatch # identically on Windows (no readline), Linux and macOS. runs-on: ${{ matrix.os }} @@ -77,7 +77,7 @@ jobs: # bodies that need a live target the suite deliberately never attaches # to. Ratchet it up as fake-target coverage grows. run: | - pytest -q --cov=peekmem --cov-report=term --cov-report=xml --cov-fail-under=55 + pytest -q --cov=picklock --cov-report=term --cov-report=xml --cov-fail-under=55 - name: Upload coverage to Codecov # Informational only — see codecov.yml — so a flaky upload never blocks # the merge; the hard gate is --cov-fail-under above. Runs even when the @@ -93,9 +93,9 @@ jobs: # The suite calls main() in-process; this proves the installed entry # point resolves and that a batch run exits cleanly. run: | - peekmem -e "version" - peekmem -e "help scan" - peekmem ps --limit 5 + picklock -e "version" + picklock -e "help scan" + picklock ps --limit 5 build: needs: [type-check, test] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0077cc9..4d250c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ -# Contributing to Peekmem +# Contributing to Picklock Thanks for your interest in contributing! -Peekmem is a terminal client for [PyMemoryEditor][pyme]. If your change is +Picklock is a terminal client for [PyMemoryEditor][pyme]. 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. @@ -26,7 +26,7 @@ make test # pytest tests -v ``` The suite never attaches to a process. It covers parsing, formatting, dispatch -and help — the parts that are Peekmem's own — and leaves reading another +and help — the parts that are Picklock's own — and leaves reading another process's memory to PyMemoryEditor's tests. That is deliberate: it means the suite runs identically on any machine, including CI runners where opening a second process is not permitted. @@ -38,8 +38,8 @@ transcript of the session is the ideal evidence. ## Linting and type checking ```bash -make lint # flake8 peekmem tests -make type-check # mypy peekmem +make lint # flake8 picklock tests +make type-check # mypy picklock ``` ## Before you push @@ -50,21 +50,21 @@ 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 `peekmem -e "version"` +identically where `readline` is missing (Windows), and `picklock -e "version"` is smoke-tested from the installed console script on every cell. ## Project layout ``` -peekmem/ +picklock/ ├── __init__.py # Version and the public re-exports -├── __main__.py # python -m peekmem +├── __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 Peekmem prints: tables, hexdump, footers +├── output.py # Every byte Picklock prints: tables, hexdump, footers ├── processes.py # Cross-platform process enumeration ├── errors.py # CommandError and friends └── commands/ # One module per group; each registers with @command @@ -76,12 +76,12 @@ Two rules keep the shape: is what makes output testable against a `StringIO`. - **Anything the user got wrong raises `CommandError`.** The shell catches that one class, prints one `ERROR:` line and returns to the prompt. An exception - that is *not* a `CommandError` is a bug in Peekmem and is allowed to escape + that is *not* a `CommandError` is a bug in Picklock and is allowed to escape with its traceback. ## Adding a command -1. Pick the module in `peekmem/commands/` that matches the namespace. +1. Pick the module in `picklock/commands/` that matches the namespace. 2. Register the handler. The name is a colon-separated path whose first segment is one of the groups in `NAMESPACES`; the heading in `help` follows from it, so there is nothing to keep in step. Do **not** give it a @@ -165,7 +165,7 @@ 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 `peekmem --help` are likewise generated from the registry, so a +`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 @@ -186,7 +186,7 @@ the moment it is registered. Please include: -- The output of `peekmem -e "version"` — it names Peekmem, PyMemoryEditor, +- The output of `picklock -e "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). diff --git a/Makefile b/Makefile index 49833dc..a99b2cf 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -# Makefile for the Peekmem Python package +# Makefile for the Picklock Python package # Variables -PACKAGE_NAME = peekmem +PACKAGE_NAME = picklock PYTHON = python3 PIP = pip3 BUILD_DIR = build @@ -20,14 +20,14 @@ NC = \033[0m # No Color # Default target .PHONY: help help: - @echo "$(GREEN)Peekmem Python package Makefile$(NC)" + @echo "$(GREEN)Picklock Python package Makefile$(NC)" @echo "" @echo "Available targets:" @echo " $(YELLOW)install$(NC) - Install package in development mode" @echo " $(YELLOW)install-deps$(NC) - Install runtime dependencies" @echo " $(YELLOW)install-dev$(NC) - Install development dependencies" @echo " $(YELLOW)install-speed$(NC) - Install with the NumPy scan accelerator" - @echo " $(YELLOW)run$(NC) - Launch the Peekmem shell" + @echo " $(YELLOW)run$(NC) - Launch the Picklock shell" @echo " $(YELLOW)test$(NC) - Run tests" @echo " $(YELLOW)test-verbose$(NC) - Run tests with verbose output" @echo " $(YELLOW)test-coverage$(NC) - Run tests with coverage report" @@ -94,7 +94,7 @@ install: # Launch the shell straight from the working tree .PHONY: run run: - @echo "$(GREEN)Starting the Peekmem shell...$(NC)" + @echo "$(GREEN)Starting the Picklock shell...$(NC)" $(PYTHON) -m $(PACKAGE_NAME) # Run tests @@ -136,7 +136,7 @@ type-check: # Check that the CLI starts and dispatches end to end. The test suite calls # main() in-process; this drives it as a real process, so argv parsing, batch # mode and the exit status are all exercised. CI additionally runs the -# installed `peekmem` console script to prove the entry point resolves. +# installed `picklock` console script to prove the entry point resolves. .PHONY: smoke smoke: @echo "$(GREEN)Checking the console script...$(NC)" diff --git a/README.md b/README.md index 873953a..22eb674 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Peekmem +# Picklock A **terminal client for [PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor)** — read, write and scan the memory of a running process from any shell, on any machine, over any SSH session. @@ -14,12 +14,12 @@ A **terminal client for [PyMemoryEditor](https://github.com/JeanExtreme002/PyMem

- Python Package - PyPI - License - Python Version - Coverage - Downloads + Python Package + PyPI + License + Python Version + Coverage + Downloads

--- @@ -27,11 +27,11 @@ A **terminal client for [PyMemoryEditor](https://github.com/JeanExtreme002/PyMem ## Install ```bash -pip install peekmem -peekmem +pip install picklock +picklock ``` -That is the whole setup. Peekmem's only dependency is PyMemoryEditor, which is +That is the whole setup. Picklock's only dependency is PyMemoryEditor, which is pure Python — so it installs on a bare server with no wheels to build, no Qt, and no display. @@ -39,17 +39,17 @@ For faster scans on large targets, add the `speed` extra. It pulls in NumPy, which PyMemoryEditor picks up automatically to vectorise the scan loop: ```bash -pip install "peekmem[speed]" +pip install "picklock[speed]" ``` ## A session ```console -$ peekmem -Welcome to Peekmem 0.1.0, a terminal client for PyMemoryEditor 2.2.0. +$ picklock +Welcome to Picklock 0.1.0, a terminal client for PyMemoryEditor 2.2.0. Type 'help' for the command list, or 'help scanning' for a walkthrough. -peekmem> ps:list game +picklock> ps:list game +-------+----------+ | PID | NAME | +-------+----------+ @@ -57,13 +57,13 @@ peekmem> ps:list game +-------+----------+ 1 row in set (0.01 sec) -peekmem> ps:open 41902 +picklock> ps:open 41902 Attached to game.exe (PID 41902, 64-bit). (0.00 sec) -peekmem [game.exe:41902]> scan:value int32 100 --writable +picklock [game.exe:41902]> scan:value int32 100 --writable Showing 20 of 3184 rows (1.42 sec) -peekmem [game.exe:41902]> scan:next 95 +picklock [game.exe:41902]> scan:next 95 +-----+--------------------+-------+ | ROW | ADDRESS | VALUE | +-----+--------------------+-------+ @@ -72,7 +72,7 @@ peekmem [game.exe:41902]> scan:next 95 +-----+--------------------+-------+ 2 rows in set (0.02 sec) -peekmem [game.exe:41902]> scan:next decreased +picklock [game.exe:41902]> scan:next decreased +-----+--------------------+-------+ | ROW | ADDRESS | VALUE | +-----+--------------------+-------+ @@ -80,7 +80,7 @@ peekmem [game.exe:41902]> scan:next decreased +-----+--------------------+-------+ 1 row in set (0.01 sec) -peekmem [game.exe:41902]> memory:write #1 int32 9999 +picklock [game.exe:41902]> memory:write #1 int32 9999 Wrote 4 byte(s) to 0x00000201A4C0F118. (0.00 sec) ``` @@ -88,7 +88,7 @@ Found the address, but it moves every launch? Find the pointer path to it, and keep it: ```console -peekmem [game.exe:41902]> pointer:scan #1 --depth 3 --max 100 +picklock [game.exe:41902]> pointer:scan #1 --depth 3 --max 100 +-----+------------------+-------------+--------------------+ | ROW | BASE | OFFSETS | TARGET | +-----+------------------+-------------+--------------------+ @@ -97,14 +97,14 @@ peekmem [game.exe:41902]> pointer:scan #1 --depth 3 --max 100 +-----+------------------+-------------+--------------------+ 2 rows in set (6.18 sec) -peekmem [game.exe:41902]> pointer:save health.json +picklock [game.exe:41902]> pointer:save health.json Saved 2 path(s) to health.json. # ... restart the target, find the value again, then: -peekmem [game.exe:52771]> pointer:rescan #1 health.json +picklock [game.exe:52771]> pointer:rescan #1 health.json 1 path(s) still reach 0x000001F73C20E118. (0.03 sec) -peekmem [game.exe:52771]> pointer:read game.exe+0x3BA228 0x3E8 --write 9999 +picklock [game.exe:52771]> pointer:read game.exe+0x3BA228 0x3E8 --write 9999 Wrote 4 byte(s) to 0x000001F73C20E118. (0.00 sec) ``` @@ -114,17 +114,17 @@ The same vocabulary works non-interactively, which is the point of a CLI on a server: ```bash -peekmem ps:list chrome # one command, then exit -peekmem -p 4242 -e "memory:read game.exe+0x1234" # attach, read, exit -peekmem -p 4242 -e "scan:value int32 100" -e "scan:results" # several, in order -peekmem -f setup.peek # a file of commands -echo "ps:list" | peekmem # a pipe +picklock ps:list chrome # one command, then exit +picklock -p 4242 -e "memory:read game.exe+0x1234" # attach, read, exit +picklock -p 4242 -e "scan:value int32 100" -e "scan:results" # several, in order +picklock -f setup.peek # a file of commands +echo "ps:list" | picklock # a pipe ``` Results go to stdout and errors to stderr, tables are plain ASCII, colour is off whenever the output is not a terminal — in a terminal it amounts to a red `ERROR` and a dimmed target in the prompt, and nothing else — and a failing -command exits non-zero — so `peekmem -e ... | grep`, `>> log.txt` and `&& deploy` all behave. +command exits non-zero — so `picklock -e ... | grep`, `>> log.txt` and `&& deploy` all behave. ## What it can do @@ -134,17 +134,17 @@ takes a subcommand documents itself with a usage line, a worked example, and its commands with the arguments they take. ```console -peekmem> scan:help +picklock> scan:help usage: scan[:SUBCOMMAND] Search memory for a value, then narrow what you found. Example: - peekmem> scan:value int32 100 --writable + picklock> scan:value int32 100 --writable Showing 20 of 3184 rows (1.42 sec) - peekmem> scan:next 95 + picklock> scan:next 95 +-----+--------------------+-------+ | ROW | ADDRESS | VALUE | +-----+--------------------+-------+ @@ -187,7 +187,7 @@ argument, every flag, and examples. That list is generated from the command's own parser, so it is always exactly what the command accepts: ```console -peekmem> help dump +picklock> help dump dump — Hex-dump a range of memory. Usage: dump
[length] [--width N] @@ -226,7 +226,7 @@ Highlights: what it already found. - **Names of your own, remembered.** `alias:add r memory:read` makes `r` do the same thing; `alias:add find-text scan:value string` carries arguments - along, so `find-text Peekmem` runs `scan:value string Peekmem`. A name + along, so `find-text Picklock` runs `scan:value string Picklock`. A name already taken by a command is refused rather than shadowing it, and the aliases are still there next time you open a terminal. - **Every listing pages the same way.** `--limit`, `--page` and `--all` on each @@ -255,11 +255,11 @@ So the whole chain fits on one line: ### Where things are kept -Aliases are the one thing Peekmem stores between runs — a name you chose would +Aliases are the one thing Picklock stores between runs — a name you chose would be pointless if you had to choose it again every session. They live in -`$XDG_CONFIG_HOME/peekmem/aliases.json` (`~/.config/peekmem/aliases.json` by -default, `%APPDATA%\peekmem` on Windows); `alias:list` prints the path, and -`PEEKMEM_CONFIG_DIR` moves it. +`$XDG_CONFIG_HOME/picklock/aliases.json` (`~/.config/picklock/aliases.json` by +default, `%APPDATA%\picklock` on Windows); `alias:list` prints the path, and +`PICKLOCK_CONFIG_DIR` moves it. Settings do not persist, on purpose: they tune one session's output, and a stale one would be a surprise on the next run. Put `config:set` lines in a file @@ -271,35 +271,35 @@ Reading another process's memory is a privileged operation everywhere: - **Windows** — run your terminal as Administrator to touch processes you do not own. -- **Linux** — `sudo peekmem`, or grant the capability once with +- **Linux** — `sudo picklock`, or grant the capability once with `sudo setcap cap_sys_ptrace+ep $(readlink -f $(which python3))`. Some distributions also need `/proc/sys/kernel/yama/ptrace_scope` set to `0`. -- **macOS** — SIP blocks reading most processes. `sudo peekmem` works for +- **macOS** — SIP blocks reading most processes. `sudo picklock` works for processes you own; anything else needs a signed binary carrying the debugger entitlement. -Peekmem says which of these applies when an `open` is refused. +Picklock says which of these applies when an `open` is refused. -## Peekmem vs. the PyMemoryEditor app +## Picklock vs. the PyMemoryEditor app They are different front ends to the same library, and installing one does not install the other: -| | **Peekmem** | **PyMemoryEditor's app** | +| | **Picklock** | **PyMemoryEditor's app** | | --- | --- | --- | | Interface | terminal, ASCII | desktop GUI (Qt) | -| Install | `pip install peekmem` | `pip install "PyMemoryEditor[app]"` | +| Install | `pip install picklock` | `pip install "PyMemoryEditor[app]"` | | Needs a display | no | yes | | Scriptable | yes — `-e`, `-f`, pipes | no | | Good for | servers, SSH, CI, automation | interactive exploration on a desktop | ## Related -Peekmem is a client. Every read, write, scan and pointer walk is performed by +Picklock is a client. Every read, write, scan and pointer walk is performed by **[PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor)** — the cross-platform memory library it is built on. -⭐ **If Peekmem is useful to you, star the repo — and +⭐ **If Picklock is useful to you, star the repo — and [star PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor) too.** It is the engine underneath, and it is what makes any of this work on three operating systems at once. @@ -309,8 +309,8 @@ operating systems at once. Issues and pull requests are welcome. ```bash -git clone https://github.com/JeanExtreme002/Peekmem -cd Peekmem +git clone https://github.com/JeanExtreme002/Picklock +cd Picklock make install-dev # pip install -e ".[dev]" make pre-commit # lint + type-check + tests ``` @@ -322,8 +322,8 @@ process, so it runs anywhere — including CI runners that would refuse. that keep its shape, and how to add a command (it is one decorator, and `help` plus the tests come along for free). -- 🐛 [Report a bug](https://github.com/JeanExtreme002/Peekmem/issues/new?template=bug_report.md) -- 💡 [Request a feature](https://github.com/JeanExtreme002/Peekmem/issues/new?template=feature_request.md) +- 🐛 [Report a bug](https://github.com/JeanExtreme002/Picklock/issues/new?template=bug_report.md) +- 💡 [Request a feature](https://github.com/JeanExtreme002/Picklock/issues/new?template=feature_request.md) - 🔒 [Security policy](SECURITY.md) — please do **not** open a public issue - 🤝 [Code of Conduct](CODE_OF_CONDUCT.md) diff --git a/SECURITY.md b/SECURITY.md index 2f6bd61..d72a3e1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,11 +10,11 @@ prepared before details become public. private thread visible only to the maintainers and the reporter, supports CVE assignment, and lets us coordinate a disclosure timeline. - **Alternative:** email `contact@jeanloui.dev` with subject - `[Peekmem security]`. + `[Picklock security]`. When reporting, please include: -- Affected version(s) — the output of `peekmem -e "version"` covers Peekmem, +- Affected version(s) — the output of `picklock -e "version"` covers Picklock, PyMemoryEditor, Python and the platform. - The exact command line or shell session that triggers it. - A minimal reproducer, and the impact you observed. @@ -22,19 +22,19 @@ When reporting, please include: ## Scope -Peekmem is a *client*. It parses commands, formats results, and calls +Picklock is a *client*. It parses commands, formats results, and calls [PyMemoryEditor], which performs every read, write and scan through OS-level APIs. Vulnerabilities in the memory operations themselves therefore belong to PyMemoryEditor — see [its security policy][pyme-security] — while everything between the keyboard and that call belongs here. -That Peekmem needs elevated privileges, a debugger entitlement or a relaxed +That Picklock needs elevated privileges, a debugger entitlement or a relaxed `ptrace_scope` to attach to a process is documented in the README; those requirements are not defects. In scope: -- Command injection or unintended code execution from anything Peekmem parses: +- Command injection or unintended code execution from anything Picklock parses: a command line, an address expression, a `source` script, a pointer-path file loaded with `ptrload`. - A command writing to an address other than the one it reported, or reporting @@ -47,10 +47,10 @@ In scope: Out of scope: -- Using Peekmem against a target you are not authorized to inspect. That is a +- Using Picklock against a target you are not authorized to inspect. That is a misuse question, not a defect. - Anti-cheat evasion or cheating-detection bypass requests. -- Peekmem being able to read and write another process's memory *at all* — +- Picklock being able to read and write another process's memory *at all* — that is the entire purpose of the tool, and the OS is what gates it. - Bugs in PyMemoryEditor's platform backends. Report those [upstream][pyme-security]; if you are unsure which side a bug is on, report it here and it will be @@ -58,10 +58,10 @@ Out of scope: ## Supported versions -Fixes land on the latest release. Peekmem follows the version of +Fixes land on the latest release. Picklock follows the version of PyMemoryEditor it depends on rather than pinning to an old one, so please reproduce on the current release of both before reporting. -[private security advisory]: https://github.com/JeanExtreme002/Peekmem/security/advisories/new +[private security advisory]: https://github.com/JeanExtreme002/Picklock/security/advisories/new [PyMemoryEditor]: https://github.com/JeanExtreme002/PyMemoryEditor [pyme-security]: https://github.com/JeanExtreme002/PyMemoryEditor/blob/main/SECURITY.md diff --git a/codecov.yml b/codecov.yml index 3e4cdb0..131a04e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -5,7 +5,7 @@ # blocking the merge button while still surfacing the trend and per-PR diff # coverage. # -# Coverage is uploaded from every OS/Python cell of the matrix. Peekmem's own +# Coverage is uploaded from every OS/Python cell of the matrix. Picklock's own # code is platform-independent, but the shell behaves differently where # readline is missing (Windows), so the merged view is what shows those # branches covered. diff --git a/peekmem/__init__.py b/picklock/__init__.py similarity index 69% rename from peekmem/__init__.py rename to picklock/__init__.py index d8c24f0..f7b697c 100644 --- a/peekmem/__init__.py +++ b/picklock/__init__.py @@ -1,29 +1,29 @@ # -*- coding: utf-8 -*- """ -Peekmem — a plain-text terminal client for PyMemoryEditor. +Picklock — a plain-text terminal client for PyMemoryEditor. -Peekmem exposes PyMemoryEditor's process introspection, memory scanning and +Picklock exposes PyMemoryEditor's process introspection, memory scanning and read/write features through an interactive shell modelled on the ``mysql`` command-line client: ASCII result tables, a one-line prompt, no curses, no GUI toolkit, no colour beyond a single highlight for errors. It runs anywhere Python does — a desktop, a headless server, an SSH session, a CI job. The package is a *client*: every memory operation is performed by -PyMemoryEditor, which Peekmem depends on but does not vendor. +PyMemoryEditor, which Picklock depends on but does not vendor. """ __author__ = "Jean Loui Bernard Silva de Jesus" __version__ = "0.1.0" -from .errors import CommandError, NoProcessError, PeekmemError +from .errors import CommandError, NoProcessError, PicklockError from .session import Session from .shell import Shell __all__ = ( "CommandError", "NoProcessError", - "PeekmemError", + "PicklockError", "Session", "Shell", "__author__", diff --git a/peekmem/__main__.py b/picklock/__main__.py similarity index 70% rename from peekmem/__main__.py rename to picklock/__main__.py index b15f0c0..1df59e1 100644 --- a/peekmem/__main__.py +++ b/picklock/__main__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -"""Entry point for ``python -m peekmem``.""" +"""Entry point for ``python -m picklock``.""" import sys diff --git a/peekmem/addressing.py b/picklock/addressing.py similarity index 98% rename from peekmem/addressing.py rename to picklock/addressing.py index e45ff73..5346a3d 100644 --- a/peekmem/addressing.py +++ b/picklock/addressing.py @@ -3,7 +3,7 @@ """ The little address language every command shares. -Anywhere Peekmem takes an address it takes an *expression*, so the workflows +Anywhere Picklock takes an address it takes an *expression*, so the workflows that matter can be typed on one line instead of copied between commands: =============================== ========================================= diff --git a/peekmem/aliases.py b/picklock/aliases.py similarity index 89% rename from peekmem/aliases.py rename to picklock/aliases.py index 085bf30..7258ac7 100644 --- a/peekmem/aliases.py +++ b/picklock/aliases.py @@ -3,14 +3,14 @@ """ Where the aliases are kept between sessions. -This is the only file Peekmem writes. Settings deliberately do not persist — +This is the only file Picklock writes. Settings deliberately do not persist — they tune one session's output and a stale one would be a surprise on the next run — but an alias is a name you chose, and having to choose it again every time would make the feature pointless. The location follows the usual convention for the platform: -``$XDG_CONFIG_HOME/peekmem`` (or ``~/.config/peekmem``) on Linux and macOS, -``%APPDATA%\\peekmem`` on Windows. The readline history stays a dotfile in the +``$XDG_CONFIG_HOME/picklock`` (or ``~/.config/picklock``) on Linux and macOS, +``%APPDATA%\\picklock`` on Windows. The readline history stays a dotfile in the home directory, where readline's own convention puts it — that is an artefact of the line editor rather than configuration. @@ -25,7 +25,7 @@ from typing import Dict, List #: Overridable so a test — or a throwaway session — can use its own file. -ENV_DIR = "PEEKMEM_CONFIG_DIR" +ENV_DIR = "PICKLOCK_CONFIG_DIR" _FILENAME = "aliases.json" @@ -33,7 +33,7 @@ def directory() -> str: - """The directory Peekmem keeps its configuration in.""" + """The directory Picklock keeps its configuration in.""" override = os.environ.get(ENV_DIR) if override: return override @@ -44,7 +44,7 @@ def directory() -> str: base = os.environ.get("XDG_CONFIG_HOME") or os.path.join( os.path.expanduser("~"), ".config" ) - return os.path.join(base, "peekmem") + return os.path.join(base, "picklock") def path() -> str: diff --git a/peekmem/cli.py b/picklock/cli.py similarity index 85% rename from peekmem/cli.py rename to picklock/cli.py index 01c93b2..5de7e2b 100644 --- a/peekmem/cli.py +++ b/picklock/cli.py @@ -1,18 +1,18 @@ # -*- coding: utf-8 -*- """ -The ``peekmem`` entry point. +The ``picklock`` entry point. Run bare, it opens the interactive shell. Given commands — with ``-e``, as a trailing command line, in a file, or on standard input — it runs them and exits with a status, so the same vocabulary works inside a script, an SSH session or a CI job: - peekmem # the shell - peekmem ps:list chrome # one command, then exit - peekmem -p 4242 -e "memory:read game.exe+0x10" # attach, read, exit - peekmem -f setup.peek # a file of commands - echo "ps:list" | peekmem # a pipe + picklock # the shell + picklock ps:list chrome # one command, then exit + picklock -p 4242 -e "memory:read game.exe+0x10" # attach, read, exit + picklock -f setup.peek # a file of commands + echo "ps:list" | picklock # a pipe """ import argparse @@ -25,7 +25,7 @@ from . import __version__, dependencies from .commands import top_level_listing from .commands.alias_commands import restore as restore_aliases -from .errors import CommandError, PeekmemError +from .errors import CommandError, PicklockError from .output import Printer from .session import Session from .shell import Shell @@ -40,20 +40,20 @@ def _format_commands() -> str: rows = top_level_listing() width = max(len(signature) for signature, _ in rows) - lines: List[str] = ["peekmem commands:", ""] + lines: List[str] = ["picklock commands:", ""] # Four spaces between the columns, as the shell's own listings use. lines += [f" {signature.ljust(width)} {summary}" for signature, summary in rows] lines += [ "", - "Run 'peekmem help ' for what a command takes, or", - "'peekmem help' for the topics ('types', 'address', 'scanning').", + "Run 'picklock help ' for what a command takes, or", + "'picklock help' for the topics ('types', 'address', 'scanning').", ] return "\n".join(lines) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="peekmem", + prog="picklock", description=( "A terminal client for PyMemoryEditor: read, write and scan the " "memory of a running process from any shell, on Windows, Linux or " @@ -114,12 +114,12 @@ def build_parser() -> argparse.ArgumentParser: "-v", "--version", action="version", - version=f"peekmem {__version__} (PyMemoryEditor {PyMemoryEditor.__version__})", + version=f"picklock {__version__} (PyMemoryEditor {PyMemoryEditor.__version__})", ) parser.add_argument( "command", nargs=argparse.REMAINDER, - help="a single command to run, e.g. 'peekmem ps:list chrome'", + help="a single command to run, e.g. 'picklock ps:list chrome'", ) return parser @@ -147,8 +147,8 @@ def _batch_lines(options: argparse.Namespace, stdin) -> Optional[List[str]]: """The commands to run non-interactively, or ``None`` for the shell. Standard input counts only when it is *not* a terminal: a pipe or a - redirect is someone scripting Peekmem, while a terminal is someone who - typed ``peekmem`` and wants the prompt. + redirect is someone scripting Picklock, while a terminal is someone who + typed ``picklock`` and wants the prompt. """ lines: List[str] = [] @@ -170,7 +170,7 @@ def _batch_lines(options: argparse.Namespace, stdin) -> Optional[List[str]]: def main(argv: Optional[Sequence[str]] = None) -> int: - """Run Peekmem. Returns the process exit status.""" + """Run Picklock. Returns the process exit status.""" parser = build_parser() options = parser.parse_args(argv) @@ -230,7 +230,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: finally: session.close() - except PeekmemError as error: + except PicklockError as error: printer.error(str(error)) return 1 except KeyboardInterrupt: @@ -238,7 +238,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: printer.write() return 130 except BrokenPipeError: # pragma: no cover - depends on the consumer - # 'peekmem ps | head' closes the pipe early; that is not an error. + # 'picklock ps | head' closes the pipe early; that is not an error. return 0 diff --git a/peekmem/commands/__init__.py b/picklock/commands/__init__.py similarity index 96% rename from peekmem/commands/__init__.py rename to picklock/commands/__init__.py index 6546e3a..caacac8 100644 --- a/peekmem/commands/__init__.py +++ b/picklock/commands/__init__.py @@ -3,7 +3,7 @@ """ The command registry. -Every Peekmem command is a plain function registered with :func:`command`. +Every Picklock command is a plain function registered with :func:`command`. The registry owns the name, the aliases, the one-line summary, the usage line and — through :class:`CommandParser` — the full argument list, which means ``help`` is generated from the very definitions the dispatcher runs. A flag @@ -48,7 +48,7 @@ class Namespace: "ps", "Process", "Find a target process and attach to it.", - "peekmem> ps:list chrome\n" + "picklock> ps:list chrome\n" "\n" "+-------+------------+\n" "| PID | NAME |\n" @@ -61,7 +61,7 @@ class Namespace: "memory", "Memory", "Read, write and inspect the target's memory.", - "peekmem> memory:read game.exe+0x1234 int32\n" + "picklock> memory:read game.exe+0x1234 int32\n" "\n" "+--------------------+-------+-------+\n" "| ADDRESS | TYPE | VALUE |\n" @@ -74,10 +74,10 @@ class Namespace: "scan", "Scanning", "Search memory for a value, then narrow what you found.", - "peekmem> scan:value int32 100 --writable\n" + "picklock> scan:value int32 100 --writable\n" "Showing 20 of 3184 rows (1.42 sec)\n" "\n" - "peekmem> scan:next 95\n" + "picklock> scan:next 95\n" "+-----+--------------------+-------+\n" "| ROW | ADDRESS | VALUE |\n" "+-----+--------------------+-------+\n" @@ -89,23 +89,23 @@ class Namespace: "alias", "Aliases", "Give a command a shorter name of your own.", - "peekmem> alias:add r memory:read\n" + "picklock> alias:add r memory:read\n" "r = memory:read\n" "\n" - "peekmem> alias:add find-text scan:value string\n" + "picklock> alias:add find-text scan:value string\n" "find-text = scan:value string\n" "\n" - "peekmem> find-text Peekmem\n" - "(runs 'scan:value string Peekmem')", + "picklock> find-text Picklock\n" + "(runs 'scan:value string Picklock')", ), Namespace( "config", "Configuration", "Show or change the session's settings.", - "peekmem> config:set writable_only on\n" + "picklock> config:set writable_only on\n" "writable_only = on\n" "\n" - "peekmem> config:list limit\n" + "picklock> config:list limit\n" "\n" "limit: 20", ), @@ -113,7 +113,7 @@ class Namespace: "pointer", "Pointers", "Follow pointer chains, and find ones that survive a restart.", - "peekmem> pointer:scan #1 --depth 3\n" + "picklock> pointer:scan #1 --depth 3\n" "+-----+-------------------+---------+--------------------+\n" "| ROW | BASE | OFFSETS | TARGET |\n" "+-----+-------------------+---------+--------------------+\n" @@ -142,7 +142,7 @@ class CommandParser(argparse.ArgumentParser): ``argparse`` calls ``sys.exit`` on a usage error, which is right for a program and fatal for a REPL. Every failure becomes a - :class:`~peekmem.errors.CommandError`, printed as one ``ERROR:`` line. + :class:`~picklock.errors.CommandError`, printed as one ``ERROR:`` line. It also keeps the actions it was given, in the order they were declared, so ``help`` can list a command's arguments without reaching into argparse's diff --git a/peekmem/commands/alias_commands.py b/picklock/commands/alias_commands.py similarity index 94% rename from peekmem/commands/alias_commands.py rename to picklock/commands/alias_commands.py index 27160bc..ecadf6a 100644 --- a/peekmem/commands/alias_commands.py +++ b/picklock/commands/alias_commands.py @@ -6,11 +6,11 @@ An alias stands for the first word of a line and, optionally, some words after it: ``r`` for ``memory:read``, or ``find-text`` for ``scan:value string``. When one is used, its words replace the alias and whatever else was typed follows -them, so ``find-text Peekmem`` runs ``scan:value string Peekmem``. +them, so ``find-text Picklock`` runs ``scan:value string Picklock``. -Aliases persist. They are the one thing Peekmem stores between runs — a name +Aliases persist. They are the one thing Picklock stores between runs — a name you chose would be pointless if you had to choose it again every session — -and they are written to :mod:`peekmem.aliases`'s file the moment they change. +and they are written to :mod:`picklock.aliases`'s file the moment they change. """ from typing import List @@ -133,12 +133,12 @@ def _alias_add_parser() -> CommandParser: details=( "The alias replaces the first word of a line, and anything else you " "type follows what it stands for — so with 'find-text' set to " - "'scan:value string', typing 'find-text Peekmem' runs " - "'scan:value string Peekmem'.\n\n" + "'scan:value string', typing 'find-text Picklock' runs " + "'scan:value string Picklock'.\n\n" "A name already taken by a command or another alias is refused rather " "than shadowing it, and the command an alias points at has to exist, " "so a typo is caught here rather than the next time you use it.\n\n" - "Aliases are remembered between runs — they are the one thing Peekmem " + "Aliases are remembered between runs — they are the one thing Picklock " "stores on disk. 'alias:list' says where." ), examples=( @@ -173,7 +173,7 @@ def _alias_list_parser() -> CommandParser: "'cls', '\\\\h', '\\\\.' — are part of the commands themselves and are " "listed with them, in 'help '.\n\n" "They are stored in a file, whose path is printed under the table. " - "Setting PEEKMEM_CONFIG_DIR moves it — useful for keeping a throwaway " + "Setting PICKLOCK_CONFIG_DIR moves it — useful for keeping a throwaway " "set apart from the one you rely on." ), ) diff --git a/peekmem/commands/memory_commands.py b/picklock/commands/memory_commands.py similarity index 99% rename from peekmem/commands/memory_commands.py rename to picklock/commands/memory_commands.py index 9540217..976861d 100644 --- a/peekmem/commands/memory_commands.py +++ b/picklock/commands/memory_commands.py @@ -380,7 +380,7 @@ def _write_parser() -> CommandParser: "memory:write 0x7ffee3a01000 int32 100", "memory:write game.exe+0x1234 float 99.5", "memory:write #2 bytes 'DE AD BE EF'", - "memory:write 0x7ffee3a01000 string Peekmem --null-terminated", + "memory:write 0x7ffee3a01000 string Picklock --null-terminated", ), ) def cmd_write(session: Session, args: List[str]) -> None: diff --git a/peekmem/commands/pointer_commands.py b/picklock/commands/pointer_commands.py similarity index 99% rename from peekmem/commands/pointer_commands.py rename to picklock/commands/pointer_commands.py index 0865ec8..d999a0c 100644 --- a/peekmem/commands/pointer_commands.py +++ b/picklock/commands/pointer_commands.py @@ -288,7 +288,7 @@ def _ptrscan_parser() -> CommandParser: "Builds a map of every pointer in the target and walks it backwards " "from ADDRESS until it reaches a static base inside a module. The " "paths found replace whatever 'pointer:paths' was showing.\n\n" - "This is the expensive command in Peekmem: minutes and hundreds of " + "This is the expensive command in Picklock: minutes and hundreds of " "megabytes on a large target. Ctrl+C stops it and keeps the paths " "found so far.\n\n" "A path is only worth trusting once it has survived a restart: save " diff --git a/peekmem/commands/ps_commands.py b/picklock/commands/ps_commands.py similarity index 98% rename from peekmem/commands/ps_commands.py rename to picklock/commands/ps_commands.py index 57e1ab1..e110ac4 100644 --- a/peekmem/commands/ps_commands.py +++ b/picklock/commands/ps_commands.py @@ -38,7 +38,7 @@ def _ps_parser() -> CommandParser: parser=_ps_parser, summary="List the processes visible to you.", details=( - "Only processes your user can see are listed. Run Peekmem elevated to " + "Only processes your user can see are listed. Run Picklock elevated to " "see (and open) processes belonging to other users." ), examples=("ps:list", "ps:list chrome", "ps:list --pid-sort --limit 50"), @@ -192,7 +192,7 @@ def _close_parser() -> CommandParser: "Takes no arguments.\n\n" "Closes the OS handle and drops the scan results, the pointer paths " "and the cached memory map. The target itself is untouched — nothing " - "Peekmem wrote to it is undone." + "Picklock wrote to it is undone." ), ) def cmd_close(session: Session, args: List[str]) -> None: diff --git a/peekmem/commands/scan_commands.py b/picklock/commands/scan_commands.py similarity index 99% rename from peekmem/commands/scan_commands.py rename to picklock/commands/scan_commands.py index 7bcbc41..4341ead 100644 --- a/peekmem/commands/scan_commands.py +++ b/picklock/commands/scan_commands.py @@ -342,7 +342,7 @@ def _scan_parser() -> CommandParser: "scan:value int32 100", "scan:value float 99.5 --writable", "scan:value int32 --between 100 200", - "scan:value string Peekmem", + "scan:value string Picklock", "scan:value int32 1000 --op gt", ), ) diff --git a/peekmem/commands/session_commands.py b/picklock/commands/session_commands.py similarity index 98% rename from peekmem/commands/session_commands.py rename to picklock/commands/session_commands.py index 098a93a..beb7eca 100644 --- a/peekmem/commands/session_commands.py +++ b/picklock/commands/session_commands.py @@ -202,10 +202,10 @@ def _print_overview(session: Session) -> None: printer.write("usage: COMMAND[:SUBCOMMAND] [arguments]") printer.write() - printer.write(f"Peekmem {__version__} — a terminal client for PyMemoryEditor.") + printer.write(f"Picklock {__version__} — a terminal client for PyMemoryEditor.") printer.write() - printer.write('peekmem commands: (get help with "help ")') + printer.write('picklock commands: (get help with "help ")') printer.write() printer.write( render_definitions( @@ -222,7 +222,7 @@ def _print_overview(session: Session) -> None: # looks like, not a preamble to the page. _print_example( session, - "peekmem> ps:open 4242\n" + "picklock> ps:open 4242\n" "Attached to game.exe (PID 4242, 64-bit). (0.00 sec)", indent=4, ) @@ -587,11 +587,11 @@ def _version_parser() -> CommandParser: @command( "version", parser=_version_parser, - summary="Print the Peekmem, PyMemoryEditor, Python and platform versions.", + summary="Print the Picklock, PyMemoryEditor, Python and platform versions.", details=( "Takes no arguments.\n\n" - "The four lines to quote in a bug report. Peekmem is a client, so which " - "PyMemoryEditor is underneath matters as much as which Peekmem is on " + "The four lines to quote in a bug report. Picklock is a client, so which " + "PyMemoryEditor is underneath matters as much as which Picklock is on " "top — the two move independently." ), ) @@ -600,7 +600,7 @@ def cmd_version(session: Session, args: List[str]) -> None: session.printer.write( render_vertical( [ - ("Peekmem", __version__), + ("Picklock", __version__), ("PyMemoryEditor", PyMemoryEditor.__version__), ("Python", platform.python_version()), ( diff --git a/peekmem/dependencies.py b/picklock/dependencies.py similarity index 90% rename from peekmem/dependencies.py rename to picklock/dependencies.py index 5939f20..294a8e3 100644 --- a/peekmem/dependencies.py +++ b/picklock/dependencies.py @@ -4,7 +4,7 @@ Checking that the PyMemoryEditor underneath is new enough. ``pyproject.toml`` declares the floor, and pip enforces it on a normal -install — but not for anyone running Peekmem out of a source tree, or in an +install — but not for anyone running Picklock out of a source tree, or in an environment where an older PyMemoryEditor was already present. Those setups used to fail much later and much more cryptically: an older backend aborts a whole macOS scan on the first file-backed page whose pager declines to read, @@ -20,7 +20,7 @@ import PyMemoryEditor -#: The oldest PyMemoryEditor Peekmem supports. Keep in step with the floor in +#: The oldest PyMemoryEditor Picklock supports. Keep in step with the floor in #: pyproject.toml — the two say the same thing to different audiences. REQUIRED_VERSION: Tuple[int, ...] = (2, 2, 0) @@ -57,7 +57,7 @@ def check() -> Optional[str]: required = ".".join(str(part) for part in REQUIRED_VERSION) return ( - f"Peekmem needs PyMemoryEditor {required} or newer, but " + f"Picklock needs PyMemoryEditor {required} or newer, but " f"{installed} is installed. Older versions abort a whole scan on the " "first page they cannot read, among other differences.\n" f'Upgrade with: pip install -U "PyMemoryEditor>={required}"' diff --git a/peekmem/errors.py b/picklock/errors.py similarity index 79% rename from peekmem/errors.py rename to picklock/errors.py index c7aac73..b01fb58 100644 --- a/peekmem/errors.py +++ b/picklock/errors.py @@ -1,22 +1,22 @@ # -*- coding: utf-8 -*- """ -Exception hierarchy for Peekmem. +Exception hierarchy for Picklock. Every error a *command* can raise against the user's input derives from :class:`CommandError`. The shell catches that one class, prints it as a single ``ERROR: ...`` line and returns to the prompt — an interactive session must never die because an address was mistyped. Anything that is *not* a -``CommandError`` (a bug in Peekmem itself) propagates with its traceback, which +``CommandError`` (a bug in Picklock itself) propagates with its traceback, which is what you want when reporting an issue. """ -class PeekmemError(Exception): - """Base class for every Peekmem exception.""" +class PicklockError(Exception): + """Base class for every Picklock exception.""" -class CommandError(PeekmemError): +class CommandError(PicklockError): """A command was given input it cannot act on. Raised for unknown commands, malformed arguments, unreadable addresses and @@ -35,7 +35,7 @@ def __init__(self, command: str = ""): ) -class ExitShell(PeekmemError): +class ExitShell(PicklockError): """Raised by ``exit`` / ``quit`` to unwind the shell loop cleanly. Not an error in the user-facing sense — the shell catches it before the @@ -47,4 +47,4 @@ def __init__(self, status: int = 0): self.status = status -__all__ = ("CommandError", "ExitShell", "NoProcessError", "PeekmemError") +__all__ = ("CommandError", "ExitShell", "NoProcessError", "PicklockError") diff --git a/peekmem/output.py b/picklock/output.py similarity index 98% rename from peekmem/output.py rename to picklock/output.py index c05246e..2f7b162 100644 --- a/peekmem/output.py +++ b/picklock/output.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- """ -Everything Peekmem prints. +Everything Picklock prints. The house style is the ``mysql`` client's: results in an ASCII box table, a footer line counting rows and timing the command, and nothing else. Colour is limited to a single highlight on ``ERROR`` and is dropped entirely when the stream is not a terminal, when ``NO_COLOR`` is set, or when ``--no-color`` was -passed — so piping Peekmem into ``grep`` or a log file yields plain text. +passed — so piping Picklock into ``grep`` or a log file yields plain text. Keeping every byte of output behind this module is what makes the shell testable: a test builds a :class:`Printer` over a ``StringIO`` and asserts on @@ -25,7 +25,7 @@ LEFT = "left" _RED = "\033[31m" -#: The one shade Peekmem uses for text that is set apart from the rest: the +#: The one shade Picklock uses for text that is set apart from the rest: the #: target in the prompt, and the contents of an example block. One shade #: because they mean the same thing — this is context, not output — and using #: two would have implied a difference that is not there. @@ -382,7 +382,7 @@ def progress(self, label: str, fraction: float) -> None: """Update the in-place progress line on stderr. Scans walk gigabytes and a silent terminal looks like a hang. The line - is written to stderr so a piped ``peekmem -e "scan ..."`` still yields + is written to stderr so a piped ``picklock -e "scan ..."`` still yields clean, parseable stdout, and is skipped entirely when stderr is not a terminal so a log file does not fill with carriage returns. """ diff --git a/peekmem/processes.py b/picklock/processes.py similarity index 89% rename from peekmem/processes.py rename to picklock/processes.py index c214ade..b757db4 100644 --- a/peekmem/processes.py +++ b/picklock/processes.py @@ -6,9 +6,9 @@ PyMemoryEditor implements process enumeration natively per platform — via ``CreateToolhelp32Snapshot`` on Windows, ``/proc`` on Linux and ``libproc`` on macOS — but only exposes it from the platform backend module, not from the -package root. Importing the backend directly is what keeps Peekmem's dependency +package root. Importing the backend directly is what keeps Picklock's dependency list at exactly one entry: the alternative is psutil, a compiled dependency -that would have to build or ship a wheel on every server Peekmem is meant to +that would have to build or ship a wheel on every server Picklock is meant to run on, to answer a question PyMemoryEditor can already answer. The import is deliberately narrow (one function per platform) and guarded, so @@ -19,7 +19,7 @@ import sys from typing import Callable, Generator, Iterator, List, Optional, Tuple -from .errors import CommandError, PeekmemError +from .errors import CommandError, PicklockError #: ``(pid, name)`` as the platform backends yield it. ProcessEntry = Tuple[int, str] @@ -41,13 +41,13 @@ def _load_enumerator() -> Callable[[], Generator[ProcessEntry, None, None]]: return get_processes except ImportError as error: # pragma: no cover - depends on the installed lib - raise PeekmemError( + raise PicklockError( "This PyMemoryEditor build does not expose process enumeration " - f"where Peekmem expects it ({error}). Upgrade PyMemoryEditor." + f"where Picklock expects it ({error}). Upgrade PyMemoryEditor." ) - raise PeekmemError( - f"Unsupported platform {sys.platform!r}. Peekmem runs on Windows, " + raise PicklockError( + f"Unsupported platform {sys.platform!r}. Picklock runs on Windows, " "Linux and macOS." ) diff --git a/peekmem/py.typed b/picklock/py.typed similarity index 100% rename from peekmem/py.typed rename to picklock/py.typed diff --git a/peekmem/session.py b/picklock/session.py similarity index 98% rename from peekmem/session.py rename to picklock/session.py index 03edfaa..3f49b28 100644 --- a/peekmem/session.py +++ b/picklock/session.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ -The state one Peekmem session carries. +The state one Picklock session carries. A shell is only as useful as what it remembers between commands. A session holds the attached process, the addresses the last scan found (so ``next`` @@ -96,7 +96,7 @@ def __init__(self, printer: Optional[Printer] = None): #: User-defined aliases: the word typed, mapped to the words it stands #: for. Kept here rather than in the command registry because they are #: the user's, not the program's. A shell loads them from disk at - #: startup (see peekmem.aliases); a Session on its own starts with + #: startup (see picklock.aliases); a Session on its own starts with #: none, so nothing built in a test or a script touches a file. self.aliases: Dict[str, List[str]] = {} self._regions: Optional[List[MemoryRegion]] = None @@ -208,7 +208,7 @@ def attach( raise CommandError(str(error)) except PermissionError as error: raise CommandError( - f"{error} Peekmem needs permission to open the target — try " + f"{error} Picklock needs permission to open the target — try " "running it as an administrator (Windows), with sudo (Linux), " "or with the debugger entitlement (macOS)." ) diff --git a/peekmem/shell.py b/picklock/shell.py similarity index 97% rename from peekmem/shell.py rename to picklock/shell.py index 90f470f..7d3cbaf 100644 --- a/peekmem/shell.py +++ b/picklock/shell.py @@ -35,7 +35,7 @@ from .session import SETTINGS, Session #: Where the interactive shell remembers what you typed. -HISTORY_FILE = os.path.join(os.path.expanduser("~"), ".peekmem_history") +HISTORY_FILE = os.path.join(os.path.expanduser("~"), ".picklock_history") HISTORY_LENGTH = 1000 _LEADING_WORD = re.compile(r"\s*(\S+)\s*(.*)", re.DOTALL) @@ -50,7 +50,7 @@ class _Handled(Exception): class Shell: - """Dispatches command lines against a :class:`~peekmem.session.Session`.""" + """Dispatches command lines against a :class:`~picklock.session.Session`.""" def __init__( self, @@ -240,16 +240,16 @@ def prompt(self) -> str: somewhere — not to compete with the output above it. """ if self.session.process is None: - return "peekmem> " + return "picklock> " name = self.session.process_name or "?" target = f"[{name}:{self.session.process.pid}]" - return f"peekmem {self.printer.dim(target, in_prompt=self._readline)}> " + return f"picklock {self.printer.dim(target, in_prompt=self._readline)}> " def banner(self) -> str: import PyMemoryEditor return ( - f"Welcome to Peekmem {__version__}, a terminal client for " + f"Welcome to Picklock {__version__}, a terminal client for " f"PyMemoryEditor {PyMemoryEditor.__version__}.\n" "Type 'help' for the command list, or 'help scanning' for a " "walkthrough.\n" diff --git a/peekmem/valuetypes.py b/picklock/valuetypes.py similarity index 100% rename from peekmem/valuetypes.py rename to picklock/valuetypes.py diff --git a/pyproject.toml b/pyproject.toml index 394dad5..d131766 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "peekmem" +name = "picklock" dynamic = ["version"] description = "A terminal client for PyMemoryEditor — read, write and scan process memory from any shell on Windows, Linux and macOS." authors = [ @@ -50,13 +50,13 @@ classifiers = [ ] requires-python = ">=3.10" -# Peekmem is a client, not a reimplementation: every memory operation is -# PyMemoryEditor's. The floor is 2.2.0 because Peekmem uses the region +# Picklock is a client, not a reimplementation: every memory operation is +# PyMemoryEditor's. The floor is 2.2.0 because Picklock uses the region # snapshot, the module/thread descriptors and the pointer-scan file format # introduced up to that release. # # Nothing else is needed. The shell is stdlib only (argparse, shlex, readline -# where the platform has it), which is what lets `pip install peekmem` work on +# where the platform has it), which is what lets `pip install picklock` work on # a bare server with no compiler and no wheels to build. dependencies = [ "PyMemoryEditor>=2.2.0", @@ -64,7 +64,7 @@ dependencies = [ [project.optional-dependencies] # Vectorised scan acceleration. NumPy lights up the fast path inside -# PyMemoryEditor automatically — Peekmem needs no code change and behaves +# PyMemoryEditor automatically — Picklock needs no code change and behaves # identically without it, only slower on large regions. speed = [ "PyMemoryEditor[speed]>=2.2.0", @@ -79,24 +79,24 @@ dev = [ ] [project.scripts] -peekmem = "peekmem.cli:main" +picklock = "picklock.cli:main" [project.urls] -Homepage = "https://github.com/JeanExtreme002/Peekmem" -Repository = "https://github.com/JeanExtreme002/Peekmem" -Issues = "https://github.com/JeanExtreme002/Peekmem/issues" -Changelog = "https://github.com/JeanExtreme002/Peekmem/releases" +Homepage = "https://github.com/JeanExtreme002/Picklock" +Repository = "https://github.com/JeanExtreme002/Picklock" +Issues = "https://github.com/JeanExtreme002/Picklock/issues" +Changelog = "https://github.com/JeanExtreme002/Picklock/releases" "PyMemoryEditor" = "https://github.com/JeanExtreme002/PyMemoryEditor" Funding = "https://github.com/sponsors/JeanExtreme002" [tool.hatch.version] -path = "peekmem/__init__.py" +path = "picklock/__init__.py" [tool.hatch.build.targets.wheel] -packages = ["peekmem"] +packages = ["picklock"] [tool.hatch.build.targets.wheel.force-include] -"peekmem/py.typed" = "peekmem/py.typed" +"picklock/py.typed" = "picklock/py.typed" [tool.hatch.build.targets.sdist] exclude = ["/.github"] @@ -109,7 +109,7 @@ warn_unused_ignores = true testpaths = ["tests"] [tool.coverage.run] -source = ["peekmem"] +source = ["picklock"] [tool.coverage.report] show_missing = true diff --git a/tests/conftest.py b/tests/conftest.py index 99daad3..da2263b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ """Shared fixtures. Nothing here attaches to a process. -The suite is deliberately target-free: it covers the parts of Peekmem that are +The suite is deliberately target-free: it covers the parts of Picklock that are its own — parsing, formatting, dispatch, help — and leaves reading another process's memory to PyMemoryEditor's own tests. That keeps the suite runnable on any CI machine, where opening a second process is usually not permitted. @@ -12,10 +12,10 @@ import pytest -from peekmem import aliases -from peekmem.output import Printer -from peekmem.session import Session -from peekmem.shell import Shell +from picklock import aliases +from picklock.output import Printer +from picklock.session import Session +from picklock.shell import Shell class Capture: diff --git a/tests/test_addressing.py b/tests/test_addressing.py index 4d39a69..21cfec0 100644 --- a/tests/test_addressing.py +++ b/tests/test_addressing.py @@ -4,8 +4,8 @@ import pytest -from peekmem.addressing import parse_address, parse_int -from peekmem.errors import CommandError +from picklock.addressing import parse_address, parse_int +from picklock.errors import CommandError class FakeSession: diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 93b73d1..5d8966a 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -7,10 +7,10 @@ import pytest -from peekmem import aliases as storage -from peekmem.commands.alias_commands import restore -from peekmem.errors import CommandError -from peekmem.session import Session +from picklock import aliases as storage +from picklock.commands.alias_commands import restore +from picklock.errors import CommandError +from picklock.session import Session def test_an_alias_stands_for_a_command(shell, capture): @@ -20,10 +20,10 @@ def test_an_alias_stands_for_a_command(shell, capture): def test_an_alias_can_carry_arguments_of_its_own(shell): - """'find-text Peekmem' has to run 'scan:value string Peekmem'.""" + """'find-text Picklock' has to run 'scan:value string Picklock'.""" shell.run_line("alias:add find-text scan:value string") - word, args = shell.session.expand_alias("find-text", ["Peekmem"]) - assert (word, args) == ("scan:value", ["string", "Peekmem"]) + word, args = shell.session.expand_alias("find-text", ["Picklock"]) + assert (word, args) == ("scan:value", ["string", "Picklock"]) def test_an_unknown_word_expands_to_itself(shell): @@ -248,4 +248,4 @@ def test_the_location_follows_the_environment(monkeypatch, tmp_path): monkeypatch.delenv(storage.ENV_DIR) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) if sys.platform != "win32": - assert storage.directory() == str(tmp_path / "xdg" / "peekmem") + assert storage.directory() == str(tmp_path / "xdg" / "picklock") diff --git a/tests/test_cli.py b/tests/test_cli.py index affe1d6..0cce32f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,7 @@ import pytest -from peekmem.cli import build_parser, main +from picklock.cli import build_parser, main def run(argv, stdin_text=None, monkeypatch=None): @@ -30,7 +30,7 @@ def run(argv, stdin_text=None, monkeypatch=None): def test_execute_runs_a_command_and_exits(): status, out, _ = run(["-e", "version"]) assert status == 0 - assert "Peekmem" in out + assert "Picklock" in out def test_execute_flags_run_in_order(): @@ -54,13 +54,13 @@ def test_a_failing_command_exits_non_zero(): def test_commands_after_a_failure_do_not_run(): status, out, _ = run(["-e", "nosuchcommand", "-e", "version"]) assert status == 1 - assert "Peekmem" not in out + assert "Picklock" not in out def test_commands_are_read_from_a_pipe(): status, out, _ = run([], stdin_text="version\n# comment\n") assert status == 0 - assert out.count("Peekmem") == 1 + assert out.count("Picklock") == 1 def test_pid_and_name_together_are_rejected(): @@ -72,7 +72,7 @@ def test_pid_and_name_together_are_rejected(): def test_a_bad_pid_stops_before_the_commands(): status, out, err = run(["-p", "2147483646", "-e", "version"]) assert status == 1 - assert "Peekmem" not in out + assert "Picklock" not in out # Specifically the PID's fault. Asserting only on the status let a real # bug hide here once: --pid built a command that no longer existed, so the # run failed for the right code and entirely the wrong reason. @@ -81,8 +81,8 @@ def test_a_bad_pid_stops_before_the_commands(): def test_the_target_flags_build_a_real_command(): """--pid and --name are spelled as a command line; it has to be one.""" - from peekmem.cli import _startup_lines - from peekmem.commands import lookup + from picklock.cli import _startup_lines + from picklock.commands import lookup for argv in (["-p", "42"], ["-n", "game.exe", "-i", "--partial"]): options = build_parser().parse_args(argv + ["-e", "version"]) @@ -100,7 +100,7 @@ def test_limit_flag_reaches_the_session(): def test_an_outdated_pymemoryeditor_stops_the_run(monkeypatch): """The check must land before any command touches a process.""" - from peekmem import dependencies + from picklock import dependencies monkeypatch.setattr(dependencies.PyMemoryEditor, "__version__", "2.1.0") status, out, err = run(["-e", "version"]) @@ -123,6 +123,6 @@ def test_help_lists_the_layers_not_every_command(capsys): assert name in text for name in ("help", "config", "version", "exit"): assert name in text - assert "peekmem help " in text + assert "picklock help " in text assert "memory:read" not in text, "the deeper layer is reached, not dumped" assert "namespace" not in text.lower() diff --git a/tests/test_commands.py b/tests/test_commands.py index e217d38..fbedd04 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -4,7 +4,7 @@ import pytest -from peekmem.commands import ( +from picklock.commands import ( NAMESPACES, all_commands, children, @@ -14,7 +14,7 @@ namespaces, top_level, ) -from peekmem.errors import CommandError, NoProcessError +from picklock.errors import CommandError, NoProcessError COMMANDS = all_commands() @@ -66,7 +66,7 @@ def test_a_parent_command_never_runs_anything(shell, line): def test_clear_leaves_the_session_alone(shell, capture): """It wipes the screen, not the work: a cleared terminal is not a reset.""" - from peekmem import valuetypes + from picklock import valuetypes shell.session.store_scan(valuetypes.resolve("int32"), 4, [0x10], [1], "t") shell.run_line("clear") @@ -97,7 +97,7 @@ def test_names_go_at_most_one_level_deep(entry): def test_the_registry_refuses_a_third_level(): - from peekmem.commands import CommandParser, command + from picklock.commands import CommandParser, command with pytest.raises(RuntimeError, match="one level deep"): command( @@ -226,7 +226,7 @@ def test_a_namespace_listing_shows_argument_signatures(shell, capture): def test_a_namespace_listing_carries_a_worked_example(shell, capture): shell.run_line("ps:help") assert "Example:" in capture.out - assert "peekmem> ps:list chrome" in capture.out + assert "picklock> ps:list chrome" in capture.out def test_a_long_signature_is_cut_at_a_token_boundary(shell, capture): @@ -346,7 +346,7 @@ def test_help_flag_wins_over_a_bad_argument(shell, capture): def test_option_words_come_from_the_parser(): - from peekmem.commands import option_words + from picklock.commands import option_words assert "--writable" in option_words("scan:value") assert "--between" in option_words("scan:value") @@ -423,9 +423,9 @@ def test_close_without_a_target_is_an_error(shell): def test_version_reports_both_halves(shell, capture): - """Peekmem is a client: which PyMemoryEditor is underneath is half the answer.""" + """Picklock is a client: which PyMemoryEditor is underneath is half the answer.""" shell.run_line("version") - for label in ("Peekmem", "PyMemoryEditor", "Python", "Platform"): + for label in ("Picklock", "PyMemoryEditor", "Python", "Platform"): assert f"{label}:" in capture.out @@ -457,7 +457,7 @@ def test_unknown_option_is_reported_not_swallowed(shell): def test_reset_reports_what_it_discarded(shell, capture): - from peekmem import valuetypes + from picklock import valuetypes shell.session.store_scan(valuetypes.resolve("int32"), 4, [1, 2], [0, 0], "t") shell.run_line("scan:reset") @@ -531,8 +531,8 @@ def test_a_truncated_listing_says_which_page_it_is(shell, capture): def test_a_scan_preview_pages_through_scan_results(session, capture): """Re-running a scan to see page two would be absurd; scan:results is the pager.""" - from peekmem import valuetypes - from peekmem.commands.scan_commands import _print_results + from picklock import valuetypes + from picklock.commands.scan_commands import _print_results session.set_option("limit", "2") state = session.store_scan( @@ -576,7 +576,7 @@ def test_the_word_namespace_never_reaches_the_reader(shell, capture): ("memory:read:help", "memory:read — Read a typed value"), ("scan:results:help", "scan:results — Show the current result set"), ("clear:help", "clear — Clear the terminal"), - ("version:help", "version — Print the Peekmem"), + ("version:help", "version — Print the Picklock"), ("help:help", "help — List the commands"), ], ) @@ -634,8 +634,8 @@ def test_an_unknown_setting_lists_the_real_ones(shell, capture, line): def _coloured_shell(capture): """A shell whose printer emits colour, as a terminal session would.""" capture.printer.color = True - from peekmem.session import Session - from peekmem.shell import Shell + from picklock.session import Session + from picklock.shell import Shell return Shell(Session(capture.printer), printer=capture.printer) @@ -651,7 +651,7 @@ def test_example_blocks_are_dimmed(capture, line): for row in capture.out.splitlines() if row.strip() and "Example:" not in row and row.startswith(" ") ] - transcript = [row for row in body if "peekmem>" in row] + transcript = [row for row in body if "picklock>" in row] assert transcript, f"{line!r} printed no example" for row in transcript: assert "\033[38;5;247m" in row and "\033[0m" in row @@ -693,9 +693,9 @@ def test_examples_and_the_prompt_share_one_shade(capture, shell): capture.printer.color = True shell.run_line("scan:help") transcript = next( - row for row in capture.out.splitlines() if "peekmem>" in row + row for row in capture.out.splitlines() if "picklock>" in row ) - assert capture.printer.dim("peekmem>").split("peekmem>")[0] in transcript + assert capture.printer.dim("picklock>").split("picklock>")[0] in transcript @pytest.mark.parametrize("line", ["help", "scan:help", "memory:help"]) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index d349675..1ba3627 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -4,7 +4,7 @@ import pytest -from peekmem import dependencies +from picklock import dependencies @pytest.mark.parametrize( diff --git a/tests/test_output.py b/tests/test_output.py index ecf1be9..d85e976 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -2,7 +2,7 @@ """Table, hexdump and footer rendering.""" -from peekmem.output import ( +from picklock.output import ( LEFT, RIGHT, render_definitions, diff --git a/tests/test_scanning.py b/tests/test_scanning.py index 7558ee0..34f0a50 100644 --- a/tests/test_scanning.py +++ b/tests/test_scanning.py @@ -10,8 +10,8 @@ from PyMemoryEditor import MemoryRegion -from peekmem.commands.scan_commands import _BATCH_BYTES, _batch_regions, _run_scan -from peekmem.session import Session +from picklock.commands.scan_commands import _BATCH_BYTES, _batch_regions, _run_scan +from picklock.session import Session def make_regions(count: int, size: int = _BATCH_BYTES): diff --git a/tests/test_session.py b/tests/test_session.py index 21cb569..510321c 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -4,9 +4,9 @@ import pytest -from peekmem import valuetypes -from peekmem.errors import CommandError, NoProcessError -from peekmem.session import SETTINGS, Session +from picklock import valuetypes +from picklock.errors import CommandError, NoProcessError +from picklock.session import SETTINGS, Session def test_defaults_match_the_documented_settings(session: Session): diff --git a/tests/test_shell.py b/tests/test_shell.py index 07b1edb..86a4109 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -4,8 +4,8 @@ import pytest -from peekmem.errors import CommandError, ExitShell -from peekmem.shell import Shell +from picklock.errors import CommandError, ExitShell +from picklock.shell import Shell @pytest.mark.parametrize( @@ -45,7 +45,7 @@ def test_unknown_command_suggests_a_near_miss(shell, capture): def test_a_failing_command_does_not_end_the_session(shell, capture): assert shell.run_line("memory:read 0x10") is False assert shell.run_line("version") is True - assert "Peekmem" in capture.out + assert "Picklock" in capture.out def test_errors_can_be_raised_instead_of_printed(shell): @@ -61,7 +61,7 @@ def test_exit_unwinds_the_loop(shell): def test_run_lines_stops_at_the_first_failure(shell, capture): status = shell.run_lines(["version", "nosuchcommand", "version"], raise_errors=True) assert status == 1 - assert capture.out.count("Peekmem") == 1 + assert capture.out.count("Picklock") == 1 def test_run_lines_returns_the_exit_status(shell): @@ -78,22 +78,22 @@ def _attach(shell): def test_prompt_names_the_target(shell): - assert shell.prompt() == "peekmem> " + assert shell.prompt() == "picklock> " _attach(shell) - assert shell.prompt() == "peekmem [game.exe:4242]> " + assert shell.prompt() == "picklock [game.exe:4242]> " def test_the_target_is_dimmed_when_colour_is_on(shell, capture): """A reminder that writes are going somewhere, not a thing to look at.""" capture.printer.color = True _attach(shell) - assert shell.prompt() == "peekmem \033[38;5;247m[game.exe:4242]\033[0m> " + assert shell.prompt() == "picklock \033[38;5;247m[game.exe:4242]\033[0m> " def test_an_empty_prompt_is_never_styled(shell, capture): """Nothing is attached, so there is nothing to point at.""" capture.printer.color = True - assert shell.prompt() == "peekmem> " + assert shell.prompt() == "picklock> " assert "\033" not in shell.prompt() @@ -172,7 +172,7 @@ def test_ctrl_c_during_a_command_returns_to_the_prompt(shell, capture, monkeypat what leaves. Losing the shell — and the scan results in it — on the keystroke that stops a scan would make the results unreachable. """ - from peekmem.commands import Command, lookup + from picklock.commands import Command, lookup def interrupted(session, args): raise KeyboardInterrupt @@ -189,7 +189,7 @@ def fake_lookup(name): ) return entry - monkeypatch.setattr("peekmem.shell.lookup", fake_lookup) + monkeypatch.setattr("picklock.shell.lookup", fake_lookup) lines = iter(["version", "exit"]) monkeypatch.setattr("builtins.input", lambda prompt="": next(lines)) diff --git a/tests/test_valuetypes.py b/tests/test_valuetypes.py index 1f16a6d..7b88d3a 100644 --- a/tests/test_valuetypes.py +++ b/tests/test_valuetypes.py @@ -4,8 +4,8 @@ import pytest -from peekmem import valuetypes -from peekmem.errors import CommandError +from picklock import valuetypes +from picklock.errors import CommandError def test_aliases_resolve_to_the_same_type(): From da763239fef35257f441d34aabc9f83a6cc4d3d8 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 22:22:42 -0300 Subject: [PATCH 32/82] feat(config): remember settings between runs, and add config:reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings now persist alongside the aliases. I had argued the other way in the help text — a stale setting being a surprise on the next run — but coming back to a shell you configured is the more useful default, and the surprise is answerable. Answerable by config:reset, which is the other half of this change rather than an extra: persistence removes the escape hatch restarting used to be, so without a way to undo one, a setting you regret is only fixable by editing a file. It takes a name, or nothing to put everything back. Only the settings that differ from their defaults are written, so the file stays a record of what you changed and a default that moves in a later release still reaches you rather than being pinned by a value nobody chose. A name or a value the current release no longer accepts is reported and skipped at startup, and a --limit given on the command line still wins over the stored one without overwriting it. The storage module stops being alias-specific: peekmem/aliases.py becomes picklock/store.py, generic JSON load and atomic save over a named file, with each command owning what goes inside its own. Duplicating an atomic write for the second file is exactly how the two would have drifted. --- CONTRIBUTING.md | 1 + README.md | 18 ++-- picklock/cli.py | 13 +++ picklock/commands/__init__.py | 2 +- picklock/commands/alias_commands.py | 33 +++++-- picklock/commands/session_commands.py | 105 +++++++++++++++++++-- picklock/{aliases.py => store.py} | 60 +++++------- tests/conftest.py | 12 +-- tests/test_aliases.py | 26 ++--- tests/test_settings_persistence.py | 131 ++++++++++++++++++++++++++ 10 files changed, 321 insertions(+), 80 deletions(-) rename picklock/{aliases.py => store.py} (59%) create mode 100644 tests/test_settings_persistence.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d250c0..2af6a3c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,7 @@ picklock/ ├── 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 ``` diff --git a/README.md b/README.md index 22eb674..b22aea0 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ and `help scan` all produce the same output. | **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | | **`scan:`** | `value` · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | | **`pointer:`** | `deref` · `read` · `scan` · `rescan` · `paths` · `save` · `load` · `diff` | -| **`config:`** | `list` · `set` | +| **`config:`** | `list` · `set` · `reset` | | **`alias:`** | `add` · `list` · `remove` | | Top level | `help` · `source` · `version` · `clear` · `exit` | @@ -255,15 +255,15 @@ So the whole chain fits on one line: ### Where things are kept -Aliases are the one thing Picklock stores between runs — a name you chose would -be pointless if you had to choose it again every session. They live in -`$XDG_CONFIG_HOME/picklock/aliases.json` (`~/.config/picklock/aliases.json` by -default, `%APPDATA%\picklock` on Windows); `alias:list` prints the path, and -`PICKLOCK_CONFIG_DIR` moves it. +Picklock remembers your aliases and your settings, so the shell comes back the +way you left it. Both live in `$XDG_CONFIG_HOME/picklock/` — by default +`~/.config/picklock/`, or `%APPDATA%\picklock` on Windows — as +`aliases.json` and `settings.json`. `alias:list` and `config:list` print their +paths, and `PICKLOCK_CONFIG_DIR` moves both. -Settings do not persist, on purpose: they tune one session's output, and a -stale one would be a surprise on the next run. Put `config:set` lines in a file -and `source` it to reuse a setup. +Only the settings you actually changed are stored, so a default that moves in a +later release still reaches you. `config:reset` puts one back, or all of them — +which is what restarting used to do. ## Permissions diff --git a/picklock/cli.py b/picklock/cli.py index 5de7e2b..b4e7104 100644 --- a/picklock/cli.py +++ b/picklock/cli.py @@ -25,6 +25,7 @@ from . import __version__, dependencies from .commands import top_level_listing from .commands.alias_commands import restore as restore_aliases +from .commands.session_commands import restore as restore_settings from .errors import CommandError, PicklockError from .output import Printer from .session import Session @@ -203,6 +204,18 @@ def main(argv: Optional[Sequence[str]] = None) -> int: ) ) + # Settings come back too. Restored before --limit is applied, so a flag + # given on this run still wins over what was stored on the last one. + forgotten = restore_settings(session) + if forgotten: + printer.note( + "Ignored %s no longer recognised: %s." + % ( + "a stored setting" if len(forgotten) == 1 else "stored settings", + ", ".join(sorted(forgotten)), + ) + ) + if options.limit is not None: session.set_option("limit", str(options.limit)) diff --git a/picklock/commands/__init__.py b/picklock/commands/__init__.py index caacac8..e1c83a5 100644 --- a/picklock/commands/__init__.py +++ b/picklock/commands/__init__.py @@ -101,7 +101,7 @@ class Namespace: Namespace( "config", "Configuration", - "Show or change the session's settings.", + "Show or change how Picklock behaves.", "picklock> config:set writable_only on\n" "writable_only = on\n" "\n" diff --git a/picklock/commands/alias_commands.py b/picklock/commands/alias_commands.py index ecadf6a..6241adb 100644 --- a/picklock/commands/alias_commands.py +++ b/picklock/commands/alias_commands.py @@ -13,14 +13,17 @@ and they are written to :mod:`picklock.aliases`'s file the moment they change. """ -from typing import List +from typing import Dict, List -from .. import aliases as storage +from .. import store from ..errors import CommandError from ..output import LEFT, render_vertical from ..session import Session from . import CommandParser, command, command_words, lookup, namespaces +#: The file the aliases live in, inside Picklock's config directory. +_FILE = "aliases.json" + #: Characters that would make an alias unusable or ambiguous. A colon is the #: separator the command hierarchy is built on, and a leading dash would read #: as a flag at the point the line is split. @@ -76,6 +79,24 @@ def _validate_target(words: List[str]) -> List[str]: return words +def _stored() -> Dict[str, List[str]]: + """The aliases as they are on disk, with anything unusable left out. + + Hand-editing this file is expected, so a single string is accepted where a + list of words belongs — ``"f": "scan:value string"`` means what it looks + like. + """ + aliases: Dict[str, List[str]] = {} + for name, words in store.load(_FILE).items(): + if not isinstance(name, str): + continue + if isinstance(words, str): + words = words.split() + if isinstance(words, list) and words and all(isinstance(w, str) for w in words): + aliases[name] = list(words) + return aliases + + def restore(session: Session) -> List[str]: """Load the stored aliases into ``session``; return the ones dropped. @@ -85,7 +106,7 @@ def restore(session: Session) -> List[str]: you start up. """ dropped = [] - for name, words in sorted(storage.load().items()): + for name, words in sorted(_stored().items()): if words[0] in namespaces(): session.aliases[name] = words continue @@ -105,10 +126,10 @@ def _persist(session: Session) -> None: reason to refuse the alias: it still works for this session. """ try: - storage.save(session.aliases) + store.save(_FILE, session.aliases) except OSError as error: session.printer.note( - f"Could not save to {storage.path()}: {error}. " + f"Could not save to {store.path(_FILE)}: {error}. " "The alias works for this session only." ) @@ -191,7 +212,7 @@ def cmd_alias_list(session: Session, args: List[str]) -> None: (name, " ".join(words)) for name, words in sorted(session.aliases.items()) ] session.printer.table(("ALIAS", "STANDS FOR"), rows, (LEFT, LEFT)) - session.printer.write(f"Stored in {storage.path()}") + session.printer.write(f"Stored in {store.path(_FILE)}") session.printer.write() diff --git a/picklock/commands/session_commands.py b/picklock/commands/session_commands.py index beb7eca..b1dd254 100644 --- a/picklock/commands/session_commands.py +++ b/picklock/commands/session_commands.py @@ -9,7 +9,7 @@ import PyMemoryEditor -from .. import __version__, valuetypes +from .. import __version__, store, valuetypes from ..errors import CommandError, ExitShell from ..output import ( LEFT, @@ -83,6 +83,47 @@ } +#: The file the changed settings live in, beside the aliases. +_SETTINGS_FILE = "settings.json" + + +def restore(session: Session) -> List[str]: + """Load the stored settings into ``session``; return the ones dropped. + + A name or a value the current release no longer accepts is left out rather + than allowed to fail later — a setting renamed between versions should cost + one line of explanation, not a confusing error the first time it is used. + """ + dropped = [] + for name, value in sorted(store.load(_SETTINGS_FILE).items()): + try: + session.set_option(str(name), str(value)) + except CommandError: + dropped.append(str(name)) + return dropped + + +def _persist(session: Session) -> None: + """Write the settings that differ from their defaults. + + Only the differences, so the file stays a record of what *you* changed: a + default that moves in a later release then reaches you, instead of being + pinned forever by a value you never chose. + """ + changed = { + setting.name: _format_setting(session.option(setting.name)) + for setting in SETTINGS + if session.option(setting.name) != setting.default + } + try: + store.save(_SETTINGS_FILE, changed) + except OSError as error: + session.printer.note( + f"Could not save to {store.path(_SETTINGS_FILE)}: {error}. " + "The change holds for this session only." + ) + + def _find_setting(name: str): """Look up a setting by name, listing the real ones when it is not one.""" setting = {item.name: item for item in SETTINGS}.get(name.strip().lower()) @@ -435,13 +476,13 @@ def _config_list_parser() -> CommandParser: @command( "config:list", parser=_config_list_parser, - summary="Show the session's settings and their current values.", + summary="Show the settings and their current values.", details=( - "Settings live for the session only. They tune one session's output, " - "and a stale one would be a surprise on the next run, so a fresh shell " - "always starts from the documented defaults — unlike aliases, which " - "are remembered. Put the 'config:set' lines in a script and run it " - "with 'source' to reuse a setup." + "A change is remembered between runs, so the shell comes back the way " + "you left it. Only what you changed is stored, so a default that moves " + "in a later release still reaches you — and 'config:reset' puts one " + "back, which restarting no longer does.\n\n" + "The path is printed under the table." ), examples=("config:list", "config:list limit"), ) @@ -463,6 +504,8 @@ def cmd_config_list(session: Session, args: List[str]) -> None: for setting in SETTINGS ] session.printer.table(("SETTING", "VALUE", "DESCRIPTION"), rows, (LEFT, RIGHT, LEFT)) + session.printer.write(f"Stored in {store.path(_SETTINGS_FILE)}") + session.printer.write() def _config_set_parser() -> CommandParser: @@ -482,7 +525,7 @@ def _config_set_parser() -> CommandParser: @command( "config:set", parser=_config_set_parser, - summary="Change one of the session's settings.", + summary="Change one of the settings.", details=( "'config:set limit 50' and 'config:set limit=50' do the same thing.\n\n" "The change lasts for the session and no longer. Run 'config:list' to " @@ -508,10 +551,56 @@ def cmd_config_set(session: Session, args: List[str]) -> None: _find_setting(name) # Reject an unknown name before parsing its value. applied = session.set_option(name, value) + _persist(session) session.printer.ok(f"{name.strip().lower()} = {_format_setting(applied)}") session.printer.write() +def _config_reset_parser() -> CommandParser: + parser = CommandParser("config:reset") + parser.add_argument( + "name", + nargs="?", + default=None, + help="the setting to put back; omit it to reset every one", + ) + return parser + + +@command( + "config:reset", + parser=_config_reset_parser, + summary="Put a setting back to its default.", + details=( + "Settings are remembered between runs, so restarting no longer undoes " + "one. This is what undoes it — for a single setting, or for all of " + "them at once.\n\n" + "A setting back at its default is dropped from the stored file rather " + "than written out as a default, so a default that moves in a later " + "release reaches you." + ), + examples=("config:reset limit", "config:reset"), +) +def cmd_config_reset(session: Session, args: List[str]) -> None: + options = _config_reset_parser().parse_args(args) + + if options.name is not None: + setting = _find_setting(options.name) + session.set_option(setting.name, _format_setting(setting.default)) + _persist(session) + session.printer.ok( + f"{setting.name} = {_format_setting(setting.default)} (the default)" + ) + session.printer.write() + return + + for setting in SETTINGS: + session.set_option(setting.name, _format_setting(setting.default)) + _persist(session) + session.printer.ok(f"All {len(SETTINGS)} settings are back to their defaults.") + session.printer.write() + + def _source_parser() -> CommandParser: parser = CommandParser("source") parser.add_argument( diff --git a/picklock/aliases.py b/picklock/store.py similarity index 59% rename from picklock/aliases.py rename to picklock/store.py index 7258ac7..0428895 100644 --- a/picklock/aliases.py +++ b/picklock/store.py @@ -1,12 +1,12 @@ # -*- coding: utf-8 -*- """ -Where the aliases are kept between sessions. +Where Picklock keeps what it remembers between runs. -This is the only file Picklock writes. Settings deliberately do not persist — -they tune one session's output and a stale one would be a surprise on the next -run — but an alias is a name you chose, and having to choose it again every -time would make the feature pointless. +Plain JSON files, one per kind of thing — the aliases you defined, the settings +you changed. This module knows how to find them, read them and replace them +safely; what belongs inside each one is the business of the command that owns +it. The location follows the usual convention for the platform: ``$XDG_CONFIG_HOME/picklock`` (or ``~/.config/picklock``) on Linux and macOS, @@ -14,23 +14,20 @@ home directory, where readline's own convention puts it — that is an artefact of the line editor rather than configuration. -Nothing here validates what it reads: a name that no longer points at a real -command is the caller's problem to report, not this module's to silently fix. +Nothing here validates what it reads: a setting that no longer exists, or a +name that no longer points at a real command, is the caller's problem to report +— not this module's to silently fix. """ import json import os import sys import tempfile -from typing import Dict, List +from typing import Any, Dict -#: Overridable so a test — or a throwaway session — can use its own file. +#: Overridable so a test — or a throwaway session — can use its own files. ENV_DIR = "PICKLOCK_CONFIG_DIR" -_FILENAME = "aliases.json" - -Aliases = Dict[str, List[str]] - def directory() -> str: """The directory Picklock keeps its configuration in.""" @@ -47,13 +44,13 @@ def directory() -> str: return os.path.join(base, "picklock") -def path() -> str: - """The alias file itself.""" - return os.path.join(directory(), _FILENAME) +def path(filename: str) -> str: + """The full path of one of Picklock's files.""" + return os.path.join(directory(), filename) -def load() -> Aliases: - """Read the stored aliases, or return none. +def load(filename: str) -> Dict[str, Any]: + """Read one file as a mapping, or return an empty one. A missing file is the ordinary case on a first run. An unreadable or malformed one returns nothing as well: a shell that refuses to start @@ -61,27 +58,16 @@ def load() -> Aliases: than the one it is reporting. """ try: - with open(path(), "r", encoding="utf-8") as handle: + with open(path(filename), "r", encoding="utf-8") as handle: stored = json.load(handle) except (OSError, ValueError): return {} - if not isinstance(stored, dict): - return {} - - aliases: Aliases = {} - for name, words in stored.items(): - if not isinstance(name, str): - continue - if isinstance(words, str): # Tolerate a hand-edited single string. - words = words.split() - if isinstance(words, list) and words and all(isinstance(w, str) for w in words): - aliases[name] = list(words) - return aliases + return stored if isinstance(stored, dict) else {} -def save(aliases: Aliases) -> None: - """Write the aliases, replacing whatever was there. +def save(filename: str, data: Dict[str, Any]) -> None: + """Write one file, replacing whatever was there. Written to a temporary file in the same directory and moved into place, so an interrupted write cannot leave a half-file behind — the next run would @@ -90,20 +76,20 @@ def save(aliases: Aliases) -> None: :raises OSError: when the file cannot be written. The caller decides whether that is worth interrupting them over. """ - target = path() + target = path(filename) os.makedirs(os.path.dirname(target), exist_ok=True) handle = tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=os.path.dirname(target), - prefix=_FILENAME, + prefix=filename, suffix=".tmp", delete=False, ) try: with handle: - json.dump(aliases, handle, indent=2, sort_keys=True) + json.dump(data, handle, indent=2, sort_keys=True) handle.write("\n") os.replace(handle.name, target) except BaseException: @@ -114,4 +100,4 @@ def save(aliases: Aliases) -> None: raise -__all__ = ("ENV_DIR", "Aliases", "directory", "load", "path", "save") +__all__ = ("ENV_DIR", "directory", "load", "path", "save") diff --git a/tests/conftest.py b/tests/conftest.py index da2263b..b4b7113 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ import pytest -from picklock import aliases +from picklock import store from picklock.output import Printer from picklock.session import Session from picklock.shell import Shell @@ -43,13 +43,13 @@ def reset(self) -> None: @pytest.fixture(autouse=True) def isolated_config(tmp_path, monkeypatch): - """Point the alias file at a throwaway directory, for every test. + """Point Picklock's files at a throwaway directory, for every test. - Autouse and unconditional: the suite must never read or write the config - of whoever is running it, and remembering to opt in per test is exactly - the kind of thing that gets forgotten once. + Autouse and unconditional: the suite must never read or write the files of + whoever is running it, and remembering to opt in per test is exactly the + kind of thing that gets forgotten once. """ - monkeypatch.setenv(aliases.ENV_DIR, str(tmp_path / "config")) + monkeypatch.setenv(store.ENV_DIR, str(tmp_path / "config")) @pytest.fixture diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 5d8966a..294e0ff 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -7,7 +7,7 @@ import pytest -from picklock import aliases as storage +from picklock import store from picklock.commands.alias_commands import restore from picklock.errors import CommandError from picklock.session import Session @@ -163,14 +163,14 @@ def test_aliases_complete(shell): def test_adding_writes_the_file(shell): shell.run_line("alias:add r memory:read") - assert storage.load() == {"r": ["memory:read"]} + assert store.load("aliases.json") == {"r": ["memory:read"]} def test_removing_rewrites_the_file(shell): shell.run_line("alias:add r memory:read") shell.run_line("alias:add w memory:write") shell.run_line("alias:remove r") - assert storage.load() == {"w": ["memory:write"]} + assert store.load("aliases.json") == {"w": ["memory:write"]} def test_a_new_session_gets_them_back(shell, capture): @@ -184,7 +184,7 @@ def test_a_new_session_gets_them_back(shell, capture): def test_restoring_drops_an_alias_whose_command_is_gone(capture): """A command can be renamed between releases; the name should not linger.""" - storage.save({"ok": ["memory:read"], "stale": ["memory:teleport"]}) + store.save("aliases.json", {"ok": ["memory:read"], "stale": ["memory:teleport"]}) session = Session(capture.printer) assert restore(session) == ["stale"] @@ -200,7 +200,7 @@ def test_a_missing_file_is_the_ordinary_first_run(capture): @pytest.mark.parametrize("content", ["not json at all", "[]", '{"r": 7}', '{"r": []}']) def test_a_malformed_file_loses_the_aliases_but_not_the_shell(content, capture): """Refusing to start over a stray character would be the worse bug.""" - path = pathlib.Path(storage.path()) + path = pathlib.Path(store.path("aliases.json")) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") @@ -211,7 +211,7 @@ def test_a_malformed_file_loses_the_aliases_but_not_the_shell(content, capture): def test_a_hand_written_string_is_tolerated(capture): """Someone will edit this file by hand; accept the obvious spelling.""" - path = pathlib.Path(storage.path()) + path = pathlib.Path(store.path("aliases.json")) path.parent.mkdir(parents=True, exist_ok=True) path.write_text('{"f": "scan:value string"}', encoding="utf-8") @@ -223,10 +223,10 @@ def test_a_hand_written_string_is_tolerated(capture): def test_a_write_failure_is_reported_but_not_fatal(shell, capture, monkeypatch): """A read-only home is a reason to say so, not to refuse the alias.""" - def refuse(_aliases): + def refuse(_filename, _data): raise OSError("read-only file system") - monkeypatch.setattr(storage, "save", refuse) + monkeypatch.setattr(store, "save", refuse) shell.run_line("alias:add r memory:read") assert shell.session.aliases == {"r": ["memory:read"]} @@ -237,15 +237,15 @@ def refuse(_aliases): def test_the_file_is_replaced_atomically(shell): """An interrupted write must not leave a half-file for the next run.""" shell.run_line("alias:add r memory:read") - directory = pathlib.Path(storage.directory()) + directory = pathlib.Path(store.directory()) assert [item.name for item in directory.iterdir()] == ["aliases.json"] def test_the_location_follows_the_environment(monkeypatch, tmp_path): - monkeypatch.setenv(storage.ENV_DIR, str(tmp_path / "explicit")) - assert storage.directory() == str(tmp_path / "explicit") + monkeypatch.setenv(store.ENV_DIR, str(tmp_path / "explicit")) + assert store.directory() == str(tmp_path / "explicit") - monkeypatch.delenv(storage.ENV_DIR) + monkeypatch.delenv(store.ENV_DIR) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) if sys.platform != "win32": - assert storage.directory() == str(tmp_path / "xdg" / "picklock") + assert store.directory() == str(tmp_path / "xdg" / "picklock") diff --git a/tests/test_settings_persistence.py b/tests/test_settings_persistence.py new file mode 100644 index 0000000..8f65d5d --- /dev/null +++ b/tests/test_settings_persistence.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- + +"""Settings survive a restart, and can be put back.""" + +import pytest + +from picklock import store +from picklock.commands.session_commands import restore +from picklock.errors import CommandError +from picklock.session import SETTINGS, Session + +_FILE = "settings.json" + + +def test_changing_one_writes_it(shell): + shell.run_line("config:set limit 3") + assert store.load(_FILE) == {"limit": "3"} + + +def test_a_new_session_comes_back_the_same_way(shell, capture): + shell.run_line("config:set limit 3") + shell.run_line("config:set hex on") + + fresh = Session(capture.printer) + assert restore(fresh) == [] + assert fresh.option("limit") == 3 + assert fresh.option("hex") is True + + +def test_only_the_changes_are_stored(shell): + """A default that moves in a later release should still reach the user. + + Writing every setting out would pin all of them to today's values forever, + including the ones nobody ever chose. + """ + shell.run_line("config:set hex on") + stored = store.load(_FILE) + assert stored == {"hex": "on"} + assert len(stored) < len(SETTINGS) + + +def test_returning_to_the_default_removes_it_from_the_file(shell): + default = {setting.name: setting.default for setting in SETTINGS}["limit"] + shell.run_line("config:set limit 3") + shell.run_line(f"config:set limit {default}") + assert store.load(_FILE) == {} + + +@pytest.mark.parametrize( + "name,value,expected", + [ + ("limit", "3", 3), + ("hex", "on", True), + ("timing", "off", False), + ("watch_interval", "0.25", 0.25), + ], +) +def test_every_kind_of_value_round_trips(shell, capture, name, value, expected): + """Ints, floats and switches all have to survive the trip through JSON.""" + shell.run_line(f"config:set {name} {value}") + + fresh = Session(capture.printer) + restore(fresh) + assert fresh.option(name) == expected + + +def test_reset_puts_one_back(shell, capture): + shell.run_line("config:set limit 3") + capture.reset() + shell.run_line("config:reset limit") + + assert shell.session.option("limit") == 20 + assert "the default" in capture.out + assert store.load(_FILE) == {} + + +def test_reset_without_a_name_puts_everything_back(shell): + shell.run_line("config:set limit 3") + shell.run_line("config:set hex on") + shell.run_line("config:reset") + + for setting in SETTINGS: + assert shell.session.option(setting.name) == setting.default + assert store.load(_FILE) == {} + + +def test_reset_rejects_a_name_that_is_not_a_setting(shell): + with pytest.raises(CommandError, match="Unknown setting"): + shell.run_line("config:reset nosuch", raise_errors=True) + + +def test_a_setting_that_no_longer_exists_is_ignored(capture): + """Renamed between releases: one line of explanation, not a later error.""" + store.save(_FILE, {"limit": "3", "colour_scheme": "solarized"}) + + session = Session(capture.printer) + assert restore(session) == ["colour_scheme"] + assert session.option("limit") == 3 + + +def test_a_stored_value_that_no_longer_parses_is_ignored(capture): + store.save(_FILE, {"limit": "as many as fit"}) + + session = Session(capture.printer) + assert restore(session) == ["limit"] + assert session.option("limit") == 20 + + +def test_a_missing_file_leaves_the_defaults(capture): + session = Session(capture.printer) + assert restore(session) == [] + assert session.option("limit") == 20 + + +def test_a_write_failure_is_reported_but_not_fatal(shell, capture, monkeypatch): + def refuse(_filename, _data): + raise OSError("read-only file system") + + monkeypatch.setattr(store, "save", refuse) + shell.run_line("config:set limit 3") + + assert shell.session.option("limit") == 3 + assert "Could not save" in capture.out + assert "this session only" in capture.out + + +def test_settings_and_aliases_are_separate_files(shell): + shell.run_line("config:set limit 3") + shell.run_line("alias:add r memory:read") + assert store.load("settings.json") == {"limit": "3"} + assert store.load("aliases.json") == {"r": ["memory:read"]} From d552dd56bc7a153067e146e0f5a6be96c2ef9235 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 22:29:15 -0300 Subject: [PATCH 33/82] docs(help): cover scan:results in the scanning walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walkthrough went from narrowing the results straight to reading one by number, never saying how you look at them in between — or that a scan only prints its first page, so the rows past twenty needed a command the topic never named. It now covers 'scan:results': what the VALUE and PREVIOUS columns mean against the comparisons just above it, and --page and --all for the rest of them. And 'scan:keep' / 'scan:drop' alongside, since seeing which rows are real is the other thing you do with that listing — and inventing a comparison to exclude the others is what you would otherwise be pushed into. --- picklock/commands/session_commands.py | 15 +++++++++++++++ tests/test_commands.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/picklock/commands/session_commands.py b/picklock/commands/session_commands.py index b1dd254..a74f9c5 100644 --- a/picklock/commands/session_commands.py +++ b/picklock/commands/session_commands.py @@ -64,6 +64,21 @@ scan:next changed / scan:next unchanged scan:next increased / scan:next decreased +'scan:results' is how you look at where you are between rounds. It re-reads +every address, so VALUE is what the target holds now rather than what the scan +found, and PREVIOUS shows the reading the comparisons above are measured +against. A scan only previews its first page; this is what reaches the rest: + + scan:results the first page, re-read + scan:results --page 2 the next one + scan:results --all every row, however many + +When you can see which rows are real, say so directly instead of inventing a +comparison that happens to exclude the others: + + scan:keep 1 4 7-9 keep those, drop the rest + scan:drop 2 the other way round + Then read, write or watch a surviving row by number: memory:read #1 int32 diff --git a/tests/test_commands.py b/tests/test_commands.py index fbedd04..e4b3900 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -727,3 +727,19 @@ def test_command_listings_breathe(shell, capture, line): gap = re.search(r"\S(\s{2,})\S", row) assert gap, f"no column separator in {row!r}" assert len(gap.group(1)) >= 4, f"only {len(gap.group(1))} spaces in {row!r}" + + +def test_the_scanning_topic_covers_the_whole_cycle(shell, capture): + """A walkthrough that stops before 'how do I see the rest?' is half a map.""" + shell.run_line("help scanning") + out = capture.out + for command in ( + "scan:value", + "scan:next", + "scan:results", + "scan:keep", + "memory:read #1", + "pointer:scan #1", + ): + assert command in out, f"the walkthrough never mentions {command}" + assert "--page" in out, "paging is how you reach past the first page" From 592084e34a8acaa566d66de6f04866db278744e4 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 22:40:28 -0300 Subject: [PATCH 34/82] refactor(scan): make every comparison a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'scan:next changed' could not tell a comparison from a search for the word "changed", because both lived in the same slot. Comparisons are flags now — '--changed', '--gt 50', '--between 10 20' — and the value slot only ever holds a value, so 'scan:next changed' looks for the word and means nothing else. No amount of documentation fixes an ambiguity the grammar has. A bare value still means equality, which is what a scan almost always is. scan:value gets the same treatment: '--op gt' was a flag whose *value* was a keyword, which is the same confusion one level down, and '--outside' only meant anything next to '--between'. Both scans now share one set of comparison flags, declared once so they cannot drift, with the refine-only ones ('--changed' and friends) added for scan:next alone — a first scan has no previous reading to compare against. The symbol spellings ('>' and friends) go with '--op'; '--gt' is the spelling now. Found while doing it: CommandParser did not record arguments added to a mutually exclusive group, because argparse gives the group its own add_argument. The help is built from that record, so all fourteen comparison flags existed and were documented nowhere — the generated usage line said 'scan:next [value]' and there was no Options section at all. The parser now proxies the group, and a test asserts a grouped flag reaches the help. Audited every other positional first: they hold addresses, patterns, names and files. The type slot ('memory:read 0x10 int32') holds a fixed vocabulary that never doubles as user data, so it stays where it reads best. --- README.md | 15 +- picklock/commands/__init__.py | 23 +++ picklock/commands/scan_commands.py | 266 +++++++++++++------------- picklock/commands/session_commands.py | 11 +- tests/test_commands.py | 96 ++++++++++ tests/test_shell.py | 2 +- 6 files changed, 274 insertions(+), 139 deletions(-) diff --git a/README.md b/README.md index b22aea0..1eadd6f 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ picklock [game.exe:41902]> scan:next 95 +-----+--------------------+-------+ 2 rows in set (0.02 sec) -picklock [game.exe:41902]> scan:next decreased +picklock [game.exe:41902]> scan:next --decreased +-----+--------------------+-------+ | ROW | ADDRESS | VALUE | +-----+--------------------+-------+ @@ -157,11 +157,11 @@ scan subcommands: (get help with "help scan:SUBCOMMAND") scan:aob [--max N] Scan for a byte pattern with wildcards (AOB). scan:drop [row ...] Remove the named result rows. scan:keep [row ...] Keep only the named result rows. - scan:next [op] [value ...] Narrow the results with another comparison. + scan:next [value] [--eq VALUE] [--ne... Narrow the results with another comparison. scan:regex [--length N] [--max N] Scan for text matching a regular expression. scan:reset Discard the current scan results. scan:results [--limit N] [--page N] [--all] Show the current result set, re-read. - scan:value [value] [--op OP]... Search the whole address space for a value. + scan:value [value] [--eq VALUE]... Search the whole address space for a value. ``` Every command answers `:help`, at any depth — `scan:help`, `scan:aob:help`, @@ -207,10 +207,11 @@ share. Highlights: -- **Every scan comparison PyMemoryEditor exposes** — exact, not-equal, greater, - smaller, and ranges — plus the refine-only ones that need no value at all: - `scan:next changed`, `scan:next unchanged`, `scan:next increased`, - `scan:next decreased`, `scan:next increased-by N`. +- **Every scan comparison PyMemoryEditor exposes** — `--eq`, `--ne`, `--gt`, + `--lt`, `--ge`, `--le`, `--between` — plus the refine-only ones that need no + value at all: `scan:next --changed`, `--unchanged`, `--increased`, + `--decreased`, `--increased-by N`. Comparisons are flags, so the value slot + only ever holds a value: `scan:next changed` looks for the word. - **AOB and regex scans.** `scan:aob "48 8B ? ? 00"` finds a signature with wildcards; `scan:regex "Player[0-9]+"` finds text. - **Thirteen value types** — `int8` … `int64`, `uint8` … `uint64`, `float`, diff --git a/picklock/commands/__init__.py b/picklock/commands/__init__.py index e1c83a5..e7718d5 100644 --- a/picklock/commands/__init__.py +++ b/picklock/commands/__init__.py @@ -159,6 +159,16 @@ def add_argument(self, *args, **kwargs): self.arguments.append(action) return action + def add_mutually_exclusive_group(self, **kwargs): + """A group whose arguments the parser still knows about. + + argparse hands a group its own ``add_argument``, which would bypass the + list above — and the help is built from that list, so a whole set of + flags would exist and be documented nowhere. The proxy records them as + the parser's own, which is what they are. + """ + return _ExclusiveGroup(self, super().add_mutually_exclusive_group(**kwargs)) + def error(self, message: str) -> None: # type: ignore[override] raise CommandError(f"{self.prog}: {message} (try 'help {self.prog}')") @@ -168,6 +178,19 @@ def exit(self, status: int = 0, message: Optional[str] = None) -> None: # type: raise CommandError(f"{self.prog}: invalid arguments (try 'help {self.prog}')") +class _ExclusiveGroup: + """Records a mutually exclusive group's arguments on the parser.""" + + def __init__(self, parser: "CommandParser", group: Any): + self._parser = parser + self._group = group + + def add_argument(self, *args, **kwargs) -> argparse.Action: + action = self._group.add_argument(*args, **kwargs) + self._parser.arguments.append(action) + return action + + def describe_action(action: argparse.Action) -> str: """Render one argument the way it is typed on the command line. diff --git a/picklock/commands/scan_commands.py b/picklock/commands/scan_commands.py index 4341ead..7e77598 100644 --- a/picklock/commands/scan_commands.py +++ b/picklock/commands/scan_commands.py @@ -38,7 +38,37 @@ _BATCH_BYTES = 64 * 1024 * 1024 #: Command-line comparison names, and the symbols people type instead. -_SCAN_OPS = { +#: Comparisons that take a value you supply. Flags, not a keyword sitting in +#: the same slot a value would: 'scan:next changed' could not tell a comparison +#: from a search for the word "changed", and no amount of documentation fixes +#: an ambiguity the grammar has. +_VALUE_FLAGS = ( + ("eq", "keep values equal to VALUE — the same as giving VALUE on its own"), + ("ne", "keep values different from VALUE"), + ("gt", "keep values greater than VALUE"), + ("lt", "keep values smaller than VALUE"), + ("ge", "keep values greater than or equal to VALUE"), + ("le", "keep values smaller than or equal to VALUE"), +) + +#: The two that take a pair. +_RANGE_FLAGS = ( + ("between", "keep values inside the range A..B, inclusive"), + ("not-between", "keep values outside the range A..B"), +) + +#: Comparisons only a refine can make, against the value the last scan read. +_REFINE_FLAGS = ( + ("changed", 0, "keep addresses whose value differs from the last reading"), + ("unchanged", 0, "keep addresses whose value equals the last reading"), + ("increased", 0, "keep addresses whose value grew since the last reading"), + ("decreased", 0, "keep addresses whose value shrank since the last reading"), + ("increased-by", 1, "keep addresses that grew by exactly VALUE"), + ("decreased-by", 1, "keep addresses that shrank by exactly VALUE"), +) + +#: The library scan type each value comparison maps to. +_SCAN_TYPE = { "eq": ScanTypesEnum.EXACT_VALUE, "ne": ScanTypesEnum.NOT_EXACT_VALUE, "gt": ScanTypesEnum.BIGGER_THAN, @@ -46,28 +76,60 @@ "ge": ScanTypesEnum.BIGGER_THAN_OR_EXACT_VALUE, "le": ScanTypesEnum.SMALLER_THAN_OR_EXACT_VALUE, } -_OP_SYMBOLS = { - "=": "eq", "==": "eq", "!=": "ne", "<>": "ne", - ">": "gt", "<": "lt", ">=": "ge", "<=": "le", -} - -#: Refine-only comparisons, which need the value recorded by the last scan. -_REFINE_OPS = ( - "changed", - "unchanged", - "increased", - "decreased", - "increased-by", - "decreased-by", - "between", -) _MAX_HELP = "stop after N hits, overriding the 'max_results' setting" -def _normalize_op(name: str) -> str: - key = name.strip().lower() - return _OP_SYMBOLS.get(key, key) +def _add_comparison_flags(parser: CommandParser, *, refine: bool) -> CommandParser: + """Give a scan the comparison flags, declared once for both of them.""" + group = parser.add_mutually_exclusive_group() + for name, help_text in _VALUE_FLAGS: + group.add_argument(f"--{name}", metavar="VALUE", default=None, help=help_text) + for name, help_text in _RANGE_FLAGS: + group.add_argument( + f"--{name}", nargs=2, metavar=("A", "B"), default=None, help=help_text + ) + if refine: + for name, arity, help_text in _REFINE_FLAGS: + if arity: + group.add_argument( + f"--{name}", metavar="VALUE", default=None, help=help_text + ) + else: + group.add_argument(f"--{name}", action="store_true", help=help_text) + return parser + + +def _comparison(options, positional, *, refine: bool) -> Tuple[str, List[str]]: + """Which comparison was asked for, and the words it was given. + + A bare value means equality, which is what a scan almost always is. Every + other comparison is named by its flag, so the value slot only ever holds a + value. + """ + names = [name for name, _ in _VALUE_FLAGS] + [name for name, _ in _RANGE_FLAGS] + if refine: + names += [name for name, _, _ in _REFINE_FLAGS] + + for name in names: + given = getattr(options, name.replace("-", "_")) + if given is None or given is False: + continue + if positional is not None: + raise CommandError( + f"Give a value or --{name}, not both — '--{name}' already says " + "what to compare." + ) + operands = [] if given is True else list(given) if isinstance(given, list) else [given] + return name, operands + + if positional is None: + listed = ", ".join(f"--{name}" for name, _ in _VALUE_FLAGS[:3]) + raise CommandError( + f"Nothing to compare against. Give a value, or one of {listed}, … " + "— the full list is in the help." + ) + return "eq", [positional] def _batch_regions( @@ -283,28 +345,9 @@ def _scan_parser() -> CommandParser: "value", nargs="?", default=None, - help="the value to search for; omit it when using --between", - ) - # Deliberately no `choices=`: argparse would reject the symbol spellings - # ('>' and friends) before _normalize_op ever sees them, and those are the - # ones people reach for first. The check below reports them itself. - parser.add_argument( - "--op", - default="eq", - metavar="OP", - help="comparison against the value: eq (default), ne, gt, lt, ge, le. " - "The symbols =, !=, >, <, >=, <= are accepted too", - ) - parser.add_argument( - "--between", - nargs=2, - metavar=("A", "B"), - default=None, - help="keep values inside the range A..B, inclusive", - ) - parser.add_argument( - "--outside", action="store_true", help="invert --between" + help="the value to search for; the same as --eq VALUE", ) + _add_comparison_flags(parser, refine=False) parser.add_argument( "--writable", action="store_true", @@ -334,8 +377,10 @@ def _scan_parser() -> CommandParser: details=( "The first scan of a cycle. Every matching address is kept as the " "result set that 'scan:next', 'scan:results' and the '#N' address form " - "work " - "on.\n\n" + "work on.\n\n" + "A bare value means equality, which is what a scan almost always is; " + "every other comparison is named by its flag, so the value slot only " + "ever holds a value.\n\n" "Ctrl+C stops a scan and keeps what it had already found." ), examples=( @@ -343,7 +388,7 @@ def _scan_parser() -> CommandParser: "scan:value float 99.5 --writable", "scan:value int32 --between 100 200", "scan:value string Picklock", - "scan:value int32 1000 --op gt", + "scan:value int32 --gt 1000", ), ) def cmd_scan(session: Session, args: List[str]) -> None: @@ -351,6 +396,7 @@ def cmd_scan(session: Session, args: List[str]) -> None: process = session.require_process("scan:value") value_type = valuetypes.resolve(options.type) + comparison, operands = _comparison(options, options.value, refine=False) if options.writable and options.all_regions: raise CommandError("--writable and --all-regions contradict each other.") @@ -366,18 +412,17 @@ def cmd_scan(session: Session, args: List[str]) -> None: # since the last one, and a stale snapshot would silently skip new regions. session.regions(refresh=True) - if options.between is not None: - if options.value is not None: - raise CommandError("Give a value or --between, not both.") - start = value_type.parse(options.between[0]) - end = value_type.parse(options.between[1]) + if comparison in ("between", "not-between"): + start = value_type.parse(operands[0]) + end = value_type.parse(operands[1]) width = max( value_type.width_for(start, options.length), value_type.width_for(end, options.length), ) + outside = comparison == "not-between" description = ( - f"{value_type.name} {'outside' if options.outside else 'between'} " - f"{options.between[0]} and {options.between[1]}" + f"{value_type.name} {'outside' if outside else 'between'} " + f"{operands[0]} and {operands[1]}" ) def search(batch: List[MemoryRegion]) -> Iterable[Any]: @@ -386,25 +431,16 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: width, value_type.encode(start), value_type.encode(end), - not_between=options.outside, + not_between=outside, writeable_only=writable_only, memory_regions=batch, ) else: - if options.value is None: - raise CommandError("scan needs a value, or --between A B.") - operation = _normalize_op(options.op) - if operation not in _SCAN_OPS: - raise CommandError( - f"Unknown comparison {options.op!r}. Use one of: " - + ", ".join(_SCAN_OPS) - + "." - ) - value = value_type.parse(options.value) + value = value_type.parse(operands[0]) width = value_type.width_for(value, options.length) - scan_type = _SCAN_OPS[operation] - description = f"{value_type.name} {operation} {options.value}" + scan_type = _SCAN_TYPE[comparison] + description = f"{value_type.name} {comparison} {operands[0]}" def search(batch: List[MemoryRegion]) -> Iterable[Any]: return process.search_by_value( @@ -433,21 +469,12 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: def _next_parser() -> CommandParser: parser = CommandParser("scan:next") parser.add_argument( - "op", + "value", nargs="?", default=None, - help="the comparison to apply: eq, ne, gt, lt, ge, le, between, " - "changed, unchanged, increased, decreased, increased-by, " - "decreased-by. Omit it to mean eq", - ) - parser.add_argument( - "value", - nargs="*", - default=[], - help="the value the comparison needs — two for 'between', one for the " - "six ordinary comparisons and the *-by pair, none for the rest", + help="the value to keep; the same as --eq VALUE", ) - return parser + return _add_comparison_flags(parser, refine=True) @command( @@ -456,20 +483,23 @@ def _next_parser() -> CommandParser: summary="Narrow the results with another comparison.", details=( "Re-reads every address in the result set and keeps the ones that " - "still match. Bare 'scan:next 100' means 'scan:next eq 100'.\n\n" - "Comparisons against a value you supply:\n\n" - " eq ne gt lt ge le VALUE the usual six\n" - " between A B inside the range, inclusive\n" - " increased-by N grew by exactly N since the last scan\n" - " decreased-by N shrank by exactly N since the last scan\n\n" - "Comparisons against the previous scan, for when you do not know the " - "value — the health bar moved, but to what?\n\n" - " changed / unchanged differs from / equals the last reading\n" - " increased / decreased moved in that direction\n\n" + "still match. A bare value means equality: 'scan:next 95' keeps the " + "addresses now holding 95.\n\n" + "The comparisons that need no value of their own are for when you " + "cannot see it — a health bar with no number. They measure each " + "address against what the last scan read there, so making the value " + "move in the target and then asking for '--decreased' narrows the set " + "without you ever knowing the number.\n\n" "Addresses that have become unreadable (the target freed them) are " "dropped." ), - examples=("scan:next 95", "scan:next changed", "scan:next decreased", "scan:next gt 50", "scan:next between 10 20"), + examples=( + "scan:next 95", + "scan:next --changed", + "scan:next --decreased", + "scan:next --gt 50", + "scan:next --between 10 20", + ), ) def cmd_next(session: Session, args: List[str]) -> None: options = _next_parser().parse_args(args) @@ -477,37 +507,15 @@ def cmd_next(session: Session, args: List[str]) -> None: state = session.require_scan() session.require_process("scan:next") - operation = _normalize_op(options.op) if options.op else "eq" - operands = list(options.value) - - # "next 100" — no operation word, just a value. Recognised by the first - # word not naming a comparison, which is unambiguous: no comparison name - # is also a valid value spelling. - if operation not in _SCAN_OPS and operation not in _REFINE_OPS: - if options.op is None: - raise CommandError("next needs a comparison or a value.") - operands.insert(0, options.op) - operation = "eq" - + comparison, operands = _comparison(options, options.value, refine=True) value_type = state.value_type - needs_value = operation in _SCAN_OPS or operation in ( - "increased-by", - "decreased-by", - ) - if operation == "between": - if len(operands) != 2: - raise CommandError( - "'scan:next between' takes two values: scan:next between A B." - ) + low = high = target = None + if comparison in ("between", "not-between"): low = value_type.parse(operands[0]) high = value_type.parse(operands[1]) - elif needs_value: - if len(operands) != 1: - raise CommandError(f"'scan:next {operation}' takes exactly one value.") - target = value_type.parse(operands[0]) elif operands: - raise CommandError(f"'scan:next {operation}' takes no value.") + target = value_type.parse(operands[0]) with Timer() as timer: current = _read_values(session, value_type, state.width, state.addresses) @@ -519,29 +527,31 @@ def cmd_next(session: Session, args: List[str]) -> None: if now is None: continue # The address is gone; it cannot match anything. try: - if operation == "eq": + if comparison == "eq": keep = now == target - elif operation == "ne": + elif comparison == "ne": keep = now != target - elif operation == "gt": + elif comparison == "gt": keep = now > target - elif operation == "lt": + elif comparison == "lt": keep = now < target - elif operation == "ge": + elif comparison == "ge": keep = now >= target - elif operation == "le": + elif comparison == "le": keep = now <= target - elif operation == "between": + elif comparison == "between": keep = low <= now <= high - elif operation == "changed": + elif comparison == "not-between": + keep = not (low <= now <= high) + elif comparison == "changed": keep = now != previous - elif operation == "unchanged": + elif comparison == "unchanged": keep = now == previous - elif operation == "increased": + elif comparison == "increased": keep = previous is not None and now > previous - elif operation == "decreased": + elif comparison == "decreased": keep = previous is not None and now < previous - elif operation == "increased-by": + elif comparison == "increased-by": keep = previous is not None and now == previous + target else: # decreased-by keep = previous is not None and now == previous - target @@ -555,11 +565,9 @@ def cmd_next(session: Session, args: List[str]) -> None: kept_addresses.append(address) kept_values.append(now) - description = f"{state.description} → {operation}" - if operation == "between": - description += f" {operands[0]} {operands[1]}" - elif needs_value: - description += f" {operands[0]}" + description = f"{state.description} → {comparison}" + if operands: + description += " " + " ".join(operands) new_state = session.store_scan( value_type, diff --git a/picklock/commands/session_commands.py b/picklock/commands/session_commands.py index a74f9c5..e806e52 100644 --- a/picklock/commands/session_commands.py +++ b/picklock/commands/session_commands.py @@ -61,8 +61,15 @@ Repeat step 3 until a handful of rows remain. When you cannot see the value — a health bar with no number — compare against the previous reading instead: - scan:next changed / scan:next unchanged - scan:next increased / scan:next decreased + scan:next --changed / scan:next --unchanged + scan:next --increased / scan:next --decreased + +Every comparison is a flag, so the value slot only ever holds a value: +'scan:next changed' looks for the word "changed", and '--changed' is the +comparison. The rest take a value of their own: + + scan:next --gt 50 / scan:next --between 10 20 + scan:next --increased-by 5 'scan:results' is how you look at where you are between rounds. It re-reads every address, so VALUE is what the target holds now rather than what the scan diff --git a/tests/test_commands.py b/tests/test_commands.py index e4b3900..39ef843 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -743,3 +743,99 @@ def test_the_scanning_topic_covers_the_whole_cycle(shell, capture): ): assert command in out, f"the walkthrough never mentions {command}" assert "--page" in out, "paging is how you reach past the first page" + + +# -- comparisons are flags, never keywords in a value slot ---------------- + + +def test_a_value_that_spells_a_comparison_is_still_a_value(shell, capture): + """The whole reason the comparisons became flags. + + 'scan:next changed' used to be a comparison, so nobody could narrow a + string scan down to the word "changed". + """ + from picklock import valuetypes + + shell.session.store_scan( + valuetypes.resolve("string"), 7, [0x10, 0x20], ["changed", "other"], "t" + ) + parsed = shell.split("scan:next changed") + options = lookup("scan:next").parser().parse_args(parsed[1]) + assert options.value == "changed" + assert options.changed is False + + +@pytest.mark.parametrize("name", ["scan:value", "scan:next"]) +def test_no_positional_names_a_comparison(name): + """A slot that holds either a keyword or data cannot tell them apart.""" + for action in lookup(name).arguments(): + if action.option_strings: + continue + assert action.dest not in ("op", "operator", "comparison") + + +@pytest.mark.parametrize("name", ["scan:value", "scan:next"]) +def test_both_scans_offer_the_same_value_comparisons(name): + """Declared once for both, so they cannot drift apart.""" + flags = { + flag for action in lookup(name).arguments() for flag in action.option_strings + } + assert {"--eq", "--ne", "--gt", "--lt", "--ge", "--le"} <= flags + assert {"--between", "--not-between"} <= flags + + +def test_only_a_refine_offers_the_previous_value_comparisons(): + """They compare against the last reading, which a first scan does not have.""" + def flags_of(name): + return { + flag + for action in lookup(name).arguments() + for flag in action.option_strings + } + + refine_only = {"--changed", "--unchanged", "--increased", "--decreased"} + assert refine_only <= flags_of("scan:next") + assert not (refine_only & flags_of("scan:value")) + + +def test_two_comparisons_at_once_are_refused(shell): + with pytest.raises(CommandError): + shell.run_line("scan:next --changed --increased", raise_errors=True) + + +def _ready_to_refine(shell): + """A session with results and a target, stopping short of reading memory. + + scan:next checks for both before it looks at its arguments, so a test about + the arguments has to get past them. + """ + from picklock import valuetypes + + class FakeProcess: + pointer_size = 8 + + shell.session.process = FakeProcess() + shell.session.store_scan(valuetypes.resolve("int32"), 4, [0x10], [1], "t") + + +def test_a_value_and_a_comparison_at_once_are_refused(shell, capture): + _ready_to_refine(shell) + assert shell.run_line("scan:next 95 --gt 50") is False + assert "not both" in capture.err + + +def test_a_refine_with_nothing_to_compare_says_so(shell, capture): + _ready_to_refine(shell) + assert shell.run_line("scan:next") is False + assert "Nothing to compare against" in capture.err + + +def test_a_grouped_flag_still_reaches_the_help(shell, capture): + """argparse gives a mutually exclusive group its own add_argument. + + Left to itself that bypasses the parser's record of its arguments, and a + whole set of flags would exist while being documented nowhere. + """ + shell.run_line("help scan:next") + for flag in ("--changed", "--between", "--increased-by"): + assert flag in capture.out diff --git a/tests/test_shell.py b/tests/test_shell.py index 86a4109..bf2103d 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -118,7 +118,7 @@ def test_help_topics_are_reachable(shell, capture): assert "module+offset" in capture.out capture.reset() shell.run_line("help scanning") - assert "next changed" in capture.out + assert "scan:next --changed" in capture.out def test_source_runs_a_file(shell, capture, tmp_path): From 644e9e24230e1db9cf0ebb10983d141fc0f2d21b Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 22:45:26 -0300 Subject: [PATCH 35/82] refactor(config): config:list always shows the whole table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The optional name filtered a list of eight rows that fits on a screen — filtering something you can already see, and a second way to read a setting next to the one that shows all of them. 'config:set' without a value now points at plain 'config:list' rather than at 'config:list ', which no longer exists. --- picklock/commands/__init__.py | 8 ++++++-- picklock/commands/session_commands.py | 27 ++++++--------------------- tests/test_commands.py | 21 +++++++++++++++------ 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/picklock/commands/__init__.py b/picklock/commands/__init__.py index e7718d5..e4241ac 100644 --- a/picklock/commands/__init__.py +++ b/picklock/commands/__init__.py @@ -105,9 +105,13 @@ class Namespace: "picklock> config:set writable_only on\n" "writable_only = on\n" "\n" - "picklock> config:list limit\n" + "picklock> config:list\n" "\n" - "limit: 20", + "+---------+-------+------------------------------------+\n" + "| SETTING | VALUE | DESCRIPTION |\n" + "+---------+-------+------------------------------------+\n" + "| limit | 20 | Rows printed per result table ... |\n" + "+---------+-------+------------------------------------+", ), Namespace( "pointer", diff --git a/picklock/commands/session_commands.py b/picklock/commands/session_commands.py index e806e52..78f63c3 100644 --- a/picklock/commands/session_commands.py +++ b/picklock/commands/session_commands.py @@ -485,14 +485,7 @@ def command_words_set() -> set: def _config_list_parser() -> CommandParser: - parser = CommandParser("config:list") - parser.add_argument( - "name", - nargs="?", - default=None, - help="show just this one setting; omit it for all of them", - ) - return parser + return CommandParser("config:list") @command( @@ -500,26 +493,18 @@ def _config_list_parser() -> CommandParser: parser=_config_list_parser, summary="Show the settings and their current values.", details=( + "Takes no arguments — the whole table, every time. There are eight of " + "them and they fit on a screen, so picking one out would be a filter " + "for a list that does not need filtering.\n\n" "A change is remembered between runs, so the shell comes back the way " "you left it. Only what you changed is stored, so a default that moves " "in a later release still reaches you — and 'config:reset' puts one " "back, which restarting no longer does.\n\n" "The path is printed under the table." ), - examples=("config:list", "config:list limit"), ) def cmd_config_list(session: Session, args: List[str]) -> None: - options = _config_list_parser().parse_args(args) - - if options.name is not None: - setting = _find_setting(options.name) - session.printer.write( - render_vertical( - [(setting.name, _format_setting(session.option(setting.name)))] - ) - ) - session.printer.write() - return + _config_list_parser().parse_args(args) rows = [ (setting.name, _format_setting(session.option(setting.name)), setting.summary) @@ -567,7 +552,7 @@ def cmd_config_set(session: Session, args: List[str]) -> None: if "=" not in name: raise CommandError( f"config:set needs a value: 'config:set {name} '. " - f"To read one back, use 'config:list {name}'." + f"To see what {name} is now, use 'config:list'." ) name, _, value = name.partition("=") diff --git a/tests/test_commands.py b/tests/test_commands.py index 39ef843..9101719 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -613,18 +613,27 @@ def test_the_help_only_ever_advertises_one_spelling(shell, capture): assert '"help ' in hint, f"{line!r} advertises something else: {hint!r}" -def test_config_reads_one_setting_back(shell, capture): - shell.run_line("config:list limit") - assert "limit: 20" in capture.out +def test_config_list_always_shows_everything(shell, capture): + """Eight settings fit on a screen; filtering a list that short is noise.""" + from picklock.session import SETTINGS + + shell.run_line("config:list") + for setting in SETTINGS: + assert setting.name in capture.out + + +def test_config_list_takes_no_arguments(shell, capture): + assert shell.run_line("config:list limit") is False + assert "unrecognized arguments" in capture.err def test_config_set_without_a_value_points_at_the_reader(shell, capture): - """Setting and reading are different commands now; say which is which.""" + """Setting and reading are different commands; say which is which.""" assert shell.run_line("config:set limit") is False - assert "config:list limit" in capture.err + assert "config:list" in capture.err -@pytest.mark.parametrize("line", ["config:list nosuch", "config:set nosuch on"]) +@pytest.mark.parametrize("line", ["config:set nosuch on", "config:reset nosuch"]) def test_an_unknown_setting_lists_the_real_ones(shell, capture, line): assert shell.run_line(line) is False assert "Unknown setting" in capture.err From 28e874a9fdac64a5bfebb46842c220c4089ea3cd Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 23:05:09 -0300 Subject: [PATCH 36/82] test: drive every command end-to-end against a real process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite covered parsing, dispatch and help but almost none of what the commands actually do: nineteen of them had under a quarter of their body ever executed, scan:next had fifty-two lines never run once. Everything past require_process was verified by hand and taken on trust. tests/test_end_to_end.py attaches to the test process itself — the trick PyMemoryEditor's own suite uses, needing no privileges and no second program — puts known values in memory with ctypes, types each command, and asserts on the table that comes back. It tests the command, not the library: the type and width worked out, the address expression resolved, the right call made, the right thing printed. Coverage of the command bodies: nought commands under 25% where there were nineteen, twenty at or above 90%. Overall 60% to 89%, and the CI floor moves from 55 to 80. The scans walk a live address space, so they are marked `slow` — the fast half still runs in two seconds, the whole thing in forty. The fixture skips itself if a platform refuses to open its own process, so a hostile runner degrades rather than fails. It found a real bug immediately. The address parser split module names on hyphens, so '_ssl.cpython-311-darwin.so+0' resolved as three subtractions — and a Python target is full of names like that. Tokens now carry their source text and whether a space preceded them, and the parser rejoins the pieces while they are written without spaces *and* the result names a loaded module. So the hyphenated name works and 'game.exe-0x10' is still a subtraction. --- .github/workflows/python-package.yml | 13 +- CONTRIBUTING.md | 25 +- README.md | 5 +- picklock/addressing.py | 116 ++++++-- picklock/session.py | 12 + pyproject.toml | 8 + tests/test_end_to_end.py | 393 +++++++++++++++++++++++++++ 7 files changed, 529 insertions(+), 43 deletions(-) create mode 100644 tests/test_end_to_end.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 479afd2..8d5cd26 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -71,13 +71,16 @@ jobs: python -m pip install --upgrade pip pip install -e ".[dev]" - name: Run tests + # The whole suite, end-to-end tests included: those attach to the test + # process itself, so they need no privileges and no second program, and + # they skip themselves on a runner that refuses even that. + # # --cov-fail-under is the real, in-repo coverage gate: deterministic and - # independent of any external service. It is a conservative floor — the - # suite measures ~60% today, and the gap is almost entirely command - # bodies that need a live target the suite deliberately never attaches - # to. Ratchet it up as fake-target coverage grows. + # independent of any external service. The suite measures ~89%; 80 is a + # floor with room for per-platform variance (the three backends, and the + # allocation commands that only exist on two of them). run: | - pytest -q --cov=picklock --cov-report=term --cov-report=xml --cov-fail-under=55 + pytest -q --cov=picklock --cov-report=term --cov-report=xml --cov-fail-under=80 - name: Upload coverage to Codecov # Informational only — see codecov.yml — so a flaky upload never blocks # the merge; the hard gate is --cov-fail-under above. Runs even when the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2af6a3c..00e07b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,17 +23,26 @@ parentheses if you would rather run it directly. ```bash make test # pytest tests -v +pytest -m "not slow" # ~2 s, skipping the scans ``` -The suite never attaches to a process. It covers parsing, formatting, dispatch -and help — the parts that are Picklock's own — and leaves reading another -process's memory to PyMemoryEditor's tests. That is deliberate: it means the -suite runs identically on any machine, including CI runners where opening a -second process is not permitted. +The suite has two halves. -The consequence is that a change to a command *body* is not covered by the -suite. Exercise it by hand against a real target and say so in the PR — a -transcript of the session is the ideal evidence. +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 are marked `slow` and add about +thirty seconds. Run them before pushing anything that touches a command body — +`make test` includes them, and CI runs the lot. 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 diff --git a/README.md b/README.md index 1eadd6f..8f81e6c 100644 --- a/README.md +++ b/README.md @@ -316,8 +316,9 @@ make install-dev # pip install -e ".[dev]" make pre-commit # lint + type-check + tests ``` -`make help` lists every target. The test suite never attaches to another -process, so it runs anywhere — including CI runners that would refuse. +`make help` lists every target. Every command is covered end-to-end against a +real process — the test process itself, so the suite needs no privileges and +no second program to launch. [**CONTRIBUTING.md**](CONTRIBUTING.md) covers the project layout, the two rules that keep its shape, and how to add a command (it is one decorator, and `help` diff --git a/picklock/addressing.py b/picklock/addressing.py index 5346a3d..23db266 100644 --- a/picklock/addressing.py +++ b/picklock/addressing.py @@ -20,7 +20,7 @@ has stopped being a convenience. """ -from typing import TYPE_CHECKING, Any, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, List, NamedTuple, Optional from .errors import CommandError @@ -33,36 +33,59 @@ _NUMBER, _IDENT, _RESULT, _OPEN, _CLOSE, _PLUS, _MINUS = range(7) -def _tokenize(text: str) -> List[Tuple[int, Any]]: - tokens: List[Tuple[int, Any]] = [] +class _Token(NamedTuple): + """One lexeme, with what it looked like and whether a space came first. + + The source text and the spacing are what let a module name containing a + hyphen be told from a subtraction: ``_ssl.cpython-311-darwin.so`` is one + name, ``game.exe - 0x10`` is a sum. + """ + + kind: int + value: Any + source: str + spaced_before: bool + + +def _tokenize(text: str) -> List["_Token"]: + tokens: List[_Token] = [] index = 0 length = len(text) + spaced = False + + def emit(kind: int, value: Any, source: str) -> None: + tokens.append(_Token(kind, value, source, spaced)) while index < length: char = text[index] if char.isspace(): index += 1 + spaced = True continue if char == "[": - tokens.append((_OPEN, "[")) + emit(_OPEN, "[", "[") index += 1 + spaced = False continue if char == "]": - tokens.append((_CLOSE, "]")) + emit(_CLOSE, "]", "]") index += 1 + spaced = False continue if char == "+": - tokens.append((_PLUS, "+")) + emit(_PLUS, "+", "+") index += 1 + spaced = False continue if char == "-": - tokens.append((_MINUS, "-")) + emit(_MINUS, "-", "-") index += 1 + spaced = False continue if char == "#": @@ -72,15 +95,17 @@ def _tokenize(text: str) -> List[Tuple[int, Any]]: index += 1 if start == index: raise CommandError("'#' must be followed by a result number, e.g. #3.") - tokens.append((_RESULT, int(text[start:index]))) + emit(_RESULT, int(text[start:index]), text[start - 1 : index]) + spaced = False continue if char in ("'", '"'): end = text.find(char, index + 1) if end == -1: raise CommandError(f"Unterminated {char} in address expression.") - tokens.append((_IDENT, text[index + 1 : end])) + emit(_IDENT, text[index + 1 : end], text[index : end + 1]) index = end + 1 + spaced = False continue if char.lower() == "0" and text[index : index + 2].lower() == "0x": @@ -89,7 +114,8 @@ def _tokenize(text: str) -> List[Tuple[int, Any]]: while index < length and text[index] in "0123456789abcdefABCDEF_": index += 1 try: - tokens.append((_NUMBER, int(text[start:index].replace("_", ""), 16))) + emit(_NUMBER, int(text[start:index].replace("_", ""), 16), text[start:index]) + spaced = False except ValueError: raise CommandError(f"{text[start:index]!r} is not a hex number.") continue @@ -102,9 +128,10 @@ def _tokenize(text: str) -> List[Tuple[int, Any]]: # A run of digits is a decimal literal; anything else (including # "game.exe" and "libc.so.6") is a module name. if word.replace("_", "").isdigit(): - tokens.append((_NUMBER, int(word.replace("_", "")))) + emit(_NUMBER, int(word.replace("_", "")), word) else: - tokens.append((_IDENT, word)) + emit(_IDENT, word, word) + spaced = False continue raise CommandError(f"Unexpected character {char!r} in address expression.") @@ -115,17 +142,18 @@ def _tokenize(text: str) -> List[Tuple[int, Any]]: class _Parser: """Recursive-descent parser over the token list. One expression per call.""" - def __init__(self, tokens: List[Tuple[int, Any]], session: "Session"): + def __init__(self, tokens: List[_Token], session: "Session"): self.tokens = tokens self.session = session self.position = 0 - def peek(self) -> Optional[Tuple[int, Any]]: - if self.position < len(self.tokens): - return self.tokens[self.position] + def peek(self, ahead: int = 0) -> Optional[_Token]: + index = self.position + ahead + if index < len(self.tokens): + return self.tokens[index] return None - def next(self) -> Tuple[int, Any]: + def next(self) -> _Token: token = self.peek() if token is None: raise CommandError("Unexpected end of address expression.") @@ -136,33 +164,65 @@ def parse_expression(self) -> int: value = self.parse_term() while True: token = self.peek() - if token is None or token[0] not in (_PLUS, _MINUS): + if token is None or token.kind not in (_PLUS, _MINUS): return value self.position += 1 operand = self.parse_term() - value = value + operand if token[0] == _PLUS else value - operand + value = value + operand if token.kind == _PLUS else value - operand def parse_term(self) -> int: - kind, value = self.next() + token = self.next() - if kind == _NUMBER: - return int(value) + if token.kind == _NUMBER: + return int(token.value) - if kind == _RESULT: - return self.session.result_address(int(value)) + if token.kind == _RESULT: + return self.session.result_address(int(token.value)) - if kind == _IDENT: - return self.session.module_base(str(value)) + if token.kind == _IDENT: + return self.session.module_base(self._module_name(str(token.value))) - if kind == _OPEN: + if token.kind == _OPEN: inner = self.parse_expression() closing = self.next() - if closing[0] != _CLOSE: + if closing.kind != _CLOSE: raise CommandError("Missing ']' in address expression.") return self.session.read_pointer(inner) raise CommandError("Expected an address, a module name or '[' here.") + def _module_name(self, name: str) -> str: + """Rejoin a module name the tokenizer split on a hyphen. + + A hyphen is a subtraction sign and also a perfectly ordinary character + in a library's name — ``_ssl.cpython-311-darwin.so`` is what every + Python process is full of. The two are told apart by asking: the pieces + are rejoined only while they are written without spaces *and* the + result names a module that is actually loaded. So + ``game.exe-0x10`` stays a subtraction, because no such module exists, + and ``game.exe - 0x10`` never even gets here. + """ + best, consumed = name, 0 + candidate = name + ahead = 0 + + while True: + minus, part = self.peek(ahead), self.peek(ahead + 1) + if minus is None or part is None: + break + if minus.kind != _MINUS or minus.spaced_before or part.spaced_before: + break + if part.kind not in (_IDENT, _NUMBER): + break + + candidate += "-" + part.source + ahead += 2 + if self.session.knows_module(candidate): + best, consumed = candidate, ahead + + self.position += consumed + return best + def parse_address(text: str, session: "Session") -> int: """Evaluate an address expression against ``session``. diff --git a/picklock/session.py b/picklock/session.py index 3f49b28..47f632c 100644 --- a/picklock/session.py +++ b/picklock/session.py @@ -301,6 +301,18 @@ def expand_alias(self, word: str, args: Sequence[str]) -> Tuple[str, List[str]]: # -- hooks used by the address expression parser ---------------------- + def knows_module(self, name: str) -> bool: + """True when ``name`` resolves to a loaded module. + + Asked by the address parser, which offers it several readings of a + hyphenated word and keeps the one the target recognises. + """ + try: + self.module_base(name) + except CommandError: + return False + return True + def module_base(self, name: str) -> int: """Base address of a loaded module, matched by name then by prefix.""" self.require_process() diff --git a/pyproject.toml b/pyproject.toml index d131766..4221dfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,14 @@ warn_unused_ignores = true [tool.pytest.ini_options] testpaths = ["tests"] +# The end-to-end tests attach to the test process itself and scan its address +# space, which costs seconds rather than milliseconds. Everything else runs in +# about two: +# pytest -m "not slow" # the fast suite +# pytest # including the scans +markers = [ + "slow: end-to-end tests that scan the whole address space of a live process", +] [tool.coverage.run] source = ["picklock"] diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py new file mode 100644 index 0000000..7fc2132 --- /dev/null +++ b/tests/test_end_to_end.py @@ -0,0 +1,393 @@ +# -*- coding: utf-8 -*- + +""" +Every command, driven from the typed line against a real process. + +The target is the test process itself — the same trick PyMemoryEditor's own +suite uses, and the reason these run anywhere without privileges or a second +program to launch. + +What is being tested is the *command*, not the library underneath it: that the +line parses, the address expression resolves, the type and width are worked +out, the right library call is made, and the table that comes back says what it +should. A test here fails when Picklock is wrong, not when PyMemoryEditor is. +""" + +import ctypes +import os +import re + +import pytest + +#: A value unlikely to be lying around in a Python process, so a scan for it +#: finds the block below and little else. +MARKER = 0x5C0FFEE1 + +#: The scans walk a real address space, which takes about a second each. Run +#: the fast suite with `pytest -m "not slow"`. +slow = pytest.mark.slow + + +class Block: + """A live chunk of this process's memory, with known contents. + + Held by a fixture for the duration of a test so the addresses stay valid — + a ctypes buffer that goes out of scope is freed, and the scan would then be + hunting a page that no longer exists. + """ + + def __init__(self) -> None: + self.ints = (ctypes.c_int32 * 4)(MARKER, MARKER, 0, 0) + self.text = ctypes.create_string_buffer(b"PicklockMarker42\x00") + self.blob = (ctypes.c_ubyte * 8)(0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE) + # A pointer to the ints, so a chain has something real to walk. + self.cell = (ctypes.c_void_p * 1)(ctypes.addressof(self.ints)) + + @property + def ints_at(self) -> int: + return ctypes.addressof(self.ints) + + @property + def text_at(self) -> int: + return ctypes.addressof(self.text) + + @property + def blob_at(self) -> int: + return ctypes.addressof(self.blob) + + @property + def cell_at(self) -> int: + return ctypes.addressof(self.cell) + + +@pytest.fixture +def block() -> Block: + return Block() + + +@pytest.fixture +def target(shell, capture): + """A shell attached to this very process.""" + if not shell.run_line(f"ps:open {os.getpid()}"): + pytest.skip(f"cannot open this process: {capture.err.strip()}") + capture.reset() + yield shell + shell.run_line("ps:close") + + +def run(shell, capture, line: str) -> str: + """Type one line and hand back what the user would have seen.""" + capture.reset() + ok = shell.run_line(line) + assert ok, f"{line!r} failed: {capture.err.strip()}" + return capture.out + + +# -- ps ------------------------------------------------------------------ + + +def test_ps_list_finds_this_process(target, capture): + out = run(target, capture, f"ps:list {os.getpid()}") + assert str(os.getpid()) in out + + +def test_ps_open_reports_what_it_attached_to(shell, capture): + assert shell.run_line(f"ps:open {os.getpid()}") + assert f"PID {os.getpid()}" in capture.out + assert "64-bit" in capture.out or "32-bit" in capture.out + shell.run_line("ps:close") + + +def test_ps_info_describes_the_target(target, capture): + out = run(target, capture, "ps:info") + assert str(os.getpid()) in out + assert "Pointer size" in out + assert "Regions" in out + + +def test_ps_close_detaches(target, capture): + out = run(target, capture, "ps:close") + assert "Detached" in out + assert target.session.process is None + target.run_line(f"ps:open {os.getpid()}") # so the fixture can close it + + +# -- memory -------------------------------------------------------------- + + +def test_memory_read_returns_what_is_there(target, capture, block): + out = run(target, capture, f"memory:read 0x{block.ints_at:X} int32") + assert str(MARKER) in out + + +def test_memory_read_counts_forward(target, capture, block): + out = run(target, capture, f"memory:read 0x{block.ints_at:X} int32 --count 4") + assert out.count(str(MARKER)) == 2, "the first two hold the marker" + + +def test_memory_read_in_hex(target, capture, block): + out = run(target, capture, f"memory:read 0x{block.ints_at:X} int32 --hex") + assert f"0x{MARKER:X}" in out + + +def test_memory_read_a_string(target, capture, block): + out = run(target, capture, f"memory:read 0x{block.text_at:X} string 16") + assert "PicklockMarker42" in out + + +def test_memory_read_bytes(target, capture, block): + out = run(target, capture, f"memory:read 0x{block.blob_at:X} bytes 4") + assert "DE AD BE EF" in out + + +def test_memory_write_changes_the_process(target, capture, block): + run(target, capture, f"memory:write 0x{block.ints_at:X} int32 4242") + assert block.ints[0] == 4242, "the write reached this process's memory" + assert "4242" in run(target, capture, f"memory:read 0x{block.ints_at:X} int32") + + +def test_memory_write_a_string(target, capture, block): + run(target, capture, f"memory:write 0x{block.text_at:X} string Picklock!") + assert block.text.value.startswith(b"Picklock!") + + +def test_memory_write_bytes(target, capture, block): + run(target, capture, f"memory:write 0x{block.blob_at:X} bytes '01 02 03 04'") + assert list(block.blob[:4]) == [1, 2, 3, 4] + + +def test_memory_dump_shows_hex_and_ascii(target, capture, block): + out = run(target, capture, f"memory:dump 0x{block.text_at:X} 16") + assert "PicklockMarker42" in out, "the ASCII column" + assert "50 69 63 6B" in out, "the hex column ('Pick')" + + +def test_memory_watch_samples_the_value(target, capture, block): + out = run( + target, + capture, + f"memory:watch 0x{block.ints_at:X} int32 --count 2 --interval 0.01 --all", + ) + assert out.count(str(MARKER)) >= 2 + assert "2 sample(s)" in out + + +def test_memory_regions_lists_the_map(target, capture): + out = run(target, capture, "memory:regions --limit 5") + assert "PERMS" in out and "rw" in out + + +def test_memory_regions_finds_the_one_holding_an_address(target, capture, block): + out = run(target, capture, f"memory:regions --at 0x{block.ints_at:X}") + assert "1 row in set" in out + + +def test_memory_modules_lists_loaded_modules(target, capture): + out = run(target, capture, "memory:modules --limit 5") + assert "BASE" in out + assert re.search(r"0x[0-9A-F]{8,}", out), "a real base address" + + +def test_memory_threads_lists_at_least_this_one(target, capture): + out = run(target, capture, "memory:threads --limit 5") + assert "TID" in out + assert "Empty set" not in out + + +def test_a_module_name_resolves_in_an_address(target, capture): + """'module+offset' has to reach the real base, not merely parse. + + The module is taken from the target's own list rather than guessed, so the + test says the same thing on every platform. + """ + run(target, capture, "memory:modules") # refreshes the module table + modules = target.session.modules() + name, base = next(iter(sorted(modules.items()))) + + out = run(target, capture, f"memory:read {name}+0 bytes 4") + assert f"{base:016X}" in out.replace("0x", ""), "landed on the module base" + + +@pytest.mark.skipif( + __import__("sys").platform.startswith("linux"), + reason="Linux has no cross-process allocation syscall", +) +def test_memory_alloc_and_free_round_trip(target, capture): + out = run(target, capture, "memory:alloc 4096") + address = re.search(r"at (0x[0-9A-F]+)", out).group(1) + + run(target, capture, f"memory:write {address} int32 1234") + assert "1234" in run(target, capture, f"memory:read {address} int32") + + assert "Freed" in run(target, capture, f"memory:free {address}") + assert target.run_line(f"memory:read {address} int32") is False, "gone" + + +# -- scan ---------------------------------------------------------------- + + +@slow +def test_scan_finds_the_marker(target, capture, block): + out = run(target, capture, f"scan:value int32 {MARKER} --writable") + assert "Empty set" not in out + assert block.ints_at in target.session.scan.addresses + + +@slow +def test_scan_then_refine_by_value(target, capture, block): + run(target, capture, f"scan:value int32 {MARKER} --writable") + block.ints[0] = MARKER + 1 + block.ints[1] = MARKER + 1 + + run(target, capture, f"scan:next {MARKER + 1}") + assert block.ints_at in target.session.scan.addresses + + +@slow +def test_scan_then_refine_against_the_previous_reading(target, capture, block): + """'--increased' with no value at all — the reason the flag exists.""" + run(target, capture, f"scan:value int32 {MARKER} --writable") + before = len(target.session.scan) + block.ints[0] = MARKER + 100 + block.ints[1] = MARKER - 100 + + run(target, capture, "scan:next --increased") + kept = target.session.scan.addresses + assert block.ints_at in kept, "the one that grew survived" + assert block.ints_at + 4 not in kept, "the one that shrank did not" + # Not an exact list: this is a live interpreter, and other counters of its + # own move between the two readings. + assert len(kept) < before + + +@slow +def test_scan_a_string(target, capture, block): + out = run(target, capture, "scan:value string PicklockMarker42 --max 20") + assert "Empty set" not in out + assert block.text_at in target.session.scan.addresses + + +@slow +def test_scan_a_range(target, capture, block): + run( + target, + capture, + f"scan:value int32 --between {MARKER - 1} {MARKER + 1} --writable --max 50", + ) + assert block.ints_at in target.session.scan.addresses + + +@slow +def test_aob_finds_the_signature(target, capture, block): + out = run(target, capture, 'scan:aob "DE AD BE EF ? ? BA BE" --max 20') + assert "Empty set" not in out + assert block.blob_at in target.session.scan.addresses + + +@slow +def test_regex_finds_the_text(target, capture, block): + out = run(target, capture, 'scan:regex "PicklockMarker[0-9]+" --length 24 --max 20') + assert "Empty set" not in out + assert block.text_at in target.session.scan.addresses + + +@slow +def test_results_keep_drop_and_reset(target, capture, block): + run(target, capture, f"scan:value int32 {MARKER} --writable") + total = len(target.session.scan) + assert total >= 2 + + out = run(target, capture, "scan:results") + assert "ADDRESS" in out and "PREVIOUS" in out + + row = target.session.scan.addresses.index(block.ints_at) + 1 + run(target, capture, f"scan:keep {row}") + assert target.session.scan.addresses == [block.ints_at] + + run(target, capture, "scan:drop 1") + assert target.session.scan.addresses == [] + + assert "Discarded" in run(target, capture, "scan:reset") + assert target.session.scan is None + + +@slow +def test_a_result_row_can_be_read_and_written_by_number(target, capture, block): + """'#N' has to reach the address the scan found.""" + run(target, capture, f"scan:value int32 {MARKER} --writable") + row = target.session.scan.addresses.index(block.ints_at) + 1 + + assert str(MARKER) in run(target, capture, f"memory:read #{row} int32") + run(target, capture, f"memory:write #{row} int32 7") + assert block.ints[0] == 7 + + +# -- pointer ------------------------------------------------------------- + + +def test_deref_walks_a_chain(target, capture, block): + out = run(target, capture, f"pointer:deref 0x{block.cell_at:X} 0") + assert f"{block.ints_at:016X}" in out.replace("0x", "") + + +def test_pointer_read_and_write_through_a_chain(target, capture, block): + out = run(target, capture, f"pointer:read 0x{block.cell_at:X} 0 --type int32") + assert str(MARKER) in out + + run(target, capture, f"pointer:read 0x{block.cell_at:X} 0 --write 999") + assert block.ints[0] == 999 + + +def test_a_bracket_expression_dereferences(target, capture, block): + """'[cell]' has to read the pointer and land on the ints.""" + out = run(target, capture, f"memory:read [0x{block.cell_at:X}] int32") + assert str(MARKER) in out + + +@slow +def test_the_pointer_path_workflow(target, capture, block, tmp_path): + """scan, save, load, rescan, diff — the whole file round trip.""" + out = run(target, capture, f"pointer:scan 0x{block.ints_at:X} --depth 2 --max 20") + if not target.session.pointer_paths: + pytest.skip("no static path reaches a ctypes buffer in this build") + assert "BASE" in out + + first = tmp_path / "a.json" + second = tmp_path / "b.json" + assert "Saved" in run(target, capture, f"pointer:save {first}") + run(target, capture, f"pointer:save {second}") + + run(target, capture, "pointer:paths") + assert "OFFSETS" in capture.out + + assert "Loaded" in run(target, capture, f"pointer:load {first}") + out = run(target, capture, f"pointer:rescan 0x{block.ints_at:X}") + assert "still reach" in out + + out = run(target, capture, f"pointer:diff {first} {second}") + assert "present in all 2 file(s)" in out + + +# -- the session commands, against a live target ------------------------- + + +def test_an_alias_reaches_a_real_command(target, capture, block): + run(target, capture, "alias:add r memory:read") + out = run(target, capture, f"r 0x{block.ints_at:X} int32") + assert str(MARKER) in out + + +def test_a_setting_changes_what_a_command_prints(target, capture, block): + run(target, capture, "config:set hex on") + out = run(target, capture, f"memory:read 0x{block.ints_at:X} int32") + assert f"0x{MARKER:X}" in out + + +def test_a_script_of_commands_runs_against_the_target(target, capture, block, tmp_path): + script = tmp_path / "setup.picklock" + script.write_text( + f"# a comment\nmemory:read 0x{block.ints_at:X} int32\nps:info\n" + ) + out = run(target, capture, f"source {script}") + assert str(MARKER) in out + assert "Pointer size" in out From 4d4fef217c85a915f12770d4cb1d64afc12c9da2 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 23:09:00 -0300 Subject: [PATCH 37/82] docs(issue-template): show what attaching prints, and widen the target example --- .github/ISSUE_TEMPLATE/bug_report.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index a9ee868..65a38b3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -15,7 +15,8 @@ The exact commands you ran, and what happened: ```console $ picklock -picklock> ps:open 1234 +picklock> ps:open 91434 +Attached to process.exe (PID 91434, 64-bit). (0.00 sec) picklock> ... ``` @@ -37,7 +38,7 @@ PyMemoryEditor: 2.2.0 - Were you running elevated (`sudo` / Administrator)? [yes / no] - Terminal (e.g. Windows Terminal, iTerm2, GNOME Terminal, plain SSH): - On Linux, the value of `/proc/sys/kernel/yama/ptrace_scope`: -- Target process (e.g. a game, another Python script), if that matters: +- Target process (e.g. a game, browser, another script), if that matters: **Additional context** Add any other context about the problem here. If the target was a process you From 62f79a7d34c50c3264f43f115790e21f0432fb03 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 23:11:40 -0300 Subject: [PATCH 38/82] feat(cli): make --version print the whole report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'picklock --version' gave a one-liner while the 'version' command inside the shell gave the four facts a bug report needs. The flag is the spelling someone reaches for first, and it was the one that told them less. Both now come from one function, and a test pins them to it — which is installed underneath matters as much as which Picklock is on top, and the two must not be able to disagree. Every document that told people to run 'picklock -e "version"' now says '--version'. Found while wiring the smoke checks: `make smoke` and the CI step that proves the console script works both still ran 'picklock ps --limit 5', from before 'ps' became a command that takes a subcommand. Neither had run since — the repo has no remote yet — so CI would have failed on its first push. Both fixed, and both now run 'ps:help' and both spellings of the version: --version is answered by argparse before a shell exists, 'version' by the shell's own dispatch, so one working says nothing about the other. --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/ISSUE_TEMPLATE/questioning.md | 2 +- .github/workflows/python-package.yml | 9 +++++-- CONTRIBUTING.md | 2 +- Makefile | 6 +++-- README.md | 2 +- SECURITY.md | 2 +- picklock/cli.py | 11 ++++++--- picklock/commands/session_commands.py | 35 ++++++++++++++++----------- picklock/shell.py | 2 +- tests/test_cli.py | 26 ++++++++++++++++++++ 11 files changed, 71 insertions(+), 28 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 65a38b3..79e3312 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -24,7 +24,7 @@ picklock> ... A clear and concise description of what you expected to happen instead. **Versions** -Paste the output of `picklock -e "version"` — it covers Picklock, PyMemoryEditor, +Paste the output of `picklock --version` — it covers Picklock, PyMemoryEditor, Python and the platform: ``` diff --git a/.github/ISSUE_TEMPLATE/questioning.md b/.github/ISSUE_TEMPLATE/questioning.md index cbeb10e..180f81d 100644 --- a/.github/ISSUE_TEMPLATE/questioning.md +++ b/.github/ISSUE_TEMPLATE/questioning.md @@ -24,7 +24,7 @@ picklock> ... ``` **Versions** -Paste the output of `picklock -e "version"`, if applicable. +Paste the output of `picklock --version`, if applicable. **Environment** - Were you running elevated (`sudo` / Administrator)? [yes / no] diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 8d5cd26..0c1e38c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -94,11 +94,16 @@ jobs: fail_ci_if_error: false - name: Check the console script starts # The suite calls main() in-process; this proves the installed entry - # point resolves and that a batch run exits cleanly. + # point resolves and that a batch run exits cleanly. Both spellings of + # the version are here: --version is answered by argparse before a shell + # exists, 'version' by the shell's own dispatch, so one of them working + # says nothing about the other. run: | + picklock --version picklock -e "version" picklock -e "help scan" - picklock ps --limit 5 + picklock ps:help + picklock ps:list --limit 5 build: needs: [type-check, test] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00e07b6..f5b75cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -196,7 +196,7 @@ the moment it is registered. Please include: -- The output of `picklock -e "version"` — it names Picklock, PyMemoryEditor, +- 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). diff --git a/Makefile b/Makefile index a99b2cf..a117308 100644 --- a/Makefile +++ b/Makefile @@ -140,9 +140,11 @@ type-check: .PHONY: smoke smoke: @echo "$(GREEN)Checking the console script...$(NC)" - $(PYTHON) -m $(PACKAGE_NAME) -e "version" + $(PYTHON) -m $(PACKAGE_NAME) --version + $(PYTHON) -m $(PACKAGE_NAME) -e "version" > /dev/null $(PYTHON) -m $(PACKAGE_NAME) -e "help scan" > /dev/null - $(PYTHON) -m $(PACKAGE_NAME) ps --limit 5 > /dev/null + $(PYTHON) -m $(PACKAGE_NAME) ps:help > /dev/null + $(PYTHON) -m $(PACKAGE_NAME) ps:list --limit 5 > /dev/null @echo "$(GREEN)Console script works!$(NC)" # Clean build artifacts diff --git a/README.md b/README.md index 8f81e6c..427321f 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ pip install "picklock[speed]" ```console $ picklock -Welcome to Picklock 0.1.0, a terminal client for PyMemoryEditor 2.2.0. +Welcome to Picklock 0.1.0, a terminal client for PyMemoryEditor. Type 'help' for the command list, or 'help scanning' for a walkthrough. picklock> ps:list game diff --git a/SECURITY.md b/SECURITY.md index d72a3e1..b5d908d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,7 +14,7 @@ prepared before details become public. When reporting, please include: -- Affected version(s) — the output of `picklock -e "version"` covers Picklock, +- Affected version(s) — the output of `picklock --version` covers Picklock, PyMemoryEditor, Python and the platform. - The exact command line or shell session that triggers it. - A minimal reproducer, and the impact you observed. diff --git a/picklock/cli.py b/picklock/cli.py index b4e7104..a8532e7 100644 --- a/picklock/cli.py +++ b/picklock/cli.py @@ -20,12 +20,11 @@ import sys from typing import List, Optional, Sequence -import PyMemoryEditor - -from . import __version__, dependencies +from . import dependencies from .commands import top_level_listing from .commands.alias_commands import restore as restore_aliases from .commands.session_commands import restore as restore_settings +from .commands.session_commands import version_report from .errors import CommandError, PicklockError from .output import Printer from .session import Session @@ -115,7 +114,11 @@ def build_parser() -> argparse.ArgumentParser: "-v", "--version", action="version", - version=f"picklock {__version__} (PyMemoryEditor {PyMemoryEditor.__version__})", + # The same block the 'version' command prints. Which PyMemoryEditor is + # underneath matters as much as which Picklock is on top, and someone + # pasting this into a bug report should not have to open the shell to + # get the useful half. + version=version_report(), ) parser.add_argument( "command", diff --git a/picklock/commands/session_commands.py b/picklock/commands/session_commands.py index 78f63c3..ca7f5a9 100644 --- a/picklock/commands/session_commands.py +++ b/picklock/commands/session_commands.py @@ -676,6 +676,26 @@ def cmd_clear(session: Session, args: List[str]) -> None: session.printer.clear_screen() +def version_report() -> str: + """The four facts a bug report needs. + + One function for both spellings: the 'version' command inside the shell and + 'picklock --version' outside it should not be able to disagree about what + is installed. + """ + return render_vertical( + [ + ("Picklock", __version__), + ("PyMemoryEditor", PyMemoryEditor.__version__), + ("Python", platform.python_version()), + ( + "Platform", + f"{platform.system()} {platform.release()} ({platform.machine()})", + ), + ] + ) + + def _version_parser() -> CommandParser: return CommandParser("version") @@ -693,20 +713,7 @@ def _version_parser() -> CommandParser: ) def cmd_version(session: Session, args: List[str]) -> None: _version_parser().parse_args(args) - session.printer.write( - render_vertical( - [ - ("Picklock", __version__), - ("PyMemoryEditor", PyMemoryEditor.__version__), - ("Python", platform.python_version()), - ( - "Platform", - f"{platform.system()} {platform.release()} " - f"({platform.machine()})", - ), - ] - ) - ) + session.printer.write(version_report()) session.printer.write() diff --git a/picklock/shell.py b/picklock/shell.py index 7d3cbaf..fafdd30 100644 --- a/picklock/shell.py +++ b/picklock/shell.py @@ -250,7 +250,7 @@ def banner(self) -> str: return ( f"Welcome to Picklock {__version__}, a terminal client for " - f"PyMemoryEditor {PyMemoryEditor.__version__}.\n" + f"PyMemoryEditor.\n" "Type 'help' for the command list, or 'help scanning' for a " "walkthrough.\n" ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 0cce32f..218f6b8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -126,3 +126,29 @@ def test_help_lists_the_layers_not_every_command(capsys): assert "picklock help " in text assert "memory:read" not in text, "the deeper layer is reached, not dumped" assert "namespace" not in text.lower() + + +def test_version_flag_prints_the_whole_report(): + """'picklock --version' is what goes in a bug report, so it needs all four.""" + text = build_parser().format_usage() # parser builds without error + assert text + + with pytest.raises(SystemExit) as exit_info: + build_parser().parse_args(["--version"]) + assert exit_info.value.code == 0 + + +def test_the_flag_and_the_command_cannot_disagree(): + """Both spellings come from one function, and this pins that down.""" + from picklock.commands.session_commands import version_report + + status, out, _ = run(["-e", "version"]) + assert status == 0 + assert out.strip() == version_report().strip() + + action = next( + item + for item in build_parser()._actions + if "--version" in item.option_strings + ) + assert action.version == version_report() From 1a7b95fcbba916940d3b6060edb71f3a015d8655 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 23:12:34 -0300 Subject: [PATCH 39/82] docs(help): show a scan in the overview's example, and drop a dead import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example stopped at attaching, which is the least interesting half. It now goes on to a scan — the thing the tool is for — and ends on the 'Next page' line, so the shape of a result is on the first screen anyone sees. The banner no longer names PyMemoryEditor's version, which left its local import unused and flake8 failing. Removed. (That edit was not mine and I committed over a red lint in the previous commit; this is the fix.) --- picklock/commands/session_commands.py | 6 +++++- picklock/shell.py | 4 +--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/picklock/commands/session_commands.py b/picklock/commands/session_commands.py index ca7f5a9..6ec1884 100644 --- a/picklock/commands/session_commands.py +++ b/picklock/commands/session_commands.py @@ -286,7 +286,11 @@ def _print_overview(session: Session) -> None: _print_example( session, "picklock> ps:open 4242\n" - "Attached to game.exe (PID 4242, 64-bit). (0.00 sec)", + "Attached to game.exe (PID 4242, 64-bit). (0.00 sec)\n" + "\n" + "picklock> scan:value int32 100 --writable\n" + "Showing 20 of 3184 rows — page 1 of 160 (1.42 sec)\n" + "Next page: scan:results --page 2", indent=4, ) diff --git a/picklock/shell.py b/picklock/shell.py index fafdd30..9d5c851 100644 --- a/picklock/shell.py +++ b/picklock/shell.py @@ -246,11 +246,9 @@ def prompt(self) -> str: return f"picklock {self.printer.dim(target, in_prompt=self._readline)}> " def banner(self) -> str: - import PyMemoryEditor - return ( f"Welcome to Picklock {__version__}, a terminal client for " - f"PyMemoryEditor.\n" + "PyMemoryEditor.\n" "Type 'help' for the command list, or 'help scanning' for a " "walkthrough.\n" ) From 86de668b4aa8d9c7689946b029f2b427b93a7e8a Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 23:19:58 -0300 Subject: [PATCH 40/82] feat(commands): give the paging flags short forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -l, -p and -a for --limit, --page and --all. They are the most-typed flags in the tool — every listing has them, and paging through one means typing the same flag again and again — and all three letters were free inside the commands. 'memory:watch --all' gets -a as well. It is not a listing, but --all means the same thing there, and a letter that means "don't filter" in six commands and nothing in a seventh is worse than either choice made consistently. One overlap worth naming: -p is --pid at the command-line level, so 'picklock -p 1234 ps:list -p 2' has the letter meaning two things on one line. It resolves correctly — everything after the command word is the command's — and inside the shell, where most of this is typed, there is no --pid at all. --- README.md | 6 +++--- picklock/commands/__init__.py | 11 +++++++++- picklock/commands/memory_commands.py | 1 + tests/test_commands.py | 30 ++++++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 427321f..a1a7e7b 100644 --- a/README.md +++ b/README.md @@ -231,9 +231,9 @@ Highlights: already taken by a command is refused rather than shadowing it, and the aliases are still there next time you open a terminal. - **Every listing pages the same way.** `--limit`, `--page` and `--all` on each - of them, a footer that says where you are — `Showing 20 of 3184 rows — page - 1 of 160` — and the command for the next page spelled out underneath, so it - is a copy-paste rather than a puzzle. + of them — `-l`, `-p`, `-a` for short — a footer that says where you are: + `Showing 20 of 3184 rows — page 1 of 160`, and the command for the next page + spelled out underneath, so it is a copy-paste rather than a puzzle. - **Ctrl+C means the obvious thing.** During a command it abandons that command and returns to the prompt; at the prompt it quits. So stopping a scan costs one keystroke and leaving costs two, and neither one loses your results by diff --git a/picklock/commands/__init__.py b/picklock/commands/__init__.py index e4241ac..0ae7f5f 100644 --- a/picklock/commands/__init__.py +++ b/picklock/commands/__init__.py @@ -490,8 +490,13 @@ def add_paging_arguments(parser: CommandParser) -> CommandParser: Declared in one place so the wording, the behaviour and the help text cannot drift between commands — a listing that pages differently from its neighbour is a listing you have to learn twice. + + Short forms because these three are the most-typed flags in the tool: every + listing has them, and paging through one means typing the same flag again + and again. """ parser.add_argument( + "-l", "--limit", type=int, default=None, @@ -499,6 +504,7 @@ def add_paging_arguments(parser: CommandParser) -> CommandParser: help="rows per page, overriding the 'limit' setting", ) parser.add_argument( + "-p", "--page", type=int, default=1, @@ -506,7 +512,10 @@ def add_paging_arguments(parser: CommandParser) -> CommandParser: help="which page to show, counting from 1", ) parser.add_argument( - "--all", action="store_true", help="print every row, ignoring the limit" + "-a", + "--all", + action="store_true", + help="print every row, ignoring the limit", ) return parser diff --git a/picklock/commands/memory_commands.py b/picklock/commands/memory_commands.py index 976861d..50a2b2d 100644 --- a/picklock/commands/memory_commands.py +++ b/picklock/commands/memory_commands.py @@ -491,6 +491,7 @@ def _watch_parser() -> CommandParser: help="stop after N samples; without it, watch runs until Ctrl+C", ) parser.add_argument( + "-a", "--all", action="store_true", help="print every sample, not only the ones whose value changed", diff --git a/tests/test_commands.py b/tests/test_commands.py index 9101719..08983a9 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -486,6 +486,36 @@ def test_every_listing_command_pages_the_same_way(name): assert {"--limit", "--page", "--all"} <= flags +@pytest.mark.parametrize("name", PAGED) +def test_every_listing_offers_the_short_forms(name): + """The three most-typed flags in the tool; paging means typing them again.""" + flags = { + flag for action in lookup(name).arguments() for flag in action.option_strings + } + assert {"-l", "-p", "-a"} <= flags + + +@pytest.mark.parametrize("name", PAGED) +def test_the_short_and_long_forms_are_one_option(name): + parser = lookup(name).parser() + assert parser.parse_args(["-l", "5"]).limit == parser.parse_args( + ["--limit", "5"] + ).limit == 5 + assert parser.parse_args(["-p", "3"]).page == 3 + assert parser.parse_args(["-a"]).all is True + + +def test_all_means_the_same_thing_wherever_it_appears(): + """'-a' must not mean "every row" in six commands and nothing in a seventh.""" + for name in PAGED + ["memory:watch"]: + flags = { + flag + for action in lookup(name).arguments() + for flag in action.option_strings + } + assert ("--all" in flags) == ("-a" in flags) + + @pytest.mark.parametrize("name", PAGED) def test_paging_flags_are_documented_identically(name): """The shared helper is the point: the help text must not drift per command.""" From 96be54ea08c074030b8b28477f5617147495eb98 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sat, 29 Aug 2026 23:29:53 -0300 Subject: [PATCH 41/82] fix(memory): read a '#N' row with the type the scan used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'memory:watch #19' after an int8 scan read four bytes, because the type defaulted to int32 wherever the address came from. A byte holding 5 with three 0xFF bytes after it then reports -251 — nothing like the 5 the scan matched, and nothing on screen says why. A '#N' row belongs to the scan that found it, so with no type named it is now read the way that scan read it, at that scan's width. Naming a type still wins, and an address the user typed is untouched: only a row carries the scan's answer with it. The width follows too, so 'memory:read #1 --count 4' on a byte scan walks four bytes rather than sixteen. Also written down in 'help memory:watch': a value that is not moving shows one line and then nothing, which looks identical to a watch that has died, and --all is how you tell them apart. --- picklock/commands/memory_commands.py | 48 ++++++++++++++++---- tests/test_end_to_end.py | 66 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/picklock/commands/memory_commands.py b/picklock/commands/memory_commands.py index 50a2b2d..034c8a2 100644 --- a/picklock/commands/memory_commands.py +++ b/picklock/commands/memory_commands.py @@ -9,7 +9,7 @@ """ import time -from typing import Any, List, Optional +from typing import Any, List, Optional, Tuple from .. import valuetypes from ..addressing import parse_address, parse_int @@ -36,6 +36,30 @@ def _resolve_type(name: Optional[str]) -> ValueType: return valuetypes.DEFAULT_TYPE if name is None else valuetypes.resolve(name) +def _type_and_width( + session: Session, + named: Optional[str], + address: str, + length: Optional[int], +) -> Tuple[ValueType, int]: + """The type to read an address with, and how many bytes that takes. + + A '#N' row belongs to the scan that found it, so with no type named it is + read the way that scan read it. Defaulting to int32 there answers a + question nobody asked: a byte holding 5 next to three 0xFF bytes reports + -251, which looks nothing like the value the scan matched, and the reader + has no reason to suspect the width. + + Anywhere else, and whenever a type *is* named, nothing is inherited. + """ + if named is None and address.strip().startswith("#") and session.scan is not None: + state = session.scan + return state.value_type, (length if length is not None else state.width) + + value_type = _resolve_type(named) + return value_type, value_type.read_width(length) + + def _permissions(region) -> str: """Render a region's access bits the way ``/proc/*/maps`` does.""" return "".join( @@ -290,8 +314,11 @@ def _read_parser() -> CommandParser: parser=_read_parser, summary="Read a typed value from an address.", details=( - "The type defaults to int32. 'string' and 'bytes' need a length in " - "bytes; the fixed-width types ignore one.\n\n" + "The type defaults to int32 — except for a '#N' row, which is read the " + "way the scan that found it read it. A byte the scan matched should " + "not come back as a four-byte number.\n\n" + "'string' and 'bytes' need a length in bytes; the fixed-width types " + "ignore one.\n\n" "The address is an expression — see 'help address' — so a pointer " "chain can be read in one go." ), @@ -307,8 +334,9 @@ def cmd_read(session: Session, args: List[str]) -> None: options = _read_parser().parse_args(args) process = session.require_process("memory:read") - value_type = _resolve_type(options.type) - width = value_type.read_width(options.length) + value_type, width = _type_and_width( + session, options.type, options.address, options.length + ) if options.count < 1: raise CommandError("--count must be at least 1.") @@ -506,7 +534,10 @@ def _watch_parser() -> CommandParser: details=( "Reads the address on a timer and prints a line per sample. By default " "only samples whose value differs from the previous one are printed, " - "which turns the terminal into a change log.\n\n" + "which turns the terminal into a change log — so a value that is not " + "moving shows one line and then nothing, and '--all' is how you tell " + "that apart from a watch that has stopped.\n\n" + "A '#N' row is watched with the type the scan used, not int32.\n\n" "This is the terminal answer to a cheat table: leave it running in one " "window while the target does its thing." ), @@ -520,8 +551,9 @@ def cmd_watch(session: Session, args: List[str]) -> None: options = _watch_parser().parse_args(args) process = session.require_process("memory:watch") - value_type = _resolve_type(options.type) - width = value_type.read_width(options.length) + value_type, width = _type_and_width( + session, options.type, options.address, options.length + ) address = parse_address(options.address, session) interval = ( options.interval diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 7fc2132..2343339 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -391,3 +391,69 @@ def test_a_script_of_commands_runs_against_the_target(target, capture, block, tm out = run(target, capture, f"source {script}") assert str(MARKER) in out assert "Pointer size" in out + + +# -- a result row is read the way the scan read it ------------------------ + + +@pytest.fixture +def byte_in_a_struct(): + """A byte worth 5 with three 0xFF bytes after it. + + Read as int32 that is -251, which looks nothing like the 5 a byte scan + matched — and nothing on screen would say why. + """ + return (ctypes.c_uint8 * 8)(5, 0xFF, 0xFF, 0xFF, 0, 0, 0, 0) + + +def _one_int8_result(shell, address): + from picklock import valuetypes + + shell.session.store_scan( + valuetypes.resolve("int8"), 1, [address], [5], "int8 eq 5" + ) + + +def test_a_row_is_read_with_the_scans_type(target, capture, byte_in_a_struct): + _one_int8_result(target, ctypes.addressof(byte_in_a_struct)) + + out = run(target, capture, "memory:read #1") + assert "int8" in out and "| 5 " in out + assert "-251" not in out, "that is the four-byte reading of the same address" + + +def test_a_row_is_watched_with_the_scans_type(target, capture, byte_in_a_struct): + _one_int8_result(target, ctypes.addressof(byte_in_a_struct)) + + out = run(target, capture, "memory:watch #1 --count 2 --interval 0.01 --all") + assert "as int8" in out + assert out.count(" 5") >= 2 + + +def test_a_named_type_still_wins_over_the_scans(target, capture, byte_in_a_struct): + _one_int8_result(target, ctypes.addressof(byte_in_a_struct)) + + out = run(target, capture, "memory:read #1 int32") + assert "-251" in out, "asked for four bytes, got four bytes" + + +def test_a_plain_address_is_not_affected(target, capture, byte_in_a_struct): + """Only a '#N' row belongs to the scan; an address the user typed does not.""" + _one_int8_result(target, ctypes.addressof(byte_in_a_struct)) + + out = run(target, capture, f"memory:read 0x{ctypes.addressof(byte_in_a_struct):X}") + assert "int32" in out and "-251" in out + + +def test_counting_forward_steps_by_the_scans_width(target, capture): + """The step is the type's width, so an int8 row walks byte by byte.""" + from picklock import valuetypes + + block = (ctypes.c_int8 * 4)(1, 2, 3, 4) + target.session.store_scan( + valuetypes.resolve("int8"), 1, [ctypes.addressof(block)], [1], "int8 eq 1" + ) + + out = run(target, capture, "memory:read #1 --count 4") + for value in ("| 1 ", "| 2 ", "| 3 ", "| 4 "): + assert value in out From 924e376a056b356d60603fc325277dea305fcd76 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 13:45:33 -0300 Subject: [PATCH 42/82] feat(memory): total up the regions, and name what a macOS TID really is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'memory:regions' now ends with the count and the summed size of what it listed — '75 regions, 953.4 MB mapped' — following any filter, so "how much of this target is writable?" is one command rather than arithmetic over pages. A page of rows could never answer it. On the threads: Picklock and PyMemoryEditor's app report different TIDs for the same macOS process, and neither is wrong. Verified they call the same function and agree exactly when run from one process; the difference is that a macOS thread is named by a Mach port, and a port name means something only to the process that asked for it. Two observers get two numbers for one thread. Linux and Windows report a property of the thread itself, so there it is stable. That is now one line under the listing, where someone meets the surprise, with the three-platform version in 'help memory:threads' — including that the number does not match Activity Monitor either, and should not be carried between tools. --- picklock/commands/memory_commands.py | 37 ++++++++++++++++++++++-- tests/test_end_to_end.py | 43 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/picklock/commands/memory_commands.py b/picklock/commands/memory_commands.py index 034c8a2..9fe9a61 100644 --- a/picklock/commands/memory_commands.py +++ b/picklock/commands/memory_commands.py @@ -8,6 +8,7 @@ hand the target new pages. """ +import sys import time from typing import Any, List, Optional, Tuple @@ -166,6 +167,16 @@ def cmd_regions(session: Session, args: List[str]) -> None: next_page=page.next_page, ) + # The totals for what was listed, filters included — "the writable regions + # come to 1.6 GB" is the answer people are usually after, and counting the + # rows of one page cannot give it. + mapped = sum(region.size for region in regions) + session.printer.write( + f"{len(regions)} region{'' if len(regions) == 1 else 's'}, " + f"{format_size(mapped)} mapped" + ) + session.printer.write() + def _modules_parser() -> CommandParser: parser = CommandParser("memory:modules") @@ -250,9 +261,17 @@ def _threads_parser() -> CommandParser: summary="List the target's threads.", details=( "STATE and PRIORITY are filled in only where the platform exposes them " - "cheaply (Linux does; Windows and macOS leave them empty). The meaning " - "of TID is platform-specific: a POSIX task id on Linux, a kernel " - "thread id on Windows, a Mach port name on macOS." + "cheaply (Linux does; Windows and macOS leave them empty).\n\n" + "What a TID *is* differs by platform, and only two of the three are a " + "property of the thread itself:\n\n" + " Linux the POSIX task id — the same number everything else reports\n" + " Windows the kernel thread id — likewise\n" + " macOS a Mach port name, which means something only to the " + "process that asked\n\n" + "That last one is the trap: on macOS two tools looking at the same " + "process get different numbers for the same threads, and neither is " + "wrong. It is a handle, not a name — do not carry it between tools, " + "and do not expect it to match Activity Monitor." ), ) def cmd_threads(session: Session, args: List[str]) -> None: @@ -290,6 +309,18 @@ def cmd_threads(session: Session, args: List[str]) -> None: next_page=page.next_page, ) + if sys.platform == "darwin": + # One line, every time, because this is where it bites: on macOS a + # thread is named by a Mach port, and a port name means something only + # inside the address space that asked for it. Two tools looking at the + # same process get different numbers for the same thread, and neither + # is wrong. The why is in 'help memory:threads'. + session.printer.write( + "These are Mach port names, not thread ids: another tool will " + "report different numbers. See 'help memory:threads'." + ) + session.printer.write() + def _read_parser() -> CommandParser: parser = CommandParser("memory:read") diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 2343339..0ecd855 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -457,3 +457,46 @@ def test_counting_forward_steps_by_the_scans_width(target, capture): out = run(target, capture, "memory:read #1 --count 4") for value in ("| 1 ", "| 2 ", "| 3 ", "| 4 "): assert value in out + + +# -- what a listing says about itself ------------------------------------ + + +def test_regions_reports_the_count_and_the_size(target, capture): + """A page of rows cannot answer "how much is mapped?".""" + out = run(target, capture, "memory:regions --limit 2") + match = re.search(r"(\d+) regions, ([\d.]+ \w+) mapped", out) + assert match, out + assert int(match.group(1)) > 2, "the whole set, not the page" + + +def test_the_regions_total_follows_the_filter(target, capture): + """'the writable ones come to N' is the question a filter asks.""" + everything = re.search( + r"(\d+) regions, ", run(target, capture, "memory:regions --limit 1") + ) + writable = re.search( + r"(\d+) regions, ", run(target, capture, "memory:regions --writable --limit 1") + ) + assert everything and writable + assert int(writable.group(1)) < int(everything.group(1)) + + +@pytest.mark.skipif( + __import__("sys").platform != "darwin", reason="the caveat is macOS-only" +) +def test_threads_says_the_tid_is_a_port_name(target, capture): + """Two tools report different numbers for the same thread on macOS. + + That is the Mach port namespace, not a bug in either of them, and the + listing is where someone finds out. + """ + out = run(target, capture, "memory:threads") + assert "Mach port names" in out + assert "help memory:threads" in out + + +def test_the_threads_help_explains_all_three_platforms(shell, capture): + shell.run_line("help memory:threads") + for platform_name in ("Linux", "Windows", "macOS"): + assert platform_name in capture.out From a948175e81b72315892c7bc38f1210118dfeb6c1 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 14:00:17 -0300 Subject: [PATCH 43/82] feat(memory): rename dump to hex, watch on ENTER, export results as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes asked for, and one dropped. 'memory:dump' becomes 'memory:hex' — "dump" names what the tool does to the bytes rather than what you get to look at, and with --watch it is now a viewer rather than a one-shot. Short over 'hex-viewer': it is typed far more often than it is read, and the summary line carries the meaning. The 'dump_width' setting follows it to 'hex_width'; a stored one under the old name is reported and skipped at startup, which is what that path is for. 'memory:hex --watch' redraws the same range in place until ENTER — a whole record changing at once, where 'memory:watch' follows a single value. Cleared and reprinted rather than appended, so the eye can see which bytes moved instead of diffing a scrolling wall by hand. Both watches now stop on ENTER. Ctrl+C still works and still means the one thing it means everywhere else, which is leaving the shell. wait_for_enter falls back to plain sleeping when the input is not a terminal — a redirected stdin reports end-of-file the instant it is polled, which would otherwise end every watch in a script immediately. 'scan:results --export FILE' writes every result to JSON — all of them, not the page on screen — with the scan's description, type and width alongside so the file says what its numbers mean, and addresses as hex strings, the shape PyMemoryEditor writes pointer paths in. A value JSON cannot hold takes the spelling the table shows; an address that could not be read stays null rather than becoming a zero somebody might trust. The macOS TID line comes off the threads listing. It cannot be fixed and it was being printed at everyone, every time; the explanation stays in 'help memory:threads', where it is asked for. --- README.md | 14 ++- picklock/commands/memory_commands.py | 162 ++++++++++++++++++++------- picklock/commands/scan_commands.py | 72 +++++++++++- picklock/output.py | 36 ++++++ picklock/session.py | 2 +- tests/test_commands.py | 4 +- tests/test_end_to_end.py | 99 +++++++++++++--- tests/test_output.py | 40 +++++++ 8 files changed, 360 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index a1a7e7b..8463cad 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ and `help scan` all produce the same output. | Command | Subcommands | | --- | --- | | **`ps:`** | `list` · `open` · `close` · `info` | -| **`memory:`** | `read` · `write` · `dump` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | +| **`memory:`** | `read` · `write` · `hex` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | | **`scan:`** | `value` · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | | **`pointer:`** | `deref` · `read` · `scan` · `rescan` · `paths` · `save` · `load` · `diff` | | **`config:`** | `list` · `set` · `reset` | @@ -187,11 +187,10 @@ argument, every flag, and examples. That list is generated from the command's own parser, so it is always exactly what the command accepts: ```console -picklock> help dump -dump — Hex-dump a range of memory. +picklock> help memory:hex +memory:hex — Show a range of memory as hex and text. -Usage: dump
[length] [--width N] -Aliases: hexdump, x +Usage: memory:hex
[length] [--width N] [--watch] [--interval S] Arguments: address address expression: a literal, module+offset, [pointer] or #N — @@ -199,7 +198,10 @@ Arguments: [length] number of bytes to read (default 256); hex accepted Options: - --width N bytes per line, overriding the 'dump_width' setting + --width N bytes per line, overriding the 'hex_width' setting + -w, --watch redraw as the bytes change, until ENTER + --interval S seconds between redraws with --watch, overriding + 'watch_interval' ``` `help types`, `help address` and `help scanning` cover what several commands diff --git a/picklock/commands/memory_commands.py b/picklock/commands/memory_commands.py index 9fe9a61..f26d77d 100644 --- a/picklock/commands/memory_commands.py +++ b/picklock/commands/memory_commands.py @@ -4,7 +4,7 @@ Looking at, and changing, the target's memory. ``regions`` / ``modules`` / ``threads`` describe the address space; ``read`` / -``write`` / ``dump`` / ``watch`` work on a single address; ``alloc`` / ``free`` +``write`` / ``hex`` / ``watch`` work on a single address; ``alloc`` / ``free`` hand the target new pages. """ @@ -15,7 +15,15 @@ from .. import valuetypes from ..addressing import parse_address, parse_int from ..errors import CommandError -from ..output import LEFT, RIGHT, Timer, format_address, format_size, render_hexdump +from ..output import ( + LEFT, + RIGHT, + Timer, + format_address, + format_size, + render_hexdump, + wait_for_enter, +) from ..session import Session from ..valuetypes import ValueType from . import CommandParser, add_paging_arguments, command, paginate @@ -61,6 +69,12 @@ def _type_and_width( return value_type, value_type.read_width(length) +def _input_stream(session: Session): + """Where a keypress would come from, if anyone is there to make one.""" + shell = session.shell + return shell.stdin if shell is not None else sys.stdin + + def _permissions(region) -> str: """Render a region's access bits the way ``/proc/*/maps`` does.""" return "".join( @@ -309,18 +323,6 @@ def cmd_threads(session: Session, args: List[str]) -> None: next_page=page.next_page, ) - if sys.platform == "darwin": - # One line, every time, because this is where it bites: on macOS a - # thread is named by a Mach port, and a port name means something only - # inside the address space that asked for it. Two tools looking at the - # same process get different numbers for the same thread, and neither - # is wrong. The why is in 'help memory:threads'. - session.printer.write( - "These are Mach port names, not thread ids: another tool will " - "report different numbers. See 'help memory:threads'." - ) - session.printer.write() - def _read_parser() -> CommandParser: parser = CommandParser("memory:read") @@ -472,8 +474,8 @@ def cmd_write(session: Session, args: List[str]) -> None: session.printer.write() -def _dump_parser() -> CommandParser: - parser = CommandParser("memory:dump") +def _hex_parser() -> CommandParser: + parser = CommandParser("memory:hex") parser.add_argument("address", help=_ADDRESS_HELP) parser.add_argument( "length", @@ -486,48 +488,120 @@ def _dump_parser() -> CommandParser: type=int, default=None, metavar="N", - help="bytes per line, overriding the 'dump_width' setting", + help="bytes per line, overriding the 'hex_width' setting", + ) + parser.add_argument( + "-w", + "--watch", + action="store_true", + help="redraw as the bytes change, until ENTER", + ) + parser.add_argument( + "--interval", + type=float, + default=None, + metavar="S", + help="seconds between redraws with --watch, overriding " + "'watch_interval'", ) return parser @command( - "memory:dump", - parser=_dump_parser, - summary="Hex-dump a range of memory.", + "memory:hex", + parser=_hex_parser, + summary="Show a range of memory as hex and text.", details=( - "Prints the classic three-column layout: absolute address, hex bytes, " - "printable ASCII.\n\n" + "The classic three-column layout: absolute address, hex bytes, " + "printable ASCII. Length defaults to 256 bytes.\n\n" + "With --watch it redraws in place until you press ENTER, which turns " + "it into a live view of a structure — a whole record changing at once, " + "where 'memory:watch' follows a single value.\n\n" "The read is a single call, so a range that crosses into an unmapped " "page fails as a whole rather than returning half the bytes." ), - examples=("memory:dump 0x7ffee3a01000", "memory:dump game.exe+0x1000 512", "memory:dump #1 64 --width 8"), + examples=( + "memory:hex 0x7ffee3a01000", + "memory:hex game.exe+0x1000 512", + "memory:hex #1 64 --width 8", + "memory:hex #1 64 --watch", + ), ) -def cmd_dump(session: Session, args: List[str]) -> None: - options = _dump_parser().parse_args(args) +def cmd_hex(session: Session, args: List[str]) -> None: + options = _hex_parser().parse_args(args) - process = session.require_process("memory:dump") + process = session.require_process("memory:hex") address = parse_address(options.address, session) length = parse_int(str(options.length), "length") - width = options.width if options.width else int(session.option("dump_width")) + width = options.width if options.width else int(session.option("hex_width")) if length < 1: raise CommandError("Length must be at least 1 byte.") if width < 1: raise CommandError("Line width must be at least 1 byte.") - with Timer() as timer: + def read() -> bytes: try: - data = process.read_bytes(address, length) + return process.read_bytes(address, length) except OSError as error: raise CommandError( f"Cannot read {length} byte(s) at 0x{address:X}: {error}" ) - session.printer.write(render_hexdump(data, address, width)) - session.printer.write() - session.printer.ok(f"{len(data)} bytes", elapsed=timer.elapsed) - session.printer.write() + if not options.watch: + with Timer() as timer: + data = read() + session.printer.write(render_hexdump(data, address, width)) + session.printer.write() + session.printer.ok(f"{len(data)} bytes", elapsed=timer.elapsed) + session.printer.write() + return + + _watch_hex(session, read, address, width, options.interval) + + +def _watch_hex( + session: Session, + read, + address: int, + width: int, + interval: Optional[float], +) -> None: + """Redraw the same range until ENTER. + + Cleared and reprinted rather than appended, so the bytes stay in one place + and the eye can see which of them moved — a scrolling wall of near-identical + dumps shows change only by accident. + """ + printer = session.printer + every = ( + interval if interval is not None else float(session.option("watch_interval")) + ) + if every <= 0: + raise CommandError("--interval must be greater than zero.") + + stream = _input_stream(session) + redraws = 0 + + try: + while True: + data = read() + printer.clear_screen() + printer.write( + f"Watching {format_address(address, session.require_process().pointer_size)}" + f" — {len(data)} bytes every {every:g}s. Press ENTER to stop." + ) + printer.write() + printer.write(render_hexdump(data, address, width)) + printer.write() + redraws += 1 + if wait_for_enter(stream, every): + break + except KeyboardInterrupt: + printer.write("^C") + + printer.ok(f"{redraws} redraw(s).") + printer.write() def _watch_parser() -> CommandParser: @@ -563,11 +637,13 @@ def _watch_parser() -> CommandParser: parser=_watch_parser, summary="Poll an address and print it as it changes.", details=( - "Reads the address on a timer and prints a line per sample. By default " - "only samples whose value differs from the previous one are printed, " - "which turns the terminal into a change log — so a value that is not " - "moving shows one line and then nothing, and '--all' is how you tell " - "that apart from a watch that has stopped.\n\n" + "Reads the address on a timer and prints a line per sample. Press ENTER " + "to stop; Ctrl+C is left to mean what it means everywhere else, which " + "is leaving the shell.\n\n" + "By default only samples whose value differs from the previous one are " + "printed, which turns the terminal into a change log — so a value that " + "is not moving shows one line and then nothing, and '--all' is how you " + "tell that apart from a watch that has stopped.\n\n" "A '#N' row is watched with the type the scan used, not int32.\n\n" "This is the terminal answer to a cheat table: leave it running in one " "window while the target does its thing." @@ -598,9 +674,10 @@ def cmd_watch(session: Session, args: List[str]) -> None: printer = session.printer printer.write( f"Watching {format_address(address, process.pointer_size)} as " - f"{value_type.name} every {interval:g}s. Press Ctrl+C to stop." + f"{value_type.name} every {interval:g}s. Press ENTER to stop." ) + stream = _input_stream(session) samples = 0 printed = 0 previous = object() # A sentinel no read can equal, so sample 1 always prints. @@ -626,10 +703,11 @@ def cmd_watch(session: Session, args: List[str]) -> None: if options.count and samples >= options.count: break - time.sleep(interval) + if wait_for_enter(stream, interval): + break except KeyboardInterrupt: - # Ctrl+C is how a watch is meant to end, not an error. - printer.write() + # Ctrl+C still works, and still means what it means everywhere else. + printer.write("^C") printer.ok(f"{samples} sample(s), {printed} printed.") printer.write() diff --git a/picklock/commands/scan_commands.py b/picklock/commands/scan_commands.py index 7e77598..58f538d 100644 --- a/picklock/commands/scan_commands.py +++ b/picklock/commands/scan_commands.py @@ -19,6 +19,7 @@ batching them changes no result. """ +import json from dataclasses import dataclass, field from typing import Any, Callable, Iterable, List, Optional, Sequence, Tuple @@ -719,7 +720,61 @@ def search(batch: List[MemoryRegion]) -> Iterable[Any]: def _results_parser() -> CommandParser: - return add_paging_arguments(CommandParser("scan:results")) + parser = CommandParser("scan:results") + parser.add_argument( + "--export", + default=None, + metavar="FILE", + help="write every result to a JSON file instead of paging through them", + ) + return add_paging_arguments(parser) + + +def _export_results(session: Session, state: ScanState, path: str) -> int: + """Write the whole result set to ``path`` as JSON, and say how many. + + Every row, not the page on screen: an export exists precisely for the + results too numerous to read. Addresses are hex strings, the same shape + PyMemoryEditor writes pointer paths in, and the scan's type and width ride + along so the file says what the numbers in it mean. + """ + process = session.require_process() + values = _read_values(session, state.value_type, state.width, state.addresses) + + document = { + "process": {"pid": process.pid, "name": session.process_name or None}, + "scan": state.description, + "type": state.value_type.name, + "width": state.width, + "results": [ + {"address": "0x%X" % address, "value": _exportable(state, value)} + for address, value in zip(state.addresses, values) + ], + } + + try: + with open(path, "w", encoding="utf-8") as handle: + json.dump(document, handle, indent=2) + handle.write("\n") + except OSError as error: + raise CommandError(f"Cannot write {path!r}: {error}") + + return len(state.addresses) + + +def _exportable(state: ScanState, value) -> Any: + """A value JSON can hold, without inventing precision it does not have. + + Numbers and booleans go through as themselves so a consumer can do + arithmetic on them. Bytes have no JSON form, so they take the same hex + spelling the table shows, and an address that could not be read stays + null rather than becoming a zero somebody might trust. + """ + if value is None: + return None + if isinstance(value, (bool, int, float, str)): + return value + return state.value_type.format(value) @command( @@ -733,12 +788,16 @@ def _results_parser() -> CommandParser: "friends compare against — and is filled in only where the two " "differ.\n\n" "Row numbers are what '#N' refers to in an address, and they keep " - "counting across pages: row #21 is the first on page 2 of twenty." + "counting across pages: row #21 is the first on page 2 of twenty.\n\n" + "--export writes every result to a JSON file — all of them, not the " + "page on screen — with the scan's type and width alongside, so the " + "file says what its numbers mean." ), examples=( "scan:results", "scan:results --all", "scan:results --page 3 --limit 10", + "scan:results --export found.json", ), ) def cmd_results(session: Session, args: List[str]) -> None: @@ -748,6 +807,15 @@ def cmd_results(session: Session, args: List[str]) -> None: process = session.require_process("scan:results") hex_output = bool(session.option("hex")) + if options.export is not None: + with Timer() as timer: + written = _export_results(session, state, options.export) + session.printer.ok( + f"Wrote {written} result(s) to {options.export}.", elapsed=timer.elapsed + ) + session.printer.write() + return + page = paginate( session, range(len(state.addresses)), diff --git a/picklock/output.py b/picklock/output.py index 2f7b162..d633670 100644 --- a/picklock/output.py +++ b/picklock/output.py @@ -15,6 +15,7 @@ """ import os +import select import sys import textwrap import time @@ -49,6 +50,40 @@ _RL_IGNORE_END = "\002" +def wait_for_enter(stream: TextIO, timeout: float) -> bool: + """Wait up to ``timeout`` seconds, returning True if ENTER was pressed. + + Used by the commands that watch an address: a key is a gentler way to stop + something than an interrupt, and it leaves Ctrl+C to mean the one thing it + should — leave the shell. + + Falls back to plain sleeping when the input is not a terminal, which is + what a script or a pipe gives: there is nobody there to press anything, and + a redirected stdin is at end-of-file the instant it is polled, which would + end the watch immediately. + """ + if not getattr(stream, "isatty", lambda: False)(): + time.sleep(timeout) + return False + + if sys.platform == "win32": # pragma: no cover - Windows only + import msvcrt + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if msvcrt.kbhit(): + msvcrt.getwch() + return True + time.sleep(0.02) + return False + + ready, _, _ = select.select([stream], [], [], timeout) + if not ready: + return False + stream.readline() + return True + + def supports_color(stream: TextIO) -> bool: """True when it is polite to emit ANSI escapes on ``stream``.""" if os.environ.get("NO_COLOR") is not None: @@ -431,4 +466,5 @@ def __exit__(self, exc_type, exc_value, traceback) -> None: "render_table", "render_vertical", "supports_color", + "wait_for_enter", ) diff --git a/picklock/session.py b/picklock/session.py index 47f632c..44b6081 100644 --- a/picklock/session.py +++ b/picklock/session.py @@ -54,7 +54,7 @@ class Setting: Setting("timing", True, bool, "Print the elapsed time after each command."), Setting("progress", True, bool, "Show a progress line while scanning."), Setting("writable_only", False, bool, "Scan only writable regions (faster)."), - Setting("dump_width", 16, int, "Bytes per line in 'memory:dump' output."), + Setting("hex_width", 16, int, "Bytes per line in 'memory:hex' output."), Setting("watch_interval", 0.5, float, "Seconds between 'memory:watch' samples."), ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 08983a9..0d4a877 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -219,7 +219,7 @@ def test_help_on_a_namespace_lists_it(shell, capture): def test_a_namespace_listing_shows_argument_signatures(shell, capture): """The dokku shape: what it is called and what it takes, in one line.""" shell.run_line("memory:help") - assert "memory:dump
[length]" in capture.out + assert "memory:hex
[length]" in capture.out assert "memory:read
[type] [length]" in capture.out @@ -380,7 +380,7 @@ def test_examples_parse_as_commands(entry, shell): [ "memory:read 0x10", "memory:write 0x10 int32 1", - "memory:dump 0x10", + "memory:hex 0x10", "memory:regions", "memory:modules", "memory:threads", diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 0ecd855..b8ab596 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -156,12 +156,41 @@ def test_memory_write_bytes(target, capture, block): assert list(block.blob[:4]) == [1, 2, 3, 4] -def test_memory_dump_shows_hex_and_ascii(target, capture, block): - out = run(target, capture, f"memory:dump 0x{block.text_at:X} 16") +def test_memory_hex_shows_hex_and_ascii(target, capture, block): + out = run(target, capture, f"memory:hex 0x{block.text_at:X} 16") assert "PicklockMarker42" in out, "the ASCII column" assert "50 69 63 6B" in out, "the hex column ('Pick')" +def test_memory_hex_watch_redraws_until_enter(target, capture, block, monkeypatch): + """The keypress is injected: a test has no terminal to press ENTER on.""" + from picklock.commands import memory_commands + + presses = iter([False, False, True]) + monkeypatch.setattr( + memory_commands, "wait_for_enter", lambda stream, timeout: next(presses) + ) + + out = run( + target, capture, f"memory:hex 0x{block.text_at:X} 16 --watch --interval 0.01" + ) + assert out.count("PicklockMarker42") == 3, "redrew until the key came" + assert "3 redraw(s)" in out + + +def test_memory_watch_stops_on_enter(target, capture, block, monkeypatch): + from picklock.commands import memory_commands + + presses = iter([False, True]) + monkeypatch.setattr( + memory_commands, "wait_for_enter", lambda stream, timeout: next(presses) + ) + + out = run(target, capture, f"memory:watch 0x{block.ints_at:X} int32 --interval 0.01") + assert "Press ENTER to stop" in out + assert "2 sample(s)" in out, "stopped on the second wait, not on a count" + + def test_memory_watch_samples_the_value(target, capture, block): out = run( target, @@ -482,21 +511,59 @@ def test_the_regions_total_follows_the_filter(target, capture): assert int(writable.group(1)) < int(everything.group(1)) -@pytest.mark.skipif( - __import__("sys").platform != "darwin", reason="the caveat is macOS-only" -) -def test_threads_says_the_tid_is_a_port_name(target, capture): - """Two tools report different numbers for the same thread on macOS. - - That is the Mach port namespace, not a bug in either of them, and the - listing is where someone finds out. - """ - out = run(target, capture, "memory:threads") - assert "Mach port names" in out - assert "help memory:threads" in out - - def test_the_threads_help_explains_all_three_platforms(shell, capture): shell.run_line("help memory:threads") for platform_name in ("Linux", "Windows", "macOS"): assert platform_name in capture.out + + +def test_results_export_writes_every_row(target, capture, block, tmp_path): + """An export exists for the results too many to read, so it takes them all.""" + import json + + from picklock import valuetypes + + base = block.ints_at + target.session.store_scan( + valuetypes.resolve("int32"), 4, [base, base + 4], [MARKER, MARKER], "int32 eq" + ) + target.session.set_option("limit", "1") # one row on screen, two in the file + + path = tmp_path / "found.json" + out = run(target, capture, f"scan:results --export {path}") + assert "Wrote 2 result(s)" in out + + document = json.loads(path.read_text()) + assert document["type"] == "int32" + assert document["width"] == 4 + assert document["process"]["pid"] == os.getpid() + assert [row["address"] for row in document["results"]] == [ + "0x%X" % base, + "0x%X" % (base + 4), + ] + assert document["results"][0]["value"] == MARKER + + +def test_results_export_spells_bytes_as_hex(target, capture, block, tmp_path): + """JSON has no form for bytes, so they take the spelling the table shows.""" + import json + + from picklock import valuetypes + + target.session.store_scan( + valuetypes.resolve("bytes"), 4, [block.blob_at], [b""], "aob" + ) + path = tmp_path / "bytes.json" + run(target, capture, f"scan:results --export {path}") + + assert json.loads(path.read_text())["results"][0]["value"] == "DE AD BE EF" + + +def test_results_export_reports_a_path_it_cannot_write(target, capture, block): + from picklock import valuetypes + + target.session.store_scan( + valuetypes.resolve("int32"), 4, [block.ints_at], [MARKER], "t" + ) + assert target.run_line("scan:results --export /nope/found.json") is False + assert "Cannot write" in capture.err diff --git a/tests/test_output.py b/tests/test_output.py index d85e976..6d9b3e7 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,6 +5,7 @@ from picklock.output import ( LEFT, RIGHT, + wait_for_enter, render_definitions, format_address, format_size, @@ -135,3 +136,42 @@ def test_the_gap_widens_both_the_first_line_and_the_wrapping(capture): assert first.startswith(" name a description") # The continuation sits under the description, not under the label. assert second.index(second.strip()[0]) == first.index("a description") + + +def test_waiting_for_enter_just_sleeps_without_a_terminal(): + """A pipe is at end-of-file the moment it is polled; that is not a keypress.""" + import io + import time + + stream = io.StringIO() + started = time.monotonic() + assert wait_for_enter(stream, 0.05) is False + assert time.monotonic() - started >= 0.04, "it waited rather than returning at once" + + +def test_waiting_for_enter_sees_a_keypress(): + """A real file descriptor, pretending to be a terminal.""" + import os + + read_fd, write_fd = os.pipe() + reader = os.fdopen(read_fd) + try: + reader.isatty = lambda: True # type: ignore[method-assign] + os.write(write_fd, b"\n") + assert wait_for_enter(reader, 1.0) is True + finally: + reader.close() + os.close(write_fd) + + +def test_waiting_for_enter_times_out_with_no_keypress(): + import os + + read_fd, write_fd = os.pipe() + reader = os.fdopen(read_fd) + try: + reader.isatty = lambda: True # type: ignore[method-assign] + assert wait_for_enter(reader, 0.05) is False + finally: + reader.close() + os.close(write_fd) From a85c7e5c60dbb6f6892ed20047ee9d153a6739e5 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 14:10:24 -0300 Subject: [PATCH 44/82] fix(export): write JSON as UTF-8 rather than escape sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refine's description carries an arrow, and Python escapes non-ASCII by default, so 'int32 eq 100 → changed' was landing in the file as 'int32 eq 100 → changed'. Valid JSON, read back correctly by any parser, and unreadable in a file whose whole job is to say what its numbers mean. The alias and settings files get the same treatment. Both are documented as hand-editable, and an alias with an accent in it should look like itself. --- picklock/commands/scan_commands.py | 6 +++++- picklock/store.py | 5 ++++- tests/test_aliases.py | 13 +++++++++++++ tests/test_end_to_end.py | 24 ++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/picklock/commands/scan_commands.py b/picklock/commands/scan_commands.py index 58f538d..0b858d4 100644 --- a/picklock/commands/scan_commands.py +++ b/picklock/commands/scan_commands.py @@ -754,7 +754,11 @@ def _export_results(session: Session, state: ScanState, path: str) -> int: try: with open(path, "w", encoding="utf-8") as handle: - json.dump(document, handle, indent=2) + # ensure_ascii=False: the file is UTF-8 and meant to be read, and + # a description like "int32 eq 100 → changed" should say that + # rather than "\u2192". Every JSON parser handles both; only one + # of them is legible. + json.dump(document, handle, indent=2, ensure_ascii=False) handle.write("\n") except OSError as error: raise CommandError(f"Cannot write {path!r}: {error}") diff --git a/picklock/store.py b/picklock/store.py index 0428895..f63ef55 100644 --- a/picklock/store.py +++ b/picklock/store.py @@ -89,7 +89,10 @@ def save(filename: str, data: Dict[str, Any]) -> None: ) try: with handle: - json.dump(data, handle, indent=2, sort_keys=True) + # UTF-8 out, not escape sequences: this file is documented as + # hand-editable, and an alias with an accent in it should look + # like itself. + json.dump(data, handle, indent=2, sort_keys=True, ensure_ascii=False) handle.write("\n") os.replace(handle.name, target) except BaseException: diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 294e0ff..0ae33b2 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -249,3 +249,16 @@ def test_the_location_follows_the_environment(monkeypatch, tmp_path): monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) if sys.platform != "win32": assert store.directory() == str(tmp_path / "xdg" / "picklock") + + +def test_the_alias_file_is_written_as_utf8(shell): + """It is documented as hand-editable, so it should look like what it holds.""" + shell.run_line("alias:add ler memory:read") + shell.session.aliases["café"] = ["memory:read"] + from picklock.commands.alias_commands import _persist + + _persist(shell.session) + + raw = pathlib.Path(store.path("aliases.json")).read_text(encoding="utf-8") + assert "café" in raw + assert "\\u00e9" not in raw diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index b8ab596..e130c2e 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -544,6 +544,30 @@ def test_results_export_writes_every_row(target, capture, block, tmp_path): assert document["results"][0]["value"] == MARKER +def test_results_export_is_written_as_utf8(target, capture, block, tmp_path): + """A refine's description carries an arrow; the file should show one. + + Python escapes non-ASCII by default, which is valid JSON and unreadable — + "int32 eq 100 \\u2192 changed" in a file whose whole job is to say what its + numbers mean. + """ + from picklock import valuetypes + + target.session.store_scan( + valuetypes.resolve("int32"), + 4, + [block.ints_at], + [MARKER], + "int32 eq 100 → changed", + ) + path = tmp_path / "arrow.json" + run(target, capture, f"scan:results --export {path}") + + raw = path.read_text(encoding="utf-8") + assert "→ changed" in raw + assert "\\u2192" not in raw + + def test_results_export_spells_bytes_as_hex(target, capture, block, tmp_path): """JSON has no form for bytes, so they take the spelling the table shows.""" import json From 819c6a9c56de35caf2c565b105fd4306ef555248 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 14:15:30 -0300 Subject: [PATCH 45/82] fix(memory): total the memory with access, not every reserved range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'memory:regions' reported 419 GB where PyMemoryEditor's app reported 39.4 GB for the same process. Neither was reading anything wrong: the app sums regions with at least one of read/write/execute, and Picklock summed all of them. The app's is the number worth having. Measured on a Python process here, 385 GB of a 391 GB total was a single anonymous range reserved with no access — a footprint dominated by one hole is not a footprint. The reserved figure is still named rather than dropped, so the difference is explained instead of quietly disappearing: 163 regions, 6.3 GB accessible (plus 385.0 GB reserved with no access) 'ps:info' had the same sum under the name "mapped", which was wrong twice over. It now lists Regions, Accessible, Writable, Executable and Reserved on their own lines. --- picklock/commands/memory_commands.py | 35 ++++++++++++++++++++------ picklock/commands/ps_commands.py | 14 +++++++++-- tests/test_end_to_end.py | 37 ++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/picklock/commands/memory_commands.py b/picklock/commands/memory_commands.py index f26d77d..f2e983d 100644 --- a/picklock/commands/memory_commands.py +++ b/picklock/commands/memory_commands.py @@ -75,6 +75,32 @@ def _input_stream(session: Session): return shell.stdin if shell is not None else sys.stdin +def _totals(regions) -> str: + """The totals for what was listed, filters included. + + Accessible first, because summing every region answers a question nobody + asks: a process holds enormous ranges of *reserved* address space with no + access at all — on macOS a single anonymous 384 GB hole — and a footprint + dominated by one of those is a number you cannot use. The reserved figure + is still named, so the difference is explained rather than hidden. + """ + accessible = sum( + region.size + for region in regions + if region.is_readable or region.is_writable or region.is_executable + ) + reserved = sum(region.size for region in regions) - accessible + + count = len(regions) + line = ( + f"{count} region{'' if count == 1 else 's'}, " + f"{format_size(accessible)} accessible" + ) + if reserved: + line += f" (plus {format_size(reserved)} reserved with no access)" + return line + + def _permissions(region) -> str: """Render a region's access bits the way ``/proc/*/maps`` does.""" return "".join( @@ -181,14 +207,7 @@ def cmd_regions(session: Session, args: List[str]) -> None: next_page=page.next_page, ) - # The totals for what was listed, filters included — "the writable regions - # come to 1.6 GB" is the answer people are usually after, and counting the - # rows of one page cannot give it. - mapped = sum(region.size for region in regions) - session.printer.write( - f"{len(regions)} region{'' if len(regions) == 1 else 's'}, " - f"{format_size(mapped)} mapped" - ) + session.printer.write(_totals(regions)) session.printer.write() diff --git a/picklock/commands/ps_commands.py b/picklock/commands/ps_commands.py index e110ac4..d26f06d 100644 --- a/picklock/commands/ps_commands.py +++ b/picklock/commands/ps_commands.py @@ -222,7 +222,12 @@ def cmd_info(session: Session, args: List[str]) -> None: with Timer() as timer: regions = session.regions(refresh=True) - mapped = sum(region.size for region in regions) + accessible = sum( + region.size + for region in regions + if region.is_readable or region.is_writable or region.is_executable + ) + reserved = sum(region.size for region in regions) - accessible writable = sum(region.size for region in regions if region.is_writable) executable = sum(region.size for region in regions if region.is_executable) @@ -234,9 +239,14 @@ def cmd_info(session: Session, args: List[str]) -> None: ("Architecture", "64-bit" if process.is_64bit else "32-bit"), ("Bitness certain", "yes" if process.is_bitness_certain else "no (assumed)"), ("Pointer size", f"{process.pointer_size} bytes"), - ("Regions", f"{len(regions)} ({format_size(mapped)} mapped)"), + ("Regions", len(regions)), + # Accessible, not the sum of every region: a process reserves address + # space it cannot touch — on macOS often hundreds of gigabytes of it — + # and totalling that says nothing about the process. + ("Accessible", format_size(accessible)), ("Writable", format_size(writable)), ("Executable", format_size(executable)), + ("Reserved", format_size(reserved)), ("Main thread", main_thread.tid if main_thread else "unknown"), ] diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index e130c2e..d374309 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -19,6 +19,8 @@ import pytest +from picklock.output import format_size + #: A value unlikely to be lying around in a Python process, so a scan for it #: finds the block below and little else. MARKER = 0x5C0FFEE1 @@ -492,9 +494,9 @@ def test_counting_forward_steps_by_the_scans_width(target, capture): def test_regions_reports_the_count_and_the_size(target, capture): - """A page of rows cannot answer "how much is mapped?".""" + """A page of rows cannot answer "how much is there?".""" out = run(target, capture, "memory:regions --limit 2") - match = re.search(r"(\d+) regions, ([\d.]+ \w+) mapped", out) + match = re.search(r"(\d+) regions, ([\d.,]+ \w+) accessible", out) assert match, out assert int(match.group(1)) > 2, "the whole set, not the page" @@ -511,6 +513,37 @@ def test_the_regions_total_follows_the_filter(target, capture): assert int(writable.group(1)) < int(everything.group(1)) +def test_the_total_counts_only_memory_with_access(target, capture): + """Summing every region answers a question nobody asks. + + A process reserves address space it cannot touch — on macOS a single + anonymous range of hundreds of gigabytes — so a total that includes it is + dominated by a hole. This is also what PyMemoryEditor's own app reports, + and the two disagreeing on the same process is what surfaced it. + """ + from picklock.commands.memory_commands import _totals + + regions = target.session.regions(refresh=True) + accessible = sum( + region.size + for region in regions + if region.is_readable or region.is_writable or region.is_executable + ) + everything = sum(region.size for region in regions) + + line = _totals(regions) + assert format_size(accessible) in line + if everything != accessible: + assert "reserved with no access" in line + assert format_size(everything - accessible) in line + + +def test_ps_info_separates_accessible_from_reserved(target, capture): + out = run(target, capture, "ps:info") + assert "Accessible:" in out + assert "Reserved:" in out + + def test_the_threads_help_explains_all_three_platforms(shell, capture): shell.run_line("help memory:threads") for platform_name in ("Linux", "Windows", "macOS"): From b5ef061b67ac535bd43cb83e4bf7b72829621c33 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 14:24:57 -0300 Subject: [PATCH 46/82] chore(config): raise the max_results default to a million MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a hundred thousand, which a byte-wide scan passes without trying — and a result set truncated that early makes '#N' point at an arbitrary early address rather than anything you were looking for. Measured at the new cap on a live process: a full million results takes about 1.2 s to collect and around 175 MB. Worth knowing before setting it to 0, which still means no cap at all. --- picklock/session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/picklock/session.py b/picklock/session.py index 44b6081..ea99abf 100644 --- a/picklock/session.py +++ b/picklock/session.py @@ -49,7 +49,7 @@ class Setting: #: Every session setting, in the order ``set`` prints them. SETTINGS: Tuple[Setting, ...] = ( Setting("limit", 20, int, "Rows printed per result table (0 = no limit)."), - Setting("max_results", 100000, int, "Scan hits kept in memory (0 = no cap)."), + Setting("max_results", 1000000, int, "Scan hits kept in memory (0 = no cap)."), Setting("hex", False, bool, "Print integer values in hexadecimal."), Setting("timing", True, bool, "Print the elapsed time after each command."), Setting("progress", True, bool, "Show a progress line while scanning."), From 27af9bbe7a55dd7cd4a69bbadf8fba4ba30b78c0 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 14:29:35 -0300 Subject: [PATCH 47/82] docs: rewrite the README plainly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old one was written to sell: centred badge blocks, flag emoji for the three platforms, a comparison table against the GUI, a star request. Picklock's readers are people who already know what a memory scanner is and want to know what this one types like. So: what it is, a session, the commands, the address syntax, how to script it, what privileges each platform wants, where the files live, how to run the tests. No emoji, no badges, a third shorter. Checked rather than written from memory — a script compares every namespace's command list, every flag and every command named in prose against the running registry. All seven rows and every flag match. --- README.md | 337 ++++++++++++++---------------------------------------- 1 file changed, 88 insertions(+), 249 deletions(-) diff --git a/README.md b/README.md index 8463cad..8e7a9da 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,20 @@ # Picklock -A **terminal client for [PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor)** — read, write and scan the memory of a running process from any shell, on any machine, over any SSH session. +A terminal client for [PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor). +Read, write and scan the memory of a running process from any shell, on Windows, +Linux and macOS. ---- +No GUI toolkit, no display, no compiler. One dependency, pure Python, so it +installs on a bare server as readily as on a desktop. -

- Cheat Engine workflows, typed instead of clicked.
- One pip install. No GUI toolkit. No X server. No compiler. -

- -

- Runs on 🪟 Windows · 🐧 Linux · 🍎 macOS — desktops, servers and containers alike. -

- -

- Python Package - PyPI - License - Python Version - Coverage - Downloads -

- ---- - -## Install - -```bash +``` pip install picklock picklock ``` -That is the whole setup. Picklock's only dependency is PyMemoryEditor, which is -pure Python — so it installs on a bare server with no wheels to build, no Qt, -and no display. +## Usage -For faster scans on large targets, add the `speed` extra. It pulls in NumPy, -which PyMemoryEditor picks up automatically to vectorise the scan loop: - -```bash -pip install "picklock[speed]" ``` - -## A session - -```console $ picklock Welcome to Picklock 0.1.0, a terminal client for PyMemoryEditor. Type 'help' for the command list, or 'help scanning' for a walkthrough. @@ -61,7 +31,8 @@ picklock> ps:open 41902 Attached to game.exe (PID 41902, 64-bit). (0.00 sec) picklock [game.exe:41902]> scan:value int32 100 --writable -Showing 20 of 3184 rows (1.42 sec) +Showing 20 of 3184 rows — page 1 of 160 (1.42 sec) +Next page: scan:results --page 2 picklock [game.exe:41902]> scan:next 95 +-----+--------------------+-------+ @@ -84,252 +55,120 @@ picklock [game.exe:41902]> memory:write #1 int32 9999 Wrote 4 byte(s) to 0x00000201A4C0F118. (0.00 sec) ``` -Found the address, but it moves every launch? Find the pointer path to it, and -keep it: - -```console -picklock [game.exe:41902]> pointer:scan #1 --depth 3 --max 100 -+-----+------------------+-------------+--------------------+ -| ROW | BASE | OFFSETS | TARGET | -+-----+------------------+-------------+--------------------+ -| #1 | game.exe+0x3BA228 | 0x3E8 | 0x00000201A4C0F118 | -| #2 | game.exe+0x3B9B70 | 0x310 0x168 | 0x00000201A4C0F118 | -+-----+------------------+-------------+--------------------+ -2 rows in set (6.18 sec) - -picklock [game.exe:41902]> pointer:save health.json -Saved 2 path(s) to health.json. - -# ... restart the target, find the value again, then: -picklock [game.exe:52771]> pointer:rescan #1 health.json -1 path(s) still reach 0x000001F73C20E118. (0.03 sec) - -picklock [game.exe:52771]> pointer:read game.exe+0x3BA228 0x3E8 --write 9999 -Wrote 4 byte(s) to 0x000001F73C20E118. (0.00 sec) -``` - -## Scriptable, too - -The same vocabulary works non-interactively, which is the point of a CLI on a -server: - -```bash -picklock ps:list chrome # one command, then exit -picklock -p 4242 -e "memory:read game.exe+0x1234" # attach, read, exit -picklock -p 4242 -e "scan:value int32 100" -e "scan:results" # several, in order -picklock -f setup.peek # a file of commands -echo "ps:list" | picklock # a pipe -``` - -Results go to stdout and errors to stderr, tables are plain ASCII, colour is -off whenever the output is not a terminal — in a terminal it amounts to a red -`ERROR` and a dimmed target in the prompt, and nothing else — and a failing -command exits non-zero — so `picklock -e ... | grep`, `>> log.txt` and `&& deploy` all behave. - -## What it can do - -The help is layered. `help` shows the words you can type first — ten lines, -not a wall of forty — and `:help` opens any of them. A command that -takes a subcommand documents itself with a usage line, a worked example, and -its commands with the arguments they take. - -```console -picklock> scan:help -usage: scan[:SUBCOMMAND] - -Search memory for a value, then narrow what you found. +`help scanning` walks through that cycle, including the comparisons that need +no value at all (`--changed`, `--increased`) for when you cannot see the number +you are looking for. -Example: +## Commands - picklock> scan:value int32 100 --writable - Showing 20 of 3184 rows (1.42 sec) +Every command is `namespace:command`. Typing a namespace alone prints its page; +`help ` prints one command's arguments, generated from the parser that +runs it, so the two cannot disagree. - picklock> scan:next 95 - +-----+--------------------+-------+ - | ROW | ADDRESS | VALUE | - +-----+--------------------+-------+ - | #1 | 0x00000201A4C0F118 | 95 | - +-----+--------------------+-------+ - 1 row in set (0.02 sec) - -scan subcommands: (get help with "help scan:SUBCOMMAND") - - scan:aob [--max N] Scan for a byte pattern with wildcards (AOB). - scan:drop [row ...] Remove the named result rows. - scan:keep [row ...] Keep only the named result rows. - scan:next [value] [--eq VALUE] [--ne... Narrow the results with another comparison. - scan:regex [--length N] [--max N] Scan for text matching a regular expression. - scan:reset Discard the current scan results. - scan:results [--limit N] [--page N] [--all] Show the current result set, re-read. - scan:value [value] [--eq VALUE]... Search the whole address space for a value. -``` - -Every command answers `:help`, at any depth — `scan:help`, `scan:aob:help`, -`clear:help` — so there is one rule and nothing to learn about which words are -which. Names go two levels at most, so there is never a third listing to walk. - -A command that takes a subcommand never runs anything itself: typing `scan` -prints its page, whichever way you ask — `scan`, `scan --help`, `scan:help` -and `help scan` all produce the same output. - -| Command | Subcommands | +| Namespace | Commands | | --- | --- | -| **`ps:`** | `list` · `open` · `close` · `info` | -| **`memory:`** | `read` · `write` · `hex` · `watch` · `regions` · `modules` · `threads` · `alloc` · `free` | -| **`scan:`** | `value` · `next` · `aob` · `regex` · `results` · `keep` · `drop` · `reset` | -| **`pointer:`** | `deref` · `read` · `scan` · `rescan` · `paths` · `save` · `load` · `diff` | -| **`config:`** | `list` · `set` · `reset` | -| **`alias:`** | `add` · `list` · `remove` | -| Top level | `help` · `source` · `version` · `clear` · `exit` | - -`help ` — or ` --help` — documents each one in full: every -argument, every flag, and examples. That list is generated from the command's -own parser, so it is always exactly what the command accepts: - -```console -picklock> help memory:hex -memory:hex — Show a range of memory as hex and text. - -Usage: memory:hex
[length] [--width N] [--watch] [--interval S] - -Arguments: - address address expression: a literal, module+offset, [pointer] or #N — - see 'help address' - [length] number of bytes to read (default 256); hex accepted - -Options: - --width N bytes per line, overriding the 'hex_width' setting - -w, --watch redraw as the bytes change, until ENTER - --interval S seconds between redraws with --watch, overriding - 'watch_interval' -``` - -`help types`, `help address` and `help scanning` cover what several commands -share. - -Highlights: - -- **Every scan comparison PyMemoryEditor exposes** — `--eq`, `--ne`, `--gt`, - `--lt`, `--ge`, `--le`, `--between` — plus the refine-only ones that need no - value at all: `scan:next --changed`, `--unchanged`, `--increased`, - `--decreased`, `--increased-by N`. Comparisons are flags, so the value slot - only ever holds a value: `scan:next changed` looks for the word. -- **AOB and regex scans.** `scan:aob "48 8B ? ? 00"` finds a signature - with wildcards; `scan:regex "Player[0-9]+"` finds text. -- **Thirteen value types** — `int8` … `int64`, `uint8` … `uint64`, `float`, - `double`, `bool`, `string`, `bytes` — with the aliases you would expect - (`dword`, `qword`, `short`, `f32`). -- **Pointer scanning and the full rescan workflow**, so an address survives a - restart. -- **`memory:watch`**, which turns a terminal into a live cheat table: - `memory:watch game.exe+0x1234 int32` prints a line every time the value - changes. -- **Progress you can trust.** Long scans report a percentage that advances - whether or not anything is being found, and Ctrl+C stops a scan while keeping - what it already found. -- **Names of your own, remembered.** `alias:add r memory:read` makes `r` do - the same thing; `alias:add find-text scan:value string` carries arguments - along, so `find-text Picklock` runs `scan:value string Picklock`. A name - already taken by a command is refused rather than shadowing it, and the - aliases are still there next time you open a terminal. -- **Every listing pages the same way.** `--limit`, `--page` and `--all` on each - of them — `-l`, `-p`, `-a` for short — a footer that says where you are: - `Showing 20 of 3184 rows — page 1 of 160`, and the command for the next page - spelled out underneath, so it is a copy-paste rather than a puzzle. -- **Ctrl+C means the obvious thing.** During a command it abandons that command - and returns to the prompt; at the prompt it quits. So stopping a scan costs - one keystroke and leaving costs two, and neither one loses your results by - surprise. Ctrl+D and `exit` quit too. - -### Addresses are expressions +| `ps:` | `list` `open` `close` `info` | +| `memory:` | `read` `write` `hex` `watch` `regions` `modules` `threads` `alloc` `free` | +| `scan:` | `value` `next` `aob` `regex` `results` `keep` `drop` `reset` | +| `pointer:` | `scan` `deref` `read` `rescan` `paths` `save` `load` `diff` | +| `alias:` | `add` `list` `remove` | +| `config:` | `list` `set` `reset` | +| top level | `help` `source` `version` `clear` `exit` | + +Notable: + +- **Scanning.** Every comparison PyMemoryEditor exposes, as flags: `--eq`, + `--ne`, `--gt`, `--lt`, `--ge`, `--le`, `--between`, plus the refine-only + `--changed`, `--unchanged`, `--increased`, `--decreased`, `--increased-by`. + AOB with wildcards (`scan:aob "48 8B ? ? 00"`) and text regex + (`scan:regex "Player[0-9]+"`). +- **Pointer chains.** `pointer:scan` finds the static paths that reach an + address; `pointer:save`, `pointer:rescan` and `pointer:diff` are the workflow + that separates a path that survives a restart from a coincidence. +- **`memory:watch`** follows one value; **`memory:hex --watch`** redraws a whole + range in place. Both stop on ENTER. +- **Paging.** `--limit`, `--page`, `--all` on every listing (`-l`, `-p`, `-a`), + a footer that says where you are, and the command for the next page spelled + out under it. +- **`scan:results --export results.json`** writes every result, not the page on + screen. + +## Addresses Anywhere an address is taken: ``` -0x7ffee3a01000 a literal (decimal works too) +0x7ffee3a01000 a literal; decimal works too game.exe+0x1234 a module base plus a static offset — survives ASLR [game.exe+0x1234]+0x10 dereference, then add [[base+0x8]+0x20]+0x4 nested as deeply as you like -#3 the address on row 3 of the last scan +#3 row 3 of the last scan ``` -So the whole chain fits on one line: -`memory:read [[game.exe+0x1a2b3c]+0x10]+0x8 float`. +So a whole chain fits on one line: `memory:read [[game.exe+0x1a2b3c]+0x10]+0x8 float`. + +A `#N` row is read with the type the scan that found it used, not a default. -### Where things are kept +## Scripting -Picklock remembers your aliases and your settings, so the shell comes back the -way you left it. Both live in `$XDG_CONFIG_HOME/picklock/` — by default -`~/.config/picklock/`, or `%APPDATA%\picklock` on Windows — as -`aliases.json` and `settings.json`. `alias:list` and `config:list` print their -paths, and `PICKLOCK_CONFIG_DIR` moves both. +Picklock is a shell first, but the same vocabulary runs non-interactively: + +``` +picklock ps:list chrome # one command, then exit +picklock -p 4242 -e "memory:read game.exe+0x1234" +picklock -p 4242 -e "scan:value int32 100" -e "scan:results" +picklock -f setup.picklock # a file of commands +echo "ps:list" | picklock # a pipe +``` -Only the settings you actually changed are stored, so a default that moves in a -later release still reaches you. `config:reset` puts one back, or all of them — -which is what restarting used to do. +Results on stdout, errors on stderr, plain ASCII tables, colour off whenever +the output is not a terminal, and a non-zero exit on failure — so +`| grep`, `>> log` and `&& deploy` all behave. ## Permissions -Reading another process's memory is a privileged operation everywhere: +Reading another process's memory is privileged everywhere: -- **Windows** — run your terminal as Administrator to touch processes you do - not own. -- **Linux** — `sudo picklock`, or grant the capability once with +- **Linux** — `sudo picklock`, or grant it once with `sudo setcap cap_sys_ptrace+ep $(readlink -f $(which python3))`. Some distributions also need `/proc/sys/kernel/yama/ptrace_scope` set to `0`. +- **Windows** — run the terminal as Administrator to touch processes you do not + own. - **macOS** — SIP blocks reading most processes. `sudo picklock` works for - processes you own; anything else needs a signed binary carrying the debugger + processes you own; anything else needs a binary signed with the debugger entitlement. -Picklock says which of these applies when an `open` is refused. - -## Picklock vs. the PyMemoryEditor app - -They are different front ends to the same library, and installing one does not -install the other: +Picklock names whichever applies when an `ps:open` is refused. -| | **Picklock** | **PyMemoryEditor's app** | -| --- | --- | --- | -| Interface | terminal, ASCII | desktop GUI (Qt) | -| Install | `pip install picklock` | `pip install "PyMemoryEditor[app]"` | -| Needs a display | no | yes | -| Scriptable | yes — `-e`, `-f`, pipes | no | -| Good for | servers, SSH, CI, automation | interactive exploration on a desktop | +## Files -## Related - -Picklock is a client. Every read, write, scan and pointer walk is performed by -**[PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor)** — the -cross-platform memory library it is built on. +Aliases and settings persist, in `$XDG_CONFIG_HOME/picklock/` (default +`~/.config/picklock/`, `%APPDATA%\picklock` on Windows) as `aliases.json` and +`settings.json`. `alias:list` and `config:list` print their paths; +`PICKLOCK_CONFIG_DIR` moves both. Only settings you changed are stored, so a +default that moves in a later release still reaches you. -⭐ **If Picklock is useful to you, star the repo — and -[star PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor) too.** -It is the engine underneath, and it is what makes any of this work on three -operating systems at once. +## Development -## Contributing - -Issues and pull requests are welcome. - -```bash +``` git clone https://github.com/JeanExtreme002/Picklock cd Picklock -make install-dev # pip install -e ".[dev]" -make pre-commit # lint + type-check + tests +make install-dev +make pre-commit # lint, type-check, tests +pytest -m "not slow" # ~2 s, skipping the live scans ``` -`make help` lists every target. Every command is covered end-to-end against a -real process — the test process itself, so the suite needs no privileges and -no second program to launch. +Every command is covered end-to-end against a real process — the test process +itself, so the suite needs no privileges and no second program to launch. See +[CONTRIBUTING.md](CONTRIBUTING.md). -[**CONTRIBUTING.md**](CONTRIBUTING.md) covers the project layout, the two rules -that keep its shape, and how to add a command (it is one decorator, and `help` -plus the tests come along for free). +## Related -- 🐛 [Report a bug](https://github.com/JeanExtreme002/Picklock/issues/new?template=bug_report.md) -- 💡 [Request a feature](https://github.com/JeanExtreme002/Picklock/issues/new?template=feature_request.md) -- 🔒 [Security policy](SECURITY.md) — please do **not** open a public issue -- 🤝 [Code of Conduct](CODE_OF_CONDUCT.md) +Every read, write, scan and pointer walk is performed by +[PyMemoryEditor](https://github.com/JeanExtreme002/PyMemoryEditor), the +cross-platform memory library Picklock is built on. If you find Picklock +useful, star that one too — it is what makes any of this work on three +operating systems at once. ## License From 0e02b13b951cf8bda5e9a5f2aa726d1368ecd422 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 16:55:02 -0300 Subject: [PATCH 48/82] docs: tighten the README intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the sentence listing what Picklock does not need — no GUI toolkit, no display, no compiler. The line below it already says pure Python and one dependency, which is the same claim stated positively. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 8e7a9da..09502ff 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,7 @@ A terminal client for [PyMemoryEditor](https://github.com/JeanExtreme002/PyMemor Read, write and scan the memory of a running process from any shell, on Windows, Linux and macOS. -No GUI toolkit, no display, no compiler. One dependency, pure Python, so it -installs on a bare server as readily as on a desktop. +One dependency, pure Python, so it installs on a bare server as readily as on a desktop. ``` pip install picklock From 2786093c1876f6e1ab5c315f035e12b49e5848b5 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 16:55:13 -0300 Subject: [PATCH 49/82] docs: show a real terminal session in the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picture under the install snippet is a capture of the shell doing the thing the README describes, not a mock-up. scripts/generate_terminal_image.py plants a value in its own memory, drives the real commands against its own process — the trick the end-to-end suite uses, so it needs no privileges and no second program — changes the value between the two scans so --decreased has something true to narrow, and renders the transcript with headless Chrome, the way build_preview.py does it in PyMemoryEditor. Every address, row count and timing in the image is whatever that run produced, which is what keeps it honest as the output changes: regenerate with 'make screenshot' rather than editing the picture. The helper and the image stay out of the sdist. --- Makefile | 10 + README.md | 11 + assets/screenshots/terminal.png | Bin 0 -> 249725 bytes pyproject.toml | 5 +- scripts/generate_terminal_image.py | 377 +++++++++++++++++++++++++++++ 5 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 assets/screenshots/terminal.png create mode 100644 scripts/generate_terminal_image.py diff --git a/Makefile b/Makefile index a117308..a3d262f 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ help: @echo " $(YELLOW)lint$(NC) - Run linter (flake8)" @echo " $(YELLOW)type-check$(NC) - Run type checker (mypy)" @echo " $(YELLOW)smoke$(NC) - Check the installed console script starts" + @echo " $(YELLOW)screenshot$(NC) - Regenerate the README terminal capture" @echo " $(YELLOW)clean$(NC) - Clean build artifacts" @echo " $(YELLOW)build$(NC) - Build package" @echo " $(YELLOW)build-wheel$(NC) - Build wheel package" @@ -147,6 +148,15 @@ smoke: $(PYTHON) -m $(PACKAGE_NAME) ps:list --limit 5 > /dev/null @echo "$(GREEN)Console script works!$(NC)" +# Regenerate the terminal capture in the README. Runs the real commands +# against a real process and screenshots the transcript, so it needs a +# Chrome/Chromium/Edge on the machine (set BROWSER to point at a specific one). +.PHONY: screenshot +screenshot: + @echo "$(GREEN)Regenerating the README terminal capture...$(NC)" + $(PYTHON) scripts/generate_terminal_image.py + @echo "$(GREEN)Screenshot written to assets/screenshots/terminal.png$(NC)" + # Clean build artifacts .PHONY: clean clean: diff --git a/README.md b/README.md index 09502ff..62ded29 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,17 @@ pip install picklock picklock ``` +

+ A Picklock session: scanning a live process for a value, narrowing it down, and writing to it +

+ +

+ A real session, captured by a script that runs the commands — + no numbers in it were typed by hand. +

+ ## Usage ``` diff --git a/assets/screenshots/terminal.png b/assets/screenshots/terminal.png new file mode 100644 index 0000000000000000000000000000000000000000..e65f9d7ce322706332bbcd3dbad3d9d2765c97af GIT binary patch literal 249725 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

ip6 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%0A~;;9?z@l16J1_ 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%0D3ASyu9`$#TsWia%-m(K`n~1B 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

QxF(o>;Ot5 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 4221dfe..be847d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,8 +98,11 @@ packages = ["picklock"] [tool.hatch.build.targets.wheel.force-include] "picklock/py.typed" = "picklock/py.typed" +# The wheel carries the package alone. The sdist additionally drops the +# repository furniture: CI config, the maintainer-only screenshot helper and +# the image it produces, none of which a person installing Picklock needs. [tool.hatch.build.targets.sdist] -exclude = ["/.github"] +exclude = ["/.github", "/scripts", "/assets"] [tool.mypy] ignore_missing_imports = true diff --git a/scripts/generate_terminal_image.py b/scripts/generate_terminal_image.py new file mode 100644 index 0000000..5903666 --- /dev/null +++ b/scripts/generate_terminal_image.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +""" +Regenerate the terminal capture shown in the README. + +This is a maintainer-only helper — it is intentionally kept out of the +published package (see the sdist excludes in ``pyproject.toml``). + +Nothing in the image is typed by hand. The script drives the *real* shell +against a real process — this one, the same trick the end-to-end suite uses, +which is what lets it run anywhere without privileges and without a second +program to launch — and records exactly what a user would have seen, escape +codes included. The addresses, the row counts and the timings in the picture +are whatever that run produced. + +The scan story it stages is real too: a value is planted in this process's +memory, scanned for, then *changed* between the two scans, so the +``--decreased`` refinement narrows a few hundred candidates to the one +address that actually moved. That is the loop the README describes, executed rather than +illustrated. + +The transcript is then rendered as HTML and screenshotted with headless +Chrome, the same way ``build_preview.py`` does it in PyMemoryEditor. + +Usage: + pip install -e . + python scripts/generate_terminal_image.py + python scripts/generate_terminal_image.py --scale 1 # 1x instead of retina + BROWSER=/path/to/chrome python scripts/generate_terminal_image.py +""" + +import argparse +import ctypes +import html +import io +import math +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import List, Tuple + +REPO_ROOT = Path(__file__).resolve().parent.parent +OUT = REPO_ROOT / "assets" / "screenshots" / "terminal.png" + +# Run against a throwaway configuration directory. Picklock persists aliases +# and settings, and a maintainer's own would otherwise leak into the picture — +# or worse, be written to by it. Set before the import so `store` never looks +# anywhere else. +_CONFIG_DIR = tempfile.TemporaryDirectory(prefix="picklock-screenshot-") +os.environ["PICKLOCK_CONFIG_DIR"] = _CONFIG_DIR.name + +sys.path.insert(0, str(REPO_ROOT)) + +from picklock.shell import Shell # noqa: E402 (must follow the env var above) + +#: The value planted in this process, and what it drops to before the refine. +#: Both are arbitrary; what matters is that the second is smaller. +HEALTH = 1337 +DAMAGED = 1200 + + +# -- the staged process --------------------------------------------------- + + +class Target: + """Live memory in this process, with contents the demo can rely on. + + Held for the length of the run: a ctypes buffer that goes out of scope is + freed, and the scan would then be hunting a page that no longer exists. + """ + + def __init__(self) -> None: + self.health = (ctypes.c_int32 * 4)(HEALTH, HEALTH, 0, 0) + self.name = ctypes.create_string_buffer(b"PicklockDemo\x00") + + def take_damage(self) -> None: + self.health[0] = DAMAGED + + +# -- recording ------------------------------------------------------------ + + +def record() -> List[str]: + """Run the demo and return the transcript, one line per element. + + Lines keep their escape codes: the renderer below turns those into colour, + so what lands in the image is what the terminal would have drawn. + """ + target = Target() + buffer = io.StringIO() + + shell = Shell() + shell.printer.stdout = buffer + shell.printer.stderr = buffer + shell.printer.color = True + + pid = os.getpid() + + #: (line, hook) — the hook runs *before* the line, so the process really + #: has changed by the time the command that notices it runs. The display + #: limit is turned down so the first scan's table stays a sample rather + #: than twenty rows of the same number; the footer still reports the true + #: total, and turning it down is itself a command worth showing. + script: List[Tuple[str, object]] = [ + (f"ps:open {pid}", None), + ("config:set limit 3", None), + (f"scan:value int32 {HEALTH} --writable", None), + ("scan:next --decreased", target.take_damage), + ("memory:write #1 int32 9999", None), + ("memory:hex #1 16", None), + ] + + lines = shell.banner().rstrip("\n").split("\n") + + for line, hook in script: + if hook is not None: + hook() + + prompt = shell.prompt() + buffer.seek(0) + buffer.truncate() + + if not shell.run_line(line): + sys.exit(f"the demo line {line!r} failed:\n{buffer.getvalue()}") + + lines.append("") + lines.append(f"{prompt}{line}") + lines.extend(buffer.getvalue().rstrip("\n").split("\n")) + + shell.run_line("ps:close") + return lines + + +# -- rendering ------------------------------------------------------------ + +#: Only what Picklock actually emits: its one red, its one grey, and reset. +_CLASSES = {"31": "err", "38;5;247": "dim"} + +_ANSI = re.compile(r"\033\[([0-9;]*)m") + +#: readline's width-ignoring brackets. They never reach a screen; the prompt +#: only carries them when readline is driving input, which it is not here, but +#: strip them so a future change cannot put control characters in the picture. +_READLINE_MARKS = re.compile(r"[\001\002]") + + +def flatten(line: str) -> str: + """Resolve a carriage return the way a terminal would: last write wins. + + The scan's progress line is drawn over itself with ``\r`` and then erased + with blanks. On a screen only the final state is ever seen, and the image + should show the same thing rather than every frame end to end. + """ + return _READLINE_MARKS.sub("", line).split("\r")[-1] + + +def to_html(line: str) -> str: + """Turn one recorded line into HTML, honouring its escape codes.""" + out: List[str] = [] + depth = 0 + position = 0 + + for match in _ANSI.finditer(line): + out.append(html.escape(line[position:match.start()])) + position = match.end() + + code = match.group(1) + if code in ("", "0"): + out.append("" * depth) + depth = 0 + elif code in _CLASSES: + out.append(f'') + depth += 1 + + out.append(html.escape(line[position:])) + out.append("" * depth) + return "".join(out) + + +#: Menlo, SF Mono, Consolas, DejaVu Sans Mono and Liberation Mono all advance +#: very close to 0.602 em, so the window can be sized without asking the +#: browser to measure anything. Two columns of slack absorb the difference. +ADVANCE = 0.602 +FONT_SIZE = 13.5 +LINE_HEIGHT = 20 +PAD_X, PAD_Y = 22, 18 +TITLEBAR = 38 +MARGIN = 26 + +#: How much wider than tall the finished picture should be. A capture that is +#: taller than it is wide swallows a README page, and a terminal is free to be +#: wider than its longest line — the width comes from the window, not from the +#: text — so the empty columns on the right are what a real one looks like. +#: Trim the demo rather than raising this if the transcript outgrows it. +ASPECT = 1.06 + +TEMPLATE = """ + + +

+
+
+
+
+
picklock
+
+
{body}
+
+""" + + +def build_page(lines: List[str]) -> Tuple[str, int, int]: + """Return the HTML and the window size that fits it exactly.""" + lines = [flatten(line) for line in lines] + widths = [len(_ANSI.sub("", line)) for line in lines] + + card_h = TITLEBAR + PAD_Y * 2 + len(lines) * LINE_HEIGHT + character = FONT_SIZE * ADVANCE + + # Wide enough for the longest line, and for the aspect — whichever asks + # for more. + columns = max( + max(widths) + 2, + math.ceil((card_h * ASPECT - PAD_X * 2) / character), + ) + card_w = round(columns * character) + PAD_X * 2 + + page = TEMPLATE.format( + margin=MARGIN, + card_w=card_w, + titlebar=TITLEBAR, + pad_x=PAD_X, + pad_y=PAD_Y, + font_size=FONT_SIZE, + line_height=LINE_HEIGHT, + body="\n".join(to_html(line) for line in lines), + ) + # +2 for the card's own border, which sits outside the declared width. + return page, card_w + MARGIN * 2 + 2, card_h + MARGIN * 2 + 2 + + +# -- the browser ---------------------------------------------------------- + +CANDIDATES = { + "darwin": [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ], + "linux": [ + "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", + "microsoft-edge", "microsoft-edge-stable", + ], + "win32": [ + r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", + ], +} + + +def find_browser() -> str: + override = os.environ.get("BROWSER") + if override: + if Path(override).exists() or shutil.which(override): + return override + sys.exit(f"BROWSER={override!r} not found.") + + platform = "win32" if sys.platform.startswith("win") else \ + "darwin" if sys.platform == "darwin" else "linux" + + for candidate in CANDIDATES[platform]: + if Path(candidate).exists() or shutil.which(candidate): + return candidate + + for name in ("google-chrome", "chromium", "chrome", "msedge"): + found = shutil.which(name) + if found: + return found + + sys.exit( + "No Chrome/Chromium/Edge found. Install one, or set the BROWSER " + "env var to its full path." + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--scale", type=int, default=2, + help="device scale factor: 2 = retina (default), 1 = exact CSS pixels", + ) + parser.add_argument( + "--keep-html", action="store_true", + help="leave the intermediate HTML next to the PNG, for tweaking the CSS", + ) + args = parser.parse_args() + + page, width, height = build_page(record()) + + OUT.parent.mkdir(parents=True, exist_ok=True) + source = OUT.with_suffix(".html") + source.write_text(page, encoding="utf-8") + + browser = find_browser() + print(f"Using browser: {browser}") + + command = [ + browser, + "--headless", + "--disable-gpu", + "--hide-scrollbars", + f"--force-device-scale-factor={args.scale}", + "--default-background-color=00000000", + f"--window-size={width},{height}", + f"--screenshot={OUT}", + source.as_uri(), + ] + + result = subprocess.run(command) + if not args.keep_html: + source.unlink() + + if result.returncode != 0 or not OUT.exists(): + sys.exit(f"Screenshot failed (exit {result.returncode}).") + + print(f"Wrote {OUT} ({width * args.scale}x{height * args.scale})") + + +if __name__ == "__main__": + main() From e3fc82f535895fa14b3c2dccb4b4f3d56f419da7 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 17:02:43 -0300 Subject: [PATCH 50/82] docs: shorten the terminal capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the 'scan:next --decreased' step. It cost eight lines — a second table and its footer — and the picture was taller than a README wants to be. The write now lands on the first row the value scan returned, which is the more ordinary thing to do with a fresh result set, and the hexdump still proves it: the range opens 0F 27, little-endian for the 9999 the line above wrote. The planted value stays, since the scan needs something to find; what goes is the mutation that only the refine step observed. --- assets/screenshots/terminal.png | Bin 249725 -> 212883 bytes scripts/generate_terminal_image.py | 29 +++++++++++++++-------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/assets/screenshots/terminal.png b/assets/screenshots/terminal.png index e65f9d7ce322706332bbcd3dbad3d9d2765c97af..072f84be76ee7041332309dae8e45cfd9851c14b 100644 GIT binary patch literal 212883 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�!pKC6c{Y6l_LHvT)vK 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 VjOs&zvp!D>=NE)|@YTi4{{l1k*aiRq literal 249725 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

ip6 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%0A~;;9?z@l16J1_ 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%0D3ASyu9`$#TsWia%-m(K`n~1B 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

QxF(o>;Ot5 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 diff --git a/scripts/generate_terminal_image.py b/scripts/generate_terminal_image.py index 5903666..85b5312 100644 --- a/scripts/generate_terminal_image.py +++ b/scripts/generate_terminal_image.py @@ -12,11 +12,10 @@ codes included. The addresses, the row counts and the timings in the picture are whatever that run produced. -The scan story it stages is real too: a value is planted in this process's -memory, scanned for, then *changed* between the two scans, so the -``--decreased`` refinement narrows a few hundred candidates to the one -address that actually moved. That is the loop the README describes, executed rather than -illustrated. +The scan it stages is real too: a value is planted in this process's memory, +scanned for across the writable regions, and the first address that comes +back is then written to and read again as hex. Nothing is arranged so that +the numbers agree — they agree because the commands ran. The transcript is then rendered as HTML and screenshotted with headless Chrome, the same way ``build_preview.py`` does it in PyMemoryEditor. @@ -56,10 +55,11 @@ from picklock.shell import Shell # noqa: E402 (must follow the env var above) -#: The value planted in this process, and what it drops to before the refine. -#: Both are arbitrary; what matters is that the second is smaller. +#: The value planted in this process for the scan to find. Arbitrary, and +#: ordinary enough in a live interpreter that the scan comes back with a few +#: hundred candidates — which is the honest picture: a first scan narrows the +#: field, it does not identify anything. HEALTH = 1337 -DAMAGED = 1200 # -- the staged process --------------------------------------------------- @@ -76,8 +76,10 @@ def __init__(self) -> None: self.health = (ctypes.c_int32 * 4)(HEALTH, HEALTH, 0, 0) self.name = ctypes.create_string_buffer(b"PicklockDemo\x00") - def take_damage(self) -> None: - self.health[0] = DAMAGED + @property + def value(self) -> int: + """What the demo scans for — read back from the memory it planted.""" + return int(self.health[0]) # -- recording ------------------------------------------------------------ @@ -99,16 +101,15 @@ def record() -> List[str]: pid = os.getpid() - #: (line, hook) — the hook runs *before* the line, so the process really - #: has changed by the time the command that notices it runs. The display + #: (line, hook) — a hook runs *before* its line, for a step that needs the + #: process to have changed by the time the command looks. The display #: limit is turned down so the first scan's table stays a sample rather #: than twenty rows of the same number; the footer still reports the true #: total, and turning it down is itself a command worth showing. script: List[Tuple[str, object]] = [ (f"ps:open {pid}", None), ("config:set limit 3", None), - (f"scan:value int32 {HEALTH} --writable", None), - ("scan:next --decreased", target.take_damage), + (f"scan:value int32 {target.value} --writable", None), ("memory:write #1 int32 9999", None), ("memory:hex #1 16", None), ] From ec66fc8f1f0d65d2178809f0a91c94bca9f15104 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 30 Aug 2026 17:10:04 -0300 Subject: [PATCH 51/82] docs: record the capture against a process named for the demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the config:set and memory:hex lines from the transcript, and record it under a hard link to this interpreter named 'game', so the picture shows the shell attached to something worth attaching to rather than to python3.11. The process really carries that name — it is what the kernel reports and what ps:open matches on — which also lets the demo attach by name instead of by a PID that means nothing to a reader. The rename runs as a child rather than an exec, so a platform where the link will not start (Windows, where the runtime DLL sits beside the executable) falls back to recording in this process instead of failing the run. With config:set gone the display limit is set on the session directly: it is a persisted setting, so a real session inherits it rather than being told it each time, and the footer still reports the true row count. The window also gets a floor of 84 columns, so dropping a command from the demo no longer shrinks the image. --- README.md | 6 +- assets/screenshots/terminal.png | Bin 212883 -> 159475 bytes scripts/generate_terminal_image.py | 126 ++++++++++++++++++++++++++--- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 62ded29..d950f01 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ picklock

A Picklock session: scanning a live process for a value, narrowing it down, and writing to it + width="780" />

## Usage diff --git a/assets/screenshots/terminal.png b/assets/screenshots/terminal.png index 072f84be76ee7041332309dae8e45cfd9851c14b..c5430a51e4051b1afd20789403891e40b3099aac 100644 GIT binary patch literal 159475 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 z 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 z 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�!pKC6c{Y6l_LHvT)vK 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 VjOs&zvp!D>=NE)|@YTi4{{l1k*aiRq diff --git a/scripts/generate_terminal_image.py b/scripts/generate_terminal_image.py index 85b5312..bf6dfa1 100644 --- a/scripts/generate_terminal_image.py +++ b/scripts/generate_terminal_image.py @@ -6,16 +6,24 @@ published package (see the sdist excludes in ``pyproject.toml``). Nothing in the image is typed by hand. The script drives the *real* shell -against a real process — this one, the same trick the end-to-end suite uses, +against a real process — itself, the same trick the end-to-end suite uses, which is what lets it run anywhere without privileges and without a second program to launch — and records exactly what a user would have seen, escape codes included. The addresses, the row counts and the timings in the picture are whatever that run produced. +So that the picture is not a screenshot of Picklock reading Python, the +recording runs under a hard link to this interpreter named ``game``: the +process really is called that, which is the name the kernel hands back and +the name the shell prints. It is staging, not faking — the same staging as +pointing a demo at a toy program written for the occasion, minus the program. +Where the link cannot be made or cannot run, the recording happens in this +process and the picture says ``python`` instead. + The scan it stages is real too: a value is planted in this process's memory, scanned for across the writable regions, and the first address that comes -back is then written to and read again as hex. Nothing is arranged so that -the numbers agree — they agree because the commands ran. +back is written to. Nothing is arranged so that the numbers agree — they +agree because the commands ran. The transcript is then rendered as HTML and screenshotted with headless Chrome, the same way ``build_preview.py`` does it in PyMemoryEditor. @@ -31,6 +39,7 @@ import ctypes import html import io +import json import math import os import re @@ -39,7 +48,7 @@ import sys import tempfile from pathlib import Path -from typing import List, Tuple +from typing import List, Optional, Tuple REPO_ROOT = Path(__file__).resolve().parent.parent OUT = REPO_ROOT / "assets" / "screenshots" / "terminal.png" @@ -55,6 +64,13 @@ from picklock.shell import Shell # noqa: E402 (must follow the env var above) +#: The name the recording runs under. Short, lowercase and evocative of what +#: people actually point a memory scanner at. +DEMO_NAME = "game" + +#: Passed to the renamed child to tell it where to leave the transcript. +RECORD_FLAG = "--record-to" + #: The value planted in this process for the scan to find. Arbitrary, and #: ordinary enough in a live interpreter that the scan comes back with a few #: hundred candidates — which is the honest picture: a first scan narrows the @@ -99,19 +115,25 @@ def record() -> List[str]: shell.printer.stderr = buffer shell.printer.color = True - pid = os.getpid() + # Attaching by name reads better than a bare PID, but only once the name + # is worth reading; unrenamed, several interpreters may share it and the + # match would be ambiguous. + renamed = Path(sys.executable).name == DEMO_NAME + open_by = f"ps:open {DEMO_NAME}" if renamed else f"ps:open {os.getpid()}" + + # Show a sample of the hits rather than a screenful. `limit` is one of the + # settings Picklock persists, so a session inherits it rather than being + # told it every time — which is why this is set here and not typed as a + # `config:set` line in the demo. The scan is unaffected: the footer still + # reports how many rows there really are. + shell.session.set_option("limit", "3") #: (line, hook) — a hook runs *before* its line, for a step that needs the - #: process to have changed by the time the command looks. The display - #: limit is turned down so the first scan's table stays a sample rather - #: than twenty rows of the same number; the footer still reports the true - #: total, and turning it down is itself a command worth showing. + #: process to have changed by the time the command looks. script: List[Tuple[str, object]] = [ - (f"ps:open {pid}", None), - ("config:set limit 3", None), + (open_by, None), (f"scan:value int32 {target.value} --writable", None), ("memory:write #1 int32 9999", None), - ("memory:hex #1 16", None), ] lines = shell.banner().rstrip("\n").split("\n") @@ -135,6 +157,67 @@ def record() -> List[str]: return lines +def link_interpreter(directory: str) -> Optional[str]: + """Return a path to this interpreter under :data:`DEMO_NAME`, or None. + + A hard link, so the bytes — and therefore the code signature macOS checks + — are the ones already on disk; a copy only if the link cannot be made. + """ + linked = os.path.join(directory, DEMO_NAME) + try: + os.link(sys.executable, linked) + except OSError: + try: + shutil.copy2(sys.executable, linked) + except OSError: + return None + return linked + + +def transcript() -> List[str]: + """Record the demo, under the demo name where the platform allows it. + + The renamed interpreter is run as a child rather than exec'd into, so + that a name that cannot be made to work — an interpreter that will not + start from outside its own directory, most likely on Windows, where the + runtime DLL sits next to the executable — costs a fallback rather than + the whole run. + """ + with tempfile.TemporaryDirectory(prefix="picklock-demo-") as directory: + linked = link_interpreter(directory) + if linked is None: + print(f"Could not create an interpreter named {DEMO_NAME!r}.") + return record() + + output = os.path.join(directory, "transcript.json") + + # The link has no directory of its own to find a standard library in, + # and no site-packages; hand it this interpreter's. + environment = dict(os.environ) + environment["PYTHONHOME"] = sys.base_prefix + environment["PYTHONPATH"] = os.pathsep.join(p for p in sys.path if p) + + result = subprocess.run( + [linked, os.path.abspath(__file__), RECORD_FLAG, output], + env=environment, + capture_output=True, + text=True, + ) + if result.returncode == 0 and os.path.exists(output): + print(f"Recorded in a process named {DEMO_NAME!r}.") + with open(output, encoding="utf-8") as handle: + return json.load(handle) + + detail = (result.stderr or result.stdout).strip().splitlines() + print( + f"Could not record under the name {DEMO_NAME!r} " + f"({detail[-1] if detail else f'exit {result.returncode}'}); " + "recording in this process instead." + ) + + return record() + + # -- rendering ------------------------------------------------------------ #: Only what Picklock actually emits: its one red, its one grey, and reset. @@ -198,6 +281,12 @@ def to_html(line: str) -> str: #: Trim the demo rather than raising this if the transcript outgrows it. ASPECT = 1.06 +#: And never narrower than this, however short the lines get. A window that +#: hugs its longest line stops looking like a terminal and starts looking like +#: a quotation, and it also keeps the image from shrinking every time a +#: command is dropped from the demo. +MIN_COLUMNS = 84 + TEMPLATE = """

- A real session, captured by a script that runs the commands — - no numbers in it were typed by hand. + A real session, captured by a script that runs the commands + against a process it stands up for the purpose — no numbers in it were typed by hand.