docstring-format-checker 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,671 @@
1
+ # ============================================================================ #
2
+ # #
3
+ # Title: Title #
4
+ # Purpose: Purpose #
5
+ # Notes: Notes #
6
+ # Author: chrimaho #
7
+ # Created: Created #
8
+ # References: References #
9
+ # Sources: Sources #
10
+ # Edited: Edited #
11
+ # #
12
+ # ============================================================================ #
13
+
14
+
15
+ # ---------------------------------------------------------------------------- #
16
+ # #
17
+ # Overview ####
18
+ # #
19
+ # ---------------------------------------------------------------------------- #
20
+
21
+
22
+ # ---------------------------------------------------------------------------- #
23
+ # Description ####
24
+ # ---------------------------------------------------------------------------- #
25
+
26
+
27
+ """
28
+ !!! note "Summary"
29
+ Core docstring checking functionality.
30
+ """
31
+
32
+
33
+ # ---------------------------------------------------------------------------- #
34
+ # #
35
+ # Setup ####
36
+ # #
37
+ # ---------------------------------------------------------------------------- #
38
+
39
+
40
+ ## --------------------------------------------------------------------------- #
41
+ ## Imports ####
42
+ ## --------------------------------------------------------------------------- #
43
+
44
+
45
+ # ## Python StdLib Imports ----
46
+ import ast
47
+ import fnmatch
48
+ import re
49
+ from pathlib import Path
50
+ from typing import Literal, NamedTuple, Optional, Union
51
+
52
+ # ## Local First Party Imports ----
53
+ from docstring_format_checker.config import SectionConfig
54
+ from docstring_format_checker.utils.exceptions import (
55
+ DirectoryNotFoundError,
56
+ DocstringError,
57
+ InvalidFileError,
58
+ )
59
+
60
+
61
+ ## --------------------------------------------------------------------------- #
62
+ ## Exports ####
63
+ ## --------------------------------------------------------------------------- #
64
+
65
+
66
+ __all__: list[str] = [
67
+ "DocstringChecker",
68
+ "FunctionAndClassDetails",
69
+ "SectionConfig",
70
+ "DocstringError",
71
+ ]
72
+
73
+
74
+ # ---------------------------------------------------------------------------- #
75
+ # #
76
+ # Main Section ####
77
+ # #
78
+ # ---------------------------------------------------------------------------- #
79
+
80
+
81
+ class FunctionAndClassDetails(NamedTuple):
82
+ """
83
+ Details about a function or class found in the AST.
84
+ """
85
+
86
+ item_type: Literal["function", "class", "method"]
87
+ name: str
88
+ node: Union[ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef]
89
+ lineno: int
90
+ parent_class: Optional[str] = None
91
+
92
+
93
+ class DocstringChecker:
94
+ """
95
+ Main class for checking docstring format and completeness.
96
+ """
97
+
98
+ def __init__(self, sections_config: list[SectionConfig]) -> None:
99
+ """
100
+ !!! note "Summary"
101
+ Initialize the docstring checker.
102
+
103
+ Params:
104
+ sections_config (list[SectionConfig]):
105
+ List of section configurations to check against.
106
+ """
107
+ self.sections_config: list[SectionConfig] = sections_config
108
+ self.required_sections: list[SectionConfig] = [s for s in sections_config if s.required]
109
+ self.optional_sections: list[SectionConfig] = [s for s in sections_config if not s.required]
110
+
111
+ def check_file(self, file_path: Union[str, Path]) -> list[DocstringError]:
112
+ """
113
+ !!! note "Summary"
114
+ Check docstrings in a Python file.
115
+
116
+ Params:
117
+ file_path (Union[str, Path]):
118
+ Path to the Python file to check.
119
+
120
+ Returns:
121
+ (list[DocstringError]):
122
+ List of DocstringError objects for any validation failures.
123
+
124
+ Raises:
125
+ (FileNotFoundError):
126
+ If the file doesn't exist.
127
+ (InvalidFileError):
128
+ If the file is not a Python file.
129
+ (UnicodeError):
130
+ If the file can't be decoded.
131
+ (SyntaxError):
132
+ If the file contains invalid Python syntax.
133
+ """
134
+
135
+ file_path = Path(file_path)
136
+ if not file_path.exists():
137
+ raise FileNotFoundError(f"File not found: {file_path}")
138
+
139
+ if file_path.suffix != ".py":
140
+ raise InvalidFileError(f"File must be a Python file (.py): {file_path}")
141
+
142
+ # Read and parse the file
143
+ try:
144
+ with open(file_path, encoding="utf-8") as f:
145
+ content: str = f.read()
146
+ except UnicodeDecodeError as e:
147
+ raise UnicodeError(f"Cannot decode file {file_path}: {e}") from e
148
+
149
+ try:
150
+ tree: ast.Module = ast.parse(content)
151
+ except SyntaxError as e:
152
+ raise SyntaxError(f"Invalid Python syntax in {file_path}: {e}") from e
153
+
154
+ # Extract all functions and classes
155
+ items: list[FunctionAndClassDetails] = self._extract_items(tree)
156
+
157
+ # Check each item
158
+ errors: list[DocstringError] = []
159
+ for item in items:
160
+ try:
161
+ self._check_single_docstring(item, str(file_path))
162
+ except DocstringError as e:
163
+ errors.append(e)
164
+
165
+ return errors
166
+
167
+ def check_directory(
168
+ self,
169
+ directory_path: Union[str, Path],
170
+ recursive: bool = True,
171
+ exclude_patterns: Optional[list[str]] = None,
172
+ ) -> dict[str, list[DocstringError]]:
173
+ """
174
+ !!! note "Summary"
175
+ Check docstrings in all Python files in a directory.
176
+
177
+ Params:
178
+ directory_path (Union[str, Path]):
179
+ Path to the directory to check.
180
+ recursive (bool):
181
+ Whether to check subdirectories recursively.
182
+ exclude_patterns (Optional[list[str]]):
183
+ List of glob patterns to exclude.
184
+
185
+ Raises:
186
+ (FileNotFoundError):
187
+ If the directory doesn't exist.
188
+ (DirectoryNotFoundError):
189
+ If the path is not a directory.
190
+
191
+ Returns:
192
+ (dict[str, list[DocstringError]]):
193
+ Dictionary mapping file paths to lists of DocstringError objects.
194
+ """
195
+
196
+ directory_path = Path(directory_path)
197
+ if not directory_path.exists():
198
+ raise FileNotFoundError(f"Directory not found: {directory_path}")
199
+
200
+ if not directory_path.is_dir():
201
+ raise DirectoryNotFoundError(f"Path is not a directory: {directory_path}")
202
+
203
+ # Find all Python files
204
+ if recursive:
205
+ pattern = "**/*.py"
206
+ else:
207
+ pattern = "*.py"
208
+
209
+ python_files: list[Path] = list(directory_path.glob(pattern))
210
+
211
+ # Filter out excluded patterns
212
+ if exclude_patterns:
213
+ filtered_files: list[Path] = []
214
+ for file_path in python_files:
215
+ relative_path: Path = file_path.relative_to(directory_path)
216
+ should_exclude = False
217
+ for pattern in exclude_patterns:
218
+ if fnmatch.fnmatch(str(relative_path), pattern):
219
+ should_exclude = True
220
+ break
221
+ if not should_exclude:
222
+ filtered_files.append(file_path)
223
+ python_files = filtered_files
224
+
225
+ # Check each file
226
+ results: dict[str, list[DocstringError]] = {}
227
+ for file_path in python_files:
228
+ try:
229
+ errors: list[DocstringError] = self.check_file(file_path)
230
+ if errors: # Only include files with errors
231
+ results[str(file_path)] = errors
232
+ except (FileNotFoundError, ValueError, SyntaxError) as e:
233
+ # Create a special error for file-level issues
234
+ error = DocstringError(
235
+ message=str(e),
236
+ file_path=str(file_path),
237
+ line_number=0,
238
+ item_name="",
239
+ item_type="file",
240
+ )
241
+ results[str(file_path)] = [error]
242
+
243
+ return results
244
+
245
+ def _extract_items(self, tree: ast.AST) -> list[FunctionAndClassDetails]:
246
+ """
247
+ !!! note "Summary"
248
+ Extract all functions and classes from the AST.
249
+
250
+ Params:
251
+ tree (ast.AST):
252
+ The Abstract Syntax Tree (AST) to extract items from.
253
+
254
+ Returns:
255
+ (list[FunctionAndClassDetails]):
256
+ A list of extracted function and class details.
257
+ """
258
+
259
+ items: list[FunctionAndClassDetails] = []
260
+
261
+ class ItemVisitor(ast.NodeVisitor):
262
+
263
+ def __init__(self) -> None:
264
+ self.class_stack: list[str] = []
265
+
266
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
267
+ if not node.name.startswith("_"): # Skip private classes
268
+ items.append(
269
+ FunctionAndClassDetails(
270
+ item_type="class",
271
+ name=node.name,
272
+ node=node,
273
+ lineno=node.lineno,
274
+ parent_class=None,
275
+ )
276
+ )
277
+
278
+ # Visit methods in this class
279
+ self.class_stack.append(node.name)
280
+ self.generic_visit(node)
281
+ self.class_stack.pop()
282
+
283
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
284
+ self._visit_function(node)
285
+
286
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
287
+ self._visit_function(node)
288
+
289
+ def _visit_function(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> None:
290
+ """Visit function definition node (sync or async)."""
291
+ if not node.name.startswith("_"): # Skip private functions
292
+ item_type: Literal["function", "method"] = "method" if self.class_stack else "function"
293
+ parent_class: Optional[str] = self.class_stack[-1] if self.class_stack else None
294
+
295
+ items.append(
296
+ FunctionAndClassDetails(
297
+ item_type=item_type,
298
+ name=node.name,
299
+ node=node,
300
+ lineno=node.lineno,
301
+ parent_class=parent_class,
302
+ )
303
+ )
304
+
305
+ self.generic_visit(node)
306
+
307
+ visitor = ItemVisitor()
308
+ visitor.visit(tree)
309
+
310
+ return items
311
+
312
+ def _check_single_docstring(self, item: FunctionAndClassDetails, file_path: str) -> None:
313
+ """
314
+ !!! note "Summary"
315
+ Check a single function or class docstring.
316
+
317
+ Params:
318
+ item (FunctionAndClassDetails):
319
+ The function or class to check.
320
+ file_path (str):
321
+ The path to the file containing the item.
322
+
323
+ Returns:
324
+ (None):
325
+ Nothing is returned.
326
+ """
327
+
328
+ docstring: Optional[str] = ast.get_docstring(item.node)
329
+
330
+ # Check if any required sections apply to this item type
331
+ requires_docstring = False
332
+ applicable_sections: list[SectionConfig] = []
333
+
334
+ for section in self.sections_config:
335
+ if section.required:
336
+ # Check if this section applies to this item type
337
+ if section.type == "free_text":
338
+ # Free text sections apply only to functions and methods, not classes
339
+ if isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
340
+ requires_docstring = True
341
+ applicable_sections.append(section)
342
+ elif section.type == "list_name_and_type":
343
+ if section.name.lower() == "params" and isinstance(
344
+ item.node, (ast.FunctionDef, ast.AsyncFunctionDef)
345
+ ):
346
+ # Params only apply to functions/methods
347
+ requires_docstring = True
348
+ applicable_sections.append(section)
349
+ elif section.name.lower() in ["returns", "return"] and isinstance(
350
+ item.node, (ast.FunctionDef, ast.AsyncFunctionDef)
351
+ ):
352
+ # Returns only apply to functions/methods
353
+ requires_docstring = True
354
+ applicable_sections.append(section)
355
+ elif section.type in ["list_type", "list_name"]:
356
+ # These sections apply to functions/methods that might have them
357
+ if isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
358
+ requires_docstring = True
359
+ applicable_sections.append(section)
360
+
361
+ if not docstring:
362
+ if requires_docstring:
363
+ message: str = f"Missing docstring for {item.item_type}"
364
+ raise DocstringError(
365
+ message=message,
366
+ file_path=file_path,
367
+ line_number=item.lineno,
368
+ item_name=item.name,
369
+ item_type=item.item_type,
370
+ )
371
+ return # No docstring required
372
+
373
+ # Validate docstring sections if docstring exists
374
+ self._validate_docstring_sections(docstring, item, file_path)
375
+
376
+ def _validate_docstring_sections(
377
+ self,
378
+ docstring: str,
379
+ item: FunctionAndClassDetails,
380
+ file_path: str,
381
+ ) -> None:
382
+ """
383
+ !!! note "Summary"
384
+ Validate the sections within a docstring.
385
+
386
+ Params:
387
+ docstring (str):
388
+ The docstring to validate.
389
+ item (FunctionAndClassDetails):
390
+ The function or class to check.
391
+ file_path (str):
392
+ The path to the file containing the item.
393
+
394
+ Returns:
395
+ (None):
396
+ Nothing is returned.
397
+ """
398
+ errors: list[str] = []
399
+
400
+ # Check each required section
401
+ for section in self.required_sections:
402
+ if section.type == "free_text":
403
+ if not self._check_free_text_section(docstring, section):
404
+ errors.append(f"Missing required section: {section.name}")
405
+
406
+ elif section.type == "list_name_and_type":
407
+ if section.name.lower() == "params" and isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
408
+ if not self._check_params_section(docstring, item.node):
409
+ errors.append("Missing or invalid Params section")
410
+ elif section.name.lower() in ["returns", "return"]:
411
+ if not self._check_returns_section(docstring):
412
+ errors.append("Missing or invalid Returns section")
413
+
414
+ elif section.type == "list_type":
415
+ if section.name.lower() in ["raises", "raise"]:
416
+ if not self._check_raises_section(docstring):
417
+ errors.append("Missing or invalid Raises section")
418
+ elif section.name.lower() in ["yields", "yield"]:
419
+ if not self._check_yields_section(docstring):
420
+ errors.append("Missing or invalid Yields section")
421
+
422
+ elif section.type == "list_name":
423
+ # Simple name sections - check if they exist
424
+ if not self._check_simple_section(docstring, section.name):
425
+ errors.append(f"Missing required section: {section.name}")
426
+
427
+ # Check section order
428
+ order_errors: list[str] = self._check_section_order(docstring)
429
+ errors.extend(order_errors)
430
+
431
+ # Check for mutual exclusivity (returns vs yields)
432
+ if self._has_both_returns_and_yields(docstring):
433
+ errors.append("Docstring cannot have both Returns and Yields sections")
434
+
435
+ if errors:
436
+ combined_message: str = "; ".join(errors)
437
+ raise DocstringError(
438
+ message=combined_message,
439
+ file_path=file_path,
440
+ line_number=item.lineno,
441
+ item_name=item.name,
442
+ item_type=item.item_type,
443
+ )
444
+
445
+ def _check_free_text_section(self, docstring: str, section: SectionConfig) -> bool:
446
+ """
447
+ !!! note "Summary"
448
+ Check if a free text section exists in the docstring.
449
+
450
+ Params:
451
+ docstring (str):
452
+ The docstring to check.
453
+ section (SectionConfig):
454
+ The section configuration to validate.
455
+
456
+ Returns:
457
+ (bool):
458
+ `True` if the section exists, `False` otherwise.
459
+ """
460
+ if section.admonition and section.prefix:
461
+ # Format like: !!! note "Summary"
462
+ pattern = rf'{re.escape(section.prefix)}\s+{re.escape(section.admonition)}\s+".*{re.escape(section.name)}"'
463
+ return bool(re.search(pattern, docstring, re.IGNORECASE))
464
+ elif section.name.lower() in ["summary"]:
465
+ # For summary, accept either formal format or simple docstring
466
+ formal_pattern = r'!!! note "Summary"'
467
+ if re.search(formal_pattern, docstring, re.IGNORECASE):
468
+ return True
469
+ # Accept any non-empty docstring as summary
470
+ return len(docstring.strip()) > 0
471
+ elif section.name.lower() in ["examples", "example"]:
472
+ # Look for examples section
473
+ return bool(re.search(r'\?\?\?\+ example "Examples"', docstring, re.IGNORECASE))
474
+
475
+ return True # Default to true for unknown free text sections
476
+
477
+ def _check_params_section(self, docstring: str, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> bool:
478
+ """
479
+ !!! note "Summary"
480
+ Check if the Params section exists and documents all parameters.
481
+
482
+ Params:
483
+ docstring (str):
484
+ The docstring to check.
485
+ node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
486
+ The function node to check.
487
+
488
+ Returns:
489
+ (bool):
490
+ `True` if the section exists and is valid, `False` otherwise.
491
+ """
492
+ # Get function parameters (excluding 'self' for methods)
493
+ params: list[str] = [arg.arg for arg in node.args.args if arg.arg != "self"]
494
+
495
+ if not params:
496
+ return True # No parameters to document
497
+
498
+ # Check if Params section exists
499
+ if not re.search(r"Params:", docstring):
500
+ return False
501
+
502
+ # Check each parameter is documented
503
+ for param in params:
504
+ param_pattern: str = rf"{re.escape(param)}\s*\([^)]+\):"
505
+ if not re.search(param_pattern, docstring):
506
+ return False
507
+
508
+ return True
509
+
510
+ def _check_returns_section(self, docstring: str) -> bool:
511
+ """
512
+ !!! note "Summary"
513
+ Check if the Returns section exists.
514
+
515
+ Params:
516
+ docstring (str):
517
+ The docstring to check.
518
+
519
+ Returns:
520
+ (bool):
521
+ `True` if the section exists, `False` otherwise.
522
+ """
523
+ return bool(re.search(r"Returns:", docstring))
524
+
525
+ def _check_raises_section(self, docstring: str) -> bool:
526
+ """
527
+ !!! note "Summary"
528
+ Check if the Raises section exists.
529
+
530
+ Params:
531
+ docstring (str):
532
+ The docstring to check.
533
+
534
+ Returns:
535
+ (bool):
536
+ `True` if the section exists, `False` otherwise.
537
+ """
538
+ return bool(re.search(r"Raises:", docstring))
539
+
540
+ def _has_both_returns_and_yields(self, docstring: str) -> bool:
541
+ """
542
+ !!! note "Summary"
543
+ Check if docstring has both Returns and Yields sections.
544
+
545
+ Params:
546
+ docstring (str):
547
+ The docstring to check.
548
+
549
+ Returns:
550
+ (bool):
551
+ `True` if the section exists, `False` otherwise.
552
+ """
553
+ has_returns = bool(re.search(r"Returns:", docstring))
554
+ has_yields = bool(re.search(r"Yields:", docstring))
555
+ return has_returns and has_yields
556
+
557
+ def _check_section_order(self, docstring: str) -> list[str]:
558
+ """
559
+ !!! note "Summary"
560
+ Check that sections appear in the correct order.
561
+
562
+ Params:
563
+ docstring (str):
564
+ The docstring to check.
565
+
566
+ Returns:
567
+ (list[str]):
568
+ A list of error messages, if any.
569
+ """
570
+ # Build expected order from configuration
571
+ section_patterns: list[tuple[str, str]] = []
572
+ for section in sorted(self.sections_config, key=lambda x: x.order):
573
+ if section.type == "free_text" and section.admonition and section.prefix:
574
+ pattern: str = (
575
+ rf'{re.escape(section.prefix)}\s+{re.escape(section.admonition)}\s+".*{re.escape(section.name)}"'
576
+ )
577
+ section_patterns.append((pattern, section.name))
578
+ elif section.name.lower() == "params":
579
+ section_patterns.append((r"Params:", "Params"))
580
+ elif section.name.lower() in ["returns", "return"]:
581
+ section_patterns.append((r"Returns:", "Returns"))
582
+ elif section.name.lower() in ["yields", "yield"]:
583
+ section_patterns.append((r"Yields:", "Yields"))
584
+ elif section.name.lower() in ["raises", "raise"]:
585
+ section_patterns.append((r"Raises:", "Raises"))
586
+
587
+ # Add some default patterns for common sections
588
+ default_patterns: list[tuple[str, str]] = [
589
+ (r'!!! note "Summary"', "Summary"),
590
+ (r'!!! details "Details"', "Details"),
591
+ (r'\?\?\?\+ example "Examples"', "Examples"),
592
+ (r'\?\?\?\+ success "Credit"', "Credit"),
593
+ (r'\?\?\?\+ calculation "Equation"', "Equation"),
594
+ (r'\?\?\?\+ info "Notes"', "Notes"),
595
+ (r'\?\?\? question "References"', "References"),
596
+ (r'\?\?\? tip "See Also"', "See Also"),
597
+ ]
598
+
599
+ all_patterns: list[tuple[str, str]] = section_patterns + default_patterns
600
+
601
+ found_sections: list[tuple[int, str]] = []
602
+ for pattern, section_name in all_patterns:
603
+ match: Optional[re.Match[str]] = re.search(pattern, docstring, re.IGNORECASE)
604
+ if match:
605
+ found_sections.append((match.start(), section_name))
606
+
607
+ # Sort by position in docstring
608
+ found_sections.sort(key=lambda x: x[0])
609
+
610
+ # Build expected order
611
+ expected_order: list[str] = [s.name.title() for s in sorted(self.sections_config, key=lambda x: x.order)]
612
+ expected_order.extend(
613
+ [
614
+ "Summary",
615
+ "Details",
616
+ "Examples",
617
+ "Credit",
618
+ "Equation",
619
+ "Notes",
620
+ "References",
621
+ "See Also",
622
+ ]
623
+ )
624
+
625
+ # Check order matches expected order
626
+ errors: list[str] = []
627
+ last_expected_index = -1
628
+ for _, section_name in found_sections:
629
+ try:
630
+ current_index: int = expected_order.index(section_name)
631
+ if current_index < last_expected_index:
632
+ errors.append(f"Section '{section_name}' appears out of order")
633
+ last_expected_index: int = current_index
634
+ except ValueError:
635
+ # Section not in expected order list - might be OK
636
+ pass
637
+
638
+ return errors
639
+
640
+ def _check_yields_section(self, docstring: str) -> bool:
641
+ """
642
+ !!! note "Summary"
643
+ Check if the Yields section exists.
644
+
645
+ Params:
646
+ docstring (str):
647
+ The docstring to check.
648
+
649
+ Returns:
650
+ (bool):
651
+ `True` if the section exists, `False` otherwise.
652
+ """
653
+ return bool(re.search(r"Yields:", docstring))
654
+
655
+ def _check_simple_section(self, docstring: str, section_name: str) -> bool:
656
+ """
657
+ !!! note "Summary"
658
+ Check if a simple named section exists.
659
+
660
+ Params:
661
+ docstring (str):
662
+ The docstring to check.
663
+ section_name (str):
664
+ The name of the section to check for.
665
+
666
+ Returns:
667
+ (bool):
668
+ `True` if the section exists, `False` otherwise.
669
+ """
670
+ pattern = rf"{re.escape(section_name)}:"
671
+ return bool(re.search(pattern, docstring, re.IGNORECASE))
File without changes