ConradLinus's picture
Upload folder using huggingface_hub
d631808 verified
# 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}")()
@property
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]
@property
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
@property
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
@property
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
@property
def _canonical_path(self) -> str:
if self.obj.is_alias:
return self.obj.path
return self.obj.canonical_path
@property
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
@property
def _relative_path(self) -> str:
return self._canonical_path[len(self._module_path) + 1 :] or "<module>"
@property
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
@property
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
@property
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
@property
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
@property
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
@property
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
@property
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)