Coverage for src/renaissance/recipes/python_refactoring.py: 99%
68 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:34 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-15 10:34 +0000
1# Copyright (C) 2024-2026 TNO
2#
3# Licensed under the terms specified in the LICENSE file in the root directory
4# of this repository, also available at:
5# https://github.com/TNO/Renaissance.Py/blob/main/LICENSE
7"""AI: Base processor for Python-specific source refactoring recipes."""
9import importlib
10from collections.abc import Sequence
11from pathlib import Path
12from typing import cast
14from termcolor import colored
16from renaissance.integrations.python.ast.factory import PythonFactory, PythonPatternFactory
17from renaissance.integrations.python.ast.rst_node import PythonRstNode
18from renaissance.integrations.python.ast.util import to_str
19from renaissance.syntax_tree import ASTProcessor
20from renaissance.syntax_tree.match_finder import match_pattern
21from renaissance.syntax_tree.semantic_kind import SemanticKind
22from renaissance.utils.text_utils import snake_case
25class PythonRefactoring(ASTProcessor):
26 """AI: Base processor for Python-specific source refactoring recipes."""
28 def __init__(self, file) -> None:
29 """AI: Prepare a Python-specific refactoring processor for the given source file."""
30 factory = PythonFactory(PythonRstNode)
31 atu = factory.create(file)
32 super().__init__(atu, factory, False)
33 self.pattern_factory = PythonPatternFactory(self.factory)
34 self.black_list_pattern = ".git"
35 self.white_list_pattern = ""
37 def replace_stmt(self, find, repl) -> None:
38 """AI: Replace all statements matching the find pattern with the repl template, expanding captures."""
39 pattern = self.pattern_factory.create_statements(find)
40 for match in match_pattern(self.root.children, pattern):
41 replacement = repl
42 for exp in match.expansions:
43 arg_str = ", ".join([to_str(node) for node in match.expansions[exp]])
44 replacement = replacement.replace(exp, arg_str)
46 replacement = replacement.replace(" ,)", ")").replace(", )", ")")
47 self.replace(replacement, match.nodes, False, False)
49 @staticmethod
50 def process(class_name, file) -> None:
51 """Return a subclass by name using importlib, like Java's Class.forName()."""
52 snake = snake_case(class_name)
53 module = importlib.import_module(f"renaissance.recipes.{snake}")
54 cls = getattr(module, class_name)
55 refactor = cls(file)
56 if refactor.black_list_pattern in refactor.filename or refactor.white_list_pattern not in refactor.filename:
57 print(f"skipping: {Path(refactor.filename).resolve()}")
58 return
60 print(colored(f"refactor {Path(refactor.filename).resolve()}", "green", attrs=["bold"]))
61 refactor.run()
63 def extract_call_arguments(self, node: PythonRstNode) -> tuple[list[str], dict[str, str]]:
64 """Extract positional and keyword arguments from a Call node.
66 The input may be a `Call` node itself or any descendant node.
67 When given a descendant, this method walks up parent links and uses the first
68 ancestor whose semantic kind is `CALL`.
70 Returned keyword arguments preserve Python call semantics where keyword
71 arguments appear after positional arguments.
72 """
73 current = node
74 while current is not None and current.semantic_kind != SemanticKind.CALL:
75 current = current.parent
77 call_node = current
78 if call_node is None:
79 return [], {}
81 args_implicit = next((c for c in call_node.children if c.name == "args"), None)
82 keywords_implicit = next((c for c in call_node.children if c.name == "keywords"), None)
84 positional_args = [arg_node.signature for arg_node in (args_implicit.children if args_implicit else [])]
85 keyword_args: dict[str, str] = {}
86 for kw_node in keywords_implicit.children if keywords_implicit else []:
87 kw_name = kw_node.node.arg
88 if kw_name:
89 value_node = kw_node.children[0] if kw_node.children else kw_node
90 keyword_args[str(kw_name)] = value_node.signature
92 return positional_args, keyword_args
94 def class_declares_base(self, class_node: PythonRstNode, base_name: str) -> bool:
95 """Return whether class_node explicitly declares base_name as a base class.
97 This check uses only the names declared in the class header's base list,
98 so implicit Python inheritance from `object` is not treated as a declared base.
99 """
100 return base_name in self.class_base_arguments(class_node)
102 def class_base_arguments(self, class_node: PythonRstNode) -> list[str]:
103 """Return base class signatures explicitly listed in the class declaration.
105 Only names inside the parentheses of ``class Name(...):`` are returned.
106 The implicit default base object is not returned when no bases are declared.
107 """
108 bases_implicit = next((c for c in class_node.children if c.name == "bases"), None)
109 if bases_implicit is None:
110 return []
111 return [child.signature for child in bases_implicit.children]
113 @property
114 def body(self) -> Sequence[PythonRstNode]:
115 """AI: Return the root node's body statements."""
116 return cast("PythonRstNode", cast("object", self.root)).body
118 def run(self) -> None:
119 """AI: Run this refactoring recipe. Subclasses override this to perform the refactoring."""