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

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, Iterable, Iterator 

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

31class Breakage: 

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

33 

34 kind: BreakageKind 

35 """The kind of breakage.""" 

36 

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

38 """Initialize the breakage. 

39 

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

54 

55 def __str__(self) -> str: 

56 return self.kind.value 

57 

58 def __repr__(self) -> str: 

59 return self.kind.name 

60 

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

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

63 

64 Parameters: 

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

66 **kwargs: Additional serialization options. 

67 

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 } 

77 

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

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

80 

81 Parameters: 

82 style: The explanation style to use. 

83 

84 Returns: 

85 An explanation. 

86 """ 

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

88 

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] 

94 

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 

100 

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 

106 

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 

118 

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 

124 

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 

130 

131 @property 

132 def _relative_path(self) -> str: 

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

134 

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 

145 

146 def _format_location(self) -> str: 

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

148 

149 def _format_title(self) -> str: 

150 return self._relative_path 

151 

152 def _format_kind(self) -> str: 

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

154 

155 def _format_old_value(self) -> str: 

156 return str(self.old_value) 

157 

158 def _format_new_value(self) -> str: 

159 return str(self.new_value) 

160 

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 

176 

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) 

194 

195 def _explain_markdown(self) -> str: 

196 return self._explain_oneline() 

197 

198 def _explain_github(self) -> str: 

199 return self._explain_oneline() 

200 

201 

202class ParameterMovedBreakage(Breakage): 

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

204 

205 kind: BreakageKind = BreakageKind.PARAMETER_MOVED 

206 

207 def _format_title(self) -> str: 

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

209 

210 def _format_old_value(self) -> str: 

211 return "" 

212 

213 def _format_new_value(self) -> str: 

214 return "" 

215 

216 

217class ParameterRemovedBreakage(Breakage): 

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

219 

220 kind: BreakageKind = BreakageKind.PARAMETER_REMOVED 

221 

222 def _format_title(self) -> str: 

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

224 

225 def _format_old_value(self) -> str: 

226 return "" 

227 

228 def _format_new_value(self) -> str: 

229 return "" 

230 

231 

232class ParameterChangedKindBreakage(Breakage): 

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

234 

235 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_KIND 

236 

237 def _format_title(self) -> str: 

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

239 

240 def _format_old_value(self) -> str: 

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

242 

243 def _format_new_value(self) -> str: 

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

245 

246 

247class ParameterChangedDefaultBreakage(Breakage): 

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

249 

250 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_DEFAULT 

251 

252 def _format_title(self) -> str: 

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

254 

255 def _format_old_value(self) -> str: 

256 return str(self.old_value.default) 

257 

258 def _format_new_value(self) -> str: 

259 return str(self.new_value.default) 

260 

261 

262class ParameterChangedRequiredBreakage(Breakage): 

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

264 

265 kind: BreakageKind = BreakageKind.PARAMETER_CHANGED_REQUIRED 

266 

267 def _format_title(self) -> str: 

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

269 

270 def _format_old_value(self) -> str: 

271 return "" 

272 

273 def _format_new_value(self) -> str: 

274 return "" 

275 

276 

277class ParameterAddedRequiredBreakage(Breakage): 

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

279 

280 kind: BreakageKind = BreakageKind.PARAMETER_ADDED_REQUIRED 

281 

282 def _format_title(self) -> str: 

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

284 

285 def _format_old_value(self) -> str: 

286 return "" 

287 

288 def _format_new_value(self) -> str: 

289 return "" 

290 

291 

292class ReturnChangedTypeBreakage(Breakage): 

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

294 

295 kind: BreakageKind = BreakageKind.RETURN_CHANGED_TYPE 

296 

297 

298class ObjectRemovedBreakage(Breakage): 

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

300 

301 kind: BreakageKind = BreakageKind.OBJECT_REMOVED 

302 

303 def _format_old_value(self) -> str: 

304 return "" 

305 

306 def _format_new_value(self) -> str: 

307 return "" 

308 

309 

310class ObjectChangedKindBreakage(Breakage): 

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

312 

313 kind: BreakageKind = BreakageKind.OBJECT_CHANGED_KIND 

314 

315 def _format_old_value(self) -> str: 

316 return self.old_value.value 

317 

318 def _format_new_value(self) -> str: 

319 return self.new_value.value 

320 

321 

322class AttributeChangedTypeBreakage(Breakage): 

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

324 

325 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_TYPE 

326 

327 

328class AttributeChangedValueBreakage(Breakage): 

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

330 

331 kind: BreakageKind = BreakageKind.ATTRIBUTE_CHANGED_VALUE 

332 

333 

334class ClassRemovedBaseBreakage(Breakage): 

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

336 

337 kind: BreakageKind = BreakageKind.CLASS_REMOVED_BASE 

338 

339 def _format_old_value(self) -> str: 

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

341 

342 def _format_new_value(self) -> str: 

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

344 

345 

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) 

357 

358 

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 

365 

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 

377 

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) 

382 

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) 

389 

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) 

413 

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 

425 

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) 

430 

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) 

433 

434 

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) 

444 

445 

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 

458 

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

460 

461 

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) 

481 

482 

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] 

518 

519 

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 

528 

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 

532 

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 

536 

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

538 return True 

539 

540 

541_sentinel = object() 

542 

543 

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. 

549 

550 The function will iterate recursively on all objects 

551 and yield breaking changes with detailed information. 

552 

553 Parameters: 

554 old_obj: The old version of an object. 

555 new_obj: The new version of an object. 

556 

557 Yields: 

558 Breaking changes. 

559 

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)