#!/usr/bin/env python """ Migrate Python Lib files from CPython, preserving explicit local adaptations. 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. Optional "blocks" lists [canonical, local] source pairs to preserve exact existing changes. Both fragments must match uniquely; no module-specific defaults are inferred. Non-marker adaptations require reviewed blocks on each migration, for example {"target": "Lib/example.py", "patches": {}, "blocks": [["old = 1", "old = 2"]]}. Use a host Python that parses the source version (Python 3.15 for 3.15 syntax), as required by the existing marker extraction and application steps. This will: 1. Extract patches from Lib/test/test_foo.py (if exists) 2. Apply them to cpython/Lib/test/test_foo.py 3. Write result to Lib/test/test_foo.py """ import argparse import ast import collections import json import pathlib import shutil import sys sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) from update_lib.file_utils import parse_lib_path def _preserve_block(old, new, canonical, local): """Preserve an explicit existing fragment; do not infer module adaptations.""" if old.count(local) != 1 or new.count(canonical) != 1: raise ValueError("Changed or ambiguous preservation block") return new.replace(canonical, local, 1) def patch_single_content( src_path: pathlib.Path, lib_path: pathlib.Path, patches_document: dict | None = None, ) -> str: """ Patch content without writing to disk. Args: src_path: Source file path (e.g., cpython/Lib/test/foo.py) lib_path: Lib path to extract patches from (e.g., Lib/test/foo.py) Returns: 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(): 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, old_content=old_content if lib_path.exists() else None, ) blocks = patches_document.get("blocks", []) if patches_document else [] if not isinstance(blocks, list) or any( not isinstance(pair, list) or len(pair) != 2 or any(not isinstance(text, str) or not text.strip() for text in pair) for pair in blocks ): raise ValueError("Blocks must be canonical/local source pairs") if blocks and not lib_path.exists(): raise ValueError("Blocks require an existing local file") # Apply patches to source content src_content = src_path.read_text(encoding="utf-8") if lib_path.exists(): if any(src_content.count(canonical) != 1 for canonical, _ in blocks): raise ValueError("Changed or ambiguous canonical preservation block") for canonical, local in blocks: src_content = _preserve_block(old_content, src_content, canonical, local) 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) if not src_content and not patches: return src_content try: result = apply_patches(src_content, patches) except SyntaxError as error: if blocks: raise ValueError("Preserved blocks produced invalid Python") from error raise return result 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. Args: src_path: Source file path (e.g., cpython/Lib/test/foo.py) lib_path: Target lib path. If None, derived from src_path. verbose: Print progress messages """ if lib_path is None: lib_path = parse_lib_path(src_path) if lib_path.exists(): if verbose: print(f"Patching: {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) lib_path.write_text(content, encoding="utf-8") def patch_directory( src_dir: pathlib.Path, lib_dir: pathlib.Path | None = None, verbose: bool = True, ) -> None: """ Patch all files in a directory from source to lib. Args: src_dir: Source directory path (e.g., cpython/Lib/test/test_foo/) lib_dir: Target lib directory. If None, derived from src_dir. verbose: Print progress messages """ 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() 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) lib_file = lib_dir / rel_path if src_file.suffix == ".py": if lib_file.exists(): if verbose: print(f"Patching: {src_file} -> {lib_file}") content = patch_single_content(src_file, lib_file) else: if verbose: print(f"Copying: {src_file} -> {lib_file}") content = src_file.read_text(encoding="utf-8") lib_file.parent.mkdir(parents=True, exist_ok=True) lib_file.write_text(content, encoding="utf-8") else: if verbose: print(f"Copying: {src_file} -> {lib_file}") lib_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src_file, lib_file) def retire_legacy_paths(src_dir: pathlib.Path, mapping: dict) -> None: """Retire explicit upstream-absent files after checking their replacements. Flat legacy maps use repository-relative Lib paths and name an already canonical replacement, or null for an upstream removal. They never rewrite replacements or recursively prune directories. """ from update_lib.file_utils import get_cpython_dir canonical = get_cpython_dir(src_dir).resolve() local = pathlib.Path.cwd().resolve() if not (canonical / "Lib").is_dir() or not src_dir.is_dir(): raise ValueError("Canonical Lib or migration source directory does not exist") def lib_file(root, name): path = pathlib.Path(name) if path.is_absolute() or ".." in path.parts or path.parts[:1] != ("Lib",): raise ValueError(f"Expected a repository-relative Lib file: {name}") result = root / path if result.is_symlink() or not result.resolve().is_relative_to(root / "Lib"): raise ValueError(f"Lib file escapes its source tree: {name}") return result legacy_files = [] for old, new in mapping.items(): legacy = lib_file(local, old) upstream_old = lib_file(canonical, old) if upstream_old.exists() or not legacy.is_file(): raise ValueError(f"Not an existing upstream-absent file: {old}") if new is not None: replacement = lib_file(local, new) upstream_new = lib_file(canonical, new) if ( not upstream_new.is_file() or not replacement.is_file() or replacement.read_bytes() != upstream_new.read_bytes() ): raise ValueError(f"Replacement is not canonical: {new}") legacy_files.append(legacy) # Check the whole map before retiring any file. for legacy in legacy_files: legacy.unlink() 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 all(value is None or isinstance(value, str) for value in mapping.values()): retire_legacy_paths(src_dir, mapping) return 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__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "path", type=pathlib.Path, help="Source path containing /Lib/ (file or directory)", ) parser.add_argument( "--legacy-map", type=pathlib.Path, help="Legacy class mapping, or exact Lib path to canonical replacement/null map", ) 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.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, patches_document=document) return 0 except ValueError as e: print(f"Error: {e}", file=sys.stderr) return 1 except FileNotFoundError as e: print(f"Error: {e}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())