Skip to content

Commit

Permalink
feat: Add CLI command to check for API breakages
Browse files Browse the repository at this point in the history
  • Loading branch information
pawamoy committed Nov 6, 2022
1 parent 07bffff commit 90bded4
Show file tree
Hide file tree
Showing 4 changed files with 324 additions and 58 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ classifiers = [
]
dependencies = [
"cached-property; python_version < '3.8'",
"colorama>=0.4",
]

[project.optional-dependencies]
Expand Down
242 changes: 186 additions & 56 deletions src/griffe/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,25 @@
import json
import logging
import os
import subprocess # noqa: S404
import sys
from datetime import datetime
from pathlib import Path
from typing import IO, Any, Sequence, Type
from typing import IO, Any, Callable, Sequence, Type

import colorama

from griffe.agents.extensions import Extension, Extensions
from griffe.agents.extensions.base import load_extensions
from griffe.diff import ExplanationStyle, find_breaking_changes
from griffe.docstrings.parsers import Parser
from griffe.encoders import JSONEncoder
from griffe.exceptions import ExtensionError
from griffe.loader import GriffeLoader
from griffe.git import load_git
from griffe.loader import GriffeLoader, load # noqa: WPS347
from griffe.logger import get_logger

DEFAULT_LOG_LEVEL = os.getenv("GRIFFE_LOG_LEVEL", "INFO").upper()
logger = get_logger(__name__)


Expand All @@ -43,6 +49,30 @@ def _print_data(data: str, output_file: str | IO | None):
print(data, file=output_file)


def _get_latest_tag(path: str | Path) -> str:
if isinstance(path, str):
path = Path(path)
if not path.is_dir():
path = path.parent
output = subprocess.check_output( # noqa: S603,S607
["git", "describe", "--tags", "--abbrev=0"],
cwd=path,
)
return output.decode().strip()


def _get_repo_root(path: str | Path) -> str:
if isinstance(path, str):
path = Path(path)
if not path.is_dir():
path = path.parent
output = subprocess.check_output( # noqa: S603,S607
["git", "rev-parse", "--show-toplevel"],
cwd=path,
)
return output.decode().strip()


def _stats(stats):
lines = []
packages = stats["packages"]
Expand Down Expand Up @@ -114,7 +144,7 @@ def _load_packages(
resolve_implicit: bool = False,
resolve_external: bool = False,
allow_inspection: bool = True,
):
) -> GriffeLoader:
loader = GriffeLoader(
extensions=extensions,
search_paths=search_paths,
Expand Down Expand Up @@ -177,13 +207,6 @@ def add_common_options(subparser): # noqa: WPS430
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(
"-e",
Expand All @@ -192,25 +215,6 @@ def add_common_options(subparser): # noqa: WPS430
type=json.loads,
help="A list of extensions to use.",
)
loading_options.add_argument(
"-r",
"--resolve-aliases",
action="store_true",
help="Whether to resolve aliases.",
)
loading_options.add_argument(
"-I",
"--resolve-implicit",
action="store_true",
help="Whether to resolve implicitely exported aliases as well. "
"Aliases are explicitely exported when defined in '__all__'.",
)
loading_options.add_argument(
"-U",
"--resolve-external",
action="store_true",
help="Whether to resolve aliases pointing to external/unknown modules (not loaded directly).",
)
loading_options.add_argument(
"-X",
"--no-inspection",
Expand All @@ -219,42 +223,21 @@ def add_common_options(subparser): # noqa: WPS430
default=True,
help="Disallow inspection of builtin/compiled/not found modules.",
)
docstring_options = subparser.add_argument_group(title="Docstrings options")
docstring_options.add_argument(
"-d",
"--docstyle",
dest="docstring_parser",
default=None,
type=Parser,
help="The docstring style to parse.",
)
docstring_options.add_argument(
"-D",
"--docopts",
dest="docstring_options",
default={},
type=json.loads,
help="The options for the docstring parser.",
)
debug_options = subparser.add_argument_group(title="Debugging options")
debug_options.add_argument(
"-L",
"--log-level",
metavar="LEVEL",
default=os.getenv("GRIFFE_LOG_LEVEL", "INFO").upper(),
default=DEFAULT_LOG_LEVEL,
choices=_level_choices,
type=str.upper,
help="Set the log level: DEBUG, INFO, WARNING, ERROR, CRITICAL.",
)
debug_options.add_argument(
"-S",
"--stats",
action="store_true",
help="Show statistics at the end.",
)

# ========= SUBPARSERS ========= #
subparsers = parser.add_subparsers(dest="subcommand", title="Commands", metavar="", prog="griffe")
subparsers = parser.add_subparsers(
dest="subcommand", title="Commands", metavar="COMMAND", prog="griffe", required=True
)

def add_subparser(command: str, text: str, **kwargs) -> argparse.ArgumentParser: # noqa: WPS430 (nested function)
return subparsers.add_parser(command, add_help=False, help=text, description=text, **kwargs)
Expand All @@ -276,8 +259,75 @@ def add_subparser(command: str, text: str, **kwargs) -> argparse.ArgumentParser:
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(
"-y",
"--sys-path",
dest="append_sys_path",
action="store_true",
help="Whether to append sys.path to search paths specified with -s.",
)
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 implicitely exported aliases as well. "
"Aliases are explicitely exported when defined in '__all__'.",
)
dump_options.add_argument(
"-U",
"--resolve-external",
action="store_true",
help="Whether to resolve aliases pointing to external/unknown modules (not loaded directly).",
)
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("-v", "--verbose", action="store_true", help="Verbose output.")
add_common_options(check_parser)

return parser


Expand Down Expand Up @@ -360,6 +410,85 @@ def dump(
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,
allow_inspection: bool = True,
verbose: bool = False,
) -> int:
"""Load packages data and dump it as JSON.
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.
allow_inspection: Whether to allow inspecting modules when visiting them is not possible.
verbose: Use a verbose output.
Returns:
`0` for success, `1` for failure.
"""
colorama.deinit()
colorama.init()

search_paths = list(search_paths) if search_paths else []

against = against or _get_latest_tag(package)
against_path = against_path or package
repository = _get_repo_root(against_path)

try:
loaded_extensions = load_extensions(extensions or ())
except ExtensionError as error:
logger.error(error)
return 1

old_package = load_git(
against_path,
commit=against,
repo=repository,
extensions=loaded_extensions,
search_paths=search_paths,
allow_inspection=allow_inspection,
)
if base_ref:
new_package = load_git(
package,
commit=base_ref,
repo=repository,
extensions=loaded_extensions,
search_paths=search_paths,
allow_inspection=allow_inspection,
)
else:
new_package = load(
package,
try_relative_path=True,
extensions=loaded_extensions,
search_paths=search_paths,
allow_inspection=allow_inspection,
)

if verbose:
style = ExplanationStyle.VERBOSE
else:
style = ExplanationStyle.ONE_LINE
breakages = list(find_breaking_changes(old_package, new_package))
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: # noqa: WPS231
"""Run the main program.
Expand All @@ -376,7 +505,7 @@ def main(args: list[str] | None = None) -> int: # noqa: WPS231
opts_dict = opts.__dict__
subcommand = opts_dict.pop("subcommand")

log_level = opts_dict.pop("log_level")
log_level = opts_dict.pop("log_level", DEFAULT_LOG_LEVEL)
try:
level = getattr(logging, log_level)
except AttributeError:
Expand All @@ -389,4 +518,5 @@ def main(args: list[str] | None = None) -> int: # noqa: WPS231
else:
logging.basicConfig(format="%(levelname)-10s %(message)s", level=level) # noqa: WPS323

return {"dump": dump}[subcommand](**opts_dict)
commands: dict[str, Callable[..., int]] = {"check": check, "dump": dump}
return commands[subcommand](**opts_dict)
Loading

0 comments on commit 90bded4

Please sign in to comment.