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

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.enumerations import BreakageKind, ExplanationStyle, ParameterKind 

18from _griffe.exceptions import AliasResolutionError 

19from _griffe.git import _WORKTREE_PREFIX 

20from _griffe.logger import logger 

21 

22if TYPE_CHECKING: 

23 from collections.abc import Iterable, Iterator 

24 

25 from _griffe.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) -> str: 

149 return f"{Style.BRIGHT}{self._location}{Style.RESET_ALL}:{self._lineno}" 

150 

151 def _format_title(self) -> str: 

152 return self._relative_path 

153 

154 def _format_kind(self) -> str: 

155 return f"{Fore.YELLOW}{self.kind.value}{Fore.RESET}" 

156 

157 def _format_old_value(self) -> str: 

158 return str(self.old_value) 

159 

160 def _format_new_value(self) -> str: 

161 return str(self.new_value) 

162 

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 

178 

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) 

196 

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 

216 

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 

238 

239 

240class ParameterMovedBreakage(Breakage): 

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

242 

243 kind: BreakageKind = BreakageKind.PARAMETER_MOVED 

244 

245 def _format_title(self) -> str: 

246 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})" 

247 

248 def _format_old_value(self) -> str: 

249 return "" 

250 

251 def _format_new_value(self) -> str: 

252 return "" 

253 

254 

255class ParameterRemovedBreakage(Breakage): 

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

257 

258 kind: BreakageKind = BreakageKind.PARAMETER_REMOVED 

259 

260 def _format_title(self) -> str: 

261 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})" 

262 

263 def _format_old_value(self) -> str: 

264 return "" 

265 

266 def _format_new_value(self) -> str: 

267 return "" 

268 

269 

270class ParameterChangedKindBreakage(Breakage): 

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

272 

273 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND 

274 

275 def _format_title(self) -> str: 

276 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})" 

277 

278 def _format_old_value(self) -> str: 

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

280 

281 def _format_new_value(self) -> str: 

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

283 

284 

285class ParameterChangedDefaultBreakage(Breakage): 

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

287 

288 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT 

289 

290 def _format_title(self) -> str: 

291 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})" 

292 

293 def _format_old_value(self) -> str: 

294 return str(self.old_value.default) 

295 

296 def _format_new_value(self) -> str: 

297 return str(self.new_value.default) 

298 

299 

300class ParameterChangedRequiredBreakage(Breakage): 

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

302 

303 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED 

304 

305 def _format_title(self) -> str: 

306 return f"{self._relative_path}({Fore.BLUE}{self.old_value.name}{Fore.RESET})" 

307 

308 def _format_old_value(self) -> str: 

309 return "" 

310 

311 def _format_new_value(self) -> str: 

312 return "" 

313 

314 

315class ParameterAddedRequiredBreakage(Breakage): 

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

317 

318 kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED 

319 

320 def _format_title(self) -> str: 

321 return f"{self._relative_path}({Fore.BLUE}{self.new_value.name}{Fore.RESET})" 

322 

323 def _format_old_value(self) -> str: 

324 return "" 

325 

326 def _format_new_value(self) -> str: 

327 return "" 

328 

329 

330class ReturnChangedTypeBreakage(Breakage): 

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

332 

333 kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE 

334 

335 

336class ObjectRemovedBreakage(Breakage): 

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

338 

339 kind: BreakageKind = BreakageKind.OBJECT_REMOVED 

340 

341 def _format_old_value(self) -> str: 

342 return "" 

343 

344 def _format_new_value(self) -> str: 

345 return "" 

346 

347 

348class ObjectChangedKindBreakage(Breakage): 

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

350 

351 kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND 

352 

353 def _format_old_value(self) -> str: 

354 return self.old_value.value 

355 

356 def _format_new_value(self) -> str: 

357 return self.new_value.value 

358 

359 

360class AttributeChangedTypeBreakage(Breakage): 

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

362 

363 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE 

364 

365 

366class AttributeChangedValueBreakage(Breakage): 

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

368 

369 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE 

370 

371 

372class ClassRemovedBaseBreakage(Breakage): 

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

374 

375 kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE 

376 

377 def _format_old_value(self) -> str: 

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

379 

380 def _format_new_value(self) -> str: 

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

382 

383 

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) 

395 

396 

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 

403 

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 

415 

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) 

420 

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) 

427 

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) 

451 

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 

463 

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) 

468 

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) 

471 

472 

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) 

482 

483 

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 

496 

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

498 

499 

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) 

519 

520 

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] 

556 

557 

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 

566 

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 

570 

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 

574 

575 # TODO: Use beartype.peps.resolve_pep563 and beartype.door.is_subhint? 

576 return True 

577 

578 

579_sentinel = object() 

580 

581 

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. 

587 

588 The function will iterate recursively on all objects 

589 and yield breaking changes with detailed information. 

590 

591 Parameters: 

592 old_obj: The old version of an object. 

593 new_obj: The new version of an object. 

594 

595 Yields: 

596 Breaking changes. 

597 

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)