Coverage for src/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 import json

26 from pathlib import Path

27 from typing import TYPE_CHECKING, Any

28

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

30 from pylint.checkers import BaseChecker

31

32 from pylint_sort_functions import messages, utils

33 from pylint_sort_functions.utils import CategoryConfig, MethodCategory

34

35 if TYPE_CHECKING:

36 pass

37

38

39 class FunctionSortChecker(BaseChecker):

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

41

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

46

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 )

259

260 # Public methods

261

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

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

264

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

266

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

268 :type node: nodes.ClassDef

269 """

270 methods = utils.get_methods_from_class(node)

271

272 # Get configured decorator exclusions and category configuration

273 ignore_decorators = self.linter.config.ignore_decorators or []

274 category_config = self._get_category_config()

275

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,))

281

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 )

289

290 # Check section header validation if enabled

291 if getattr(self.linter.config, "enforce_section_headers", False):

292 self._validate_method_sections(methods, node)

293

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

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

296

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

298

299 :param node: The module AST node to analyze

300 :type node: nodes.Module

301 """

302 functions = utils.get_functions_from_node(node)

303

304 # Get configured decorator exclusions and category configuration

305 ignore_decorators = self.linter.config.ignore_decorators or []

306 category_config = self._get_category_config()

307

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",))

313

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",))

317

318 # Check section header validation if enabled

319 if getattr(self.linter.config, "enforce_section_headers", False):

320 self._validate_function_sections(functions, node)

321

322 # Check if any public functions should be private

323 self._check_function_privacy(functions, node)

324

325 # Private methods

326

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.

331

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

340

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

346

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

352

353 # Get configured public API patterns

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

355

356 # Get privacy exclusion configuration

357 privacy_config = self._get_privacy_config()

358

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 )

377

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

384

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

386

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

395

396 def _get_category_config(self) -> CategoryConfig:

397 """Create CategoryConfig from linter configuration.

398

399 :returns: Category configuration for method sorting

400 :rtype: CategoryConfig

401 """

402 config = CategoryConfig()

403

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)

413

414 config.enable_categories = enable_categories

415 config.category_sorting = category_sorting

416

417 # If categories are disabled, return with defaults

418 if not enable_categories:

419 return config

420

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

433

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

440

441 return config

442

443 def _get_framework_preset_categories(self, preset: str) -> list[MethodCategory]:

444 """Get method categories for a framework preset.

445

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 }

538

539 if preset not in presets:

540 available = ", ".join(presets.keys())

541 raise ValueError(

542 f"Unknown framework preset '{preset}'. Available: {available}"

543 )

544

545 return presets[preset]

546

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

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

549

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

567

568 def _get_privacy_config(self) -> dict[str, Any]:

569 """Extract privacy-related configuration from linter config.

570

571 :returns: Dictionary containing privacy configuration options

572 :rtype: dict[str, Any]

573 """

574 config = {}

575

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

586

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 )

596

597 return config

598

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

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

601

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 ]

617

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

624

625 # Fallback: use the module's parent directory

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

627 return module_path.parent

628

629 def _parse_method_categories_json(self, json_str: str) -> list[MethodCategory]:

630 """Parse JSON method categories configuration.

631

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

646

647 if not isinstance(categories_data, list):

648 raise ValueError(

649 "method-categories must be a JSON array of category objects"

650 )

651

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

656

657 # Validate required fields

658 if "name" not in category_data:

659 raise ValueError(f"Category {i} is missing required 'name' field")

660

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

679

680 return categories

681

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.

686

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

694

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

699

700 try:

701 lines = module_path.read_text(encoding="utf-8").splitlines()

702 except (OSError, UnicodeDecodeError):

703 return

704

705 category_config = self._get_category_config()

706 self._validate_sections_common(functions, lines, category_config, module_node)

707

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.

712

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

720

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

725

726 try:

727 lines = module_path.read_text(encoding="utf-8").splitlines()

728 except (OSError, UnicodeDecodeError):

729 return

730

731 category_config = self._get_category_config()

732 self._validate_sections_common(methods, lines, category_config, class_node)

733

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.

742

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 )

760

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 )

776

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 )