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.
+
+
+
+
+
+
+
+
+
+
+---
+
+## 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
+
@@ -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