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.
@@ -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
+ ]
@@ -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"]
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/z4j-fastapi.svg)](https://pypi.org/project/z4j-fastapi/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/z4j-fastapi.svg)](https://pypi.org/project/z4j-fastapi/)
34
+ [![License](https://img.shields.io/pypi/l/z4j-fastapi.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.