Remove one-off partial update_lib migration modes (#9394)

Keep whole-file and package migration with existing marker, doctest and explicit customization preservation. Retire copy-test and selected assignment/function/export import machinery rather than maintaining extra partial-file update paths.

Remove the separate historical-file deletion command after its migration use. This does not delete any Lib files or make whole-directory migration prune destination-only files; future obsolete-file removal still needs explicit review.

Preserve dependency handling and contributor content-comparison fixes. All 103 retained synthetic migration, patch and copy-library tooling tests pass under an isolated bounded host Python harness. No RustPython runtime or migrated Lib source was executed.

Assisted-by: OpenAI Codex:model version unavailable

Co-authored-by: youknowdot <youknowdot@users.noreply.github.com>
This commit is contained in:
youknowdotandyouknowdot authored and GitHub committed 2026-10-26 21:07:23 +09:00
1 parent 60bd8451c2
commit b811715799
6 files changed
-1960

No files matched your search

-11
View File
@@ -8,7 +8,6 @@ Usage:
python scripts/update_lib migrate cpython/Lib/test/test_foo.py
python scripts/update_lib patches --from Lib/test/foo.py --to cpython/Lib/test/foo.py
python scripts/update_lib auto-mark Lib/test/test_foo.py
python scripts/update_lib copy-test SOURCE DESTINATION --test ClassName.method_name --source-sha256 HASH --destination-sha256 HASH
"""
import argparse
@@ -50,11 +49,6 @@ def main(argv: list[str] | None = None) -> int:
help="Copy library file/directory from CPython (delete existing first)",
add_help=False,
)
subparsers.add_parser(
"copy-test",
help="Copy one complete canonical test from hash-pinned inputs",
add_help=False,
)
subparsers.add_parser(
"deps",
help="Show dependency information for a module",
@@ -78,11 +72,6 @@ def main(argv: list[str] | None = None) -> int:
return copy_lib_main(remaining)
if args.command == "copy-test":
from update_lib.cmd_copy_test import main as copy_test_main
return copy_test_main(remaining)
if args.command == "migrate":
from update_lib.cmd_migrate import main as migrate_main
-134
View File
@@ -1,134 +0,0 @@
"""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
-794
View File
@@ -5,7 +5,6 @@ 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
python scripts/update_lib migrate cpython/Lib/test/test_foo --canonical-removals removals.json --dry-run
Remaining metadata names the exact repository-relative target and its existing
decorator patches. An optional "doctests" object selects existing guarded inputs
@@ -19,19 +18,6 @@ Non-marker adaptations require reviewed blocks on each migration, for example
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.
For additive updates, --assignments or --functions selects contiguous canonical
top-level definitions before an unchanged anchor. Both input SHA256 pins are
required. --exports copies only complete selected __all__ lines. Function
dependencies are checked without importing or executing either module; unchanged
imports and simple assignments are supported, not arbitrary existing helpers.
Canonical removals are a separate, explicit operation. JSON source metadata
pins path (Lib/test/test_*), commit and tree; removals maps explicit relative
filenames to sha256 and upstream_commit. The caller must first verify and
review historical provenance, including release-branch evidence. This command
checks pinned canonical absence and exact local bytes; it does not authenticate
historical evidence. --dry-run performs all validation without deleting files.
This will:
1. Extract patches from Lib/test/test_foo.py (if exists)
2. Apply them to cpython/Lib/test/test_foo.py
@@ -40,16 +26,10 @@ This will:
import argparse
import ast
import builtins
import collections
import hashlib
import json
import os
import pathlib
import re
import shutil
import subprocess
import symtable
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
@@ -64,553 +44,6 @@ def _preserve_block(old, new, canonical, local):
return new.replace(canonical, local, 1)
def _single_assignment(tree, name):
matches = [
node
for node in ast.walk(tree)
if isinstance(node, ast.Name)
and isinstance(node.ctx, ast.Store)
and node.id == name
]
nodes = [
node
for node in tree.body
if isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == name
]
rebound = any(
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
and node.name == name
or isinstance(node, ast.alias)
and (node.asname or node.name.split(".")[0]) == name
for node in ast.walk(tree)
)
if len(nodes) != 1 or matches != [nodes[0].targets[0]] or rebound:
raise ValueError(f"Expected one top-level assignment to {name}")
return nodes[0]
def _selected_export_addition(src, dst, source_lines, lines, exports):
src_all, dst_all = (
_single_assignment(src, "__all__"),
_single_assignment(dst, "__all__"),
)
if not isinstance(src_all.value, ast.List) or not isinstance(
dst_all.value, ast.List
):
raise ValueError("__all__ must be a literal list")
if any(
not isinstance(node, ast.Constant) or not isinstance(node.value, str)
for node in src_all.value.elts + dst_all.value.elts
):
raise ValueError("__all__ entries must be literal strings")
src_values, dst_values = (
ast.literal_eval(src_all.value),
ast.literal_eval(dst_all.value),
)
if any(src_values.count(name) != 1 or name in dst_values for name in exports):
raise ValueError(
"Exports must occur once in canonical __all__ and be absent locally"
)
export_nodes = [node for node in src_all.value.elts if node.value in exports]
export_lines = sorted({node.lineno - 1 for node in export_nodes})
fragment = "".join(source_lines[line] for line in export_lines)
if ast.literal_eval("[\n" + fragment + "]") != exports:
raise ValueError(
"Canonical export lines include unselected entries or differ in order"
)
if lines[dst_all.lineno - 1].strip() != "__all__ = [":
raise ValueError("Expected a multiline destination __all__ list")
return dst_all.lineno, [fragment]
def copy_selected_assignments(source, destination, names, exports):
"""Copy contiguous canonical assignments and export lines, failing closed.
Only additions are supported. Dependencies must already be imported with the
same bindings, or be builtins/earlier selected assignments. The next unchanged
top-level statement is the insertion anchor; unrelated bytes stay untouched.
"""
if (
not names
or len(set(names)) != len(names)
or not all(name.isidentifier() for name in names)
):
raise ValueError("Assignment names must be unique identifiers")
if len(set(exports)) != len(exports) or not set(exports) <= set(names):
raise ValueError("Exports must be unique selected assignment names")
src, dst = ast.parse(source), ast.parse(destination)
selected = [_single_assignment(src, name) for name in names]
start = src.body.index(selected[0])
if src.body[start : start + len(names)] != selected or start + len(names) == len(
src.body
):
raise ValueError("Assignments must be contiguous and followed by an anchor")
if (start and src.body[start - 1].end_lineno >= selected[0].lineno) or (
selected[-1].end_lineno >= src.body[start + len(names)].lineno
):
raise ValueError("Selected assignments must occupy complete source lines")
for node in ast.walk(dst):
if (
isinstance(node, ast.Name)
and isinstance(node.ctx, ast.Store)
and node.id in names
or isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
and node.name in names
or isinstance(node, ast.alias)
and (node.asname or node.name.split(".")[0]) in names
):
raise ValueError("A selected assignment already exists in the destination")
anchor = src.body[start + len(names)]
matches = [node for node in dst.body if ast.dump(node) == ast.dump(anchor)]
if len(matches) != 1:
raise ValueError("Expected one unchanged destination anchor")
for tree, node in ((src, anchor), (dst, matches[0])):
index = tree.body.index(node)
if (
getattr(node, "decorator_list", ())
or node.col_offset
or index
and tree.body[index - 1].end_lineno >= node.lineno
or index + 1 < len(tree.body)
and tree.body[index + 1].lineno <= node.end_lineno
):
raise ValueError(
"Anchor must be undecorated and occupy complete source lines"
)
available = set(vars(builtins))
imports = (set(), set())
for node in src.body:
matching = [
other
for other in dst.body
if ast.dump(node) == ast.dump(other) and other.lineno < matches[0].lineno
]
if (
isinstance(node, (ast.Import, ast.ImportFrom))
and node.lineno < selected[0].lineno
and len(matching) == 1
):
imports[0].update(node.names)
imports[1].update(matching[0].names)
available.update(
alias.asname or alias.name.split(".")[0] for alias in node.names
)
dependencies = set()
for name, node in zip(names, selected):
used = {
child.id
for child in ast.walk(node.value)
if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load)
}
missing = used - available
if missing:
raise ValueError(f"Missing dependencies for {name}: {sorted(missing)}")
dependencies.update(used - set(names))
available.add(name)
for tree, boundary, allowed in (
(src, selected[0].lineno, imports[0]),
(dst, matches[0].lineno, imports[1]),
):
if dependencies and any(
isinstance(node, ast.ImportFrom)
and node.lineno < boundary
and any(alias.name == "*" for alias in node.names)
for node in ast.walk(tree)
):
raise ValueError("Wildcard import makes dependency bindings ambiguous")
for dependency in dependencies:
bindings = []
for node in ast.walk(tree):
if getattr(node, "lineno", boundary) >= boundary:
continue
if isinstance(node, ast.Name) and isinstance(
node.ctx, (ast.Store, ast.Del)
):
name = node.id
elif isinstance(node, ast.alias):
name = node.asname or node.name.split(".")[0]
elif isinstance(
node,
(
ast.FunctionDef,
ast.AsyncFunctionDef,
ast.ClassDef,
ast.ExceptHandler,
ast.MatchAs,
ast.MatchStar,
),
):
name = node.name
elif isinstance(node, ast.arg):
name = node.arg
elif isinstance(node, ast.MatchMapping):
name = node.rest
else:
continue
if name == dependency:
bindings.append(node)
if len(bindings) > 1 or any(node not in allowed for node in bindings):
raise ValueError(f"Ambiguous dependency binding: {dependency}")
source_lines, lines = source.splitlines(True), destination.splitlines(True)
additions = [
(
matches[0].lineno - 1,
source_lines[selected[0].lineno - 1 : anchor.lineno - 1],
)
]
if exports:
additions.append(
_selected_export_addition(src, dst, source_lines, lines, exports)
)
for offset, fragment in sorted(additions, reverse=True):
lines[offset:offset] = fragment
result = "".join(lines)
ast.parse(result)
return result
def _module_bindings(tree):
"""Collect possible module bindings without mistaking function locals for them."""
bindings = collections.defaultdict(list)
def visit(node):
children = ast.iter_child_nodes(node)
names = []
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
names = [node.name]
# A declaration in a nested scope can rebind a module dependency.
for child in ast.walk(node):
if isinstance(child, ast.Global):
for name in child.names:
bindings[name].append(child)
children = list(node.decorator_list)
if isinstance(node, ast.ClassDef):
children += node.bases + node.keywords
else:
children += node.args.defaults + [
value for value in node.args.kw_defaults if value is not None
]
elif isinstance(node, ast.Lambda):
children = node.args.defaults + [
value for value in node.args.kw_defaults if value is not None
]
elif isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
names = [node.id]
elif isinstance(node, ast.alias):
names = [node.asname or node.name.split(".")[0]]
elif isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)):
names = [node.name] if node.name is not None else []
elif isinstance(node, ast.MatchMapping) and node.rest is not None:
names = [node.rest]
for name in names:
bindings[name].append(node)
for child in children:
visit(child)
visit(tree)
return bindings
def _function_dependencies(fragment):
"""Find definition-time and deferred global reads without executing source."""
table = symtable.symtable(fragment, "<canonical function>", "exec")
if any(
symbol.is_assigned() and not symbol.is_namespace()
for symbol in table.get_symbols()
):
raise ValueError("Function headers cannot bind additional module names")
immediate = {
symbol.get_name() for symbol in table.get_symbols() if symbol.is_referenced()
}
deferred = set()
def visit(scope):
for symbol in scope.get_symbols():
if symbol.is_global():
if symbol.is_assigned() or symbol.is_imported():
raise ValueError("Selected functions cannot rebind module globals")
if symbol.is_referenced():
deferred.add(symbol.get_name())
for child in scope.get_children():
visit(child)
for child in table.get_children():
visit(child)
if (immediate | deferred) & {"eval", "exec", "globals", "locals", "vars"}:
raise ValueError("Dynamic namespace dependencies require a full migration")
return immediate, deferred
def copy_selected_functions(source, destination, names, exports):
"""Add contiguous complete functions, preserving unrelated destination bytes.
Global dependencies must be builtins, selected functions, or unambiguously
bound identical imports/simple assignments (checked transitively). Existing
helper functions and dynamic namespace dependencies require a full migration.
Scoped expressions in definition-time headers and classes anywhere inside
selected functions also require a full migration. Symbol-table analysis
distinguishes body locals, closures and comprehensions from module
dependencies; it never imports or calls the selected functions.
"""
if (
not names
or len(set(names)) != len(names)
or not all(name.isidentifier() for name in names)
):
raise ValueError("Function names must be unique identifiers")
if len(set(exports)) != len(exports) or not set(exports) <= set(names):
raise ValueError("Exports must be unique selected function names")
src, dst = ast.parse(source), ast.parse(destination)
src_bindings, dst_bindings = _module_bindings(src), _module_bindings(dst)
selected = []
for name in names:
matches = src_bindings.get(name, [])
if (
len(matches) != 1
or not isinstance(matches[0], (ast.FunctionDef, ast.AsyncFunctionDef))
or matches[0] not in src.body
or getattr(matches[0], "type_params", ())
):
raise ValueError(f"Expected one top-level non-generic function: {name}")
if dst_bindings.get(name):
raise ValueError(
f"A selected function already exists in the destination: {name}"
)
node = matches[0]
headers = (
node.decorator_list
+ node.args.defaults
+ [value for value in node.args.kw_defaults if value is not None]
)
arguments = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
arguments += [arg for arg in (node.args.vararg, node.args.kwarg) if arg]
headers += [arg.annotation for arg in arguments if arg.annotation is not None]
if node.returns is not None:
headers.append(node.returns)
if any(
isinstance(
child,
(
ast.Lambda,
ast.ListComp,
ast.SetComp,
ast.DictComp,
ast.GeneratorExp,
),
)
for header in headers
for child in ast.walk(header)
):
raise ValueError(
"Scoped expressions in function headers require a full migration"
)
if any(isinstance(child, ast.ClassDef) for child in ast.walk(node)):
raise ValueError("Classes in selected functions require a full migration")
selected.append(node)
start = src.body.index(selected[0])
if src.body[start : start + len(names)] != selected or start + len(names) == len(
src.body
):
raise ValueError("Functions must be contiguous and followed by an anchor")
first_line = min(
[selected[0].lineno, *(node.lineno for node in selected[0].decorator_list)]
)
anchor = src.body[start + len(names)]
matches = [node for node in dst.body if ast.dump(node) == ast.dump(anchor)]
if len(matches) != 1:
raise ValueError("Expected one unchanged destination anchor")
for tree, node in ((src, anchor), (dst, matches[0])):
index = tree.body.index(node)
if (
getattr(node, "decorator_list", ())
or node.col_offset
or index
and tree.body[index - 1].end_lineno >= node.lineno
or index + 1 < len(tree.body)
and tree.body[index + 1].lineno <= node.end_lineno
):
raise ValueError(
"Anchor must be undecorated and occupy complete source lines"
)
if start and src.body[start - 1].end_lineno >= first_line:
raise ValueError("Selected functions must occupy complete source lines")
source_lines, lines = source.splitlines(True), destination.splitlines(True)
futures = [
[
node
for node in tree.body
if isinstance(node, ast.ImportFrom) and node.module == "__future__"
]
for tree in (src, dst)
]
if [ast.dump(node) for node in futures[0]] != [
ast.dump(node) for node in futures[1]
]:
raise ValueError("Source and destination future imports differ")
prefix = "".join(
"".join(source_lines[node.lineno - 1 : node.end_lineno]) for node in futures[0]
)
if "*" in src_bindings or "*" in dst_bindings:
raise ValueError("Wildcard import makes dependency bindings ambiguous")
def dependency(name, source_boundary, destination_boundary):
if name in {"eval", "exec", "globals", "locals", "vars"}:
raise ValueError("Dynamic namespace dependencies require a full migration")
declarations = []
for tree, bindings, boundary in (
(src, src_bindings, source_boundary),
(dst, dst_bindings, destination_boundary),
):
bound = bindings.get(name, [])
if not bound and name in vars(builtins):
declarations.append(None)
continue
if len(bound) != 1:
raise ValueError(f"Missing or ambiguous dependency binding: {name}")
candidates = [
node
for node in tree.body
if isinstance(node, (ast.Import, ast.ImportFrom))
and bound[0] in node.names
or isinstance(node, ast.Assign)
and len(node.targets) == 1
and node.targets[0] is bound[0]
]
if len(candidates) != 1 or candidates[0].lineno >= boundary:
raise ValueError(
f"Expected an earlier import or simple assignment: {name}"
)
declarations.append(candidates[0])
left, right = declarations
if left is None and right is None:
return
if left is None or right is None or ast.dump(left) != ast.dump(right):
raise ValueError(f"Changed dependency binding: {name}")
if isinstance(left, ast.Assign):
for child in ast.walk(left.value):
if isinstance(
child,
(
ast.Lambda,
ast.NamedExpr,
ast.ListComp,
ast.SetComp,
ast.DictComp,
ast.GeneratorExp,
),
):
raise ValueError(f"Unsupported dependency expression: {name}")
if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load):
dependency(child.id, left.lineno, right.lineno)
earlier = set()
for node in selected:
line = min([node.lineno, *(item.lineno for item in node.decorator_list)])
immediate, deferred = _function_dependencies(
prefix + "".join(source_lines[line - 1 : node.end_lineno])
)
if (immediate & set(names)) - earlier:
raise ValueError("Definition-time dependency is not yet bound")
for name in (immediate | deferred) - set(names):
dependency(name, first_line, matches[0].lineno)
earlier.add(node.name)
# Keep a following anchor comment attached to its original statement. Only
# canonical function bytes and their trailing whitespace/comments are added.
end = anchor.lineno - 1
comment = next(
(
i
for i in range(selected[-1].end_lineno, end)
if source_lines[i].startswith("#")
),
end,
)
anchor_comments = source_lines[comment:end]
offset = matches[0].lineno - 1 - len(anchor_comments)
if offset < 0 or lines[offset : matches[0].lineno - 1] != anchor_comments:
raise ValueError("Anchor's leading comments differ")
additions = [(offset, source_lines[first_line - 1 : comment])]
if exports:
for tree, bindings in ((src, src_bindings), (dst, dst_bindings)):
if bindings.get("__all__") != [
_single_assignment(tree, "__all__").targets[0]
]:
raise ValueError("Ambiguous __all__ binding")
additions.append(
_selected_export_addition(src, dst, source_lines, lines, exports)
)
for offset, fragment in sorted(additions, reverse=True):
lines[offset:offset] = fragment
result = "".join(lines)
ast.parse(result)
return result
def _migrate_selected(
path, names, exports, source_hash, destination_hash, kind, copier
):
target = parse_lib_path(path)
if target.parts[:1] != ("Lib",) or ".." in target.parts or target.suffix != ".py":
raise ValueError("Selective imports require an explicit Lib Python file")
source, destination = path.read_bytes(), target.read_bytes()
for label, contents, expected in (
("source", source, source_hash),
("destination", destination, destination_hash),
):
if hashlib.sha256(contents).hexdigest() != expected:
raise ValueError(f"{label} SHA256 does not match the pinned input")
result = copier(
source.decode("utf-8"), destination.decode("utf-8"), names, exports
).encode("utf-8")
if target.read_bytes() != destination:
raise ValueError("Destination changed during selective import preparation")
target.write_bytes(result)
print(
json.dumps(
{
kind: names,
"exports": exports,
"source_sha256": source_hash,
"destination_before_sha256": destination_hash,
"destination_after_sha256": hashlib.sha256(result).hexdigest(),
},
sort_keys=True,
)
)
def migrate_selected_assignments(path, names, exports, source_hash, destination_hash):
return _migrate_selected(
path,
names,
exports,
source_hash,
destination_hash,
"assignments",
copy_selected_assignments,
)
def migrate_selected_functions(path, names, exports, source_hash, destination_hash):
return _migrate_selected(
path,
names,
exports,
source_hash,
destination_hash,
"functions",
copy_selected_functions,
)
def patch_single_content(
src_path: pathlib.Path,
lib_path: pathlib.Path,
@@ -913,159 +346,6 @@ def migrate_package(
legacy.unlink()
def remove_canonical_files(src_dir, manifest_path, *, dry_run=False):
"""Remove only explicitly reviewed files, validating the whole batch first."""
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f"Duplicate removal metadata key: {key}")
result[key] = value
return result
def relative(name):
path = pathlib.PurePosixPath(name) if isinstance(name, str) else None
if (
not path
or not path.parts
or path.is_absolute()
or ".." in path.parts
or "\\" in name
or path.as_posix() != name
or "\0" in name
):
raise ValueError("Expected an exact relative POSIX filename")
return pathlib.Path(path)
def no_symlinks(path):
if any(p.is_symlink() or p.is_junction() for p in (path, *path.parents)):
raise ValueError(f"Symlink or junction in removal path: {path}")
def git(*args):
result = subprocess.run(
["git", "-C", str(src_dir), *args],
env={
**os.environ,
"GIT_NO_LAZY_FETCH": "1",
"GIT_NO_REPLACE_OBJECTS": "1",
"GIT_TERMINAL_PROMPT": "0",
},
capture_output=True,
check=False,
)
if result.returncode:
raise ValueError("Cannot verify pinned canonical source")
return result.stdout.decode().strip()
document = json.loads(
manifest_path.read_text(encoding="utf-8"), object_pairs_hook=unique_object
)
if not isinstance(document, dict) or set(document) != {"source", "removals"}:
raise ValueError("Expected source and removals metadata")
source, removals = document["source"], document["removals"]
if not isinstance(source, dict) or set(source) != {"path", "commit", "tree"}:
raise ValueError("Expected source path, commit and tree")
package = relative(source["path"])
if (
len(package.parts) != 3
or package.parts[:2] != ("Lib", "test")
or not package.name.startswith("test_")
or not package.name.isidentifier()
):
raise ValueError("Removals require an exact Lib/test/test_* package")
if any(
not isinstance(source[k], str) or not re.fullmatch(r"[0-9a-f]{40}", source[k])
for k in ("commit", "tree")
):
raise ValueError("Source commit and tree must be full Git hashes")
if not isinstance(removals, dict) or not removals:
raise ValueError("Removals must be a nonempty filename map")
src_dir = src_dir.absolute()
destination = pathlib.Path.cwd() / package
for path in (src_dir, destination):
no_symlinks(path)
if not path.is_dir():
raise ValueError("Source and destination packages must exist")
if (
src_dir == destination
or src_dir in destination.parents
or destination in src_dir.parents
):
raise ValueError("Source and destination overlap")
if (
src_dir != pathlib.Path(git("rev-parse", "--show-toplevel")) / package
or git("rev-parse", "HEAD") != source["commit"]
or git("rev-parse", f"HEAD:{package.as_posix()}") != source["tree"]
):
raise ValueError("Canonical source identity differs from the pinned input")
canonical = set(
git(
"ls-tree",
"--full-tree",
"-r",
"--name-only",
"-z",
"HEAD",
"--",
package.as_posix(),
).split("\0")
)
snapshots, receipt = {}, {"source": source, "dry_run": dry_run, "removals": {}}
for name, entry in sorted(removals.items()):
path = relative(name)
if (
not isinstance(entry, dict)
or set(entry) != {"sha256", "upstream_commit"}
or any(
not isinstance(entry[k], str)
or not re.fullmatch(f"[0-9a-f]{{{length}}}", entry[k])
for k, length in (("sha256", 64), ("upstream_commit", 40))
)
):
raise ValueError(f"Missing or invalid hash/provenance pins: {name}")
target = destination / path
no_symlinks(target)
no_symlinks(src_dir / path)
if (package / path).as_posix() in canonical or (src_dir / path).exists():
raise ValueError(f"Selected file is still canonical: {name}")
if target.exists() and not target.is_file():
raise ValueError(f"Removal target is not a regular file: {name}")
data = target.read_bytes() if target.exists() else None
if data is not None and (
hashlib.sha256(data).hexdigest() != entry["sha256"]
or b"rustpython" in data.lower()
):
raise ValueError(f"Changed bytes or RustPython customization: {name}")
snapshots[target] = data
receipt["removals"][name] = {
**entry,
"status": "already_absent"
if data is None
else "would_remove"
if dry_run
else "removed",
}
if git("rev-parse", "HEAD") != source["commit"]:
raise ValueError("Canonical source changed during validation")
for target, data in snapshots.items():
no_symlinks(target)
canonical_path = src_dir / target.relative_to(destination)
no_symlinks(canonical_path)
if canonical_path.exists():
raise ValueError("Canonical source changed during validation")
if (target.exists() and not target.is_file()) or (
target.read_bytes() if target.exists() else None
) != data:
raise ValueError(f"Removal target changed during validation: {target}")
if not dry_run:
for target, data in snapshots.items():
if data is not None:
target.unlink()
print(json.dumps(receipt, sort_keys=True))
return receipt
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__,
@@ -1082,88 +362,14 @@ def main(argv: list[str] | None = None) -> int:
type=pathlib.Path,
help="Explicit legacy-file/class to package-file JSON mapping",
)
parser.add_argument(
"--canonical-removals",
type=pathlib.Path,
help="Remove only selected unchanged historical files absent from a pinned canonical tree",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate canonical removals without deleting",
)
parser.add_argument(
"--patches",
type=pathlib.Path,
help="Single-file target, remaining decorator patches, and optional doctests JSON",
)
parser.add_argument(
"--assignments",
nargs="+",
help="Copy selected canonical assignments",
)
parser.add_argument(
"--functions",
nargs="+",
help="Copy complete selected canonical functions without executing them",
)
parser.add_argument("--exports", nargs="+", default=[])
parser.add_argument("--source-sha256")
parser.add_argument("--destination-sha256")
args = parser.parse_args(argv)
try:
if args.canonical_removals is not None:
if (
args.legacy_map is not None
or args.patches is not None
or args.assignments
or args.functions
or args.exports
or args.source_sha256
or args.destination_sha256
):
raise ValueError(
"Canonical removals cannot combine with other migration modes"
)
try:
remove_canonical_files(
args.path, args.canonical_removals, dry_run=args.dry_run
)
except (OSError, UnicodeError) as exc:
raise ValueError(str(exc)) from exc
return 0
if args.dry_run:
raise ValueError("--dry-run requires --canonical-removals")
if args.assignments or args.functions:
if (
args.legacy_map is not None
or args.patches is not None
or args.assignments
and args.functions
):
raise ValueError(
"Selective imports cannot combine with other migration modes"
)
try:
migrate = (
migrate_selected_functions
if args.functions
else migrate_selected_assignments
)
migrate(
args.path,
args.functions or args.assignments,
args.exports,
args.source_sha256,
args.destination_sha256,
)
except (SyntaxError, UnicodeError) as exc:
raise ValueError(str(exc)) from exc
return 0
if args.exports or args.source_sha256 or args.destination_sha256:
raise ValueError("Selective options require --assignments or --functions")
if args.patches is not None and (
args.path.is_dir() or args.legacy_map is not None
):
-206
View File
@@ -1,206 +0,0 @@
"""Tests for bounded, hash-pinned canonical test copying."""
import ast
import contextlib
import hashlib
import io
import tempfile
import unittest
from pathlib import Path
from update_lib.cmd_copy_test import copy_test, main
class TestCopyTest(unittest.TestCase):
destination = '''# Destination-only header
class Finder:
if int.__doc__:
def example():
r"""Count is below 800. π\\n"""
def untouched(self):
return "destination"
'''
source = '''# Canonical header must not be copied
class Finder:
if int.__doc__:
def example():
r"""Count is below 850. π\\n"""
def untouched(self):
return "canonical"
'''
def test_complete_definition_and_surroundings(self):
result = copy_test(self.source, self.destination, "Finder.example")
self.assertEqual(result, self.destination.replace("below 800", "below 850"))
self.assertIn('return "destination"', result)
source_method = ast.parse(self.source).body[0].body[0].body[0]
result_method = ast.parse(result).body[0].body[0].body[0]
self.assertEqual(
ast.get_source_segment(self.source, source_method),
ast.get_source_segment(result, result_method),
)
def test_guard_mismatch_is_rejected(self):
with self.assertRaisesRegex(ValueError, "guards differ"):
copy_test(
self.source.replace("if int.__doc__", "if enabled"),
self.destination,
"Finder.example",
)
def test_missing_or_ambiguous_definition_is_rejected(self):
for source in ("class Other: pass\n", self.source + self.source):
with self.subTest(source=source):
with self.assertRaisesRegex(ValueError, "Expected one class"):
copy_test(source, self.destination, "Finder.example")
duplicate = self.source.replace(
" def untouched(self):",
" def example():\n pass\n\n def untouched(self):",
)
with self.assertRaisesRegex(ValueError, "Expected one method"):
copy_test(duplicate, self.destination, "Finder.example")
def test_decorator_and_signature_changes_are_rejected(self):
decorated = self.source.replace(
" def example():", " @decorator\n def example():"
)
with self.assertRaisesRegex(ValueError, "decorators differ"):
copy_test(decorated, self.destination, "Finder.example")
with self.assertRaisesRegex(ValueError, "signatures differ"):
copy_test(
self.source.replace("def example():", "def example(arg):"),
self.destination,
"Finder.example",
)
def test_nested_local_scope_is_not_selected(self):
local = "def factory():\n" + "".join(
" " + line for line in self.source.splitlines(True)
)
with self.assertRaisesRegex(ValueError, "Expected one class"):
copy_test(local, self.destination, "Finder.example")
def test_decorator_comments_must_match_exactly(self):
for source_marker, destination_marker in (
(
"@unittest.expectedFailure",
"@unittest.expectedFailure # TODO: RUSTPYTHON; reason",
),
(
"@unittest.expectedFailure # original reason",
"@unittest.expectedFailure # changed reason",
),
(
"@unittest.expectedFailure\n @other",
"@unittest.expectedFailure\n # marker rationale\n @other",
),
(
"@unittest.expectedFailureIf(\n condition,\n 'reason',\n )",
"@unittest.expectedFailureIf(\n condition, # project limitation\n 'reason',\n )",
),
):
with self.subTest(destination_marker=destination_marker):
source = self.source.replace(
" def example():",
f" {source_marker}\n def example():",
)
destination = self.destination.replace(
" def example():",
f" {destination_marker}\n def example():",
)
with self.assertRaisesRegex(ValueError, "decorator text differs"):
copy_test(source, destination, "Finder.example")
def test_identical_decorator_comments_are_preserved(self):
prefix = (
" @unittest.expectedFailure # TODO: RUSTPYTHON; reason\n"
" # Keep this rationale with the marker.\n"
" def example():"
)
for declaration in ("def example():", "async def example():"):
with self.subTest(declaration=declaration):
source = self.source.replace(" def example():", prefix)
destination = self.destination.replace(" def example():", prefix)
source = source.replace("def example():", declaration)
destination = destination.replace("def example():", declaration)
self.assertEqual(
copy_test(source, destination, "Finder.example"),
destination.replace("below 800", "below 850"),
)
def test_cli_does_not_write_on_decorator_comment_mismatch(self):
with tempfile.TemporaryDirectory() as temp:
source = Path(temp) / "source.py"
destination = Path(temp) / "destination.py"
source.write_text(
self.source.replace(
" def example():",
" @unittest.expectedFailure\n def example():",
)
)
destination.write_text(
self.destination.replace(
" def example():",
" @unittest.expectedFailure # TODO: RUSTPYTHON; reason\n"
" def example():",
)
)
before = destination.read_bytes()
with (
contextlib.redirect_stderr(io.StringIO()) as errors,
self.assertRaises(SystemExit),
):
main(
[
str(source),
str(destination),
"--test",
"Finder.example",
"--source-sha256",
hashlib.sha256(source.read_bytes()).hexdigest(),
"--destination-sha256",
hashlib.sha256(before).hexdigest(),
]
)
self.assertIn("decorator text differs", errors.getvalue())
self.assertEqual(destination.read_bytes(), before)
def test_return_annotation_and_type_parameters_are_rejected(self):
for declaration in ("def example() -> int:", "def example[T]():"):
with self.subTest(declaration=declaration):
source = self.source.replace("def example():", declaration)
with self.assertRaisesRegex(ValueError, "signatures differ"):
copy_test(source, self.destination, "Finder.example")
def test_cli_hashes_and_no_write_on_stale_input(self):
with tempfile.TemporaryDirectory() as temp:
source = Path(temp) / "source.py"
destination = Path(temp) / "destination.py"
source.write_bytes(self.source.encode())
destination.write_bytes(self.destination.encode())
args = [
str(source),
str(destination),
"--test",
"Finder.example",
"--source-sha256",
hashlib.sha256(source.read_bytes()).hexdigest(),
"--destination-sha256",
"0" * 64,
]
with (
contextlib.redirect_stderr(io.StringIO()),
self.assertRaises(SystemExit),
):
main(args)
self.assertEqual(destination.read_bytes(), self.destination.encode())
args[-1] = hashlib.sha256(destination.read_bytes()).hexdigest()
with contextlib.redirect_stdout(io.StringIO()) as output:
self.assertEqual(main(args), 0)
self.assertIn('"source_sha256"', output.getvalue())
self.assertEqual(
destination.read_text(),
self.destination.replace("below 800", "below 850"),
)
-495
View File
@@ -1,12 +1,9 @@
"""Tests for migrate.py - file migration operations."""
import contextlib
import hashlib
import io
import json
import os
import pathlib
import subprocess
import tempfile
import unittest
from pathlib import Path
@@ -14,13 +11,11 @@ from unittest.mock import patch
from update_lib.cmd_migrate import (
_preserve_block,
copy_selected_assignments,
main,
migrate_package,
patch_directory,
patch_file,
patch_single_content,
remove_canonical_files,
)
from update_lib.patch_spec import (
CANONICAL_LOAD_TESTS,
@@ -34,233 +29,6 @@ from update_lib.patch_spec import (
)
class TestSelectedAssignments(unittest.TestCase):
names = ["_available", "requires_available", "requires_unavailable"]
exports = names[1:]
imports = "import os\nimport unittest\n\n"
export_line = ' "requires_available", "requires_unavailable",\n'
block = (
"_available = hasattr(os, 'geteuid') and os.geteuid() == 0\n"
'requires_available = unittest.skipUnless(_available, "needs privilege")\n'
'requires_unavailable = unittest.skipIf(_available, "needs no privilege")\n\n\n'
)
source = (
imports
+ '__all__ = [\n "existing",\n'
+ export_line
+ "]\n\n\n"
+ block
+ "STATUS = 1\n"
)
destination = (
imports
+ '__all__ = [\n "existing",\n]\n\n\ndef legacy():\n return "keep π"\n\n\nSTATUS = 1\n'
)
def test_canonical_bytes_and_unrelated_helpers_are_preserved(self):
result = copy_selected_assignments(
self.source, self.destination, self.names, self.exports
)
self.assertEqual(
result,
self.destination.replace(
"__all__ = [\n", "__all__ = [\n" + self.export_line
).replace("STATUS = 1\n", self.block + "STATUS = 1\n"),
)
def test_missing_ambiguous_and_noncontiguous_assignments_are_rejected(self):
for source in (
self.source.replace("_available =", "_other ="),
self.source + "_available = False\n",
self.source + "def requires_available(): pass\n",
self.source + "import other as requires_unavailable\n",
self.source.replace(
"requires_available =", "unselected = 0\nrequires_available ="
),
self.source.replace("_available =", "unselected = 0; _available =", 1),
self.source.replace("STATUS = 1", ""),
):
with self.subTest(source=source), self.assertRaises(ValueError):
copy_selected_assignments(
source, self.destination, self.names, self.exports
)
def test_existing_bindings_dependencies_and_anchor_fail_closed(self):
for destination in (
self.destination + "_available = False\n",
self.destination + "def requires_available(): pass\n",
self.destination + "import other as requires_unavailable\n",
self.destination.replace("import os\n", ""),
self.destination.replace("import os\n", "import os as other\n"),
self.destination.replace("import os\n", "") + "import os\n",
self.destination.replace("STATUS = 1", "STATUS = 2"),
self.destination + "STATUS = 1\n",
):
with self.subTest(destination=destination), self.assertRaises(ValueError):
copy_selected_assignments(
self.source, destination, self.names, self.exports
)
def test_invalid_exports_are_rejected(self):
for source, destination, exports in (
(self.source, self.destination, self.exports + self.exports),
(self.source, self.destination, ["unselected"]),
(self.source, self.destination, list(reversed(self.exports))),
(self.source, self.destination, self.exports[:1]),
(
self.source,
self.destination.replace('"existing"', '"requires_available"'),
self.exports,
),
(self.source.replace(self.export_line, ""), self.destination, self.exports),
(
self.source,
self.destination.replace('[\n "existing",\n]', '("existing",)'),
self.exports,
),
(self.source.replace('"existing"', "3"), self.destination, self.exports),
(self.source, self.destination.replace('"existing"', "3"), self.exports),
):
with self.subTest(exports=exports), self.assertRaises(ValueError):
copy_selected_assignments(source, destination, self.names, exports)
def test_dependency_rebinding_deletion_and_builtin_shadowing_are_rejected(self):
for binding in (
"os = replacement",
"del os",
"import other as os",
"hasattr = replacement",
"del hasattr",
"if condition:\n unittest = replacement",
):
for source_side in (False, True):
source, destination = self.source, self.destination
if source_side:
source = source.replace("__all__ = [", binding + "\n__all__ = [")
else:
destination = destination.replace(
"__all__ = [", binding + "\n__all__ = ["
)
with (
self.subTest(binding=binding, source_side=source_side),
self.assertRaisesRegex(ValueError, "Ambiguous dependency binding"),
):
copy_selected_assignments(
source, destination, self.names, self.exports
)
def test_decorated_and_partial_line_anchors_are_rejected(self):
for replacement in (
"@decorator\ndef anchor(): pass",
"STATUS = 1; other = 2",
"other = 2; STATUS = 1",
):
with self.subTest(replacement=replacement), self.assertRaises(ValueError):
copy_selected_assignments(
self.source.replace("STATUS = 1", replacement),
self.destination.replace("STATUS = 1", replacement),
self.names,
self.exports,
)
def test_wildcard_import_dependencies_are_rejected(self):
for source, destination in (
("from other import *\n" + self.source, self.destination),
(self.source, "from other import *\n" + self.destination),
):
with self.assertRaisesRegex(ValueError, "Wildcard import"):
copy_selected_assignments(source, destination, self.names, self.exports)
def test_cli_hash_pins_scope_and_receipt(self):
with tempfile.TemporaryDirectory() as temp, contextlib.chdir(temp):
source = pathlib.Path("canonical/Lib/example.py")
target = pathlib.Path("Lib/example.py")
source.parent.mkdir(parents=True)
target.parent.mkdir(parents=True)
source.write_text(self.source)
target.write_text(self.destination)
args = [
str(source),
"--assignments",
*self.names,
"--exports",
*self.exports,
"--source-sha256",
hashlib.sha256(source.read_bytes()).hexdigest(),
"--destination-sha256",
"0" * 64,
]
with contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(main(args), 1)
self.assertEqual(main([str(source), "--assignments", *self.names]), 1)
self.assertEqual(target.read_text(), self.destination)
args[-1] = hashlib.sha256(target.read_bytes()).hexdigest()
with contextlib.redirect_stdout(io.StringIO()) as output:
self.assertEqual(main(args), 0)
self.assertIn('"destination_after_sha256"', output.getvalue())
self.assertIn(self.block, target.read_text())
for path in (
"canonical/Lib/example.txt",
"canonical/Lib/../outside.py",
):
with contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(main([path, *args[1:]]), 1)
def test_selective_mode_rejects_existing_modes_before_reading_metadata(self):
for mode in ("--patches", "--legacy-map"):
with contextlib.redirect_stderr(io.StringIO()) as errors:
self.assertEqual(
main(
[
"missing/Lib/example.py",
"--assignments",
*self.names,
mode,
"missing-metadata.json",
]
),
1,
)
self.assertIn("cannot combine", errors.getvalue())
with contextlib.redirect_stderr(io.StringIO()) as errors:
self.assertEqual(
main(
[
"missing/Lib/example.py",
"--exports",
*self.exports,
mode,
"missing-metadata.json",
]
),
1,
)
self.assertIn("require --assignments", errors.getvalue())
def test_default_syntax_error_behavior_is_unchanged(self):
with patch(
"update_lib.cmd_migrate.patch_file", side_effect=SyntaxError("invalid")
):
with self.assertRaises(SyntaxError):
main(["missing/Lib/example.py"])
with patch(
"update_lib.cmd_migrate.migrate_selected_assignments",
side_effect=SyntaxError("invalid"),
):
with contextlib.redirect_stderr(io.StringIO()) as errors:
self.assertEqual(
main(
[
"missing/Lib/example.py",
"--assignments",
*self.names,
]
),
1,
)
self.assertIn("invalid", errors.getvalue())
class TestPatchSingleContent(unittest.TestCase):
"""Tests for patch_single_content function."""
@@ -1057,268 +825,5 @@ class TestMigratePackage(unittest.TestCase):
self.assert_rejected()
class TestCanonicalRemovals(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.source = self.root / "canonical/Lib/test/test_example"
self.target = self.root / "local/Lib/test/test_example"
self.source.mkdir(parents=True)
self.target.mkdir(parents=True)
(self.source / "__init__.py").write_bytes(b"")
self.git("init", "-q")
self.git("add", ".")
self.git("commit", "-qm", "canonical test fixture")
self.document = {
"source": {
"path": "Lib/test/test_example",
"commit": self.git("rev-parse", "HEAD"),
"tree": self.git("rev-parse", "HEAD:Lib/test/test_example"),
},
"removals": {},
}
for name, data in (("a.py", b"old\n"), ("data/z.bin", b"\x00\xff")):
path = self.target / name
path.parent.mkdir(exist_ok=True)
path.write_bytes(data)
self.document["removals"][name] = {
"sha256": hashlib.sha256(data).hexdigest(),
"upstream_commit": "1" * 40,
}
self.manifest = self.root / "removals.json"
def git(self, *args):
return subprocess.check_output(
["git", "-C", str(self.source.parents[2]), *args],
env={
**os.environ,
"GIT_AUTHOR_NAME": "Fixture",
"GIT_AUTHOR_EMAIL": "fixture@example.invalid",
"GIT_COMMITTER_NAME": "Fixture",
"GIT_COMMITTER_EMAIL": "fixture@example.invalid",
},
text=True,
).strip()
def run_removal(self, dry_run=False):
self.manifest.write_text(json.dumps(self.document))
with (
contextlib.chdir(self.target.parents[2]),
contextlib.redirect_stdout(io.StringIO()),
):
return remove_canonical_files(self.source, self.manifest, dry_run=dry_run)
def test_dry_run_explicit_scope_receipt_and_idempotence(self):
(self.target / "unselected").write_text("keep")
for dry_run, expected in (
(True, "would_remove"),
(False, "removed"),
(False, "already_absent"),
):
receipt = self.run_removal(dry_run)
self.assertEqual(receipt["source"], self.document["source"])
self.assertEqual(
{v["status"] for v in receipt["removals"].values()}, {expected}
)
self.assertEqual((self.target / "a.py").exists(), dry_run)
self.assertEqual((self.target / "unselected").read_text(), "keep")
def test_junction_components_reject_the_whole_batch(self):
before = {
name: (self.target / name).read_bytes()
for name in self.document["removals"]
}
for junction in (
self.source,
self.source.parent,
self.source / "data",
self.target,
self.target.parent,
self.target / "data",
):
for dry_run in (True, False):
with self.subTest(junction=junction, dry_run=dry_run):
self.assertFalse(junction.is_symlink())
with (
patch.object(
Path,
"is_junction",
autospec=True,
side_effect=lambda path: path == junction,
),
self.assertRaisesRegex(ValueError, "[Jj]unction"),
):
self.run_removal(dry_run)
self.assertEqual(
{name: (self.target / name).read_bytes() for name in before},
before,
)
def test_junction_revalidation_prevents_partial_removal(self):
for junction in (self.target / "data", self.source / "data"):
checks = 0
def becomes_junction(path):
nonlocal checks
if path == junction:
checks += 1
return checks > 1
return False
with self.subTest(junction=junction):
with (
patch.object(
Path, "is_junction", autospec=True, side_effect=becomes_junction
),
self.assertRaisesRegex(ValueError, "[Jj]unction"),
):
self.run_removal()
self.assertEqual(checks, 2)
self.assertEqual((self.target / "a.py").read_bytes(), b"old\n")
self.assertEqual((self.target / "data/z.bin").read_bytes(), b"\x00\xff")
def test_utf8_manifest_ignores_non_utf8_default_encoding(self):
name = "caf\u00e9.py"
target = self.target / name
target.write_bytes(b"old\n")
self.document["removals"][name] = dict(self.document["removals"]["a.py"])
self.manifest.write_bytes(
json.dumps(self.document, ensure_ascii=False).encode("utf-8")
)
with (
patch(
"pathlib.io.text_encoding",
side_effect=lambda encoding, *args: (
"latin-1" if encoding is None else encoding
),
),
contextlib.chdir(self.target.parents[2]),
contextlib.redirect_stdout(io.StringIO()),
):
self.assertNotEqual(
self.manifest.read_text(), self.manifest.read_text(encoding="utf-8")
)
for dry_run, expected in (
(True, "would_remove"),
(False, "removed"),
(False, "already_absent"),
):
receipt = remove_canonical_files(
self.source, self.manifest, dry_run=dry_run
)
self.assertEqual(
set(receipt["removals"]), set(self.document["removals"])
)
self.assertEqual(receipt["removals"][name]["status"], expected)
self.assertEqual(target.exists(), dry_run)
def test_cli_dry_run_never_migrates_or_changes_destination_bytes(self):
def snapshot():
return {
p.relative_to(self.target): p.read_bytes()
for p in self.target.rglob("*")
if p.is_file()
}
before = snapshot()
self.manifest.write_text(json.dumps(self.document))
with contextlib.ExitStack() as stack:
stack.enter_context(contextlib.chdir(self.target.parents[2]))
stack.enter_context(contextlib.redirect_stdout(io.StringIO()))
for name in (
"patch_file",
"patch_directory",
"migrate_package",
"migrate_selected_assignments",
):
stack.enter_context(
patch(
f"update_lib.cmd_migrate.{name}",
side_effect=AssertionError("Dry-run must not migrate"),
)
)
self.assertEqual(
main(
[
str(self.source),
"--canonical-removals",
str(self.manifest),
"--dry-run",
]
),
0,
)
self.assertEqual(snapshot(), before)
def test_hash_customization_and_all_batch_preflight(self):
path = self.target / "data/z.bin"
for data, repin in ((b"changed", False), (b"# TODO: RUSTPYTHON", True)):
path.write_bytes(data)
if repin:
self.document["removals"]["data/z.bin"]["sha256"] = hashlib.sha256(
data
).hexdigest()
with self.assertRaises(ValueError):
self.run_removal()
self.assertEqual((self.target / "a.py").read_bytes(), b"old\n")
def test_canonical_source_identity_and_tracked_absence(self):
for key, value in (
("commit", "0" * 40),
("tree", "0" * 40),
("path", "Lib/test/test_other"),
):
old = self.document["source"][key]
self.document["source"][key] = value
with self.subTest(key=key), self.assertRaises(ValueError):
self.run_removal()
self.document["source"][key] = old
(self.source / "__init__.py").unlink()
self.document["removals"]["__init__.py"] = self.document["removals"]["a.py"]
with self.assertRaisesRegex(ValueError, "still canonical"):
self.run_removal()
self.assertTrue((self.target / "a.py").exists())
def test_invalid_paths_symlinks_directories_and_empty_selection(self):
original = self.document["removals"]
for name in ("../a.py", "/a.py", "./a.py", "data//z.bin", "data\\z.bin"):
self.document["removals"] = {name: original["a.py"]}
with self.subTest(name=name), self.assertRaises(ValueError):
self.run_removal()
self.document["removals"] = {}
with self.assertRaises(ValueError):
self.run_removal()
self.document["removals"] = original
target = self.target / "a.py"
target.unlink()
target.symlink_to(self.target / "data/z.bin")
with self.assertRaisesRegex(ValueError, "Symlink"):
self.run_removal()
target.unlink()
target.mkdir()
with self.assertRaisesRegex(ValueError, "regular file"):
self.run_removal()
def test_cli_refuses_combined_modes_and_duplicate_metadata(self):
for option in ("--patches", "--legacy-map", "--assignments"):
with contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(
main(
[
str(self.source),
"--canonical-removals",
"unused",
option,
"unused",
]
),
1,
)
self.manifest.write_text('{"source": {}, "source": {}}')
with self.assertRaisesRegex(ValueError, "Duplicate"):
remove_canonical_files(self.source, self.manifest)
if __name__ == "__main__":
unittest.main()
@@ -1,320 +0,0 @@
"""Source-only selective function migration, without importing copied helpers."""
import contextlib
import hashlib
import io
import json
import pathlib
import tempfile
import unittest
from unittest.mock import patch
from update_lib.cmd_migrate import copy_selected_functions, main
class TestSelectedFunctions(unittest.TestCase):
names = ["available", "requires_available"]
exports = names
imports = "import functools\nimport os\nimport sys\nimport unittest\n\n"
export_line = ' "available", "requires_available",\n'
dependencies = 'IS_MOBILE = sys.platform == "mobile"\nAVAILABLE = not IS_MOBILE\n\n'
block = (
"@functools.cache\n"
"def available(limit=1):\n"
" import errno\n"
" try:\n"
' return AVAILABLE and bool(errno.EINVAL) and os.name != "" and limit > 0\n'
" except ValueError as error:\n"
" return bool(str(error))\n\n\n"
"def requires_available():\n"
' return unittest.skipUnless(available(), "needs availability")\n\n\n'
)
anchor = "# Keep this comment with STATUS.\nSTATUS = 1\n"
source = (
imports
+ '__all__ = [\n "existing",\n'
+ export_line
+ "]\n\n"
+ dependencies
+ block
+ anchor
)
destination = (
imports
+ '__all__ = [\n "existing",\n]\n\n'
+ dependencies
+ 'def existing(sys):\n return "keep π"\n\n'
+ anchor
)
def copy(self, source=None, destination=None, names=None, exports=None):
return copy_selected_functions(
self.source if source is None else source,
self.destination if destination is None else destination,
self.names if names is None else names,
self.exports if exports is None else exports,
)
def test_exact_functions_exports_and_unrelated_bytes(self):
self.assertEqual(
self.copy(),
self.destination.replace(
"__all__ = [\n", "__all__ = [\n" + self.export_line
).replace(self.anchor, self.block + self.anchor),
)
self.assertEqual(self.copy().count(self.anchor), 1)
def test_destination_line_endings_and_local_adaptations_survive(self):
source = self.source.replace(self.anchor, "STATUS = 1\n")
destination = self.destination.replace(self.anchor, "STATUS = 1\n").replace(
"\n", "\r\n"
)
self.assertEqual(
self.copy(source, destination),
destination.replace(
"__all__ = [\r\n", "__all__ = [\r\n" + self.export_line
).replace("STATUS = 1\r\n", self.block + "STATUS = 1\r\n"),
)
def test_local_imports_closures_and_comprehensions_are_not_global_dependencies(
self,
):
block = (
"@functools.cache\n"
"def available(items=()):\n"
" def inner(value):\n"
" return value in items\n"
" return [inner(value) for value in items]\n\n\n"
"async def requires_available():\n"
" import errno as local\n"
" return local.EINVAL\n\n\n"
)
self.assertIn(block, self.copy(self.source.replace(self.block, block)))
with self.assertRaises(ValueError):
self.copy(
self.source.replace(
self.block, block.replace("value in items", "MISSING", 1)
)
)
def test_multiple_decorators_defaults_and_body_are_copied_without_execution(self):
block = self.block.replace(
"@functools.cache\ndef available(limit=1):",
"@functools.cache\n@functools.lru_cache(\n maxsize=None\n)\n"
"def available(limit=os.name):",
).replace(
" import errno\n",
" raise RuntimeError('must not run')\n import errno\n",
)
self.assertIn(block, self.copy(self.source.replace(self.block, block)))
def test_selection_and_existing_bindings_fail_closed(self):
for source, destination, names in (
(self.source, self.destination, []),
(self.source, self.destination, self.names * 2),
(self.source, self.destination, list(reversed(self.names))),
(self.source, self.destination, ["missing"]),
(self.source + "available = None\n", self.destination, self.names),
(self.source, self.destination + "del available\n", self.names),
(self.source, self.destination + "import other as available\n", self.names),
(
self.source,
self.destination + "if condition:\n available = None\n",
self.names,
),
(
self.source.replace(
"def requires_available", "UNSELECTED = 1\ndef requires_available"
),
self.destination,
self.names,
),
(self.source.replace(self.anchor, ""), self.destination, self.names),
):
with self.subTest(names=names), self.assertRaises(ValueError):
self.copy(source, destination, names)
def test_missing_changed_late_and_rebound_dependencies_fail_closed(self):
for destination in (
self.destination.replace("import functools\n", ""),
self.destination.replace(
"import functools\n", "import other as functools\n"
),
self.destination.replace("import os\n", "") + "import os\n",
self.destination.replace(
'sys.platform == "mobile"', 'sys.platform == "other"'
),
self.destination + "AVAILABLE = True\n",
self.destination + "del sys\n",
self.destination + "bool = None\n",
self.destination + "def rebind():\n global sys\n sys = None\n",
self.destination + "from unknown import *\n",
):
with self.subTest(destination=destination), self.assertRaises(ValueError):
self.copy(destination=destination)
for source in (
self.source + "del sys\n",
self.source + "from unknown import *\n",
self.source.replace("AVAILABLE = not IS_MOBILE", "AVAILABLE = MISSING"),
self.source.replace("limit=1", "limit=requires_available()"),
):
with self.subTest(source=source), self.assertRaises(ValueError):
self.copy(source=source)
def test_unsupported_namespace_changes_and_future_mismatch_fail_closed(self):
for statement in (
"global AVAILABLE\n AVAILABLE = True",
"return globals()",
"return eval('AVAILABLE')",
):
with self.subTest(statement=statement), self.assertRaises(ValueError):
self.copy(
self.source.replace(
" import errno", " " + statement + "\n import errno"
)
)
with self.assertRaises(ValueError):
self.copy("from __future__ import annotations\n" + self.source)
with self.assertRaises(ValueError):
self.copy(self.source.replace("limit=1", "limit=(EXTRA := 1)"))
def test_anchor_ambiguity_partial_lines_and_changed_comments_fail_closed(self):
for destination in (
self.destination.replace("STATUS = 1", "STATUS = 2"),
self.destination + "STATUS = 1\n",
self.destination.replace("STATUS = 1", "STATUS = 1; other = 2"),
self.destination.replace("STATUS = 1", "other = 2; STATUS = 1"),
self.destination.replace(
"# Keep this comment with STATUS.", "# Changed comment."
),
):
with self.subTest(destination=destination), self.assertRaises(ValueError):
self.copy(destination=destination)
replacement = "@functools.cache\ndef anchor(): pass\n"
with self.assertRaises(ValueError):
self.copy(
self.source.replace("STATUS = 1\n", replacement),
self.destination.replace("STATUS = 1\n", replacement),
)
def test_exports_require_complete_canonical_lines(self):
for exports in (
self.exports[:1],
list(reversed(self.exports)),
self.exports * 2,
["other"],
):
with self.subTest(exports=exports), self.assertRaises(ValueError):
self.copy(exports=exports)
with self.assertRaises(ValueError):
self.copy(destination=self.destination + "del __all__\n")
self.assertNotIn(self.export_line, self.copy(exports=[]))
def test_cli_hash_pins_receipt_and_concurrent_destination_guard(self):
with tempfile.TemporaryDirectory() as temp, contextlib.chdir(temp):
source = pathlib.Path("canonical/Lib/example.py")
destination = pathlib.Path("Lib/example.py")
source.parent.mkdir(parents=True)
destination.parent.mkdir(parents=True)
source.write_text(self.source)
destination.write_text(self.destination)
args = [
str(source),
"--functions",
*self.names,
"--exports",
*self.exports,
"--source-sha256",
hashlib.sha256(source.read_bytes()).hexdigest(),
"--destination-sha256",
"0" * 64,
]
with contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(main(args), 1)
self.assertEqual(destination.read_text(), self.destination)
args[-1] = hashlib.sha256(destination.read_bytes()).hexdigest()
with contextlib.redirect_stdout(io.StringIO()) as output:
self.assertEqual(main(args), 0)
receipt = json.loads(output.getvalue())
self.assertEqual(receipt["functions"], self.names)
self.assertEqual(destination.read_text(), self.copy())
destination.write_text(self.destination)
def concurrent_change(*unused):
destination.write_text("# changed meanwhile\n")
return self.copy()
with patch(
"update_lib.cmd_migrate.copy_selected_functions", concurrent_change
):
with contextlib.redirect_stderr(io.StringIO()) as errors:
self.assertEqual(main(args), 1)
self.assertIn("Destination changed", errors.getvalue())
self.assertEqual(destination.read_text(), "# changed meanwhile\n")
def test_cli_modes_are_exclusive_before_reading_inputs(self):
for options in (
["--assignments", "value"],
["--patches", "missing.json"],
["--legacy-map", "missing.json"],
["--canonical-removals", "missing.json"],
):
with (
self.subTest(options=options),
contextlib.redirect_stderr(io.StringIO()) as errors,
):
self.assertEqual(
main(
["missing/Lib/example.py", "--functions", *self.names, *options]
),
1,
)
self.assertIn("cannot combine", errors.getvalue())
def test_scoped_header_expressions_fail_closed(self):
for header in (
"def available(limit=(lambda: requires_available())()):",
"def available(limit=[requires_available() for _ in (0,)]):",
"def available(limit={requires_available() for _ in (0,)}):",
"def available(limit={0: requires_available() for _ in (0,)}):",
"def available(limit=(requires_available() for _ in (0,))):",
"def available(*, limit=(lambda: requires_available())()):",
"def available(limit: (lambda: requires_available())() = 1):",
):
with (
self.subTest(header=header),
self.assertRaisesRegex(
ValueError, "Scoped expressions in function headers"
),
):
self.copy(self.source.replace("def available(limit=1):", header))
for decorator in (
"@(lambda: requires_available())()",
"@[requires_available for _ in (0,)][0]",
):
with (
self.subTest(decorator=decorator),
self.assertRaisesRegex(
ValueError, "Scoped expressions in function headers"
),
):
self.copy(self.source.replace("@functools.cache", decorator))
def test_nested_classes_fail_closed(self):
for body in (
" class Inner:\n os = os\n",
" def nested():\n class Inner:\n os = os\n",
):
with (
self.subTest(body=body),
self.assertRaisesRegex(ValueError, "Classes in selected functions"),
):
self.copy(
self.source.replace(
" import errno\n", body + " import errno\n"
)
)
if __name__ == "__main__":
unittest.main()