Skip to content
ejfkdevPublic

About

Decompile and disassemble Python bytecode (.pyc/.pyo) back to readable source. Supports Python 2.0 - 3.15 (CPython, PyPy and other implementations). Written in Rust.

Topics

Resources

Stars

10 stars

Watchers

0 watching

Forks

Repository files navigation

pycdc-rs

English | 简体中文

License: MIT Python Corpus Behavior Built with ZCode

Decompile and disassemble Python bytecode (.pyc) from Python 2.0 through 3.15 back into readable source — written in Rust, no Python required at runtime, with an optional in-process pass through the cure simplifier.

pycdc program.pyc          # decompile to stdout
pycdc dis program.pyc      # disassemble (exception tables, line numbers, nested code)

Highlights

  • 100% semantic equivalence on a 622-module real-stdlib corpus across 14 interpreters (2.6–3.15); 80.2% are signature-exact
  • Behavior matrix 754/754 = 100%: every case passes on 2.6–3.15, including the new 3.15 bytecode encodings
  • Modern syntax: PEP 695/696 type parameters, PEP 750 t-strings, PEP 649 deferred annotations, match/case, walrus, async, f-strings
  • Optional --cure: folds the output through the cure simplifier/formatter in-process — no external binary, no Python — turning if cond: return a / return b into return a if cond else b and trimming ~10% of the lines
  • Version differences are pure data: one embedded opcode table per release, overridable via --opcodes
  • Graceful degradation: unrecognized constructs become comment placeholders — never a crash

Install

Homebrew (macOS & Linux):

brew install ejfkdev/tap/pycdc

Scoop (Windows):

scoop install https://raw.githubusercontent.com/ejfkdev/scoop-bucket/main/bucket/pycdc.json

The manifest selects x64 or arm64 automatically and installs both pycdc and pycdas. To install by name instead, add the bucket once:

scoop bucket add ejfkdev https://github.com/ejfkdev/scoop-bucket
scoop install pycdc

cargo, from crates.io:

cargo install pycdc

Prebuilt binaries: raw executables (no archive wrapper) on the Releases page — Linux / Windows x86_64 + arm64, macOS arm64 + x86_64; Linux and Windows builds are UPX-compressed, macOS ships uncompressed.

From source:

cargo build --release      # -> target/release/pycdc + pycdas
cargo install --path .     # or install both binaries from a checkout

Every method provides both pycdc and pycdas.

Usage

# single file -> stdout, a file, or a directory
pycdc program.pyc > program.py
pycdc program.pyc -o program.py
pycdc program.pyc -o ./outdir/

# stdin (pipelines): - reads the pyc from stdin
cat program.pyc | pycdc -

# batch: mirror a directory tree (parallel by default, -j N to control,
# -q suppresses the per-file src -> dst lines)
pycdc ./pyc-corpus -o ./src-out
pycdc ./pyc-corpus               # -> ./pyc-corpus-decompiled/
pycdc a.pyc b.pyc                # -> a.py, b.py next to the inputs
pycdc ./pyc-corpus -o ./src-out -q -j 8

# raw marshal (no pyc header) with an explicit version
pycdc -v 3.8 payload.marshal

# disassembly — same CLI surface (files, dirs, stdin, -o/-j/-q)
pycdc dis program.pyc
pycdc dis ./pyc-corpus -o ./dis-out # -> mirrored .dis files
# the standalone `pycdas` binary is kept as a compatibility alias
# with byte-identical output
pycdas program.pyc

# optional simplification/formatting pass (cure, linked in-process)
pycdc --cure program.pyc

--cure is opt-in and purely cosmetic: pycdc's output is already verified, so the default stays untouched. With it, conditionals collapse into ternaries, quotes normalize to single quotes, and formatting is reflowed:

# default                                  # --cure
def classify(v):                           def classify(v):
    if v is True:                              return 1 if v is True else 0
        return 1
    return 0

If the input cannot be parsed by cure, or if it contains compiler-introspection metadata (__annotate__, co_freevars, __conditional_annotations__, __classdict__) where simplifying could change runtime behaviour, the file is emitted unchanged and a note with the reason goes to stderr. The behavior matrix passes 754/754 both with and without --cure, all 622 corpus modules still compile under their own interpreter with it, and tools/cure_differential.py checks 45 side-effect-heavy programs for behaviour drift between the two forms. Because the pass deliberately rewrites statement shapes (if/else → ternary), the corpus's bytecode-signature metric is not meaningful for --cure output; judge it by the behavior matrix.

pycdc --help, pycdc dis --help and pycdas --help document every flag. Exit codes: 0 success, 1 a file failed to process, 2 usage error.

Performance

Apple Silicon, 526 real .pyc files (~9.5 MB, Python 2.6–3.14 stdlib):

Scenario Time Peak RSS
Batch, parallel (default) ≈0.05 s ≈25 MB
Batch, serial (-j 1) ≈0.34 s ≈13 MB
Largest single file (74 KB, incl. startup) ≈5.5 ms ≈4.4 MB
Process startup ≈2 ms —

Real-world corpus (seven large projects, latest release tags)

tools/fetch_realworld.sh sparse-checks-out one directory from each of seven projects at its latest release tag, compiles every .py to .pyc (compileall -b), and lands the tree in tests/realworld/ (git-ignored — the script is the way to recreate it):

Project (tag) modules serial -j 1 parallel (default) recompiles AST-identical¹
yt-dlp (2026.08.19) 1045 0.39 s 0.07 s 1045/1045 502/1045
matplotlib (v3.11.2) 253 0.30 s 0.06 s 253/253 60/253
pandas (v3.0.5) 1420 0.83 s 0.13 s 1420/1420 586/1420
django (6.1.1) 907 0.35 s 0.11 s 907/907 470/907
sympy (1.14.0) 1532 2.91 s 1.54 s 1532/1532 482/1532
scikit-learn (1.9.1) 671 0.43 s 0.07 s 671/671 216/671
ansible (v2.21.4) 583 0.24 s 0.04 s 583/583 186/583
total 6411 5.45 s 2.02 s 6411/6411 (100 %) 2502/6411

¹ strict: docstring-stripped AST equal to the original source — equivalent normalizations (an if/else instead of a ternary, a redundant *()/**{} around an unpack, reordered keyword arguments) count as differences, so treat it as a shape-fidelity floor rather than an error rate. The parse/recompile column is the acceptance bar.

One pycdc process per project: ~0.85 ms per module serial, peak RSS ≈84 MB for the whole 6.4 k-module batch. All 6411 modules recompile; the last family (a dict comprehension nested inside a generator expression's element, call arguments included) was closed by the community PR batch. Raw data: benchmarks/realworld.json, harness: tools/bench_realworld.py.

Verification

Three independent harnesses, all runnable locally (need pyenv/uv interpreters 2.6–3.15):

Harness What it proves Current
tools/verify_corpus.py 622 stdlib modules: decompile → recompile with the same interpreter → compare bytecode signatures, fall back to normalized-AST diff 622/622 semantic (499 signature-exact, 0 syntax errors, 0 placeholders)
tools/run_behavior.py 96 behavior cases × 14 interpreters: run original vs decompiled, compare stdout + exit code 754/754 = 100% (2.6–3.15)
tests/roundtrip.py fixture matrix: compile → decompile → recompile → strict bytecode compare 204/240 strict (the rest differ structurally but are semantically equivalent)

The 3.15 result reflects CPython 3.15.0/3.15.1, whose inline-comprehension, call and import encodings changed; its opcode table is generated from the live interpreter. All behavior cases pass and pandas+scikit-learn recompile 2091/2091 under 3.15; the remaining with-body-return edge is documented in docs/LIMITATIONS.md.

Per-version results live in tests/corpus/<version>/report.json. A cross-tool comparison (speed, memory, output quality vs Decompyle++, uncompyle6, decompyle3) lives in benchmarks/. Beyond these harnesses, releases are gated by ad-hoc quality batteries: a 25-shape syntax-family fuzz matrix (×10 interpreters), a hostile-input robustness suite (truncated/corrupted/OOM-bomb pycs — zero crashes), and a third-party venv smoke (six/packaging/click/attrs/pluggy). See docs/LIMITATIONS.md for the detailed known-gap list and the third-party backlog.

Project layout

src/    loader → marshal → bytecode → decompiler → codegen  (version.rs/opcode.rs hold the per-version data; simplify.rs wraps cure)
configs/ embedded opcode + magic tables (regenerate: tools/gen_configs.py)
tools/   config generation, corpus building, verification harnesses
tests/   Rust tests, fixtures, 622-module corpus, 96 behavior cases

Acknowledgements

Design informed by Decompyle++ (pycdc), uncompyle6/decompyle3 and xdis. The optional --cure post-processing is provided by cure.

License

MIT

About

Decompile and disassemble Python bytecode (.pyc/.pyo) back to readable source. Supports Python 2.0 - 3.15 (CPython, PyPy and other implementations). Written in Rust.

Topics

Resources

Stars

10 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages