FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

`scripts/update_lib migrate` to preserve patches on classes (#8057) · RustPython/RustPython@7f75ef1 · GitHub

Commit 7f75ef1

Browse files
authored
scripts/update_lib migrate to preserve patches on classes (#8057)
1 parent a50880e commit 7f75ef1

2 files changed

Lines changed: 198 additions & 98 deletions

File tree

‎scripts/update_lib/patch_spec.py‎

Lines changed: 172 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
- Applying patches to test files (JSON -> file)
77
"""
88

9+
from __future__ import annotations
10+
911
import ast
1012
import collections
13+
import contextlib
1114
import enum
15+
import re
1216
import textwrap
1317
import typing
1418

@@ -91,33 +95,109 @@ def as_decorator(self) -> str:
9195

9296
return f"@{unparsed}"
9397

98+
@classmethod
99+
def try_from_ast_node(
100+
cls, node: ast.Attribute | ast.Call, lines: list[str]
101+
) -> typing.Self | None:
102+
if isinstance(node, ast.Attribute):
103+
attr_node = node
104+
elif isinstance(node, ast.Call):
105+
attr_node = node.func
106+
else:
107+
return
108+
109+
if (
110+
isinstance(attr_node, ast.Name)
111+
or getattr(attr_node.value, "id", None) != UT
112+
):
113+
return
114+
115+
cond = None
116+
try:
117+
ut_method = UtMethod(attr_node.attr)
118+
except ValueError:
119+
return
120+
121+
# If our ut_method has args then,
122+
# we need to search for a constant that contains our `COMMENT`.
123+
# Otherwise we need to search it in the raw source code :/
124+
if ut_method.has_args():
125+
reason = next(
126+
(
127+
inner_node.value
128+
for inner_node in ast.walk(node)
129+
if isinstance(inner_node, ast.Constant)
130+
and isinstance(inner_node.value, str)
131+
and COMMENT in inner_node.value
132+
),
133+
None,
134+
)
94135

95-
def _single_to_double_quotes(s: str) -> str:
96-
"""Convert single-quoted strings to double-quoted strings.
136+
# If we didn't find a constant containing <COMMENT>,
137+
# then we didn't put this decorator
138+
if not reason:
139+
return
140+
141+
if ut_method.has_cond():
142+
cond = ast.unparse(node.args[0])
143+
else:
144+
pattern = re.compile(rf"{COMMENT}.?(.*)")
145+
dec_lineno = node.lineno
146+
147+
curr_line = lines[dec_lineno - 1]
148+
prev_line = lines[dec_lineno - 2]
149+
150+
# If we see our comment at the decorator line, take it
151+
if found := pattern.search(curr_line):
152+
reason = found.group()
153+
elif prev_line.strip().startswith("#") and (
154+
found := pattern.search(prev_line)
155+
):
156+
# Search the previous line of the decorator,
157+
# only take the comment if the line starts with a `#`
158+
reason = found.group()
159+
else:
160+
# Didn't find our `COMMENT`, so the patch isn't ours :)
161+
return
97162

98-
Falls back to original if conversion breaks the AST equivalence.
99-
"""
100-
import re
163+
reason = reason.removeprefix(COMMENT).strip(";:, ")
164+
return cls(ut_method, cond, reason)
101165

102-
def replace_string(match: re.Match) -> str:
103-
content = match.group(1)
104-
# Unescape single quotes and escape double quotes
105-
content = content.replace("\\'", "'").replace('"', '\\"')
106-
return f'"{content}"'
107166

108-
# Match single-quoted strings (handles escaped single quotes inside)
109-
converted = re.sub(r"'((?:[^'\\]|\\.)*)'", replace_string, s)
167+
class PatchEntryVisitor(ast.NodeVisitor):
168+
def __init__(self, lines: list[str]):
169+
self.current_class = None
170+
self.patches = []
171+
self.lines = lines
110172

111-
# Verify: parse converted and unparse should equal original
112-
try:
113-
converted_ast = ast.parse(converted, mode="eval")
114-
if ast.unparse(converted_ast) == s:
115-
return converted
116-
except SyntaxError:
117-
pass
173+
def patches_from_node(
174+
self, node: ast.FunctionDef | ast.AsyncFunctionDef
175+
) -> Iterator[PatchEntry]:
176+
for dec_node in node.decorator_list:
177+
spec = PatchSpec.try_from_ast_node(dec_node, self.lines)
118178

119-
# Fall back to original if conversion failed
120-
return s
179+
if spec is None:
180+
continue
181+
182+
yield PatchEntry(self.current_class, node.name, spec)
183+
184+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
185+
self.patches.extend(self.patches_from_node(node))
186+
# TODO: Support nested classes/methods
187+
# self.generic_visit(node)
188+
189+
def visit_FunctionDef(self, node: ast.FunctionDef):
190+
self.patches.extend(self.patches_from_node(node))
191+
# TODO: Support nested classes/methods
192+
# self.generic_visit(node)
193+
194+
def visit_ClassDef(self, node: ast.ClassDef):
195+
with temp_attr(self, "current_class", node.name):
196+
for patch in self.patches_from_node(node):
197+
patch = patch._replace(test_name="__self__")
198+
self.patches.append(patch)
199+
200+
self.generic_visit(node)
121201

122202

123203
class PatchEntry(typing.NamedTuple):
@@ -142,76 +222,10 @@ class PatchEntry(typing.NamedTuple):
142222
def iter_patch_entries(
143223
cls, tree: ast.Module, lines: list[str]
144224
) -> "Iterator[typing.Self]":
145-
import re
146-
import sys
147225

148-
for cls_node, fn_node in iter_tests(tree):
149-
parent_class = cls_node.name
150-
for dec_node in fn_node.decorator_list:
151-
if not isinstance(dec_node, (ast.Attribute, ast.Call)):
152-
continue
153-
154-
attr_node = (
155-
dec_node if isinstance(dec_node, ast.Attribute) else dec_node.func
156-
)
157-
158-
if (
159-
isinstance(attr_node, ast.Name)
160-
or getattr(attr_node.value, "id", None) != UT
161-
):
162-
continue
163-
164-
cond = None
165-
try:
166-
ut_method = UtMethod(attr_node.attr)
167-
except ValueError:
168-
continue
169-
170-
# If our ut_method has args then,
171-
# we need to search for a constant that contains our `COMMENT`.
172-
# Otherwise we need to search it in the raw source code :/
173-
if ut_method.has_args():
174-
reason = next(
175-
(
176-
node.value
177-
for node in ast.walk(dec_node)
178-
if isinstance(node, ast.Constant)
179-
and isinstance(node.value, str)
180-
and COMMENT in node.value
181-
),
182-
None,
183-
)
184-
185-
# If we didn't find a constant containing <COMMENT>,
186-
# then we didn't put this decorator
187-
if not reason:
188-
continue
189-
190-
if ut_method.has_cond():
191-
cond = ast.unparse(dec_node.args[0])
192-
else:
193-
pattern = re.compile(rf"{COMMENT}.?(.*)")
194-
dec_lineno = dec_node.lineno
195-
196-
curr_line = lines[dec_lineno - 1]
197-
prev_line = lines[dec_lineno - 2]
198-
199-
# If we see our comment at the decorator line, take it
200-
if found := pattern.search(curr_line):
201-
reason = found.group()
202-
elif prev_line.strip().startswith("#") and (
203-
found := pattern.search(prev_line)
204-
):
205-
# Search the previous line of the decorator,
206-
# only take the comment if the line starts with a `#`
207-
reason = found.group()
208-
else:
209-
# Didn't find our `COMMENT`, so the patch isn't ours :)
210-
continue
211-
212-
reason = reason.removeprefix(COMMENT).strip(";:, ")
213-
spec = PatchSpec(ut_method, cond, reason)
214-
yield cls(parent_class, fn_node.name, spec)
226+
visitor = PatchEntryVisitor(lines)
227+
visitor.visit(tree)
228+
yield from visitor.patches
215229

216230

217231
def iter_tests(
@@ -251,6 +265,15 @@ def extract_patches(contents: str) -> Patches:
251265
return build_patch_dict(iter_patches(contents))
252266

253267

268+
def modification_from_node_specs(node, specs):
269+
lineno = min(
270+
(dec_node.lineno for dec_node in node.decorator_list), default=node.lineno
271+
)
272+
indent = " " * node.col_offset
273+
patch_lines = "\n".join(spec.as_decorator() for spec in specs)
274+
return (lineno - 1, textwrap.indent(patch_lines, indent))
275+
276+
254277
def _iter_patch_lines(
255278
tree: ast.Module, patches: Patches
256279
) -> "Iterator[tuple[int, str]]":
@@ -262,7 +285,15 @@ def _iter_patch_lines(
262285
async_methods: dict[str, set[str]] = {}
263286
# Track class bases for inherited async method lookup
264287
class_bases: dict[str, list[str]] = {}
265-
all_classes = {node.name for node in tree.body if isinstance(node, ast.ClassDef)}
288+
all_classes = set()
289+
all_class_nodes = []
290+
for node in tree.body:
291+
if not isinstance(node, ast.ClassDef):
292+
continue
293+
294+
all_classes.add(node.name)
295+
all_class_nodes.append(node)
296+
266297
for node in tree.body:
267298
if isinstance(node, ast.ClassDef):
268299
cache[node.name] = node.end_lineno
@@ -284,13 +315,7 @@ def _iter_patch_lines(
284315
if not specs:
285316
continue
286317

287-
lineno = min(
288-
(dec_node.lineno for dec_node in fn_node.decorator_list),
289-
default=fn_node.lineno,
290-
)
291-
indent = " " * fn_node.col_offset
292-
patch_lines = "\n".join(spec.as_decorator() for spec in specs)
293-
yield (lineno - 1, textwrap.indent(patch_lines, indent))
318+
yield modification_from_node_specs(fn_node, specs)
294319

295320
# Phase 2: Iterate and mark inherited tests
296321
for cls_name, tests in sorted(patches.items()):
@@ -300,6 +325,10 @@ def _iter_patch_lines(
300325
continue
301326

302327
for test_name, specs in sorted(tests.items()):
328+
if test_name == "__self__":
329+
# Yielding modifications for the class itself should be done during phase 3
330+
continue
331+
303332
decorators = "\n".join(spec.as_decorator() for spec in specs)
304333
# Check current class and ancestors for async method
305334
is_async = False
@@ -314,6 +343,7 @@ def _iter_patch_lines(
314343
is_async = True
315344
break
316345
queue.extend(class_bases.get(cur, []))
346+
317347
if is_async:
318348
patch_lines = f"""
319349
{decorators}
@@ -328,6 +358,11 @@ def {test_name}(self):
328358
""".rstrip()
329359
yield (lineno, textwrap.indent(patch_lines, DEFAULT_INDENT))
330360

361+
# Phase 3: Mark the class itself
362+
for cls_node in all_class_nodes:
363+
if cls_specs := patches.get(cls_node.name, {}).pop("__self__", None):
364+
yield modification_from_node_specs(cls_node, cls_specs)
365+
331366

332367
def _has_unittest_import(tree: ast.Module) -> bool:
333368
"""Check if 'import unittest' is already present in the file."""
@@ -406,3 +441,42 @@ def patches_from_json(data: dict) -> Patches:
406441
}
407442
for cls_name, tests in data.items()
408443
}
444+
445+
446+
def _single_to_double_quotes(s: str) -> str:
447+
"""
448+
Convert single-quoted strings to double-quoted strings.
449+
450+
Falls back to original if conversion breaks the AST equivalence.
451+
"""
452+
import re
453+
454+
def replace_string(match: re.Match) -> str:
455+
content = match.group(1)
456+
# Unescape single quotes and escape double quotes
457+
content = content.replace("\\'", "'").replace('"', '\\"')
458+
return f'"{content}"'
459+
460+
# Match single-quoted strings (handles escaped single quotes inside)
461+
converted = re.sub(r"'((?:[^'\\]|\\.)*)'", replace_string, s)
462+
463+
# Verify: parse converted and unparse should equal original
464+
try:
465+
converted_ast = ast.parse(converted, mode="eval")
466+
if ast.unparse(converted_ast) == s:
467+
return converted
468+
except SyntaxError:
469+
pass
470+
471+
# Fall back to original if conversion failed
472+
return s
473+
474+
475+
@contextlib.contextmanager
476+
def temp_attr(obj: object, attr: str, value: object):
477+
old = getattr(obj, attr, None)
478+
setattr(obj, attr, value)
479+
try:
480+
yield obj
481+
finally:
482+
setattr(obj, attr, old)

‎scripts/update_lib/tests/test_patch_spec.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,32 @@ def test_one(self):
345345
self.assertIn("@unittest.expectedFailure", result)
346346
self.assertIn(COMMENT, result)
347347

348+
def test_round_trip_with_patches_on_class(self):
349+
"""Test that extracted patches can be re-applied."""
350+
original = f"""import unittest
351+
352+
@unittest.skipIf(a == b, "{COMMENT}")
353+
@unittest.expectedFailure # {COMMENT}
354+
class TestFoo(unittest.TestCase):
355+
...
356+
"""
357+
# Extract patches
358+
patches = extract_patches(original)
359+
360+
# Apply to clean code
361+
clean = """import unittest
362+
363+
class TestFoo(unittest.TestCase):
364+
def test_one(self):
365+
pass
366+
"""
367+
result = apply_patches(clean, patches)
368+
369+
# Should have the decorator
370+
self.assertIn("@unittest.expectedFailure", result)
371+
self.assertIn("@unittest.skipIf", result)
372+
self.assertIn(COMMENT, result)
373+
348374

349375
class TestFindImportInsertLine(unittest.TestCase):
350376
"""Tests for _find_import_insert_line function."""

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL