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
« 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# ============================================================================ #
9# ---------------------------------------------------------------------------- #
10# #
11# Overview ####
12# #
13# ---------------------------------------------------------------------------- #
16# ---------------------------------------------------------------------------- #
17# Description ####
18# ---------------------------------------------------------------------------- #
21"""
22!!! note "Summary"
23 Core docstring checking functionality.
24"""
27# ---------------------------------------------------------------------------- #
28# #
29# Setup ####
30# #
31# ---------------------------------------------------------------------------- #
34## --------------------------------------------------------------------------- #
35## Imports ####
36## --------------------------------------------------------------------------- #
39# ## Python StdLib Imports ----
40import ast
41import fnmatch
42import re
43from pathlib import Path
44from typing import Iterator, Literal, NamedTuple, Optional, Union
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)
55## --------------------------------------------------------------------------- #
56## Exports ####
57## --------------------------------------------------------------------------- #
60__all__: list[str] = [
61 "DocstringChecker",
62 "FunctionAndClassDetails",
63 "SectionConfig",
64 "DocstringError",
65]
68# ---------------------------------------------------------------------------- #
69# #
70# Main Section ####
71# #
72# ---------------------------------------------------------------------------- #
75class FunctionAndClassDetails(NamedTuple):
76 """
77 !!! note "Summary"
78 Details about a function or class found in the AST.
79 """
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
88class DocstringChecker:
89 """
90 !!! note "Summary"
91 Main class for checking docstring format and completeness.
92 """
94 def __init__(self, config: Config) -> None:
95 """
96 !!! note "Summary"
97 Initialize the docstring checker.
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]
108 def check_file(self, file_path: Union[str, Path]) -> list[DocstringError]:
109 """
110 !!! note "Summary"
111 Check docstrings in a Python file.
113 Params:
114 file_path (Union[str, Path]):
115 Path to the Python file to check.
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.
127 Returns:
128 (list[DocstringError]):
129 List of DocstringError objects for any validation failures.
130 """
132 file_path = Path(file_path)
133 if not file_path.exists():
134 raise FileNotFoundError(f"File not found: {file_path}")
136 if file_path.suffix != ".py":
137 raise InvalidFileError(f"File must be a Python file (.py): {file_path}")
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
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
151 # Extract all functions and classes
152 items: list[FunctionAndClassDetails] = self._extract_items(tree)
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)
162 return errors
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.
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.
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
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.
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.
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
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.
218 Params:
219 file_path (Path):
220 Path to the file to check.
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]
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.
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.
254 Raises:
255 (FileNotFoundError):
256 If the directory doesn't exist.
257 (DirectoryNotFoundError):
258 If the path is not a directory.
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}")
268 if not directory_path.is_dir():
269 raise DirectoryNotFoundError(f"Path is not a directory: {directory_path}")
271 python_files: list[Path] = list(directory_path.glob("**/*.py"))
273 # Filter out excluded patterns if provided
274 if exclude_patterns:
275 python_files = self._filter_python_files(python_files, directory_path, exclude_patterns)
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
284 return results
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.
291 Params:
292 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
293 The function node to check for @overload decorator.
295 Returns:
296 (bool):
297 True if the function has @overload decorator, False otherwise.
298 """
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
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.
314 ???+ abstract "Details"
315 Extract all parameter types including:
317 - Positional-only parameters (before `/`)
318 - Regular positional parameters
319 - Keyword-only parameters (after `*`)
320 - Variable positional arguments (`*args`)
321 - Variable keyword arguments (`**kwargs`)
323 Exclude `self` and `cls` parameters (method context parameters).
325 Params:
326 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
327 The function node to extract parameters from.
329 Returns:
330 (list[str]):
331 List of all parameter names in the function signature.
333 ???+ example "Examples"
335 ```python
336 def func(a, b, /, c, *args, d, **kwargs): ...
339 # Returns: ['a', 'b', 'c', 'args', 'd', 'kwargs']
340 ```
341 """
342 params: list[str] = []
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)
349 # Regular positional parameters
350 for arg in node.args.args:
351 if arg.arg not in ("self", "cls"):
352 params.append(arg.arg)
354 # Variable positional arguments (*args)
355 if node.args.vararg:
356 params.append(node.args.vararg.arg)
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)
363 # Variable keyword arguments (**kwargs)
364 if node.args.kwarg:
365 params.append(node.args.kwarg.arg)
367 return params
369 def _extract_items(self, tree: ast.AST) -> list[FunctionAndClassDetails]:
370 """
371 !!! note "Summary"
372 Extract all functions and classes from the AST.
374 Params:
375 tree (ast.AST):
376 The Abstract Syntax Tree (AST) to extract items from.
378 Returns:
379 (list[FunctionAndClassDetails]):
380 A list of extracted function and class details.
381 """
383 items: list[FunctionAndClassDetails] = []
385 class ItemVisitor(ast.NodeVisitor):
386 """
387 !!! note "Summary"
388 AST visitor to extract function and class definitions
389 """
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
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 )
417 # Visit methods in this class
418 self.class_stack.append(node.name)
419 self.generic_visit(node)
420 self.class_stack.pop()
422 def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
423 """
424 !!! note "Summary"
425 Visit function definition node.
426 """
427 self._visit_function(node)
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)
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 """
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
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 )
460 self.generic_visit(node)
462 visitor = ItemVisitor(self)
463 visitor.visit(tree)
465 return items
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.
476 Params:
477 section (SectionConfig):
478 The section configuration to check.
479 item (FunctionAndClassDetails):
480 The function or class to check against.
482 Returns:
483 (bool):
484 True if the section applies to this item type.
485 """
487 is_function: bool = isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef))
489 # Free text sections apply only to functions and methods, not classes
490 if section.type == "free_text":
491 return is_function
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()
497 # Params only apply to functions/methods
498 if section_name_lower == "params" and is_function:
499 return True
501 # Returns only apply to functions/methods
502 if section_name_lower in ["returns", "return"] and is_function:
503 return True
505 return False
507 # These sections apply to functions/methods that might have them
508 if section.type in ["list_type", "list_name"]:
509 return is_function
511 return False
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.
518 Params:
519 item (FunctionAndClassDetails):
520 The function or class to check.
522 Returns:
523 (list[SectionConfig]):
524 List of section configurations that are required and apply to this item.
525 """
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
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.
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.
552 Raises:
553 DocstringError: If docstring is required but missing.
554 """
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 )
567 def _check_single_docstring(self, item: FunctionAndClassDetails, file_path: str) -> None:
568 """
569 !!! note "Summary"
570 Check a single function or class docstring.
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.
578 Returns:
579 (None):
580 Nothing is returned.
581 """
583 docstring: Optional[str] = ast.get_docstring(item.node)
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
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
594 # Validate docstring sections if docstring exists
595 self._validate_docstring_sections(docstring, item, file_path)
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.
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.
615 Returns:
616 (None):
617 Nothing is returned.
618 """
620 errors: list[str] = []
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)
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)
630 # Perform comprehensive validation checks
631 comprehensive_errors: list[str] = self._perform_comprehensive_validation(docstring)
632 errors.extend(comprehensive_errors)
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 )
645 def _is_params_section_required(self, item: FunctionAndClassDetails) -> bool:
646 """
647 !!! note "Summary"
648 Check if params section is required for this item.
650 Params:
651 item (FunctionAndClassDetails):
652 The function or class details.
654 Returns:
655 (bool):
656 True if params section is required, False otherwise.
657 """
659 # For classes, params section not required (attributes handled differently)
660 if isinstance(item.node, ast.ClassDef):
661 return False
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
668 def _validate_all_required_sections(self, docstring: str, item: FunctionAndClassDetails) -> list[str]:
669 """
670 !!! note "Summary"
671 Validate all required sections are present.
673 Params:
674 docstring (str):
675 The docstring to validate.
676 item (FunctionAndClassDetails):
677 The function or class details.
679 Returns:
680 (list[str]):
681 List of validation error messages for missing required sections.
682 """
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
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
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).
701 Params:
702 docstring (str):
703 The docstring to validate.
704 item (FunctionAndClassDetails):
705 The function or class details.
707 Returns:
708 (list[str]):
709 List of validation error messages for invalid section content.
710 """
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
721 def _section_exists(self, docstring: str, section: SectionConfig) -> bool:
722 """
723 !!! note "Summary"
724 Check if a section exists in the docstring.
726 Params:
727 docstring (str):
728 The docstring to check.
729 section (SectionConfig):
730 The section configuration.
732 Returns:
733 (bool):
734 `True` if section exists, `False` otherwise.
735 """
737 section_name: str = section.name.lower()
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)
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
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
756 return False
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.
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.
773 Returns:
774 (Optional[str]):
775 Error message if validation fails, None otherwise.
776 """
778 if section.type == "list_name_and_type":
779 return self._validate_list_name_and_type_section(docstring, section, item)
781 if section.type == "list_name":
782 return self._validate_list_name_section(docstring, section)
784 # For `section.type in ("free_text", "list_type")`
785 # these sections do not need content validation beyond existence
786 return None
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).
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.
803 Returns:
804 (Optional[str]):
805 Error message if section is invalid, None otherwise.
806 """
808 section_name: str = section.name.lower()
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
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
822 # For returns/return sections, no additional validation beyond existence
823 # The _section_exists check already verified the section is present
825 return None
827 def _validate_list_name_section(self, docstring: str, section: SectionConfig) -> Optional[str]:
828 """
829 !!! note "Summary"
830 Validate list_name sections.
832 Params:
833 docstring (str):
834 The docstring to validate.
835 section (SectionConfig):
836 The section configuration.
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
846 def _perform_comprehensive_validation(self, docstring: str) -> list[str]:
847 """
848 !!! note "Summary"
849 Perform comprehensive validation checks on docstring.
851 Params:
852 docstring (str):
853 The docstring to validate.
855 Returns:
856 (list[str]):
857 List of validation error messages.
858 """
860 errors: list[str] = []
862 # Check section order
863 order_errors: list[str] = self._check_section_order(docstring)
864 errors.extend(order_errors)
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")
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)
875 # Perform formatting validation
876 formatting_errors: list[str] = self._perform_formatting_validation(docstring)
877 errors.extend(formatting_errors)
879 return errors
881 def _perform_formatting_validation(self, docstring: str) -> list[str]:
882 """
883 !!! note "Summary"
884 Perform formatting validation checks.
886 Params:
887 docstring (str):
888 The docstring to validate.
890 Returns:
891 (list[str]):
892 List of formatting error messages.
893 """
895 errors: list[str] = []
897 # Check admonition values
898 admonition_errors: list[str] = self._check_admonition_values(docstring)
899 errors.extend(admonition_errors)
901 # Check colon usage
902 colon_errors: list[str] = self._check_colon_usage(docstring)
903 errors.extend(colon_errors)
905 # Check title case
906 title_case_errors: list[str] = self._check_title_case_sections(docstring)
907 errors.extend(title_case_errors)
909 # Check parentheses
910 parentheses_errors: list[str] = self._check_parentheses_validation(docstring)
911 errors.extend(parentheses_errors)
913 return errors
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.
920 Params:
921 docstring (str):
922 The docstring to check.
923 section (SectionConfig):
924 The section configuration to validate.
926 Returns:
927 (bool):
928 `True` if the section exists, `False` otherwise.
929 """
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))
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
948 # Look for examples section
949 elif section.name.lower() in ["examples", "example"]:
950 return bool(re.search(r'\?\?\?\+ example "Examples"', docstring, re.IGNORECASE))
952 # Default to true for unknown free text sections
953 return True
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.
960 Params:
961 docstring (str):
962 The docstring to check.
963 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
964 The function node to check.
966 Returns:
967 (bool):
968 `True` if the section exists and is valid, `False` otherwise.
969 """
971 # Get function parameters (excluding 'self' for methods)
972 params: list[str] = self._extract_all_params(node)
974 if not params:
975 return True # No parameters to document
977 # Check if Params section exists
978 if not re.search(r"Params:", docstring):
979 return False
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
987 return True
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.
994 Params:
995 docstring (str):
996 The docstring to parse.
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
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
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
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))
1024 return documented_params
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.
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.
1037 Returns:
1038 (str):
1039 Formatted error message.
1040 """
1041 error_parts: list[str] = []
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)
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
1061 if missing_copy:
1062 missing_str: str = "', '".join(missing_copy)
1063 error_parts.append(f" - In signature but not in docstring: '{missing_str}'")
1065 if extra_copy:
1066 extra_str: str = "', '".join(extra_copy)
1067 error_parts.append(f" - In docstring but not in signature: '{extra_str}'")
1069 return "Parameter mismatch:\n" + "\n".join(error_parts)
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.
1078 Params:
1079 docstring (str):
1080 The docstring to check.
1081 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
1082 The function node to check.
1084 Returns:
1085 (tuple[bool, Optional[str]]):
1086 Tuple of (is_valid, error_message). If valid, error_message is None.
1087 """
1089 # Get function parameters (excluding 'self' and 'cls' for methods)
1090 signature_params: list[str] = self._extract_all_params(node)
1092 if not signature_params:
1093 return (True, None) # No parameters to document
1095 # Check if Params section exists
1096 if not re.search(r"Params:", docstring):
1097 return (False, "Params section not found in docstring")
1099 # Extract documented parameters from docstring
1100 documented_params: list[str] = self._extract_documented_params(docstring)
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]
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]
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)
1113 return (True, None)
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.
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)
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.
1135 Params:
1136 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
1137 The function AST node.
1139 Returns:
1140 (dict[str, str]):
1141 Dictionary mapping parameter names to their type annotation strings.
1142 """
1143 param_types: dict[str, str] = {}
1145 # Positional-only parameters (before /)
1146 self._add_arg_types_to_dict(node.args.posonlyargs, param_types)
1148 # Regular positional parameters
1149 self._add_arg_types_to_dict(node.args.args, param_types)
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)
1155 # Keyword-only parameters (after *)
1156 self._add_arg_types_to_dict(node.args.kwonlyargs, param_types)
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)
1162 return param_types
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.
1169 Params:
1170 docstring (str):
1171 The docstring to parse.
1173 Returns:
1174 (dict[str, str]):
1175 Dictionary mapping parameter names to their documented types.
1176 """
1177 param_types: dict[str, str] = {}
1179 # Find the Params section
1180 if not re.search(r"Params:", docstring):
1181 return param_types
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*:"
1191 lines: list[str] = docstring.split("\n")
1192 in_params_section: bool = False
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
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
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
1213 return param_types
1215 def _normalize_type_string(self, type_str: str) -> str:
1216 """
1217 !!! note "Summary"
1218 Normalize a type string for comparison.
1220 Params:
1221 type_str (str):
1222 The type string to normalize.
1224 Returns:
1225 (str):
1226 Normalized type string.
1227 """
1229 # Remove whitespace
1230 normalized: str = re.sub(r"\s+", "", type_str)
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('"', "'")
1236 # Make case-insensitive for basic types
1237 # But preserve case for complex types to avoid breaking things like Optional
1238 return normalized
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.
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.
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]] = []
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
1265 doc_type: str = docstring_types[param_name]
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)
1271 # Case-insensitive comparison
1272 if normalized_sig.lower() != normalized_doc.lower():
1273 mismatches.append((param_name, sig_type, doc_type))
1275 return mismatches
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.
1282 Params:
1283 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
1284 The function node to analyse.
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
1293 # Combine positional-only and regular arguments
1294 all_positional_args = args.posonlyargs + args.args
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)
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)
1311 return params_with_defaults
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.
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'.
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
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"
1353 return clean_type, error_message
1355 def _format_optional_errors(self, errors: list[str]) -> str:
1356 """
1357 !!! note "Summary"
1358 Format multiple optional suffix validation errors.
1360 Params:
1361 errors (list[str]):
1362 List of error messages.
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}"
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.
1378 Params:
1379 mismatches (list[tuple[str, str, str]]):
1380 List of (param_name, sig_type, doc_type) tuples.
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)
1395 formatted_details: str = "\n - ".join([""] + mismatch_blocks)
1396 return f"Parameter type mismatch:{formatted_details}"
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.
1405 ???+ abstract "Details"
1406 Implements three validation modes based on `optional_style` configuration:
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.
1412 Params:
1413 docstring (str):
1414 The docstring to validate.
1415 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
1416 The function node with type annotations.
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)
1426 # Get parameters with default values
1427 params_with_defaults: set[str] = self._get_params_with_defaults(node)
1429 # Get all parameter names (excluding self/cls)
1430 all_params: list[str] = self._extract_all_params(node)
1432 # Get the optional_style mode
1433 optional_style: str = self.config.global_config.optional_style
1435 # Process docstring types based on optional_style mode
1436 docstring_types: dict[str, str] = {}
1437 optional_errors: list[str] = []
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)
1447 # Return optional_style errors first if any
1448 if optional_errors:
1449 return self._format_optional_errors(optional_errors)
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"
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 )
1463 # Compare types
1464 mismatches: list[tuple[str, str, str]] = self._compare_param_types(signature_types, docstring_types)
1466 if mismatches:
1467 return self._format_type_mismatches(mismatches)
1469 return None
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.
1476 Params:
1477 docstring (str):
1478 The docstring to check.
1480 Returns:
1481 (bool):
1482 `True` if the section exists, `False` otherwise.
1483 """
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
1489 def _build_section_patterns(self) -> list[tuple[str, str]]:
1490 """
1491 !!! note "Summary"
1492 Build regex patterns for detecting sections from configuration.
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]
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"))
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 ]
1538 return section_patterns + default_patterns
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.
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.
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))
1561 # Sort by position in docstring
1562 found_sections.sort(key=lambda x: x[0])
1563 return found_sections
1565 def _build_expected_section_order(self) -> list[str]:
1566 """
1567 !!! note "Summary"
1568 Build the expected order of sections from configuration.
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
1595 def _check_section_order(self, docstring: str) -> list[str]:
1596 """
1597 !!! note "Summary"
1598 Check that sections appear in the correct order.
1600 Params:
1601 docstring (str):
1602 The docstring to check.
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()
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
1626 return errors
1628 def _normalize_section_name(self, section_name: str) -> str:
1629 """
1630 !!! note "Summary"
1631 Normalize section name by removing colons and whitespace.
1633 Params:
1634 section_name (str):
1635 The raw section name to normalize.
1637 Returns:
1638 (str):
1639 The normalized section name.
1640 """
1641 return section_name.lower().strip().rstrip(":")
1643 def _is_valid_section_name(self, section_name: str) -> bool:
1644 """
1645 !!! note "Summary"
1646 Check if section name is valid.
1648 !!! abstract "Details"
1649 Filters out empty names, code block markers, and special characters.
1651 Params:
1652 section_name (str):
1653 The section name to validate.
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
1663 # Skip code blocks and inline code
1664 if any(char in section_name for char in ["`", ".", "/", "\\"]):
1665 return False
1667 return True
1669 def _extract_section_names_from_docstring(self, docstring: str) -> set[str]:
1670 """
1671 !!! note "Summary"
1672 Extract all section names found in docstring.
1674 Params:
1675 docstring (str):
1676 The docstring to extract section names from.
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 ]
1690 found_sections: set[str] = set()
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))
1697 if self._is_valid_section_name(section_name):
1698 found_sections.add(section_name)
1700 return found_sections
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.
1707 Params:
1708 docstring (str):
1709 The docstring to check.
1711 Returns:
1712 (list[str]):
1713 A list of error messages for undefined sections.
1714 """
1715 errors: list[str] = []
1717 # Get all configured section names (case-insensitive)
1718 configured_sections: set[str] = {section.name.lower() for section in self.sections_config}
1720 # Extract all section names from docstring
1721 found_sections: set[str] = self._extract_section_names_from_docstring(docstring)
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")
1728 return errors
1730 def _build_admonition_mapping(self) -> dict[str, str]:
1731 """
1732 !!! note "Summary"
1733 Build mapping of section names to expected admonitions.
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
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.
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.
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()
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 )
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"
1780 return None
1782 def _check_admonition_values(self, docstring: str) -> list[str]:
1783 """
1784 !!! note "Summary"
1785 Check that admonition values in docstring match configuration.
1787 Params:
1788 docstring (str):
1789 The docstring to check.
1791 Returns:
1792 (list[str]):
1793 A list of error messages for mismatched admonitions.
1794 """
1795 errors: list[str] = []
1797 # Build admonition mapping
1798 section_admonitions = self._build_admonition_mapping()
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)
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)
1810 return errors
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.
1817 Params:
1818 match (re.Match[str]):
1819 The regex match for an admonition section.
1821 Returns:
1822 (Optional[str]):
1823 An error message if colon found, None otherwise.
1824 """
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()
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 )
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 )
1843 return None
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.
1850 Params:
1851 docstring (str):
1852 The docstring to check.
1854 Returns:
1855 (list[str]):
1856 A list of error messages.
1857 """
1859 errors: list[str] = []
1860 admonition_pattern = r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\""
1861 matches: Iterator[re.Match[str]] = re.finditer(admonition_pattern, docstring, re.IGNORECASE)
1863 for match in matches:
1864 error: Optional[str] = self._validate_admonition_has_no_colon(match)
1865 if error:
1866 errors.append(error)
1868 return errors
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.
1875 Params:
1876 line (str):
1877 The line to check.
1878 pattern (str):
1879 The regex pattern to match.
1881 Returns:
1882 (Optional[str]):
1883 An error message if colon missing, None otherwise.
1884 """
1886 match: Optional[re.Match[str]] = re.match(pattern, line)
1887 if not match:
1888 return None
1890 section_name: str = match.group(1)
1891 has_colon: bool = match.group(2) == ":"
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 )
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}'"
1902 return None
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.
1909 Params:
1910 docstring (str):
1911 The docstring to check.
1913 Returns:
1914 (list[str]):
1915 A list of error messages.
1916 """
1918 errors: list[str] = []
1919 non_admonition_pattern = r"^(\w+)(:?)$"
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)
1927 return errors
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.
1934 Params:
1935 docstring (str):
1936 The docstring to check.
1938 Returns:
1939 (list[str]):
1940 A list of error messages.
1941 """
1943 errors: list[str] = []
1945 # Check admonition sections (should not end with colon)
1946 errors.extend(self._check_admonition_colon_usage(docstring))
1948 # Check non-admonition sections (should end with colon)
1949 errors.extend(self._check_non_admonition_colon_usage(docstring))
1951 return errors
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 """
1959 errors: list[str] = []
1961 # Pattern to find section headers (single word followed by optional colon)
1962 section_pattern = r"^(\w+):?$"
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()
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 )
1984 return errors
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 """
1992 errors: list[str] = []
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 ]
1999 if not parentheses_sections:
2000 return errors
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
2007 for line in lines:
2008 stripped_line: str = line.strip()
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
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
2032 return errors
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).
2039 Params:
2040 stripped_line (str):
2041 The stripped line content.
2042 full_line (str):
2043 The full line with indentation.
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)
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)
2067 return False
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.
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.
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)
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)
2109 return None
2111 def _is_content_line(self, stripped_line: str) -> bool:
2112 """
2113 !!! note "Summary"
2114 Check if line is content that needs validation.
2116 Params:
2117 stripped_line (str):
2118 The stripped line content.
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
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.
2131 Params:
2132 stripped_line (str):
2133 The stripped line content.
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 ]
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 )
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.
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.
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())
2185 # Skip description lines
2186 if self._is_description_line(stripped_line):
2187 return errors, type_line_indent
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 )
2198 return errors, new_indent if new_indent is not None else type_line_indent
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.
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.
2217 Returns:
2218 (tuple[list[str], Optional[int]]):
2219 Tuple of error messages and updated type line indent.
2220 """
2221 errors: list[str] = []
2223 # Check for valid type definition format
2224 if re.search(r"^\s*\([^)]+\):", stripped_line):
2225 return errors, current_indent
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
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
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.
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.
2256 Returns:
2257 (tuple[list[str], Optional[int]]):
2258 Tuple of error messages and updated type line indent.
2259 """
2260 errors: list[str] = []
2262 # Check for valid parameter definition format
2263 if re.search(r"\([^)]+\):", stripped_line):
2264 return errors, current_indent
2266 # Check if this is likely a description line
2267 colon_part: str = stripped_line.split(":")[0].strip()
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
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
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
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 )
2289 return errors, None