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

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

19 

20if TYPE_CHECKING: 

21 from collections.abc import Iterable, Iterator 

22 

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

24 

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

29 

30 

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" 

71 

72 

73class Breakage: 

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

75 

76 kind: BreakageKind 

77 """The kind of breakage.""" 

78 

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

80 """Initialize the breakage. 

81 

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

96 

97 def __str__(self) -> str: 

98 return self.kind.value 

99 

100 def __repr__(self) -> str: 

101 return self.kind.name 

102 

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

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

105 

106 Parameters: 

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

108 **kwargs: Additional serialization options. 

109 

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 } 

119 

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

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

122 

123 Parameters: 

124 style: The explanation style to use. 

125 

126 Returns: 

127 An explanation. 

128 """ 

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

130 

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] 

136 

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 

142 

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 

148 

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 

160 

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 

166 

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 

172 

173 @property 

174 def _relative_path(self) -> str: 

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

176 

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 

187 

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

192 

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

194 return self._relative_path 

195 

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

200 

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

202 return str(self.old_value) 

203 

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

205 return str(self.new_value) 

206 

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 

222 

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) 

240 

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 

260 

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 

282 

283 

284class ParameterMovedBreakage(Breakage): 

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

286 

287 kind: BreakageKind = BreakageKind.PARAMETER_MOVED 

288 

289 @property 

290 def _relative_path(self) -> str: 

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

292 

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

297 

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

299 return "" 

300 

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

302 return "" 

303 

304 

305class ParameterRemovedBreakage(Breakage): 

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

307 

308 kind: BreakageKind = BreakageKind.PARAMETER_REMOVED 

309 

310 @property 

311 def _relative_path(self) -> str: 

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

313 

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

318 

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

320 return "" 

321 

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

323 return "" 

324 

325 

326class ParameterChangedKindBreakage(Breakage): 

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

328 

329 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND 

330 

331 @property 

332 def _relative_path(self) -> str: 

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

334 

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

339 

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

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

342 

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

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

345 

346 

347class ParameterChangedDefaultBreakage(Breakage): 

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

349 

350 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT 

351 

352 @property 

353 def _relative_path(self) -> str: 

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

355 

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

360 

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

362 return str(self.old_value.default) 

363 

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

365 return str(self.new_value.default) 

366 

367 

368class ParameterChangedRequiredBreakage(Breakage): 

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

370 

371 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED 

372 

373 @property 

374 def _relative_path(self) -> str: 

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

376 

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

381 

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

383 return "" 

384 

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

386 return "" 

387 

388 

389class ParameterAddedRequiredBreakage(Breakage): 

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

391 

392 kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED 

393 

394 @property 

395 def _relative_path(self) -> str: 

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

397 

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

402 

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

404 return "" 

405 

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

407 return "" 

408 

409 

410class ReturnChangedTypeBreakage(Breakage): 

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

412 

413 kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE 

414 

415 

416class ObjectRemovedBreakage(Breakage): 

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

418 

419 kind: BreakageKind = BreakageKind.OBJECT_REMOVED 

420 

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

422 return "" 

423 

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

425 return "" 

426 

427 

428class ObjectChangedKindBreakage(Breakage): 

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

430 

431 kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND 

432 

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

434 return self.old_value.value 

435 

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

437 return self.new_value.value 

438 

439 

440class AttributeChangedTypeBreakage(Breakage): 

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

442 

443 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE 

444 

445 

446class AttributeChangedValueBreakage(Breakage): 

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

448 

449 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE 

450 

451 

452class ClassRemovedBaseBreakage(Breakage): 

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

454 

455 kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE 

456 

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

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

459 

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

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

462 

463 

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) 

475 

476 

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 

483 

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 

495 

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) 

500 

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) 

507 

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) 

531 

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 

543 

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) 

548 

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) 

551 

552 

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) 

560 

561 

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 

574 

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

576 

577 

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) 

597 

598 

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] 

634 

635 

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 

644 

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 

648 

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 

652 

653 # TODO: Support annotation breaking changes. 

654 return True 

655 

656 

657_sentinel = object() 

658 

659 

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. 

665 

666 The function will iterate recursively on all objects 

667 and yield breaking changes with detailed information. 

668 

669 Parameters: 

670 old_obj: The old version of an object. 

671 new_obj: The new version of an object. 

672 

673 Yields: 

674 Breaking changes. 

675 

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)