Coverage for src/pylint_sort_functions/checker.py: 100%
168 statements
« prev ^ index » next coverage.py v7.10.1, created at 2025-08-12 16:06 +0200
« prev ^ index » next coverage.py v7.10.1, created at 2025-08-12 16:06 +0200
1"""Main checker class for enforcing function and method sorting.
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.
6For detailed information about the sorting rules and algorithm, see docs/sorting.rst.
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
17User Experience:
18 $ pylint --load-plugins=pylint_sort_functions mycode.py
19 # PyLint automatically uses this checker and reports any sorting violations
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"""
25import json
26from pathlib import Path
27from typing import TYPE_CHECKING, Any
29from astroid import nodes # type: ignore[import-untyped]
30from pylint.checkers import BaseChecker
32from pylint_sort_functions import messages, utils
33from pylint_sort_functions.utils import CategoryConfig, MethodCategory
35if TYPE_CHECKING:
36 pass
39class FunctionSortChecker(BaseChecker):
40 """Checker to enforce alphabetical sorting of functions and methods.
42 Inherits from PyLint's BaseChecker which provides the visitor pattern
43 infrastructure. PyLint will automatically call our visit_* methods as it
44 traverses the AST.
45 """
47 name = "function-sort" # Identifier used by PyLint for this checker
48 msgs: dict[str, Any] = messages.MESSAGES # Message definitions from messages.py
49 options = (
50 (
51 "public-api-patterns",
52 {
53 "default": [
54 "main",
55 "run",
56 "execute",
57 "start",
58 "stop",
59 "setup",
60 "teardown",
61 ],
62 "type": "csv",
63 "metavar": "<pattern1,pattern2,...>",
64 "help": (
65 "List of function names to always treat as public API. "
66 "These functions will not be flagged for privacy even if only used "
67 "internally. Useful for entry points and framework callbacks."
68 ),
69 },
70 ),
71 (
72 "enable-privacy-detection",
73 {
74 "default": True,
75 "type": "yn",
76 "metavar": "<y or n>",
77 "help": (
78 "Enable detection of functions that should be made private "
79 "based on usage analysis."
80 ),
81 },
82 ),
83 (
84 "ignore-decorators",
85 {
86 "default": [],
87 "type": "csv",
88 "metavar": "<pattern1,pattern2,...>",
89 "help": (
90 "Decorator patterns to exclude from sorting requirements. "
91 "Supports exact matches and wildcards (e.g., @app.route)."
92 ),
93 },
94 ),
95 (
96 "privacy-exclude-dirs",
97 {
98 "default": [],
99 "type": "csv",
100 "metavar": "<dir1,dir2,...>",
101 "help": (
102 "Directories to exclude from privacy analysis. Files in these "
103 "directories are scanned but their references are ignored when "
104 "determining if functions should be private. Useful for test "
105 "directories and other non-production code."
106 ),
107 },
108 ),
109 (
110 "privacy-exclude-patterns",
111 {
112 "default": [],
113 "type": "csv",
114 "metavar": "<pattern1,pattern2,...>",
115 "help": (
116 "File patterns to exclude from privacy analysis. Files matching "
117 "these patterns are scanned but their references are ignored when "
118 "determining if functions should be private. Supports glob "
119 "patterns like 'test_*.py', '*_test.py', 'conftest.py'."
120 ),
121 },
122 ),
123 (
124 "privacy-additional-test-patterns",
125 {
126 "default": [],
127 "type": "csv",
128 "metavar": "<pattern1,pattern2,...>",
129 "help": (
130 "Additional file patterns to treat as test files, beyond the "
131 "built-in detection. These patterns are added to the default "
132 "test detection (test_*.py, *_test.py, conftest.py, tests/). "
133 "Supports glob patterns like 'spec_*.py', '*_spec.py'."
134 ),
135 },
136 ),
137 (
138 "privacy-update-tests",
139 {
140 "default": False,
141 "type": "yn",
142 "metavar": "<y or n>",
143 "help": (
144 "Enable automatic updating of test files when functions are "
145 "privatized. When enabled, test files will be automatically "
146 "updated to use the new private function names. Requires the "
147 "privacy fixer to be run."
148 ),
149 },
150 ),
151 (
152 "privacy-override-test-detection",
153 {
154 "default": False,
155 "type": "yn",
156 "metavar": "<y or n>",
157 "help": (
158 "Override the built-in test file detection entirely and only "
159 "use the patterns specified in privacy-exclude-patterns and "
160 "privacy-exclude-dirs. When disabled, both built-in detection "
161 "and custom patterns are used together."
162 ),
163 },
164 ),
165 (
166 "enable-method-categories",
167 {
168 "default": False,
169 "type": "yn",
170 "metavar": "<y or n>",
171 "help": (
172 "Enable flexible method categorization system. When disabled, "
173 "uses the original binary public/private sorting. When enabled, "
174 "allows custom method categories and framework presets."
175 ),
176 },
177 ),
178 (
179 "framework-preset",
180 {
181 "default": None,
182 "type": "string",
183 "metavar": "<preset_name>",
184 "help": (
185 "Use a built-in framework preset for method categorization. "
186 "Available presets: pytest, unittest, pyqt, django. "
187 "Requires enable-method-categories=true."
188 ),
189 },
190 ),
191 (
192 "method-categories",
193 {
194 "default": None,
195 "type": "string",
196 "metavar": "<json_config>",
197 "help": (
198 "JSON configuration for custom method categories. Defines "
199 "category names, patterns, decorators, and priorities. "
200 'Example: \'[{"name":"properties","decorators":["@property"]}]\''
201 ),
202 },
203 ),
204 (
205 "category-sorting",
206 {
207 "default": "alphabetical",
208 "type": "choice",
209 "choices": ["alphabetical", "declaration"],
210 "metavar": "<alphabetical|declaration>",
211 "help": (
212 "How to sort methods within each category. "
213 "'alphabetical' sorts methods alphabetically within categories. "
214 "'declaration' preserves the original declaration order."
215 ),
216 },
217 ),
218 (
219 "enforce-section-headers",
220 {
221 "default": False,
222 "type": "yn",
223 "metavar": "<y or n>",
224 "help": (
225 "Enforce that methods must be organized under correct section "
226 "headers according to their categorization. When enabled, "
227 "methods appearing under wrong section headers will trigger "
228 "warnings. Requires enable-method-categories=true."
229 ),
230 },
231 ),
232 (
233 "require-section-headers",
234 {
235 "default": False,
236 "type": "yn",
237 "metavar": "<y or n>",
238 "help": (
239 "Require section headers to be present for each category that "
240 "contains methods. When enabled, missing section headers will "
241 "trigger warnings. Requires enforce-section-headers=true."
242 ),
243 },
244 ),
245 (
246 "allow-empty-sections",
247 {
248 "default": True,
249 "type": "yn",
250 "metavar": "<y or n>",
251 "help": (
252 "Allow section headers to exist without any methods underneath. "
253 "When disabled, empty section headers will trigger warnings. "
254 "Requires enforce-section-headers=true."
255 ),
256 },
257 ),
258 )
260 # Public methods
262 def visit_classdef(self, node: nodes.ClassDef) -> None:
263 """Visit a class definition to check method sorting.
265 Called by PyLint for each class definition in the code.
267 :param node: The class definition AST node to analyze
268 :type node: nodes.ClassDef
269 """
270 methods = utils.get_methods_from_class(node)
272 # Get configured decorator exclusions and category configuration
273 ignore_decorators = self.linter.config.ignore_decorators or []
274 category_config = self._get_category_config()
276 if not utils.are_methods_sorted_with_exclusions(
277 methods, ignore_decorators, category_config
278 ):
279 # Report unsorted methods - see docs/usage.rst for message details
280 self.add_message("unsorted-methods", node=node, args=(node.name,))
282 if not utils.are_functions_properly_separated(methods):
283 # Report mixed visibility - see docs/usage.rst for suppression options
284 self.add_message(
285 "mixed-function-visibility",
286 node=node,
287 args=(f"class {node.name}",),
288 )
290 # Check section header validation if enabled
291 if getattr(self.linter.config, "enforce_section_headers", False):
292 self._validate_method_sections(methods, node)
294 def visit_module(self, node: nodes.Module) -> None:
295 """Visit a module node to check function sorting and privacy.
297 Called by PyLint once for each Python module (file) being analyzed.
299 :param node: The module AST node to analyze
300 :type node: nodes.Module
301 """
302 functions = utils.get_functions_from_node(node)
304 # Get configured decorator exclusions and category configuration
305 ignore_decorators = self.linter.config.ignore_decorators or []
306 category_config = self._get_category_config()
308 if not utils.are_functions_sorted_with_exclusions(
309 functions, ignore_decorators, category_config
310 ):
311 # Report unsorted functions - see docs/usage.rst for configuration
312 self.add_message("unsorted-functions", node=node, args=("module",))
314 if not utils.are_functions_properly_separated(functions):
315 # Report mixed visibility - see docs/usage.rst for severity levels
316 self.add_message("mixed-function-visibility", node=node, args=("module",))
318 # Check section header validation if enabled
319 if getattr(self.linter.config, "enforce_section_headers", False):
320 self._validate_function_sections(functions, node)
322 # Check if any public functions should be private
323 self._check_function_privacy(functions, node)
325 # Private methods
327 def _check_function_privacy(
328 self, functions: list[nodes.FunctionDef], node: nodes.Module
329 ) -> None:
330 """Check if any public functions should be private using import analysis.
332 :param functions: List of functions to check
333 :type functions: list[nodes.FunctionDef]
334 :param node: The module node
335 :type node: nodes.Module
336 """
337 # Check if privacy detection is enabled
338 if not self.linter.config.enable_privacy_detection:
339 return
341 module_path = self._get_module_path()
342 if not module_path:
343 # Fallback to heuristic approach when path info unavailable
344 self._check_function_privacy_heuristic(functions, node)
345 return
347 project_root = self._get_project_root(module_path)
348 if not project_root:
349 # Fallback to heuristic approach when project root can't be determined
350 self._check_function_privacy_heuristic(functions, node)
351 return
353 # Get configured public API patterns
354 public_patterns = set(self.linter.config.public_api_patterns)
356 # Get privacy exclusion configuration
357 privacy_config = self._get_privacy_config()
359 # Use import analysis for more accurate detection
360 for func in functions:
361 if utils.should_function_be_private(
362 func, module_path, project_root, public_patterns, privacy_config
363 ):
364 # Report function that should be private
365 # See docs/usage.rst for privacy detection feature
366 self.add_message(
367 "function-should-be-private", node=func, args=(func.name,)
368 )
369 elif utils.should_function_be_public(
370 func, module_path, project_root, privacy_config
371 ):
372 # Report private function that should be public
373 # See docs/usage.rst for privacy detection feature
374 self.add_message(
375 "function-should-be-public", node=func, args=(func.name,)
376 )
378 def _check_function_privacy_heuristic(
379 self,
380 functions: list[nodes.FunctionDef],
381 node: nodes.Module, # pylint: disable=unused-argument
382 ) -> None:
383 """Check function privacy using heuristic approach (fallback).
385 Used when import analysis is not available due to missing path information.
387 :param functions: List of functions to check
388 :type functions: list[nodes.FunctionDef]
389 :param node: The module node
390 :type node: nodes.Module
391 """
392 # Skip privacy check in heuristic mode - we can't determine without paths
393 # This fallback mode is rarely used (only when linter has no file info)
394 pass # pragma: no cover
396 def _get_category_config(self) -> CategoryConfig:
397 """Create CategoryConfig from linter configuration.
399 :returns: Category configuration for method sorting
400 :rtype: CategoryConfig
401 """
402 config = CategoryConfig()
404 # Get basic configuration options
405 enable_categories = getattr(
406 self.linter.config, "enable_method_categories", False
407 )
408 category_sorting = getattr(
409 self.linter.config, "category_sorting", "alphabetical"
410 )
411 framework_preset = getattr(self.linter.config, "framework_preset", None)
412 method_categories_json = getattr(self.linter.config, "method_categories", None)
414 config.enable_categories = enable_categories
415 config.category_sorting = category_sorting
417 # If categories are disabled, return with defaults
418 if not enable_categories:
419 return config
421 try:
422 # Handle framework preset
423 if framework_preset:
424 config.categories = self._get_framework_preset_categories(
425 framework_preset
426 )
427 # Handle custom JSON configuration
428 elif method_categories_json:
429 config.categories = self._parse_method_categories_json(
430 method_categories_json
431 )
432 # Use defaults if nothing specified
434 except (ValueError, json.JSONDecodeError) as e:
435 # Configuration error - report it and use defaults
436 # Note: We can't use self.add_message here as we're not in a visit method
437 # The error will surface when pylint runs and encounters invalid config
438 print(f"Warning: Invalid method category configuration: {e}")
439 # Keep default categories
441 return config
443 def _get_framework_preset_categories(self, preset: str) -> list[MethodCategory]:
444 """Get method categories for a framework preset.
446 :param preset: Framework preset name
447 :type preset: str
448 :returns: List of method categories for the preset
449 :rtype: list[MethodCategory]
450 :raises ValueError: If preset is not recognized
451 """
452 presets = {
453 "pytest": [
454 MethodCategory(
455 name="test_fixtures",
456 patterns=["setUp", "tearDown", "setup_*", "teardown_*"],
457 priority=10,
458 section_header="# Test fixtures",
459 ),
460 MethodCategory(
461 name="test_methods",
462 patterns=["test_*"],
463 priority=5,
464 section_header="# Test methods",
465 ),
466 MethodCategory(
467 name="public_methods",
468 patterns=["*"],
469 priority=1,
470 section_header="# Public methods",
471 ),
472 MethodCategory(
473 name="private_methods",
474 patterns=["_*"],
475 priority=2,
476 section_header="# Private methods",
477 ),
478 ],
479 "unittest": [
480 MethodCategory(
481 name="test_fixtures",
482 patterns=["setUp", "tearDown", "setUpClass", "tearDownClass"],
483 priority=10,
484 section_header="# Test fixtures",
485 ),
486 MethodCategory(
487 name="test_methods",
488 patterns=["test_*"],
489 priority=5,
490 section_header="# Test methods",
491 ),
492 MethodCategory(
493 name="public_methods",
494 patterns=["*"],
495 priority=1,
496 section_header="# Public methods",
497 ),
498 MethodCategory(
499 name="private_methods",
500 patterns=["_*"],
501 priority=2,
502 section_header="# Private methods",
503 ),
504 ],
505 "pyqt": [
506 MethodCategory(
507 name="initialization",
508 patterns=["__init__", "setup*", "*_ui"],
509 priority=10,
510 section_header="# Initialization",
511 ),
512 MethodCategory(
513 name="properties",
514 decorators=["@property", "@*.setter", "@*.deleter"],
515 priority=8,
516 section_header="# Properties",
517 ),
518 MethodCategory(
519 name="event_handlers",
520 patterns=["*Event", "on_*", "handle_*", "eventFilter"],
521 priority=6,
522 section_header="# Event handlers",
523 ),
524 MethodCategory(
525 name="public_methods",
526 patterns=["*"],
527 priority=1,
528 section_header="# Public methods",
529 ),
530 MethodCategory(
531 name="private_methods",
532 patterns=["_*"],
533 priority=2,
534 section_header="# Private methods",
535 ),
536 ],
537 }
539 if preset not in presets:
540 available = ", ".join(presets.keys())
541 raise ValueError(
542 f"Unknown framework preset '{preset}'. Available: {available}"
543 )
545 return presets[preset]
547 def _get_module_path(self) -> Path | None:
548 """Get the current module's file path from the linter.
550 :returns: Path to the module file, or None if not available
551 :rtype: Path | None
552 """
553 # Defensive check: ensure linter has current_file attribute
554 # (version compatibility)
555 if hasattr(self.linter, "current_file") and self.linter.current_file:
556 try:
557 # Handle Mock objects and other invalid file paths gracefully
558 current_file = self.linter.current_file
559 if hasattr(current_file, "_mock_name"):
560 # This is a Mock object, return None
561 return None
562 return Path(current_file).resolve()
563 except (TypeError, OSError, ValueError):
564 # Handle cases where current_file is not a valid path
565 return None
566 return None
568 def _get_privacy_config(self) -> dict[str, Any]:
569 """Extract privacy-related configuration from linter config.
571 :returns: Dictionary containing privacy configuration options
572 :rtype: dict[str, Any]
573 """
574 config = {}
576 # Handle both real config and Mock objects robustly
577 def get_config_value(attr_name: str, default_value: Any) -> Any:
578 try:
579 value = getattr(self.linter.config, attr_name, default_value)
580 # If it's a Mock object, return the default instead
581 if hasattr(value, "_mock_name"):
582 return default_value
583 return value
584 except (AttributeError, TypeError):
585 return default_value
587 config["exclude_dirs"] = get_config_value("privacy_exclude_dirs", [])
588 config["exclude_patterns"] = get_config_value("privacy_exclude_patterns", [])
589 config["additional_test_patterns"] = get_config_value(
590 "privacy_additional_test_patterns", []
591 )
592 config["update_tests"] = get_config_value("privacy_update_tests", False)
593 config["override_test_detection"] = get_config_value(
594 "privacy_override_test_detection", False
595 )
597 return config
599 def _get_project_root(self, module_path: Path) -> Path | None:
600 """Find the project root directory by looking for common project markers.
602 :param module_path: Path to the current module
603 :type module_path: Path
604 :returns: Project root path, or module's parent directory as fallback
605 :rtype: Path | None
606 """
607 # Common project markers that indicate a project root
608 project_markers = [
609 "pyproject.toml",
610 "setup.py",
611 "setup.cfg",
612 ".git",
613 "requirements.txt",
614 "Pipfile",
615 "poetry.lock",
616 ]
618 current = module_path.parent
619 while current != current.parent:
620 # Check if any project marker exists in current directory
621 if any((current / marker).exists() for marker in project_markers):
622 return current
623 current = current.parent
625 # Fallback: use the module's parent directory
626 # This handles cases where we're testing in isolated directories
627 return module_path.parent
629 def _parse_method_categories_json(self, json_str: str) -> list[MethodCategory]:
630 """Parse JSON method categories configuration.
632 :param json_str: JSON string containing category definitions
633 :type json_str: str
634 :returns: List of parsed method categories
635 :rtype: list[MethodCategory]
636 :raises ValueError: If JSON is malformed or contains invalid category
637 definitions
638 :raises json.JSONDecodeError: If JSON syntax is invalid
639 """
640 try:
641 categories_data = json.loads(json_str)
642 except json.JSONDecodeError as e:
643 raise json.JSONDecodeError(
644 f"Invalid JSON in method-categories: {e}", json_str, 0
645 ) from e
647 if not isinstance(categories_data, list):
648 raise ValueError(
649 "method-categories must be a JSON array of category objects"
650 )
652 categories = []
653 for i, category_data in enumerate(categories_data):
654 if not isinstance(category_data, dict):
655 raise ValueError(f"Category {i} must be a JSON object")
657 # Validate required fields
658 if "name" not in category_data:
659 raise ValueError(f"Category {i} is missing required 'name' field")
661 # Create category with validation
662 try:
663 category = MethodCategory(
664 name=category_data["name"],
665 patterns=category_data.get("patterns", []),
666 decorators=category_data.get("decorators", []),
667 priority=category_data.get("priority", 0),
668 section_header=category_data.get(
669 "section_header",
670 f"# {category_data['name'].replace('_', ' ').title()}",
671 ),
672 )
673 categories.append(category)
674 except (TypeError, ValueError) as e:
675 raise ValueError(
676 f"Invalid category {i} "
677 f"({category_data.get('name', 'unnamed')}): {e}"
678 ) from e
680 return categories
682 def _validate_function_sections(
683 self, functions: list[nodes.FunctionDef], module_node: nodes.Module
684 ) -> None:
685 """Validate that functions are in correct sections according to headers.
687 :param functions: List of function nodes to validate
688 :type functions: list[nodes.FunctionDef]
689 :param module_node: Module containing the functions
690 :type module_node: nodes.Module
691 """
692 if not functions:
693 return
695 # Get the source file content
696 module_path = self._get_module_path()
697 if not module_path or not module_path.exists():
698 return
700 try:
701 lines = module_path.read_text(encoding="utf-8").splitlines()
702 except (OSError, UnicodeDecodeError):
703 return
705 category_config = self._get_category_config()
706 self._validate_sections_common(functions, lines, category_config, module_node)
708 def _validate_method_sections(
709 self, methods: list[nodes.FunctionDef], class_node: nodes.ClassDef
710 ) -> None:
711 """Validate that methods are in correct sections according to headers.
713 :param methods: List of method nodes to validate
714 :type methods: list[nodes.FunctionDef]
715 :param class_node: Class containing the methods
716 :type class_node: nodes.ClassDef
717 """
718 if not methods:
719 return
721 # Get the source file content
722 module_path = self._get_module_path()
723 if not module_path or not module_path.exists():
724 return
726 try:
727 lines = module_path.read_text(encoding="utf-8").splitlines()
728 except (OSError, UnicodeDecodeError):
729 return
731 category_config = self._get_category_config()
732 self._validate_sections_common(methods, lines, category_config, class_node)
734 def _validate_sections_common(
735 self,
736 methods: list[nodes.FunctionDef],
737 lines: list[str],
738 config: utils.CategoryConfig,
739 node: nodes.ClassDef | nodes.Module,
740 ) -> None:
741 """Common section validation logic for both methods and functions.
743 :param methods: List of method/function nodes to validate
744 :type methods: list[nodes.FunctionDef]
745 :param lines: Source file lines
746 :type lines: list[str]
747 :param config: Category configuration
748 :type config: utils.CategoryConfig
749 :param node: AST node for error reporting (class or module)
750 :type node: nodes.ClassDef | nodes.Module
751 """
752 # Check for methods in wrong sections
753 violations = utils.get_section_violations(methods, lines, config)
754 for method, expected_section, actual_section in violations:
755 self.add_message(
756 "method-wrong-section",
757 node=method,
758 args=(method.name, expected_section, actual_section),
759 )
761 # Check for missing section headers if required
762 if getattr(self.linter.config, "require_section_headers", False):
763 missing_headers = utils.find_missing_section_headers(methods, lines, config)
764 for category_name in missing_headers:
765 # Find category to get section header text
766 category = next(
767 (cat for cat in config.categories if cat.name == category_name),
768 None,
769 )
770 if category and category.section_header:
771 self.add_message(
772 "missing-section-header",
773 node=node,
774 args=(category.section_header, category_name),
775 )
777 # Check for empty section headers if not allowed
778 if not getattr(self.linter.config, "allow_empty_sections", True):
779 empty_headers = utils.find_empty_section_headers(methods, lines, config)
780 for category_name in empty_headers:
781 # Find category to get section header text
782 category = next(
783 (cat for cat in config.categories if cat.name == category_name),
784 None,
785 )
786 if category and category.section_header:
787 self.add_message(
788 "empty-section-header",
789 node=node,
790 args=(category.section_header,),
791 )