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.
- pyc_revive-1.0.0/PKG-INFO +95 -0
- pyc_revive-1.0.0/README.md +71 -0
- pyc_revive-1.0.0/pyc_revive/__init__.py +6 -0
- pyc_revive-1.0.0/pyc_revive/__main__.py +3 -0
- pyc_revive-1.0.0/pyc_revive/autofix.py +247 -0
- pyc_revive-1.0.0/pyc_revive/bytecode.py +166 -0
- pyc_revive-1.0.0/pyc_revive/cfg.py +272 -0
- pyc_revive-1.0.0/pyc_revive/cli.py +71 -0
- pyc_revive-1.0.0/pyc_revive/emitter.py +2385 -0
- pyc_revive-1.0.0/pyc_revive/finder.py +118 -0
- pyc_revive-1.0.0/pyc_revive/loader.py +53 -0
- pyc_revive-1.0.0/pyc_revive/normalizer.py +70 -0
- pyc_revive-1.0.0/pyc_revive/pycdas.exe +0 -0
- pyc_revive-1.0.0/pyc_revive/pycdas_parser.py +337 -0
- pyc_revive-1.0.0/pyc_revive/pymagic.py +161 -0
- pyc_revive-1.0.0/pyc_revive/reconstructor.py +109 -0
- pyc_revive-1.0.0/pyc_revive/segmenter.py +122 -0
- pyc_revive-1.0.0/pyc_revive/shim.py +182 -0
- pyc_revive-1.0.0/pyc_revive/translator.py +766 -0
- pyc_revive-1.0.0/pyc_revive/verifier.py +106 -0
- pyc_revive-1.0.0/pyc_revive.egg-info/PKG-INFO +95 -0
- pyc_revive-1.0.0/pyc_revive.egg-info/SOURCES.txt +25 -0
- pyc_revive-1.0.0/pyc_revive.egg-info/dependency_links.txt +1 -0
- pyc_revive-1.0.0/pyc_revive.egg-info/entry_points.txt +2 -0
- pyc_revive-1.0.0/pyc_revive.egg-info/top_level.txt +1 -0
- pyc_revive-1.0.0/pyproject.toml +41 -0
- pyc_revive-1.0.0/setup.cfg +4 -0
|
@@ -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,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
|
+
}
|