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,337 @@
|
|
|
1
|
+
"""The public API of a Python library, read from its source without running it.
|
|
2
|
+
|
|
3
|
+
A library is parsed, never imported: importing runs its code, and the point of
|
|
4
|
+
reading two versions side by side is to do it before trusting either. What is
|
|
5
|
+
recorded is what a caller can depend on -- public functions, classes, methods,
|
|
6
|
+
and each parameter's name, kind, position, and whether it has a default.
|
|
7
|
+
|
|
8
|
+
"Public" follows the conventions a caller relies on: a name without a leading
|
|
9
|
+
underscore, in a module whose path has no private component, filtered by
|
|
10
|
+
``__all__`` where a module declares one -- plus anything a public module
|
|
11
|
+
re-exports with ``from .impl import Name``, which is how most libraries expose
|
|
12
|
+
code that lives in private modules.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ast
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from patchahead.analysis.edits import read_source
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
#: Directories in a source tree or wheel that are not the library's code.
|
|
28
|
+
_SKIP_DIRS = {"tests", "test", "testing", "docs", "examples", "benchmarks"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Param:
|
|
33
|
+
"""One parameter, as a caller sees it."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
#: ``positional`` (positional-only), ``normal``, ``var_positional``,
|
|
37
|
+
#: ``keyword`` (keyword-only), or ``var_keyword``.
|
|
38
|
+
kind: str
|
|
39
|
+
has_default: bool
|
|
40
|
+
|
|
41
|
+
def render(self) -> str:
|
|
42
|
+
prefix = {"var_positional": "*", "var_keyword": "**"}.get(self.kind, "")
|
|
43
|
+
return f"{prefix}{self.name}{'=...' if self.has_default else ''}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Member:
|
|
48
|
+
"""A public function, class, or method, where it is defined."""
|
|
49
|
+
|
|
50
|
+
#: Dotted module the definition lives in, e.g. ``storekit._client``.
|
|
51
|
+
module: str
|
|
52
|
+
#: ``fetch_all`` for a function, ``Client`` for a class, ``Client.fetch_all``
|
|
53
|
+
#: for a method.
|
|
54
|
+
qualname: str
|
|
55
|
+
kind: str # "function" | "class" | "method"
|
|
56
|
+
params: tuple[Param, ...] = ()
|
|
57
|
+
#: For a class: its public method names, used to recognize a renamed class.
|
|
58
|
+
methods: frozenset[str] = frozenset()
|
|
59
|
+
#: Marked deprecated: a ``@deprecated`` decorator, or a deprecation warning.
|
|
60
|
+
deprecated: bool = False
|
|
61
|
+
#: The sibling a deprecated member names as its replacement, in its
|
|
62
|
+
#: warning, decorator, or docstring.
|
|
63
|
+
deprecated_for: str = ""
|
|
64
|
+
line: int = 0
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def name(self) -> str:
|
|
68
|
+
return self.qualname.rsplit(".", 1)[-1]
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def container(self) -> str:
|
|
72
|
+
"""What holds it: the class for a method, the module otherwise."""
|
|
73
|
+
return self.qualname.rsplit(".", 1)[0] if "." in self.qualname else ""
|
|
74
|
+
|
|
75
|
+
def signature(self) -> str:
|
|
76
|
+
return f"{self.qualname}({', '.join(p.render() for p in self.params)})"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Surface:
|
|
81
|
+
"""Every public member of a library, keyed by the dotted path a caller uses."""
|
|
82
|
+
|
|
83
|
+
#: ``storekit.Client.fetch_all`` -> the member, however it got there.
|
|
84
|
+
public: dict[str, Member] = field(default_factory=dict)
|
|
85
|
+
#: Modules that could not be parsed, with the reason.
|
|
86
|
+
skipped: dict[str, str] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
def by_definition(self) -> dict[tuple[str, str], Member]:
|
|
89
|
+
"""The same members keyed by where they are defined."""
|
|
90
|
+
return {(m.module, m.qualname): m for m in self.public.values()}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read(root: Path) -> Surface:
|
|
94
|
+
"""Read the public API of every top-level package or module under ``root``.
|
|
95
|
+
|
|
96
|
+
``root`` may also be a package directory itself; its parent is read then.
|
|
97
|
+
"""
|
|
98
|
+
if (root / "__init__.py").exists():
|
|
99
|
+
root = root.parent
|
|
100
|
+
modules = _modules(root)
|
|
101
|
+
definitions: dict[str, dict[str, Member]] = {}
|
|
102
|
+
imports: dict[str, dict[str, tuple[str, str]]] = {}
|
|
103
|
+
exported: dict[str, set[str] | None] = {}
|
|
104
|
+
surface = Surface()
|
|
105
|
+
|
|
106
|
+
for dotted, path in modules.items():
|
|
107
|
+
try:
|
|
108
|
+
tree = ast.parse(read_source(path), filename=str(path))
|
|
109
|
+
except (SyntaxError, UnicodeDecodeError, OSError) as exc:
|
|
110
|
+
surface.skipped[dotted] = str(exc)
|
|
111
|
+
continue
|
|
112
|
+
definitions[dotted] = _definitions(dotted, tree)
|
|
113
|
+
imports[dotted] = _imports(dotted, tree, path.name == "__init__.py")
|
|
114
|
+
exported[dotted] = _all(tree)
|
|
115
|
+
|
|
116
|
+
for dotted in modules:
|
|
117
|
+
if dotted not in definitions or _private_module(dotted):
|
|
118
|
+
continue
|
|
119
|
+
allowed = exported[dotted]
|
|
120
|
+
|
|
121
|
+
def visible(name: str, allowed: set[str] | None = allowed) -> bool:
|
|
122
|
+
return name in allowed if allowed is not None else not name.startswith("_")
|
|
123
|
+
|
|
124
|
+
for name, member in definitions[dotted].items():
|
|
125
|
+
if "." not in name and visible(name):
|
|
126
|
+
_publish(surface, f"{dotted}.{name}", member, definitions[dotted])
|
|
127
|
+
for alias, (source, original) in imports[dotted].items():
|
|
128
|
+
member = definitions.get(source, {}).get(original)
|
|
129
|
+
if member is not None and visible(alias):
|
|
130
|
+
_publish(surface, f"{dotted}.{alias}", member, definitions[source])
|
|
131
|
+
return surface
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _publish(surface: Surface, path: str, member: Member, siblings: dict[str, Member]) -> None:
|
|
135
|
+
surface.public.setdefault(path, member)
|
|
136
|
+
if member.kind == "class":
|
|
137
|
+
for qualname, method in siblings.items():
|
|
138
|
+
if qualname.startswith(f"{member.qualname}."):
|
|
139
|
+
surface.public.setdefault(f"{path}.{method.name}", method)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _modules(root: Path) -> dict[str, Path]:
|
|
143
|
+
"""Dotted module name -> file, for every package or module at ``root``."""
|
|
144
|
+
found: dict[str, Path] = {}
|
|
145
|
+
for path in sorted(root.rglob("*.py")):
|
|
146
|
+
relative = path.relative_to(root)
|
|
147
|
+
parts = relative.parts
|
|
148
|
+
if any(p.endswith((".dist-info", ".egg-info", ".data")) for p in parts):
|
|
149
|
+
continue
|
|
150
|
+
if any(p in _SKIP_DIRS for p in parts[:-1]):
|
|
151
|
+
continue
|
|
152
|
+
# Only the importable tree: every directory above the file is a package.
|
|
153
|
+
if any(
|
|
154
|
+
not (root.joinpath(*parts[:i]) / "__init__.py").exists() for i in range(1, len(parts))
|
|
155
|
+
):
|
|
156
|
+
continue
|
|
157
|
+
dotted = ".".join(parts[:-1] + ((path.stem,) if path.stem != "__init__" else ()))
|
|
158
|
+
if dotted:
|
|
159
|
+
found[dotted] = path
|
|
160
|
+
return found
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _private_module(dotted: str) -> bool:
|
|
164
|
+
return any(part.startswith("_") for part in dotted.split("."))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _all(tree: ast.Module) -> set[str] | None:
|
|
168
|
+
"""A module's literal ``__all__``, or None when it has none we can read."""
|
|
169
|
+
for node in tree.body:
|
|
170
|
+
names_all = isinstance(node, ast.Assign) and any(
|
|
171
|
+
isinstance(t, ast.Name) and t.id == "__all__" for t in node.targets
|
|
172
|
+
)
|
|
173
|
+
if (
|
|
174
|
+
names_all
|
|
175
|
+
and isinstance(node.value, (ast.List, ast.Tuple))
|
|
176
|
+
and all(
|
|
177
|
+
isinstance(e, ast.Constant) and isinstance(e.value, str) for e in node.value.elts
|
|
178
|
+
)
|
|
179
|
+
):
|
|
180
|
+
return {e.value for e in node.value.elts}
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _imports(dotted: str, tree: ast.Module, is_package: bool) -> dict[str, tuple[str, str]]:
|
|
185
|
+
"""``alias -> (source module, original name)`` for a module's top-level imports."""
|
|
186
|
+
package = dotted if is_package else dotted.rpartition(".")[0]
|
|
187
|
+
found: dict[str, tuple[str, str]] = {}
|
|
188
|
+
for node in tree.body:
|
|
189
|
+
if not isinstance(node, ast.ImportFrom):
|
|
190
|
+
continue
|
|
191
|
+
if node.level:
|
|
192
|
+
base = package.split(".")
|
|
193
|
+
base = base[: len(base) - (node.level - 1)] if node.level > 1 else base
|
|
194
|
+
source = ".".join(base + ([node.module] if node.module else []))
|
|
195
|
+
else:
|
|
196
|
+
source = node.module or ""
|
|
197
|
+
for alias in node.names:
|
|
198
|
+
if alias.name != "*":
|
|
199
|
+
found[alias.asname or alias.name] = (source, alias.name)
|
|
200
|
+
return found
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _definitions(dotted: str, tree: ast.Module) -> dict[str, Member]:
|
|
204
|
+
members: dict[str, Member] = {}
|
|
205
|
+
for node in tree.body:
|
|
206
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
207
|
+
members[node.name] = _function(dotted, node.name, node, "function", tree.body)
|
|
208
|
+
elif isinstance(node, ast.ClassDef):
|
|
209
|
+
methods = [
|
|
210
|
+
child
|
|
211
|
+
for child in node.body
|
|
212
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
213
|
+
and (not child.name.startswith("_") or child.name == "__init__")
|
|
214
|
+
]
|
|
215
|
+
init = next((m for m in methods if m.name == "__init__"), None)
|
|
216
|
+
members[node.name] = Member(
|
|
217
|
+
module=dotted,
|
|
218
|
+
qualname=node.name,
|
|
219
|
+
kind="class",
|
|
220
|
+
params=_params(init, drop_first=True) if init else (),
|
|
221
|
+
methods=frozenset(m.name for m in methods if m.name != "__init__"),
|
|
222
|
+
line=node.lineno,
|
|
223
|
+
)
|
|
224
|
+
for method in methods:
|
|
225
|
+
if method.name == "__init__":
|
|
226
|
+
continue
|
|
227
|
+
qualname = f"{node.name}.{method.name}"
|
|
228
|
+
members[qualname] = _function(dotted, qualname, method, "method", node.body)
|
|
229
|
+
return members
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _function(dotted: str, qualname: str, node, kind: str, scope: list[ast.stmt]) -> Member:
|
|
233
|
+
is_static = any(_decorator_name(d) == "staticmethod" for d in node.decorator_list)
|
|
234
|
+
deprecated, replacement = _deprecation(node, scope)
|
|
235
|
+
return Member(
|
|
236
|
+
module=dotted,
|
|
237
|
+
qualname=qualname,
|
|
238
|
+
kind=kind,
|
|
239
|
+
params=_params(node, drop_first=kind == "method" and not is_static),
|
|
240
|
+
deprecated=deprecated,
|
|
241
|
+
deprecated_for=replacement,
|
|
242
|
+
line=node.lineno,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _params(node, drop_first: bool) -> tuple[Param, ...]:
|
|
247
|
+
args = node.args
|
|
248
|
+
positional = [*args.posonlyargs, *args.args]
|
|
249
|
+
defaults_from = len(positional) - len(args.defaults)
|
|
250
|
+
params: list[Param] = []
|
|
251
|
+
for index, arg in enumerate(positional):
|
|
252
|
+
kind = "positional" if index < len(args.posonlyargs) else "normal"
|
|
253
|
+
params.append(Param(arg.arg, kind, index >= defaults_from))
|
|
254
|
+
if args.vararg:
|
|
255
|
+
params.append(Param(args.vararg.arg, "var_positional", False))
|
|
256
|
+
for arg, default in zip(args.kwonlyargs, args.kw_defaults, strict=True):
|
|
257
|
+
params.append(Param(arg.arg, "keyword", default is not None))
|
|
258
|
+
if args.kwarg:
|
|
259
|
+
params.append(Param(args.kwarg.arg, "var_keyword", False))
|
|
260
|
+
return tuple(params[1:] if drop_first and params and params[0].kind != "keyword" else params)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _decorator_name(node: ast.expr) -> str:
|
|
264
|
+
target = node.func if isinstance(node, ast.Call) else node
|
|
265
|
+
if isinstance(target, ast.Attribute):
|
|
266
|
+
return target.attr
|
|
267
|
+
return target.id if isinstance(target, ast.Name) else ""
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_DEPRECATION_CATEGORIES = {"DeprecationWarning", "PendingDeprecationWarning", "FutureWarning"}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _deprecation(node, scope: list[ast.stmt]) -> tuple[bool, str]:
|
|
274
|
+
"""Whether a function is deprecated, and the sibling it names as replacement.
|
|
275
|
+
|
|
276
|
+
Deprecated means a ``@deprecated`` decorator, or a ``warnings.warn`` the body
|
|
277
|
+
always raises that is about deprecation: a deprecation category, or
|
|
278
|
+
"deprecat" in the message. A warning about something else -- an insecure
|
|
279
|
+
option -- is not, and nor is one raised only for a deprecated argument.
|
|
280
|
+
|
|
281
|
+
The replacement must be *named*: a sibling (a method of the same class, or
|
|
282
|
+
a function of the same module) mentioned in the warning, the decorator, or
|
|
283
|
+
the docstring. Calling a sibling is not naming it -- a deprecated function
|
|
284
|
+
usually calls helpers, often as its whole body.
|
|
285
|
+
"""
|
|
286
|
+
texts: list[str] = []
|
|
287
|
+
deprecated = False
|
|
288
|
+
for decorator in node.decorator_list:
|
|
289
|
+
if "deprecat" in _decorator_name(decorator).lower():
|
|
290
|
+
deprecated = True
|
|
291
|
+
texts.extend(_strings(decorator))
|
|
292
|
+
# Only a warning the body always raises deprecates the function. One inside
|
|
293
|
+
# an `if` -- `if skip_defaults is not None: warn(...)` -- deprecates an
|
|
294
|
+
# argument, and the function itself is as current as ever.
|
|
295
|
+
warn_calls = [
|
|
296
|
+
stmt.value
|
|
297
|
+
for stmt in node.body
|
|
298
|
+
if isinstance(stmt, ast.Expr)
|
|
299
|
+
and isinstance(stmt.value, ast.Call)
|
|
300
|
+
and _decorator_name(stmt.value) == "warn"
|
|
301
|
+
]
|
|
302
|
+
for call in warn_calls:
|
|
303
|
+
if _about_deprecation(call):
|
|
304
|
+
deprecated = True
|
|
305
|
+
texts.extend(_strings(call))
|
|
306
|
+
if not deprecated:
|
|
307
|
+
return False, ""
|
|
308
|
+
docstring = ast.get_docstring(node)
|
|
309
|
+
if docstring:
|
|
310
|
+
texts.append(docstring)
|
|
311
|
+
|
|
312
|
+
siblings = {
|
|
313
|
+
n.name
|
|
314
|
+
for n in scope
|
|
315
|
+
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name != node.name
|
|
316
|
+
}
|
|
317
|
+
named = {
|
|
318
|
+
name for name in siblings for text in texts if re.search(rf"\b{re.escape(name)}\b", text)
|
|
319
|
+
}
|
|
320
|
+
return True, named.pop() if len(named) == 1 else ""
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _strings(node: ast.AST) -> list[str]:
|
|
324
|
+
return [
|
|
325
|
+
child.value
|
|
326
|
+
for child in ast.walk(node)
|
|
327
|
+
if isinstance(child, ast.Constant) and isinstance(child.value, str)
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _about_deprecation(call: ast.Call) -> bool:
|
|
332
|
+
for arg in [*call.args, *(k.value for k in call.keywords)]:
|
|
333
|
+
if _decorator_name(arg) in _DEPRECATION_CATEGORIES:
|
|
334
|
+
return True
|
|
335
|
+
if any("deprecat" in text.lower() for text in _strings(arg)):
|
|
336
|
+
return True
|
|
337
|
+
return False
|
patchahead/ci.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""PatchAhead as a CI step: ``python -m patchahead.ci``.
|
|
2
|
+
|
|
3
|
+
Built for the pull requests Dependabot and Renovate open. Such a pull request
|
|
4
|
+
already says which packages moved between which versions, and usually quotes
|
|
5
|
+
their release notes. This module reads both:
|
|
6
|
+
|
|
7
|
+
1. **The release notes** in the pull request body (Dependabot writes them as
|
|
8
|
+
HTML; the Markdown parser reads that).
|
|
9
|
+
2. **The library itself.** For each package the pull request bumps, the two
|
|
10
|
+
versions are compared with :mod:`patchahead.apidiff`. That finds renames the
|
|
11
|
+
notes never mention -- and it checks the notes: a rename to a name the new
|
|
12
|
+
version does not have is a misreading, and is dropped with a note.
|
|
13
|
+
|
|
14
|
+
The changes are combined into one change document, migrated by the ordinary
|
|
15
|
+
engine in a temporary copy, and reported the way GitHub Actions expects: step
|
|
16
|
+
outputs in ``$GITHUB_OUTPUT``, a summary in ``$GITHUB_STEP_SUMMARY``, and the
|
|
17
|
+
combined diff and pull-request summary as files. It never writes to the
|
|
18
|
+
repository and never fails the step for a verdict -- ``action.yml`` decides
|
|
19
|
+
that from the outputs, according to the workflow's ``fail-on``.
|
|
20
|
+
|
|
21
|
+
Configuration comes from ``PATCHAHEAD_*`` environment variables, set by
|
|
22
|
+
``action.yml`` from the action's inputs.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import tempfile
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from patchahead import apidiff, engine, reporting
|
|
36
|
+
from patchahead.config import ConfigError
|
|
37
|
+
from patchahead.domain.change import BreakingChange, ChangeKind
|
|
38
|
+
from patchahead.ingest import IngestError, parse_file
|
|
39
|
+
from patchahead.ingest.base import ChangeDocument, parse_document
|
|
40
|
+
from patchahead.ingest.structured import change_to_mapping
|
|
41
|
+
from patchahead.workspace import RepositoryError, WorkspaceError
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("patchahead.ci")
|
|
44
|
+
|
|
45
|
+
#: Marks PatchAhead's pull-request comment, so a re-run updates it in place.
|
|
46
|
+
COMMENT_MARKER = "<!-- patchahead -->"
|
|
47
|
+
|
|
48
|
+
_VERSION = r"v?([0-9][\w.+!-]*?)\.?"
|
|
49
|
+
_UPGRADE_PATTERNS = (
|
|
50
|
+
# Dependabot: "Bumps [storekit](https://...) from 4.9.0 to 5.0.0."
|
|
51
|
+
re.compile(rf"Bumps \[([^\]]+)\]\([^)]*\) from {_VERSION} to {_VERSION}(?=\s|$)"),
|
|
52
|
+
# Dependabot, grouped: "Updates `storekit` from 4.9.0 to 5.0.0"
|
|
53
|
+
re.compile(rf"Updates `([^`]+)` from {_VERSION} to {_VERSION}(?=\s|$)"),
|
|
54
|
+
# Dependabot title: "Bump storekit from 4.9.0 to 5.0.0"
|
|
55
|
+
re.compile(rf"\b[Bb]ump ([\w.\-\[\]]+) from {_VERSION} to {_VERSION}(?=\s|$)"),
|
|
56
|
+
# Renovate's table, whatever columns sit between the package and the change:
|
|
57
|
+
# "| [storekit](https://...) | major | `4.9.0` -> `5.0.0` |"
|
|
58
|
+
re.compile(rf"\|\s*\[([\w.\-]+)\]\([^)]*\)\s*\|[^\n]*?`{_VERSION}`\s*(?:->|→)\s*`{_VERSION}`"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Upgrade:
|
|
64
|
+
package: str
|
|
65
|
+
old: str
|
|
66
|
+
new: str
|
|
67
|
+
|
|
68
|
+
def __str__(self) -> str:
|
|
69
|
+
return f"{self.package} {self.old} -> {self.new}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Plan:
|
|
74
|
+
"""What a CI run will migrate, and what it learned getting there."""
|
|
75
|
+
|
|
76
|
+
changes: list[BreakingChange] = field(default_factory=list)
|
|
77
|
+
upgrades: list[Upgrade] = field(default_factory=list)
|
|
78
|
+
sources: list[str] = field(default_factory=list)
|
|
79
|
+
notes: list[str] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def upgrades_in(*texts: str) -> list[Upgrade]:
|
|
83
|
+
"""The package upgrades a Dependabot or Renovate pull request describes."""
|
|
84
|
+
found: dict[str, Upgrade] = {}
|
|
85
|
+
for text in texts:
|
|
86
|
+
for pattern in _UPGRADE_PATTERNS:
|
|
87
|
+
for match in pattern.finditer(text or ""):
|
|
88
|
+
package, old, new = match.group(1), match.group(2), match.group(3)
|
|
89
|
+
found.setdefault(package.lower(), Upgrade(package, old, new))
|
|
90
|
+
return list(found.values())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def plan(
|
|
94
|
+
*,
|
|
95
|
+
change_path: str = "",
|
|
96
|
+
pull_request: dict | None = None,
|
|
97
|
+
compare_versions: bool = True,
|
|
98
|
+
scratch: Path,
|
|
99
|
+
) -> Plan:
|
|
100
|
+
"""Collect the changes to migrate from every source the run was given."""
|
|
101
|
+
result = Plan()
|
|
102
|
+
noted: list[BreakingChange] = []
|
|
103
|
+
|
|
104
|
+
if change_path:
|
|
105
|
+
noted.extend(parse_file(change_path))
|
|
106
|
+
result.sources.append(f"the change document `{change_path}`")
|
|
107
|
+
|
|
108
|
+
if pull_request:
|
|
109
|
+
body = pull_request.get("body") or ""
|
|
110
|
+
title = pull_request.get("title") or ""
|
|
111
|
+
result.upgrades = upgrades_in(title, body)
|
|
112
|
+
if body.strip():
|
|
113
|
+
document = ChangeDocument(text=body, path="pull request body", suffix=".md")
|
|
114
|
+
read = [c for c in parse_document(document) if c.kind is not ChangeKind.UNKNOWN]
|
|
115
|
+
if read:
|
|
116
|
+
noted.extend(read)
|
|
117
|
+
result.sources.append("the release notes in this pull request")
|
|
118
|
+
|
|
119
|
+
surfaces: list[apidiff.Surface] = []
|
|
120
|
+
compared: list[BreakingChange] = []
|
|
121
|
+
if compare_versions:
|
|
122
|
+
for upgrade in result.upgrades:
|
|
123
|
+
try:
|
|
124
|
+
old = apidiff.read(apidiff.fetch(upgrade.package, upgrade.old, scratch / "old"))
|
|
125
|
+
new = apidiff.read(apidiff.fetch(upgrade.package, upgrade.new, scratch / "new"))
|
|
126
|
+
except apidiff.ApiDiffError as exc:
|
|
127
|
+
result.notes.append(f"could not compare {upgrade}: {exc}")
|
|
128
|
+
continue
|
|
129
|
+
surfaces.append(new)
|
|
130
|
+
diff = apidiff.compare(old, new, upgrade.package, (upgrade.old, upgrade.new))
|
|
131
|
+
compared.extend(diff.changes)
|
|
132
|
+
result.sources.append(f"a comparison of {upgrade}")
|
|
133
|
+
|
|
134
|
+
# A release note that renames something to a name the new version does not
|
|
135
|
+
# have was misread -- or describes a different package. Either way it is
|
|
136
|
+
# not something to rewrite code toward.
|
|
137
|
+
for change in noted:
|
|
138
|
+
missing = _absent_from(change, surfaces) if surfaces else ""
|
|
139
|
+
if missing:
|
|
140
|
+
result.notes.append(
|
|
141
|
+
f"dropped the reading `{change.target.symbol}` -> `{missing}` from the "
|
|
142
|
+
f"release notes: no `{missing}` exists in the new version's public API"
|
|
143
|
+
)
|
|
144
|
+
continue
|
|
145
|
+
result.changes.append(change)
|
|
146
|
+
|
|
147
|
+
known = {_identity(c) for c in result.changes}
|
|
148
|
+
for change in compared:
|
|
149
|
+
if _identity(change) not in known:
|
|
150
|
+
known.add(_identity(change))
|
|
151
|
+
result.changes.append(change)
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _identity(change: BreakingChange) -> tuple[str, str, str, str]:
|
|
156
|
+
"""What makes two readings the same change. The owner is part of it: the same
|
|
157
|
+
keyword renamed on two functions is two changes."""
|
|
158
|
+
target = change.target
|
|
159
|
+
return change.kind.value, target.symbol, target.replacement, target.owner.lower()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _absent_from(change: BreakingChange, surfaces: list[apidiff.Surface]) -> str:
|
|
163
|
+
"""The new name of a rename that none of the new versions define, else ""."""
|
|
164
|
+
new = change.target.replacement
|
|
165
|
+
if change.kind is ChangeKind.METHOD_RENAME:
|
|
166
|
+
names = {member.name for s in surfaces for member in s.public.values()}
|
|
167
|
+
elif change.kind is ChangeKind.KWARG_RENAME:
|
|
168
|
+
names = {p.name for s in surfaces for member in s.public.values() for p in member.params}
|
|
169
|
+
else:
|
|
170
|
+
# A field is data the API returns, not a name in the library's code.
|
|
171
|
+
return ""
|
|
172
|
+
return "" if new in names else new
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def run(environ: dict[str, str] | None = None) -> dict[str, str]:
|
|
176
|
+
"""Do one CI run. Returns the step outputs it also writes to ``$GITHUB_OUTPUT``."""
|
|
177
|
+
env = dict(os.environ if environ is None else environ)
|
|
178
|
+
out_dir = Path(env.get("RUNNER_TEMP") or tempfile.gettempdir()) / "patchahead"
|
|
179
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
|
|
181
|
+
pull_request = None
|
|
182
|
+
if _flag(env, "PATCHAHEAD_FROM_PULL_REQUEST") and env.get("GITHUB_EVENT_PATH"):
|
|
183
|
+
event = json.loads(Path(env["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8"))
|
|
184
|
+
pull_request = event.get("pull_request")
|
|
185
|
+
if pull_request is None:
|
|
186
|
+
log.warning("from-pull-request is set, but this run was not triggered by one")
|
|
187
|
+
|
|
188
|
+
outputs = {"outcome": "", "succeeded": "false", "complete": "true", "exit-code": "0"}
|
|
189
|
+
try:
|
|
190
|
+
with tempfile.TemporaryDirectory(prefix="patchahead-ci-") as scratch:
|
|
191
|
+
work = plan(
|
|
192
|
+
change_path=env.get("PATCHAHEAD_CHANGE", ""),
|
|
193
|
+
pull_request=pull_request,
|
|
194
|
+
compare_versions=_flag(env, "PATCHAHEAD_COMPARE_VERSIONS", default=True),
|
|
195
|
+
scratch=Path(scratch),
|
|
196
|
+
)
|
|
197
|
+
if not work.changes:
|
|
198
|
+
outputs["outcome"] = "nothing_to_migrate"
|
|
199
|
+
summary = _summary_header(outputs["outcome"], work, None)
|
|
200
|
+
else:
|
|
201
|
+
changes_path = out_dir / "changes.json"
|
|
202
|
+
changes_path.write_text(
|
|
203
|
+
json.dumps({"changes": [change_to_mapping(c) for c in work.changes]}, indent=2),
|
|
204
|
+
encoding="utf-8",
|
|
205
|
+
)
|
|
206
|
+
run_result = engine.migrate(
|
|
207
|
+
env.get("PATCHAHEAD_REPO") or ".",
|
|
208
|
+
changes_path,
|
|
209
|
+
engine.EngineOptions(
|
|
210
|
+
write_artifacts=False, test_command=env.get("PATCHAHEAD_TEST_COMMAND", "")
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
from patchahead.cli import _migration_exit_code
|
|
214
|
+
|
|
215
|
+
code = _migration_exit_code(
|
|
216
|
+
run_result,
|
|
217
|
+
require_complete=_flag(env, "PATCHAHEAD_REQUIRE_COMPLETE"),
|
|
218
|
+
)
|
|
219
|
+
outcome = run_result.outcome
|
|
220
|
+
outputs.update(
|
|
221
|
+
{
|
|
222
|
+
"outcome": outcome.value if outcome else "nothing_to_migrate",
|
|
223
|
+
"succeeded": str(run_result.succeeded).lower(),
|
|
224
|
+
"complete": str(run_result.complete).lower(),
|
|
225
|
+
"exit-code": str(code),
|
|
226
|
+
"changes": str(changes_path),
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
if run_result.diff:
|
|
230
|
+
diff_path = out_dir / "patchahead.diff"
|
|
231
|
+
diff_path.write_text(run_result.diff, encoding="utf-8")
|
|
232
|
+
outputs["diff"] = str(diff_path)
|
|
233
|
+
summary = _summary_header(outputs["outcome"], work, run_result) + "\n\n".join(
|
|
234
|
+
reporting.render_pr_markdown(result) for result in run_result.results
|
|
235
|
+
)
|
|
236
|
+
except (IngestError, RepositoryError, ConfigError, WorkspaceError) as exc:
|
|
237
|
+
outputs.update({"outcome": "error", "exit-code": "2"})
|
|
238
|
+
summary = f"## PatchAhead could not run\n\n{exc}\n"
|
|
239
|
+
log.error("%s", exc)
|
|
240
|
+
|
|
241
|
+
summary_path = out_dir / "summary.md"
|
|
242
|
+
summary_path.write_text(f"{COMMENT_MARKER}\n{summary}", encoding="utf-8")
|
|
243
|
+
outputs["summary"] = str(summary_path)
|
|
244
|
+
_append(env.get("GITHUB_STEP_SUMMARY"), summary)
|
|
245
|
+
_append(env.get("GITHUB_OUTPUT"), "".join(f"{k}={v}\n" for k, v in outputs.items()))
|
|
246
|
+
return outputs
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
_HEADLINES = {
|
|
250
|
+
"migrated": "verified migration",
|
|
251
|
+
"patched_unverified": "patched, not verified",
|
|
252
|
+
"validation_failed": "rejected by the tests",
|
|
253
|
+
"not_plannable": "found affected code it would not rewrite",
|
|
254
|
+
"patch_failed": "could not apply the patch",
|
|
255
|
+
"unsupported_change": "nothing it can migrate",
|
|
256
|
+
"no_impact": "no affected code",
|
|
257
|
+
"nothing_to_migrate": "nothing to migrate",
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _summary_header(outcome: str, work: Plan, run_result) -> str:
|
|
262
|
+
lines = [f"## PatchAhead: {_HEADLINES.get(outcome, outcome)}", ""]
|
|
263
|
+
if run_result is not None and run_result.diff:
|
|
264
|
+
lines.append(
|
|
265
|
+
"PatchAhead proposed a patch in a temporary copy of this branch; nothing was "
|
|
266
|
+
"committed. The diff and the evidence are below."
|
|
267
|
+
)
|
|
268
|
+
if work.sources:
|
|
269
|
+
lines.append(f"Read from {', '.join(work.sources)}.")
|
|
270
|
+
elif not work.changes:
|
|
271
|
+
lines.append(
|
|
272
|
+
"No change document was given, and this pull request names no breaking change "
|
|
273
|
+
"PatchAhead could read."
|
|
274
|
+
)
|
|
275
|
+
if run_result is not None and not run_result.complete:
|
|
276
|
+
lines.append("Code or tests still use an old name; see each change's last section.")
|
|
277
|
+
if work.notes:
|
|
278
|
+
lines += ["", "**Notes**", ""] + [f"- {note}" for note in work.notes]
|
|
279
|
+
return "\n".join(lines) + "\n\n"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _flag(env: dict[str, str], name: str, default: bool = False) -> bool:
|
|
283
|
+
value = env.get(name, "").strip().lower()
|
|
284
|
+
return default if not value else value in ("1", "true", "yes", "on")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _append(path: str | None, text: str) -> None:
|
|
288
|
+
if path:
|
|
289
|
+
with open(path, "a", encoding="utf-8") as handle:
|
|
290
|
+
handle.write(text)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def main() -> int:
|
|
294
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
295
|
+
outputs = run()
|
|
296
|
+
log.info("outcome: %s (exit code %s)", outputs["outcome"], outputs["exit-code"])
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__": # pragma: no cover
|
|
301
|
+
raise SystemExit(main())
|