683 lines
25 KiB
Python
683 lines
25 KiB
Python
# 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 griffe._internal.enumerations import BreakageKind, ExplanationStyle, ParameterKind
|
|
from griffe._internal.exceptions import AliasResolutionError
|
|
from griffe._internal.git import _WORKTREE_PREFIX
|
|
from griffe._internal.logger import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterable, Iterator
|
|
|
|
from griffe._internal.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))
|
|
|
|
|
|
# Colors for terminal output.
|
|
class _ANSI:
|
|
FG_BLACK = "\033[30m"
|
|
FG_RED = "\033[31m"
|
|
FG_GREEN = "\033[32m"
|
|
FG_YELLOW = "\033[33m"
|
|
FG_BLUE = "\033[34m"
|
|
FG_MAGENTA = "\033[35m"
|
|
FG_CYAN = "\033[36m"
|
|
FG_WHITE = "\033[37m"
|
|
FG_RESET = "\033[39m"
|
|
FG_LIGHTBLACK_EX = "\033[90m"
|
|
FG_LIGHTRED_EX = "\033[91m"
|
|
FG_LIGHTGREEN_EX = "\033[92m"
|
|
FG_LIGHTYELLOW_EX = "\033[93m"
|
|
FG_LIGHTBLUE_EX = "\033[94m"
|
|
FG_LIGHTMAGENTA_EX = "\033[95m"
|
|
FG_LIGHTCYAN_EX = "\033[96m"
|
|
FG_LIGHTWHITE_EX = "\033[97m"
|
|
BG_BLACK = "\033[40m"
|
|
BG_RED = "\033[41m"
|
|
BG_GREEN = "\033[42m"
|
|
BG_YELLOW = "\033[43m"
|
|
BG_BLUE = "\033[44m"
|
|
BG_MAGENTA = "\033[45m"
|
|
BG_CYAN = "\033[46m"
|
|
BG_WHITE = "\033[47m"
|
|
BG_RESET = "\033[49m"
|
|
BG_LIGHTBLACK_EX = "\033[100m"
|
|
BG_LIGHTRED_EX = "\033[101m"
|
|
BG_LIGHTGREEN_EX = "\033[102m"
|
|
BG_LIGHTYELLOW_EX = "\033[103m"
|
|
BG_LIGHTBLUE_EX = "\033[104m"
|
|
BG_LIGHTMAGENTA_EX = "\033[105m"
|
|
BG_LIGHTCYAN_EX = "\033[106m"
|
|
BG_LIGHTWHITE_EX = "\033[107m"
|
|
BRIGHT = "\033[1m"
|
|
DIM = "\033[2m"
|
|
NORMAL = "\033[22m"
|
|
RESET_ALL = "\033[0m"
|
|
|
|
|
|
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 = _ANSI.BRIGHT if colors else ""
|
|
reset = _ANSI.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 = _ANSI.FG_YELLOW if colors else ""
|
|
reset = _ANSI.FG_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 = _ANSI.FG_BLUE if colors else ""
|
|
reset = _ANSI.FG_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 = _ANSI.FG_BLUE if colors else ""
|
|
reset = _ANSI.FG_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 = _ANSI.FG_BLUE if colors else ""
|
|
reset = _ANSI.FG_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 = _ANSI.FG_BLUE if colors else ""
|
|
reset = _ANSI.FG_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 = _ANSI.FG_BLUE if colors else ""
|
|
reset = _ANSI.FG_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 = _ANSI.FG_BLUE if colors else ""
|
|
reset = _ANSI.FG_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)
|