Files
rea/docs/python-pyc.md
T
2c3dd946d5 feat(python): inspect CPython pyc headers and marshal code objects (#4826)
* feat(python): inspect CPython pyc headers and marshal code objects

Add inspect_python_pyc / inspect-python-pyc to read one explicit local
CPython 3.7–3.13 .pyc without executing bytecode or recovering source.

* chore(python): drop unused PythonPycHeader export

Knip reported the type as unused after inspect_python_pyc landed.

* fix(python): bound pyc expansion and decode marshal like CPython

- Count a nested code object reached through TYPE_REF once per copy the
  report carries, against max_objects and max_file_bytes, before counting
  or validating the tree
- Charge long digits to max_string_bytes, reject out-of-range and
  unnormalized digits, build the value in one pass, and report integers
  above 256 bits by bit length only
- Decode TYPE_UNICODE with surrogatepass semantics and reject other
  ill-formed UTF-8
- Map only 3.11 magics 3494-3495 to the py311 layout
- Report non-finite float and complex parts as inf, -inf or nan
- Allow an empty co_filename
- Require bytes for co_linetable and co_exceptiontable
- Bind cleanup-failure errors to their retained descriptor leases

* fix(python): list pyc code objects once instead of a recursive tree

Report each distinct code object once in code_objects, top-level first,
with nested_code as indexes. A child shared through TYPE_REF is no longer
copied per reference, and the MCP output schema is no longer recursive.
Names repeated through TYPE_REF still count against max_file_bytes for
each copy the report carries.

* fix(python): bound shared pyc constants and reject malformed tables

- Cache byte-string digests so a buffer shared through TYPE_REF is
  hashed once instead of on every reference.
- Charge every reported co_consts entry, including tables shared
  through TYPE_REF, against max_objects before summaries are built.
- Resolve the inspect-python-pyc path with absoluteCallerPath so
  link/../file.pyc reads the file the filesystem selects.
- Reject TYPE_NULL inside marshal sequences, as CPython does.
- Reject a co_localspluskinds length that does not match
  co_localsplusnames in the parser and the report schema.

* fix(python): bound shared pyc names and read CPython marshal graphs

Charge each reported code-constant name, keep integer bit lengths on the
decoded value, and list code objects nested inside constant containers.
Accept self-referential lists and dicts, marshal version 0 text floats,
empty code names, the original string byte count, and Python set and dict
lengths. Pass the CLI cancellation signal into command cleanup.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(python): bound pyc key expansion and shared nested code

Charge set and dictionary keys before a shared tuple doubles them, list each
nested code object once per owner, and accept code objects as CPython keys.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(python): correct pyc equality, counts, and nested walks

Summarize code objects only after the marshal graph is complete, and
collect nested code with an iterative walk that shares wrapper results
instead of copying every descendant list. Keep tuple boundaries and the
original string bytes in equality keys, and reject negative CPython counts.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(python): bound shared pyc names and keep the first dict key

Charge every copied name-table entry against the object budget before
building the report array, including empty strings that add no bytes.
When dictionary keys compare equal, keep the first key object and
replace only its value, matching CPython.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(python): walk each pyc dictionary child list once

Keep a container's child array on the nested-code frame so a wide
dictionary is not copied on every step of the synchronous marshal walk.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(python): reuse stored pyc dictionary children when combining

The nested walk keeps each container's child list on its frame.
Combining nested code rebuilt that list, and the 8,000-key regression
still accepted the old per-key copy. Use the stored list, and require
that dictionary to finish well under the copy's runtime while still
reporting a nested code object.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(python): keep distinct pyc code objects and reject corrupt ones

Encode each name table's length and entry boundaries in the equality
key so a different name list cannot share a memo key. Walk tuple and
frozenset keys on an explicit stack so a long TYPE_REF chain does not
overflow. Treat code objects CPython rejects for an odd co_code or an
undersized local table as malformed instead of complete coverage.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-22 19:00:08 +08:00

4.8 KiB
Raw Blame History

CPython pyc inspection

inspect_python_pyc / inspect-python-pyc answers which CPython version wrote one selected local .pyc file, how its 16-byte header records source identity, and which nested code objects marshal stored. It reads the file without running bytecode and requires no Python installation, active binary target, Hopper, Ghidra, or IDA.

rea inspect-python-pyc /absolute/path/to/module.pyc --json

The CLI resolves relative paths against the caller's working directory. MCP requires an absolute filesystem path and preserves that exact selection:

{
  "name": "inspect_python_pyc",
  "arguments": { "path": "/artifacts/module.pyc" }
}

Observations

The inline Evidence result contains the artifact path, SHA-256, and byte length. header contains:

  • magic, the 16-bit little-endian field;
  • python_version and code_layout, mapped from the pinned CPython 3.7–3.13 table (py37, py38, or py311); 3.11 alpha magics before 3494 used other code layouts and are unsupported;
  • flags, hash_based, and check_source from PEP 552;
  • source_hash_hex for hash-based pycs, or source_mtime and source_size for timestamp pycs.

code_objects lists each distinct marshal code object once, the top-level module code first. nested_code holds the indexes of the distinct code objects reachable from that object's constants, including code nested in tuples and other containers and the owner itself when a constant container reaches it. A function shared through a marshal reference is named by index once per owner, even when that owner reaches it again through the same container. Relationships are collected after the marshal graph is complete, so a container that a nested code object also references is not summarized while its items are still missing. Each object reports name, optional qualname, filename, argument counts, flags, names, locals (or localsplusnames on 3.11+), const summaries, and bytecode length plus SHA-256.

Bytecode is not decompiled. Const strings may be truncated in the summary while the marshal walk still consumed the original bytes; a string summary's bytes is that marshal length. Integer consts report bits; their decimal value is null above 256 bits. Non-finite float and complex parts use CPython's inf, -inf, and nan spellings. Dictionary and set lengths follow CPython: unhashable keys and members fail the walk, equal set members count once, equal dictionary keys keep the first key object and replace only its value, code objects are valid keys and members, and a null dictionary value ends the dict without that entry.

Limits and failures

The selected file is limited to 16 MiB by default (64 MiB maximum). Marshal object count, nesting depth, and per-string size are bounded; exceeding a budget fails without partial success. Argument counts, positional-only counts, keyword-only counts, local counts, and stack size are signed CPython fields; a negative encoding is malformed. On the 3.11+ layout, co_code must cover a whole number of code units. Every layout rejects argument counts that do not fit the local table CPython checks (co_varnames, including *args / **kwargs, or fast locals on 3.11+). Those files are malformed, so the workflow does not report complete coverage. 3.7–3.10 still accept an odd co_code, matching those constructors. Nested code collection walks reference chains iteratively and keeps one shared descendant list for a container that merely wraps another, instead of copying that list onto every wrapper. Set and dictionary keys walk tuple and frozenset reference chains the same way, so a long TYPE_REF chain stays within the call stack. A shared child stored twice still counts its expanded key against the string budget. Code-object equality keys record each name table's length and each entry's boundaries, so () and ("",) stay distinct, and co_varnames, co_freevars, and co_cellvars stay separate tables. Names repeated through a marshal reference, including each code-constant name the report copies, count against the file budget once per copy. Empty strings add no bytes, so every copied name-table entry (co_names, varnames, freevars, cellvars, and localsplusnames) counts against the object budget before its array is built. Copied constant tables and each reported nested code relationship count against that same object budget. Integer digits and expanded set or dictionary keys, including a shared tuple repeated at each level, count against the string budget. Magics outside the pinned 3.7–3.13 table, truncated headers, trailing marshal bytes, and a non-code top-level object are typed failures.

A decoded header does not prove the matching .py is present. python_version is the magic table, not a running interpreter. This workflow does not recover source.