Skip to content

Commit

Permalink
feat: Add function to find API breaking changes
Browse files Browse the repository at this point in the history
Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
  • Loading branch information
pawamoy and tlambert03 committed Nov 6, 2022
1 parent 760b091 commit a4f1280
Show file tree
Hide file tree
Showing 7 changed files with 517 additions and 26 deletions.
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,25 +70,25 @@ See [the Usage section](https://mkdocstrings.github.io/griffe/usage/#with-python
- Flat JSON
- JSON schema
- API diff:
- Mecanism to cache APIs? Should users version them, or store them somewhere (docs)?
- Ability to return warnings (things that are not backward-compatibility-friendly)
- [ ] Mecanism to cache APIs? Should users version them, or store them somewhere (docs)?
- [ ] Ability to return warnings (things that are not backward-compatibility-friendly)
- List of things to consider for warnings
- Multiple positional-or-keyword parameters
- Public imports in public modules
- Private things made public through imports/assignments
- Too many public things? Generally annoying. Configuration?
- Ability to compare two APIs to return breaking changes
- [x] Ability to compare two APIs to return breaking changes
- List of things to consider for breaking changes
- Changed position of positional only parameter
- Changed position of positional or keyword parameter
- Changed type of parameter
- Changed type of public module attribute
- Changed return type of a public function/method
- Added parameter without a default value
- Removed keyword-only parameter without a default value, without **kwargs to swallow it
- Removed positional-only parameter without a default value, without *args to swallow it
- Removed positional-or_keyword argument without a default value, without *args and **kwargs to swallow it
- Removed public module/class/function/method/attribute
- All of the previous even when parent is private (could be publicly imported or assigned somewhere),
- [x] Changed position of positional only parameter
- [x] Changed position of positional or keyword parameter
- [ ] Changed type of parameter
- [ ] Changed type of public module attribute
- [ ] Changed return type of a public function/method
- [x] Added parameter without a default value
- [x] Removed keyword-only parameter without a default value, without **kwargs to swallow it
- [x] Removed positional-only parameter without a default value, without *args to swallow it
- [x] Removed positional-or_keyword argument without a default value, without *args and **kwargs to swallow it
- [x] Removed public module/class/function/method/attribute
- [ ] All of the previous even when parent is private (could be publicly imported or assigned somewhere),
and later be smarter: public assign/import makes private things public!
- Inheritance: removed base, or added/changed base that changes MRO
- [ ] Inheritance: removed, added or changed base that changes MRO
1 change: 1 addition & 0 deletions config/flake8.ini
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ ignore =

per-file-ignores =
src/griffe/dataclasses.py:WPS115
src/griffe/diff.py:WPS115
src/griffe/agents/nodes.py:WPS115,WPS116,WPS120
src/griffe/visitor.py:N802,D102
src/griffe/encoders.py:WPS232
Expand Down
4 changes: 3 additions & 1 deletion src/griffe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
to generate API documentation or find breaking changes in your API.
"""

from griffe.diff import find_breaking_changes
from griffe.git import load_git
from griffe.loader import load # noqa: WPS347

__all__ = ["load"]
__all__ = ["find_breaking_changes", "load", "load_git"]
15 changes: 15 additions & 0 deletions src/griffe/dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,21 @@ def __init__(
self.kind: ParameterKind | None = kind
self.default: str | None = default

def __str__(self) -> str:
param = f"{self.name}: {self.annotation} = {self.default}"
if self.kind:
return f"[{self.kind.value}] {param}"
return param

@property
def required(self) -> bool:
"""Tell if this parameter is required.
Returns:
True or False.
"""
return self.default is None

def as_dict(self, **kwargs: Any) -> dict[str, Any]:
"""Return this parameter's data as a dictionary.
Expand Down
290 changes: 290 additions & 0 deletions src/griffe/diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
"""This module exports "breaking changes" related utilities."""

from __future__ import annotations

import contextlib
import enum
from typing import Any, Iterable, Iterator

from griffe.dataclasses import Alias, Attribute, Class, Function, Object, ParameterKind
from griffe.logger import get_logger

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))

logger = get_logger(__name__)


class BreakageKind(enum.Enum):
"""An enumeration of the possible breakages."""

PARAMETER_MOVED: str = "Positional parameter was moved"
PARAMETER_REMOVED: str = "Parameter was removed"
PARAMETER_CHANGED_KIND: str = "Parameter kind was changed"
PARAMETER_CHANGED_DEFAULT: str = "Parameter default was changed"
PARAMETER_CHANGED_REQUIRED: str = "Parameter is now required"
PARAMETER_ADDED_REQUIRED: str = "Parameter was added as required"
RETURN_CHANGED_TYPE: str = "Return types are incompatible"
OBJECT_REMOVED: str = "Public object was removed"
OBJECT_CHANGED_KIND: str = "Public object points to a different kind of object"
ATTRIBUTE_CHANGED_TYPE: str = "Attribute types are incompatible"
ATTRIBUTE_CHANGED_VALUE: str = "Attribute value was changed"
CLASS_REMOVED_BASE: str = "Base class was removed"


class Breakage:
"""Breakages can explain what broke from a version to another."""

kind: BreakageKind

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
self.old_value = old_value
self.new_value = new_value
self.details = details

def __str__(self) -> str:
return f"{self.kind.value}"

def __repr__(self) -> str:
return f"<{self.kind.name}>"

def as_dict(self, full: bool = False, **kwargs: Any) -> dict[str, Any]:
"""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,
}


class ParameterMovedBreakage(Breakage):
"""Specific breakage class for moved parameters."""

kind: BreakageKind = BreakageKind.PARAMETER_MOVED


class ParameterRemovedBreakage(Breakage):
"""Specific breakage class for removed parameters."""

kind: BreakageKind = BreakageKind.PARAMETER_REMOVED


class ParameterChangedKindBreakage(Breakage):
"""Specific breakage class for parameters whose kind changed."""

kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND


class ParameterChangedDefaultBreakage(Breakage):
"""Specific breakage class for parameters whose default value changed."""

kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT


class ParameterChangedRequiredBreakage(Breakage):
"""Specific breakage class for parameters which became required."""

kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED


class ParameterAddedRequiredBreakage(Breakage):
"""Specific breakage class for new parameters added as required."""

kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED


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


class ObjectChangedKindBreakage(Breakage):
"""Specific breakage class for objects whose kind changed."""

kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND


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


# TODO: decorators!
def _class_incompatibilities(old_class: Class, new_class: Class, ignore_private: bool = True) -> Iterable[Breakage]:
yield from () # noqa WPS353
if new_class.bases != old_class.bases:
if len(new_class.bases) < len(old_class.bases):
yield ClassRemovedBaseBreakage(new_class, old_class.bases, new_class.bases)
else:
# TODO: check mro
...
yield from _member_incompatibilities(old_class, new_class, ignore_private=ignore_private)


# TODO: decorators!
def _function_incompatibilities(old_function: Function, new_function: Function) -> Iterator[Breakage]: # noqa: WPS231
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):
# checking if parameter was removed
if old_param.name not in new_function.parameters:
swallowed = (
(old_param.kind is ParameterKind.keyword_only and has_variadic_kwargs) # noqa: WPS222,WPS408
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

# checking if 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)

# checking if 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)

# checking if 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)

# checking if parameter changed default
breakage = ParameterChangedDefaultBreakage(new_function, old_param, new_param)
try:
if old_param.default is not None and old_param.default != new_param.default:
yield breakage
except Exception: # equality checks sometimes fail, e.g. numpy arrays
# TODO: emitting breakage on a failed comparison could be a preference
yield breakage

# checking if required parameters were added
for new_param in new_function.parameters: # noqa: WPS440
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: use beartype.peps.resolve_pep563 and beartype.door.is_subhint?
# if old_attribute.annotation is not None and new_attribute.annotation is not None:
# if not is_subhint(new_attribute.annotation, old_attribute.annotation):
# yield AttributeChangedTypeBreakage(new_attribute, old_attribute.annotation, new_attribute.annotation)
if old_attribute.value != new_attribute.value:
yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, new_attribute.value)


def _member_incompatibilities( # noqa: WPS231
old_obj: Object | Alias,
new_obj: Object | Alias,
ignore_private: bool = True,
) -> Iterator[Breakage]:
for name, old_member in old_obj.members.items():
if ignore_private and name.startswith("_"):
continue

if old_member.is_alias:
continue # TODO

try:
new_member = new_obj.members[name]
except KeyError:
if old_member.is_exported(explicitely=False):
yield ObjectRemovedBreakage(old_member, old_member, None) # type: ignore[arg-type]
continue

if 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, ignore_private=ignore_private) # type: ignore[arg-type]
elif old_member.is_class:
yield from _class_incompatibilities(old_member, new_member, ignore_private=ignore_private) # type: ignore[arg-type]
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:
if old_function.returns is None:
return True
if new_function.returns is None:
# TODO: it should 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: use beartype.peps.resolve_pep563 and beartype.door.is_subhint?
return True


find_breaking_changes = _member_incompatibilities
Loading

0 comments on commit a4f1280

Please sign in to comment.