Files
RustPython/scripts/update_lib/cmd_migrate.py
T
youknowdotandCodex a20c0b8954 Retire obsolete stdlib paths after Python 3.15 relocations (#9413)
* Retire obsolete stdlib paths after Python 3.15 relocations

Verify canonical replacements before retiring 39 upstream-removed or relocated files. Preserve RustPython originals and update stale module/test metadata.

Assisted-by: Codex:GPT-6.1

* Preserve the frozen no-threading fallback after stdlib retirement

Replace the internal symlink with the exact previously referenced implementation, keeping the removed public _dummy_thread module retired.

Assisted-by: Codex:GPT-6.1

---------

Co-authored-by: Codex <codex@openai.com>
2026-10-28 17:30:21 +09:00

445 lines
17 KiB
Python

#!/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())