arkitekt-fastapi 2.0.2__tar.gz → 2.1.0__tar.gz
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.
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/PKG-INFO +18 -3
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/README.md +15 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/__init__.py +2 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/__init__.py +2 -0
- arkitekt_fastapi-2.1.0/arkitekt_fastapi/route_groups/assets.py +119 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/schemas.py +8 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/routes.py +20 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/pyproject.toml +2 -2
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/.gitignore +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/agent.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/auth.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/detail_routes/__init__.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/detail_routes/states.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/detail_routes/tasks.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/models.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/openapi_utils.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/retriever/__init__.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/retriever/memory_retriever.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/retriever/protocol.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/retriever/replay.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/common.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/core.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/implementations.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/journal.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/locks.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/states.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/tasks.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sink/__init__.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sink/backend.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sink/memory_sink.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sink/protocol.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sql_lite/__init__.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sql_lite/retriever.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sql_lite/schema.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sql_lite/sink.py +0 -0
- {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/testing.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: arkitekt-fastapi
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: server mode for an Arkitekt app: serve a declared app over HTTP with FastAPI
|
|
5
5
|
Author-email: jhnnsrs <jhnnsrs@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Requires-Python: >=3.11
|
|
8
8
|
Requires-Dist: aiosqlite>=0.19
|
|
9
|
-
Requires-Dist: arkitekt-runtime>=4.
|
|
10
|
-
Requires-Dist: arkitekt-spec>=3.
|
|
9
|
+
Requires-Dist: arkitekt-runtime>=4.5
|
|
10
|
+
Requires-Dist: arkitekt-spec>=3.7
|
|
11
11
|
Requires-Dist: fastapi>=0.100
|
|
12
12
|
Requires-Dist: httpx>=0.27
|
|
13
13
|
Requires-Dist: jsonpatch>=1.33
|
|
@@ -37,4 +37,19 @@ websockets, no rath, no rekuest server.
|
|
|
37
37
|
A served app registers with no one and so cannot call other actions: `Task.call` raises
|
|
38
38
|
`NoCallerError` at once.
|
|
39
39
|
|
|
40
|
+
## Assets
|
|
41
|
+
|
|
42
|
+
The files an app registers with `register_asset` are served by the name its bloks call them
|
|
43
|
+
by (`@self.assets.<name>`):
|
|
44
|
+
|
|
45
|
+
- `GET /schemas/assets` lists them as they are declared: name, kind, reference, and the size
|
|
46
|
+
or the source.
|
|
47
|
+
- `GET /assets/<name>` is the file, with its reference as the `ETag` (`If-None-Match` answers
|
|
48
|
+
304). An asset registered with a `source` is not held by the app: the route redirects (307)
|
|
49
|
+
to that source and fetches nothing.
|
|
50
|
+
|
|
51
|
+
A file that changed on disk since it was registered is refused with 409, since a reader checks
|
|
52
|
+
the bytes against the reference. Like the schema routes, the asset routes ask for no user.
|
|
53
|
+
`configure_fastapi(..., add_assets=False)` leaves them out; `assets_path=` moves them.
|
|
54
|
+
|
|
40
55
|
`AsyncAgentTestClient` drives a served app in tests without a network.
|
|
@@ -20,4 +20,19 @@ websockets, no rath, no rekuest server.
|
|
|
20
20
|
A served app registers with no one and so cannot call other actions: `Task.call` raises
|
|
21
21
|
`NoCallerError` at once.
|
|
22
22
|
|
|
23
|
+
## Assets
|
|
24
|
+
|
|
25
|
+
The files an app registers with `register_asset` are served by the name its bloks call them
|
|
26
|
+
by (`@self.assets.<name>`):
|
|
27
|
+
|
|
28
|
+
- `GET /schemas/assets` lists them as they are declared: name, kind, reference, and the size
|
|
29
|
+
or the source.
|
|
30
|
+
- `GET /assets/<name>` is the file, with its reference as the `ETag` (`If-None-Match` answers
|
|
31
|
+
304). An asset registered with a `source` is not held by the app: the route redirects (307)
|
|
32
|
+
to that source and fetches nothing.
|
|
33
|
+
|
|
34
|
+
A file that changed on disk since it was registered is refused with 409, since a reader checks
|
|
35
|
+
the bytes against the reference. Like the schema routes, the asset routes ask for no user.
|
|
36
|
+
`configure_fastapi(..., add_assets=False)` leaves them out; `assets_path=` moves them.
|
|
37
|
+
|
|
23
38
|
`AsyncAgentTestClient` drives a served app in tests without a network.
|
|
@@ -12,6 +12,7 @@ from .auth import (
|
|
|
12
12
|
)
|
|
13
13
|
from .routes import (
|
|
14
14
|
add_agent_routes,
|
|
15
|
+
add_asset_routes,
|
|
15
16
|
add_implementation_routes,
|
|
16
17
|
add_lock_routes,
|
|
17
18
|
add_schema_routes,
|
|
@@ -40,6 +41,7 @@ __all__ = [
|
|
|
40
41
|
"configure_fastapi",
|
|
41
42
|
"create_lifespan",
|
|
42
43
|
"add_agent_routes",
|
|
44
|
+
"add_asset_routes",
|
|
43
45
|
"add_implementation_routes",
|
|
44
46
|
"add_lock_routes",
|
|
45
47
|
"add_schema_routes",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Grouped FastAPI route builders."""
|
|
2
2
|
|
|
3
|
+
from .assets import build_asset_router
|
|
3
4
|
from .core import build_core_router
|
|
4
5
|
from .implementations import add_implementation_route, build_implementation_router
|
|
5
6
|
from .journal import build_journal_router
|
|
@@ -9,6 +10,7 @@ from .states import build_state_router
|
|
|
9
10
|
from .tasks import build_task_router
|
|
10
11
|
|
|
11
12
|
__all__ = [
|
|
13
|
+
"build_asset_router",
|
|
12
14
|
"build_core_router",
|
|
13
15
|
"build_implementation_router",
|
|
14
16
|
"add_implementation_route",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Asset route builders: the files an app registered, served by name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
10
|
+
from fastapi.responses import FileResponse, RedirectResponse, Response
|
|
11
|
+
|
|
12
|
+
from arkitekt_spec.declare.app import AssetDeclaration
|
|
13
|
+
|
|
14
|
+
from arkitekt_fastapi.agent import FastApiAgent
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
#: What a file is served as, by its extension. A file with another extension (its
|
|
19
|
+
#: kind was said with ``kind=``) is served as bytes.
|
|
20
|
+
_CONTENT_TYPES: dict[str, str] = {
|
|
21
|
+
".glb": "model/gltf-binary",
|
|
22
|
+
".png": "image/png",
|
|
23
|
+
".jpg": "image/jpeg",
|
|
24
|
+
".jpeg": "image/jpeg",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _still_as_registered(asset: AssetDeclaration, path: Path) -> bool:
|
|
30
|
+
"""Whether the file at ``path`` still has the bytes its reference was computed from."""
|
|
31
|
+
try:
|
|
32
|
+
digest = hashlib.sha256()
|
|
33
|
+
with path.open("rb") as file:
|
|
34
|
+
for chunk in iter(lambda: file.read(1 << 20), b""):
|
|
35
|
+
digest.update(chunk)
|
|
36
|
+
except OSError:
|
|
37
|
+
return False
|
|
38
|
+
return "sha256:" + digest.hexdigest() == asset.ref
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _same_size(asset: AssetDeclaration, path: Path) -> bool:
|
|
42
|
+
try:
|
|
43
|
+
return path.stat().st_size == asset.size
|
|
44
|
+
except OSError:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_asset_router(
|
|
49
|
+
agent: FastApiAgent,
|
|
50
|
+
assets_path: str = "/assets",
|
|
51
|
+
) -> APIRouter:
|
|
52
|
+
"""Build the routes serving the app's registered assets.
|
|
53
|
+
|
|
54
|
+
A blok names an asset as ``@self.assets.<name>``; ``{assets_path}/<name>`` is
|
|
55
|
+
that file. A file the app brings is served from where it was registered, with
|
|
56
|
+
its reference as the ETag. A file the app only names a source for is answered
|
|
57
|
+
with a redirect to that source: nothing is fetched here.
|
|
58
|
+
|
|
59
|
+
Every held file is hashed once, now. One that no longer has the bytes it was
|
|
60
|
+
registered with is refused when asked for, since whoever reads it checks it
|
|
61
|
+
against the reference and would only see that check fail.
|
|
62
|
+
"""
|
|
63
|
+
router = APIRouter(tags=["Assets"])
|
|
64
|
+
assets = dict(agent.app_registry.registered_assets)
|
|
65
|
+
changed: set[str] = set()
|
|
66
|
+
for name, asset in assets.items():
|
|
67
|
+
if asset.path is not None and not _still_as_registered(asset, asset.path):
|
|
68
|
+
changed.add(name)
|
|
69
|
+
logger.warning(
|
|
70
|
+
"The asset '%s' (%s) changed since it was registered: it is not served",
|
|
71
|
+
name,
|
|
72
|
+
asset.where,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def get_asset(name: str, request: Request) -> Response:
|
|
76
|
+
asset = assets.get(name)
|
|
77
|
+
if asset is None:
|
|
78
|
+
raise HTTPException(status_code=404, detail=f"No asset is registered as '{name}'")
|
|
79
|
+
if asset.path is None:
|
|
80
|
+
return RedirectResponse(str(asset.source), status_code=307)
|
|
81
|
+
if name in changed or not _same_size(asset, asset.path):
|
|
82
|
+
raise HTTPException(
|
|
83
|
+
status_code=409,
|
|
84
|
+
detail=(
|
|
85
|
+
f"The asset '{name}' ({asset.where}) changed since it was "
|
|
86
|
+
f"registered: restart the app"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
etag = f'"{asset.ref}"'
|
|
90
|
+
headers = {"ETag": etag, "Cache-Control": "no-cache"}
|
|
91
|
+
asked = request.headers.get("if-none-match", "")
|
|
92
|
+
if etag in (tag.strip().removeprefix("W/") for tag in asked.split(",")):
|
|
93
|
+
return Response(status_code=304, headers=headers)
|
|
94
|
+
return FileResponse(
|
|
95
|
+
asset.path,
|
|
96
|
+
media_type=_CONTENT_TYPES.get(
|
|
97
|
+
asset.path.suffix.lower(), "application/octet-stream"
|
|
98
|
+
),
|
|
99
|
+
headers=headers,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
router.add_api_route(
|
|
103
|
+
f"{assets_path}/{{name}}",
|
|
104
|
+
get_asset,
|
|
105
|
+
methods=["GET"],
|
|
106
|
+
response_class=Response,
|
|
107
|
+
responses={
|
|
108
|
+
200: {"description": "The file."},
|
|
109
|
+
304: {"description": "The file is the one the caller already has."},
|
|
110
|
+
307: {"description": "The app does not hold the file: its source."},
|
|
111
|
+
404: {"description": "No asset is registered under this name."},
|
|
112
|
+
409: {"description": "The file changed since it was registered."},
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
# HEAD is the same answer without the body; one entry in the OpenAPI schema.
|
|
116
|
+
router.add_api_route(
|
|
117
|
+
f"{assets_path}/{{name}}", get_asset, methods=["HEAD"], include_in_schema=False
|
|
118
|
+
)
|
|
119
|
+
return router
|
|
@@ -31,10 +31,18 @@ def build_schema_router(
|
|
|
31
31
|
blok_schemas = agent.app_registry.get_declared_bloks()
|
|
32
32
|
return {"count": len(blok_schemas), "bloks": blok_schemas}
|
|
33
33
|
|
|
34
|
+
async def get_asset_schemas() -> dict:
|
|
35
|
+
assets = [
|
|
36
|
+
asset.model_dump(mode="json", exclude_none=True)
|
|
37
|
+
for asset in agent.app_registry.get_declared_assets()
|
|
38
|
+
]
|
|
39
|
+
return {"count": len(assets), "assets": assets}
|
|
40
|
+
|
|
34
41
|
router.add_api_route(
|
|
35
42
|
"/schemas/implementations", get_implementation_schemas, methods=["GET"]
|
|
36
43
|
)
|
|
37
44
|
router.add_api_route("/schemas/states", get_state_schemas, methods=["GET"])
|
|
38
45
|
router.add_api_route("/schemas/locks", get_lock_schemas, methods=["GET"])
|
|
39
46
|
router.add_api_route("/schemas/bloks", get_blok_schemas, methods=["GET"])
|
|
47
|
+
router.add_api_route("/schemas/assets", get_asset_schemas, methods=["GET"])
|
|
40
48
|
return router
|
|
@@ -25,6 +25,7 @@ from arkitekt_fastapi.openapi_utils import (
|
|
|
25
25
|
)
|
|
26
26
|
from arkitekt_fastapi.route_groups import (
|
|
27
27
|
add_implementation_route,
|
|
28
|
+
build_asset_router,
|
|
28
29
|
build_core_router,
|
|
29
30
|
build_implementation_router,
|
|
30
31
|
build_journal_router,
|
|
@@ -233,6 +234,15 @@ def add_schema_routes(
|
|
|
233
234
|
_include_router(app, build_schema_router(agent))
|
|
234
235
|
|
|
235
236
|
|
|
237
|
+
def add_asset_routes(
|
|
238
|
+
app: FastAPI,
|
|
239
|
+
agent: FastApiAgent,
|
|
240
|
+
assets_path: str = "/assets",
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Include the routes serving the app's registered assets by name."""
|
|
243
|
+
_include_router(app, build_asset_router(agent, assets_path=assets_path))
|
|
244
|
+
|
|
245
|
+
|
|
236
246
|
def configure_fastapi(
|
|
237
247
|
app: FastAPI,
|
|
238
248
|
app_registry: AppRegistry,
|
|
@@ -246,11 +256,13 @@ def configure_fastapi(
|
|
|
246
256
|
add_tasks: bool = True,
|
|
247
257
|
add_task_details: bool = True,
|
|
248
258
|
add_journal: bool = True,
|
|
259
|
+
add_assets: bool = True,
|
|
249
260
|
ws_path: str = "/ws",
|
|
250
261
|
tasks_path: str = "/tasks",
|
|
251
262
|
assign_path: str = "/assign",
|
|
252
263
|
states_path: str = "/states",
|
|
253
264
|
locks_path: str = "/locks",
|
|
265
|
+
assets_path: str = "/assets",
|
|
254
266
|
db_file: str = "agent_data.db",
|
|
255
267
|
app_context: Any | None = None,
|
|
256
268
|
bound_app: Any | None = None,
|
|
@@ -284,6 +296,11 @@ def configure_fastapi(
|
|
|
284
296
|
absent, in which case the websocket stays unauthenticated as before.
|
|
285
297
|
add_journal: Serve the journal routes (``/journal…`` and
|
|
286
298
|
``{tasks_path}/{task_id}/events``, see ``docs/journal.md``).
|
|
299
|
+
add_assets: Serve the files the app registered with ``register_asset``,
|
|
300
|
+
each at ``{assets_path}/<name>``: the file itself, or a redirect to
|
|
301
|
+
the source of one the app does not hold. Like the schema routes,
|
|
302
|
+
they ask for no user.
|
|
303
|
+
assets_path: Where the assets are served.
|
|
287
304
|
"""
|
|
288
305
|
# Imported here: the sqlite retriever reads the protocol under this
|
|
289
306
|
# package, so a module-level import would be circular.
|
|
@@ -331,6 +348,8 @@ def configure_fastapi(
|
|
|
331
348
|
get_user_from_request=get_user_from_request,
|
|
332
349
|
expand_user_from_request=expand_user_from_request,
|
|
333
350
|
)
|
|
351
|
+
if add_assets:
|
|
352
|
+
add_asset_routes(fastapi_app, agent, assets_path=assets_path)
|
|
334
353
|
if add_schema:
|
|
335
354
|
add_schema_routes(fastapi_app, agent)
|
|
336
355
|
configure_openapi(fastapi_app)
|
|
@@ -348,6 +367,7 @@ def configure_fastapi(
|
|
|
348
367
|
|
|
349
368
|
__all__ = [
|
|
350
369
|
"add_agent_routes",
|
|
370
|
+
"add_asset_routes",
|
|
351
371
|
"add_implementation_route",
|
|
352
372
|
"add_implementation_routes",
|
|
353
373
|
"add_journal_routes",
|
|
@@ -9,8 +9,8 @@ license = "MIT"
|
|
|
9
9
|
# Serving an app needs the execution core and FastAPI, not the distributed runtime:
|
|
10
10
|
# no rekuest, no websockets client, no rath, no graphql-core.
|
|
11
11
|
dependencies = [
|
|
12
|
-
"arkitekt-spec>=3.
|
|
13
|
-
"arkitekt-runtime>=4.
|
|
12
|
+
"arkitekt-spec>=3.7",
|
|
13
|
+
"arkitekt-runtime>=4.5",
|
|
14
14
|
"pydantic>=2.11",
|
|
15
15
|
"koil>=3.3.6",
|
|
16
16
|
"fastapi>=0.100",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/detail_routes/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/retriever/memory_retriever.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/implementations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|