Coverage for packages / griffecli / src / griffecli / _internal / cli.py: 62.70%

212 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-11 11:48 +0100

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 griffecli`, Python will execute 

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

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

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

11# there's no `griffecli.__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 

24 

25import colorama 

26 

27if TYPE_CHECKING: 

28 from collections.abc import Callable, Sequence 

29 

30 from griffe._internal.docstrings.parsers import DocstringOptions, DocstringStyle 

31 from griffe._internal.enumerations import ExplanationStyle, Parser 

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

33 from griffe._internal.loader import GriffeLoader 

34 

35 

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

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

38 

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

40""" 

41 

42 

43class _DebugInfo(argparse.Action): 

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

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

46 

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

48 from griffe._internal import debug # noqa: PLC0415 

49 

50 debug._print_debug_info() 

51 sys.exit(0) 

52 

53 

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

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

56 with open(output_file, "w", encoding="utf8") as fd: # noqa: PTH123 

57 print(data, file=fd) 

58 else: 

59 if output_file is None: 

60 output_file = sys.stdout 

61 print(data, file=output_file) 

62 

63 

64def _load_packages( 

65 packages: Sequence[str], 

66 *, 

67 extensions: Extensions | None = None, 

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

69 docstring_parser: DocstringStyle | Parser | None = None, 

70 docstring_options: DocstringOptions | None = None, 

71 resolve_aliases: bool = True, 

72 resolve_implicit: bool = False, 

73 resolve_external: bool | None = None, 

74 allow_inspection: bool = True, 

75 force_inspection: bool = False, 

76 store_source: bool = True, 

77 find_stubs_package: bool = False, 

78) -> GriffeLoader: 

79 from griffe._internal.loader import GriffeLoader # noqa: PLC0415 

80 from griffe._internal.logger import logger # noqa: PLC0415 

81 

82 # Create a single loader. 

83 loader = GriffeLoader( 

84 extensions=extensions, 

85 search_paths=search_paths, 

86 docstring_parser=docstring_parser, 

87 docstring_options=docstring_options, 

88 allow_inspection=allow_inspection, 

89 force_inspection=force_inspection, 

90 store_source=store_source, 

91 ) 

92 

93 # Load each package. 

94 for package in packages: 

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

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

97 continue 

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

99 try: 

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

101 except ModuleNotFoundError as error: 

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

103 except ImportError: 

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

105 logger.info("Finished loading packages") 

106 

107 # Resolve aliases. 

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

109 logger.info("Starting alias resolution") 

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

111 if unresolved: 

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

113 else: 

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

115 return loader 

116 

117 

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

119 

120 

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

122 try: 

123 return json.loads(value) 

124 except json.JSONDecodeError: 

125 return value.split(",") 

126 

127 

128def get_parser() -> argparse.ArgumentParser: 

129 """Return the CLI argument parser. 

130 

131 Returns: 

132 An argparse parser. 

133 """ 

134 from griffe._internal import debug # noqa: PLC0415 

135 from griffe._internal.enumerations import ExplanationStyle, Parser # noqa: PLC0415 

136 

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

138 description = "Signatures for entire Python programs. " 

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

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

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

142 

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

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

145 

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

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

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

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

150 

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

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

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

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

155 search_options.add_argument( 

156 "-s", 

157 "--search", 

158 dest="search_paths", 

159 action="append", 

160 type=Path, 

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

162 ) 

163 search_options.add_argument( 

164 "-y", 

165 "--sys-path", 

166 dest="append_sys_path", 

167 action="store_true", 

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

169 ) 

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

171 loading_options.add_argument( 

172 "-B", 

173 "--find-stubs-packages", 

174 dest="find_stubs_package", 

175 action="store_true", 

176 default=False, 

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

178 ) 

179 loading_options.add_argument( 

180 "-e", 

181 "--extensions", 

182 default={}, 

183 type=_extensions_type, 

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

185 ) 

186 loading_options.add_argument( 

187 "-X", 

188 "--no-inspection", 

189 dest="allow_inspection", 

190 action="store_false", 

191 default=True, 

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

193 ) 

194 loading_options.add_argument( 

195 "-x", 

196 "--force-inspection", 

197 dest="force_inspection", 

198 action="store_true", 

199 default=False, 

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

201 ) 

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

203 debug_options.add_argument( 

204 "-L", 

205 "--log-level", 

206 metavar="LEVEL", 

207 default=DEFAULT_LOG_LEVEL, 

208 choices=_level_choices, 

209 type=str.upper, 

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

211 ) 

212 

213 # ========= SUBPARSERS ========= # 

214 subparsers = parser.add_subparsers( 

215 dest="subcommand", 

216 title="Commands", 

217 metavar="COMMAND", 

218 prog="griffe", 

219 required=True, 

220 ) 

221 

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

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

224 

225 # ========= DUMP PARSER ========= # 

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

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

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

229 dump_options.add_argument( 

230 "-f", 

231 "--full", 

232 action="store_true", 

233 default=False, 

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

235 ) 

236 dump_options.add_argument( 

237 "-o", 

238 "--output", 

239 default=sys.stdout, 

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

241 ) 

242 dump_options.add_argument( 

243 "-d", 

244 "--docstyle", 

245 dest="docstring_parser", 

246 default=None, 

247 type=Parser, 

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

249 ) 

250 dump_options.add_argument( 

251 "-D", 

252 "--docopts", 

253 dest="docstring_options", 

254 default={}, 

255 type=json.loads, 

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

257 ) 

258 dump_options.add_argument( 

259 "-r", 

260 "--resolve-aliases", 

261 action="store_true", 

262 help="Whether to resolve aliases.", 

263 ) 

264 dump_options.add_argument( 

265 "-I", 

266 "--resolve-implicit", 

267 action="store_true", 

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

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

270 ) 

271 dump_options.add_argument( 

272 "-U", 

273 "--resolve-external", 

274 dest="resolve_external", 

275 action="store_true", 

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

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

278 ) 

279 dump_options.add_argument( 

280 "--no-resolve-external", 

281 dest="resolve_external", 

282 action="store_false", 

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

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

285 ) 

286 dump_options.add_argument( 

287 "-S", 

288 "--stats", 

289 action="store_true", 

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

291 ) 

292 add_common_options(dump_parser) 

293 

294 # ========= CHECK PARSER ========= # 

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

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

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

298 check_options.add_argument( 

299 "-a", 

300 "--against", 

301 metavar="REF", 

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

303 ) 

304 check_options.add_argument( 

305 "-b", 

306 "--base-ref", 

307 metavar="BASE_REF", 

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

309 ) 

310 check_options.add_argument( 

311 "--color", 

312 dest="color", 

313 action="store_true", 

314 default=None, 

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

316 ) 

317 check_options.add_argument( 

318 "--no-color", 

319 dest="color", 

320 action="store_false", 

321 default=None, 

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

323 ) 

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

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

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

327 add_common_options(check_parser) 

328 

329 return parser 

330 

331 

332def dump( 

333 packages: Sequence[str], 

334 *, 

335 output: str | IO | None = None, 

336 full: bool = False, 

337 docstring_parser: DocstringStyle | Parser | None = None, 

338 docstring_options: DocstringOptions | None = None, 

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

340 resolve_aliases: bool = False, 

341 resolve_implicit: bool = False, 

342 resolve_external: bool | None = None, 

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

344 find_stubs_package: bool = False, 

345 append_sys_path: bool = False, 

346 allow_inspection: bool = True, 

347 force_inspection: bool = False, 

348 stats: bool = False, 

349) -> int: 

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

351 

352 Parameters: 

353 packages: The packages to load and dump. 

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

355 full: Whether to output full or minimal data. 

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

357 docstring_options: Docstring parsing options. 

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

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

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

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

362 extensions: The extensions to use. 

363 search_paths: The paths to search into. 

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

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

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

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

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

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

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

371 

372 Returns: 

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

374 """ 

375 from griffe._internal.encoders import JSONEncoder # noqa: PLC0415 

376 from griffe._internal.exceptions import ExtensionError # noqa: PLC0415 

377 from griffe._internal.extensions.base import load_extensions # noqa: PLC0415 

378 from griffe._internal.logger import logger # noqa: PLC0415 

379 

380 # Prepare options. 

381 per_package_output = False 

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

383 per_package_output = True 

384 

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

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

387 search_paths.extend(sys.path) 

388 

389 try: 

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

391 except ExtensionError: 

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

393 return 1 

394 

395 # Load packages. 

396 loader = _load_packages( 

397 packages, 

398 extensions=loaded_extensions, 

399 search_paths=search_paths, 

400 docstring_parser=docstring_parser, 

401 docstring_options=docstring_options, 

402 resolve_aliases=resolve_aliases, 

403 resolve_implicit=resolve_implicit, 

404 resolve_external=resolve_external, 

405 allow_inspection=allow_inspection, 

406 force_inspection=force_inspection, 

407 store_source=False, 

408 find_stubs_package=find_stubs_package, 

409 ) 

410 data_packages = loader.modules_collection.members 

411 

412 # Serialize and dump packages. 

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

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

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

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

417 _print_data(serialized, output.format(package=package_name)) # ty:ignore[possibly-missing-attribute] 

418 else: 

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

420 _print_data(serialized, output) 

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

422 

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

424 loader_stats = loader.stats() 

425 loader_stats.time_spent_serializing = elapsed.microseconds 

426 logger.info(loader_stats.as_text()) 

427 

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

429 

430 

431def check( 

432 package: str | Path, 

433 against: str | None = None, 

434 against_path: str | Path | None = None, 

435 *, 

436 base_ref: str | None = None, 

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

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

439 append_sys_path: bool = False, 

440 find_stubs_package: bool = False, 

441 allow_inspection: bool = True, 

442 force_inspection: bool = False, 

443 verbose: bool = False, 

444 color: bool | None = None, 

445 style: str | ExplanationStyle | None = None, 

446) -> int: 

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

448 

449 Parameters: 

450 package: The package to load and check. 

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

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

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

454 extensions: The extensions to use. 

455 search_paths: The paths to search into. 

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

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

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

459 verbose: Use a verbose output. 

460 

461 Returns: 

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

463 """ 

464 from griffe._internal.diff import find_breaking_changes # noqa: PLC0415 

465 from griffe._internal.enumerations import ExplanationStyle # noqa: PLC0415 

466 from griffe._internal.exceptions import ExtensionError, GitError # noqa: PLC0415 

467 from griffe._internal.extensions.base import load_extensions # noqa: PLC0415 

468 from griffe._internal.git import _get_latest_tag, _get_repo_root # noqa: PLC0415 

469 from griffe._internal.loader import load, load_git, load_pypi # noqa: PLC0415 

470 from griffe._internal.logger import logger # noqa: PLC0415 

471 

472 # Prepare options. 

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

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

475 search_paths.extend(sys.path) 

476 

477 against_path = against_path or package 

478 try: 

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

480 except ExtensionError: 

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

482 return 1 

483 

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

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

486 against_version = match_against.group(2) 

487 old_package = load_pypi( 

488 str(package), 

489 against_dist, 

490 against_version, 

491 extensions=loaded_extensions, 

492 search_paths=search_paths, 

493 allow_inspection=allow_inspection, 

494 force_inspection=force_inspection, 

495 find_stubs_package=find_stubs_package, 

496 resolve_aliases=True, 

497 resolve_external=None, 

498 ) 

499 

500 if base_ref: 

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

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

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

504 base_version = match_base.group(2) 

505 else: 

506 base_dist = against_dist 

507 base_version = "" 

508 new_package = load_pypi( 

509 str(package), 

510 base_dist, 

511 base_version, 

512 extensions=loaded_extensions, 

513 search_paths=search_paths, 

514 allow_inspection=allow_inspection, 

515 force_inspection=force_inspection, 

516 find_stubs_package=find_stubs_package, 

517 resolve_aliases=True, 

518 resolve_external=None, 

519 ) 

520 

521 else: 

522 against_path = against_path or package 

523 try: 

524 against = against or _get_latest_tag(package) 

525 repository = _get_repo_root(against_path) 

526 except GitError as error: 

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

528 return 2 

529 

530 # Load old and new version of the package. 

531 old_package = load_git( 

532 against_path, 

533 ref=against, 

534 repo=repository, 

535 extensions=loaded_extensions, 

536 search_paths=search_paths, 

537 allow_inspection=allow_inspection, 

538 force_inspection=force_inspection, 

539 find_stubs_package=find_stubs_package, 

540 resolve_aliases=True, 

541 resolve_external=None, 

542 ) 

543 

544 if base_ref: 

545 new_package = load_git( 

546 package, 

547 ref=base_ref, 

548 repo=repository, 

549 extensions=loaded_extensions, 

550 search_paths=search_paths, 

551 allow_inspection=allow_inspection, 

552 force_inspection=force_inspection, 

553 find_stubs_package=find_stubs_package, 

554 resolve_aliases=True, 

555 resolve_external=None, 

556 ) 

557 else: 

558 new_package = load( 

559 package, 

560 try_relative_path=True, 

561 extensions=loaded_extensions, 

562 search_paths=search_paths, 

563 allow_inspection=allow_inspection, 

564 force_inspection=force_inspection, 

565 find_stubs_package=find_stubs_package, 

566 resolve_aliases=True, 

567 resolve_external=None, 

568 ) 

569 

570 # Find and display API breakages. 

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

572 

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

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

575 colorama.deinit() 

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

577 

578 if style is None: 

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

580 else: 

581 style = ExplanationStyle(style) 

582 for breakage in breakages: 

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

584 

585 if breakages: 

586 return 1 

587 return 0 

588 

589 

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

591 """Run the main program. 

592 

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

594 

595 Parameters: 

596 args: Arguments passed from the command line. 

597 

598 Returns: 

599 An exit code. 

600 """ 

601 # Parse arguments. 

602 parser = get_parser() 

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

604 opts_dict = opts.__dict__ 

605 opts_dict.pop("debug_info") 

606 subcommand = opts_dict.pop("subcommand") 

607 

608 # Initialize logging. 

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

610 try: 

611 level = getattr(logging, log_level) 

612 except AttributeError: 

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

614 print( 

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

616 file=sys.stderr, 

617 ) 

618 return 1 

619 else: 

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

621 

622 # Increase maximum recursion limit to 2000. 

623 sys.setrecursionlimit(max(2000, sys.getrecursionlimit())) 

624 

625 # Run subcommand. 

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

627 return commands[subcommand](**opts_dict)