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,51 @@
|
|
|
1
|
+
# orders-service (PatchAhead example repository)
|
|
2
|
+
|
|
3
|
+
A small, self-contained Python service that integrates with a third-party
|
|
4
|
+
**Orders API**. It exists so you can run PatchAhead against a *separate*
|
|
5
|
+
repository without writing one first.
|
|
6
|
+
|
|
7
|
+
This repo is **intentionally broken**. The vendored upstream client in
|
|
8
|
+
`upstream/` is the new (v2) version of the Orders API; the integration code in
|
|
9
|
+
`app/` is still written against the old (v1) contract. So:
|
|
10
|
+
|
|
11
|
+
```console
|
|
12
|
+
$ cd "$(patchahead demo --print-paths | awk '/^repository/ {print $2}')"
|
|
13
|
+
$ python -m pytest
|
|
14
|
+
...
|
|
15
|
+
FAILED tests/test_order_sync.py::test_sync_all_orders - KeyError: 'total_pages'
|
|
16
|
+
FAILED tests/test_order_report.py::test_total_revenue - KeyError: 'total'
|
|
17
|
+
FAILED tests/test_client.py::test_fetch_recent - TypeError: ... 'timeout_seconds'
|
|
18
|
+
3 failed
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That is the situation PatchAhead is for. Each failure corresponds to one
|
|
22
|
+
breaking change described in `../changes/`:
|
|
23
|
+
|
|
24
|
+
| Change document | Breaking change | Breaks |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `../changes/pagination-cursor.md` | page-based -> cursor-based pagination | `app/order_sync.py` |
|
|
27
|
+
| `../changes/field-rename.md` | order field `total` -> `amount` | `app/order_report.py` |
|
|
28
|
+
| `../changes/kwarg-rename.md` | `timeout_seconds=` -> `timeout=` | `app/client.py` |
|
|
29
|
+
| `../changes/method-rename.md` | `fetch_orders()` -> `list_orders()` | `app/client.py` |
|
|
30
|
+
| `../changes/pagination-cursor.json` | the same pagination change, as structured JSON | `app/order_sync.py` |
|
|
31
|
+
| `../changes/sdk-v2.md` | both SDK changes in one release note | `app/client.py` |
|
|
32
|
+
| `../changes/invoice-field-rename.md` | a rename on `invoice` objects — **nothing here** | nothing: it is refused |
|
|
33
|
+
|
|
34
|
+
The easiest way to see all of this is `patchahead demo`, which serves these
|
|
35
|
+
files through the UI with a short explanation of each scenario. To drive them
|
|
36
|
+
from the command line instead, `patchahead demo --print-paths` prints where this
|
|
37
|
+
directory and `../changes` landed in your installation:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
repo=$(patchahead demo --print-paths | awk '/^repository/ {print $2}')
|
|
41
|
+
changes=$(patchahead demo --print-paths | awk '/^changes/ {print $2}')
|
|
42
|
+
|
|
43
|
+
patchahead analyze --repo "$repo" --change "$changes/pagination-cursor.md"
|
|
44
|
+
patchahead migrate --repo "$repo" --change "$changes/pagination-cursor.md"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`migrate` copies this repo into a temporary workspace, patches the copy, runs the
|
|
48
|
+
tests there, and prints a diff. This directory is left untouched.
|
|
49
|
+
|
|
50
|
+
`upstream/api_v1.py` is kept only as documentation of the contract the `app/`
|
|
51
|
+
code was originally written against. Nothing imports it.
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Thin wrapper over the upstream Orders API client.
|
|
2
|
+
|
|
3
|
+
Written against the v1 SDK surface: ``fetch_orders(..., timeout_seconds=...)``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
DEFAULT_TIMEOUT_SECONDS = 15.0
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fetch_recent(api_client, limit=3):
|
|
10
|
+
return api_client.fetch_orders(limit=limit, timeout_seconds=DEFAULT_TIMEOUT_SECONDS)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fetch_one(api_client):
|
|
14
|
+
orders = api_client.fetch_orders(limit=1, timeout_seconds=5.0)
|
|
15
|
+
return orders[0] if orders else None
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Revenue reporting over synced orders.
|
|
2
|
+
|
|
3
|
+
Reads each order's `total` field. Written against the v1 Orders schema.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
# Unrelated identifiers that merely contain the word "total". A correct
|
|
7
|
+
# migration must not touch any of these.
|
|
8
|
+
TOTAL_LABEL = "total"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def total_revenue(orders):
|
|
12
|
+
return round(sum(order["total"] for order in orders), 2)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def revenue_by_customer(orders):
|
|
16
|
+
totals = {}
|
|
17
|
+
for order in orders:
|
|
18
|
+
name = order["customer"]
|
|
19
|
+
totals[name] = round(totals.get(name, 0) + order["total"], 2)
|
|
20
|
+
return totals
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_summary(orders):
|
|
24
|
+
return f"{TOTAL_LABEL}: {total_revenue(orders)}"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Downstream order-sync integration.
|
|
2
|
+
|
|
3
|
+
Syncs ALL orders from the upstream Orders API. Written against the v1
|
|
4
|
+
(page-based) pagination contract.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def sync_all_orders(api_client):
|
|
9
|
+
page = 1
|
|
10
|
+
all_orders = []
|
|
11
|
+
|
|
12
|
+
while True:
|
|
13
|
+
response = api_client.get_orders(page=page)
|
|
14
|
+
all_orders.extend(response["orders"])
|
|
15
|
+
|
|
16
|
+
if page >= response["total_pages"]:
|
|
17
|
+
break
|
|
18
|
+
|
|
19
|
+
page += 1
|
|
20
|
+
|
|
21
|
+
return all_orders
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "orders-service"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Example downstream service for PatchAhead."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
|
|
7
|
+
[tool.patchahead]
|
|
8
|
+
# PatchAhead reads this section. Everything here is optional; these values
|
|
9
|
+
# happen to match what PatchAhead would infer anyway, and are written out so
|
|
10
|
+
# the example doubles as documentation of the config format.
|
|
11
|
+
source_dirs = ["app"]
|
|
12
|
+
test_command = "python -m pytest"
|
|
13
|
+
max_changed_files = 3
|
|
14
|
+
|
|
15
|
+
[tool.pytest.ini_options]
|
|
16
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Integration test for the thin upstream client wrapper."""
|
|
2
|
+
|
|
3
|
+
from app import client
|
|
4
|
+
from upstream.api_v2 import OrdersAPIClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_fetch_recent():
|
|
8
|
+
orders = client.fetch_recent(OrdersAPIClient(), limit=2)
|
|
9
|
+
|
|
10
|
+
assert [o["id"] for o in orders] == [1, 2]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_fetch_one():
|
|
14
|
+
assert client.fetch_one(OrdersAPIClient())["id"] == 1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Integration test for revenue reporting against the live upstream."""
|
|
2
|
+
|
|
3
|
+
from app import order_report
|
|
4
|
+
from upstream.api_v2 import OrdersAPIClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _orders():
|
|
8
|
+
return OrdersAPIClient().get_orders(cursor=None)["orders"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_total_revenue():
|
|
12
|
+
assert order_report.total_revenue(_orders()) == 55.5
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_revenue_by_customer():
|
|
16
|
+
by_customer = order_report.revenue_by_customer(_orders())
|
|
17
|
+
|
|
18
|
+
assert by_customer == {"Ada": 42.0, "Linus": 13.5}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_summary_label_is_not_a_data_field():
|
|
22
|
+
# `TOTAL_LABEL` is display text, not an API field. A migration that renames
|
|
23
|
+
# the `total` field must leave it alone.
|
|
24
|
+
assert order_report.TOTAL_LABEL == "total"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Integration test for the downstream order-sync against the live upstream."""
|
|
2
|
+
|
|
3
|
+
from app import order_sync
|
|
4
|
+
from upstream.api_v2 import OrdersAPIClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_sync_all_orders():
|
|
8
|
+
orders = order_sync.sync_all_orders(OrdersAPIClient())
|
|
9
|
+
|
|
10
|
+
assert len(orders) == 6, f"expected 6 orders, got {len(orders)}"
|
|
11
|
+
assert sorted(o["id"] for o in orders) == [1, 2, 3, 4, 5, 6]
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Vendored Orders API client -- v1.
|
|
2
|
+
|
|
3
|
+
Kept purely as documentation of the contract `app/` was originally written
|
|
4
|
+
against. Nothing imports this module; the service depends on ``api_v2``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
_ORDERS = [
|
|
8
|
+
{"id": 1, "customer": "Ada", "total": 42.00},
|
|
9
|
+
{"id": 2, "customer": "Linus", "total": 13.50},
|
|
10
|
+
{"id": 3, "customer": "Grace", "total": 88.10},
|
|
11
|
+
{"id": 4, "customer": "Alan", "total": 27.75},
|
|
12
|
+
{"id": 5, "customer": "Edsger", "total": 64.20},
|
|
13
|
+
{"id": 6, "customer": "Margaret", "total": 9.99},
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
_PAGE_SIZE = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OrdersAPIClient:
|
|
20
|
+
"""Page-based Orders API client (v1)."""
|
|
21
|
+
|
|
22
|
+
version = "v1"
|
|
23
|
+
|
|
24
|
+
def get_orders(self, page: int = 1, **_ignored):
|
|
25
|
+
total_pages = (len(_ORDERS) + _PAGE_SIZE - 1) // _PAGE_SIZE
|
|
26
|
+
start = (page - 1) * _PAGE_SIZE
|
|
27
|
+
return {
|
|
28
|
+
"orders": _ORDERS[start : start + _PAGE_SIZE],
|
|
29
|
+
"page": page,
|
|
30
|
+
"total_pages": total_pages,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def fetch_orders(self, limit=3, timeout_seconds=30.0):
|
|
34
|
+
return _ORDERS[:limit]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Vendored Orders API client -- v2 (the version this repo now depends on).
|
|
2
|
+
|
|
3
|
+
BREAKING CHANGES vs v1, all of which the `app/` code has not been migrated to:
|
|
4
|
+
|
|
5
|
+
1. Pagination moved from integer ``page`` + ``total_pages`` to opaque
|
|
6
|
+
``cursor`` + ``next_cursor`` + ``has_more``. The ``page`` and
|
|
7
|
+
``total_pages`` response fields are GONE.
|
|
8
|
+
|
|
9
|
+
``get_orders`` still ACCEPTS a ``page`` kwarg, so old callers do not get a
|
|
10
|
+
loud ``TypeError`` -- they get something quieter and worse: a response with
|
|
11
|
+
no ``total_pages``, which raises ``KeyError`` and (in sloppier code) can
|
|
12
|
+
silently sync incomplete data.
|
|
13
|
+
|
|
14
|
+
2. The order money field ``total`` was renamed to ``amount``.
|
|
15
|
+
|
|
16
|
+
3. ``fetch_orders()`` was renamed to ``list_orders()``.
|
|
17
|
+
|
|
18
|
+
4. The ``timeout_seconds=`` keyword argument was renamed to ``timeout=``.
|
|
19
|
+
|
|
20
|
+
Fully in-memory and deterministic -- no network.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_ORDERS = [
|
|
24
|
+
{"id": 1, "customer": "Ada", "amount": 42.00},
|
|
25
|
+
{"id": 2, "customer": "Linus", "amount": 13.50},
|
|
26
|
+
{"id": 3, "customer": "Grace", "amount": 88.10},
|
|
27
|
+
{"id": 4, "customer": "Alan", "amount": 27.75},
|
|
28
|
+
{"id": 5, "customer": "Edsger", "amount": 64.20},
|
|
29
|
+
{"id": 6, "customer": "Margaret", "amount": 9.99},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
_PAGE_SIZE = 2
|
|
33
|
+
# Opaque cursor -> start index into the dataset.
|
|
34
|
+
_CURSOR_START = {None: 0, "c1": 2, "c2": 4}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OrdersAPIClient:
|
|
38
|
+
"""Cursor-based Orders API client (v2)."""
|
|
39
|
+
|
|
40
|
+
version = "v2"
|
|
41
|
+
|
|
42
|
+
def get_orders(self, cursor=None, page=None, **_ignored):
|
|
43
|
+
start = _CURSOR_START.get(cursor, 0)
|
|
44
|
+
end = start + _PAGE_SIZE
|
|
45
|
+
chunk = _ORDERS[start:end]
|
|
46
|
+
has_more = end < len(_ORDERS)
|
|
47
|
+
next_cursor = None
|
|
48
|
+
if has_more:
|
|
49
|
+
next_cursor = "c1" if end == 2 else "c2"
|
|
50
|
+
return {"orders": chunk, "next_cursor": next_cursor, "has_more": has_more}
|
|
51
|
+
|
|
52
|
+
def list_orders(self, limit=3, timeout=30.0):
|
|
53
|
+
"""v2 name for what v1 called ``fetch_orders(..., timeout_seconds=...)``."""
|
|
54
|
+
if not isinstance(timeout, (int, float)):
|
|
55
|
+
raise TypeError(f"timeout must be a number, got {timeout!r}")
|
|
56
|
+
return _ORDERS[:limit]
|
patchahead/demo/serve.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Starting the bundled demo: pick a port, print the URL, serve the UI.
|
|
2
|
+
|
|
3
|
+
Everything here is about removing steps between ``pip install`` and seeing the
|
|
4
|
+
product work. It contains no migration logic and no second execution path -- it
|
|
5
|
+
builds the same FastAPI app :mod:`patchahead.web.server` builds for any
|
|
6
|
+
repository, hands it the bundled fixtures, and runs it on the loopback
|
|
7
|
+
interface.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import socket
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import webbrowser
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from patchahead import __version__
|
|
22
|
+
from patchahead.demo import DemoError, changes_root, repo_root, scenarios
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("patchahead.demo")
|
|
25
|
+
|
|
26
|
+
DEFAULT_PORT = 8000
|
|
27
|
+
#: How far to look for a free port before giving up. Twenty is enough to get
|
|
28
|
+
#: past a handful of other dev servers and small enough to fail quickly.
|
|
29
|
+
PORT_SEARCH_RANGE = 20
|
|
30
|
+
HOST = "127.0.0.1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def port_is_free(port: int, host: str = HOST) -> bool:
|
|
34
|
+
"""Whether a server could bind this port right now.
|
|
35
|
+
|
|
36
|
+
Asks the only question that matters -- "can I bind?" -- rather than probing
|
|
37
|
+
for a listener, because a port can be unbindable for reasons other than
|
|
38
|
+
something serving on it.
|
|
39
|
+
"""
|
|
40
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
41
|
+
try:
|
|
42
|
+
probe.bind((host, port))
|
|
43
|
+
except OSError:
|
|
44
|
+
return False
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def choose_port(preferred: int = DEFAULT_PORT, *, explicit: bool = False) -> int:
|
|
49
|
+
"""Return a bindable port, or explain why there is not one.
|
|
50
|
+
|
|
51
|
+
When the user named a port, an occupied one is an error: silently serving
|
|
52
|
+
somewhere else would send them to a URL they did not ask for, and in the
|
|
53
|
+
worst case to a *different* server that is already there. When the port is
|
|
54
|
+
just our default, moving on is the friendlier behaviour and is announced.
|
|
55
|
+
"""
|
|
56
|
+
if port_is_free(preferred):
|
|
57
|
+
return preferred
|
|
58
|
+
if explicit:
|
|
59
|
+
raise DemoError(
|
|
60
|
+
f"port {preferred} is already in use. Pass a different --port, or stop "
|
|
61
|
+
f"whatever is listening on {HOST}:{preferred}."
|
|
62
|
+
)
|
|
63
|
+
for candidate in range(preferred + 1, preferred + 1 + PORT_SEARCH_RANGE):
|
|
64
|
+
if port_is_free(candidate):
|
|
65
|
+
log.info("port %d was busy; using %d instead", preferred, candidate)
|
|
66
|
+
return candidate
|
|
67
|
+
raise DemoError(
|
|
68
|
+
f"ports {preferred}-{preferred + PORT_SEARCH_RANGE} are all in use. "
|
|
69
|
+
f"Free one, or pass --port."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def browser_is_practical() -> bool:
|
|
74
|
+
"""Whether opening a browser would do something useful.
|
|
75
|
+
|
|
76
|
+
On a headless machine -- a container, a CI runner, an SSH session --
|
|
77
|
+
``webbrowser.open`` can launch a terminal browser over the top of the
|
|
78
|
+
output, which is worse than doing nothing. The URL is always printed, so
|
|
79
|
+
declining here costs the user one click.
|
|
80
|
+
"""
|
|
81
|
+
if os.environ.get("PATCHAHEAD_NO_BROWSER"):
|
|
82
|
+
return False
|
|
83
|
+
if sys.platform in ("darwin", "win32"):
|
|
84
|
+
return True
|
|
85
|
+
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
|
86
|
+
return False
|
|
87
|
+
try:
|
|
88
|
+
webbrowser.get()
|
|
89
|
+
except webbrowser.Error:
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _open_later(url: str, delay: float = 1.0) -> None:
|
|
95
|
+
"""Open the browser once the server has had a moment to start listening."""
|
|
96
|
+
|
|
97
|
+
def _open() -> None:
|
|
98
|
+
try:
|
|
99
|
+
webbrowser.open(url)
|
|
100
|
+
except Exception as exc: # pragma: no cover - platform dependent
|
|
101
|
+
log.debug("could not open a browser: %s", exc)
|
|
102
|
+
|
|
103
|
+
timer = threading.Timer(delay, _open)
|
|
104
|
+
timer.daemon = True
|
|
105
|
+
timer.start()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_runner_available() -> bool:
|
|
109
|
+
"""Whether the bundled repository's test command can actually run.
|
|
110
|
+
|
|
111
|
+
The demo's whole point is the difference between "patched" and "verified",
|
|
112
|
+
and verification needs a test runner. Without pytest every scenario reports,
|
|
113
|
+
honestly and uselessly, that the test command could not start -- so this is
|
|
114
|
+
checked up front and the fix is printed, rather than leaving the viewer to
|
|
115
|
+
conclude the tool is broken.
|
|
116
|
+
"""
|
|
117
|
+
return importlib.util.find_spec("pytest") is not None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def banner(url: str, repo: Path, test_command: str, *, can_run_tests: bool = True) -> str:
|
|
121
|
+
"""The text printed before the server takes over the terminal."""
|
|
122
|
+
lines = [
|
|
123
|
+
"",
|
|
124
|
+
f" PatchAhead {__version__} — demo",
|
|
125
|
+
"",
|
|
126
|
+
f" Open {url}",
|
|
127
|
+
"",
|
|
128
|
+
f" Repository {repo}",
|
|
129
|
+
f" Scenarios {len(scenarios())} bundled, including one PatchAhead refuses",
|
|
130
|
+
"",
|
|
131
|
+
" This is the real engine. Migrating copies the bundled repository to a",
|
|
132
|
+
f" temporary directory, patches the copy, and runs `{test_command}` there.",
|
|
133
|
+
" The bundled repository is never modified. Serving on localhost only.",
|
|
134
|
+
"",
|
|
135
|
+
" Ctrl-C to stop.",
|
|
136
|
+
"",
|
|
137
|
+
]
|
|
138
|
+
if not can_run_tests:
|
|
139
|
+
lines[-1:] = [
|
|
140
|
+
" WARNING: pytest is not installed, so no scenario can be verified.",
|
|
141
|
+
" Every run will report that the test command could not start.",
|
|
142
|
+
" Fix: pip install 'patchahead[demo]'",
|
|
143
|
+
"",
|
|
144
|
+
]
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def serve(
|
|
149
|
+
port: int = DEFAULT_PORT,
|
|
150
|
+
*,
|
|
151
|
+
port_was_explicit: bool = False,
|
|
152
|
+
open_browser: bool = True,
|
|
153
|
+
scenario: str = "",
|
|
154
|
+
) -> int:
|
|
155
|
+
"""Run the demo server until interrupted. Returns a process exit code."""
|
|
156
|
+
from patchahead.config import load as load_config
|
|
157
|
+
from patchahead.web.server import create_app
|
|
158
|
+
|
|
159
|
+
repo = repo_root()
|
|
160
|
+
changes = changes_root()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
import uvicorn
|
|
164
|
+
except ImportError:
|
|
165
|
+
log.error(
|
|
166
|
+
"the demo needs its extra dependencies: pip install 'patchahead[demo]' "
|
|
167
|
+
"(FastAPI, uvicorn, and the pytest that verifies the migrations)"
|
|
168
|
+
)
|
|
169
|
+
return 2
|
|
170
|
+
|
|
171
|
+
chosen = choose_port(port, explicit=port_was_explicit)
|
|
172
|
+
app = create_app(repo, changes, scenarios())
|
|
173
|
+
url = f"http://{HOST}:{chosen}"
|
|
174
|
+
if scenario:
|
|
175
|
+
url = f"{url}/?scenario={scenario}"
|
|
176
|
+
|
|
177
|
+
print(
|
|
178
|
+
banner(
|
|
179
|
+
url,
|
|
180
|
+
repo,
|
|
181
|
+
load_config(repo).test_command,
|
|
182
|
+
can_run_tests=test_runner_available(),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
if open_browser and browser_is_practical():
|
|
186
|
+
_open_later(url)
|
|
187
|
+
|
|
188
|
+
uvicorn.run(app, host=HOST, port=chosen, log_level="warning")
|
|
189
|
+
return 0
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The PatchAhead domain model.
|
|
2
|
+
|
|
3
|
+
Everything that crosses a stage boundary is one of these objects. No stage
|
|
4
|
+
passes a loosely-structured dict to another stage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from patchahead.domain.change import (
|
|
8
|
+
BreakingChange,
|
|
9
|
+
ChangeKind,
|
|
10
|
+
Confidence,
|
|
11
|
+
Evidence,
|
|
12
|
+
PaginationContract,
|
|
13
|
+
Severity,
|
|
14
|
+
SymbolTarget,
|
|
15
|
+
)
|
|
16
|
+
from patchahead.domain.impact import (
|
|
17
|
+
AccessKind,
|
|
18
|
+
CodeReference,
|
|
19
|
+
ImpactFinding,
|
|
20
|
+
ImpactGraph,
|
|
21
|
+
ImpactReport,
|
|
22
|
+
)
|
|
23
|
+
from patchahead.domain.patch import FileEdit, PatchProposal
|
|
24
|
+
from patchahead.domain.plan import MigrationPlan, Risk, TextEdit, Transformation
|
|
25
|
+
from patchahead.domain.result import (
|
|
26
|
+
AnalysisResult,
|
|
27
|
+
MigrationResult,
|
|
28
|
+
MigrationRun,
|
|
29
|
+
Outcome,
|
|
30
|
+
)
|
|
31
|
+
from patchahead.domain.validation import (
|
|
32
|
+
GateName,
|
|
33
|
+
GateResult,
|
|
34
|
+
GateStatus,
|
|
35
|
+
TestRun,
|
|
36
|
+
ValidationResult,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"AccessKind",
|
|
41
|
+
"AnalysisResult",
|
|
42
|
+
"BreakingChange",
|
|
43
|
+
"ChangeKind",
|
|
44
|
+
"CodeReference",
|
|
45
|
+
"Confidence",
|
|
46
|
+
"Evidence",
|
|
47
|
+
"FileEdit",
|
|
48
|
+
"GateName",
|
|
49
|
+
"GateResult",
|
|
50
|
+
"GateStatus",
|
|
51
|
+
"ImpactFinding",
|
|
52
|
+
"ImpactGraph",
|
|
53
|
+
"ImpactReport",
|
|
54
|
+
"MigrationPlan",
|
|
55
|
+
"MigrationResult",
|
|
56
|
+
"MigrationRun",
|
|
57
|
+
"Outcome",
|
|
58
|
+
"PaginationContract",
|
|
59
|
+
"PatchProposal",
|
|
60
|
+
"Risk",
|
|
61
|
+
"Severity",
|
|
62
|
+
"SymbolTarget",
|
|
63
|
+
"TestRun",
|
|
64
|
+
"TextEdit",
|
|
65
|
+
"Transformation",
|
|
66
|
+
"ValidationResult",
|
|
67
|
+
]
|