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.
Files changed (36) hide show
  1. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/PKG-INFO +18 -3
  2. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/README.md +15 -0
  3. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/__init__.py +2 -0
  4. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/__init__.py +2 -0
  5. arkitekt_fastapi-2.1.0/arkitekt_fastapi/route_groups/assets.py +119 -0
  6. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/schemas.py +8 -0
  7. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/routes.py +20 -0
  8. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/pyproject.toml +2 -2
  9. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/.gitignore +0 -0
  10. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/agent.py +0 -0
  11. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/auth.py +0 -0
  12. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/detail_routes/__init__.py +0 -0
  13. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/detail_routes/states.py +0 -0
  14. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/detail_routes/tasks.py +0 -0
  15. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/models.py +0 -0
  16. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/openapi_utils.py +0 -0
  17. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/retriever/__init__.py +0 -0
  18. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/retriever/memory_retriever.py +0 -0
  19. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/retriever/protocol.py +0 -0
  20. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/retriever/replay.py +0 -0
  21. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/common.py +0 -0
  22. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/core.py +0 -0
  23. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/implementations.py +0 -0
  24. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/journal.py +0 -0
  25. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/locks.py +0 -0
  26. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/states.py +0 -0
  27. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/route_groups/tasks.py +0 -0
  28. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sink/__init__.py +0 -0
  29. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sink/backend.py +0 -0
  30. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sink/memory_sink.py +0 -0
  31. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sink/protocol.py +0 -0
  32. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sql_lite/__init__.py +0 -0
  33. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sql_lite/retriever.py +0 -0
  34. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sql_lite/schema.py +0 -0
  35. {arkitekt_fastapi-2.0.2 → arkitekt_fastapi-2.1.0}/arkitekt_fastapi/sql_lite/sink.py +0 -0
  36. {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.2
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.3
10
- Requires-Dist: arkitekt-spec>=3.4
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.4",
13
- "arkitekt-runtime>=4.3",
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",