Coverage for src/docstring_format_checker/core.py: 100%

383 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-10 13:31 +0000

1# ============================================================================ # 

2# # 

3# Title: Title # 

4# Purpose: Purpose # 

5# Notes: Notes # 

6# Author: chrimaho # 

7# Created: Created # 

8# References: References # 

9# Sources: Sources # 

10# Edited: Edited # 

11# # 

12# ============================================================================ # 

13 

14 

15# ---------------------------------------------------------------------------- # 

16# # 

17# Overview #### 

18# # 

19# ---------------------------------------------------------------------------- # 

20 

21 

22# ---------------------------------------------------------------------------- # 

23# Description #### 

24# ---------------------------------------------------------------------------- # 

25 

26 

27""" 

28!!! note "Summary" 

29 Core docstring checking functionality. 

30""" 

31 

32 

33# ---------------------------------------------------------------------------- # 

34# # 

35# Setup #### 

36# # 

37# ---------------------------------------------------------------------------- # 

38 

39 

40## --------------------------------------------------------------------------- # 

41## Imports #### 

42## --------------------------------------------------------------------------- # 

43 

44 

45# ## Python StdLib Imports ---- 

46import ast 

47import fnmatch 

48import re 

49from pathlib import Path 

50from typing import Iterator, Literal, NamedTuple, Optional, Union 

51 

52# ## Local First Party Imports ---- 

53from docstring_format_checker.config import Config, SectionConfig 

54from docstring_format_checker.utils.exceptions import ( 

55 DirectoryNotFoundError, 

56 DocstringError, 

57 InvalidFileError, 

58) 

59 

60 

61## --------------------------------------------------------------------------- # 

62## Exports #### 

63## --------------------------------------------------------------------------- # 

64 

65 

66__all__: list[str] = [ 

67 "DocstringChecker", 

68 "FunctionAndClassDetails", 

69 "SectionConfig", 

70 "DocstringError", 

71] 

72 

73 

74# ---------------------------------------------------------------------------- # 

75# # 

76# Main Section #### 

77# # 

78# ---------------------------------------------------------------------------- # 

79 

80 

81class FunctionAndClassDetails(NamedTuple): 

82 """ 

83 !!! note "Summary" 

84 Details about a function or class found in the AST. 

85 """ 

86 

87 item_type: Literal["function", "class", "method"] 

88 name: str 

89 node: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef] 

90 lineno: int 

91 parent_class: Optional[str] = None 

92 

93 

94class DocstringChecker: 

95 """ 

96 !!! note "Summary" 

97 Main class for checking docstring format and completeness. 

98 """ 

99 

100 def __init__(self, config: Config) -> None: 

101 """ 

102 !!! note "Summary" 

103 Initialize the docstring checker. 

104 

105 Params: 

106 config (Config): 

107 Configuration object containing global settings and section definitions. 

108 """ 

109 self.config = config 

110 self.sections_config: list[SectionConfig] = config.sections 

111 self.required_sections: list[SectionConfig] = [s for s in config.sections if s.required] 

112 self.optional_sections: list[SectionConfig] = [s for s in config.sections if not s.required] 

113 

114 def check_file(self, file_path: Union[str, Path]) -> list[DocstringError]: 

115 """ 

116 !!! note "Summary" 

117 Check docstrings in a Python file. 

118 

119 Params: 

120 file_path (Union[str, Path]): 

121 Path to the Python file to check. 

122 

123 Raises: 

124 (FileNotFoundError): 

125 If the file doesn't exist. 

126 (InvalidFileError): 

127 If the file is not a Python file. 

128 (UnicodeError): 

129 If the file can't be decoded. 

130 (SyntaxError): 

131 If the file contains invalid Python syntax. 

132 

133 Returns: 

134 (list[DocstringError]): 

135 List of DocstringError objects for any validation failures. 

136 """ 

137 

138 file_path = Path(file_path) 

139 if not file_path.exists(): 

140 raise FileNotFoundError(f"File not found: {file_path}") 

141 

142 if file_path.suffix != ".py": 

143 raise InvalidFileError(f"File must be a Python file (.py): {file_path}") 

144 

145 # Read and parse the file 

146 try: 

147 with open(file_path, encoding="utf-8") as f: 

148 content: str = f.read() 

149 except UnicodeDecodeError as e: 

150 raise UnicodeError(f"Cannot decode file {file_path}: {e}") from e 

151 

152 try: 

153 tree: ast.Module = ast.parse(content) 

154 except SyntaxError as e: 

155 raise SyntaxError(f"Invalid Python syntax in {file_path}: {e}") from e 

156 

157 # Extract all functions and classes 

158 items: list[FunctionAndClassDetails] = self._extract_items(tree) 

159 

160 # Check each item 

161 errors: list[DocstringError] = [] 

162 for item in items: 

163 try: 

164 self._check_single_docstring(item, str(file_path)) 

165 except DocstringError as e: 

166 errors.append(e) 

167 

168 return errors 

169 

170 def check_directory( 

171 self, 

172 directory_path: Union[str, Path], 

173 exclude_patterns: Optional[list[str]] = None, 

174 ) -> dict[str, list[DocstringError]]: 

175 """ 

176 !!! note "Summary" 

177 Check docstrings in all Python files in a directory recursively. 

178 

179 Params: 

180 directory_path (Union[str, Path]): 

181 Path to the directory to check. 

182 exclude_patterns (Optional[list[str]]): 

183 List of glob patterns to exclude. 

184 

185 Raises: 

186 (FileNotFoundError): 

187 If the directory doesn't exist. 

188 (DirectoryNotFoundError): 

189 If the path is not a directory. 

190 

191 Returns: 

192 (dict[str, list[DocstringError]]): 

193 Dictionary mapping file paths to lists of DocstringError objects. 

194 """ 

195 

196 directory_path = Path(directory_path) 

197 if not directory_path.exists(): 

198 raise FileNotFoundError(f"Directory not found: {directory_path}") 

199 

200 if not directory_path.is_dir(): 

201 raise DirectoryNotFoundError(f"Path is not a directory: {directory_path}") 

202 

203 python_files: list[Path] = list(directory_path.glob("**/*.py")) 

204 

205 # Filter out excluded patterns 

206 if exclude_patterns: 

207 filtered_files: list[Path] = [] 

208 for file_path in python_files: 

209 relative_path: Path = file_path.relative_to(directory_path) 

210 should_exclude = False 

211 for pattern in exclude_patterns: 

212 if fnmatch.fnmatch(str(relative_path), pattern): 

213 should_exclude = True 

214 break 

215 if not should_exclude: 

216 filtered_files.append(file_path) 

217 python_files = filtered_files 

218 

219 # Check each file 

220 results: dict[str, list[DocstringError]] = {} 

221 for file_path in python_files: 

222 try: 

223 errors: list[DocstringError] = self.check_file(file_path) 

224 if errors: # Only include files with errors 

225 results[str(file_path)] = errors 

226 except (FileNotFoundError, ValueError, SyntaxError) as e: 

227 # Create a special error for file-level issues 

228 error = DocstringError( 

229 message=str(e), 

230 file_path=str(file_path), 

231 line_number=0, 

232 item_name="", 

233 item_type="file", 

234 ) 

235 results[str(file_path)] = [error] 

236 

237 return results 

238 

239 def _is_overload_function(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> bool: 

240 """ 

241 !!! note "Summary" 

242 Check if a function definition is decorated with @overload. 

243 

244 Params: 

245 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]): 

246 The function node to check for @overload decorator. 

247 

248 Returns: 

249 (bool): 

250 True if the function has @overload decorator, False otherwise. 

251 """ 

252 

253 for decorator in node.decorator_list: 

254 # Handle direct name reference: @overload 

255 if isinstance(decorator, ast.Name) and decorator.id == "overload": 

256 return True 

257 # Handle attribute reference: @typing.overload 

258 elif isinstance(decorator, ast.Attribute) and decorator.attr == "overload": 

259 return True 

260 return False 

261 

262 def _extract_items(self, tree: ast.AST) -> list[FunctionAndClassDetails]: 

263 """ 

264 !!! note "Summary" 

265 Extract all functions and classes from the AST. 

266 

267 Params: 

268 tree (ast.AST): 

269 The Abstract Syntax Tree (AST) to extract items from. 

270 

271 Returns: 

272 (list[FunctionAndClassDetails]): 

273 A list of extracted function and class details. 

274 """ 

275 

276 items: list[FunctionAndClassDetails] = [] 

277 

278 class ItemVisitor(ast.NodeVisitor): 

279 """ 

280 !!! note "Summary" 

281 AST visitor to extract function and class definitions 

282 """ 

283 

284 def __init__(self, checker: DocstringChecker) -> None: 

285 """ 

286 !!! note "Summary" 

287 Initialize the AST visitor. 

288 """ 

289 self.class_stack: list[str] = [] 

290 self.checker: DocstringChecker = checker 

291 

292 def visit_ClassDef(self, node: ast.ClassDef) -> None: 

293 """ 

294 !!! note "Summary" 

295 Visit class definition node. 

296 """ 

297 # Skip private classes unless check_private is enabled 

298 should_check: bool = self.checker.config.global_config.check_private or not node.name.startswith("_") 

299 if should_check: 

300 items.append( 

301 FunctionAndClassDetails( 

302 item_type="class", 

303 name=node.name, 

304 node=node, 

305 lineno=node.lineno, 

306 parent_class=None, 

307 ) 

308 ) 

309 

310 # Visit methods in this class 

311 self.class_stack.append(node.name) 

312 self.generic_visit(node) 

313 self.class_stack.pop() 

314 

315 def visit_FunctionDef(self, node: ast.FunctionDef) -> None: 

316 """ 

317 !!! note "Summary" 

318 Visit function definition node. 

319 """ 

320 self._visit_function(node) 

321 

322 def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: 

323 """ 

324 !!! note "Summary" 

325 Visit async function definition node. 

326 """ 

327 self._visit_function(node) 

328 

329 def _visit_function(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> None: 

330 """ 

331 !!! note "Summary" 

332 Visit function definition node (sync or async). 

333 """ 

334 

335 # Skip private functions unless check_private is enabled 

336 should_check: bool = self.checker.config.global_config.check_private or not node.name.startswith("_") 

337 if should_check: 

338 # Skip @overload functions - they don't need docstrings 

339 if not self.checker._is_overload_function(node): 

340 item_type: Literal["function", "method"] = "method" if self.class_stack else "function" 

341 parent_class: Optional[str] = self.class_stack[-1] if self.class_stack else None 

342 

343 items.append( 

344 FunctionAndClassDetails( 

345 item_type=item_type, 

346 name=node.name, 

347 node=node, 

348 lineno=node.lineno, 

349 parent_class=parent_class, 

350 ) 

351 ) 

352 

353 self.generic_visit(node) 

354 

355 visitor = ItemVisitor(self) 

356 visitor.visit(tree) 

357 

358 return items 

359 

360 def _check_single_docstring(self, item: FunctionAndClassDetails, file_path: str) -> None: 

361 """ 

362 !!! note "Summary" 

363 Check a single function or class docstring. 

364 

365 Params: 

366 item (FunctionAndClassDetails): 

367 The function or class to check. 

368 file_path (str): 

369 The path to the file containing the item. 

370 

371 Returns: 

372 (None): 

373 Nothing is returned. 

374 """ 

375 

376 docstring: Optional[str] = ast.get_docstring(item.node) 

377 

378 # Check if any required sections apply to this item type 

379 requires_docstring = False 

380 applicable_sections: list[SectionConfig] = [] 

381 

382 for section in self.sections_config: 

383 if section.required: 

384 # Check if this section applies to this item type 

385 if section.type == "free_text": 

386 # Free text sections apply only to functions and methods, not classes 

387 if isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)): 

388 requires_docstring = True 

389 applicable_sections.append(section) 

390 elif section.type == "list_name_and_type": 

391 if section.name.lower() == "params" and isinstance( 

392 item.node, (ast.FunctionDef, ast.AsyncFunctionDef) 

393 ): 

394 # Params only apply to functions/methods 

395 requires_docstring = True 

396 applicable_sections.append(section) 

397 elif section.name.lower() in ["returns", "return"] and isinstance( 

398 item.node, (ast.FunctionDef, ast.AsyncFunctionDef) 

399 ): 

400 # Returns only apply to functions/methods 

401 requires_docstring = True 

402 applicable_sections.append(section) 

403 elif section.type in ["list_type", "list_name"]: 

404 # These sections apply to functions/methods that might have them 

405 if isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)): 

406 requires_docstring = True 

407 applicable_sections.append(section) 

408 

409 if not docstring: 

410 # Only require docstrings if the global flag is enabled 

411 if requires_docstring and self.config.global_config.require_docstrings: 

412 message: str = f"Missing docstring for {item.item_type}" 

413 raise DocstringError( 

414 message=message, 

415 file_path=file_path, 

416 line_number=item.lineno, 

417 item_name=item.name, 

418 item_type=item.item_type, 

419 ) 

420 return # No docstring required or docstring requirement disabled 

421 

422 # Validate docstring sections if docstring exists 

423 self._validate_docstring_sections(docstring, item, file_path) 

424 

425 def _validate_docstring_sections( 

426 self, 

427 docstring: str, 

428 item: FunctionAndClassDetails, 

429 file_path: str, 

430 ) -> None: 

431 """ 

432 !!! note "Summary" 

433 Validate the sections within a docstring. 

434 

435 Params: 

436 docstring (str): 

437 The docstring to validate. 

438 item (FunctionAndClassDetails): 

439 The function or class to check. 

440 file_path (str): 

441 The path to the file containing the item. 

442 

443 Returns: 

444 (None): 

445 Nothing is returned. 

446 """ 

447 

448 errors: list[str] = [] 

449 

450 # Check each required section 

451 for section in self.required_sections: 

452 if section.type == "free_text": 

453 if not self._check_free_text_section(docstring, section): 

454 errors.append(f"Missing required section: {section.name}") 

455 

456 elif section.type == "list_name_and_type": 

457 if section.name.lower() == "params" and isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)): 

458 if not self._check_params_section(docstring, item.node): 

459 errors.append("Missing or invalid Params section") 

460 elif section.name.lower() in ["returns", "return"]: 

461 if not self._check_returns_section(docstring): 

462 errors.append("Missing or invalid Returns section") 

463 

464 elif section.type == "list_type": 

465 if section.name.lower() in ["raises", "raise"]: 

466 if not self._check_raises_section(docstring): 

467 errors.append("Missing or invalid Raises section") 

468 elif section.name.lower() in ["yields", "yield"]: 

469 if not self._check_yields_section(docstring): 

470 errors.append("Missing or invalid Yields section") 

471 

472 elif section.type == "list_name": 

473 # Simple name sections - check if they exist 

474 if not self._check_simple_section(docstring, section.name): 

475 errors.append(f"Missing required section: {section.name}") 

476 

477 # Check section order 

478 order_errors: list[str] = self._check_section_order(docstring) 

479 errors.extend(order_errors) 

480 

481 # Check for mutual exclusivity (returns vs yields) 

482 if self._has_both_returns_and_yields(docstring): 

483 errors.append("Docstring cannot have both Returns and Yields sections") 

484 

485 # Check for undefined sections in docstring (only if not allowed) 

486 if not self.config.global_config.allow_undefined_sections: 

487 undefined_errors: list[str] = self._check_undefined_sections(docstring) 

488 errors.extend(undefined_errors) 

489 

490 # Check admonition values match configuration 

491 admonition_errors: list[str] = self._check_admonition_values(docstring) 

492 errors.extend(admonition_errors) 

493 

494 # Check colon usage for admonition vs non-admonition sections 

495 colon_errors: list[str] = self._check_colon_usage(docstring) 

496 errors.extend(colon_errors) 

497 

498 # Check title case for non-admonition sections 

499 title_case_errors: list[str] = self._check_title_case_sections(docstring) 

500 errors.extend(title_case_errors) 

501 

502 # Check parentheses for list type sections 

503 parentheses_errors: list[str] = self._check_parentheses_validation(docstring) 

504 errors.extend(parentheses_errors) 

505 

506 if errors: 

507 combined_message: str = "; ".join(errors) 

508 raise DocstringError( 

509 message=combined_message, 

510 file_path=file_path, 

511 line_number=item.lineno, 

512 item_name=item.name, 

513 item_type=item.item_type, 

514 ) 

515 

516 def _check_free_text_section(self, docstring: str, section: SectionConfig) -> bool: 

517 """ 

518 !!! note "Summary" 

519 Check if a free text section exists in the docstring. 

520 

521 Params: 

522 docstring (str): 

523 The docstring to check. 

524 section (SectionConfig): 

525 The section configuration to validate. 

526 

527 Returns: 

528 (bool): 

529 `True` if the section exists, `False` otherwise. 

530 """ 

531 

532 if isinstance(section.admonition, str) and section.admonition and section.prefix: 

533 # Format like: !!! note "Summary" 

534 # Make the section name part case-insensitive too 

535 escaped_name = re.escape(section.name) 

536 pattern = rf'{re.escape(section.prefix)}\s+{re.escape(section.admonition)}\s+"[^"]*{escaped_name}[^"]*"' 

537 return bool(re.search(pattern, docstring, re.IGNORECASE)) 

538 elif section.name.lower() in ["summary"]: 

539 # For summary, accept either formal format or simple docstring 

540 formal_pattern = r'!!! note "Summary"' 

541 if re.search(formal_pattern, docstring, re.IGNORECASE): 

542 return True 

543 # Accept any non-empty docstring as summary 

544 return len(docstring.strip()) > 0 

545 elif section.name.lower() in ["examples", "example"]: 

546 # Look for examples section 

547 return bool(re.search(r'\?\?\?\+ example "Examples"', docstring, re.IGNORECASE)) 

548 

549 return True # Default to true for unknown free text sections 

550 

551 def _check_params_section(self, docstring: str, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> bool: 

552 """ 

553 !!! note "Summary" 

554 Check if the Params section exists and documents all parameters. 

555 

556 Params: 

557 docstring (str): 

558 The docstring to check. 

559 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]): 

560 The function node to check. 

561 

562 Returns: 

563 (bool): 

564 `True` if the section exists and is valid, `False` otherwise. 

565 """ 

566 

567 # Get function parameters (excluding 'self' for methods) 

568 params: list[str] = [arg.arg for arg in node.args.args if arg.arg != "self"] 

569 

570 if not params: 

571 return True # No parameters to document 

572 

573 # Check if Params section exists 

574 if not re.search(r"Params:", docstring): 

575 return False 

576 

577 # Check each parameter is documented 

578 for param in params: 

579 param_pattern: str = rf"{re.escape(param)}\s*\([^)]+\):" 

580 if not re.search(param_pattern, docstring): 

581 return False 

582 

583 return True 

584 

585 def _check_returns_section(self, docstring: str) -> bool: 

586 """ 

587 !!! note "Summary" 

588 Check if the Returns section exists. 

589 

590 Params: 

591 docstring (str): 

592 The docstring to check. 

593 

594 Returns: 

595 (bool): 

596 `True` if the section exists, `False` otherwise. 

597 """ 

598 

599 return bool(re.search(r"Returns:", docstring)) 

600 

601 def _check_raises_section(self, docstring: str) -> bool: 

602 """ 

603 !!! note "Summary" 

604 Check if the Raises section exists. 

605 

606 Params: 

607 docstring (str): 

608 The docstring to check. 

609 

610 Returns: 

611 (bool): 

612 `True` if the section exists, `False` otherwise. 

613 """ 

614 

615 return bool(re.search(r"Raises:", docstring)) 

616 

617 def _has_both_returns_and_yields(self, docstring: str) -> bool: 

618 """ 

619 !!! note "Summary" 

620 Check if docstring has both Returns and Yields sections. 

621 

622 Params: 

623 docstring (str): 

624 The docstring to check. 

625 

626 Returns: 

627 (bool): 

628 `True` if the section exists, `False` otherwise. 

629 """ 

630 

631 has_returns = bool(re.search(r"Returns:", docstring)) 

632 has_yields = bool(re.search(r"Yields:", docstring)) 

633 return has_returns and has_yields 

634 

635 def _check_section_order(self, docstring: str) -> list[str]: 

636 """ 

637 !!! note "Summary" 

638 Check that sections appear in the correct order. 

639 

640 Params: 

641 docstring (str): 

642 The docstring to check. 

643 

644 Returns: 

645 (list[str]): 

646 A list of error messages, if any. 

647 """ 

648 

649 # Build expected order from configuration 

650 section_patterns: list[tuple[str, str]] = [] 

651 for section in sorted(self.sections_config, key=lambda x: x.order): 

652 if ( 

653 section.type == "free_text" 

654 and isinstance(section.admonition, str) 

655 and section.admonition 

656 and section.prefix 

657 ): 

658 pattern: str = ( 

659 rf'{re.escape(section.prefix)}\s+{re.escape(section.admonition)}\s+".*{re.escape(section.name)}"' 

660 ) 

661 section_patterns.append((pattern, section.name)) 

662 elif section.name.lower() == "params": 

663 section_patterns.append((r"Params:", "Params")) 

664 elif section.name.lower() in ["returns", "return"]: 

665 section_patterns.append((r"Returns:", "Returns")) 

666 elif section.name.lower() in ["yields", "yield"]: 

667 section_patterns.append((r"Yields:", "Yields")) 

668 elif section.name.lower() in ["raises", "raise"]: 

669 section_patterns.append((r"Raises:", "Raises")) 

670 

671 # Add some default patterns for common sections 

672 default_patterns: list[tuple[str, str]] = [ 

673 (r'!!! note "Summary"', "Summary"), 

674 (r'!!! details "Details"', "Details"), 

675 (r'\?\?\?\+ example "Examples"', "Examples"), 

676 (r'\?\?\?\+ success "Credit"', "Credit"), 

677 (r'\?\?\?\+ calculation "Equation"', "Equation"), 

678 (r'\?\?\?\+ info "Notes"', "Notes"), 

679 (r'\?\?\? question "References"', "References"), 

680 (r'\?\?\? tip "See Also"', "See Also"), 

681 ] 

682 

683 all_patterns: list[tuple[str, str]] = section_patterns + default_patterns 

684 

685 found_sections: list[tuple[int, str]] = [] 

686 for pattern, section_name in all_patterns: 

687 match: Optional[re.Match[str]] = re.search(pattern, docstring, re.IGNORECASE) 

688 if match: 

689 found_sections.append((match.start(), section_name)) 

690 

691 # Sort by position in docstring 

692 found_sections.sort(key=lambda x: x[0]) 

693 

694 # Build expected order 

695 expected_order: list[str] = [s.name.title() for s in sorted(self.sections_config, key=lambda x: x.order)] 

696 expected_order.extend( 

697 [ 

698 "Summary", 

699 "Details", 

700 "Examples", 

701 "Credit", 

702 "Equation", 

703 "Notes", 

704 "References", 

705 "See Also", 

706 ] 

707 ) 

708 

709 # Check order matches expected order 

710 errors: list[str] = [] 

711 last_expected_index = -1 

712 for _, section_name in found_sections: 

713 try: 

714 current_index: int = expected_order.index(section_name) 

715 if current_index < last_expected_index: 

716 errors.append(f"Section '{section_name}' appears out of order") 

717 last_expected_index: int = current_index 

718 except ValueError: 

719 # Section not in expected order list - might be OK 

720 pass 

721 

722 return errors 

723 

724 def _check_yields_section(self, docstring: str) -> bool: 

725 """ 

726 !!! note "Summary" 

727 Check if the Yields section exists. 

728 

729 Params: 

730 docstring (str): 

731 The docstring to check. 

732 

733 Returns: 

734 (bool): 

735 `True` if the section exists, `False` otherwise. 

736 """ 

737 

738 return bool(re.search(r"Yields:", docstring)) 

739 

740 def _check_simple_section(self, docstring: str, section_name: str) -> bool: 

741 """ 

742 !!! note "Summary" 

743 Check if a simple named section exists. 

744 

745 Params: 

746 docstring (str): 

747 The docstring to check. 

748 section_name (str): 

749 The name of the section to check for. 

750 

751 Returns: 

752 (bool): 

753 `True` if the section exists, `False` otherwise. 

754 """ 

755 

756 pattern: str = rf"{re.escape(section_name)}:" 

757 return bool(re.search(pattern, docstring, re.IGNORECASE)) 

758 

759 def _check_undefined_sections(self, docstring: str) -> list[str]: 

760 """ 

761 !!! note "Summary" 

762 Check for sections in docstring that are not defined in configuration. 

763 

764 Params: 

765 docstring (str): 

766 The docstring to check. 

767 

768 Returns: 

769 (list[str]): 

770 A list of error messages for undefined sections. 

771 """ 

772 

773 errors: list[str] = [] 

774 

775 # Get all configured section names (case-insensitive) 

776 configured_sections: set[str] = {section.name.lower() for section in self.sections_config} 

777 

778 # Common patterns for different section types 

779 section_patterns: list[tuple[str, str]] = [ 

780 # Standard sections with colons (but not inside quotes) 

781 (r"^(\w+):\s*", "colon"), 

782 # Admonition sections with various prefixes 

783 (r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\"", "admonition"), 

784 ] 

785 

786 found_sections: set[str] = set() 

787 

788 for pattern, pattern_type in section_patterns: 

789 matches: Iterator[re.Match[str]] = re.finditer(pattern, docstring, re.IGNORECASE | re.MULTILINE) 

790 for match in matches: 

791 section_name: str = match.group(1).lower().strip() 

792 

793 # Remove colon if present (for colon pattern matches) 

794 section_name = section_name.rstrip(":") 

795 

796 # Skip empty matches or common docstring content 

797 if not section_name or section_name in ["", "py", "python", "sh", "shell"]: 

798 continue 

799 

800 # Skip code blocks and inline code 

801 if any(char in section_name for char in ["`", ".", "/", "\\"]): 

802 continue 

803 

804 found_sections.add(section_name) 

805 

806 # Check which found sections are not configured 

807 for section_name in found_sections: 

808 if section_name not in configured_sections: 

809 errors.append(f"Section '{section_name}' found in docstring but not defined in configuration") 

810 

811 return errors 

812 

813 def _check_admonition_values(self, docstring: str) -> list[str]: 

814 """ 

815 !!! note "Summary" 

816 Check that admonition values in docstring match configuration. 

817 

818 Params: 

819 docstring (str): 

820 The docstring to check. 

821 

822 Returns: 

823 (list[str]): 

824 A list of error messages for mismatched admonitions. 

825 """ 

826 

827 errors: list[str] = [] 

828 

829 # Create mapping of section names to expected admonitions 

830 section_admonitions: dict[str, str] = {} 

831 for section in self.sections_config: 

832 if section.type == "free_text" and isinstance(section.admonition, str) and section.admonition: 

833 section_admonitions[section.name.lower()] = section.admonition.lower() 

834 

835 # Pattern to find all admonition sections 

836 admonition_pattern = r"(?:\?\?\?[+]?|!!!)\s+(\w+)\s+\"([^\"]+)\"" 

837 matches: Iterator[re.Match[str]] = re.finditer(admonition_pattern, docstring, re.IGNORECASE) 

838 

839 for match in matches: 

840 actual_admonition: str = match.group(1).lower() 

841 section_title: str = match.group(2).lower() 

842 

843 # Check if this section is configured with a specific admonition 

844 if section_title in section_admonitions: 

845 expected_admonition: str = section_admonitions[section_title] 

846 if actual_admonition != expected_admonition: 

847 errors.append( 

848 f"Section '{section_title}' has incorrect admonition '{actual_admonition}', " 

849 f"expected '{expected_admonition}'" 

850 ) 

851 

852 # Check if section shouldn't have admonition but does 

853 section_config: Optional[SectionConfig] = next( 

854 (s for s in self.sections_config if s.name.lower() == section_title), None 

855 ) 

856 if section_config and section_config.admonition is False: 

857 errors.append(f"Section '{section_title}' is configured as non-admonition but found as admonition") 

858 

859 return errors 

860 

861 def _check_colon_usage(self, docstring: str) -> list[str]: 

862 """ 

863 !!! note "Summary" 

864 Check that colons are used correctly for admonition vs non-admonition sections. 

865 """ 

866 

867 errors: list[str] = [] 

868 

869 # Check admonition sections (should not end with colon) 

870 admonition_pattern = r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\"" 

871 matches: Iterator[re.Match[str]] = re.finditer(admonition_pattern, docstring, re.IGNORECASE) 

872 

873 for match in matches: 

874 section_title: str = match.group(1) 

875 has_colon: bool = section_title.endswith(":") 

876 section_title_clean: str = section_title.rstrip(":").lower() 

877 

878 # Find config for this section 

879 section_config: Optional[SectionConfig] = next( 

880 (s for s in self.sections_config if s.name.lower() == section_title_clean), None 

881 ) 

882 if section_config and isinstance(section_config.admonition, str) and section_config.admonition: 

883 if has_colon: 

884 errors.append( 

885 f"Section '{section_title_clean}' is an admonition, therefore it should not end with ':', " 

886 f"see: '{match.group(0)}'" 

887 ) 

888 

889 # Check non-admonition sections (should end with colon) 

890 non_admonition_pattern = r"^(\w+)(:?)$" 

891 for line in docstring.split("\n"): 

892 line: str = line.strip() 

893 match: Optional[re.Match[str]] = re.match(non_admonition_pattern, line) 

894 if match: 

895 section_name: str = match.group(1).lower() 

896 has_colon: bool = match.group(2) == ":" 

897 

898 # Find config for this section 

899 section_config = next((s for s in self.sections_config if s.name.lower() == section_name), None) 

900 if section_config and section_config.admonition is False: 

901 if not has_colon: 

902 errors.append( 

903 f"Section '{section_name}' is non-admonition, therefore it must end with ':', " 

904 f"see: '{line}'" 

905 ) 

906 

907 return errors 

908 

909 def _check_title_case_sections(self, docstring: str) -> list[str]: 

910 """ 

911 !!! note "Summary" 

912 Check that non-admonition sections are single word, title case, and match config name. 

913 """ 

914 

915 errors: list[str] = [] 

916 

917 # Pattern to find section headers (single word followed by optional colon) 

918 section_pattern = r"^(\w+):?$" 

919 

920 for line in docstring.split("\n"): 

921 line: str = line.strip() 

922 match: Optional[re.Match[str]] = re.match(section_pattern, line) 

923 if match: 

924 section_word: str = match.group(1) 

925 section_name_lower: str = section_word.lower() 

926 

927 # Check if this is a configured non-admonition section 

928 section_config: Optional[SectionConfig] = next( 

929 (s for s in self.sections_config if s.name.lower() == section_name_lower), None 

930 ) 

931 if section_config and section_config.admonition is False: 

932 # Check if it's title case 

933 expected_title_case: str = section_config.name.title() 

934 if section_word != expected_title_case: 

935 errors.append( 

936 f"Section '{section_name_lower}' must be in title case as '{expected_title_case}', " 

937 f"found: '{section_word}'" 

938 ) 

939 

940 return errors 

941 

942 def _check_parentheses_validation(self, docstring: str) -> list[str]: 

943 """ 

944 !!! note "Summary" 

945 Check that list_type and list_name_and_type sections have proper parentheses. 

946 """ 

947 

948 errors: list[str] = [] 

949 

950 # Get sections that require parentheses 

951 parentheses_sections: list[SectionConfig] = [ 

952 s for s in self.sections_config if s.type in ["list_type", "list_name_and_type"] 

953 ] 

954 

955 if not parentheses_sections: 

956 return errors 

957 

958 # Check each line in the docstring 

959 lines: list[str] = docstring.split("\n") 

960 current_section = None 

961 type_line_indent = None # Track indentation of type definition lines 

962 

963 for i, line in enumerate(lines): 

964 stripped_line: str = line.strip() 

965 

966 # Detect section headers 

967 # Admonition sections 

968 admonition_match: Optional[re.Match[str]] = re.match( 

969 r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\"", stripped_line, re.IGNORECASE 

970 ) 

971 if admonition_match: 

972 section_name: str = admonition_match.group(1).lower() 

973 current_section: Optional[SectionConfig] = next( 

974 (s for s in parentheses_sections if s.name.lower() == section_name), None 

975 ) 

976 type_line_indent = None # Reset for new section 

977 continue 

978 

979 # Non-admonition sections - only match actual section headers, not indented content 

980 # Section headers should be at the start of the line (no leading whitespace) 

981 if not line.startswith((" ", "\t")): # Not indented 

982 simple_section_match: Optional[re.Match[str]] = re.match(r"^(\w+):?$", stripped_line) 

983 if simple_section_match: 

984 section_name: str = simple_section_match.group(1).lower() 

985 # Only consider it a section if it matches our known sections 

986 potential_section: Optional[SectionConfig] = next( 

987 (s for s in self.sections_config if s.name.lower() == section_name), None 

988 ) 

989 if potential_section: 

990 # This is a real section header 

991 current_section = next( 

992 (s for s in parentheses_sections if s.name.lower() == section_name), None 

993 ) 

994 type_line_indent = None # Reset for new section 

995 continue 

996 # If it doesn't match a known section, fall through to content processing 

997 

998 # Check content lines if we're in a parentheses-required section 

999 if current_section and stripped_line and not stripped_line.startswith(("!", "?", "#")): 

1000 # Look for parameter/type definitions 

1001 if ":" in stripped_line: 

1002 # Calculate current line indentation 

1003 current_indent = len(line) - len(line.lstrip()) 

1004 

1005 # Skip description lines that start with common description words 

1006 description_prefixes = [ 

1007 "default:", 

1008 "note:", 

1009 "example:", 

1010 "see:", 

1011 "warning:", 

1012 "info:", 

1013 "tip:", 

1014 "returns:", 

1015 ] 

1016 is_description_line = any( 

1017 stripped_line.lower().startswith(prefix) for prefix in description_prefixes 

1018 ) 

1019 

1020 # Skip lines that are clearly descriptions (containing "Default:", etc.) 

1021 if ( 

1022 is_description_line 

1023 or "Default:" in stripped_line 

1024 or "Output format:" in stripped_line 

1025 or "Show examples:" in stripped_line 

1026 ): 

1027 continue 

1028 

1029 # For list_type sections, we need special handling 

1030 if current_section.type == "list_type": 

1031 # Check if this line has parentheses at the beginning 

1032 if re.search(r"^\s*\([^)]+\):", stripped_line): 

1033 # This is a valid type definition line, remember its indentation 

1034 type_line_indent = current_indent 

1035 continue 

1036 else: 

1037 # If no type definition has been found yet, allow lines with colons as possible descriptions 

1038 if type_line_indent is None: 

1039 continue 

1040 # Check if this is a description line (more indented than type line) 

1041 if current_indent > type_line_indent: 

1042 # This is a description line, skip validation 

1043 continue 

1044 else: 

1045 # This should be a type definition but doesn't have proper format 

1046 errors.append( 

1047 f"Section '{current_section.name}' (type: '{current_section.type}') requires " 

1048 f"parenthesized types, see: '{stripped_line}'" 

1049 ) 

1050 # For list_name_and_type sections, check format like "name (type):" or "(type):" 

1051 elif current_section.type == "list_name_and_type": 

1052 # Check if this line has parentheses and looks like a parameter definition 

1053 if re.search(r"\([^)]+\):", stripped_line): 

1054 # This is a valid parameter definition line, remember its indentation 

1055 type_line_indent = current_indent 

1056 continue 

1057 else: 

1058 # Check if this is likely a description line based on various criteria 

1059 colon_part = stripped_line.split(":")[0].strip() 

1060 

1061 # Skip if it contains phrases that indicate it's a description, not a parameter 

1062 if any( 

1063 word in colon_part.lower() 

1064 for word in ["default", "output", "format", "show", "example"] 

1065 ): 

1066 continue 

1067 

1068 # Skip if it starts with bullet points or list markers 

1069 if stripped_line.strip().startswith(("-", "*", "•", "+")): 

1070 continue 

1071 

1072 # If we have found a parameter definition, check if this is a description line 

1073 if type_line_indent is not None: 

1074 # Skip if this is more indented than the parameter definition (description line) 

1075 if current_indent > type_line_indent: 

1076 continue 

1077 

1078 # Skip if the line before the colon contains multiple words (likely description) 

1079 words_before_colon = colon_part.split() 

1080 if len(words_before_colon) > 2: # More than "param_name (type)" 

1081 continue 

1082 

1083 # Only flag lines that could reasonably be parameter definitions 

1084 if ":" in stripped_line and not stripped_line.strip().startswith("#"): 

1085 errors.append( 

1086 f"Section '{current_section.name}' (type: '{current_section.type}') requires " 

1087 f"parenthesized types, see: '{stripped_line}'" 

1088 ) 

1089 

1090 return errors