Coverage for src/docstring_format_checker/core.py: 100%
383 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-10 13:31 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-10 13:31 +0000
1# ============================================================================ #
2# #
3# Title: Title #
4# Purpose: Purpose #
5# Notes: Notes #
6# Author: chrimaho #
7# Created: Created #
8# References: References #
9# Sources: Sources #
10# Edited: Edited #
11# #
12# ============================================================================ #
15# ---------------------------------------------------------------------------- #
16# #
17# Overview ####
18# #
19# ---------------------------------------------------------------------------- #
22# ---------------------------------------------------------------------------- #
23# Description ####
24# ---------------------------------------------------------------------------- #
27"""
28!!! note "Summary"
29 Core docstring checking functionality.
30"""
33# ---------------------------------------------------------------------------- #
34# #
35# Setup ####
36# #
37# ---------------------------------------------------------------------------- #
40## --------------------------------------------------------------------------- #
41## Imports ####
42## --------------------------------------------------------------------------- #
45# ## Python StdLib Imports ----
46import ast
47import fnmatch
48import re
49from pathlib import Path
50from typing import Iterator, Literal, NamedTuple, Optional, Union
52# ## Local First Party Imports ----
53from docstring_format_checker.config import Config, SectionConfig
54from docstring_format_checker.utils.exceptions import (
55 DirectoryNotFoundError,
56 DocstringError,
57 InvalidFileError,
58)
61## --------------------------------------------------------------------------- #
62## Exports ####
63## --------------------------------------------------------------------------- #
66__all__: list[str] = [
67 "DocstringChecker",
68 "FunctionAndClassDetails",
69 "SectionConfig",
70 "DocstringError",
71]
74# ---------------------------------------------------------------------------- #
75# #
76# Main Section ####
77# #
78# ---------------------------------------------------------------------------- #
81class FunctionAndClassDetails(NamedTuple):
82 """
83 !!! note "Summary"
84 Details about a function or class found in the AST.
85 """
87 item_type: Literal["function", "class", "method"]
88 name: str
89 node: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef]
90 lineno: int
91 parent_class: Optional[str] = None
94class DocstringChecker:
95 """
96 !!! note "Summary"
97 Main class for checking docstring format and completeness.
98 """
100 def __init__(self, config: Config) -> None:
101 """
102 !!! note "Summary"
103 Initialize the docstring checker.
105 Params:
106 config (Config):
107 Configuration object containing global settings and section definitions.
108 """
109 self.config = config
110 self.sections_config: list[SectionConfig] = config.sections
111 self.required_sections: list[SectionConfig] = [s for s in config.sections if s.required]
112 self.optional_sections: list[SectionConfig] = [s for s in config.sections if not s.required]
114 def check_file(self, file_path: Union[str, Path]) -> list[DocstringError]:
115 """
116 !!! note "Summary"
117 Check docstrings in a Python file.
119 Params:
120 file_path (Union[str, Path]):
121 Path to the Python file to check.
123 Raises:
124 (FileNotFoundError):
125 If the file doesn't exist.
126 (InvalidFileError):
127 If the file is not a Python file.
128 (UnicodeError):
129 If the file can't be decoded.
130 (SyntaxError):
131 If the file contains invalid Python syntax.
133 Returns:
134 (list[DocstringError]):
135 List of DocstringError objects for any validation failures.
136 """
138 file_path = Path(file_path)
139 if not file_path.exists():
140 raise FileNotFoundError(f"File not found: {file_path}")
142 if file_path.suffix != ".py":
143 raise InvalidFileError(f"File must be a Python file (.py): {file_path}")
145 # Read and parse the file
146 try:
147 with open(file_path, encoding="utf-8") as f:
148 content: str = f.read()
149 except UnicodeDecodeError as e:
150 raise UnicodeError(f"Cannot decode file {file_path}: {e}") from e
152 try:
153 tree: ast.Module = ast.parse(content)
154 except SyntaxError as e:
155 raise SyntaxError(f"Invalid Python syntax in {file_path}: {e}") from e
157 # Extract all functions and classes
158 items: list[FunctionAndClassDetails] = self._extract_items(tree)
160 # Check each item
161 errors: list[DocstringError] = []
162 for item in items:
163 try:
164 self._check_single_docstring(item, str(file_path))
165 except DocstringError as e:
166 errors.append(e)
168 return errors
170 def check_directory(
171 self,
172 directory_path: Union[str, Path],
173 exclude_patterns: Optional[list[str]] = None,
174 ) -> dict[str, list[DocstringError]]:
175 """
176 !!! note "Summary"
177 Check docstrings in all Python files in a directory recursively.
179 Params:
180 directory_path (Union[str, Path]):
181 Path to the directory to check.
182 exclude_patterns (Optional[list[str]]):
183 List of glob patterns to exclude.
185 Raises:
186 (FileNotFoundError):
187 If the directory doesn't exist.
188 (DirectoryNotFoundError):
189 If the path is not a directory.
191 Returns:
192 (dict[str, list[DocstringError]]):
193 Dictionary mapping file paths to lists of DocstringError objects.
194 """
196 directory_path = Path(directory_path)
197 if not directory_path.exists():
198 raise FileNotFoundError(f"Directory not found: {directory_path}")
200 if not directory_path.is_dir():
201 raise DirectoryNotFoundError(f"Path is not a directory: {directory_path}")
203 python_files: list[Path] = list(directory_path.glob("**/*.py"))
205 # Filter out excluded patterns
206 if exclude_patterns:
207 filtered_files: list[Path] = []
208 for file_path in python_files:
209 relative_path: Path = file_path.relative_to(directory_path)
210 should_exclude = False
211 for pattern in exclude_patterns:
212 if fnmatch.fnmatch(str(relative_path), pattern):
213 should_exclude = True
214 break
215 if not should_exclude:
216 filtered_files.append(file_path)
217 python_files = filtered_files
219 # Check each file
220 results: dict[str, list[DocstringError]] = {}
221 for file_path in python_files:
222 try:
223 errors: list[DocstringError] = self.check_file(file_path)
224 if errors: # Only include files with errors
225 results[str(file_path)] = errors
226 except (FileNotFoundError, ValueError, SyntaxError) as e:
227 # Create a special error for file-level issues
228 error = DocstringError(
229 message=str(e),
230 file_path=str(file_path),
231 line_number=0,
232 item_name="",
233 item_type="file",
234 )
235 results[str(file_path)] = [error]
237 return results
239 def _is_overload_function(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> bool:
240 """
241 !!! note "Summary"
242 Check if a function definition is decorated with @overload.
244 Params:
245 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
246 The function node to check for @overload decorator.
248 Returns:
249 (bool):
250 True if the function has @overload decorator, False otherwise.
251 """
253 for decorator in node.decorator_list:
254 # Handle direct name reference: @overload
255 if isinstance(decorator, ast.Name) and decorator.id == "overload":
256 return True
257 # Handle attribute reference: @typing.overload
258 elif isinstance(decorator, ast.Attribute) and decorator.attr == "overload":
259 return True
260 return False
262 def _extract_items(self, tree: ast.AST) -> list[FunctionAndClassDetails]:
263 """
264 !!! note "Summary"
265 Extract all functions and classes from the AST.
267 Params:
268 tree (ast.AST):
269 The Abstract Syntax Tree (AST) to extract items from.
271 Returns:
272 (list[FunctionAndClassDetails]):
273 A list of extracted function and class details.
274 """
276 items: list[FunctionAndClassDetails] = []
278 class ItemVisitor(ast.NodeVisitor):
279 """
280 !!! note "Summary"
281 AST visitor to extract function and class definitions
282 """
284 def __init__(self, checker: DocstringChecker) -> None:
285 """
286 !!! note "Summary"
287 Initialize the AST visitor.
288 """
289 self.class_stack: list[str] = []
290 self.checker: DocstringChecker = checker
292 def visit_ClassDef(self, node: ast.ClassDef) -> None:
293 """
294 !!! note "Summary"
295 Visit class definition node.
296 """
297 # Skip private classes unless check_private is enabled
298 should_check: bool = self.checker.config.global_config.check_private or not node.name.startswith("_")
299 if should_check:
300 items.append(
301 FunctionAndClassDetails(
302 item_type="class",
303 name=node.name,
304 node=node,
305 lineno=node.lineno,
306 parent_class=None,
307 )
308 )
310 # Visit methods in this class
311 self.class_stack.append(node.name)
312 self.generic_visit(node)
313 self.class_stack.pop()
315 def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
316 """
317 !!! note "Summary"
318 Visit function definition node.
319 """
320 self._visit_function(node)
322 def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
323 """
324 !!! note "Summary"
325 Visit async function definition node.
326 """
327 self._visit_function(node)
329 def _visit_function(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> None:
330 """
331 !!! note "Summary"
332 Visit function definition node (sync or async).
333 """
335 # Skip private functions unless check_private is enabled
336 should_check: bool = self.checker.config.global_config.check_private or not node.name.startswith("_")
337 if should_check:
338 # Skip @overload functions - they don't need docstrings
339 if not self.checker._is_overload_function(node):
340 item_type: Literal["function", "method"] = "method" if self.class_stack else "function"
341 parent_class: Optional[str] = self.class_stack[-1] if self.class_stack else None
343 items.append(
344 FunctionAndClassDetails(
345 item_type=item_type,
346 name=node.name,
347 node=node,
348 lineno=node.lineno,
349 parent_class=parent_class,
350 )
351 )
353 self.generic_visit(node)
355 visitor = ItemVisitor(self)
356 visitor.visit(tree)
358 return items
360 def _check_single_docstring(self, item: FunctionAndClassDetails, file_path: str) -> None:
361 """
362 !!! note "Summary"
363 Check a single function or class docstring.
365 Params:
366 item (FunctionAndClassDetails):
367 The function or class to check.
368 file_path (str):
369 The path to the file containing the item.
371 Returns:
372 (None):
373 Nothing is returned.
374 """
376 docstring: Optional[str] = ast.get_docstring(item.node)
378 # Check if any required sections apply to this item type
379 requires_docstring = False
380 applicable_sections: list[SectionConfig] = []
382 for section in self.sections_config:
383 if section.required:
384 # Check if this section applies to this item type
385 if section.type == "free_text":
386 # Free text sections apply only to functions and methods, not classes
387 if isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
388 requires_docstring = True
389 applicable_sections.append(section)
390 elif section.type == "list_name_and_type":
391 if section.name.lower() == "params" and isinstance(
392 item.node, (ast.FunctionDef, ast.AsyncFunctionDef)
393 ):
394 # Params only apply to functions/methods
395 requires_docstring = True
396 applicable_sections.append(section)
397 elif section.name.lower() in ["returns", "return"] and isinstance(
398 item.node, (ast.FunctionDef, ast.AsyncFunctionDef)
399 ):
400 # Returns only apply to functions/methods
401 requires_docstring = True
402 applicable_sections.append(section)
403 elif section.type in ["list_type", "list_name"]:
404 # These sections apply to functions/methods that might have them
405 if isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
406 requires_docstring = True
407 applicable_sections.append(section)
409 if not docstring:
410 # Only require docstrings if the global flag is enabled
411 if requires_docstring and self.config.global_config.require_docstrings:
412 message: str = f"Missing docstring for {item.item_type}"
413 raise DocstringError(
414 message=message,
415 file_path=file_path,
416 line_number=item.lineno,
417 item_name=item.name,
418 item_type=item.item_type,
419 )
420 return # No docstring required or docstring requirement disabled
422 # Validate docstring sections if docstring exists
423 self._validate_docstring_sections(docstring, item, file_path)
425 def _validate_docstring_sections(
426 self,
427 docstring: str,
428 item: FunctionAndClassDetails,
429 file_path: str,
430 ) -> None:
431 """
432 !!! note "Summary"
433 Validate the sections within a docstring.
435 Params:
436 docstring (str):
437 The docstring to validate.
438 item (FunctionAndClassDetails):
439 The function or class to check.
440 file_path (str):
441 The path to the file containing the item.
443 Returns:
444 (None):
445 Nothing is returned.
446 """
448 errors: list[str] = []
450 # Check each required section
451 for section in self.required_sections:
452 if section.type == "free_text":
453 if not self._check_free_text_section(docstring, section):
454 errors.append(f"Missing required section: {section.name}")
456 elif section.type == "list_name_and_type":
457 if section.name.lower() == "params" and isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
458 if not self._check_params_section(docstring, item.node):
459 errors.append("Missing or invalid Params section")
460 elif section.name.lower() in ["returns", "return"]:
461 if not self._check_returns_section(docstring):
462 errors.append("Missing or invalid Returns section")
464 elif section.type == "list_type":
465 if section.name.lower() in ["raises", "raise"]:
466 if not self._check_raises_section(docstring):
467 errors.append("Missing or invalid Raises section")
468 elif section.name.lower() in ["yields", "yield"]:
469 if not self._check_yields_section(docstring):
470 errors.append("Missing or invalid Yields section")
472 elif section.type == "list_name":
473 # Simple name sections - check if they exist
474 if not self._check_simple_section(docstring, section.name):
475 errors.append(f"Missing required section: {section.name}")
477 # Check section order
478 order_errors: list[str] = self._check_section_order(docstring)
479 errors.extend(order_errors)
481 # Check for mutual exclusivity (returns vs yields)
482 if self._has_both_returns_and_yields(docstring):
483 errors.append("Docstring cannot have both Returns and Yields sections")
485 # Check for undefined sections in docstring (only if not allowed)
486 if not self.config.global_config.allow_undefined_sections:
487 undefined_errors: list[str] = self._check_undefined_sections(docstring)
488 errors.extend(undefined_errors)
490 # Check admonition values match configuration
491 admonition_errors: list[str] = self._check_admonition_values(docstring)
492 errors.extend(admonition_errors)
494 # Check colon usage for admonition vs non-admonition sections
495 colon_errors: list[str] = self._check_colon_usage(docstring)
496 errors.extend(colon_errors)
498 # Check title case for non-admonition sections
499 title_case_errors: list[str] = self._check_title_case_sections(docstring)
500 errors.extend(title_case_errors)
502 # Check parentheses for list type sections
503 parentheses_errors: list[str] = self._check_parentheses_validation(docstring)
504 errors.extend(parentheses_errors)
506 if errors:
507 combined_message: str = "; ".join(errors)
508 raise DocstringError(
509 message=combined_message,
510 file_path=file_path,
511 line_number=item.lineno,
512 item_name=item.name,
513 item_type=item.item_type,
514 )
516 def _check_free_text_section(self, docstring: str, section: SectionConfig) -> bool:
517 """
518 !!! note "Summary"
519 Check if a free text section exists in the docstring.
521 Params:
522 docstring (str):
523 The docstring to check.
524 section (SectionConfig):
525 The section configuration to validate.
527 Returns:
528 (bool):
529 `True` if the section exists, `False` otherwise.
530 """
532 if isinstance(section.admonition, str) and section.admonition and section.prefix:
533 # Format like: !!! note "Summary"
534 # Make the section name part case-insensitive too
535 escaped_name = re.escape(section.name)
536 pattern = rf'{re.escape(section.prefix)}\s+{re.escape(section.admonition)}\s+"[^"]*{escaped_name}[^"]*"'
537 return bool(re.search(pattern, docstring, re.IGNORECASE))
538 elif section.name.lower() in ["summary"]:
539 # For summary, accept either formal format or simple docstring
540 formal_pattern = r'!!! note "Summary"'
541 if re.search(formal_pattern, docstring, re.IGNORECASE):
542 return True
543 # Accept any non-empty docstring as summary
544 return len(docstring.strip()) > 0
545 elif section.name.lower() in ["examples", "example"]:
546 # Look for examples section
547 return bool(re.search(r'\?\?\?\+ example "Examples"', docstring, re.IGNORECASE))
549 return True # Default to true for unknown free text sections
551 def _check_params_section(self, docstring: str, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> bool:
552 """
553 !!! note "Summary"
554 Check if the Params section exists and documents all parameters.
556 Params:
557 docstring (str):
558 The docstring to check.
559 node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
560 The function node to check.
562 Returns:
563 (bool):
564 `True` if the section exists and is valid, `False` otherwise.
565 """
567 # Get function parameters (excluding 'self' for methods)
568 params: list[str] = [arg.arg for arg in node.args.args if arg.arg != "self"]
570 if not params:
571 return True # No parameters to document
573 # Check if Params section exists
574 if not re.search(r"Params:", docstring):
575 return False
577 # Check each parameter is documented
578 for param in params:
579 param_pattern: str = rf"{re.escape(param)}\s*\([^)]+\):"
580 if not re.search(param_pattern, docstring):
581 return False
583 return True
585 def _check_returns_section(self, docstring: str) -> bool:
586 """
587 !!! note "Summary"
588 Check if the Returns section exists.
590 Params:
591 docstring (str):
592 The docstring to check.
594 Returns:
595 (bool):
596 `True` if the section exists, `False` otherwise.
597 """
599 return bool(re.search(r"Returns:", docstring))
601 def _check_raises_section(self, docstring: str) -> bool:
602 """
603 !!! note "Summary"
604 Check if the Raises section exists.
606 Params:
607 docstring (str):
608 The docstring to check.
610 Returns:
611 (bool):
612 `True` if the section exists, `False` otherwise.
613 """
615 return bool(re.search(r"Raises:", docstring))
617 def _has_both_returns_and_yields(self, docstring: str) -> bool:
618 """
619 !!! note "Summary"
620 Check if docstring has both Returns and Yields sections.
622 Params:
623 docstring (str):
624 The docstring to check.
626 Returns:
627 (bool):
628 `True` if the section exists, `False` otherwise.
629 """
631 has_returns = bool(re.search(r"Returns:", docstring))
632 has_yields = bool(re.search(r"Yields:", docstring))
633 return has_returns and has_yields
635 def _check_section_order(self, docstring: str) -> list[str]:
636 """
637 !!! note "Summary"
638 Check that sections appear in the correct order.
640 Params:
641 docstring (str):
642 The docstring to check.
644 Returns:
645 (list[str]):
646 A list of error messages, if any.
647 """
649 # Build expected order from configuration
650 section_patterns: list[tuple[str, str]] = []
651 for section in sorted(self.sections_config, key=lambda x: x.order):
652 if (
653 section.type == "free_text"
654 and isinstance(section.admonition, str)
655 and section.admonition
656 and section.prefix
657 ):
658 pattern: str = (
659 rf'{re.escape(section.prefix)}\s+{re.escape(section.admonition)}\s+".*{re.escape(section.name)}"'
660 )
661 section_patterns.append((pattern, section.name))
662 elif section.name.lower() == "params":
663 section_patterns.append((r"Params:", "Params"))
664 elif section.name.lower() in ["returns", "return"]:
665 section_patterns.append((r"Returns:", "Returns"))
666 elif section.name.lower() in ["yields", "yield"]:
667 section_patterns.append((r"Yields:", "Yields"))
668 elif section.name.lower() in ["raises", "raise"]:
669 section_patterns.append((r"Raises:", "Raises"))
671 # Add some default patterns for common sections
672 default_patterns: list[tuple[str, str]] = [
673 (r'!!! note "Summary"', "Summary"),
674 (r'!!! details "Details"', "Details"),
675 (r'\?\?\?\+ example "Examples"', "Examples"),
676 (r'\?\?\?\+ success "Credit"', "Credit"),
677 (r'\?\?\?\+ calculation "Equation"', "Equation"),
678 (r'\?\?\?\+ info "Notes"', "Notes"),
679 (r'\?\?\? question "References"', "References"),
680 (r'\?\?\? tip "See Also"', "See Also"),
681 ]
683 all_patterns: list[tuple[str, str]] = section_patterns + default_patterns
685 found_sections: list[tuple[int, str]] = []
686 for pattern, section_name in all_patterns:
687 match: Optional[re.Match[str]] = re.search(pattern, docstring, re.IGNORECASE)
688 if match:
689 found_sections.append((match.start(), section_name))
691 # Sort by position in docstring
692 found_sections.sort(key=lambda x: x[0])
694 # Build expected order
695 expected_order: list[str] = [s.name.title() for s in sorted(self.sections_config, key=lambda x: x.order)]
696 expected_order.extend(
697 [
698 "Summary",
699 "Details",
700 "Examples",
701 "Credit",
702 "Equation",
703 "Notes",
704 "References",
705 "See Also",
706 ]
707 )
709 # Check order matches expected order
710 errors: list[str] = []
711 last_expected_index = -1
712 for _, section_name in found_sections:
713 try:
714 current_index: int = expected_order.index(section_name)
715 if current_index < last_expected_index:
716 errors.append(f"Section '{section_name}' appears out of order")
717 last_expected_index: int = current_index
718 except ValueError:
719 # Section not in expected order list - might be OK
720 pass
722 return errors
724 def _check_yields_section(self, docstring: str) -> bool:
725 """
726 !!! note "Summary"
727 Check if the Yields section exists.
729 Params:
730 docstring (str):
731 The docstring to check.
733 Returns:
734 (bool):
735 `True` if the section exists, `False` otherwise.
736 """
738 return bool(re.search(r"Yields:", docstring))
740 def _check_simple_section(self, docstring: str, section_name: str) -> bool:
741 """
742 !!! note "Summary"
743 Check if a simple named section exists.
745 Params:
746 docstring (str):
747 The docstring to check.
748 section_name (str):
749 The name of the section to check for.
751 Returns:
752 (bool):
753 `True` if the section exists, `False` otherwise.
754 """
756 pattern: str = rf"{re.escape(section_name)}:"
757 return bool(re.search(pattern, docstring, re.IGNORECASE))
759 def _check_undefined_sections(self, docstring: str) -> list[str]:
760 """
761 !!! note "Summary"
762 Check for sections in docstring that are not defined in configuration.
764 Params:
765 docstring (str):
766 The docstring to check.
768 Returns:
769 (list[str]):
770 A list of error messages for undefined sections.
771 """
773 errors: list[str] = []
775 # Get all configured section names (case-insensitive)
776 configured_sections: set[str] = {section.name.lower() for section in self.sections_config}
778 # Common patterns for different section types
779 section_patterns: list[tuple[str, str]] = [
780 # Standard sections with colons (but not inside quotes)
781 (r"^(\w+):\s*", "colon"),
782 # Admonition sections with various prefixes
783 (r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\"", "admonition"),
784 ]
786 found_sections: set[str] = set()
788 for pattern, pattern_type in section_patterns:
789 matches: Iterator[re.Match[str]] = re.finditer(pattern, docstring, re.IGNORECASE | re.MULTILINE)
790 for match in matches:
791 section_name: str = match.group(1).lower().strip()
793 # Remove colon if present (for colon pattern matches)
794 section_name = section_name.rstrip(":")
796 # Skip empty matches or common docstring content
797 if not section_name or section_name in ["", "py", "python", "sh", "shell"]:
798 continue
800 # Skip code blocks and inline code
801 if any(char in section_name for char in ["`", ".", "/", "\\"]):
802 continue
804 found_sections.add(section_name)
806 # Check which found sections are not configured
807 for section_name in found_sections:
808 if section_name not in configured_sections:
809 errors.append(f"Section '{section_name}' found in docstring but not defined in configuration")
811 return errors
813 def _check_admonition_values(self, docstring: str) -> list[str]:
814 """
815 !!! note "Summary"
816 Check that admonition values in docstring match configuration.
818 Params:
819 docstring (str):
820 The docstring to check.
822 Returns:
823 (list[str]):
824 A list of error messages for mismatched admonitions.
825 """
827 errors: list[str] = []
829 # Create mapping of section names to expected admonitions
830 section_admonitions: dict[str, str] = {}
831 for section in self.sections_config:
832 if section.type == "free_text" and isinstance(section.admonition, str) and section.admonition:
833 section_admonitions[section.name.lower()] = section.admonition.lower()
835 # Pattern to find all admonition sections
836 admonition_pattern = r"(?:\?\?\?[+]?|!!!)\s+(\w+)\s+\"([^\"]+)\""
837 matches: Iterator[re.Match[str]] = re.finditer(admonition_pattern, docstring, re.IGNORECASE)
839 for match in matches:
840 actual_admonition: str = match.group(1).lower()
841 section_title: str = match.group(2).lower()
843 # Check if this section is configured with a specific admonition
844 if section_title in section_admonitions:
845 expected_admonition: str = section_admonitions[section_title]
846 if actual_admonition != expected_admonition:
847 errors.append(
848 f"Section '{section_title}' has incorrect admonition '{actual_admonition}', "
849 f"expected '{expected_admonition}'"
850 )
852 # Check if section shouldn't have admonition but does
853 section_config: Optional[SectionConfig] = next(
854 (s for s in self.sections_config if s.name.lower() == section_title), None
855 )
856 if section_config and section_config.admonition is False:
857 errors.append(f"Section '{section_title}' is configured as non-admonition but found as admonition")
859 return errors
861 def _check_colon_usage(self, docstring: str) -> list[str]:
862 """
863 !!! note "Summary"
864 Check that colons are used correctly for admonition vs non-admonition sections.
865 """
867 errors: list[str] = []
869 # Check admonition sections (should not end with colon)
870 admonition_pattern = r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\""
871 matches: Iterator[re.Match[str]] = re.finditer(admonition_pattern, docstring, re.IGNORECASE)
873 for match in matches:
874 section_title: str = match.group(1)
875 has_colon: bool = section_title.endswith(":")
876 section_title_clean: str = section_title.rstrip(":").lower()
878 # Find config for this section
879 section_config: Optional[SectionConfig] = next(
880 (s for s in self.sections_config if s.name.lower() == section_title_clean), None
881 )
882 if section_config and isinstance(section_config.admonition, str) and section_config.admonition:
883 if has_colon:
884 errors.append(
885 f"Section '{section_title_clean}' is an admonition, therefore it should not end with ':', "
886 f"see: '{match.group(0)}'"
887 )
889 # Check non-admonition sections (should end with colon)
890 non_admonition_pattern = r"^(\w+)(:?)$"
891 for line in docstring.split("\n"):
892 line: str = line.strip()
893 match: Optional[re.Match[str]] = re.match(non_admonition_pattern, line)
894 if match:
895 section_name: str = match.group(1).lower()
896 has_colon: bool = match.group(2) == ":"
898 # Find config for this section
899 section_config = next((s for s in self.sections_config if s.name.lower() == section_name), None)
900 if section_config and section_config.admonition is False:
901 if not has_colon:
902 errors.append(
903 f"Section '{section_name}' is non-admonition, therefore it must end with ':', "
904 f"see: '{line}'"
905 )
907 return errors
909 def _check_title_case_sections(self, docstring: str) -> list[str]:
910 """
911 !!! note "Summary"
912 Check that non-admonition sections are single word, title case, and match config name.
913 """
915 errors: list[str] = []
917 # Pattern to find section headers (single word followed by optional colon)
918 section_pattern = r"^(\w+):?$"
920 for line in docstring.split("\n"):
921 line: str = line.strip()
922 match: Optional[re.Match[str]] = re.match(section_pattern, line)
923 if match:
924 section_word: str = match.group(1)
925 section_name_lower: str = section_word.lower()
927 # Check if this is a configured non-admonition section
928 section_config: Optional[SectionConfig] = next(
929 (s for s in self.sections_config if s.name.lower() == section_name_lower), None
930 )
931 if section_config and section_config.admonition is False:
932 # Check if it's title case
933 expected_title_case: str = section_config.name.title()
934 if section_word != expected_title_case:
935 errors.append(
936 f"Section '{section_name_lower}' must be in title case as '{expected_title_case}', "
937 f"found: '{section_word}'"
938 )
940 return errors
942 def _check_parentheses_validation(self, docstring: str) -> list[str]:
943 """
944 !!! note "Summary"
945 Check that list_type and list_name_and_type sections have proper parentheses.
946 """
948 errors: list[str] = []
950 # Get sections that require parentheses
951 parentheses_sections: list[SectionConfig] = [
952 s for s in self.sections_config if s.type in ["list_type", "list_name_and_type"]
953 ]
955 if not parentheses_sections:
956 return errors
958 # Check each line in the docstring
959 lines: list[str] = docstring.split("\n")
960 current_section = None
961 type_line_indent = None # Track indentation of type definition lines
963 for i, line in enumerate(lines):
964 stripped_line: str = line.strip()
966 # Detect section headers
967 # Admonition sections
968 admonition_match: Optional[re.Match[str]] = re.match(
969 r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\"", stripped_line, re.IGNORECASE
970 )
971 if admonition_match:
972 section_name: str = admonition_match.group(1).lower()
973 current_section: Optional[SectionConfig] = next(
974 (s for s in parentheses_sections if s.name.lower() == section_name), None
975 )
976 type_line_indent = None # Reset for new section
977 continue
979 # Non-admonition sections - only match actual section headers, not indented content
980 # Section headers should be at the start of the line (no leading whitespace)
981 if not line.startswith((" ", "\t")): # Not indented
982 simple_section_match: Optional[re.Match[str]] = re.match(r"^(\w+):?$", stripped_line)
983 if simple_section_match:
984 section_name: str = simple_section_match.group(1).lower()
985 # Only consider it a section if it matches our known sections
986 potential_section: Optional[SectionConfig] = next(
987 (s for s in self.sections_config if s.name.lower() == section_name), None
988 )
989 if potential_section:
990 # This is a real section header
991 current_section = next(
992 (s for s in parentheses_sections if s.name.lower() == section_name), None
993 )
994 type_line_indent = None # Reset for new section
995 continue
996 # If it doesn't match a known section, fall through to content processing
998 # Check content lines if we're in a parentheses-required section
999 if current_section and stripped_line and not stripped_line.startswith(("!", "?", "#")):
1000 # Look for parameter/type definitions
1001 if ":" in stripped_line:
1002 # Calculate current line indentation
1003 current_indent = len(line) - len(line.lstrip())
1005 # Skip description lines that start with common description words
1006 description_prefixes = [
1007 "default:",
1008 "note:",
1009 "example:",
1010 "see:",
1011 "warning:",
1012 "info:",
1013 "tip:",
1014 "returns:",
1015 ]
1016 is_description_line = any(
1017 stripped_line.lower().startswith(prefix) for prefix in description_prefixes
1018 )
1020 # Skip lines that are clearly descriptions (containing "Default:", etc.)
1021 if (
1022 is_description_line
1023 or "Default:" in stripped_line
1024 or "Output format:" in stripped_line
1025 or "Show examples:" in stripped_line
1026 ):
1027 continue
1029 # For list_type sections, we need special handling
1030 if current_section.type == "list_type":
1031 # Check if this line has parentheses at the beginning
1032 if re.search(r"^\s*\([^)]+\):", stripped_line):
1033 # This is a valid type definition line, remember its indentation
1034 type_line_indent = current_indent
1035 continue
1036 else:
1037 # If no type definition has been found yet, allow lines with colons as possible descriptions
1038 if type_line_indent is None:
1039 continue
1040 # Check if this is a description line (more indented than type line)
1041 if current_indent > type_line_indent:
1042 # This is a description line, skip validation
1043 continue
1044 else:
1045 # This should be a type definition but doesn't have proper format
1046 errors.append(
1047 f"Section '{current_section.name}' (type: '{current_section.type}') requires "
1048 f"parenthesized types, see: '{stripped_line}'"
1049 )
1050 # For list_name_and_type sections, check format like "name (type):" or "(type):"
1051 elif current_section.type == "list_name_and_type":
1052 # Check if this line has parentheses and looks like a parameter definition
1053 if re.search(r"\([^)]+\):", stripped_line):
1054 # This is a valid parameter definition line, remember its indentation
1055 type_line_indent = current_indent
1056 continue
1057 else:
1058 # Check if this is likely a description line based on various criteria
1059 colon_part = stripped_line.split(":")[0].strip()
1061 # Skip if it contains phrases that indicate it's a description, not a parameter
1062 if any(
1063 word in colon_part.lower()
1064 for word in ["default", "output", "format", "show", "example"]
1065 ):
1066 continue
1068 # Skip if it starts with bullet points or list markers
1069 if stripped_line.strip().startswith(("-", "*", "•", "+")):
1070 continue
1072 # If we have found a parameter definition, check if this is a description line
1073 if type_line_indent is not None:
1074 # Skip if this is more indented than the parameter definition (description line)
1075 if current_indent > type_line_indent:
1076 continue
1078 # Skip if the line before the colon contains multiple words (likely description)
1079 words_before_colon = colon_part.split()
1080 if len(words_before_colon) > 2: # More than "param_name (type)"
1081 continue
1083 # Only flag lines that could reasonably be parameter definitions
1084 if ":" in stripped_line and not stripped_line.strip().startswith("#"):
1085 errors.append(
1086 f"Section '{current_section.name}' (type: '{current_section.type}') requires "
1087 f"parenthesized types, see: '{stripped_line}'"
1088 )
1090 return errors