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

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# ============================================================================ # 

13 

14 

15# ---------------------------------------------------------------------------- # 

16# # 

17# Overview #### 

18# # 

19# ---------------------------------------------------------------------------- # 

20 

21 

22# ---------------------------------------------------------------------------- # 

23# Description #### 

24# ---------------------------------------------------------------------------- # 

25 

26 

27""" 

28!!! note "Summary" 

29 Command-line interface for the docstring format checker. 

30""" 

31 

32 

33# ---------------------------------------------------------------------------- # 

34# # 

35# Setup #### 

36# # 

37# ---------------------------------------------------------------------------- # 

38 

39 

40## --------------------------------------------------------------------------- # 

41## Imports #### 

42## --------------------------------------------------------------------------- # 

43 

44 

45# ## Python StdLib Imports ---- 

46from functools import partial 

47from pathlib import Path 

48from textwrap import dedent 

49from typing import Optional 

50 

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 

56 

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 

65 

66 

67## --------------------------------------------------------------------------- # 

68## Exports #### 

69## --------------------------------------------------------------------------- # 

70 

71 

72__all__: list[str] = [ 

73 "main", 

74 "entry_point", 

75 "check_docstrings", 

76] 

77 

78 

79## --------------------------------------------------------------------------- # 

80## Constants #### 

81## --------------------------------------------------------------------------- # 

82 

83 

84NEW_LINE = "\n" 

85 

86 

87## --------------------------------------------------------------------------- # 

88## Helpers #### 

89## --------------------------------------------------------------------------- # 

90 

91 

92### Colours ---- 

93def _colour(text: str, colour: str) -> str: 

94 """ 

95 !!! note "Summary" 

96 Apply Rich colour markup to text. 

97 

98 Params: 

99 text (str): 

100 The text to colour. 

101 colour (str): 

102 The colour to apply, e.g., 'red', 'green', 'blue'. 

103 

104 Returns: 

105 (str): 

106 The text wrapped in Rich colour markup. 

107 """ 

108 return f"[{colour}]{text}[/{colour}]" 

109 

110 

111_green = partial(_colour, colour="green") 

112_red = partial(_colour, colour="red") 

113_cyan = partial(_colour, colour="cyan") 

114_blue = partial(_colour, colour="blue") 

115 

116 

117# ---------------------------------------------------------------------------- # 

118# # 

119# Main Application #### 

120# # 

121# ---------------------------------------------------------------------------- # 

122 

123 

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() 

132 

133 

134# ---------------------------------------------------------------------------- # 

135# # 

136# Callbacks #### 

137# # 

138# ---------------------------------------------------------------------------- # 

139 

140 

141def _version_callback(ctx: Context, param: CallbackParam, value: bool) -> None: 

142 """ 

143 !!! note "Summary" 

144 Print version and exit. 

145 

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. 

153 

154 Returns: 

155 (None): 

156 Nothing is returned. 

157 """ 

158 if value: 

159 echo(f"docstring-format-checker version {__version__}") 

160 raise Exit() 

161 

162 

163def _help_callback_main(ctx: Context, param: CallbackParam, value: bool) -> None: 

164 """ 

165 !!! note "Summary" 

166 Show help and exit. 

167 

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. 

175 

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() 

184 

185 

186def _example_callback(ctx: Context, param: CallbackParam, value: Optional[str]) -> None: 

187 """ 

188 !!! note "Summary" 

189 Handle example flag and show appropriate example content. 

190 

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'. 

198 

199 Returns: 

200 (None): 

201 Nothing is returned. 

202 """ 

203 

204 if not value or ctx.resilient_parsing: 

205 return 

206 

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) 

214 

215 

216def _show_usage_examples_callback() -> None: 

217 """ 

218 !!! note "Summary" 

219 Show examples and exit. 

220 

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. 

228 

229 Returns: 

230 (None): 

231 Nothing is returned. 

232 """ 

233 

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() 

249 

250 panel = Panel( 

251 examples_content, 

252 title="Examples", 

253 title_align="left", 

254 border_style="dim", 

255 padding=(0, 1), 

256 ) 

257 

258 console.print(panel) 

259 raise Exit() 

260 

261 

262def _show_config_example_callback() -> None: 

263 """ 

264 !!! note "Summary" 

265 Show configuration example and exit. 

266 

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. 

274 

275 Returns: 

276 (None): 

277 Nothing is returned. 

278 """ 

279 

280 example_config: str = dedent( 

281 """ 

282 # Example configuration for docstring-format-checker 

283 # Place this in your pyproject.toml file 

284 

285 [tool.dfc] 

286 # or [tool.docstring-format-checker] 

287 

288 [[tool.dfc.sections]] 

289 order = 1 

290 name = "summary" 

291 type = "free_text" 

292 admonition = "note" 

293 prefix = "!!!" 

294 required = true 

295 

296 [[tool.dfc.sections]] 

297 order = 2 

298 name = "details" 

299 type = "free_text" 

300 admonition = "info" 

301 prefix = "???+" 

302 required = false 

303 

304 [[tool.dfc.sections]] 

305 order = 3 

306 name = "params" 

307 type = "list_name_and_type" 

308 required = true 

309 

310 [[tool.dfc.sections]] 

311 order = 4 

312 name = "returns" 

313 type = "list_name_and_type" 

314 required = false 

315 

316 [[tool.dfc.sections]] 

317 order = 5 

318 name = "yields" 

319 type = "list_type" 

320 required = false 

321 

322 [[tool.dfc.sections]] 

323 order = 6 

324 name = "raises" 

325 type = "list_type" 

326 required = false 

327 

328 [[tool.dfc.sections]] 

329 order = 7 

330 name = "examples" 

331 type = "free_text" 

332 admonition = "example" 

333 prefix = "???+" 

334 required = false 

335 

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() 

345 

346 # Print without Rich markup processing to avoid bracket interpretation 

347 console.print(example_config, markup=False) 

348 raise Exit() 

349 

350 

351def _format_error_messages(error_message: str) -> str: 

352 """ 

353 !!! note "Summary" 

354 Format error messages for better readability in CLI output. 

355 

356 Params: 

357 error_message (str): 

358 The raw error message that may contain semicolon-separated errors 

359 

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()}." 

372 

373 

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. 

378 

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) 

388 

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 

397 

398 # Count total errors (individual error messages, not error objects) 

399 total_individual_errors: int = 0 

400 total_functions: int = 0 

401 

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 

411 

412 total_errors: int = total_individual_errors 

413 total_files: int = len(results) 

414 

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" 

421 

422 if total_files == 1: 

423 files_text = f"1 file" 

424 else: 

425 files_text = f"{total_files} files" 

426 

427 console.print(_red(f"{NEW_LINE}Found {total_errors} error(s) in {functions_text} over {files_text}")) 

428 return 1 

429 

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") 

438 

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 "" 

442 

443 # Format error message with improved formatting 

444 formatted_error_message: str = _format_error_messages(error.message) 

445 

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) 

454 

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}':") 

465 

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()}") 

474 

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" 

480 

481 if total_files == 1: 

482 files_text = f"1 file" 

483 else: 

484 files_text = f"{total_files} files" 

485 

486 console.print(_red(f"{NEW_LINE}Found {total_errors} error(s) in {functions_text} over {files_text}")) 

487 

488 return 1 

489 

490 

491# ---------------------------------------------------------------------------- # 

492# # 

493# Main Logic #### 

494# # 

495# ---------------------------------------------------------------------------- # 

496 

497 

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. 

510 

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`. 

529 

530 Returns: 

531 (None): 

532 Nothing is returned. 

533 """ 

534 

535 target_path = Path(path) 

536 

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) 

541 

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() 

557 

558 except Exception as e: 

559 console.print(_red(f"Error loading configuration: {e}")) 

560 raise Exit(1) 

561 

562 # Initialize checker 

563 checker = DocstringChecker(config_obj) 

564 

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) 

575 

576 # Display results 

577 exit_code: int = _display_results(results, quiet, output, check) 

578 

579 # Always exit with error code if issues are found, regardless of check flag 

580 if exit_code != 0: 

581 raise Exit(exit_code) 

582 

583 

584# ---------------------------------------------------------------------------- # 

585# # 

586# App Operators #### 

587# # 

588# ---------------------------------------------------------------------------- # 

589 

590 

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. 

650 

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. 

653 

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. 

675 

676 Returns: 

677 (None): 

678 Nothing is returned. 

679 """ 

680 

681 # If no path is provided, show help 

682 if path is None: 

683 echo(ctx.get_help()) 

684 raise Exit(0) 

685 

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) 

690 

691 check_docstrings( 

692 path=path, 

693 config=config, 

694 exclude=exclude, 

695 quiet=quiet, 

696 output=output, 

697 check=check, 

698 ) 

699 

700 

701def entry_point() -> None: 

702 """ 

703 !!! note "Summary" 

704 Entry point for the CLI scripts defined in pyproject.toml. 

705 """ 

706 app() 

707 

708 

709if __name__ == "__main__": 

710 app()