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.
- patchahead/__init__.py +8 -0
- patchahead/analysis/__init__.py +52 -0
- patchahead/analysis/edits.py +143 -0
- patchahead/analysis/index.py +203 -0
- patchahead/analysis/python_ast.py +457 -0
- patchahead/apidiff/__init__.py +23 -0
- patchahead/apidiff/compare.py +366 -0
- patchahead/apidiff/download.py +95 -0
- patchahead/apidiff/surface.py +337 -0
- patchahead/ci.py +301 -0
- patchahead/cli.py +627 -0
- patchahead/config.py +284 -0
- patchahead/demo/__init__.py +256 -0
- patchahead/demo/fixtures/changes/field-rename.md +14 -0
- patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
- patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
- patchahead/demo/fixtures/changes/method-rename.md +12 -0
- patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
- patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
- patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
- patchahead/demo/fixtures/orders-service/README.md +51 -0
- patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/app/client.py +15 -0
- patchahead/demo/fixtures/orders-service/app/models.py +10 -0
- patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
- patchahead/demo/fixtures/orders-service/conftest.py +6 -0
- patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
- patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
- patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
- patchahead/demo/serve.py +189 -0
- patchahead/domain/__init__.py +67 -0
- patchahead/domain/change.py +269 -0
- patchahead/domain/completeness.py +91 -0
- patchahead/domain/impact.py +248 -0
- patchahead/domain/patch.py +81 -0
- patchahead/domain/plan.py +170 -0
- patchahead/domain/result.py +210 -0
- patchahead/domain/validation.py +200 -0
- patchahead/engine.py +609 -0
- patchahead/handlers/__init__.py +35 -0
- patchahead/handlers/base.py +211 -0
- patchahead/handlers/field_rename.py +425 -0
- patchahead/handlers/kwarg_rename.py +201 -0
- patchahead/handlers/method_rename.py +608 -0
- patchahead/handlers/pagination.py +582 -0
- patchahead/ingest/__init__.py +32 -0
- patchahead/ingest/base.py +102 -0
- patchahead/ingest/markdown.py +1138 -0
- patchahead/ingest/structured.py +218 -0
- patchahead/llm/__init__.py +28 -0
- patchahead/llm/client.py +152 -0
- patchahead/llm/proposer.py +620 -0
- patchahead/observability.py +223 -0
- patchahead/reporting.py +451 -0
- patchahead/testing/__init__.py +22 -0
- patchahead/testing/discovery.py +113 -0
- patchahead/testing/runner.py +138 -0
- patchahead/validation/__init__.py +5 -0
- patchahead/validation/completeness.py +265 -0
- patchahead/validation/engine.py +531 -0
- patchahead/web/__init__.py +13 -0
- patchahead/web/server.py +279 -0
- patchahead/web/static/index.html +650 -0
- patchahead/workspace.py +382 -0
- patchahead-0.3.0.dist-info/METADATA +368 -0
- patchahead-0.3.0.dist-info/RECORD +75 -0
- patchahead-0.3.0.dist-info/WHEEL +5 -0
- patchahead-0.3.0.dist-info/entry_points.txt +2 -0
- patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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
|