depyo 1.0.3 → 1.1.0

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.
@@ -150,6 +150,8 @@ class PycDecompiler {
150
150
  this.enrichGenericAnnotations(functonBody);
151
151
  this.rewriteClassDefinitions(functonBody);
152
152
  this.removeNullSentinelComparisons(functonBody);
153
+ this.cleanupExcMatchArtifacts(functonBody);
154
+ this.hoistNestedExceptBlocks(functonBody);
153
155
  this.dedupeExceptHandlers(functonBody);
154
156
  this.removeDuplicateReturns(functonBody);
155
157
 
@@ -428,7 +430,19 @@ class PycDecompiler {
428
430
  if (offset === undefined) {
429
431
  return;
430
432
  }
431
- const maxHandlerEnd = this.maxExceptionHandlerEnd || Math.max(...entries.filter(e => e.depth > 0).map(e => e.end || 0), 0);
433
+ // Exclude cleanup-only handler entries (depth>0 whose start is a RERAISE site,
434
+ // not a PUSH_EXC_INFO) from the try-block span. Those entries protect CPython's
435
+ // implicit re-raise tail, not a user-written except, and must not be treated
436
+ // as handler bodies when sizing the enclosing try.
437
+ const isUserHandlerEntry = (e) => {
438
+ if (!e || e.depth <= 0) return false;
439
+ const startInstr = this.code.PeekInstructionAtOffset(e.start);
440
+ return startInstr && startInstr.OpCodeID === this.OpCodes.PUSH_EXC_INFO;
441
+ };
442
+ const maxHandlerEnd = this.maxExceptionHandlerEnd || Math.max(
443
+ ...entries.filter(e => e.depth > 0 && isUserHandlerEntry(e)).map(e => e.end || 0),
444
+ 0
445
+ );
432
446
 
433
447
  // Build set of WITH_EXCEPT_START handler ranges (lazily, once per code object)
434
448
  if (!this._withExceptRanges) {
@@ -579,6 +593,16 @@ class PycDecompiler {
579
593
  }
580
594
  }
581
595
  }
596
+ // Skip depth>0 entries whose start is a RERAISE cleanup site rather than
597
+ // a user-level PUSH_EXC_INFO handler. These appear in CPython 3.11+ as
598
+ // protected tails around `RERAISE 0` or `COPY n; POP_EXCEPT; RERAISE 1`
599
+ // and have no source-level except counterpart.
600
+ if (entry.depth > 0 && !isUserHandlerEntry(entry)) {
601
+ if (g_cliArgs?.debug) {
602
+ console.log(`[EnsureExcBlocks] Skipping cleanup-only handler entry at ${entry.start} (not PUSH_EXC_INFO)`);
603
+ }
604
+ continue;
605
+ }
582
606
  const handlerEnd = entry.depth === 0
583
607
  ? (maxHandlerEnd || entry.end || this.code.LastOffset || this.object.CodeSize || 0)
584
608
  : (entry.end || maxHandlerEnd || this.code.LastOffset || this.object.CodeSize || 0);
@@ -644,6 +668,54 @@ class PycDecompiler {
644
668
  if (!next) break;
645
669
  end = next.end;
646
670
  }
671
+ // The exception-table entry only covers the prologue (the protected
672
+ // region around CHECK_EXC_MATCH). The actual handler body — user
673
+ // statements, POP_EXCEPT, and the terminating exit — lives past the
674
+ // entry end. Walk forward to include POP_EXCEPT and the following
675
+ // terminator (RETURN/JUMP_FORWARD/RERAISE) so the Except block
676
+ // captures the body that renders as user code.
677
+ if (this.code && this.OpCodes) {
678
+ let cursor = end;
679
+ let steps = 0;
680
+ let sawHandlerExit = false;
681
+ while (cursor >= 0 && steps++ < 500) {
682
+ const instr = this.code.PeekInstructionAtOffset(cursor);
683
+ if (!instr) break;
684
+ const op = instr.OpCodeID;
685
+ // Stop if we enter another handler's prologue.
686
+ if (op === this.OpCodes.PUSH_EXC_INFO) break;
687
+ if (op === this.OpCodes.POP_EXCEPT) {
688
+ sawHandlerExit = true;
689
+ end = instr.Offset + (instr.Size || 2);
690
+ cursor = end;
691
+ continue;
692
+ }
693
+ if (sawHandlerExit && (
694
+ op === this.OpCodes.RETURN_VALUE ||
695
+ op === this.OpCodes.RETURN_VALUE_A ||
696
+ op === this.OpCodes.RETURN_CONST_A ||
697
+ op === this.OpCodes.JUMP_FORWARD ||
698
+ op === this.OpCodes.JUMP_FORWARD_A ||
699
+ op === this.OpCodes.JUMP_BACKWARD ||
700
+ op === this.OpCodes.JUMP_BACKWARD_A ||
701
+ op === this.OpCodes.RERAISE ||
702
+ op === this.OpCodes.RERAISE_A)) {
703
+ end = instr.Offset + (instr.Size || 2);
704
+ break;
705
+ }
706
+ if (sawHandlerExit) {
707
+ // Skip trailing CACHE/POP_TOP cleanup that sometimes sits
708
+ // between POP_EXCEPT and the terminator on 3.11+.
709
+ if (op === this.OpCodes.CACHE ||
710
+ op === this.OpCodes.POP_TOP) {
711
+ cursor = instr.Offset + (instr.Size || 2);
712
+ continue;
713
+ }
714
+ break;
715
+ }
716
+ cursor = instr.Offset + (instr.Size || 2);
717
+ }
718
+ }
647
719
  return end;
648
720
  }
649
721
 
@@ -1653,11 +1725,45 @@ class PycDecompiler {
1653
1725
  if (hasDataclass) {
1654
1726
  node.addDecorator(new AST.ASTName('dataclass'));
1655
1727
  }
1728
+ } else if (node.src instanceof AST.ASTCall && this.isDecoratedClassCall(node.src)) {
1729
+ // Pattern: `Name = decorator(ClassLiteral[, ...])` produced by Py3.12+
1730
+ // dataclass/decorator-of-class flows. Promote to a real class with
1731
+ // the call-of-decorator attached as an @decorator.
1732
+ const call = node.src;
1733
+ const classNode = call.pparams[0];
1734
+ const decoratorExpr = this._buildDecoratorExpr(call);
1735
+ node.src = classNode;
1736
+ this.cleanupClassBody(classNode);
1737
+ if (decoratorExpr) {
1738
+ node.addDecorator(decoratorExpr);
1739
+ }
1656
1740
  } else if (node.src instanceof AST.ASTClass) {
1657
1741
  this.cleanupClassBody(node.src);
1658
1742
  }
1659
1743
  }
1660
1744
 
1745
+ isDecoratedClassCall(call) {
1746
+ if (!(call instanceof AST.ASTCall)) return false;
1747
+ if (call.func instanceof AST.ASTClass) return false;
1748
+ if (!call.pparams || call.pparams.length < 1) return false;
1749
+ if (!(call.pparams[0] instanceof AST.ASTClass)) return false;
1750
+ // Any further pparams would become positional args to the decorator,
1751
+ // which cannot be expressed as `@decorator(class)` sugar. Reject.
1752
+ return call.pparams.length === 1;
1753
+ }
1754
+
1755
+ _buildDecoratorExpr(call) {
1756
+ // `decorator(class)` → `@decorator`
1757
+ // `decorator(class, kw=val, ...)` → `@decorator(kw=val, ...)`
1758
+ const kwargs = call.kwparams || [];
1759
+ if (kwargs.length === 0 && !call.hasVar && !call.hasKw) {
1760
+ return call.func;
1761
+ }
1762
+ const wrapper = new AST.ASTCall(call.func, [], kwargs);
1763
+ wrapper.line = call.line;
1764
+ return wrapper;
1765
+ }
1766
+
1661
1767
  _childContainers(node) {
1662
1768
  const containers = [];
1663
1769
  if (node instanceof AST.ASTBlock) {
@@ -1801,6 +1907,539 @@ class PycDecompiler {
1801
1907
  }
1802
1908
  }
1803
1909
 
1910
+ // Rewrites 3.11+ CHECK_EXC_MATCH artifacts that leaked past block
1911
+ // reconstruction: unwraps `except <exc> EXC_MATCH <type>` conditions
1912
+ // down to `<type>`, rewrites sibling `while __exception__ EXC_MATCH T:`
1913
+ // blocks into `except T:` handlers on the preceding Try, and strips
1914
+ // trailing compiler-generated reraise/cleanup nodes (bare `raise`,
1915
+ // `e = None; del e`, empty `try: pass`). Runs once on the whole tree.
1916
+ 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;
1920
+
1921
+ const EXC_MATCH = AST.ASTCompare.CompareOp.Exception;
1922
+
1923
+ const unwrapExcMatch = (cond) => {
1924
+ if (!cond) return cond;
1925
+ // The condition may have been wrapped in ASTStore (alias binding) -
1926
+ // unwrap first, then strip the EXC_MATCH compare.
1927
+ let inner = cond;
1928
+ if (inner instanceof AST.ASTStore && inner.src) {
1929
+ inner = inner.src;
1930
+ }
1931
+ if (inner instanceof AST.ASTCompare && inner.op === EXC_MATCH) {
1932
+ return inner.right;
1933
+ }
1934
+ return cond;
1935
+ };
1936
+
1937
+ const isExcMatchCompare = (node) =>
1938
+ node instanceof AST.ASTCompare && node.op === EXC_MATCH;
1939
+
1940
+ const isExceptionName = (node) =>
1941
+ node instanceof AST.ASTName && node.name === '__exception__';
1942
+
1943
+ const containsExceptionRef = (node, depth = 0) => {
1944
+ if (!node || depth > 4) return false;
1945
+ if (isExceptionName(node)) return true;
1946
+ if (node instanceof AST.ASTCompare || node instanceof AST.ASTBinary) {
1947
+ return containsExceptionRef(node.left, depth + 1) ||
1948
+ containsExceptionRef(node.right, depth + 1);
1949
+ }
1950
+ if (node instanceof AST.ASTCall) {
1951
+ return containsExceptionRef(node.func, depth + 1) ||
1952
+ (node.pparams || []).some(p => containsExceptionRef(p, depth + 1));
1953
+ }
1954
+ if (node instanceof AST.ASTStore) {
1955
+ return containsExceptionRef(node.src, depth + 1);
1956
+ }
1957
+ return false;
1958
+ };
1959
+
1960
+ const isEmptyTryBlock = (node) =>
1961
+ node instanceof AST.ASTBlock &&
1962
+ node.blockType === AST.ASTBlock.BlockType.Try &&
1963
+ (node.nodes.length === 0 ||
1964
+ (node.nodes.length === 1 &&
1965
+ node.nodes[0] instanceof AST.ASTKeyword &&
1966
+ node.nodes[0].key === AST.ASTKeyword.Word.Pass));
1967
+
1968
+ const isOrphanExceptionStmt = (node) => {
1969
+ // Bare `raise` with no params → fall-through RERAISE artifact
1970
+ if (node instanceof AST.ASTRaise && (!node.params || node.params.length === 0)) {
1971
+ return true;
1972
+ }
1973
+ // `__exception__(None, None, None)` or `__exception__` alone
1974
+ if (node instanceof AST.ASTCall && isExceptionName(node.func)) {
1975
+ return true;
1976
+ }
1977
+ if (isExceptionName(node)) {
1978
+ return true;
1979
+ }
1980
+ // `x = __exception__` - CPython alias-clear artifact
1981
+ if (node instanceof AST.ASTStore && isExceptionName(node.src)) {
1982
+ return true;
1983
+ }
1984
+ // `return __exception__` - misrendered reraise epilogue
1985
+ if (node instanceof AST.ASTReturn && isExceptionName(node.value)) {
1986
+ return true;
1987
+ }
1988
+ return false;
1989
+ };
1990
+
1991
+ const rewriteContainer = (nodes, parentBlockType) => {
1992
+ if (!Array.isArray(nodes)) return;
1993
+
1994
+ // Pass 1: unwrap EXC_MATCH in Except block conditions.
1995
+ for (const node of nodes) {
1996
+ if (node instanceof AST.ASTCondBlock &&
1997
+ node.blockType === AST.ASTBlock.BlockType.Except) {
1998
+ node.condition = unwrapExcMatch(node.condition);
1999
+ }
2000
+ }
2001
+
2002
+ // Pass 2: convert ASTWhile/ASTIf with EXC_MATCH condition to Except
2003
+ // handlers and attach them to the preceding Try block. If no preceding
2004
+ // Try exists (deeply misparsed region), drop them to avoid invalid output.
2005
+ for (let i = nodes.length - 1; i >= 0; i--) {
2006
+ const node = nodes[i];
2007
+ if (!(node instanceof AST.ASTCondBlock) ||
2008
+ !(node.blockType === AST.ASTBlock.BlockType.While ||
2009
+ node.blockType === AST.ASTBlock.BlockType.If)) {
2010
+ continue;
2011
+ }
2012
+ if (!isExcMatchCompare(node.condition) ||
2013
+ !containsExceptionRef(node.condition.left)) {
2014
+ continue;
2015
+ }
2016
+ const matchType = node.condition.right;
2017
+ // Find nearest preceding Try block in this container.
2018
+ let tryIdx = -1;
2019
+ for (let j = i - 1; j >= 0; j--) {
2020
+ const cand = nodes[j];
2021
+ if (cand instanceof AST.ASTBlock &&
2022
+ cand.blockType === AST.ASTBlock.BlockType.Try) {
2023
+ tryIdx = j;
2024
+ break;
2025
+ }
2026
+ // Allow existing Except handlers between; skip past them.
2027
+ if (cand instanceof AST.ASTCondBlock &&
2028
+ cand.blockType === AST.ASTBlock.BlockType.Except) {
2029
+ continue;
2030
+ }
2031
+ break;
2032
+ }
2033
+ // Strip any trailing orphan reraise/alias-clear statements from body.
2034
+ const cleanBody = node.nodes.filter(n => !isOrphanExceptionStmt(n));
2035
+ if (cleanBody.length === 0) {
2036
+ cleanBody.push(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
2037
+ }
2038
+
2039
+ if (tryIdx >= 0) {
2040
+ const exceptBlk = new AST.ASTCondBlock(
2041
+ AST.ASTBlock.BlockType.Except,
2042
+ node.start, node.end, matchType, false);
2043
+ exceptBlk.line = node.line;
2044
+ exceptBlk.m_nodes = cleanBody;
2045
+ // Insert right after last existing Except handler for that Try.
2046
+ let insertAt = tryIdx + 1;
2047
+ while (insertAt < nodes.length &&
2048
+ nodes[insertAt] instanceof AST.ASTCondBlock &&
2049
+ nodes[insertAt].blockType === AST.ASTBlock.BlockType.Except) {
2050
+ insertAt++;
2051
+ }
2052
+ nodes.splice(i, 1);
2053
+ if (insertAt > i) insertAt--;
2054
+ nodes.splice(insertAt, 0, exceptBlk);
2055
+ } else {
2056
+ // No Try to attach to — dropping keeps output parseable.
2057
+ nodes.splice(i, 1);
2058
+ }
2059
+ }
2060
+
2061
+ // Pass 3: strip orphan exception-related statements at container
2062
+ // level — but NOT inside Except/Finally bodies, where bare `raise`
2063
+ // is a legitimate re-raise statement.
2064
+ const inHandlerBody = parentBlockType === AST.ASTBlock.BlockType.Except ||
2065
+ parentBlockType === AST.ASTBlock.BlockType.Finally;
2066
+ if (!inHandlerBody) {
2067
+ for (let i = nodes.length - 1; i >= 0; i--) {
2068
+ if (isOrphanExceptionStmt(nodes[i])) {
2069
+ nodes.splice(i, 1);
2070
+ }
2071
+ }
2072
+ }
2073
+
2074
+ // Pass 3.5: strip `X = None; del X` pairs — CPython 3.11+ emits this
2075
+ // at the tail/fall-through of every `except Foo as X:` body to clear
2076
+ // the binding. The pattern is synthetic and user code effectively
2077
+ // never writes it literally, so removing it is safe and prevents
2078
+ // the artifact from breaking up adjacent except clauses.
2079
+ for (let i = nodes.length - 2; i >= 0; i--) {
2080
+ const a = nodes[i], b = nodes[i + 1];
2081
+ if (a instanceof AST.ASTStore &&
2082
+ a.src instanceof AST.ASTNone &&
2083
+ a.dest instanceof AST.ASTName &&
2084
+ b instanceof AST.ASTDelete &&
2085
+ b.value instanceof AST.ASTName &&
2086
+ a.dest.name === b.value.name) {
2087
+ nodes.splice(i, 2);
2088
+ }
2089
+ }
2090
+
2091
+ // Pass 4: drop empty `try:` blocks. With no following handler the
2092
+ // try is a bare leftover. With a trivial `except: pass` handler the
2093
+ // whole pair is a reconstruction artifact — Python forbids
2094
+ // `try:<empty>`, so either outcome requires the try to go. When the
2095
+ // handler has real body drop only the try and splice a `pass`
2096
+ // placeholder into the handler's matching — but the current
2097
+ // sightings all have pass-only handlers, so mirror that case.
2098
+ const isTrivialHandlerBlock = (n) =>
2099
+ n instanceof AST.ASTCondBlock &&
2100
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2101
+ n.blockType === AST.ASTBlock.BlockType.Finally) &&
2102
+ (n.nodes.length === 0 ||
2103
+ (n.nodes.length === 1 &&
2104
+ n.nodes[0] instanceof AST.ASTKeyword &&
2105
+ n.nodes[0].key === AST.ASTKeyword.Word.Pass));
2106
+ for (let i = nodes.length - 1; i >= 0; i--) {
2107
+ if (!isEmptyTryBlock(nodes[i])) continue;
2108
+ const next = nodes[i + 1];
2109
+ const hasHandler = next instanceof AST.ASTCondBlock &&
2110
+ (next.blockType === AST.ASTBlock.BlockType.Except ||
2111
+ next.blockType === AST.ASTBlock.BlockType.Finally);
2112
+ if (!hasHandler) {
2113
+ nodes.splice(i, 1);
2114
+ continue;
2115
+ }
2116
+ // Walk all trailing handlers; drop the whole cluster iff every
2117
+ // one is a trivial pass-body — keeping any would still leave
2118
+ // the empty try as a syntax error anyway.
2119
+ let tailEnd = i + 1;
2120
+ while (tailEnd < nodes.length && isTrivialHandlerBlock(nodes[tailEnd])) {
2121
+ tailEnd++;
2122
+ }
2123
+ const allTrivial = tailEnd > i + 1 &&
2124
+ nodes.slice(i + 1, tailEnd).every(isTrivialHandlerBlock) &&
2125
+ (tailEnd === nodes.length ||
2126
+ !(nodes[tailEnd] instanceof AST.ASTCondBlock &&
2127
+ (nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Except ||
2128
+ nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Finally)));
2129
+ if (allTrivial) {
2130
+ nodes.splice(i, tailEnd - i);
2131
+ }
2132
+ }
2133
+
2134
+ // Pass 4.5: move a sibling Except/Finally that immediately follows
2135
+ // a Try into the Try's body. When CFG reconstruction hoists the
2136
+ // handler one level up (e.g. into the parent Else), the block
2137
+ // renderer dedents `except:` to the parent's indent — producing
2138
+ // `else: / try: / except:` where `except:` lands at the `else:`
2139
+ // column instead of the `try:` column, which is a SyntaxError.
2140
+ // Absorbing the handler into the Try body lets the renderer's
2141
+ // single-step dedent land it at the correct `try:` column.
2142
+ for (let i = 0; i < nodes.length - 1; i++) {
2143
+ const cur = nodes[i];
2144
+ if (!(cur instanceof AST.ASTBlock) ||
2145
+ cur.blockType !== AST.ASTBlock.BlockType.Try) continue;
2146
+ const next = nodes[i + 1];
2147
+ if (!(next instanceof AST.ASTCondBlock)) continue;
2148
+ if (next.blockType !== AST.ASTBlock.BlockType.Except &&
2149
+ next.blockType !== AST.ASTBlock.BlockType.Finally) continue;
2150
+ // Skip if the Try already carries its own trailing handler —
2151
+ // moving another one in would stack two handlers, which is
2152
+ // only valid for multi-except chains (same Try). Those cases
2153
+ // already render fine.
2154
+ const last = cur.nodes[cur.nodes.length - 1];
2155
+ const hasOwnHandler = last instanceof AST.ASTCondBlock &&
2156
+ (last.blockType === AST.ASTBlock.BlockType.Except ||
2157
+ last.blockType === AST.ASTBlock.BlockType.Finally);
2158
+ if (hasOwnHandler) continue;
2159
+ // Empty Try bodies + non-trivial handler would collapse to an
2160
+ // invalid `try: / except:` with nothing between. Inject a
2161
+ // `pass` so the rendered block is syntactically valid.
2162
+ if (cur.nodes.length === 0) {
2163
+ cur.nodes.push(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
2164
+ }
2165
+ cur.nodes.push(next);
2166
+ nodes.splice(i + 1, 1);
2167
+ }
2168
+
2169
+ // Pass 5: a Try block with a non-empty body but no following
2170
+ // Except/Finally handler is a SyntaxError. Append a synthetic
2171
+ // `except: pass` so the output parses — real structural recovery
2172
+ // needs dedicated work, but the fallback keeps the file usable.
2173
+ // Skip when the Try already has an Except/Finally nested in its
2174
+ // body (except* groups build nested handler structures).
2175
+ for (let i = 0; i < nodes.length; i++) {
2176
+ const cur = nodes[i];
2177
+ if (!(cur instanceof AST.ASTBlock) ||
2178
+ cur.blockType !== AST.ASTBlock.BlockType.Try) {
2179
+ continue;
2180
+ }
2181
+ if (cur.nodes.length === 0) continue;
2182
+ const next = nodes[i + 1];
2183
+ const hasHandler = next instanceof AST.ASTCondBlock &&
2184
+ (next.blockType === AST.ASTBlock.BlockType.Except ||
2185
+ next.blockType === AST.ASTBlock.BlockType.Finally);
2186
+ if (hasHandler) continue;
2187
+ const hasNestedExcept = cur.nodes.some(n =>
2188
+ n instanceof AST.ASTCondBlock &&
2189
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2190
+ n.blockType === AST.ASTBlock.BlockType.Finally));
2191
+ if (hasNestedExcept) continue;
2192
+ const synthetic = new AST.ASTCondBlock(
2193
+ AST.ASTBlock.BlockType.Except,
2194
+ cur.end, cur.end, null, false);
2195
+ synthetic.line = cur.line;
2196
+ synthetic.m_nodes.push(new AST.ASTKeyword(AST.ASTKeyword.Word.Pass));
2197
+ nodes.splice(i + 1, 0, synthetic);
2198
+ i++;
2199
+ }
2200
+
2201
+ // Pass 5.5: adopt orphan Except into a preceding branch whose
2202
+ // body ends with an un-paired Try. CPython can emit a try/except
2203
+ // inside the tail of an if/else branch so that, after CFG
2204
+ // reconstruction, the Try ends up as the last child of the branch
2205
+ // while its handler gets hoisted one level up as a stray sibling.
2206
+ // Moving the handler back in keeps the pair valid. Absorbed
2207
+ // trailing statements stay in the handler body — not semantically
2208
+ // correct, but renders to parseable Python instead of a stray
2209
+ // `except:` at the outer indent.
2210
+ const branchTypes = [
2211
+ AST.ASTBlock.BlockType.If,
2212
+ AST.ASTBlock.BlockType.Elif,
2213
+ AST.ASTBlock.BlockType.Else,
2214
+ ];
2215
+ const endsWithOrphanTry = (block) => {
2216
+ if (!(block instanceof AST.ASTBlock)) return false;
2217
+ if (!branchTypes.includes(block.blockType)) return false;
2218
+ const body = block.nodes;
2219
+ if (!body || body.length === 0) return false;
2220
+ const last = body[body.length - 1];
2221
+ if (!(last instanceof AST.ASTBlock) ||
2222
+ last.blockType !== AST.ASTBlock.BlockType.Try) {
2223
+ return false;
2224
+ }
2225
+ const hasHandlerInside = last.nodes.some(n =>
2226
+ n instanceof AST.ASTCondBlock &&
2227
+ (n.blockType === AST.ASTBlock.BlockType.Except ||
2228
+ n.blockType === AST.ASTBlock.BlockType.Finally));
2229
+ return !hasHandlerInside;
2230
+ };
2231
+ for (let i = nodes.length - 1; i > 0; i--) {
2232
+ const cur = nodes[i];
2233
+ if (!(cur instanceof AST.ASTCondBlock)) continue;
2234
+ if (cur.blockType !== AST.ASTBlock.BlockType.Except &&
2235
+ cur.blockType !== AST.ASTBlock.BlockType.Finally) continue;
2236
+ const prev = nodes[i - 1];
2237
+ if (!endsWithOrphanTry(prev)) continue;
2238
+ // Confirm there's no matching Try at this container level,
2239
+ // otherwise leave the pair alone.
2240
+ let hasLocalTry = false;
2241
+ for (let j = i - 1; j >= 0; j--) {
2242
+ const p = nodes[j];
2243
+ if (p instanceof AST.ASTBlock &&
2244
+ p.blockType === AST.ASTBlock.BlockType.Try) {
2245
+ hasLocalTry = true;
2246
+ break;
2247
+ }
2248
+ if (p instanceof AST.ASTCondBlock &&
2249
+ (p.blockType === AST.ASTBlock.BlockType.Except ||
2250
+ p.blockType === AST.ASTBlock.BlockType.Finally)) {
2251
+ continue;
2252
+ }
2253
+ break;
2254
+ }
2255
+ if (hasLocalTry) continue;
2256
+ prev.nodes.push(cur);
2257
+ nodes.splice(i, 1);
2258
+ }
2259
+
2260
+ // Pass 6: drop orphan Except/Finally blocks that have no matching
2261
+ // Try (or preceding Except) anywhere earlier in this container.
2262
+ // These are leftovers from misreconstructed CFGs — keeping them
2263
+ // produces SyntaxError at parse time. Inside a Try body Except
2264
+ // can legitimately nest (Py3.11+ handler layout, except* groups),
2265
+ // but only at the tail. A mid-body Except with non-handler
2266
+ // statements after it is a stray handler — drop it.
2267
+ const inTryBody = parentBlockType === AST.ASTBlock.BlockType.Try;
2268
+ const isHandlerBlock = (x) =>
2269
+ x instanceof AST.ASTCondBlock &&
2270
+ (x.blockType === AST.ASTBlock.BlockType.Except ||
2271
+ x.blockType === AST.ASTBlock.BlockType.Finally);
2272
+ for (let i = nodes.length - 1; i >= 0; i--) {
2273
+ const cur = nodes[i];
2274
+ if (!(cur instanceof AST.ASTCondBlock)) continue;
2275
+ if (cur.blockType !== AST.ASTBlock.BlockType.Except &&
2276
+ cur.blockType !== AST.ASTBlock.BlockType.Finally) continue;
2277
+ if (inTryBody) {
2278
+ // except* groups have complex internal structure — never
2279
+ // touch them.
2280
+ if (cur.isExceptStar) continue;
2281
+ // For regular Except nested inside Try body (Py3.11+
2282
+ // handler layout): legitimate only when at the tail.
2283
+ let allTrailingHandlers = true;
2284
+ for (let k = i + 1; k < nodes.length; k++) {
2285
+ if (!isHandlerBlock(nodes[k])) {
2286
+ allTrailingHandlers = false;
2287
+ break;
2288
+ }
2289
+ }
2290
+ if (allTrailingHandlers) continue;
2291
+ }
2292
+ let anchorFound = false;
2293
+ for (let j = i - 1; j >= 0; j--) {
2294
+ const prev = nodes[j];
2295
+ if (prev instanceof AST.ASTBlock &&
2296
+ prev.blockType === AST.ASTBlock.BlockType.Try) {
2297
+ anchorFound = true;
2298
+ break;
2299
+ }
2300
+ if (prev instanceof AST.ASTCondBlock &&
2301
+ (prev.blockType === AST.ASTBlock.BlockType.Except ||
2302
+ prev.blockType === AST.ASTBlock.BlockType.Finally)) {
2303
+ continue;
2304
+ }
2305
+ break;
2306
+ }
2307
+ if (!anchorFound) nodes.splice(i, 1);
2308
+ }
2309
+
2310
+ // Pass 7: after earlier passes may have emptied a Try body, drop
2311
+ // Try+trivial-Except pairs where the body is now empty. Python
2312
+ // forbids `try:` with no body; a pass-only handler carries no
2313
+ // recoverable intent, so splice the cluster out entirely.
2314
+ for (let i = nodes.length - 1; i >= 0; i--) {
2315
+ if (!isEmptyTryBlock(nodes[i])) continue;
2316
+ let tailEnd = i + 1;
2317
+ while (tailEnd < nodes.length && isTrivialHandlerBlock(nodes[tailEnd])) {
2318
+ tailEnd++;
2319
+ }
2320
+ if (tailEnd === i + 1) continue;
2321
+ const trailingHandler = nodes[tailEnd] instanceof AST.ASTCondBlock &&
2322
+ (nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Except ||
2323
+ nodes[tailEnd].blockType === AST.ASTBlock.BlockType.Finally);
2324
+ if (trailingHandler) continue;
2325
+ nodes.splice(i, tailEnd - i);
2326
+ }
2327
+ };
2328
+
2329
+ // Post-order traversal: process children before parent so nested
2330
+ // emptyings bubble up — an outer Try can only be recognised as empty
2331
+ // after the inner Try/Except artifacts inside its body were dropped.
2332
+ const visited = new WeakSet();
2333
+ const visit = (n) => {
2334
+ if (!n || visited.has(n)) return;
2335
+ visited.add(n);
2336
+ let nodes = null;
2337
+ let parentType = null;
2338
+ if (n instanceof AST.ASTNodeList) {
2339
+ nodes = n.list;
2340
+ parentType = null;
2341
+ } else if (n instanceof AST.ASTBlock) {
2342
+ nodes = n.nodes;
2343
+ parentType = n.blockType;
2344
+ } else if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2345
+ const body = n.src.code?.object?.SourceCode;
2346
+ if (body) visit(body);
2347
+ return;
2348
+ }
2349
+ if (nodes) {
2350
+ for (const c of nodes) visit(c);
2351
+ rewriteContainer(nodes, parentType);
2352
+ }
2353
+ };
2354
+ visit(root);
2355
+ }
2356
+
2357
+ // An Except block can get reconstructed inside a non-Try block's body
2358
+ // (most often a For/While/If inside the protected try-body) when the
2359
+ // handler's offset range overlaps a nested loop. Python requires the
2360
+ // except clause to sit next to its `try:`, so walk the tree and pull
2361
+ // such Except blocks out of any non-Try ancestor until they become a
2362
+ // sibling of their enclosing Try.
2363
+ hoistNestedExceptBlocks(root) {
2364
+ const isHoistableBlock = (node) =>
2365
+ node instanceof AST.ASTBlock &&
2366
+ node.blockType !== AST.ASTBlock.BlockType.Try;
2367
+
2368
+ const isExcept = (node) =>
2369
+ node instanceof AST.ASTCondBlock &&
2370
+ node.blockType === AST.ASTBlock.BlockType.Except;
2371
+
2372
+ // Fixed-point loop: one pass hoists Except up one level. Bounded by
2373
+ // tree depth; excessive iterations just exit.
2374
+ for (let iter = 0; iter < 64; iter++) {
2375
+ let moved = false;
2376
+ const stack = [{container: null, nodes: null, parent: null}];
2377
+ const parentMap = new WeakMap(); // child container → {arr, index, parent}
2378
+
2379
+ const visit = (arr, parentInfo) => {
2380
+ for (let i = 0; i < arr.length; i++) {
2381
+ const n = arr[i];
2382
+ if (!n) continue;
2383
+ let children = null;
2384
+ if (n instanceof AST.ASTBlock || n instanceof AST.ASTCondBlock) {
2385
+ children = n.nodes;
2386
+ } else if (n instanceof AST.ASTNodeList) {
2387
+ children = n.list;
2388
+ } else if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2389
+ const src = n.src.code?.object?.SourceCode;
2390
+ if (src instanceof AST.ASTNodeList) {
2391
+ visit(src.list, null);
2392
+ }
2393
+ continue;
2394
+ }
2395
+ if (Array.isArray(children) && children.length > 0) {
2396
+ // If this block is non-Try and its first child is an
2397
+ // Except, pull that Except out into the parent arr
2398
+ // right after `n`.
2399
+ if (isHoistableBlock(n)) {
2400
+ // Scan forward for leading Except children only;
2401
+ // an Except appearing later in the body is usually
2402
+ // a real construct we should leave alone.
2403
+ while (children.length > 0 && isExcept(children[0])) {
2404
+ const ex = children.shift();
2405
+ arr.splice(i + 1, 0, ex);
2406
+ moved = true;
2407
+ }
2408
+ }
2409
+ visit(children, {arr, index: i});
2410
+ }
2411
+ }
2412
+ };
2413
+
2414
+ const roots = [];
2415
+ if (root instanceof AST.ASTNodeList) roots.push(root.list);
2416
+ else if (root instanceof AST.ASTBlock) roots.push(root.nodes);
2417
+
2418
+ for (const r of roots) visit(r, null);
2419
+
2420
+ // Also dive into nested function bodies.
2421
+ const walk = (n, seen) => {
2422
+ if (!n || seen.has(n)) return;
2423
+ seen.add(n);
2424
+ if (n instanceof AST.ASTStore && n.src instanceof AST.ASTFunction) {
2425
+ const src = n.src.code?.object?.SourceCode;
2426
+ if (src instanceof AST.ASTNodeList) {
2427
+ visit(src.list, null);
2428
+ }
2429
+ }
2430
+ let children = null;
2431
+ if (n instanceof AST.ASTNodeList) children = n.list;
2432
+ else if (n instanceof AST.ASTBlock || n instanceof AST.ASTCondBlock) children = n.nodes;
2433
+ if (Array.isArray(children)) {
2434
+ for (const c of children) walk(c, seen);
2435
+ }
2436
+ };
2437
+ walk(root, new WeakSet());
2438
+
2439
+ if (!moved) break;
2440
+ }
2441
+ }
2442
+
1804
2443
  dedupeExceptHandlers(root) {
1805
2444
  const queue = [root];
1806
2445
  const visited = new WeakSet();
@@ -2085,6 +2724,14 @@ class PycDecompiler {
2085
2724
  const frag = ch?.codeFragment?.()?.toString?.() || "";
2086
2725
  return frag === "pass" || frag.includes("__exception__") || frag.includes("##ERROR##");
2087
2726
  }))) {
2727
+ // Drop only synthetic except* handlers without a user condition.
2728
+ // User-written `except:` / `except E:` must be preserved; replace
2729
+ // cleanup-only body with `pass` so the try statement stays valid.
2730
+ if (node.isExceptStar && !node.condition) {
2731
+ continue;
2732
+ }
2733
+ node.m_nodes = [new AST.ASTKeyword(AST.ASTKeyword.Word.Pass)];
2734
+ rewritten.push(node);
2088
2735
  continue;
2089
2736
  }
2090
2737
  if (typeof node?.codeFragment === 'function') {
@@ -57,7 +57,9 @@ class PythonObject {
57
57
  .replace(/\\/g, '\\\\')
58
58
  .replace(/\n/g, '\\n')
59
59
  .replace(/\r/g, '\\r')
60
- .replace(/\t/g, '\\t');
60
+ .replace(/\t/g, '\\t')
61
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, c =>
62
+ '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'));
61
63
  let quote = '"';
62
64
  if (escaped.includes('"')) {
63
65
  if (!escaped.includes("'")) {
@@ -258,7 +258,19 @@ class ASTNodeList extends ASTNode {
258
258
  }
259
259
  }
260
260
 
261
- if (prevNode && spacing == 0 && sourceFragment.length == 1) {
261
+ // Only join with `; ` between two simple (non-compound) statements
262
+ // that also happen to be on the same source line. A compound block
263
+ // (if/while/for/try/function/class) must never be semicolon-joined
264
+ // to a following sibling — doing so swallows the sibling into the
265
+ // block's body and breaks structure.
266
+ const prevIsCompound = prevNode instanceof ASTBlock ||
267
+ prevNode instanceof ASTFunction ||
268
+ prevNode instanceof ASTClass;
269
+ const nodeIsCompound = node instanceof ASTBlock ||
270
+ node instanceof ASTFunction ||
271
+ node instanceof ASTClass;
272
+ if (prevNode && spacing == 0 && sourceFragment.length == 1 &&
273
+ !prevIsCompound && !nodeIsCompound) {
262
274
  result.lastLineAppend((prevNode ? "; " : "") + sourceFragment.toString(), false);
263
275
  } else {
264
276
  const blankCount = rawSpacing ? Math.max(0, spacing - 1) : (spacing > 1 ? 1 : 0);
@@ -465,7 +477,11 @@ class ASTObject extends ASTNode {
465
477
  .replace(/\\/g, '\\\\')
466
478
  .replace(/\n/g, '\\n')
467
479
  .replace(/\r/g, '\\r')
468
- .replace(/\t/g, '\\t');
480
+ .replace(/\t/g, '\\t')
481
+ // Any remaining C0/DEL byte must be hex-escaped; leaving a raw
482
+ // NUL or BEL in the source makes the output unparseable.
483
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, c =>
484
+ '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'));
469
485
  let quote = '"';
470
486
  if (escaped.includes('"')) {
471
487
  if (!escaped.includes("'")) {
@@ -1103,7 +1119,13 @@ class ASTStore extends ASTNode {
1103
1119
  if (inLambda) {
1104
1120
  result.lastLineAppend(methodBody);
1105
1121
  } else {
1106
- result.add(methodBody);
1122
+ const bodyHasContent = methodBody && methodBody.toString().trim().length;
1123
+ const hasGlobals = codeObject.Globals && codeObject.Globals.size > 0;
1124
+ if (bodyHasContent) {
1125
+ result.add(methodBody);
1126
+ } else if (!hasGlobals) {
1127
+ result.add("pass");
1128
+ }
1107
1129
  }
1108
1130
  result.decreaseIndent();
1109
1131
 
@@ -2539,19 +2561,33 @@ class ASTBlock extends ASTNode {
2539
2561
  result.add("pass");
2540
2562
  }
2541
2563
  } else {
2564
+ // Emit body nodes first, then trailing dedented handlers. If
2565
+ // every body node was `skip`-ed (e.g. Try body whose sole
2566
+ // child was consumed by ternary collapse), the block would
2567
+ // print `try:` with no body — inject a `pass` placeholder so
2568
+ // the resulting source stays parseable.
2569
+ let emittedBodyNode = false;
2570
+ const handlerTail = [];
2542
2571
  renderNodes.map(node => {
2543
2572
  if (!node || node.skip) {
2544
2573
  return;
2545
2574
  }
2546
2575
  const isHandler = node.blockType == ASTBlock.BlockType.Except || node.blockType == ASTBlock.BlockType.Finally;
2547
2576
  if (isHandler) {
2548
- result.decreaseIndent();
2549
- result.add(node.codeFragment());
2550
- result.increaseIndent();
2577
+ handlerTail.push(node);
2551
2578
  } else {
2552
2579
  result.add(node.codeFragment());
2580
+ emittedBodyNode = true;
2553
2581
  }
2554
2582
  });
2583
+ if (!emittedBodyNode) {
2584
+ result.add("pass");
2585
+ }
2586
+ for (const h of handlerTail) {
2587
+ result.decreaseIndent();
2588
+ result.add(h.codeFragment());
2589
+ result.increaseIndent();
2590
+ }
2555
2591
  }
2556
2592
  } else {
2557
2593
  result.add("pass");
@@ -3137,12 +3173,27 @@ class ASTFormattedValue extends ASTNode {
3137
3173
  return this.m_format_spec;
3138
3174
  }
3139
3175
 
3140
- codeFragment() {
3176
+ codeFragment(outerQuote = null) {
3141
3177
  // Format: {value} or {value!r} or {value:.2f}
3178
+ // `outerQuote` is set only when this FormattedValue is being rendered
3179
+ // inside an enclosing f-string; nested strings must then use the
3180
+ // opposite delimiter so the outer string does not close prematurely.
3181
+ const innerQuote = outerQuote === '"' ? "'" :
3182
+ outerQuote === "'" ? '"' : '"';
3142
3183
  let result = "{";
3143
3184
 
3144
3185
  if (this.m_val) {
3145
- result += this.m_val.codeFragment();
3186
+ if (this.m_val instanceof ASTJoinedStr) {
3187
+ // Only force a flipped quote when we actually have an outer
3188
+ // f-string; otherwise let the nested string keep its default.
3189
+ if (outerQuote) {
3190
+ result += this.m_val.codeFragment(innerQuote);
3191
+ } else {
3192
+ result += this.m_val.codeFragment();
3193
+ }
3194
+ } else {
3195
+ result += this.m_val.codeFragment();
3196
+ }
3146
3197
  }
3147
3198
 
3148
3199
  // Add conversion flag (!s, !r, !a)
@@ -3163,7 +3214,10 @@ class ASTFormattedValue extends ASTNode {
3163
3214
  result += ":";
3164
3215
  // Format spec can be ASTJoinedStr (nested f-string) or string constant
3165
3216
  if (this.m_format_spec instanceof ASTJoinedStr) {
3166
- result += this.m_format_spec.codeFragment();
3217
+ // Format-spec content is already inside the outer f-string's
3218
+ // braces, so its literal parts must not use the outer quote.
3219
+ const specQuote = outerQuote ? innerQuote : '"';
3220
+ result += this.m_format_spec.codeFragment(specQuote, true);
3167
3221
  } else if (this.m_format_spec instanceof ASTObject) {
3168
3222
  // String constant like ".2f"
3169
3223
  result += this.m_format_spec.object.Value;
@@ -3196,9 +3250,14 @@ class ASTJoinedStr extends ASTNode {
3196
3250
  get lastLine() {
3197
3251
  return this.values[this.values.length - 1]?.lastLine;
3198
3252
  }
3199
- codeFragment() {
3253
+ codeFragment(quoteChar = '"', bareInnerForFormatSpec = false) {
3200
3254
  // PEP 750 t-strings use t"..." prefix; otherwise f-string.
3201
- let result = (this.isTemplateString ? 't"' : 'f"');
3255
+ // `quoteChar` lets nested f-strings pick the opposite delimiter so
3256
+ // they don't collide with the enclosing string. `bareInnerForFormatSpec`
3257
+ // skips the f"..." wrapper entirely (used when this ASTJoinedStr is a
3258
+ // format spec nested inside {...}).
3259
+ const prefix = this.isTemplateString ? 't' : 'f';
3260
+ let result = bareInnerForFormatSpec ? "" : `${prefix}${quoteChar}`;
3202
3261
 
3203
3262
  // Values are in reverse order (BUILD_STRING pops from stack)
3204
3263
  // So we need to reverse them
@@ -3232,8 +3291,10 @@ class ASTJoinedStr extends ASTNode {
3232
3291
  let prefix = prevStr.substring(0, prevStr.length - match[0].length);
3233
3292
 
3234
3293
  // Remove the previously added literal and replace with prefix + {var=}
3294
+ let quoteEsc = quoteChar === '"' ? /"/g : /'/g;
3295
+ let quoteReplace = quoteChar === '"' ? '\\"' : "\\'";
3235
3296
  let beforeLiteral = result.lastIndexOf(prevStr.replace(/\\/g, '\\\\')
3236
- .replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t'));
3297
+ .replace(quoteEsc, quoteReplace).replace(/\n/g, '\\n').replace(/\t/g, '\\t'));
3237
3298
 
3238
3299
  if (beforeLiteral !== -1) {
3239
3300
  result = result.substring(0, beforeLiteral);
@@ -3242,7 +3303,7 @@ class ASTJoinedStr extends ASTNode {
3242
3303
  // Add prefix (escaped) if present
3243
3304
  if (prefix) {
3244
3305
  let escapedPrefix = prefix.replace(/\\/g, '\\\\');
3245
- escapedPrefix = escapedPrefix.replace(/"/g, '\\"');
3306
+ escapedPrefix = escapedPrefix.replace(quoteEsc, quoteReplace);
3246
3307
  escapedPrefix = escapedPrefix.replace(/\n/g, '\\n');
3247
3308
  escapedPrefix = escapedPrefix.replace(/\t/g, '\\t');
3248
3309
  result += escapedPrefix;
@@ -3254,16 +3315,25 @@ class ASTJoinedStr extends ASTNode {
3254
3315
  }
3255
3316
  }
3256
3317
 
3257
- // {expression} part
3258
- result += value.codeFragment();
3259
- } else if (value instanceof ASTObject && value.object?.ClassName === 'Py_String') {
3318
+ // {expression} part - pass our own quote char so nested
3319
+ // f-strings / strings inside pick a compatible delimiter.
3320
+ result += value.codeFragment(quoteChar);
3321
+ // result above calls ASTFormattedValue.codeFragment(outerQuote).
3322
+ } else if (value instanceof ASTObject && ['Py_String', 'Py_Unicode'].includes(value.object?.ClassName)) {
3260
3323
  // Literal string part - need to escape special chars
3261
3324
  let str = value.object.Value;
3262
- // Escape backslashes and quotes
3325
+ // Escape backslashes and the current quote char
3263
3326
  str = str.replace(/\\/g, '\\\\');
3264
- str = str.replace(/"/g, '\\"');
3327
+ if (quoteChar === '"') {
3328
+ str = str.replace(/"/g, '\\"');
3329
+ } else {
3330
+ str = str.replace(/'/g, "\\'");
3331
+ }
3265
3332
  str = str.replace(/\n/g, '\\n');
3333
+ str = str.replace(/\r/g, '\\r');
3266
3334
  str = str.replace(/\t/g, '\\t');
3335
+ str = str.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, c =>
3336
+ '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'));
3267
3337
  result += str;
3268
3338
  } else {
3269
3339
  // Fallback for unexpected types
@@ -3271,7 +3341,9 @@ class ASTJoinedStr extends ASTNode {
3271
3341
  }
3272
3342
  }
3273
3343
 
3274
- result += '"';
3344
+ if (!bareInnerForFormatSpec) {
3345
+ result += quoteChar;
3346
+ }
3275
3347
  return result;
3276
3348
  }
3277
3349
 
@@ -33,17 +33,10 @@ function handleCompareOpA() {
33
33
  let left = this.dataStack.pop();
34
34
  let arg = this.code.Current.Argument;
35
35
  if (this.object.Reader.versionCompare(3, 13) >= 0) {
36
- // Python 3.13 encodes the comparison in packed form; map observed
37
- // specializations back to standard compare ops.
38
- const mapped = {
39
- 18: AST.ASTCompare.CompareOp.Less, // guards: n < 0
40
- 72: AST.ASTCompare.CompareOp.Equal, // len == N
41
- 88: AST.ASTCompare.CompareOp.Equal, // literal == value
42
- 148: AST.ASTCompare.CompareOp.Greater // guards: n > 0
43
- };
44
- arg = mapped[arg] ?? (arg & 0x0F);
36
+ // Py3.13: oparg = (op << 5) | (cast_bool<<4) | cache_flags. op ∈ 0..5.
37
+ arg >>= 5;
45
38
  } else if (this.object.Reader.versionCompare(3, 12) >= 0) {
46
- // 3.12 quickened form stores compare op in high nibble
39
+ // Py3.12: oparg = (op << 4) | cache_flags. op 0..5.
47
40
  arg >>= 4;
48
41
  }
49
42
 
@@ -477,7 +477,8 @@ function handlePopExcept() {
477
477
  this.curBlock = this.defBlock || exceptBlock;
478
478
  }
479
479
 
480
- let bodyNode = exceptBlock.nodes;
480
+ // exceptBlock.nodes returns the block's m_nodes array directly (getter with no setter).
481
+ let bodyNodes = exceptBlock.nodes;
481
482
 
482
483
  if (this.curBlock.blockType == AST.ASTBlock.BlockType.Try) {
483
484
  const pendingEgType = this.egMatchTypeStack?.shift?.() || this.pendingEgMatchType;
@@ -502,7 +503,11 @@ function handlePopExcept() {
502
503
  }
503
504
  let catchBlock = new AST.ASTCondBlock(AST.ASTBlock.BlockType.Except, exceptBlock.start, exceptBlock.end, matchType, false);
504
505
  catchBlock.line = exceptBlock.line;
505
- catchBlock.nodes = bodyNode.nodes;
506
+ // Copy the handler body from the popped Except. `nodes` is getter-only,
507
+ // so mutate m_nodes directly — plain assignment here silently no-ops.
508
+ if (Array.isArray(bodyNodes) && bodyNodes.length) {
509
+ catchBlock.m_nodes.push(...bodyNodes);
510
+ }
506
511
  catchBlock.isExceptStar = isExceptStar;
507
512
  if (catchBlock.condition && typeof catchBlock.condition === 'object' && 'src' in catchBlock.condition) {
508
513
  const storeSrc = catchBlock.condition.src;
@@ -297,7 +297,10 @@ function handleInstrumentedCallA() {
297
297
  const topType = sourceTop?.constructor?.name || 'null';
298
298
  console.log(`[CALL] func=${codeObj?.Name || codeObj?.QualName?.Value || '<?>'} sourceTop=${topType} compNode=${compNode?.constructor?.name || 'null'} hasSource=${!!codeObj?.SourceCode} pparams=${pparamList.length}`);
299
299
  }
300
- const looksLikeComp = compNames.has(func.code?.object?.Name) ||
300
+ // Name is a PythonObject (Py_String/Py_Unicode), not a raw string — compare
301
+ // via toString() so the Set lookup actually matches.
302
+ const codeName = func.code?.object?.Name?.toString?.() || '';
303
+ const looksLikeComp = compNames.has(codeName) ||
301
304
  sourceTop instanceof AST.ASTComprehension ||
302
305
  compNode instanceof AST.ASTComprehension ||
303
306
  (sourceTop instanceof AST.ASTReturn && sourceTop.value instanceof AST.ASTComprehension);
@@ -313,6 +316,15 @@ function handleInstrumentedCallA() {
313
316
  if (!(resultNode instanceof AST.ASTComprehension) && codeObj?.SourceCode) {
314
317
  let yieldExpr = null;
315
318
  let loopVar = null;
319
+ // Seed async-ness from the code flags: async comprehensions and
320
+ // async generator expressions carry CO_ASYNC_GENERATOR /
321
+ // CO_COROUTINE on the inner code object, so use that as the
322
+ // default in case the inner tree lacks an explicit AsyncFor
323
+ // block (e.g. pre-3.8 async comprehensions use GET_ANEXT +
324
+ // STORE instead of a FOR_ITER-driven iter block).
325
+ const flags = codeObj?.Flags | 0;
326
+ let isAsyncLoop = !!(flags & AST.ASTFunction.CodeFlags.CO_ASYNC_GENERATOR) ||
327
+ !!(flags & AST.ASTFunction.CodeFlags.CO_COROUTINE);
316
328
  const searchNodes = (nodes) => {
317
329
  if (!nodes) return;
318
330
  for (const node of nodes) {
@@ -322,8 +334,13 @@ function handleInstrumentedCallA() {
322
334
  if (node instanceof AST.ASTStore && !loopVar) {
323
335
  loopVar = node.dest;
324
336
  }
325
- if (node instanceof AST.ASTIterBlock && node.blockType === AST.ASTBlock.BlockType.AsyncFor) {
326
- if (node.index) loopVar = node.index;
337
+ if (node instanceof AST.ASTIterBlock &&
338
+ (node.blockType === AST.ASTBlock.BlockType.AsyncFor ||
339
+ node.blockType === AST.ASTBlock.BlockType.For)) {
340
+ if (node.index) {
341
+ loopVar = node.index;
342
+ isAsyncLoop = (node.blockType === AST.ASTBlock.BlockType.AsyncFor);
343
+ }
327
344
  if (node.nodes) searchNodes(node.nodes);
328
345
  }
329
346
  if (node?.nodes) searchNodes(node.nodes);
@@ -356,13 +373,14 @@ function handleInstrumentedCallA() {
356
373
  const varName = op.Name?.toString?.() || '';
357
374
  if (varName) {
358
375
  loopVar = new AST.ASTName(varName);
376
+ isAsyncLoop = true;
359
377
  break;
360
378
  }
361
379
  }
362
380
  }
363
381
  }
364
382
  if (global.g_cliArgs?.debug) {
365
- console.log(`[CALL-RECONSTRUCT] ${codeObj.Name?.toString?.()} yield=${yieldExpr?.constructor?.name} loopVar=${loopVar?.name}`);
383
+ console.log(`[CALL-RECONSTRUCT] ${codeObj.Name?.toString?.()} yield=${yieldExpr?.constructor?.name} loopVar=${loopVar?.name} isAsyncLoop=${isAsyncLoop}`);
366
384
  }
367
385
  if (yieldExpr && loopVar) {
368
386
  let kind = AST.ASTComprehension.GENERATOR;
@@ -377,7 +395,8 @@ function handleInstrumentedCallA() {
377
395
  comp = new AST.ASTComprehension(yieldExpr);
378
396
  }
379
397
  comp.kind = kind;
380
- let gen = new AST.ASTIterBlock(AST.ASTBlock.BlockType.AsyncFor, 0, 0, new AST.ASTName('.0'));
398
+ const genBlockType = isAsyncLoop ? AST.ASTBlock.BlockType.AsyncFor : AST.ASTBlock.BlockType.For;
399
+ let gen = new AST.ASTIterBlock(genBlockType, 0, 0, new AST.ASTName('.0'));
381
400
  gen.index = loopVar;
382
401
  gen.comprehension = true;
383
402
  comp.addGenerator(gen);
@@ -379,7 +379,17 @@ function handleBuildConstKeyMapA() {
379
379
  function handleBuildStringA() {
380
380
  let values = [];
381
381
  for (let idx = 0; idx < this.code.Current.Argument; idx++) {
382
- values.push(this.dataStack.pop());
382
+ let v = this.dataStack.pop();
383
+ // Unwrap premature single-FormattedValue JoinedStr wrappers. These arise
384
+ // when FORMAT_SIMPLE's lookahead (hasUpcomingStringBuild) bailed early
385
+ // on a CALL_A that actually belongs to a later interpolation in this
386
+ // same BUILD_STRING — rendering the wrapper would otherwise produce
387
+ // `f"f"{expr}""` nested inside the outer f-string.
388
+ if (v instanceof AST.ASTJoinedStr && v.values && v.values.length === 1 &&
389
+ v.values[0] instanceof AST.ASTFormattedValue) {
390
+ v = v.values[0];
391
+ }
392
+ values.push(v);
383
393
  }
384
394
 
385
395
  let stringNode = new AST.ASTJoinedStr(values);
@@ -582,7 +582,10 @@ function hasUpcomingStringBuild(context) {
582
582
  context.OpCodes.STORE_DEREF_A
583
583
  ]);
584
584
 
585
- for (let step = 1; step <= 12; step++) {
585
+ // 3.11+ inline caches inflate the physical instruction stream; a single
586
+ // f-string with a few interpolations easily spans 30+ code slots before
587
+ // reaching BUILD_STRING. Cap generously and rely on earlyStops to bound.
588
+ for (let step = 1; step <= 48; step++) {
586
589
  const instr = context.code.PeekInstructionAtOffset(context.code.Current.Offset + step * 2);
587
590
  if (!instr) {
588
591
  break;
@@ -745,6 +748,28 @@ function handlePopTop() {
745
748
  return;
746
749
  }
747
750
 
751
+ // Mid-UNPACK_SEQUENCE POP_TOP: CPython emits `a, _, b = tup` as
752
+ // UNPACK 3 / STORE a / POP_TOP / STORE b, where POP_TOP drops the
753
+ // middle slot. Our unpack-state-machine accumulates STORE targets
754
+ // into a tuple; treat POP_TOP as an anonymous `_` slot so the
755
+ // remaining STORE_FAST still sees the tuple on the stack.
756
+ if (this.unpack > 0) {
757
+ let tupleNode = this.dataStack.top();
758
+ if (tupleNode instanceof AST.ASTTuple) {
759
+ let placeholder = new AST.ASTName("_");
760
+ if (this.starPos-- == 0) placeholder.name = "*_";
761
+ tupleNode.add(placeholder);
762
+ if (--this.unpack <= 0) {
763
+ this.dataStack.pop();
764
+ let seqNode = this.dataStack.pop();
765
+ let node = new AST.ASTStore(seqNode, tupleNode);
766
+ node.line = this.code.Current.LineNo;
767
+ this.curBlock.append(node);
768
+ }
769
+ return;
770
+ }
771
+ }
772
+
748
773
  if ([this.OpCodes.JUMP_ABSOLUTE_A, this.OpCodes.JUMP_FORWARD_A, this.OpCodes.POP_JUMP_IF_FALSE_A, this.OpCodes.JUMP_IF_FALSE_A].includes(this.code.Prev?.OpCodeID)) {
749
774
  if (this.curBlock.blockType == AST.ASTBlock.BlockType.Except) {
750
775
  // Skipping POP_TOP, POP_TOP, POP_TOP
@@ -1111,7 +1136,15 @@ function handleReturnConstA() {
1111
1136
  nextOp.OpCodeID === this.OpCodes.RERAISE;
1112
1137
  const prevIsPOPExcept = this.code.Prev?.OpCodeID === this.OpCodes.POP_EXCEPT;
1113
1138
  const nextIsSWAP = nextOp?.OpCodeID === this.OpCodes.SWAP_A;
1139
+ const nextIsRERAISE = nextOp?.OpCodeID === this.OpCodes.RERAISE ||
1140
+ nextOp?.OpCodeID === this.OpCodes.RERAISE_A;
1114
1141
  const isExceptCleanup = isNone && prevIsPOPExcept && nextIsSWAP;
1142
+ // CPython 3.11+ places the function's implicit `return None` at the tail of an
1143
+ // except handler (POP_EXCEPT → RETURN_CONST None → RERAISE or end-of-function).
1144
+ // The source has no explicit return there; drop it so the handler body doesn't
1145
+ // inherit a phantom return.
1146
+ const isExceptTailImplicitReturn = isNone && prevIsPOPExcept &&
1147
+ (nextIsRERAISE || !nextOp);
1115
1148
 
1116
1149
  if (isNone && isModuleLevel && isAtEnd) {
1117
1150
  return; // Skip implicit return None at module end
@@ -1119,6 +1152,9 @@ function handleReturnConstA() {
1119
1152
  if (isExceptCleanup) {
1120
1153
  return; // Skip return None in exception cleanup (except* success path)
1121
1154
  }
1155
+ if (isExceptTailImplicitReturn) {
1156
+ return; // Skip synthetic return None at end of except handler
1157
+ }
1122
1158
 
1123
1159
  let value = new AST.ASTObject(this.code.Current.ConstantObject);
1124
1160
  let node = new AST.ASTReturn(value);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "depyo",
3
- "version": "1.0.3",
4
- "description": "Python bytecode decompiler (Python 1.0–3.14) implemented in Node.js",
3
+ "version": "1.1.0",
4
+ "description": "Python bytecode decompiler (Python 1.0–3.15) implemented in Node.js",
5
5
  "bin": {
6
6
  "depyo": "./depyo.js"
7
7
  },