Spaces:
Running
Running
# This module exports "breaking changes" related utilities. | |
# The logic here is to iterate on objects and their members recursively, | |
# to yield found breaking changes. | |
# | |
# The breakage class definitions might sound a bit verbose, | |
# but declaring them this way helps with (de)serialization, | |
# which we don't use yet, but could use in the future. | |
from __future__ import annotations | |
import contextlib | |
from pathlib import Path | |
from typing import TYPE_CHECKING, Any | |
from colorama import Fore, Style | |
from _griffe.enumerations import BreakageKind, ExplanationStyle, ParameterKind | |
from _griffe.exceptions import AliasResolutionError | |
from _griffe.git import _WORKTREE_PREFIX | |
from _griffe.logger import logger | |
if TYPE_CHECKING: | |
from collections.abc import Iterable, Iterator | |
from _griffe.models import Alias, Attribute, Class, Function, Object | |
_POSITIONAL = frozenset((ParameterKind.positional_only, ParameterKind.positional_or_keyword)) | |
_KEYWORD = frozenset((ParameterKind.keyword_only, ParameterKind.positional_or_keyword)) | |
_POSITIONAL_KEYWORD_ONLY = frozenset((ParameterKind.positional_only, ParameterKind.keyword_only)) | |
_VARIADIC = frozenset((ParameterKind.var_positional, ParameterKind.var_keyword)) | |
class Breakage: | |
"""Breakages can explain what broke from a version to another.""" | |
kind: BreakageKind | |
"""The kind of breakage.""" | |
def __init__(self, obj: Object, old_value: Any, new_value: Any, details: str = "") -> None: | |
"""Initialize the breakage. | |
Parameters: | |
obj: The object related to the breakage. | |
old_value: The old value. | |
new_value: The new, incompatible value. | |
details: Some details about the breakage. | |
""" | |
self.obj = obj | |
"""The object related to the breakage.""" | |
self.old_value = old_value | |
"""The old value.""" | |
self.new_value = new_value | |
"""The new, incompatible value.""" | |
self.details = details | |
"""Some details about the breakage.""" | |
def __str__(self) -> str: | |
return self.kind.value | |
def __repr__(self) -> str: | |
return self.kind.name | |
def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002 | |
"""Return this object's data as a dictionary. | |
Parameters: | |
full: Whether to return full info, or just base info. | |
**kwargs: Additional serialization options. | |
Returns: | |
A dictionary. | |
""" | |
return { | |
"kind": self.kind, | |
"object_path": self.obj.path, | |
"old_value": self.old_value, | |
"new_value": self.new_value, | |
} | |
def explain(self, style: ExplanationStyle = ExplanationStyle.ONE_LINE) -> str: | |
"""Explain the breakage by showing old and new value. | |
Parameters: | |
style: The explanation style to use. | |
Returns: | |
An explanation. | |
""" | |
return getattr(self, f"_explain_{style.value}")() | |
def _filepath(self) -> Path: | |
if self.obj.is_alias: | |
return self.obj.parent.filepath # type: ignore[union-attr,return-value] | |
return self.obj.filepath # type: ignore[return-value] | |
def _relative_filepath(self) -> Path: | |
if self.obj.is_alias: | |
return self.obj.parent.relative_filepath # type: ignore[union-attr] | |
return self.obj.relative_filepath | |
def _relative_package_filepath(self) -> Path: | |
if self.obj.is_alias: | |
return self.obj.parent.relative_package_filepath # type: ignore[union-attr] | |
return self.obj.relative_package_filepath | |
def _location(self) -> Path: | |
# Absolute file path probably means temporary worktree. | |
# We use our worktree prefix to remove some components | |
# of the path on the left (`/tmp/griffe-worktree-*/griffe_*/repo`). | |
if self._relative_filepath.is_absolute(): | |
parts = self._relative_filepath.parts | |
for index, part in enumerate(parts): | |
if part.startswith(_WORKTREE_PREFIX): | |
return Path(*parts[index + 2 :]) | |
return self._relative_filepath | |
def _canonical_path(self) -> str: | |
if self.obj.is_alias: | |
return self.obj.path | |
return self.obj.canonical_path | |
def _module_path(self) -> str: | |
if self.obj.is_alias: | |
return self.obj.parent.module.path # type: ignore[union-attr] | |
return self.obj.module.path | |
def _relative_path(self) -> str: | |
return self._canonical_path[len(self._module_path) + 1 :] or "<module>" | |
def _lineno(self) -> int: | |
# If the object was removed, and we are able to get the location (file path) | |
# as a relative path, then we use 0 instead of the original line number | |
# (it helps when checking current sources, and avoids pointing to now missing contents). | |
if self.kind is BreakageKind.OBJECT_REMOVED and self._relative_filepath != self._location: | |
return 0 | |
if self.obj.is_alias: | |
return self.obj.alias_lineno or 0 # type: ignore[attr-defined] | |
return self.obj.lineno or 0 | |
def _format_location(self, *, colors: bool = True) -> str: | |
bright = Style.BRIGHT if colors else "" | |
reset = Style.RESET_ALL if colors else "" | |
return f"{bright}{self._location}{reset}:{self._lineno}" | |
def _format_title(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return self._relative_path | |
def _format_kind(self, *, colors: bool = True) -> str: | |
yellow = Fore.YELLOW if colors else "" | |
reset = Fore.RESET if colors else "" | |
return f"{yellow}{self.kind.value}{reset}" | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return str(self.old_value) | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return str(self.new_value) | |
def _explain_oneline(self) -> str: | |
explanation = f"{self._format_location()}: {self._format_title()}: {self._format_kind()}" | |
old = self._format_old_value() | |
new = self._format_new_value() | |
if old and new: | |
change = f"{old} -> {new}" | |
elif old: | |
change = old | |
elif new: | |
change = new | |
else: | |
change = "" | |
if change: | |
return f"{explanation}: {change}" | |
return explanation | |
def _explain_verbose(self) -> str: | |
lines = [f"{self._format_location()}: {self._format_title()}:"] | |
kind = self._format_kind() | |
old = self._format_old_value() | |
new = self._format_new_value() | |
if old or new: | |
lines.append(f"{kind}:") | |
else: | |
lines.append(kind) | |
if old: | |
lines.append(f" Old: {old}") | |
if new: | |
lines.append(f" New: {new}") | |
if self.details: | |
lines.append(f" Details: {self.details}") | |
lines.append("") | |
return "\n".join(lines) | |
def _explain_markdown(self) -> str: | |
explanation = f"- `{self._relative_path}`: *{self.kind.value}*" | |
old = self._format_old_value(colors=False) | |
if old and old != "unset": | |
old = f"`{old}`" | |
new = self._format_new_value(colors=False) | |
if new and new != "unset": | |
new = f"`{new}`" | |
if old and new: | |
change = f"{old} -> {new}" | |
elif old: | |
change = old | |
elif new: | |
change = new | |
else: | |
change = "" | |
if change: | |
return f"{explanation}: {change}" | |
return explanation | |
def _explain_github(self) -> str: | |
location = f"file={self._location},line={self._lineno}" | |
title = f"title={self._format_title(colors=False)}" | |
explanation = f"::warning {location},{title}::{self.kind.value}" | |
old = self._format_old_value(colors=False) | |
if old and old != "unset": | |
old = f"`{old}`" | |
new = self._format_new_value(colors=False) | |
if new and new != "unset": | |
new = f"`{new}`" | |
if old and new: | |
change = f"{old} -> {new}" | |
elif old: | |
change = old | |
elif new: | |
change = new | |
else: | |
change = "" | |
if change: | |
return f"{explanation}: {change}" | |
return explanation | |
class ParameterMovedBreakage(Breakage): | |
"""Specific breakage class for moved parameters.""" | |
kind: BreakageKind = BreakageKind.PARAMETER_MOVED | |
def _relative_path(self) -> str: | |
return f"{super()._relative_path}({self.old_value.name})" | |
def _format_title(self, *, colors: bool = True) -> str: | |
blue = Fore.BLUE if colors else "" | |
reset = Fore.RESET if colors else "" | |
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})" | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
class ParameterRemovedBreakage(Breakage): | |
"""Specific breakage class for removed parameters.""" | |
kind: BreakageKind = BreakageKind.PARAMETER_REMOVED | |
def _relative_path(self) -> str: | |
return f"{super()._relative_path}({self.old_value.name})" | |
def _format_title(self, *, colors: bool = True) -> str: | |
blue = Fore.BLUE if colors else "" | |
reset = Fore.RESET if colors else "" | |
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})" | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
class ParameterChangedKindBreakage(Breakage): | |
"""Specific breakage class for parameters whose kind changed.""" | |
kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND | |
def _relative_path(self) -> str: | |
return f"{super()._relative_path}({self.old_value.name})" | |
def _format_title(self, *, colors: bool = True) -> str: | |
blue = Fore.BLUE if colors else "" | |
reset = Fore.RESET if colors else "" | |
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})" | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return str(self.old_value.kind.value) | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return str(self.new_value.kind.value) | |
class ParameterChangedDefaultBreakage(Breakage): | |
"""Specific breakage class for parameters whose default value changed.""" | |
kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT | |
def _relative_path(self) -> str: | |
return f"{super()._relative_path}({self.old_value.name})" | |
def _format_title(self, *, colors: bool = True) -> str: | |
blue = Fore.BLUE if colors else "" | |
reset = Fore.RESET if colors else "" | |
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})" | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return str(self.old_value.default) | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return str(self.new_value.default) | |
class ParameterChangedRequiredBreakage(Breakage): | |
"""Specific breakage class for parameters which became required.""" | |
kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED | |
def _relative_path(self) -> str: | |
return f"{super()._relative_path}({self.old_value.name})" | |
def _format_title(self, *, colors: bool = True) -> str: | |
blue = Fore.BLUE if colors else "" | |
reset = Fore.RESET if colors else "" | |
return f"{super()._relative_path}({blue}{self.old_value.name}{reset})" | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
class ParameterAddedRequiredBreakage(Breakage): | |
"""Specific breakage class for new parameters added as required.""" | |
kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED | |
def _relative_path(self) -> str: | |
return f"{super()._relative_path}({self.new_value.name})" | |
def _format_title(self, *, colors: bool = True) -> str: | |
blue = Fore.BLUE if colors else "" | |
reset = Fore.RESET if colors else "" | |
return f"{super()._relative_path}({blue}{self.new_value.name}{reset})" | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
class ReturnChangedTypeBreakage(Breakage): | |
"""Specific breakage class for return values which changed type.""" | |
kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE | |
class ObjectRemovedBreakage(Breakage): | |
"""Specific breakage class for removed objects.""" | |
kind: BreakageKind = BreakageKind.OBJECT_REMOVED | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "" | |
class ObjectChangedKindBreakage(Breakage): | |
"""Specific breakage class for objects whose kind changed.""" | |
kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return self.old_value.value | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return self.new_value.value | |
class AttributeChangedTypeBreakage(Breakage): | |
"""Specific breakage class for attributes whose type changed.""" | |
kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE | |
class AttributeChangedValueBreakage(Breakage): | |
"""Specific breakage class for attributes whose value changed.""" | |
kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE | |
class ClassRemovedBaseBreakage(Breakage): | |
"""Specific breakage class for removed base classes.""" | |
kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE | |
def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "[" + ", ".join(base.canonical_path for base in self.old_value) + "]" | |
def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 | |
return "[" + ", ".join(base.canonical_path for base in self.new_value) + "]" | |
# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis. | |
def _class_incompatibilities( | |
old_class: Class, | |
new_class: Class, | |
*, | |
seen_paths: set[str], | |
) -> Iterable[Breakage]: | |
yield from () | |
if new_class.bases != old_class.bases and len(new_class.bases) < len(old_class.bases): | |
yield ClassRemovedBaseBreakage(new_class, old_class.bases, new_class.bases) | |
yield from _member_incompatibilities(old_class, new_class, seen_paths=seen_paths) | |
# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis. | |
def _function_incompatibilities(old_function: Function, new_function: Function) -> Iterator[Breakage]: | |
new_param_names = [param.name for param in new_function.parameters] | |
param_kinds = {param.kind for param in new_function.parameters} | |
has_variadic_args = ParameterKind.var_positional in param_kinds | |
has_variadic_kwargs = ParameterKind.var_keyword in param_kinds | |
for old_index, old_param in enumerate(old_function.parameters): | |
# Check if the parameter was removed. | |
if old_param.name not in new_function.parameters: | |
swallowed = ( | |
(old_param.kind is ParameterKind.keyword_only and has_variadic_kwargs) | |
or (old_param.kind is ParameterKind.positional_only and has_variadic_args) | |
or (old_param.kind is ParameterKind.positional_or_keyword and has_variadic_args and has_variadic_kwargs) | |
) | |
if not swallowed: | |
yield ParameterRemovedBreakage(new_function, old_param, None) | |
continue | |
# Check if the parameter became required. | |
new_param = new_function.parameters[old_param.name] | |
if new_param.required and not old_param.required: | |
yield ParameterChangedRequiredBreakage(new_function, old_param, new_param) | |
# Check if the parameter was moved. | |
if old_param.kind in _POSITIONAL and new_param.kind in _POSITIONAL: | |
new_index = new_param_names.index(old_param.name) | |
if new_index != old_index: | |
details = f"position: from {old_index} to {new_index} ({new_index - old_index:+})" | |
yield ParameterMovedBreakage(new_function, old_param, new_param, details=details) | |
# Check if the parameter changed kind. | |
if old_param.kind is not new_param.kind: | |
incompatible_kind = any( | |
( | |
# Positional-only to keyword-only. | |
old_param.kind is ParameterKind.positional_only and new_param.kind is ParameterKind.keyword_only, | |
# Keyword-only to positional-only. | |
old_param.kind is ParameterKind.keyword_only and new_param.kind is ParameterKind.positional_only, | |
# Positional or keyword to positional-only/keyword-only. | |
old_param.kind is ParameterKind.positional_or_keyword | |
and new_param.kind in _POSITIONAL_KEYWORD_ONLY, | |
# Not keyword-only to variadic keyword, without variadic positional. | |
new_param.kind is ParameterKind.var_keyword | |
and old_param.kind is not ParameterKind.keyword_only | |
and not has_variadic_args, | |
# Not positional-only to variadic positional, without variadic keyword. | |
new_param.kind is ParameterKind.var_positional | |
and old_param.kind is not ParameterKind.positional_only | |
and not has_variadic_kwargs, | |
), | |
) | |
if incompatible_kind: | |
yield ParameterChangedKindBreakage(new_function, old_param, new_param) | |
# Check if the parameter changed default. | |
breakage = ParameterChangedDefaultBreakage(new_function, old_param, new_param) | |
non_required = not old_param.required and not new_param.required | |
non_variadic = old_param.kind not in _VARIADIC and new_param.kind not in _VARIADIC | |
if non_required and non_variadic: | |
try: | |
if old_param.default != new_param.default: | |
yield breakage | |
except Exception: # noqa: BLE001 (equality checks sometimes fail, e.g. numpy arrays) | |
# NOTE: Emitting breakage on a failed comparison could be a preference. | |
yield breakage | |
# Check if required parameters were added. | |
for new_param in new_function.parameters: | |
if new_param.name not in old_function.parameters and new_param.required: | |
yield ParameterAddedRequiredBreakage(new_function, None, new_param) | |
if not _returns_are_compatible(old_function, new_function): | |
yield ReturnChangedTypeBreakage(new_function, old_function.returns, new_function.returns) | |
def _attribute_incompatibilities(old_attribute: Attribute, new_attribute: Attribute) -> Iterable[Breakage]: | |
# TODO: Support annotation breaking changes. | |
if old_attribute.value != new_attribute.value: | |
if new_attribute.value is None: | |
yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, "unset") | |
else: | |
yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, new_attribute.value) | |
def _alias_incompatibilities( | |
old_obj: Object | Alias, | |
new_obj: Object | Alias, | |
*, | |
seen_paths: set[str], | |
) -> Iterable[Breakage]: | |
try: | |
old_member = old_obj.target if old_obj.is_alias else old_obj # type: ignore[union-attr] | |
new_member = new_obj.target if new_obj.is_alias else new_obj # type: ignore[union-attr] | |
except AliasResolutionError: | |
logger.debug("API check: %s | %s: skip alias with unknown target", old_obj.path, new_obj.path) | |
return | |
yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths) | |
def _member_incompatibilities( | |
old_obj: Object | Alias, | |
new_obj: Object | Alias, | |
*, | |
seen_paths: set[str] | None = None, | |
) -> Iterator[Breakage]: | |
seen_paths = set() if seen_paths is None else seen_paths | |
for name, old_member in old_obj.all_members.items(): | |
if not old_member.is_public: | |
logger.debug("API check: %s.%s: skip non-public object", old_obj.path, name) | |
continue | |
logger.debug("API check: %s.%s", old_obj.path, name) | |
try: | |
new_member = new_obj.all_members[name] | |
except KeyError: | |
if (not old_member.is_alias and old_member.is_module) or old_member.is_public: | |
yield ObjectRemovedBreakage(old_member, old_member, None) # type: ignore[arg-type] | |
else: | |
yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths) | |
def _type_based_yield( | |
old_member: Object | Alias, | |
new_member: Object | Alias, | |
*, | |
seen_paths: set[str], | |
) -> Iterator[Breakage]: | |
if old_member.path in seen_paths: | |
return | |
seen_paths.add(old_member.path) | |
if old_member.is_alias or new_member.is_alias: | |
# Should be first, since there can be the case where there is an alias and another kind of object, | |
# which may not be a breaking change. | |
yield from _alias_incompatibilities( | |
old_member, | |
new_member, | |
seen_paths=seen_paths, | |
) | |
elif new_member.kind != old_member.kind: | |
yield ObjectChangedKindBreakage(new_member, old_member.kind, new_member.kind) # type: ignore[arg-type] | |
elif old_member.is_module: | |
yield from _member_incompatibilities( | |
old_member, | |
new_member, | |
seen_paths=seen_paths, | |
) | |
elif old_member.is_class: | |
yield from _class_incompatibilities( | |
old_member, # type: ignore[arg-type] | |
new_member, # type: ignore[arg-type] | |
seen_paths=seen_paths, | |
) | |
elif old_member.is_function: | |
yield from _function_incompatibilities(old_member, new_member) # type: ignore[arg-type] | |
elif old_member.is_attribute: | |
yield from _attribute_incompatibilities(old_member, new_member) # type: ignore[arg-type] | |
def _returns_are_compatible(old_function: Function, new_function: Function) -> bool: | |
# We consider that a return value of `None` only is not a strong contract, | |
# it just means that the function returns nothing. We don't expect users | |
# to be asserting that the return value is `None`. | |
# Therefore we don't consider it a breakage if the return changes from `None` | |
# to something else: the function just gained a return value. | |
if old_function.returns is None: | |
return True | |
if new_function.returns is None: | |
# NOTE: Should it be configurable to allow/disallow removing a return type? | |
return False | |
with contextlib.suppress(AttributeError): | |
if new_function.returns == old_function.returns: | |
return True | |
# TODO: Support annotation breaking changes. | |
return True | |
_sentinel = object() | |
def find_breaking_changes( | |
old_obj: Object | Alias, | |
new_obj: Object | Alias, | |
) -> Iterator[Breakage]: | |
"""Find breaking changes between two versions of the same API. | |
The function will iterate recursively on all objects | |
and yield breaking changes with detailed information. | |
Parameters: | |
old_obj: The old version of an object. | |
new_obj: The new version of an object. | |
Yields: | |
Breaking changes. | |
Examples: | |
>>> import sys, griffe | |
>>> new = griffe.load("pkg") | |
>>> old = griffe.load_git("pkg", "1.2.3") | |
>>> for breakage in griffe.find_breaking_changes(old, new) | |
... print(breakage.explain(style=style), file=sys.stderr) | |
""" | |
yield from _member_incompatibilities(old_obj, new_obj) | |