| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -6,9 +6,13 @@ | |||
| 6 | 6 | - Applying patches to test files (JSON -> file) | |
| 7 | 7 | """ | |
| 8 | 8 | ||
| 9 | + from __future__ import annotations | ||
| 10 | + | ||
| 9 | 11 | import ast | |
| 10 | 12 | import collections | |
| 13 | + import contextlib | ||
| 11 | 14 | import enum | |
| 15 | + import re | ||
| 12 | 16 | import textwrap | |
| 13 | 17 | import typing | |
| 14 | 18 | ||
@@ -91,33 +95,109 @@ def as_decorator(self) -> str: | |||
| 91 | 95 | ||
| 92 | 96 | return f"@{unparsed}" | |
| 93 | 97 | ||
| 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 | + ) | ||
| 94 | 135 | ||
| 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 | ||
| 97 | 162 | ||
| 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) | ||
| 101 | 165 | ||
| 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}"' | ||
| 107 | 166 | ||
| 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 | ||
| 110 | 172 | ||
| 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) | ||
| 118 | 178 | ||
| 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) | ||
| 121 | 201 | ||
| 122 | 202 | ||
| 123 | 203 | class PatchEntry(typing.NamedTuple): | |
@@ -142,76 +222,10 @@ class PatchEntry(typing.NamedTuple): | |||
| 142 | 222 | def iter_patch_entries( | |
| 143 | 223 | cls, tree: ast.Module, lines: list[str] | |
| 144 | 224 | ) -> "Iterator[typing.Self]": | |
| 145 | - import re | ||
| 146 | - import sys | ||
| 147 | 225 | ||
| 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 | ||
| 215 | 229 | ||
| 216 | 230 | ||
| 217 | 231 | def iter_tests( | |
@@ -251,6 +265,15 @@ def extract_patches(contents: str) -> Patches: | |||
| 251 | 265 | return build_patch_dict(iter_patches(contents)) | |
| 252 | 266 | ||
| 253 | 267 | ||
| 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 | + | ||
| 254 | 277 | def _iter_patch_lines( | |
| 255 | 278 | tree: ast.Module, patches: Patches | |
| 256 | 279 | ) -> "Iterator[tuple[int, str]]": | |
@@ -262,7 +285,15 @@ def _iter_patch_lines( | |||
| 262 | 285 | async_methods: dict[str, set[str]] = {} | |
| 263 | 286 | # Track class bases for inherited async method lookup | |
| 264 | 287 | 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 | + | ||
| 266 | 297 | for node in tree.body: | |
| 267 | 298 | if isinstance(node, ast.ClassDef): | |
| 268 | 299 | cache[node.name] = node.end_lineno | |
@@ -284,13 +315,7 @@ def _iter_patch_lines( | |||
| 284 | 315 | if not specs: | |
| 285 | 316 | continue | |
| 286 | 317 | ||
| 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) | ||
| 294 | 319 | ||
| 295 | 320 | # Phase 2: Iterate and mark inherited tests | |
| 296 | 321 | for cls_name, tests in sorted(patches.items()): | |
@@ -300,6 +325,10 @@ def _iter_patch_lines( | |||
| 300 | 325 | continue | |
| 301 | 326 | ||
| 302 | 327 | 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 | + | ||
| 303 | 332 | decorators = "\n".join(spec.as_decorator() for spec in specs) | |
| 304 | 333 | # Check current class and ancestors for async method | |
| 305 | 334 | is_async = False | |
@@ -314,6 +343,7 @@ def _iter_patch_lines( | |||
| 314 | 343 | is_async = True | |
| 315 | 344 | break | |
| 316 | 345 | queue.extend(class_bases.get(cur, [])) | |
| 346 | + | ||
| 317 | 347 | if is_async: | |
| 318 | 348 | patch_lines = f""" | |
| 319 | 349 | {decorators} | |
@@ -328,6 +358,11 @@ def {test_name}(self): | |||
| 328 | 358 | """.rstrip() | |
| 329 | 359 | yield (lineno, textwrap.indent(patch_lines, DEFAULT_INDENT)) | |
| 330 | 360 | ||
| 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 | + | ||
| 331 | 366 | ||
| 332 | 367 | def _has_unittest_import(tree: ast.Module) -> bool: | |
| 333 | 368 | """Check if 'import unittest' is already present in the file.""" | |
@@ -406,3 +441,42 @@ def patches_from_json(data: dict) -> Patches: | |||
| 406 | 441 | } | |
| 407 | 442 | for cls_name, tests in data.items() | |
| 408 | 443 | } | |
| 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) | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -345,6 +345,32 @@ def test_one(self): | |||
| 345 | 345 | self.assertIn("@unittest.expectedFailure", result) | |
| 346 | 346 | self.assertIn(COMMENT, result) | |
| 347 | 347 | ||
| 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 | + | ||
| 348 | 374 | ||
| 349 | 375 | class TestFindImportInsertLine(unittest.TestCase): | |
| 350 | 376 | """Tests for _find_import_insert_line function.""" | |
| Back | FazBrowse Home | New Git URL |
0 commit comments