Coverage for src/_griffe/diff.py: 54.09%
322 statements
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 01:34 +0200
« prev ^ index » next coverage.py v7.6.2, created at 2024-10-12 01:34 +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.enumerations import BreakageKind, ExplanationStyle, ParameterKind
18from _griffe.exceptions import AliasResolutionError
19from _griffe.git import _WORKTREE_PREFIX
20from _griffe.logger import logger
22if TYPE_CHECKING:
23 from collections.abc import Iterable, Iterator
25 from _griffe.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) -> str:
149 return f"{Style.BRIGHT}{self._location}{Style.RESET_ALL}:{self._lineno}"
151 def _format_title(self) -> str:
152 return self._relative_path
154 def _format_kind(self) -> str:
155 return f"{Fore.YELLOW}{self.kind.value}{Fore.RESET}"
157 def _format_old_value(self) -> str:
158 return str(self.old_value)
160 def _format_new_value(self) -> str:
161 return str(self.new_value)
163 def _explain_oneline(self) -> str:
164 explanation = f"{self._format_location()}: {self._format_title()}: {self._format_kind()}"
165 old = self._format_old_value()
166 new = self._format_new_value()
167 if old and new:
168 change = f"{old} -> {new}"
169 elif old:
170 change = old
171 elif new:
172 change = new
173 else:
174 change = ""
175 if change:
176 return f"{explanation}: {change}"
177 return explanation
179 def _explain_verbose(self) -> str:
180 lines = [f"{self._format_location()}: {self._format_title()}:"]
181 kind = self._format_kind()
182 old = self._format_old_value()
183 new = self._format_new_value()
184 if old or new:
185 lines.append(f"{kind}:")
186 else:
187 lines.append(kind)
188 if old:
189 lines.append(f" Old: {old}")
190 if new:
191 lines.append(f" New: {new}")
192 if self.details:
193 lines.append(f" Details: {self.details}")
194 lines.append("")
195 return "\n".join(lines)
197 def _explain_markdown(self) -> str:
198 explanation = f"- `{self._canonical_path}`: *{self.kind.value}*"
199 old = self._format_old_value()
200 if old != "unset":
201 old = f"`{old}`"
202 new = self._format_new_value()
203 if new != "unset":
204 new = f"`{new}`"
205 if old and new:
206 change = f"{old} -> {new}"
207 elif old:
208 change = old
209 elif new:
210 change = new
211 else:
212 change = ""
213 if change:
214 return f"{explanation}: {change}"
215 return explanation
217 def _explain_github(self) -> str:
218 location = f"file={self._location},line={self._lineno}"
219 title = f"title={self._format_title()}"
220 explanation = f"::warning {location},{title}::{self.kind.value}"
221 old = self._format_old_value()
222 if old != "unset":
223 old = f"`{old}`"
224 new = self._format_new_value()
225 if new != "unset":
226 new = f"`{new}`"
227 if old and new:
228 change = f"{old} -> {new}"
229 elif old:
230 change = old
231 elif new:
232 change = new
233 else:
234 change = ""
235 if change:
236 return f"{explanation}: {change}"
237 return explanation
240class ParameterMovedBreakage(Breakage):
241 """Specific breakage class for moved parameters."""
243 kind: BreakageKind = BreakageKind.PARAMETER_MOVED
245 def _format_title(self) -> str:
246 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
248 def _format_old_value(self) -> str:
249 return ""
251 def _format_new_value(self) -> str:
252 return ""
255class ParameterRemovedBreakage(Breakage):
256 """Specific breakage class for removed parameters."""
258 kind: BreakageKind = BreakageKind.PARAMETER_REMOVED
260 def _format_title(self) -> str:
261 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
263 def _format_old_value(self) -> str:
264 return ""
266 def _format_new_value(self) -> str:
267 return ""
270class ParameterChangedKindBreakage(Breakage):
271 """Specific breakage class for parameters whose kind changed."""
273 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND
275 def _format_title(self) -> str:
276 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
278 def _format_old_value(self) -> str:
279 return str(self.old_value.kind.value)
281 def _format_new_value(self) -> str:
282 return str(self.new_value.kind.value)
285class ParameterChangedDefaultBreakage(Breakage):
286 """Specific breakage class for parameters whose default value changed."""
288 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT
290 def _format_title(self) -> str:
291 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
293 def _format_old_value(self) -> str:
294 return str(self.old_value.default)
296 def _format_new_value(self) -> str:
297 return str(self.new_value.default)
300class ParameterChangedRequiredBreakage(Breakage):
301 """Specific breakage class for parameters which became required."""
303 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED
305 def _format_title(self) -> str:
306 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
308 def _format_old_value(self) -> str:
309 return ""
311 def _format_new_value(self) -> str:
312 return ""
315class ParameterAddedRequiredBreakage(Breakage):
316 """Specific breakage class for new parameters added as required."""
318 kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED
320 def _format_title(self) -> str:
321 return f"{self._relative_path}({Fore.BLUE}{self.new_value.name}{Fore.RESET})"
323 def _format_old_value(self) -> str:
324 return ""
326 def _format_new_value(self) -> str:
327 return ""
330class ReturnChangedTypeBreakage(Breakage):
331 """Specific breakage class for return values which changed type."""
333 kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE
336class ObjectRemovedBreakage(Breakage):
337 """Specific breakage class for removed objects."""
339 kind: BreakageKind = BreakageKind.OBJECT_REMOVED
341 def _format_old_value(self) -> str:
342 return ""
344 def _format_new_value(self) -> str:
345 return ""
348class ObjectChangedKindBreakage(Breakage):
349 """Specific breakage class for objects whose kind changed."""
351 kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND
353 def _format_old_value(self) -> str:
354 return self.old_value.value
356 def _format_new_value(self) -> str:
357 return self.new_value.value
360class AttributeChangedTypeBreakage(Breakage):
361 """Specific breakage class for attributes whose type changed."""
363 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE
366class AttributeChangedValueBreakage(Breakage):
367 """Specific breakage class for attributes whose value changed."""
369 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE
372class ClassRemovedBaseBreakage(Breakage):
373 """Specific breakage class for removed base classes."""
375 kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE
377 def _format_old_value(self) -> str:
378 return "[" + ", ".join(base.canonical_path for base in self.old_value) + "]"
380 def _format_new_value(self) -> str:
381 return "[" + ", ".join(base.canonical_path for base in self.new_value) + "]"
384# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
385def _class_incompatibilities(
386 old_class: Class,
387 new_class: Class,
388 *,
389 seen_paths: set[str],
390) -> Iterable[Breakage]:
391 yield from ()
392 if new_class.bases != old_class.bases and len(new_class.bases) < len(old_class.bases):
393 yield ClassRemovedBaseBreakage(new_class, old_class.bases, new_class.bases)
394 yield from _member_incompatibilities(old_class, new_class, seen_paths=seen_paths)
397# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
398def _function_incompatibilities(old_function: Function, new_function: Function) -> Iterator[Breakage]:
399 new_param_names = [param.name for param in new_function.parameters]
400 param_kinds = {param.kind for param in new_function.parameters}
401 has_variadic_args = ParameterKind.var_positional in param_kinds
402 has_variadic_kwargs = ParameterKind.var_keyword in param_kinds
404 for old_index, old_param in enumerate(old_function.parameters):
405 # Check if the parameter was removed.
406 if old_param.name not in new_function.parameters:
407 swallowed = (
408 (old_param.kind is ParameterKind.keyword_only and has_variadic_kwargs)
409 or (old_param.kind is ParameterKind.positional_only and has_variadic_args)
410 or (old_param.kind is ParameterKind.positional_or_keyword and has_variadic_args and has_variadic_kwargs)
411 )
412 if not swallowed: 412 ↛ 414line 412 didn't jump to line 414 because the condition on line 412 was always true
413 yield ParameterRemovedBreakage(new_function, old_param, None)
414 continue
416 # Check if the parameter became required.
417 new_param = new_function.parameters[old_param.name]
418 if new_param.required and not old_param.required:
419 yield ParameterChangedRequiredBreakage(new_function, old_param, new_param)
421 # Check if the parameter was moved.
422 if old_param.kind in _POSITIONAL and new_param.kind in _POSITIONAL:
423 new_index = new_param_names.index(old_param.name)
424 if new_index != old_index:
425 details = f"position: from {old_index} to {new_index} ({new_index - old_index:+})"
426 yield ParameterMovedBreakage(new_function, old_param, new_param, details=details)
428 # Check if the parameter changed kind.
429 if old_param.kind is not new_param.kind:
430 incompatible_kind = any(
431 (
432 # positional-only to keyword-only
433 old_param.kind is ParameterKind.positional_only and new_param.kind is ParameterKind.keyword_only,
434 # keyword-only to positional-only
435 old_param.kind is ParameterKind.keyword_only and new_param.kind is ParameterKind.positional_only,
436 # positional or keyword to positional-only/keyword-only
437 old_param.kind is ParameterKind.positional_or_keyword
438 and new_param.kind in _POSITIONAL_KEYWORD_ONLY,
439 # not keyword-only to variadic keyword, without variadic positional
440 new_param.kind is ParameterKind.var_keyword
441 and old_param.kind is not ParameterKind.keyword_only
442 and not has_variadic_args,
443 # not positional-only to variadic positional, without variadic keyword
444 new_param.kind is ParameterKind.var_positional
445 and old_param.kind is not ParameterKind.positional_only
446 and not has_variadic_kwargs,
447 ),
448 )
449 if incompatible_kind:
450 yield ParameterChangedKindBreakage(new_function, old_param, new_param)
452 # Check if the parameter changed default.
453 breakage = ParameterChangedDefaultBreakage(new_function, old_param, new_param)
454 non_required = not old_param.required and not new_param.required
455 non_variadic = old_param.kind not in _VARIADIC and new_param.kind not in _VARIADIC
456 if non_required and non_variadic:
457 try:
458 if old_param.default != new_param.default: 458 ↛ 404line 458 didn't jump to line 404 because the condition on line 458 was always true
459 yield breakage
460 except Exception: # noqa: BLE001 (equality checks sometimes fail, e.g. numpy arrays)
461 # NOTE: Emitting breakage on a failed comparison could be a preference.
462 yield breakage
464 # Check if required parameters were added.
465 for new_param in new_function.parameters:
466 if new_param.name not in old_function.parameters and new_param.required:
467 yield ParameterAddedRequiredBreakage(new_function, None, new_param)
469 if not _returns_are_compatible(old_function, new_function): 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true
470 yield ReturnChangedTypeBreakage(new_function, old_function.returns, new_function.returns)
473def _attribute_incompatibilities(old_attribute: Attribute, new_attribute: Attribute) -> Iterable[Breakage]:
474 # TODO: Use beartype.peps.resolve_pep563 and beartype.door.is_subhint?
475 # if old_attribute.annotation is not None and new_attribute.annotation is not None:
476 # if not is_subhint(new_attribute.annotation, old_attribute.annotation):
477 if old_attribute.value != new_attribute.value: 477 ↛ exitline 477 didn't return from function '_attribute_incompatibilities' because the condition on line 477 was always true
478 if new_attribute.value is None:
479 yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, "unset")
480 else:
481 yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, new_attribute.value)
484def _alias_incompatibilities(
485 old_obj: Object | Alias,
486 new_obj: Object | Alias,
487 *,
488 seen_paths: set[str],
489) -> Iterable[Breakage]:
490 try:
491 old_member = old_obj.target if old_obj.is_alias else old_obj # type: ignore[union-attr]
492 new_member = new_obj.target if new_obj.is_alias else new_obj # type: ignore[union-attr]
493 except AliasResolutionError:
494 logger.debug("API check: %s | %s: skip alias with unknown target", old_obj.path, new_obj.path)
495 return
497 yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
500def _member_incompatibilities(
501 old_obj: Object | Alias,
502 new_obj: Object | Alias,
503 *,
504 seen_paths: set[str] | None = None,
505) -> Iterator[Breakage]:
506 seen_paths = set() if seen_paths is None else seen_paths
507 for name, old_member in old_obj.all_members.items():
508 if not old_member.is_public:
509 logger.debug("API check: %s.%s: skip non-public object", old_obj.path, name)
510 continue
511 logger.debug("API check: %s.%s", old_obj.path, name)
512 try:
513 new_member = new_obj.all_members[name]
514 except KeyError:
515 if (not old_member.is_alias and old_member.is_module) or old_member.is_public: 515 ↛ 507line 515 didn't jump to line 507 because the condition on line 515 was always true
516 yield ObjectRemovedBreakage(old_member, old_member, None) # type: ignore[arg-type]
517 else:
518 yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
521def _type_based_yield(
522 old_member: Object | Alias,
523 new_member: Object | Alias,
524 *,
525 seen_paths: set[str],
526) -> Iterator[Breakage]:
527 if old_member.path in seen_paths:
528 return
529 seen_paths.add(old_member.path)
530 if old_member.is_alias or new_member.is_alias:
531 # Should be first, since there can be the case where there is an alias and another kind of object, which may
532 # not be a breaking change
533 yield from _alias_incompatibilities(
534 old_member,
535 new_member,
536 seen_paths=seen_paths,
537 )
538 elif new_member.kind != old_member.kind:
539 yield ObjectChangedKindBreakage(new_member, old_member.kind, new_member.kind) # type: ignore[arg-type]
540 elif old_member.is_module: 540 ↛ 541line 540 didn't jump to line 541 because the condition on line 540 was never true
541 yield from _member_incompatibilities(
542 old_member,
543 new_member,
544 seen_paths=seen_paths,
545 )
546 elif old_member.is_class:
547 yield from _class_incompatibilities(
548 old_member, # type: ignore[arg-type]
549 new_member, # type: ignore[arg-type]
550 seen_paths=seen_paths,
551 )
552 elif old_member.is_function:
553 yield from _function_incompatibilities(old_member, new_member) # type: ignore[arg-type]
554 elif old_member.is_attribute: 554 ↛ exitline 554 didn't return from function '_type_based_yield' because the condition on line 554 was always true
555 yield from _attribute_incompatibilities(old_member, new_member) # type: ignore[arg-type]
558def _returns_are_compatible(old_function: Function, new_function: Function) -> bool:
559 # We consider that a return value of `None` only is not a strong contract,
560 # it just means that the function returns nothing. We don't expect users
561 # to be asserting that the return value is `None`.
562 # Therefore we don't consider it a breakage if the return changes from `None`
563 # to something else: the function just gained a return value.
564 if old_function.returns is None:
565 return True
567 if new_function.returns is None: 567 ↛ 569line 567 didn't jump to line 569 because the condition on line 567 was never true
568 # NOTE: Should it be configurable to allow/disallow removing a return type?
569 return False
571 with contextlib.suppress(AttributeError):
572 if new_function.returns == old_function.returns: 572 ↛ 573line 572 didn't jump to line 573 because the condition on line 572 was never true
573 return True
575 # TODO: Use beartype.peps.resolve_pep563 and beartype.door.is_subhint?
576 return True
579_sentinel = object()
582def find_breaking_changes(
583 old_obj: Object | Alias,
584 new_obj: Object | Alias,
585) -> Iterator[Breakage]:
586 """Find breaking changes between two versions of the same API.
588 The function will iterate recursively on all objects
589 and yield breaking changes with detailed information.
591 Parameters:
592 old_obj: The old version of an object.
593 new_obj: The new version of an object.
595 Yields:
596 Breaking changes.
598 Examples:
599 >>> import sys, griffe
600 >>> new = griffe.load("pkg")
601 >>> old = griffe.load_git("pkg", "1.2.3")
602 >>> for breakage in griffe.find_breaking_changes(old, new)
603 ... print(breakage.explain(style=style), file=sys.stderr)
604 """
605 yield from _member_incompatibilities(old_obj, new_obj)