Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions scripts/update_lib/cmd_copy_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ def _copy_single(
shutil.copy2(src_path, lib_path)


def _reject_python_tests(src_path: pathlib.Path, relative: pathlib.Path) -> None:
is_test_tree = len(relative.parts) >= 2 and relative.parts[1].casefold() == "test"
if is_test_tree and (src_path.is_dir() or src_path.suffix.casefold() == ".py"):
raise ValueError(
"Use migrate for Python tests; copy-lib would replace Lib/test"
)


def copy_lib(
src_path: pathlib.Path,
verbose: bool = True,
Expand All @@ -77,11 +85,13 @@ def copy_lib(
raise ValueError(f"Path must contain '/Lib/' (got: {src_path})")
if not src_path.exists():
raise FileNotFoundError(f"Source path does not exist: {src_path}")
relative = parse_lib_path(src_path)
_reject_python_tests(src_path, relative)

# Data files name an exact asset, not the containing top-level module.
# Expanding Lib/test/data.txt as "test" would replace the entire test tree.
if src_path.is_file() and src_path.suffix != ".py":
_copy_single(src_path, parse_lib_path(src_path), verbose)
_copy_single(src_path, relative, verbose)
return

cpython_prefix, after_lib = path_str.split("/Lib/", 1)
Expand All @@ -93,11 +103,12 @@ def copy_lib(
# Get all paths to copy from DEPENDENCIES table
all_src_paths = get_lib_paths(name, cpython_prefix)

# Copy each file
for src in all_src_paths:
if src.exists():
lib_path = parse_lib_path(src)
_copy_single(src, lib_path, verbose)
# Validate the entire expansion before removing any destination.
plan = [(src, parse_lib_path(src)) for src in all_src_paths if src.exists()]
for src, lib_path in plan:
_reject_python_tests(src, lib_path)
for src, lib_path in plan:
_copy_single(src, lib_path, verbose)


def main(argv: list[str] | None = None) -> int:
Expand Down
5 changes: 4 additions & 1 deletion scripts/update_lib/file_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,10 @@ def parse_lib_path(path: pathlib.Path | str) -> pathlib.Path:
raise ValueError(f"Path must contain '/Lib/' or '\\Lib\\' (got: {path})")

idx = path_str.index(lib_marker)
return pathlib.Path(path_str[idx + 1 :])
relative = pathlib.Path(path_str[idx + 1 :])
if ".." in relative.parts:
raise ValueError(f"Path must not escape or traverse within Lib/ (got: {path})")
return relative


def is_lib_path(path: pathlib.Path) -> bool:
Expand Down
7 changes: 5 additions & 2 deletions scripts/update_lib/patch_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,9 +596,12 @@ def _conditional_retirements(document, old_content):
"Original conditional decorator does not match exactly once"
)
marker = PatchSpec.try_from_ast_node(matches[0], old_content.splitlines())
if marker is None or marker.ut_method != UtMethod.ExpectedFailureIf:
if marker is None or marker.ut_method not in (
UtMethod.ExpectedFailureIf,
UtMethod.Skip,
):
raise ValueError(
"Only explicit expectedFailureIf retirements are supported"
"Only explicit expectedFailureIf and skip retirements are supported"
)
result[key] = marker
return result
Expand Down
55 changes: 55 additions & 0 deletions scripts/update_lib/tests/test_migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,61 @@ def test_other_guards_and_original_source_required(self):
with self.assertRaises(ValueError):
self.select(None, current, document)

def test_explicit_skip_retirement_preserves_other_markers(self):
decorator = ' @unittest.skip("TODO: RUSTPYTHON; known limitation")\n'
old = OVERRIDE_OLD.replace(
" @unittest.expectedFailure # TODO: RUSTPYTHON; old failure\n",
decorator,
)

def authorize(document):
document["retire_conditional"] = [
{
"class": "Example",
"method": "test_known",
"decorator": decorator,
}
]

with patch(__name__ + ".OVERRIDE_OLD", old):
status, result = MarkerOverrideTests().invoke(authorize)
self.assertEqual(status, 0)
self.assertNotIn("known limitation", result)
self.assertIn('unittest.skip("TODO: RUSTPYTHON; retained guard")', result)
status, result = MarkerOverrideTests().invoke()
self.assertEqual(status, 1)
self.assertEqual(result, old)

def test_skip_retirement_fails_closed(self):
self.decorator = ' @unittest.skip("TODO: RUSTPYTHON; known limitation")\n'
for change in (
"text",
"duplicate",
"retained",
"duplicate_source",
"skipIf",
"skipUnless",
):
old, current, document = self.fixture()
if change == "text":
document["retire_conditional"][0]["decorator"] = self.decorator.replace(
'"', "'"
)
elif change == "duplicate":
document["retire_conditional"] *= 2
elif change == "retained":
document["patches"] = patches_to_json(current)
elif change == "duplicate_source":
old = old.replace(self.decorator, self.decorator * 2)
current = extract_patches(old)
else:
replacement = self.decorator.replace("skip(", change + "(True, ")
old = old.replace(self.decorator, replacement)
current = extract_patches(old)
document["retire_conditional"][0]["decorator"] = replacement
with self.subTest(change=change), self.assertRaises(ValueError):
self.select(old, current, document)

def test_cli_conditional_retirement_preserves_remaining_markers(self):
old = OVERRIDE_OLD.replace(
" @unittest.expectedFailure # TODO: RUSTPYTHON; old failure\n",
Expand Down
Loading