Coverage for src/griffe/_internal/cli.py: 60.49%

203 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-11 13:44 +0200

1# This module contains all CLI-related things. 

2# Why does this file exist, and why not put this in `__main__`? 

3# 

4# We might be tempted to import things from `__main__` later, 

5# but that will cause problems; the code will get executed twice: 

6# 

7# - When we run `python -m griffe`, Python will execute 

8# `__main__.py` as a script. That means there won't be any 

9# `griffe.__main__` in `sys.modules`. 

10# - When you import `__main__` it will get executed again (as a module) because 

11# there's no `griffe.__main__` in `sys.modules`. 

12 

13from __future__ import annotations 

14 

15import argparse 

16import json 

17import logging 

18import os 

19import re 

20import sys 

21from datetime import datetime, timezone 

22from pathlib import Path 

23from typing import IO, TYPE_CHECKING, Any, Callable 

24 

25import colorama 

26 

27from griffe._internal import debug 

28from griffe._internal.diff import find_breaking_changes 

29from griffe._internal.encoders import JSONEncoder 

30from griffe._internal.enumerations import ExplanationStyle, Parser 

31from griffe._internal.exceptions import ExtensionError, GitError 

32from griffe._internal.extensions.base import load_extensions 

33from griffe._internal.git import get_latest_tag, get_repo_root 

34from griffe._internal.loader import GriffeLoader, load, load_git, load_pypi 

35from griffe._internal.logger import logger 

36 

37if TYPE_CHECKING: 

38 from collections.abc import Sequence 

39 

40 from griffe._internal.extensions.base import Extension, Extensions 

41 

42 

43DEFAULT_LOG_LEVEL = os.getenv("GRIFFE_LOG_LEVEL", "INFO").upper() 

44"""The default log level for the CLI. 

45 

46This can be overridden by the `GRIFFE_LOG_LEVEL` environment variable. 

47""" 

48 

49 

50class _DebugInfo(argparse.Action): 

51 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None: 

52 super().__init__(nargs=nargs, **kwargs) 

53 

54 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 

55 debug._print_debug_info() 

56 sys.exit(0) 

57 

58 

59def _print_data(data: str, output_file: str | IO | None) -> None: 

60 if isinstance(output_file, str): 60 ↛ 64line 60 didn't jump to line 64 because the condition on line 60 was always true

61 with open(output_file, "w") as fd: # noqa: PTH123 

62 print(data, file=fd) 

63 else: 

64 if output_file is None: 

65 output_file = sys.stdout 

66 print(data, file=output_file) 

67 

68 

69def _load_packages( 

70 packages: Sequence[str], 

71 *, 

72 extensions: Extensions | None = None, 

73 search_paths: Sequence[str | Path] | None = None, 

74 docstring_parser: Parser | None = None, 

75 docstring_options: dict[str, Any] | None = None, 

76 resolve_aliases: bool = True, 

77 resolve_implicit: bool = False, 

78 resolve_external: bool | None = None, 

79 allow_inspection: bool = True, 

80 force_inspection: bool = False, 

81 store_source: bool = True, 

82 find_stubs_package: bool = False, 

83) -> GriffeLoader: 

84 # Create a single loader. 

85 loader = GriffeLoader( 

86 extensions=extensions, 

87 search_paths=search_paths, 

88 docstring_parser=docstring_parser, 

89 docstring_options=docstring_options, 

90 allow_inspection=allow_inspection, 

91 force_inspection=force_inspection, 

92 store_source=store_source, 

93 ) 

94 

95 # Load each package. 

96 for package in packages: 

97 if not package: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true

98 logger.debug("Empty package name, continuing") 

99 continue 

100 logger.info("Loading package %s", package) 

101 try: 

102 loader.load(package, try_relative_path=True, find_stubs_package=find_stubs_package) 

103 except ModuleNotFoundError as error: 

104 logger.error("Could not find package %s: %s", package, error) 

105 except ImportError: 

106 logger.exception("Tried but could not import package %s", package) 

107 logger.info("Finished loading packages") 

108 

109 # Resolve aliases. 

110 if resolve_aliases: 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true

111 logger.info("Starting alias resolution") 

112 unresolved, iterations = loader.resolve_aliases(implicit=resolve_implicit, external=resolve_external) 

113 if unresolved: 

114 logger.info("%s aliases were still unresolved after %s iterations", len(unresolved), iterations) 

115 else: 

116 logger.info("All aliases were resolved after %s iterations", iterations) 

117 return loader 

118 

119 

120_level_choices = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") 

121 

122 

123def _extensions_type(value: str) -> Sequence[str | dict[str, Any]]: 

124 try: 

125 return json.loads(value) 

126 except json.JSONDecodeError: 

127 return value.split(",") 

128 

129 

130def get_parser() -> argparse.ArgumentParser: 

131 """Return the CLI argument parser. 

132 

133 Returns: 

134 An argparse parser. 

135 """ 

136 usage = "%(prog)s [GLOBAL_OPTS...] COMMAND [COMMAND_OPTS...]" 

137 description = "Signatures for entire Python programs. " 

138 "Extract the structure, the frame, the skeleton of your project, " 

139 "to generate API documentation or find breaking changes in your API." 

140 parser = argparse.ArgumentParser(add_help=False, usage=usage, description=description, prog="griffe") 

141 

142 main_help = "Show this help message and exit. Commands also accept the -h/--help option." 

143 subcommand_help = "Show this help message and exit." 

144 

145 global_options = parser.add_argument_group(title="Global options") 

146 global_options.add_argument("-h", "--help", action="help", help=main_help) 

147 global_options.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}") 

148 global_options.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.") 

149 

150 def add_common_options(subparser: argparse.ArgumentParser) -> None: 

151 common_options = subparser.add_argument_group(title="Common options") 

152 common_options.add_argument("-h", "--help", action="help", help=subcommand_help) 

153 search_options = subparser.add_argument_group(title="Search options") 

154 search_options.add_argument( 

155 "-s", 

156 "--search", 

157 dest="search_paths", 

158 action="append", 

159 type=Path, 

160 help="Paths to search packages into.", 

161 ) 

162 search_options.add_argument( 

163 "-y", 

164 "--sys-path", 

165 dest="append_sys_path", 

166 action="store_true", 

167 help="Whether to append `sys.path` to search paths specified with `-s`.", 

168 ) 

169 loading_options = subparser.add_argument_group(title="Loading options") 

170 loading_options.add_argument( 

171 "-B", 

172 "--find-stubs-packages", 

173 dest="find_stubs_package", 

174 action="store_true", 

175 default=False, 

176 help="Whether to look for stubs-only packages and merge them with concrete ones.", 

177 ) 

178 loading_options.add_argument( 

179 "-e", 

180 "--extensions", 

181 default={}, 

182 type=_extensions_type, 

183 help="A list of extensions to use.", 

184 ) 

185 loading_options.add_argument( 

186 "-X", 

187 "--no-inspection", 

188 dest="allow_inspection", 

189 action="store_false", 

190 default=True, 

191 help="Disallow inspection of builtin/compiled/not found modules.", 

192 ) 

193 loading_options.add_argument( 

194 "-x", 

195 "--force-inspection", 

196 dest="force_inspection", 

197 action="store_true", 

198 default=False, 

199 help="Force inspection of everything, even when sources are found.", 

200 ) 

201 debug_options = subparser.add_argument_group(title="Debugging options") 

202 debug_options.add_argument( 

203 "-L", 

204 "--log-level", 

205 metavar="LEVEL", 

206 default=DEFAULT_LOG_LEVEL, 

207 choices=_level_choices, 

208 type=str.upper, 

209 help="Set the log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`.", 

210 ) 

211 

212 # ========= SUBPARSERS ========= # 

213 subparsers = parser.add_subparsers( 

214 dest="subcommand", 

215 title="Commands", 

216 metavar="COMMAND", 

217 prog="griffe", 

218 required=True, 

219 ) 

220 

221 def add_subparser(command: str, text: str, **kwargs: Any) -> argparse.ArgumentParser: 

222 return subparsers.add_parser(command, add_help=False, help=text, description=text, **kwargs) 

223 

224 # ========= DUMP PARSER ========= # 

225 dump_parser = add_subparser("dump", "Load package-signatures and dump them as JSON.") 

226 dump_options = dump_parser.add_argument_group(title="Dump options") 

227 dump_options.add_argument("packages", metavar="PACKAGE", nargs="+", help="Packages to find, load and dump.") 

228 dump_options.add_argument( 

229 "-f", 

230 "--full", 

231 action="store_true", 

232 default=False, 

233 help="Whether to dump full data in JSON.", 

234 ) 

235 dump_options.add_argument( 

236 "-o", 

237 "--output", 

238 default=sys.stdout, 

239 help="Output file. Supports templating to output each package in its own file, with `{package}`.", 

240 ) 

241 dump_options.add_argument( 

242 "-d", 

243 "--docstyle", 

244 dest="docstring_parser", 

245 default=None, 

246 type=Parser, 

247 help="The docstring style to parse.", 

248 ) 

249 dump_options.add_argument( 

250 "-D", 

251 "--docopts", 

252 dest="docstring_options", 

253 default={}, 

254 type=json.loads, 

255 help="The options for the docstring parser.", 

256 ) 

257 dump_options.add_argument( 

258 "-r", 

259 "--resolve-aliases", 

260 action="store_true", 

261 help="Whether to resolve aliases.", 

262 ) 

263 dump_options.add_argument( 

264 "-I", 

265 "--resolve-implicit", 

266 action="store_true", 

267 help="Whether to resolve implicitly exported aliases as well. " 

268 "Aliases are explicitly exported when defined in `__all__`.", 

269 ) 

270 dump_options.add_argument( 

271 "-U", 

272 "--resolve-external", 

273 dest="resolve_external", 

274 action="store_true", 

275 help="Always resolve aliases pointing to external/unknown modules (not loaded directly)." 

276 "Default is to resolve only from one module to its private sibling (`ast` -> `_ast`).", 

277 ) 

278 dump_options.add_argument( 

279 "--no-resolve-external", 

280 dest="resolve_external", 

281 action="store_false", 

282 help="Never resolve aliases pointing to external/unknown modules (not loaded directly)." 

283 "Default is to resolve only from one module to its private sibling (`ast` -> `_ast`).", 

284 ) 

285 dump_options.add_argument( 

286 "-S", 

287 "--stats", 

288 action="store_true", 

289 help="Show statistics at the end.", 

290 ) 

291 add_common_options(dump_parser) 

292 

293 # ========= CHECK PARSER ========= # 

294 check_parser = add_subparser("check", "Check for API breakages or possible improvements.") 

295 check_options = check_parser.add_argument_group(title="Check options") 

296 check_options.add_argument("package", metavar="PACKAGE", help="Package to find, load and check, as path.") 

297 check_options.add_argument( 

298 "-a", 

299 "--against", 

300 metavar="REF", 

301 help="Older Git reference (commit, branch, tag) to check against. Default: load latest tag.", 

302 ) 

303 check_options.add_argument( 

304 "-b", 

305 "--base-ref", 

306 metavar="BASE_REF", 

307 help="Git reference (commit, branch, tag) to check. Default: load current code.", 

308 ) 

309 check_options.add_argument( 

310 "--color", 

311 dest="color", 

312 action="store_true", 

313 default=None, 

314 help="Force enable colors in the output.", 

315 ) 

316 check_options.add_argument( 

317 "--no-color", 

318 dest="color", 

319 action="store_false", 

320 default=None, 

321 help="Force disable colors in the output.", 

322 ) 

323 check_options.add_argument("-v", "--verbose", action="store_true", help="Verbose output.") 

324 formats = [fmt.value for fmt in ExplanationStyle] 

325 check_options.add_argument("-f", "--format", dest="style", choices=formats, default=None, help="Output format.") 

326 add_common_options(check_parser) 

327 

328 return parser 

329 

330 

331def dump( 

332 packages: Sequence[str], 

333 *, 

334 output: str | IO | None = None, 

335 full: bool = False, 

336 docstring_parser: Parser | None = None, 

337 docstring_options: dict[str, Any] | None = None, 

338 extensions: Sequence[str | dict[str, Any] | Extension | type[Extension]] | None = None, 

339 resolve_aliases: bool = False, 

340 resolve_implicit: bool = False, 

341 resolve_external: bool | None = None, 

342 search_paths: Sequence[str | Path] | None = None, 

343 find_stubs_package: bool = False, 

344 append_sys_path: bool = False, 

345 allow_inspection: bool = True, 

346 force_inspection: bool = False, 

347 stats: bool = False, 

348) -> int: 

349 """Load packages data and dump it as JSON. 

350 

351 Parameters: 

352 packages: The packages to load and dump. 

353 output: Where to output the JSON-serialized data. 

354 full: Whether to output full or minimal data. 

355 docstring_parser: The docstring parser to use. By default, no parsing is done. 

356 docstring_options: Additional docstring parsing options. 

357 resolve_aliases: Whether to resolve aliases (indirect objects references). 

358 resolve_implicit: Whether to resolve every alias or only the explicitly exported ones. 

359 resolve_external: Whether to load additional, unspecified modules to resolve aliases. 

360 Default is to resolve only from one module to its private sibling (`ast` -> `_ast`). 

361 extensions: The extensions to use. 

362 search_paths: The paths to search into. 

363 find_stubs_package: Whether to search for stubs-only packages. 

364 If both the package and its stubs are found, they'll be merged together. 

365 If only the stubs are found, they'll be used as the package itself. 

366 append_sys_path: Whether to append the contents of `sys.path` to the search paths. 

367 allow_inspection: Whether to allow inspecting modules when visiting them is not possible. 

368 force_inspection: Whether to force using dynamic analysis when loading data. 

369 stats: Whether to compute and log stats about loading. 

370 

371 Returns: 

372 `0` for success, `1` for failure. 

373 """ 

374 # Prepare options. 

375 per_package_output = False 

376 if isinstance(output, str) and output.format(package="package") != output: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true

377 per_package_output = True 

378 

379 search_paths = list(search_paths) if search_paths else [] 

380 if append_sys_path: 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true

381 search_paths.extend(sys.path) 

382 

383 try: 

384 loaded_extensions = load_extensions(*(extensions or ())) 

385 except ExtensionError: 

386 logger.exception("Could not load extensions") 

387 return 1 

388 

389 # Load packages. 

390 loader = _load_packages( 

391 packages, 

392 extensions=loaded_extensions, 

393 search_paths=search_paths, 

394 docstring_parser=docstring_parser, 

395 docstring_options=docstring_options, 

396 resolve_aliases=resolve_aliases, 

397 resolve_implicit=resolve_implicit, 

398 resolve_external=resolve_external, 

399 allow_inspection=allow_inspection, 

400 force_inspection=force_inspection, 

401 store_source=False, 

402 find_stubs_package=find_stubs_package, 

403 ) 

404 data_packages = loader.modules_collection.members 

405 

406 # Serialize and dump packages. 

407 started = datetime.now(tz=timezone.utc) 

408 if per_package_output: 408 ↛ 409line 408 didn't jump to line 409 because the condition on line 408 was never true

409 for package_name, data in data_packages.items(): 

410 serialized = data.as_json(indent=2, full=full, sort_keys=True) 

411 _print_data(serialized, output.format(package=package_name)) # type: ignore[union-attr] 

412 else: 

413 serialized = json.dumps(data_packages, cls=JSONEncoder, indent=2, full=full, sort_keys=True) 

414 _print_data(serialized, output) 

415 elapsed = datetime.now(tz=timezone.utc) - started 

416 

417 if stats: 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true

418 loader_stats = loader.stats() 

419 loader_stats.time_spent_serializing = elapsed.microseconds 

420 logger.info(loader_stats.as_text()) 

421 

422 return 0 if len(data_packages) == len(packages) else 1 

423 

424 

425def check( 

426 package: str | Path, 

427 against: str | None = None, 

428 against_path: str | Path | None = None, 

429 *, 

430 base_ref: str | None = None, 

431 extensions: Sequence[str | dict[str, Any] | Extension | type[Extension]] | None = None, 

432 search_paths: Sequence[str | Path] | None = None, 

433 append_sys_path: bool = False, 

434 find_stubs_package: bool = False, 

435 allow_inspection: bool = True, 

436 force_inspection: bool = False, 

437 verbose: bool = False, 

438 color: bool | None = None, 

439 style: str | ExplanationStyle | None = None, 

440) -> int: 

441 """Check for API breaking changes in two versions of the same package. 

442 

443 Parameters: 

444 package: The package to load and check. 

445 against: Older Git reference (commit, branch, tag) to check against. 

446 against_path: Path when the "against" reference is checked out. 

447 base_ref: Git reference (commit, branch, tag) to check. 

448 extensions: The extensions to use. 

449 search_paths: The paths to search into. 

450 append_sys_path: Whether to append the contents of `sys.path` to the search paths. 

451 allow_inspection: Whether to allow inspecting modules when visiting them is not possible. 

452 force_inspection: Whether to force using dynamic analysis when loading data. 

453 verbose: Use a verbose output. 

454 

455 Returns: 

456 `0` for success, `1` for failure. 

457 """ 

458 # Prepare options. 

459 search_paths = list(search_paths) if search_paths else [] 

460 if append_sys_path: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 search_paths.extend(sys.path) 

462 

463 try: 

464 loaded_extensions = load_extensions(*(extensions or ())) 

465 except ExtensionError: 

466 logger.exception("Could not load extensions") 

467 return 1 

468 

469 if match_against := re.match(r"([\w.-]+)?((==|<=|<|>=|>|!=).+)", against or ""): 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true

470 against_dist = (match_against.group(1) or str(package)).lower().replace("-", "_") 

471 against_version = match_against.group(2) 

472 old_package = load_pypi( 

473 str(package), 

474 against_dist, 

475 against_version, 

476 extensions=loaded_extensions, 

477 search_paths=search_paths, 

478 allow_inspection=allow_inspection, 

479 force_inspection=force_inspection, 

480 find_stubs_package=find_stubs_package, 

481 resolve_aliases=True, 

482 resolve_external=None, 

483 ) 

484 

485 if base_ref: 

486 if not (match_base := re.match(r"([\w.-]+)?((==|<=|<|>=|>|!=).+)", base_ref)): 

487 raise ValueError(f"Base {base_ref} is not a valid dependency specifier.") 

488 base_dist = (match_base.group(1) or str(package)).lower().replace("-", "_") 

489 base_version = match_base.group(2) 

490 else: 

491 base_dist = against_dist 

492 base_version = "" 

493 new_package = load_pypi( 

494 str(package), 

495 base_dist, 

496 base_version, 

497 extensions=loaded_extensions, 

498 search_paths=search_paths, 

499 allow_inspection=allow_inspection, 

500 force_inspection=force_inspection, 

501 find_stubs_package=find_stubs_package, 

502 resolve_aliases=True, 

503 resolve_external=None, 

504 ) 

505 

506 else: 

507 try: 

508 against = against or get_latest_tag(package) 

509 except GitError as error: 

510 print(f"griffe: error: {error}", file=sys.stderr) 

511 return 2 

512 against_path = against_path or package 

513 repository = get_repo_root(against_path) 

514 

515 # Load old and new version of the package. 

516 old_package = load_git( 

517 against_path, 

518 ref=against, 

519 repo=repository, 

520 extensions=loaded_extensions, 

521 search_paths=search_paths, 

522 allow_inspection=allow_inspection, 

523 force_inspection=force_inspection, 

524 find_stubs_package=find_stubs_package, 

525 resolve_aliases=True, 

526 resolve_external=None, 

527 ) 

528 

529 if base_ref: 

530 new_package = load_git( 

531 package, 

532 ref=base_ref, 

533 repo=repository, 

534 extensions=loaded_extensions, 

535 search_paths=search_paths, 

536 allow_inspection=allow_inspection, 

537 force_inspection=force_inspection, 

538 find_stubs_package=find_stubs_package, 

539 resolve_aliases=True, 

540 resolve_external=None, 

541 ) 

542 else: 

543 new_package = load( 

544 package, 

545 try_relative_path=True, 

546 extensions=loaded_extensions, 

547 search_paths=search_paths, 

548 allow_inspection=allow_inspection, 

549 force_inspection=force_inspection, 

550 find_stubs_package=find_stubs_package, 

551 resolve_aliases=True, 

552 resolve_external=None, 

553 ) 

554 

555 # Find and display API breakages. 

556 breakages = list(find_breaking_changes(old_package, new_package)) 

557 

558 if color is None and (force_color := os.getenv("FORCE_COLOR", None)) is not None: 

559 color = force_color.lower() in {"1", "true", "y", "yes", "on"} 

560 colorama.deinit() 

561 colorama.init(strip=color if color is None else not color) 

562 

563 if style is None: 

564 style = ExplanationStyle.VERBOSE if verbose else ExplanationStyle.ONE_LINE 

565 else: 

566 style = ExplanationStyle(style) 

567 for breakage in breakages: 

568 print(breakage.explain(style=style), file=sys.stderr) 

569 

570 if breakages: 

571 return 1 

572 return 0 

573 

574 

575def main(args: list[str] | None = None) -> int: 

576 """Run the main program. 

577 

578 This function is executed when you type `griffe` or `python -m griffe`. 

579 

580 Parameters: 

581 args: Arguments passed from the command line. 

582 

583 Returns: 

584 An exit code. 

585 """ 

586 # Parse arguments. 

587 parser = get_parser() 

588 opts: argparse.Namespace = parser.parse_args(args) 

589 opts_dict = opts.__dict__ 

590 opts_dict.pop("debug_info") 

591 subcommand = opts_dict.pop("subcommand") 

592 

593 # Initialize logging. 

594 log_level = opts_dict.pop("log_level", DEFAULT_LOG_LEVEL) 

595 try: 

596 level = getattr(logging, log_level) 

597 except AttributeError: 

598 choices = "', '".join(_level_choices) 

599 print( 

600 f"griffe: error: invalid log level '{log_level}' (choose from '{choices}')", 

601 file=sys.stderr, 

602 ) 

603 return 1 

604 else: 

605 logging.basicConfig(format="%(levelname)-10s %(message)s", level=level) 

606 

607 # Run subcommand. 

608 commands: dict[str, Callable[..., int]] = {"check": check, "dump": dump} 

609 return commands[subcommand](**opts_dict)