Files
RustPython/scripts/update_lib/tests/test_patch_spec.py
T
youknowdot 9e18d7be6b Preserve explicit customizations during canonical library migration (#9142)
* Preserve explicit customizations during canonical library migration

Add bounded runtime, call, and doctest preservation plus explicit remaining-marker metadata. Support mapped legacy test-package migrations without losing ordered guards, and exclude cache directories from both migration paths while retaining canonical assets.

Validation: 53 bounded host-Python tooling tests passed. Tests inspect source and temporary files without executing migrated fixtures. Existing copy-library cache protection is unchanged. No Lib or interpreter changes.

Assisted-by: OpenAI Codex:model version unavailable

* Integrate migration preservation into existing updater modules

Keep migration orchestration and guarded package moves in cmd_migrate, and marker/doctest metadata in patch_spec. Reuse AST anchor validation and existing test modules, removing all eleven newly introduced helper, test, and data files.

Require caller-selected doctest metadata instead of embedding syntax-case policy. Reject guarded legacy doctests before package writes. Preserve the prior 53 test scenarios and add seven focused metadata regressions; all 60 bounded host tooling tests passed. No Lib, workflow, or native changes.

Assisted-by: OpenAI Codex:model version unavailable
2026-10-15 19:54:38 +09:00

739 lines
23 KiB
Python

"""Tests for patch_spec.py - core patch extraction and application."""
import ast
import unittest
from update_lib.patch_spec import (
CANONICAL_LOAD_TESTS,
COMMENT,
PATCHED_LOAD_TESTS,
PatchSpec,
UtMethod,
_find_import_insert_line,
apply_doctest_metadata,
apply_patches,
extract_doctest_guards,
extract_patches,
iter_tests,
)
class TestIterTests(unittest.TestCase):
"""Tests for iter_tests function."""
def test_iter_tests_simple(self):
"""Test iterating over test methods in a class."""
code = """
class TestFoo(unittest.TestCase):
def test_one(self):
pass
def test_two(self):
pass
"""
tree = ast.parse(code)
results = list(iter_tests(tree))
self.assertEqual(len(results), 2)
self.assertEqual(results[0][0].name, "TestFoo")
self.assertEqual(results[0][1].name, "test_one")
self.assertEqual(results[1][1].name, "test_two")
def test_iter_tests_multiple_classes(self):
"""Test iterating over multiple test classes."""
code = """
class TestFoo(unittest.TestCase):
def test_foo(self):
pass
class TestBar(unittest.TestCase):
def test_bar(self):
pass
"""
tree = ast.parse(code)
results = list(iter_tests(tree))
self.assertEqual(len(results), 2)
self.assertEqual(results[0][0].name, "TestFoo")
self.assertEqual(results[1][0].name, "TestBar")
def test_iter_tests_async(self):
"""Test iterating over async test methods."""
code = """
class TestAsync(unittest.TestCase):
async def test_async(self):
pass
"""
tree = ast.parse(code)
results = list(iter_tests(tree))
self.assertEqual(len(results), 1)
self.assertEqual(results[0][1].name, "test_async")
class TestExtractPatches(unittest.TestCase):
"""Tests for extract_patches function."""
def test_class_conditional_method_markers_are_preserved(self):
code = """class Owner:
if enabled:
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_value(self):
return 42
"""
patches = extract_patches(code)
self.assertIn("test_value", patches["Owner"])
clean = code.replace(
" @unittest.expectedFailure # TODO: RUSTPYTHON\n", ""
)
result = apply_patches(clean, patches)
self.assertIn(" @unittest.expectedFailure", result)
method = list(iter_tests(ast.parse(result)))[0][1]
self.assertEqual(method.col_offset, 8)
self.assertEqual(ast.dump(method.body[0]), "Return(value=Constant(value=42))")
def test_nested_class_markers_do_not_target_module_names(self):
code = """class Container:
class Target:
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_value(self):
pass
def factory():
class Target:
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_value(self):
pass
"""
self.assertEqual(extract_patches(code), {})
def test_bare_marked_skip_roundtrip(self):
clean = """class Owner:
def test_value(self):
return 42
"""
marked = clean.replace(
" def test_value",
" @unittest.skip # TODO: RUSTPYTHON; original guard\n def test_value",
)
patches = extract_patches(marked)
self.assertEqual(
patches["Owner"]["test_value"],
[PatchSpec(UtMethod.Skip, reason="original guard")],
)
result = apply_patches(clean, patches)
self.assertIn('@unittest.skip("TODO: RUSTPYTHON; original guard")', result)
self.assertEqual(apply_patches(clean, extract_patches(result)), result)
method = list(iter_tests(ast.parse(result)))[0][1]
self.assertEqual(ast.dump(method.body[0]), "Return(value=Constant(value=42))")
def test_unmarked_bare_skip_and_unrelated_decorators_are_not_patches(self):
code = """class Owner:
@unittest.skip
def test_unmarked(self):
pass
@other.skip # TODO: RUSTPYTHON; unrelated
def test_other(self):
pass
@unittest.other # TODO: RUSTPYTHON; unrelated
def test_unknown(self):
pass
"""
self.assertEqual(extract_patches(code), {})
self.assertEqual(apply_patches(code, {}), code)
def test_extract_expected_failure(self):
"""Test extracting @unittest.expectedFailure decorator."""
code = f"""
class TestFoo(unittest.TestCase):
# {COMMENT}
@unittest.expectedFailure
def test_one(self):
pass
"""
patches = extract_patches(code)
self.assertIn("TestFoo", patches)
self.assertIn("test_one", patches["TestFoo"])
specs = patches["TestFoo"]["test_one"]
self.assertEqual(len(specs), 1)
self.assertEqual(specs[0].ut_method, UtMethod.ExpectedFailure)
def test_extract_expected_failure_inline_comment(self):
"""Test extracting expectedFailure with inline comment."""
code = f"""
class TestFoo(unittest.TestCase):
@unittest.expectedFailure # {COMMENT}
def test_one(self):
pass
"""
patches = extract_patches(code)
self.assertIn("TestFoo", patches)
self.assertIn("test_one", patches["TestFoo"])
def test_extract_skip_with_reason(self):
"""Test extracting @unittest.skip with reason."""
code = f'''
class TestFoo(unittest.TestCase):
@unittest.skip("{COMMENT}; not implemented")
def test_one(self):
pass
'''
patches = extract_patches(code)
self.assertIn("TestFoo", patches)
specs = patches["TestFoo"]["test_one"]
self.assertEqual(specs[0].ut_method, UtMethod.Skip)
self.assertIn("not implemented", specs[0].reason)
def test_extract_skip_if(self):
"""Test extracting @unittest.skipIf decorator."""
code = f'''
class TestFoo(unittest.TestCase):
@unittest.skipIf(sys.platform == "win32", "{COMMENT}; windows issue")
def test_one(self):
pass
'''
patches = extract_patches(code)
specs = patches["TestFoo"]["test_one"]
self.assertEqual(specs[0].ut_method, UtMethod.SkipIf)
# ast.unparse normalizes quotes to single quotes
self.assertIn("sys.platform", specs[0].cond)
self.assertIn("win32", specs[0].cond)
def test_no_patches_without_comment(self):
"""Test that decorators without COMMENT are not extracted."""
code = """
class TestFoo(unittest.TestCase):
@unittest.expectedFailure
def test_one(self):
pass
"""
patches = extract_patches(code)
self.assertEqual(patches, {})
def test_multiple_patches_same_method(self):
"""Test extracting multiple decorators on same method."""
code = f'''
class TestFoo(unittest.TestCase):
# {COMMENT}
@unittest.expectedFailure
@unittest.skip("{COMMENT}; reason")
def test_one(self):
pass
'''
patches = extract_patches(code)
specs = patches["TestFoo"]["test_one"]
self.assertEqual(len(specs), 2)
def test_comment_confusion(self):
"""
Test that we only extract our patches when CPython set one of the UT methods,
that we search for
"""
code = f"""
class TestFoo(unittest.TestCase):
@unittest.expectedSuccess # {COMMENT}; reason
@unittest.expectedFailure
def test_one(self):
pass
"""
patches = extract_patches(code)
specs = patches["TestFoo"]["test_one"]
self.assertEqual(len(specs), 1)
spec = specs[0]
self.assertEqual(
spec,
PatchSpec(ut_method=UtMethod.ExpectedSuccess, cond=None, reason="reason"),
)
class TestApplyPatches(unittest.TestCase):
"""Tests for apply_patches function."""
def test_conditional_class_existing_method(self):
code = """import unittest
if enabled:
class TestConditional:
def test_value(self):
assert 1 + 1 == 2
"""
result = apply_patches(
code,
{
"TestConditional": {
"test_value": [
PatchSpec(
UtMethod.ExpectedFailureIf, "condition", "known limitation"
)
]
}
},
)
original = list(iter_tests(ast.parse(code)))[0][1]
updated = list(iter_tests(ast.parse(result)))[0][1]
self.assertEqual(ast.dump(original.body[0]), ast.dump(updated.body[0]))
self.assertEqual(updated.col_offset, 8)
self.assertIn(" @unittest.expectedFailureIf", result)
def test_conditional_class_inherited_override(self):
code = """import unittest
class Parent:
async def test_value(self):
return 42
if enabled:
class Child(Parent):
pass
"""
result = apply_patches(
code, {"Child": {"test_value": [PatchSpec(UtMethod.ExpectedSuccess)]}}
)
methods = list(iter_tests(ast.parse(result)))
self.assertEqual([cls.name for cls, _ in methods], ["Parent", "Child"])
self.assertIsInstance(methods[1][1], ast.AsyncFunctionDef)
self.assertIn(" return await super().test_value()", result)
self.assertIn(" @unittest.expectedSuccess", result)
def test_duplicate_conditional_class_names_are_rejected(self):
code = """if enabled:
class Duplicate:
pass
else:
class Duplicate:
pass
"""
with self.assertRaisesRegex(ValueError, "Ambiguous.*Duplicate"):
apply_patches(
code,
{"Duplicate": {"test_value": [PatchSpec(UtMethod.ExpectedFailure)]}},
)
def test_unpatched_conditional_helpers_are_allowed(self):
code = """if enabled:
class Helper:
pass
else:
class Helper:
pass
class Target:
helper = Helper
def test_value(self):
return 1
"""
result = apply_patches(
code, {"Target": {"test_value": [PatchSpec(UtMethod.ExpectedFailure)]}}
)
self.assertEqual(result.count("class Helper:"), 2)
self.assertEqual(result.count("@unittest.expectedFailure"), 1)
def test_ambiguous_transitive_ancestor_is_rejected(self):
code = """if enabled:
class Ancestor:
async def test_value(self):
pass
else:
class Ancestor:
def test_value(self):
pass
class Middle(Ancestor):
pass
class Target(Middle):
pass
"""
with self.assertRaisesRegex(ValueError, "Ambiguous.*Ancestor"):
apply_patches(
code, {"Target": {"test_value": [PatchSpec(UtMethod.ExpectedFailure)]}}
)
def test_local_class_names_do_not_shadow_module_class(self):
code = """import unittest
class Target:
def test_value(self):
return 1
class Container:
class Target:
def test_value(self):
return 2
def factory():
class Target:
def test_value(self):
return 3
"""
result = apply_patches(
code, {"Target": {"test_value": [PatchSpec(UtMethod.ExpectedFailure)]}}
)
tree = ast.parse(result)
targets = [
node
for node in ast.walk(tree)
if isinstance(node, ast.ClassDef) and node.name == "Target"
]
self.assertEqual(
[len(node.body[0].decorator_list) for node in targets], [1, 0, 0]
)
def test_duplicate_conditional_method_names_are_rejected(self):
code = """class Owner:
if enabled:
def test_value(self):
return 1
else:
def test_value(self):
return 2
"""
with self.assertRaisesRegex(
ValueError, "Ambiguous method name: Owner.test_value"
):
apply_patches(
code, {"Owner": {"test_value": [PatchSpec(UtMethod.ExpectedFailure)]}}
)
def test_apply_expected_failure(self):
"""Test applying @unittest.expectedFailure."""
code = """import unittest
class TestFoo(unittest.TestCase):
def test_one(self):
pass
"""
patches = {
"TestFoo": {"test_one": [PatchSpec(UtMethod.ExpectedFailure, None, "")]}
}
result = apply_patches(code, patches)
self.assertIn("@unittest.expectedFailure", result)
self.assertIn(COMMENT, result)
def test_apply_skip_with_reason(self):
"""Test applying @unittest.skip with reason."""
code = """import unittest
class TestFoo(unittest.TestCase):
def test_one(self):
pass
"""
patches = {
"TestFoo": {"test_one": [PatchSpec(UtMethod.Skip, None, "not ready")]}
}
result = apply_patches(code, patches)
self.assertIn("@unittest.skip", result)
self.assertIn("not ready", result)
def test_apply_skip_if(self):
"""Test applying @unittest.skipIf."""
code = """import unittest
class TestFoo(unittest.TestCase):
def test_one(self):
pass
"""
patches = {
"TestFoo": {
"test_one": [
PatchSpec(UtMethod.SkipIf, "sys.platform == 'win32'", "windows")
]
}
}
result = apply_patches(code, patches)
self.assertIn("@unittest.skipIf", result)
self.assertIn('sys.platform == "win32"', result)
def test_apply_preserves_existing_decorators(self):
"""Test that existing decorators are preserved."""
code = """import unittest
class TestFoo(unittest.TestCase):
@some_decorator
def test_one(self):
pass
"""
patches = {
"TestFoo": {"test_one": [PatchSpec(UtMethod.ExpectedFailure, None, "")]}
}
result = apply_patches(code, patches)
self.assertIn("@some_decorator", result)
self.assertIn("@unittest.expectedFailure", result)
def test_inherited_override_expands_inline_class(self):
for source in (
"class Child(Base): pass # keep comment\n",
"class Child(Base): value = 1; other = 2\n",
"class Child(\n Base\n): pass\n",
"if True:\n class Child(Base): pass\n",
"class Café(Base): pass\n",
):
with self.subTest(source=source):
name = "Café" if "Café" in source else "Child"
patches = {
name: {
"test_parent": [PatchSpec(UtMethod.ExpectedFailure, None, "")]
}
}
before = ast.parse(source)
result = apply_patches(source, patches)
after = ast.parse(result)
old_class = next(
node for node in ast.walk(before) if isinstance(node, ast.ClassDef)
)
new_class = next(
node for node in ast.walk(after) if isinstance(node, ast.ClassDef)
)
self.assertEqual(
[ast.dump(node) for node in old_class.body],
[ast.dump(node) for node in new_class.body[:-1]],
)
self.assertEqual(new_class.body[-1].name, "test_parent")
self.assertIn("return super().test_parent()", result)
if "# keep comment" in source:
self.assertIn("# keep comment", result)
def test_apply_inherited_method(self):
"""Test applying patch to inherited method (creates override)."""
code = """import unittest
class TestFoo(unittest.TestCase):
pass
"""
patches = {
"TestFoo": {
"test_inherited": [PatchSpec(UtMethod.ExpectedFailure, None, "")]
}
}
result = apply_patches(code, patches)
self.assertIn("def test_inherited(self):", result)
self.assertIn("return super().test_inherited()", result)
def test_apply_adds_unittest_import(self):
"""Test that unittest import is added if missing."""
code = """import sys
class TestFoo:
def test_one(self):
pass
"""
patches = {
"TestFoo": {"test_one": [PatchSpec(UtMethod.ExpectedFailure, None, "")]}
}
result = apply_patches(code, patches)
# Should add unittest import after existing imports
self.assertIn("import unittest", result)
def test_apply_no_duplicate_import(self):
"""Test that unittest import is not duplicated."""
code = """import unittest
class TestFoo(unittest.TestCase):
def test_one(self):
pass
"""
patches = {
"TestFoo": {"test_one": [PatchSpec(UtMethod.ExpectedFailure, None, "")]}
}
result = apply_patches(code, patches)
# Count occurrences of 'import unittest'
count = result.count("import unittest")
self.assertEqual(count, 1)
class TestPatchSpec(unittest.TestCase):
"""Tests for PatchSpec class."""
def test_as_decorator_expected_failure(self):
"""Test generating expectedFailure decorator string."""
spec = PatchSpec(UtMethod.ExpectedFailure, None, "reason")
decorator = spec.as_decorator()
self.assertIn("@unittest.expectedFailure", decorator)
self.assertIn(COMMENT, decorator)
self.assertIn("reason", decorator)
def test_as_decorator_skip(self):
"""Test generating skip decorator string."""
spec = PatchSpec(UtMethod.Skip, None, "not ready")
decorator = spec.as_decorator()
self.assertIn("@unittest.skip", decorator)
self.assertIn("not ready", decorator)
def test_as_decorator_skip_if(self):
"""Test generating skipIf decorator string."""
spec = PatchSpec(UtMethod.SkipIf, "condition", "reason")
decorator = spec.as_decorator()
self.assertIn("@unittest.skipIf", decorator)
self.assertIn("condition", decorator)
class TestRoundTrip(unittest.TestCase):
"""Tests for extract -> apply round trip."""
def test_round_trip_expected_failure(self):
"""Test that extracted patches can be re-applied."""
original = f"""import unittest
class TestFoo(unittest.TestCase):
# {COMMENT}
@unittest.expectedFailure
def test_one(self):
pass
"""
# Extract patches
patches = extract_patches(original)
# Apply to clean code
clean = """import unittest
class TestFoo(unittest.TestCase):
def test_one(self):
pass
"""
result = apply_patches(clean, patches)
# Should have the decorator
self.assertIn("@unittest.expectedFailure", result)
self.assertIn(COMMENT, result)
def test_round_trip_with_patches_on_class(self):
"""Test that extracted patches can be re-applied."""
original = f"""import unittest
@unittest.skipIf(a == b, "{COMMENT}")
@unittest.expectedFailure # {COMMENT}
class TestFoo(unittest.TestCase):
...
"""
# Extract patches
patches = extract_patches(original)
# Apply to clean code
clean = """import unittest
class TestFoo(unittest.TestCase):
def test_one(self):
pass
"""
result = apply_patches(clean, patches)
# Should have the decorator
self.assertIn("@unittest.expectedFailure", result)
self.assertIn("@unittest.skipIf", result)
self.assertIn(COMMENT, result)
class TestFindImportInsertLine(unittest.TestCase):
"""Tests for _find_import_insert_line function."""
def test_with_imports(self):
"""Test finding line after imports."""
code = """import os
import sys
class Foo:
pass
"""
tree = ast.parse(code)
line = _find_import_insert_line(tree)
self.assertEqual(line, 2)
def test_no_imports_with_docstring(self):
"""Test fallback to after docstring when no imports."""
code = '''"""Module docstring."""
class Foo:
pass
'''
tree = ast.parse(code)
line = _find_import_insert_line(tree)
self.assertEqual(line, 1)
def test_no_imports_no_docstring(self):
"""Test fallback to line 0 when no imports and no docstring."""
code = """class Foo:
pass
"""
tree = ast.parse(code)
line = _find_import_insert_line(tree)
self.assertEqual(line, 0)
DOCTEST_SOURCE = "raise X, Y\n"
DOCTEST_METADATA = {DOCTEST_SOURCE: "known mismatch"}
DOCTEST_OLD = (
'''"""
>>> raise X, Y # TODO: RUSTPYTHON; old reason # doctest: +EXPECTED_FAILURE
Traceback (most recent call last):
SyntaxError: old message
"""
'''
+ PATCHED_LOAD_TESTS
+ "\n"
)
DOCTEST_CANONICAL = (
'''"""
>>> raise X, Y
Traceback (most recent call last):
SyntaxError: final message
"""
'''
+ CANONICAL_LOAD_TESTS
+ "\n"
)
class ExactDoctestTests(unittest.TestCase):
def test_extracts_only_existing_guards(self):
self.assertEqual(set(extract_doctest_guards(DOCTEST_OLD)), {DOCTEST_SOURCE})
self.assertEqual(extract_doctest_guards(DOCTEST_CANONICAL), {})
self.assertEqual(extract_doctest_guards("value = 1\n"), {})
def test_duplicate_existing_guards_are_rejected(self):
duplicate = DOCTEST_OLD.replace(
">>> raise X, Y # TODO:",
">>> raise X, Y # TODO: RUSTPYTHON; duplicate # doctest: +EXPECTED_FAILURE\n"
">>> raise X, Y # TODO:",
)
with self.assertRaises(ValueError):
extract_doctest_guards(duplicate)
def test_preserves_final_output_and_roundtrips(self):
result = apply_doctest_metadata(
DOCTEST_OLD, DOCTEST_CANONICAL, DOCTEST_METADATA
)
self.assertIn("SyntaxError: final message", result)
self.assertNotIn("old message", result)
self.assertEqual(result.count("doctest: +EXPECTED_FAILURE"), 1)
self.assertIn(PATCHED_LOAD_TESTS, result)
self.assertEqual(
apply_doctest_metadata(result, DOCTEST_CANONICAL, DOCTEST_METADATA), result
)
def test_duplicate_input_is_rejected(self):
duplicate = DOCTEST_CANONICAL.replace(
">>> raise X, Y\n", ">>> raise X, Y\n>>> raise X, Y\n"
)
with self.assertRaises(ValueError):
apply_doctest_metadata(DOCTEST_OLD, duplicate, DOCTEST_METADATA)
def test_missing_canonical_or_old_case_is_rejected(self):
with self.assertRaises(ValueError):
apply_doctest_metadata(
DOCTEST_OLD,
DOCTEST_CANONICAL.replace("raise X, Y", "raise X"),
DOCTEST_METADATA,
)
with self.assertRaises(ValueError):
apply_doctest_metadata(
DOCTEST_OLD.replace("raise X, Y", "raise X"),
DOCTEST_CANONICAL,
DOCTEST_METADATA,
)
def test_changed_loader_and_unmarked_old_case_are_rejected(self):
with self.assertRaises(ValueError):
apply_doctest_metadata(
DOCTEST_OLD,
DOCTEST_CANONICAL.replace("DocTestSuite()", "DocTestSuite(None)"),
DOCTEST_METADATA,
)
with self.assertRaises(ValueError):
apply_doctest_metadata(
DOCTEST_CANONICAL, DOCTEST_CANONICAL, DOCTEST_METADATA
)
if __name__ == "__main__":
unittest.main()