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

679 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-25 08:09 +0000

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

2# # 

3# Title: Docstring Format Checker Core Module # 

4# Purpose: Core docstring checking functionality. # 

5# # 

6# ============================================================================ # 

7 

8 

9# ---------------------------------------------------------------------------- # 

10# # 

11# Overview #### 

12# # 

13# ---------------------------------------------------------------------------- # 

14 

15 

16# ---------------------------------------------------------------------------- # 

17# Description #### 

18# ---------------------------------------------------------------------------- # 

19 

20 

21""" 

22!!! note "Summary" 

23 Core docstring checking functionality. 

24""" 

25 

26 

27# ---------------------------------------------------------------------------- # 

28# # 

29# Setup #### 

30# # 

31# ---------------------------------------------------------------------------- # 

32 

33 

34## --------------------------------------------------------------------------- # 

35## Imports #### 

36## --------------------------------------------------------------------------- # 

37 

38 

39# ## Python StdLib Imports ---- 

40import ast 

41import fnmatch 

42import re 

43from pathlib import Path 

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

45 

46# ## Local First Party Imports ---- 

47from docstring_format_checker.config import Config, SectionConfig 

48from docstring_format_checker.utils.exceptions import ( 

49 DirectoryNotFoundError, 

50 DocstringError, 

51 InvalidFileError, 

52) 

53 

54 

55## --------------------------------------------------------------------------- # 

56## Exports #### 

57## --------------------------------------------------------------------------- # 

58 

59 

60__all__: list[str] = [ 

61 "DocstringChecker", 

62 "FunctionAndClassDetails", 

63 "SectionConfig", 

64 "DocstringError", 

65] 

66 

67 

68# ---------------------------------------------------------------------------- # 

69# # 

70# Main Section #### 

71# # 

72# ---------------------------------------------------------------------------- # 

73 

74 

75class FunctionAndClassDetails(NamedTuple): 

76 """ 

77 !!! note "Summary" 

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

79 """ 

80 

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

82 name: str 

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

84 lineno: int 

85 parent_class: Optional[str] = None 

86 

87 

88class DocstringChecker: 

89 """ 

90 !!! note "Summary" 

91 Main class for checking docstring format and completeness. 

92 """ 

93 

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

95 """ 

96 !!! note "Summary" 

97 Initialize the docstring checker. 

98 

99 Params: 

100 config (Config): 

101 Configuration object containing global settings and section definitions. 

102 """ 

103 self.config: Config = config 

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

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

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

107 

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

109 """ 

110 !!! note "Summary" 

111 Check docstrings in a Python file. 

112 

113 Params: 

114 file_path (Union[str, Path]): 

115 Path to the Python file to check. 

116 

117 Raises: 

118 (FileNotFoundError): 

119 If the file doesn't exist. 

120 (InvalidFileError): 

121 If the file is not a Python file. 

122 (UnicodeError): 

123 If the file can't be decoded. 

124 (SyntaxError): 

125 If the file contains invalid Python syntax. 

126 

127 Returns: 

128 (list[DocstringError]): 

129 List of DocstringError objects for any validation failures. 

130 """ 

131 

132 file_path = Path(file_path) 

133 if not file_path.exists(): 

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

135 

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

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

138 

139 # Read and parse the file 

140 try: 

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

142 content: str = f.read() 

143 except UnicodeDecodeError as e: 

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

145 

146 try: 

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

148 except SyntaxError as e: 

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

150 

151 # Extract all functions and classes 

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

153 

154 # Check each item 

155 errors: list[DocstringError] = [] 

156 for item in items: 

157 try: 

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

159 except DocstringError as e: 

160 errors.append(e) 

161 

162 return errors 

163 

164 def _should_exclude_file(self, relative_path: Path, exclude_patterns: list[str]) -> bool: 

165 """ 

166 !!! note "Summary" 

167 Check if a file should be excluded based on patterns. 

168 

169 Params: 

170 relative_path (Path): 

171 The relative path of the file to check. 

172 exclude_patterns (list[str]): 

173 List of glob patterns to check against. 

174 

175 Returns: 

176 (bool): 

177 True if the file matches any exclusion pattern. 

178 """ 

179 for pattern in exclude_patterns: 

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

181 return True 

182 return False 

183 

184 def _filter_python_files( 

185 self, 

186 python_files: list[Path], 

187 directory_path: Path, 

188 exclude_patterns: list[str], 

189 ) -> list[Path]: 

190 """ 

191 !!! note "Summary" 

192 Filter Python files based on exclusion patterns. 

193 

194 Params: 

195 python_files (list[Path]): 

196 List of Python files to filter. 

197 directory_path (Path): 

198 The base directory path for relative path calculation. 

199 exclude_patterns (list[str]): 

200 List of glob patterns to exclude. 

201 

202 Returns: 

203 (list[Path]): 

204 Filtered list of Python files that don't match exclusion patterns. 

205 """ 

206 filtered_files: list[Path] = [] 

207 for file_path in python_files: 

208 relative_path: Path = file_path.relative_to(directory_path) 

209 if not self._should_exclude_file(relative_path, exclude_patterns): 

210 filtered_files.append(file_path) 

211 return filtered_files 

212 

213 def _check_file_with_error_handling(self, file_path: Path) -> list[DocstringError]: 

214 """ 

215 !!! note "Summary" 

216 Check a single file and handle exceptions gracefully. 

217 

218 Params: 

219 file_path (Path): 

220 Path to the file to check. 

221 

222 Returns: 

223 (list[DocstringError]): 

224 List of DocstringError objects found in the file. 

225 """ 

226 try: 

227 return self.check_file(file_path) 

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

229 # Create a special error for file-level issues 

230 error = DocstringError( 

231 message=str(e), 

232 file_path=str(file_path), 

233 line_number=0, 

234 item_name="", 

235 item_type="file", 

236 ) 

237 return [error] 

238 

239 def check_directory( 

240 self, 

241 directory_path: Union[str, Path], 

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

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

244 """ 

245 !!! note "Summary" 

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

247 

248 Params: 

249 directory_path (Union[str, Path]): 

250 Path to the directory to check. 

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

252 List of glob patterns to exclude. 

253 

254 Raises: 

255 (FileNotFoundError): 

256 If the directory doesn't exist. 

257 (DirectoryNotFoundError): 

258 If the path is not a directory. 

259 

260 Returns: 

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

262 Dictionary mapping file paths to lists of DocstringError objects. 

263 """ 

264 directory_path = Path(directory_path) 

265 if not directory_path.exists(): 

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

267 

268 if not directory_path.is_dir(): 

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

270 

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

272 

273 # Filter out excluded patterns if provided 

274 if exclude_patterns: 

275 python_files = self._filter_python_files(python_files, directory_path, exclude_patterns) 

276 

277 # Check each file and collect results 

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

279 for file_path in python_files: 

280 errors: list[DocstringError] = self._check_file_with_error_handling(file_path) 

281 if errors: # Only include files with errors 

282 results[str(file_path)] = errors 

283 

284 return results 

285 

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

287 """ 

288 !!! note "Summary" 

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

290 

291 Params: 

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

293 The function node to check for @overload decorator. 

294 

295 Returns: 

296 (bool): 

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

298 """ 

299 

300 for decorator in node.decorator_list: 

301 # Handle direct name reference: @overload 

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

303 return True 

304 # Handle attribute reference: @typing.overload 

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

306 return True 

307 return False 

308 

309 def _extract_all_params(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> list[str]: 

310 """ 

311 !!! note "Summary" 

312 Extract all parameter names from a function signature. 

313 

314 ???+ abstract "Details" 

315 Extract all parameter types including: 

316 

317 - Positional-only parameters (before `/`) 

318 - Regular positional parameters 

319 - Keyword-only parameters (after `*`) 

320 - Variable positional arguments (`*args`) 

321 - Variable keyword arguments (`**kwargs`) 

322 

323 Exclude `self` and `cls` parameters (method context parameters). 

324 

325 Params: 

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

327 The function node to extract parameters from. 

328 

329 Returns: 

330 (list[str]): 

331 List of all parameter names in the function signature. 

332 

333 ???+ example "Examples" 

334 

335 ```python 

336 def func(a, b, /, c, *args, d, **kwargs): ... 

337 

338 

339 # Returns: ['a', 'b', 'c', 'args', 'd', 'kwargs'] 

340 ``` 

341 """ 

342 params: list[str] = [] 

343 

344 # Positional-only parameters (before /) 

345 for arg in node.args.posonlyargs: 

346 if arg.arg not in ("self", "cls"): 

347 params.append(arg.arg) 

348 

349 # Regular positional parameters 

350 for arg in node.args.args: 

351 if arg.arg not in ("self", "cls"): 

352 params.append(arg.arg) 

353 

354 # Variable positional arguments (*args) 

355 if node.args.vararg: 

356 params.append(node.args.vararg.arg) 

357 

358 # Keyword-only parameters (after *) 

359 for arg in node.args.kwonlyargs: 

360 if arg.arg not in ("self", "cls"): 

361 params.append(arg.arg) 

362 

363 # Variable keyword arguments (**kwargs) 

364 if node.args.kwarg: 

365 params.append(node.args.kwarg.arg) 

366 

367 return params 

368 

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

370 """ 

371 !!! note "Summary" 

372 Extract all functions and classes from the AST. 

373 

374 Params: 

375 tree (ast.AST): 

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

377 

378 Returns: 

379 (list[FunctionAndClassDetails]): 

380 A list of extracted function and class details. 

381 """ 

382 

383 items: list[FunctionAndClassDetails] = [] 

384 

385 class ItemVisitor(ast.NodeVisitor): 

386 """ 

387 !!! note "Summary" 

388 AST visitor to extract function and class definitions 

389 """ 

390 

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

392 """ 

393 !!! note "Summary" 

394 Initialize the AST visitor. 

395 """ 

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

397 self.checker: DocstringChecker = checker 

398 

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

400 """ 

401 !!! note "Summary" 

402 Visit class definition node. 

403 """ 

404 # Skip private classes unless check_private is enabled 

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

406 if should_check: 

407 items.append( 

408 FunctionAndClassDetails( 

409 item_type="class", 

410 name=node.name, 

411 node=node, 

412 lineno=node.lineno, 

413 parent_class=None, 

414 ) 

415 ) 

416 

417 # Visit methods in this class 

418 self.class_stack.append(node.name) 

419 self.generic_visit(node) 

420 self.class_stack.pop() 

421 

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

423 """ 

424 !!! note "Summary" 

425 Visit function definition node. 

426 """ 

427 self._visit_function(node) 

428 

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

430 """ 

431 !!! note "Summary" 

432 Visit async function definition node. 

433 """ 

434 self._visit_function(node) 

435 

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

437 """ 

438 !!! note "Summary" 

439 Visit function definition node (sync or async). 

440 """ 

441 

442 # Skip private functions unless check_private is enabled 

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

444 if should_check: 

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

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

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

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

449 

450 items.append( 

451 FunctionAndClassDetails( 

452 item_type=item_type, 

453 name=node.name, 

454 node=node, 

455 lineno=node.lineno, 

456 parent_class=parent_class, 

457 ) 

458 ) 

459 

460 self.generic_visit(node) 

461 

462 visitor = ItemVisitor(self) 

463 visitor.visit(tree) 

464 

465 return items 

466 

467 def _is_section_applicable_to_item( 

468 self, 

469 section: SectionConfig, 

470 item: FunctionAndClassDetails, 

471 ) -> bool: 

472 """ 

473 !!! note "Summary" 

474 Check if a section configuration applies to the given item type. 

475 

476 Params: 

477 section (SectionConfig): 

478 The section configuration to check. 

479 item (FunctionAndClassDetails): 

480 The function or class to check against. 

481 

482 Returns: 

483 (bool): 

484 True if the section applies to this item type. 

485 """ 

486 

487 is_function: bool = isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)) 

488 

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

490 if section.type == "free_text": 

491 return is_function 

492 

493 # List name and type sections have specific rules 

494 if section.type == "list_name_and_type": 

495 section_name_lower: str = section.name.lower() 

496 

497 # Params only apply to functions/methods 

498 if section_name_lower == "params" and is_function: 

499 return True 

500 

501 # Returns only apply to functions/methods 

502 if section_name_lower in ["returns", "return"] and is_function: 

503 return True 

504 

505 return False 

506 

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

508 if section.type in ["list_type", "list_name"]: 

509 return is_function 

510 

511 return False 

512 

513 def _get_applicable_required_sections(self, item: FunctionAndClassDetails) -> list[SectionConfig]: 

514 """ 

515 !!! note "Summary" 

516 Get all required sections that apply to the given item. 

517 

518 Params: 

519 item (FunctionAndClassDetails): 

520 The function or class to check. 

521 

522 Returns: 

523 (list[SectionConfig]): 

524 List of section configurations that are required and apply to this item. 

525 """ 

526 

527 # Filter required sections based on item type 

528 applicable_sections: list[SectionConfig] = [] 

529 for section in self.sections_config: 

530 if section.required and self._is_section_applicable_to_item(section, item): 

531 applicable_sections.append(section) 

532 return applicable_sections 

533 

534 def _handle_missing_docstring( 

535 self, 

536 item: FunctionAndClassDetails, 

537 file_path: str, 

538 requires_docstring: bool, 

539 ) -> None: 

540 """ 

541 !!! note "Summary" 

542 Handle the case where a docstring is missing. 

543 

544 Params: 

545 item (FunctionAndClassDetails): 

546 The function or class without a docstring. 

547 file_path (str): 

548 The path to the file containing the item. 

549 requires_docstring (bool): 

550 Whether a docstring is required for this item. 

551 

552 Raises: 

553 DocstringError: If docstring is required but missing. 

554 """ 

555 

556 # Raise error if docstring is required 

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

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

559 raise DocstringError( 

560 message=message, 

561 file_path=file_path, 

562 line_number=item.lineno, 

563 item_name=item.name, 

564 item_type=item.item_type, 

565 ) 

566 

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

568 """ 

569 !!! note "Summary" 

570 Check a single function or class docstring. 

571 

572 Params: 

573 item (FunctionAndClassDetails): 

574 The function or class to check. 

575 file_path (str): 

576 The path to the file containing the item. 

577 

578 Returns: 

579 (None): 

580 Nothing is returned. 

581 """ 

582 

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

584 

585 # Determine which required sections apply to this item type 

586 applicable_sections: list[SectionConfig] = self._get_applicable_required_sections(item) 

587 requires_docstring: bool = len(applicable_sections) > 0 

588 

589 # Only require docstrings if the global flag is enabled 

590 if not docstring: 

591 self._handle_missing_docstring(item, file_path, requires_docstring) 

592 return # No docstring required or docstring requirement disabled 

593 

594 # Validate docstring sections if docstring exists 

595 self._validate_docstring_sections(docstring, item, file_path) 

596 

597 def _validate_docstring_sections( 

598 self, 

599 docstring: str, 

600 item: FunctionAndClassDetails, 

601 file_path: str, 

602 ) -> None: 

603 """ 

604 !!! note "Summary" 

605 Validate the sections within a docstring. 

606 

607 Params: 

608 docstring (str): 

609 The docstring to validate. 

610 item (FunctionAndClassDetails): 

611 The function or class to check. 

612 file_path (str): 

613 The path to the file containing the item. 

614 

615 Returns: 

616 (None): 

617 Nothing is returned. 

618 """ 

619 

620 errors: list[str] = [] 

621 

622 # Validate required sections are present 

623 required_section_errors: list[str] = self._validate_all_required_sections(docstring, item) 

624 errors.extend(required_section_errors) 

625 

626 # Validate all existing sections (required or not) 

627 existing_section_errors: list[str] = self._validate_all_existing_sections(docstring, item) 

628 errors.extend(existing_section_errors) 

629 

630 # Perform comprehensive validation checks 

631 comprehensive_errors: list[str] = self._perform_comprehensive_validation(docstring) 

632 errors.extend(comprehensive_errors) 

633 

634 # Report errors if found 

635 if errors: 

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

637 raise DocstringError( 

638 message=combined_message, 

639 file_path=file_path, 

640 line_number=item.lineno, 

641 item_name=item.name, 

642 item_type=item.item_type, 

643 ) 

644 

645 def _is_params_section_required(self, item: FunctionAndClassDetails) -> bool: 

646 """ 

647 !!! note "Summary" 

648 Check if params section is required for this item. 

649 

650 Params: 

651 item (FunctionAndClassDetails): 

652 The function or class details. 

653 

654 Returns: 

655 (bool): 

656 True if params section is required, False otherwise. 

657 """ 

658 

659 # For classes, params section not required (attributes handled differently) 

660 if isinstance(item.node, ast.ClassDef): 

661 return False 

662 

663 # For functions, only required if function has parameters (excluding self/cls) 

664 # item.node is guaranteed to be FunctionDef or AsyncFunctionDef due to type constraints 

665 params = self._extract_all_params(item.node) 

666 return len(params) > 0 

667 

668 def _validate_all_required_sections(self, docstring: str, item: FunctionAndClassDetails) -> list[str]: 

669 """ 

670 !!! note "Summary" 

671 Validate all required sections are present. 

672 

673 Params: 

674 docstring (str): 

675 The docstring to validate. 

676 item (FunctionAndClassDetails): 

677 The function or class details. 

678 

679 Returns: 

680 (list[str]): 

681 List of validation error messages for missing required sections. 

682 """ 

683 

684 errors: list[str] = [] 

685 for section in self.required_sections: 

686 # Special handling for params section - only required if function/class has parameters 

687 if section.name.lower() == "params": 

688 if not self._is_params_section_required(item): 

689 continue 

690 

691 # Only check if the section exists, don't validate content yet 

692 if not self._section_exists(docstring, section): 

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

694 return errors 

695 

696 def _validate_all_existing_sections(self, docstring: str, item: FunctionAndClassDetails) -> list[str]: 

697 """ 

698 !!! note "Summary" 

699 Validate content of all existing sections (required or not). 

700 

701 Params: 

702 docstring (str): 

703 The docstring to validate. 

704 item (FunctionAndClassDetails): 

705 The function or class details. 

706 

707 Returns: 

708 (list[str]): 

709 List of validation error messages for invalid section content. 

710 """ 

711 

712 errors: list[str] = [] 

713 for section in self.config.sections: 

714 # Only validate if the section actually exists in the docstring 

715 if self._section_exists(docstring, section): 

716 section_error = self._validate_single_section_content(docstring, section, item) 

717 if section_error: 

718 errors.append(section_error) 

719 return errors 

720 

721 def _section_exists(self, docstring: str, section: SectionConfig) -> bool: 

722 """ 

723 !!! note "Summary" 

724 Check if a section exists in the docstring. 

725 

726 Params: 

727 docstring (str): 

728 The docstring to check. 

729 section (SectionConfig): 

730 The section configuration. 

731 

732 Returns: 

733 (bool): 

734 `True` if section exists, `False` otherwise. 

735 """ 

736 

737 section_name: str = section.name.lower() 

738 

739 # For free text sections, use the existing logic from _check_free_text_section 

740 if section.type == "free_text": 

741 return self._check_free_text_section(docstring, section) 

742 

743 # Check for admonition style sections (for non-free-text types) 

744 if section.admonition and isinstance(section.admonition, str): 

745 if section.prefix and isinstance(section.prefix, str): 

746 # e.g., "!!! note" or "???+ abstract" 

747 pattern: str = rf"{re.escape(section.prefix)}\s+{re.escape(section.admonition)}" 

748 if re.search(pattern, docstring, re.IGNORECASE): 

749 return True 

750 

751 # Check for standard sections with colons (e.g., "Params:", "Returns:") 

752 pattern = rf"^[ \t]*{re.escape(section_name)}:[ \t]*$" 

753 if re.search(pattern, docstring, re.IGNORECASE | re.MULTILINE): 

754 return True 

755 

756 return False 

757 

758 def _validate_single_section_content( 

759 self, docstring: str, section: SectionConfig, item: FunctionAndClassDetails 

760 ) -> Optional[str]: 

761 """ 

762 !!! note "Summary" 

763 Validate the content of a single section based on its type. 

764 

765 Params: 

766 docstring (str): 

767 The docstring to validate. 

768 section (SectionConfig): 

769 The section configuration to validate against. 

770 item (FunctionAndClassDetails): 

771 The function or class details. 

772 

773 Returns: 

774 (Optional[str]): 

775 Error message if validation fails, None otherwise. 

776 """ 

777 

778 if section.type == "list_name_and_type": 

779 return self._validate_list_name_and_type_section(docstring, section, item) 

780 

781 if section.type == "list_name": 

782 return self._validate_list_name_section(docstring, section) 

783 

784 # For `section.type in ("free_text", "list_type")` 

785 # these sections do not need content validation beyond existence 

786 return None 

787 

788 def _validate_list_name_and_type_section( 

789 self, docstring: str, section: SectionConfig, item: FunctionAndClassDetails 

790 ) -> Optional[str]: 

791 """ 

792 !!! note "Summary" 

793 Validate list_name_and_type sections (params, returns). 

794 

795 Params: 

796 docstring (str): 

797 The docstring to validate. 

798 section (SectionConfig): 

799 The section configuration. 

800 item (FunctionAndClassDetails): 

801 The function or class details. 

802 

803 Returns: 

804 (Optional[str]): 

805 Error message if section is invalid, None otherwise. 

806 """ 

807 

808 section_name: str = section.name.lower() 

809 

810 if section_name == "params" and isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)): 

811 # Check params section exists and is properly formatted with detailed error reporting 

812 is_valid, error_message = self._check_params_section_detailed(docstring, item.node) 

813 if not is_valid: 

814 return error_message 

815 

816 # If validate_param_types is enabled, validate type annotations match 

817 if self.config.global_config.validate_param_types: 

818 type_error: Optional[str] = self._validate_param_types(docstring, item.node) 

819 if type_error: 

820 return type_error 

821 

822 # For returns/return sections, no additional validation beyond existence 

823 # The _section_exists check already verified the section is present 

824 

825 return None 

826 

827 def _validate_list_name_section(self, docstring: str, section: SectionConfig) -> Optional[str]: 

828 """ 

829 !!! note "Summary" 

830 Validate list_name sections. 

831 

832 Params: 

833 docstring (str): 

834 The docstring to validate. 

835 section (SectionConfig): 

836 The section configuration. 

837 

838 Returns: 

839 (Optional[str]): 

840 Error message if section is missing, None otherwise. 

841 """ 

842 # No additional validation beyond existence 

843 # The _section_exists check already verified the section is present 

844 return None 

845 

846 def _perform_comprehensive_validation(self, docstring: str) -> list[str]: 

847 """ 

848 !!! note "Summary" 

849 Perform comprehensive validation checks on docstring. 

850 

851 Params: 

852 docstring (str): 

853 The docstring to validate. 

854 

855 Returns: 

856 (list[str]): 

857 List of validation error messages. 

858 """ 

859 

860 errors: list[str] = [] 

861 

862 # Check section order 

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

864 errors.extend(order_errors) 

865 

866 # Check for mutual exclusivity (returns vs yields) 

867 if self._has_both_returns_and_yields(docstring): 

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

869 

870 # Check for undefined sections (only if not allowed) 

871 if not self.config.global_config.allow_undefined_sections: 

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

873 errors.extend(undefined_errors) 

874 

875 # Perform formatting validation 

876 formatting_errors: list[str] = self._perform_formatting_validation(docstring) 

877 errors.extend(formatting_errors) 

878 

879 return errors 

880 

881 def _perform_formatting_validation(self, docstring: str) -> list[str]: 

882 """ 

883 !!! note "Summary" 

884 Perform formatting validation checks. 

885 

886 Params: 

887 docstring (str): 

888 The docstring to validate. 

889 

890 Returns: 

891 (list[str]): 

892 List of formatting error messages. 

893 """ 

894 

895 errors: list[str] = [] 

896 

897 # Check admonition values 

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

899 errors.extend(admonition_errors) 

900 

901 # Check colon usage 

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

903 errors.extend(colon_errors) 

904 

905 # Check title case 

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

907 errors.extend(title_case_errors) 

908 

909 # Check parentheses 

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

911 errors.extend(parentheses_errors) 

912 

913 return errors 

914 

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

916 """ 

917 !!! note "Summary" 

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

919 

920 Params: 

921 docstring (str): 

922 The docstring to check. 

923 section (SectionConfig): 

924 The section configuration to validate. 

925 

926 Returns: 

927 (bool): 

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

929 """ 

930 

931 # Make the section name part case-insensitive too 

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

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

934 escaped_name: str = re.escape(section.name) 

935 pattern: str = ( 

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

937 ) 

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

939 

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

941 if section.name.lower() in ["summary"]: 

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

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

944 return True 

945 # Accept any non-empty docstring as summary 

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

947 

948 # Look for examples section 

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

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

951 

952 # Default to true for unknown free text sections 

953 return True 

954 

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

956 """ 

957 !!! note "Summary" 

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

959 

960 Params: 

961 docstring (str): 

962 The docstring to check. 

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

964 The function node to check. 

965 

966 Returns: 

967 (bool): 

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

969 """ 

970 

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

972 params: list[str] = self._extract_all_params(node) 

973 

974 if not params: 

975 return True # No parameters to document 

976 

977 # Check if Params section exists 

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

979 return False 

980 

981 # Check each parameter is documented 

982 for param in params: 

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

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

985 return False 

986 

987 return True 

988 

989 def _extract_documented_params(self, docstring: str) -> list[str]: 

990 """ 

991 !!! note "Summary" 

992 Extract parameter names from the Params section of a docstring. 

993 

994 Params: 

995 docstring (str): 

996 The docstring to parse. 

997 

998 Returns: 

999 (list[str]): 

1000 List of parameter names found in the Params section. 

1001 """ 

1002 documented_params: list[str] = [] 

1003 param_pattern: str = r"^\s*(\*{0,2}\w+)\s*\([^)]+\):" 

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

1005 in_params_section: bool = False 

1006 

1007 for line in lines: 

1008 # Check if we've entered the Params section 

1009 if "Params:" in line: 

1010 in_params_section = True 

1011 continue 

1012 

1013 # Check if we've left the Params section (next section starts) 

1014 # Match section names (can include spaces) followed by a colon 

1015 if in_params_section and re.match(r"^[ ]{0,4}[A-Z][\w\s]+:", line): 

1016 break 

1017 

1018 # Extract parameter name 

1019 if in_params_section: 

1020 match = re.match(param_pattern, line) 

1021 if match: 

1022 documented_params.append(match.group(1)) 

1023 

1024 return documented_params 

1025 

1026 def _build_param_mismatch_error(self, missing_in_docstring: list[str], extra_in_docstring: list[str]) -> str: 

1027 """ 

1028 !!! note "Summary" 

1029 Build detailed error message for parameter mismatches. 

1030 

1031 Params: 

1032 missing_in_docstring (list[str]): 

1033 Parameters in signature but not in docstring. 

1034 extra_in_docstring (list[str]): 

1035 Parameters in docstring but not in signature. 

1036 

1037 Returns: 

1038 (str): 

1039 Formatted error message. 

1040 """ 

1041 error_parts: list[str] = [] 

1042 

1043 # Create copies to avoid mutating inputs 

1044 missing_copy: list[str] = list(missing_in_docstring) 

1045 extra_copy: list[str] = list(extra_in_docstring) 

1046 

1047 # Check for asterisk mismatch 

1048 # We iterate over a copy of missing_copy to allow modification 

1049 for missing in list(missing_copy): 

1050 for extra in list(extra_copy): 

1051 if extra.lstrip("*") == missing: 

1052 asterisk_count: int = len(extra) - len(extra.lstrip("*")) 

1053 asterisk_word: str = "asterisk" if asterisk_count == 1 else "asterisks" 

1054 error_parts.append( 

1055 f" - Parameter '{missing}' found in docstring as '{extra}'. Please remove the {asterisk_word}." 

1056 ) 

1057 missing_copy.remove(missing) 

1058 extra_copy.remove(extra) 

1059 break 

1060 

1061 if missing_copy: 

1062 missing_str: str = "', '".join(missing_copy) 

1063 error_parts.append(f" - In signature but not in docstring: '{missing_str}'") 

1064 

1065 if extra_copy: 

1066 extra_str: str = "', '".join(extra_copy) 

1067 error_parts.append(f" - In docstring but not in signature: '{extra_str}'") 

1068 

1069 return "Parameter mismatch:\n" + "\n".join(error_parts) 

1070 

1071 def _check_params_section_detailed( 

1072 self, docstring: str, node: Union[ast.FunctionDef, ast.AsyncFunctionDef] 

1073 ) -> tuple[bool, Optional[str]]: 

1074 """ 

1075 !!! note "Summary" 

1076 Check if the Params section exists and documents all parameters, with detailed error reporting. 

1077 

1078 Params: 

1079 docstring (str): 

1080 The docstring to check. 

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

1082 The function node to check. 

1083 

1084 Returns: 

1085 (tuple[bool, Optional[str]]): 

1086 Tuple of (is_valid, error_message). If valid, error_message is None. 

1087 """ 

1088 

1089 # Get function parameters (excluding 'self' and 'cls' for methods) 

1090 signature_params: list[str] = self._extract_all_params(node) 

1091 

1092 if not signature_params: 

1093 return (True, None) # No parameters to document 

1094 

1095 # Check if Params section exists 

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

1097 return (False, "Params section not found in docstring") 

1098 

1099 # Extract documented parameters from docstring 

1100 documented_params: list[str] = self._extract_documented_params(docstring) 

1101 

1102 # Find parameters in signature but not in docstring 

1103 missing_in_docstring: list[str] = [p for p in signature_params if p not in documented_params] 

1104 

1105 # Find parameters in docstring but not in signature 

1106 extra_in_docstring: list[str] = [p for p in documented_params if p not in signature_params] 

1107 

1108 # Build detailed error message if there are mismatches 

1109 if missing_in_docstring or extra_in_docstring: 

1110 error_message: str = self._build_param_mismatch_error(missing_in_docstring, extra_in_docstring) 

1111 return (False, error_message) 

1112 

1113 return (True, None) 

1114 

1115 def _add_arg_types_to_dict(self, args: list[ast.arg], param_types: dict[str, str]) -> None: 

1116 """ 

1117 !!! note "Summary" 

1118 Add type annotations from a list of arguments to the parameter types dictionary. 

1119 

1120 Params: 

1121 args (list[ast.arg]): 

1122 List of AST argument nodes. 

1123 param_types (dict[str, str]): 

1124 Dictionary to add parameter types to. 

1125 """ 

1126 for arg in args: 

1127 if arg.arg not in ("self", "cls") and arg.annotation: 

1128 param_types[arg.arg] = ast.unparse(arg.annotation) 

1129 

1130 def _extract_param_types(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> dict[str, str]: 

1131 """ 

1132 !!! note "Summary" 

1133 Extract parameter names and their type annotations from function signature. 

1134 

1135 Params: 

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

1137 The function AST node. 

1138 

1139 Returns: 

1140 (dict[str, str]): 

1141 Dictionary mapping parameter names to their type annotation strings. 

1142 """ 

1143 param_types: dict[str, str] = {} 

1144 

1145 # Positional-only parameters (before /) 

1146 self._add_arg_types_to_dict(node.args.posonlyargs, param_types) 

1147 

1148 # Regular positional parameters 

1149 self._add_arg_types_to_dict(node.args.args, param_types) 

1150 

1151 # Variable positional arguments (*args) 

1152 if node.args.vararg and node.args.vararg.annotation: 

1153 param_types[node.args.vararg.arg] = ast.unparse(node.args.vararg.annotation) 

1154 

1155 # Keyword-only parameters (after *) 

1156 self._add_arg_types_to_dict(node.args.kwonlyargs, param_types) 

1157 

1158 # Variable keyword arguments (**kwargs) 

1159 if node.args.kwarg and node.args.kwarg.annotation: 

1160 param_types[node.args.kwarg.arg] = ast.unparse(node.args.kwarg.annotation) 

1161 

1162 return param_types 

1163 

1164 def _extract_param_types_from_docstring(self, docstring: str) -> dict[str, str]: 

1165 """ 

1166 !!! note "Summary" 

1167 Extract parameter types from the Params section of docstring. 

1168 

1169 Params: 

1170 docstring (str): 

1171 The docstring to parse. 

1172 

1173 Returns: 

1174 (dict[str, str]): 

1175 Dictionary mapping parameter names to their documented types. 

1176 """ 

1177 param_types: dict[str, str] = {} 

1178 

1179 # Find the Params section 

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

1181 return param_types 

1182 

1183 # Pattern to match parameter documentation: name (type): 

1184 # Handles variations like: 

1185 # - name (str): 

1186 # - name (Optional[str]): 

1187 # - name (Union[str, int]): 

1188 # - name (list[str]): 

1189 pattern: str = r"^\s*(\w+)\s*\(([^)]+)\)\s*:" 

1190 

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

1192 in_params_section: bool = False 

1193 

1194 for line in lines: 

1195 # Check if we've entered the Params section 

1196 if "Params:" in line: 

1197 in_params_section = True 

1198 continue 

1199 

1200 # Check if we've left the Params section (next section starts) 

1201 # Section headers have minimal indentation (0-4 spaces), not deep indentation like param descriptions 

1202 if in_params_section and re.match(r"^[ ]{0,4}[A-Z]\w+:", line): 

1203 break 

1204 

1205 # Extract parameter name and type 

1206 if in_params_section: 

1207 match = re.match(pattern, line) 

1208 if match: 

1209 param_name: str = match.group(1) 

1210 param_type: str = match.group(2) 

1211 param_types[param_name] = param_type 

1212 

1213 return param_types 

1214 

1215 def _normalize_type_string(self, type_str: str) -> str: 

1216 """ 

1217 !!! note "Summary" 

1218 Normalize a type string for comparison. 

1219 

1220 Params: 

1221 type_str (str): 

1222 The type string to normalize. 

1223 

1224 Returns: 

1225 (str): 

1226 Normalized type string. 

1227 """ 

1228 

1229 # Remove whitespace 

1230 normalized: str = re.sub(r"\s+", "", type_str) 

1231 

1232 # Normalize quotes: ast.unparse() uses single quotes but docstrings typically use double quotes 

1233 # Convert all quotes to single quotes for consistent comparison 

1234 normalized = normalized.replace('"', "'") 

1235 

1236 # Make case-insensitive for basic types 

1237 # But preserve case for complex types to avoid breaking things like Optional 

1238 return normalized 

1239 

1240 def _compare_param_types( 

1241 self, signature_types: dict[str, str], docstring_types: dict[str, str] 

1242 ) -> list[tuple[str, str, str]]: 

1243 """ 

1244 !!! note "Summary" 

1245 Compare parameter types from signature and docstring. 

1246 

1247 Params: 

1248 signature_types (dict[str, str]): 

1249 Parameter types from function signature. 

1250 docstring_types (dict[str, str]): 

1251 Parameter types from docstring. 

1252 

1253 Returns: 

1254 (list[tuple[str, str, str]]): 

1255 List of mismatches as (param_name, signature_type, docstring_type). 

1256 """ 

1257 mismatches: list[tuple[str, str, str]] = [] 

1258 

1259 for param_name, sig_type in signature_types.items(): 

1260 # Check if parameter is documented in docstring 

1261 if param_name not in docstring_types: 

1262 # Parameter not documented - this is handled by other validation 

1263 continue 

1264 

1265 doc_type: str = docstring_types[param_name] 

1266 

1267 # Normalize both types for comparison 

1268 normalized_sig: str = self._normalize_type_string(sig_type) 

1269 normalized_doc: str = self._normalize_type_string(doc_type) 

1270 

1271 # Case-insensitive comparison 

1272 if normalized_sig.lower() != normalized_doc.lower(): 

1273 mismatches.append((param_name, sig_type, doc_type)) 

1274 

1275 return mismatches 

1276 

1277 def _get_params_with_defaults(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> set[str]: 

1278 """ 

1279 !!! note "Summary" 

1280 Get set of parameter names that have default values. 

1281 

1282 Params: 

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

1284 The function node to analyse. 

1285 

1286 Returns: 

1287 (set[str]): 

1288 Set of parameter names that have default values. 

1289 """ 

1290 params_with_defaults: set[str] = set() 

1291 args = node.args 

1292 

1293 # Combine positional-only and regular arguments 

1294 all_positional_args = args.posonlyargs + args.args 

1295 

1296 # Check defaults for positional arguments 

1297 num_defaults = len(args.defaults) 

1298 if num_defaults > 0: 

1299 # Defaults apply to the last n arguments of the combined list 

1300 num_args = len(all_positional_args) 

1301 for i in range(num_args - num_defaults, num_args): 

1302 arg = all_positional_args[i] 

1303 if arg.arg not in ("self", "cls"): 

1304 params_with_defaults.add(arg.arg) 

1305 

1306 # Keyword-only args with defaults 

1307 for i, arg in enumerate(args.kwonlyargs): 

1308 if args.kw_defaults[i] is not None: 

1309 params_with_defaults.add(arg.arg) 

1310 

1311 return params_with_defaults 

1312 

1313 def _process_optional_suffix( 

1314 self, 

1315 param_name: str, 

1316 doc_type: str, 

1317 params_with_defaults: set[str], 

1318 optional_style: str, 

1319 ) -> tuple[str, Optional[str]]: 

1320 """ 

1321 !!! note "Summary" 

1322 Process the ', optional' suffix based on the optional_style mode. 

1323 

1324 Params: 

1325 param_name (str): 

1326 Name of the parameter. 

1327 doc_type (str): 

1328 Docstring type including potential ', optional' suffix. 

1329 params_with_defaults (set[str]): 

1330 Set of parameters that have default values. 

1331 optional_style (str): 

1332 The validation mode: 'silent', 'validate', or 'strict'. 

1333 

1334 Returns: 

1335 (tuple[str, Optional[str]]): 

1336 Tuple of (cleaned_type, error_message). 

1337 """ 

1338 has_optional_suffix: bool = bool(re.search(r",\s*optional$", doc_type, flags=re.IGNORECASE)) 

1339 clean_type: str = re.sub(r",\s*optional$", "", doc_type, flags=re.IGNORECASE).strip() 

1340 error_message: Optional[str] = None 

1341 

1342 if optional_style == "validate": 

1343 if has_optional_suffix and param_name not in params_with_defaults: 

1344 error_message = f"Parameter '{param_name}' has ', optional' suffix but no default value in signature" 

1345 elif optional_style == "strict": 

1346 if param_name in params_with_defaults and not has_optional_suffix: 

1347 error_message = ( 

1348 f"Parameter '{param_name}' has default value but missing ', optional' suffix in docstring" 

1349 ) 

1350 elif has_optional_suffix and param_name not in params_with_defaults: 

1351 error_message = f"Parameter '{param_name}' has ', optional' suffix but no default value in signature" 

1352 

1353 return clean_type, error_message 

1354 

1355 def _format_optional_errors(self, errors: list[str]) -> str: 

1356 """ 

1357 !!! note "Summary" 

1358 Format multiple optional suffix validation errors. 

1359 

1360 Params: 

1361 errors (list[str]): 

1362 List of error messages. 

1363 

1364 Returns: 

1365 (str): 

1366 Formatted error message. 

1367 """ 

1368 if len(errors) == 1: 

1369 return errors[0] 

1370 formatted_errors: str = "\n - ".join([""] + errors) 

1371 return f"Optional suffix validation errors:{formatted_errors}" 

1372 

1373 def _format_type_mismatches(self, mismatches: list[tuple[str, str, str]]) -> str: 

1374 """ 

1375 !!! note "Summary" 

1376 Format parameter type mismatches for error output. 

1377 

1378 Params: 

1379 mismatches (list[tuple[str, str, str]]): 

1380 List of (param_name, sig_type, doc_type) tuples. 

1381 

1382 Returns: 

1383 (str): 

1384 Formatted error message. 

1385 """ 

1386 mismatch_blocks: list[str] = [] 

1387 for name, sig_type, doc_type in mismatches: 

1388 sig_type_clean: str = sig_type.replace("'", '"') 

1389 doc_type_clean: str = doc_type.replace("'", '"') 

1390 param_block: str = ( 

1391 f"""'{name}':\n - signature: '{sig_type_clean}'\n - docstring: '{doc_type_clean}'""" 

1392 ) 

1393 mismatch_blocks.append(param_block) 

1394 

1395 formatted_details: str = "\n - ".join([""] + mismatch_blocks) 

1396 return f"Parameter type mismatch:{formatted_details}" 

1397 

1398 def _validate_param_types( 

1399 self, docstring: str, node: Union[ast.FunctionDef, ast.AsyncFunctionDef] 

1400 ) -> Optional[str]: 

1401 """ 

1402 !!! note "Summary" 

1403 Validate that parameter types in docstring match the signature. 

1404 

1405 ???+ abstract "Details" 

1406 Implements three validation modes based on `optional_style` configuration: 

1407 

1408 - **`"silent"`**: Strip `, optional` from docstring types before comparison. 

1409 - **`"validate"`**: Error if `, optional` appears on required parameters. 

1410 - **`"strict"`**: Require `, optional` for parameters with defaults, error if on required parameters. 

1411 

1412 Params: 

1413 docstring (str): 

1414 The docstring to validate. 

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

1416 The function node with type annotations. 

1417 

1418 Returns: 

1419 (Optional[str]): 

1420 Error message if validation fails, None otherwise. 

1421 """ 

1422 # Extract types from both sources 

1423 signature_types: dict[str, str] = self._extract_param_types(node) 

1424 docstring_types_raw: dict[str, str] = self._extract_param_types_from_docstring(docstring) 

1425 

1426 # Get parameters with default values 

1427 params_with_defaults: set[str] = self._get_params_with_defaults(node) 

1428 

1429 # Get all parameter names (excluding self/cls) 

1430 all_params: list[str] = self._extract_all_params(node) 

1431 

1432 # Get the optional_style mode 

1433 optional_style: str = self.config.global_config.optional_style 

1434 

1435 # Process docstring types based on optional_style mode 

1436 docstring_types: dict[str, str] = {} 

1437 optional_errors: list[str] = [] 

1438 

1439 for param_name, doc_type in docstring_types_raw.items(): 

1440 clean_type, error_message = self._process_optional_suffix( 

1441 param_name, doc_type, params_with_defaults, optional_style 

1442 ) 

1443 docstring_types[param_name] = clean_type 

1444 if error_message: 

1445 optional_errors.append(error_message) 

1446 

1447 # Return optional_style errors first if any 

1448 if optional_errors: 

1449 return self._format_optional_errors(optional_errors) 

1450 

1451 # Check for parameters documented with type in docstring but missing annotation in signature 

1452 for param_name in all_params: 

1453 if param_name in docstring_types and param_name not in signature_types: 

1454 return f"Parameter '{param_name}' has type in docstring but no type annotation in signature" 

1455 

1456 # Check for parameters with annotations but no type in docstring 

1457 for param_name, sig_type in signature_types.items(): 

1458 if param_name not in docstring_types: 

1459 return ( 

1460 f"Parameter '{param_name}' has type annotation '{sig_type}' in signature but no type in docstring" 

1461 ) 

1462 

1463 # Compare types 

1464 mismatches: list[tuple[str, str, str]] = self._compare_param_types(signature_types, docstring_types) 

1465 

1466 if mismatches: 

1467 return self._format_type_mismatches(mismatches) 

1468 

1469 return None 

1470 

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

1472 """ 

1473 !!! note "Summary" 

1474 Check if docstring has both Returns and Yields sections. 

1475 

1476 Params: 

1477 docstring (str): 

1478 The docstring to check. 

1479 

1480 Returns: 

1481 (bool): 

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

1483 """ 

1484 

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

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

1487 return has_returns and has_yields 

1488 

1489 def _build_section_patterns(self) -> list[tuple[str, str]]: 

1490 """ 

1491 !!! note "Summary" 

1492 Build regex patterns for detecting sections from configuration. 

1493 

1494 Returns: 

1495 (list[tuple[str, str]]): 

1496 List of tuples containing (pattern, section_name). 

1497 """ 

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

1499 # Sort sections that have an order, then append those that don't 

1500 ordered_sections = sorted( 

1501 [s for s in self.sections_config if s.order is not None], 

1502 key=lambda x: x.order if x.order is not None else 0, 

1503 ) 

1504 unordered_sections: list[SectionConfig] = [s for s in self.sections_config if s.order is None] 

1505 

1506 for section in ordered_sections + unordered_sections: 

1507 if ( 

1508 section.type == "free_text" 

1509 and isinstance(section.admonition, str) 

1510 and section.admonition 

1511 and section.prefix 

1512 ): 

1513 pattern: str = ( 

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

1515 ) 

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

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

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

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

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

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

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

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

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

1525 

1526 # Add default patterns for common sections 

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

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

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

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

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

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

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

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

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

1536 ] 

1537 

1538 return section_patterns + default_patterns 

1539 

1540 def _find_sections_with_positions(self, docstring: str, patterns: list[tuple[str, str]]) -> list[tuple[int, str]]: 

1541 """ 

1542 !!! note "Summary" 

1543 Find all sections in docstring and their positions. 

1544 

1545 Params: 

1546 docstring (str): 

1547 The docstring to search. 

1548 patterns (list[tuple[str, str]]): 

1549 List of (pattern, section_name) tuples to search for. 

1550 

1551 Returns: 

1552 (list[tuple[int, str]]): 

1553 List of (position, section_name) tuples sorted by position. 

1554 """ 

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

1556 for pattern, section_name in patterns: 

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

1558 if match: 

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

1560 

1561 # Sort by position in docstring 

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

1563 return found_sections 

1564 

1565 def _build_expected_section_order(self) -> list[str]: 

1566 """ 

1567 !!! note "Summary" 

1568 Build the expected order of sections from configuration. 

1569 

1570 Returns: 

1571 (list[str]): 

1572 List of section names in expected order. 

1573 """ 

1574 expected_order: list[str] = [ 

1575 s.name.title() 

1576 for s in sorted( 

1577 [s for s in self.sections_config if s.order is not None], 

1578 key=lambda x: x.order if x.order is not None else 0, 

1579 ) 

1580 ] 

1581 expected_order.extend( 

1582 [ 

1583 "Summary", 

1584 "Details", 

1585 "Examples", 

1586 "Credit", 

1587 "Equation", 

1588 "Notes", 

1589 "References", 

1590 "See Also", 

1591 ] 

1592 ) 

1593 return expected_order 

1594 

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

1596 """ 

1597 !!! note "Summary" 

1598 Check that sections appear in the correct order. 

1599 

1600 Params: 

1601 docstring (str): 

1602 The docstring to check. 

1603 

1604 Returns: 

1605 (list[str]): 

1606 A list of error messages, if any. 

1607 """ 

1608 # Build patterns and find sections 

1609 patterns = self._build_section_patterns() 

1610 found_sections = self._find_sections_with_positions(docstring, patterns) 

1611 expected_order = self._build_expected_section_order() 

1612 

1613 # Check order matches expected order 

1614 errors: list[str] = [] 

1615 last_expected_index = -1 

1616 for _, section_name in found_sections: 

1617 try: 

1618 current_index: int = expected_order.index(section_name) 

1619 if current_index < last_expected_index: 

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

1621 last_expected_index: int = current_index 

1622 except ValueError: 

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

1624 pass 

1625 

1626 return errors 

1627 

1628 def _normalize_section_name(self, section_name: str) -> str: 

1629 """ 

1630 !!! note "Summary" 

1631 Normalize section name by removing colons and whitespace. 

1632 

1633 Params: 

1634 section_name (str): 

1635 The raw section name to normalize. 

1636 

1637 Returns: 

1638 (str): 

1639 The normalized section name. 

1640 """ 

1641 return section_name.lower().strip().rstrip(":") 

1642 

1643 def _is_valid_section_name(self, section_name: str) -> bool: 

1644 """ 

1645 !!! note "Summary" 

1646 Check if section name is valid. 

1647 

1648 !!! abstract "Details" 

1649 Filters out empty names, code block markers, and special characters. 

1650 

1651 Params: 

1652 section_name (str): 

1653 The section name to validate. 

1654 

1655 Returns: 

1656 (bool): 

1657 True if the section name is valid, False otherwise. 

1658 """ 

1659 # Skip empty matches or common docstring content 

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

1661 return False 

1662 

1663 # Skip code blocks and inline code 

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

1665 return False 

1666 

1667 return True 

1668 

1669 def _extract_section_names_from_docstring(self, docstring: str) -> set[str]: 

1670 """ 

1671 !!! note "Summary" 

1672 Extract all section names found in docstring. 

1673 

1674 Params: 

1675 docstring (str): 

1676 The docstring to extract section names from. 

1677 

1678 Returns: 

1679 (set[str]): 

1680 A set of normalized section names found in the docstring. 

1681 """ 

1682 # Common patterns for different section types 

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

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

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

1686 # Admonition sections with various prefixes 

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

1688 ] 

1689 

1690 found_sections: set[str] = set() 

1691 

1692 for pattern, pattern_type in section_patterns: 

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

1694 for match in matches: 

1695 section_name: str = self._normalize_section_name(match.group(1)) 

1696 

1697 if self._is_valid_section_name(section_name): 

1698 found_sections.add(section_name) 

1699 

1700 return found_sections 

1701 

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

1703 """ 

1704 !!! note "Summary" 

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

1706 

1707 Params: 

1708 docstring (str): 

1709 The docstring to check. 

1710 

1711 Returns: 

1712 (list[str]): 

1713 A list of error messages for undefined sections. 

1714 """ 

1715 errors: list[str] = [] 

1716 

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

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

1719 

1720 # Extract all section names from docstring 

1721 found_sections: set[str] = self._extract_section_names_from_docstring(docstring) 

1722 

1723 # Check which found sections are not configured 

1724 for section_name in found_sections: 

1725 if section_name not in configured_sections: 

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

1727 

1728 return errors 

1729 

1730 def _build_admonition_mapping(self) -> dict[str, str]: 

1731 """ 

1732 !!! note "Summary" 

1733 Build mapping of section names to expected admonitions. 

1734 

1735 Returns: 

1736 (dict[str, str]): 

1737 Dictionary mapping section name to admonition type. 

1738 """ 

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

1740 for section in self.sections_config: 

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

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

1743 return section_admonitions 

1744 

1745 def _validate_single_admonition(self, match: re.Match[str], section_admonitions: dict[str, str]) -> Optional[str]: 

1746 """ 

1747 !!! note "Summary" 

1748 Validate a single admonition match against configuration. 

1749 

1750 Params: 

1751 match (re.Match[str]): 

1752 The regex match for an admonition section. 

1753 section_admonitions (dict[str, str]): 

1754 Mapping of section names to expected admonitions. 

1755 

1756 Returns: 

1757 (Optional[str]): 

1758 Error message if validation fails, None otherwise. 

1759 """ 

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

1761 section_title: str = match.group(2) 

1762 section_title_lower: str = section_title.lower() 

1763 

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

1765 if section_title_lower in section_admonitions: 

1766 expected_admonition: str = section_admonitions[section_title_lower] 

1767 if actual_admonition != expected_admonition: 

1768 return ( 

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

1770 f"expected '{expected_admonition}'" 

1771 ) 

1772 

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

1774 section_config: Optional[SectionConfig] = next( 

1775 (s for s in self.sections_config if s.name.lower() == section_title_lower), None 

1776 ) 

1777 if section_config and section_config.admonition is False: 

1778 return f"Section '{section_title}' is configured as non-admonition but found as admonition" 

1779 

1780 return None 

1781 

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

1783 """ 

1784 !!! note "Summary" 

1785 Check that admonition values in docstring match configuration. 

1786 

1787 Params: 

1788 docstring (str): 

1789 The docstring to check. 

1790 

1791 Returns: 

1792 (list[str]): 

1793 A list of error messages for mismatched admonitions. 

1794 """ 

1795 errors: list[str] = [] 

1796 

1797 # Build admonition mapping 

1798 section_admonitions = self._build_admonition_mapping() 

1799 

1800 # Pattern to find all admonition sections 

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

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

1803 

1804 # Validate each admonition 

1805 for match in matches: 

1806 error = self._validate_single_admonition(match, section_admonitions) 

1807 if error: 

1808 errors.append(error) 

1809 

1810 return errors 

1811 

1812 def _validate_admonition_has_no_colon(self, match: re.Match[str]) -> Optional[str]: 

1813 """ 

1814 !!! note "Summary" 

1815 Validate that a single admonition section does not have a colon. 

1816 

1817 Params: 

1818 match (re.Match[str]): 

1819 The regex match for an admonition section. 

1820 

1821 Returns: 

1822 (Optional[str]): 

1823 An error message if colon found, None otherwise. 

1824 """ 

1825 

1826 section_title: str = match.group(1) 

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

1828 section_title_clean: str = section_title.rstrip(":") 

1829 section_title_lower: str = section_title_clean.lower() 

1830 

1831 # Find config for this section 

1832 section_config: Optional[SectionConfig] = next( 

1833 (s for s in self.sections_config if s.name.lower() == section_title_lower), None 

1834 ) 

1835 

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

1837 if has_colon: 

1838 return ( 

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

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

1841 ) 

1842 

1843 return None 

1844 

1845 def _check_admonition_colon_usage(self, docstring: str) -> list[str]: 

1846 """ 

1847 !!! note "Summary" 

1848 Check that admonition sections don't end with colon. 

1849 

1850 Params: 

1851 docstring (str): 

1852 The docstring to check. 

1853 

1854 Returns: 

1855 (list[str]): 

1856 A list of error messages. 

1857 """ 

1858 

1859 errors: list[str] = [] 

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

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

1862 

1863 for match in matches: 

1864 error: Optional[str] = self._validate_admonition_has_no_colon(match) 

1865 if error: 

1866 errors.append(error) 

1867 

1868 return errors 

1869 

1870 def _validate_non_admonition_has_colon(self, line: str, pattern: str) -> Optional[str]: 

1871 """ 

1872 !!! note "Summary" 

1873 Validate that a single line has colon if it's a non-admonition section. 

1874 

1875 Params: 

1876 line (str): 

1877 The line to check. 

1878 pattern (str): 

1879 The regex pattern to match. 

1880 

1881 Returns: 

1882 (Optional[str]): 

1883 An error message if colon missing, None otherwise. 

1884 """ 

1885 

1886 match: Optional[re.Match[str]] = re.match(pattern, line) 

1887 if not match: 

1888 return None 

1889 

1890 section_name: str = match.group(1) 

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

1892 

1893 # Find config for this section 

1894 section_config: Optional[SectionConfig] = next( 

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

1896 ) 

1897 

1898 if section_config and section_config.admonition is False: 

1899 if not has_colon: 

1900 return f"Section '{section_name}' is non-admonition, therefore it must end with ':', " f"see: '{line}'" 

1901 

1902 return None 

1903 

1904 def _check_non_admonition_colon_usage(self, docstring: str) -> list[str]: 

1905 """ 

1906 !!! note "Summary" 

1907 Check that non-admonition sections end with colon. 

1908 

1909 Params: 

1910 docstring (str): 

1911 The docstring to check. 

1912 

1913 Returns: 

1914 (list[str]): 

1915 A list of error messages. 

1916 """ 

1917 

1918 errors: list[str] = [] 

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

1920 

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

1922 line: str = line.strip() 

1923 error: Optional[str] = self._validate_non_admonition_has_colon(line, non_admonition_pattern) 

1924 if error: 

1925 errors.append(error) 

1926 

1927 return errors 

1928 

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

1930 """ 

1931 !!! note "Summary" 

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

1933 

1934 Params: 

1935 docstring (str): 

1936 The docstring to check. 

1937 

1938 Returns: 

1939 (list[str]): 

1940 A list of error messages. 

1941 """ 

1942 

1943 errors: list[str] = [] 

1944 

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

1946 errors.extend(self._check_admonition_colon_usage(docstring)) 

1947 

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

1949 errors.extend(self._check_non_admonition_colon_usage(docstring)) 

1950 

1951 return errors 

1952 

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

1954 """ 

1955 !!! note "Summary" 

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

1957 """ 

1958 

1959 errors: list[str] = [] 

1960 

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

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

1963 

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

1965 line: str = line.strip() 

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

1967 if match: 

1968 section_word: str = match.group(1) 

1969 section_name_lower: str = section_word.lower() 

1970 

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

1972 section_config: Optional[SectionConfig] = next( 

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

1974 ) 

1975 if section_config and section_config.admonition is False: 

1976 # Check if it's title case 

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

1978 if section_word != expected_title_case: 

1979 errors.append( 

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

1981 f"found: '{section_word}'" 

1982 ) 

1983 

1984 return errors 

1985 

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

1987 """ 

1988 !!! note "Summary" 

1989 Check that list_type and list_name_and_type sections have proper parentheses. 

1990 """ 

1991 

1992 errors: list[str] = [] 

1993 

1994 # Get sections that require parentheses 

1995 parentheses_sections: list[SectionConfig] = [ 

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

1997 ] 

1998 

1999 if not parentheses_sections: 

2000 return errors 

2001 

2002 # Process each line in the docstring 

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

2004 current_section: Optional[SectionConfig] = None 

2005 type_line_indent: Optional[int] = None 

2006 

2007 for line in lines: 

2008 stripped_line: str = line.strip() 

2009 

2010 # Check for any section header (to properly transition out of current section) 

2011 section_detected: bool = self._detect_any_section_header(stripped_line, line) 

2012 if section_detected: 

2013 # Check if it's a parentheses-required section 

2014 new_section: Optional[SectionConfig] = self._detect_section_header( 

2015 stripped_line, line, parentheses_sections 

2016 ) 

2017 current_section = new_section # None if not parentheses-required 

2018 type_line_indent = None 

2019 continue 

2020 

2021 # Process content lines within parentheses-required sections 

2022 if current_section and self._is_content_line(stripped_line): 

2023 line_errors: list[str] 

2024 new_indent: Optional[int] 

2025 line_errors, new_indent = self._validate_parentheses_line( 

2026 line, stripped_line, current_section, type_line_indent 

2027 ) 

2028 errors.extend(line_errors) 

2029 if new_indent is not None: 

2030 type_line_indent = new_indent 

2031 

2032 return errors 

2033 

2034 def _detect_any_section_header(self, stripped_line: str, full_line: str) -> bool: 

2035 """ 

2036 !!! note "Summary" 

2037 Detect any section header (for section transitions). 

2038 

2039 Params: 

2040 stripped_line (str): 

2041 The stripped line content. 

2042 full_line (str): 

2043 The full line with indentation. 

2044 

2045 Returns: 

2046 (bool): 

2047 True if line is a section header, False otherwise. 

2048 """ 

2049 # Admonition sections 

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

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

2052 ) 

2053 if admonition_match: 

2054 section_name: str = admonition_match.group(1) 

2055 # Check if it's a known section 

2056 return any(s.name.lower() == section_name.lower() for s in self.sections_config) 

2057 

2058 # Non-admonition sections (must not be indented) 

2059 if not full_line.startswith((" ", "\t")): 

2060 # Match section names (can include spaces) followed by an optional colon 

2061 simple_section_match: Optional[re.Match[str]] = re.match(r"^([A-Z][\w\s]+):?$", stripped_line) 

2062 if simple_section_match: 

2063 section_name: str = simple_section_match.group(1).strip() 

2064 # Check if it's a known section 

2065 return any(s.name.lower() == section_name.lower() for s in self.sections_config) 

2066 

2067 return False 

2068 

2069 def _detect_section_header( 

2070 self, stripped_line: str, full_line: str, parentheses_sections: list[SectionConfig] 

2071 ) -> Optional[SectionConfig]: 

2072 """ 

2073 !!! note "Summary" 

2074 Detect section headers and return matching section config. 

2075 

2076 Params: 

2077 stripped_line (str): 

2078 The stripped line content. 

2079 full_line (str): 

2080 The full line with indentation. 

2081 parentheses_sections (list[SectionConfig]): 

2082 List of sections requiring parentheses validation. 

2083 

2084 Returns: 

2085 (Optional[SectionConfig]): 

2086 Matching section config or None if not found. 

2087 """ 

2088 # Admonition sections 

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

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

2091 ) 

2092 if admonition_match: 

2093 section_name: str = admonition_match.group(1) 

2094 return next((s for s in parentheses_sections if s.name.lower() == section_name.lower()), None) 

2095 

2096 # Non-admonition sections (must not be indented) 

2097 if not full_line.startswith((" ", "\t")): 

2098 # Match section names (can include spaces) followed by an optional colon 

2099 simple_section_match: Optional[re.Match[str]] = re.match(r"^([A-Z][\w\s]+):?$", stripped_line) 

2100 if simple_section_match: 

2101 section_name: str = simple_section_match.group(1).strip() 

2102 # Check if it's a known section 

2103 potential_section: Optional[SectionConfig] = next( 

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

2105 ) 

2106 if potential_section: 

2107 return next((s for s in parentheses_sections if s.name.lower() == section_name.lower()), None) 

2108 

2109 return None 

2110 

2111 def _is_content_line(self, stripped_line: str) -> bool: 

2112 """ 

2113 !!! note "Summary" 

2114 Check if line is content that needs validation. 

2115 

2116 Params: 

2117 stripped_line (str): 

2118 The stripped line content. 

2119 

2120 Returns: 

2121 (bool): 

2122 True if line is content requiring validation, False otherwise. 

2123 """ 

2124 return bool(stripped_line) and not stripped_line.startswith(("!", "?", "#")) and ":" in stripped_line 

2125 

2126 def _is_description_line(self, stripped_line: str) -> bool: 

2127 """ 

2128 !!! note "Summary" 

2129 Check if line is a description rather than a type definition. 

2130 

2131 Params: 

2132 stripped_line (str): 

2133 The stripped line content. 

2134 

2135 Returns: 

2136 (bool): 

2137 True if line is a description, False otherwise. 

2138 """ 

2139 description_prefixes: list[str] = [ 

2140 "default:", 

2141 "note:", 

2142 "example:", 

2143 "see:", 

2144 "warning:", 

2145 "info:", 

2146 "tip:", 

2147 "returns:", 

2148 ] 

2149 

2150 return ( 

2151 any(stripped_line.lower().startswith(prefix) for prefix in description_prefixes) 

2152 or "Default:" in stripped_line 

2153 or "Output format:" in stripped_line 

2154 or "Show examples:" in stripped_line 

2155 or "Example code:" in stripped_line 

2156 or stripped_line.strip().startswith(("-", "*", "•", "+")) 

2157 or stripped_line.startswith(">>>") # Doctest examples 

2158 ) 

2159 

2160 def _validate_parentheses_line( 

2161 self, full_line: str, stripped_line: str, current_section: SectionConfig, type_line_indent: Optional[int] 

2162 ) -> tuple[list[str], Optional[int]]: 

2163 """ 

2164 !!! note "Summary" 

2165 Validate a single line for parentheses requirements. 

2166 

2167 Params: 

2168 full_line (str): 

2169 The full line with indentation. 

2170 stripped_line (str): 

2171 The stripped line content. 

2172 current_section (SectionConfig): 

2173 The current section being validated. 

2174 type_line_indent (Optional[int]): 

2175 The indentation level of type definitions. 

2176 

2177 Returns: 

2178 (tuple[list[str], Optional[int]]): 

2179 Tuple of error messages and updated type line indent. 

2180 """ 

2181 errors: list[str] = [] 

2182 new_indent: Optional[int] = None 

2183 current_indent: int = len(full_line) - len(full_line.lstrip()) 

2184 

2185 # Skip description lines 

2186 if self._is_description_line(stripped_line): 

2187 return errors, type_line_indent 

2188 

2189 if current_section.type == "list_type": 

2190 errors, new_indent = self._validate_list_type_line( 

2191 stripped_line, current_indent, type_line_indent, current_section 

2192 ) 

2193 elif current_section.type == "list_name_and_type": 

2194 errors, new_indent = self._validate_list_name_and_type_line( 

2195 stripped_line, current_indent, type_line_indent, current_section 

2196 ) 

2197 

2198 return errors, new_indent if new_indent is not None else type_line_indent 

2199 

2200 def _validate_list_type_line( 

2201 self, stripped_line: str, current_indent: int, type_line_indent: Optional[int], current_section: SectionConfig 

2202 ) -> tuple[list[str], Optional[int]]: 

2203 """ 

2204 !!! note "Summary" 

2205 Validate list_type section lines. 

2206 

2207 Params: 

2208 stripped_line (str): 

2209 The stripped line content. 

2210 current_indent (int): 

2211 The current line's indentation level. 

2212 type_line_indent (Optional[int]): 

2213 The indentation level of type definitions. 

2214 current_section (SectionConfig): 

2215 The current section being validated. 

2216 

2217 Returns: 

2218 (tuple[list[str], Optional[int]]): 

2219 Tuple of error messages and updated type line indent. 

2220 """ 

2221 errors: list[str] = [] 

2222 

2223 # Check for valid type definition format 

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

2225 return errors, current_indent 

2226 

2227 # Handle lines without proper format 

2228 if type_line_indent is None or current_indent > type_line_indent: 

2229 # Allow as possible description 

2230 return errors, None 

2231 

2232 # This should be a type definition but lacks proper format 

2233 errors.append( 

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

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

2236 ) 

2237 return errors, None 

2238 

2239 def _validate_list_name_and_type_line( 

2240 self, stripped_line: str, current_indent: int, type_line_indent: Optional[int], current_section: SectionConfig 

2241 ) -> tuple[list[str], Optional[int]]: 

2242 """ 

2243 !!! note "Summary" 

2244 Validate list_name_and_type section lines. 

2245 

2246 Params: 

2247 stripped_line (str): 

2248 The stripped line content. 

2249 current_indent (int): 

2250 The current line's indentation level. 

2251 type_line_indent (Optional[int]): 

2252 The indentation level of type definitions. 

2253 current_section (SectionConfig): 

2254 The current section being validated. 

2255 

2256 Returns: 

2257 (tuple[list[str], Optional[int]]): 

2258 Tuple of error messages and updated type line indent. 

2259 """ 

2260 errors: list[str] = [] 

2261 

2262 # Check for valid parameter definition format 

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

2264 return errors, current_indent 

2265 

2266 # Check if this is likely a description line 

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

2268 

2269 # Skip description-like content 

2270 if any(word in colon_part.lower() for word in ["default", "output", "format", "show", "example"]): 

2271 return errors, None 

2272 

2273 # Skip if more indented than parameter definition (description line) 

2274 if type_line_indent is not None and current_indent > type_line_indent: 

2275 return errors, None 

2276 

2277 # Skip if too many words before colon (likely description) 

2278 words_before_colon: list[str] = colon_part.split() 

2279 if len(words_before_colon) > 2: 

2280 return errors, None 

2281 

2282 # Flag potential parameter definitions without proper format 

2283 if not stripped_line.strip().startswith("#"): 

2284 errors.append( 

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

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

2287 ) 

2288 

2289 return errors, None