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
« 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`.
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
25import colorama
27if TYPE_CHECKING:
28 from collections.abc import Callable, Sequence
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
36DEFAULT_LOG_LEVEL = os.getenv("GRIFFE_LOG_LEVEL", "INFO").upper()
37"""The default log level for the CLI.
39This can be overridden by the `GRIFFE_LOG_LEVEL` environment variable.
40"""
43class _DebugInfo(argparse.Action):
44 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
45 super().__init__(nargs=nargs, **kwargs)
47 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002
48 from griffe._internal import debug # noqa: PLC0415
50 debug._print_debug_info()
51 sys.exit(0)
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)
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
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 )
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")
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
118_level_choices = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
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(",")
128def get_parser() -> argparse.ArgumentParser:
129 """Return the CLI argument parser.
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
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")
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."
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.")
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 )
213 # ========= SUBPARSERS ========= #
214 subparsers = parser.add_subparsers(
215 dest="subcommand",
216 title="Commands",
217 metavar="COMMAND",
218 prog="griffe",
219 required=True,
220 )
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)
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)
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)
329 return parser
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.
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.
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
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
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)
389 try:
390 loaded_extensions = load_extensions(*(extensions or ()))
391 except ExtensionError:
392 logger.exception("Could not load extensions")
393 return 1
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
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
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())
428 return 0 if len(data_packages) == len(packages) else 1
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.
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.
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
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)
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
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 )
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 )
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
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 )
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 )
570 # Find and display API breakages.
571 breakages = list(find_breaking_changes(old_package, new_package))
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)
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)
585 if breakages:
586 return 1
587 return 0
590def main(args: list[str] | None = None) -> int:
591 """Run the main program.
593 This function is executed when you type `griffe` or `python -m griffe`.
595 Parameters:
596 args: Arguments passed from the command line.
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")
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)
622 # Increase maximum recursion limit to 2000.
623 sys.setrecursionlimit(max(2000, sys.getrecursionlimit()))
625 # Run subcommand.
626 commands: dict[str, Callable[..., int]] = {"check": check, "dump": dump}
627 return commands[subcommand](**opts_dict)