Spaces:
Running
Running
# This module contains all CLI-related things. | |
# Why does this file exist, and why not put this in `__main__`? | |
# | |
# We might be tempted to import things from `__main__` later, | |
# but that will cause problems; the code will get executed twice: | |
# | |
# - When we run `python -m griffe`, Python will execute | |
# `__main__.py` as a script. That means there won't be any | |
# `griffe.__main__` in `sys.modules`. | |
# - When you import `__main__` it will get executed again (as a module) because | |
# there's no `griffe.__main__` in `sys.modules`. | |
from __future__ import annotations | |
import argparse | |
import json | |
import logging | |
import os | |
import sys | |
from datetime import datetime, timezone | |
from pathlib import Path | |
from typing import IO, TYPE_CHECKING, Any, Callable | |
import colorama | |
from _griffe import debug | |
from _griffe.diff import find_breaking_changes | |
from _griffe.encoders import JSONEncoder | |
from _griffe.enumerations import ExplanationStyle, Parser | |
from _griffe.exceptions import ExtensionError, GitError | |
from _griffe.extensions.base import load_extensions | |
from _griffe.git import get_latest_tag, get_repo_root | |
from _griffe.loader import GriffeLoader, load, load_git | |
from _griffe.logger import logger | |
if TYPE_CHECKING: | |
from collections.abc import Sequence | |
from _griffe.extensions.base import Extension, Extensions | |
DEFAULT_LOG_LEVEL = os.getenv("GRIFFE_LOG_LEVEL", "INFO").upper() | |
"""The default log level for the CLI. | |
This can be overridden by the `GRIFFE_LOG_LEVEL` environment variable. | |
""" | |
class _DebugInfo(argparse.Action): | |
def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None: | |
super().__init__(nargs=nargs, **kwargs) | |
def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 | |
debug._print_debug_info() | |
sys.exit(0) | |
def _print_data(data: str, output_file: str | IO | None) -> None: | |
if isinstance(output_file, str): | |
with open(output_file, "w") as fd: # noqa: PTH123 | |
print(data, file=fd) | |
else: | |
if output_file is None: | |
output_file = sys.stdout | |
print(data, file=output_file) | |
def _load_packages( | |
packages: Sequence[str], | |
*, | |
extensions: Extensions | None = None, | |
search_paths: Sequence[str | Path] | None = None, | |
docstring_parser: Parser | None = None, | |
docstring_options: dict[str, Any] | None = None, | |
resolve_aliases: bool = True, | |
resolve_implicit: bool = False, | |
resolve_external: bool | None = None, | |
allow_inspection: bool = True, | |
force_inspection: bool = False, | |
store_source: bool = True, | |
find_stubs_package: bool = False, | |
) -> GriffeLoader: | |
# Create a single loader. | |
loader = GriffeLoader( | |
extensions=extensions, | |
search_paths=search_paths, | |
docstring_parser=docstring_parser, | |
docstring_options=docstring_options, | |
allow_inspection=allow_inspection, | |
force_inspection=force_inspection, | |
store_source=store_source, | |
) | |
# Load each package. | |
for package in packages: | |
if not package: | |
logger.debug("Empty package name, continuing") | |
continue | |
logger.info("Loading package %s", package) | |
try: | |
loader.load(package, try_relative_path=True, find_stubs_package=find_stubs_package) | |
except ModuleNotFoundError as error: | |
logger.error("Could not find package %s: %s", package, error) # noqa: TRY400 | |
except ImportError: | |
logger.exception("Tried but could not import package %s", package) | |
logger.info("Finished loading packages") | |
# Resolve aliases. | |
if resolve_aliases: | |
logger.info("Starting alias resolution") | |
unresolved, iterations = loader.resolve_aliases(implicit=resolve_implicit, external=resolve_external) | |
if unresolved: | |
logger.info("%s aliases were still unresolved after %s iterations", len(unresolved), iterations) | |
else: | |
logger.info("All aliases were resolved after %s iterations", iterations) | |
return loader | |
_level_choices = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") | |
def _extensions_type(value: str) -> Sequence[str | dict[str, Any]]: | |
try: | |
return json.loads(value) | |
except json.JSONDecodeError: | |
return value.split(",") | |
def get_parser() -> argparse.ArgumentParser: | |
"""Return the CLI argument parser. | |
Returns: | |
An argparse parser. | |
""" | |
usage = "%(prog)s [GLOBAL_OPTS...] COMMAND [COMMAND_OPTS...]" | |
description = "Signatures for entire Python programs. " | |
"Extract the structure, the frame, the skeleton of your project, " | |
"to generate API documentation or find breaking changes in your API." | |
parser = argparse.ArgumentParser(add_help=False, usage=usage, description=description, prog="griffe") | |
main_help = "Show this help message and exit. Commands also accept the -h/--help option." | |
subcommand_help = "Show this help message and exit." | |
global_options = parser.add_argument_group(title="Global options") | |
global_options.add_argument("-h", "--help", action="help", help=main_help) | |
global_options.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug._get_version()}") | |
global_options.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.") | |
def add_common_options(subparser: argparse.ArgumentParser) -> None: | |
common_options = subparser.add_argument_group(title="Common options") | |
common_options.add_argument("-h", "--help", action="help", help=subcommand_help) | |
search_options = subparser.add_argument_group(title="Search options") | |
search_options.add_argument( | |
"-s", | |
"--search", | |
dest="search_paths", | |
action="append", | |
type=Path, | |
help="Paths to search packages into.", | |
) | |
search_options.add_argument( | |
"-y", | |
"--sys-path", | |
dest="append_sys_path", | |
action="store_true", | |
help="Whether to append `sys.path` to search paths specified with `-s`.", | |
) | |
loading_options = subparser.add_argument_group(title="Loading options") | |
loading_options.add_argument( | |
"-B", | |
"--find-stubs-packages", | |
dest="find_stubs_package", | |
action="store_true", | |
default=False, | |
help="Whether to look for stubs-only packages and merge them with concrete ones.", | |
) | |
loading_options.add_argument( | |
"-e", | |
"--extensions", | |
default={}, | |
type=_extensions_type, | |
help="A list of extensions to use.", | |
) | |
loading_options.add_argument( | |
"-X", | |
"--no-inspection", | |
dest="allow_inspection", | |
action="store_false", | |
default=True, | |
help="Disallow inspection of builtin/compiled/not found modules.", | |
) | |
loading_options.add_argument( | |
"-x", | |
"--force-inspection", | |
dest="force_inspection", | |
action="store_true", | |
default=False, | |
help="Force inspection of everything, even when sources are found.", | |
) | |
debug_options = subparser.add_argument_group(title="Debugging options") | |
debug_options.add_argument( | |
"-L", | |
"--log-level", | |
metavar="LEVEL", | |
default=DEFAULT_LOG_LEVEL, | |
choices=_level_choices, | |
type=str.upper, | |
help="Set the log level: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`.", | |
) | |
# ========= SUBPARSERS ========= # | |
subparsers = parser.add_subparsers( | |
dest="subcommand", | |
title="Commands", | |
metavar="COMMAND", | |
prog="griffe", | |
required=True, | |
) | |
def add_subparser(command: str, text: str, **kwargs: Any) -> argparse.ArgumentParser: | |
return subparsers.add_parser(command, add_help=False, help=text, description=text, **kwargs) | |
# ========= DUMP PARSER ========= # | |
dump_parser = add_subparser("dump", "Load package-signatures and dump them as JSON.") | |
dump_options = dump_parser.add_argument_group(title="Dump options") | |
dump_options.add_argument("packages", metavar="PACKAGE", nargs="+", help="Packages to find, load and dump.") | |
dump_options.add_argument( | |
"-f", | |
"--full", | |
action="store_true", | |
default=False, | |
help="Whether to dump full data in JSON.", | |
) | |
dump_options.add_argument( | |
"-o", | |
"--output", | |
default=sys.stdout, | |
help="Output file. Supports templating to output each package in its own file, with `{package}`.", | |
) | |
dump_options.add_argument( | |
"-d", | |
"--docstyle", | |
dest="docstring_parser", | |
default=None, | |
type=Parser, | |
help="The docstring style to parse.", | |
) | |
dump_options.add_argument( | |
"-D", | |
"--docopts", | |
dest="docstring_options", | |
default={}, | |
type=json.loads, | |
help="The options for the docstring parser.", | |
) | |
dump_options.add_argument( | |
"-r", | |
"--resolve-aliases", | |
action="store_true", | |
help="Whether to resolve aliases.", | |
) | |
dump_options.add_argument( | |
"-I", | |
"--resolve-implicit", | |
action="store_true", | |
help="Whether to resolve implicitly exported aliases as well. " | |
"Aliases are explicitly exported when defined in `__all__`.", | |
) | |
dump_options.add_argument( | |
"-U", | |
"--resolve-external", | |
dest="resolve_external", | |
action="store_true", | |
help="Always resolve aliases pointing to external/unknown modules (not loaded directly)." | |
"Default is to resolve only from one module to its private sibling (`ast` -> `_ast`).", | |
) | |
dump_options.add_argument( | |
"--no-resolve-external", | |
dest="resolve_external", | |
action="store_false", | |
help="Never resolve aliases pointing to external/unknown modules (not loaded directly)." | |
"Default is to resolve only from one module to its private sibling (`ast` -> `_ast`).", | |
) | |
dump_options.add_argument( | |
"-S", | |
"--stats", | |
action="store_true", | |
help="Show statistics at the end.", | |
) | |
add_common_options(dump_parser) | |
# ========= CHECK PARSER ========= # | |
check_parser = add_subparser("check", "Check for API breakages or possible improvements.") | |
check_options = check_parser.add_argument_group(title="Check options") | |
check_options.add_argument("package", metavar="PACKAGE", help="Package to find, load and check, as path.") | |
check_options.add_argument( | |
"-a", | |
"--against", | |
metavar="REF", | |
help="Older Git reference (commit, branch, tag) to check against. Default: load latest tag.", | |
) | |
check_options.add_argument( | |
"-b", | |
"--base-ref", | |
metavar="BASE_REF", | |
help="Git reference (commit, branch, tag) to check. Default: load current code.", | |
) | |
check_options.add_argument( | |
"--color", | |
dest="color", | |
action="store_true", | |
default=None, | |
help="Force enable colors in the output.", | |
) | |
check_options.add_argument( | |
"--no-color", | |
dest="color", | |
action="store_false", | |
default=None, | |
help="Force disable colors in the output.", | |
) | |
check_options.add_argument("-v", "--verbose", action="store_true", help="Verbose output.") | |
formats = [fmt.value for fmt in ExplanationStyle] | |
check_options.add_argument("-f", "--format", dest="style", choices=formats, default=None, help="Output format.") | |
add_common_options(check_parser) | |
return parser | |
def dump( | |
packages: Sequence[str], | |
*, | |
output: str | IO | None = None, | |
full: bool = False, | |
docstring_parser: Parser | None = None, | |
docstring_options: dict[str, Any] | None = None, | |
extensions: Sequence[str | dict[str, Any] | Extension | type[Extension]] | None = None, | |
resolve_aliases: bool = False, | |
resolve_implicit: bool = False, | |
resolve_external: bool | None = None, | |
search_paths: Sequence[str | Path] | None = None, | |
find_stubs_package: bool = False, | |
append_sys_path: bool = False, | |
allow_inspection: bool = True, | |
force_inspection: bool = False, | |
stats: bool = False, | |
) -> int: | |
"""Load packages data and dump it as JSON. | |
Parameters: | |
packages: The packages to load and dump. | |
output: Where to output the JSON-serialized data. | |
full: Whether to output full or minimal data. | |
docstring_parser: The docstring parser to use. By default, no parsing is done. | |
docstring_options: Additional docstring parsing options. | |
resolve_aliases: Whether to resolve aliases (indirect objects references). | |
resolve_implicit: Whether to resolve every alias or only the explicitly exported ones. | |
resolve_external: Whether to load additional, unspecified modules to resolve aliases. | |
Default is to resolve only from one module to its private sibling (`ast` -> `_ast`). | |
extensions: The extensions to use. | |
search_paths: The paths to search into. | |
find_stubs_package: Whether to search for stubs-only packages. | |
If both the package and its stubs are found, they'll be merged together. | |
If only the stubs are found, they'll be used as the package itself. | |
append_sys_path: Whether to append the contents of `sys.path` to the search paths. | |
allow_inspection: Whether to allow inspecting modules when visiting them is not possible. | |
force_inspection: Whether to force using dynamic analysis when loading data. | |
stats: Whether to compute and log stats about loading. | |
Returns: | |
`0` for success, `1` for failure. | |
""" | |
# Prepare options. | |
per_package_output = False | |
if isinstance(output, str) and output.format(package="package") != output: | |
per_package_output = True | |
search_paths = list(search_paths) if search_paths else [] | |
if append_sys_path: | |
search_paths.extend(sys.path) | |
try: | |
loaded_extensions = load_extensions(*(extensions or ())) | |
except ExtensionError: | |
logger.exception("Could not load extensions") | |
return 1 | |
# Load packages. | |
loader = _load_packages( | |
packages, | |
extensions=loaded_extensions, | |
search_paths=search_paths, | |
docstring_parser=docstring_parser, | |
docstring_options=docstring_options, | |
resolve_aliases=resolve_aliases, | |
resolve_implicit=resolve_implicit, | |
resolve_external=resolve_external, | |
allow_inspection=allow_inspection, | |
force_inspection=force_inspection, | |
store_source=False, | |
find_stubs_package=find_stubs_package, | |
) | |
data_packages = loader.modules_collection.members | |
# Serialize and dump packages. | |
started = datetime.now(tz=timezone.utc) | |
if per_package_output: | |
for package_name, data in data_packages.items(): | |
serialized = data.as_json(indent=2, full=full, sort_keys=True) | |
_print_data(serialized, output.format(package=package_name)) # type: ignore[union-attr] | |
else: | |
serialized = json.dumps(data_packages, cls=JSONEncoder, indent=2, full=full, sort_keys=True) | |
_print_data(serialized, output) | |
elapsed = datetime.now(tz=timezone.utc) - started | |
if stats: | |
loader_stats = loader.stats() | |
loader_stats.time_spent_serializing = elapsed.microseconds | |
logger.info(loader_stats.as_text()) | |
return 0 if len(data_packages) == len(packages) else 1 | |
def check( | |
package: str | Path, | |
against: str | None = None, | |
against_path: str | Path | None = None, | |
*, | |
base_ref: str | None = None, | |
extensions: Sequence[str | dict[str, Any] | Extension | type[Extension]] | None = None, | |
search_paths: Sequence[str | Path] | None = None, | |
append_sys_path: bool = False, | |
find_stubs_package: bool = False, | |
allow_inspection: bool = True, | |
force_inspection: bool = False, | |
verbose: bool = False, | |
color: bool | None = None, | |
style: str | ExplanationStyle | None = None, | |
) -> int: | |
"""Check for API breaking changes in two versions of the same package. | |
Parameters: | |
package: The package to load and check. | |
against: Older Git reference (commit, branch, tag) to check against. | |
against_path: Path when the "against" reference is checked out. | |
base_ref: Git reference (commit, branch, tag) to check. | |
extensions: The extensions to use. | |
search_paths: The paths to search into. | |
append_sys_path: Whether to append the contents of `sys.path` to the search paths. | |
allow_inspection: Whether to allow inspecting modules when visiting them is not possible. | |
force_inspection: Whether to force using dynamic analysis when loading data. | |
verbose: Use a verbose output. | |
Returns: | |
`0` for success, `1` for failure. | |
""" | |
# Prepare options. | |
search_paths = list(search_paths) if search_paths else [] | |
if append_sys_path: | |
search_paths.extend(sys.path) | |
try: | |
against = against or get_latest_tag(package) | |
except GitError as error: | |
print(f"griffe: error: {error}", file=sys.stderr) | |
return 2 | |
against_path = against_path or package | |
repository = get_repo_root(against_path) | |
try: | |
loaded_extensions = load_extensions(*(extensions or ())) | |
except ExtensionError: | |
logger.exception("Could not load extensions") | |
return 1 | |
# Load old and new version of the package. | |
old_package = load_git( | |
against_path, | |
ref=against, | |
repo=repository, | |
extensions=loaded_extensions, | |
search_paths=search_paths, | |
allow_inspection=allow_inspection, | |
force_inspection=force_inspection, | |
resolve_aliases=True, | |
resolve_external=None, | |
) | |
if base_ref: | |
new_package = load_git( | |
package, | |
ref=base_ref, | |
repo=repository, | |
extensions=loaded_extensions, | |
search_paths=search_paths, | |
allow_inspection=allow_inspection, | |
force_inspection=force_inspection, | |
find_stubs_package=find_stubs_package, | |
resolve_aliases=True, | |
resolve_external=None, | |
) | |
else: | |
new_package = load( | |
package, | |
try_relative_path=True, | |
extensions=loaded_extensions, | |
search_paths=search_paths, | |
allow_inspection=allow_inspection, | |
force_inspection=force_inspection, | |
find_stubs_package=find_stubs_package, | |
resolve_aliases=True, | |
resolve_external=None, | |
) | |
# Find and display API breakages. | |
breakages = list(find_breaking_changes(old_package, new_package)) | |
if color is None and (force_color := os.getenv("FORCE_COLOR", None)) is not None: | |
color = force_color.lower() in {"1", "true", "y", "yes", "on"} | |
colorama.deinit() | |
colorama.init(strip=color if color is None else not color) | |
if style is None: | |
style = ExplanationStyle.VERBOSE if verbose else ExplanationStyle.ONE_LINE | |
else: | |
style = ExplanationStyle(style) | |
for breakage in breakages: | |
print(breakage.explain(style=style), file=sys.stderr) | |
if breakages: | |
return 1 | |
return 0 | |
def main(args: list[str] | None = None) -> int: | |
"""Run the main program. | |
This function is executed when you type `griffe` or `python -m griffe`. | |
Parameters: | |
args: Arguments passed from the command line. | |
Returns: | |
An exit code. | |
""" | |
# Parse arguments. | |
parser = get_parser() | |
opts: argparse.Namespace = parser.parse_args(args) | |
opts_dict = opts.__dict__ | |
opts_dict.pop("debug_info") | |
subcommand = opts_dict.pop("subcommand") | |
# Initialize logging. | |
log_level = opts_dict.pop("log_level", DEFAULT_LOG_LEVEL) | |
try: | |
level = getattr(logging, log_level) | |
except AttributeError: | |
choices = "', '".join(_level_choices) | |
print( | |
f"griffe: error: invalid log level '{log_level}' (choose from '{choices}')", | |
file=sys.stderr, | |
) | |
return 1 | |
else: | |
logging.basicConfig(format="%(levelname)-10s %(message)s", level=level) | |
# Run subcommand. | |
commands: dict[str, Callable[..., int]] = {"check": check, "dump": dump} | |
return commands[subcommand](**opts_dict) | |