relay-backend 0.0.1__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 (41) hide show
  1. relay_agent/__init__.py +7 -0
  2. relay_agent/__main__.py +3 -0
  3. relay_agent/app.py +216 -0
  4. relay_agent/cli.py +47 -0
  5. relay_agent/executor.py +32 -0
  6. relay_agent/files.py +88 -0
  7. relay_agent/git_source.py +128 -0
  8. relay_agent/git_status.py +83 -0
  9. relay_agent/integration.py +48 -0
  10. relay_agent/openapi.py +117 -0
  11. relay_agent/security.py +60 -0
  12. relay_agent/static/assets/index-D9Mabhh3.css +1 -0
  13. relay_agent/static/assets/index-bkUH1ypy.js +15 -0
  14. relay_agent/static/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  15. relay_agent/static/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  16. relay_agent/static/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  17. relay_agent/static/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  18. relay_agent/static/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  19. relay_agent/static/assets/relay-key.webp +0 -0
  20. relay_agent/static/assets/relay-key.webp.json +4 -0
  21. relay_agent/static/assets/relay-signal.webp +0 -0
  22. relay_agent/static/assets/relay-signal.webp.json +4 -0
  23. relay_agent/static/brand/identity.css +13 -0
  24. relay_agent/static/brand/identity.html +21 -0
  25. relay_agent/static/brand/relay-icon.svg +1 -0
  26. relay_agent/static/brand/relay-logo-black.svg +1 -0
  27. relay_agent/static/brand/relay-logo-light.svg +1 -0
  28. relay_agent/static/brand/relay-logo-white.svg +1 -0
  29. relay_agent/static/brand/relay-logo.svg +1 -0
  30. relay_agent/static/brand/relay-symbol.svg +1 -0
  31. relay_agent/static/brand/relay-wordmark.svg +1 -0
  32. relay_agent/static/index.html +4 -0
  33. relay_agent/static/relay-mark.svg +1 -0
  34. relay_agent/workspace.py +100 -0
  35. relay_backend-0.0.1.dist-info/METADATA +70 -0
  36. relay_backend-0.0.1.dist-info/RECORD +41 -0
  37. relay_backend-0.0.1.dist-info/WHEEL +5 -0
  38. relay_backend-0.0.1.dist-info/entry_points.txt +2 -0
  39. relay_backend-0.0.1.dist-info/licenses/LICENSE +202 -0
  40. relay_backend-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +169 -0
  41. relay_backend-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,7 @@
1
+ """Relay's local API tester and native FastAPI integration."""
2
+
3
+ from .integration import install_relay
4
+
5
+ __version__ = "0.0.1"
6
+
7
+ __all__ = ["install_relay"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
relay_agent/app.py ADDED
@@ -0,0 +1,216 @@
1
+ import json
2
+ import secrets
3
+ import ipaddress
4
+ import posixpath
5
+ import asyncio
6
+ from html import escape
7
+ from urllib.parse import unquote
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ import httpx
13
+ from fastapi import APIRouter, FastAPI, HTTPException, Request
14
+ from fastapi.responses import JSONResponse, HTMLResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+ from pydantic import BaseModel, Field
17
+
18
+ from .executor import send
19
+ from .openapi import SpecError, normalize
20
+ from .security import MAX_REQUEST, MAX_RESPONSE, Settings, safe_headers, validate_path
21
+ from .workspace import Workspace
22
+ from .git_source import GitSource
23
+
24
+
25
+ class ExecuteRequest(BaseModel):
26
+ method: Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
27
+ path: str = Field(max_length=8192)
28
+ headers: dict[str, str] = Field(default_factory=dict)
29
+ query: dict[str, str] = Field(default_factory=dict)
30
+ body_mode: Literal["none", "json", "text", "form"] = "none"
31
+ body: str = ""
32
+
33
+
34
+ class EndpointUpdate(BaseModel):
35
+ endpoint_id: str = Field(max_length=8192)
36
+ fingerprint: str = Field(min_length=64, max_length=64)
37
+ progress: Literal["not_started", "in_progress", "done", "needs_fixing"] | None = None
38
+ note: str | None = Field(default=None, max_length=4000)
39
+
40
+
41
+ def create_app(settings: Settings | None = None, transport=None, static_dir: Path | None = None, db_path: str | Path = ":memory:", project: Path | None = None, native_host: FastAPI | None = None):
42
+ settings = settings or Settings()
43
+ token = secrets.token_urlsafe(32)
44
+
45
+ @asynccontextmanager
46
+ async def lifespan(app):
47
+ workspace = Workspace(db_path, project=project)
48
+ app.state.workspace = workspace
49
+ app.state.git_source = GitSource(workspace.shared.root if workspace.shared else None)
50
+ app.state.snapshot = None
51
+ app.state.raw_spec = None
52
+ try:
53
+ async with httpx.AsyncClient(timeout=20, follow_redirects=False, trust_env=False, transport=transport) as client:
54
+ app.state.client = client
55
+ yield
56
+ finally:
57
+ workspace.close()
58
+
59
+ app = FastAPI(title="Relay Local Companion", docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
60
+ app.state.snapshot = None
61
+ app.state.raw_spec = None
62
+
63
+ @app.middleware("http")
64
+ async def guard(request: Request, call_next):
65
+ expected_origin = f"{request.url.scheme}://{request.url.netloc}" if native_host else settings.origin
66
+ if native_host:
67
+ def local(host):
68
+ try:
69
+ return ipaddress.ip_address(host).is_loopback
70
+ except ValueError:
71
+ return host == "localhost"
72
+ if not request.client or not local(request.client.host) or not local(request.url.hostname or ""):
73
+ return JSONResponse({"detail": "Relay is available only on the developer's local machine."}, 403)
74
+ elif request.headers.get("host") != f"127.0.0.1:{settings.port}":
75
+ return JSONResponse({"detail": f"Open Relay at {settings.origin}; this host is not allowed."}, 403)
76
+ origin = request.headers.get("origin")
77
+ if origin and origin != expected_origin:
78
+ return JSONResponse({"detail": "Untrusted browser origin."}, 403)
79
+ if request.headers.get("sec-fetch-site") not in {None, "none", "same-origin"}:
80
+ return JSONResponse({"detail": "Cross-origin access to the local runner is forbidden."}, 403)
81
+ if request.method not in {"GET", "HEAD"}:
82
+ if origin != expected_origin or not secrets.compare_digest(request.headers.get("x-relay-csrf", "").encode(), token.encode()):
83
+ return JSONResponse({"detail": "Local session expired or CSRF check failed. Reload Relay."}, 403)
84
+ # Read the stream with a hard cap before Pydantic allocates the request body.
85
+ body = bytearray()
86
+ async for chunk in request.stream():
87
+ body.extend(chunk)
88
+ if len(body) > MAX_REQUEST:
89
+ return JSONResponse({"detail": "Request exceeds the 1 MiB limit."}, 413)
90
+ request._body = bytes(body)
91
+ response = await call_next(request)
92
+ response.headers.update({"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff",
93
+ "Referrer-Policy": "no-referrer", "X-Frame-Options": "DENY",
94
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'"})
95
+ return response
96
+
97
+ def target_base(request: Request):
98
+ if not native_host:
99
+ return settings.base_url.rstrip("/")
100
+ root = request.scope.get("root_path", "").removesuffix("/relay")
101
+ return f"{request.url.scheme}://{request.url.netloc}{root}"
102
+
103
+ @app.get("/__relay/health")
104
+ def health(request: Request):
105
+ return {"status": "ok", "mode": "local", "version": "0.0.1", "csrf_token": token,
106
+ "base_url": target_base(request), "spec_url": target_base(request) + (native_host.openapi_url or "") if native_host else settings.target(settings.spec_path)}
107
+
108
+ @app.post("/__relay/sources/inspect")
109
+ async def inspect():
110
+ try:
111
+ if native_host:
112
+ raw = native_host.openapi()
113
+ if len(json.dumps(raw).encode()) > MAX_RESPONSE:
114
+ raise HTTPException(413, "OpenAPI document exceeds the 2 MiB limit.")
115
+ else:
116
+ result = await send(app.state.client, "GET", settings.target(settings.spec_path), limit=MAX_RESPONSE)
117
+ if result["status"] != 200:
118
+ raise HTTPException(502, f"OpenAPI returned HTTP {result['status']}. Check the configured spec path; redirects are not followed.")
119
+ if result["truncated"]:
120
+ raise HTTPException(413, "OpenAPI document exceeds the 2 MiB limit.")
121
+ raw = json.loads(result["body"])
122
+ snapshot = normalize(raw)
123
+ except (ValueError, SpecError, RecursionError, TypeError, AttributeError) as exc:
124
+ raise HTTPException(422, f"Could not import OpenAPI: {exc}. Last good import is preserved.")
125
+ app.state.workspace.sync(snapshot)
126
+ if native_host:
127
+ app.state.workspace.sources = await asyncio.to_thread(app.state.git_source.collect, native_host)
128
+ app.state.snapshot, app.state.raw_spec = snapshot, raw
129
+ return snapshot
130
+
131
+ @app.get("/__relay/workspace")
132
+ def read_workspace():
133
+ return app.state.workspace.read()
134
+
135
+ @app.post("/__relay/endpoints/update")
136
+ def update_endpoint(payload: EndpointUpdate):
137
+ app.state.workspace.update(payload.endpoint_id, payload.fingerprint, payload.progress, payload.note)
138
+ return app.state.workspace.read()
139
+
140
+ @app.get("/__relay/sources/current")
141
+ def current():
142
+ return {"snapshot": app.state.snapshot, "raw_spec": app.state.raw_spec}
143
+
144
+ @app.post("/__relay/execute")
145
+ async def execute(payload: ExecuteRequest, request: Request):
146
+ validate_path(payload.path)
147
+ decoded_path = posixpath.normpath(unquote(payload.path))
148
+ if native_host and (decoded_path == "/relay" or decoded_path.startswith("/relay/")):
149
+ raise HTTPException(400, "Relay's own routes cannot be executed.")
150
+ url = target_base(request) + payload.path
151
+ root = request.scope.get("root_path", "").removesuffix("/relay") if native_host else ""
152
+ if native_host:
153
+ resolved_path = posixpath.normpath(unquote(httpx.URL(url).path))
154
+ if root and not resolved_path.startswith(root + "/"):
155
+ raise HTTPException(400, "Request path escapes the application's mount.")
156
+ route_path = resolved_path.removeprefix(root)
157
+ if route_path == "/relay" or route_path.startswith("/relay/"):
158
+ raise HTTPException(400, "Relay's own routes cannot be executed.")
159
+ headers = safe_headers(dict(payload.headers))
160
+ content = None
161
+ if payload.body_mode == "json":
162
+ try:
163
+ json.loads(payload.body)
164
+ except (ValueError, RecursionError):
165
+ raise HTTPException(400, "Invalid JSON body. Fix the JSON before executing.")
166
+ content = payload.body.encode()
167
+ headers = {k: v for k, v in headers.items() if k.lower() != "content-type"}
168
+ headers["Content-Type"] = "application/json"
169
+ elif payload.body_mode != "none":
170
+ content = payload.body.encode()
171
+ if not any(k.lower() == "content-type" for k in headers):
172
+ headers["Content-Type"] = "application/x-www-form-urlencoded" if payload.body_mode == "form" else "text/plain"
173
+ if native_host:
174
+ async def bounded_host(scope, receive, send_message):
175
+ # HTTPX's ASGI transport buffers response chunks. Cap what it
176
+ # retains, while allowing the application to finish normally.
177
+ retained = 0
178
+ scope["state"] = dict(request.scope.get("state", {}))
179
+ async def bounded_send(message):
180
+ nonlocal retained
181
+ if message["type"] == "http.response.body":
182
+ chunk = message.get("body", b"")[:max(0, MAX_RESPONSE + 1 - retained)]
183
+ retained += len(chunk)
184
+ message = {**message, "body": chunk}
185
+ await send_message(message)
186
+ await native_host(scope, receive, bounded_send)
187
+ native_transport = httpx.ASGITransport(app=bounded_host, root_path=root, raise_app_exceptions=False,
188
+ client=(request.client.host, request.client.port))
189
+ # Never reuse cookies from a previous response as implicit auth.
190
+ async with httpx.AsyncClient(transport=native_transport, follow_redirects=False, trust_env=False) as client:
191
+ return await send(client, payload.method, url, headers=headers, query=payload.query, content=content)
192
+ return await send(app.state.client, payload.method, url, headers=headers, query=payload.query, content=content)
193
+
194
+ bundled = Path(__file__).resolve().parent / "static"
195
+ static_dir = static_dir or (bundled if bundled.is_dir() else Path(__file__).resolve().parents[2] / "web" / "dist")
196
+ if static_dir.is_dir():
197
+ # Preserve CLI API URLs while the browser uses namespaced URLs.
198
+ if not native_host:
199
+ aliases = APIRouter()
200
+ aliases.routes = list(app.router.routes)
201
+ app.include_router(aliases, prefix="/relay", include_in_schema=False)
202
+ @app.get("/" if native_host else "/relay", include_in_schema=False)
203
+ @app.get("/index.html" if native_host else "/relay/", include_in_schema=False)
204
+ def relay_ui(request: Request):
205
+ base = request.scope.get("root_path", "") if native_host else request.scope.get("root_path", "") + "/relay"
206
+ html = (static_dir / "index.html").read_text(encoding="utf-8")
207
+ html = html.replace("<head>", f'<head><base href="{escape(base + "/", quote=True)}">', 1)
208
+ return HTMLResponse(html)
209
+ if not native_host:
210
+ app.mount("/relay", StaticFiles(directory=static_dir, html=True), name="relay-web")
211
+ app.mount("/", StaticFiles(directory=static_dir, html=True), name="web")
212
+ else:
213
+ @app.get("/")
214
+ def missing_ui():
215
+ return JSONResponse({"detail": "Relay's bundled UI is missing. Reinstall the Relay package."}, 503)
216
+ return app
relay_agent/cli.py ADDED
@@ -0,0 +1,47 @@
1
+ import argparse
2
+ import hashlib
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import uvicorn
7
+
8
+ from .app import create_app
9
+ from .security import Settings
10
+
11
+
12
+ def main():
13
+ if len(sys.argv) > 1 and sys.argv[1] in {"open", "associate"}:
14
+ from .files import associate_windows, open_report
15
+ action = sys.argv[1]
16
+ file_parser = argparse.ArgumentParser(description="Open Relay reports or enable Windows double-click support.")
17
+ if action == "open":
18
+ file_parser.add_argument("file", type=Path)
19
+ file_args = file_parser.parse_args(sys.argv[2:])
20
+ try:
21
+ if action == "open":
22
+ open_report(file_args.file)
23
+ else:
24
+ associate_windows()
25
+ print("Relay .relay reports now open in your default browser. Existing Windows user choices remain unchanged.")
26
+ except (OSError, UnicodeError, ValueError, RuntimeError) as error:
27
+ file_parser.error(str(error))
28
+ return
29
+ parser = argparse.ArgumentParser(description="Relay local API explorer. Targets are approved explicitly at startup.")
30
+ parser.add_argument("--target", default="http://127.0.0.1:8000")
31
+ parser.add_argument("--spec-path", default="/openapi.json")
32
+ parser.add_argument("--port", type=int, default=4477)
33
+ parser.add_argument("--static-dir", type=Path)
34
+ parser.add_argument("--data-dir", type=Path, default=Path.home() / ".relay")
35
+ parser.add_argument("--project", type=Path, help="Git repository for shared .relay/status.json metadata")
36
+ args = parser.parse_args()
37
+ try:
38
+ settings = Settings(args.target, args.spec_path, args.port)
39
+ except Exception as exc:
40
+ parser.error(str(exc))
41
+ workspace_id = hashlib.sha256((str(args.project.resolve()) if args.project else settings.base_url.rstrip("/")).encode()).hexdigest()[:16]
42
+ db_path = args.data_dir / workspace_id / "workspace.sqlite3"
43
+ try:
44
+ app = create_app(settings, static_dir=args.static_dir, db_path=db_path, project=args.project)
45
+ except ValueError as exc:
46
+ parser.error(str(exc))
47
+ uvicorn.run(app, host="127.0.0.1", port=args.port, access_log=False)
@@ -0,0 +1,32 @@
1
+ import asyncio
2
+ import time
3
+
4
+ import httpx
5
+ from fastapi import HTTPException
6
+
7
+ from .security import MAX_RESPONSE
8
+
9
+
10
+ async def send(client: httpx.AsyncClient, method: str, url: str, *, headers=None, query=None,
11
+ content=None, limit=MAX_RESPONSE):
12
+ started = time.perf_counter()
13
+ try:
14
+ async with asyncio.timeout(20):
15
+ async with client.stream(method, url, headers=headers, params=query, content=content) as response:
16
+ data = bytearray()
17
+ truncated = False
18
+ async for chunk in response.aiter_bytes(chunk_size=65536):
19
+ remaining = limit - len(data)
20
+ data.extend(chunk[:remaining])
21
+ if len(chunk) > remaining:
22
+ truncated = True
23
+ break
24
+ return {"status": response.status_code, "reason": response.reason_phrase,
25
+ "headers": dict(response.headers), "body": data.decode(response.encoding or "utf-8", errors="replace"),
26
+ "content_type": response.headers.get("content-type", ""), "bytes": len(data),
27
+ "truncated": truncated, "duration_ms": round((time.perf_counter() - started) * 1000, 1),
28
+ "url": str(response.url), "method": method}
29
+ except (httpx.TimeoutException, TimeoutError):
30
+ raise HTTPException(504, "Request timed out after 20 seconds. Check the backend and try again.")
31
+ except httpx.RequestError:
32
+ raise HTTPException(502, "Could not connect to the configured API. Start your backend and check its address and port.")
relay_agent/files.py ADDED
@@ -0,0 +1,88 @@
1
+ """Open portable Relay reports and register their Windows file association."""
2
+ import argparse
3
+ import hashlib
4
+ import sys
5
+ import webbrowser
6
+ from pathlib import Path
7
+
8
+ REPORT_MARKER = '<meta name="relay-format" content="relay.endpoint.report.v1">'
9
+ PROG_ID = "Relay.EndpointReport"
10
+
11
+
12
+ def prepare_report(path: Path, cache_dir: Path | None = None) -> Path:
13
+ path = path.expanduser().resolve(strict=True)
14
+ if path.suffix.lower() != ".relay":
15
+ raise ValueError("Choose a .relay endpoint report.")
16
+ if path.stat().st_size > 32 * 1024 * 1024:
17
+ raise ValueError("Relay report exceeds the 32 MiB limit.")
18
+ data = path.read_bytes()
19
+ content = data.decode("utf-8-sig")
20
+ if REPORT_MARKER not in content or not content.lstrip().lower().startswith("<!doctype html>"):
21
+ raise ValueError("This is not a supported Relay browser report.")
22
+ # A .html copy gives every browser the correct local-file content type.
23
+ # Keep the original report intact; no backend or HTTP server is required.
24
+ root = cache_dir or Path.home() / ".relay" / "reports"
25
+ root.mkdir(parents=True, exist_ok=True)
26
+ destination = root / (hashlib.sha256(data).hexdigest() + ".html")
27
+ destination.write_bytes(data)
28
+ return destination
29
+
30
+
31
+ def open_report(path: Path) -> Path:
32
+ destination = prepare_report(path)
33
+ if not webbrowser.open(destination.as_uri()):
34
+ raise RuntimeError(f"Could not open your browser. Open {destination} manually.")
35
+ return destination
36
+
37
+
38
+ def associate_windows() -> str:
39
+ if sys.platform != "win32":
40
+ raise ValueError("Automatic association currently supports Windows. Use relay open <file.relay> on this platform.")
41
+ import ctypes
42
+ import subprocess
43
+ import winreg
44
+
45
+ extension = r"Software\Classes\.relay"
46
+ # Do not replace another application's association.
47
+ for hive, key_path in ((winreg.HKEY_CURRENT_USER, extension), (winreg.HKEY_CLASSES_ROOT, ".relay")):
48
+ try:
49
+ with winreg.OpenKey(hive, key_path) as key:
50
+ existing = winreg.QueryValue(key, None)
51
+ if existing and existing != PROG_ID:
52
+ raise ValueError(".relay already belongs to another application. Choose Relay explicitly in Windows Open with settings.")
53
+ except FileNotFoundError:
54
+ pass
55
+ python = Path(sys.executable)
56
+ pythonw = python.with_name("pythonw.exe")
57
+ executable = pythonw if pythonw.is_file() else python
58
+ command = subprocess.list2cmdline([str(executable), "-m", "relay_agent.files"]) + ' "%1"'
59
+ values = {
60
+ extension: PROG_ID,
61
+ rf"Software\Classes\{PROG_ID}": "Relay endpoint report",
62
+ rf"Software\Classes\{PROG_ID}\shell\open\command": command,
63
+ }
64
+ for key_path, value in values.items():
65
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path) as key:
66
+ winreg.SetValueEx(key, "", 0, winreg.REG_SZ, value)
67
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, extension) as key:
68
+ winreg.SetValueEx(key, "Content Type", 0, winreg.REG_SZ, "text/html")
69
+ ctypes.windll.shell32.SHChangeNotify(0x08000000, 0, None, None)
70
+ return command
71
+
72
+
73
+ def main():
74
+ parser = argparse.ArgumentParser(description="Open a Relay endpoint report in your default browser.")
75
+ parser.add_argument("file", type=Path)
76
+ args = parser.parse_args()
77
+ try:
78
+ open_report(args.file)
79
+ except (OSError, UnicodeError, ValueError, RuntimeError) as error:
80
+ if sys.platform == "win32" and sys.executable.lower().endswith("pythonw.exe"):
81
+ import ctypes
82
+ ctypes.windll.user32.MessageBoxW(None, str(error), "Relay report", 0x10)
83
+ else:
84
+ parser.error(str(error))
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
@@ -0,0 +1,128 @@
1
+ """Read-only source attribution for native FastAPI endpoints.
2
+
3
+ Creation is inferred from the oldest available history of the handler's
4
+ definition line. It is not ownership or verified identity.
5
+ """
6
+ import ast
7
+ import inspect
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from fastapi.routing import APIRoute
12
+
13
+
14
+ def api_routes(routes, prefix=""):
15
+ """Support flattened routes and FastAPI's newer included-router branches."""
16
+ for route in routes:
17
+ if isinstance(route, APIRoute):
18
+ if route.include_in_schema:
19
+ yield route, prefix + route.path
20
+ else:
21
+ router = getattr(route, "original_router", None)
22
+ context = getattr(route, "include_context", None)
23
+ if router is not None and context is not None and context.include_in_schema:
24
+ yield from api_routes(router.routes, prefix + context.prefix)
25
+
26
+
27
+ class GitSource:
28
+ def __init__(self, root: Path | None):
29
+ self.root = root
30
+ self.cache = {}
31
+ self.files = {}
32
+
33
+ def git(self, *args):
34
+ result = subprocess.run(["git", "-C", str(self.root), "-c", "core.quotepath=false", *args],
35
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5)
36
+ if result.returncode:
37
+ raise ValueError("Git history unavailable")
38
+ return result.stdout
39
+
40
+ def collect(self, app):
41
+ try:
42
+ head = self.git("rev-parse", "HEAD").strip() if self.root else None
43
+ except (ValueError, OSError, subprocess.TimeoutExpired):
44
+ head = None
45
+ sources = {}
46
+ for route, route_path in api_routes(app.routes):
47
+ try:
48
+ handler = inspect.unwrap(route.endpoint)
49
+ file = Path(inspect.getsourcefile(handler)).resolve()
50
+ lines, start = inspect.getsourcelines(handler)
51
+ # inspect includes route decorators; the definition itself is
52
+ # the stable anchor for tracing this handler back through Git.
53
+ import textwrap
54
+ tree = ast.parse(textwrap.dedent("".join(lines)))
55
+ node = next(n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)))
56
+ definition = start + node.lineno - 1
57
+ end = start + len(lines) - 1
58
+ relative = file.relative_to(self.root).as_posix() if self.root else file.name
59
+ key = (head, relative, start, definition, end, file.stat().st_mtime_ns)
60
+ if key not in self.cache:
61
+ self.cache[key] = self.history(relative, start, definition, end, head, handler.__name__)
62
+ source = self.cache[key]
63
+ except (ValueError, TypeError, OSError, StopIteration, SyntaxError):
64
+ source = {"state": "unavailable"}
65
+ for method in route.methods:
66
+ sources[f"{method} {route_path}"] = source
67
+ # Bound the cache across auto-sync and edits.
68
+ if len(self.cache) > 1024:
69
+ self.cache.clear()
70
+ self.files.clear()
71
+ return sources
72
+
73
+ def history(self, file, start, definition, end, head, name):
74
+ source = {"file": file, "line": definition, "state": "unavailable"}
75
+ if not head:
76
+ if self.root:
77
+ source["state"] = "uncommitted"
78
+ return source
79
+ try:
80
+ file_key = (head, file)
81
+ if file_key not in self.files:
82
+ try:
83
+ self.files[file_key] = ast.parse(self.git("show", f"{head}:{file}"))
84
+ except ValueError:
85
+ source["state"] = "uncommitted"
86
+ return source
87
+ definitions = [n for n in ast.walk(self.files[file_key])
88
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name == name]
89
+ if definitions:
90
+ node = min(definitions, key=lambda n: abs(n.lineno - definition))
91
+ committed_start = min([node.lineno, *(d.lineno for d in node.decorator_list)])
92
+ history = self.git("log", "--no-patch", "--format=%H%x00%an%x00%ae%x00%aI",
93
+ "-L", f"{committed_start},{node.end_lineno}:{file}", head)
94
+ entries = [line.split("\0") for line in history.splitlines() if line.count("\0") == 3]
95
+ if entries:
96
+ commit, author, email, date = entries[0]
97
+ source.update(last_changed_by={"name": author, "email": email}, commit=commit, changed_at=date)
98
+ output = self.git("blame", "--line-porcelain", "-L", f"{start},{end}", "--", file)
99
+ records = []
100
+ record = {}
101
+ for line in output.splitlines():
102
+ if not record:
103
+ sha, original, final, *_ = line.split()
104
+ record = {"commit": sha, "original": int(original), "final": int(final)}
105
+ elif line.startswith("\t"):
106
+ records.append(record)
107
+ record = {}
108
+ else:
109
+ name, _, value = line.partition(" ")
110
+ record[name] = value
111
+ committed = [r for r in records if set(r["commit"]) != {"0"}]
112
+ source["state"] = "uncommitted" if len(committed) != len(records) else "tracked"
113
+ anchor = next((r for r in committed if r["final"] == definition), None)
114
+ if anchor:
115
+ old_file = anchor["filename"]
116
+ if old_file.startswith('"'):
117
+ old_file = ast.literal_eval(old_file)
118
+ history = self.git("log", "--no-patch", "--format=%H%x00%an%x00%ae%x00%aI",
119
+ "-L", f'{anchor["original"]},{anchor["original"]}:{old_file}', anchor["commit"])
120
+ entries = [line.split("\0") for line in history.splitlines() if line.count("\0") == 3]
121
+ if entries:
122
+ commit, name, email, date = entries[-1]
123
+ source.update(created_by={"name": name, "email": email}, created_commit=commit, created_at=date)
124
+ except (ValueError, KeyError, OSError, subprocess.TimeoutExpired, SyntaxError):
125
+ # Missing/untracked files, shallow history, and failed Git commands
126
+ # never prevent API discovery or testing.
127
+ pass
128
+ return source
@@ -0,0 +1,83 @@
1
+ """Git-trackable status metadata. No Git commits or network operations are automatic."""
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import tempfile
6
+ from contextlib import contextmanager
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ from fastapi import HTTPException
11
+
12
+ PROGRESS = {"not_started", "in_progress", "done", "needs_fixing"}
13
+
14
+
15
+ class GitStatus:
16
+ def __init__(self, project: Path):
17
+ self.root = Path(self._git(project, "rev-parse", "--show-toplevel")).resolve()
18
+ self.path = self.root / ".relay" / "status.json"
19
+
20
+ @staticmethod
21
+ def _git(project, *args):
22
+ result = subprocess.run(["git", "-C", str(project), *args], capture_output=True, text=True, timeout=5)
23
+ if result.returncode:
24
+ raise ValueError("Relay --project requires an existing Git repository.")
25
+ return result.stdout.strip()
26
+
27
+ def read(self):
28
+ if not self.path.exists():
29
+ return {}
30
+ try:
31
+ if self.path.stat().st_size > 2 * 1024 * 1024:
32
+ raise ValueError("Status file is too large")
33
+ data = json.loads(self.path.read_text(encoding="utf-8"))
34
+ if data.get("version") != 1 or not isinstance(data.get("endpoints"), dict):
35
+ raise ValueError("Unknown status format")
36
+ for key, row in data["endpoints"].items():
37
+ if not isinstance(key, str) or not isinstance(row, dict) or row.get("progress") not in PROGRESS:
38
+ raise ValueError("Invalid endpoint status")
39
+ if not isinstance(row.get("fingerprint"), str) or len(row["fingerprint"]) != 64:
40
+ raise ValueError("Invalid contract fingerprint")
41
+ author = row.get("updated_by")
42
+ if not isinstance(author, dict) or not all(isinstance(author.get(k), str) for k in ("name", "email")):
43
+ raise ValueError("Invalid author")
44
+ if not isinstance(row.get("updated_at"), str):
45
+ raise ValueError("Invalid timestamp")
46
+ return data["endpoints"]
47
+ except (ValueError, OSError, AttributeError, TypeError) as exc:
48
+ raise HTTPException(409, "Cannot read .relay/status.json. Resolve Git conflicts or fix the file before changing status.") from exc
49
+
50
+ @contextmanager
51
+ def _lock(self):
52
+ self.path.parent.mkdir(parents=True, exist_ok=True)
53
+ lock = self.path.with_suffix(".lock")
54
+ try:
55
+ fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
56
+ except FileExistsError as exc:
57
+ raise HTTPException(409, "Another Relay process is updating statuses. Retry shortly; remove a stale .relay/status.lock after stopping Relay.") from exc
58
+ try:
59
+ os.close(fd)
60
+ yield
61
+ finally:
62
+ lock.unlink(missing_ok=True)
63
+
64
+ def update(self, endpoint_id, fingerprint, progress):
65
+ try:
66
+ name = self._git(self.root, "config", "user.name")
67
+ email = self._git(self.root, "config", "user.email")
68
+ if not name or not email:
69
+ raise ValueError("Missing identity")
70
+ except ValueError as exc:
71
+ raise HTTPException(409, "Configure Git user.name and user.email before changing a shared status.") from exc
72
+ with self._lock():
73
+ rows = self.read()
74
+ rows[endpoint_id] = {"fingerprint": fingerprint, "progress": progress,
75
+ "updated_by": {"name": name, "email": email}, "updated_at": datetime.now(timezone.utc).isoformat()}
76
+ fd, temporary = tempfile.mkstemp(prefix="status-", suffix=".tmp", dir=self.path.parent)
77
+ try:
78
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
79
+ json.dump({"version": 1, "endpoints": dict(sorted(rows.items()))}, stream, indent=2, ensure_ascii=False)
80
+ stream.write("\n")
81
+ os.replace(temporary, self.path)
82
+ finally:
83
+ Path(temporary).unlink(missing_ok=True)
@@ -0,0 +1,48 @@
1
+ """Opt-in, same-process FastAPI integration."""
2
+ import hashlib
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI
7
+ from .app import create_app
8
+
9
+
10
+ def install_relay(app: FastAPI, *, enabled: bool = True, project: Path | None = None,
11
+ data_dir: Path | None = None, db_path: str | Path | None = None,
12
+ static_dir: Path | None = None) -> FastAPI | None:
13
+ """Mount the bundled tester at /relay. Enable only in development.
14
+
15
+ No target URL, subprocess, or extra port is required. The parent lifespan
16
+ runs normally; Relay owns only its client/workspace resources.
17
+ """
18
+ if not enabled:
19
+ return None
20
+ if getattr(app.state, "relay", None) is not None:
21
+ raise ValueError("Relay is already installed on this application.")
22
+ for route in app.routes:
23
+ path = getattr(route, "path", "")
24
+ if path == "/relay" or path.startswith("/relay/"):
25
+ raise ValueError("The /relay namespace is already in use.")
26
+ if project is None:
27
+ project = next((p for p in (Path.cwd(), *Path.cwd().parents)
28
+ if (p / ".git").exists()), None)
29
+ if db_path is None:
30
+ identity = f"{Path(project or Path.cwd()).resolve()}:{app.title}"
31
+ workspace_id = hashlib.sha256(identity.encode()).hexdigest()[:16]
32
+ db_path = Path(data_dir or Path.home() / ".relay") / workspace_id / "workspace.sqlite3"
33
+ relay = create_app(native_host=app, static_dir=static_dir, db_path=db_path, project=project)
34
+ previous = app.router.lifespan_context
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(application):
38
+ async with previous(application) as state:
39
+ # Mounted apps do not receive lifespan automatically.
40
+ async with relay.router.lifespan_context(relay):
41
+ yield state
42
+
43
+ app.router.lifespan_context = lifespan
44
+ app.mount("/relay", relay, name="relay")
45
+ # Reserve this namespace even when the host has a catch-all route.
46
+ app.router.routes.insert(0, app.router.routes.pop())
47
+ app.state.relay = relay
48
+ return relay