patchahead 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. patchahead/__init__.py +8 -0
  2. patchahead/analysis/__init__.py +52 -0
  3. patchahead/analysis/edits.py +143 -0
  4. patchahead/analysis/index.py +203 -0
  5. patchahead/analysis/python_ast.py +457 -0
  6. patchahead/apidiff/__init__.py +23 -0
  7. patchahead/apidiff/compare.py +366 -0
  8. patchahead/apidiff/download.py +95 -0
  9. patchahead/apidiff/surface.py +337 -0
  10. patchahead/ci.py +301 -0
  11. patchahead/cli.py +627 -0
  12. patchahead/config.py +284 -0
  13. patchahead/demo/__init__.py +256 -0
  14. patchahead/demo/fixtures/changes/field-rename.md +14 -0
  15. patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
  16. patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
  17. patchahead/demo/fixtures/changes/method-rename.md +12 -0
  18. patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
  19. patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
  20. patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
  21. patchahead/demo/fixtures/orders-service/README.md +51 -0
  22. patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
  23. patchahead/demo/fixtures/orders-service/app/client.py +15 -0
  24. patchahead/demo/fixtures/orders-service/app/models.py +10 -0
  25. patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
  26. patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
  27. patchahead/demo/fixtures/orders-service/conftest.py +6 -0
  28. patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
  29. patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
  30. patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
  31. patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
  32. patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
  33. patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
  34. patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
  35. patchahead/demo/serve.py +189 -0
  36. patchahead/domain/__init__.py +67 -0
  37. patchahead/domain/change.py +269 -0
  38. patchahead/domain/completeness.py +91 -0
  39. patchahead/domain/impact.py +248 -0
  40. patchahead/domain/patch.py +81 -0
  41. patchahead/domain/plan.py +170 -0
  42. patchahead/domain/result.py +210 -0
  43. patchahead/domain/validation.py +200 -0
  44. patchahead/engine.py +609 -0
  45. patchahead/handlers/__init__.py +35 -0
  46. patchahead/handlers/base.py +211 -0
  47. patchahead/handlers/field_rename.py +425 -0
  48. patchahead/handlers/kwarg_rename.py +201 -0
  49. patchahead/handlers/method_rename.py +608 -0
  50. patchahead/handlers/pagination.py +582 -0
  51. patchahead/ingest/__init__.py +32 -0
  52. patchahead/ingest/base.py +102 -0
  53. patchahead/ingest/markdown.py +1138 -0
  54. patchahead/ingest/structured.py +218 -0
  55. patchahead/llm/__init__.py +28 -0
  56. patchahead/llm/client.py +152 -0
  57. patchahead/llm/proposer.py +620 -0
  58. patchahead/observability.py +223 -0
  59. patchahead/reporting.py +451 -0
  60. patchahead/testing/__init__.py +22 -0
  61. patchahead/testing/discovery.py +113 -0
  62. patchahead/testing/runner.py +138 -0
  63. patchahead/validation/__init__.py +5 -0
  64. patchahead/validation/completeness.py +265 -0
  65. patchahead/validation/engine.py +531 -0
  66. patchahead/web/__init__.py +13 -0
  67. patchahead/web/server.py +279 -0
  68. patchahead/web/static/index.html +650 -0
  69. patchahead/workspace.py +382 -0
  70. patchahead-0.3.0.dist-info/METADATA +368 -0
  71. patchahead-0.3.0.dist-info/RECORD +75 -0
  72. patchahead-0.3.0.dist-info/WHEEL +5 -0
  73. patchahead-0.3.0.dist-info/entry_points.txt +2 -0
  74. patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
  75. patchahead-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,366 @@
1
+ """Comparing two versions of a library's public API into breaking changes.
2
+
3
+ Every reading here is something PatchAhead will rewrite code on, so a match is
4
+ made only when the evidence leaves one answer, and everything else is reported:
5
+
6
+ ========================================================== ============ ==========
7
+ Old public member Reading Confidence
8
+ ========================================================== ============ ==========
9
+ still there, deprecated, hands its work to one sibling rename high
10
+ gone; exactly one new sibling with an identical signature rename medium
11
+ gone; a class with the same name elsewhere / the same moved reported
12
+ methods under a new name rename medium
13
+ gone; several possible replacements ambiguous reported
14
+ gone; nothing like it removed reported
15
+ a keyword parameter gone, one added at the same position, kwarg rename high
16
+ same kind, same default-ness
17
+ a keyword parameter gone with no counterpart removed reported
18
+ a new parameter without a default new required reported
19
+ ========================================================== ============ ==========
20
+
21
+ A parameter folded into ``**kwargs`` is still accepted, so it is not a break.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from dataclasses import dataclass, field
28
+
29
+ from patchahead.apidiff.surface import Member, Param, Surface
30
+ from patchahead.domain.change import (
31
+ BreakingChange,
32
+ ChangeKind,
33
+ Confidence,
34
+ Evidence,
35
+ Severity,
36
+ SymbolTarget,
37
+ )
38
+
39
+ #: Parameter kinds a caller can pass by keyword.
40
+ _KEYWORD_KINDS = {"normal", "keyword"}
41
+
42
+
43
+ @dataclass
44
+ class ApiDiff:
45
+ """The breaking changes between two versions, in a stable order."""
46
+
47
+ package: str
48
+ old_version: str
49
+ new_version: str
50
+ changes: list[BreakingChange] = field(default_factory=list)
51
+ members_compared: int = 0
52
+
53
+
54
+ def compare(
55
+ old: Surface, new: Surface, package: str = "", versions: tuple[str, str] = ("", "")
56
+ ) -> ApiDiff:
57
+ diff = ApiDiff(package=package, old_version=versions[0], new_version=versions[1])
58
+ new_by_definition = new.by_definition()
59
+ old_paths = set(old.public)
60
+
61
+ # One reading per definition, however many modules re-export it. A
62
+ # definition counts as gone only when none of its public paths survive: a
63
+ # re-export one module dropped is not a change a caller has to make.
64
+ paths_of: dict[tuple[str, str], list[str]] = {}
65
+ for path, member in old.public.items():
66
+ paths_of.setdefault((member.module, member.qualname), []).append(path)
67
+
68
+ gone_classes: list[str] = []
69
+ for key in sorted(paths_of, key=lambda k: _canonical(k, paths_of[k])):
70
+ paths = paths_of[key]
71
+ path = _canonical(key, paths)
72
+ before = old.public[path]
73
+ diff.members_compared += 1
74
+ if any(p.startswith(f"{gone}.") for p in paths for gone in gone_classes):
75
+ # A method of a class that was renamed, moved, or removed: the
76
+ # class's own reading covers it.
77
+ continue
78
+ surviving = [p for p in [path, *sorted(paths)] if p in new.public]
79
+ if surviving:
80
+ diff.changes.extend(
81
+ _compare_member(surviving[0], before, new.public[surviving[0]], new)
82
+ )
83
+ continue
84
+ if before.kind == "class":
85
+ gone_classes.extend(paths)
86
+ diff.changes.append(_gone(path, before, old, new, old_paths, new_by_definition))
87
+ return diff
88
+
89
+
90
+ def _canonical(key: tuple[str, str], paths: list[str]) -> str:
91
+ """The path a definition is best known by: where it is defined, else the shortest."""
92
+ defined = f"{key[0]}.{key[1]}"
93
+ return defined if defined in paths else min(paths, key=lambda p: (len(p), p))
94
+
95
+
96
+ def _compare_member(path: str, before: Member, after: Member, new: Surface) -> list[BreakingChange]:
97
+ changes: list[BreakingChange] = []
98
+ if after.deprecated and not before.deprecated and before.kind != "class":
99
+ changes.append(_deprecated(path, before, after, new))
100
+ changes.extend(_parameter_changes(path, before, after))
101
+ return changes
102
+
103
+
104
+ def _deprecated(path: str, before: Member, after: Member, new: Surface) -> BreakingChange:
105
+ """A member newly deprecated in favor of a sibling: a rename only if a drop-in one."""
106
+ if not after.deprecated_for:
107
+ return _unsupported(
108
+ path,
109
+ before,
110
+ f"`{path}` is deprecated",
111
+ f"`{before.qualname}` is newly deprecated and names no replacement",
112
+ after,
113
+ )
114
+ replacement = f"{after.container + '.' if after.container else ''}{after.deprecated_for}"
115
+ module = path[: -len(before.qualname) - 1]
116
+ target = new.public.get(f"{module}.{replacement}")
117
+ if target is not None and _accepts_every_call(after.params, target.params):
118
+ return _rename(
119
+ path,
120
+ before,
121
+ replacement,
122
+ Confidence.HIGH,
123
+ f"`{before.qualname}` is newly deprecated in favor of `{replacement}`, "
124
+ f"which accepts every call it does",
125
+ )
126
+ return _unsupported(
127
+ path,
128
+ before,
129
+ f"`{path}` is deprecated in favor of `{after.deprecated_for}`",
130
+ f"`{before.qualname}` is newly deprecated in favor of `{replacement}`, which "
131
+ f"does not accept every call it does -- not a rename PatchAhead can apply",
132
+ target or after,
133
+ )
134
+
135
+
136
+ def _gone(
137
+ path: str,
138
+ before: Member,
139
+ old: Surface,
140
+ new: Surface,
141
+ old_paths: set[str],
142
+ new_by_definition: dict[tuple[str, str], Member],
143
+ ) -> BreakingChange:
144
+ module, _, _ = path.rpartition(f".{before.qualname}")
145
+ added = {p: m for p, m in new.public.items() if p not in old_paths}
146
+
147
+ # Same name, different module: a move, not a rename.
148
+ elsewhere = [p for p, m in added.items() if m.qualname == before.qualname]
149
+ if elsewhere:
150
+ return _unsupported(
151
+ path,
152
+ before,
153
+ f"`{path}` moved to `{elsewhere[0]}`",
154
+ f"`{before.qualname}` is no longer at `{path}` but is at "
155
+ f"`{', '.join(sorted(elsewhere))}` -- a move, which PatchAhead v1 cannot migrate",
156
+ )
157
+
158
+ # A new sibling that looks exactly like it: same container, same signature
159
+ # (for a class: same public methods).
160
+ def sibling(member: Member) -> bool:
161
+ if member.kind != before.kind or member.container != before.container:
162
+ return False
163
+ if before.kind == "class":
164
+ return bool(before.methods) and member.methods == before.methods
165
+ return _accepts_every_call(before.params, member.params) and _accepts_every_call(
166
+ member.params, before.params
167
+ )
168
+
169
+ candidates = sorted(
170
+ {m.qualname: m for p, m in added.items() if p.startswith(f"{module}.") and sibling(m)}
171
+ )
172
+ if len(candidates) == 1:
173
+ return _rename(
174
+ path,
175
+ before,
176
+ candidates[0],
177
+ Confidence.MEDIUM,
178
+ f"`{before.qualname}` is gone and `{candidates[0]}` is new, with "
179
+ + (
180
+ "the same public methods"
181
+ if before.kind == "class"
182
+ else f"an identical signature `{before.signature().split('(', 1)[1]}`"
183
+ ),
184
+ )
185
+ if candidates:
186
+ return _unsupported(
187
+ path,
188
+ before,
189
+ f"`{path}` was removed; several new members could replace it",
190
+ f"`{before.qualname}` is gone and {len(candidates)} new members look like it "
191
+ f"({', '.join(f'`{c}`' for c in candidates)}); PatchAhead does not pick one",
192
+ )
193
+ return _unsupported(
194
+ path,
195
+ before,
196
+ f"`{path}` was removed",
197
+ f"`{before.qualname}` is gone and nothing in the new version looks like its replacement",
198
+ )
199
+
200
+
201
+ def _parameter_changes(path: str, before: Member, after: Member) -> list[BreakingChange]:
202
+ if any(p.kind == "var_keyword" for p in after.params):
203
+ # Anything removed is still accepted by **kwargs; nothing to migrate.
204
+ removed: list[Param] = []
205
+ else:
206
+ names_after = {p.name for p in after.params}
207
+ removed = [
208
+ p for p in before.params if p.kind in _KEYWORD_KINDS and p.name not in names_after
209
+ ]
210
+ names_before = {p.name for p in before.params}
211
+ added = [p for p in after.params if p.name not in names_before and p.kind in _KEYWORD_KINDS]
212
+ callable_name = before.name if before.kind != "class" else before.qualname
213
+
214
+ changes: list[BreakingChange] = []
215
+ paired: set[str] = set()
216
+ for gone in removed:
217
+ twin = [
218
+ p
219
+ for p in added
220
+ if p.kind == gone.kind
221
+ and p.has_default == gone.has_default
222
+ and _position(after.params, p) == _position(before.params, gone)
223
+ ]
224
+ if len(twin) == 1 and len(removed) == len(added) == 1 and _related(gone.name, twin[0].name):
225
+ paired.add(twin[0].name)
226
+ changes.append(
227
+ BreakingChange(
228
+ title=f"`{gone.name}=` on `{path}` renamed to `{twin[0].name}=`",
229
+ kind=ChangeKind.KWARG_RENAME,
230
+ target=SymbolTarget(
231
+ symbol=gone.name,
232
+ replacement=twin[0].name,
233
+ owner=callable_name,
234
+ owner_is_explicit=True,
235
+ ),
236
+ severity=Severity.HIGH,
237
+ confidence=Confidence.HIGH,
238
+ evidence=_evidence(before, after),
239
+ source="api-diff",
240
+ classification_reason=(
241
+ f"`{gone.name}` was removed and `{twin[0].name}` added at the same "
242
+ f"position, with the same kind and default"
243
+ ),
244
+ )
245
+ )
246
+ else:
247
+ changes.append(
248
+ _unsupported(
249
+ path,
250
+ before,
251
+ f"`{gone.name}=` removed from `{path}`",
252
+ f"the `{gone.name}` parameter of `{path}` was removed, and no single "
253
+ f"new parameter replaces it",
254
+ after,
255
+ )
256
+ )
257
+ for new in added:
258
+ if new.name not in paired and not new.has_default:
259
+ changes.append(
260
+ _unsupported(
261
+ path,
262
+ before,
263
+ f"`{path}` has a new required parameter `{new.name}`",
264
+ f"calls to `{path}` must now pass `{new.name}`; PatchAhead cannot "
265
+ f"invent the value",
266
+ after,
267
+ )
268
+ )
269
+ return changes
270
+
271
+
272
+ def _related(old: str, new: str) -> bool:
273
+ """Whether two parameter names plausibly name the same thing.
274
+
275
+ Position, kind and default alone pair unrelated options -- a library that
276
+ drops `use_proxy` and adds `slots` in its place has not renamed anything.
277
+ A shared word (`verify_ssl` / `verify`, `info` / `current_info`) is the
278
+ evidence a rename leaves behind.
279
+ """
280
+
281
+ def words(name: str) -> set[str]:
282
+ spaced = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name).lower()
283
+ return {w for w in spaced.split("_") if len(w) >= 3}
284
+
285
+ return bool(words(old) & words(new))
286
+
287
+
288
+ def _accepts_every_call(old: tuple[Param, ...], new: tuple[Param, ...]) -> bool:
289
+ """Whether every call valid against ``old`` is also valid against ``new``.
290
+
291
+ Positional parameters keep their order and names; every name a caller can
292
+ pass by keyword is still accepted; whatever ``new`` adds has a default.
293
+ """
294
+ by_name = {p.name: p for p in new}
295
+ old_positional = [p for p in old if p.kind in ("positional", "normal")]
296
+ new_positional = [p for p in new if p.kind in ("positional", "normal")]
297
+ if [p.name for p in new_positional[: len(old_positional)]] != [p.name for p in old_positional]:
298
+ return False
299
+ for param in old:
300
+ if param.kind in ("var_positional", "var_keyword"):
301
+ if not any(p.kind == param.kind for p in new):
302
+ return False
303
+ continue
304
+ counterpart = by_name.get(param.name)
305
+ if counterpart is None:
306
+ return False
307
+ if param.kind == "normal" and counterpart.kind != "normal":
308
+ return False
309
+ if param.has_default and not counterpart.has_default:
310
+ return False
311
+ known = {p.name for p in old}
312
+ return all(
313
+ p.has_default or p.kind in ("var_positional", "var_keyword")
314
+ for p in new
315
+ if p.name not in known
316
+ )
317
+
318
+
319
+ def _position(params: tuple[Param, ...], param: Param) -> int:
320
+ same_kind = [p.name for p in params if p.kind == param.kind]
321
+ return same_kind.index(param.name) if param.kind != "keyword" else 0
322
+
323
+
324
+ def _rename(
325
+ path: str, before: Member, replacement: str, confidence: Confidence, why: str
326
+ ) -> BreakingChange:
327
+ new_name = replacement.rsplit(".", 1)[-1]
328
+ owner = before.container if before.kind == "method" else ""
329
+ return BreakingChange(
330
+ title=f"`{path}` renamed to `{new_name}`",
331
+ kind=ChangeKind.METHOD_RENAME,
332
+ target=SymbolTarget(
333
+ symbol=before.name,
334
+ replacement=new_name,
335
+ owner=owner,
336
+ # The class is known; what a caller names its instance is not. A
337
+ # hint ranks a matching receiver higher without vetoing the rest.
338
+ owner_is_explicit=False,
339
+ ),
340
+ severity=Severity.HIGH,
341
+ confidence=confidence,
342
+ evidence=[Evidence(quote=f"before: {before.signature()}", note=before.module)],
343
+ source="api-diff",
344
+ classification_reason=why,
345
+ )
346
+
347
+
348
+ def _unsupported(
349
+ path: str, before: Member, title: str, why: str, after: Member | None = None
350
+ ) -> BreakingChange:
351
+ return BreakingChange(
352
+ title=title,
353
+ kind=ChangeKind.UNSUPPORTED,
354
+ severity=Severity.HIGH,
355
+ confidence=Confidence.LOW,
356
+ evidence=_evidence(before, after),
357
+ source="api-diff",
358
+ classification_reason=why,
359
+ )
360
+
361
+
362
+ def _evidence(before: Member, after: Member | None) -> list[Evidence]:
363
+ evidence = [Evidence(quote=f"before: {before.signature()}", note=before.module)]
364
+ if after is not None:
365
+ evidence.append(Evidence(quote=f"after: {after.signature()}", note=after.module))
366
+ return evidence
@@ -0,0 +1,95 @@
1
+ """Getting one version of a library to read: a local tree, a wheel, or PyPI.
2
+
3
+ Only wheels are fetched. A wheel is a zip of files; reading it runs nothing. A
4
+ source distribution may have to be *built* before its files can be read, and
5
+ building runs the package's own setup code -- so refusing sdists is not an
6
+ optimization here, it is the safety property.
7
+
8
+ Wheels come straight from PyPI's JSON API with the standard library, rather
9
+ than through ``pip download``: that works in environments without pip (a
10
+ ``uv``-made virtualenv has none), and the file is checked against the SHA-256
11
+ digest PyPI publishes for it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+ import zipfile
23
+ from pathlib import Path
24
+
25
+
26
+ class ApiDiffError(Exception):
27
+ """A version could not be fetched or read."""
28
+
29
+
30
+ #: The package index's JSON API. Overridable for a mirror that serves the same API.
31
+ INDEX_URL = os.environ.get("PATCHAHEAD_PYPI_URL", "https://pypi.org/pypi")
32
+ #: How long one request may take.
33
+ TIMEOUT_SECONDS = 60
34
+
35
+
36
+ def fetch(package: str, version: str, dest: Path) -> Path:
37
+ """Download ``package==version`` as a wheel into ``dest``, unpack it, return the tree."""
38
+ url = f"{INDEX_URL}/{urllib.parse.quote(package)}/{urllib.parse.quote(version)}/json"
39
+ try:
40
+ with urllib.request.urlopen(url, timeout=TIMEOUT_SECONDS) as response:
41
+ release = json.load(response)
42
+ except urllib.error.HTTPError as exc:
43
+ if exc.code == 404:
44
+ raise ApiDiffError(f"{package}=={version} was not found on the package index") from None
45
+ raise ApiDiffError(f"could not look up {package}=={version}: {exc}") from None
46
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
47
+ raise ApiDiffError(f"could not look up {package}=={version}: {exc}") from None
48
+
49
+ wheels = [f for f in release.get("urls", []) if f.get("packagetype") == "bdist_wheel"]
50
+ if not wheels:
51
+ raise ApiDiffError(
52
+ f"{package}=={version} has no wheel. PatchAhead reads wheels only, because "
53
+ f"building a source distribution runs the package's own code; pass a local "
54
+ f"directory or .whl file with --old/--new instead"
55
+ )
56
+ # Any wheel carries the Python source; a pure one is simply the smallest.
57
+ wheels.sort(key=lambda f: (not f["filename"].endswith("-none-any.whl"), f["filename"]))
58
+ chosen = wheels[0]
59
+
60
+ dest.mkdir(parents=True, exist_ok=True)
61
+ target = dest / Path(chosen["filename"]).name
62
+ try:
63
+ with urllib.request.urlopen(chosen["url"], timeout=TIMEOUT_SECONDS) as response:
64
+ data = response.read()
65
+ except (urllib.error.URLError, TimeoutError) as exc:
66
+ raise ApiDiffError(f"could not download {chosen['filename']}: {exc}") from None
67
+ expected = (chosen.get("digests") or {}).get("sha256")
68
+ if expected and hashlib.sha256(data).hexdigest() != expected:
69
+ raise ApiDiffError(f"{chosen['filename']} does not match the digest the index published")
70
+ target.write_bytes(data)
71
+ return unpack(target, dest / "unpacked")
72
+
73
+
74
+ def unpack(source: Path, dest: Path) -> Path:
75
+ """A directory as is; a ``.whl`` or ``.zip`` extracted into ``dest``."""
76
+ if source.is_dir():
77
+ return source
78
+ if source.suffix not in (".whl", ".zip") or not source.is_file():
79
+ raise ApiDiffError(f"{source} is not a directory, a .whl, or a .zip")
80
+ dest.mkdir(parents=True, exist_ok=True)
81
+ root = dest.resolve()
82
+ try:
83
+ with zipfile.ZipFile(source) as archive:
84
+ for member in archive.infolist():
85
+ target = (dest / member.filename).resolve()
86
+ # A crafted archive can name `../../somewhere`; extract nothing
87
+ # outside the destination.
88
+ if root != target and root not in target.parents:
89
+ raise ApiDiffError(f"{source} contains an unsafe path: {member.filename}")
90
+ if not member.is_dir():
91
+ target.parent.mkdir(parents=True, exist_ok=True)
92
+ target.write_bytes(archive.read(member))
93
+ except zipfile.BadZipFile as exc:
94
+ raise ApiDiffError(f"{source} is not a valid archive: {exc}") from exc
95
+ return dest