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

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

2

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

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

5

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

7

8 How 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

17 User Experience:

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

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

20

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

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

23"""

24

25 from pathlib import Path

26 from typing import TYPE_CHECKING, Any

27

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

29 from pylint.checkers import BaseChecker

30

31 from pylint_sort_functions import messages, utils

32

33 if TYPE_CHECKING:

34 pass

35

36

37 class 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