ecosemantic-view 0.1.8__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.
@@ -0,0 +1 @@
1
+ """The browser view the server serves: its API, its event stream, its page and its panels."""
@@ -0,0 +1,99 @@
1
+ """The activity routes: the agents, their calls, the trail of a log entry, an agent's open
2
+ trail, a call run again, and a system's calculations."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Sequence
7
+ from datetime import datetime
8
+
9
+ from fastmcp import FastMCP
10
+ from pydantic import BaseModel
11
+ from starlette.requests import Request
12
+ from starlette.responses import JSONResponse, Response
13
+ from starlette.routing import Route
14
+
15
+ from ecosemantic.errors import Invalid
16
+ from ecosemantic.mcp.context import ServerContext
17
+ from ecosemantic.mcp.envelopes import as_uuid
18
+ from ecosemantic.mcp.recording import current_hash
19
+ from ecosemantic.ports.types import open_trail, trail
20
+ from ecosemantic.view.api import IMPACT_TOOL, structured
21
+ from ecosemantic.view.handling import answering
22
+
23
+
24
+ def _listed(items: Sequence[BaseModel]) -> JSONResponse:
25
+ return JSONResponse({"value": [item.model_dump(mode="json") for item in items]})
26
+
27
+
28
+ def _time(text: str | None) -> datetime | None:
29
+ if text is None:
30
+ return None
31
+ try:
32
+ return datetime.fromisoformat(text)
33
+ except ValueError as error:
34
+ raise Invalid(f"after={text!r} is not an ISO 8601 time") from error
35
+
36
+
37
+ def routes(server: FastMCP, context: ServerContext) -> list[Route]:
38
+ """The six activity routes."""
39
+
40
+ @answering
41
+ async def agents(request: Request) -> Response:
42
+ return _listed(await context.activity.list_agents())
43
+
44
+ @answering
45
+ async def calls(request: Request) -> Response:
46
+ query = request.query_params
47
+ named = query.get("product_system_id")
48
+ return _listed(
49
+ await context.activity.list_calls(
50
+ agent=query.get("agent"),
51
+ product_system_id=as_uuid(named) if named is not None else None,
52
+ after=_time(query.get("after")),
53
+ )
54
+ )
55
+
56
+ @answering
57
+ async def entry_trail(request: Request) -> Response:
58
+ system_id = as_uuid(request.path_params["id"])
59
+ await context.storage.get_product_system(system_id)
60
+ recorded = await context.activity.list_calls()
61
+ return _listed(trail(recorded, system_id, request.path_params["seq"]))
62
+
63
+ @answering
64
+ async def calculations(request: Request) -> Response:
65
+ system_id = as_uuid(request.path_params["id"])
66
+ await context.storage.get_product_system(system_id)
67
+ recorded = await context.activity.list_calls(product_system_id=system_id)
68
+ return _listed([call for call in reversed(recorded) if call.tool == IMPACT_TOOL])
69
+
70
+ @answering
71
+ async def agent_trail(request: Request) -> Response:
72
+ recorded = await context.activity.list_calls()
73
+ named = request.query_params.get("product_system_id")
74
+ system_id = as_uuid(named) if named is not None else None
75
+ return _listed(open_trail(recorded, request.path_params["agent"], system_id))
76
+
77
+ @answering
78
+ async def rerun(request: Request) -> Response:
79
+ call = await context.activity.get_call(as_uuid(request.path_params["call_id"]))
80
+ if call.effect in ("write", "delete"):
81
+ raise Invalid(f"{call.tool} changes a model; the page reruns reads and calculations")
82
+ result = await structured(server, call.tool, call.arguments, started=True)
83
+ hash_now = (
84
+ await current_hash(context.storage, call.product_system_id)
85
+ if call.product_system_id is not None
86
+ else None
87
+ )
88
+ return JSONResponse(
89
+ {"value": {"result": result, "at_hash": call.at_hash, "hash_now": hash_now}}
90
+ )
91
+
92
+ return [
93
+ Route("/api/agents", agents),
94
+ Route("/api/agents/{agent}/trail", agent_trail),
95
+ Route("/api/activity", calls),
96
+ Route("/api/activity/{call_id}/rerun", rerun, methods=["POST"]),
97
+ Route("/api/product-systems/{id}/log/{seq:int}/trail", entry_trail),
98
+ Route("/api/product-systems/{id}/calculations", calculations),
99
+ ]
@@ -0,0 +1,303 @@
1
+ """The routes over the tools: each calls the tool the agent calls, through the in-process client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+ from importlib import metadata
7
+
8
+ from fastmcp import Client, FastMCP
9
+ from fastmcp.exceptions import ToolError
10
+ from mcp.shared.exceptions import MCPError
11
+ from mcp.types import INVALID_PARAMS, Implementation
12
+ from pydantic import JsonValue, ValidationError
13
+ from starlette.requests import Request
14
+ from starlette.responses import JSONResponse, Response
15
+ from starlette.routing import Route
16
+
17
+ from ecosemantic.errors import Invalid
18
+ from ecosemantic.mcp.context import ServerContext
19
+ from ecosemantic.mcp.envelopes import Answer, Listing, as_uuid, envelope_of
20
+ from ecosemantic.mcp.recording import STARTED, VIEW
21
+ from ecosemantic.model.validation import anchors
22
+ from ecosemantic.ports.types import Layout, LogEntry, RequestDraft
23
+ from ecosemantic.view.handling import (
24
+ ToolRefused,
25
+ answering,
26
+ created,
27
+ envelope_response,
28
+ error_response,
29
+ )
30
+
31
+ GATE_PARAMETER = "token"
32
+ IMPACT_TOOL = "calculate_impact"
33
+ STARTED_PARAMETER = "started"
34
+
35
+
36
+ async def structured(
37
+ server: FastMCP, name: str, arguments: Mapping[str, JsonValue], started: bool = False
38
+ ) -> dict[str, JsonValue]:
39
+ """A tool's structured content, or ToolRefused carrying its error as the envelope. A call the
40
+ server refuses as invalid parameters carries the invalid envelope. A call a person started is
41
+ marked so, and the recorder keeps such a calculation."""
42
+ try:
43
+ async with Client(
44
+ server,
45
+ client_info=Implementation(name=VIEW, version=metadata.version("ecosemantic-view")),
46
+ ) as client:
47
+ result = await client.call_tool(
48
+ name, dict(arguments), meta={STARTED: True} if started else None
49
+ )
50
+ except MCPError as error:
51
+ if error.code != INVALID_PARAMS:
52
+ raise
53
+ raise ToolRefused(error_response(Invalid(f"{name}: {error.message}"))) from error
54
+ except ToolError as error:
55
+ raise ToolRefused(envelope_response(envelope_of(error))) from error
56
+ return result.structured_content
57
+
58
+
59
+ async def call_tool(
60
+ server: FastMCP, name: str, arguments: Mapping[str, JsonValue], started: bool = False
61
+ ) -> Response:
62
+ """One tool call rendered as an HTTP response: its structured content, or its error."""
63
+ try:
64
+ return JSONResponse(await structured(server, name, arguments, started))
65
+ except ToolRefused as refused:
66
+ return refused.response
67
+
68
+
69
+ async def json_object(request: Request) -> dict[str, JsonValue]:
70
+ """The request body as a JSON object; Invalid when it is not one."""
71
+ try:
72
+ body = await request.json()
73
+ except ValueError as error:
74
+ raise Invalid("the body is not JSON") from error
75
+ if not isinstance(body, dict):
76
+ raise Invalid("the body is not a JSON object")
77
+ return body
78
+
79
+
80
+ def arguments(query: Mapping[str, str]) -> dict[str, JsonValue]:
81
+ """Query parameters as tool arguments; the token stays out, the tool's schema types the rest."""
82
+ return {key: value for key, value in query.items() if key != GATE_PARAMETER}
83
+
84
+
85
+ def routes(server: FastMCP, context: ServerContext) -> list[Route]:
86
+ """Every JSON route under /api, in the order the spec lists them."""
87
+
88
+ async def databases(request: Request) -> Response:
89
+ return await call_tool(server, "list_databases", {})
90
+
91
+ async def vocabulary(request: Request) -> Response:
92
+ params = request.path_params
93
+ return await call_tool(
94
+ server, "list_vocabulary", {"database": params["name"], "kind": params["kind"]}
95
+ )
96
+
97
+ async def processes(request: Request) -> Response:
98
+ args = arguments(request.query_params)
99
+ return await call_tool(
100
+ server, "search_processes", {"database": request.path_params["name"], **args}
101
+ )
102
+
103
+ async def elementary_flows(request: Request) -> Response:
104
+ args = arguments(request.query_params)
105
+ return await call_tool(
106
+ server, "search_elementary_flows", {"database": request.path_params["name"], **args}
107
+ )
108
+
109
+ async def methods(request: Request) -> Response:
110
+ args = arguments(request.query_params)
111
+ return await call_tool(
112
+ server, "search_methods", {"database": request.path_params["name"], **args}
113
+ )
114
+
115
+ async def process(request: Request) -> Response:
116
+ params = request.path_params
117
+ return await call_tool(
118
+ server,
119
+ "get_background_process",
120
+ {"database": params["name"], "process_id": params["ref"], "exchanges": True},
121
+ )
122
+
123
+ async def elementary_flow(request: Request) -> Response:
124
+ params = request.path_params
125
+ return await call_tool(
126
+ server, "get_elementary_flow", {"database": params["name"], "flow_id": params["ref"]}
127
+ )
128
+
129
+ async def systems(request: Request) -> Response:
130
+ return await call_tool(server, "list_product_systems", {})
131
+
132
+ async def system(request: Request) -> Response:
133
+ args = arguments(request.query_params)
134
+ return await call_tool(
135
+ server,
136
+ "get_product_system",
137
+ {"product_system_id": request.path_params["id"], "variant": args.get("variant")},
138
+ )
139
+
140
+ async def parameters(request: Request) -> Response:
141
+ args = arguments(request.query_params)
142
+ content_hash = args.pop("hash", None)
143
+ return await call_tool(
144
+ server,
145
+ "get_parameters",
146
+ {"product_system_id": request.path_params["id"], "content_hash": content_hash},
147
+ )
148
+
149
+ @answering
150
+ async def log(request: Request) -> Response:
151
+ entries: list[LogEntry] = []
152
+ cursor: str | None = None
153
+ while True:
154
+ page = Listing[LogEntry].model_validate(
155
+ await structured(
156
+ server,
157
+ "get_product_system_log",
158
+ {"product_system_id": request.path_params["id"], "cursor": cursor},
159
+ )
160
+ )
161
+ entries.extend(page.items)
162
+ cursor = page.next_cursor
163
+ if cursor is None:
164
+ return JSONResponse(Answer[list[LogEntry]](value=entries).model_dump(mode="json"))
165
+
166
+ @answering
167
+ async def version(request: Request) -> Response:
168
+ params = request.path_params
169
+ stored = await context.storage.get_version(as_uuid(params["id"]), params["hash"])
170
+ return JSONResponse(stored.model_dump(mode="json"))
171
+
172
+ @answering
173
+ async def system_anchors(request: Request) -> Response:
174
+ stored = await context.storage.get_product_system(as_uuid(request.path_params["id"]))
175
+ return JSONResponse(Answer[list[str]](value=anchors(stored)).model_dump(mode="json"))
176
+
177
+ async def validation(request: Request) -> Response:
178
+ args = arguments(request.query_params)
179
+ return await call_tool(
180
+ server,
181
+ "validate_product_system",
182
+ {"product_system_id": request.path_params["id"], **args},
183
+ )
184
+
185
+ async def server_details(request: Request) -> Response:
186
+ """The client that introduced itself, this package's version and the two directories."""
187
+ return JSONResponse(
188
+ {
189
+ "client": context.client or None,
190
+ "version": metadata.version("ecosemantic"),
191
+ "data_dir": str(context.data_dir),
192
+ "systems_dir": str(context.systems_dir),
193
+ }
194
+ )
195
+
196
+ def calculation(tool: str) -> Callable[[Request], Awaitable[Response]]:
197
+ @answering
198
+ async def handler(request: Request) -> Response:
199
+ body = await json_object(request)
200
+ return await call_tool(
201
+ server,
202
+ tool,
203
+ {"product_system_id": request.path_params["id"], **body},
204
+ started=request.query_params.get(STARTED_PARAMETER) == "true",
205
+ )
206
+
207
+ return handler
208
+
209
+ async def runs_listed(request: Request) -> Response:
210
+ return await call_tool(
211
+ server, "list_runs", {"product_system_id": request.path_params["id"]}
212
+ )
213
+
214
+ async def run_read(request: Request) -> Response:
215
+ return await call_tool(server, "get_run", {"run_id": request.path_params["run_id"]})
216
+
217
+ async def run_cancelled(request: Request) -> Response:
218
+ return await call_tool(server, "cancel_run", {"run_id": request.path_params["run_id"]})
219
+
220
+ async def requests_listed(request: Request) -> Response:
221
+ args = arguments(request.query_params)
222
+ return await call_tool(
223
+ server,
224
+ "list_requests",
225
+ {"product_system_id": request.path_params["id"], "open_only": False, **args},
226
+ )
227
+
228
+ @answering
229
+ async def request_posted(request: Request) -> Response:
230
+ body = await json_object(request)
231
+ system_id = as_uuid(request.path_params["id"])
232
+ try:
233
+ draft = RequestDraft.model_validate(
234
+ {
235
+ "actor": {"kind": "person", "name": str(body.get("actor", ""))},
236
+ "kind": body.get("kind"),
237
+ "at_hash": body.get("at_hash", ""),
238
+ "message": body.get("message", ""),
239
+ "entity": body.get("entity"),
240
+ "entity_id": body.get("entity_id"),
241
+ }
242
+ )
243
+ except ValidationError as exc:
244
+ raise Invalid(str(exc)) from exc
245
+ posted = await context.storage.post_request(system_id, draft)
246
+ return created(posted)
247
+
248
+ async def layout(request: Request) -> Response:
249
+ args = arguments(request.query_params)
250
+ return await call_tool(
251
+ server,
252
+ "graph_layout",
253
+ {"product_system_id": request.path_params["id"], "variant": args.get("variant")},
254
+ )
255
+
256
+ @answering
257
+ async def place(request: Request) -> Response:
258
+ body = await json_object(request)
259
+ return await call_tool(
260
+ server, "arrange_graph", {"product_system_id": request.path_params["id"], **body}
261
+ )
262
+
263
+ @answering
264
+ async def release(request: Request) -> Response:
265
+ system_id = as_uuid(request.path_params["id"])
266
+ await context.storage.put_layout(system_id, Layout())
267
+ args = arguments(request.query_params)
268
+ return await call_tool(
269
+ server,
270
+ "graph_layout",
271
+ {"product_system_id": str(system_id), "variant": args.get("variant")},
272
+ )
273
+
274
+ base = "/api/product-systems/{id}"
275
+ return [
276
+ Route("/api/server", server_details),
277
+ Route("/api/databases", databases),
278
+ Route("/api/databases/{name}/vocabulary/{kind}", vocabulary),
279
+ Route("/api/databases/{name}/processes", processes),
280
+ Route("/api/databases/{name}/elementary-flows", elementary_flows),
281
+ Route("/api/databases/{name}/elementary-flows/{ref}", elementary_flow),
282
+ Route("/api/databases/{name}/methods", methods),
283
+ Route("/api/databases/{name}/processes/{ref}", process),
284
+ Route("/api/product-systems", systems),
285
+ Route(base, system),
286
+ Route(f"{base}/parameters", parameters),
287
+ Route(f"{base}/log", log),
288
+ Route(f"{base}/versions/{{hash}}", version),
289
+ Route(f"{base}/validation", validation),
290
+ Route(f"{base}/anchors", system_anchors),
291
+ Route(f"{base}/impact", calculation(IMPACT_TOOL), methods=["POST"]),
292
+ Route(f"{base}/contributions", calculation("contribution_analysis"), methods=["POST"]),
293
+ Route(f"{base}/inventory", calculation("calculate_inventory"), methods=["POST"]),
294
+ Route(f"{base}/runs", runs_listed),
295
+ Route(f"{base}/runs", calculation("start_monte_carlo"), methods=["POST"]),
296
+ Route("/api/runs/{run_id}", run_read),
297
+ Route("/api/runs/{run_id}/cancel", run_cancelled, methods=["POST"]),
298
+ Route(f"{base}/requests", requests_listed),
299
+ Route(f"{base}/requests", request_posted, methods=["POST"]),
300
+ Route(f"{base}/layout", layout),
301
+ Route(f"{base}/layout", place, methods=["POST"]),
302
+ Route(f"{base}/layout", release, methods=["DELETE"]),
303
+ ]
@@ -0,0 +1,159 @@
1
+ """The view application: the token gate, the routes and the page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import hmac
8
+ from collections.abc import AsyncIterator, Sequence
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from urllib.parse import parse_qsl
12
+
13
+ from fastmcp import FastMCP
14
+ from starlette.applications import Starlette
15
+ from starlette.requests import Request
16
+ from starlette.responses import FileResponse, HTMLResponse, Response
17
+ from starlette.routing import BaseRoute, Mount, Route
18
+ from starlette.staticfiles import StaticFiles
19
+ from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send
20
+
21
+ from ecosemantic.errors import NotFound, Unauthorised, Unavailable
22
+ from ecosemantic.events import pump_installs, pump_runs, pump_storage
23
+ from ecosemantic.mcp.context import ServerContext
24
+ from ecosemantic.view.activity import routes as activity_routes
25
+ from ecosemantic.view.api import GATE_PARAMETER
26
+ from ecosemantic.view.api import routes as api_routes
27
+ from ecosemantic.view.credentials import routes as credential_routes
28
+ from ecosemantic.view.handling import answering, error_response
29
+ from ecosemantic.view.installs import routes as install_routes
30
+ from ecosemantic.view.olca import routes as olca_routes
31
+ from ecosemantic.view.stream import routes as stream_routes
32
+ from ecosemantic.view.tables import routes as tables_routes
33
+
34
+ UNBUILT = (
35
+ "<!doctype html><title>EcoSemantic</title>"
36
+ "<p>The page is not built in this installation. The API answers under /api.</p>"
37
+ )
38
+
39
+
40
+ class TokenGate:
41
+ """Refuse a call under /api, or under a prefix the caller gates, whose token query parameter
42
+ is not the process's."""
43
+
44
+ def __init__(self, app: ASGIApp, token: str, gated: tuple[str, ...] = ()) -> None:
45
+ self.app = app
46
+ self.token = token
47
+ self.prefixes = ("/api", *gated)
48
+
49
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
50
+ if scope["type"] == "http" and scope["path"].startswith(self.prefixes):
51
+ query = dict(parse_qsl(scope.get("query_string", b"").decode()))
52
+ if not hmac.compare_digest(query.get(GATE_PARAMETER, ""), self.token):
53
+ refused = error_response(Unauthorised("the token in the link is required"))
54
+ await refused(scope, receive, send)
55
+ return
56
+ await self.app(scope, receive, send)
57
+
58
+
59
+ def static_dir() -> Path:
60
+ """Where the built page lives inside the package."""
61
+ return Path(str(resources.files("ecosemantic.view") / "static"))
62
+
63
+
64
+ def page_routes(static: Path) -> list[BaseRoute]:
65
+ """The built page with its assets, or the one line that says there is none."""
66
+ if (static / "index.html").is_file():
67
+
68
+ async def page(request: Request) -> Response:
69
+ return FileResponse(static / "index.html")
70
+
71
+ return [
72
+ Mount("/assets", app=StaticFiles(directory=static / "assets", check_dir=False)),
73
+ Route("/{path:path}", page),
74
+ ]
75
+
76
+ async def unbuilt(request: Request) -> Response:
77
+ return HTMLResponse(UNBUILT)
78
+
79
+ return [Route("/{path:path}", unbuilt)]
80
+
81
+
82
+ @answering
83
+ async def not_found(request: Request) -> Response:
84
+ """A JSON 404 for any `/api/...` path no route above this one matched."""
85
+ raise NotFound(f"no route /api/{request.path_params['path']}")
86
+
87
+
88
+ def pumping(context: ServerContext, inner: Lifespan[Starlette] | None) -> Lifespan[Starlette]:
89
+ """The application's lifespan: publish what other processes write for as long as it serves."""
90
+
91
+ @contextlib.asynccontextmanager
92
+ async def lifespan(app: Starlette) -> AsyncIterator[None]:
93
+ storage_ready = asyncio.Event()
94
+ runs_ready = asyncio.Event()
95
+ installs_ready = asyncio.Event()
96
+ pumps = [
97
+ asyncio.create_task(pump_storage(context.storage, context.events, storage_ready)),
98
+ asyncio.create_task(pump_runs(context.runs, context.events, runs_ready)),
99
+ asyncio.create_task(pump_installs(context.installs, context.events, installs_ready)),
100
+ ]
101
+ try:
102
+ await _pumps_started(pumps, (storage_ready, runs_ready, installs_ready))
103
+ if inner is None:
104
+ yield
105
+ else:
106
+ async with inner(app):
107
+ yield
108
+ finally:
109
+ for pump in pumps:
110
+ pump.cancel()
111
+ await asyncio.gather(*pumps, return_exceptions=True)
112
+
113
+ return lifespan
114
+
115
+
116
+ async def _pumps_started(pumps: list[asyncio.Task[None]], ready: tuple[asyncio.Event, ...]) -> None:
117
+ """Wait until every pump is armed and has taken the stored state as published, or raise the
118
+ error of one that ended first: a write the application goes on to serve is one a ready pump
119
+ publishes."""
120
+ waiting = asyncio.gather(*(event.wait() for event in ready))
121
+ done, _ = await asyncio.wait([*pumps, waiting], return_when=asyncio.FIRST_COMPLETED)
122
+ if waiting in done:
123
+ return
124
+ ended = await asyncio.gather(*pumps, return_exceptions=True)
125
+ for outcome in ended:
126
+ if isinstance(outcome, BaseException) and not isinstance(outcome, asyncio.CancelledError):
127
+ raise outcome
128
+ raise Unavailable("a pump of the application ended before it was ready")
129
+
130
+
131
+ def build_view(
132
+ context: ServerContext,
133
+ server: FastMCP,
134
+ token: str | None = None,
135
+ static: Path | None = None,
136
+ routes: Sequence[BaseRoute] = (),
137
+ gated: tuple[str, ...] = (),
138
+ inner: Lifespan[Starlette] | None = None,
139
+ ) -> ASGIApp:
140
+ """The page's API and the page. The caller's routes sit ahead of the page and its lifespan
141
+ runs inside the view's; the token gates /api and every prefix in gated."""
142
+ served: list[BaseRoute] = [
143
+ *api_routes(server, context),
144
+ *activity_routes(server, context),
145
+ *stream_routes(context),
146
+ *olca_routes(context),
147
+ *install_routes(server, context),
148
+ *credential_routes(context),
149
+ *tables_routes(server, context),
150
+ *routes,
151
+ Route(
152
+ "/api/{path:path}",
153
+ not_found,
154
+ methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
155
+ ),
156
+ *page_routes(static if static is not None else static_dir()),
157
+ ]
158
+ app = Starlette(routes=served, lifespan=pumping(context, inner))
159
+ return TokenGate(app, token, gated) if token else app
@@ -0,0 +1,81 @@
1
+ """The page's own credential routes: which kinds this server holds, one entered and one cleared.
2
+ They answer a peer on this machine only, since the page's token travels over plain HTTP."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import get_args
7
+
8
+ from pydantic import TypeAdapter, ValidationError
9
+ from starlette.requests import Request
10
+ from starlette.responses import JSONResponse, Response
11
+ from starlette.routing import Route
12
+
13
+ from ecosemantic.errors import Invalid, Unauthorised
14
+ from ecosemantic.mcp.context import ServerContext
15
+ from ecosemantic.ports.types import Account, CredentialKind
16
+ from ecosemantic.view.api import json_object
17
+ from ecosemantic.view.handling import answering
18
+
19
+ LOOPBACK_PEERS = frozenset({"127.0.0.1", "::1"})
20
+ HINT = "run ecosemantic setup, or reach the page through an SSH tunnel"
21
+ KIND = TypeAdapter(CredentialKind)
22
+ KINDS: tuple[str, ...] = get_args(CredentialKind)
23
+
24
+
25
+ def _peer(request: Request) -> None:
26
+ """Unauthorised unless the request comes from this machine."""
27
+ client = request.client
28
+ peer = "" if client is None else client.host
29
+ if peer not in LOOPBACK_PEERS:
30
+ raise Unauthorised("a credential is entered only from this machine", hint=HINT)
31
+
32
+
33
+ def _kind(name: str) -> CredentialKind:
34
+ """The credential kind a path names, Invalid for a name that is none of them."""
35
+ try:
36
+ return KIND.validate_python(name)
37
+ except ValidationError as error:
38
+ raise Invalid(
39
+ f"{name!r} is no credential kind; the kinds are {', '.join(KINDS)}"
40
+ ) from error
41
+
42
+
43
+ def routes(context: ServerContext) -> list[Route]:
44
+ """The kinds held, one kind entered and one cleared, from this machine only."""
45
+
46
+ @answering
47
+ async def held(request: Request) -> Response:
48
+ _peer(request)
49
+ return JSONResponse({"held": sorted(context.credentials)})
50
+
51
+ @answering
52
+ async def entered(request: Request) -> Response:
53
+ _peer(request)
54
+ kind = _kind(request.path_params["kind"])
55
+ body = await json_object(request)
56
+ try:
57
+ account = Account.model_validate(
58
+ {
59
+ "username": body.get("username"),
60
+ "password": body.get("password"),
61
+ "token": body.get("token"),
62
+ }
63
+ )
64
+ except ValidationError as error:
65
+ raise Invalid(
66
+ "a credential is a username and a password as text, and a token as text or none"
67
+ ) from error
68
+ context.credentials[kind] = account
69
+ return Response(status_code=204)
70
+
71
+ @answering
72
+ async def cleared(request: Request) -> Response:
73
+ _peer(request)
74
+ context.credentials.pop(_kind(request.path_params["kind"]), None)
75
+ return Response(status_code=204)
76
+
77
+ return [
78
+ Route("/api/credentials", held),
79
+ Route("/api/credentials/{kind}", entered, methods=["PUT"]),
80
+ Route("/api/credentials/{kind}", cleared, methods=["DELETE"]),
81
+ ]