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.
- ecosemantic/view/__init__.py +1 -0
- ecosemantic/view/activity.py +99 -0
- ecosemantic/view/api.py +303 -0
- ecosemantic/view/app.py +159 -0
- ecosemantic/view/credentials.py +81 -0
- ecosemantic/view/handling.py +146 -0
- ecosemantic/view/installs.py +125 -0
- ecosemantic/view/olca.py +62 -0
- ecosemantic/view/py.typed +0 -0
- ecosemantic/view/static/THIRD_PARTY_NOTICES.txt +2707 -0
- ecosemantic/view/static/assets/graph-DcSFYMYX.js +7 -0
- ecosemantic/view/static/assets/index-CF8_VBnk.js +49 -0
- ecosemantic/view/static/assets/index-D9m29h7N.css +1 -0
- ecosemantic/view/static/assets/logo-BTOAr_MC.svg +1 -0
- ecosemantic/view/static/assets/react-Di0xI-E3.js +3 -0
- ecosemantic/view/static/assets/streamWorker-9cS8ciPJ.js +1 -0
- ecosemantic/view/static/index.html +15 -0
- ecosemantic/view/stream.py +61 -0
- ecosemantic/view/tables.py +128 -0
- ecosemantic_view-0.1.8.dist-info/METADATA +60 -0
- ecosemantic_view-0.1.8.dist-info/RECORD +24 -0
- ecosemantic_view-0.1.8.dist-info/WHEEL +4 -0
- ecosemantic_view-0.1.8.dist-info/licenses/LICENSE +73 -0
- ecosemantic_view-0.1.8.dist-info/licenses/NOTICE +13 -0
|
@@ -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
|
+
]
|
ecosemantic/view/api.py
ADDED
|
@@ -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
|
+
]
|
ecosemantic/view/app.py
ADDED
|
@@ -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
|
+
]
|