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 +65 -0
- pycrefine/__main__.py +3 -0
- pycrefine/_constants.py +136 -0
- pycrefine/cli.py +53 -0
- pycrefine/codeobj.py +26 -0
- pycrefine/decompiler/__init__.py +0 -0
- pycrefine/decompiler/base.py +258 -0
- pycrefine/decompiler/generic.py +5942 -0
- pycrefine/decompiler/py311.py +117 -0
- pycrefine/decompiler/py314.py +219 -0
- pycrefine/decompiler/py39.py +1269 -0
- pycrefine/expr.py +91 -0
- pycrefine/instructions.py +393 -0
- pycrefine/loader.py +342 -0
- pycrefine/opcodes/__init__.py +0 -0
- pycrefine/opcodes/py39.py +135 -0
- pycrefine/postprocess.py +494 -0
- pycrefine/versions.py +41 -0
- pycrefine-1.1.1.dist-info/METADATA +123 -0
- pycrefine-1.1.1.dist-info/RECORD +24 -0
- pycrefine-1.1.1.dist-info/WHEEL +5 -0
- pycrefine-1.1.1.dist-info/entry_points.txt +2 -0
- pycrefine-1.1.1.dist-info/licenses/LICENSE +21 -0
- pycrefine-1.1.1.dist-info/top_level.txt +1 -0
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
pycrefine/_constants.py
ADDED
|
@@ -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
|