pycrefine 1.1.1__py3-none-any.whl

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.
pycrefine/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """pycrefine: a Python .pyc decompiler that rebuilds source code from compiled bytecode.
2
+
3
+ Every name that used to live in the single-file ``pycrefine`` module is re-exported here,
4
+ so ``import pycrefine`` and ``from pycrefine import ...`` behave as before.
5
+ """
6
+ __version__ = "1.1.1"
7
+
8
+ from ._constants import ( # noqa: F401
9
+ _AUG_ASSIGN_MAP,
10
+ _COMPOUND_EXPR_OPS,
11
+ _COMPOUND_SKIP,
12
+ _GENERIC_SKIP_OPS,
13
+ _INPLACE_ASSIGN_MAP,
14
+ _IS_COMP_GEN_SKIP,
15
+ _IS_COMP_GEN_STOP,
16
+ _MAX_TERNARY_MINI_SEARCH_WINDOW,
17
+ _MAX_TERNARY_SEARCH_WINDOW,
18
+ _MAX_TERNARY_SHORT_SEARCH_WINDOW,
19
+ _MAX_WITH_EXIT_SEARCH_WINDOW,
20
+ _NO_INDENT_TYPES,
21
+ _NO_PASS_TYPES,
22
+ _STATEMENT_BOUNDARY_OPS,
23
+ _TERNARY_PURE,
24
+ _TERNARY_SKIP,
25
+ _TERNARY_STORES,
26
+ _TERNARY_TERM,
27
+ _TRY_HANDLER_EXIT_OPS,
28
+ _TRY_LOAD_OPS,
29
+ _TRY_LOOP_HEADS,
30
+ _TRY_SKIP_NOP,
31
+ _UNARY_SYMBOLS,
32
+ _WHILE_TRIVIAL_OPS,
33
+ )
34
+ from .expr import StackNULL, _BoolExpr, _CollectionAccumSentinel, _unary_operand # noqa: F401
35
+ from .instructions import ( # noqa: F401
36
+ BytecodeInstruction,
37
+ _const_repr,
38
+ _is_anonymous_func_body,
39
+ _render_func_tuple,
40
+ )
41
+ from .postprocess import ( # noqa: F401
42
+ _block_opener_keyword,
43
+ _collect_multiline_header,
44
+ _line_is_in_triple_quoted_string,
45
+ collapse_chained_comparisons,
46
+ flatten_elif,
47
+ post_process_source,
48
+ )
49
+ from .codeobj import ForeignCode, _is_code # noqa: F401
50
+ from .decompiler.base import DecompilerBase # noqa: F401
51
+ from .decompiler.generic import DecompilerGeneric, _pick_decompiler_class # noqa: F401
52
+ from .opcodes.py39 import _OPCODES_39 # noqa: F401
53
+ from .decompiler.py39 import Decompiler39 # noqa: F401
54
+ from .decompiler.py311 import Decompiler311Plus # noqa: F401
55
+ from .decompiler.py314 import Decompiler314 # noqa: F401
56
+ from .loader import ( # noqa: F401
57
+ MarshalParser,
58
+ _CO_FAST_CELL,
59
+ _CO_FAST_FREE,
60
+ _CO_FAST_LOCAL,
61
+ _MARSHAL_NULL,
62
+ get_decompiler,
63
+ )
64
+ from .versions import _get_python_version_from_magic # noqa: F401
65
+ from .cli import main # noqa: F401
pycrefine/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,136 @@
1
+ """Shared constant instruction sets, lookup maps and search-window sizes."""
2
+
3
+
4
+ # --- Magic Numbers & Search Windows ---
5
+ _MAX_WITH_EXIT_SEARCH_WINDOW = 200
6
+
7
+
8
+ _MAX_TERNARY_SEARCH_WINDOW = 20
9
+
10
+
11
+ _MAX_TERNARY_SHORT_SEARCH_WINDOW = 12
12
+
13
+
14
+ _MAX_TERNARY_MINI_SEARCH_WINDOW = 8
15
+
16
+
17
+ # --- Constant Instruction Sets ---
18
+ _NO_PASS_TYPES = frozenset({"exc_cleanup", "finally_wrapper"})
19
+
20
+
21
+ _NO_INDENT_TYPES = frozenset({"finally_wrapper"})
22
+
23
+
24
+ _UNARY_SYMBOLS = {
25
+ "UNARY_NEGATIVE": "-", "UNARY_POSITIVE": "+", "UNARY_INVERT": "~", "UNARY_NOT": "not ",
26
+ }
27
+
28
+
29
+ _TERNARY_STORES = frozenset(("STORE_FAST", "STORE_NAME", "STORE_GLOBAL", "STORE_DEREF"))
30
+
31
+
32
+ _TERNARY_SKIP = frozenset(("CACHE", "RESUME", "NOT_TAKEN", "COPY_FREE_VARS"))
33
+
34
+
35
+ _TERNARY_TERM = frozenset(("RETURN_CONST", "RETURN_VALUE"))
36
+
37
+
38
+ _TERNARY_PURE = frozenset((
39
+ "LOAD_FAST", "LOAD_NAME", "LOAD_GLOBAL", "LOAD_CONST", "LOAD_DEREF",
40
+ "LOAD_SMALL_INT", "LOAD_GLOBAL_MODULE", "LOAD_ATTR", "LOAD_METHOD",
41
+ "GET_ATTR", "LOAD_FAST_BORROW", "LOAD_FAST_BORROW_LOAD_FAST_BORROW",
42
+ "LOAD_CONST_BORROW", "CALL", "CALL_FUNCTION", "CALL_METHOD",
43
+ "COMPARE_OP", "BINARY_OP", "IS_OP", "CONTAINS_OP",
44
+ "UNARY_NOT", "UNARY_NEGATIVE", "UNARY_POSITIVE", "UNARY_INVERT", "CALL_INTRINSIC_1",
45
+ "BUILD_TUPLE", "BUILD_LIST", "BUILD_SET", "BUILD_MAP",
46
+ "PRECALL", "PUSH_NULL", "FORMAT_VALUE", "FORMAT_SIMPLE", "BUILD_STRING",
47
+ "BINARY_SUBSCR", "TO_BOOL", "BINARY_SLICE", "BUILD_SLICE",
48
+ # Python 3.9 named binary opcodes (3.11+ uses BINARY_OP instead):
49
+ "BINARY_ADD", "BINARY_SUBTRACT", "BINARY_MULTIPLY", "BINARY_TRUE_DIVIDE",
50
+ "BINARY_FLOOR_DIVIDE", "BINARY_MODULO", "BINARY_POWER", "BINARY_LSHIFT",
51
+ "BINARY_RSHIFT", "BINARY_AND", "BINARY_OR", "BINARY_XOR",
52
+ "BINARY_MATRIX_MULTIPLY",
53
+ )) | _TERNARY_SKIP | _TERNARY_TERM
54
+
55
+
56
+ _COMPOUND_SKIP = frozenset(("CACHE", "RESUME", "NOT_TAKEN", "COPY_FREE_VARS", "PRECALL", "PUSH_NULL"))
57
+
58
+
59
+ _COMPOUND_EXPR_OPS = frozenset((
60
+ "LOAD_FAST", "LOAD_NAME", "LOAD_GLOBAL", "LOAD_CONST", "LOAD_DEREF",
61
+ "LOAD_SMALL_INT", "LOAD_FAST_BORROW", "LOAD_CONST_BORROW", "LOAD_FAST_BORROW_LOAD_FAST_BORROW",
62
+ "LOAD_GLOBAL_MODULE", "LOAD_ATTR", "LOAD_METHOD", "GET_ATTR",
63
+ "CALL", "CALL_FUNCTION", "CALL_METHOD", "COMPARE_OP", "BINARY_OP", "IS_OP", "CONTAINS_OP",
64
+ "UNARY_NOT", "UNARY_NEGATIVE", "UNARY_POSITIVE", "UNARY_INVERT",
65
+ "BUILD_TUPLE", "BUILD_LIST", "BUILD_SET", "BUILD_MAP",
66
+ "TO_BOOL", "FORMAT_VALUE", "FORMAT_SIMPLE", "BUILD_STRING", "BINARY_SUBSCR", "BINARY_SLICE", "BUILD_SLICE",
67
+ "DUP_TOP", "DUP_TOP_TWO", "ROT_TWO", "ROT_THREE", "ROT_FOUR", "SWAP", "COPY",
68
+ # Python 3.9 binary opcodes:
69
+ "BINARY_ADD", "BINARY_SUBTRACT", "BINARY_MULTIPLY", "BINARY_TRUE_DIVIDE",
70
+ "BINARY_FLOOR_DIVIDE", "BINARY_MODULO", "BINARY_POWER", "BINARY_LSHIFT",
71
+ "BINARY_RSHIFT", "BINARY_AND", "BINARY_OR", "BINARY_XOR",
72
+ "BINARY_MATRIX_MULTIPLY"
73
+ )) | _COMPOUND_SKIP
74
+
75
+
76
+ _WHILE_TRIVIAL_OPS = frozenset({"NOP", "RESUME", "NOT_TAKEN", "CACHE", "COPY_FREE_VARS"})
77
+
78
+
79
+ _STATEMENT_BOUNDARY_OPS = frozenset({
80
+ "POP_TOP", "POP_ITER", "END_FOR", "RETURN_VALUE", "RETURN_CONST", "RAISE_VARARGS",
81
+ "RERAISE", "POP_BLOCK", "PUSH_EXC_INFO", "POP_EXCEPT",
82
+ })
83
+
84
+
85
+ _TRY_SKIP_NOP = frozenset({"RESUME", "CACHE", "NOT_TAKEN"})
86
+
87
+ # Placeholders between a protected range's end and the code that follows it (a with
88
+ # block's inlined exit call, a finally copy): skipped when matching positions.
89
+ _FILLER_OPS = frozenset({"NOP", "CACHE"})
90
+
91
+
92
+ _TRY_LOAD_OPS = frozenset({"LOAD_NAME", "LOAD_GLOBAL", "LOAD_FAST",
93
+ "LOAD_DEREF", "LOAD_SMALL_INT", "LOAD_GLOBAL_MODULE"})
94
+
95
+
96
+ _TRY_LOOP_HEADS = frozenset({"FOR_ITER", "GET_ITER", "GET_ANEXT", "GET_AWAITABLE"})
97
+
98
+
99
+ _TRY_HANDLER_EXIT_OPS = frozenset({"POP_EXCEPT", "DELETE_FAST", "DELETE_NAME",
100
+ "DELETE_GLOBAL", "STORE_FAST", "STORE_NAME"})
101
+
102
+
103
+ _IS_COMP_GEN_SKIP = frozenset({"POP_TOP", "CACHE", "NOP", "RESUME", "COPY", "NOT_TAKEN"})
104
+
105
+
106
+ _IS_COMP_GEN_STOP = frozenset({"LOAD_CONST", "LOAD_NAME", "LOAD_FAST", "LOAD_GLOBAL", "LOAD_DEREF", "LOAD_SMALL_INT",
107
+ "LOAD_ATTR", "PUSH_NULL", "RERAISE", "RAISE_VARARGS", "RETURN_CONST", "RETURN_VALUE", "JUMP_FORWARD"})
108
+
109
+
110
+ _GENERIC_SKIP_OPS = frozenset({"RESUME", "CACHE", "NOP", "NOT_TAKEN", "COPY_FREE_VARS", "EXTENDED_ARG"})
111
+
112
+
113
+ # --- Augmented Assignment Opcodes ---
114
+ _AUG_ASSIGN_MAP = {
115
+ 13: "+=", 14: "&=", 15: "//=", 16: "<<=", 17: "@=",
116
+ 18: "*=", 19: "%=", 20: "|=", 21: "**=", 22: ">>=",
117
+ 23: "-=", 24: "/=", 25: "^=",
118
+ }
119
+
120
+
121
+ _INPLACE_ASSIGN_MAP = {
122
+ "INPLACE_ADD": "+=", "INPLACE_SUBTRACT": "-=",
123
+ "INPLACE_MULTIPLY": "*=", "INPLACE_TRUE_DIVIDE": "/=",
124
+ "INPLACE_FLOOR_DIVIDE": "//=", "INPLACE_MODULO": "%=",
125
+ "INPLACE_POWER": "**=", "INPLACE_LSHIFT": "<<=",
126
+ "INPLACE_RSHIFT": ">>=", "INPLACE_AND": "&=",
127
+ "INPLACE_OR": "|=", "INPLACE_XOR": "^=",
128
+ "INPLACE_MATRIX_MULTIPLY": "@=",
129
+ }
130
+
131
+
132
+ # --- Value-pushing opcodes (prefixes) ---
133
+ # Instructions that push a value, such as the callee of a call before its arguments;
134
+ # an inlined `with` exit call never directly follows one of them.
135
+ _VALUE_OPS = ("LOAD_", "PUSH_NULL", "CALL", "BINARY_", "BUILD_", "COPY", "GET_", "UNARY_",
136
+ "COMPARE_", "FORMAT_", "MAKE_FUNCTION", "CONTAINS_OP", "IS_OP")
pycrefine/cli.py ADDED
@@ -0,0 +1,53 @@
1
+ """Command-line interface."""
2
+ import argparse
3
+ import sys
4
+ import traceback
5
+ from .loader import get_decompiler
6
+
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # CLI
10
+ # ---------------------------------------------------------------------------
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser(
14
+ description="A Python .pyc decompiler that reconstructs readable source code from compiled bytecode."
15
+ )
16
+ parser.add_argument("input", help="The .pyc file to decompile")
17
+ parser.add_argument(
18
+ "-o", "--output",
19
+ help="Optional filename to save the decompiled output to. If not supplied, prints to screen."
20
+ )
21
+ parser.add_argument(
22
+ "--beautification-level",
23
+ choices=['none', 'core', 'aggressive'], default='core',
24
+ help="Level of code beautification to apply. 'none' does no beautification. 'core' collapses else-if to elif. 'aggressive' is a stub for future use."
25
+ )
26
+
27
+ args = parser.parse_args()
28
+
29
+ try:
30
+ decompiler = get_decompiler(args.input, args.beautification_level)
31
+ output_text = decompiler.decompile()
32
+
33
+ if args.output:
34
+ try:
35
+ with open(args.output, "w", encoding="utf-8") as f:
36
+ f.write(output_text)
37
+ print(f"Decompiled output saved to {args.output}", file=sys.stderr)
38
+ except OSError as e:
39
+ print(f"Error: Failed to save decompiled output to {args.output}: {e}", file=sys.stderr)
40
+ sys.exit(1)
41
+ else:
42
+ if output_text and output_text.strip():
43
+ print(output_text)
44
+ else:
45
+ print(f"Warning: Decompiler returned no source code for {args.input}", file=sys.stderr)
46
+ sys.exit(1)
47
+
48
+ except ValueError as e:
49
+ print(f"Error: {e}", file=sys.stderr)
50
+ sys.exit(1)
51
+ except Exception:
52
+ traceback.print_exc()
53
+ sys.exit(1)
pycrefine/codeobj.py ADDED
@@ -0,0 +1,26 @@
1
+ """Code-object helpers shared by the loader and the decompilers (``ForeignCode`` for foreign-version .pyc files)."""
2
+ import types
3
+ from typing import Any
4
+
5
+
6
+ class ForeignCode:
7
+ """
8
+ A code object read from a .pyc of a different Python version.
9
+
10
+ The host interpreter cannot build a real ``types.CodeType`` from another
11
+ version's bytecode, so the parsed fields are kept as plain ``co_*``
12
+ attributes. The decompilers only read those attributes.
13
+ """
14
+
15
+ co_name: str
16
+
17
+ def __init__(self, **fields: Any):
18
+ self.__dict__.update(fields)
19
+
20
+ def __repr__(self) -> str:
21
+ return f"<ForeignCode {self.co_name!r}>"
22
+
23
+
24
+ def _is_code(obj: Any) -> bool:
25
+ """True for a host code object or one parsed from another Python version."""
26
+ return isinstance(obj, (types.CodeType, ForeignCode))
File without changes
@@ -0,0 +1,258 @@
1
+ """Base class holding the state every decompiler backend shares."""
2
+ import ast
3
+ import dis
4
+ import sys
5
+ import types
6
+ from typing import Any, List, Optional, Tuple
7
+ from ..instructions import BytecodeInstruction
8
+
9
+
10
+ class DecompilerBase:
11
+ def __init__(self, code_obj: types.CodeType, indent_level: int = 0, beautification_level: str = 'core', target_version: Optional[Tuple[int, int]] = None):
12
+ self.code_obj = code_obj
13
+ # Exception table, filled in on first use by _exception_entries(); False = not yet read.
14
+ self._exception_entries_cache: Any = False
15
+ self.instructions: List[BytecodeInstruction] = []
16
+ self.reconstructed: List[str] = []
17
+ self.indent_level = indent_level
18
+ self.stack: List[Any] = []
19
+ self.instructions = list(code_obj.instructions) if hasattr(code_obj, 'instructions') else []
20
+ self.starts_as_function = indent_level > 0
21
+ self.blocks: List[Tuple[int, str]] = [] # stack of (end_offset, type)
22
+ self.pc = 0
23
+ self.beautification_level = beautification_level
24
+ self.target_version = target_version or sys.version_info[:2]
25
+ self._accum_counter = 0
26
+
27
+ def _disassemble(self):
28
+ """Convert code object bytecode into a list of BytecodeInstruction."""
29
+ for instr in dis.get_instructions(self.code_obj):
30
+ starts_line = instr.starts_line
31
+ if isinstance(starts_line, bool):
32
+ # 3.13+: a flag, with the number in ``line_number``.
33
+ starts_line = getattr(instr, "line_number", None) if starts_line else None
34
+ self.instructions.append(BytecodeInstruction(
35
+ opcode=instr.opcode,
36
+ opname=instr.opname,
37
+ arg=instr.arg,
38
+ argval=instr.argval,
39
+ offset=instr.offset,
40
+ starts_line=starts_line,
41
+ is_jump_target=instr.is_jump_target,
42
+ argrepr=instr.argrepr,
43
+ ))
44
+
45
+ def decompile(self) -> str:
46
+ """
47
+ Decompile the stored code object into a human-readable Python source string.
48
+
49
+ Returns:
50
+ source (str): Reconstructed Python source for the decompiled code object.
51
+ """
52
+ raise NotImplementedError("Subclasses must implement decompile()")
53
+
54
+ def _fresh_accum_name(self) -> str:
55
+ """
56
+ Generate a fresh accumulator variable name that doesn't collide with existing names.
57
+
58
+ Returns:
59
+ str: A unique accumulator name like "_res", "_res1", "_res2", etc.
60
+ """
61
+ base_name = "_res"
62
+ # Collect all existing names from the code object
63
+ existing_names = set()
64
+ if hasattr(self.code_obj, 'co_varnames'):
65
+ existing_names.update(self.code_obj.co_varnames)
66
+ if hasattr(self.code_obj, 'co_names'):
67
+ existing_names.update(self.code_obj.co_names)
68
+
69
+ # Try base name first
70
+ if base_name not in existing_names:
71
+ return base_name
72
+
73
+ # Otherwise use counter to generate unique name
74
+ while True:
75
+ candidate = f"{base_name}{self._accum_counter}"
76
+ self._accum_counter += 1
77
+ if candidate not in existing_names:
78
+ return candidate
79
+
80
+ def _get_jump_target(self, instr: BytecodeInstruction) -> int:
81
+ """
82
+ Determine the absolute jump target offset for the given instruction.
83
+
84
+ @param instr: The disassembled instruction whose jump target should be resolved.
85
+ @returns: The target bytecode offset as an integer; returns 0 when no target is available.
86
+ """
87
+ if isinstance(instr.argval, int):
88
+ return instr.argval
89
+ return int(instr.arg) if (instr.arg is not None) else 0
90
+
91
+ def is_effectively_last(self) -> bool:
92
+ """
93
+ Determine whether the remaining instructions from the current program counter are only epilogue/handler or otherwise ignorable operations.
94
+
95
+ Scans instructions starting at self.pc and treats common epilogue/exception-handler/filler opcodes and trailing `return None` patterns as non-meaningful; returns False if any remaining instruction is considered meaningful.
96
+
97
+ Returns:
98
+ True if no meaningful instructions remain after the current program counter, False otherwise.
99
+ """
100
+ # Code the prescans already placed elsewhere (a finally body's re-raise copy,
101
+ # handler sections rendered at their merge label) is not on the fall-through path.
102
+ placed = set()
103
+ for name in ("_finally_body_suppress", "_wrapper_body_suppress", "_handler_section_suppress"):
104
+ placed |= getattr(self, name, set())
105
+ # Search ahead from current PC.
106
+ for i in range(self.pc, len(self.instructions)):
107
+ instr = self.instructions[i]
108
+ op = instr.opname
109
+ if instr.offset in placed:
110
+ continue
111
+ # Skip re-raise epilogue, filler, and terminal stack-clearing instructions
112
+ # common in modern Python (3.11-3.14) exception handlers and epilogues.
113
+ if op in (
114
+ "COPY", "POP_EXCEPT", "RERAISE", "CACHE", "RESUME", "PUSH_EXC_INFO",
115
+ "POP_TOP", "LOAD_CONST", "EXTENDED_ARG", "CHECK_EXC_MATCH",
116
+ "NOT_TAKEN", "TO_BOOL", "POP_JUMP_IF_TRUE", "POP_JUMP_IF_FALSE",
117
+ "JUMP_BACKWARD", "JUMP_BACKWARD_NO_INTERRUPT", "NOP"
118
+ ):
119
+ continue
120
+ # Skip subsequent None returns (common in 3.14 multiple exit paths)
121
+ stack = getattr(self, "stack", None)
122
+ if op == "RETURN_VALUE" and stack and str(stack[-1]) == "None":
123
+ continue
124
+ if op == "RETURN_CONST" and str(instr.argval) == "None":
125
+ continue
126
+ return False
127
+ return True
128
+
129
+ def _wrap_negative_literal(self, value: Any) -> str:
130
+ """
131
+ Wrap negative numeric literals in parentheses to ensure valid syntax for attribute access or method calls.
132
+
133
+ Example: -1 -> (-1). bit_length()
134
+ """
135
+ s = str(value)
136
+ if s.startswith("-"):
137
+ try:
138
+ evaluated = ast.literal_eval(s)
139
+ if isinstance(evaluated, (int, float, complex)):
140
+ return f"({s})"
141
+ except (ValueError, SyntaxError):
142
+ pass
143
+ # Also wrap variables with explicit unary minus like (-x) if needed?
144
+ # Only focus on numeric/complex literals that break attr access:
145
+ if s.startswith("(-") and s.endswith(")"):
146
+ return s
147
+ return s
148
+
149
+ def _module_level_return_none(self) -> bool:
150
+ """
151
+ Handle a `return None` in code that is not a function body.
152
+
153
+ Module and class bodies cannot contain a `return` statement, so every
154
+ `return None` there is compiler-generated: 3.12+ copies the implicit
155
+ final return to the end of each branch, and compiles a `break` out of a
156
+ loop at the end of the module as `return None`. Emits `break` inside a
157
+ loop and nothing elsewhere.
158
+
159
+ Returns:
160
+ True if the return was handled here, False for function bodies.
161
+ """
162
+ if getattr(self.code_obj, "co_flags", 0) & 0x1: # CO_OPTIMIZED: a function
163
+ return False
164
+ here = self.instructions[self.pc - 1].offset if 0 < self.pc <= len(self.instructions) else -1
165
+ # A loop whose end is at or before this return has already finished (it is
166
+ # only still on the stack because a block opened inside it ends later).
167
+ if any(btype in ("for", "while") and end > here for end, btype in getattr(self, "blocks", [])):
168
+ self._append_reconstructed("break")
169
+ return True
170
+
171
+ def _exception_entries(self) -> Optional[list]:
172
+ """
173
+ The code object's exception table (3.11+), computed once; None before 3.11, whose
174
+ code objects have none (``dis.Bytecode`` has no ``exception_entries`` there).
175
+ """
176
+ cached = self._exception_entries_cache
177
+ if cached is False:
178
+ try:
179
+ cached = list(dis.Bytecode(self.code_obj).exception_entries)
180
+ except (AttributeError, TypeError, ValueError, IndexError):
181
+ cached = None
182
+ self._exception_entries_cache = cached
183
+ return cached
184
+
185
+ def _ends_try_before_handlers(self, instr_index: int) -> bool:
186
+ """
187
+ True if the return at *instr_index* is the normal exit of a `try` that ends the
188
+ function: the exception handlers start right after it (3.11+ lays them out after
189
+ the code they protect), so nothing but handler code follows on any path.
190
+ """
191
+ instrs = self.instructions
192
+ nxt = next((ins for ins in instrs[instr_index + 1:]
193
+ if ins.opname not in ("CACHE", "NOP", "EXTENDED_ARG")), None)
194
+ if nxt is None or nxt.opname != "PUSH_EXC_INFO":
195
+ return False
196
+ # A handler that completes jumps to the code after the try statement. If that
197
+ # is more than the implicit `return None`, the try does not end the function
198
+ # (``try: ... except E: ... else: return None`` followed by more code).
199
+ by_offset = {ins.offset: i for i, ins in enumerate(instrs)}
200
+ for ins in instrs[instr_index + 1:]:
201
+ if ins.opname not in ("JUMP_FORWARD", "JUMP", "JUMP_NO_INTERRUPT"):
202
+ continue
203
+ k = by_offset.get(self._get_jump_target(ins))
204
+ while k is not None and k < len(instrs) and instrs[k].opname in ("CACHE", "NOP", "EXTENDED_ARG"):
205
+ k += 1
206
+ if k is None or k >= len(instrs):
207
+ continue
208
+ target = instrs[k]
209
+ implicit = (target.opname == "RETURN_CONST" and target.argval is None) or (
210
+ target.opname == "LOAD_CONST" and target.argval is None
211
+ and k + 1 < len(instrs) and instrs[k + 1].opname == "RETURN_VALUE")
212
+ if not implicit:
213
+ return False
214
+ return True
215
+
216
+ def is_compiler_generated_return(self, instr_index: int) -> bool:
217
+ """
218
+ Determine whether the RETURN at the given instruction index is the implicit compiler-inserted `return None`.
219
+
220
+ Performs conservative checks: the instruction must be effectively the final unbranched exit, must not reside inside user-level control-flow blocks (e.g., `if`, `for`, `while`, `try`, `except`, `finally`, `with`), and — when available — must not lie inside an exception-table protected range (Python 3.11+). These checks aim to distinguish a compiler-generated trailing `return None` from an explicit return written in source.
221
+
222
+ Parameters:
223
+ instr_index (int): Index into `self.instructions` identifying the RETURN instruction to evaluate.
224
+
225
+ Returns:
226
+ `true` if the RETURN is likely the compiler-inserted implicit `return None`, `false` otherwise.
227
+ """
228
+ instr = self.instructions[instr_index]
229
+ # Save current state for lookahead
230
+ old_pc = self.pc
231
+ self.pc = instr_index + 1
232
+ effectively_last = self.is_effectively_last()
233
+ self.pc = old_pc
234
+
235
+ # Robust check for Python 3.11+: if the return is within a protected
236
+ # range in the exception table, it's explicit. Compiler-generated
237
+ # returns for with/try blocks are always outside the protected range.
238
+ entries = self._exception_entries()
239
+ if entries and any(e.start <= instr.offset < e.end for e in entries):
240
+ return False
241
+
242
+ if not effectively_last and not (entries and self._ends_try_before_handlers(instr_index)):
243
+ return False
244
+
245
+ # If we're inside any user-facing control flow block, it's likely
246
+ # an explicit return in the source. A `try` or `with` block is still open
247
+ # at the implicit return that follows it (its handlers come after), but
248
+ # that return lies outside every protected range, which the check above
249
+ # established: those blocks do not make it explicit.
250
+ user_blocks = {"if", "else", "for", "while", "try_body", "except", "finally_body", "with", "with_body"}
251
+ if entries:
252
+ user_blocks -= {"try_body", "with", "with_body"}
253
+ blocks = getattr(self, "blocks", [])
254
+ for _, btype in blocks:
255
+ if btype in user_blocks:
256
+ return False
257
+
258
+ return True