pyc_revive
.pyc → .py reconstructor. Core pipeline is pure stdlib — no uncompyle6, no pycdc, no xdis. A bundled disassembler handles foreign versions; Node.js + depyo handles 3.14 and 3.15.
install
pip install pyc-revive
The Python package itself has no third-party dependencies. Node.js and depyo are only needed for the 3.14+ backend, and they're installed on demand.
On first run, pyc-revive checks for Node.js and depyo and installs them if missing. Everything goes under %LOCALAPPDATA%\pyc_revive on Windows, or the equivalent user directory elsewhere. No admin rights, no system changes.
- Node.js ≥ 20 via winget (user scope) if available, otherwise a portable ZIP extracted into the user directory. No MSI, no registry writes.
- depyo via npm into a user-writable prefix.
- optional pip backends (pychd, byteripper, uncompyle6, decompyle3) installed individually with a
--userfallback.
# run installer explicitly
pyc-revive --install-deps
# skip first-run bootstrap entirely
pyc-revive --no-bootstrap Backdoor.pyc
From a checkout:
python -m pyc_revive my file.pyc
python -m pyc_revive ./dist/app.pyc --out ./revived.py
python -m pyc_revive --install-deps
Input handling: exact path, spaces with no quotes, or basename search in the current directory, all subfolders, and the parent folder.
versions
The magic-number table covers every CPython release from 2.0 through 3.15, with nearest-lower fallback for unrecognized magics and correct header sizes per version (pymagic.py). Practical testing to date has focused on 3.9 through 3.15.
Three backends, one shared pipeline:
- native —
.pycmagic matches the running interpreter. stdlibmarshalloads the code object, stdlibdisdisassembles it. No external dependencies at all. - pycdas — anything else from 2.x through 3.13. A bundled disassembler emits a text dump;
pycdas_parser.pyparses it into code trees;shim.pyadapts them to look like real code objects. The rest of the pipeline (normalize, segment, translate, CFG, emit, verify ×3) runs unchanged on the result. - depyo — 3.14 and 3.15.
depyo_parser.pyrunsdepyo --skip-source-gen --asmand parses the.pyasmdump;depyo_postprocess.pyrepairs depyo's mechanical emitter bugs using the pycdas pipeline's output as a repair source when both ran. When the pipeline's output doesn't compile, a fallback path produces source from depyo's own decompiler.
Routing is decided by magic number before any bytecode is touched. A foreign magic goes straight to the appropriate backend. marshal.loads can succeed on foreign payloads because the marshal format is stable across versions — and then stdlib dis mis-decodes unknown opcodes and can crash the interpreter instead of raising. So "try native, fall back on error" is deliberately not used for foreign files.
benchmark
Reference corpus: 500 randomly sampled .py files drawn from PyPI ecosystem packages — PyTorch internals, sympy, pandas, Django, telegram client libraries, gevent, trio, PyInstaller hooks, CPython's own Compiler/ directory, generated parser tables, and one 918 KB data module. Compiled with Python 3.11.2, which is one of the hardest modern targets: exception tables, PRECALL/CALL split, adaptive specialization, CACHE entries between instructions.
Measured with a byte-exact verification harness: compile source → .pyc → decompile with pyc-revive → recompile the output → recursive instruction-level comparison against the original (opcode, arg, argval, co_names, co_varnames, co_consts, and nested code objects).
| result | files | percent |
|---|---|---|
| byte-equivalent | 127 | 25.4% |
| structurally equivalent (opcode-name match) | 126 | 25.2% |
| not equivalent | 247 | 49.4% |
| total runtime | 500 files | 67 seconds |
That's approximately 130 ms per file including process spawn, source compilation, tool invocation, and comparison.
Nothing is skipped. The corpus is the full random sample.
Honest context on the number. This corpus is not representative of typical Python code. It is deliberately weighted toward categories that are hard for any decompiler: code generators, machine-generated tables, metaclass-heavy object systems, async generator machinery, and annotation-dense auto-generated API stubs. A corpus of hand-written application code would score higher. A corpus compiled with Python 3.8 would score higher. The number above is what you get when you stack every hard case next to each other on the worst realistic version target.
Trajectory. The byte-equivalent rate roughly doubled (12.2% → 25.4%) in a single development session with no change in runtime. The structural rate went from 9.4% to 25.2% over the same period. The tool is under active development and the ceiling has not been reached.
To reproduce the number, the harness is pyc_strict_bench.py in the source repository.
3.14 and 3.15 support
3.14 was the first foreign 3.11+ target exercised end-to-end, and it required real work beyond extending the opcode table.
Translator additions:
LOAD_SMALL_INT,LOAD_FAST_BORROW,LOAD_FAST_BORROW_LOAD_FAST_BORROW(double 4-bit index, high nibble first),LOAD_SPECIAL,FORMAT_SIMPLE,TO_BOOL(transparent),CALL_KW.BINARY_OPopargs ≥ 26 (3.14 renumbers the table; 26 =NB_SUBSCR, 27 = inplace).KW_NAMES— pycdas 3.13 sometimes prints it asLOAD_CONST <i> // Value: ('a', 'b'), a tuple of key names.shim.pyrewrites the opname when the argval is a string tuple immediately followed by aCALL_KW.
Parser additions (pycdas_parser.py):
[Locals+Names]sections read intoco_varnames.[Exception Table]entries parsed and re-encoded toco_exceptiontablebytes on the shim, so the shared try/with-region machinery runs unchanged.
Shim additions (shim.py):
- 3.14 conditional-branch targets from the bundled disassembler land 2 bytes low. The disassembler suppresses a hidden
CACHEbut doesn't account for it in target math. Repaired when the fingerprint is exact: a 3.14+ dump,NOT_TAKENatbranch + 4, andtarget + 2a real instruction offset. - 3.14 replaced
BEFORE_WITHwith an inline prologue (COPY, LOAD_SPECIAL, SWAP, SWAP, LOAD_SPECIAL, CALL). The shim folds it back to one syntheticBEFORE_WITHso the shared with-region machinery applies. LOAD_FAST_BORROWindex resolution, including the pair form.
Region detection:
withbodies are scoped by their exception-table range, so fallthrough code after the body dedents correctly.tryinteriors stop at the body end, so post-try joins don't indent undertry:.- Post-try joins stranded textually before out-of-line handlers are deferred past the
exceptclause. RERAISE-ended handlers that pair with nothing are skipped as implicit-cleanup residue.- Duplicated
finallybodies are paired by twin match: an unpairedRERAISE-ended handler whose leading statements duplicate a post-try block's leading statements is the exceptional finally copy, and the tails decide the split.
3.15 goes through the same depyo path. depyo's unresolved name masks (##NAME_N##, ##ERROR##, __common_const_N__) are collapsed or substituted in depyo_postprocess.py. stdlib imports that depyo drops are reinserted from a canonical table.
pipeline
- find file (
finder.py) - detect version via magic (
pymagic.py) - load: native marshal, else pycdas or depyo backend (
loader.py,pycdas_parser.py,depyo_parser.py,shim.py) - disassemble and abstract (
bytecode.py:Inst,EditableBytecode, opcode/oparg/value-stack/metadata tables) - normalize and mask constants and names (
normalizer.py) - opcode-aware B/I/E segmentation (
segmenter.py) - statement translation (
translator.py: stack machine, defaults recovery, inline lambdas, 2.x print/exec/class ops) - control-flow graph from jumps (
cfg.py: if/for/while/try/except-as including bareexcept:/with/finally, clause and cluster detection, 3.11+ exception table,SETUP-region splits) - emit and repair (
emitter.py: source-order splicing onto def/class stub lines, base-class recovery, decorator restoration,globaldeclarations, condition chains, ternaries, nested if/else, loop ranges, break/continue recovery, indent and suite-guard passes) - equivalence verification ×3 sweeps (
verifier.py: recompile revived source, compare opcode structure, repair, repeat) - self-validation (
autofix.py: compile the output, diagnose the first error, apply a targeted repair — orphan handler, empty suite, bad indent, orphantry:— until it compiles or the budget runs out; ships the closest parse) - backend fallback (
reconstructor.py: if the pipeline's output doesn't compile and depyo is available, depyo-direct runs with the pipeline's source as a repair companion, then post-processing fixes mechanical bugs)
layout
pyc_revive/
finder.py file resolution
pymagic.py magic-number table + version detection
loader.py native marshal loading
pycdas_parser.py bundled-disassembler dump parser
depyo_parser.py depyo --asm dump parser
depyo_postprocess.py repair pass for depyo output
shim.py foreign-version code trees behave like code objects
bytecode.py Inst + EditableBytecode
normalizer.py const/name masking
segmenter.py B/I/E opcode segmentation
translator.py opcode stream -> statements
cfg.py basic blocks + structured control flow
emitter.py source emission + structural repairs
verifier.py recompile + compare x3
autofix.py compile-repair loop
reconstructor.py orchestrator
cli.py interface
bootstrap.py first-run dependency check
install_deps.py Node + depyo + pip backend installer
deps.py optional-backend reporter
debug_log.py verbose logging (PYC_REVIVE_VERBOSE=1)
__main__.py
pycdas.exe bundled disassembler (Windows)
what this is and isn't
What it is: a bytecode-to-source reconstructor. It takes a compiled Python file and produces Python source that, when recompiled, matches the original as closely as the pipeline can get. It runs offline, does not execute the input, and does not require the input to be safe to open.
What it isn't: a malware detector. It does not scan files, does not flag behavior, and does not tell you whether a file is dangerous. It reconstructs source from bytecode you already have. In a malware-analysis workflow it comes after something else extracts the .pyc from an executable or archive — it is the step that turns that .pyc back into readable source.
What it will do to your machine: nothing on its own. The pipeline compiles the revived source to check that it parses (compile()), but never executes it (exec() is not called anywhere in the pipeline). If you run it on a hostile .pyc, it will try to decompile it and will not run any of the code inside it.
What it will not do for you: guarantee a reconstruction is safe to run. If you decompile malware and then run the output yourself, that's on you. The tool never runs the source it produces.
known limits
- Inline comprehensions and generator expressions render as
func(...)placeholders in some cases. trybodies that fall through into always-run handlers keep flat order instead of guarded flow, unless an exception table is available. Native 3.11+ and foreign dumps with[Exception Table]sections scope bodies, pair handlers, and defer stranded post-try joins properly.- Exotic decorators reduce to bare
defs. Exotic call shapes degrade to commented disassembly rather than failing. - Pre-3.8 default-argument recovery is best-effort. Flag-style
MAKE_FUNCTIONfrom 3.8 onward recovers exactly. - Verifier scores dip across versions: opcode sets drift between releases, so cross-version similarity reads lower than same-version.
- 3.14
FOR_ITERtargets are untested. No loop has appeared in any 3.14 sample so far. If the bundled disassembler hides caches afterFOR_ITERthe same way it does for conditional branches, loop joins may land 2 bytes off. - The bundled
pycdas.exeis a Windows binary. On Linux and macOS the native and depyo backends still work, but foreign-version support through pycdas requires a platform-appropriate build of that disassembler, which is not currently shipped.
changelog
1.0.3 — depyo backend wired end-to-end. First-run bootstrap installs Node ≥ 20 and depyo without admin rights. --install-deps and --no-bootstrap flags added. depyo_postprocess.py added: drops unresolved name masks, recovers dropped imports from companion source, strips orphan returns, fixes wrong from X import Y. 3.14 rc1 magic added (rc1 and rc3 have different magic bytes). Byte-exact rate on the reference corpus: 12.2% → 25.4%.
1.0.2 — 3.14 support beyond the opcode table. Exception-table parsing, [Locals+Names] sections, hidden-CACHE target repair, BEFORE_WITH folding, region scoping from exception tables.
1.0.1 — first PyPI release. Native and pycdas backends, versions 2.0–3.13 through the magic table, practical testing focused on 3.9–3.13.
license
MIT
Metadata
Release files for pyc-revive 1.0.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pyc_revive-1.0.3.tar.gz | 119.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pyc_revive-1.0.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 241.7 kB
Release files / pyc_revive-1.0.3.tar.gz
| Download URL | pyc_revive-1.0.3.tar.gz |
|---|---|
| Size | 119.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
19954a9ba522096f834f36dd881c1f9c93f4c1829c45790b9f7f64a25532231c
|
|
BLAKE2b-256 checksum How to use checksums |
ade7c5200eb9a8fbd1e77cb433b56a927b54d1d0904a1936fdf45e7bc033b1ab
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.2
|
Release files / pyc_revive-1.0.3-py3-none-any.whl
| Download URL | pyc_revive-1.0.3-py3-none-any.whl |
|---|---|
| Size | 122.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4e931b3a346ee6fcb94622ab7aa75e6e670423b241d3126be9daf0b52d4372c8
|
|
BLAKE2b-256 checksum How to use checksums |
3cd6382bf303644c18a332f74b087abf0af2d1d56cc4df97d124017d3d744f9e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.11.2
|