pyc-revive 1.0.0__tar.gz

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.
@@ -0,0 +1,95 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyc-revive
3
+ Version: 1.0.0
4
+ Summary: Stdlib-only .pyc -> .py reconstructor with native and pycdas backends
5
+ Author: opsecalyzer
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/opsecalyzer/pyc_revive
8
+ Project-URL: Source, https://github.com/opsecalyzer/pyc_revive
9
+ Keywords: pyc,decompiler,bytecode,reverse-engineering,malware-analysis
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Information Technology
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Disassemblers
21
+ Classifier: Operating System :: OS Independent
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+
25
+ # pyc_revive
26
+
27
+ `.pyc` -> `.py` reconstructor. no uncompyle6. no pycdc. no xdis.
28
+
29
+ ## use
30
+
31
+ ```bash
32
+ python -m pyc_revive my file.pyc
33
+ python -m pyc_revive ./dist/app.pyc --out ./revived.py
34
+ ```
35
+
36
+ input handling: exact path, spaces with no quotes, basename search in
37
+ cwd + all subfolders + parent folder.
38
+
39
+ ## versions
40
+
41
+ every CPython from 2.0 through 3.15 resolves by magic number
42
+ (`pymagic.py`, full table + nearest fallback + header sizes).
43
+
44
+ two backends, one pipeline:
45
+
46
+ - native: `.pyc` matches the running interpreter. stdlib marshal loads
47
+ the code object, stdlib dis disassembles. zero dependencies.
48
+ - pycdas: anything else. the bundled `pycdas.exe` disassembles the
49
+ foreign version, `pycdas_parser.py` parses the dump into code trees,
50
+ `shim.py` adapts them to the pipeline. everything downstream
51
+ (normalize, segment, translate, CFG, emit, verify x3) runs unchanged.
52
+
53
+ ## pipeline
54
+
55
+ 1. find file (`finder.py`)
56
+ 2. detect version via magic (`pymagic.py`)
57
+ 3. load: native marshal, else pycdas backend (`loader.py`,
58
+ `pycdas_parser.py`, `shim.py`)
59
+ 4. disassemble + abstract (`bytecode.py`: `Inst`, `EditableBytecode`,
60
+ opcode/oparg/value-stack/metadata tables)
61
+ 5. normalize / mask consts + names (`normalizer.py`)
62
+ 6. opcode-aware B/I/E segmentation (`segmenter.py`)
63
+ 7. statement translation (`translator.py`: stack machine, defaults
64
+ recovery, lambdas inline, 2.x print/exec/class ops)
65
+ 8. control-flow graph from jumps (`cfg.py`: if/for/while/try/except-as
66
+ (bare `except:` too)/with/finally, clauses + clusters, 3.11+
67
+ exception table, SETUP-region splits)
68
+ 9. emit + repairs (`emitter.py`: source-order splicing onto def/class
69
+ stub lines, bases recovery, `@decorator` restore, `global` decls,
70
+ cond chains, ternaries, nested if/else, loop ranges, break/continue
71
+ recovery, indent + suite-guard passes)
72
+ 10. equivalence verify, 3 sweeps (`verifier.py`: recompile revived source,
73
+ compare opcode structure, repair, repeat x3)
74
+
75
+ ## layout
76
+
77
+ ```
78
+ pyc_revive/
79
+ finder.py pymagic.py loader.py pycdas_parser.py shim.py
80
+ bytecode.py normalizer.py segmenter.py translator.py cfg.py
81
+ emitter.py verifier.py reconstructor.py cli.py __main__.py
82
+ pycdas.exe README.md requirements.txt
83
+ ```
84
+
85
+ ## known limits
86
+
87
+ - inline comprehensions/genexps render as `func(...)` placeholders.
88
+ - `try` bodies that fall through into always-run handlers keep flat
89
+ order instead of guarded flow.
90
+ - exotic decorators reduce to bare defs; exotic call shapes degrade to
91
+ commented disassembly rather than failing.
92
+ - pre-3.8 default-argument recovery is best-effort (flag-style MAKE
93
+ from 3.8+ recovers exactly).
94
+ - verifier scores dip across versions: opcode sets drift between
95
+ releases, so cross-version similarity reads lower than same-version.
@@ -0,0 +1,71 @@
1
+ # pyc_revive
2
+
3
+ `.pyc` -> `.py` reconstructor. no uncompyle6. no pycdc. no xdis.
4
+
5
+ ## use
6
+
7
+ ```bash
8
+ python -m pyc_revive my file.pyc
9
+ python -m pyc_revive ./dist/app.pyc --out ./revived.py
10
+ ```
11
+
12
+ input handling: exact path, spaces with no quotes, basename search in
13
+ cwd + all subfolders + parent folder.
14
+
15
+ ## versions
16
+
17
+ every CPython from 2.0 through 3.15 resolves by magic number
18
+ (`pymagic.py`, full table + nearest fallback + header sizes).
19
+
20
+ two backends, one pipeline:
21
+
22
+ - native: `.pyc` matches the running interpreter. stdlib marshal loads
23
+ the code object, stdlib dis disassembles. zero dependencies.
24
+ - pycdas: anything else. the bundled `pycdas.exe` disassembles the
25
+ foreign version, `pycdas_parser.py` parses the dump into code trees,
26
+ `shim.py` adapts them to the pipeline. everything downstream
27
+ (normalize, segment, translate, CFG, emit, verify x3) runs unchanged.
28
+
29
+ ## pipeline
30
+
31
+ 1. find file (`finder.py`)
32
+ 2. detect version via magic (`pymagic.py`)
33
+ 3. load: native marshal, else pycdas backend (`loader.py`,
34
+ `pycdas_parser.py`, `shim.py`)
35
+ 4. disassemble + abstract (`bytecode.py`: `Inst`, `EditableBytecode`,
36
+ opcode/oparg/value-stack/metadata tables)
37
+ 5. normalize / mask consts + names (`normalizer.py`)
38
+ 6. opcode-aware B/I/E segmentation (`segmenter.py`)
39
+ 7. statement translation (`translator.py`: stack machine, defaults
40
+ recovery, lambdas inline, 2.x print/exec/class ops)
41
+ 8. control-flow graph from jumps (`cfg.py`: if/for/while/try/except-as
42
+ (bare `except:` too)/with/finally, clauses + clusters, 3.11+
43
+ exception table, SETUP-region splits)
44
+ 9. emit + repairs (`emitter.py`: source-order splicing onto def/class
45
+ stub lines, bases recovery, `@decorator` restore, `global` decls,
46
+ cond chains, ternaries, nested if/else, loop ranges, break/continue
47
+ recovery, indent + suite-guard passes)
48
+ 10. equivalence verify, 3 sweeps (`verifier.py`: recompile revived source,
49
+ compare opcode structure, repair, repeat x3)
50
+
51
+ ## layout
52
+
53
+ ```
54
+ pyc_revive/
55
+ finder.py pymagic.py loader.py pycdas_parser.py shim.py
56
+ bytecode.py normalizer.py segmenter.py translator.py cfg.py
57
+ emitter.py verifier.py reconstructor.py cli.py __main__.py
58
+ pycdas.exe README.md requirements.txt
59
+ ```
60
+
61
+ ## known limits
62
+
63
+ - inline comprehensions/genexps render as `func(...)` placeholders.
64
+ - `try` bodies that fall through into always-run handlers keep flat
65
+ order instead of guarded flow.
66
+ - exotic decorators reduce to bare defs; exotic call shapes degrade to
67
+ commented disassembly rather than failing.
68
+ - pre-3.8 default-argument recovery is best-effort (flag-style MAKE
69
+ from 3.8+ recovers exactly).
70
+ - verifier scores dip across versions: opcode sets drift between
71
+ releases, so cross-version similarity reads lower than same-version.
@@ -0,0 +1,6 @@
1
+ """pyc_revive — stdlib-only .pyc -> .py reconstructor."""
2
+ from .reconstructor import reconstruct_file, reconstruct_code
3
+ from .finder import resolve_target
4
+
5
+ __all__ = ["reconstruct_file", "reconstruct_code", "resolve_target"]
6
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+ if __name__ == "__main__":
3
+ raise SystemExit(main())
@@ -0,0 +1,247 @@
1
+ """Self-validation loop: compile the revived source, diagnose failures,
2
+ apply targeted structural repairs, repeat until clean or out of budget.
3
+
4
+ This is the last gate before a build ships: it proves the artifact parses,
5
+ and when it does not, it fixes what it can (orphan handlers, bad indents,
6
+ empty suites) and reports what it could not. Returns the best source found
7
+ plus a repair log for the report.
8
+ """
9
+ from __future__ import annotations
10
+
11
+
12
+ def _compile_errors(src: str) -> list[dict]:
13
+ try:
14
+ compile(src, "<revived>", "exec")
15
+ return []
16
+ except SyntaxError as e:
17
+ return [{"line": e.lineno or 0, "msg": str(e.msg or e), "text": (e.text or "").rstrip()}]
18
+ except Exception as e:
19
+ return [{"line": 0, "msg": str(e), "text": ""}]
20
+
21
+
22
+ def _indent_of(line: str) -> int:
23
+ return len(line) - len(line.lstrip(" "))
24
+
25
+
26
+ def _is_header(s: str) -> bool:
27
+ t = s.strip()
28
+ if not t or t.startswith("#"):
29
+ return False
30
+ head = t.split(":")[0].split()
31
+ if not head:
32
+ return False
33
+ if not t.rstrip().endswith(":"):
34
+ return False
35
+ return head[0] in (
36
+ "if", "elif", "else", "for", "while", "try", "except", "finally",
37
+ "with", "def", "class", "match", "case")
38
+
39
+
40
+ def _fix_empty_suite(lines: list[str], err: dict) -> list[str] | None:
41
+ if "expected an indented block" not in err["msg"]:
42
+ return None
43
+ n = (err["line"] or 1) - 1
44
+ if n < 0 or n >= len(lines):
45
+ return None
46
+ hdr = lines[n]
47
+ if not _is_header(hdr):
48
+ return None
49
+ lvl = _indent_of(hdr)
50
+ nxt = next((k for k in range(n + 1, len(lines)) if lines[k].strip()), None)
51
+ if nxt is not None and _indent_of(lines[nxt]) > lvl:
52
+ return None # suite exists; different problem
53
+ out = list(lines)
54
+ out.insert(n + 1, " " * (lvl + 4) + "pass")
55
+ return out
56
+
57
+
58
+ def _fix_orphan(lines: list[str], err: dict) -> list[str] | None:
59
+ msg = err["msg"]
60
+ n = (err["line"] or 1) - 1
61
+ if n < 0 or n >= len(lines):
62
+ return None
63
+ s = lines[n].strip()
64
+ if msg == "expected 'except' or 'finally' block" and not (s.startswith("except") or s.startswith("finally") or s == "try:"):
65
+ # try suite broke early (stray flat line closed it): find the
66
+ # enclosing try and either drop a stray pass or reindent the line in
67
+ for k in range(n - 1, -1, -1):
68
+ if not lines[k].strip():
69
+ continue
70
+ if _indent_of(lines[k]) > _indent_of(lines[n]):
71
+ continue
72
+ if lines[k].strip() == "try:" and _indent_of(lines[k]) <= _indent_of(lines[n]):
73
+ out = list(lines)
74
+ if s == "pass":
75
+ del out[n]
76
+ return out
77
+ lvl = _indent_of(lines[k]) + 4
78
+ delta = _indent_of(lines[n]) - lvl
79
+ if delta == 0:
80
+ return None
81
+ end = n
82
+ for m in range(n + 1, len(lines)):
83
+ if not lines[m].strip():
84
+ continue
85
+ if _indent_of(lines[m]) > _indent_of(lines[n]):
86
+ end = m
87
+ else:
88
+ break
89
+ if delta > 0:
90
+ for m in range(n, end + 1):
91
+ if lines[m].strip() and len(lines[m]) - len(lines[m].lstrip(" ")) >= delta:
92
+ out[m] = lines[m][delta:]
93
+ else:
94
+ for m in range(n, end + 1):
95
+ if lines[m].strip():
96
+ out[m] = " " * (-delta) + lines[m].lstrip(" ")
97
+ return out
98
+ break
99
+ return None
100
+ want: str | None = None
101
+ if s.startswith("except") or s.startswith("finally"):
102
+ want = "try"
103
+ elif s.startswith("else:"):
104
+ want = "if/for/while"
105
+ elif s.startswith("elif"):
106
+ want = "if"
107
+ if want is None:
108
+ if s == "try:":
109
+ out = list(lines)
110
+ out.insert(n + 1, " " * (_indent_of(lines[n]) + 4) + "pass")
111
+ return out
112
+ if "expected 'except' or 'finally'" not in msg:
113
+ return None
114
+ want = "body" # try suite ran dry; just needs any suite
115
+ lvl = _indent_of(lines[n])
116
+ # nearest shallower line decides: a matching opener reindents us to it
117
+ for k in range(n - 1, -1, -1):
118
+ if not lines[k].strip():
119
+ continue
120
+ ilk = _indent_of(lines[k])
121
+ if ilk > lvl:
122
+ continue
123
+ if ilk == lvl:
124
+ continue
125
+ t = lines[k].strip()
126
+ ok = ((want == "try" and t == "try:") or
127
+ (want == "if/for/while" and (t.startswith("if ") or t.startswith("if(") or t.startswith("for ") or t.startswith("while ") or t == "try:")) or
128
+ (want == "if" and (t.startswith("if ") or t.startswith("if("))) or
129
+ (want == "body"))
130
+ if ok:
131
+ delta = lvl - ilk
132
+ if delta <= 0:
133
+ return None
134
+ out = list(lines)
135
+ end = n
136
+ for m in range(n + 1, len(lines)):
137
+ if not lines[m].strip():
138
+ continue
139
+ if _indent_of(lines[m]) > lvl:
140
+ end = m
141
+ else:
142
+ break
143
+ for m in range(n, end + 1):
144
+ if lines[m].strip() and len(lines[m]) - len(lines[m].lstrip(" ")) >= delta:
145
+ out[m] = lines[m][delta:]
146
+ return out
147
+ break
148
+ # no partner: neutralize the orphan so the file parses; body stays visible
149
+ out = list(lines)
150
+ if s.startswith("except") or s.startswith("finally"):
151
+ out[n] = " " * lvl + "if False: # unattached handler kept visible"
152
+ elif s.startswith("else:") or s.startswith("elif"):
153
+ out[n] = " " * lvl + "if True: # unattached else kept visible"
154
+ else:
155
+ return None
156
+ return out
157
+
158
+
159
+ def _fix_indent(lines: list[str], err: dict) -> list[str] | None:
160
+ if "unexpected indent" not in err["msg"] and "unindent" not in err["msg"]:
161
+ return None
162
+ n = (err["line"] or 1) - 1
163
+ if n <= 0 or n >= len(lines):
164
+ return None
165
+ p = n - 1
166
+ while p >= 0 and not lines[p].strip():
167
+ p -= 1
168
+ if p < 0:
169
+ return None
170
+ plvl = _indent_of(lines[p])
171
+ cur = _indent_of(lines[n])
172
+ target = plvl + 4 if lines[p].rstrip().endswith(":") else plvl
173
+ if cur == target:
174
+ return None
175
+ if "unindent" in err["msg"] and cur in (plvl, plvl + 4) and target in (plvl, plvl + 4):
176
+ return None
177
+ delta = cur - target
178
+ if delta <= 0:
179
+ # needs indenting up (rare); shift line and suite right
180
+ out = list(lines)
181
+ end = n
182
+ for m in range(n + 1, len(lines)):
183
+ if not lines[m].strip():
184
+ continue
185
+ if _indent_of(lines[m]) > cur:
186
+ end = m
187
+ else:
188
+ break
189
+ for m in range(n, end + 1):
190
+ if lines[m].strip():
191
+ out[m] = " " * (-delta) + lines[m].lstrip(" ")
192
+ return out
193
+ out = list(lines)
194
+ end = n
195
+ for k in range(n + 1, len(lines)):
196
+ if not lines[k].strip():
197
+ continue
198
+ if _indent_of(lines[k]) > cur:
199
+ end = k
200
+ else:
201
+ break
202
+ for k in range(n, end + 1):
203
+ if lines[k].strip() and len(lines[k]) - len(lines[k].lstrip(" ")) >= delta:
204
+ out[k] = lines[k][delta:]
205
+ return out
206
+
207
+
208
+ _REPAIRS = (_fix_empty_suite, _fix_orphan, _fix_indent)
209
+
210
+
211
+ def autovalidate(src: str, budget: int = 8) -> tuple[str, dict]:
212
+ """Compile-repair loop. Returns (best_src, log). Never raises."""
213
+ rounds: list[dict] = []
214
+ cur = src
215
+ errs = _compile_errors(cur)
216
+ if not errs:
217
+ return cur, {"compiles": True, "rounds": rounds, "repairs": 0}
218
+ best, best_key = cur, (len(errs) and -errs[0]["line"] or 0)
219
+ for _ in range(budget):
220
+ errs = _compile_errors(cur)
221
+ if not errs:
222
+ return cur, {"compiles": True, "rounds": rounds, "repairs": len(rounds)}
223
+ err = errs[0]
224
+ fixed = None
225
+ used = None
226
+ for fn in _REPAIRS:
227
+ try:
228
+ fixed = fn(cur.splitlines(), err)
229
+ except Exception:
230
+ fixed = None
231
+ if fixed is not None:
232
+ used = fn.__name__
233
+ break
234
+ if fixed is None:
235
+ rounds.append({"error": err, "repair": None})
236
+ break
237
+ cur = "\n".join(fixed).rstrip() + "\n"
238
+ rounds.append({"error": err, "repair": used})
239
+ key = 0
240
+ errs2 = _compile_errors(cur)
241
+ if not errs2:
242
+ return cur, {"compiles": True, "rounds": rounds, "repairs": len(rounds)}
243
+ key = -errs2[0]["line"]
244
+ if key >= best_key:
245
+ best, best_key = cur, key
246
+ return best, {"compiles": False, "rounds": rounds, "repairs": len(rounds),
247
+ "note": "budget spent; shipping closest parse"}
@@ -0,0 +1,166 @@
1
+ """Version-agnostic bytecode model: Inst + EditableBytecode.
2
+
3
+ Wraps stdlib `dis` into mutable instruction objects so the rest of the
4
+ pipeline (normalize / segment / cfg) never touches raw bytes directly.
5
+ Tracks opcode, oparg, value-stack depth, metadata tables.
6
+ """
7
+ from __future__ import annotations
8
+ import dis
9
+ from dataclasses import dataclass, field
10
+ from types import CodeType
11
+ from typing import Any
12
+
13
+ # opnames that transfer control — used by CFG, version-agnostic superset
14
+ _JUMP_OPS = {
15
+ "POP_JUMP_IF_FALSE", "POP_JUMP_IF_TRUE",
16
+ "POP_JUMP_FORWARD_IF_FALSE", "POP_JUMP_FORWARD_IF_TRUE",
17
+ "POP_JUMP_BACKWARD_IF_FALSE", "POP_JUMP_BACKWARD_IF_TRUE",
18
+ "JUMP_IF_FALSE_OR_POP", "JUMP_IF_TRUE_OR_POP",
19
+ "JUMP_IF_TRUE", "JUMP_IF_FALSE", "JUMP_IF_NOT_EXC_MATCH",
20
+ "CONTINUE_LOOP", "BREAK_LOOP",
21
+ "JUMP_FORWARD", "JUMP_BACKWARD", "JUMP_ABSOLUTE", "JUMP",
22
+ "JUMP_BACKWARD_NO_INTERRUPT", "JUMP_NO_INTERRUPT",
23
+ "FOR_ITER", "SEND",
24
+ "SETUP_FINALLY", "SETUP_EXCEPT", "SETUP_WITH", "SETUP_LOOP",
25
+ "SETUP_FINALLY_PRE", "BEFORE_WITH",
26
+ "RERAISE", "RAISE_VARARGS",
27
+ "RETURN_VALUE", "RETURN_CONST", "RETURN_GENERATOR",
28
+ }
29
+ _HASJUMP = set(dis.hasjump) if hasattr(dis, "hasjump") else set()
30
+ _COND_JUMPS = {
31
+ "POP_JUMP_IF_FALSE", "POP_JUMP_IF_TRUE",
32
+ "POP_JUMP_FORWARD_IF_FALSE", "POP_JUMP_FORWARD_IF_TRUE",
33
+ "POP_JUMP_BACKWARD_IF_FALSE", "POP_JUMP_BACKWARD_IF_TRUE",
34
+ "JUMP_IF_FALSE_OR_POP", "JUMP_IF_TRUE_OR_POP",
35
+ "JUMP_IF_TRUE", "JUMP_IF_FALSE", "JUMP_IF_NOT_EXC_MATCH",
36
+ "FOR_ITER", "SEND",
37
+ }
38
+
39
+ # cross-version aliases: canonical -> set of spellings
40
+ ALIASES = {
41
+ "BINARY_ADD": {"BINARY_ADD", "BINARY_OP"},
42
+ "LOAD_METHOD": {"LOAD_METHOD", "LOAD_ATTR"},
43
+ "CALL": {"CALL", "CALL_FUNCTION", "CALL_FUNCTION_EX", "CALL_METHOD", "CALL_KW", "PRECALL"},
44
+ "RETURN": {"RETURN_VALUE", "RETURN_CONST", "RETURN_GENERATOR"},
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class Inst:
50
+ offset: int
51
+ opname: str
52
+ opcode: int
53
+ arg: Any = None
54
+ argval: Any = None
55
+ argrepr: str = ""
56
+ starts_line: Any = None
57
+ is_jump_target: bool = False
58
+ positions: Any = None
59
+ index: int = 0
60
+ stack_depth_before: int = 0
61
+ stack_effect: int = 0
62
+
63
+ @property
64
+ def is_jump(self) -> bool:
65
+ return self.opname in _JUMP_OPS or self.opcode in _HASJUMP
66
+
67
+ @property
68
+ def is_cond_jump(self) -> bool:
69
+ return self.opname in _COND_JUMPS
70
+
71
+ @property
72
+ def is_uncond_jump(self) -> bool:
73
+ return self.is_jump and not self.is_cond_jump and self.opname not in (
74
+ "FOR_ITER", "SEND", "SETUP_FINALLY", "SETUP_EXCEPT", "SETUP_WITH",
75
+ "SETUP_LOOP", "BEFORE_WITH", "RERAISE", "RAISE_VARARGS",
76
+ "RETURN_VALUE", "RETURN_CONST", "RETURN_GENERATOR",
77
+ )
78
+
79
+ @property
80
+ def is_return(self) -> bool:
81
+ return self.opname in ("RETURN_VALUE", "RETURN_CONST", "RETURN_GENERATOR")
82
+
83
+ @property
84
+ def jump_target(self):
85
+ return self.argval if self.is_jump and isinstance(self.argval, int) else None
86
+
87
+ def canonical(self) -> str:
88
+ for canon, spellings in ALIASES.items():
89
+ if self.opname in spellings:
90
+ return canon
91
+ return self.opname
92
+
93
+
94
+ @dataclass
95
+ class EditableBytecode:
96
+ code: CodeType
97
+ insts: list = field(default_factory=list)
98
+ consts: tuple = ()
99
+ names: tuple = ()
100
+ varnames: tuple = ()
101
+ cellvars: tuple = ()
102
+ freevars: tuple = ()
103
+
104
+ @classmethod
105
+ def from_code(cls, code: CodeType) -> "EditableBytecode":
106
+ raw = list(dis.get_instructions(code))
107
+ insts: list[Inst] = []
108
+ depth = 0
109
+ for i, ins in enumerate(raw):
110
+ try:
111
+ eff = dis.stack_effect(ins.opcode, ins.arg if ins.arg is not None else 0)
112
+ except Exception:
113
+ eff = 0
114
+ insts.append(Inst(
115
+ offset=ins.offset, opname=ins.opname, opcode=ins.opcode,
116
+ arg=ins.arg, argval=ins.argval, argrepr=ins.argrepr or "",
117
+ starts_line=ins.starts_line, is_jump_target=bool(ins.is_jump_target),
118
+ positions=getattr(ins, "positions", None),
119
+ index=i, stack_depth_before=depth, stack_effect=eff,
120
+ ))
121
+ depth = max(0, depth + eff)
122
+ # 3.11+ KW_NAMES references co_consts by index; resolve the tuple now
123
+ # so calls below can pair keyword arguments (dis leaves it unknown)
124
+ for _ins in insts:
125
+ if _ins.opname == "KW_NAMES" and isinstance(_ins.arg, int):
126
+ try:
127
+ _kc = code.co_consts[_ins.arg]
128
+ except Exception:
129
+ _kc = None
130
+ if isinstance(_kc, tuple):
131
+ _ins.argval = _kc
132
+ return cls(code=code, insts=insts, consts=code.co_consts,
133
+ names=code.co_names, varnames=code.co_varnames,
134
+ cellvars=getattr(code, "co_cellvars", ()),
135
+ freevars=getattr(code, "co_freevars", ()))
136
+
137
+ def __len__(self):
138
+ return len(self.insts)
139
+
140
+ def opnames(self) -> list[str]:
141
+ return [ins.canonical() for ins in self.insts]
142
+
143
+ def jump_map(self) -> dict[int, int]:
144
+ """offset -> index for jump resolution."""
145
+ return {ins.offset: ins.index for ins in self.insts}
146
+
147
+ def targets(self) -> set[int]:
148
+ out: set[int] = set()
149
+ m = self.jump_map()
150
+ for ins in self.insts:
151
+ t = ins.jump_target
152
+ if t is not None and t in m:
153
+ out.add(m[t])
154
+ return out
155
+
156
+ def metadata_table(self) -> dict:
157
+ c = self.code
158
+ return {
159
+ "co_name": c.co_name, "co_filename": c.co_filename,
160
+ "co_firstlineno": c.co_firstlineno, "co_argcount": c.co_argcount,
161
+ "co_posonlyargcount": getattr(c, "co_posonlyargcount", 0),
162
+ "co_kwonlyargcount": c.co_kwonlyargcount,
163
+ "co_nlocals": c.co_nlocals, "co_flags": c.co_flags,
164
+ "co_consts": [type(x).__name__ for x in c.co_consts],
165
+ "co_names": list(c.co_names), "co_varnames": list(c.co_varnames),
166
+ }