depyo 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,116 +1,193 @@
1
1
  # depyo — Python bytecode decompiler in Node.js
2
2
 
3
- Depyo converts Python `.pyc` files (or archives of them) back to readable Python source. It aims for broad coverage (Python 1.0 through 3.14) and fast throughput, with fixtures for modern features (exception groups, pattern matching, walrus, f-strings, async, context managers).
3
+ Depyo converts Python `.pyc` files (or archives of them) back to readable Python source — right from Node.js, without a Python runtime. Coverage spans **Python 1.0 through 3.15** plus PyPy, with first-class support for modern features: match/case, walrus, f-strings, exception groups, async/await, type parameters, PEP 696 TypeVar defaults, and t-strings (PEP 750).
4
4
 
5
- ## Why depyo?
6
- - **Wide version coverage:** Opcode tables and expected outputs for Python 1.0–3.14, plus decompilation support for PyPy bytecode sets.
7
- - **Modern features:** WITH_EXCEPT_START/PREP_RERAISE_STAR, async/await, walrus, match/case, f-strings, type params, dict/set merges.
8
- - **Workflow friendly:** CLI options for asm dumps, raw spacing hints, raw `.pyc` preservation, and flattened output paths.
9
- - **Verification harness:** `run-fixtures.js` and `run-matrix.js` compare decompiled output against expected fixtures across versions.
5
+ ```bash
6
+ npx depyo my_script.pyc
7
+ # writes my_script.py next to the input
8
+ ```
9
+
10
+ ## What it's good for
11
+
12
+ - **Reverse engineering stripped Python.** You have a `.pyc` (maybe extracted from a PyInstaller binary, an Android APK's Kivy bundle, or an old archive) and no source. Depyo reconstructs the source — even for Python versions the original `uncompyle6`/`decompyle3` no longer follow.
13
+ - **Malware / threat analysis.** Quickly triage suspicious Python payloads without setting up a matching Python interpreter. Add `--asm` for a bytecode listing alongside the source.
14
+ - **Forensics on old codebases.** Resurrect Python 2.x (even 1.x) modules when the source is long gone.
15
+ - **CI-side audits.** Depyo is a pure Node.js CLI — drop it in any Node pipeline to spot-check compiled `.pyc` against expected sources, or to extract and diff shipped bytecode.
16
+ - **Learning tool.** Inspect how CPython lowers a given Python feature (comprehensions, pattern matching, exception groups) across versions. `--asm` is handy here.
17
+ - **Batch processing.** Feed a `.zip` of `.pyc` files and get back a mirrored tree of `.py` sources.
18
+
19
+ ## Why depyo (vs alternatives)
20
+
21
+ | Tool | Versions | Modern features¹ | Runtime | Throughput | Notes |
22
+ | --------------------- | --------------------- | ---------------- | ------- | ---------- | -------------------------------------------- |
23
+ | **depyo** | 1.0–3.15 + PyPy | Yes | Node.js | ~0.1 ms/file² | Modern opcodes land fast; no Python needed |
24
+ | uncompyle6/decompyle3 | 2.x–3.12 (stalled) | Partial | Python | slower | Development largely halted on 3.13+ |
25
+ | pycdc (C++) | 2.x–3.x (limited new) | Partial | native | fast | Rich history, but slow to adopt new opcodes |
26
+
27
+ ¹ match/case, walrus, f-strings, exception groups, async/await, type params.
28
+ ² Informal: `py314_exception_groups.pyc` × 50 in-process, Node 25, single thread (`--stats` on your machine for real numbers).
10
29
 
11
30
  ## Install
12
- - Global: `npm i -g depyo`
13
- - One-off: `npx depyo <file.pyc>`
14
31
 
15
- Node.js 20+ recommended (matches CI).
32
+ ```bash
33
+ npm i -g depyo # global CLI
34
+ npx depyo <file.pyc> # one-off, no install
35
+ ```
36
+
37
+ Node.js 20+ recommended (CI gate).
16
38
 
17
39
  ## Quick start
18
40
 
19
41
  ```bash
20
- # Decompile a single .pyc
42
+ # Single .pyc writes <name>.py next to it
21
43
  node depyo.js /path/to/file.pyc
22
44
 
23
- # Decompile a zip of .pyc files, emit asm and keep raw bytes
24
- node depyo.js --asm --raw my_archive.zip
45
+ # ZIP of .pyc files mirrors structure
46
+ node depyo.js my_archive.zip
25
47
 
26
- # Write sources next to inputs (skip directory mirroring)
27
- node depyo.js --skip-path /path/to/file.pyc
48
+ # Also emit disassembly and preserve the raw .pyc
49
+ node depyo.js --asm --raw my_archive.zip
28
50
 
29
- # Dump to stdout instead of files
51
+ # Stream to stdout (no files written)
30
52
  node depyo.js --out /path/to/file.pyc
31
53
 
32
- # Marshal-only blob (no .pyc header)
33
- node depyo.js --marshal --py-version 3.11 /path/to/blob.bin
34
- node depyo.js --marshal /path/to/blob.bin
54
+ # Flatten outputs (drop mirrored directories)
55
+ node depyo.js --skip-path /path/to/file.pyc
35
56
 
36
- # Fast marshal scan (no decompile)
37
- node depyo.js --marshal-scan /path/to/blob.bin
57
+ # Headerless marshal blob (no .pyc magic)
58
+ node depyo.js --marshal --py-version 3.11 /path/to/blob.bin
59
+ node depyo.js --marshal /path/to/blob.bin # auto-scan
60
+ node depyo.js --marshal-scan /path/to/blob.bin # fast scan, no decompile
38
61
  ```
62
+
39
63
  Without `--py-version`, depyo scans supported versions (oldest → newest) and accepts the first clean output when all clean candidates agree. If outputs diverge (ambiguous), it stops and asks for `--py-version`. Use `--debug` to see scan results.
40
64
 
41
- ### CLI options
42
- - `--asm` emit `.pyasm` disassembly alongside source
43
- - `--raw` emit raw `.pyc` next to output
44
- - `--raw-spacing` preserve blank lines/comment gaps
45
- - `--dump` dump marshalled object tree
46
- - `--stats` print throughput stats
47
- - `--skip-source-gen` skip writing `.py` (use with `--asm/--dump`)
48
- - `--skip-path` flatten output paths (write next to input)
49
- - `--out` print source to stdout instead of files
50
- - `--marshal` treat input as raw marshalled data (no .pyc header, auto-scan versions)
51
- - `--marshal-scan` fast scan marshal blobs and print version candidates
52
- - `--py-version <x.y>` bytecode version hint (use with `--marshal`)
53
- - `--basedir <dir>` override output root (default: alongside input)
54
- - `--file-ext <ext>` change emitted extension (default `py`)
55
-
56
- ## Examples
57
- - Disassemble only (no source): `node depyo.js --skip-source-gen --asm file.pyc`
58
- - Keep raw + disassembly next to source: `node depyo.js --raw --asm path/to/file.pyc`
59
- - Flatten outputs (helpful for bulk zips): `node depyo.js --skip-path archive.zip`
65
+ ## Example
60
66
 
61
- ## Testing
62
- - Smoke per version:
63
- ```bash
64
- node scripts/run-fixtures.js --root test/bytecode_3.14 --pattern py314_with_except_star --fail-fast
65
- node scripts/run-fixtures.js --root test/bytecode_3.6 --pattern py36_fstrings --fail-fast
66
- ```
67
- - Matrix (all versions, optional PyPy):
68
- ```bash
69
- node scripts/run-matrix.js # full sweep
70
- node scripts/run-matrix.js --pattern py311_exception_groups --fail-fast
71
- ```
72
- - Marshal fixtures (headerless marshal blobs):
73
- ```bash
74
- node scripts/run-marshal-fixtures.js
75
- ```
76
- - Regenerate marshal fixtures:
77
- ```bash
78
- node scripts/generate-marshal-fixtures.js --clean
79
- ```
80
- - Modern fixtures are generated via `test/generate_modern_tests.py` (Python 3.8+ on PATH).
67
+ Input `greet.py`:
68
+
69
+ ```python
70
+ async def greet(names: list[str], *, greeting: str = "Hello") -> None:
71
+ seen = set()
72
+ for name in names:
73
+ if name in seen:
74
+ continue
75
+ seen.add(name)
76
+ print(f"{greeting}, {name}!")
77
+ ```
78
+
79
+ Compile (`python3.13 -c 'import py_compile; py_compile.compile("greet.py", "greet.pyc")'`) then:
80
+
81
+ ```bash
82
+ $ npx depyo --out greet.pyc
83
+ async def greet(names: list[str], *, greeting: str = "Hello") -> None:
84
+ seen = set()
85
+ for name in names:
86
+ if name in seen:
87
+ continue
88
+ seen.add(name)
89
+ print(f"{greeting}, {name}!")
90
+ ```
91
+
92
+ Pattern matching round-trips too:
93
+
94
+ ```python
95
+ match command.split():
96
+ case [action]:
97
+ run(action)
98
+ case [action, obj] if action in VERBS:
99
+ run(action, obj)
100
+ case _:
101
+ print("usage: ...")
102
+ ```
103
+
104
+ ## CLI options
105
+
106
+ | Option | Effect |
107
+ | ------------------------ | --------------------------------------------------------------- |
108
+ | `--asm` | Emit `.pyasm` disassembly alongside source |
109
+ | `--raw` | Copy raw `.pyc` next to output |
110
+ | `--raw-spacing` | Preserve blank-line / comment gaps |
111
+ | `--dump` | Dump the marshalled object tree |
112
+ | `--stats` | Print throughput stats |
113
+ | `--skip-source-gen` | Skip writing `.py` (useful with `--asm`/`--dump`) |
114
+ | `--skip-path` | Flatten output paths (write next to input) |
115
+ | `--out` | Print source to stdout instead of files |
116
+ | `--marshal` | Treat input as raw marshalled data (no `.pyc` header) |
117
+ | `--marshal-scan` | Fast scan marshal blobs; print candidate versions |
118
+ | `--py-version <x.y>` | Bytecode version hint (required for some headerless marshals) |
119
+ | `--basedir <dir>` | Override output root (default: alongside input) |
120
+ | `--file-ext <ext>` | Change emitted extension (default `py`) |
121
+
122
+ ## Programmatic API
123
+
124
+ ```js
125
+ const {PycReader} = require('depyo/lib/PycReader');
126
+ const {PycDecompiler} = require('depyo/lib/PycDecompiler');
127
+
128
+ const fs = require('fs');
129
+ const buffer = fs.readFileSync('greet.pyc');
130
+ const reader = new PycReader(buffer);
131
+ const obj = reader.ReadObject();
132
+
133
+ const decompiler = new PycDecompiler(obj);
134
+ const ast = decompiler.decompile();
135
+ console.log(ast.codeFragment().toString());
136
+ ```
81
137
 
82
138
  ## Support matrix
83
- - Python 1.0–3.14 opcode tables with expected fixtures.
84
- - Modern features: match/case, walrus, f-strings, exception groups, type params.
85
- - PyPy bytecode sets decompile; expected files are not yet part of CI.
86
- - Legacy CI smokes (1.x/2.7/3.0–3.6) are informational (`continue-on-error`); modern feature checks are blocking.
139
+
140
+ - **Python 1.0–3.15** opcode tables and expected fixtures.
141
+ - **Modern features:** match/case (guards, OR-patterns, bindings, wildcards), walrus, f-strings (nested, equals-sign debug), exception groups (`except*`), async comprehensions, type parameters, PEP 696 TypeVar defaults, PEP 750 t-strings.
142
+ - **PyPy** bytecode decompiles; expected fixtures not yet part of CI.
143
+ - **CI gates:** Modern feature checks are blocking; legacy 1.x / 2.7 / 3.0–3.6 smokes gate as well.
87
144
 
88
145
  ## Known limitations
89
- - **Inline comprehensions (Python 3.12+):** PEP 709 inlines list/dict/set comprehensions into parent code objects. Depyo currently reconstructs these as for-loops rather than comprehension expressions. Functions, classes, match/case, exception handling, and other constructs work correctly.
90
146
 
91
- ## Contributing / DX tips
147
+ - **Inline comprehensions (3.12+):** PEP 709 inlines list/dict/set comprehensions into the parent code object. Depyo currently reconstructs these as for-loops rather than comprehension expressions. Functions, classes, match/case, exception handling, and other constructs work correctly.
148
+ - **Comments / blank lines:** Lost in compilation and not recoverable. `--raw-spacing` can hint at original gaps using line-number attributes.
149
+ - **Source-level AST drift:** Some constructs are normalized by CPython before bytecode (e.g. `if not x: raise AssertionError` ↔ `assert x`). Depyo renders what the compiler produced.
150
+
151
+ ## Testing
152
+
153
+ ```bash
154
+ # Smoke per version
155
+ node scripts/run-fixtures.js --root test/bytecode_3.14 --pattern py314_with_except_star --fail-fast
156
+ node scripts/run-fixtures.js --root test/bytecode_3.6 --pattern py36_fstrings --fail-fast
157
+
158
+ # Full matrix
159
+ node scripts/run-matrix.js
160
+ node scripts/run-matrix.js --pattern py311_exception_groups --fail-fast
161
+
162
+ # Marshal-blob fixtures (headerless)
163
+ node scripts/run-marshal-fixtures.js
164
+
165
+ # Regenerate snapshot fixtures (destructive)
166
+ node scripts/generate-marshal-fixtures.js --clean
167
+
168
+ # Tier-1 oracle: parseability of every decompiled fixture
169
+ node scripts/check-parseable.js
170
+
171
+ # Tier-2 oracle: AST equivalence between source .py and decompiled .py
172
+ node scripts/check-ast-equivalence.js
173
+
174
+ # Sentinel leak gate (CI-critical)
175
+ node scripts/check-no-sentinels.js
176
+ ```
177
+
178
+ Modern fixtures are generated via `test/generate_modern_tests.py` (Python 3.8+ on PATH).
179
+
180
+ ## Contributing
181
+
92
182
  - Use `node scripts/run-fixtures.js --pattern <piece>` for fast repros.
93
183
  - For full coverage, `node scripts/run-matrix.js --fail-fast` (optionally add `--pattern`).
94
- - Enable `--raw-spacing` to inspect potential comment/blank-line gaps.
184
+ - `--raw-spacing` helps inspect potential comment/blank-line gaps.
95
185
  - `--stats` helps when profiling throughput.
96
186
 
97
- Comments and docs are in English; output mirrors the target Python version syntax.
187
+ Issues, repro `.pyc` files, and PRs welcome at https://github.com/skuznetsov/depyo.js/issues.
98
188
 
99
- ## Comparison snapshot (at a glance)
100
-
101
- | Project | Supported versions | Modern features (match, walrus, f-strings, exc groups) | Delivery | Expected fixtures | Notes |
102
- | ------------------ | --------------------------- | ------------------------------------------------------ | ------------ | ----------------- | ----------------------------------------- |
103
- | depyo | 1.0–3.14 (PyPy decompiles) | Yes | npm/npx, CLI | Yes (1.0–3.14) | Node.js CLI, asm/raw-spacing options |
104
- | uncompyle6/decompyle3 | 2.x–3.12+ (lag on 3.13/3.14) | Partial (depends on branch) | pip | Partial | Python-based, slower adoption of new ops |
105
- | pycdc (C++) | Mostly 2.x–3.x (limited new) | Partial | source build | No | Fast, but modern coverage limited |
189
+ Comments and docs are in English; output mirrors the target Python version syntax.
106
190
 
107
- ## Quick benchmark (informal)
108
- - Machine: local Node 25, single-thread.
109
- - Case: `py314_exception_groups.pyc` decompiled 50× in-process: ~5.3 ms total (≈0.1 ms per decompile).
110
- Use `node depyo.js --stats <file.pyc>` for your environment.
191
+ ## License
111
192
 
112
- ## Promotion ideas (OSS)
113
- - Announce on HN/Reddit (Show HN / r/Python) with npm/npx one-liners.
114
- - Add to awesome lists (`awesome-python`, `awesome-reverse-engineering`).
115
- - Provide asciinema/GIF of `npx depyo file.pyc` + `--asm`.
116
- - Encourage contributions via Issues/Discussions and `help wanted` labels.
193
+ MIT see [LICENSE](LICENSE).
@@ -933,6 +933,23 @@ class PycDecompiler {
933
933
  break; // This WITH block hasn't reached its end yet
934
934
  }
935
935
 
936
+ // Close any blocks ABOVE this WITH whose end is also reached.
937
+ // 3.14 with-bodies that contain an if/else commonly have the
938
+ // else's end coincide with the with's end (both target the
939
+ // post-with cleanup offset). If we close the WITH while an
940
+ // Else is still open above it, the else body gets reparented
941
+ // to the WITH's parent — producing a stray `else:` dedented
942
+ // out of the with-block in the rendered source.
943
+ while (this.blocks.length - 1 > withIdx) {
944
+ const above = this.blocks.top();
945
+ if (above.end <= 0 || this.code.Current.Offset < above.end) {
946
+ break;
947
+ }
948
+ this.blocks.pop();
949
+ const newTop = this.blocks.top();
950
+ if (newTop) newTop.append(above);
951
+ }
952
+
936
953
  // Close this WITH block
937
954
  this.blocks.splice(withIdx, 1);
938
955
  const parentBlock = this.blocks[withIdx - 1] || this.blocks.top();
@@ -1914,9 +1931,16 @@ class PycDecompiler {
1914
1931
  // trailing compiler-generated reraise/cleanup nodes (bare `raise`,
1915
1932
  // `e = None; del e`, empty `try: pass`). Runs once on the whole tree.
1916
1933
  cleanupExcMatchArtifacts(root) {
1917
- // CHECK_EXC_MATCH only exists in Py3.11+; earlier versions never produce
1918
- // the EXC_MATCH compare op or the __exception__ sentinel, so skip.
1919
- if (this.object.Reader.versionCompare(3, 11) < 0) return;
1934
+ // Pre-3.11 except-as handlers still need two cleanups: strip the
1935
+ // `X = None; del X` pair CPython emits after every `except ... as X:`
1936
+ // body (PEP 3134), and unwrap the synthetic `try: body / finally: pass`
1937
+ // that wraps the handler body (CPython lowers `except ... as X:` to
1938
+ // `try: body; finally: X = None; del X`, and the finally body ends up
1939
+ // emptied as the X=None/del X move out to the handler tail).
1940
+ if (this.object.Reader.versionCompare(3, 11) < 0) {
1941
+ this.cleanupPre311ExceptAsArtifacts(root);
1942
+ return;
1943
+ }
1920
1944
 
1921
1945
  const EXC_MATCH = AST.ASTCompare.CompareOp.Exception;
1922
1946
 
@@ -2354,6 +2378,139 @@ class PycDecompiler {
2354
2378
  visit(root);
2355
2379
  }
2356
2380
 
2381
+ // Pre-3.11 `except Foo as X:` expands to `try: body / finally: X = None;
2382
+ // del X`. The decompiler hoists the cleanup out to the handler tail,
2383
+ // leaving `try: body / finally: pass / X = None / del X`. Strip the pair
2384
+ // and unwrap the synthetic try/finally so the handler renders as `body`.
2385
+ cleanupPre311ExceptAsArtifacts(root) {
2386
+ const isNoneDelPair = (a, b) =>
2387
+ a instanceof AST.ASTStore &&
2388
+ a.src instanceof AST.ASTNone &&
2389
+ a.dest instanceof AST.ASTName &&
2390
+ b instanceof AST.ASTDelete &&
2391
+ b.value instanceof AST.ASTName &&
2392
+ a.dest.name === b.value.name;
2393
+
2394
+ const isFinallyTrivial = (n) =>
2395
+ n instanceof AST.ASTBlock &&
2396
+ n.blockType === AST.ASTBlock.BlockType.Finally &&
2397
+ (n.nodes.length === 0 ||
2398
+ (n.nodes.length === 1 &&
2399
+ n.nodes[0] instanceof AST.ASTKeyword &&
2400
+ n.nodes[0].key === AST.ASTKeyword.Word.Pass));
2401
+
2402
+ // A CPython `except Foo as X:` body wraps user code in a synthetic
2403
+ // `try: body / finally: X=None; del X`. After the Pass 3.5 strip the
2404
+ // finally becomes empty / pass-only, leaving an ASTContainerBlock
2405
+ // whose children are [Try(body), Finally(pass)]. Collapse that back
2406
+ // to just the try-body so the handler renders as `body`.
2407
+ const unwrapTryFinallyPassContainer = (nodes) => {
2408
+ for (let i = nodes.length - 1; i >= 0; i--) {
2409
+ const cont = nodes[i];
2410
+ if (!(cont instanceof AST.ASTContainerBlock)) continue;
2411
+ const children = cont.nodes;
2412
+ if (!children || children.length !== 2) continue;
2413
+ const tryBlk = children[0];
2414
+ const finBlk = children[1];
2415
+ if (!(tryBlk instanceof AST.ASTBlock) ||
2416
+ tryBlk.blockType !== AST.ASTBlock.BlockType.Try) continue;
2417
+ if (!isFinallyTrivial(finBlk)) continue;
2418
+ if (tryBlk.nodes.length === 0) continue;
2419
+ // Keep real user try/except nested inside — unwrapping
2420
+ // would change semantics when the try body itself carries
2421
+ // handlers that could fire.
2422
+ const hasNestedHandler = tryBlk.nodes.some(n =>
2423
+ n instanceof AST.ASTCondBlock &&
2424
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2425
+ n.blockType === AST.ASTBlock.BlockType.Finally));
2426
+ if (hasNestedHandler) continue;
2427
+ nodes.splice(i, 1, ...tryBlk.nodes);
2428
+ }
2429
+ };
2430
+
2431
+ const stripNoneDelPairs = (nodes) => {
2432
+ for (let i = nodes.length - 2; i >= 0; i--) {
2433
+ if (isNoneDelPair(nodes[i], nodes[i + 1])) {
2434
+ nodes.splice(i, 2);
2435
+ }
2436
+ }
2437
+ };
2438
+
2439
+ // CPython 3.6+ `except Foo as X:` lowers to a try/finally cleanup
2440
+ // wrapper around the handler body. The reconstruction sometimes
2441
+ // emits the handler ASTCondBlock twice when the inner cleanup's
2442
+ // END_FINALLY is misread as a second handler match. Collapse
2443
+ // consecutive identical Except blocks (same rendered condition +
2444
+ // body) since real Python source can't legally have them.
2445
+ const dedupeConsecutiveExcepts = (nodes) => {
2446
+ const renderedKey = (n) => {
2447
+ try {
2448
+ const cf = n.codeFragment?.();
2449
+ return typeof cf === 'string' ? cf : (cf?.toString?.() || null);
2450
+ } catch (e) {
2451
+ return null;
2452
+ }
2453
+ };
2454
+ const isExcept = (n) => (n instanceof AST.ASTCondBlock)
2455
+ && n.blockType === AST.ASTBlock.BlockType.Except;
2456
+ // Walk runs of contiguous Except siblings and drop duplicates
2457
+ // (handles both adjacent and non-adjacent duplicates within
2458
+ // the same handler chain — e.g. `except A / except: / except A`).
2459
+ let i = 0;
2460
+ while (i < nodes.length) {
2461
+ if (!isExcept(nodes[i])) { i++; continue; }
2462
+ let j = i;
2463
+ while (j < nodes.length && isExcept(nodes[j])) j++;
2464
+ const seen = new Map();
2465
+ for (let k = i; k < j; k++) {
2466
+ const key = renderedKey(nodes[k]);
2467
+ if (key && seen.has(key)) {
2468
+ nodes.splice(k, 1);
2469
+ j--;
2470
+ k--;
2471
+ } else if (key) {
2472
+ seen.set(key, true);
2473
+ }
2474
+ }
2475
+ i = j;
2476
+ }
2477
+ };
2478
+
2479
+ const visited = new WeakSet();
2480
+ const visit = (n) => {
2481
+ if (!n || visited.has(n)) return;
2482
+ visited.add(n);
2483
+ let nodes = null;
2484
+ let parentType = null;
2485
+ if (n instanceof AST.ASTNodeList) {
2486
+ nodes = n.list;
2487
+ } else if (n instanceof AST.ASTBlock) {
2488
+ nodes = n.nodes;
2489
+ parentType = n.blockType;
2490
+ } else if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2491
+ const body = n.src.code?.object?.SourceCode;
2492
+ if (body) visit(body);
2493
+ return;
2494
+ }
2495
+ if (nodes) {
2496
+ for (const c of nodes) visit(c);
2497
+ stripNoneDelPairs(nodes);
2498
+ // The try/finally:pass wrapper is CPython's synthetic scaffold
2499
+ // for `except Foo as X:` — only unwrap inside Except bodies,
2500
+ // otherwise real user-written `try: body / finally: pass`
2501
+ // constructs at top-level/function body get collapsed too.
2502
+ if (parentType === AST.ASTBlock.BlockType.Except) {
2503
+ unwrapTryFinallyPassContainer(nodes);
2504
+ }
2505
+ // Dedupe at every level (Container, Try, NodeList) since the
2506
+ // duplicate Except siblings can land in any of these depending
2507
+ // on how SETUP_FINALLY/END_FINALLY interleave.
2508
+ dedupeConsecutiveExcepts(nodes);
2509
+ }
2510
+ };
2511
+ visit(root);
2512
+ }
2513
+
2357
2514
  // An Except block can get reconstructed inside a non-Try block's body
2358
2515
  // (most often a For/While/If inside the protected try-body) when the
2359
2516
  // handler's offset range overlaps a nested loop. Python requires the
package/lib/PycReader.js CHANGED
@@ -38,13 +38,14 @@ const TypeSmallTuple = ')';
38
38
  const TypeShortAscii = 'z';
39
39
  const TypeShortAsciiInterned = 'Z';
40
40
  const TypeObjectReference = 'r';
41
+ const TypeSlice = ':'; // CPython 3.14+: marshal'd slice constant (start, stop, step)
41
42
  const KnownTypes = new Set([
42
43
  TypeNull, TypeNone, TypeFalse, TypeTrue, TypeStopiter, TypeEllipsis,
43
44
  TypeInt, TypeInt64, TypeFloat, TypeBinaryFloat, TypeComplex, TypeBinaryComplex,
44
45
  TypeLong, TypeString, TypeInterned, TypeStringRef, TypeTuple, TypeList,
45
46
  TypeDict, TypeCode, TypeCode2, TypeUnicode, TypeUnknown, TypeSet, TypeFrozenset,
46
47
  TypeAscii, TypeAsciiInterned, TypeSmallTuple, TypeShortAscii, TypeShortAsciiInterned,
47
- TypeObjectReference
48
+ TypeObjectReference, TypeSlice
48
49
  ]);
49
50
 
50
51
  const MagicToVersion = {
@@ -590,6 +591,14 @@ class PycReader
590
591
  obj.ClassName = "Py_FrozenSet";
591
592
  obj.Value = frozenSet;
592
593
  break;
594
+ case TypeSlice: {
595
+ const sliceStart = this.ReadObject();
596
+ const sliceStop = this.ReadObject();
597
+ const sliceStep = this.ReadObject();
598
+ obj.ClassName = "Py_Slice";
599
+ obj.Value = { start: sliceStart, stop: sliceStop, step: sliceStep };
600
+ break;
601
+ }
593
602
  case TypeCode:
594
603
  case TypeCode2:
595
604
  let codeObj = this.ReadCodeObject();
@@ -194,7 +194,13 @@ class PythonObject {
194
194
 
195
195
  case "Py_Complex":
196
196
  return `${this.Value[0]}+${this.Value[1]}j`;
197
-
197
+
198
+ case "Py_Slice": {
199
+ const part = (v) => v instanceof PythonObject ? v.toReprString() : String(v);
200
+ const { start, stop, step } = this.Value || {};
201
+ return `slice(${part(start)}, ${part(stop)}, ${part(step)})`;
202
+ }
203
+
198
204
  default:
199
205
  return ["Py_String"].includes(this.ClassName) ? this.Value.toString("ascii") : null;
200
206
  }
@@ -212,9 +212,14 @@ class ASTNodeList extends ASTNode {
212
212
  if (this.emptyBlock()) {
213
213
  result.add("pass");
214
214
  } else {
215
+ // Defensive: a handler may have appended an undefined slot (e.g.
216
+ // a stack-pop that returned undefined on a malformed pyc).
217
+ // Without this, the sibling-link loop crashes on `undefined.prevSibling`
218
+ // and aborts rendering of the whole code object.
219
+ const list = this.list.filter(n => n);
215
220
  let prevNode = null;
216
221
 
217
- for (let node of this.list) {
222
+ for (let node of list) {
218
223
  if (prevNode) {
219
224
  prevNode.nextSibling = node;
220
225
  }
@@ -223,7 +228,7 @@ class ASTNodeList extends ASTNode {
223
228
  }
224
229
  prevNode = null;
225
230
 
226
- for (let node of this.list) {
231
+ for (let node of list) {
227
232
  if (node.skip) {
228
233
  continue;
229
234
  }
@@ -2602,6 +2607,17 @@ class ASTBlock extends ASTNode {
2602
2607
  }
2603
2608
  }
2604
2609
 
2610
+ // Predicate: can this node legally appear as one branch of a ternary `x if c else y`?
2611
+ // Block-shaped nodes render multi-line and an ASTKeyword (pass/break/continue) is a
2612
+ // statement, not an expression — appending " if c else y" to either is invalid Python.
2613
+ function isTernaryExprNode(node) {
2614
+ if (node == null) return false;
2615
+ if (node instanceof ASTBlock) return false;
2616
+ if (node instanceof ASTKeyword) return false;
2617
+ if (node instanceof ASTReturn) return false;
2618
+ return true;
2619
+ }
2620
+
2605
2621
  class ASTCondBlock extends ASTBlock {
2606
2622
  static InitCondition = {
2607
2623
  Uninited: 0,
@@ -2636,7 +2652,23 @@ class ASTCondBlock extends ASTBlock {
2636
2652
 
2637
2653
  codeFragment() {
2638
2654
  let result = new PycResult("", true);
2655
+ // Cycle guard: if a control-flow handler accidentally nests a block
2656
+ // inside its own descendants, the recursive render would blow the JS
2657
+ // stack and abort the enclosing code object. Detect re-entry on the
2658
+ // same instance, emit a sentinel, and let the rest of the file render.
2659
+ if (this.__rendering) {
2660
+ result.add("###CYCLE###");
2661
+ return result;
2662
+ }
2663
+ this.__rendering = true;
2664
+ try {
2665
+ return this._codeFragmentImpl(result);
2666
+ } finally {
2667
+ this.__rendering = false;
2668
+ }
2669
+ }
2639
2670
 
2671
+ _codeFragmentImpl(result) {
2640
2672
  // This is the assert case
2641
2673
  if (this.blockType == ASTBlock.BlockType.If && this.negative && this.condition && this.nodes.length == 1 && this.nodes[0] instanceof ASTRaise) {
2642
2674
  result.lastLineAppend("assert ", false);
@@ -2651,9 +2683,11 @@ class ASTCondBlock extends ASTBlock {
2651
2683
  this.prevSibling instanceof ASTStore &&
2652
2684
  this.blockType == ASTBlock.BlockType.If &&
2653
2685
  this.nodes.length == 1 &&
2686
+ isTernaryExprNode(this.nodes[0]) &&
2654
2687
  this.nextSibling instanceof ASTCondBlock &&
2655
2688
  this.nextSibling?.type == ASTBlock.BlockType.Else &&
2656
2689
  this.nextSibling?.nodes.length == 1 &&
2690
+ isTernaryExprNode(this.nextSibling.nodes[0]) &&
2657
2691
  this.condition.line >= 0 &&
2658
2692
  this.condition.line == this.nodes[0].line &&
2659
2693
  this.condition.line == this.nextSibling?.nodes[0].line
@@ -2670,7 +2704,10 @@ class ASTCondBlock extends ASTBlock {
2670
2704
  this.prevSibling == null &&
2671
2705
  this.blockType == ASTBlock.BlockType.If &&
2672
2706
  this.nodes.length == 1 &&
2707
+ isTernaryExprNode(this.nodes[0]) &&
2673
2708
  this.nextSibling instanceof ASTReturn &&
2709
+ this.nextSibling.value &&
2710
+ isTernaryExprNode(this.nextSibling.value) &&
2674
2711
  this.condition.line == this.nodes[0].line &&
2675
2712
  this.condition.line == this.nextSibling?.value.line
2676
2713
  ) {
@@ -7,7 +7,7 @@ const opcodes = [
7
7
  [0, new OpCode(OpCodes.CACHE, "CACHE")],
8
8
  [1, new OpCode(OpCodes.BINARY_SLICE, "BINARY_SLICE")],
9
9
  [2, new OpCode(OpCodes.BUILD_TEMPLATE, "BUILD_TEMPLATE")],
10
- [4, new OpCode(OpCodes.CALL_FUNCTION_EX, "CALL_FUNCTION_EX")],
10
+ [4, new OpCode(OpCodes.CALL_FUNCTION_EX_A, "CALL_FUNCTION_EX", {HasArgument: true})],
11
11
  [5, new OpCode(OpCodes.CHECK_EG_MATCH, "CHECK_EG_MATCH")],
12
12
  [6, new OpCode(OpCodes.CHECK_EXC_MATCH, "CHECK_EXC_MATCH")],
13
13
  [7, new OpCode(OpCodes.CLEANUP_THROW, "CLEANUP_THROW")],
@@ -2,6 +2,15 @@ const AST = require('../ast/ast_node');
2
2
 
3
3
  function handleBinaryOpA()
4
4
  {
5
+ // Python 3.14 fused BINARY_SUBSCR into BINARY_OP arg=26 (NB_SUBSCR).
6
+ if (this.code.Current.Argument === 26) {
7
+ let subscr = this.dataStack.pop();
8
+ let src = this.dataStack.pop();
9
+ let node = new AST.ASTSubscr(src, subscr);
10
+ node.line = this.code.Current.LineNo;
11
+ this.dataStack.push(node);
12
+ return;
13
+ }
5
14
  let rVal = this.dataStack.pop();
6
15
  let lVal = this.dataStack.pop();
7
16
  let op = AST.ASTBinary.from_binary_op(this.code.Current.Argument);
@@ -1177,12 +1177,37 @@ function handleJumpAbsoluteA() {
1177
1177
  this.blocks.push(next);
1178
1178
  prev = null;
1179
1179
  } else if (prev.blockType == AST.ASTBlock.BlockType.Except) {
1180
- let top = this.blocks.top();
1181
- let next = new AST.ASTCondBlock(AST.ASTBlock.BlockType.Except, top.start, top.end, null, false);
1182
- next.init();
1180
+ // After closing one handler we speculatively open a fresh
1181
+ // Except slot in case another handler follows. But if the
1182
+ // chain has no more user-written handlers — only CPython's
1183
+ // synthetic END_FINALLY re-raise terminator before our
1184
+ // jump target — pushing an empty Except here produces a
1185
+ // phantom `except: pass` in the output.
1186
+ let chainTerminated = false;
1187
+ const target = this.code.Current.JumpTarget;
1188
+ const cursorStart = this.code.Next?.Offset;
1189
+ if (target != null && cursorStart != null && cursorStart < target) {
1190
+ let cursor = cursorStart;
1191
+ for (let k = 0; k < 32 && cursor < target; k++) {
1192
+ const instr = this.code.PeekInstructionAtOffset(cursor);
1193
+ if (!instr) break;
1194
+ if (instr.OpCodeID === this.OpCodes.END_FINALLY) {
1195
+ chainTerminated = true;
1196
+ break;
1197
+ }
1198
+ cursor = instr.Offset + (instr.Size || 1);
1199
+ }
1200
+ }
1201
+ if (chainTerminated) {
1202
+ prev = null;
1203
+ } else {
1204
+ let top = this.blocks.top();
1205
+ let next = new AST.ASTCondBlock(AST.ASTBlock.BlockType.Except, top.start, top.end, null, false);
1206
+ next.init();
1183
1207
 
1184
- this.blocks.push(next);
1185
- prev = null;
1208
+ this.blocks.push(next);
1209
+ prev = null;
1210
+ }
1186
1211
  } else if (prev.blockType == AST.ASTBlock.BlockType.Else) {
1187
1212
  /* Special case */
1188
1213
  if (this.blocks.top().blockType != AST.ASTBlock.BlockType.Main) {
@@ -182,6 +182,22 @@ function handleLoadGlobalA() {
182
182
  this.dataStack.push(node);
183
183
  }
184
184
 
185
+ function handleLoadFromDictOrGlobalsA() {
186
+ // Python 3.12+: pops the class-body locals dict pushed by LOAD_LOCALS,
187
+ // looks up co_names[A] there, falling back to globals. For source
188
+ // reconstruction we render it as a bare name reference.
189
+ if (this.dataStack.top() instanceof AST.ASTLocals) {
190
+ this.dataStack.pop();
191
+ }
192
+ const varName = this.code.Current.Name || "";
193
+ if (!varName.length) {
194
+ return;
195
+ }
196
+ let node = new AST.ASTName(varName);
197
+ node.line = this.code.Current.LineNo;
198
+ this.dataStack.push(node);
199
+ }
200
+
185
201
  function handleLoadFromDictOrDerefA() {
186
202
  // Python 3.12+: LOAD_FROM_DICT_OR_DEREF consumes the locals dict from TOS
187
203
  // (pushed by the preceding LOAD_LOCALS) and resolves the name against it,
@@ -253,9 +269,15 @@ function handleLoadSpecialA() {
253
269
  }
254
270
 
255
271
  // For __exit__ (oparg 1): save the context manager expression for with-block
256
- // This is called BEFORE __enter__ in 3.14 bytecode pattern
272
+ // This is called BEFORE __enter__ in 3.14 bytecode pattern.
273
+ // The 3.14 with-prologue uses LOAD_FAST(ctx); COPY 1; LOAD_SPECIAL 1, which
274
+ // looks like the LOAD+COPY 1 match-subject idiom — clear the candidate so
275
+ // a later COMPARE_OP inside the with-body doesn't get wrapped in a
276
+ // synthetic `match ctx:` rooted on the context manager.
257
277
  if (oparg === 1) {
258
278
  this._py314WithContextMgr = obj;
279
+ this.potentialMatchSubject = null;
280
+ this.matchCandidateStart = -1;
259
281
  }
260
282
 
261
283
  // For __enter__ (oparg 0): create with-block using saved context manager
@@ -478,6 +500,28 @@ function processStore(nameOverride) {
478
500
  }
479
501
  }
480
502
 
503
+ // `case _ as name:` final clause: no preceding pattern ops, no LOAD
504
+ // before the STORE, and we're inside a match. CPython emits a bare STORE
505
+ // to bind the subject still living on the stack.
506
+ if (this.currentMatch && !this.currentCase && !this.inMatchPattern &&
507
+ (this.patternOps?.length || 0) === 0) {
508
+ const prevId = this.code.Prev?.OpCodeID;
509
+ const isAfterReturn = prevId == this.OpCodes.RETURN_VALUE ||
510
+ prevId == this.OpCodes.RETURN_VALUE_A ||
511
+ prevId == this.OpCodes.RETURN_CONST_A;
512
+ if (isAfterReturn) {
513
+ const wildcard = new AST.ASTPattern(AST.ASTPattern.PatternType.Wildcard, '_');
514
+ const asPattern = new AST.ASTPattern(AST.ASTPattern.PatternType.As, {
515
+ pattern: wildcard,
516
+ name: currentName
517
+ });
518
+ this.currentCase = new AST.ASTCase(asPattern, null, null);
519
+ this.currentCase.line = this.code.Current.LineNo;
520
+ this.caseBodyStartIndex = this.curBlock?.nodes?.length || 0;
521
+ return;
522
+ }
523
+ }
524
+
481
525
  if (global.g_cliArgs?.debug && currentName && (currentName === 'b' || currentName === 'i')) {
482
526
  console.log(`[processStore] varName=${currentName}, curBlock=${this.curBlock.type_str}, inited=${this.curBlock.inited}, unpack=${this.unpack}`);
483
527
  console.log(` Block stack: ${this.blocks.map((b,i) => `[${i}]${b.type_str}(inited=${b.inited})`).join(' → ')}`);
@@ -832,6 +876,7 @@ module.exports = {
832
876
  handleLoadZeroSuperAttrA,
833
877
  handleLoadZeroSuperMethodA,
834
878
  handleLoadFromDictOrDerefA,
879
+ handleLoadFromDictOrGlobalsA,
835
880
  handleStoreAttrA,
836
881
  handleStoreDerefA,
837
882
  handleStoreFastA,
@@ -241,11 +241,25 @@ function reconstructPattern(patternOps) {
241
241
  ));
242
242
  }
243
243
 
244
- // Literal OR pattern: multiple COMPARE_OP checks in a row
244
+ // Literal OR pattern: multiple COMPARE_OP checks in a row.
245
+ // Pattern compares share the same `left` (the matched subject). Anything
246
+ // with a different left is a guard expression that bled into patternOps.
245
247
  const allCompareOps = ops.filter(op => op.type === 'COMPARE');
246
- if (!hasMatchSeq && allCompareOps.length > 1) {
248
+ const sameLeft = (a, b) => {
249
+ if (a === b) return true;
250
+ if (!a || !b) return false;
251
+ const af = a.codeFragment?.()?.toString?.();
252
+ const bf = b.codeFragment?.()?.toString?.();
253
+ return !!af && af === bf;
254
+ };
255
+ let patternCompareOps = allCompareOps;
256
+ if (allCompareOps.length > 1) {
257
+ const subjectLeft = allCompareOps[0].left;
258
+ patternCompareOps = allCompareOps.filter(op => sameLeft(op.left, subjectLeft));
259
+ }
260
+ if (!hasMatchSeq && patternCompareOps.length > 1) {
247
261
  const orPatterns = [];
248
- for (const cmp of allCompareOps) {
262
+ for (const cmp of patternCompareOps) {
249
263
  markConsumed(cmp);
250
264
  if (cmp.right instanceof AST.ASTObject) {
251
265
  orPatterns.push(new AST.ASTPattern(AST.ASTPattern.PatternType.Literal, cmp.right));
@@ -366,6 +380,21 @@ function hasUpcomingMatchCase() {
366
380
  return true;
367
381
  }
368
382
 
383
+ // `case _ as name:` final clause — CPython emits a bare STORE for the
384
+ // binding right after the previous case's RETURN. No LOAD precedes it
385
+ // (the stored value is the matched subject preserved on the stack).
386
+ if (this.currentMatch && this.matchSubject &&
387
+ (nextOpCodeId == this.OpCodes.STORE_FAST_A ||
388
+ nextOpCodeId == this.OpCodes.STORE_NAME_A)) {
389
+ const prevId = this.code.Current.OpCodeID;
390
+ const isAfterReturn = prevId == this.OpCodes.RETURN_VALUE ||
391
+ prevId == this.OpCodes.RETURN_VALUE_A ||
392
+ prevId == this.OpCodes.RETURN_CONST_A;
393
+ if (isAfterReturn) {
394
+ return true;
395
+ }
396
+ }
397
+
369
398
  // NOP followed by code (no COMPARE_OP) = default case (case _:)
370
399
  // This pattern appears in 3.10/3.11 after the last literal case
371
400
  if (nextOpCodeId == this.OpCodes.NOP && this.currentMatch) {
@@ -1330,6 +1359,12 @@ function handleInstrumentedLineA() {
1330
1359
  }
1331
1360
  }
1332
1361
 
1362
+ // CPython 3.13+: POP_ITER replaces the post-loop POP_TOP that discards an
1363
+ // exhausted iterator. Semantically identical to POP_TOP for our purposes.
1364
+ function handlePopIter() {
1365
+ return handlePopTop.call(this);
1366
+ }
1367
+
1333
1368
  module.exports = {
1334
1369
  beginMatchCaseFromPattern,
1335
1370
  flushCurrentCaseBody,
@@ -1338,6 +1373,7 @@ module.exports = {
1338
1373
  handleFormatSimple,
1339
1374
  handleFormatWithSpec,
1340
1375
  handlePopTop,
1376
+ handlePopIter,
1341
1377
  handlePrintExpr,
1342
1378
  handlePrintItem,
1343
1379
  handlePrintItemTo,
@@ -28,7 +28,8 @@ function handleDupTop() {
28
28
 
29
29
  // Look ahead to confirm match pattern immediately
30
30
  if (!this.currentMatch && this.lookAheadForMatchPattern()) {
31
- this.currentMatch = new AST.ASTMatch(this.potentialMatchSubject);
31
+ this.matchSubject = this.potentialMatchSubject;
32
+ this.currentMatch = new AST.ASTMatch(this.matchSubject);
32
33
  this.currentMatch.line = this.code.Current.LineNo;
33
34
  this.matchParentBlock = this.curBlock;
34
35
  this.inMatchPattern = true; // Start tracking pattern operations
@@ -46,7 +47,8 @@ function handleDupTop() {
46
47
  if (!this.currentMatch && this.potentialMatchSubject &&
47
48
  this.object.Reader.versionCompare(3, 10) >= 0) {
48
49
  // First time - create match (fallback if look-ahead didn't work)
49
- this.currentMatch = new AST.ASTMatch(this.potentialMatchSubject);
50
+ this.matchSubject = this.potentialMatchSubject;
51
+ this.currentMatch = new AST.ASTMatch(this.matchSubject);
50
52
  this.currentMatch.line = this.code.Current.LineNo;
51
53
  this.matchParentBlock = this.curBlock;
52
54
  this.inMatchPattern = true;
@@ -69,6 +69,13 @@ function handleUnaryPositive() {
69
69
 
70
70
  function handleToBool() {
71
71
  // Python 3.13+ TO_BOOL: normalize truthiness. Preserve stack value.
72
+ // The preceding LOAD+COPY 1 here is the boolean-shortcut idiom for
73
+ // `if not X` / `while X`, not a match-case subject. Clear the
74
+ // potential-match candidate so a later COMPARE_OP (e.g. `e.code == 409`
75
+ // inside an except handler) doesn't get retroactively wrapped in a
76
+ // synthetic `match X:` rooted on this stale subject.
77
+ this.potentialMatchSubject = null;
78
+ this.matchCandidateStart = -1;
72
79
  const arg = this.dataStack.pop();
73
80
  this.dataStack.push(arg);
74
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "depyo",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Python bytecode decompiler (Python 1.0–3.15) implemented in Node.js",
5
5
  "bin": {
6
6
  "depyo": "./depyo.js"