z4j-fastapi 1.0.0__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.
- z4j_fastapi/__init__.py +53 -0
- z4j_fastapi/extension.py +376 -0
- z4j_fastapi/framework.py +381 -0
- z4j_fastapi/py.typed +0 -0
- z4j_fastapi-1.0.0.dist-info/METADATA +128 -0
- z4j_fastapi-1.0.0.dist-info/RECORD +8 -0
- z4j_fastapi-1.0.0.dist-info/WHEEL +4 -0
- z4j_fastapi-1.0.0.dist-info/licenses/LICENSE +201 -0
z4j_fastapi/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""z4j-fastapi - FastAPI framework adapter for z4j.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
- :func:`z4j_lifespan` - returns a lifespan context manager that starts
|
|
6
|
+
and stops the z4j agent. Pass it to ``FastAPI(lifespan=...)``.
|
|
7
|
+
- :func:`install_z4j` - manual install for apps that cannot use lifespan.
|
|
8
|
+
- :func:`get_runtime` - retrieve the running agent runtime.
|
|
9
|
+
- :class:`FastAPIFrameworkAdapter` - the framework adapter implementation.
|
|
10
|
+
|
|
11
|
+
Typical usage (lifespan, recommended)::
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI
|
|
14
|
+
from z4j_fastapi import z4j_lifespan
|
|
15
|
+
|
|
16
|
+
app = FastAPI(lifespan=z4j_lifespan(
|
|
17
|
+
brain_url="http://localhost:7700",
|
|
18
|
+
token="your-token",
|
|
19
|
+
))
|
|
20
|
+
|
|
21
|
+
Licensed under Apache License 2.0.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from z4j_fastapi.extension import get_runtime, install_z4j, z4j_lifespan
|
|
27
|
+
from z4j_fastapi.framework import FastAPIFrameworkAdapter
|
|
28
|
+
|
|
29
|
+
# Importing ``z4j_celery`` (if installed) registers the Celery
|
|
30
|
+
# ``worker_init`` signal handler that auto-bootstraps the agent
|
|
31
|
+
# inside Celery worker processes. Without this, FastAPI apps that
|
|
32
|
+
# run their Celery workers via ``celery -A app:celery_app worker``
|
|
33
|
+
# never register as z4j agents - the lifespan only fires under
|
|
34
|
+
# uvicorn, not under the Celery worker command.
|
|
35
|
+
#
|
|
36
|
+
# This mirrors the Django AppConfig's auto-bootstrap behaviour so
|
|
37
|
+
# FastAPI workers are first-class citizens in the agent registry.
|
|
38
|
+
# If z4j_celery is not installed, the import is silently skipped
|
|
39
|
+
# and nothing changes.
|
|
40
|
+
try:
|
|
41
|
+
import z4j_celery # noqa: F401 (imported for its side-effects)
|
|
42
|
+
except ImportError:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
__version__ = "1.0.0"
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"FastAPIFrameworkAdapter",
|
|
49
|
+
"__version__",
|
|
50
|
+
"get_runtime",
|
|
51
|
+
"install_z4j",
|
|
52
|
+
"z4j_lifespan",
|
|
53
|
+
]
|
z4j_fastapi/extension.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""FastAPI integration entry points.
|
|
2
|
+
|
|
3
|
+
Provides two usage patterns for wiring z4j into a FastAPI application:
|
|
4
|
+
|
|
5
|
+
**Pattern 1 - lifespan (recommended)**::
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from z4j_fastapi import z4j_lifespan
|
|
9
|
+
|
|
10
|
+
app = FastAPI(lifespan=z4j_lifespan(
|
|
11
|
+
brain_url="http://localhost:7700",
|
|
12
|
+
token="your-token",
|
|
13
|
+
celery_app=celery_app,
|
|
14
|
+
))
|
|
15
|
+
|
|
16
|
+
**Pattern 2 - manual install**::
|
|
17
|
+
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
from z4j_fastapi import install_z4j
|
|
20
|
+
|
|
21
|
+
app = FastAPI()
|
|
22
|
+
install_z4j(app, brain_url="http://localhost:7700", token="your-token")
|
|
23
|
+
|
|
24
|
+
Both patterns wrap all z4j work in try/except so that a failure inside
|
|
25
|
+
z4j never crashes the FastAPI application. The host app is more
|
|
26
|
+
important than our observability tool.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import atexit
|
|
32
|
+
import logging
|
|
33
|
+
import os
|
|
34
|
+
from collections.abc import AsyncIterator, Callable
|
|
35
|
+
from contextlib import asynccontextmanager
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from z4j_bare.runtime import AgentRuntime
|
|
40
|
+
|
|
41
|
+
from z4j_fastapi.framework import (
|
|
42
|
+
FastAPIFrameworkAdapter,
|
|
43
|
+
discover_engines,
|
|
44
|
+
discover_schedulers,
|
|
45
|
+
resolve_config,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger("z4j.agent.fastapi.extension")
|
|
49
|
+
|
|
50
|
+
# Module-level state - there is at most one runtime per process.
|
|
51
|
+
_runtime: AgentRuntime | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def z4j_lifespan(
|
|
55
|
+
*,
|
|
56
|
+
brain_url: str | None = None,
|
|
57
|
+
token: str | None = None,
|
|
58
|
+
project_id: str | None = None,
|
|
59
|
+
celery_app: Any = None,
|
|
60
|
+
hmac_secret: str | None = None,
|
|
61
|
+
environment: str | None = None,
|
|
62
|
+
transport: str | None = None,
|
|
63
|
+
log_level: str | None = None,
|
|
64
|
+
engines: list[str] | None = None,
|
|
65
|
+
schedulers: list[str] | None = None,
|
|
66
|
+
tags: dict[str, str] | None = None,
|
|
67
|
+
dev_mode: bool | None = None,
|
|
68
|
+
strict_mode: bool | None = None,
|
|
69
|
+
autostart: bool | None = None,
|
|
70
|
+
heartbeat_seconds: int | None = None,
|
|
71
|
+
buffer_path: str | Path | None = None,
|
|
72
|
+
buffer_max_events: int | None = None,
|
|
73
|
+
buffer_max_bytes: int | None = None,
|
|
74
|
+
max_payload_bytes: int | None = None,
|
|
75
|
+
inner_lifespan: Callable[..., Any] | None = None,
|
|
76
|
+
) -> Callable[..., AsyncIterator[None]]:
|
|
77
|
+
"""Return a lifespan context manager that starts and stops z4j.
|
|
78
|
+
|
|
79
|
+
This is the recommended integration pattern for FastAPI >= 0.135.
|
|
80
|
+
Pass the return value directly to ``FastAPI(lifespan=...)``.
|
|
81
|
+
|
|
82
|
+
If the application already has its own lifespan, pass it as
|
|
83
|
+
``inner_lifespan`` and z4j will wrap it::
|
|
84
|
+
|
|
85
|
+
app = FastAPI(lifespan=z4j_lifespan(
|
|
86
|
+
brain_url="...",
|
|
87
|
+
token="...",
|
|
88
|
+
inner_lifespan=my_existing_lifespan,
|
|
89
|
+
))
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
brain_url: URL of the z4j brain.
|
|
93
|
+
token: Project-scoped bearer token.
|
|
94
|
+
project_id: Project slug (default ``"default"``).
|
|
95
|
+
celery_app: The Celery application instance, if using Celery.
|
|
96
|
+
hmac_secret: Shared HMAC secret for command verification.
|
|
97
|
+
environment: Environment label (e.g. ``"production"``).
|
|
98
|
+
transport: Transport mode (``"auto"``, ``"ws"``, ``"longpoll"``).
|
|
99
|
+
log_level: Agent log level.
|
|
100
|
+
engines: Engine adapter names to register.
|
|
101
|
+
schedulers: Scheduler adapter names to register.
|
|
102
|
+
tags: Per-deployment tags.
|
|
103
|
+
dev_mode: Enable development mode.
|
|
104
|
+
strict_mode: Fail fast on config problems.
|
|
105
|
+
autostart: Whether the runtime starts automatically.
|
|
106
|
+
heartbeat_seconds: Heartbeat interval.
|
|
107
|
+
buffer_path: On-disk SQLite buffer path.
|
|
108
|
+
buffer_max_events: Max buffered events.
|
|
109
|
+
buffer_max_bytes: Max buffer file size.
|
|
110
|
+
max_payload_bytes: Per-field truncation limit.
|
|
111
|
+
inner_lifespan: An existing lifespan to compose with.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
An async context manager suitable for ``FastAPI(lifespan=...)``.
|
|
115
|
+
"""
|
|
116
|
+
config_kwargs: dict[str, Any] = {}
|
|
117
|
+
# Collect all non-None config kwargs.
|
|
118
|
+
_set_if_not_none(config_kwargs, "brain_url", brain_url)
|
|
119
|
+
_set_if_not_none(config_kwargs, "token", token)
|
|
120
|
+
_set_if_not_none(config_kwargs, "project_id", project_id)
|
|
121
|
+
_set_if_not_none(config_kwargs, "hmac_secret", hmac_secret)
|
|
122
|
+
_set_if_not_none(config_kwargs, "environment", environment)
|
|
123
|
+
_set_if_not_none(config_kwargs, "transport", transport)
|
|
124
|
+
_set_if_not_none(config_kwargs, "log_level", log_level)
|
|
125
|
+
_set_if_not_none(config_kwargs, "engines", engines)
|
|
126
|
+
_set_if_not_none(config_kwargs, "schedulers", schedulers)
|
|
127
|
+
_set_if_not_none(config_kwargs, "tags", tags)
|
|
128
|
+
_set_if_not_none(config_kwargs, "dev_mode", dev_mode)
|
|
129
|
+
_set_if_not_none(config_kwargs, "strict_mode", strict_mode)
|
|
130
|
+
_set_if_not_none(config_kwargs, "autostart", autostart)
|
|
131
|
+
_set_if_not_none(config_kwargs, "heartbeat_seconds", heartbeat_seconds)
|
|
132
|
+
_set_if_not_none(config_kwargs, "buffer_path", buffer_path)
|
|
133
|
+
_set_if_not_none(config_kwargs, "buffer_max_events", buffer_max_events)
|
|
134
|
+
_set_if_not_none(config_kwargs, "buffer_max_bytes", buffer_max_bytes)
|
|
135
|
+
_set_if_not_none(config_kwargs, "max_payload_bytes", max_payload_bytes)
|
|
136
|
+
|
|
137
|
+
@asynccontextmanager
|
|
138
|
+
async def _lifespan(app: Any) -> AsyncIterator[None]:
|
|
139
|
+
# Start z4j - wrapped so failures never crash the FastAPI app.
|
|
140
|
+
runtime = _safe_start(config_kwargs, celery_app)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
if inner_lifespan is not None:
|
|
144
|
+
async with inner_lifespan(app):
|
|
145
|
+
yield
|
|
146
|
+
else:
|
|
147
|
+
yield
|
|
148
|
+
finally:
|
|
149
|
+
# Stop z4j on shutdown.
|
|
150
|
+
_safe_stop(runtime)
|
|
151
|
+
|
|
152
|
+
return _lifespan
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def install_z4j(
|
|
156
|
+
app: Any,
|
|
157
|
+
*,
|
|
158
|
+
brain_url: str | None = None,
|
|
159
|
+
token: str | None = None,
|
|
160
|
+
project_id: str | None = None,
|
|
161
|
+
celery_app: Any = None,
|
|
162
|
+
hmac_secret: str | None = None,
|
|
163
|
+
environment: str | None = None,
|
|
164
|
+
transport: str | None = None,
|
|
165
|
+
log_level: str | None = None,
|
|
166
|
+
engines: list[str] | None = None,
|
|
167
|
+
schedulers: list[str] | None = None,
|
|
168
|
+
tags: dict[str, str] | None = None,
|
|
169
|
+
dev_mode: bool | None = None,
|
|
170
|
+
strict_mode: bool | None = None,
|
|
171
|
+
autostart: bool | None = None,
|
|
172
|
+
heartbeat_seconds: int | None = None,
|
|
173
|
+
buffer_path: str | Path | None = None,
|
|
174
|
+
buffer_max_events: int | None = None,
|
|
175
|
+
buffer_max_bytes: int | None = None,
|
|
176
|
+
max_payload_bytes: int | None = None,
|
|
177
|
+
) -> AgentRuntime | None:
|
|
178
|
+
"""Install z4j agent into a FastAPI app (manual pattern).
|
|
179
|
+
|
|
180
|
+
This is the fallback pattern for applications that cannot use the
|
|
181
|
+
lifespan approach (e.g. because of legacy code). It starts the
|
|
182
|
+
runtime immediately and registers an ``atexit`` hook to stop it
|
|
183
|
+
when the process exits.
|
|
184
|
+
|
|
185
|
+
Audit H16: this path now hooks BOTH ``app.add_event_handler
|
|
186
|
+
("shutdown", ...)`` AND ``atexit``. The shutdown event handler
|
|
187
|
+
is what fires under uvicorn / gunicorn's graceful SIGTERM
|
|
188
|
+
handling (i.e. K8s rolling restarts); ``atexit`` is the
|
|
189
|
+
fallback for processes that exit without ASGI lifespan
|
|
190
|
+
teardown. Either path runs the same ``runtime.stop(...)`` so
|
|
191
|
+
buffered events flush before the process dies.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
app: The FastAPI application instance. Stored on ``app.state.z4j_runtime``
|
|
195
|
+
for access by middleware or route handlers.
|
|
196
|
+
brain_url: URL of the z4j brain.
|
|
197
|
+
token: Project-scoped bearer token.
|
|
198
|
+
project_id: Project slug.
|
|
199
|
+
celery_app: The Celery application instance, if using Celery.
|
|
200
|
+
hmac_secret: Shared HMAC secret for command verification.
|
|
201
|
+
environment: Environment label.
|
|
202
|
+
transport: Transport mode.
|
|
203
|
+
log_level: Agent log level.
|
|
204
|
+
engines: Engine adapter names.
|
|
205
|
+
schedulers: Scheduler adapter names.
|
|
206
|
+
tags: Per-deployment tags.
|
|
207
|
+
dev_mode: Enable development mode.
|
|
208
|
+
strict_mode: Fail fast on config problems.
|
|
209
|
+
autostart: Whether the runtime starts automatically.
|
|
210
|
+
heartbeat_seconds: Heartbeat interval.
|
|
211
|
+
buffer_path: On-disk SQLite buffer path.
|
|
212
|
+
buffer_max_events: Max buffered events.
|
|
213
|
+
buffer_max_bytes: Max buffer file size.
|
|
214
|
+
max_payload_bytes: Per-field truncation limit.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
The running AgentRuntime, or None if startup failed.
|
|
218
|
+
"""
|
|
219
|
+
config_kwargs: dict[str, Any] = {}
|
|
220
|
+
_set_if_not_none(config_kwargs, "brain_url", brain_url)
|
|
221
|
+
_set_if_not_none(config_kwargs, "token", token)
|
|
222
|
+
_set_if_not_none(config_kwargs, "project_id", project_id)
|
|
223
|
+
_set_if_not_none(config_kwargs, "hmac_secret", hmac_secret)
|
|
224
|
+
_set_if_not_none(config_kwargs, "environment", environment)
|
|
225
|
+
_set_if_not_none(config_kwargs, "transport", transport)
|
|
226
|
+
_set_if_not_none(config_kwargs, "log_level", log_level)
|
|
227
|
+
_set_if_not_none(config_kwargs, "engines", engines)
|
|
228
|
+
_set_if_not_none(config_kwargs, "schedulers", schedulers)
|
|
229
|
+
_set_if_not_none(config_kwargs, "tags", tags)
|
|
230
|
+
_set_if_not_none(config_kwargs, "dev_mode", dev_mode)
|
|
231
|
+
_set_if_not_none(config_kwargs, "strict_mode", strict_mode)
|
|
232
|
+
_set_if_not_none(config_kwargs, "autostart", autostart)
|
|
233
|
+
_set_if_not_none(config_kwargs, "heartbeat_seconds", heartbeat_seconds)
|
|
234
|
+
_set_if_not_none(config_kwargs, "buffer_path", buffer_path)
|
|
235
|
+
_set_if_not_none(config_kwargs, "buffer_max_events", buffer_max_events)
|
|
236
|
+
_set_if_not_none(config_kwargs, "buffer_max_bytes", buffer_max_bytes)
|
|
237
|
+
_set_if_not_none(config_kwargs, "max_payload_bytes", max_payload_bytes)
|
|
238
|
+
|
|
239
|
+
runtime = _safe_start(config_kwargs, celery_app)
|
|
240
|
+
if runtime is not None:
|
|
241
|
+
# Stash on the app so middleware/routes can reach the runtime.
|
|
242
|
+
app.state.z4j_runtime = runtime
|
|
243
|
+
atexit.register(_atexit_stop)
|
|
244
|
+
|
|
245
|
+
# Audit H16: also hook the FastAPI shutdown event so SIGTERM
|
|
246
|
+
# under uvicorn / gunicorn / k8s gets a clean stop with
|
|
247
|
+
# buffer flush. The handler is best-effort; an exception
|
|
248
|
+
# here must never block the ASGI shutdown.
|
|
249
|
+
async def _on_app_shutdown() -> None:
|
|
250
|
+
try:
|
|
251
|
+
_safe_stop(runtime)
|
|
252
|
+
except Exception: # noqa: BLE001
|
|
253
|
+
logger.exception(
|
|
254
|
+
"z4j: error during FastAPI shutdown handler",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
app.add_event_handler("shutdown", _on_app_shutdown)
|
|
259
|
+
except Exception: # noqa: BLE001
|
|
260
|
+
# Some FastAPI subclasses or test doubles may not
|
|
261
|
+
# support add_event_handler. Fall back to atexit only.
|
|
262
|
+
logger.debug(
|
|
263
|
+
"z4j: app.add_event_handler unavailable, "
|
|
264
|
+
"atexit-only shutdown",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return runtime
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_runtime() -> AgentRuntime | None:
|
|
271
|
+
"""Return the running agent runtime, if any.
|
|
272
|
+
|
|
273
|
+
Used by tests and by application code that wants to flush the
|
|
274
|
+
buffer manually or check the runtime state.
|
|
275
|
+
"""
|
|
276
|
+
return _runtime
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
# Internal helpers
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _safe_start(
|
|
285
|
+
config_kwargs: dict[str, Any],
|
|
286
|
+
celery_app: Any,
|
|
287
|
+
) -> AgentRuntime | None:
|
|
288
|
+
"""Build and start the runtime, catching all errors.
|
|
289
|
+
|
|
290
|
+
Returns the running runtime, or None if anything went wrong. Never
|
|
291
|
+
raises - z4j must not crash the host application.
|
|
292
|
+
"""
|
|
293
|
+
# Allow tests and tooling to disable the autostart entirely.
|
|
294
|
+
if os.environ.get("Z4J_DISABLED", "").lower() in ("1", "true", "yes", "on"):
|
|
295
|
+
logger.info("z4j: Z4J_DISABLED is set; skipping agent startup")
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
global _runtime
|
|
299
|
+
if _runtime is not None:
|
|
300
|
+
return _runtime # already started in this process
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
runtime = _build_and_start_runtime(config_kwargs, celery_app)
|
|
304
|
+
except Exception: # noqa: BLE001
|
|
305
|
+
logger.exception("z4j: failed to start agent runtime; continuing without it")
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
# Cooperate with other in-process install paths (e.g. a Celery
|
|
309
|
+
# worker_init signal in the same process also tries to install).
|
|
310
|
+
# Whoever registered first keeps the live WebSocket; we drop our
|
|
311
|
+
# freshly-built runtime if we lost the race.
|
|
312
|
+
from z4j_bare._process_singleton import try_register
|
|
313
|
+
active = try_register(runtime, owner="z4j_fastapi.extension")
|
|
314
|
+
if active is not runtime:
|
|
315
|
+
# We built + started a runtime, then lost the race. Stop
|
|
316
|
+
# the local copy so we don't leak a zombie WS connection.
|
|
317
|
+
try:
|
|
318
|
+
runtime.stop(timeout=2.0)
|
|
319
|
+
except Exception: # noqa: BLE001
|
|
320
|
+
logger.exception("z4j: error stopping duplicate runtime")
|
|
321
|
+
_runtime = active
|
|
322
|
+
return active
|
|
323
|
+
|
|
324
|
+
_runtime = runtime
|
|
325
|
+
logger.info("z4j: agent runtime started for fastapi")
|
|
326
|
+
return runtime
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _build_and_start_runtime(
|
|
330
|
+
config_kwargs: dict[str, Any],
|
|
331
|
+
celery_app: Any,
|
|
332
|
+
) -> AgentRuntime:
|
|
333
|
+
"""Resolve config, discover adapters, build the runtime, start it."""
|
|
334
|
+
config = resolve_config(**config_kwargs)
|
|
335
|
+
framework = FastAPIFrameworkAdapter(config)
|
|
336
|
+
engine_list = discover_engines(celery_app)
|
|
337
|
+
scheduler_list = discover_schedulers(celery_app)
|
|
338
|
+
|
|
339
|
+
runtime = AgentRuntime(
|
|
340
|
+
config=config,
|
|
341
|
+
framework=framework,
|
|
342
|
+
engines=engine_list,
|
|
343
|
+
schedulers=scheduler_list,
|
|
344
|
+
)
|
|
345
|
+
if config.autostart:
|
|
346
|
+
runtime.start()
|
|
347
|
+
framework.fire_startup()
|
|
348
|
+
return runtime
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _safe_stop(runtime: AgentRuntime | None) -> None:
|
|
352
|
+
"""Stop the runtime, swallowing errors. Never raises."""
|
|
353
|
+
global _runtime
|
|
354
|
+
if runtime is None:
|
|
355
|
+
return
|
|
356
|
+
try:
|
|
357
|
+
runtime.stop(timeout=5.0)
|
|
358
|
+
except Exception: # noqa: BLE001
|
|
359
|
+
logger.exception("z4j: error during shutdown")
|
|
360
|
+
finally:
|
|
361
|
+
if _runtime is runtime:
|
|
362
|
+
_runtime = None
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _atexit_stop() -> None:
|
|
366
|
+
"""``atexit`` handler that flushes the buffer and stops the runtime."""
|
|
367
|
+
_safe_stop(_runtime)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _set_if_not_none(d: dict[str, Any], key: str, value: Any) -> None:
|
|
371
|
+
"""Set ``d[key] = value`` only if ``value is not None``."""
|
|
372
|
+
if value is not None:
|
|
373
|
+
d[key] = value
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
__all__ = ["get_runtime", "install_z4j", "z4j_lifespan"]
|
z4j_fastapi/framework.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""The :class:`FastAPIFrameworkAdapter`.
|
|
2
|
+
|
|
3
|
+
Implements :class:`z4j_core.protocols.FrameworkAdapter` for FastAPI.
|
|
4
|
+
The adapter is constructed inside :func:`extension.install_z4j` or
|
|
5
|
+
:func:`extension.z4j_lifespan`, which resolve configuration from
|
|
6
|
+
explicit kwargs and environment variables, then hand the resulting
|
|
7
|
+
Config to the adapter and the agent runtime.
|
|
8
|
+
|
|
9
|
+
FastAPI has no app-level settings dict like Django's ``settings.Z4J``.
|
|
10
|
+
Configuration comes from either:
|
|
11
|
+
|
|
12
|
+
1. Explicit kwargs passed to ``install_z4j`` / ``z4j_lifespan``
|
|
13
|
+
2. ``Z4J_*`` environment variables (same names as the Django adapter)
|
|
14
|
+
3. Defaults declared on :class:`z4j_core.models.Config`
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from z4j_core.errors import ConfigError
|
|
26
|
+
from z4j_core.models import Config, DiscoveryHints, RequestContext, User
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("z4j.agent.fastapi.framework")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FastAPIFrameworkAdapter:
|
|
32
|
+
"""Framework adapter for FastAPI.
|
|
33
|
+
|
|
34
|
+
Implements the :class:`FrameworkAdapter` Protocol via duck typing
|
|
35
|
+
(no inheritance - see ``docs/patterns.md``). Lifecycle hooks are
|
|
36
|
+
stored as plain lists; :meth:`fire_startup` and :meth:`fire_shutdown`
|
|
37
|
+
invoke them when called by the extension module.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
name: Always ``"fastapi"``.
|
|
41
|
+
_config: The resolved :class:`Config` for this process.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name: str = "fastapi"
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: Config) -> None:
|
|
47
|
+
self._config = config
|
|
48
|
+
self._startup_hooks: list[Callable[[], None]] = []
|
|
49
|
+
self._shutdown_hooks: list[Callable[[], None]] = []
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# FrameworkAdapter Protocol
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def discover_config(self) -> Config:
|
|
56
|
+
return self._config
|
|
57
|
+
|
|
58
|
+
def discovery_hints(self) -> DiscoveryHints:
|
|
59
|
+
return DiscoveryHints(framework_name="fastapi")
|
|
60
|
+
|
|
61
|
+
def current_context(self) -> RequestContext | None:
|
|
62
|
+
# FastAPI does not have a global "current request" by default.
|
|
63
|
+
# A future middleware could stash the Starlette request on a
|
|
64
|
+
# ContextVar (like the Django adapter does). For now, return
|
|
65
|
+
# None - the agent handles this gracefully.
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def current_user(self) -> User | None:
|
|
69
|
+
# Same rationale as current_context - no global user context
|
|
70
|
+
# in FastAPI without explicit middleware.
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def on_startup(self, hook: Callable[[], None]) -> None:
|
|
74
|
+
self._startup_hooks.append(hook)
|
|
75
|
+
|
|
76
|
+
def on_shutdown(self, hook: Callable[[], None]) -> None:
|
|
77
|
+
self._shutdown_hooks.append(hook)
|
|
78
|
+
|
|
79
|
+
def register_admin_view(self, view: Any) -> None: # noqa: ARG002
|
|
80
|
+
# No-op. FastAPI does not have a built-in admin panel.
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Internal helpers used by the extension module
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def fire_startup(self) -> None:
|
|
88
|
+
"""Invoke every registered startup hook in order.
|
|
89
|
+
|
|
90
|
+
Called once after the agent runtime has connected. Exceptions
|
|
91
|
+
from individual hooks are caught and logged so a single bad
|
|
92
|
+
hook does not abort the others.
|
|
93
|
+
"""
|
|
94
|
+
for hook in self._startup_hooks:
|
|
95
|
+
try:
|
|
96
|
+
hook()
|
|
97
|
+
except Exception: # noqa: BLE001
|
|
98
|
+
logger.exception("z4j fastapi startup hook failed")
|
|
99
|
+
|
|
100
|
+
def fire_shutdown(self) -> None:
|
|
101
|
+
"""Invoke every registered shutdown hook in order.
|
|
102
|
+
|
|
103
|
+
Called once during application shutdown. Same exception
|
|
104
|
+
semantics as :meth:`fire_startup`.
|
|
105
|
+
"""
|
|
106
|
+
for hook in self._shutdown_hooks:
|
|
107
|
+
try:
|
|
108
|
+
hook()
|
|
109
|
+
except Exception: # noqa: BLE001
|
|
110
|
+
logger.exception("z4j fastapi shutdown hook failed")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Configuration resolution
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_config(
|
|
119
|
+
*,
|
|
120
|
+
brain_url: str | None = None,
|
|
121
|
+
token: str | None = None,
|
|
122
|
+
project_id: str | None = None,
|
|
123
|
+
hmac_secret: str | None = None,
|
|
124
|
+
environment: str | None = None,
|
|
125
|
+
transport: str | None = None,
|
|
126
|
+
log_level: str | None = None,
|
|
127
|
+
engines: list[str] | None = None,
|
|
128
|
+
schedulers: list[str] | None = None,
|
|
129
|
+
tags: dict[str, str] | None = None,
|
|
130
|
+
dev_mode: bool | None = None,
|
|
131
|
+
strict_mode: bool | None = None,
|
|
132
|
+
autostart: bool | None = None,
|
|
133
|
+
heartbeat_seconds: int | None = None,
|
|
134
|
+
buffer_path: str | Path | None = None,
|
|
135
|
+
buffer_max_events: int | None = None,
|
|
136
|
+
buffer_max_bytes: int | None = None,
|
|
137
|
+
max_payload_bytes: int | None = None,
|
|
138
|
+
) -> Config:
|
|
139
|
+
"""Build a :class:`Config` from explicit kwargs and environment variables.
|
|
140
|
+
|
|
141
|
+
Resolution priority (highest first):
|
|
142
|
+
|
|
143
|
+
1. Explicit keyword arguments (passed to ``install_z4j`` / ``z4j_lifespan``)
|
|
144
|
+
2. ``Z4J_*`` environment variables
|
|
145
|
+
3. Defaults declared on :class:`z4j_core.models.Config`
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ConfigError: Required values are missing or invalid.
|
|
149
|
+
"""
|
|
150
|
+
from pydantic import ValidationError
|
|
151
|
+
|
|
152
|
+
env = os.environ
|
|
153
|
+
resolved: dict[str, Any] = {}
|
|
154
|
+
|
|
155
|
+
# Required fields — explicit kwarg takes priority over env var.
|
|
156
|
+
# Use ``is not None`` so an operator who passes an explicit
|
|
157
|
+
# empty string fails the non-empty required-field check below
|
|
158
|
+
# instead of silently sliding onto the env fallback. A falsy
|
|
159
|
+
# ``or`` would have treated ``brain_url=""`` the same as
|
|
160
|
+
# ``brain_url=None`` (not passed) — surfaced by audit pass 8
|
|
161
|
+
# 2026-04-21.
|
|
162
|
+
r_brain_url = brain_url if brain_url is not None else env.get("Z4J_BRAIN_URL")
|
|
163
|
+
r_token = token if token is not None else env.get("Z4J_TOKEN")
|
|
164
|
+
r_project_id = project_id if project_id is not None else env.get("Z4J_PROJECT_ID")
|
|
165
|
+
|
|
166
|
+
missing: list[str] = []
|
|
167
|
+
if not r_brain_url:
|
|
168
|
+
missing.append("brain_url (or Z4J_BRAIN_URL)")
|
|
169
|
+
if not r_token:
|
|
170
|
+
missing.append("token (or Z4J_TOKEN)")
|
|
171
|
+
if not r_project_id:
|
|
172
|
+
missing.append("project_id (or Z4J_PROJECT_ID)")
|
|
173
|
+
if missing:
|
|
174
|
+
raise ConfigError(
|
|
175
|
+
"missing required Z4J settings: " + ", ".join(missing),
|
|
176
|
+
details={"missing": missing},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
resolved["brain_url"] = r_brain_url
|
|
180
|
+
resolved["token"] = r_token
|
|
181
|
+
resolved["project_id"] = r_project_id
|
|
182
|
+
|
|
183
|
+
# HMAC secret — same ``is not None`` discipline as the
|
|
184
|
+
# required fields. Explicit ``hmac_secret=""`` means the
|
|
185
|
+
# caller is deliberately opting out; we do not quietly
|
|
186
|
+
# rescue with an env fallback.
|
|
187
|
+
r_hmac_secret = (
|
|
188
|
+
hmac_secret if hmac_secret is not None else env.get("Z4J_HMAC_SECRET")
|
|
189
|
+
)
|
|
190
|
+
if r_hmac_secret:
|
|
191
|
+
resolved["hmac_secret"] = r_hmac_secret
|
|
192
|
+
|
|
193
|
+
# Optional string fields - kwarg > env > omit (use Config default)
|
|
194
|
+
_maybe_set_str(resolved, "environment", environment, env, "Z4J_ENVIRONMENT")
|
|
195
|
+
_maybe_set_str(resolved, "transport", transport, env, "Z4J_TRANSPORT")
|
|
196
|
+
_maybe_set_str(resolved, "log_level", log_level, env, "Z4J_LOG_LEVEL")
|
|
197
|
+
|
|
198
|
+
# List fields
|
|
199
|
+
if engines is not None:
|
|
200
|
+
resolved["engines"] = engines
|
|
201
|
+
elif "Z4J_ENGINES" in env:
|
|
202
|
+
resolved["engines"] = [
|
|
203
|
+
x.strip() for x in env["Z4J_ENGINES"].split(",") if x.strip()
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
if schedulers is not None:
|
|
207
|
+
resolved["schedulers"] = schedulers
|
|
208
|
+
elif "Z4J_SCHEDULERS" in env:
|
|
209
|
+
resolved["schedulers"] = [
|
|
210
|
+
x.strip() for x in env["Z4J_SCHEDULERS"].split(",") if x.strip()
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
# Tags
|
|
214
|
+
if tags is not None:
|
|
215
|
+
resolved["tags"] = tags
|
|
216
|
+
|
|
217
|
+
# Booleans
|
|
218
|
+
_maybe_set_bool(resolved, "dev_mode", dev_mode, env, "Z4J_DEV_MODE")
|
|
219
|
+
_maybe_set_bool(resolved, "strict_mode", strict_mode, env, "Z4J_STRICT_MODE")
|
|
220
|
+
_maybe_set_bool(resolved, "autostart", autostart, env, "Z4J_AUTOSTART")
|
|
221
|
+
|
|
222
|
+
# Integers
|
|
223
|
+
_maybe_set_int(resolved, "heartbeat_seconds", heartbeat_seconds, env, "Z4J_HEARTBEAT_SECONDS")
|
|
224
|
+
_maybe_set_int(resolved, "buffer_max_events", buffer_max_events, env, "Z4J_BUFFER_MAX_EVENTS")
|
|
225
|
+
_maybe_set_int(resolved, "buffer_max_bytes", buffer_max_bytes, env, "Z4J_BUFFER_MAX_BYTES")
|
|
226
|
+
_maybe_set_int(resolved, "max_payload_bytes", max_payload_bytes, env, "Z4J_MAX_PAYLOAD_BYTES")
|
|
227
|
+
|
|
228
|
+
# Path
|
|
229
|
+
if buffer_path is not None:
|
|
230
|
+
resolved["buffer_path"] = Path(buffer_path)
|
|
231
|
+
elif "Z4J_BUFFER_PATH" in env:
|
|
232
|
+
resolved["buffer_path"] = Path(env["Z4J_BUFFER_PATH"])
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
return Config(**resolved)
|
|
236
|
+
except ValidationError as exc:
|
|
237
|
+
# Redact values from the error message (same pattern as Django adapter).
|
|
238
|
+
details = [
|
|
239
|
+
{
|
|
240
|
+
"loc": ".".join(str(p) for p in err["loc"]),
|
|
241
|
+
"type": err["type"],
|
|
242
|
+
}
|
|
243
|
+
for err in exc.errors()
|
|
244
|
+
]
|
|
245
|
+
raise ConfigError(
|
|
246
|
+
f"invalid Z4J configuration ({len(details)} field(s))",
|
|
247
|
+
details={"errors": details},
|
|
248
|
+
) from None
|
|
249
|
+
except (TypeError, ValueError) as exc:
|
|
250
|
+
raise ConfigError(
|
|
251
|
+
f"invalid Z4J configuration: {type(exc).__name__}",
|
|
252
|
+
) from None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Engine / scheduler discovery
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def discover_engines(celery_app: Any = None) -> list[Any]:
|
|
261
|
+
"""Try to import every supported engine adapter and instantiate it.
|
|
262
|
+
|
|
263
|
+
If ``celery_app`` is provided explicitly, it is passed straight to
|
|
264
|
+
the engine adapter. Otherwise, we skip Celery engine discovery -
|
|
265
|
+
FastAPI does not have a conventional auto-discoverable Celery app
|
|
266
|
+
like Django does.
|
|
267
|
+
|
|
268
|
+
Failure to import an adapter (because ``z4j-celery`` is not
|
|
269
|
+
installed) is silent - the user simply gets the engines they
|
|
270
|
+
pip-installed.
|
|
271
|
+
"""
|
|
272
|
+
engines: list[Any] = []
|
|
273
|
+
|
|
274
|
+
celery_engine = _try_import_celery_engine(celery_app)
|
|
275
|
+
if celery_engine is not None:
|
|
276
|
+
engines.append(celery_engine)
|
|
277
|
+
|
|
278
|
+
if not engines:
|
|
279
|
+
logger.info(
|
|
280
|
+
"z4j: no queue engine adapters discovered; the agent will run "
|
|
281
|
+
"but will not capture task events. Install z4j-celery and pass "
|
|
282
|
+
"celery_app= to fix.",
|
|
283
|
+
)
|
|
284
|
+
return engines
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def discover_schedulers(celery_app: Any = None) -> list[Any]:
|
|
288
|
+
"""Try to import every supported scheduler adapter."""
|
|
289
|
+
schedulers: list[Any] = []
|
|
290
|
+
|
|
291
|
+
beat = _try_import_celerybeat_scheduler(celery_app)
|
|
292
|
+
if beat is not None:
|
|
293
|
+
schedulers.append(beat)
|
|
294
|
+
|
|
295
|
+
return schedulers
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# Internal helpers
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _try_import_celery_engine(celery_app: Any) -> Any:
|
|
304
|
+
"""Best-effort import of CeleryEngineAdapter.
|
|
305
|
+
|
|
306
|
+
Unlike the Django adapter, FastAPI does not have a convention for
|
|
307
|
+
where the Celery app lives. The user must pass it explicitly.
|
|
308
|
+
"""
|
|
309
|
+
if celery_app is None:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
from z4j_celery.engine import CeleryEngineAdapter
|
|
314
|
+
except ImportError:
|
|
315
|
+
logger.warning(
|
|
316
|
+
"z4j: celery_app was provided but z4j-celery is not installed; "
|
|
317
|
+
"pip install z4j-celery to enable Celery integration.",
|
|
318
|
+
)
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
return CeleryEngineAdapter(celery_app=celery_app)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _try_import_celerybeat_scheduler(celery_app: Any) -> Any:
|
|
325
|
+
"""Best-effort import of CeleryBeatSchedulerAdapter."""
|
|
326
|
+
try:
|
|
327
|
+
from z4j_celerybeat.scheduler import CeleryBeatSchedulerAdapter
|
|
328
|
+
except ImportError:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
return CeleryBeatSchedulerAdapter(celery_app=celery_app)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _maybe_set_str(
|
|
335
|
+
resolved: dict[str, Any],
|
|
336
|
+
key: str,
|
|
337
|
+
kwarg_value: str | None,
|
|
338
|
+
env: os._Environ[str] | dict[str, str],
|
|
339
|
+
env_key: str,
|
|
340
|
+
) -> None:
|
|
341
|
+
if kwarg_value is not None:
|
|
342
|
+
resolved[key] = kwarg_value
|
|
343
|
+
elif env_key in env:
|
|
344
|
+
resolved[key] = env[env_key]
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _maybe_set_bool(
|
|
348
|
+
resolved: dict[str, Any],
|
|
349
|
+
key: str,
|
|
350
|
+
kwarg_value: bool | None,
|
|
351
|
+
env: os._Environ[str] | dict[str, str],
|
|
352
|
+
env_key: str,
|
|
353
|
+
) -> None:
|
|
354
|
+
if kwarg_value is not None:
|
|
355
|
+
resolved[key] = kwarg_value
|
|
356
|
+
elif env_key in env:
|
|
357
|
+
resolved[key] = env[env_key].strip().lower() in ("1", "true", "yes", "on")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _maybe_set_int(
|
|
361
|
+
resolved: dict[str, Any],
|
|
362
|
+
key: str,
|
|
363
|
+
kwarg_value: int | None,
|
|
364
|
+
env: os._Environ[str] | dict[str, str],
|
|
365
|
+
env_key: str,
|
|
366
|
+
) -> None:
|
|
367
|
+
if kwarg_value is not None:
|
|
368
|
+
resolved[key] = kwarg_value
|
|
369
|
+
elif env_key in env:
|
|
370
|
+
try:
|
|
371
|
+
resolved[key] = int(env[env_key])
|
|
372
|
+
except ValueError as exc:
|
|
373
|
+
raise ConfigError(f"{env_key} must be an integer: {exc}") from exc
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
__all__ = [
|
|
377
|
+
"FastAPIFrameworkAdapter",
|
|
378
|
+
"discover_engines",
|
|
379
|
+
"discover_schedulers",
|
|
380
|
+
"resolve_config",
|
|
381
|
+
]
|
z4j_fastapi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: z4j-fastapi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: z4j FastAPI framework adapter (Apache 2.0)
|
|
5
|
+
Project-URL: Changelog, https://github.com/z4jdev/z4j-fastapi/blob/main/CHANGELOG.md
|
|
6
|
+
Project-URL: Documentation, https://z4j.dev
|
|
7
|
+
Project-URL: Homepage, https://z4j.com
|
|
8
|
+
Project-URL: Issues, https://github.com/z4jdev/z4j-fastapi/issues
|
|
9
|
+
Project-URL: Source, https://github.com/z4jdev/z4j-fastapi
|
|
10
|
+
Author: z4j contributors
|
|
11
|
+
License: Apache-2.0
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: celery,fastapi,task,z4j
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.13
|
|
23
|
+
Requires-Dist: fastapi>=0.135
|
|
24
|
+
Requires-Dist: z4j-bare
|
|
25
|
+
Requires-Dist: z4j-core
|
|
26
|
+
Provides-Extra: celery
|
|
27
|
+
Requires-Dist: z4j-celery; extra == 'celery'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# z4j-fastapi
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/z4j-fastapi/)
|
|
33
|
+
[](https://pypi.org/project/z4j-fastapi/)
|
|
34
|
+
[](https://github.com/z4jdev/z4j-fastapi/blob/main/LICENSE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
**License:** Apache 2.0
|
|
38
|
+
**Status:** v1.0.0 — first public release.
|
|
39
|
+
|
|
40
|
+
FastAPI framework adapter for [z4j](https://z4j.com). Integrates via
|
|
41
|
+
FastAPI's lifespan hook — one context manager wraps the agent's lifecycle
|
|
42
|
+
with your app's startup and shutdown.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# FastAPI + arq (the most common async-native pairing)
|
|
48
|
+
pip install z4j-fastapi z4j-arq z4j-arqcron
|
|
49
|
+
|
|
50
|
+
# FastAPI + Celery (when the sync stack is preferred)
|
|
51
|
+
pip install z4j-fastapi z4j-celery z4j-celerybeat
|
|
52
|
+
|
|
53
|
+
# FastAPI + Dramatiq / RQ / Huey / taskiq — all supported
|
|
54
|
+
pip install z4j-fastapi z4j-dramatiq z4j-apscheduler
|
|
55
|
+
pip install z4j-fastapi z4j-taskiq z4j-taskiqscheduler
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configure
|
|
59
|
+
|
|
60
|
+
Mount the z4j lifespan in your FastAPI app:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from contextlib import asynccontextmanager
|
|
64
|
+
from fastapi import FastAPI
|
|
65
|
+
from z4j_fastapi import Z4JLifespan
|
|
66
|
+
|
|
67
|
+
z4j = Z4JLifespan(
|
|
68
|
+
brain_url="https://z4j.internal",
|
|
69
|
+
token="z4j_agent_...", # minted in the brain dashboard
|
|
70
|
+
project_id="my-project",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@asynccontextmanager
|
|
74
|
+
async def lifespan(app: FastAPI):
|
|
75
|
+
async with z4j(app):
|
|
76
|
+
yield
|
|
77
|
+
|
|
78
|
+
app = FastAPI(lifespan=lifespan)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Or, if you have no other lifespan logic:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from z4j_fastapi import Z4JLifespan
|
|
85
|
+
|
|
86
|
+
app = FastAPI(lifespan=Z4JLifespan(
|
|
87
|
+
brain_url="https://z4j.internal",
|
|
88
|
+
token="z4j_agent_...",
|
|
89
|
+
project_id="my-project",
|
|
90
|
+
))
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
On `uvicorn` startup the agent connects to the brain and z4j's dashboard
|
|
94
|
+
populates with every arq / Celery / Dramatiq / taskiq / Huey task your
|
|
95
|
+
workers register.
|
|
96
|
+
|
|
97
|
+
## What it does
|
|
98
|
+
|
|
99
|
+
| Piece | Purpose |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `Z4JLifespan(...)` | Async context manager matching FastAPI's lifespan contract |
|
|
102
|
+
| Graceful shutdown | Flushes the event buffer on `await lifespan.aclose()` |
|
|
103
|
+
| Async-native | Uses `asyncio.create_task` — never blocks the event loop |
|
|
104
|
+
|
|
105
|
+
## Reliability
|
|
106
|
+
|
|
107
|
+
`z4j-fastapi` follows the project-wide safety rule: **z4j never breaks
|
|
108
|
+
your FastAPI app**. Agent failures are caught at the boundary and never
|
|
109
|
+
propagate into your request handlers.
|
|
110
|
+
|
|
111
|
+
## Documentation
|
|
112
|
+
|
|
113
|
+
- [Quickstart (FastAPI)](https://z4j.dev/getting-started/quickstart-fastapi/)
|
|
114
|
+
- [Install guide](https://z4j.dev/getting-started/install/)
|
|
115
|
+
- [Architecture](https://z4j.dev/concepts/architecture/)
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
Apache 2.0 — see [LICENSE](LICENSE). Your FastAPI application is never
|
|
120
|
+
AGPL-tainted by importing `z4j_fastapi`.
|
|
121
|
+
|
|
122
|
+
## Links
|
|
123
|
+
|
|
124
|
+
- Homepage: <https://z4j.com>
|
|
125
|
+
- Documentation: <https://z4j.dev>
|
|
126
|
+
- Issues: <https://github.com/z4jdev/z4j-fastapi/issues>
|
|
127
|
+
- Changelog: [CHANGELOG.md](CHANGELOG.md)
|
|
128
|
+
- Security: `security@z4j.com` (see [SECURITY.md](SECURITY.md))
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
z4j_fastapi/__init__.py,sha256=_Rg9I_w0U9ukVgVIrDdxr6V6d-LNYRtY8OPk_cFJJQc,1698
|
|
2
|
+
z4j_fastapi/extension.py,sha256=JprMgF6goPaRkHietZdCMRrTcfKS9YSE5bv4dO5o7Wk,13949
|
|
3
|
+
z4j_fastapi/framework.py,sha256=SWbcZ9YaWYdKc9HI6fic7FtbXHvF34Un1nkqTlMTxGw,13052
|
|
4
|
+
z4j_fastapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
z4j_fastapi-1.0.0.dist-info/METADATA,sha256=RA__6cCcfdB-074BWd4WEASYmzJ1r5cie1WI5AG_10c,4003
|
|
6
|
+
z4j_fastapi-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
z4j_fastapi-1.0.0.dist-info/licenses/LICENSE,sha256=JUYyp0BTJLCIDk7IcFrLs93vJUYe8wL_t__I0iUc-8g,11314
|
|
8
|
+
z4j_fastapi-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for describing the origin of the Work and
|
|
141
|
+
reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright 2026 z4j contributors
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|