Coverage for src/griffe/_internal/diff.py: 52.74%
356 statements
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-11 13:44 +0200
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-11 13:44 +0200
1# This module exports "breaking changes" related utilities.
2# The logic here is to iterate on objects and their members recursively,
3# to yield found breaking changes.
4#
5# The breakage class definitions might sound a bit verbose,
6# but declaring them this way helps with (de)serialization,
7# which we don't use yet, but could use in the future.
9from __future__ import annotations
11import contextlib
12from pathlib import Path
13from typing import TYPE_CHECKING, Any
15from colorama import Fore, Style
17from griffe._internal.enumerations import BreakageKind, ExplanationStyle, ParameterKind
18from griffe._internal.exceptions import AliasResolutionError
19from griffe._internal.git import _WORKTREE_PREFIX
20from griffe._internal.logger import logger
22if TYPE_CHECKING:
23 from collections.abc import Iterable, Iterator
25 from griffe._internal.models import Alias, Attribute, Class, Function, Object
27_POSITIONAL = frozenset((ParameterKind.positional_only, ParameterKind.positional_or_keyword))
28_KEYWORD = frozenset((ParameterKind.keyword_only, ParameterKind.positional_or_keyword))
29_POSITIONAL_KEYWORD_ONLY = frozenset((ParameterKind.positional_only, ParameterKind.keyword_only))
30_VARIADIC = frozenset((ParameterKind.var_positional, ParameterKind.var_keyword))
33class Breakage:
34 """Breakages can explain what broke from a version to another."""
36 kind: BreakageKind
37 """The kind of breakage."""
39 def __init__(self, obj: Object, old_value: Any, new_value: Any, details: str = "") -> None:
40 """Initialize the breakage.
42 Parameters:
43 obj: The object related to the breakage.
44 old_value: The old value.
45 new_value: The new, incompatible value.
46 details: Some details about the breakage.
47 """
48 self.obj = obj
49 """The object related to the breakage."""
50 self.old_value = old_value
51 """The old value."""
52 self.new_value = new_value
53 """The new, incompatible value."""
54 self.details = details
55 """Some details about the breakage."""
57 def __str__(self) -> str:
58 return self.kind.value
60 def __repr__(self) -> str:
61 return self.kind.name
63 def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002
64 """Return this object's data as a dictionary.
66 Parameters:
67 full: Whether to return full info, or just base info.
68 **kwargs: Additional serialization options.
70 Returns:
71 A dictionary.
72 """
73 return {
74 "kind": self.kind,
75 "object_path": self.obj.path,
76 "old_value": self.old_value,
77 "new_value": self.new_value,
78 }
80 def explain(self, style: ExplanationStyle = ExplanationStyle.ONE_LINE) -> str:
81 """Explain the breakage by showing old and new value.
83 Parameters:
84 style: The explanation style to use.
86 Returns:
87 An explanation.
88 """
89 return getattr(self, f"_explain_{style.value}")()
91 @property
92 def _filepath(self) -> Path:
93 if self.obj.is_alias:
94 return self.obj.parent.filepath # type: ignore[union-attr,return-value]
95 return self.obj.filepath # type: ignore[return-value]
97 @property
98 def _relative_filepath(self) -> Path:
99 if self.obj.is_alias:
100 return self.obj.parent.relative_filepath # type: ignore[union-attr]
101 return self.obj.relative_filepath
103 @property
104 def _relative_package_filepath(self) -> Path:
105 if self.obj.is_alias:
106 return self.obj.parent.relative_package_filepath # type: ignore[union-attr]
107 return self.obj.relative_package_filepath
109 @property
110 def _location(self) -> Path:
111 # Absolute file path probably means temporary worktree.
112 # We use our worktree prefix to remove some components
113 # of the path on the left (`/tmp/griffe-worktree-*/griffe_*/repo`).
114 if self._relative_filepath.is_absolute():
115 parts = self._relative_filepath.parts
116 for index, part in enumerate(parts):
117 if part.startswith(_WORKTREE_PREFIX):
118 return Path(*parts[index + 2 :])
119 return self._relative_filepath
121 @property
122 def _canonical_path(self) -> str:
123 if self.obj.is_alias:
124 return self.obj.path
125 return self.obj.canonical_path
127 @property
128 def _module_path(self) -> str:
129 if self.obj.is_alias:
130 return self.obj.parent.module.path # type: ignore[union-attr]
131 return self.obj.module.path
133 @property
134 def _relative_path(self) -> str:
135 return self._canonical_path[len(self._module_path) + 1 :] or "<module>"
137 @property
138 def _lineno(self) -> int:
139 # If the object was removed, and we are able to get the location (file path)
140 # as a relative path, then we use 0 instead of the original line number
141 # (it helps when checking current sources, and avoids pointing to now missing contents).
142 if self.kind is BreakageKind.OBJECT_REMOVED and self._relative_filepath != self._location:
143 return 0
144 if self.obj.is_alias:
145 return self.obj.alias_lineno or 0 # type: ignore[attr-defined]
146 return self.obj.lineno or 0
148 def _format_location(self, *, colors: bool = True) -> str:
149 bright = Style.BRIGHT if colors else ""
150 reset = Style.RESET_ALL if colors else ""
151 return f"{bright}{self._location}{reset}:{self._lineno}"
153 def _format_title(self, *, colors: bool = True) -> str: # noqa: ARG002
154 return self._relative_path
156 def _format_kind(self, *, colors: bool = True) -> str:
157 yellow = Fore.YELLOW if colors else ""
158 reset = Fore.RESET if colors else ""
159 return f"{yellow}{self.kind.value}{reset}"
161 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
162 return str(self.old_value)
164 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
165 return str(self.new_value)
167 def _explain_oneline(self) -> str:
168 explanation = f"{self._format_location()}: {self._format_title()}: {self._format_kind()}"
169 old = self._format_old_value()
170 new = self._format_new_value()
171 if old and new:
172 change = f"{old} -> {new}"
173 elif old:
174 change = old
175 elif new:
176 change = new
177 else:
178 change = ""
179 if change:
180 return f"{explanation}: {change}"
181 return explanation
183 def _explain_verbose(self) -> str:
184 lines = [f"{self._format_location()}: {self._format_title()}:"]
185 kind = self._format_kind()
186 old = self._format_old_value()
187 new = self._format_new_value()
188 if old or new:
189 lines.append(f"{kind}:")
190 else:
191 lines.append(kind)
192 if old:
193 lines.append(f" Old: {old}")
194 if new:
195 lines.append(f" New: {new}")
196 if self.details:
197 lines.append(f" Details: {self.details}")
198 lines.append("")
199 return "\n".join(lines)
201 def _explain_markdown(self) -> str:
202 explanation = f"- `{self._relative_path}`: *{self.kind.value}*"
203 old = self._format_old_value(colors=False)
204 if old and old != "unset":
205 old = f"`{old}`"
206 new = self._format_new_value(colors=False)
207 if new and new != "unset":
208 new = f"`{new}`"
209 if old and new:
210 change = f"{old} -> {new}"
211 elif old:
212 change = old
213 elif new:
214 change = new
215 else:
216 change = ""
217 if change:
218 return f"{explanation}: {change}"
219 return explanation
221 def _explain_github(self) -> str:
222 location = f"file={self._location},line={self._lineno}"
223 title = f"title={self._format_title(colors=False)}"
224 explanation = f"::warning {location},{title}::{self.kind.value}"
225 old = self._format_old_value(colors=False)
226 if old and old != "unset":
227 old = f"`{old}`"
228 new = self._format_new_value(colors=False)
229 if new and new != "unset":
230 new = f"`{new}`"
231 if old and new:
232 change = f"{old} -> {new}"
233 elif old:
234 change = old
235 elif new:
236 change = new
237 else:
238 change = ""
239 if change:
240 return f"{explanation}: {change}"
241 return explanation
244class ParameterMovedBreakage(Breakage):
245 """Specific breakage class for moved parameters."""
247 kind: BreakageKind = BreakageKind.PARAMETER_MOVED
249 @property
250 def _relative_path(self) -> str:
251 return f"{super()._relative_path}({self.old_value.name})"
253 def _format_title(self, *, colors: bool = True) -> str:
254 blue = Fore.BLUE if colors else ""
255 reset = Fore.RESET if colors else ""
256 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
258 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
259 return ""
261 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
262 return ""
265class ParameterRemovedBreakage(Breakage):
266 """Specific breakage class for removed parameters."""
268 kind: BreakageKind = BreakageKind.PARAMETER_REMOVED
270 @property
271 def _relative_path(self) -> str:
272 return f"{super()._relative_path}({self.old_value.name})"
274 def _format_title(self, *, colors: bool = True) -> str:
275 blue = Fore.BLUE if colors else ""
276 reset = Fore.RESET if colors else ""
277 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
279 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
280 return ""
282 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
283 return ""
286class ParameterChangedKindBreakage(Breakage):
287 """Specific breakage class for parameters whose kind changed."""
289 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND
291 @property
292 def _relative_path(self) -> str:
293 return f"{super()._relative_path}({self.old_value.name})"
295 def _format_title(self, *, colors: bool = True) -> str:
296 blue = Fore.BLUE if colors else ""
297 reset = Fore.RESET if colors else ""
298 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
300 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
301 return str(self.old_value.kind.value)
303 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
304 return str(self.new_value.kind.value)
307class ParameterChangedDefaultBreakage(Breakage):
308 """Specific breakage class for parameters whose default value changed."""
310 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT
312 @property
313 def _relative_path(self) -> str:
314 return f"{super()._relative_path}({self.old_value.name})"
316 def _format_title(self, *, colors: bool = True) -> str:
317 blue = Fore.BLUE if colors else ""
318 reset = Fore.RESET if colors else ""
319 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
321 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
322 return str(self.old_value.default)
324 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
325 return str(self.new_value.default)
328class ParameterChangedRequiredBreakage(Breakage):
329 """Specific breakage class for parameters which became required."""
331 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED
333 @property
334 def _relative_path(self) -> str:
335 return f"{super()._relative_path}({self.old_value.name})"
337 def _format_title(self, *, colors: bool = True) -> str:
338 blue = Fore.BLUE if colors else ""
339 reset = Fore.RESET if colors else ""
340 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
342 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
343 return ""
345 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
346 return ""
349class ParameterAddedRequiredBreakage(Breakage):
350 """Specific breakage class for new parameters added as required."""
352 kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED
354 @property
355 def _relative_path(self) -> str:
356 return f"{super()._relative_path}({self.new_value.name})"
358 def _format_title(self, *, colors: bool = True) -> str:
359 blue = Fore.BLUE if colors else ""
360 reset = Fore.RESET if colors else ""
361 return f"{super()._relative_path}({blue}{self.new_value.name}{reset})"
363 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
364 return ""
366 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
367 return ""
370class ReturnChangedTypeBreakage(Breakage):
371 """Specific breakage class for return values which changed type."""
373 kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE
376class ObjectRemovedBreakage(Breakage):
377 """Specific breakage class for removed objects."""
379 kind: BreakageKind = BreakageKind.OBJECT_REMOVED
381 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
382 return ""
384 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
385 return ""
388class ObjectChangedKindBreakage(Breakage):
389 """Specific breakage class for objects whose kind changed."""
391 kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND
393 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
394 return self.old_value.value
396 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
397 return self.new_value.value
400class AttributeChangedTypeBreakage(Breakage):
401 """Specific breakage class for attributes whose type changed."""
403 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE
406class AttributeChangedValueBreakage(Breakage):
407 """Specific breakage class for attributes whose value changed."""
409 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE
412class ClassRemovedBaseBreakage(Breakage):
413 """Specific breakage class for removed base classes."""
415 kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE
417 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
418 return "[" + ", ".join(base.canonical_path for base in self.old_value) + "]"
420 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
421 return "[" + ", ".join(base.canonical_path for base in self.new_value) + "]"
424# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
425def _class_incompatibilities(
426 old_class: Class,
427 new_class: Class,
428 *,
429 seen_paths: set[str],
430) -> Iterable[Breakage]:
431 yield from ()
432 if new_class.bases != old_class.bases and len(new_class.bases) < len(old_class.bases):
433 yield ClassRemovedBaseBreakage(new_class, old_class.bases, new_class.bases)
434 yield from _member_incompatibilities(old_class, new_class, seen_paths=seen_paths)
437# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
438def _function_incompatibilities(old_function: Function, new_function: Function) -> Iterator[Breakage]:
439 new_param_names = [param.name for param in new_function.parameters]
440 param_kinds = {param.kind for param in new_function.parameters}
441 has_variadic_args = ParameterKind.var_positional in param_kinds
442 has_variadic_kwargs = ParameterKind.var_keyword in param_kinds
444 for old_index, old_param in enumerate(old_function.parameters):
445 # Check if the parameter was removed.
446 if old_param.name not in new_function.parameters:
447 swallowed = (
448 (old_param.kind is ParameterKind.keyword_only and has_variadic_kwargs)
449 or (old_param.kind is ParameterKind.positional_only and has_variadic_args)
450 or (old_param.kind is ParameterKind.positional_or_keyword and has_variadic_args and has_variadic_kwargs)
451 )
452 if not swallowed: 452 ↛ 454line 452 didn't jump to line 454 because the condition on line 452 was always true
453 yield ParameterRemovedBreakage(new_function, old_param, None)
454 continue
456 # Check if the parameter became required.
457 new_param = new_function.parameters[old_param.name]
458 if new_param.required and not old_param.required:
459 yield ParameterChangedRequiredBreakage(new_function, old_param, new_param)
461 # Check if the parameter was moved.
462 if old_param.kind in _POSITIONAL and new_param.kind in _POSITIONAL:
463 new_index = new_param_names.index(old_param.name)
464 if new_index != old_index:
465 details = f"position: from {old_index} to {new_index} ({new_index - old_index:+})"
466 yield ParameterMovedBreakage(new_function, old_param, new_param, details=details)
468 # Check if the parameter changed kind.
469 if old_param.kind is not new_param.kind:
470 incompatible_kind = any(
471 (
472 # Positional-only to keyword-only.
473 old_param.kind is ParameterKind.positional_only and new_param.kind is ParameterKind.keyword_only,
474 # Keyword-only to positional-only.
475 old_param.kind is ParameterKind.keyword_only and new_param.kind is ParameterKind.positional_only,
476 # Positional or keyword to positional-only/keyword-only.
477 old_param.kind is ParameterKind.positional_or_keyword
478 and new_param.kind in _POSITIONAL_KEYWORD_ONLY,
479 # Not keyword-only to variadic keyword, without variadic positional.
480 new_param.kind is ParameterKind.var_keyword
481 and old_param.kind is not ParameterKind.keyword_only
482 and not has_variadic_args,
483 # Not positional-only to variadic positional, without variadic keyword.
484 new_param.kind is ParameterKind.var_positional
485 and old_param.kind is not ParameterKind.positional_only
486 and not has_variadic_kwargs,
487 ),
488 )
489 if incompatible_kind:
490 yield ParameterChangedKindBreakage(new_function, old_param, new_param)
492 # Check if the parameter changed default.
493 breakage = ParameterChangedDefaultBreakage(new_function, old_param, new_param)
494 non_required = not old_param.required and not new_param.required
495 non_variadic = old_param.kind not in _VARIADIC and new_param.kind not in _VARIADIC
496 if non_required and non_variadic:
497 try:
498 if old_param.default != new_param.default: 498 ↛ 444line 498 didn't jump to line 444 because the condition on line 498 was always true
499 yield breakage
500 except Exception: # noqa: BLE001 (equality checks sometimes fail, e.g. numpy arrays)
501 # NOTE: Emitting breakage on a failed comparison could be a preference.
502 yield breakage
504 # Check if required parameters were added.
505 for new_param in new_function.parameters:
506 if new_param.name not in old_function.parameters and new_param.required:
507 yield ParameterAddedRequiredBreakage(new_function, None, new_param)
509 if not _returns_are_compatible(old_function, new_function): 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 yield ReturnChangedTypeBreakage(new_function, old_function.returns, new_function.returns)
513def _attribute_incompatibilities(old_attribute: Attribute, new_attribute: Attribute) -> Iterable[Breakage]:
514 # TODO: Support annotation breaking changes.
515 if old_attribute.value != new_attribute.value: 515 ↛ exitline 515 didn't return from function '_attribute_incompatibilities' because the condition on line 515 was always true
516 if new_attribute.value is None:
517 yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, "unset")
518 else:
519 yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, new_attribute.value)
522def _alias_incompatibilities(
523 old_obj: Object | Alias,
524 new_obj: Object | Alias,
525 *,
526 seen_paths: set[str],
527) -> Iterable[Breakage]:
528 try:
529 old_member = old_obj.target if old_obj.is_alias else old_obj # type: ignore[union-attr]
530 new_member = new_obj.target if new_obj.is_alias else new_obj # type: ignore[union-attr]
531 except AliasResolutionError:
532 logger.debug("API check: %s | %s: skip alias with unknown target", old_obj.path, new_obj.path)
533 return
535 yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
538def _member_incompatibilities(
539 old_obj: Object | Alias,
540 new_obj: Object | Alias,
541 *,
542 seen_paths: set[str] | None = None,
543) -> Iterator[Breakage]:
544 seen_paths = set() if seen_paths is None else seen_paths
545 for name, old_member in old_obj.all_members.items():
546 if not old_member.is_public:
547 logger.debug("API check: %s.%s: skip non-public object", old_obj.path, name)
548 continue
549 logger.debug("API check: %s.%s", old_obj.path, name)
550 try:
551 new_member = new_obj.all_members[name]
552 except KeyError:
553 if (not old_member.is_alias and old_member.is_module) or old_member.is_public: 553 ↛ 545line 553 didn't jump to line 545 because the condition on line 553 was always true
554 yield ObjectRemovedBreakage(old_member, old_member, None) # type: ignore[arg-type]
555 else:
556 yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
559def _type_based_yield(
560 old_member: Object | Alias,
561 new_member: Object | Alias,
562 *,
563 seen_paths: set[str],
564) -> Iterator[Breakage]:
565 if old_member.path in seen_paths:
566 return
567 seen_paths.add(old_member.path)
568 if old_member.is_alias or new_member.is_alias:
569 # Should be first, since there can be the case where there is an alias and another kind of object,
570 # which may not be a breaking change.
571 yield from _alias_incompatibilities(
572 old_member,
573 new_member,
574 seen_paths=seen_paths,
575 )
576 elif new_member.kind != old_member.kind:
577 yield ObjectChangedKindBreakage(new_member, old_member.kind, new_member.kind) # type: ignore[arg-type]
578 elif old_member.is_module: 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true
579 yield from _member_incompatibilities(
580 old_member,
581 new_member,
582 seen_paths=seen_paths,
583 )
584 elif old_member.is_class:
585 yield from _class_incompatibilities(
586 old_member, # type: ignore[arg-type]
587 new_member, # type: ignore[arg-type]
588 seen_paths=seen_paths,
589 )
590 elif old_member.is_function:
591 yield from _function_incompatibilities(old_member, new_member) # type: ignore[arg-type]
592 elif old_member.is_attribute: 592 ↛ exitline 592 didn't return from function '_type_based_yield' because the condition on line 592 was always true
593 yield from _attribute_incompatibilities(old_member, new_member) # type: ignore[arg-type]
596def _returns_are_compatible(old_function: Function, new_function: Function) -> bool:
597 # We consider that a return value of `None` only is not a strong contract,
598 # it just means that the function returns nothing. We don't expect users
599 # to be asserting that the return value is `None`.
600 # Therefore we don't consider it a breakage if the return changes from `None`
601 # to something else: the function just gained a return value.
602 if old_function.returns is None:
603 return True
605 if new_function.returns is None: 605 ↛ 607line 605 didn't jump to line 607 because the condition on line 605 was never true
606 # NOTE: Should it be configurable to allow/disallow removing a return type?
607 return False
609 with contextlib.suppress(AttributeError):
610 if new_function.returns == old_function.returns: 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true
611 return True
613 # TODO: Support annotation breaking changes.
614 return True
617_sentinel = object()
620def find_breaking_changes(
621 old_obj: Object | Alias,
622 new_obj: Object | Alias,
623) -> Iterator[Breakage]:
624 """Find breaking changes between two versions of the same API.
626 The function will iterate recursively on all objects
627 and yield breaking changes with detailed information.
629 Parameters:
630 old_obj: The old version of an object.
631 new_obj: The new version of an object.
633 Yields:
634 Breaking changes.
636 Examples:
637 >>> import sys, griffe
638 >>> new = griffe.load("pkg")
639 >>> old = griffe.load_git("pkg", "1.2.3")
640 >>> for breakage in griffe.find_breaking_changes(old, new)
641 ... print(breakage.explain(style=style), file=sys.stderr)
642 """
643 yield from _member_incompatibilities(old_obj, new_obj)