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
« 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`.
13from __future__ import annotations
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
25import colorama
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
37if TYPE_CHECKING:
38 from collections.abc import Sequence
40 from griffe._internal.extensions.base import Extension, Extensions
43DEFAULT_LOG_LEVEL = os.getenv("GRIFFE_LOG_LEVEL", "INFO").upper()
44"""The default log level for the CLI.
46This can be overridden by the `GRIFFE_LOG_LEVEL` environment variable.
47"""
50class _DebugInfo(argparse.Action):
51 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
52 super().__init__(nargs=nargs, **kwargs)
54 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
55 debug._print_debug_info()
56 sys.exit(0)
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)
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 )
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")
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
120_level_choices = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
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(",")
130def get_parser() -> argparse.ArgumentParser:
131 """Return the CLI argument parser.
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")
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."
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.")
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 )
212 # ========= SUBPARSERS ========= #
213 subparsers = parser.add_subparsers(
214 dest="subcommand",
215 title="Commands",
216 metavar="COMMAND",
217 prog="griffe",
218 required=True,
219 )
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)
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)
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)
328 return parser
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.
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.
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
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)
383 try:
384 loaded_extensions = load_extensions(*(extensions or ()))
385 except ExtensionError:
386 logger.exception("Could not load extensions")
387 return 1
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
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
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())
422 return 0 if len(data_packages) == len(packages) else 1
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.
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.
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)
463 try:
464 loaded_extensions = load_extensions(*(extensions or ()))
465 except ExtensionError:
466 logger.exception("Could not load extensions")
467 return 1
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 )
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 )
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)
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 )
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 )
555 # Find and display API breakages.
556 breakages = list(find_breaking_changes(old_package, new_package))
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)
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)
570 if breakages:
571 return 1
572 return 0
575def main(args: list[str] | None = None) -> int:
576 """Run the main program.
578 This function is executed when you type `griffe` or `python -m griffe`.
580 Parameters:
581 args: Arguments passed from the command line.
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")
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)
607 # Run subcommand.
608 commands: dict[str, Callable[..., int]] = {"check": check, "dump": dump}
609 return commands[subcommand](**opts_dict)