docstring-format-checker 0.2.0__tar.gz → 0.4.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docstring-format-checker
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A CLI tool to check and validate Python docstring formatting and completeness
5
5
  Author: Chris Mahoney
6
6
  Author-email: Chris Mahoney <docstring-format-checker@data-science-extensions.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docstring-format-checker"
3
- version = "v0.2.0"
3
+ version = "v0.4.0"
4
4
  description = "A CLI tool to check and validate Python docstring formatting and completeness"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -4,7 +4,7 @@ Docstring Format Checker.
4
4
  A CLI tool to check and validate Python docstring formatting and completeness.
5
5
  """
6
6
 
7
- __version__ = "v0.2.0"
7
+ __version__ = "v0.4.0"
8
8
  __author__ = "Chris Mahoney"
9
9
  __email__ = "docstring-format-checker@data-science-extensions.com"
10
10
 
@@ -302,6 +302,29 @@ def _show_check_examples_callback(ctx: Context, param: CallbackParam, value: boo
302
302
  raise Exit()
303
303
 
304
304
 
305
+ def _format_error_messages(error_message: str) -> str:
306
+ """
307
+ !!! note "Summary"
308
+ Format error messages for better readability in CLI output.
309
+
310
+ Params:
311
+ error_message (str):
312
+ The raw error message that may contain semicolon-separated errors
313
+
314
+ Returns:
315
+ (str):
316
+ Formatted error message with each error prefixed with "- " and separated by ";\n"
317
+ """
318
+ if "; " in error_message:
319
+ # Split by semicolon and rejoin with proper formatting
320
+ errors: list[str] = error_message.split("; ")
321
+ formatted_errors: list[str] = [f"- {error.strip()}" for error in errors if error.strip()]
322
+ return ";\n".join(formatted_errors) + "."
323
+ else:
324
+ # Single error message
325
+ return f"- {error_message.strip()}."
326
+
327
+
305
328
  def _display_results(results: dict[str, list[DocstringError]], quiet: bool, verbose: bool) -> int:
306
329
  """
307
330
  !!! note "Summary"
@@ -340,12 +363,16 @@ def _display_results(results: dict[str, list[DocstringError]], quiet: bool, verb
340
363
  for file_path, errors in results.items():
341
364
  for i, error in enumerate(errors):
342
365
  file_display: str = file_path if i == 0 else ""
366
+
367
+ # Format error message with improved formatting
368
+ formatted_error_message: str = _format_error_messages(error.message)
369
+
343
370
  table.add_row(
344
371
  file_display,
345
372
  str(error.line_number) if error.line_number > 0 else "",
346
373
  error.item_name,
347
374
  error.item_type,
348
- error.message,
375
+ formatted_error_message,
349
376
  )
350
377
  console.print(table)
351
378
 
@@ -354,12 +381,15 @@ def _display_results(results: dict[str, list[DocstringError]], quiet: bool, verb
354
381
  for file_path, errors in results.items():
355
382
  console.print(f"{NEW_LINE}[cyan]{file_path}[/cyan]")
356
383
  for error in errors:
384
+ # Format error message with improved formatting
385
+ formatted_error_message: str = _format_error_messages(error.message)
386
+
357
387
  if error.line_number > 0:
358
388
  console.print(
359
- f" [red]Line {error.line_number}[/red] - {error.item_type} '{error.item_name}': {error.message}"
389
+ f" [red]Line {error.line_number}[/red] - {error.item_type} '{error.item_name}': {formatted_error_message}"
360
390
  )
361
391
  else:
362
- console.print(f" [red]Error[/red]: {error.message}")
392
+ console.print(f" [red]Error[/red]: {formatted_error_message}")
363
393
 
364
394
  # Summary
365
395
  console.print(f"{NEW_LINE}[red]Found {total_errors} error(s) in {total_files} file(s)[/red]")
@@ -111,16 +111,43 @@ class SectionConfig:
111
111
  order: int
112
112
  name: str
113
113
  type: Literal["free_text", "list_name", "list_type", "list_name_and_type"]
114
- admonition: str = ""
114
+ admonition: Union[bool, str] = False
115
115
  prefix: str = "" # Support any prefix string
116
116
  required: bool = False
117
117
  message: str = "" # Optional message for validation errors
118
118
 
119
119
  def __post_init__(self) -> None:
120
120
  """Validate configuration after initialization."""
121
+ self._validate_types()
122
+ self._validate_admonition_prefix_combination()
123
+
124
+ def _validate_types(self) -> None:
125
+ """Validate the 'type' field."""
121
126
  if self.type not in VALID_TYPES:
122
127
  raise InvalidTypeValuesError(f"Invalid section type: {self.type}. Valid types: {VALID_TYPES}")
123
128
 
129
+ def _validate_admonition_prefix_combination(self) -> None:
130
+ """Validate admonition and prefix combination rules."""
131
+
132
+ if isinstance(self.admonition, bool):
133
+ # Rule: admonition cannot be True (only False or string)
134
+ if self.admonition is True:
135
+ raise ValueError(f"Section '{self.name}': admonition cannot be True, must be False or a string")
136
+
137
+ # Rule: if admonition is False, prefix cannot be provided
138
+ if self.admonition is False and self.prefix:
139
+ raise ValueError(f"Section '{self.name}': when admonition=False, prefix cannot be provided")
140
+
141
+ elif isinstance(self.admonition, str):
142
+ # Rule: if admonition is a string, prefix must be provided
143
+ if not self.prefix:
144
+ raise ValueError(f"Section '{self.name}': when admonition is a string, prefix must be provided")
145
+
146
+ else:
147
+ raise ValueError(
148
+ f"Section '{self.name}': admonition must be a boolean or string, got {type(self.admonition)}"
149
+ )
150
+
124
151
 
125
152
  ## --------------------------------------------------------------------------- #
126
153
  ## Validations ####
@@ -271,11 +298,16 @@ def load_config(config_path: Optional[Union[str, Path]] = None) -> list[SectionC
271
298
  sections_data = tool_config["sections"]
272
299
  for section_data in sections_data:
273
300
  try:
301
+ # Get admonition value with proper default handling
302
+ admonition_value: Union[str, bool] = section_data.get("admonition")
303
+ if admonition_value is None:
304
+ admonition_value = False # Use SectionConfig default
305
+
274
306
  section = SectionConfig(
275
307
  order=section_data.get("order", 0),
276
308
  name=section_data.get("name", ""),
277
309
  type=section_data.get("type", ""),
278
- admonition=section_data.get("admonition", ""),
310
+ admonition=admonition_value,
279
311
  prefix=section_data.get("prefix", ""),
280
312
  required=section_data.get("required", False),
281
313
  )
@@ -47,7 +47,7 @@ import ast
47
47
  import fnmatch
48
48
  import re
49
49
  from pathlib import Path
50
- from typing import Literal, NamedTuple, Optional, Union
50
+ from typing import Iterator, Literal, NamedTuple, Optional, Union
51
51
 
52
52
  # ## Local First Party Imports ----
53
53
  from docstring_format_checker.config import SectionConfig
@@ -255,6 +255,7 @@ class DocstringChecker:
255
255
  (bool):
256
256
  True if the function has @overload decorator, False otherwise.
257
257
  """
258
+
258
259
  for decorator in node.decorator_list:
259
260
  # Handle direct name reference: @overload
260
261
  if isinstance(decorator, ast.Name) and decorator.id == "overload":
@@ -422,6 +423,7 @@ class DocstringChecker:
422
423
  (None):
423
424
  Nothing is returned.
424
425
  """
426
+
425
427
  errors: list[str] = []
426
428
 
427
429
  # Check each required section
@@ -459,6 +461,26 @@ class DocstringChecker:
459
461
  if self._has_both_returns_and_yields(docstring):
460
462
  errors.append("Docstring cannot have both Returns and Yields sections")
461
463
 
464
+ # Check for undefined sections in docstring
465
+ undefined_errors: list[str] = self._check_undefined_sections(docstring)
466
+ errors.extend(undefined_errors)
467
+
468
+ # Check admonition values match configuration
469
+ admonition_errors: list[str] = self._check_admonition_values(docstring)
470
+ errors.extend(admonition_errors)
471
+
472
+ # Check colon usage for admonition vs non-admonition sections
473
+ colon_errors: list[str] = self._check_colon_usage(docstring)
474
+ errors.extend(colon_errors)
475
+
476
+ # Check title case for non-admonition sections
477
+ title_case_errors: list[str] = self._check_title_case_sections(docstring)
478
+ errors.extend(title_case_errors)
479
+
480
+ # Check parentheses for list type sections
481
+ parentheses_errors: list[str] = self._check_parentheses_validation(docstring)
482
+ errors.extend(parentheses_errors)
483
+
462
484
  if errors:
463
485
  combined_message: str = "; ".join(errors)
464
486
  raise DocstringError(
@@ -484,9 +506,12 @@ class DocstringChecker:
484
506
  (bool):
485
507
  `True` if the section exists, `False` otherwise.
486
508
  """
487
- if section.admonition and section.prefix:
509
+
510
+ if isinstance(section.admonition, str) and section.admonition and section.prefix:
488
511
  # Format like: !!! note "Summary"
489
- pattern = rf'{re.escape(section.prefix)}\s+{re.escape(section.admonition)}\s+".*{re.escape(section.name)}"'
512
+ # Make the section name part case-insensitive too
513
+ escaped_name = re.escape(section.name)
514
+ pattern = rf'{re.escape(section.prefix)}\s+{re.escape(section.admonition)}\s+"[^"]*{escaped_name}[^"]*"'
490
515
  return bool(re.search(pattern, docstring, re.IGNORECASE))
491
516
  elif section.name.lower() in ["summary"]:
492
517
  # For summary, accept either formal format or simple docstring
@@ -516,6 +541,7 @@ class DocstringChecker:
516
541
  (bool):
517
542
  `True` if the section exists and is valid, `False` otherwise.
518
543
  """
544
+
519
545
  # Get function parameters (excluding 'self' for methods)
520
546
  params: list[str] = [arg.arg for arg in node.args.args if arg.arg != "self"]
521
547
 
@@ -547,6 +573,7 @@ class DocstringChecker:
547
573
  (bool):
548
574
  `True` if the section exists, `False` otherwise.
549
575
  """
576
+
550
577
  return bool(re.search(r"Returns:", docstring))
551
578
 
552
579
  def _check_raises_section(self, docstring: str) -> bool:
@@ -562,6 +589,7 @@ class DocstringChecker:
562
589
  (bool):
563
590
  `True` if the section exists, `False` otherwise.
564
591
  """
592
+
565
593
  return bool(re.search(r"Raises:", docstring))
566
594
 
567
595
  def _has_both_returns_and_yields(self, docstring: str) -> bool:
@@ -577,6 +605,7 @@ class DocstringChecker:
577
605
  (bool):
578
606
  `True` if the section exists, `False` otherwise.
579
607
  """
608
+
580
609
  has_returns = bool(re.search(r"Returns:", docstring))
581
610
  has_yields = bool(re.search(r"Yields:", docstring))
582
611
  return has_returns and has_yields
@@ -594,10 +623,16 @@ class DocstringChecker:
594
623
  (list[str]):
595
624
  A list of error messages, if any.
596
625
  """
626
+
597
627
  # Build expected order from configuration
598
628
  section_patterns: list[tuple[str, str]] = []
599
629
  for section in sorted(self.sections_config, key=lambda x: x.order):
600
- if section.type == "free_text" and section.admonition and section.prefix:
630
+ if (
631
+ section.type == "free_text"
632
+ and isinstance(section.admonition, str)
633
+ and section.admonition
634
+ and section.prefix
635
+ ):
601
636
  pattern: str = (
602
637
  rf'{re.escape(section.prefix)}\s+{re.escape(section.admonition)}\s+".*{re.escape(section.name)}"'
603
638
  )
@@ -677,6 +712,7 @@ class DocstringChecker:
677
712
  (bool):
678
713
  `True` if the section exists, `False` otherwise.
679
714
  """
715
+
680
716
  return bool(re.search(r"Yields:", docstring))
681
717
 
682
718
  def _check_simple_section(self, docstring: str, section_name: str) -> bool:
@@ -694,5 +730,263 @@ class DocstringChecker:
694
730
  (bool):
695
731
  `True` if the section exists, `False` otherwise.
696
732
  """
697
- pattern = rf"{re.escape(section_name)}:"
733
+
734
+ pattern: str = rf"{re.escape(section_name)}:"
698
735
  return bool(re.search(pattern, docstring, re.IGNORECASE))
736
+
737
+ def _check_undefined_sections(self, docstring: str) -> list[str]:
738
+ """
739
+ !!! note "Summary"
740
+ Check for sections in docstring that are not defined in configuration.
741
+
742
+ Params:
743
+ docstring (str):
744
+ The docstring to check.
745
+
746
+ Returns:
747
+ (list[str]):
748
+ A list of error messages for undefined sections.
749
+ """
750
+
751
+ errors: list[str] = []
752
+
753
+ # Get all configured section names (case-insensitive)
754
+ configured_sections: set[str] = {section.name.lower() for section in self.sections_config}
755
+
756
+ # Common patterns for different section types
757
+ section_patterns: list[tuple[str, str]] = [
758
+ # Standard sections with colons (but not inside quotes)
759
+ (r"^(\w+):\s*", "colon"),
760
+ # Admonition sections with various prefixes
761
+ (r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\"", "admonition"),
762
+ ]
763
+
764
+ found_sections: set[str] = set()
765
+
766
+ for pattern, pattern_type in section_patterns:
767
+ matches: Iterator[re.Match[str]] = re.finditer(pattern, docstring, re.IGNORECASE | re.MULTILINE)
768
+ for match in matches:
769
+ section_name: str = match.group(1).lower().strip()
770
+
771
+ # Remove colon if present (for colon pattern matches)
772
+ section_name = section_name.rstrip(":")
773
+
774
+ # Skip empty matches or common docstring content
775
+ if not section_name or section_name in ["", "py", "python", "sh", "shell"]:
776
+ continue
777
+
778
+ # Skip code blocks and inline code
779
+ if any(char in section_name for char in ["`", ".", "/", "\\"]):
780
+ continue
781
+
782
+ found_sections.add(section_name)
783
+
784
+ # Check which found sections are not configured
785
+ for section_name in found_sections:
786
+ if section_name not in configured_sections:
787
+ errors.append(f"Section '{section_name}' found in docstring but not defined in configuration")
788
+
789
+ return errors
790
+
791
+ def _check_admonition_values(self, docstring: str) -> list[str]:
792
+ """
793
+ !!! note "Summary"
794
+ Check that admonition values in docstring match configuration.
795
+
796
+ Params:
797
+ docstring (str):
798
+ The docstring to check.
799
+
800
+ Returns:
801
+ (list[str]):
802
+ A list of error messages for mismatched admonitions.
803
+ """
804
+
805
+ errors: list[str] = []
806
+
807
+ # Create mapping of section names to expected admonitions
808
+ section_admonitions: dict[str, str] = {}
809
+ for section in self.sections_config:
810
+ if section.type == "free_text" and isinstance(section.admonition, str) and section.admonition:
811
+ section_admonitions[section.name.lower()] = section.admonition.lower()
812
+
813
+ # Pattern to find all admonition sections
814
+ admonition_pattern = r"(?:\?\?\?[+]?|!!!)\s+(\w+)\s+\"([^\"]+)\""
815
+ matches: Iterator[re.Match[str]] = re.finditer(admonition_pattern, docstring, re.IGNORECASE)
816
+
817
+ for match in matches:
818
+ actual_admonition: str = match.group(1).lower()
819
+ section_title: str = match.group(2).lower()
820
+
821
+ # Check if this section is configured with a specific admonition
822
+ if section_title in section_admonitions:
823
+ expected_admonition: str = section_admonitions[section_title]
824
+ if actual_admonition != expected_admonition:
825
+ errors.append(
826
+ f"Section '{section_title}' has incorrect admonition '{actual_admonition}', "
827
+ f"expected '{expected_admonition}'"
828
+ )
829
+
830
+ # Check if section shouldn't have admonition but does
831
+ section_config: Optional[SectionConfig] = next(
832
+ (s for s in self.sections_config if s.name.lower() == section_title), None
833
+ )
834
+ if section_config and section_config.admonition is False:
835
+ errors.append(f"Section '{section_title}' is configured as non-admonition but found as admonition")
836
+
837
+ return errors
838
+
839
+ def _check_colon_usage(self, docstring: str) -> list[str]:
840
+ """
841
+ Check that colons are used correctly for admonition vs non-admonition sections.
842
+ """
843
+
844
+ errors: list[str] = []
845
+
846
+ # Check admonition sections (should not end with colon)
847
+ admonition_pattern = r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\""
848
+ matches: Iterator[re.Match[str]] = re.finditer(admonition_pattern, docstring, re.IGNORECASE)
849
+
850
+ for match in matches:
851
+ section_title: str = match.group(1)
852
+ has_colon: bool = section_title.endswith(":")
853
+ section_title_clean: str = section_title.rstrip(":").lower()
854
+
855
+ # Find config for this section
856
+ section_config: Optional[SectionConfig] = next(
857
+ (s for s in self.sections_config if s.name.lower() == section_title_clean), None
858
+ )
859
+ if section_config and isinstance(section_config.admonition, str) and section_config.admonition:
860
+ if has_colon:
861
+ errors.append(
862
+ f"Section '{section_title_clean}' is an admonition, therefore it should not end with ':', "
863
+ f"see: '{match.group(0)}'"
864
+ )
865
+
866
+ # Check non-admonition sections (should end with colon)
867
+ non_admonition_pattern = r"^(\w+)(:?)$"
868
+ for line in docstring.split("\n"):
869
+ line: str = line.strip()
870
+ match: Optional[re.Match[str]] = re.match(non_admonition_pattern, line)
871
+ if match:
872
+ section_name: str = match.group(1).lower()
873
+ has_colon: bool = match.group(2) == ":"
874
+
875
+ # Find config for this section
876
+ section_config = next((s for s in self.sections_config if s.name.lower() == section_name), None)
877
+ if section_config and section_config.admonition is False:
878
+ if not has_colon:
879
+ errors.append(
880
+ f"Section '{section_name}' is non-admonition, therefore it must end with ':', "
881
+ f"see: '{line}'"
882
+ )
883
+
884
+ return errors
885
+
886
+ def _check_title_case_sections(self, docstring: str) -> list[str]:
887
+ """
888
+ Check that non-admonition sections are single word, title case, and match config name.
889
+ """
890
+
891
+ errors: list[str] = []
892
+
893
+ # Pattern to find section headers (single word followed by optional colon)
894
+ section_pattern = r"^(\w+):?$"
895
+
896
+ for line in docstring.split("\n"):
897
+ line: str = line.strip()
898
+ match: Optional[re.Match[str]] = re.match(section_pattern, line)
899
+ if match:
900
+ section_word: str = match.group(1)
901
+ section_name_lower: str = section_word.lower()
902
+
903
+ # Check if this is a configured non-admonition section
904
+ section_config: Optional[SectionConfig] = next(
905
+ (s for s in self.sections_config if s.name.lower() == section_name_lower), None
906
+ )
907
+ if section_config and section_config.admonition is False:
908
+ # Check if it's title case
909
+ expected_title_case: str = section_config.name.title()
910
+ if section_word != expected_title_case:
911
+ errors.append(
912
+ f"Section '{section_name_lower}' must be in title case as '{expected_title_case}', "
913
+ f"found: '{section_word}'"
914
+ )
915
+
916
+ return errors
917
+
918
+ def _check_parentheses_validation(self, docstring: str) -> list[str]:
919
+ """
920
+ Check that list_type and list_name_and_type sections have proper parentheses.
921
+ """
922
+
923
+ errors: list[str] = []
924
+
925
+ # Get sections that require parentheses
926
+ parentheses_sections: list[SectionConfig] = [
927
+ s for s in self.sections_config if s.type in ["list_type", "list_name_and_type"]
928
+ ]
929
+
930
+ if not parentheses_sections:
931
+ return errors
932
+
933
+ # Check each line in the docstring
934
+ lines: list[str] = docstring.split("\n")
935
+ current_section = None
936
+
937
+ for i, line in enumerate(lines):
938
+ stripped_line: str = line.strip()
939
+
940
+ # Detect section headers
941
+ # Admonition sections
942
+ admonition_match: Optional[re.Match[str]] = re.match(
943
+ r"(?:\?\?\?[+]?|!!!)\s+\w+\s+\"([^\"]+)\"", stripped_line, re.IGNORECASE
944
+ )
945
+ if admonition_match:
946
+ section_name: str = admonition_match.group(1).lower()
947
+ current_section: Optional[SectionConfig] = next(
948
+ (s for s in parentheses_sections if s.name.lower() == section_name), None
949
+ )
950
+ continue
951
+
952
+ # Non-admonition sections - only match actual section headers, not indented content
953
+ # Section headers should be at the start of the line (no leading whitespace)
954
+ if not line.startswith((" ", "\t")): # Not indented
955
+ simple_section_match: Optional[re.Match[str]] = re.match(r"^(\w+):?$", stripped_line)
956
+ if simple_section_match:
957
+ section_name: str = simple_section_match.group(1).lower()
958
+ # Only consider it a section if it matches our known sections
959
+ potential_section: Optional[SectionConfig] = next(
960
+ (s for s in self.sections_config if s.name.lower() == section_name), None
961
+ )
962
+ if potential_section:
963
+ # This is a real section header
964
+ current_section = next(
965
+ (s for s in parentheses_sections if s.name.lower() == section_name), None
966
+ )
967
+ continue
968
+ # If it doesn't match a known section, fall through to content processing
969
+
970
+ # Check content lines if we're in a parentheses-required section
971
+ if current_section and stripped_line and not stripped_line.startswith(("!", "?", "#")):
972
+ # Look for parameter/type definitions
973
+ if ":" in stripped_line:
974
+ # For list_name_and_type sections, check format like "name (type):" or "(type):"
975
+ if current_section.type == "list_name_and_type":
976
+ # Pattern: name (type): or (type):
977
+ if not re.search(r"\([^)]+\):", stripped_line):
978
+ errors.append(
979
+ f"Section '{current_section.name}' (type: '{current_section.type}') requires "
980
+ f"parenthesized types, see: '{stripped_line}'"
981
+ )
982
+
983
+ # For list_type sections, check format like "(Type):"
984
+ elif current_section.type == "list_type":
985
+ # Pattern: (Type):
986
+ if not re.search(r"^\s*\([^)]+\):", stripped_line):
987
+ errors.append(
988
+ f"Section '{current_section.name}' (type: '{current_section.type}') requires "
989
+ f"parenthesized types, see: '{stripped_line}'"
990
+ )
991
+
992
+ return errors