mirror of
https://github.com/RustPython/RustPython.git
synced 2026-10-28 10:44:36 +08:00
Preserve explicit customizations during canonical library migration (#9142)
* Preserve explicit customizations during canonical library migration Add bounded runtime, call, and doctest preservation plus explicit remaining-marker metadata. Support mapped legacy test-package migrations without losing ordered guards, and exclude cache directories from both migration paths while retaining canonical assets. Validation: 53 bounded host-Python tooling tests passed. Tests inspect source and temporary files without executing migrated fixtures. Existing copy-library cache protection is unchanged. No Lib or interpreter changes. Assisted-by: OpenAI Codex:model version unavailable * Integrate migration preservation into existing updater modules Keep migration orchestration and guarded package moves in cmd_migrate, and marker/doctest metadata in patch_spec. Reuse AST anchor validation and existing test modules, removing all eleven newly introduced helper, test, and data files. Require caller-selected doctest metadata instead of embedding syntax-case policy. Reject guarded legacy doctests before package writes. Preserve the prior 53 test scenarios and add seven focused metadata regressions; all 60 bounded host tooling tests passed. No Lib, workflow, or native changes. Assisted-by: OpenAI Codex:model version unavailable
This commit is contained in:
1 parent
0384ddcd70
commit
9e18d7be6b
4 files changed
+1312
-8
No files matched your search
@@ -4,6 +4,13 @@ Migrate test file(s) from CPython, preserving RustPython markers.
|
||||
|
||||
Usage:
|
||||
python scripts/update_lib migrate cpython/Lib/test/test_foo.py
|
||||
python scripts/update_lib migrate cpython/Lib/test/test_foo.py --patches remaining.json
|
||||
|
||||
Remaining metadata names the exact repository-relative target and its existing
|
||||
decorator patches. An optional "doctests" object selects existing guarded inputs
|
||||
by exact source text, mapping each to a single-line reason. Guarded doctests need
|
||||
this explicit selection; an empty object retires all their guards. Canonical
|
||||
expected output is never copied from the old file.
|
||||
|
||||
This will:
|
||||
1. Extract patches from Lib/test/test_foo.py (if exists)
|
||||
@@ -12,6 +19,9 @@ This will:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import collections
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
@@ -20,10 +30,166 @@ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||
|
||||
from update_lib.file_utils import parse_lib_path
|
||||
|
||||
SYSCONFIG_UPSTREAM = "def _get_implementation():\n return 'Python'\n"
|
||||
SYSCONFIG_LOCAL = (
|
||||
"def _get_implementation():\n"
|
||||
" return 'RustPython' # XXX: RUSTPYTHON; For site-packages\n"
|
||||
)
|
||||
JIT_ERROR = """# NOTE: RUSTPYTHON exceptions
|
||||
try:
|
||||
JitError
|
||||
except NameError:
|
||||
pass
|
||||
else:
|
||||
PYTHON2_EXCEPTIONS += ("JitError",)
|
||||
|
||||
"""
|
||||
EXCEPTION_LOOP = """for excname in PYTHON2_EXCEPTIONS:
|
||||
NAME_MAPPING[("exceptions", excname)] = ("builtins", excname)
|
||||
"""
|
||||
|
||||
|
||||
def _unique_anchor(contents, root, predicate, *, direct=False, exact=None):
|
||||
"""Select one real AST anchor, optionally in the immediate owner body."""
|
||||
matches = [node for node in ast.walk(root) if predicate(node)]
|
||||
if (
|
||||
len(matches) != 1
|
||||
or (direct and matches[0] not in root.body)
|
||||
or (exact is not None and ast.get_source_segment(contents, matches[0]) != exact)
|
||||
):
|
||||
raise ValueError("Missing, changed, or ambiguous migration anchor")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _unique_function(contents: str, expected: str) -> bool:
|
||||
tree = ast.parse(contents)
|
||||
target = ast.parse(expected).body[0]
|
||||
node = _unique_anchor(
|
||||
contents,
|
||||
tree,
|
||||
lambda node: (
|
||||
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == target.name
|
||||
),
|
||||
direct=True,
|
||||
)
|
||||
return ast.dump(node) == ast.dump(target) and not any(
|
||||
(
|
||||
isinstance(node, ast.Name)
|
||||
and node.id == target.name
|
||||
and isinstance(node.ctx, (ast.Store, ast.Del))
|
||||
)
|
||||
or (isinstance(node, ast.ClassDef) and node.name == target.name)
|
||||
or (
|
||||
isinstance(node, ast.alias)
|
||||
and (node.asname or node.name.split(".")[0]) == target.name
|
||||
)
|
||||
for node in ast.walk(tree)
|
||||
)
|
||||
|
||||
|
||||
def _exception_loop(contents: str) -> bool:
|
||||
_unique_anchor(
|
||||
contents,
|
||||
ast.parse(contents),
|
||||
lambda node: (
|
||||
isinstance(node, ast.For)
|
||||
and isinstance(node.iter, ast.Name)
|
||||
and node.iter.id == "PYTHON2_EXCEPTIONS"
|
||||
),
|
||||
direct=True,
|
||||
exact=EXCEPTION_LOOP.rstrip(),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _jit_block(contents: str) -> bool:
|
||||
tree = ast.parse(contents)
|
||||
expected = ast.dump(ast.parse(JIT_ERROR).body[0])
|
||||
_unique_anchor(contents, tree, lambda node: ast.dump(node) == expected, direct=True)
|
||||
return (
|
||||
sum(
|
||||
isinstance(node, ast.Name) and node.id == "JitError"
|
||||
for node in ast.walk(tree)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def preserve_migration_customizations(path: pathlib.Path, old: str, new: str) -> str:
|
||||
"""Fail closed if either known local patch or its canonical anchor changes."""
|
||||
parts = path.parts
|
||||
if path.is_absolute():
|
||||
raise ValueError("Runtime patch target must be repository-relative")
|
||||
if parts == ("Lib", "sysconfig", "__init__.py"):
|
||||
if old.count(SYSCONFIG_LOCAL) != 1 or not _unique_function(
|
||||
old, SYSCONFIG_LOCAL
|
||||
):
|
||||
raise ValueError("Unrecognized sysconfig implementation identity")
|
||||
if (
|
||||
new.count(SYSCONFIG_UPSTREAM) != 1
|
||||
or not _unique_function(new, SYSCONFIG_UPSTREAM)
|
||||
or "RustPython" in new
|
||||
):
|
||||
raise ValueError("Unrecognized canonical sysconfig implementation anchor")
|
||||
return new.replace(SYSCONFIG_UPSTREAM, SYSCONFIG_LOCAL, 1)
|
||||
if parts == ("Lib", "_compat_pickle.py"):
|
||||
if (
|
||||
old.count(JIT_ERROR + EXCEPTION_LOOP) != 1
|
||||
or not _jit_block(old)
|
||||
or not _exception_loop(old)
|
||||
):
|
||||
raise ValueError("Unrecognized local JitError compatibility mapping")
|
||||
if (
|
||||
new.count(EXCEPTION_LOOP) != 1
|
||||
or not _exception_loop(new)
|
||||
or "JitError" in new
|
||||
):
|
||||
raise ValueError("Unrecognized canonical exception mapping anchor")
|
||||
return new.replace(EXCEPTION_LOOP, JIT_ERROR + EXCEPTION_LOOP, 1)
|
||||
if parts == ("Lib", "test", "test_cmd_line.py"):
|
||||
_verify_call(old, LOCAL_CALL)
|
||||
_verify_call(new, CANONICAL_CALL)
|
||||
return new.replace(CANONICAL_CALL, LOCAL_CALL, 1)
|
||||
return new
|
||||
|
||||
|
||||
CANONICAL_CALL = """assert_python_ok('-c', code, PYTHONPATH="")"""
|
||||
LOCAL_CALL = """assert_python_ok('-c', code, PYTHONPATH="", RUSTPYTHONPATH="")"""
|
||||
|
||||
|
||||
def _verify_call(contents: str, expected: str) -> None:
|
||||
tree = ast.parse(contents)
|
||||
owner = _unique_anchor(
|
||||
contents,
|
||||
tree,
|
||||
lambda node: isinstance(node, ast.ClassDef) and node.name == "CmdLineTest",
|
||||
direct=True,
|
||||
)
|
||||
method = _unique_anchor(
|
||||
contents,
|
||||
owner,
|
||||
lambda node: (
|
||||
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == "test_empty_PYTHONPATH_issue16309"
|
||||
),
|
||||
direct=True,
|
||||
)
|
||||
target = ast.dump(ast.parse(expected, mode="eval").body)
|
||||
_unique_anchor(
|
||||
contents,
|
||||
method,
|
||||
lambda node: isinstance(node, ast.Call) and ast.dump(node) == target,
|
||||
exact=expected,
|
||||
)
|
||||
if contents.count(expected) != 1:
|
||||
raise ValueError("Ambiguous environment-reset call text")
|
||||
|
||||
|
||||
def patch_single_content(
|
||||
src_path: pathlib.Path,
|
||||
lib_path: pathlib.Path,
|
||||
patches_document: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Patch content without writing to disk.
|
||||
@@ -36,15 +202,40 @@ def patch_single_content(
|
||||
The patched content.
|
||||
"""
|
||||
from update_lib import apply_patches, extract_patches
|
||||
from update_lib.patch_spec import apply_doctest_metadata, extract_doctest_guards
|
||||
|
||||
# Extract patches from existing file (if exists)
|
||||
if lib_path.exists():
|
||||
patches = extract_patches(lib_path.read_text(encoding="utf-8"))
|
||||
old_content = lib_path.read_text(encoding="utf-8")
|
||||
patches = extract_patches(old_content)
|
||||
else:
|
||||
patches = {}
|
||||
|
||||
repository = pathlib.Path(__file__).resolve().parents[2]
|
||||
try:
|
||||
relative_path = lib_path.resolve().relative_to(repository)
|
||||
except ValueError:
|
||||
# Explicit output paths outside this repository are not its Lib.
|
||||
relative_path = pathlib.Path("__external_output__")
|
||||
if patches_document is not None:
|
||||
from update_lib.patch_spec import select_remaining_patches
|
||||
|
||||
patches = select_remaining_patches(patches, patches_document, relative_path)
|
||||
|
||||
# Apply patches to source content
|
||||
src_content = src_path.read_text(encoding="utf-8")
|
||||
if lib_path.exists():
|
||||
src_content = preserve_migration_customizations(
|
||||
relative_path, old_content, src_content
|
||||
)
|
||||
guards = extract_doctest_guards(old_content)
|
||||
selection = patches_document.get("doctests") if patches_document else None
|
||||
if guards and selection is None:
|
||||
raise ValueError(
|
||||
"Existing doctest guards require explicit doctests metadata"
|
||||
)
|
||||
if selection is not None:
|
||||
src_content = apply_doctest_metadata(old_content, src_content, selection)
|
||||
return apply_patches(src_content, patches)
|
||||
|
||||
|
||||
@@ -52,6 +243,7 @@ def patch_file(
|
||||
src_path: pathlib.Path,
|
||||
lib_path: pathlib.Path | None = None,
|
||||
verbose: bool = True,
|
||||
patches_document: dict | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Patch a single file from source to lib.
|
||||
@@ -67,10 +259,12 @@ def patch_file(
|
||||
if lib_path.exists():
|
||||
if verbose:
|
||||
print(f"Patching: {src_path} -> {lib_path}")
|
||||
content = patch_single_content(src_path, lib_path)
|
||||
content = patch_single_content(src_path, lib_path, patches_document)
|
||||
else:
|
||||
if verbose:
|
||||
print(f"Copying: {src_path} -> {lib_path}")
|
||||
if patches_document is not None:
|
||||
raise ValueError("Marker override requires an existing target")
|
||||
content = src_path.read_text(encoding="utf-8")
|
||||
|
||||
lib_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -93,7 +287,11 @@ def patch_directory(
|
||||
if lib_dir is None:
|
||||
lib_dir = parse_lib_path(src_dir)
|
||||
|
||||
src_files = sorted(f for f in src_dir.glob("**/*") if f.is_file())
|
||||
src_files = sorted(
|
||||
f
|
||||
for f in src_dir.glob("**/*")
|
||||
if f.is_file() and "__pycache__" not in f.relative_to(src_dir).parts[:-1]
|
||||
)
|
||||
|
||||
for src_file in src_files:
|
||||
rel_path = src_file.relative_to(src_dir)
|
||||
@@ -118,6 +316,153 @@ def patch_directory(
|
||||
shutil.copy2(src_file, lib_file)
|
||||
|
||||
|
||||
def migrate_package(
|
||||
src_dir: pathlib.Path, lib_dir: pathlib.Path, mapping_path: pathlib.Path
|
||||
) -> None:
|
||||
"""Validate the complete migration before writing or removing legacy files.
|
||||
|
||||
The JSON map names legacy files beside lib_dir, mapping every marked class
|
||||
to its canonical file relative to src_dir. Empty maps retire unmarked files.
|
||||
No test modules are imported or executed.
|
||||
"""
|
||||
from update_lib.patch_spec import (
|
||||
_iter_class_methods,
|
||||
_iter_module_classes,
|
||||
apply_patches,
|
||||
build_patch_dict,
|
||||
extract_doctest_guards,
|
||||
iter_patches,
|
||||
)
|
||||
|
||||
mapping = json.loads(mapping_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(mapping, dict) or not mapping:
|
||||
raise ValueError("Legacy mapping must be a nonempty object")
|
||||
if lib_dir.exists():
|
||||
raise ValueError(f"Destination already exists: {lib_dir}")
|
||||
if (
|
||||
src_dir.resolve() == lib_dir.resolve()
|
||||
or src_dir.resolve() in lib_dir.resolve().parents
|
||||
):
|
||||
raise ValueError("Source and destination overlap")
|
||||
source_files = sorted(
|
||||
p
|
||||
for p in src_dir.rglob("*")
|
||||
if p.is_file() and "__pycache__" not in p.relative_to(src_dir).parts[:-1]
|
||||
)
|
||||
if not (src_dir / "__init__.py").is_file():
|
||||
raise ValueError("Source must be a Python package")
|
||||
if any(
|
||||
p.is_symlink() or src_dir.resolve() not in p.resolve().parents
|
||||
for p in source_files
|
||||
):
|
||||
raise ValueError("Source files must remain inside the canonical package")
|
||||
contents = {p.relative_to(src_dir): p.read_bytes() for p in source_files}
|
||||
entries = collections.defaultdict(list)
|
||||
owners = {}
|
||||
legacy_files = []
|
||||
for name, class_map in mapping.items():
|
||||
if pathlib.Path(name).name != name or not name.endswith(".py"):
|
||||
raise ValueError(f"Legacy file must be a sibling .py file: {name}")
|
||||
if not isinstance(class_map, dict):
|
||||
raise ValueError(f"Class map must be an object: {name}")
|
||||
legacy = lib_dir.parent / name
|
||||
if legacy.is_symlink() or legacy.resolve().parent != lib_dir.parent.resolve():
|
||||
raise ValueError(f"Legacy file escapes destination parent: {name}")
|
||||
if (
|
||||
legacy.resolve() == lib_dir.resolve()
|
||||
or src_dir.resolve() in legacy.resolve().parents
|
||||
):
|
||||
raise ValueError(f"Legacy file overlaps canonical package: {name}")
|
||||
legacy_files.append(legacy)
|
||||
legacy_content = legacy.read_text(encoding="utf-8")
|
||||
if extract_doctest_guards(legacy_content):
|
||||
raise ValueError("Legacy package migration cannot select doctest guards")
|
||||
patches = list(iter_patches(legacy_content))
|
||||
legacy_classes = collections.defaultdict(list)
|
||||
for cls in _iter_module_classes(ast.parse(legacy_content)):
|
||||
legacy_classes[cls.name].append(cls)
|
||||
if set(class_map) != {p.parent_class for p in patches}:
|
||||
raise ValueError(f"Missing or unused class mapping: {name}")
|
||||
for entry in patches:
|
||||
source_classes = legacy_classes[entry.parent_class]
|
||||
if len(source_classes) != 1:
|
||||
raise ValueError(f"Ambiguous legacy class: {entry.parent_class}")
|
||||
if (
|
||||
entry.test_name != "__self__"
|
||||
and sum(
|
||||
method.name == entry.test_name
|
||||
for method in _iter_class_methods(source_classes[0])
|
||||
)
|
||||
!= 1
|
||||
):
|
||||
raise ValueError(
|
||||
f"Ambiguous legacy method: {entry.parent_class}.{entry.test_name}"
|
||||
)
|
||||
target = pathlib.Path(class_map[entry.parent_class])
|
||||
if target.is_absolute() or ".." in target.parts or target not in contents:
|
||||
raise ValueError(f"Invalid canonical destination: {target}")
|
||||
if target.suffix != ".py":
|
||||
raise ValueError(f"Marker destination is not Python: {target}")
|
||||
tree = ast.parse(contents[target].decode("utf-8"))
|
||||
classes = collections.defaultdict(list)
|
||||
for cls in _iter_module_classes(tree):
|
||||
classes[cls.name].append(cls)
|
||||
|
||||
def has_method(class_name, seen):
|
||||
if class_name in seen:
|
||||
return False
|
||||
seen = seen | {class_name}
|
||||
nodes = classes.get(class_name, [])
|
||||
if len(nodes) != 1:
|
||||
raise ValueError(f"Missing or ambiguous class: {class_name}")
|
||||
cls = nodes[0]
|
||||
methods = [
|
||||
m for m in _iter_class_methods(cls) if m.name == entry.test_name
|
||||
]
|
||||
if len(methods) > 1:
|
||||
raise ValueError(
|
||||
f"Ambiguous method: {class_name}.{entry.test_name}"
|
||||
)
|
||||
if methods or entry.test_name == "__self__":
|
||||
return True
|
||||
return any(
|
||||
has_method(base.id, seen)
|
||||
for base in cls.bases
|
||||
if isinstance(base, ast.Name) and base.id in classes
|
||||
)
|
||||
|
||||
if not has_method(entry.parent_class, set()):
|
||||
raise ValueError(
|
||||
f"Missing method: {entry.parent_class}.{entry.test_name}"
|
||||
)
|
||||
owner_key = (target, entry.parent_class, entry.test_name)
|
||||
if owner_key in owners and owners[owner_key] != legacy:
|
||||
raise ValueError(
|
||||
f"Duplicate mapped marker: {entry.parent_class}.{entry.test_name}"
|
||||
)
|
||||
owners[owner_key] = legacy
|
||||
entries[target].append(entry)
|
||||
# Render and validate everything before changing the destination.
|
||||
for target, patches in entries.items():
|
||||
contents[target] = apply_patches(
|
||||
contents[target].decode("utf-8"), build_patch_dict(iter(patches))
|
||||
).encode("utf-8")
|
||||
for target, data in contents.items():
|
||||
if target.suffix == ".py":
|
||||
text = data.decode("utf-8")
|
||||
ast.parse(text)
|
||||
if collections.Counter(iter_patches(text)) != collections.Counter(
|
||||
entries[target]
|
||||
):
|
||||
raise ValueError(f"Marker roundtrip mismatch: {target}")
|
||||
for target, data in contents.items():
|
||||
destination = lib_dir / target
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(data)
|
||||
for legacy in legacy_files:
|
||||
legacy.unlink()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
@@ -129,13 +474,33 @@ def main(argv: list[str] | None = None) -> int:
|
||||
help="Source path containing /Lib/ (file or directory)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--legacy-map",
|
||||
type=pathlib.Path,
|
||||
help="Explicit legacy-file/class to package-file JSON mapping",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--patches",
|
||||
type=pathlib.Path,
|
||||
help="Single-file target, remaining decorator patches, and optional doctests JSON",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.path.is_dir():
|
||||
if args.patches is not None and (
|
||||
args.path.is_dir() or args.legacy_map is not None
|
||||
):
|
||||
raise ValueError("--patches requires a single file without --legacy-map")
|
||||
document = (
|
||||
json.loads(args.patches.read_text()) if args.patches is not None else None
|
||||
)
|
||||
if args.legacy_map is not None:
|
||||
migrate_package(args.path, parse_lib_path(args.path), args.legacy_map)
|
||||
elif args.path.is_dir():
|
||||
patch_directory(args.path)
|
||||
else:
|
||||
patch_file(args.path)
|
||||
patch_file(args.path, patches_document=document)
|
||||
return 0
|
||||
except ValueError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
|
||||
@@ -11,10 +11,12 @@ from __future__ import annotations
|
||||
import ast
|
||||
import collections
|
||||
import contextlib
|
||||
import doctest
|
||||
import enum
|
||||
import re
|
||||
import textwrap
|
||||
import typing
|
||||
from pathlib import Path
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@@ -121,7 +123,9 @@ class PatchSpec(typing.NamedTuple):
|
||||
# If our ut_method has args then,
|
||||
# we need to search for a constant that contains our `COMMENT`.
|
||||
# Otherwise we need to search it in the raw source code :/
|
||||
if ut_method.has_args():
|
||||
if ut_method.has_args() and not (
|
||||
ut_method is UtMethod.Skip and isinstance(node, ast.Attribute)
|
||||
):
|
||||
reason = next(
|
||||
(
|
||||
inner_node.value
|
||||
@@ -303,8 +307,23 @@ def _iter_patch_lines(
|
||||
class_bases: dict[str, list[str]] = {}
|
||||
all_classes = set()
|
||||
all_class_nodes = list(_iter_module_classes(tree))
|
||||
# Only names used to place a patch (including inherited methods) need a
|
||||
# unique definition. Conditional, unpatched helpers are common upstream.
|
||||
relevant_classes = set(patches)
|
||||
while True:
|
||||
ancestors = {
|
||||
base.id
|
||||
for node in all_class_nodes
|
||||
if node.name in relevant_classes
|
||||
for base in node.bases
|
||||
if isinstance(base, ast.Name)
|
||||
}
|
||||
expanded = relevant_classes | ancestors
|
||||
if expanded == relevant_classes:
|
||||
break
|
||||
relevant_classes = expanded
|
||||
for node in all_class_nodes:
|
||||
if node.name in all_classes:
|
||||
if node.name in all_classes and node.name in relevant_classes:
|
||||
raise ValueError(f"Ambiguous module-scope class name: {node.name}")
|
||||
all_classes.add(node.name)
|
||||
method_counts = collections.Counter(
|
||||
@@ -530,3 +549,140 @@ def temp_attr(obj: object, attr: str, value: object):
|
||||
yield obj
|
||||
finally:
|
||||
setattr(obj, attr, old)
|
||||
|
||||
|
||||
def select_remaining_patches(current, document, relative_path: Path):
|
||||
if not isinstance(document, dict) or (
|
||||
set(document) not in ({"target", "patches"}, {"target", "patches", "doctests"})
|
||||
):
|
||||
raise ValueError("Expected target and patches metadata")
|
||||
if (
|
||||
relative_path.parts[:2] != ("Lib", "test")
|
||||
or relative_path.suffix != ".py"
|
||||
or document["target"] != relative_path.as_posix()
|
||||
):
|
||||
raise ValueError("Marker override target does not match the test file")
|
||||
if "doctests" in document and not isinstance(document["doctests"], dict):
|
||||
raise ValueError("Doctests metadata must be an object")
|
||||
try:
|
||||
remaining = patches_from_json(document["patches"])
|
||||
except (TypeError, KeyError, AttributeError, ValueError) as error:
|
||||
raise ValueError("Invalid remaining marker metadata") from error
|
||||
for cls, methods in remaining.items():
|
||||
if cls not in current or any(method not in current[cls] for method in methods):
|
||||
raise ValueError("Marker override cannot add targets")
|
||||
for cls, methods in current.items():
|
||||
for method, existing in methods.items():
|
||||
pending = list(existing)
|
||||
omitted = []
|
||||
for marker in remaining.get(cls, {}).get(method, []):
|
||||
if marker not in pending:
|
||||
raise ValueError(
|
||||
"Marker override cannot add, change, or reorder markers"
|
||||
)
|
||||
index = pending.index(marker)
|
||||
omitted.extend(pending[:index])
|
||||
pending = pending[index + 1 :]
|
||||
omitted.extend(pending)
|
||||
if any(marker.ut_method != UtMethod.ExpectedFailure for marker in omitted):
|
||||
raise ValueError(
|
||||
"Only unconditional expectedFailure markers may be removed"
|
||||
)
|
||||
return remaining
|
||||
|
||||
|
||||
EXPECTED_FAILURE = doctest.register_optionflag("EXPECTED_FAILURE")
|
||||
CANONICAL_LOAD_TESTS = """def load_tests(loader, tests, pattern):
|
||||
tests.addTest(doctest.DocTestSuite())
|
||||
return tests"""
|
||||
PATCHED_LOAD_TESTS = """def load_tests(loader, tests, pattern):
|
||||
from test.support.rustpython import DocTestChecker # TODO: RUSTPYTHON
|
||||
tests.addTest(doctest.DocTestSuite(checker=DocTestChecker())) # TODO: RUSTPYTHON
|
||||
return tests"""
|
||||
|
||||
|
||||
def _examples(contents):
|
||||
tree = ast.parse(contents)
|
||||
if not tree.body or not isinstance(tree.body[0], ast.Expr):
|
||||
raise ValueError("Missing module doctest")
|
||||
doc = tree.body[0].value
|
||||
if not isinstance(doc, ast.Constant) or not isinstance(doc.value, str):
|
||||
raise ValueError("Missing module doctest")
|
||||
# Raw docstring offsets map directly to source lines, unlike cleaned docs.
|
||||
return tree, doc, doctest.DocTestParser().get_examples(doc.value)
|
||||
|
||||
|
||||
def apply_doctest_metadata(old, canonical, metadata):
|
||||
"""Preserve only listed guards, without carrying old expected output."""
|
||||
if not isinstance(metadata, dict) or any(
|
||||
not isinstance(source, str)
|
||||
or not isinstance(reason, str)
|
||||
or "\n" in reason
|
||||
or "\r" in reason
|
||||
for source, reason in metadata.items()
|
||||
):
|
||||
raise ValueError("Doctest metadata must map exact input to single-line reasons")
|
||||
_, _, old_examples = _examples(old)
|
||||
tree, doc, examples = _examples(canonical)
|
||||
lines = canonical.splitlines(keepends=True)
|
||||
for source in metadata:
|
||||
old_matches = [
|
||||
example
|
||||
for example in old_examples
|
||||
if re.sub(r"\s+# TODO: RUSTPYTHON[^\n]*", "", example.source) == source
|
||||
]
|
||||
if len(old_matches) != 1 or not old_matches[0].options.get(EXPECTED_FAILURE):
|
||||
raise ValueError("Missing or ambiguous existing doctest guard")
|
||||
matches = [example for example in examples if example.source == source]
|
||||
if len(matches) != 1 or matches[0].options:
|
||||
raise ValueError("Missing, ambiguous, or already marked canonical doctest")
|
||||
index = doc.lineno - 1 + matches[0].lineno
|
||||
line = lines[index]
|
||||
if (
|
||||
">>>" not in line
|
||||
or line.split(">>>", 1)[1].strip() != source.splitlines()[0]
|
||||
):
|
||||
raise ValueError("Unrecognized canonical doctest prompt")
|
||||
lines[index] = (
|
||||
line.rstrip("\n")
|
||||
+ " # TODO: RUSTPYTHON; "
|
||||
+ metadata[source]
|
||||
+ " # doctest: +EXPECTED_FAILURE\n"
|
||||
)
|
||||
|
||||
if not metadata:
|
||||
return canonical
|
||||
|
||||
loaders = [
|
||||
node
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == "load_tests"
|
||||
]
|
||||
if len(loaders) != 1 or loaders[0] not in tree.body:
|
||||
raise ValueError("Missing or ambiguous doctest loader")
|
||||
if ast.get_source_segment(canonical, loaders[0]) != CANONICAL_LOAD_TESTS:
|
||||
raise ValueError("Changed canonical doctest loader")
|
||||
result = "".join(lines)
|
||||
if result.count(CANONICAL_LOAD_TESTS) != 1:
|
||||
raise ValueError("Ambiguous canonical doctest loader text")
|
||||
return result.replace(CANONICAL_LOAD_TESTS, PATCHED_LOAD_TESTS, 1)
|
||||
|
||||
|
||||
def extract_doctest_guards(contents: str) -> dict[str, str]:
|
||||
"""Extract guarded module-docstring inputs without executing examples."""
|
||||
tree = ast.parse(contents)
|
||||
if not tree.body or not isinstance(tree.body[0], ast.Expr):
|
||||
return {}
|
||||
doc = tree.body[0].value
|
||||
if not isinstance(doc, ast.Constant) or not isinstance(doc.value, str):
|
||||
return {}
|
||||
guards = {}
|
||||
for example in doctest.DocTestParser().get_examples(doc.value):
|
||||
if not example.options.get(EXPECTED_FAILURE):
|
||||
continue
|
||||
source = re.sub(r"\s+# TODO: RUSTPYTHON[^\n]*", "", example.source)
|
||||
if source in guards:
|
||||
raise ValueError("Ambiguous existing doctest guard")
|
||||
guards[source] = example.source
|
||||
return guards
|
||||
@@ -1,20 +1,127 @@
|
||||
"""Tests for migrate.py - file migration operations."""
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from update_lib.cmd_migrate import (
|
||||
CANONICAL_CALL,
|
||||
EXCEPTION_LOOP,
|
||||
JIT_ERROR,
|
||||
LOCAL_CALL,
|
||||
SYSCONFIG_LOCAL,
|
||||
SYSCONFIG_UPSTREAM,
|
||||
main,
|
||||
migrate_package,
|
||||
patch_directory,
|
||||
patch_file,
|
||||
patch_single_content,
|
||||
preserve_migration_customizations,
|
||||
)
|
||||
from update_lib.patch_spec import (
|
||||
CANONICAL_LOAD_TESTS,
|
||||
COMMENT,
|
||||
PatchSpec,
|
||||
UtMethod,
|
||||
extract_patches,
|
||||
iter_patches,
|
||||
patches_to_json,
|
||||
select_remaining_patches,
|
||||
)
|
||||
from update_lib.patch_spec import COMMENT
|
||||
|
||||
|
||||
class TestPatchSingleContent(unittest.TestCase):
|
||||
"""Tests for patch_single_content function."""
|
||||
|
||||
def migrate_doctest(self, selection=None, explicit=False):
|
||||
old = '''"""
|
||||
>>> raise X, Y # TODO: RUSTPYTHON; old mismatch # doctest: +EXPECTED_FAILURE
|
||||
SyntaxError: old result
|
||||
>>> raise Z # TODO: RUSTPYTHON; obsolete mismatch # doctest: +EXPECTED_FAILURE
|
||||
SyntaxError: obsolete result
|
||||
"""
|
||||
'''
|
||||
canonical = (
|
||||
'''"""
|
||||
>>> raise X, Y
|
||||
SyntaxError: canonical result
|
||||
>>> raise Z
|
||||
SyntaxError: canonical second result
|
||||
"""
|
||||
'''
|
||||
+ CANONICAL_LOAD_TESTS
|
||||
+ "\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary) / "repository"
|
||||
target = root / "Lib/test/test_example.py"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(old)
|
||||
source = Path(temporary) / "canonical/Lib/test/test_example.py"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text(canonical)
|
||||
args = [str(source)]
|
||||
if explicit:
|
||||
metadata = Path(temporary) / "selection.json"
|
||||
metadata.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"target": "Lib/test/test_example.py",
|
||||
"patches": {},
|
||||
"doctests": selection,
|
||||
}
|
||||
)
|
||||
)
|
||||
args += ["--patches", str(metadata)]
|
||||
with (
|
||||
contextlib.chdir(root),
|
||||
patch(
|
||||
"update_lib.cmd_migrate.__file__",
|
||||
str(root / "scripts/update_lib/cmd_migrate.py"),
|
||||
),
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
contextlib.redirect_stderr(io.StringIO()),
|
||||
):
|
||||
result = main(args)
|
||||
return result, target.read_text(), old, canonical
|
||||
|
||||
def test_doctest_default_requires_explicit_selection(self):
|
||||
status, result, old, _ = self.migrate_doctest()
|
||||
self.assertEqual(status, 1)
|
||||
self.assertEqual(result, old)
|
||||
|
||||
def test_doctest_cli_selection_keeps_only_reviewed_guard(self):
|
||||
status, result, _, _ = self.migrate_doctest(
|
||||
{"raise X, Y\n": "known mismatch"}, True
|
||||
)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(result.count("+EXPECTED_FAILURE"), 1)
|
||||
self.assertIn("SyntaxError: canonical result", result)
|
||||
self.assertIn("SyntaxError: canonical second result", result)
|
||||
self.assertNotIn("obsolete mismatch", result)
|
||||
|
||||
def test_doctest_empty_selection_keeps_canonical_unchanged(self):
|
||||
status, result, _, canonical = self.migrate_doctest({}, True)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertEqual(result, canonical)
|
||||
|
||||
def test_doctest_invalid_selection_writes_nothing(self):
|
||||
for selection in (
|
||||
None,
|
||||
[],
|
||||
{"raise unknown\n": "unrecognized"},
|
||||
{"raise X, Y\n": "line\nbreak"},
|
||||
):
|
||||
with self.subTest(selection=selection):
|
||||
status, result, old, _ = self.migrate_doctest(selection, True)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertEqual(result, old)
|
||||
|
||||
def test_patch_with_no_existing_file(self):
|
||||
"""Test patching when lib file doesn't exist."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -136,6 +243,37 @@ class TestFoo(unittest.TestCase):
|
||||
class TestPatchDirectory(unittest.TestCase):
|
||||
"""Tests for patch_directory function."""
|
||||
|
||||
def test_excludes_cache_directories_but_preserves_canonical_assets(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
source = root / "source"
|
||||
destination = root / "destination"
|
||||
for relative in ("__pycache__", "nested/__pycache__", "assets"):
|
||||
(source / relative).mkdir(parents=True)
|
||||
files = {
|
||||
"module.py": b"# canonical source\n",
|
||||
"__pycache__/module.cpython-315.pyc": b"stale cache",
|
||||
"nested/__pycache__/module.cpython-315.pyc": b"nested cache",
|
||||
"assets/__pycache__": b"ordinary canonical file",
|
||||
"assets/fixture.pyc": b"canonical bytecode fixture",
|
||||
}
|
||||
for relative, content in files.items():
|
||||
(source / relative).write_bytes(content)
|
||||
patch_directory(source, destination, verbose=False)
|
||||
expected = {
|
||||
relative: content
|
||||
for relative, content in files.items()
|
||||
if "__pycache__" not in pathlib.Path(relative).parts[:-1]
|
||||
}
|
||||
actual = {
|
||||
path.relative_to(destination).as_posix(): path.read_bytes()
|
||||
for path in destination.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
self.assertEqual(actual, expected)
|
||||
for relative, content in files.items():
|
||||
self.assertEqual((source / relative).read_bytes(), content)
|
||||
|
||||
def test_patch_directory_all_files(self):
|
||||
"""Test that patch_directory processes all .py files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -192,5 +330,489 @@ class TestA(unittest.TestCase):
|
||||
self.assertIn("@unittest.expectedFailure", content)
|
||||
|
||||
|
||||
class RuntimePatchTests(unittest.TestCase):
|
||||
def test_sysconfig_identity_and_new_content(self):
|
||||
path = Path("Lib/sysconfig/__init__.py")
|
||||
source = SYSCONFIG_UPSTREAM + "new_final_behavior = True\n"
|
||||
result = preserve_migration_customizations(path, SYSCONFIG_LOCAL, source)
|
||||
self.assertEqual(result, SYSCONFIG_LOCAL + "new_final_behavior = True\n")
|
||||
self.assertEqual(
|
||||
preserve_migration_customizations(path, result, source), result
|
||||
)
|
||||
|
||||
def test_pickle_jit_mapping_and_new_content(self):
|
||||
path = Path("Lib/_compat_pickle.py")
|
||||
source = "final_mapping = 'ImportCycleError'\n" + EXCEPTION_LOOP
|
||||
result = preserve_migration_customizations(
|
||||
path, JIT_ERROR + EXCEPTION_LOOP, source
|
||||
)
|
||||
self.assertEqual(
|
||||
result, "final_mapping = 'ImportCycleError'\n" + JIT_ERROR + EXCEPTION_LOOP
|
||||
)
|
||||
self.assertEqual(
|
||||
preserve_migration_customizations(path, result, source), result
|
||||
)
|
||||
|
||||
def test_changed_or_ambiguous_anchors_fail(self):
|
||||
cases = [
|
||||
(Path("Lib/sysconfig/__init__.py"), SYSCONFIG_LOCAL, SYSCONFIG_UPSTREAM),
|
||||
(Path("Lib/_compat_pickle.py"), JIT_ERROR + EXCEPTION_LOOP, EXCEPTION_LOOP),
|
||||
]
|
||||
for path, old, source in cases:
|
||||
for bad_old in (
|
||||
"",
|
||||
old + old,
|
||||
old.replace("RustPython", "Other").replace("JitError", "Other"),
|
||||
):
|
||||
with (
|
||||
self.subTest(path=path, bad_old=bad_old),
|
||||
self.assertRaises(ValueError),
|
||||
):
|
||||
preserve_migration_customizations(path, bad_old, source)
|
||||
for bad_source in (
|
||||
"",
|
||||
source + source,
|
||||
source.replace("Python", "Other").replace("excname", "other"),
|
||||
):
|
||||
with (
|
||||
self.subTest(path=path, bad_source=bad_source),
|
||||
self.assertRaises(ValueError),
|
||||
):
|
||||
preserve_migration_customizations(path, old, bad_source)
|
||||
|
||||
def test_unrelated_file_unchanged(self):
|
||||
self.assertEqual(
|
||||
preserve_migration_customizations(Path("Lib/other.py"), JIT_ERROR, "new\n"),
|
||||
"new\n",
|
||||
)
|
||||
|
||||
def test_decoy_and_shadowed_sysconfig_anchors_fail(self):
|
||||
path = Path("Lib/sysconfig/__init__.py")
|
||||
for source in (
|
||||
'doc = """' + SYSCONFIG_UPSTREAM + '"""\n',
|
||||
SYSCONFIG_UPSTREAM + "def _get_implementation():\n return 'Other'\n",
|
||||
SYSCONFIG_UPSTREAM + "_get_implementation = lambda: 'Other'\n",
|
||||
SYSCONFIG_UPSTREAM + "from other import _get_implementation\n",
|
||||
SYSCONFIG_UPSTREAM + "import other as _get_implementation\n",
|
||||
SYSCONFIG_UPSTREAM + "class _get_implementation: pass\n",
|
||||
SYSCONFIG_UPSTREAM + "del _get_implementation\n",
|
||||
):
|
||||
with self.subTest(source=source), self.assertRaises(ValueError):
|
||||
preserve_migration_customizations(path, SYSCONFIG_LOCAL, source)
|
||||
|
||||
def test_decoy_and_reordered_pickle_anchors_fail(self):
|
||||
path = Path("Lib/_compat_pickle.py")
|
||||
old = JIT_ERROR + EXCEPTION_LOOP
|
||||
with self.assertRaises(ValueError):
|
||||
preserve_migration_customizations(
|
||||
path, old, 'doc = """' + EXCEPTION_LOOP + '"""\n'
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
preserve_migration_customizations(
|
||||
path, EXCEPTION_LOOP + JIT_ERROR, EXCEPTION_LOOP
|
||||
)
|
||||
|
||||
def test_similarly_named_nested_files_are_untouched(self):
|
||||
for path in (
|
||||
"Lib/other/sysconfig/__init__.py",
|
||||
"Lib/other/_compat_pickle.py",
|
||||
"other/sysconfig/__init__.py",
|
||||
):
|
||||
self.assertEqual(
|
||||
preserve_migration_customizations(Path(path), "old", "new"), "new"
|
||||
)
|
||||
|
||||
def test_absolute_target_requires_explicit_repository_resolution(self):
|
||||
with self.assertRaises(ValueError):
|
||||
preserve_migration_customizations(
|
||||
Path("/parent/Lib/repository/Lib/sysconfig/__init__.py"),
|
||||
SYSCONFIG_LOCAL,
|
||||
SYSCONFIG_UPSTREAM,
|
||||
)
|
||||
|
||||
def test_migrate_resolves_real_repository_under_lib_parent(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary) / "Lib" / "repository"
|
||||
output = root / "Lib" / "sysconfig" / "__init__.py"
|
||||
output.parent.mkdir(parents=True)
|
||||
output.write_text(SYSCONFIG_LOCAL, encoding="utf-8")
|
||||
source = Path(temporary) / "canonical.py"
|
||||
source.write_text(SYSCONFIG_UPSTREAM, encoding="utf-8")
|
||||
module_file = root / "scripts" / "update_lib" / "cmd_migrate.py"
|
||||
with patch("update_lib.cmd_migrate.__file__", str(module_file)):
|
||||
self.assertEqual(patch_single_content(source, output), SYSCONFIG_LOCAL)
|
||||
outside = (
|
||||
Path(temporary) / "outside" / "Lib" / "sysconfig" / "__init__.py"
|
||||
)
|
||||
outside.parent.mkdir(parents=True)
|
||||
outside.write_text(SYSCONFIG_LOCAL, encoding="utf-8")
|
||||
self.assertEqual(
|
||||
patch_single_content(source, outside), SYSCONFIG_UPSTREAM
|
||||
)
|
||||
|
||||
|
||||
CALL_PATH = Path("Lib/test/test_cmd_line.py")
|
||||
CALL_TEMPLATE = """class CmdLineTest:
|
||||
def test_empty_PYTHONPATH_issue16309(self):
|
||||
result = CALL
|
||||
self.assertEqual(result, 'new final assertion')
|
||||
"""
|
||||
CALL_OLD = CALL_TEMPLATE.replace("CALL", LOCAL_CALL).replace(
|
||||
"new final assertion", "old assertion"
|
||||
)
|
||||
CALL_CANONICAL = CALL_TEMPLATE.replace("CALL", CANONICAL_CALL)
|
||||
|
||||
|
||||
class CallPatchTests(unittest.TestCase):
|
||||
def test_final_body_and_roundtrip(self):
|
||||
result = preserve_migration_customizations(CALL_PATH, CALL_OLD, CALL_CANONICAL)
|
||||
self.assertEqual(result, CALL_TEMPLATE.replace("CALL", LOCAL_CALL))
|
||||
self.assertIn("new final assertion", result)
|
||||
self.assertEqual(
|
||||
preserve_migration_customizations(CALL_PATH, result, CALL_CANONICAL), result
|
||||
)
|
||||
|
||||
def test_missing_duplicate_owners_and_calls_fail(self):
|
||||
for source in (
|
||||
CALL_CANONICAL.replace("CmdLineTest", "Other"),
|
||||
CALL_CANONICAL + CALL_CANONICAL,
|
||||
CALL_CANONICAL.replace(
|
||||
" result = ", " " + CANONICAL_CALL + "\n result = "
|
||||
),
|
||||
CALL_CANONICAL.replace("test_empty_PYTHONPATH_issue16309", "other"),
|
||||
):
|
||||
with self.subTest(source=source), self.assertRaises(ValueError):
|
||||
preserve_migration_customizations(CALL_PATH, CALL_OLD, source)
|
||||
|
||||
def test_changed_keywords_and_decoy_fail(self):
|
||||
for old in (
|
||||
CALL_OLD.replace('RUSTPYTHONPATH=""', 'RUSTPYTHONPATH="changed"'),
|
||||
CALL_CANONICAL,
|
||||
'doc = """' + CALL_OLD + '"""\n',
|
||||
):
|
||||
with self.subTest(old=old), self.assertRaises(ValueError):
|
||||
preserve_migration_customizations(CALL_PATH, old, CALL_CANONICAL)
|
||||
with self.assertRaises(ValueError):
|
||||
preserve_migration_customizations(
|
||||
CALL_PATH,
|
||||
CALL_OLD,
|
||||
CALL_CANONICAL.replace('PYTHONPATH=""', 'PYTHONPATH="changed"'),
|
||||
)
|
||||
|
||||
def test_unrelated_file_unchanged(self):
|
||||
self.assertEqual(
|
||||
preserve_migration_customizations(Path("Lib/test/other.py"), "old", "new"),
|
||||
"new",
|
||||
)
|
||||
|
||||
|
||||
OVERRIDE_CLEAN = """import unittest
|
||||
class Example(unittest.TestCase):
|
||||
def test_known(self):
|
||||
self.assertEqual(1, 1)
|
||||
def test_guarded(self):
|
||||
self.assertEqual(2, 2)
|
||||
"""
|
||||
OVERRIDE_OLD = OVERRIDE_CLEAN.replace(
|
||||
" def test_known",
|
||||
" @unittest.expectedFailure # TODO: RUSTPYTHON; old failure\n def test_known",
|
||||
).replace(
|
||||
" def test_guarded",
|
||||
' @unittest.skip("TODO: RUSTPYTHON; retained guard")\n def test_guarded',
|
||||
)
|
||||
|
||||
|
||||
class MarkerOverrideTests(unittest.TestCase):
|
||||
def invoke(self, mutation=None, directory=False, override=True, legacy=False):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary) / "repository"
|
||||
output = root / "Lib" / "test" / "test_example.py"
|
||||
output.parent.mkdir(parents=True)
|
||||
output.write_text(OVERRIDE_OLD)
|
||||
source = Path(temporary) / "cpython" / "Lib" / "test" / "test_example.py"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text(OVERRIDE_CLEAN)
|
||||
markers = patches_to_json(extract_patches(OVERRIDE_OLD))
|
||||
del markers["Example"]["test_known"]
|
||||
document = {"target": "Lib/test/test_example.py", "patches": markers}
|
||||
if mutation:
|
||||
mutation(document)
|
||||
metadata = Path(temporary) / "remaining.json"
|
||||
metadata.write_text(json.dumps(document))
|
||||
args = [str(source.parent if directory else source)]
|
||||
if override:
|
||||
args += ["--patches", str(metadata)]
|
||||
if legacy:
|
||||
args += ["--legacy-map", str(metadata)]
|
||||
with (
|
||||
contextlib.chdir(root),
|
||||
patch(
|
||||
"update_lib.cmd_migrate.__file__",
|
||||
str(root / "scripts/update_lib/cmd_migrate.py"),
|
||||
),
|
||||
contextlib.redirect_stdout(io.StringIO()),
|
||||
contextlib.redirect_stderr(io.StringIO()),
|
||||
):
|
||||
status = main(args)
|
||||
return status, output.read_text()
|
||||
|
||||
def test_cli_override_removes_only_requested_expected_failure(self):
|
||||
status, result = self.invoke()
|
||||
self.assertEqual(status, 0)
|
||||
self.assertNotIn("expectedFailure", result)
|
||||
self.assertIn('unittest.skip("TODO: RUSTPYTHON; retained guard")', result)
|
||||
self.assertIn("self.assertEqual(1, 1)", result)
|
||||
self.assertIn("self.assertEqual(2, 2)", result)
|
||||
|
||||
def test_default_migration_preserves_all_markers(self):
|
||||
status, result = self.invoke(override=False)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIn("expectedFailure", result)
|
||||
self.assertIn("retained guard", result)
|
||||
|
||||
def test_invalid_target_does_not_write(self):
|
||||
status, result = self.invoke(
|
||||
lambda document: document.update(target="Lib/test/other.py")
|
||||
)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertEqual(result, OVERRIDE_OLD)
|
||||
|
||||
def test_removing_skip_does_not_write(self):
|
||||
status, result = self.invoke(
|
||||
lambda document: document["patches"]["Example"].clear()
|
||||
)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertEqual(result, OVERRIDE_OLD)
|
||||
|
||||
def test_directory_override_does_not_write(self):
|
||||
status, result = self.invoke(directory=True)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertEqual(result, OVERRIDE_OLD)
|
||||
|
||||
def test_legacy_combination_does_not_write(self):
|
||||
status, result = self.invoke(legacy=True)
|
||||
self.assertEqual(status, 1)
|
||||
self.assertEqual(result, OVERRIDE_OLD)
|
||||
|
||||
def test_added_changed_or_reordered_markers_fail(self):
|
||||
failure = PatchSpec(UtMethod.ExpectedFailure, reason="known failure")
|
||||
success = PatchSpec(UtMethod.ExpectedSuccess, reason="known success")
|
||||
current = {"Example": {"test_known": [failure, success]}}
|
||||
for markers in (
|
||||
[failure, success, failure],
|
||||
[PatchSpec(UtMethod.ExpectedFailure, reason="changed"), success],
|
||||
[success, failure],
|
||||
):
|
||||
document = {
|
||||
"target": "Lib/test/test_example.py",
|
||||
"patches": patches_to_json({"Example": {"test_known": markers}}),
|
||||
}
|
||||
with self.subTest(markers=markers), self.assertRaises(ValueError):
|
||||
select_remaining_patches(current, document, Path(document["target"]))
|
||||
|
||||
|
||||
PACKAGE_MARKED = """import unittest
|
||||
class Example:
|
||||
@unittest.expectedFailure # TODO: RUSTPYTHON; retained reason
|
||||
def test_one(self):
|
||||
pass
|
||||
"""
|
||||
PACKAGE_CANONICAL = """import unittest
|
||||
class Example:
|
||||
def test_one(self):
|
||||
pass
|
||||
"""
|
||||
|
||||
|
||||
class TestMigratePackage(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.src = self.root / "canonical" / "package"
|
||||
self.src.mkdir(parents=True)
|
||||
(self.src / "__init__.py").write_text("# canonical package\n")
|
||||
(self.src / "test_part.py").write_text(PACKAGE_CANONICAL)
|
||||
self.dest = self.root / "Lib" / "test" / "package"
|
||||
self.dest.parent.mkdir(parents=True)
|
||||
self.old = self.dest.parent / "old.py"
|
||||
self.old.write_text(PACKAGE_MARKED)
|
||||
self.manifest = self.root / "map.json"
|
||||
self.mapping = {"old.py": {"Example": "test_part.py"}}
|
||||
|
||||
def run_migration(self):
|
||||
self.manifest.write_text(json.dumps(self.mapping))
|
||||
migrate_package(self.src, self.dest, self.manifest)
|
||||
|
||||
def assert_rejected(self):
|
||||
before = self.old.read_bytes()
|
||||
with self.assertRaises((ValueError, FileNotFoundError, SyntaxError)):
|
||||
self.run_migration()
|
||||
self.assertFalse(self.dest.exists())
|
||||
self.assertEqual(self.old.read_bytes(), before)
|
||||
|
||||
def test_preserves_marker_and_canonical_asset(self):
|
||||
(self.src / "asset.dat").write_bytes(b"\x00canonical")
|
||||
self.run_migration()
|
||||
self.assertFalse(self.old.exists())
|
||||
self.assertEqual((self.dest / "asset.dat").read_bytes(), b"\x00canonical")
|
||||
self.assertEqual(
|
||||
list(iter_patches((self.dest / "test_part.py").read_text())),
|
||||
list(iter_patches(PACKAGE_MARKED)),
|
||||
)
|
||||
|
||||
def test_preserves_inherited_override(self):
|
||||
(self.src / "test_part.py").write_text(
|
||||
PACKAGE_CANONICAL.replace("class Example:", "class Base:")
|
||||
+ "\nclass Example(Base):\n pass\n"
|
||||
)
|
||||
self.run_migration()
|
||||
text = (self.dest / "test_part.py").read_text()
|
||||
self.assertIn("return super().test_one()", text)
|
||||
self.assertEqual(list(iter_patches(text)), list(iter_patches(PACKAGE_MARKED)))
|
||||
|
||||
def test_excludes_cache_directories_but_preserves_canonical_assets(self):
|
||||
for relative in ("__pycache__", "nested/__pycache__", "assets"):
|
||||
(self.src / relative).mkdir(parents=True)
|
||||
files = {
|
||||
"__pycache__/module.cpython-315.pyc": b"stale cache",
|
||||
"nested/__pycache__/module.cpython-315.pyc": b"nested cache",
|
||||
"assets/__pycache__": b"ordinary canonical file",
|
||||
"assets/fixture.pyc": b"canonical bytecode fixture",
|
||||
}
|
||||
for relative, content in files.items():
|
||||
(self.src / relative).write_bytes(content)
|
||||
self.run_migration()
|
||||
self.assertFalse((self.dest / "__pycache__").exists())
|
||||
self.assertFalse((self.dest / "nested" / "__pycache__").exists())
|
||||
for relative in ("assets/__pycache__", "assets/fixture.pyc"):
|
||||
self.assertEqual((self.dest / relative).read_bytes(), files[relative])
|
||||
for relative, content in files.items():
|
||||
self.assertEqual((self.src / relative).read_bytes(), content)
|
||||
|
||||
def test_missing_method(self):
|
||||
(self.src / "test_part.py").write_text(
|
||||
PACKAGE_CANONICAL.replace("test_one", "test_other")
|
||||
)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_missing_class(self):
|
||||
(self.src / "test_part.py").write_text(
|
||||
PACKAGE_CANONICAL.replace("Example", "Other")
|
||||
)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_ambiguous_class(self):
|
||||
(self.src / "test_part.py").write_text(PACKAGE_CANONICAL + PACKAGE_CANONICAL)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_ambiguous_method(self):
|
||||
(self.src / "test_part.py").write_text(
|
||||
PACKAGE_CANONICAL + " def test_one(self):\n pass\n"
|
||||
)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_missing_destination(self):
|
||||
self.mapping["old.py"]["Example"] = "missing.py"
|
||||
self.assert_rejected()
|
||||
|
||||
def test_unused_mapping(self):
|
||||
self.mapping["old.py"]["Unused"] = "test_part.py"
|
||||
self.assert_rejected()
|
||||
|
||||
def test_unmapped_marker(self):
|
||||
self.mapping["old.py"] = {}
|
||||
self.assert_rejected()
|
||||
|
||||
def test_target_traversal(self):
|
||||
self.mapping["old.py"]["Example"] = "../outside.py"
|
||||
self.assert_rejected()
|
||||
|
||||
def test_legacy_traversal(self):
|
||||
self.mapping["../old.py"] = self.mapping.pop("old.py")
|
||||
self.assert_rejected()
|
||||
|
||||
def test_duplicate_marker(self):
|
||||
(self.old.parent / "second.py").write_text(PACKAGE_MARKED)
|
||||
self.mapping["second.py"] = self.mapping["old.py"]
|
||||
self.assert_rejected()
|
||||
self.assertEqual((self.old.parent / "second.py").read_text(), PACKAGE_MARKED)
|
||||
|
||||
def test_preserves_ordered_markers_from_one_legacy_method(self):
|
||||
marked = PACKAGE_MARKED.replace(
|
||||
" def test_one",
|
||||
' @unittest.skip("TODO: RUSTPYTHON; retained skip")\n def test_one',
|
||||
)
|
||||
self.old.write_text(marked)
|
||||
self.run_migration()
|
||||
result = (self.dest / "test_part.py").read_text()
|
||||
self.assertEqual(list(iter_patches(result)), list(iter_patches(marked)))
|
||||
self.assertLess(result.index("expectedFailure"), result.index("unittest.skip"))
|
||||
|
||||
def test_duplicate_legacy_owner_is_rejected(self):
|
||||
for source in (
|
||||
PACKAGE_MARKED + PACKAGE_MARKED,
|
||||
PACKAGE_MARKED + " def test_one(self):\n pass\n",
|
||||
):
|
||||
with self.subTest(source=source):
|
||||
self.old.write_text(source)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_unmarked_legacy_retired(self):
|
||||
self.old.write_text(PACKAGE_CANONICAL)
|
||||
self.mapping["old.py"] = {}
|
||||
self.run_migration()
|
||||
self.assertFalse(self.old.exists())
|
||||
self.assertEqual((self.dest / "test_part.py").read_text(), PACKAGE_CANONICAL)
|
||||
|
||||
def test_legacy_doctest_guards_prevent_all_writes(self):
|
||||
self.old.write_text(
|
||||
'"""\n'
|
||||
">>> raise ValueError # TODO: RUSTPYTHON; mismatch # doctest: +EXPECTED_FAILURE\n"
|
||||
"ValueError\n"
|
||||
'"""\n' + PACKAGE_MARKED
|
||||
)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_validation_failure_after_valid_entry_writes_nothing(self):
|
||||
self.mapping["missing.py"] = {}
|
||||
self.assert_rejected()
|
||||
|
||||
def test_existing_destination_is_not_overwritten(self):
|
||||
self.dest.mkdir()
|
||||
with self.assertRaises(ValueError):
|
||||
self.run_migration()
|
||||
self.assertTrue(self.old.exists())
|
||||
self.assertEqual(list(self.dest.iterdir()), [])
|
||||
|
||||
def test_source_escape_rejected(self):
|
||||
outside = self.root / "outside.py"
|
||||
outside.write_text(PACKAGE_CANONICAL)
|
||||
(self.src / "test_part.py").unlink()
|
||||
(self.src / "test_part.py").symlink_to(outside)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_legacy_symlink_rejected(self):
|
||||
outside = self.root / "outside.py"
|
||||
outside.write_text(PACKAGE_MARKED)
|
||||
self.old.unlink()
|
||||
self.old.symlink_to(outside)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_inheritance_cycle_rejected(self):
|
||||
(self.src / "test_part.py").write_text(
|
||||
"class Example(Base):\n pass\nclass Base(Example):\n pass\n"
|
||||
)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_canonical_marker_collision_rejected(self):
|
||||
(self.src / "test_part.py").write_text(PACKAGE_MARKED)
|
||||
self.assert_rejected()
|
||||
|
||||
def test_unmarked_syntax_error_prevents_all_writes(self):
|
||||
(self.src / "broken.py").write_text("class :\n")
|
||||
self.assert_rejected()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -4,11 +4,15 @@ import ast
|
||||
import unittest
|
||||
|
||||
from update_lib.patch_spec import (
|
||||
CANONICAL_LOAD_TESTS,
|
||||
COMMENT,
|
||||
PATCHED_LOAD_TESTS,
|
||||
PatchSpec,
|
||||
UtMethod,
|
||||
_find_import_insert_line,
|
||||
apply_doctest_metadata,
|
||||
apply_patches,
|
||||
extract_doctest_guards,
|
||||
extract_patches,
|
||||
iter_tests,
|
||||
)
|
||||
@@ -99,6 +103,41 @@ def factory():
|
||||
"""
|
||||
self.assertEqual(extract_patches(code), {})
|
||||
|
||||
def test_bare_marked_skip_roundtrip(self):
|
||||
clean = """class Owner:
|
||||
def test_value(self):
|
||||
return 42
|
||||
"""
|
||||
marked = clean.replace(
|
||||
" def test_value",
|
||||
" @unittest.skip # TODO: RUSTPYTHON; original guard\n def test_value",
|
||||
)
|
||||
patches = extract_patches(marked)
|
||||
self.assertEqual(
|
||||
patches["Owner"]["test_value"],
|
||||
[PatchSpec(UtMethod.Skip, reason="original guard")],
|
||||
)
|
||||
result = apply_patches(clean, patches)
|
||||
self.assertIn('@unittest.skip("TODO: RUSTPYTHON; original guard")', result)
|
||||
self.assertEqual(apply_patches(clean, extract_patches(result)), result)
|
||||
method = list(iter_tests(ast.parse(result)))[0][1]
|
||||
self.assertEqual(ast.dump(method.body[0]), "Return(value=Constant(value=42))")
|
||||
|
||||
def test_unmarked_bare_skip_and_unrelated_decorators_are_not_patches(self):
|
||||
code = """class Owner:
|
||||
@unittest.skip
|
||||
def test_unmarked(self):
|
||||
pass
|
||||
@other.skip # TODO: RUSTPYTHON; unrelated
|
||||
def test_other(self):
|
||||
pass
|
||||
@unittest.other # TODO: RUSTPYTHON; unrelated
|
||||
def test_unknown(self):
|
||||
pass
|
||||
"""
|
||||
self.assertEqual(extract_patches(code), {})
|
||||
self.assertEqual(apply_patches(code, {}), code)
|
||||
|
||||
def test_extract_expected_failure(self):
|
||||
"""Test extracting @unittest.expectedFailure decorator."""
|
||||
code = f"""
|
||||
@@ -266,6 +305,43 @@ else:
|
||||
{"Duplicate": {"test_value": [PatchSpec(UtMethod.ExpectedFailure)]}},
|
||||
)
|
||||
|
||||
def test_unpatched_conditional_helpers_are_allowed(self):
|
||||
code = """if enabled:
|
||||
class Helper:
|
||||
pass
|
||||
else:
|
||||
class Helper:
|
||||
pass
|
||||
class Target:
|
||||
helper = Helper
|
||||
def test_value(self):
|
||||
return 1
|
||||
"""
|
||||
result = apply_patches(
|
||||
code, {"Target": {"test_value": [PatchSpec(UtMethod.ExpectedFailure)]}}
|
||||
)
|
||||
self.assertEqual(result.count("class Helper:"), 2)
|
||||
self.assertEqual(result.count("@unittest.expectedFailure"), 1)
|
||||
|
||||
def test_ambiguous_transitive_ancestor_is_rejected(self):
|
||||
code = """if enabled:
|
||||
class Ancestor:
|
||||
async def test_value(self):
|
||||
pass
|
||||
else:
|
||||
class Ancestor:
|
||||
def test_value(self):
|
||||
pass
|
||||
class Middle(Ancestor):
|
||||
pass
|
||||
class Target(Middle):
|
||||
pass
|
||||
"""
|
||||
with self.assertRaisesRegex(ValueError, "Ambiguous.*Ancestor"):
|
||||
apply_patches(
|
||||
code, {"Target": {"test_value": [PatchSpec(UtMethod.ExpectedFailure)]}}
|
||||
)
|
||||
|
||||
def test_local_class_names_do_not_shadow_module_class(self):
|
||||
code = """import unittest
|
||||
class Target:
|
||||
@@ -573,5 +649,90 @@ class Foo:
|
||||
self.assertEqual(line, 0)
|
||||
|
||||
|
||||
DOCTEST_SOURCE = "raise X, Y\n"
|
||||
DOCTEST_METADATA = {DOCTEST_SOURCE: "known mismatch"}
|
||||
DOCTEST_OLD = (
|
||||
'''"""
|
||||
>>> raise X, Y # TODO: RUSTPYTHON; old reason # doctest: +EXPECTED_FAILURE
|
||||
Traceback (most recent call last):
|
||||
SyntaxError: old message
|
||||
"""
|
||||
'''
|
||||
+ PATCHED_LOAD_TESTS
|
||||
+ "\n"
|
||||
)
|
||||
DOCTEST_CANONICAL = (
|
||||
'''"""
|
||||
>>> raise X, Y
|
||||
Traceback (most recent call last):
|
||||
SyntaxError: final message
|
||||
"""
|
||||
'''
|
||||
+ CANONICAL_LOAD_TESTS
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
class ExactDoctestTests(unittest.TestCase):
|
||||
def test_extracts_only_existing_guards(self):
|
||||
self.assertEqual(set(extract_doctest_guards(DOCTEST_OLD)), {DOCTEST_SOURCE})
|
||||
self.assertEqual(extract_doctest_guards(DOCTEST_CANONICAL), {})
|
||||
self.assertEqual(extract_doctest_guards("value = 1\n"), {})
|
||||
|
||||
def test_duplicate_existing_guards_are_rejected(self):
|
||||
duplicate = DOCTEST_OLD.replace(
|
||||
">>> raise X, Y # TODO:",
|
||||
">>> raise X, Y # TODO: RUSTPYTHON; duplicate # doctest: +EXPECTED_FAILURE\n"
|
||||
">>> raise X, Y # TODO:",
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
extract_doctest_guards(duplicate)
|
||||
|
||||
def test_preserves_final_output_and_roundtrips(self):
|
||||
result = apply_doctest_metadata(
|
||||
DOCTEST_OLD, DOCTEST_CANONICAL, DOCTEST_METADATA
|
||||
)
|
||||
self.assertIn("SyntaxError: final message", result)
|
||||
self.assertNotIn("old message", result)
|
||||
self.assertEqual(result.count("doctest: +EXPECTED_FAILURE"), 1)
|
||||
self.assertIn(PATCHED_LOAD_TESTS, result)
|
||||
self.assertEqual(
|
||||
apply_doctest_metadata(result, DOCTEST_CANONICAL, DOCTEST_METADATA), result
|
||||
)
|
||||
|
||||
def test_duplicate_input_is_rejected(self):
|
||||
duplicate = DOCTEST_CANONICAL.replace(
|
||||
">>> raise X, Y\n", ">>> raise X, Y\n>>> raise X, Y\n"
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
apply_doctest_metadata(DOCTEST_OLD, duplicate, DOCTEST_METADATA)
|
||||
|
||||
def test_missing_canonical_or_old_case_is_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
apply_doctest_metadata(
|
||||
DOCTEST_OLD,
|
||||
DOCTEST_CANONICAL.replace("raise X, Y", "raise X"),
|
||||
DOCTEST_METADATA,
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
apply_doctest_metadata(
|
||||
DOCTEST_OLD.replace("raise X, Y", "raise X"),
|
||||
DOCTEST_CANONICAL,
|
||||
DOCTEST_METADATA,
|
||||
)
|
||||
|
||||
def test_changed_loader_and_unmarked_old_case_are_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
apply_doctest_metadata(
|
||||
DOCTEST_OLD,
|
||||
DOCTEST_CANONICAL.replace("DocTestSuite()", "DocTestSuite(None)"),
|
||||
DOCTEST_METADATA,
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
apply_doctest_metadata(
|
||||
DOCTEST_CANONICAL, DOCTEST_CANONICAL, DOCTEST_METADATA
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user