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

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. 

8 

9from __future__ import annotations 

10 

11import contextlib 

12from pathlib import Path 

13from typing import TYPE_CHECKING, Any 

14 

15from colorama import Fore, Style 

16 

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 

21 

22if TYPE_CHECKING: 

23 from collections.abc import Iterable, Iterator 

24 

25 from griffe._internal.models import Alias, Attribute, Class, Function, Object 

26 

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

31 

32 

33class Breakage: 

34 """Breakages can explain what broke from a version to another.""" 

35 

36 kind: BreakageKind 

37 """The kind of breakage.""" 

38 

39 def __init__(self, obj: Object, old_value: Any, new_value: Any, details: str = "") -> None: 

40 """Initialize the breakage. 

41 

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.""" 

56 

57 def __str__(self) -> str: 

58 return self.kind.value 

59 

60 def __repr__(self) -> str: 

61 return self.kind.name 

62 

63 def as_dict(self, *, full: bool = False, **kwargs: Any) -> dict[str, Any]: # noqa: ARG002 

64 """Return this object's data as a dictionary. 

65 

66 Parameters: 

67 full: Whether to return full info, or just base info. 

68 **kwargs: Additional serialization options. 

69 

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 } 

79 

80 def explain(self, style: ExplanationStyle = ExplanationStyle.ONE_LINE) -> str: 

81 """Explain the breakage by showing old and new value. 

82 

83 Parameters: 

84 style: The explanation style to use. 

85 

86 Returns: 

87 An explanation. 

88 """ 

89 return getattr(self, f"_explain_{style.value}")() 

90 

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] 

96 

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 

102 

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 

108 

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 

120 

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 

126 

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 

132 

133 @property 

134 def _relative_path(self) -> str: 

135 return self._canonical_path[len(self._module_path) + 1 :] or "<module>" 

136 

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 

147 

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}" 

152 

153 def _format_title(self, *, colors: bool = True) -> str: # noqa: ARG002 

154 return self._relative_path 

155 

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}" 

160 

161 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

162 return str(self.old_value) 

163 

164 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

165 return str(self.new_value) 

166 

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 

182 

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) 

200 

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 

220 

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 

242 

243 

244class ParameterMovedBreakage(Breakage): 

245 """Specific breakage class for moved parameters.""" 

246 

247 kind: BreakageKind = BreakageKind.PARAMETER_MOVED 

248 

249 @property 

250 def _relative_path(self) -> str: 

251 return f"{super()._relative_path}({self.old_value.name})" 

252 

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

257 

258 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

259 return "" 

260 

261 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

262 return "" 

263 

264 

265class ParameterRemovedBreakage(Breakage): 

266 """Specific breakage class for removed parameters.""" 

267 

268 kind: BreakageKind = BreakageKind.PARAMETER_REMOVED 

269 

270 @property 

271 def _relative_path(self) -> str: 

272 return f"{super()._relative_path}({self.old_value.name})" 

273 

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

278 

279 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

280 return "" 

281 

282 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

283 return "" 

284 

285 

286class ParameterChangedKindBreakage(Breakage): 

287 """Specific breakage class for parameters whose kind changed.""" 

288 

289 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND 

290 

291 @property 

292 def _relative_path(self) -> str: 

293 return f"{super()._relative_path}({self.old_value.name})" 

294 

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

299 

300 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

301 return str(self.old_value.kind.value) 

302 

303 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

304 return str(self.new_value.kind.value) 

305 

306 

307class ParameterChangedDefaultBreakage(Breakage): 

308 """Specific breakage class for parameters whose default value changed.""" 

309 

310 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT 

311 

312 @property 

313 def _relative_path(self) -> str: 

314 return f"{super()._relative_path}({self.old_value.name})" 

315 

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

320 

321 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

322 return str(self.old_value.default) 

323 

324 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

325 return str(self.new_value.default) 

326 

327 

328class ParameterChangedRequiredBreakage(Breakage): 

329 """Specific breakage class for parameters which became required.""" 

330 

331 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED 

332 

333 @property 

334 def _relative_path(self) -> str: 

335 return f"{super()._relative_path}({self.old_value.name})" 

336 

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

341 

342 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

343 return "" 

344 

345 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

346 return "" 

347 

348 

349class ParameterAddedRequiredBreakage(Breakage): 

350 """Specific breakage class for new parameters added as required.""" 

351 

352 kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED 

353 

354 @property 

355 def _relative_path(self) -> str: 

356 return f"{super()._relative_path}({self.new_value.name})" 

357 

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

362 

363 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

364 return "" 

365 

366 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

367 return "" 

368 

369 

370class ReturnChangedTypeBreakage(Breakage): 

371 """Specific breakage class for return values which changed type.""" 

372 

373 kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE 

374 

375 

376class ObjectRemovedBreakage(Breakage): 

377 """Specific breakage class for removed objects.""" 

378 

379 kind: BreakageKind = BreakageKind.OBJECT_REMOVED 

380 

381 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

382 return "" 

383 

384 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

385 return "" 

386 

387 

388class ObjectChangedKindBreakage(Breakage): 

389 """Specific breakage class for objects whose kind changed.""" 

390 

391 kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND 

392 

393 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

394 return self.old_value.value 

395 

396 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

397 return self.new_value.value 

398 

399 

400class AttributeChangedTypeBreakage(Breakage): 

401 """Specific breakage class for attributes whose type changed.""" 

402 

403 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE 

404 

405 

406class AttributeChangedValueBreakage(Breakage): 

407 """Specific breakage class for attributes whose value changed.""" 

408 

409 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE 

410 

411 

412class ClassRemovedBaseBreakage(Breakage): 

413 """Specific breakage class for removed base classes.""" 

414 

415 kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE 

416 

417 def _format_old_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

418 return "[" + ", ".join(base.canonical_path for base in self.old_value) + "]" 

419 

420 def _format_new_value(self, *, colors: bool = True) -> str: # noqa: ARG002 

421 return "[" + ", ".join(base.canonical_path for base in self.new_value) + "]" 

422 

423 

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) 

435 

436 

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 

443 

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 

455 

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) 

460 

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) 

467 

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) 

491 

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 

503 

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) 

508 

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) 

511 

512 

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) 

520 

521 

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 

534 

535 yield from _type_based_yield(old_member, new_member, seen_paths=seen_paths) 

536 

537 

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) 

557 

558 

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] 

594 

595 

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 

604 

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 

608 

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 

612 

613 # TODO: Support annotation breaking changes. 

614 return True 

615 

616 

617_sentinel = object() 

618 

619 

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. 

625 

626 The function will iterate recursively on all objects 

627 and yield breaking changes with detailed information. 

628 

629 Parameters: 

630 old_obj: The old version of an object. 

631 new_obj: The new version of an object. 

632 

633 Yields: 

634 Breaking changes. 

635 

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)