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

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 

6 

7"""AI: Base processor for Python-specific source refactoring recipes.""" 

8 

9import importlib 

10from collections.abc import Sequence 

11from pathlib import Path 

12from typing import cast 

13 

14from termcolor import colored 

15 

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 

23 

24 

25class PythonRefactoring(ASTProcessor): 

26 """AI: Base processor for Python-specific source refactoring recipes.""" 

27 

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 = "" 

36 

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) 

45 

46 replacement = replacement.replace(" ,)", ")").replace(", )", ")") 

47 self.replace(replacement, match.nodes, False, False) 

48 

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 

59 

60 print(colored(f"refactor {Path(refactor.filename).resolve()}", "green", attrs=["bold"])) 

61 refactor.run() 

62 

63 def extract_call_arguments(self, node: PythonRstNode) -> tuple[list[str], dict[str, str]]: 

64 """Extract positional and keyword arguments from a Call node. 

65 

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`. 

69 

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 

76 

77 call_node = current 

78 if call_node is None: 

79 return [], {} 

80 

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) 

83 

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 

91 

92 return positional_args, keyword_args 

93 

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. 

96 

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) 

101 

102 def class_base_arguments(self, class_node: PythonRstNode) -> list[str]: 

103 """Return base class signatures explicitly listed in the class declaration. 

104 

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] 

112 

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 

117 

118 def run(self) -> None: 

119 """AI: Run this refactoring recipe. Subclasses override this to perform the refactoring."""