docstring-format-checker 1.6.2__tar.gz → 1.7.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: 1.6.2
3
+ Version: 1.7.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 = "1.6.2"
3
+ version = "1.7.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"
@@ -75,7 +75,8 @@ docs = [
75
75
  "pygithub==2.*",
76
76
  ]
77
77
  test = [
78
- "mypy==1.*,!=1.17.*",
78
+ "ty==0.*",
79
+ # "mypy==1.*,!=1.17.*",
79
80
  "parameterized==0.*",
80
81
  "pytest==8.*",
81
82
  "pytest-clarity==1.*",
@@ -154,6 +155,7 @@ disable = [
154
155
  "C0103", # invalid-name
155
156
  "C0301", # line-too-long
156
157
  "C0302", # too-many-lines
158
+ "R0912", # too-many-branches
157
159
  "R0913", # too-many-arguments
158
160
  "R0914", # too-many-locals
159
161
  "R0915", # too-many-statements
@@ -188,6 +190,7 @@ allow_undefined_sections = false
188
190
  require_docstrings = true
189
191
  check_private = true
190
192
  validate_param_types = true
193
+ optional_style = "silent" #<-- optional: "silent", "validate", or "strict"
191
194
  sections = [
192
195
  { order=1, name="summary", type="free_text", required=true, admonition="note", prefix="!!!" },
193
196
  { order=2, name="details", type="free_text", required=false, admonition="abstract", prefix="???+" },
@@ -52,6 +52,7 @@ from typing import Optional
52
52
  # ## Python Third Party Imports ----
53
53
  import pyfiglet
54
54
  from rich.console import Console
55
+ from rich.markup import escape
55
56
  from rich.panel import Panel
56
57
  from rich.table import Table
57
58
  from typer import Argument, CallbackParam, Context, Exit, Option, Typer, echo
@@ -270,6 +271,7 @@ def _show_config_example_callback() -> None:
270
271
  [blue]require_docstrings = true[/blue]
271
272
  [blue]check_private = true[/blue]
272
273
  [blue]validate_param_types = true[/blue]
274
+ [blue]optional_style = "validate"[/blue] [green]# "silent", "validate", or "strict"[/green]
273
275
  [blue]sections = [[/blue]
274
276
  [blue]{ order = 1, name = "summary", type = "free_text", required = true, admonition = "note", prefix = "!!!" },[/blue]
275
277
  [blue]{ order = 2, name = "details", type = "free_text", required = false, admonition = "abstract", prefix = "???+" },[/blue]
@@ -485,7 +487,7 @@ def _display_table_output(results: dict[str, list[DocstringError]]) -> None:
485
487
  str(error.line_number) if error.line_number > 0 else "",
486
488
  error.item_name,
487
489
  error.item_type,
488
- formatted_error_message,
490
+ f"[red]{formatted_error_message}[/red]",
489
491
  )
490
492
  console.print(table)
491
493
 
@@ -545,10 +547,13 @@ def _format_error_output(error: DocstringError) -> list[str]:
545
547
  individual_errors: list[str] = _split_error_messages(error.message)
546
548
 
547
549
  for individual_error in individual_errors:
550
+ # Escape square brackets for Rich markup using Rich's escape function
551
+ individual_error: str = escape(individual_error)
552
+
548
553
  # Check if this error has multi-line content (e.g., parameter type mismatches)
549
554
  if "\n" in individual_error:
550
555
  # Split by newlines and add 4 spaces of extra indentation to each line
551
- error_lines = individual_error.split("\n")
556
+ error_lines: list[str] = individual_error.split("\n")
552
557
  lines.append(f" - {error_lines[0]}") # First line gets the bullet
553
558
  for sub_line in error_lines[1:]:
554
559
  if sub_line.strip(): # Only add non-empty lines
@@ -115,6 +115,7 @@ class GlobalConfig:
115
115
  require_docstrings: bool = True
116
116
  check_private: bool = False
117
117
  validate_param_types: bool = True
118
+ optional_style: Literal["silent", "validate", "strict"] = "validate"
118
119
 
119
120
 
120
121
  ## --------------------------------------------------------------------------- #
@@ -447,11 +448,20 @@ def _parse_global_config(tool_config: dict[str, Any]) -> GlobalConfig:
447
448
  (GlobalConfig):
448
449
  Parsed global configuration object.
449
450
  """
451
+ # Validate optional_style if provided
452
+ optional_style: str = tool_config.get("optional_style", "validate")
453
+ valid_styles: tuple[str, str, str] = ("silent", "validate", "strict")
454
+ if optional_style not in valid_styles:
455
+ raise InvalidConfigError(
456
+ f"Invalid optional_style: '{optional_style}'. Must be one of: {', '.join(valid_styles)}"
457
+ )
458
+
450
459
  return GlobalConfig(
451
460
  allow_undefined_sections=tool_config.get("allow_undefined_sections", False),
452
461
  require_docstrings=tool_config.get("require_docstrings", True),
453
462
  check_private=tool_config.get("check_private", False),
454
463
  validate_param_types=tool_config.get("validate_param_types", True),
464
+ optional_style=optional_style, # type:ignore
455
465
  )
456
466
 
457
467
 
@@ -1124,9 +1124,14 @@ class DocstringChecker:
1124
1124
  (str):
1125
1125
  Normalized type string.
1126
1126
  """
1127
+
1127
1128
  # Remove whitespace
1128
1129
  normalized: str = re.sub(r"\s+", "", type_str)
1129
1130
 
1131
+ # Normalize quotes: ast.unparse() uses single quotes but docstrings typically use double quotes
1132
+ # Convert all quotes to single quotes for consistent comparison
1133
+ normalized = normalized.replace('"', "'")
1134
+
1130
1135
  # Make case-insensitive for basic types
1131
1136
  # But preserve case for complex types to avoid breaking things like Optional
1132
1137
  return normalized
@@ -1168,6 +1173,123 @@ class DocstringChecker:
1168
1173
 
1169
1174
  return mismatches
1170
1175
 
1176
+ def _get_params_with_defaults(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]) -> set[str]:
1177
+ """
1178
+ !!! note "Summary"
1179
+ Get set of parameter names that have default values.
1180
+
1181
+ Params:
1182
+ node (Union[ast.FunctionDef, ast.AsyncFunctionDef]):
1183
+ The function node to analyse.
1184
+
1185
+ Returns:
1186
+ (set[str]):
1187
+ Set of parameter names that have default values.
1188
+ """
1189
+ params_with_defaults: set[str] = set()
1190
+ args = node.args
1191
+
1192
+ # Regular args with defaults
1193
+ num_defaults = len(args.defaults)
1194
+ if num_defaults > 0:
1195
+ # Defaults apply to the last n arguments
1196
+ num_args = len(args.args)
1197
+ for i in range(num_args - num_defaults, num_args):
1198
+ if args.args[i].arg not in ("self", "cls"):
1199
+ params_with_defaults.add(args.args[i].arg)
1200
+
1201
+ # Keyword-only args with defaults
1202
+ for i, arg in enumerate(args.kwonlyargs):
1203
+ if args.kw_defaults[i] is not None:
1204
+ params_with_defaults.add(arg.arg)
1205
+
1206
+ return params_with_defaults
1207
+
1208
+ def _process_optional_suffix(
1209
+ self,
1210
+ param_name: str,
1211
+ doc_type: str,
1212
+ params_with_defaults: set[str],
1213
+ optional_style: str,
1214
+ ) -> tuple[str, Optional[str]]:
1215
+ """
1216
+ !!! note "Summary"
1217
+ Process the ', optional' suffix based on the optional_style mode.
1218
+
1219
+ Params:
1220
+ param_name (str):
1221
+ Name of the parameter.
1222
+ doc_type (str):
1223
+ Docstring type including potential ', optional' suffix.
1224
+ params_with_defaults (set[str]):
1225
+ Set of parameters that have default values.
1226
+ optional_style (str):
1227
+ The validation mode: 'silent', 'validate', or 'strict'.
1228
+
1229
+ Returns:
1230
+ (tuple[str, Optional[str]]):
1231
+ Tuple of (cleaned_type, error_message).
1232
+ """
1233
+ has_optional_suffix: bool = bool(re.search(r",\s*optional$", doc_type, flags=re.IGNORECASE))
1234
+ clean_type: str = re.sub(r",\s*optional$", "", doc_type, flags=re.IGNORECASE).strip()
1235
+ error_message: Optional[str] = None
1236
+
1237
+ if optional_style == "validate":
1238
+ if has_optional_suffix and param_name not in params_with_defaults:
1239
+ error_message = f"Parameter '{param_name}' has ', optional' suffix but no default value in signature"
1240
+ elif optional_style == "strict":
1241
+ if param_name in params_with_defaults and not has_optional_suffix:
1242
+ error_message = (
1243
+ f"Parameter '{param_name}' has default value but missing ', optional' suffix in docstring"
1244
+ )
1245
+ elif has_optional_suffix and param_name not in params_with_defaults:
1246
+ error_message = f"Parameter '{param_name}' has ', optional' suffix but no default value in signature"
1247
+
1248
+ return clean_type, error_message
1249
+
1250
+ def _format_optional_errors(self, errors: list[str]) -> str:
1251
+ """
1252
+ !!! note "Summary"
1253
+ Format multiple optional suffix validation errors.
1254
+
1255
+ Params:
1256
+ errors (list[str]):
1257
+ List of error messages.
1258
+
1259
+ Returns:
1260
+ (str):
1261
+ Formatted error message.
1262
+ """
1263
+ if len(errors) == 1:
1264
+ return errors[0]
1265
+ formatted_errors: str = "\n - ".join([""] + errors)
1266
+ return f"Optional suffix validation errors:{formatted_errors}"
1267
+
1268
+ def _format_type_mismatches(self, mismatches: list[tuple[str, str, str]]) -> str:
1269
+ """
1270
+ !!! note "Summary"
1271
+ Format parameter type mismatches for error output.
1272
+
1273
+ Params:
1274
+ mismatches (list[tuple[str, str, str]]):
1275
+ List of (param_name, sig_type, doc_type) tuples.
1276
+
1277
+ Returns:
1278
+ (str):
1279
+ Formatted error message.
1280
+ """
1281
+ mismatch_blocks: list[str] = []
1282
+ for name, sig_type, doc_type in mismatches:
1283
+ sig_type_clean: str = sig_type.replace("'", '"')
1284
+ doc_type_clean: str = doc_type.replace("'", '"')
1285
+ param_block: str = (
1286
+ f"""'{name}':\n - signature: '{sig_type_clean}'\n - docstring: '{doc_type_clean}'"""
1287
+ )
1288
+ mismatch_blocks.append(param_block)
1289
+
1290
+ formatted_details: str = "\n - ".join([""] + mismatch_blocks)
1291
+ return f"Parameter type mismatch:{formatted_details}"
1292
+
1171
1293
  def _validate_param_types(
1172
1294
  self, docstring: str, node: Union[ast.FunctionDef, ast.AsyncFunctionDef]
1173
1295
  ) -> Optional[str]:
@@ -1175,6 +1297,13 @@ class DocstringChecker:
1175
1297
  !!! note "Summary"
1176
1298
  Validate that parameter types in docstring match the signature.
1177
1299
 
1300
+ ???+ abstract "Details"
1301
+ Implements three validation modes based on `optional_style` configuration:
1302
+
1303
+ - **`"silent"`**: Strip `, optional` from docstring types before comparison.
1304
+ - **`"validate"`**: Error if `, optional` appears on required parameters.
1305
+ - **`"strict"`**: Require `, optional` for parameters with defaults, error if on required parameters.
1306
+
1178
1307
  Params:
1179
1308
  docstring (str):
1180
1309
  The docstring to validate.
@@ -1187,11 +1316,33 @@ class DocstringChecker:
1187
1316
  """
1188
1317
  # Extract types from both sources
1189
1318
  signature_types: dict[str, str] = self._extract_param_types(node)
1190
- docstring_types: dict[str, str] = self._extract_param_types_from_docstring(docstring)
1319
+ docstring_types_raw: dict[str, str] = self._extract_param_types_from_docstring(docstring)
1320
+
1321
+ # Get parameters with default values
1322
+ params_with_defaults: set[str] = self._get_params_with_defaults(node)
1191
1323
 
1192
1324
  # Get all parameter names (excluding self/cls)
1193
1325
  all_params: list[str] = [arg.arg for arg in node.args.args if arg.arg not in ("self", "cls")]
1194
1326
 
1327
+ # Get the optional_style mode
1328
+ optional_style: str = self.config.global_config.optional_style
1329
+
1330
+ # Process docstring types based on optional_style mode
1331
+ docstring_types: dict[str, str] = {}
1332
+ optional_errors: list[str] = []
1333
+
1334
+ for param_name, doc_type in docstring_types_raw.items():
1335
+ clean_type, error_message = self._process_optional_suffix(
1336
+ param_name, doc_type, params_with_defaults, optional_style
1337
+ )
1338
+ docstring_types[param_name] = clean_type
1339
+ if error_message:
1340
+ optional_errors.append(error_message)
1341
+
1342
+ # Return optional_style errors first if any
1343
+ if optional_errors:
1344
+ return self._format_optional_errors(optional_errors)
1345
+
1195
1346
  # Check for parameters documented with type in docstring but missing annotation in signature
1196
1347
  for param_name in all_params:
1197
1348
  if param_name in docstring_types and param_name not in signature_types:
@@ -1208,20 +1359,7 @@ class DocstringChecker:
1208
1359
  mismatches: list[tuple[str, str, str]] = self._compare_param_types(signature_types, docstring_types)
1209
1360
 
1210
1361
  if mismatches:
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)
1220
-
1221
- # Join all parameter blocks with proper indentation
1222
- formatted_details: str = "\n - ".join([""] + mismatch_blocks)
1223
-
1224
- return f"Parameter type mismatch:{formatted_details}"
1362
+ return self._format_type_mismatches(mismatches)
1225
1363
 
1226
1364
  return None
1227
1365