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
patchahead/web/server.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Optional web UI for PatchAhead.
|
|
3
|
+
|
|
4
|
+
pip install 'patchahead[demo]' # the bundled walkthrough; needs pytest
|
|
5
|
+
patchahead demo
|
|
6
|
+
|
|
7
|
+
pip install 'patchahead[web]' # this module alone, on your repository
|
|
8
|
+
patchahead web --repo ./my-service
|
|
9
|
+
|
|
10
|
+
The CLI is the product. This exists because a diff, an impact list, and five
|
|
11
|
+
gate results are easier to read side by side than stacked in a terminal.
|
|
12
|
+
|
|
13
|
+
It is a *view*, with no logic of its own: every endpoint calls
|
|
14
|
+
:mod:`patchahead.engine` exactly as ``patchahead analyze`` and
|
|
15
|
+
``patchahead migrate`` do, and renders the same result objects. There is no
|
|
16
|
+
demo-only code path here -- that was the prototype's arrangement, and it meant
|
|
17
|
+
the dashboard could only ever work on the bundled fixtures. ``patchahead demo``
|
|
18
|
+
is this same app, pointed at the bundled repository with a list of scenarios
|
|
19
|
+
attached; the scenarios choose *which* change document to run and say what to
|
|
20
|
+
look at, and nothing else.
|
|
21
|
+
|
|
22
|
+
Binds to 127.0.0.1. It runs a repository's test command, so it must not be
|
|
23
|
+
exposed to a network -- see ``docs/safety.md``.
|
|
24
|
+
|
|
25
|
+
Binding to loopback keeps other machines out, but not other *web pages*: any
|
|
26
|
+
site open in the same browser can send a request to ``127.0.0.1``. So every
|
|
27
|
+
request must name a loopback host (which defeats DNS rebinding, where an
|
|
28
|
+
attacker's domain is re-pointed at 127.0.0.1), a cross-origin ``Origin`` is
|
|
29
|
+
refused, and anything that is not a read needs the per-process token embedded in
|
|
30
|
+
the page this server serves -- which another origin cannot read.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import logging
|
|
37
|
+
import secrets
|
|
38
|
+
from collections.abc import Sequence
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from urllib.parse import urlsplit
|
|
41
|
+
|
|
42
|
+
from patchahead import __version__, engine, handlers, observability, reporting
|
|
43
|
+
from patchahead.config import ConfigError
|
|
44
|
+
from patchahead.config import load as load_config
|
|
45
|
+
from patchahead.demo import Scenario
|
|
46
|
+
from patchahead.ingest import IngestError
|
|
47
|
+
from patchahead.workspace import RepositoryError
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger("patchahead.web")
|
|
50
|
+
|
|
51
|
+
#: Allowed change-document extensions, mirroring what the ingest layer reads.
|
|
52
|
+
DOCUMENT_SUFFIXES = {".md", ".txt", ".rst", ".json", ".yaml", ".yml"}
|
|
53
|
+
|
|
54
|
+
#: Host names a request may address. Anything else is a page on another origin
|
|
55
|
+
#: that resolved its own domain to 127.0.0.1.
|
|
56
|
+
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
57
|
+
|
|
58
|
+
#: The header a state-changing request must carry, and the placeholder in the
|
|
59
|
+
#: served page that is replaced with its value.
|
|
60
|
+
TOKEN_HEADER = "X-PatchAhead-Token"
|
|
61
|
+
TOKEN_PLACEHOLDER = "__PATCHAHEAD_TOKEN__"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _hostname(netloc: str) -> str:
|
|
65
|
+
"""``127.0.0.1:8000`` -> ``127.0.0.1``; ``[::1]:8000`` -> ``::1``."""
|
|
66
|
+
return (urlsplit(f"//{netloc}").hostname or "") if netloc else ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def static_root() -> Path:
|
|
70
|
+
"""Directory holding the UI's static assets.
|
|
71
|
+
|
|
72
|
+
Package data. Resolved from the module rather than the working directory so
|
|
73
|
+
that an installed wheel serves the same page a checkout does.
|
|
74
|
+
"""
|
|
75
|
+
return Path(__file__).resolve().parent / "static"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def index_path() -> Path:
|
|
79
|
+
path = static_root() / "index.html"
|
|
80
|
+
if not path.is_file(): # pragma: no cover - a broken install
|
|
81
|
+
raise RuntimeError(
|
|
82
|
+
f"the web UI's page is missing from the installed package (expected {path})"
|
|
83
|
+
)
|
|
84
|
+
return path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def create_app(repo: Path, changes_dir: Path, scenarios: Sequence[Scenario] = ()):
|
|
88
|
+
"""Build the FastAPI app for one repository and one directory of changes.
|
|
89
|
+
|
|
90
|
+
``scenarios`` is presentation metadata for the bundled demo: which documents
|
|
91
|
+
to offer first and a sentence about each. It never affects how a migration
|
|
92
|
+
runs -- a scenario only chooses a change document and whether tests execute,
|
|
93
|
+
both of which are ordinary engine inputs.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
97
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
98
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
99
|
+
raise SystemExit(
|
|
100
|
+
"the web UI needs FastAPI: install the `web` extra (pip install 'patchahead[web]')"
|
|
101
|
+
) from None
|
|
102
|
+
|
|
103
|
+
app = FastAPI(title="PatchAhead", version=__version__, docs_url=None, redoc_url=None)
|
|
104
|
+
by_id = {scenario.id: scenario for scenario in scenarios}
|
|
105
|
+
token = secrets.token_urlsafe(32)
|
|
106
|
+
app.state.token = token
|
|
107
|
+
|
|
108
|
+
@app.middleware("http")
|
|
109
|
+
async def same_machine_same_page(request: Request, call_next):
|
|
110
|
+
"""Refuse requests another web page could have made. See the module docstring."""
|
|
111
|
+
if _hostname(request.headers.get("host", "")) not in LOOPBACK_HOSTS:
|
|
112
|
+
return JSONResponse({"detail": "this server only answers to localhost"}, 403)
|
|
113
|
+
origin = request.headers.get("origin")
|
|
114
|
+
if origin is not None and _hostname(urlsplit(origin).netloc) not in LOOPBACK_HOSTS:
|
|
115
|
+
return JSONResponse({"detail": "cross-origin requests are refused"}, 403)
|
|
116
|
+
if request.method not in ("GET", "HEAD") and not secrets.compare_digest(
|
|
117
|
+
request.headers.get(TOKEN_HEADER, ""), token
|
|
118
|
+
):
|
|
119
|
+
return JSONResponse(
|
|
120
|
+
{"detail": f"missing or wrong {TOKEN_HEADER}; reload the page"}, 403
|
|
121
|
+
)
|
|
122
|
+
return await call_next(request)
|
|
123
|
+
|
|
124
|
+
def _resolve_change(name: str) -> Path:
|
|
125
|
+
"""Resolve a change-document name inside the configured directory.
|
|
126
|
+
|
|
127
|
+
Path traversal is refused: the UI exposes one directory, and a crafted
|
|
128
|
+
name must not be able to read outside it.
|
|
129
|
+
"""
|
|
130
|
+
candidate = (changes_dir / name).resolve()
|
|
131
|
+
if not candidate.is_file() or changes_dir.resolve() not in candidate.parents:
|
|
132
|
+
raise HTTPException(status_code=404, detail=f"no such change document: {name}")
|
|
133
|
+
return candidate
|
|
134
|
+
|
|
135
|
+
@app.get("/", response_class=HTMLResponse)
|
|
136
|
+
def index() -> HTMLResponse:
|
|
137
|
+
page = index_path().read_text(encoding="utf-8")
|
|
138
|
+
return HTMLResponse(page.replace(TOKEN_PLACEHOLDER, token))
|
|
139
|
+
|
|
140
|
+
@app.get("/api/context")
|
|
141
|
+
def context() -> JSONResponse:
|
|
142
|
+
"""What this instance is pointed at, and what it can do."""
|
|
143
|
+
try:
|
|
144
|
+
config = load_config(repo)
|
|
145
|
+
except ConfigError as exc:
|
|
146
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
147
|
+
return JSONResponse(
|
|
148
|
+
{
|
|
149
|
+
"version": __version__,
|
|
150
|
+
"repo": str(repo),
|
|
151
|
+
"repo_name": repo.name,
|
|
152
|
+
"changes_dir": str(changes_dir),
|
|
153
|
+
"demo": bool(scenarios),
|
|
154
|
+
"scenarios": [scenario.to_dict() for scenario in scenarios],
|
|
155
|
+
"documents": sorted(
|
|
156
|
+
path.name
|
|
157
|
+
for path in changes_dir.iterdir()
|
|
158
|
+
if path.is_file() and path.suffix.lower() in DOCUMENT_SUFFIXES
|
|
159
|
+
),
|
|
160
|
+
"config": config.to_dict(),
|
|
161
|
+
"handlers": [
|
|
162
|
+
{
|
|
163
|
+
"name": handler.name,
|
|
164
|
+
"summary": handler.summary,
|
|
165
|
+
"kinds": [kind.value for kind in handler.kinds],
|
|
166
|
+
"limitations": list(handler.limitations),
|
|
167
|
+
}
|
|
168
|
+
for handler in handlers.registered()
|
|
169
|
+
],
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
@app.get("/api/document")
|
|
174
|
+
def document_source(document: str) -> JSONResponse:
|
|
175
|
+
"""The raw text of a change document.
|
|
176
|
+
|
|
177
|
+
The UI shows the upstream change as the vendor wrote it, next to what
|
|
178
|
+
PatchAhead made of it. Reading the release note is the first step of the
|
|
179
|
+
story and the one a viewer can check for themselves.
|
|
180
|
+
"""
|
|
181
|
+
path = _resolve_change(document)
|
|
182
|
+
return JSONResponse({"name": path.name, "text": path.read_text(encoding="utf-8")})
|
|
183
|
+
|
|
184
|
+
@app.post("/api/analyze")
|
|
185
|
+
def analyze(document: str) -> JSONResponse:
|
|
186
|
+
try:
|
|
187
|
+
result = engine.analyze(repo, _resolve_change(document))
|
|
188
|
+
except (RepositoryError, IngestError, ConfigError) as exc:
|
|
189
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
190
|
+
return JSONResponse(result.to_dict())
|
|
191
|
+
|
|
192
|
+
@app.post("/api/migrate")
|
|
193
|
+
def migrate(
|
|
194
|
+
document: str,
|
|
195
|
+
dry_run: bool = False,
|
|
196
|
+
use_llm: bool = False,
|
|
197
|
+
run_tests: bool = True,
|
|
198
|
+
scenario: str = "",
|
|
199
|
+
) -> JSONResponse:
|
|
200
|
+
# A scenario supplies exactly two ordinary engine inputs: the document
|
|
201
|
+
# and whether tests run. It cannot reach anything else.
|
|
202
|
+
if scenario:
|
|
203
|
+
if scenario not in by_id:
|
|
204
|
+
raise HTTPException(status_code=404, detail=f"no such scenario: {scenario}")
|
|
205
|
+
chosen = by_id[scenario]
|
|
206
|
+
document, run_tests = chosen.document, chosen.run_tests
|
|
207
|
+
|
|
208
|
+
options = engine.EngineOptions(
|
|
209
|
+
dry_run=dry_run, use_llm=use_llm, run_tests=run_tests, write_artifacts=False
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
run = engine.migrate(repo, _resolve_change(document), options)
|
|
213
|
+
except (RepositoryError, IngestError, ConfigError) as exc:
|
|
214
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
215
|
+
|
|
216
|
+
payload = run.to_dict()
|
|
217
|
+
payload["scenario"] = scenario
|
|
218
|
+
payload["run_tests"] = run_tests
|
|
219
|
+
# The reviewable artifact, rendered by the same code the CLI's
|
|
220
|
+
# `--pr-summary` uses.
|
|
221
|
+
payload["pr_summaries"] = [reporting.render_pr_markdown(result) for result in run.results]
|
|
222
|
+
return JSONResponse(payload)
|
|
223
|
+
|
|
224
|
+
return app
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def main(argv: list[str] | None = None) -> int:
|
|
228
|
+
"""Serve the UI against an arbitrary repository. ``patchahead web``."""
|
|
229
|
+
from patchahead import demo as demo_module
|
|
230
|
+
|
|
231
|
+
parser = argparse.ArgumentParser(
|
|
232
|
+
prog="patchahead web",
|
|
233
|
+
description="Optional web UI for PatchAhead. Binds to localhost only.",
|
|
234
|
+
)
|
|
235
|
+
parser.add_argument(
|
|
236
|
+
"--repo",
|
|
237
|
+
default=None,
|
|
238
|
+
help="repository to analyze (default: the bundled example)",
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument(
|
|
241
|
+
"--changes",
|
|
242
|
+
default=None,
|
|
243
|
+
help="directory of change documents to offer (default: the bundled ones)",
|
|
244
|
+
)
|
|
245
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
246
|
+
args = parser.parse_args(argv)
|
|
247
|
+
|
|
248
|
+
observability.configure_logging()
|
|
249
|
+
|
|
250
|
+
repo = Path(args.repo).expanduser().resolve() if args.repo else demo_module.repo_root()
|
|
251
|
+
changes = (
|
|
252
|
+
Path(args.changes).expanduser().resolve() if args.changes else demo_module.changes_root()
|
|
253
|
+
)
|
|
254
|
+
if not repo.is_dir():
|
|
255
|
+
log.error("not a directory: %s", repo)
|
|
256
|
+
return 2
|
|
257
|
+
if not changes.is_dir():
|
|
258
|
+
log.error("not a directory: %s", changes)
|
|
259
|
+
return 2
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
import uvicorn
|
|
263
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
264
|
+
log.error(
|
|
265
|
+
"the web UI needs uvicorn: install the `web` extra (pip install 'patchahead[web]')"
|
|
266
|
+
)
|
|
267
|
+
return 2
|
|
268
|
+
|
|
269
|
+
app = create_app(repo, changes)
|
|
270
|
+
print(f"PatchAhead {__version__} -> http://127.0.0.1:{args.port}")
|
|
271
|
+
print(f" repository {repo}")
|
|
272
|
+
print(f" change documents {changes}")
|
|
273
|
+
print(" note: migrating runs this repository's test command. localhost only.")
|
|
274
|
+
uvicorn.run(app, host="127.0.0.1", port=args.port, log_level="warning")
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__": # pragma: no cover
|
|
279
|
+
raise SystemExit(main())
|