Coverage for src/docstring_format_checker/cli.py: 100%
135 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-04 12:45 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-04 12:45 +0000
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# ============================================================================ #
15# ---------------------------------------------------------------------------- #
16# #
17# Overview ####
18# #
19# ---------------------------------------------------------------------------- #
22# ---------------------------------------------------------------------------- #
23# Description ####
24# ---------------------------------------------------------------------------- #
27"""
28!!! note "Summary"
29 Command-line interface for the docstring format checker.
30"""
33# ---------------------------------------------------------------------------- #
34# #
35# Setup ####
36# #
37# ---------------------------------------------------------------------------- #
40## --------------------------------------------------------------------------- #
41## Imports ####
42## --------------------------------------------------------------------------- #
45# ## Python StdLib Imports ----
46from pathlib import Path
47from textwrap import dedent
48from typing import Optional, Union
50# ## Python Third Party Imports ----
51from rich.console import Console
52from rich.panel import Panel
53from rich.table import Table
54from toolbox_python.bools import strtobool
55from typer import (
56 Argument,
57 BadParameter,
58 CallbackParam,
59 Context,
60 Exit,
61 Option,
62 Typer,
63 echo,
64)
66# ## Local First Party Imports ----
67from docstring_format_checker import __version__
68from docstring_format_checker.config import SectionConfig, find_config_file, load_config
69from docstring_format_checker.core import DocstringChecker, DocstringError
72## --------------------------------------------------------------------------- #
73## Exports ####
74## --------------------------------------------------------------------------- #
77__all__: list[str] = [
78 "main",
79 "config_example",
80 "check",
81 "entry_point",
82]
85## --------------------------------------------------------------------------- #
86## Constants ####
87## --------------------------------------------------------------------------- #
90NEW_LINE = "\n"
93# ---------------------------------------------------------------------------- #
94# #
95# Main Application ####
96# #
97# ---------------------------------------------------------------------------- #
100app = Typer(
101 name="docstring-format-checker",
102 help="A CLI tool to check and validate Python docstring formatting and completeness.",
103 add_completion=False,
104 rich_markup_mode="rich",
105 add_help_option=False, # Disable automatic help so we can add our own with -h
106)
107console = Console()
110# ---------------------------------------------------------------------------- #
111# #
112# Callbacks ####
113# #
114# ---------------------------------------------------------------------------- #
117def _version_callback(ctx: Context, param: CallbackParam, value: bool) -> None:
118 """
119 !!! note "Summary"
120 Print version and exit.
122 Params:
123 ctx (Context):
124 The context object.
125 param (CallbackParam):
126 The parameter object.
127 value (bool):
128 The boolean value indicating if the flag was set.
130 Returns:
131 (None):
132 Nothing is returned.
133 """
134 if value:
135 echo(f"docstring-format-checker version {__version__}")
136 raise Exit()
139def _help_callback(ctx: Context, param: CallbackParam, value: bool) -> None:
140 """
141 !!! note "Summary"
142 Show help and exit.
144 Params:
145 ctx (Context):
146 The context object.
147 param (CallbackParam):
148 The parameter object.
149 value (bool):
150 The boolean value indicating if the flag was set.
152 Returns:
153 (None):
154 Nothing is returned.
155 """
156 if not value or ctx.resilient_parsing:
157 return
158 echo(ctx.get_help())
159 raise Exit()
162def _parse_boolean_flag(ctx: Context, param: CallbackParam, value: Optional[str]) -> Optional[bool]:
163 """
164 !!! note "Summary"
165 Parse boolean flag that accepts various true/false values.
167 Params:
168 ctx (Context):
169 The context object.
170 param (CallbackParam):
171 The parameter object.
172 value (Optional[str]):
173 The string value of the flag.
175 Returns:
176 (Optional[bool]):
177 The parsed boolean value or `None` if not provided.
178 """
180 # Handle the case where the flag is provided without a value (e.g., just --recursive or -r)
181 # In this case, Typer doesn't call the callback, so we need to handle it differently
182 if value is None:
183 # This means the flag wasn't provided at all, use default
184 return True
186 # If value is an empty string, it means the flag was provided without a value
187 if value == "":
188 return True
190 try:
191 return strtobool(value)
192 except ValueError as e:
193 raise BadParameter(
194 message=(
195 f"Invalid boolean value: '{value}'.{NEW_LINE}"
196 "Use one of: true/false, t/f, yes/no, y/n, 1/0, or on/off."
197 )
198 ) from e
201def _parse_recursive_flag(value: str) -> bool:
202 """
203 !!! note "Summary"
204 Parse recursive flag using `strtobool()` utility.
206 Params:
207 value (str):
208 The string value of the flag.
210 Returns:
211 (bool):
212 The parsed boolean value.
213 """
214 return strtobool(value)
217def _show_examples_callback(ctx: Context, param: CallbackParam, value: bool) -> None:
218 """
219 !!! note "Summary"
220 Show examples and exit.
222 Params:
223 ctx (Context):
224 The context object.
225 param (CallbackParam):
226 The parameter object.
227 value (bool):
228 The boolean value indicating if the flag was set.
230 Returns:
231 (None):
232 Nothing is returned.
233 """
235 if not value or ctx.resilient_parsing:
236 return
238 examples_content: str = dedent(
239 """
240 [green]dfc check myfile.py[/green] Check a single Python file
241 [green]dfc check src/[/green] Check all Python files in src/ directory
242 [green]dfc check . --exclude "*/tests/*"[/green] Check current directory, excluding tests
243 [green]dfc check . -c custom.toml[/green] Use custom configuration file
244 [green]dfc check . --verbose[/green] Show detailed validation output
245 [green]dfc config-example[/green] Show example configuration
246 """
247 ).strip()
249 panel = Panel(
250 examples_content,
251 title="Examples",
252 title_align="left",
253 border_style="dim",
254 padding=(0, 1),
255 )
257 console.print(panel)
258 raise Exit()
261def _show_check_examples_callback(ctx: Context, param: CallbackParam, value: bool) -> None:
262 """
263 !!! note "Summary"
264 Show check command examples and exit.
266 Params:
267 ctx (Context):
268 The context object.
269 param (CallbackParam):
270 The parameter object.
271 value (bool):
272 The boolean value indicating if the flag was set.
274 Returns:
275 (None):
276 Nothing is returned.
277 """
279 if not value or ctx.resilient_parsing:
280 return
282 examples_content: str = dedent(
283 """
284 [green]dfc check myfile.py[/green] Check a single Python file
285 [green]dfc check src/[/green] Check all Python files in src/ directory
286 [green]dfc check . --exclude "*/tests/*"[/green] Check current directory, excluding tests
287 [green]dfc check . --config custom.toml[/green] Use custom configuration file
288 [green]dfc check . --verbose --recursive[/green] Show detailed output for all subdirectories
289 [green]dfc check . --quiet[/green] Only show errors, suppress success messages
290 """
291 )
293 panel = Panel(
294 examples_content,
295 title="Check Command Examples",
296 title_align="left",
297 border_style="dim",
298 padding=(0, 1),
299 )
301 console.print(panel)
302 raise Exit()
305def _display_results(results: dict[str, list[DocstringError]], quiet: bool, verbose: bool) -> int:
306 """
307 !!! note "Summary"
308 Display the results of docstring checking.
310 Params:
311 results (dict[str, list[DocstringError]]):
312 Dictionary mapping file paths to lists of errors
313 quiet (bool):
314 Whether to suppress success messages
315 verbose (bool):
316 Whether to show detailed output
318 Returns:
319 (int):
320 Exit code (`0` for success, `1` for errors found)
321 """
322 if not results:
323 if not quiet:
324 console.print("[green]✓ All docstrings are valid![/green]")
325 return 0
327 # Count total errors
328 total_errors: int = sum(len(errors) for errors in results.values())
329 total_files: int = len(results)
331 if verbose:
332 # Show detailed table
333 table = Table(show_header=True, header_style="bold magenta")
334 table.add_column("File", style="cyan", no_wrap=False)
335 table.add_column("Line", justify="right", style="white")
336 table.add_column("Item", style="yellow")
337 table.add_column("Type", style="blue")
338 table.add_column("Error", style="red")
340 for file_path, errors in results.items():
341 for i, error in enumerate(errors):
342 file_display: str = file_path if i == 0 else ""
343 table.add_row(
344 file_display,
345 str(error.line_number) if error.line_number > 0 else "",
346 error.item_name,
347 error.item_type,
348 error.message,
349 )
350 console.print(table)
352 else:
353 # Show compact output
354 for file_path, errors in results.items():
355 console.print(f"{NEW_LINE}[cyan]{file_path}[/cyan]")
356 for error in errors:
357 if error.line_number > 0:
358 console.print(
359 f" [red]Line {error.line_number}[/red] - {error.item_type} '{error.item_name}': {error.message}"
360 )
361 else:
362 console.print(f" [red]Error[/red]: {error.message}")
364 # Summary
365 console.print(f"{NEW_LINE}[red]Found {total_errors} error(s) in {total_files} file(s)[/red]")
367 return 1
370# ---------------------------------------------------------------------------- #
371# #
372# Main Logic ####
373# #
374# ---------------------------------------------------------------------------- #
377# This will be the default behavior when no command is specified
378def _check_docstrings(
379 path: str,
380 config: Optional[str] = None,
381 recursive: bool = True,
382 exclude: Optional[list[str]] = None,
383 quiet: bool = False,
384 verbose: bool = False,
385) -> None:
386 """
387 !!! note "Summary"
388 Core logic for checking docstrings.
390 Params:
391 path (str):
392 The path to the file or directory to check.
393 config (Optional[str]):
394 The path to the configuration file.
395 recursive (bool):
396 Whether to check files recursively.
397 exclude (Optional[list[str]]):
398 List of glob patterns to exclude from checking.
399 quiet (bool):
400 Whether to suppress output.
401 verbose (bool):
402 Whether to show detailed output.
404 Returns:
405 (None):
406 Nothing is returned.
407 """
409 target_path = Path(path)
411 # Validate target path
412 if not target_path.exists():
413 console.print(f"[red]Error: Path does not exist: {path}[/red]")
414 raise Exit(1)
416 # Load configuration
417 try:
418 if config:
419 config_path = Path(config)
420 if not config_path.exists():
421 console.print(f"[red]Error: Configuration file does not exist: {config}[/red]")
422 raise Exit(1)
423 sections_config = load_config(config_path)
424 else:
425 # Try to find config file automatically
426 found_config: Union[Path, None] = find_config_file(
427 target_path if target_path.is_dir() else target_path.parent
428 )
429 if found_config:
430 if verbose:
431 console.print(f"[blue]Using configuration from: {found_config}[/blue]")
432 sections_config: list[SectionConfig] = load_config(found_config)
433 else:
434 if verbose:
435 console.print("[blue]Using default configuration[/blue]")
436 sections_config: list[SectionConfig] = load_config()
438 except Exception as e:
439 console.print(f"[red]Error loading configuration: {e}[/red]")
440 raise Exit(1)
442 # Initialize checker
443 checker = DocstringChecker(sections_config)
445 # Check files
446 try:
447 if target_path.is_file():
448 if verbose:
449 console.print(f"[blue]Checking file: {target_path}[/blue]")
450 errors: list[DocstringError] = checker.check_file(target_path)
451 results: dict[str, list[DocstringError]] = {str(target_path): errors} if errors else {}
452 else:
453 if verbose:
454 console.print(f"[blue]Checking directory: {target_path} (recursive={recursive})[/blue]")
455 results: dict[str, list[DocstringError]] = checker.check_directory(
456 target_path, recursive=recursive, exclude_patterns=exclude
457 )
458 except Exception as e:
459 console.print(f"[red]Error during checking: {e}[/red]")
460 raise Exit(1)
462 # Display results
463 exit_code: int = _display_results(results, quiet, verbose)
465 if exit_code != 0:
466 raise Exit(exit_code)
469# ---------------------------------------------------------------------------- #
470# #
471# App Operators ####
472# #
473# ---------------------------------------------------------------------------- #
476# Simple callback that only handles global options and delegates to subcommands
477@app.callback(invoke_without_command=True)
478def main(
479 ctx: Context,
480 version: Optional[bool] = Option(
481 None,
482 "--version",
483 "-v",
484 callback=_version_callback,
485 is_eager=True,
486 help="Show version and exit",
487 ),
488 examples: Optional[bool] = Option(
489 None,
490 "--examples",
491 "-e",
492 callback=_show_examples_callback,
493 is_eager=True,
494 help="Show usage examples and exit",
495 ),
496 help_flag: Optional[bool] = Option(
497 None,
498 "--help",
499 "-h",
500 callback=_help_callback,
501 is_eager=True,
502 help="Show this message and exit",
503 ),
504) -> None:
505 """
506 !!! note "Summary"
507 Check Python docstring formatting and completeness.
509 ???+ abstract "Details"
510 This tool analyzes Python files and validates that functions, methods, and classes have properly formatted docstrings according to the configured sections.
512 Params:
513 ctx (Context):
514 The context object for the command.
515 version (Optional[bool]):
516 Show version and exit.
517 examples (Optional[bool]):
518 Show usage examples and exit.
519 help_flag (Optional[bool]):
520 Show help message and exit.
522 Returns:
523 (None):
524 Nothing is returned.
525 """
526 # If no subcommand is provided, show help
527 if ctx.invoked_subcommand is None:
528 echo(ctx.get_help())
529 raise Exit()
532@app.command(
533 rich_help_panel="Commands",
534 add_help_option=False, # Disable automatic help so we can add our own with -h
535)
536def check(
537 path: str = Argument(..., help="Path to Python file or directory to check"),
538 config: Optional[str] = Option(None, "--config", "-c", help="Path to configuration file (TOML format)"),
539 recursive: str = Option(
540 "true",
541 "--recursive",
542 "-r",
543 help="Check directories recursively (default: true). Accepts: true/false, t/f, yes/no, y/n, 1/0, on/off",
544 ),
545 exclude: Optional[list[str]] = Option(
546 None,
547 "--exclude",
548 "-x",
549 help="Glob patterns to exclude (can be used multiple times)",
550 ),
551 quiet: bool = Option(False, "--quiet", "-q", help="Only show errors, no success messages"),
552 verbose: bool = Option(False, "--verbose", "-n", help="Show detailed output"),
553 examples: Optional[bool] = Option(
554 None,
555 "--examples",
556 "-e",
557 callback=_show_check_examples_callback,
558 is_eager=True,
559 help="Show usage examples and exit",
560 ),
561 help_flag: Optional[bool] = Option(
562 None,
563 "--help",
564 "-h",
565 callback=_help_callback,
566 is_eager=True,
567 help="Show this message and exit",
568 ),
569) -> None:
570 """
571 !!! note "Summary"
572 Check docstrings in Python files.
574 ???+ abstract "Details"
575 This command checks the docstrings in the specified Python file or directory.
577 Params:
578 path (str):
579 The path to the Python file or directory to check.
580 config (Optional[str]):
581 The path to the configuration file (TOML format).
582 recursive (bool):
583 Whether to check directories recursively.
584 exclude (list[str]):
585 Glob patterns to exclude (can be used multiple times).
586 quiet (bool):
587 Whether to only show errors, no success messages.
588 verbose (bool):
589 Whether to show detailed output.
590 examples (Optional[bool]):
591 Show usage examples and exit.
592 help_flag (Optional[bool]):
593 Show help message and exit.
595 Returns:
596 (None):
597 Nothing is returned.
598 """
599 # Parse the recursive string value into a boolean
600 try:
601 recursive_bool: bool = _parse_recursive_flag(recursive)
602 except ValueError as e:
603 raise BadParameter(
604 message=(
605 f"Invalid value for --recursive: '{recursive}'.{NEW_LINE}"
606 "Use one of: true/false, t/f, yes/no, y/n, 1/0, or on/off."
607 )
608 ) from e
609 _check_docstrings(path, config, recursive_bool, exclude, quiet, verbose)
612@app.command(
613 rich_help_panel="Commands",
614 add_help_option=False, # Disable automatic help so we can add our own with -h
615)
616def config_example(
617 help_flag: Optional[bool] = Option(
618 None,
619 "--help",
620 "-h",
621 callback=_help_callback,
622 is_eager=True,
623 help="Show this message and exit",
624 ),
625) -> None:
626 """
627 !!! note "Summary"
628 Show example configuration file.
630 Params:
631 help_flag (Optional[bool]):
632 Show help message and exit.
634 Returns:
635 (None):
636 Nothing is returned.
637 """
638 example_config: str = dedent(
639 """
640 # Example configuration for docstring-format-checker
641 # Place this in your pyproject.toml file
643 [tool.dfc]
644 # or [tool.docstring-format-checker]
646 [[tool.dfc.sections]]
647 order = 1
648 name = "summary"
649 type = "free_text"
650 admonition = "note"
651 prefix = "!!!"
652 required = true
654 [[tool.dfc.sections]]
655 order = 2
656 name = "details"
657 type = "free_text"
658 admonition = "info"
659 prefix = "???+"
660 required = false
662 [[tool.dfc.sections]]
663 order = 3
664 name = "params"
665 type = "list_name_and_type"
666 required = true
668 [[tool.dfc.sections]]
669 order = 4
670 name = "returns"
671 type = "list_name_and_type"
672 required = false
674 [[tool.dfc.sections]]
675 order = 5
676 name = "yields"
677 type = "list_type"
678 required = false
680 [[tool.dfc.sections]]
681 order = 6
682 name = "raises"
683 type = "list_type"
684 required = false
686 [[tool.dfc.sections]]
687 order = 7
688 name = "examples"
689 type = "free_text"
690 admonition = "example"
691 prefix = "???+"
692 required = false
694 [[tool.dfc.sections]]
695 order = 8
696 name = "notes"
697 type = "free_text"
698 admonition = "note"
699 prefix = "???"
700 required = false
701 """.strip()
702 )
704 print(example_config)
707def entry_point() -> None:
708 """
709 !!! note "Summary"
710 Entry point for the CLI scripts defined in pyproject.toml.
711 """
712 app()
715if __name__ == "__main__":
716 app()