Coverage for src/_griffe/diff.py: 63.57%
288 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-16 15:54 +0200
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-16 15:54 +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, Iterable, Iterator
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 _griffe.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))
31class Breakage:
32 """Breakages can explain what broke from a version to another."""
34 kind: BreakageKind
35 """The kind of breakage."""
37 def __init__(self, obj: Object, old_value: Any, new_value: Any, details: str = "") -> None:
38 """Initialize the breakage.
40 Parameters:
41 obj: The object related to the breakage.
42 old_value: The old value.
43 new_value: The new, incompatible value.
44 details: Some details about the breakage.
45 """
46 self.obj = obj
47 """The object related to the breakage."""
48 self.old_value = old_value
49 """The old value."""
50 self.new_value = new_value
51 """The new, incompatible value."""
52 self.details = details
53 """Some details about the breakage."""
55 def __str__(self) -> str:
56 return self.kind.value
58 def __repr__(self) -> str:
59 return self.kind.name
61 def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002
62 """Return this object's data as a dictionary.
64 Parameters:
65 full: Whether to return full info, or just base info.
66 **kwargs: Additional serialization options.
68 Returns:
69 A dictionary.
70 """
71 return {
72 "kind": self.kind,
73 "object_path": self.obj.path,
74 "old_value": self.old_value,
75 "new_value": self.new_value,
76 }
78 def explain(self, style: ExplanationStyle = ExplanationStyle.ONE_LINE) -> str:
79 """Explain the breakage by showing old and new value.
81 Parameters:
82 style: The explanation style to use.
84 Returns:
85 An explanation.
86 """
87 return getattr(self, f"_explain_{style.value}")()
89 @property
90 def _filepath(self) -> Path:
91 if self.obj.is_alias:
92 return self.obj.parent.filepath # type: ignore[union-attr,return-value]
93 return self.obj.filepath # type: ignore[return-value]
95 @property
96 def _relative_filepath(self) -> Path:
97 if self.obj.is_alias:
98 return self.obj.parent.relative_filepath # type: ignore[union-attr]
99 return self.obj.relative_filepath
101 @property
102 def _relative_package_filepath(self) -> Path:
103 if self.obj.is_alias:
104 return self.obj.parent.relative_package_filepath # type: ignore[union-attr]
105 return self.obj.relative_package_filepath
107 @property
108 def _location(self) -> Path:
109 # Absolute file path probably means temporary worktree.
110 # We use our worktree prefix to remove some components
111 # of the path on the left (`/tmp/griffe-worktree-*/griffe_*/repo`).
112 if self._relative_filepath.is_absolute():
113 parts = self._relative_filepath.parts
114 for index, part in enumerate(parts):
115 if part.startswith(_WORKTREE_PREFIX):
116 return Path(*parts[index + 2 :])
117 return self._relative_filepath
119 @property
120 def _canonical_path(self) -> str:
121 if self.obj.is_alias:
122 return self.obj.path
123 return self.obj.canonical_path
125 @property
126 def _module_path(self) -> str:
127 if self.obj.is_alias:
128 return self.obj.parent.module.path # type: ignore[union-attr]
129 return self.obj.module.path
131 @property
132 def _relative_path(self) -> str:
133 return self._canonical_path[len(self._module_path) + 1 :] or "<module>"
135 @property
136 def _lineno(self) -> int:
137 # If the object was removed, and we are able to get the location (file path)
138 # as a relative path, then we use 0 instead of the original line number
139 # (it helps when checking current sources, and avoids pointing to now missing contents).
140 if self.kind is BreakageKind.OBJECT_REMOVED and self._relative_filepath != self._location:
141 return 0
142 if self.obj.is_alias:
143 return self.obj.alias_lineno or 0 # type: ignore[attr-defined]
144 return self.obj.lineno or 0
146 def _format_location(self) -> str:
147 return f"{Style.BRIGHT}{self._location}{Style.RESET_ALL}:{self._lineno}"
149 def _format_title(self) -> str:
150 return self._relative_path
152 def _format_kind(self) -> str:
153 return f"{Fore.YELLOW}{self.kind.value}{Fore.RESET}"
155 def _format_old_value(self) -> str:
156 return str(self.old_value)
158 def _format_new_value(self) -> str:
159 return str(self.new_value)
161 def _explain_oneline(self) -> str:
162 explanation = f"{self._format_location()}: {self._format_title()}: {self._format_kind()}"
163 old = self._format_old_value()
164 new = self._format_new_value()
165 if old and new:
166 change = f"{old} -> {new}"
167 elif old:
168 change = old
169 elif new:
170 change = new
171 else:
172 change = ""
173 if change:
174 return f"{explanation}: {change}"
175 return explanation
177 def _explain_verbose(self) -> str:
178 lines = [f"{self._format_location()}: {self._format_title()}:"]
179 kind = self._format_kind()
180 old = self._format_old_value()
181 new = self._format_new_value()
182 if old or new:
183 lines.append(f"{kind}:")
184 else:
185 lines.append(kind)
186 if old:
187 lines.append(f" Old: {old}")
188 if new:
189 lines.append(f" New: {new}")
190 if self.details:
191 lines.append(f" Details: {self.details}")
192 lines.append("")
193 return "\n".join(lines)
195 def _explain_markdown(self) -> str:
196 return self._explain_oneline()
198 def _explain_github(self) -> str:
199 return self._explain_oneline()
202class ParameterMovedBreakage(Breakage):
203 """Specific breakage class for moved parameters."""
205 kind: BreakageKind = BreakageKind.PARAMETER_MOVED
207 def _format_title(self) -> str:
208 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
210 def _format_old_value(self) -> str:
211 return ""
213 def _format_new_value(self) -> str:
214 return ""
217class ParameterRemovedBreakage(Breakage):
218 """Specific breakage class for removed parameters."""
220 kind: BreakageKind = BreakageKind.PARAMETER_REMOVED
222 def _format_title(self) -> str:
223 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
225 def _format_old_value(self) -> str:
226 return ""
228 def _format_new_value(self) -> str:
229 return ""
232class ParameterChangedKindBreakage(Breakage):
233 """Specific breakage class for parameters whose kind changed."""
235 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND
237 def _format_title(self) -> str:
238 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
240 def _format_old_value(self) -> str:
241 return str(self.old_value.kind.value)
243 def _format_new_value(self) -> str:
244 return str(self.new_value.kind.value)
247class ParameterChangedDefaultBreakage(Breakage):
248 """Specific breakage class for parameters whose default value changed."""
250 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT
252 def _format_title(self) -> str:
253 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
255 def _format_old_value(self) -> str:
256 return str(self.old_value.default)
258 def _format_new_value(self) -> str:
259 return str(self.new_value.default)
262class ParameterChangedRequiredBreakage(Breakage):
263 """Specific breakage class for parameters which became required."""
265 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED
267 def _format_title(self) -> str:
268 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})"
270 def _format_old_value(self) -> str:
271 return ""
273 def _format_new_value(self) -> str:
274 return ""
277class ParameterAddedRequiredBreakage(Breakage):
278 """Specific breakage class for new parameters added as required."""
280 kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED
282 def _format_title(self) -> str:
283 return f"{self._relative_path}({Fore.BLUE}{self.new_value.name}{Fore.RESET})"
285 def _format_old_value(self) -> str:
286 return ""
288 def _format_new_value(self) -> str:
289 return ""
292class ReturnChangedTypeBreakage(Breakage):
293 """Specific breakage class for return values which changed type."""
295 kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE
298class ObjectRemovedBreakage(Breakage):
299 """Specific breakage class for removed objects."""
301 kind: BreakageKind = BreakageKind.OBJECT_REMOVED
303 def _format_old_value(self) -> str:
304 return ""
306 def _format_new_value(self) -> str:
307 return ""
310class ObjectChangedKindBreakage(Breakage):
311 """Specific breakage class for objects whose kind changed."""
313 kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND
315 def _format_old_value(self) -> str:
316 return self.old_value.value
318 def _format_new_value(self) -> str:
319 return self.new_value.value
322class AttributeChangedTypeBreakage(Breakage):
323 """Specific breakage class for attributes whose type changed."""
325 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE
328class AttributeChangedValueBreakage(Breakage):
329 """Specific breakage class for attributes whose value changed."""
331 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE
334class ClassRemovedBaseBreakage(Breakage):
335 """Specific breakage class for removed base classes."""
337 kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE
339 def _format_old_value(self) -> str:
340 return "[" + ", ".join(base.canonical_path for base in self.old_value) + "]"
342 def _format_new_value(self) -> str:
343 return "[" + ", ".join(base.canonical_path for base in self.new_value) + "]"
346# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
347def _class_incompatibilities(
348 old_class: Class,
349 new_class: Class,
350 *,
351 seen_paths: set[str],
352) -> Iterable[Breakage]:
353 yield from ()
354 if new_class.bases != old_class.bases and len(new_class.bases) < len(old_class.bases):
355 yield ClassRemovedBaseBreakage(new_class, old_class.bases, new_class.bases)
356 yield from _member_incompatibilities(old_class, new_class, seen_paths=seen_paths)
359# TODO: Check decorators? Maybe resolved by extensions and/or dynamic analysis.
360def _function_incompatibilities(old_function: Function, new_function: Function) -> Iterator[Breakage]:
361 new_param_names = [param.name for param in new_function.parameters]
362 param_kinds = {param.kind for param in new_function.parameters}
363 has_variadic_args = ParameterKind.var_positional in param_kinds
364 has_variadic_kwargs = ParameterKind.var_keyword in param_kinds
366 for old_index, old_param in enumerate(old_function.parameters):
367 # Check if the parameter was removed.
368 if old_param.name not in new_function.parameters:
369 swallowed = (
370 (old_param.kind is ParameterKind.keyword_only and has_variadic_kwargs)
371 or (old_param.kind is ParameterKind.positional_only and has_variadic_args)
372 or (old_param.kind is ParameterKind.positional_or_keyword and has_variadic_args and has_variadic_kwargs)
373 )
374 if not swallowed: 374 ↛ 376line 374 didn't jump to line 376 because the condition on line 374 was always true
375 yield ParameterRemovedBreakage(new_function, old_param, None)
376 continue
378 # Check if the parameter became required.
379 new_param = new_function.parameters[old_param.name]
380 if new_param.required and not old_param.required:
381 yield ParameterChangedRequiredBreakage(new_function, old_param, new_param)
383 # Check if the parameter was moved.
384 if old_param.kind in _POSITIONAL and new_param.kind in _POSITIONAL:
385 new_index = new_param_names.index(old_param.name)
386 if new_index != old_index:
387 details = f"position: from {old_index} to {new_index} ({new_index - old_index:+})"
388 yield ParameterMovedBreakage(new_function, old_param, new_param, details=details)
390 # Check if the parameter changed kind.
391 if old_param.kind is not new_param.kind:
392 incompatible_kind = any(
393 (
394 # positional-only to keyword-only
395 old_param.kind is ParameterKind.positional_only and new_param.kind is ParameterKind.keyword_only,
396 # keyword-only to positional-only
397 old_param.kind is ParameterKind.keyword_only and new_param.kind is ParameterKind.positional_only,
398 # positional or keyword to positional-only/keyword-only
399 old_param.kind is ParameterKind.positional_or_keyword
400 and new_param.kind in _POSITIONAL_KEYWORD_ONLY,
401 # not keyword-only to variadic keyword, without variadic positional
402 new_param.kind is ParameterKind.var_keyword
403 and old_param.kind is not ParameterKind.keyword_only
404 and not has_variadic_args,
405 # not positional-only to variadic positional, without variadic keyword
406 new_param.kind is ParameterKind.var_positional
407 and old_param.kind is not ParameterKind.positional_only
408 and not has_variadic_kwargs,
409 ),
410 )
411 if incompatible_kind:
412 yield ParameterChangedKindBreakage(new_function, old_param, new_param)
414 # Check if the parameter changed default.
415 breakage = ParameterChangedDefaultBreakage(new_function, old_param, new_param)
416 non_required = not old_param.required and not new_param.required
417 non_variadic = old_param.kind not in _VARIADIC and new_param.kind not in _VARIADIC
418 if non_required and non_variadic:
419 try:
420 if old_param.default != new_param.default: 420 ↛ 366line 420 didn't jump to line 366 because the condition on line 420 was always true
421 yield breakage
422 except Exception: # noqa: BLE001 (equality checks sometimes fail, e.g. numpy arrays)
423 # NOTE: Emitting breakage on a failed comparison could be a preference.
424 yield breakage
426 # Check if required parameters were added.
427 for new_param in new_function.parameters:
428 if new_param.name not in old_function.parameters and new_param.required:
429 yield ParameterAddedRequiredBreakage(new_function, None, new_param)
431 if not _returns_are_compatible(old_function, new_function): 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true
432 yield ReturnChangedTypeBreakage(new_function, old_function.returns, new_function.returns)
435def _attribute_incompatibilities(old_attribute: Attribute, new_attribute: Attribute) -> Iterable[Breakage]:
436 # TODO: Use beartype.peps.resolve_pep563 and beartype.door.is_subhint?
437 # if old_attribute.annotation is not None and new_attribute.annotation is not None:
438 # if not is_subhint(new_attribute.annotation, old_attribute.annotation):
439 if old_attribute.value != new_attribute.value: 439 ↛ exitline 439 didn't return from function '_attribute_incompatibilities' because the condition on line 439 was always true
440 if new_attribute.value is None:
441 yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, "unset")
442 else:
443 yield AttributeChangedValueBreakage(new_attribute, old_attribute.value, new_attribute.value)
446def _alias_incompatibilities(
447 old_obj: Object | Alias,
448 new_obj: Object | Alias,
449 *,
450 seen_paths: set[str],
451) -> Iterable[Breakage]:
452 try:
453 old_member = old_obj.target if old_obj.is_alias else old_obj # type: ignore[union-attr]
454 new_member = new_obj.target if new_obj.is_alias else new_obj # type: ignore[union-attr]
455 except AliasResolutionError:
456 logger.debug(f"API check: {old_obj.path} | {new_obj.path}: skip alias with unknown target")
457 return
459 yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
462def _member_incompatibilities(
463 old_obj: Object | Alias,
464 new_obj: Object | Alias,
465 *,
466 seen_paths: set[str] | None = None,
467) -> Iterator[Breakage]:
468 seen_paths = set() if seen_paths is None else seen_paths
469 for name, old_member in old_obj.all_members.items():
470 if not old_member.is_public:
471 logger.debug(f"API check: {old_obj.path}.{name}: skip non-public object")
472 continue
473 logger.debug(f"API check: {old_obj.path}.{name}")
474 try:
475 new_member = new_obj.all_members[name]
476 except KeyError:
477 if (not old_member.is_alias and old_member.is_module) or old_member.is_public: 477 ↛ 469line 477 didn't jump to line 469 because the condition on line 477 was always true
478 yield ObjectRemovedBreakage(old_member, old_member, None) # type: ignore[arg-type]
479 else:
480 yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths)
483def _type_based_yield(
484 old_member: Object | Alias,
485 new_member: Object | Alias,
486 *,
487 seen_paths: set[str],
488) -> Iterator[Breakage]:
489 if old_member.path in seen_paths:
490 return
491 seen_paths.add(old_member.path)
492 if old_member.is_alias or new_member.is_alias:
493 # Should be first, since there can be the case where there is an alias and another kind of object, which may
494 # not be a breaking change
495 yield from _alias_incompatibilities(
496 old_member,
497 new_member,
498 seen_paths=seen_paths,
499 )
500 elif new_member.kind != old_member.kind:
501 yield ObjectChangedKindBreakage(new_member, old_member.kind, new_member.kind) # type: ignore[arg-type]
502 elif old_member.is_module: 502 ↛ 503line 502 didn't jump to line 503 because the condition on line 502 was never true
503 yield from _member_incompatibilities(
504 old_member,
505 new_member,
506 seen_paths=seen_paths,
507 )
508 elif old_member.is_class:
509 yield from _class_incompatibilities(
510 old_member, # type: ignore[arg-type]
511 new_member, # type: ignore[arg-type]
512 seen_paths=seen_paths,
513 )
514 elif old_member.is_function:
515 yield from _function_incompatibilities(old_member, new_member) # type: ignore[arg-type]
516 elif old_member.is_attribute: 516 ↛ exitline 516 didn't return from function '_type_based_yield' because the condition on line 516 was always true
517 yield from _attribute_incompatibilities(old_member, new_member) # type: ignore[arg-type]
520def _returns_are_compatible(old_function: Function, new_function: Function) -> bool:
521 # We consider that a return value of `None` only is not a strong contract,
522 # it just means that the function returns nothing. We don't expect users
523 # to be asserting that the return value is `None`.
524 # Therefore we don't consider it a breakage if the return changes from `None`
525 # to something else: the function just gained a return value.
526 if old_function.returns is None:
527 return True
529 if new_function.returns is None: 529 ↛ 531line 529 didn't jump to line 531 because the condition on line 529 was never true
530 # NOTE: Should it be configurable to allow/disallow removing a return type?
531 return False
533 with contextlib.suppress(AttributeError): 533 ↛ exitline 533 didn't return from function '_returns_are_compatible' because the return on line 535 wasn't executed
534 if new_function.returns == old_function.returns: 534 ↛ 535line 534 didn't jump to line 535 because the condition on line 534 was never true
535 return True
537 # TODO: Use beartype.peps.resolve_pep563 and beartype.door.is_subhint?
538 return True
541_sentinel = object()
544def find_breaking_changes(
545 old_obj: Object | Alias,
546 new_obj: Object | Alias,
547) -> Iterator[Breakage]:
548 """Find breaking changes between two versions of the same API.
550 The function will iterate recursively on all objects
551 and yield breaking changes with detailed information.
553 Parameters:
554 old_obj: The old version of an object.
555 new_obj: The new version of an object.
557 Yields:
558 Breaking changes.
560 Examples:
561 >>> import sys, griffe
562 >>> new = griffe.load("pkg")
563 >>> old = griffe.load_git("pkg", "1.2.3")
564 >>> for breakage in griffe.find_breaking_changes(old, new)
565 ... print(breakage.explain(style=style), file=sys.stderr)
566 """
567 yield from _member_incompatibilities(old_obj, new_obj)