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

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 pathlib import Path 

47from textwrap import dedent 

48from typing import Optional, Union 

49 

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) 

65 

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 

70 

71 

72## --------------------------------------------------------------------------- # 

73## Exports #### 

74## --------------------------------------------------------------------------- # 

75 

76 

77__all__: list[str] = [ 

78 "main", 

79 "config_example", 

80 "check", 

81 "entry_point", 

82] 

83 

84 

85## --------------------------------------------------------------------------- # 

86## Constants #### 

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

88 

89 

90NEW_LINE = "\n" 

91 

92 

93# ---------------------------------------------------------------------------- # 

94# # 

95# Main Application #### 

96# # 

97# ---------------------------------------------------------------------------- # 

98 

99 

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

108 

109 

110# ---------------------------------------------------------------------------- # 

111# # 

112# Callbacks #### 

113# # 

114# ---------------------------------------------------------------------------- # 

115 

116 

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

118 """ 

119 !!! note "Summary" 

120 Print version and exit. 

121 

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. 

129 

130 Returns: 

131 (None): 

132 Nothing is returned. 

133 """ 

134 if value: 

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

136 raise Exit() 

137 

138 

139def _help_callback(ctx: Context, param: CallbackParam, value: bool) -> None: 

140 """ 

141 !!! note "Summary" 

142 Show help and exit. 

143 

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. 

151 

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

160 

161 

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. 

166 

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. 

174 

175 Returns: 

176 (Optional[bool]): 

177 The parsed boolean value or `None` if not provided. 

178 """ 

179 

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 

185 

186 # If value is an empty string, it means the flag was provided without a value 

187 if value == "": 

188 return True 

189 

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 

199 

200 

201def _parse_recursive_flag(value: str) -> bool: 

202 """ 

203 !!! note "Summary" 

204 Parse recursive flag using `strtobool()` utility. 

205 

206 Params: 

207 value (str): 

208 The string value of the flag. 

209 

210 Returns: 

211 (bool): 

212 The parsed boolean value. 

213 """ 

214 return strtobool(value) 

215 

216 

217def _show_examples_callback(ctx: Context, param: CallbackParam, value: bool) -> None: 

218 """ 

219 !!! note "Summary" 

220 Show examples and exit. 

221 

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. 

229 

230 Returns: 

231 (None): 

232 Nothing is returned. 

233 """ 

234 

235 if not value or ctx.resilient_parsing: 

236 return 

237 

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

248 

249 panel = Panel( 

250 examples_content, 

251 title="Examples", 

252 title_align="left", 

253 border_style="dim", 

254 padding=(0, 1), 

255 ) 

256 

257 console.print(panel) 

258 raise Exit() 

259 

260 

261def _show_check_examples_callback(ctx: Context, param: CallbackParam, value: bool) -> None: 

262 """ 

263 !!! note "Summary" 

264 Show check command examples and exit. 

265 

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. 

273 

274 Returns: 

275 (None): 

276 Nothing is returned. 

277 """ 

278 

279 if not value or ctx.resilient_parsing: 

280 return 

281 

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 ) 

292 

293 panel = Panel( 

294 examples_content, 

295 title="Check Command Examples", 

296 title_align="left", 

297 border_style="dim", 

298 padding=(0, 1), 

299 ) 

300 

301 console.print(panel) 

302 raise Exit() 

303 

304 

305def _display_results(results: dict[str, list[DocstringError]], quiet: bool, verbose: bool) -> int: 

306 """ 

307 !!! note "Summary" 

308 Display the results of docstring checking. 

309 

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 

317 

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 

326 

327 # Count total errors 

328 total_errors: int = sum(len(errors) for errors in results.values()) 

329 total_files: int = len(results) 

330 

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

339 

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) 

351 

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

363 

364 # Summary 

365 console.print(f"{NEW_LINE}[red]Found {total_errors} error(s) in {total_files} file(s)[/red]") 

366 

367 return 1 

368 

369 

370# ---------------------------------------------------------------------------- # 

371# # 

372# Main Logic #### 

373# # 

374# ---------------------------------------------------------------------------- # 

375 

376 

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. 

389 

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. 

403 

404 Returns: 

405 (None): 

406 Nothing is returned. 

407 """ 

408 

409 target_path = Path(path) 

410 

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) 

415 

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

437 

438 except Exception as e: 

439 console.print(f"[red]Error loading configuration: {e}[/red]") 

440 raise Exit(1) 

441 

442 # Initialize checker 

443 checker = DocstringChecker(sections_config) 

444 

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) 

461 

462 # Display results 

463 exit_code: int = _display_results(results, quiet, verbose) 

464 

465 if exit_code != 0: 

466 raise Exit(exit_code) 

467 

468 

469# ---------------------------------------------------------------------------- # 

470# # 

471# App Operators #### 

472# # 

473# ---------------------------------------------------------------------------- # 

474 

475 

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. 

508 

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. 

511 

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. 

521 

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

530 

531 

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. 

573 

574 ???+ abstract "Details" 

575 This command checks the docstrings in the specified Python file or directory. 

576 

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. 

594 

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) 

610 

611 

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. 

629 

630 Params: 

631 help_flag (Optional[bool]): 

632 Show help message and exit. 

633 

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 

642 

643 [tool.dfc] 

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

645 

646 [[tool.dfc.sections]] 

647 order = 1 

648 name = "summary" 

649 type = "free_text" 

650 admonition = "note" 

651 prefix = "!!!" 

652 required = true 

653 

654 [[tool.dfc.sections]] 

655 order = 2 

656 name = "details" 

657 type = "free_text" 

658 admonition = "info" 

659 prefix = "???+" 

660 required = false 

661 

662 [[tool.dfc.sections]] 

663 order = 3 

664 name = "params" 

665 type = "list_name_and_type" 

666 required = true 

667 

668 [[tool.dfc.sections]] 

669 order = 4 

670 name = "returns" 

671 type = "list_name_and_type" 

672 required = false 

673 

674 [[tool.dfc.sections]] 

675 order = 5 

676 name = "yields" 

677 type = "list_type" 

678 required = false 

679 

680 [[tool.dfc.sections]] 

681 order = 6 

682 name = "raises" 

683 type = "list_type" 

684 required = false 

685 

686 [[tool.dfc.sections]] 

687 order = 7 

688 name = "examples" 

689 type = "free_text" 

690 admonition = "example" 

691 prefix = "???+" 

692 required = false 

693 

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 ) 

703 

704 print(example_config) 

705 

706 

707def entry_point() -> None: 

708 """ 

709 !!! note "Summary" 

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

711 """ 

712 app() 

713 

714 

715if __name__ == "__main__": 

716 app()