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,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())