English | 简体中文
Decompile and disassemble Python bytecode (.pyc) from Python 2.0 through 3.15 (dev) back into readable source — written in Rust, three dependencies, no Python required at runtime.
pycdc program.pyc # decompile to stdout
pycdas program.pyc # disassemble (exception tables, line numbers, nested code)- 100% semantic equivalence on a 520-module real-stdlib corpus across 13 interpreters (2.6–3.14); 76.5% are byte-exact at the bytecode-signature level
- Behavior matrix 516/516: decompiled code runs identically to the original under the same interpreter
- Modern syntax: PEP 695/696 type parameters, PEP 750 t-strings, PEP 649 deferred annotations, match/case, walrus, async, f-strings
- Version differences are pure data: one embedded opcode table per release, overridable via
--opcodes - Graceful degradation: unrecognized constructs become comment placeholders — never a crash
Homebrew (macOS & Linux):
brew install ejfkdev/tap/pycdccargo, from crates.io:
cargo install pycdcPrebuilt 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 checkoutEvery method provides both pycdc and pycdas.
# 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);
# also reachable as the `pycdc dis` subcommand (identical output)
pycdas program.pyc
pycdc dis program.pyc
pycdas ./pyc-corpus -o ./dis-out # -> mirrored .dis filespycdc --help, pycdc dis --help and pycdas --help document every
flag. Exit codes: 0 success, 1 a file failed to process, 2 usage
error.
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 | — |
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.93 s | 0.12 s | 1045/1045 | 500/1045 |
| matplotlib (v3.11.2) | 253 | 0.47 s | 0.09 s | 253/253 | 59/253 |
| pandas (v3.0.5) | 1420 | 1.35 s | 0.21 s | 1419/1420 | 582/1420 |
| django (6.1.1) | 907 | 0.67 s | 0.16 s | 907/907 | 467/907 |
| sympy (1.14.0) | 1532 | 3.68 s | 1.85 s | 1532/1532 | 479/1532 |
| scikit-learn (1.9.1) | 671 | 0.71 s | 0.10 s | 669/671 | 216/671 |
| ansible (v2.21.4) | 583 | 0.35 s | 0.06 s | 583/583 | 185/583 |
| total | 6411 | 8.16 s | 2.59 s | 6408/6411 (99.95 %) | 2488/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: ~1.0 ms per module serial, peak RSS
≈99 MB for the whole 6.4 k-module batch. The three remaining modules are one shape: a dict comprehension
nested inside a generator expression's element (call arguments
included) — marked incomplete, never a crash. Raw data: benchmarks/realworld.json,
harness: tools/bench_realworld.py.
Three independent harnesses, all runnable locally (need pyenv/uv interpreters 2.6–3.14):
| Harness | What it proves | Current |
|---|---|---|
tools/verify_corpus.py |
520 stdlib modules: decompile → recompile with the same interpreter → compare bytecode signatures, fall back to normalized-AST diff | 520/520 semantic (398 signature-exact, 0 syntax errors, 0 placeholders) |
tools/run_behavior.py |
50 behavior cases × 13 interpreters: run original vs decompiled, compare stdout + exit code | 516/516 = 100% |
tests/roundtrip.py |
fixture matrix: compile → decompile → recompile → strict bytecode compare | 49/54 strict (exceptions fixtures differ structurally, semantically equivalent) |
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.
src/ loader → marshal → bytecode → decompiler → codegen (version.rs/opcode.rs hold the per-version data)
configs/ embedded opcode + magic tables (regenerate: tools/gen_configs.py)
tools/ config generation, corpus building, verification harnesses
tests/ Rust tests, fixtures, 520-module corpus, 48 behavior cases
Design informed by Decompyle++ (pycdc), uncompyle6/decompyle3 and xdis.