docstring-format-checker 1.2.0__tar.gz → 1.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: 1.2.0
3
+ Version: 1.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>
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
20
21
  Classifier: Intended Audience :: Developers
21
22
  Classifier: Environment :: Console
22
23
  Requires-Dist: typer>=0.9.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docstring-format-checker"
3
- version = "1.2.0"
3
+ version = "1.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"
@@ -23,6 +23,7 @@ classifiers = [
23
23
  "Programming Language :: Python :: 3.11",
24
24
  "Programming Language :: Python :: 3.12",
25
25
  "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
26
27
  "Intended Audience :: Developers",
27
28
  "Environment :: Console",
28
29
  ]
@@ -83,6 +84,7 @@ test = [
83
84
  "pytest-sugar==1.*",
84
85
  "pytest-xdist==3.*",
85
86
  "requests==2.*",
87
+ "complexipy==4.*",
86
88
  ]
87
89
 
88
90
  [tool.black]
@@ -147,6 +149,25 @@ lines_after_imports = 2
147
149
  [tool.codespell]
148
150
  ignore-words-list = "demog"
149
151
 
152
+ [tool.pylint.main]
153
+ disable = [
154
+ "C0103", # invalid-name
155
+ "C0301", # line-too-long
156
+ "C0302", # too-many-lines
157
+ "R0913", # too-many-arguments
158
+ "R0914", # too-many-locals
159
+ "R0915", # too-many-statements
160
+ "R0917", # too-many-positional-arguments
161
+ "R1705", # no-else-return
162
+ "R1724", # no-else-continue
163
+ "W0107", # unnecessary-pass
164
+ "W0212", # protected-access
165
+ "W0612", # unused-variable
166
+ "W0613", # unused-argument
167
+ "W0622", # redefined-builtin
168
+ "W0718", # broad-exception-caught
169
+ ]
170
+
150
171
  [tool.bump_version.replacements]
151
172
  files = [
152
173
  { file = "src/docstring_format_checker/__init__.py", pattern = "__version__ = \"{VERSION}\"" },
@@ -157,16 +178,16 @@ files = [
157
178
  [tool.dfc]
158
179
  allow_undefined_sections = false
159
180
  require_docstrings = true
160
- check_private = true
181
+ check_private = false
161
182
  sections = [
162
- { order = 1, name = "summary", type = "free_text", required = true, admonition = "note", prefix = "!!!" },
163
- { order = 2, name = "details", type = "free_text", required = false, admonition = "abstract", prefix = "???+" },
164
- { order = 3, name = "params", type = "list_name_and_type", required = false },
165
- { order = 4, name = "raises", type = "list_type", required = false },
166
- { order = 5, name = "returns", type = "list_name_and_type", required = false },
167
- { order = 6, name = "yields", type = "list_type", required = false },
168
- { order = 7, name = "examples", type = "free_text", required = false, admonition = "example", prefix = "???+" },
169
- { order = 8, name = "notes", type = "free_text", required = false, admonition = "note", prefix = "???" },
183
+ { order=1, name="summary", type="free_text", required=true, admonition="note", prefix="!!!" },
184
+ { order=2, name="details", type="free_text", required=false, admonition="abstract", prefix="???+" },
185
+ { order=3, name="params", type="list_name_and_type", required=false, admonition=false },
186
+ { order=4, name="raises", type="list_type", required=false, admonition=false },
187
+ { order=5, name="returns", type="list_name_and_type", required=false, admonition=false },
188
+ { order=6, name="yields", type="list_type", required=false, admonition=false },
189
+ { order=7, name="examples", type="free_text", required=false, admonition="example", prefix="???+" },
190
+ { order=8, name="notes", type="free_text", required=false, admonition="note", prefix="???" },
170
191
  ]
171
192
 
172
193
  [build-system]
@@ -360,12 +360,18 @@ def _format_error_messages(error_message: str) -> str:
360
360
  errors: list[str] = error_message.split("; ")
361
361
  formatted_errors: list[str] = [f"- {error.strip()}" for error in errors if error.strip()]
362
362
  return ";\n".join(formatted_errors) + "."
363
+
364
+ # Single error message
363
365
  else:
364
- # Single error message
365
366
  return f"- {error_message.strip()}."
366
367
 
367
368
 
368
- def _display_results(results: dict[str, list[DocstringError]], quiet: bool, output: str, check: bool) -> int:
369
+ def _display_results(
370
+ results: dict[str, list[DocstringError]],
371
+ quiet: bool,
372
+ output: str,
373
+ check: bool,
374
+ ) -> int:
369
375
  """
370
376
  !!! note "Summary"
371
377
  Display the results of docstring checking.
@@ -386,100 +392,106 @@ def _display_results(results: dict[str, list[DocstringError]], quiet: bool, outp
386
392
  """
387
393
  if not results:
388
394
  if not quiet:
389
- console.print(_green("✓ All docstrings are valid!"))
395
+ console.print(_green("✅ All docstrings are valid!"))
390
396
  return 0
391
397
 
392
- # Count total errors (individual error messages, not error objects)
398
+ # Count errors and generate summary statistics
399
+ error_stats = _count_errors_and_files(results)
400
+
401
+ if quiet:
402
+ _display_quiet_summary(error_stats)
403
+ return 1
404
+
405
+ # Display detailed results based on output format
406
+ if output == "table":
407
+ _display_table_output(results)
408
+ else:
409
+ _display_list_output(results)
410
+
411
+ # Display final summary
412
+ _display_final_summary(error_stats)
413
+ return 1
414
+
415
+
416
+ def _count_errors_and_files(results: dict[str, list[DocstringError]]) -> dict[str, int]:
417
+ """Count total errors, functions, and files from results."""
393
418
  total_individual_errors: int = 0
394
419
  total_functions: int = 0
395
420
 
396
421
  for errors in results.values():
397
- total_functions += len(errors) # Count functions/items with errors
422
+ total_functions += len(errors)
398
423
  for error in errors:
399
- # Count individual error messages within each error object
400
424
  if "; " in error.message:
401
425
  individual_errors = [msg.strip() for msg in error.message.split("; ") if msg.strip()]
402
426
  total_individual_errors += len(individual_errors)
403
427
  else:
404
428
  total_individual_errors += 1
405
429
 
406
- total_errors: int = total_individual_errors
407
- total_files: int = len(results)
408
-
409
- if quiet:
410
- # In quiet mode, only show summary with improved format
411
- if total_functions == 1:
412
- functions_text = f"1 function"
413
- else:
414
- functions_text = f"{total_functions} functions"
415
-
416
- if total_files == 1:
417
- files_text = f"1 file"
418
- else:
419
- files_text = f"{total_files} files"
430
+ return {"total_errors": total_individual_errors, "total_functions": total_functions, "total_files": len(results)}
420
431
 
421
- console.print(_red(f"{NEW_LINE}Found {total_errors} error(s) in {functions_text} over {files_text}"))
422
- return 1
423
432
 
424
- if output == "table":
425
- # Show detailed table
426
- table = Table(show_header=True, header_style="bold magenta")
427
- table.add_column("File", style="cyan", no_wrap=False)
428
- table.add_column("Line", justify="right", style="white")
429
- table.add_column("Item", style="yellow")
430
- table.add_column("Type", style="blue")
431
- table.add_column("Error", style="red")
432
-
433
- for file_path, errors in results.items():
434
- for i, error in enumerate(errors):
435
- file_display: str = file_path if i == 0 else ""
436
-
437
- # Format error message with improved formatting
438
- formatted_error_message: str = _format_error_messages(error.message)
439
-
440
- table.add_row(
441
- file_display,
442
- str(error.line_number) if error.line_number > 0 else "",
443
- error.item_name,
444
- error.item_type,
445
- formatted_error_message,
446
- )
447
- console.print(table)
433
+ def _display_quiet_summary(error_stats: dict[str, int]) -> None:
434
+ """Display summary in quiet mode."""
435
+ functions_text = (
436
+ "1 function" if error_stats["total_functions"] == 1 else f"{error_stats['total_functions']} functions"
437
+ )
438
+ files_text = "1 file" if error_stats["total_files"] == 1 else f"{error_stats['total_files']} files"
439
+
440
+ console.print(_red(f"{NEW_LINE}Found {error_stats['total_errors']} error(s) in {functions_text} over {files_text}"))
441
+
442
+
443
+ def _display_table_output(results: dict[str, list[DocstringError]]) -> None:
444
+ """Display results in table format."""
445
+ table = Table(show_header=True, header_style="bold magenta")
446
+ table.add_column("File", style="cyan", no_wrap=False)
447
+ table.add_column("Line", justify="right", style="white")
448
+ table.add_column("Item", style="yellow")
449
+ table.add_column("Type", style="blue")
450
+ table.add_column("Error", style="red")
451
+
452
+ for file_path, errors in results.items():
453
+ for i, error in enumerate(errors):
454
+ file_display = file_path if i == 0 else ""
455
+ formatted_error_message = _format_error_messages(error.message)
456
+
457
+ table.add_row(
458
+ file_display,
459
+ str(error.line_number) if error.line_number > 0 else "",
460
+ error.item_name,
461
+ error.item_type,
462
+ formatted_error_message,
463
+ )
464
+ console.print(table)
465
+
466
+
467
+ def _display_list_output(results: dict[str, list[DocstringError]]) -> None:
468
+ """Display results in list format."""
469
+ for file_path, errors in results.items():
470
+ console.print(f"{NEW_LINE}{_cyan(file_path)}")
471
+ for error in errors:
472
+ # Print header with line number, item type and name
473
+ if error.line_number > 0:
474
+ console.print(f" [red]Line {error.line_number}[/red] - {error.item_type} '{error.item_name}':")
475
+ else:
476
+ console.print(f" {_red('Error')} - {error.item_type} '{error.item_name}':")
448
477
 
449
- else:
450
- # Show compact output with grouped errors under function/class headers
451
- for file_path, errors in results.items():
452
- console.print(f"{NEW_LINE}{_cyan(file_path)}")
453
- for error in errors:
454
- # Print the header line with line number, item type and name
455
- if error.line_number > 0:
456
- console.print(f" [red]Line {error.line_number}[/red] - {error.item_type} '{error.item_name}':")
457
- else:
458
- console.print(f" {_red('Error')} - {error.item_type} '{error.item_name}':")
459
-
460
- # Split error message into individual errors and indent them
461
- if "; " in error.message:
462
- individual_errors = [msg.strip() for msg in error.message.split("; ") if msg.strip()]
463
- for individual_error in individual_errors:
464
- console.print(f" - {individual_error}")
465
- else:
466
- # Single error message
467
- console.print(f" - {error.message.strip()}")
468
-
469
- # Summary - more descriptive message
470
- if total_functions == 1:
471
- functions_text = f"1 function"
472
- else:
473
- functions_text = f"{total_functions} functions"
478
+ # Format and print error messages
479
+ if "; " in error.message:
480
+ individual_errors = [msg.strip() for msg in error.message.split("; ") if msg.strip()]
481
+ for individual_error in individual_errors:
482
+ console.print(f" - {individual_error}")
483
+ else:
484
+ console.print(f" - {error.message.strip()}")
474
485
 
475
- if total_files == 1:
476
- files_text = f"1 file"
477
- else:
478
- files_text = f"{total_files} files"
479
486
 
480
- console.print(_red(f"{NEW_LINE}Found {total_errors} error(s) in {functions_text} over {files_text}"))
487
+ def _display_final_summary(error_stats: dict[str, int]) -> None:
488
+ """Display the final summary line."""
489
+ functions_text = (
490
+ "1 function" if error_stats["total_functions"] == 1 else f"{error_stats['total_functions']} functions"
491
+ )
492
+ files_text = "1 file" if error_stats["total_files"] == 1 else f"{error_stats['total_files']} files"
481
493
 
482
- return 1
494
+ console.print(_red(f"{NEW_LINE}Found {error_stats['total_errors']} error(s) in {functions_text} over {files_text}"))
483
495
 
484
496
 
485
497
  # ---------------------------------------------------------------------------- #
@@ -525,69 +537,92 @@ def check_docstrings(
525
537
  (None):
526
538
  Nothing is returned.
527
539
  """
540
+ # Validate and process input paths
541
+ target_paths = _validate_and_process_paths(paths)
542
+
543
+ # Load and validate configuration
544
+ config_obj = _load_and_validate_config(config, target_paths)
528
545
 
529
- # Validate all target paths
546
+ # Initialize checker and process all paths
547
+ checker = DocstringChecker(config_obj)
548
+ all_results = _process_all_paths(checker, target_paths, exclude)
549
+
550
+ # Display results and handle exit
551
+ exit_code = _display_results(all_results, quiet, output, check)
552
+ if exit_code != 0:
553
+ raise Exit(exit_code)
554
+
555
+
556
+ def _validate_and_process_paths(paths: list[str]) -> list[Path]:
557
+ """Validate input paths and return valid paths."""
530
558
  path_objs: list[Path] = [Path(path) for path in paths]
531
559
  target_paths: list[Path] = [p for p in path_objs if p.exists()]
532
560
  invalid_paths: list[Path] = [p for p in path_objs if not p.exists()]
533
561
 
534
- if len(invalid_paths) > 0:
562
+ if invalid_paths:
535
563
  console.print(
536
- _red(f"[bold]Error: Paths do not exist:[/bold]"),
564
+ _red("[bold]Error: Paths do not exist:[/bold]"),
537
565
  NEW_LINE,
538
566
  NEW_LINE.join([f"- '{invalid_path}'" for invalid_path in invalid_paths]),
539
567
  )
540
568
  raise Exit(1)
541
569
 
542
- # Load configuration (use first path for config discovery if no config specified)
570
+ return target_paths
571
+
572
+
573
+ def _load_and_validate_config(config: Optional[str], target_paths: list[Path]) -> Config:
574
+ """Load and validate configuration from file or auto-discovery."""
543
575
  try:
544
576
  if config:
545
- config_path = Path(config)
546
- if not config_path.exists():
547
- console.print(_red(f"Error: Configuration file does not exist: {config}"))
548
- raise Exit(1)
549
- config_obj = load_config(config_path)
577
+ return _load_explicit_config(config)
550
578
  else:
551
- # Try to find config file automatically using the first path
552
- first_path: Path = target_paths[0]
553
- found_config: Optional[Path] = find_config_file(first_path if first_path.is_dir() else first_path.parent)
554
- if found_config:
555
- config_obj: Config = load_config(found_config)
556
- else:
557
- config_obj: Config = load_config()
558
-
579
+ return _load_auto_discovered_config(target_paths)
559
580
  except Exception as e:
560
581
  console.print(_red(f"Error loading configuration: {e}"))
582
+ raise Exit(1) from e
583
+
584
+
585
+ def _load_explicit_config(config: str) -> Config:
586
+ """Load configuration from explicitly specified path."""
587
+ config_path = Path(config)
588
+ if not config_path.exists():
589
+ console.print(_red(f"Error: Configuration file does not exist: {config}"))
561
590
  raise Exit(1)
591
+ return load_config(config_path)
562
592
 
563
- # Initialize checker
564
- checker = DocstringChecker(config_obj)
565
593
 
566
- # Check all paths and collect results
594
+ def _load_auto_discovered_config(target_paths: list[Path]) -> Config:
595
+ """Load configuration from auto-discovery or defaults."""
596
+ first_path: Path = target_paths[0]
597
+ search_path = first_path if first_path.is_dir() else first_path.parent
598
+ found_config: Optional[Path] = find_config_file(search_path)
599
+
600
+ if found_config:
601
+ return load_config(found_config)
602
+ else:
603
+ return load_config()
604
+
605
+
606
+ def _process_all_paths(
607
+ checker: DocstringChecker, target_paths: list[Path], exclude: Optional[list[str]]
608
+ ) -> dict[str, list[DocstringError]]:
609
+ """Process all target paths and collect docstring errors."""
567
610
  all_results: dict[str, list[DocstringError]] = {}
568
611
 
569
612
  try:
570
613
  for target_path in target_paths:
571
614
  if target_path.is_file():
572
- errors: list[DocstringError] = checker.check_file(target_path)
615
+ errors = checker.check_file(target_path)
573
616
  if errors:
574
617
  all_results[str(target_path)] = errors
575
618
  else:
576
- directory_results: dict[str, list[DocstringError]] = checker.check_directory(
577
- target_path, exclude_patterns=exclude
578
- )
619
+ directory_results = checker.check_directory(target_path, exclude_patterns=exclude)
579
620
  all_results.update(directory_results)
580
-
581
621
  except Exception as e:
582
622
  console.print(_red(f"Error during checking: {e}"))
583
- raise Exit(1)
623
+ raise Exit(1) from e
584
624
 
585
- # Display results
586
- exit_code: int = _display_results(all_results, quiet, output, check)
587
-
588
- # Always exit with error code if issues are found, regardless of check flag
589
- if exit_code != 0:
590
- raise Exit(exit_code)
625
+ return all_results
591
626
 
592
627
 
593
628
  # ---------------------------------------------------------------------------- #
@@ -331,78 +331,111 @@ def load_config(config_path: Optional[Union[str, Path]] = None) -> Config:
331
331
  (Config):
332
332
  Configuration object containing global settings and section definitions.
333
333
  """
334
+ # Resolve config file path
335
+ resolved_path = _resolve_config_path(config_path)
336
+ if resolved_path is None:
337
+ return DEFAULT_CONFIG
338
+
339
+ # Parse TOML configuration
340
+ config_data = _parse_toml_file(resolved_path)
341
+
342
+ # Extract tool configuration
343
+ tool_config = _extract_tool_config(config_data)
344
+ if tool_config is None:
345
+ return DEFAULT_CONFIG
346
+
347
+ # Parse configuration components
348
+ global_config = _parse_global_config(tool_config)
349
+ sections_config = _parse_sections_config(tool_config)
350
+
351
+ return Config(global_config=global_config, sections=sections_config)
352
+
334
353
 
354
+ def _resolve_config_path(config_path: Optional[Union[str, Path]]) -> Optional[Path]:
355
+ """Resolve configuration file path."""
335
356
  if config_path is None:
336
357
  # Look for pyproject.toml in current directory
337
358
  pyproject_path: Path = Path.cwd().joinpath("pyproject.toml")
338
359
  if pyproject_path.exists():
339
- config_path = pyproject_path
360
+ return pyproject_path
340
361
  else:
341
- return DEFAULT_CONFIG
362
+ return None
342
363
 
343
364
  # Convert to Path object and check existence
344
365
  config_path = Path(config_path)
345
366
  if not config_path.exists():
346
367
  raise FileNotFoundError(f"Configuration file not found: {config_path}")
347
368
 
369
+ return config_path
370
+
371
+
372
+ def _parse_toml_file(config_path: Path) -> dict[str, Any]:
373
+ """Parse TOML configuration file."""
348
374
  try:
349
375
  with open(config_path, "rb") as f:
350
- config_data: dict[str, Any] = tomllib.load(f)
376
+ return tomllib.load(f)
351
377
  except Exception as e:
352
378
  raise InvalidConfigError(f"Failed to parse TOML file {config_path}: {e}") from e
353
379
 
354
- # Try to find configuration under [tool.dfc] or [tool.docstring-format-checker]
355
- tool_config = None
356
- if "tool" in config_data:
357
- if "dfc" in config_data["tool"]:
358
- tool_config = config_data["tool"]["dfc"]
359
- elif "docstring-format-checker" in config_data["tool"]:
360
- tool_config = config_data["tool"]["docstring-format-checker"]
361
380
 
362
- if tool_config is None:
363
- return DEFAULT_CONFIG
381
+ def _extract_tool_config(config_data: dict[str, Any]) -> Optional[dict[str, Any]]:
382
+ """Extract tool configuration from TOML data."""
383
+ if "tool" not in config_data:
384
+ return None
385
+
386
+ tool_section = config_data["tool"]
387
+ if "dfc" in tool_section:
388
+ return tool_section["dfc"]
389
+ elif "docstring-format-checker" in tool_section:
390
+ return tool_section["docstring-format-checker"]
364
391
 
365
- # Parse global configuration flags
366
- global_config = GlobalConfig(
392
+ return None
393
+
394
+
395
+ def _parse_global_config(tool_config: dict[str, Any]) -> GlobalConfig:
396
+ """Parse global configuration flags."""
397
+ return GlobalConfig(
367
398
  allow_undefined_sections=tool_config.get("allow_undefined_sections", False),
368
399
  require_docstrings=tool_config.get("require_docstrings", True),
369
400
  check_private=tool_config.get("check_private", False),
370
401
  )
371
402
 
372
- # Parse sections configuration
403
+
404
+ def _parse_sections_config(tool_config: dict[str, Any]) -> list[SectionConfig]:
405
+ """Parse sections configuration."""
406
+ if "sections" not in tool_config:
407
+ return DEFAULT_SECTIONS
408
+
373
409
  sections_config: list[SectionConfig] = []
374
- if "sections" in tool_config:
375
- sections_data = tool_config["sections"]
376
- for section_data in sections_data:
377
- try:
378
- # Get admonition value with proper default handling
379
- admonition_value: Union[str, bool] = section_data.get("admonition")
380
- if admonition_value is None:
381
- admonition_value = False # Use SectionConfig default
382
-
383
- section = SectionConfig(
384
- order=section_data.get("order", 0),
385
- name=section_data.get("name", ""),
386
- type=section_data.get("type", ""),
387
- admonition=admonition_value,
388
- prefix=section_data.get("prefix", ""),
389
- required=section_data.get("required", False),
390
- )
391
- sections_config.append(section)
392
- except (KeyError, TypeError, ValueError, InvalidTypeValuesError) as e:
393
- raise InvalidConfigError(f"Invalid section configuration: {section_data}. Error: {e}") from e
394
-
395
- # Use default sections if none provided, otherwise validate and sort
396
- if not sections_config:
397
- sections_config = DEFAULT_SECTIONS
398
- else:
399
- # Validate no duplicate order values
400
- _validate_config_order(config_sections=sections_config)
410
+ sections_data = tool_config["sections"]
411
+
412
+ for section_data in sections_data:
413
+ try:
414
+ # Get admonition value with proper default handling
415
+ admonition_value: Union[str, bool] = section_data.get("admonition")
416
+ if admonition_value is None:
417
+ admonition_value = False # Use SectionConfig default
418
+
419
+ section = SectionConfig(
420
+ order=section_data.get("order", 0),
421
+ name=section_data.get("name", ""),
422
+ type=section_data.get("type", ""),
423
+ admonition=admonition_value,
424
+ prefix=section_data.get("prefix", ""),
425
+ required=section_data.get("required", False),
426
+ )
427
+ sections_config.append(section)
428
+ except (KeyError, TypeError, ValueError, InvalidTypeValuesError) as e:
429
+ raise InvalidConfigError(f"Invalid section configuration: {section_data}. Error: {e}") from e
401
430
 
402
- # Sort by order
431
+ # Validate and sort sections
432
+ if sections_config:
433
+ _validate_config_order(config_sections=sections_config)
403
434
  sections_config.sort(key=lambda x: x.order)
435
+ else:
436
+ sections_config = DEFAULT_SECTIONS
404
437
 
405
- return Config(global_config=global_config, sections=sections_config)
438
+ return sections_config
406
439
 
407
440
 
408
441
  def find_config_file(start_path: Optional[Path] = None) -> Optional[Path]: