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

190 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-15 16:47 +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, Sequence 

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 _griffe.extensions.base import Extension, Extensions 

38 

39 

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

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

42 

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

44""" 

45 

46 

47class _DebugInfo(argparse.Action): 

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

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

50 

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

52 debug._print_debug_info() 

53 sys.exit(0) 

54 

55 

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

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

58 with open(output_file, "w") as fd: 

59 print(data, file=fd) 

60 else: 

61 if output_file is None: 

62 output_file = sys.stdout 

63 print(data, file=output_file) 

64 

65 

66def _load_packages( 

67 packages: Sequence[str], 

68 *, 

69 extensions: Extensions | None = None, 

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

71 docstring_parser: Parser | None = None, 

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

73 resolve_aliases: bool = True, 

74 resolve_implicit: bool = False, 

75 resolve_external: bool | None = None, 

76 allow_inspection: bool = True, 

77 force_inspection: bool = False, 

78 store_source: bool = True, 

79 find_stubs_package: bool = False, 

80) -> GriffeLoader: 

81 # Create a single loader. 

82 loader = GriffeLoader( 

83 extensions=extensions, 

84 search_paths=search_paths, 

85 docstring_parser=docstring_parser, 

86 docstring_options=docstring_options, 

87 allow_inspection=allow_inspection, 

88 force_inspection=force_inspection, 

89 store_source=store_source, 

90 ) 

91 

92 # Load each package. 

93 for package in packages: 

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

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

96 continue 

97 logger.info(f"Loading package {package}") 

98 try: 

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

100 except ModuleNotFoundError as error: 

101 logger.error(f"Could not find package {package}: {error}") 

102 except ImportError as error: 

103 logger.exception(f"Tried but could not import package {package}: {error}") 

104 logger.info("Finished loading packages") 

105 

106 # Resolve aliases. 

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

108 logger.info("Starting alias resolution") 

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

110 if unresolved: 

111 logger.info(f"{len(unresolved)} aliases were still unresolved after {iterations} iterations") 

112 else: 

113 logger.info(f"All aliases were resolved after {iterations} iterations") 

114 return loader 

115 

116 

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

118 

119 

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

121 try: 

122 return json.loads(value) 

123 except json.JSONDecodeError: 

124 return value.split(",") 

125 

126 

127def get_parser() -> argparse.ArgumentParser: 

128 """Return the CLI argument parser. 

129 

130 Returns: 

131 An argparse parser. 

132 """ 

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

134 description = "Signatures for entire Python programs. " 

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

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

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

138 

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

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

141 

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

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

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

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

146 

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

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

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

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

151 search_options.add_argument( 

152 "-s", 

153 "--search", 

154 dest="search_paths", 

155 action="append", 

156 type=Path, 

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

158 ) 

159 search_options.add_argument( 

160 "-y", 

161 "--sys-path", 

162 dest="append_sys_path", 

163 action="store_true", 

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

165 ) 

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

167 loading_options.add_argument( 

168 "-B", 

169 "--find-stubs-packages", 

170 dest="find_stubs_package", 

171 action="store_true", 

172 default=False, 

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

174 ) 

175 loading_options.add_argument( 

176 "-e", 

177 "--extensions", 

178 default={}, 

179 type=_extensions_type, 

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

181 ) 

182 loading_options.add_argument( 

183 "-X", 

184 "--no-inspection", 

185 dest="allow_inspection", 

186 action="store_false", 

187 default=True, 

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

189 ) 

190 loading_options.add_argument( 

191 "-x", 

192 "--force-inspection", 

193 dest="force_inspection", 

194 action="store_true", 

195 default=False, 

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

197 ) 

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

199 debug_options.add_argument( 

200 "-L", 

201 "--log-level", 

202 metavar="LEVEL", 

203 default=DEFAULT_LOG_LEVEL, 

204 choices=_level_choices, 

205 type=str.upper, 

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

207 ) 

208 

209 # ========= SUBPARSERS ========= # 

210 subparsers = parser.add_subparsers( 

211 dest="subcommand", 

212 title="Commands", 

213 metavar="COMMAND", 

214 prog="griffe", 

215 required=True, 

216 ) 

217 

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

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

220 

221 # ========= DUMP PARSER ========= # 

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

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

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

225 dump_options.add_argument( 

226 "-f", 

227 "--full", 

228 action="store_true", 

229 default=False, 

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

231 ) 

232 dump_options.add_argument( 

233 "-o", 

234 "--output", 

235 default=sys.stdout, 

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

237 ) 

238 dump_options.add_argument( 

239 "-d", 

240 "--docstyle", 

241 dest="docstring_parser", 

242 default=None, 

243 type=Parser, 

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

245 ) 

246 dump_options.add_argument( 

247 "-D", 

248 "--docopts", 

249 dest="docstring_options", 

250 default={}, 

251 type=json.loads, 

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

253 ) 

254 dump_options.add_argument( 

255 "-r", 

256 "--resolve-aliases", 

257 action="store_true", 

258 help="Whether to resolve aliases.", 

259 ) 

260 dump_options.add_argument( 

261 "-I", 

262 "--resolve-implicit", 

263 action="store_true", 

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

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

266 ) 

267 dump_options.add_argument( 

268 "-U", 

269 "--resolve-external", 

270 dest="resolve_external", 

271 action="store_true", 

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

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

274 ) 

275 dump_options.add_argument( 

276 "--no-resolve-external", 

277 dest="resolve_external", 

278 action="store_false", 

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

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

281 ) 

282 dump_options.add_argument( 

283 "-S", 

284 "--stats", 

285 action="store_true", 

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

287 ) 

288 add_common_options(dump_parser) 

289 

290 # ========= CHECK PARSER ========= # 

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

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

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

294 check_options.add_argument( 

295 "-a", 

296 "--against", 

297 metavar="REF", 

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

299 ) 

300 check_options.add_argument( 

301 "-b", 

302 "--base-ref", 

303 metavar="BASE_REF", 

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

305 ) 

306 check_options.add_argument( 

307 "--color", 

308 dest="color", 

309 action="store_true", 

310 default=None, 

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

312 ) 

313 check_options.add_argument( 

314 "--no-color", 

315 dest="color", 

316 action="store_false", 

317 default=None, 

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

319 ) 

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

321 formats = ("oneline", "verbose") 

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

323 add_common_options(check_parser) 

324 

325 return parser 

326 

327 

328def dump( 

329 packages: Sequence[str], 

330 *, 

331 output: str | IO | None = None, 

332 full: bool = False, 

333 docstring_parser: Parser | None = None, 

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

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

336 resolve_aliases: bool = False, 

337 resolve_implicit: bool = False, 

338 resolve_external: bool | None = None, 

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

340 find_stubs_package: bool = False, 

341 append_sys_path: bool = False, 

342 allow_inspection: bool = True, 

343 force_inspection: bool = False, 

344 stats: bool = False, 

345) -> int: 

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

347 

348 Parameters: 

349 packages: The packages to load and dump. 

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

351 full: Whether to output full or minimal data. 

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

353 docstring_options: Additional docstring parsing options. 

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

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

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

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

358 extensions: The extensions to use. 

359 search_paths: The paths to search into. 

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

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

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

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

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

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

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

367 

368 Returns: 

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

370 """ 

371 # Prepare options. 

372 per_package_output = False 

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

374 per_package_output = True 

375 

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

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

378 search_paths.extend(sys.path) 

379 

380 try: 

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

382 except ExtensionError as error: 

383 logger.exception(str(error)) 

384 return 1 

385 

386 # Load packages. 

387 loader = _load_packages( 

388 packages, 

389 extensions=loaded_extensions, 

390 search_paths=search_paths, 

391 docstring_parser=docstring_parser, 

392 docstring_options=docstring_options, 

393 resolve_aliases=resolve_aliases, 

394 resolve_implicit=resolve_implicit, 

395 resolve_external=resolve_external, 

396 allow_inspection=allow_inspection, 

397 force_inspection=force_inspection, 

398 store_source=False, 

399 find_stubs_package=find_stubs_package, 

400 ) 

401 data_packages = loader.modules_collection.members 

402 

403 # Serialize and dump packages. 

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

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

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

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

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

409 else: 

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

411 _print_data(serialized, output) 

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

413 

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

415 loader_stats = loader.stats() 

416 loader_stats.time_spent_serializing = elapsed.microseconds 

417 logger.info(loader_stats.as_text()) 

418 

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

420 

421 

422def check( 

423 package: str | Path, 

424 against: str | None = None, 

425 against_path: str | Path | None = None, 

426 *, 

427 base_ref: str | None = None, 

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

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

430 append_sys_path: bool = False, 

431 find_stubs_package: bool = False, 

432 allow_inspection: bool = True, 

433 force_inspection: bool = False, 

434 verbose: bool = False, 

435 color: bool | None = None, 

436 style: str | ExplanationStyle | None = None, 

437) -> int: 

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

439 

440 Parameters: 

441 package: The package to load and check. 

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

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

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

445 extensions: The extensions to use. 

446 search_paths: The paths to search into. 

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

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

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

450 verbose: Use a verbose output. 

451 

452 Returns: 

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

454 """ 

455 # Prepare options. 

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

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

458 search_paths.extend(sys.path) 

459 

460 try: 

461 against = against or get_latest_tag(package) 

462 except GitError as error: 

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

464 return 2 

465 against_path = against_path or package 

466 repository = get_repo_root(against_path) 

467 

468 try: 

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

470 except ExtensionError as error: 

471 logger.exception(str(error)) 

472 return 1 

473 

474 # Load old and new version of the package. 

475 old_package = load_git( 

476 against_path, 

477 ref=against, 

478 repo=repository, 

479 extensions=loaded_extensions, 

480 search_paths=search_paths, 

481 allow_inspection=allow_inspection, 

482 force_inspection=force_inspection, 

483 ) 

484 if base_ref: 

485 new_package = load_git( 

486 package, 

487 ref=base_ref, 

488 repo=repository, 

489 extensions=loaded_extensions, 

490 search_paths=search_paths, 

491 allow_inspection=allow_inspection, 

492 force_inspection=force_inspection, 

493 find_stubs_package=find_stubs_package, 

494 ) 

495 else: 

496 new_package = load( 

497 package, 

498 try_relative_path=True, 

499 extensions=loaded_extensions, 

500 search_paths=search_paths, 

501 allow_inspection=allow_inspection, 

502 force_inspection=force_inspection, 

503 find_stubs_package=find_stubs_package, 

504 ) 

505 

506 # Find and display API breakages. 

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

508 

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

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

511 colorama.deinit() 

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

513 

514 if style is None: 

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

516 else: 

517 style = ExplanationStyle(style) 

518 for breakage in breakages: 

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

520 

521 if breakages: 

522 return 1 

523 return 0 

524 

525 

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

527 """Run the main program. 

528 

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

530 

531 Parameters: 

532 args: Arguments passed from the command line. 

533 

534 Returns: 

535 An exit code. 

536 """ 

537 # Parse arguments. 

538 parser = get_parser() 

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

540 opts_dict = opts.__dict__ 

541 opts_dict.pop("debug_info") 

542 subcommand = opts_dict.pop("subcommand") 

543 

544 # Initialize logging. 

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

546 try: 

547 level = getattr(logging, log_level) 

548 except AttributeError: 

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

550 print( 

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

552 file=sys.stderr, 

553 ) 

554 return 1 

555 else: 

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

557 

558 # Run subcommand. 

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

560 return commands[subcommand](**opts_dict)