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

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 

25import json 

26from pathlib import Path 

27from typing import TYPE_CHECKING, Any 

28 

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

30from pylint.checkers import BaseChecker 

31 

32from pylint_sort_functions import messages, utils 

33from pylint_sort_functions.utils import CategoryConfig, MethodCategory 

34 

35if TYPE_CHECKING: 

36 pass 

37 

38 

39class 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 )