Bytecode parity - exception (#7557)

* Fix exception handling: except* chaining, finally cleanup, RERAISE

- Align except* bytecode chaining
- Fix exception state model and finally handler cleanup
- Fix RERAISE to only pop exception, preserve values below

* Port IR optimization passes from flowgraph.c

- BUILD_TUPLE n + UNPACK_SEQUENCE n elimination
- Dead store elimination within basic blocks
- apply_static_swaps for SWAP reduction

* Add bytecode comparison and disassembly dump scripts

- compare_bytecode.py: compare CPython vs RustPython bytecode output
- dis_dump.py: extract disassembly in normalized JSON format
This commit is contained in:
Jeong, YunWon authored and GitHub committed 2026-04-10 12:51:48 +09:00
1 parent dcae8f0d53
commit 0b7e0f007b
8 files changed
+1726 -207

No files matched your search

+540
View File
@@ -0,0 +1,540 @@
#!/usr/bin/env python3
"""Compare bytecode between CPython and RustPython.
Compiles all Python files under Lib/ with both interpreters and reports
differences in the generated bytecode instructions.
Usage:
python scripts/compare_bytecode.py
python scripts/compare_bytecode.py --detail
python scripts/compare_bytecode.py --filter "asyncio/*.py"
python scripts/compare_bytecode.py --summary-json report.json
"""
import argparse
import fnmatch
import json
import os
import random
import subprocess
import sys
import tempfile
from collections import defaultdict
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
DIS_DUMP = os.path.join(SCRIPT_DIR, "dis_dump.py")
DEFAULT_REPORT = os.path.join(PROJECT_ROOT, "compare_bytecode.report")
def find_rustpython():
"""Locate the RustPython binary, allowing release builds only."""
if "RUSTPYTHON" in os.environ:
path = os.environ["RUSTPYTHON"]
normalized = os.path.normpath(path)
debug_fragment = os.path.join("target", "debug", "rustpython")
if normalized.endswith(debug_fragment):
raise ValueError(
"RUSTPYTHON must point to a release binary, not target/debug/rustpython"
)
return path
path = os.path.join(PROJECT_ROOT, "target", "release", "rustpython")
if os.path.isfile(path) and os.access(path, os.X_OK):
return path
return None
def collect_targets(lib_dir, pattern=None):
"""Collect Python files to compare, relative to lib_dir."""
targets = []
for root, dirs, files in os.walk(lib_dir):
dirs[:] = sorted(
d for d in dirs if d != "__pycache__" and not d.startswith(".")
)
for fname in sorted(files):
if not fname.endswith(".py"):
continue
fpath = os.path.join(root, fname)
relpath = os.path.relpath(fpath, lib_dir)
if pattern and not fnmatch.fnmatch(relpath, pattern):
continue
targets.append((relpath, fpath))
return targets
def _start_one(interpreter, targets, base_dir):
"""Start a single dis_dump.py subprocess."""
env = os.environ.copy()
if interpreter != sys.executable:
env["RUSTPYTHONPATH"] = base_dir
files_file = tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", delete=False, dir=PROJECT_ROOT
)
try:
for _, path in targets:
files_file.write(path)
files_file.write("\n")
files_file.close()
cmd = [
interpreter,
DIS_DUMP,
"--base-dir",
base_dir,
"--files-from",
files_file.name,
"--progress",
"10",
]
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=None, # inherit stderr so progress dots appear on terminal
env=env,
cwd=PROJECT_ROOT,
)
return proc, files_file.name
except Exception:
os.unlink(files_file.name)
raise
def _finish_one(proc, files_file):
"""Wait for a single dis_dump.py process and return parsed JSON."""
try:
stdout = proc.communicate(timeout=600)[0]
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
print(" Timeout (600s)", file=sys.stderr)
os.unlink(files_file)
return {}
finally:
if os.path.exists(files_file):
os.unlink(files_file)
if proc.returncode != 0:
print(" Warning: exited with code %d" % proc.returncode, file=sys.stderr)
content = stdout.decode(errors="replace").strip()
if not content:
return {}
try:
return json.loads(content)
except json.JSONDecodeError as e:
print(" JSON parse error: %s" % e, file=sys.stderr)
return {}
def start_dump(interpreter, targets, base_dir, num_workers=1):
"""Start dis_dump.py under the given interpreter, split across workers."""
if num_workers <= 1 or len(targets) <= num_workers:
proc, ff = _start_one(interpreter, targets, base_dir)
return [(proc, ff)]
chunks = [[] for _ in range(num_workers)]
for i, t in enumerate(targets):
chunks[i % num_workers].append(t)
return [_start_one(interpreter, chunk, base_dir) for chunk in chunks if chunk]
def finish_dump(procs):
"""Wait for all dis_dump.py processes and merge results."""
merged = {}
for proc, files_file in procs:
merged.update(_finish_one(proc, files_file))
return merged
def compare_insts(cp_insts, rp_insts):
"""Compare two instruction lists. Returns list of (index, cp, rp) diffs."""
diffs = []
for i in range(max(len(cp_insts), len(rp_insts))):
cp = cp_insts[i] if i < len(cp_insts) else None
rp = rp_insts[i] if i < len(rp_insts) else None
if cp != rp:
diffs.append((i, cp, rp))
return diffs
def compare_code(cp_code, rp_code, path=""):
"""Recursively compare code objects. Returns [(code_path, diffs)]."""
results = []
name = cp_code["name"]
full = (path + "/" + name) if path else name
diffs = compare_insts(cp_code.get("insts", []), rp_code.get("insts", []))
if diffs:
results.append((full, diffs))
cp_ch = cp_code.get("children", [])
rp_ch = rp_code.get("children", [])
cp_by_name = defaultdict(list)
rp_by_name = defaultdict(list)
for c in cp_ch:
cp_by_name[c["name"]].append(c)
for c in rp_ch:
rp_by_name[c["name"]].append(c)
all_names = list(dict.fromkeys(c["name"] for c in cp_ch))
for c in rp_ch:
if c["name"] not in cp_by_name:
all_names.append(c["name"])
for name in all_names:
cp_list = cp_by_name.get(name, [])
rp_list = rp_by_name.get(name, [])
for i in range(max(len(cp_list), len(rp_list))):
if i < len(cp_list) and i < len(rp_list):
results.extend(compare_code(cp_list[i], rp_list[i], full))
elif i < len(cp_list):
results.append((full + "/" + name, [(-1, "extra in CPython", None)]))
else:
results.append((full + "/" + name, [(-1, None, "extra in RustPython")]))
return results
def compare_code_summary(cp_code, rp_code):
"""Recursively compare code objects and return summary counts."""
diff_code_objects = 0
diff_instructions = compare_insts_count(
cp_code.get("insts", []), rp_code.get("insts", [])
)
if diff_instructions:
diff_code_objects += 1
cp_ch = cp_code.get("children", [])
rp_ch = rp_code.get("children", [])
cp_by_name = defaultdict(list)
rp_by_name = defaultdict(list)
for child in cp_ch:
cp_by_name[child["name"]].append(child)
for child in rp_ch:
rp_by_name[child["name"]].append(child)
all_names = list(dict.fromkeys(child["name"] for child in cp_ch))
for child in rp_ch:
if child["name"] not in cp_by_name:
all_names.append(child["name"])
for name in all_names:
cp_list = cp_by_name.get(name, [])
rp_list = rp_by_name.get(name, [])
for i in range(max(len(cp_list), len(rp_list))):
if i < len(cp_list) and i < len(rp_list):
child_objects, child_insts = compare_code_summary(
cp_list[i], rp_list[i]
)
diff_code_objects += child_objects
diff_instructions += child_insts
else:
diff_code_objects += 1
diff_instructions += 1
return diff_code_objects, diff_instructions
def compare_insts_count(cp_insts, rp_insts):
"""Count mismatched instruction slots without storing the full diff."""
diff_count = 0
for i in range(max(len(cp_insts), len(rp_insts))):
cp = cp_insts[i] if i < len(cp_insts) else None
rp = rp_insts[i] if i < len(rp_insts) else None
if cp != rp:
diff_count += 1
return diff_count
def main():
parser = argparse.ArgumentParser(description="Compare CPython/RustPython bytecode")
parser.add_argument(
"--detail", action="store_true", help="Show per-file instruction differences"
)
parser.add_argument("--filter", default=None, help="Glob pattern to filter files")
parser.add_argument(
"--max-diffs",
type=int,
default=5,
help="Max diffs shown per code object (default: 5)",
)
parser.add_argument(
"--summary-json", default=None, help="Write summary as JSON to file"
)
parser.add_argument(
"--sample",
type=int,
default=None,
help="Compare a random sample of N matching files",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="Random seed used with --sample",
)
parser.add_argument(
"--list-limit",
type=int,
default=10,
help="Max differing files to print in non-detail mode (default: 10)",
)
parser.add_argument(
"--lib-dir",
default=os.path.join(PROJECT_ROOT, "Lib"),
help="Library directory to compare",
)
parser.add_argument(
"-j",
"--jobs",
type=int,
default=None,
help="Number of parallel workers per interpreter (default: cpu_count)",
)
parser.add_argument(
"-o",
"--output",
default=DEFAULT_REPORT,
help="Report output file (default: compare_bytecode.report)",
)
args = parser.parse_args()
try:
rp_bin = find_rustpython()
except ValueError as exc:
print("Error: %s" % exc, file=sys.stderr)
sys.exit(1)
if not rp_bin:
print("Error: RustPython binary not found.", file=sys.stderr)
print(" Build with: cargo build --release", file=sys.stderr)
print(" Or set RUSTPYTHON=/path/to/binary", file=sys.stderr)
sys.exit(1)
if not os.path.isfile(DIS_DUMP):
print("Error: disassembler helper not found: %s" % DIS_DUMP, file=sys.stderr)
print(
" Expected scripts/dis_dump.py from origin/bytecode-parity",
file=sys.stderr,
)
sys.exit(1)
targets = collect_targets(args.lib_dir, args.filter)
sample_seed = None
if args.sample is not None:
if args.sample < 1:
print("Error: --sample must be >= 1", file=sys.stderr)
sys.exit(1)
sample_seed = (
args.seed
if args.seed is not None
else random.SystemRandom().randrange(2**32)
)
rng = random.Random(sample_seed)
sample_size = min(args.sample, len(targets))
targets = sorted(rng.sample(targets, sample_size), key=lambda item: item[0])
if not targets:
print("Error: no Python files matched", file=sys.stderr)
sys.exit(1)
report_path = args.output
log = lambda *a, **kw: print(*a, file=sys.stderr, **kw)
log("Report: %s" % os.path.relpath(report_path, PROJECT_ROOT))
log("Targets: %d file(s)" % len(targets))
num_workers = args.jobs if args.jobs else os.cpu_count() or 4
log("Workers: %d per interpreter" % num_workers)
sys.stderr.write("Dumping bytecode ")
sys.stderr.flush()
cp_procs = start_dump(sys.executable, targets, args.lib_dir, num_workers)
rp_procs = start_dump(rp_bin, targets, args.lib_dir, num_workers)
cp_data = finish_dump(cp_procs)
rp_data = finish_dump(rp_procs)
sys.stderr.write("\n")
if not cp_data:
log("Error: CPython dump produced no data")
sys.exit(1)
# Phase 2: Compare
all_files = sorted(set(cp_data) | set(rp_data))
match = 0
differ = 0
cp_err = 0
rp_err = 0
both_err = 0
rp_miss = 0
diff_files = []
rp_error_files = []
diff_summaries = []
need_detailed_diffs = args.detail
for fp in all_files:
cp = cp_data.get(fp)
rp = rp_data.get(fp)
if rp is None:
rp_miss += 1
continue
cp_ok = cp and cp.get("status") == "ok"
rp_ok = rp and rp.get("status") == "ok"
if not cp_ok and not rp_ok:
both_err += 1
elif not cp_ok:
cp_err += 1
elif not rp_ok:
rp_err += 1
rp_error_files.append((fp, rp.get("error", "?")))
else:
if need_detailed_diffs:
code_diffs = compare_code(cp["code"], rp["code"])
if code_diffs:
differ += 1
diff_files.append((fp, code_diffs))
else:
match += 1
else:
diff_code_objects, diff_instructions = compare_code_summary(
cp["code"], rp["code"]
)
if diff_code_objects:
differ += 1
diff_summaries.append(
{
"path": fp,
"diff_code_objects": diff_code_objects,
"diff_instructions": diff_instructions,
}
)
else:
match += 1
total = match + differ + cp_err + rp_err + both_err + rp_miss
def pct(n):
return "%.1f%%" % (100.0 * n / total) if total else "0%"
# Phase 3: Write report to file
with open(report_path, "w") as out:
p = lambda *a: print(*a, file=out)
p("CPython: %s (%s)" % (sys.executable, sys.version.split()[0]))
p("RustPython: %s" % rp_bin)
p("Lib: %s" % args.lib_dir)
if sample_seed is not None:
p("Sample: %s file(s), seed=%s" % (len(targets), sample_seed))
p()
p("=" * 60)
p(" Bytecode Comparison Report")
p("=" * 60)
p()
p(" Total files: %6d" % total)
p(" Match: %6d (%s)" % (match, pct(match)))
p(" Differ: %6d (%s)" % (differ, pct(differ)))
p(" RustPython error: %6d (%s)" % (rp_err, pct(rp_err)))
p(" CPython error: %6d (%s)" % (cp_err, pct(cp_err)))
p(" Both error: %6d (%s)" % (both_err, pct(both_err)))
if rp_miss:
p(" RustPython missing: %6d (%s)" % (rp_miss, pct(rp_miss)))
p()
if args.detail:
if rp_error_files:
p("-" * 60)
p(" RustPython Compile Errors")
p("-" * 60)
for fp, err in rp_error_files[:50]:
p(" %s: %s" % (fp, err))
if len(rp_error_files) > 50:
p(" ... and %d more" % (len(rp_error_files) - 50))
p()
if diff_files:
p("-" * 60)
p(" Bytecode Differences")
p("-" * 60)
for fp, code_diffs in diff_files:
p()
p(" %s:" % fp)
for code_path, diffs in code_diffs:
shown = min(len(diffs), args.max_diffs)
p(" %s: %d difference(s)" % (code_path, len(diffs)))
for idx, cp_inst, rp_inst in diffs[:shown]:
if idx == -1:
p(" %s" % (cp_inst or rp_inst))
else:
p(" [%3d] CPython: %s" % (idx, cp_inst))
p(" RustPython: %s" % rp_inst)
if len(diffs) > shown:
p(" ... and %d more" % (len(diffs) - shown))
p()
else:
list_limit = 0 if args.summary_json else max(args.list_limit, 0)
if diff_summaries and list_limit:
shown = min(list_limit, len(diff_summaries))
total = len(diff_summaries)
p(f"Top differing files ({shown} shown of {total}):")
top = sorted(
diff_summaries,
key=lambda item: (
item["diff_instructions"],
item["diff_code_objects"],
item["path"],
),
reverse=True,
)[:list_limit]
for item in top:
p(
" %s (%d code objects, %d instruction diffs)"
% (
item["path"],
item["diff_code_objects"],
item["diff_instructions"],
)
)
p()
p("Use --detail to see specific instruction differences.")
p()
# Summary JSON output
if args.summary_json:
summary = {
"total": total,
"sample": args.sample,
"sample_seed": sample_seed,
"match": match,
"differ": differ,
"rp_error": rp_err,
"cp_error": cp_err,
"both_error": both_err,
"rp_missing": rp_miss,
"match_pct": round(100.0 * match / total, 2) if total else 0,
"diff_files": [fp for fp, _ in diff_files]
if need_detailed_diffs
else [item["path"] for item in diff_summaries],
"top_diff_files": sorted(
diff_summaries,
key=lambda item: (
item["diff_instructions"],
item["diff_code_objects"],
item["path"],
),
reverse=True,
)[: min(20, len(diff_summaries))],
"rp_error_files": [fp for fp, _ in rp_error_files],
}
with open(args.summary_json, "w") as f:
json.dump(summary, f, indent=2)
log("Summary JSON: %s" % args.summary_json)
log("Done: %d match, %d differ, %d errors" % (match, differ, rp_err))
sys.exit(0 if differ == 0 and rp_err == 0 else 1)
if __name__ == "__main__":
main()
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""Dump normalized bytecode for Python source files as JSON.
Designed to produce comparable output across different Python implementations.
Normalizes away implementation-specific details (byte offsets, memory addresses)
while preserving semantic instruction content.
Usage:
python dis_dump.py Lib/
python dis_dump.py --base-dir Lib path/to/file.py
"""
import argparse
import dis
import json
import os
import re
import sys
import types
# Non-semantic filler instructions to skip
SKIP_OPS = frozenset({"CACHE", "PRECALL", "EXTENDED_ARG"})
# Opname normalization: map variant instructions to their base form.
# These variants differ only in optimization hints, not semantics.
_OPNAME_NORMALIZE = {
"LOAD_FAST_BORROW": "LOAD_FAST",
"LOAD_FAST_BORROW_LOAD_FAST_BORROW": "LOAD_FAST_LOAD_FAST",
"LOAD_FAST_CHECK": "LOAD_FAST",
"JUMP_BACKWARD_NO_INTERRUPT": "JUMP_BACKWARD",
"POP_ITER": "POP_TOP",
# Superinstruction normalization: these get decomposed in _extract_instructions
"STORE_FAST_LOAD_FAST_BORROW": "STORE_FAST_LOAD_FAST",
}
# Jump instruction names (fallback when hasjrel/hasjabs is incomplete)
_JUMP_OPNAMES = frozenset(
{
"JUMP",
"JUMP_FORWARD",
"JUMP_BACKWARD",
"JUMP_BACKWARD_NO_INTERRUPT",
"POP_JUMP_IF_TRUE",
"POP_JUMP_IF_FALSE",
"POP_JUMP_IF_NONE",
"POP_JUMP_IF_NOT_NONE",
"JUMP_IF_TRUE_OR_POP",
"JUMP_IF_FALSE_OR_POP",
"FOR_ITER",
"SEND",
}
)
_JUMP_OPCODES = None
def _jump_opcodes():
global _JUMP_OPCODES
if _JUMP_OPCODES is None:
_JUMP_OPCODES = set()
if hasattr(dis, "hasjrel"):
_JUMP_OPCODES.update(dis.hasjrel)
if hasattr(dis, "hasjabs"):
_JUMP_OPCODES.update(dis.hasjabs)
return _JUMP_OPCODES
def _is_jump(inst):
"""Check if an instruction is a jump (by opcode set or name)."""
return inst.opcode in _jump_opcodes() or inst.opname in _JUMP_OPNAMES
def _normalize_argrepr(argrepr):
"""Strip runtime-specific details from arg repr."""
if argrepr.startswith("<code object "):
# Extract just the name, stripping address and file/line info.
# Formats seen across interpreters:
# <code object foo at 0xADDR, file "x.py", line 1> (CPython 3.14)
# <code object foo at 0xADDR> (RustPython)
name = argrepr[len("<code object ") :]
for marker in (" at 0x", ", file ", " file "):
idx = name.find(marker)
if idx >= 0:
name = name[:idx]
return "<code object %s>" % name.rstrip(">").strip()
# Normalize COMPARE_OP: strip bool(...) wrapper from CPython 3.14
# e.g. "bool(==)" -> "==", "bool(<)" -> "<"
m = re.match(r"^bool\((.+)\)$", argrepr)
if m:
return m.group(1)
# Remove memory addresses from other reprs
argrepr = re.sub(r" at 0x[0-9a-fA-F]+", "", argrepr)
# Remove LOAD_ATTR/LOAD_SUPER_ATTR suffixes: " + NULL|self", " + NULL"
argrepr = re.sub(r" \+ NULL\|self$", "", argrepr)
argrepr = re.sub(r" \+ NULL$", "", argrepr)
# Normalize unicode escapes
def _unescape(m):
try:
cp = int(m.group(1), 16)
if 0xD800 <= cp <= 0xDFFF:
return m.group(0)
return chr(cp)
except (ValueError, OverflowError):
return m.group(0)
argrepr = re.sub(r"\\u([0-9a-fA-F]{4})", _unescape, argrepr)
argrepr = re.sub(r"\\U([0-9a-fA-F]{8})", _unescape, argrepr)
return argrepr
_IS_RUSTPYTHON = (
hasattr(sys, "implementation") and sys.implementation.name == "rustpython"
)
# RustPython's ComparisonOperator enum values → operator strings
_RP_CMP_OPS = {0: "<", 1: "<=", 2: "==", 3: "!=", 4: ">", 5: ">="}
def _resolve_arg_fallback(code, opname, arg):
"""Resolve a raw argument to its human-readable form.
Used when the dis module doesn't populate argrepr (e.g., on RustPython).
"""
if not isinstance(arg, int):
return arg
try:
if "FAST" in opname:
if 0 <= arg < len(code.co_varnames):
return code.co_varnames[arg]
elif opname == "LOAD_CONST":
if 0 <= arg < len(code.co_consts):
return _normalize_argrepr(repr(code.co_consts[arg]))
elif opname in (
"LOAD_DEREF",
"STORE_DEREF",
"DELETE_DEREF",
"LOAD_CLOSURE",
"MAKE_CELL",
):
# arg is localsplus index:
# 0..nlocals-1 = varnames (parameter cells reuse these slots)
# nlocals.. = non-parameter cells + freevars
nlocals = len(code.co_varnames)
if arg < nlocals:
return code.co_varnames[arg]
varnames_set = set(code.co_varnames)
nonparam_cells = [v for v in code.co_cellvars if v not in varnames_set]
extra = nonparam_cells + list(code.co_freevars)
idx = arg - nlocals
if 0 <= idx < len(extra):
return extra[idx]
elif opname in (
"LOAD_NAME",
"STORE_NAME",
"DELETE_NAME",
"LOAD_GLOBAL",
"STORE_GLOBAL",
"DELETE_GLOBAL",
"LOAD_ATTR",
"STORE_ATTR",
"DELETE_ATTR",
"IMPORT_NAME",
"IMPORT_FROM",
"LOAD_FROM_DICT_OR_GLOBALS",
):
if 0 <= arg < len(code.co_names):
return code.co_names[arg]
elif opname == "LOAD_SUPER_ATTR":
name_idx = arg >> 2
if 0 <= name_idx < len(code.co_names):
return code.co_names[name_idx]
except Exception:
pass
return arg
def _extract_instructions(code):
"""Extract normalized instruction list from a code object.
- Filters out CACHE/PRECALL instructions
- Converts jump targets from byte offsets to instruction indices
- Resolves argument names via fallback when argrepr is missing
- Normalizes argument representations
"""
try:
raw = list(dis.get_instructions(code))
except Exception as e:
return [["ERROR", str(e)]]
# Build filtered list and offset-to-index mapping
filtered = []
offset_to_idx = {}
for inst in raw:
if inst.opname in SKIP_OPS:
continue
offset_to_idx[inst.offset] = len(filtered)
filtered.append(inst)
# Map offsets that land on CACHE slots to the next real instruction
for inst in raw:
if inst.offset not in offset_to_idx:
for fi, finst in enumerate(filtered):
if finst.offset >= inst.offset:
offset_to_idx[inst.offset] = fi
break
# Superinstruction decomposition: split into constituent parts
# so we compare individual operations regardless of combining.
_SUPER_DECOMPOSE = {
"STORE_FAST_LOAD_FAST": ("STORE_FAST", "LOAD_FAST"),
"STORE_FAST_STORE_FAST": ("STORE_FAST", "STORE_FAST"),
"LOAD_FAST_LOAD_FAST": ("LOAD_FAST", "LOAD_FAST"),
}
result = []
for inst in filtered:
opname = _OPNAME_NORMALIZE.get(inst.opname, inst.opname)
# Decompose superinstructions into individual ops
if opname in _SUPER_DECOMPOSE:
op1, op2 = _SUPER_DECOMPOSE[opname]
if isinstance(inst.arg, int):
idx1 = (inst.arg >> 4) & 0xF
idx2 = inst.arg & 0xF
else:
idx1, idx2 = 0, 0
name1 = _resolve_arg_fallback(code, op1, idx1)
name2 = _resolve_arg_fallback(code, op2, idx2)
result.append([op1, name1])
result.append([op2, name2])
continue
if _is_jump(inst) and isinstance(inst.argval, int):
target_idx = offset_to_idx.get(inst.argval)
# Detect unresolved argval (RustPython may not resolve jump targets):
# 1. argval not in offset_to_idx (not a valid byte offset)
# 2. argval == arg (raw arg returned as-is, not resolved to offset)
# 3. For backward jumps: argval should be < current offset
is_backward = "BACKWARD" in inst.opname
argval_is_raw = inst.argval == inst.arg and inst.arg is not None
if target_idx is None or argval_is_raw:
target_idx = None # force recalculation
if is_backward:
# Target = current_offset + INSTR_SIZE + cache
# - arg * INSTR_SIZE
# Try different cache sizes (NOT_TAKEN=1 for JUMP_BACKWARD, 0 for NO_INTERRUPT)
if "NO_INTERRUPT" in inst.opname:
cache_order = (0, 1, 2)
else:
cache_order = (1, 0, 2, 3)
for cache in cache_order:
target_off = inst.offset + 2 + cache * 2 - inst.arg * 2
if target_off >= 0 and target_off in offset_to_idx:
target_idx = offset_to_idx[target_off]
break
elif inst.arg is not None:
# Forward jumps: compute target offset using cache entry count.
# POP_JUMP_IF_* have 1 cache entry (NOT_TAKEN), others have 0.
if "POP_JUMP_IF" in inst.opname:
cache_order = (1, 0, 2)
elif inst.opname == "FOR_ITER":
cache_order = (0, 1, 2)
elif inst.opname == "SEND":
cache_order = (1, 0, 2)
else:
cache_order = (0, 1, 2)
for extra in cache_order:
target_off = inst.offset + 2 + extra * 2 + inst.arg * 2
if target_off in offset_to_idx:
target_idx = offset_to_idx[target_off]
break
if target_idx is None:
target_idx = inst.argval
result.append([opname, "->%d" % target_idx])
elif inst.opname == "COMPARE_OP":
# Normalize COMPARE_OP across interpreters (different encodings)
if _IS_RUSTPYTHON:
cmp_str = _RP_CMP_OPS.get(inst.arg, inst.argrepr)
else:
cmp_str = (
_normalize_argrepr(inst.argrepr) if inst.argrepr else str(inst.arg)
)
result.append([opname, cmp_str])
elif inst.arg is not None and inst.argrepr:
# If argrepr is just a number, try to resolve it via fallback
# (RustPython may return raw index instead of variable name)
argrepr = inst.argrepr
if argrepr.isdigit() or (argrepr.startswith("-") and argrepr[1:].isdigit()):
resolved = _resolve_arg_fallback(code, opname, inst.arg)
if isinstance(resolved, str) and not resolved.isdigit():
argrepr = resolved
result.append([opname, _normalize_argrepr(argrepr)])
elif inst.arg is not None:
resolved = _resolve_arg_fallback(code, opname, inst.arg)
result.append([opname, resolved])
else:
result.append([opname])
return result
def _dump_code(code):
"""Recursively dump a code object and its nested code objects."""
name = getattr(code, "co_qualname", None) or code.co_name
children = [_dump_code(c) for c in code.co_consts if isinstance(c, types.CodeType)]
r = {"name": name, "insts": _extract_instructions(code)}
if children:
r["children"] = children
return r
def process_file(path):
"""Compile a single file and return its bytecode dump."""
try:
with open(path, "rb") as f:
source = f.read()
code = compile(source, path, "exec")
return {"status": "ok", "code": _dump_code(code)}
except SyntaxError as e:
return {"status": "error", "error": "%s (line %s)" % (e.msg, e.lineno)}
except Exception as e:
return {"status": "error", "error": str(e)}
def main():
parser = argparse.ArgumentParser(description="Dump normalized bytecode as JSON")
parser.add_argument(
"--base-dir",
default=None,
help="Base directory used to compute relative output paths",
)
parser.add_argument(
"--files-from",
default=None,
help="Read newline-separated target paths from this file",
)
parser.add_argument(
"targets", nargs="*", help="Python files or directories to process"
)
parser.add_argument(
"--progress",
type=int,
default=0,
help="Print a dot to stderr every N files processed",
)
args = parser.parse_args()
targets = list(args.targets)
if args.files_from:
with open(args.files_from, encoding="utf-8") as f:
targets.extend(line.strip() for line in f if line.strip())
results = {}
count = 0
for target in targets:
if os.path.isdir(target):
for root, dirs, files in os.walk(target):
dirs[:] = sorted(
d for d in dirs if d != "__pycache__" and not d.startswith(".")
)
for fname in sorted(files):
if fname.endswith(".py"):
fpath = os.path.join(root, fname)
rel_base = args.base_dir or target
relpath = os.path.relpath(fpath, rel_base)
results[relpath] = process_file(fpath)
count += 1
if args.progress and count % args.progress == 0:
sys.stderr.write(".")
sys.stderr.flush()
elif target.endswith(".py"):
rel_base = args.base_dir or os.path.dirname(target) or "."
relpath = os.path.relpath(target, rel_base)
results[relpath] = process_file(target)
count += 1
if args.progress and count % args.progress == 0:
sys.stderr.write(".")
sys.stderr.flush()
json.dump(results, sys.stdout, ensure_ascii=False, separators=(",", ":"))
if __name__ == "__main__":
main()