Coverage for src/_griffe/cli.py: 63.39%

190 statements  

« prev     ^ index     » next       coverage.py v7.6.2, created at 2024-10-12 01:34 +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 sys 

20from datetime import datetime, timezone 

21from pathlib import Path 

22from typing import IO, TYPE_CHECKING, Any, Callable 

23 

24import colorama 

25 

26from _griffe import debug 

27from _griffe.diff import find_breaking_changes 

28from _griffe.encoders import JSONEncoder 

29from _griffe.enumerations import ExplanationStyle, Parser 

30from _griffe.exceptions import ExtensionError, GitError 

31from _griffe.extensions.base import load_extensions 

32from _griffe.git import get_latest_tag, get_repo_root 

33from _griffe.loader import GriffeLoader, load, load_git 

34from _griffe.logger import logger 

35 

36if TYPE_CHECKING: 

37 from collections.abc import Sequence 

38 

39 from _griffe.extensions.base import Extension, Extensions 

40 

41 

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

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

44 

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

46""" 

47 

48 

49class _DebugInfo(argparse.Action): 

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

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

52 

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

54 debug._print_debug_info() 

55 sys.exit(0) 

56 

57 

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

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

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

61 print(data, file=fd) 

62 else: 

63 if output_file is None: 

64 output_file = sys.stdout 

65 print(data, file=output_file) 

66 

67 

68def _load_packages( 

69 packages: Sequence[str], 

70 *, 

71 extensions: Extensions | None = None, 

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

73 docstring_parser: Parser | None = None, 

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

75 resolve_aliases: bool = True, 

76 resolve_implicit: bool = False, 

77 resolve_external: bool | None = None, 

78 allow_inspection: bool = True, 

79 force_inspection: bool = False, 

80 store_source: bool = True, 

81 find_stubs_package: bool = False, 

82) -> GriffeLoader: 

83 # Create a single loader. 

84 loader = GriffeLoader( 

85 extensions=extensions, 

86 search_paths=search_paths, 

87 docstring_parser=docstring_parser, 

88 docstring_options=docstring_options, 

89 allow_inspection=allow_inspection, 

90 force_inspection=force_inspection, 

91 store_source=store_source, 

92 ) 

93 

94 # Load each package. 

95 for package in packages: 

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

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

98 continue 

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

100 try: 

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

102 except ModuleNotFoundError as error: 

103 logger.error("Could not find package %s: %s", package, error) # noqa: TRY400 

104 except ImportError: 

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

106 logger.info("Finished loading packages") 

107 

108 # Resolve aliases. 

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

110 logger.info("Starting alias resolution") 

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

112 if unresolved: 

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

114 else: 

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

116 return loader 

117 

118 

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

120 

121 

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

123 try: 

124 return json.loads(value) 

125 except json.JSONDecodeError: 

126 return value.split(",") 

127 

128 

129def get_parser() -> argparse.ArgumentParser: 

130 """Return the CLI argument parser. 

131 

132 Returns: 

133 An argparse parser. 

134 """ 

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

136 description = "Signatures for entire Python programs. " 

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

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

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

140 

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

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

143 

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

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

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

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

148 

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

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

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

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

153 search_options.add_argument( 

154 "-s", 

155 "--search", 

156 dest="search_paths", 

157 action="append", 

158 type=Path, 

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

160 ) 

161 search_options.add_argument( 

162 "-y", 

163 "--sys-path", 

164 dest="append_sys_path", 

165 action="store_true", 

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

167 ) 

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

169 loading_options.add_argument( 

170 "-B", 

171 "--find-stubs-packages", 

172 dest="find_stubs_package", 

173 action="store_true", 

174 default=False, 

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

176 ) 

177 loading_options.add_argument( 

178 "-e", 

179 "--extensions", 

180 default={}, 

181 type=_extensions_type, 

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

183 ) 

184 loading_options.add_argument( 

185 "-X", 

186 "--no-inspection", 

187 dest="allow_inspection", 

188 action="store_false", 

189 default=True, 

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

191 ) 

192 loading_options.add_argument( 

193 "-x", 

194 "--force-inspection", 

195 dest="force_inspection", 

196 action="store_true", 

197 default=False, 

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

199 ) 

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

201 debug_options.add_argument( 

202 "-L", 

203 "--log-level", 

204 metavar="LEVEL", 

205 default=DEFAULT_LOG_LEVEL, 

206 choices=_level_choices, 

207 type=str.upper, 

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

209 ) 

210 

211 # ========= SUBPARSERS ========= # 

212 subparsers = parser.add_subparsers( 

213 dest="subcommand", 

214 title="Commands", 

215 metavar="COMMAND", 

216 prog="griffe", 

217 required=True, 

218 ) 

219 

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

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

222 

223 # ========= DUMP PARSER ========= # 

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

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

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

227 dump_options.add_argument( 

228 "-f", 

229 "--full", 

230 action="store_true", 

231 default=False, 

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

233 ) 

234 dump_options.add_argument( 

235 "-o", 

236 "--output", 

237 default=sys.stdout, 

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

239 ) 

240 dump_options.add_argument( 

241 "-d", 

242 "--docstyle", 

243 dest="docstring_parser", 

244 default=None, 

245 type=Parser, 

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

247 ) 

248 dump_options.add_argument( 

249 "-D", 

250 "--docopts", 

251 dest="docstring_options", 

252 default={}, 

253 type=json.loads, 

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

255 ) 

256 dump_options.add_argument( 

257 "-r", 

258 "--resolve-aliases", 

259 action="store_true", 

260 help="Whether to resolve aliases.", 

261 ) 

262 dump_options.add_argument( 

263 "-I", 

264 "--resolve-implicit", 

265 action="store_true", 

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

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

268 ) 

269 dump_options.add_argument( 

270 "-U", 

271 "--resolve-external", 

272 dest="resolve_external", 

273 action="store_true", 

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

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

276 ) 

277 dump_options.add_argument( 

278 "--no-resolve-external", 

279 dest="resolve_external", 

280 action="store_false", 

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

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

283 ) 

284 dump_options.add_argument( 

285 "-S", 

286 "--stats", 

287 action="store_true", 

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

289 ) 

290 add_common_options(dump_parser) 

291 

292 # ========= CHECK PARSER ========= # 

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

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

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

296 check_options.add_argument( 

297 "-a", 

298 "--against", 

299 metavar="REF", 

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

301 ) 

302 check_options.add_argument( 

303 "-b", 

304 "--base-ref", 

305 metavar="BASE_REF", 

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

307 ) 

308 check_options.add_argument( 

309 "--color", 

310 dest="color", 

311 action="store_true", 

312 default=None, 

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

314 ) 

315 check_options.add_argument( 

316 "--no-color", 

317 dest="color", 

318 action="store_false", 

319 default=None, 

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

321 ) 

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

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

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

325 add_common_options(check_parser) 

326 

327 return parser 

328 

329 

330def dump( 

331 packages: Sequence[str], 

332 *, 

333 output: str | IO | None = None, 

334 full: bool = False, 

335 docstring_parser: Parser | None = None, 

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

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

338 resolve_aliases: bool = False, 

339 resolve_implicit: bool = False, 

340 resolve_external: bool | None = None, 

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

342 find_stubs_package: bool = False, 

343 append_sys_path: bool = False, 

344 allow_inspection: bool = True, 

345 force_inspection: bool = False, 

346 stats: bool = False, 

347) -> int: 

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

349 

350 Parameters: 

351 packages: The packages to load and dump. 

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

353 full: Whether to output full or minimal data. 

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

355 docstring_options: Additional docstring parsing options. 

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

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

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

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

360 extensions: The extensions to use. 

361 search_paths: The paths to search into. 

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

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

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

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

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

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

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

369 

370 Returns: 

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

372 """ 

373 # Prepare options. 

374 per_package_output = False 

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

376 per_package_output = True 

377 

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

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

380 search_paths.extend(sys.path) 

381 

382 try: 

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

384 except ExtensionError: 

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

386 return 1 

387 

388 # Load packages. 

389 loader = _load_packages( 

390 packages, 

391 extensions=loaded_extensions, 

392 search_paths=search_paths, 

393 docstring_parser=docstring_parser, 

394 docstring_options=docstring_options, 

395 resolve_aliases=resolve_aliases, 

396 resolve_implicit=resolve_implicit, 

397 resolve_external=resolve_external, 

398 allow_inspection=allow_inspection, 

399 force_inspection=force_inspection, 

400 store_source=False, 

401 find_stubs_package=find_stubs_package, 

402 ) 

403 data_packages = loader.modules_collection.members 

404 

405 # Serialize and dump packages. 

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

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

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

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

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

411 else: 

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

413 _print_data(serialized, output) 

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

415 

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

417 loader_stats = loader.stats() 

418 loader_stats.time_spent_serializing = elapsed.microseconds 

419 logger.info(loader_stats.as_text()) 

420 

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

422 

423 

424def check( 

425 package: str | Path, 

426 against: str | None = None, 

427 against_path: str | Path | None = None, 

428 *, 

429 base_ref: str | None = None, 

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

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

432 append_sys_path: bool = False, 

433 find_stubs_package: bool = False, 

434 allow_inspection: bool = True, 

435 force_inspection: bool = False, 

436 verbose: bool = False, 

437 color: bool | None = None, 

438 style: str | ExplanationStyle | None = None, 

439) -> int: 

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

441 

442 Parameters: 

443 package: The package to load and check. 

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

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

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

447 extensions: The extensions to use. 

448 search_paths: The paths to search into. 

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

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

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

452 verbose: Use a verbose output. 

453 

454 Returns: 

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

456 """ 

457 # Prepare options. 

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

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

460 search_paths.extend(sys.path) 

461 

462 try: 

463 against = against or get_latest_tag(package) 

464 except GitError as error: 

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

466 return 2 

467 against_path = against_path or package 

468 repository = get_repo_root(against_path) 

469 

470 try: 

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

472 except ExtensionError: 

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

474 return 1 

475 

476 # Load old and new version of the package. 

477 old_package = load_git( 

478 against_path, 

479 ref=against, 

480 repo=repository, 

481 extensions=loaded_extensions, 

482 search_paths=search_paths, 

483 allow_inspection=allow_inspection, 

484 force_inspection=force_inspection, 

485 ) 

486 if base_ref: 

487 new_package = load_git( 

488 package, 

489 ref=base_ref, 

490 repo=repository, 

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 ) 

497 else: 

498 new_package = load( 

499 package, 

500 try_relative_path=True, 

501 extensions=loaded_extensions, 

502 search_paths=search_paths, 

503 allow_inspection=allow_inspection, 

504 force_inspection=force_inspection, 

505 find_stubs_package=find_stubs_package, 

506 ) 

507 

508 # Find and display API breakages. 

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

510 

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

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

513 colorama.deinit() 

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

515 

516 if style is None: 

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

518 else: 

519 style = ExplanationStyle(style) 

520 for breakage in breakages: 

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

522 

523 if breakages: 

524 return 1 

525 return 0 

526 

527 

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

529 """Run the main program. 

530 

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

532 

533 Parameters: 

534 args: Arguments passed from the command line. 

535 

536 Returns: 

537 An exit code. 

538 """ 

539 # Parse arguments. 

540 parser = get_parser() 

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

542 opts_dict = opts.__dict__ 

543 opts_dict.pop("debug_info") 

544 subcommand = opts_dict.pop("subcommand") 

545 

546 # Initialize logging. 

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

548 try: 

549 level = getattr(logging, log_level) 

550 except AttributeError: 

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

552 print( 

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

554 file=sys.stderr, 

555 ) 

556 return 1 

557 else: 

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

559 

560 # Run subcommand. 

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

562 return commands[subcommand](**opts_dict)