Files
RustPython/scripts/update_lib/cmd_copy_test.py
T
dot c6ddfae40d Preserve decorator rationale during canonical test copying
Reject differing decorator source blocks even when their ASTs match. Cover inline and multiline marker comments, guarded async methods, and CLI no-write behavior.

Assisted-by: OpenAI Codex:model identifier unavailable
2026-10-12 04:02:02 +09:00

135 lines
5.6 KiB
Python

"""Copy one complete canonical test definition without refreshing its module."""
import argparse
import ast
import hashlib
import json
from pathlib import Path
from update_lib.patch_spec import _iter_class_methods, _iter_module_classes
def _definition(contents: str, qualified_name: str):
parts = qualified_name.split(".")
if len(parts) != 2 or not all(part.isidentifier() for part in parts):
raise ValueError("test name must be ClassName.method_name")
tree = ast.parse(contents)
classes = [node for node in _iter_module_classes(tree) if node.name == parts[0]]
if len(classes) != 1:
raise ValueError(f"Expected one class named {parts[0]}, found {len(classes)}")
methods = [
node for node in _iter_class_methods(classes[0]) if node.name == parts[1]
]
if len(methods) != 1:
raise ValueError(
f"Expected one method named {qualified_name}, found {len(methods)}"
)
method = methods[0]
parents = {
child: parent
for parent in ast.walk(tree)
for child in ast.iter_child_nodes(parent)
}
guards = []
child = method
while child in parents:
parent = parents[child]
if isinstance(parent, ast.If):
branch = "body" if child in parent.body else "orelse"
guards.append((ast.dump(parent.test), branch))
elif not isinstance(parent, (ast.Module, ast.ClassDef)):
raise ValueError("Only module/class scopes and if guards are supported")
child = parent
start = min([method.lineno, *(node.lineno for node in method.decorator_list)]) - 1
return method, guards, start, method.end_lineno
def copy_test(source: str, destination: str, qualified_name: str) -> str:
"""Replace an unambiguous whole definition, preserving all surrounding bytes.
Guard, signature, decorator and indentation differences require a broader
migration rather than silently changing the selected test's execution context.
"""
src, src_guards, src_start, src_end = _definition(source, qualified_name)
dst, dst_guards, dst_start, dst_end = _definition(destination, qualified_name)
if src_guards != dst_guards:
raise ValueError("Source and destination test guards differ")
def signature(node):
return (
ast.dump(node.args),
ast.dump(node.returns) if node.returns is not None else None,
[ast.dump(param) for param in getattr(node, "type_params", [])],
)
if type(src) is not type(dst) or signature(src) != signature(dst):
raise ValueError("Source and destination test signatures differ")
if [ast.dump(node) for node in src.decorator_list] != [
ast.dump(node) for node in dst.decorator_list
]:
raise ValueError(
"Source and destination decorators differ; preserve markers explicitly"
)
if src.col_offset != dst.col_offset:
raise ValueError("Source and destination indentation differs")
source_lines = source.splitlines(keepends=True)
destination_lines = destination.splitlines(keepends=True)
# AST equality omits comments, including project marker rationales. Require
# the complete decorator block to match before replacing the definition.
if (
source_lines[src_start : src.lineno - 1]
!= destination_lines[dst_start : dst.lineno - 1]
):
raise ValueError(
"Source and destination decorator text differs; preserve markers explicitly"
)
definition = source_lines[src_start:src_end]
result = "".join(
destination_lines[:dst_start] + definition + destination_lines[dst_end:]
)
_, _, result_start, result_end = _definition(result, qualified_name)
if result.splitlines(keepends=True)[result_start:result_end] != definition:
raise ValueError("Copied definition is not byte-identical to the source")
return result
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path)
parser.add_argument("destination", type=Path)
parser.add_argument("--test", required=True, help="Exact ClassName.method_name")
parser.add_argument("--source-sha256", required=True)
parser.add_argument("--destination-sha256", required=True)
args = parser.parse_args(argv)
try:
source = args.source.read_bytes()
destination = args.destination.read_bytes()
for label, contents, expected in (
("source", source, args.source_sha256),
("destination", destination, args.destination_sha256),
):
if hashlib.sha256(contents).hexdigest() != expected:
raise ValueError(f"{label} SHA256 does not match the pinned input")
result = copy_test(
source.decode("utf-8"), destination.decode("utf-8"), args.test
).encode("utf-8")
# Recheck the destination immediately before writing so stale invocations
# fail instead of overwriting an intervening edit.
if args.destination.read_bytes() != destination:
raise ValueError("Destination changed during test preparation")
args.destination.write_bytes(result)
except (OSError, UnicodeError, SyntaxError, ValueError) as exc:
parser.exit(1, f"Error: {exc}\n")
print(
json.dumps(
{
"test": args.test,
"source_sha256": args.source_sha256,
"destination_before_sha256": args.destination_sha256,
"destination_after_sha256": hashlib.sha256(result).hexdigest(),
},
sort_keys=True,
)
)
return 0