docstring-format-checker 1.6.0__tar.gz → 1.6.2__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: 1.6.0
3
+ Version: 1.6.2
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 = "1.6.0"
3
+ version = "1.6.2"
4
4
  description = "A CLI tool to check and validate Python docstring formatting and completeness"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -543,8 +543,19 @@ def _format_error_output(error: DocstringError) -> list[str]:
543
543
  """
544
544
  lines: list[str] = [_create_error_header(error)]
545
545
  individual_errors: list[str] = _split_error_messages(error.message)
546
+
546
547
  for individual_error in individual_errors:
547
- lines.append(f" - {individual_error}")
548
+ # Check if this error has multi-line content (e.g., parameter type mismatches)
549
+ if "\n" in individual_error:
550
+ # Split by newlines and add 4 spaces of extra indentation to each line
551
+ error_lines = individual_error.split("\n")
552
+ lines.append(f" - {error_lines[0]}") # First line gets the bullet
553
+ for sub_line in error_lines[1:]:
554
+ if sub_line.strip(): # Only add non-empty lines
555
+ lines.append(f" {sub_line}") # Continuation lines get 4 spaces
556
+ else:
557
+ lines.append(f" - {individual_error}")
558
+
548
559
  return lines
549
560
 
550
561
 
@@ -559,10 +559,14 @@ class DocstringChecker:
559
559
 
560
560
  errors: list[str] = []
561
561
 
562
- # Validate required sections
562
+ # Validate required sections are present
563
563
  required_section_errors: list[str] = self._validate_all_required_sections(docstring, item)
564
564
  errors.extend(required_section_errors)
565
565
 
566
+ # Validate all existing sections (required or not)
567
+ existing_section_errors: list[str] = self._validate_all_existing_sections(docstring, item)
568
+ errors.extend(existing_section_errors)
569
+
566
570
  # Perform comprehensive validation checks
567
571
  comprehensive_errors: list[str] = self._perform_comprehensive_validation(docstring)
568
572
  errors.extend(comprehensive_errors)
@@ -578,10 +582,33 @@ class DocstringChecker:
578
582
  item_type=item.item_type,
579
583
  )
580
584
 
585
+ def _is_params_section_required(self, item: FunctionAndClassDetails) -> bool:
586
+ """
587
+ !!! note "Summary"
588
+ Check if params section is required for this item.
589
+
590
+ Params:
591
+ item (FunctionAndClassDetails):
592
+ The function or class details.
593
+
594
+ Returns:
595
+ (bool):
596
+ True if params section is required, False otherwise.
597
+ """
598
+
599
+ # For classes, params section not required (attributes handled differently)
600
+ if isinstance(item.node, ast.ClassDef):
601
+ return False
602
+
603
+ # For functions, only required if function has parameters (excluding self/cls)
604
+ # item.node is guaranteed to be FunctionDef or AsyncFunctionDef due to type constraints
605
+ params = [arg.arg for arg in item.node.args.args if arg.arg not in ("self", "cls")]
606
+ return len(params) > 0
607
+
581
608
  def _validate_all_required_sections(self, docstring: str, item: FunctionAndClassDetails) -> list[str]:
582
609
  """
583
610
  !!! note "Summary"
584
- Validate all required sections are present and valid.
611
+ Validate all required sections are present.
585
612
 
586
613
  Params:
587
614
  docstring (str):
@@ -591,115 +618,127 @@ class DocstringChecker:
591
618
 
592
619
  Returns:
593
620
  (list[str]):
594
- List of validation error messages.
621
+ List of validation error messages for missing required sections.
595
622
  """
596
623
 
597
624
  errors: list[str] = []
598
625
  for section in self.required_sections:
599
- section_error = self._validate_single_required_section(docstring, section, item)
600
- if section_error:
601
- errors.append(section_error)
626
+ # Special handling for params section - only required if function/class has parameters
627
+ if section.name.lower() == "params":
628
+ if not self._is_params_section_required(item):
629
+ continue
630
+
631
+ # Only check if the section exists, don't validate content yet
632
+ if not self._section_exists(docstring, section):
633
+ errors.append(f"Missing required section: {section.name}")
602
634
  return errors
603
635
 
604
- def _validate_single_required_section(
605
- self, docstring: str, section: SectionConfig, item: FunctionAndClassDetails
606
- ) -> Optional[str]:
636
+ def _validate_all_existing_sections(self, docstring: str, item: FunctionAndClassDetails) -> list[str]:
607
637
  """
608
638
  !!! note "Summary"
609
- Validate a single required section based on its type.
639
+ Validate content of all existing sections (required or not).
610
640
 
611
641
  Params:
612
642
  docstring (str):
613
643
  The docstring to validate.
614
- section (SectionConfig):
615
- The section configuration to validate against.
616
644
  item (FunctionAndClassDetails):
617
645
  The function or class details.
618
646
 
619
647
  Returns:
620
- (Optional[str]):
621
- Error message if validation fails, None otherwise.
648
+ (list[str]):
649
+ List of validation error messages for invalid section content.
622
650
  """
623
651
 
624
- if section.type == "free_text":
625
- return self._validate_free_text_section(docstring, section)
626
- elif section.type == "list_name_and_type":
627
- return self._validate_list_name_and_type_section(docstring, section, item)
628
- elif section.type == "list_type":
629
- return self._validate_list_type_section(docstring, section)
630
- elif section.type == "list_name":
631
- return self._validate_list_name_section(docstring, section)
632
- return None
652
+ errors: list[str] = []
653
+ for section in self.config.sections:
654
+ # Only validate if the section actually exists in the docstring
655
+ if self._section_exists(docstring, section):
656
+ section_error = self._validate_single_section_content(docstring, section, item)
657
+ if section_error:
658
+ errors.append(section_error)
659
+ return errors
633
660
 
634
- def _validate_free_text_section(self, docstring: str, section: SectionConfig) -> Optional[str]:
661
+ def _section_exists(self, docstring: str, section: SectionConfig) -> bool:
635
662
  """
636
663
  !!! note "Summary"
637
- Validate free text sections.
664
+ Check if a section exists in the docstring.
638
665
 
639
666
  Params:
640
667
  docstring (str):
641
- The docstring to validate.
668
+ The docstring to check.
642
669
  section (SectionConfig):
643
670
  The section configuration.
644
671
 
645
672
  Returns:
646
- (Optional[str]):
647
- Error message if section is missing, None otherwise.
673
+ (bool):
674
+ `True` if section exists, `False` otherwise.
648
675
  """
649
676
 
650
- if not self._check_free_text_section(docstring, section):
651
- return f"Missing required section: {section.name}"
652
- return None
677
+ section_name: str = section.name.lower()
653
678
 
654
- def _validate_list_name_and_type_section(
679
+ # For free text sections, use the existing logic from _check_free_text_section
680
+ if section.type == "free_text":
681
+ return self._check_free_text_section(docstring, section)
682
+
683
+ # Check for admonition style sections (for non-free-text types)
684
+ if section.admonition and isinstance(section.admonition, str):
685
+ if section.prefix and isinstance(section.prefix, str):
686
+ # e.g., "!!! note" or "???+ abstract"
687
+ pattern: str = rf"{re.escape(section.prefix)}\s+{re.escape(section.admonition)}"
688
+ if re.search(pattern, docstring, re.IGNORECASE):
689
+ return True
690
+
691
+ # Check for standard sections with colons (e.g., "Params:", "Returns:")
692
+ pattern = rf"^[ \t]*{re.escape(section_name)}:[ \t]*$"
693
+ if re.search(pattern, docstring, re.IGNORECASE | re.MULTILINE):
694
+ return True
695
+
696
+ return False
697
+
698
+ def _validate_single_section_content(
655
699
  self, docstring: str, section: SectionConfig, item: FunctionAndClassDetails
656
700
  ) -> Optional[str]:
657
701
  """
658
702
  !!! note "Summary"
659
- Validate list_name_and_type sections (params, returns).
703
+ Validate the content of a single section based on its type.
660
704
 
661
705
  Params:
662
706
  docstring (str):
663
707
  The docstring to validate.
664
708
  section (SectionConfig):
665
- The section configuration.
709
+ The section configuration to validate against.
666
710
  item (FunctionAndClassDetails):
667
711
  The function or class details.
668
712
 
669
713
  Returns:
670
714
  (Optional[str]):
671
- Error message if section is invalid, None otherwise.
715
+ Error message if validation fails, None otherwise.
672
716
  """
673
717
 
674
- section_name: str = section.name.lower()
675
-
676
- if section_name == "params" and isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
677
- # Check params section exists and is properly formatted
678
- if not self._check_params_section(docstring, item.node):
679
- return "Missing or invalid Params section"
680
-
681
- # If validate_param_types is enabled, validate type annotations match
682
- if self.config.global_config.validate_param_types:
683
- type_error: Optional[str] = self._validate_param_types(docstring, item.node)
684
- if type_error:
685
- return type_error
718
+ if section.type == "list_name_and_type":
719
+ return self._validate_list_name_and_type_section(docstring, section, item)
686
720
 
687
- elif section_name in ["returns", "return"]:
688
- if not self._check_returns_section(docstring):
689
- return "Missing or invalid Returns section"
721
+ if section.type == "list_name":
722
+ return self._validate_list_name_section(docstring, section)
690
723
 
724
+ # For `section.type in ("free_text", "list_type")`
725
+ # these sections do not need content validation beyond existence
691
726
  return None
692
727
 
693
- def _validate_list_type_section(self, docstring: str, section: SectionConfig) -> Optional[str]:
728
+ def _validate_list_name_and_type_section(
729
+ self, docstring: str, section: SectionConfig, item: FunctionAndClassDetails
730
+ ) -> Optional[str]:
694
731
  """
695
732
  !!! note "Summary"
696
- Validate list_type sections (raises, yields).
733
+ Validate list_name_and_type sections (params, returns).
697
734
 
698
735
  Params:
699
736
  docstring (str):
700
737
  The docstring to validate.
701
738
  section (SectionConfig):
702
739
  The section configuration.
740
+ item (FunctionAndClassDetails):
741
+ The function or class details.
703
742
 
704
743
  Returns:
705
744
  (Optional[str]):
@@ -708,12 +747,20 @@ class DocstringChecker:
708
747
 
709
748
  section_name: str = section.name.lower()
710
749
 
711
- if section_name in ["raises", "raise"]:
712
- if not self._check_raises_section(docstring):
713
- return "Missing or invalid Raises section"
714
- elif section_name in ["yields", "yield"]:
715
- if not self._check_yields_section(docstring):
716
- return "Missing or invalid Yields section"
750
+ if section_name == "params" and isinstance(item.node, (ast.FunctionDef, ast.AsyncFunctionDef)):
751
+ # Check params section exists and is properly formatted with detailed error reporting
752
+ is_valid, error_message = self._check_params_section_detailed(docstring, item.node)
753
+ if not is_valid:
754
+ return error_message
755
+
756
+ # If validate_param_types is enabled, validate type annotations match
757
+ if self.config.global_config.validate_param_types:
758
+ type_error: Optional[str] = self._validate_param_types(docstring, item.node)
759
+ if type_error:
760
+ return type_error
761
+
762
+ # For returns/return sections, no additional validation beyond existence
763
+ # The _section_exists check already verified the section is present
717
764
 
718
765
  return None
719
766
 
@@ -732,8 +779,8 @@ class DocstringChecker:
732
779
  (Optional[str]):
733
780
  Error message if section is missing, None otherwise.
734
781
  """
735
- if not self._check_simple_section(docstring, section.name):
736
- return f"Missing required section: {section.name}"
782
+ # No additional validation beyond existence
783
+ # The _section_exists check already verified the section is present
737
784
  return None
738
785
 
739
786
  def _perform_comprehensive_validation(self, docstring: str) -> list[str]:
@@ -879,6 +926,113 @@ class DocstringChecker:
879
926
 
880
927
  return True
881
928
 
929
+ def _extract_documented_params(self, docstring: str) -> list[str]:
930
+ """
931
+ !!! note "Summary"
932
+ Extract parameter names from the Params section of a docstring.
933
+
934
+ Params:
935
+ docstring (str):
936
+ The docstring to parse.
937
+
938
+ Returns:
939
+ (list[str]):
940
+ List of parameter names found in the Params section.
941
+ """
942
+ documented_params: list[str] = []
943
+ param_pattern: str = r"^\s*(\w+)\s*\([^)]+\):"
944
+ lines: list[str] = docstring.split("\n")
945
+ in_params_section: bool = False
946
+
947
+ for line in lines:
948
+ # Check if we've entered the Params section
949
+ if "Params:" in line:
950
+ in_params_section = True
951
+ continue
952
+
953
+ # Check if we've left the Params section (next section starts)
954
+ if in_params_section and re.match(r"^[ ]{0,4}[A-Z]\w+:", line):
955
+ break
956
+
957
+ # Extract parameter name
958
+ if in_params_section:
959
+ match = re.match(param_pattern, line)
960
+ if match:
961
+ documented_params.append(match.group(1))
962
+
963
+ return documented_params
964
+
965
+ def _build_param_mismatch_error(self, missing_in_docstring: list[str], extra_in_docstring: list[str]) -> str:
966
+ """
967
+ !!! note "Summary"
968
+ Build detailed error message for parameter mismatches.
969
+
970
+ Params:
971
+ missing_in_docstring (list[str]):
972
+ Parameters in signature but not in docstring.
973
+ extra_in_docstring (list[str]):
974
+ Parameters in docstring but not in signature.
975
+
976
+ Returns:
977
+ (str):
978
+ Formatted error message.
979
+ """
980
+ error_parts: list[str] = []
981
+
982
+ if missing_in_docstring:
983
+ missing_str: str = "', '".join(missing_in_docstring)
984
+ error_parts.append(f" - In signature but not in docstring: '{missing_str}'")
985
+
986
+ if extra_in_docstring:
987
+ extra_str: str = "', '".join(extra_in_docstring)
988
+ error_parts.append(f" - In docstring but not in signature: '{extra_str}'")
989
+
990
+ return "Parameter mismatch:\n" + "\n".join(error_parts)
991
+
992
+ def _check_params_section_detailed(
993
+ self, docstring: str, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
994
+ ) -> tuple[bool, Optional[str]]:
995
+ """
996
+ !!! note "Summary"
997
+ Check if the Params section exists and documents all parameters, with detailed error reporting.
998
+
999
+ Params:
1000
+ docstring (str):
1001
+ The docstring to check.
1002
+ node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
1003
+ The function node to check.
1004
+
1005
+ Returns:
1006
+ (tuple[bool, Optional[str]]):
1007
+ Tuple of (is_valid, error_message). If valid, error_message is None.
1008
+ """
1009
+
1010
+ # Get function parameters (excluding 'self' and 'cls' for methods)
1011
+ signature_params: list[str] = [arg.arg for arg in node.args.args if arg.arg not in ("self", "cls")]
1012
+
1013
+ if not signature_params:
1014
+ return (True, None) # No parameters to document
1015
+
1016
+ # Check if Params section exists
1017
+ if not re.search(r"Params:", docstring):
1018
+ return (False, "Params section not found in docstring")
1019
+
1020
+ # Extract documented parameters from docstring
1021
+ documented_params: list[str] = self._extract_documented_params(docstring)
1022
+
1023
+ # Find parameters in signature but not in docstring
1024
+ missing_in_docstring: list[str] = [p for p in signature_params if p not in documented_params]
1025
+
1026
+ # Find parameters in docstring but not in signature
1027
+ extra_in_docstring: list[str] = [p for p in documented_params if p not in signature_params]
1028
+
1029
+ # Build detailed error message if there are mismatches
1030
+ if missing_in_docstring or extra_in_docstring:
1031
+ error_message: str = self._build_param_mismatch_error(missing_in_docstring, extra_in_docstring)
1032
+ return (False, error_message)
1033
+
1034
+ return (True, None)
1035
+
882
1036
  def _extract_param_types(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> dict[str, str]:
883
1037
  """
884
1038
  !!! note "Summary"
@@ -943,7 +1097,8 @@ class DocstringChecker:
943
1097
  continue
944
1098
 
945
1099
  # Check if we've left the Params section (next section starts)
946
- if in_params_section and re.match(r"^\s*[A-Z]\w+:", line):
1100
+ # Section headers have minimal indentation (0-4 spaces), not deep indentation like param descriptions
1101
+ if in_params_section and re.match(r"^[ ]{0,4}[A-Z]\w+:", line):
947
1102
  break
948
1103
 
949
1104
  # Extract parameter name and type
@@ -1053,45 +1208,22 @@ class DocstringChecker:
1053
1208
  mismatches: list[tuple[str, str, str]] = self._compare_param_types(signature_types, docstring_types)
1054
1209
 
1055
1210
  if mismatches:
1056
- mismatch_details: list[str] = [
1057
- f"'{name}': signature has '{sig_type}', docstring has '{doc_type}'"
1058
- for name, sig_type, doc_type in mismatches
1059
- ]
1060
- return f"Parameter type mismatch: {'; '.join(mismatch_details)}"
1061
-
1062
- return None
1063
-
1064
- def _check_returns_section(self, docstring: str) -> bool:
1065
- """
1066
- !!! note "Summary"
1067
- Check if the Returns section exists.
1068
-
1069
- Params:
1070
- docstring (str):
1071
- The docstring to check.
1072
-
1073
- Returns:
1074
- (bool):
1075
- `True` if the section exists, `False` otherwise.
1076
- """
1077
-
1078
- return bool(re.search(r"Returns:", docstring))
1079
-
1080
- def _check_raises_section(self, docstring: str) -> bool:
1081
- """
1082
- !!! note "Summary"
1083
- Check if the Raises section exists.
1211
+ # Format each mismatch with parameter name on one line, signature and docstring indented below
1212
+ # Use 2 spaces for params, 4 for sig/doc (suitable for table output)
1213
+ # List output will add additional indentation via CLI formatting
1214
+ mismatch_blocks: list[str] = []
1215
+ for name, sig_type, doc_type in mismatches:
1216
+ sig_type: str = sig_type.replace("'", '"')
1217
+ doc_type: str = doc_type.replace("'", '"')
1218
+ param_block: str = f"""'{name}':\n - signature: '{sig_type}'\n - docstring: '{doc_type}' """
1219
+ mismatch_blocks.append(param_block)
1084
1220
 
1085
- Params:
1086
- docstring (str):
1087
- The docstring to check.
1221
+ # Join all parameter blocks with proper indentation
1222
+ formatted_details: str = "\n - ".join([""] + mismatch_blocks)
1088
1223
 
1089
- Returns:
1090
- (bool):
1091
- `True` if the section exists, `False` otherwise.
1092
- """
1224
+ return f"Parameter type mismatch:{formatted_details}"
1093
1225
 
1094
- return bool(re.search(r"Raises:", docstring))
1226
+ return None
1095
1227
 
1096
1228
  def _has_both_returns_and_yields(self, docstring: str) -> bool:
1097
1229
  """
@@ -1237,41 +1369,6 @@ class DocstringChecker:
1237
1369
 
1238
1370
  return errors
1239
1371
 
1240
- def _check_yields_section(self, docstring: str) -> bool:
1241
- """
1242
- !!! note "Summary"
1243
- Check if the Yields section exists.
1244
-
1245
- Params:
1246
- docstring (str):
1247
- The docstring to check.
1248
-
1249
- Returns:
1250
- (bool):
1251
- `True` if the section exists, `False` otherwise.
1252
- """
1253
-
1254
- return bool(re.search(r"Yields:", docstring))
1255
-
1256
- def _check_simple_section(self, docstring: str, section_name: str) -> bool:
1257
- """
1258
- !!! note "Summary"
1259
- Check if a simple named section exists.
1260
-
1261
- Params:
1262
- docstring (str):
1263
- The docstring to check.
1264
- section_name (str):
1265
- The name of the section to check for.
1266
-
1267
- Returns:
1268
- (bool):
1269
- `True` if the section exists, `False` otherwise.
1270
- """
1271
-
1272
- pattern: str = rf"{re.escape(section_name)}:"
1273
- return bool(re.search(pattern, docstring, re.IGNORECASE))
1274
-
1275
1372
  def _normalize_section_name(self, section_name: str) -> str:
1276
1373
  """
1277
1374
  !!! note "Summary"