Coverage for src/docstring_format_checker/cli.py: 100%
155 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-10 13:31 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-10 13:31 +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 functools import partial
47from pathlib import Path
48from textwrap import dedent
49from typing import Optional
51# ## Python Third Party Imports ----
52from rich.console import Console
53from rich.panel import Panel
54from rich.table import Table
55from typer import Argument, CallbackParam, Context, Exit, Option, Typer, echo
57# ## Local First Party Imports ----
58from docstring_format_checker import __version__
59from docstring_format_checker.config import (
60 Config,
61 find_config_file,
62 load_config,
63)
64from docstring_format_checker.core import DocstringChecker, DocstringError
67## --------------------------------------------------------------------------- #
68## Exports ####
69## --------------------------------------------------------------------------- #
72__all__: list[str] = [
73 "main",
74 "entry_point",
75 "check_docstrings",
76]
79## --------------------------------------------------------------------------- #
80## Constants ####
81## --------------------------------------------------------------------------- #
84NEW_LINE = "\n"
87## --------------------------------------------------------------------------- #
88## Helpers ####
89## --------------------------------------------------------------------------- #
92### Colours ----
93def _colour(text: str, colour: str) -> str:
94 """
95 !!! note "Summary"
96 Apply Rich colour markup to text.
98 Params:
99 text (str):
100 The text to colour.
101 colour (str):
102 The colour to apply, e.g., 'red', 'green', 'blue'.
104 Returns:
105 (str):
106 The text wrapped in Rich colour markup.
107 """
108 return f"[{colour}]{text}[/{colour}]"
111_green = partial(_colour, colour="green")
112_red = partial(_colour, colour="red")
113_cyan = partial(_colour, colour="cyan")
114_blue = partial(_colour, colour="blue")
117# ---------------------------------------------------------------------------- #
118# #
119# Main Application ####
120# #
121# ---------------------------------------------------------------------------- #
124app = Typer(
125 name="docstring-format-checker",
126 help="A CLI tool to check and validate Python docstring formatting and completeness.",
127 add_completion=False,
128 rich_markup_mode="rich",
129 add_help_option=False, # Disable automatic help so we can add our own with -h
130)
131console = Console()
134# ---------------------------------------------------------------------------- #
135# #
136# Callbacks ####
137# #
138# ---------------------------------------------------------------------------- #
141def _version_callback(ctx: Context, param: CallbackParam, value: bool) -> None:
142 """
143 !!! note "Summary"
144 Print version and exit.
146 Params:
147 ctx (Context):
148 The context object.
149 param (CallbackParam):
150 The parameter object.
151 value (bool):
152 The boolean value indicating if the flag was set.
154 Returns:
155 (None):
156 Nothing is returned.
157 """
158 if value:
159 echo(f"docstring-format-checker version {__version__}")
160 raise Exit()
163def _help_callback_main(ctx: Context, param: CallbackParam, value: bool) -> None:
164 """
165 !!! note "Summary"
166 Show help and exit.
168 Params:
169 ctx (Context):
170 The context object.
171 param (CallbackParam):
172 The parameter object.
173 value (bool):
174 The boolean value indicating if the flag was set.
176 Returns:
177 (None):
178 Nothing is returned.
179 """
180 if not value or ctx.resilient_parsing:
181 return
182 echo(ctx.get_help())
183 raise Exit()
186def _example_callback(ctx: Context, param: CallbackParam, value: Optional[str]) -> None:
187 """
188 !!! note "Summary"
189 Handle example flag and show appropriate example content.
191 Params:
192 ctx (Context):
193 The context object.
194 param (CallbackParam):
195 The parameter object.
196 value (Optional[str]):
197 The example type to show: 'config' or 'usage'.
199 Returns:
200 (None):
201 Nothing is returned.
202 """
204 if not value or ctx.resilient_parsing:
205 return
207 if value == "config":
208 _show_config_example_callback()
209 elif value == "usage":
210 _show_usage_examples_callback()
211 else:
212 console.print(_red(f"Error: Invalid example type '{value}'. Use 'config' or 'usage'."))
213 raise Exit(1)
216def _show_usage_examples_callback() -> None:
217 """
218 !!! note "Summary"
219 Show examples and exit.
221 Params:
222 ctx (Context):
223 The context object.
224 param (CallbackParam):
225 The parameter object.
226 value (bool):
227 The boolean value indicating if the flag was set.
229 Returns:
230 (None):
231 Nothing is returned.
232 """
234 examples_content: str = dedent(
235 f"""
236 {_green("dfc myfile.py")} Check a single Python file (list output)
237 {_green("dfc src/")} Check all Python files in src/ directory
238 {_green("dfc --output=table myfile.py")} Check with table output format
239 {_green("dfc -o list myfile.py")} Check with list output format (default)
240 {_green("dfc --check myfile.py")} Check and exit with error if issues found
241 {_green("dfc --quiet myfile.py")} Check quietly, only show pass/fail
242 {_green("dfc --quiet --check myfile.py")} Check quietly and exit with error if issues found
243 {_green("dfc . --exclude '*/tests/*'")} Check current directory, excluding tests
244 {_green("dfc . -c custom.toml")} Use custom configuration file
245 {_green("dfc --example=config")} Show example configuration
246 {_green("dfc -e usage")} Show usage examples (this help)
247 """
248 ).strip()
250 panel = Panel(
251 examples_content,
252 title="Examples",
253 title_align="left",
254 border_style="dim",
255 padding=(0, 1),
256 )
258 console.print(panel)
259 raise Exit()
262def _show_config_example_callback() -> None:
263 """
264 !!! note "Summary"
265 Show configuration example and exit.
267 Params:
268 ctx (Context):
269 The context object.
270 param (CallbackParam):
271 The parameter object.
272 value (bool):
273 The boolean value indicating if the flag was set.
275 Returns:
276 (None):
277 Nothing is returned.
278 """
280 example_config: str = dedent(
281 """
282 # Example configuration for docstring-format-checker
283 # Place this in your pyproject.toml file
285 [tool.dfc]
286 # or [tool.docstring-format-checker]
288 [[tool.dfc.sections]]
289 order = 1
290 name = "summary"
291 type = "free_text"
292 admonition = "note"
293 prefix = "!!!"
294 required = true
296 [[tool.dfc.sections]]
297 order = 2
298 name = "details"
299 type = "free_text"
300 admonition = "info"
301 prefix = "???+"
302 required = false
304 [[tool.dfc.sections]]
305 order = 3
306 name = "params"
307 type = "list_name_and_type"
308 required = true
310 [[tool.dfc.sections]]
311 order = 4
312 name = "returns"
313 type = "list_name_and_type"
314 required = false
316 [[tool.dfc.sections]]
317 order = 5
318 name = "yields"
319 type = "list_type"
320 required = false
322 [[tool.dfc.sections]]
323 order = 6
324 name = "raises"
325 type = "list_type"
326 required = false
328 [[tool.dfc.sections]]
329 order = 7
330 name = "examples"
331 type = "free_text"
332 admonition = "example"
333 prefix = "???+"
334 required = false
336 [[tool.dfc.sections]]
337 order = 8
338 name = "notes"
339 type = "free_text"
340 admonition = "note"
341 prefix = "???"
342 required = false
343 """
344 ).strip()
346 # Print without Rich markup processing to avoid bracket interpretation
347 console.print(example_config, markup=False)
348 raise Exit()
351def _format_error_messages(error_message: str) -> str:
352 """
353 !!! note "Summary"
354 Format error messages for better readability in CLI output.
356 Params:
357 error_message (str):
358 The raw error message that may contain semicolon-separated errors
360 Returns:
361 (str):
362 Formatted error message with each error prefixed with "- " and separated by ";\n"
363 """
364 if "; " in error_message:
365 # Split by semicolon and rejoin with proper formatting
366 errors: list[str] = error_message.split("; ")
367 formatted_errors: list[str] = [f"- {error.strip()}" for error in errors if error.strip()]
368 return ";\n".join(formatted_errors) + "."
369 else:
370 # Single error message
371 return f"- {error_message.strip()}."
374def _display_results(results: dict[str, list[DocstringError]], quiet: bool, output: str, check: bool) -> int:
375 """
376 !!! note "Summary"
377 Display the results of docstring checking.
379 Params:
380 results (dict[str, list[DocstringError]]):
381 Dictionary mapping file paths to lists of errors
382 quiet (bool):
383 Whether to suppress success messages and error details
384 output (str):
385 Output format: 'table' or 'list'
386 check (bool):
387 Whether this is a check run (affects quiet behavior)
389 Returns:
390 (int):
391 Exit code (`0` for success, `1` for errors found)
392 """
393 if not results:
394 if not quiet:
395 console.print(_green("✓ All docstrings are valid!"))
396 return 0
398 # Count total errors (individual error messages, not error objects)
399 total_individual_errors: int = 0
400 total_functions: int = 0
402 for errors in results.values():
403 total_functions += len(errors) # Count functions/items with errors
404 for error in errors:
405 # Count individual error messages within each error object
406 if "; " in error.message:
407 individual_errors = [msg.strip() for msg in error.message.split("; ") if msg.strip()]
408 total_individual_errors += len(individual_errors)
409 else:
410 total_individual_errors += 1
412 total_errors: int = total_individual_errors
413 total_files: int = len(results)
415 if quiet:
416 # In quiet mode, only show summary with improved format
417 if total_functions == 1:
418 functions_text = f"1 function"
419 else:
420 functions_text = f"{total_functions} functions"
422 if total_files == 1:
423 files_text = f"1 file"
424 else:
425 files_text = f"{total_files} files"
427 console.print(_red(f"{NEW_LINE}Found {total_errors} error(s) in {functions_text} over {files_text}"))
428 return 1
430 if output == "table":
431 # Show detailed table
432 table = Table(show_header=True, header_style="bold magenta")
433 table.add_column("File", style="cyan", no_wrap=False)
434 table.add_column("Line", justify="right", style="white")
435 table.add_column("Item", style="yellow")
436 table.add_column("Type", style="blue")
437 table.add_column("Error", style="red")
439 for file_path, errors in results.items():
440 for i, error in enumerate(errors):
441 file_display: str = file_path if i == 0 else ""
443 # Format error message with improved formatting
444 formatted_error_message: str = _format_error_messages(error.message)
446 table.add_row(
447 file_display,
448 str(error.line_number) if error.line_number > 0 else "",
449 error.item_name,
450 error.item_type,
451 formatted_error_message,
452 )
453 console.print(table)
455 else:
456 # Show compact output with grouped errors under function/class headers
457 for file_path, errors in results.items():
458 console.print(f"{NEW_LINE}{_cyan(file_path)}")
459 for error in errors:
460 # Print the header line with line number, item type and name
461 if error.line_number > 0:
462 console.print(f" [red]Line {error.line_number}[/red] - {error.item_type} '{error.item_name}':")
463 else:
464 console.print(f" {_red('Error')} - {error.item_type} '{error.item_name}':")
466 # Split error message into individual errors and indent them
467 if "; " in error.message:
468 individual_errors = [msg.strip() for msg in error.message.split("; ") if msg.strip()]
469 for individual_error in individual_errors:
470 console.print(f" - {individual_error}")
471 else:
472 # Single error message
473 console.print(f" - {error.message.strip()}")
475 # Summary - more descriptive message
476 if total_functions == 1:
477 functions_text = f"1 function"
478 else:
479 functions_text = f"{total_functions} functions"
481 if total_files == 1:
482 files_text = f"1 file"
483 else:
484 files_text = f"{total_files} files"
486 console.print(_red(f"{NEW_LINE}Found {total_errors} error(s) in {functions_text} over {files_text}"))
488 return 1
491# ---------------------------------------------------------------------------- #
492# #
493# Main Logic ####
494# #
495# ---------------------------------------------------------------------------- #
498# This will be the default behavior when no command is specified
499def check_docstrings(
500 path: str,
501 config: Optional[str] = None,
502 exclude: Optional[list[str]] = None,
503 quiet: bool = False,
504 output: str = "list",
505 check: bool = False,
506) -> None:
507 """
508 !!! note "Summary"
509 Core logic for checking docstrings.
511 Params:
512 path (str):
513 The path to the file or directory to check.
514 config (Optional[str]):
515 The path to the configuration file.
516 Default: `None`.
517 exclude (Optional[list[str]]):
518 List of glob patterns to exclude from checking.
519 Default: `None`.
520 quiet (bool):
521 Whether to suppress output.
522 Default: `False`.
523 output (str):
524 Output format: 'table' or 'list'.
525 Default: `'list'`.
526 check (bool):
527 Whether to throw error if issues are found.
528 Default: `False`.
530 Returns:
531 (None):
532 Nothing is returned.
533 """
535 target_path = Path(path)
537 # Validate target path
538 if not target_path.exists():
539 console.print(_red(f"Error: Path does not exist: '{path}'"))
540 raise Exit(1)
542 # Load configuration
543 try:
544 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)
550 else:
551 # Try to find config file automatically
552 found_config: Optional[Path] = find_config_file(target_path if target_path.is_dir() else target_path.parent)
553 if found_config:
554 config_obj: Config = load_config(found_config)
555 else:
556 config_obj: Config = load_config()
558 except Exception as e:
559 console.print(_red(f"Error loading configuration: {e}"))
560 raise Exit(1)
562 # Initialize checker
563 checker = DocstringChecker(config_obj)
565 # Check files
566 try:
567 if target_path.is_file():
568 errors: list[DocstringError] = checker.check_file(target_path)
569 results: dict[str, list[DocstringError]] = {str(target_path): errors} if errors else {}
570 else:
571 results: dict[str, list[DocstringError]] = checker.check_directory(target_path, exclude_patterns=exclude)
572 except Exception as e:
573 console.print(_red(f"Error during checking: {e}"))
574 raise Exit(1)
576 # Display results
577 exit_code: int = _display_results(results, quiet, output, check)
579 # Always exit with error code if issues are found, regardless of check flag
580 if exit_code != 0:
581 raise Exit(exit_code)
584# ---------------------------------------------------------------------------- #
585# #
586# App Operators ####
587# #
588# ---------------------------------------------------------------------------- #
591# Simple callback that only handles global options and delegates to subcommands
592@app.callback(invoke_without_command=True)
593def main(
594 ctx: Context,
595 path: Optional[str] = Argument(None, help="Path to Python file or directory to check"),
596 config: Optional[str] = Option(None, "--config", "-f", help="Path to configuration file (TOML format)"),
597 exclude: Optional[list[str]] = Option(
598 None,
599 "--exclude",
600 "-x",
601 help="Glob patterns to exclude (can be used multiple times)",
602 ),
603 output: str = Option(
604 "list",
605 "--output",
606 "-o",
607 help="Output format: 'table' or 'list'",
608 show_default=True,
609 ),
610 check: bool = Option(
611 False,
612 "--check",
613 "-c",
614 help="Throw error (exit 1) if any issues are found",
615 ),
616 quiet: bool = Option(
617 False,
618 "--quiet",
619 "-q",
620 help="Only output pass/fail confirmation, suppress errors unless failing",
621 ),
622 example: Optional[str] = Option(
623 None,
624 "--example",
625 "-e",
626 callback=_example_callback,
627 is_eager=True,
628 help="Show examples: 'config' for configuration example, 'usage' for usage examples",
629 ),
630 version: Optional[bool] = Option(
631 None,
632 "--version",
633 "-v",
634 callback=_version_callback,
635 is_eager=True,
636 help="Show version and exit",
637 ),
638 help_flag: Optional[bool] = Option(
639 None,
640 "--help",
641 "-h",
642 callback=_help_callback_main,
643 is_eager=True,
644 help="Show this message and exit",
645 ),
646) -> None:
647 """
648 !!! note "Summary"
649 Check Python docstring formatting and completeness.
651 ???+ abstract "Details"
652 This tool analyzes Python files and validates that functions, methods, and classes have properly formatted docstrings according to the configured sections.
654 Params:
655 ctx (Context):
656 The context object for the command.
657 path (Optional[str]):
658 Path to Python file or directory to check.
659 config (Optional[str]):
660 Path to configuration file (TOML format).
661 exclude (Optional[list[str]]):
662 Glob patterns to exclude.
663 output (str):
664 Output format: 'table' or 'list'.
665 check (bool):
666 Throw error if any issues are found.
667 quiet (bool):
668 Only output pass/fail confirmation.
669 example (Optional[str]):
670 Show examples: 'config' or 'usage'.
671 version (Optional[bool]):
672 Show version and exit.
673 help_flag (Optional[bool]):
674 Show help message and exit.
676 Returns:
677 (None):
678 Nothing is returned.
679 """
681 # If no path is provided, show help
682 if path is None:
683 echo(ctx.get_help())
684 raise Exit(0)
686 # Validate output format
687 if output not in ["table", "list"]:
688 console.print(_red(f"Error: Invalid output format '{output}'. Use 'table' or 'list'."))
689 raise Exit(1)
691 check_docstrings(
692 path=path,
693 config=config,
694 exclude=exclude,
695 quiet=quiet,
696 output=output,
697 check=check,
698 )
701def entry_point() -> None:
702 """
703 !!! note "Summary"
704 Entry point for the CLI scripts defined in pyproject.toml.
705 """
706 app()
709if __name__ == "__main__":
710 app()