Coverage for .tox/py313/lib/python3.13/site-packages/pylint_sort_functions/checker.py: 100%

50 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-07 04:45 +0200

1"""Main checker class for enforcing function and method sorting. 

2 

3The FunctionSortChecker is used by PyLint itself, not by end users directly. 

4PyLint discovers this checker via the plugin entry point and manages its lifecycle. 

5 

6For detailed information about the sorting rules and algorithm, see docs/sorting.rst. 

7 

8How it works: 

9 1. PyLint loads the plugin and calls register() function (the plugin entry point 

10 defined in __init__.py and configured in pyproject.toml) 

11 2. register() creates a FunctionSortChecker instance and gives it to PyLint 

12 3. PyLint walks the AST (Abstract Syntax Tree) of user code 

13 4. For each AST node, PyLint calls corresponding visit_* methods on this checker 

14 (we only implement visit_module and visit_classdef from the many available) 

15 5. The checker analyzes nodes and calls self.add_message() when issues are found 

16 

17User Experience: 

18 $ pylint --load-plugins=pylint_sort_functions mycode.py 

19 # PyLint automatically uses this checker and reports any sorting violations 

20 

21The visitor pattern: PyLint calls visit_module() for modules and visit_classdef() 

22for class definitions. Each method analyzes the code structure and reports issues. 

23""" 

24 

25from pathlib import Path 

26from typing import TYPE_CHECKING, Any 

27 

28from astroid import nodes # type: ignore[import-untyped] 

29from pylint.checkers import BaseChecker 

30 

31from pylint_sort_functions import messages, utils 

32 

33if TYPE_CHECKING: 

34 pass 

35 

36 

37class FunctionSortChecker(BaseChecker): 

38 """Checker to enforce alphabetical sorting of functions and methods. 

39 

40 Inherits from PyLint's BaseChecker which provides the visitor pattern 

41 infrastructure. PyLint will automatically call our visit_* methods as it 

42 traverses the AST. 

43 """ 

44 

45 name = "function-sort" # Identifier used by PyLint for this checker 

46 msgs: dict[str, Any] = messages.MESSAGES # Message definitions from messages.py 

47 options = ( 

48 ( 

49 "public-api-patterns", 

50 { 

51 "default": [ 

52 "main", 

53 "run", 

54 "execute", 

55 "start", 

56 "stop", 

57 "setup", 

58 "teardown", 

59 ], 

60 "type": "csv", 

61 "metavar": "<pattern1,pattern2,...>", 

62 "help": ( 

63 "List of function names to always treat as public API. " 

64 "These functions will not be flagged for privacy even if only used " 

65 "internally. Useful for entry points and framework callbacks." 

66 ), 

67 }, 

68 ), 

69 ( 

70 "enable-privacy-detection", 

71 { 

72 "default": True, 

73 "type": "yn", 

74 "metavar": "<y or n>", 

75 "help": ( 

76 "Enable detection of functions that should be made private " 

77 "based on usage analysis." 

78 ), 

79 }, 

80 ), 

81 ) 

82 

83 # Public methods 

84 

85 def visit_classdef(self, node: nodes.ClassDef) -> None: 

86 """Visit a class definition to check method sorting. 

87 

88 Called by PyLint for each class definition in the code. 

89 

90 :param node: The class definition AST node to analyze 

91 :type node: nodes.ClassDef 

92 """ 

93 methods = utils.get_methods_from_class(node) 

94 if not utils.are_methods_sorted(methods): 

95 # Report unsorted methods - see docs/usage.rst for message details 

96 self.add_message("unsorted-methods", node=node, args=(node.name,)) 

97 

98 if not utils.are_functions_properly_separated(methods): 

99 # Report mixed visibility - see docs/usage.rst for suppression options 

100 self.add_message( 

101 "mixed-function-visibility", 

102 node=node, 

103 args=(f"class {node.name}",), 

104 ) 

105 

106 def visit_module(self, node: nodes.Module) -> None: 

107 """Visit a module node to check function sorting and privacy. 

108 

109 Called by PyLint once for each Python module (file) being analyzed. 

110 

111 :param node: The module AST node to analyze 

112 :type node: nodes.Module 

113 """ 

114 functions = utils.get_functions_from_node(node) 

115 if not utils.are_functions_sorted(functions): 

116 # Report unsorted functions - see docs/usage.rst for configuration 

117 self.add_message("unsorted-functions", node=node, args=("module",)) 

118 

119 if not utils.are_functions_properly_separated(functions): 

120 # Report mixed visibility - see docs/usage.rst for severity levels 

121 self.add_message("mixed-function-visibility", node=node, args=("module",)) 

122 

123 # Check if any public functions should be private 

124 self._check_function_privacy(functions, node) 

125 

126 # Private methods 

127 

128 def _check_function_privacy( 

129 self, functions: list[nodes.FunctionDef], node: nodes.Module 

130 ) -> None: 

131 """Check if any public functions should be private using import analysis. 

132 

133 :param functions: List of functions to check 

134 :type functions: list[nodes.FunctionDef] 

135 :param node: The module node 

136 :type node: nodes.Module 

137 """ 

138 # Check if privacy detection is enabled 

139 if not self.linter.config.enable_privacy_detection: 

140 return 

141 

142 module_path = self._get_module_path() 

143 if not module_path: 

144 # Fallback to heuristic approach when path info unavailable 

145 self._check_function_privacy_heuristic(functions, node) 

146 return 

147 

148 project_root = self._get_project_root(module_path) 

149 if not project_root: 

150 # Fallback to heuristic approach when project root can't be determined 

151 self._check_function_privacy_heuristic(functions, node) 

152 return 

153 

154 # Get configured public API patterns 

155 public_patterns = set(self.linter.config.public_api_patterns) 

156 

157 # Use import analysis for more accurate detection 

158 for func in functions: 

159 if utils.should_function_be_private( 

160 func, module_path, project_root, public_patterns 

161 ): 

162 # Report function that should be private 

163 # See docs/usage.rst for privacy detection feature 

164 self.add_message( 

165 "function-should-be-private", node=func, args=(func.name,) 

166 ) 

167 

168 def _check_function_privacy_heuristic( 

169 self, 

170 functions: list[nodes.FunctionDef], 

171 node: nodes.Module, # pylint: disable=unused-argument 

172 ) -> None: 

173 """Check function privacy using heuristic approach (fallback). 

174 

175 Used when import analysis is not available due to missing path information. 

176 

177 :param functions: List of functions to check 

178 :type functions: list[nodes.FunctionDef] 

179 :param node: The module node 

180 :type node: nodes.Module 

181 """ 

182 # Skip privacy check in heuristic mode - we can't determine without paths 

183 # This fallback mode is rarely used (only when linter has no file info) 

184 pass # pragma: no cover 

185 

186 def _get_module_path(self) -> Path | None: 

187 """Get the current module's file path from the linter. 

188 

189 :returns: Path to the module file, or None if not available 

190 :rtype: Path | None 

191 """ 

192 # Defensive check: ensure linter has current_file attribute 

193 # (version compatibility) 

194 if hasattr(self.linter, "current_file") and self.linter.current_file: 

195 return Path(self.linter.current_file).resolve() 

196 return None 

197 

198 def _get_project_root(self, module_path: Path) -> Path | None: 

199 """Find the project root directory by looking for common project markers. 

200 

201 :param module_path: Path to the current module 

202 :type module_path: Path 

203 :returns: Project root path, or module's parent directory as fallback 

204 :rtype: Path | None 

205 """ 

206 # Common project markers that indicate a project root 

207 project_markers = [ 

208 "pyproject.toml", 

209 "setup.py", 

210 "setup.cfg", 

211 ".git", 

212 "requirements.txt", 

213 "Pipfile", 

214 "poetry.lock", 

215 ] 

216 

217 current = module_path.parent 

218 while current != current.parent: 

219 # Check if any project marker exists in current directory 

220 if any((current / marker).exists() for marker in project_markers): 

221 return current 

222 current = current.parent 

223 

224 # Fallback: use the module's parent directory 

225 # This handles cases where we're testing in isolated directories 

226 return module_path.parent