Coverage for packages / griffelib / src / griffe / _internal / diff.py: 56.25%
394 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-11 11:48 +0100
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-11 11:48 +0100
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 griffe._internal.enumerations import BreakageKind, ExplanationStyle, ParameterKind
16from griffe._internal.exceptions import AliasResolutionError
17from griffe._internal.git import _WORKTREE_PREFIX
18from griffe._internal.logger import logger
20if TYPE_CHECKING:
21 from collections.abc import Iterable, Iterator
23 from griffe._internal.models import Alias, Attribute, Class, Function, Object
25_POSITIONAL = frozenset((ParameterKind.positional_only, ParameterKind.positional_or_keyword))
26_KEYWORD = frozenset((ParameterKind.keyword_only, ParameterKind.positional_or_keyword))
27_POSITIONAL_KEYWORD_ONLY = frozenset((ParameterKind.positional_only, ParameterKind.keyword_only))
28_VARIADIC = frozenset((ParameterKind.var_positional, ParameterKind.var_keyword))
31# Colors for terminal output.
32class _ANSI:
33 FG_BLACK = "\033[30m"
34 FG_RED = "\033[31m"
35 FG_GREEN = "\033[32m"
36 FG_YELLOW = "\033[33m"
37 FG_BLUE = "\033[34m"
38 FG_MAGENTA = "\033[35m"
39 FG_CYAN = "\033[36m"
40 FG_WHITE = "\033[37m"
41 FG_RESET = "\033[39m"
42 FG_LIGHTBLACK_EX = "\033[90m"
43 FG_LIGHTRED_EX = "\033[91m"
44 FG_LIGHTGREEN_EX = "\033[92m"
45 FG_LIGHTYELLOW_EX = "\033[93m"
46 FG_LIGHTBLUE_EX = "\033[94m"
47 FG_LIGHTMAGENTA_EX = "\033[95m"
48 FG_LIGHTCYAN_EX = "\033[96m"
49 FG_LIGHTWHITE_EX = "\033[97m"
50 BG_BLACK = "\033[40m"
51 BG_RED = "\033[41m"
52 BG_GREEN = "\033[42m"
53 BG_YELLOW = "\033[43m"
54 BG_BLUE = "\033[44m"
55 BG_MAGENTA = "\033[45m"
56 BG_CYAN = "\033[46m"
57 BG_WHITE = "\033[47m"
58 BG_RESET = "\033[49m"
59 BG_LIGHTBLACK_EX = "\033[100m"
60 BG_LIGHTRED_EX = "\033[101m"
61 BG_LIGHTGREEN_EX = "\033[102m"
62 BG_LIGHTYELLOW_EX = "\033[103m"
63 BG_LIGHTBLUE_EX = "\033[104m"
64 BG_LIGHTMAGENTA_EX = "\033[105m"
65 BG_LIGHTCYAN_EX = "\033[106m"
66 BG_LIGHTWHITE_EX = "\033[107m"
67 BRIGHT = "\033[1m"
68 DIM = "\033[2m"
69 NORMAL = "\033[22m"
70 RESET_ALL = "\033[0m"
73class Breakage:
74 """Breakages can explain what broke from a version to another."""
76 kind: BreakageKind
77 """The kind of breakage."""
79 def __init__(self, obj: Object, old_value: Any, new_value: Any, details: str = "") -> None:
80 """Initialize the breakage.
82 Parameters:
83 obj: The object related to the breakage.
84 old_value: The old value.
85 new_value: The new, incompatible value.
86 details: Some details about the breakage.
87 """
88 self.obj = obj
89 """The object related to the breakage."""
90 self.old_value = old_value
91 """The old value."""
92 self.new_value = new_value
93 """The new, incompatible value."""
94 self.details = details
95 """Some details about the breakage."""
97 def __str__(self) -> str:
98 return self.kind.value
100 def __repr__(self) -> str:
101 return self.kind.name
103 def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002
104 """Return this object's data as a dictionary.
106 Parameters:
107 full: Whether to return full info, or just base info.
108 **kwargs: Additional serialization options.
110 Returns:
111 A dictionary.
112 """
113 return {
114 "kind": self.kind,
115 "object_path": self.obj.path,
116 "old_value": self.old_value,
117 "new_value": self.new_value,
118 }
120 def explain(self, style: ExplanationStyle = ExplanationStyle.ONE_LINE) -> str:
121 """Explain the breakage by showing old and new value.
123 Parameters:
124 style: The explanation style to use.
126 Returns:
127 An explanation.
128 """
129 return getattr(self, f"_explain_{style.value}")()
131 @property
132 def _filepath(self) -> Path:
133 if self.obj.is_alias:
134 return self.obj.parent.filepath # ty:ignore[invalid-return-type, possibly-missing-attribute]
135 return self.obj.filepath # ty:ignore[invalid-return-type]
137 @property
138 def _relative_filepath(self) -> Path:
139 if self.obj.is_alias:
140 return self.obj.parent.relative_filepath # ty:ignore[possibly-missing-attribute]
141 return self.obj.relative_filepath
143 @property
144 def _relative_package_filepath(self) -> Path:
145 if self.obj.is_alias:
146 return self.obj.parent.relative_package_filepath # ty:ignore[possibly-missing-attribute]
147 return self.obj.relative_package_filepath
149 @property
150 def _location(self) -> Path:
151 # Absolute file path probably means temporary worktree.
152 # We use our worktree prefix to remove some components
153 # of the path on the left (`/tmp/griffe-worktree-*/griffe_*/repo`).
154 if self._relative_filepath.is_absolute():
155 parts = self._relative_filepath.parts
156 for index, part in enumerate(parts):
157 if part.startswith(_WORKTREE_PREFIX):
158 return Path(*parts[index + 2 :])
159 return self._relative_filepath
161 @property
162 def _canonical_path(self) -> str:
163 if self.obj.is_alias:
164 return self.obj.path
165 return self.obj.canonical_path
167 @property
168 def _module_path(self) -> str:
169 if self.obj.is_alias:
170 return self.obj.parent.module.path # ty:ignore[possibly-missing-attribute]
171 return self.obj.module.path
173 @property
174 def _relative_path(self) -> str:
175 return self._canonical_path[len(self._module_path) + 1 :] or "<module>"
177 @property
178 def _lineno(self) -> int:
179 # If the object was removed, and we are able to get the location (file path)
180 # as a relative path, then we use 0 instead of the original line number
181 # (it helps when checking current sources, and avoids pointing to now missing contents).
182 if self.kind is BreakageKind.OBJECT_REMOVED and self._relative_filepath != self._location:
183 return 0
184 if self.obj.is_alias:
185 return self.obj.alias_lineno or 0 # ty:ignore[possibly-missing-attribute]
186 return self.obj.lineno or 0
188 def _format_location(self, *, colors: bool = True) -> str:
189 bright = _ANSI.BRIGHT if colors else ""
190 reset = _ANSI.RESET_ALL if colors else ""
191 return f"{bright}{self._location}{reset}:{self._lineno}"
193 def _format_title(self, *, colors: bool = True) -> str: # noqa: ARG002
194 return self._relative_path
196 def _format_kind(self, *, colors: bool = True) -> str:
197 yellow = _ANSI.FG_YELLOW if colors else ""
198 reset = _ANSI.FG_RESET if colors else ""
199 return f"{yellow}{self.kind.value}{reset}"
201 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
202 return str(self.old_value)
204 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
205 return str(self.new_value)
207 def _explain_oneline(self) -> str:
208 explanation = f"{self._format_location()}: {self._format_title()}: {self._format_kind()}"
209 old = self._format_old_value()
210 new = self._format_new_value()
211 if old and new:
212 change = f"{old} -> {new}"
213 elif old:
214 change = old
215 elif new:
216 change = new
217 else:
218 change = ""
219 if change:
220 return f"{explanation}: {change}"
221 return explanation
223 def _explain_verbose(self) -> str:
224 lines = [f"{self._format_location()}: {self._format_title()}:"]
225 kind = self._format_kind()
226 old = self._format_old_value()
227 new = self._format_new_value()
228 if old or new:
229 lines.append(f"{kind}:")
230 else:
231 lines.append(kind)
232 if old:
233 lines.append(f" Old: {old}")
234 if new:
235 lines.append(f" New: {new}")
236 if self.details:
237 lines.append(f" Details: {self.details}")
238 lines.append("")
239 return "\n".join(lines)
241 def _explain_markdown(self) -> str:
242 explanation = f"- `{self._relative_path}`: *{self.kind.value}*"
243 old = self._format_old_value(colors=False)
244 if old and old != "unset":
245 old = f"`{old}`"
246 new = self._format_new_value(colors=False)
247 if new and new != "unset":
248 new = f"`{new}`"
249 if old and new:
250 change = f"{old} -> {new}"
251 elif old:
252 change = old
253 elif new:
254 change = new
255 else:
256 change = ""
257 if change:
258 return f"{explanation}: {change}"
259 return explanation
261 def _explain_github(self) -> str:
262 location = f"file={self._location},line={self._lineno}"
263 title = f"title={self._format_title(colors=False)}"
264 explanation = f"::warning {location},{title}::{self.kind.value}"
265 old = self._format_old_value(colors=False)
266 if old and old != "unset":
267 old = f"`{old}`"
268 new = self._format_new_value(colors=False)
269 if new and new != "unset":
270 new = f"`{new}`"
271 if old and new:
272 change = f"{old} -> {new}"
273 elif old:
274 change = old
275 elif new:
276 change = new
277 else:
278 change = ""
279 if change:
280 return f"{explanation}: {change}"
281 return explanation
284class ParameterMovedBreakage(Breakage):
285 """Specific breakage class for moved parameters."""
287 kind: BreakageKind = BreakageKind.PARAMETER_MOVED
289 @property
290 def _relative_path(self) -> str:
291 return f"{super()._relative_path}({self.old_value.name})"
293 def _format_title(self, *, colors: bool = True) -> str:
294 blue = _ANSI.FG_BLUE if colors else ""
295 reset = _ANSI.FG_RESET if colors else ""
296 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
298 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
299 return ""
301 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
302 return ""
305class ParameterRemovedBreakage(Breakage):
306 """Specific breakage class for removed parameters."""
308 kind: BreakageKind = BreakageKind.PARAMETER_REMOVED
310 @property
311 def _relative_path(self) -> str:
312 return f"{super()._relative_path}({self.old_value.name})"
314 def _format_title(self, *, colors: bool = True) -> str:
315 blue = _ANSI.FG_BLUE if colors else ""
316 reset = _ANSI.FG_RESET if colors else ""
317 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
319 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
320 return ""
322 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
323 return ""
326class ParameterChangedKindBreakage(Breakage):
327 """Specific breakage class for parameters whose kind changed."""
329 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND
331 @property
332 def _relative_path(self) -> str:
333 return f"{super()._relative_path}({self.old_value.name})"
335 def _format_title(self, *, colors: bool = True) -> str:
336 blue = _ANSI.FG_BLUE if colors else ""
337 reset = _ANSI.FG_RESET if colors else ""
338 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
340 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
341 return str(self.old_value.kind.value)
343 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
344 return str(self.new_value.kind.value)
347class ParameterChangedDefaultBreakage(Breakage):
348 """Specific breakage class for parameters whose default value changed."""
350 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT
352 @property
353 def _relative_path(self) -> str:
354 return f"{super()._relative_path}({self.old_value.name})"
356 def _format_title(self, *, colors: bool = True) -> str:
357 blue = _ANSI.FG_BLUE if colors else ""
358 reset = _ANSI.FG_RESET if colors else ""
359 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
361 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
362 return str(self.old_value.default)
364 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
365 return str(self.new_value.default)
368class ParameterChangedRequiredBreakage(Breakage):
369 """Specific breakage class for parameters which became required."""
371 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED
373 @property
374 def _relative_path(self) -> str:
375 return f"{super()._relative_path}({self.old_value.name})"
377 def _format_title(self, *, colors: bool = True) -> str:
378 blue = _ANSI.FG_BLUE if colors else ""
379 reset = _ANSI.FG_RESET if colors else ""
380 return f"{super()._relative_path}({blue}{self.old_value.name}{reset})"
382 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
383 return ""
385 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
386 return ""
389class ParameterAddedRequiredBreakage(Breakage):
390 """Specific breakage class for new parameters added as required."""
392 kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED
394 @property
395 def _relative_path(self) -> str:
396 return f"{super()._relative_path}({self.new_value.name})"
398 def _format_title(self, *, colors: bool = True) -> str:
399 blue = _ANSI.FG_BLUE if colors else ""
400 reset = _ANSI.FG_RESET if colors else ""
401 return f"{super()._relative_path}({blue}{self.new_value.name}{reset})"
403 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
404 return ""
406 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
407 return ""
410class ReturnChangedTypeBreakage(Breakage):
411 """Specific breakage class for return values which changed type."""
413 kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE
416class ObjectRemovedBreakage(Breakage):
417 """Specific breakage class for removed objects."""
419 kind: BreakageKind = BreakageKind.OBJECT_REMOVED
421 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
422 return ""
424 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
425 return ""
428class ObjectChangedKindBreakage(Breakage):
429 """Specific breakage class for objects whose kind changed."""
431 kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND
433 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
434 return self.old_value.value
436 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
437 return self.new_value.value
440class AttributeChangedTypeBreakage(Breakage):
441 """Specific breakage class for attributes whose type changed."""
443 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE
446class AttributeChangedValueBreakage(Breakage):
447 """Specific breakage class for attributes whose value changed."""
449 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE
452class ClassRemovedBaseBreakage(Breakage):
453 """Specific breakage class for removed base classes."""
455 kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE
457 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002
458 return "[" + ", ".join(base.canonical_path for base in self.old_value) + "]"
460 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002
461 return "[" + ", ".join(base.canonical_path for base in self.new_value) + "]"
464# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
465def _class_incompatibilities(
466 old_class: Class,
467 new_class: Class,
468 *,
469 seen_paths: set[str],
470) -> Iterable[Breakage]:
471 yield from ()
472 if new_class.bases != old_class.bases and len(new_class.bases) < len(old_class.bases):
473 yield ClassRemovedBaseBreakage(new_class, old_class.bases, new_class.bases)
474 yield from _member_incompatibilities(old_class, new_class, seen_paths=seen_paths)
477# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
478def _function_incompatibilities(old_function: Function, new_function: Function) -> Iterator[Breakage]:
479 new_param_names = [param.name for param in new_function.parameters]
480 param_kinds = {param.kind for param in new_function.parameters}
481 has_variadic_args = ParameterKind.var_positional in param_kinds
482 has_variadic_kwargs = ParameterKind.var_keyword in param_kinds
484 for old_index, old_param in enumerate(old_function.parameters):
485 # Check if the parameter was removed.
486 if old_param.name not in new_function.parameters:
487 swallowed = (
488 (old_param.kind is ParameterKind.keyword_only and has_variadic_kwargs)
489 or (old_param.kind is ParameterKind.positional_only and has_variadic_args)
490 or (old_param.kind is ParameterKind.positional_or_keyword and has_variadic_args and has_variadic_kwargs)
491 )
492 if not swallowed: 492 ↛ 494line 492 didn't jump to line 494 because the condition on line 492 was always true
493 yield ParameterRemovedBreakage(new_function, old_param, None)
494 continue
496 # Check if the parameter became required.
497 new_param = new_function.parameters[old_param.name]
498 if new_param.required and not old_param.required:
499 yield ParameterChangedRequiredBreakage(new_function, old_param, new_param)
501 # Check if the parameter was moved.
502 if old_param.kind in _POSITIONAL and new_param.kind in _POSITIONAL:
503 new_index = new_param_names.index(old_param.name)
504 if new_index != old_index:
505 details = f"position: from {old_index} to {new_index} ({new_index - old_index:+})"
506 yield ParameterMovedBreakage(new_function, old_param, new_param, details=details)
508 # Check if the parameter changed kind.
509 if old_param.kind is not new_param.kind:
510 incompatible_kind = any(
511 (
512 # Positional-only to keyword-only.
513 old_param.kind is ParameterKind.positional_only and new_param.kind is ParameterKind.keyword_only,
514 # Keyword-only to positional-only.
515 old_param.kind is ParameterKind.keyword_only and new_param.kind is ParameterKind.positional_only,
516 # Positional or keyword to positional-only/keyword-only.
517 old_param.kind is ParameterKind.positional_or_keyword
518 and new_param.kind in _POSITIONAL_KEYWORD_ONLY,
519 # Not keyword-only to variadic keyword, without variadic positional.
520 new_param.kind is ParameterKind.var_keyword
521 and old_param.kind is not ParameterKind.keyword_only
522 and not has_variadic_args,
523 # Not positional-only to variadic positional, without variadic keyword.
524 new_param.kind is ParameterKind.var_positional
525 and old_param.kind is not ParameterKind.positional_only
526 and not has_variadic_kwargs,
527 ),
528 )
529 if incompatible_kind:
530 yield ParameterChangedKindBreakage(new_function, old_param, new_param)
532 # Check if the parameter changed default.
533 breakage = ParameterChangedDefaultBreakage(new_function, old_param, new_param)
534 non_required = not old_param.required and not new_param.required
535 non_variadic = old_param.kind not in _VARIADIC and new_param.kind not in _VARIADIC
536 if non_required and non_variadic:
537 try:
538 if old_param.default != new_param.default: 538 ↛ 484line 538 didn't jump to line 484 because the condition on line 538 was always true
539 yield breakage
540 except Exception: # noqa: BLE001 (equality checks sometimes fail, e.g. numpy arrays)
541 # NOTE: Emitting breakage on a failed comparison could be a preference.
542 yield breakage
544 # Check if required parameters were added.
545 for new_param in new_function.parameters:
546 if new_param.name not in old_function.parameters and new_param.required:
547 yield ParameterAddedRequiredBreakage(new_function, None, new_param)
549 if not _returns_are_compatible(old_function, new_function): 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true
550 yield ReturnChangedTypeBreakage(new_function, old_function.returns, new_function.returns)
553def _attribute_incompatibilities(old_attribute: Attribute, new_attribute: Attribute) -> Iterable[Breakage]:
554 # TODO: Support annotation breaking changes.
555 if old_attribute.value != new_attribute.value: 555 ↛ exitline 555 didn't return from function '_attribute_incompatibilities' because the condition on line 555 was always true
556 if new_attribute.value is None:
557 yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, "unset")
558 else:
559 yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, new_attribute.value)
562def _alias_incompatibilities(
563 old_obj: Object | Alias,
564 new_obj: Object | Alias,
565 *,
566 seen_paths: set[str],
567) -> Iterable[Breakage]:
568 try:
569 old_member = old_obj.target if old_obj.is_alias else old_obj # ty:ignore[possibly-missing-attribute]
570 new_member = new_obj.target if new_obj.is_alias else new_obj # ty:ignore[possibly-missing-attribute]
571 except AliasResolutionError:
572 logger.debug("API check: %s | %s: skip alias with unknown target", old_obj.path, new_obj.path)
573 return
575 yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
578def _member_incompatibilities(
579 old_obj: Object | Alias,
580 new_obj: Object | Alias,
581 *,
582 seen_paths: set[str] | None = None,
583) -> Iterator[Breakage]:
584 seen_paths = set() if seen_paths is None else seen_paths
585 for name, old_member in old_obj.all_members.items():
586 if not old_member.is_public:
587 logger.debug("API check: %s.%s: skip non-public object", old_obj.path, name)
588 continue
589 logger.debug("API check: %s.%s", old_obj.path, name)
590 try:
591 new_member = new_obj.all_members[name]
592 except KeyError:
593 if (not old_member.is_alias and old_member.is_module) or old_member.is_public: 593 ↛ 585line 593 didn't jump to line 585 because the condition on line 593 was always true
594 yield ObjectRemovedBreakage(old_member, old_member, None) # ty:ignore[invalid-argument-type]
595 else:
596 yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
599def _type_based_yield(
600 old_member: Object | Alias,
601 new_member: Object | Alias,
602 *,
603 seen_paths: set[str],
604) -> Iterator[Breakage]:
605 if old_member.path in seen_paths:
606 return
607 seen_paths.add(old_member.path)
608 if old_member.is_alias or new_member.is_alias:
609 # Should be first, since there can be the case where there is an alias and another kind of object,
610 # which may not be a breaking change.
611 yield from _alias_incompatibilities(
612 old_member,
613 new_member,
614 seen_paths=seen_paths,
615 )
616 elif new_member.kind != old_member.kind:
617 yield ObjectChangedKindBreakage(new_member, old_member.kind, new_member.kind) # ty:ignore[invalid-argument-type]
618 elif old_member.is_module: 618 ↛ 619line 618 didn't jump to line 619 because the condition on line 618 was never true
619 yield from _member_incompatibilities(
620 old_member,
621 new_member,
622 seen_paths=seen_paths,
623 )
624 elif old_member.is_class:
625 yield from _class_incompatibilities(
626 old_member, # ty:ignore[invalid-argument-type]
627 new_member, # ty:ignore[invalid-argument-type]
628 seen_paths=seen_paths,
629 )
630 elif old_member.is_function:
631 yield from _function_incompatibilities(old_member, new_member) # ty:ignore[invalid-argument-type]
632 elif old_member.is_attribute: 632 ↛ exitline 632 didn't return from function '_type_based_yield' because the condition on line 632 was always true
633 yield from _attribute_incompatibilities(old_member, new_member) # ty:ignore[invalid-argument-type]
636def _returns_are_compatible(old_function: Function, new_function: Function) -> bool:
637 # We consider that a return value of `None` only is not a strong contract,
638 # it just means that the function returns nothing. We don't expect users
639 # to be asserting that the return value is `None`.
640 # Therefore we don't consider it a breakage if the return changes from `None`
641 # to something else: the function just gained a return value.
642 if old_function.returns is None:
643 return True
645 if new_function.returns is None: 645 ↛ 647line 645 didn't jump to line 647 because the condition on line 645 was never true
646 # NOTE: Should it be configurable to allow/disallow removing a return type?
647 return False
649 with contextlib.suppress(AttributeError):
650 if new_function.returns == old_function.returns: 650 ↛ 651line 650 didn't jump to line 651 because the condition on line 650 was never true
651 return True
653 # TODO: Support annotation breaking changes.
654 return True
657_sentinel = object()
660def find_breaking_changes(
661 old_obj: Object | Alias,
662 new_obj: Object | Alias,
663) -> Iterator[Breakage]:
664 """Find breaking changes between two versions of the same API.
666 The function will iterate recursively on all objects
667 and yield breaking changes with detailed information.
669 Parameters:
670 old_obj: The old version of an object.
671 new_obj: The new version of an object.
673 Yields:
674 Breaking changes.
676 Examples:
677 >>> import sys, griffe
678 >>> new = griffe.load("pkg")
679 >>> old = griffe.load_git("pkg", "1.2.3")
680 >>> for breakage in griffe.find_breaking_changes(old, new)
681 ... print(breakage.explain(style=style), file=sys.stderr)
682 """
683 yield from _member_incompatibilities(old_obj, new_obj)