From ce6372e6ad310979bc703eff56eda3097bb0aca0 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 01:34:51 -0700 Subject: [PATCH 01/18] feat(vite): auto-load templates with template helper and config flag ViteProvider now binds a Jinja2Templates engine out of the box so fresh apps get a working template engine and the vite() globals are injected during boot. An existing 'templates' binding always wins, keeping the change backward-compatible. - Add ViteConfig.template and ViteConfig.templates_directory flags - Auto-bind templates in ViteProvider.register() with a guarded jinja2 import that points users to the [vite] extra - Add Template class and template() helper (Laravel view()-style) that resolve the request from context or a per-request ContextVar - Add RequestContextMiddleware to expose the active request - Export Template and template from fastapi_startkit.vite - Cover the new behaviour with tests - Simplify example/vite-app to rely on the auto-bound engine --- example/vite-app/bootstrap/application.py | 5 +- .../vite-app/providers/fastapi_provider.py | 8 -- example/vite-app/routes/web.py | 11 ++- .../src/fastapi_startkit/fastapi/context.py | 9 ++ .../fastapi_startkit/fastapi/middleware.py | 20 +++++ .../fastapi/providers/fastapi_provider.py | 3 + .../src/fastapi_startkit/vite/__init__.py | 3 + .../src/fastapi_startkit/vite/config/vite.py | 2 + .../vite/providers/provider.py | 19 +++++ .../src/fastapi_startkit/vite/template.py | 52 ++++++++++++ fastapi_startkit/tests/vite/test_template.py | 85 +++++++++++++++++++ 11 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/fastapi/context.py create mode 100644 fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py create mode 100644 fastapi_startkit/src/fastapi_startkit/vite/template.py create mode 100644 fastapi_startkit/tests/vite/test_template.py diff --git a/example/vite-app/bootstrap/application.py b/example/vite-app/bootstrap/application.py index 9dc64939..0fbc882a 100644 --- a/example/vite-app/bootstrap/application.py +++ b/example/vite-app/bootstrap/application.py @@ -4,7 +4,6 @@ from fastapi_startkit.logging import LogProvider from fastapi_startkit.vite import ViteProvider -# from config.vite import ViteConfig from providers.fastapi_provider import FastAPIProvider app: Application = Application( @@ -12,6 +11,8 @@ providers=[ LogProvider, FastAPIProvider, - ViteProvider, + # ViteProvider auto-binds a Jinja2Templates engine (with the vite() + # globals injected) at the configured templates directory. + (ViteProvider, {"templates_directory": "templates"}), ], ) diff --git a/example/vite-app/providers/fastapi_provider.py b/example/vite-app/providers/fastapi_provider.py index 2ae5d739..e43b4331 100644 --- a/example/vite-app/providers/fastapi_provider.py +++ b/example/vite-app/providers/fastapi_provider.py @@ -1,7 +1,4 @@ -from pathlib import Path - from fastapi import FastAPI -from starlette.templating import Jinja2Templates from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider @@ -14,11 +11,6 @@ def register(self) -> None: ) self.app.use_fastapi(fastapi) - # Bind Jinja2Templates so ViteProvider can inject vite() globals into it. - templates_dir = Path(self.app.base_path) / "templates" - templates = Jinja2Templates(directory=str(templates_dir)) - self.app.bind("templates", templates) - def boot(self) -> None: super().boot() diff --git a/example/vite-app/routes/web.py b/example/vite-app/routes/web.py index 78a3f090..73b952b1 100644 --- a/example/vite-app/routes/web.py +++ b/example/vite-app/routes/web.py @@ -1,15 +1,14 @@ -from fastapi import APIRouter, Request +from fastapi import APIRouter from fastapi.responses import HTMLResponse +from fastapi_startkit.vite import template + web = APIRouter() @web.get("/", response_class=HTMLResponse) -async def index(request: Request): - from bootstrap.application import app - - templates = app.make("templates") - return templates.TemplateResponse(request, "index.html") +async def index(): + return template("index.html") @web.get("/api/health") diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/context.py b/fastapi_startkit/src/fastapi_startkit/fastapi/context.py new file mode 100644 index 00000000..625224f6 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/context.py @@ -0,0 +1,9 @@ +from contextvars import ContextVar +from typing import Optional + +from starlette.requests import Request + +# Holds the request currently being handled so helpers such as the Vite +# `template()` view renderer can resolve it without it being passed explicitly. +# Set by RequestContextMiddleware for the duration of each request. +current_request: ContextVar[Optional[Request]] = ContextVar("current_request", default=None) diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py b/fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py new file mode 100644 index 00000000..c855ea64 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py @@ -0,0 +1,20 @@ +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import Response + +from fastapi_startkit.fastapi.context import current_request + + +class RequestContextMiddleware(BaseHTTPMiddleware): + """Expose the active request through a ContextVar for the request lifetime. + + Lets helpers that have no access to the handler signature (e.g. the Vite + ``template()`` view renderer) resolve the current request implicitly. + """ + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + token = current_request.set(request) + try: + return await call_next(request) + finally: + current_request.reset(token) diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py index 8639e782..627ec535 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py @@ -24,8 +24,11 @@ def register(self) -> None: def boot(self): import os + from fastapi_startkit.fastapi.middleware import RequestContextMiddleware + self.commands([ServeCommand]) self._register_exception_handlers() + self.app.add_middleware(RequestContextMiddleware) source = os.path.abspath(os.path.join(os.path.dirname(__file__), "../config/fastapi.py")) self.publishes({source: "config/fastapi.py"}) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py index c6c8cf31..4447516b 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py @@ -1,10 +1,13 @@ from .vite import Vite from .providers.provider import ViteProvider +from .template import Template, template from .exceptions import ViteException, ViteManifestNotFoundException __all__ = [ "Vite", "ViteProvider", + "Template", + "template", "ViteException", "ViteManifestNotFoundException", ] diff --git a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py index 71431700..80102997 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py @@ -10,3 +10,5 @@ class ViteConfig: asset_url: str = "" static_url: str = "/build" mount_static: bool = True + template: bool = True + templates_directory: str = "resources/templates" diff --git a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py index 4e0e7c33..22269276 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py @@ -28,6 +28,25 @@ def register(self) -> None: self.app.bind("vite", vite) + self.register_templates(config) + + def register_templates(self, config: ViteConfig) -> None: + # Provide a template engine out of the box so fresh apps can render + # views and have the vite() globals injected during boot. An existing + # binding always wins, keeping this backward-compatible. + if not config.template or self.app.has("templates"): + return + + try: + from starlette.templating import Jinja2Templates + except ImportError as exc: + raise ImportError( + "Rendering templates requires Jinja2. Install it with: pip install fastapi-startkit[vite]" + ) from exc + + templates_dir = self.app.base_path / config.templates_directory + self.app.bind("templates", Jinja2Templates(directory=str(templates_dir))) + def boot(self) -> None: vite: Vite = self.app.make("vite") config = self.app.make("config").get(self.provider_key) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/template.py b/fastapi_startkit/src/fastapi_startkit/vite/template.py new file mode 100644 index 00000000..8735c5ae --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/vite/template.py @@ -0,0 +1,52 @@ +from typing import Optional + +from .exceptions import ViteException + + +def _resolve_request(context: dict): + """Return the request from the context, falling back to the request ContextVar.""" + request = context.pop("request", None) + if request is not None: + return request + + try: + from fastapi_startkit.fastapi.context import current_request + except ImportError: + return None + + return current_request.get() + + +def template(name: str, context: Optional[dict] = None): + """Render a Jinja2 template by name, Laravel ``view()`` style. + + The current request does not need to be passed explicitly: it is taken from + ``context['request']`` when given, otherwise from the per-request ContextVar + set by ``RequestContextMiddleware``. + """ + from fastapi_startkit.application import app as container + + if not container().has("templates"): + raise ViteException( + "No 'templates' binding found. Register the ViteProvider (with " + "`template` enabled) or bind a Jinja2Templates instance as 'templates'." + ) + + templates = container().make("templates") + context = dict(context or {}) + request = _resolve_request(context) + + try: + return templates.TemplateResponse(request, name, context) + except TypeError: + # Starlette < 0.29 only supports the legacy signature where the request + # is supplied inside the context dict. + return templates.TemplateResponse(name, {"request": request, **context}) + + +class Template: + """Static-style accessor for rendering templates.""" + + @staticmethod + def render(name: str, context: Optional[dict] = None): + return template(name, context) diff --git a/fastapi_startkit/tests/vite/test_template.py b/fastapi_startkit/tests/vite/test_template.py new file mode 100644 index 00000000..d4ff6223 --- /dev/null +++ b/fastapi_startkit/tests/vite/test_template.py @@ -0,0 +1,85 @@ +import pytest +from starlette.requests import Request +from starlette.responses import Response + +from fastapi_startkit.application import Application +from fastapi_startkit.providers import Provider +from fastapi_startkit.vite import Template, ViteProvider, template +from fastapi_startkit.vite.exceptions import ViteException + + +def make_request() -> Request: + return Request( + { + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "query_string": b"", + } + ) + + +def make_app(tmp_path, providers=None, write_template=False) -> Application: + if write_template: + templates_dir = tmp_path / "resources" / "templates" + templates_dir.mkdir(parents=True) + (templates_dir / "index.html").write_text("

{{ title }}

") + + return Application( + base_path=tmp_path, + env="testing", + providers=providers or [ViteProvider], + ) + + +class _SentinelTemplatesProvider(Provider): + provider_key = "sentinel_templates" + + def register(self) -> None: + self.app.bind("templates", "SENTINEL") + + +class TestTemplateBinding: + def test_binds_templates_when_enabled_and_none_prebound(self, tmp_path): + app = make_app(tmp_path) + assert app.has("templates") + + def test_respects_existing_templates_binding(self, tmp_path): + app = make_app(tmp_path, providers=[_SentinelTemplatesProvider, ViteProvider]) + assert app.make("templates") == "SENTINEL" + + def test_skips_binding_when_template_disabled(self, tmp_path): + app = make_app(tmp_path, providers=[(ViteProvider, {"template": False})]) + assert not app.has("templates") + + def test_vite_globals_injected_after_boot(self, tmp_path): + app = make_app(tmp_path) + env_globals = app.make("templates").env.globals + assert "vite" in env_globals + assert "vite_asset" in env_globals + assert "vite_react_refresh" in env_globals + + +class TestTemplateRendering: + def test_template_helper_returns_template_response(self, tmp_path): + make_app(tmp_path, write_template=True) + response = template("index.html", {"request": make_request(), "title": "Hi"}) + assert isinstance(response, Response) + + def test_template_class_render_uses_request_contextvar(self, tmp_path): + make_app(tmp_path, write_template=True) + from fastapi_startkit.fastapi.context import current_request + + token = current_request.set(make_request()) + try: + response = Template.render("index.html", {"title": "Hi"}) + finally: + current_request.reset(token) + + assert isinstance(response, Response) + + def test_template_raises_when_no_binding(self, tmp_path): + make_app(tmp_path, providers=[(ViteProvider, {"template": False})]) + with pytest.raises(ViteException): + template("index.html", {"request": make_request()}) From 0562c632555b47b1158b21f6c27d194da08cd6f9 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 01:41:01 -0700 Subject: [PATCH 02/18] Revert "feat(vite): auto-load templates + Template helper + config flag" --- example/vite-app/bootstrap/application.py | 5 +- .../vite-app/providers/fastapi_provider.py | 8 ++ example/vite-app/routes/web.py | 11 +-- .../src/fastapi_startkit/fastapi/context.py | 9 -- .../fastapi_startkit/fastapi/middleware.py | 20 ----- .../fastapi/providers/fastapi_provider.py | 3 - .../src/fastapi_startkit/vite/__init__.py | 3 - .../src/fastapi_startkit/vite/config/vite.py | 2 - .../vite/providers/provider.py | 19 ----- .../src/fastapi_startkit/vite/template.py | 52 ------------ fastapi_startkit/tests/vite/test_template.py | 85 ------------------- 11 files changed, 16 insertions(+), 201 deletions(-) delete mode 100644 fastapi_startkit/src/fastapi_startkit/fastapi/context.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py delete mode 100644 fastapi_startkit/src/fastapi_startkit/vite/template.py delete mode 100644 fastapi_startkit/tests/vite/test_template.py diff --git a/example/vite-app/bootstrap/application.py b/example/vite-app/bootstrap/application.py index 0fbc882a..9dc64939 100644 --- a/example/vite-app/bootstrap/application.py +++ b/example/vite-app/bootstrap/application.py @@ -4,6 +4,7 @@ from fastapi_startkit.logging import LogProvider from fastapi_startkit.vite import ViteProvider +# from config.vite import ViteConfig from providers.fastapi_provider import FastAPIProvider app: Application = Application( @@ -11,8 +12,6 @@ providers=[ LogProvider, FastAPIProvider, - # ViteProvider auto-binds a Jinja2Templates engine (with the vite() - # globals injected) at the configured templates directory. - (ViteProvider, {"templates_directory": "templates"}), + ViteProvider, ], ) diff --git a/example/vite-app/providers/fastapi_provider.py b/example/vite-app/providers/fastapi_provider.py index e43b4331..2ae5d739 100644 --- a/example/vite-app/providers/fastapi_provider.py +++ b/example/vite-app/providers/fastapi_provider.py @@ -1,4 +1,7 @@ +from pathlib import Path + from fastapi import FastAPI +from starlette.templating import Jinja2Templates from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider @@ -11,6 +14,11 @@ def register(self) -> None: ) self.app.use_fastapi(fastapi) + # Bind Jinja2Templates so ViteProvider can inject vite() globals into it. + templates_dir = Path(self.app.base_path) / "templates" + templates = Jinja2Templates(directory=str(templates_dir)) + self.app.bind("templates", templates) + def boot(self) -> None: super().boot() diff --git a/example/vite-app/routes/web.py b/example/vite-app/routes/web.py index 73b952b1..78a3f090 100644 --- a/example/vite-app/routes/web.py +++ b/example/vite-app/routes/web.py @@ -1,14 +1,15 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse -from fastapi_startkit.vite import template - web = APIRouter() @web.get("/", response_class=HTMLResponse) -async def index(): - return template("index.html") +async def index(request: Request): + from bootstrap.application import app + + templates = app.make("templates") + return templates.TemplateResponse(request, "index.html") @web.get("/api/health") diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/context.py b/fastapi_startkit/src/fastapi_startkit/fastapi/context.py deleted file mode 100644 index 625224f6..00000000 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/context.py +++ /dev/null @@ -1,9 +0,0 @@ -from contextvars import ContextVar -from typing import Optional - -from starlette.requests import Request - -# Holds the request currently being handled so helpers such as the Vite -# `template()` view renderer can resolve it without it being passed explicitly. -# Set by RequestContextMiddleware for the duration of each request. -current_request: ContextVar[Optional[Request]] = ContextVar("current_request", default=None) diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py b/fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py deleted file mode 100644 index c855ea64..00000000 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py +++ /dev/null @@ -1,20 +0,0 @@ -from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint -from starlette.requests import Request -from starlette.responses import Response - -from fastapi_startkit.fastapi.context import current_request - - -class RequestContextMiddleware(BaseHTTPMiddleware): - """Expose the active request through a ContextVar for the request lifetime. - - Lets helpers that have no access to the handler signature (e.g. the Vite - ``template()`` view renderer) resolve the current request implicitly. - """ - - async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: - token = current_request.set(request) - try: - return await call_next(request) - finally: - current_request.reset(token) diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py index 627ec535..8639e782 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py @@ -24,11 +24,8 @@ def register(self) -> None: def boot(self): import os - from fastapi_startkit.fastapi.middleware import RequestContextMiddleware - self.commands([ServeCommand]) self._register_exception_handlers() - self.app.add_middleware(RequestContextMiddleware) source = os.path.abspath(os.path.join(os.path.dirname(__file__), "../config/fastapi.py")) self.publishes({source: "config/fastapi.py"}) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py index 4447516b..c6c8cf31 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py @@ -1,13 +1,10 @@ from .vite import Vite from .providers.provider import ViteProvider -from .template import Template, template from .exceptions import ViteException, ViteManifestNotFoundException __all__ = [ "Vite", "ViteProvider", - "Template", - "template", "ViteException", "ViteManifestNotFoundException", ] diff --git a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py index 80102997..71431700 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py @@ -10,5 +10,3 @@ class ViteConfig: asset_url: str = "" static_url: str = "/build" mount_static: bool = True - template: bool = True - templates_directory: str = "resources/templates" diff --git a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py index 22269276..4e0e7c33 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py @@ -28,25 +28,6 @@ def register(self) -> None: self.app.bind("vite", vite) - self.register_templates(config) - - def register_templates(self, config: ViteConfig) -> None: - # Provide a template engine out of the box so fresh apps can render - # views and have the vite() globals injected during boot. An existing - # binding always wins, keeping this backward-compatible. - if not config.template or self.app.has("templates"): - return - - try: - from starlette.templating import Jinja2Templates - except ImportError as exc: - raise ImportError( - "Rendering templates requires Jinja2. Install it with: pip install fastapi-startkit[vite]" - ) from exc - - templates_dir = self.app.base_path / config.templates_directory - self.app.bind("templates", Jinja2Templates(directory=str(templates_dir))) - def boot(self) -> None: vite: Vite = self.app.make("vite") config = self.app.make("config").get(self.provider_key) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/template.py b/fastapi_startkit/src/fastapi_startkit/vite/template.py deleted file mode 100644 index 8735c5ae..00000000 --- a/fastapi_startkit/src/fastapi_startkit/vite/template.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Optional - -from .exceptions import ViteException - - -def _resolve_request(context: dict): - """Return the request from the context, falling back to the request ContextVar.""" - request = context.pop("request", None) - if request is not None: - return request - - try: - from fastapi_startkit.fastapi.context import current_request - except ImportError: - return None - - return current_request.get() - - -def template(name: str, context: Optional[dict] = None): - """Render a Jinja2 template by name, Laravel ``view()`` style. - - The current request does not need to be passed explicitly: it is taken from - ``context['request']`` when given, otherwise from the per-request ContextVar - set by ``RequestContextMiddleware``. - """ - from fastapi_startkit.application import app as container - - if not container().has("templates"): - raise ViteException( - "No 'templates' binding found. Register the ViteProvider (with " - "`template` enabled) or bind a Jinja2Templates instance as 'templates'." - ) - - templates = container().make("templates") - context = dict(context or {}) - request = _resolve_request(context) - - try: - return templates.TemplateResponse(request, name, context) - except TypeError: - # Starlette < 0.29 only supports the legacy signature where the request - # is supplied inside the context dict. - return templates.TemplateResponse(name, {"request": request, **context}) - - -class Template: - """Static-style accessor for rendering templates.""" - - @staticmethod - def render(name: str, context: Optional[dict] = None): - return template(name, context) diff --git a/fastapi_startkit/tests/vite/test_template.py b/fastapi_startkit/tests/vite/test_template.py deleted file mode 100644 index d4ff6223..00000000 --- a/fastapi_startkit/tests/vite/test_template.py +++ /dev/null @@ -1,85 +0,0 @@ -import pytest -from starlette.requests import Request -from starlette.responses import Response - -from fastapi_startkit.application import Application -from fastapi_startkit.providers import Provider -from fastapi_startkit.vite import Template, ViteProvider, template -from fastapi_startkit.vite.exceptions import ViteException - - -def make_request() -> Request: - return Request( - { - "type": "http", - "method": "GET", - "path": "/", - "headers": [], - "query_string": b"", - } - ) - - -def make_app(tmp_path, providers=None, write_template=False) -> Application: - if write_template: - templates_dir = tmp_path / "resources" / "templates" - templates_dir.mkdir(parents=True) - (templates_dir / "index.html").write_text("

{{ title }}

") - - return Application( - base_path=tmp_path, - env="testing", - providers=providers or [ViteProvider], - ) - - -class _SentinelTemplatesProvider(Provider): - provider_key = "sentinel_templates" - - def register(self) -> None: - self.app.bind("templates", "SENTINEL") - - -class TestTemplateBinding: - def test_binds_templates_when_enabled_and_none_prebound(self, tmp_path): - app = make_app(tmp_path) - assert app.has("templates") - - def test_respects_existing_templates_binding(self, tmp_path): - app = make_app(tmp_path, providers=[_SentinelTemplatesProvider, ViteProvider]) - assert app.make("templates") == "SENTINEL" - - def test_skips_binding_when_template_disabled(self, tmp_path): - app = make_app(tmp_path, providers=[(ViteProvider, {"template": False})]) - assert not app.has("templates") - - def test_vite_globals_injected_after_boot(self, tmp_path): - app = make_app(tmp_path) - env_globals = app.make("templates").env.globals - assert "vite" in env_globals - assert "vite_asset" in env_globals - assert "vite_react_refresh" in env_globals - - -class TestTemplateRendering: - def test_template_helper_returns_template_response(self, tmp_path): - make_app(tmp_path, write_template=True) - response = template("index.html", {"request": make_request(), "title": "Hi"}) - assert isinstance(response, Response) - - def test_template_class_render_uses_request_contextvar(self, tmp_path): - make_app(tmp_path, write_template=True) - from fastapi_startkit.fastapi.context import current_request - - token = current_request.set(make_request()) - try: - response = Template.render("index.html", {"title": "Hi"}) - finally: - current_request.reset(token) - - assert isinstance(response, Response) - - def test_template_raises_when_no_binding(self, tmp_path): - make_app(tmp_path, providers=[(ViteProvider, {"template": False})]) - with pytest.raises(ViteException): - template("index.html", {"request": make_request()}) From 8fb2b3dcfd44e0fb3e45a155fc69ea9908c64312 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 02:02:00 -0700 Subject: [PATCH 03/18] feat(vite): auto-load Jinja2 templates from the Vite provider Fresh apps got no template engine because ViteProvider only injected the vite() globals when a "templates" binding already existed. The provider now binds a Jinja2Templates engine out of the box, config-gated and backward-compatible. - Add ViteConfig.template (default True) and ViteConfig.templates_directory (default "resources/templates") - ViteProvider.register() binds "templates" when enabled and none is pre-bound; an existing binding always wins. The jinja2 import is guarded with a clear "install fastapi-startkit[vite]" message - Publish the template stub under resources/templates/index.html so all scaffolding stays consistent under resources/ - Sync the example app's config/vite.py with the new fields - Cover binding, override, disabled, post-boot globals and the missing jinja2 error in tests/vite/ Rendering stays the standard Starlette way: app.make("templates").TemplateResponse(request, name, context). --- example/vite-app/config/vite.py | 2 + .../src/fastapi_startkit/vite/config/vite.py | 2 + .../vite/providers/provider.py | 21 +++++- .../{ => resources}/templates/index.html | 0 .../tests/vite/test_template_binding.py | 64 +++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) rename fastapi_startkit/src/fastapi_startkit/vite/stubs/{ => resources}/templates/index.html (100%) create mode 100644 fastapi_startkit/tests/vite/test_template_binding.py diff --git a/example/vite-app/config/vite.py b/example/vite-app/config/vite.py index 71431700..80102997 100644 --- a/example/vite-app/config/vite.py +++ b/example/vite-app/config/vite.py @@ -10,3 +10,5 @@ class ViteConfig: asset_url: str = "" static_url: str = "/build" mount_static: bool = True + template: bool = True + templates_directory: str = "resources/templates" diff --git a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py index 71431700..80102997 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py @@ -10,3 +10,5 @@ class ViteConfig: asset_url: str = "" static_url: str = "/build" mount_static: bool = True + template: bool = True + templates_directory: str = "resources/templates" diff --git a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py index 4e0e7c33..cf56b890 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py @@ -28,6 +28,25 @@ def register(self) -> None: self.app.bind("vite", vite) + self.register_templates(config) + + def register_templates(self, config: ViteConfig) -> None: + # Bind a Jinja2 template engine out of the box so fresh apps can render + # views and receive the vite() globals during boot. An existing + # "templates" binding always wins, keeping this backward-compatible. + if not config.template or self.app.has("templates"): + return + + try: + from starlette.templating import Jinja2Templates + except ImportError as exc: + raise ImportError( + "Rendering Vite templates requires Jinja2. Install it with: pip install fastapi-startkit[vite]" + ) from exc + + templates_dir = self.app.base_path / config.templates_directory + self.app.bind("templates", Jinja2Templates(directory=str(templates_dir))) + def boot(self) -> None: vite: Vite = self.app.make("vite") config = self.app.make("config").get(self.provider_key) @@ -48,7 +67,7 @@ def boot(self) -> None: os.path.join(stubs_path, "tsconfig.json"): "tsconfig.json", os.path.join(stubs_path, "resources/js/app.ts"): "resources/js/app.ts", os.path.join(stubs_path, "resources/css/app.css"): "resources/css/app.css", - os.path.join(stubs_path, "templates/index.html"): "templates/index.html", + os.path.join(stubs_path, "resources/templates/index.html"): "resources/templates/index.html", } ) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/stubs/templates/index.html b/fastapi_startkit/src/fastapi_startkit/vite/stubs/resources/templates/index.html similarity index 100% rename from fastapi_startkit/src/fastapi_startkit/vite/stubs/templates/index.html rename to fastapi_startkit/src/fastapi_startkit/vite/stubs/resources/templates/index.html diff --git a/fastapi_startkit/tests/vite/test_template_binding.py b/fastapi_startkit/tests/vite/test_template_binding.py new file mode 100644 index 00000000..20060339 --- /dev/null +++ b/fastapi_startkit/tests/vite/test_template_binding.py @@ -0,0 +1,64 @@ +import sys +from unittest import mock + +import pytest + +from fastapi_startkit.application import Application +from fastapi_startkit.providers import Provider +from fastapi_startkit.vite import ViteProvider +from fastapi_startkit.vite.config.vite import ViteConfig + + +def make_app(tmp_path, providers=None) -> Application: + if providers is None: + providers = [ViteProvider] + return Application(base_path=tmp_path, env="testing", providers=providers) + + +class _SentinelTemplatesProvider(Provider): + provider_key = "sentinel_templates" + + def register(self) -> None: + self.app.bind("templates", "SENTINEL") + + +class TestTemplateBinding: + def test_binds_templates_when_enabled_and_none_prebound(self, tmp_path): + from starlette.templating import Jinja2Templates + + app = make_app(tmp_path) + + assert app.has("templates") + assert isinstance(app.make("templates"), Jinja2Templates) + + def test_respects_existing_templates_binding(self, tmp_path): + app = make_app(tmp_path, providers=[_SentinelTemplatesProvider, ViteProvider]) + + assert app.make("templates") == "SENTINEL" + + def test_skips_binding_when_template_disabled(self, tmp_path): + app = make_app(tmp_path, providers=[(ViteProvider, {"template": False})]) + + assert not app.has("templates") + + def test_vite_globals_injected_after_boot(self, tmp_path): + app = make_app(tmp_path) + + env_globals = app.make("templates").env.globals + assert "vite" in env_globals + assert "vite_asset" in env_globals + assert "vite_react_refresh" in env_globals + + +class TestMissingJinja2: + def test_missing_jinja2_raises_clear_install_error(self, tmp_path): + app = make_app(tmp_path, providers=[]) + provider = ViteProvider(app) + + # Jinja2Templates is only importable from starlette.templating when + # jinja2 is installed; setting the module to None makes the import fail. + with mock.patch.dict(sys.modules, {"starlette.templating": None}): + with pytest.raises(ImportError) as exc_info: + provider.register_templates(ViteConfig()) + + assert "fastapi-startkit[vite]" in str(exc_info.value) From bcf9ca169cf7fa2602483f5ee43e7ad67bcdb470 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 02:25:39 -0700 Subject: [PATCH 04/18] feat(vite): re-export ViteConfig from vite package root --- fastapi_startkit/.coverage | Bin 0 -> 135168 bytes .../src/fastapi_startkit/vite/__init__.py | 2 ++ fastapi_startkit/tests/vite/test_vite.py | 23 ++++++++++++++++++ fastapi_startkit/uv.lock | 2 +- 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 fastapi_startkit/.coverage diff --git a/fastapi_startkit/.coverage b/fastapi_startkit/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..43962310e710cee54d1a3fd7dd67c66a7959a8cf GIT binary patch literal 135168 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 VnwQJU4%PqF)w+KheK(D?{|BYq{tEyA literal 0 HcmV?d00001 diff --git a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py index 4447516b..dab671ad 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py @@ -1,10 +1,12 @@ from .vite import Vite +from .config.vite import ViteConfig from .providers.provider import ViteProvider from .template import Template, template from .exceptions import ViteException, ViteManifestNotFoundException __all__ = [ "Vite", + "ViteConfig", "ViteProvider", "Template", "template", diff --git a/fastapi_startkit/tests/vite/test_vite.py b/fastapi_startkit/tests/vite/test_vite.py index b7cb6a11..e5973984 100644 --- a/fastapi_startkit/tests/vite/test_vite.py +++ b/fastapi_startkit/tests/vite/test_vite.py @@ -298,3 +298,26 @@ def test_flush_clears_preloaded_assets(self, tmp_path): vite("resources/js/app.js") vite.flush() assert vite.preloaded_assets() == {} + + +# --------------------------------------------------------------------------- +# Package-root re-exports +# --------------------------------------------------------------------------- + + +class TestViteConfigExport: + def test_viteconfig_importable_from_package_root(self): + from fastapi_startkit.vite import ViteConfig + + assert ViteConfig is not None + + def test_viteconfig_is_same_class_as_config_module(self): + from fastapi_startkit.vite import ViteConfig + from fastapi_startkit.vite.config.vite import ViteConfig as ConfigViteConfig + + assert ViteConfig is ConfigViteConfig + + def test_viteconfig_in_package_all(self): + import fastapi_startkit.vite as vite_pkg + + assert "ViteConfig" in vite_pkg.__all__ diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index de9262fe..8b674dea 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -593,7 +593,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.44.0" +version = "0.45.0" source = { editable = "." } dependencies = [ { name = "cleo" }, From 5f1988b55e68c203c9ab4718562577673326d4d8 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 02:27:38 -0700 Subject: [PATCH 05/18] feat(masoniteorm): re-export all relationship classes from package root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-export the full set of public relationship classes (including MorphMany, MorphOne, MorphToMany, and BaseRelationship) from fastapi_startkit.masoniteorm so they can be imported directly from the package root instead of the .relationships submodule. Switch in-repo example models, fixtures, and tests to the root import. Pure re-export — no behavior change. --- example/database-app/app/models/category.py | 2 +- example/database-app/app/models/course.py | 2 +- example/database-app/app/models/lesson.py | 2 +- example/database-app/app/models/profile.py | 2 +- example/database-app/app/models/review.py | 2 +- example/database-app/app/models/user.py | 2 +- .../fastapi_startkit/masoniteorm/__init__.py | 18 ++++++++++++- .../tests/masoniteorm/fixtures/model.py | 2 +- .../sqlite/models/test_attach_detach.py | 2 +- .../test_relationship_reexports.py | 25 +++++++++++++++++++ 10 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 fastapi_startkit/tests/masoniteorm/test_relationship_reexports.py diff --git a/example/database-app/app/models/category.py b/example/database-app/app/models/category.py index 29655e70..76af5966 100644 --- a/example/database-app/app/models/category.py +++ b/example/database-app/app/models/category.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import HasMany, HasManyThrough +from fastapi_startkit.masoniteorm import HasMany, HasManyThrough if TYPE_CHECKING: from app.models.course import Course diff --git a/example/database-app/app/models/course.py b/example/database-app/app/models/course.py index 7b31a9a6..9f030a09 100644 --- a/example/database-app/app/models/course.py +++ b/example/database-app/app/models/course.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import BelongsTo, HasMany, BelongsToMany, MorphMany +from fastapi_startkit.masoniteorm import BelongsTo, HasMany, BelongsToMany, MorphMany if TYPE_CHECKING: from app.models.category import Category diff --git a/example/database-app/app/models/lesson.py b/example/database-app/app/models/lesson.py index c5540bf2..e507d16e 100644 --- a/example/database-app/app/models/lesson.py +++ b/example/database-app/app/models/lesson.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import BelongsTo, MorphMany +from fastapi_startkit.masoniteorm import BelongsTo, MorphMany if TYPE_CHECKING: from app.models.course import Course diff --git a/example/database-app/app/models/profile.py b/example/database-app/app/models/profile.py index 5a6088fe..11fa8a7a 100644 --- a/example/database-app/app/models/profile.py +++ b/example/database-app/app/models/profile.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import BelongsTo +from fastapi_startkit.masoniteorm import BelongsTo if TYPE_CHECKING: from app.models.user import User diff --git a/example/database-app/app/models/review.py b/example/database-app/app/models/review.py index f36f2738..2d19021f 100644 --- a/example/database-app/app/models/review.py +++ b/example/database-app/app/models/review.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import MorphTo +from fastapi_startkit.masoniteorm import MorphTo class Review(Model): __table__ = "reviews" diff --git a/example/database-app/app/models/user.py b/example/database-app/app/models/user.py index 81107cee..c31152c7 100644 --- a/example/database-app/app/models/user.py +++ b/example/database-app/app/models/user.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING from fastapi_startkit.masoniteorm.models import Model -from fastapi_startkit.masoniteorm.relationships import HasMany, HasOne, BelongsToMany +from fastapi_startkit.masoniteorm import HasMany, HasOne, BelongsToMany if TYPE_CHECKING: from app.models.profile import Profile diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py index 3f3a0613..4b6ab996 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/__init__.py @@ -7,7 +7,19 @@ from .models import Model from .models.fields import CreatedAtField, DateTimeField, Field, ModelField, UpdatedAtField from .providers import DatabaseProvider -from .relationships import BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, HasOneThrough, MorphTo +from .relationships import ( + BaseRelationship, + BelongsTo, + BelongsToMany, + HasMany, + HasManyThrough, + HasOne, + HasOneThrough, + MorphMany, + MorphOne, + MorphTo, + MorphToMany, +) __all__ = [ "DatabaseProvider", @@ -26,6 +38,7 @@ "UpdatedAtField", "Field", # Relationships + "BaseRelationship", "HasOne", "BelongsTo", "HasMany", @@ -33,4 +46,7 @@ "BelongsToMany", "HasOneThrough", "MorphTo", + "MorphMany", + "MorphOne", + "MorphToMany", ] diff --git a/fastapi_startkit/tests/masoniteorm/fixtures/model.py b/fastapi_startkit/tests/masoniteorm/fixtures/model.py index 167343f4..5abbc181 100644 --- a/fastapi_startkit/tests/masoniteorm/fixtures/model.py +++ b/fastapi_startkit/tests/masoniteorm/fixtures/model.py @@ -3,7 +3,7 @@ from fastapi_startkit.carbon.carbon import Carbon from tests.masoniteorm.fixtures.casts import Address from fastapi_startkit.masoniteorm import ModelField, Field -from fastapi_startkit.masoniteorm.relationships import ( +from fastapi_startkit.masoniteorm import ( HasOne, BelongsTo, HasMany, diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/models/test_attach_detach.py b/fastapi_startkit/tests/masoniteorm/sqlite/models/test_attach_detach.py index 1aaed80a..0e6efe60 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/models/test_attach_detach.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/models/test_attach_detach.py @@ -1,5 +1,5 @@ from fastapi_startkit.masoniteorm.models.model import Model -from fastapi_startkit.masoniteorm.relationships import HasOne, BelongsTo +from fastapi_startkit.masoniteorm import HasOne, BelongsTo from ..test_case import TestCase diff --git a/fastapi_startkit/tests/masoniteorm/test_relationship_reexports.py b/fastapi_startkit/tests/masoniteorm/test_relationship_reexports.py new file mode 100644 index 00000000..3e891694 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/test_relationship_reexports.py @@ -0,0 +1,25 @@ +import fastapi_startkit.masoniteorm as orm +from fastapi_startkit.masoniteorm import relationships + +RELATIONSHIP_CLASSES = [ + "BaseRelationship", + "BelongsTo", + "BelongsToMany", + "HasMany", + "HasManyThrough", + "HasOne", + "HasOneThrough", + "MorphMany", + "MorphOne", + "MorphTo", + "MorphToMany", +] + + +def test_relationship_classes_are_reexported_from_root(): + for name in RELATIONSHIP_CLASSES: + assert hasattr(orm, name), f"{name} is not re-exported from fastapi_startkit.masoniteorm" + assert name in orm.__all__, f"{name} missing from fastapi_startkit.masoniteorm.__all__" + assert getattr(orm, name) is getattr(relationships, name), ( + f"{name} re-export is not the same class object as in .relationships" + ) From 61a13db76f758e72ea8945ca004c4f8dbad9468d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 09:35:27 -0700 Subject: [PATCH 06/18] test(masoniteorm): drop relationship re-export test --- .../test_relationship_reexports.py | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 fastapi_startkit/tests/masoniteorm/test_relationship_reexports.py diff --git a/fastapi_startkit/tests/masoniteorm/test_relationship_reexports.py b/fastapi_startkit/tests/masoniteorm/test_relationship_reexports.py deleted file mode 100644 index 3e891694..00000000 --- a/fastapi_startkit/tests/masoniteorm/test_relationship_reexports.py +++ /dev/null @@ -1,25 +0,0 @@ -import fastapi_startkit.masoniteorm as orm -from fastapi_startkit.masoniteorm import relationships - -RELATIONSHIP_CLASSES = [ - "BaseRelationship", - "BelongsTo", - "BelongsToMany", - "HasMany", - "HasManyThrough", - "HasOne", - "HasOneThrough", - "MorphMany", - "MorphOne", - "MorphTo", - "MorphToMany", -] - - -def test_relationship_classes_are_reexported_from_root(): - for name in RELATIONSHIP_CLASSES: - assert hasattr(orm, name), f"{name} is not re-exported from fastapi_startkit.masoniteorm" - assert name in orm.__all__, f"{name} missing from fastapi_startkit.masoniteorm.__all__" - assert getattr(orm, name) is getattr(relationships, name), ( - f"{name} re-export is not the same class object as in .relationships" - ) From 18c95021caca6d8f46d8289a04898a0b70adb26c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 09:42:29 -0700 Subject: [PATCH 07/18] refactor(vite-app): move templates to resources/templates and use provider default Move the example templates into resources/templates to match ViteConfig's default templates_directory, and drop the bootstrap override so the example relies on ViteProvider's auto-bound Jinja2Templates engine. --- example/vite-app/bootstrap/application.py | 4 ++-- example/vite-app/{ => resources}/templates/index.html | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename example/vite-app/{ => resources}/templates/index.html (100%) diff --git a/example/vite-app/bootstrap/application.py b/example/vite-app/bootstrap/application.py index 0fbc882a..1572a5fe 100644 --- a/example/vite-app/bootstrap/application.py +++ b/example/vite-app/bootstrap/application.py @@ -12,7 +12,7 @@ LogProvider, FastAPIProvider, # ViteProvider auto-binds a Jinja2Templates engine (with the vite() - # globals injected) at the configured templates directory. - (ViteProvider, {"templates_directory": "templates"}), + # globals injected) from resources/templates, its default directory. + ViteProvider, ], ) diff --git a/example/vite-app/templates/index.html b/example/vite-app/resources/templates/index.html similarity index 100% rename from example/vite-app/templates/index.html rename to example/vite-app/resources/templates/index.html From 6d3d7f1ed08c762cb68ed7375501d1024141c40d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 10:27:31 -0700 Subject: [PATCH 08/18] refactor(vite-app): resolve templates via canonical app accessor Use `from fastapi_startkit.application import app` -> app().make('templates') in the example route instead of the bootstrap module, matching the framework's canonical app accessor (no top-level export). --- example/vite-app/routes/web.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/example/vite-app/routes/web.py b/example/vite-app/routes/web.py index 73b952b1..063e1854 100644 --- a/example/vite-app/routes/web.py +++ b/example/vite-app/routes/web.py @@ -1,14 +1,15 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse -from fastapi_startkit.vite import template +from fastapi_startkit.application import app web = APIRouter() @web.get("/", response_class=HTMLResponse) -async def index(): - return template("index.html") +async def index(request: Request): + templates = app().make("templates") + return templates.TemplateResponse(request, "index.html") @web.get("/api/health") From 685ec7223bb993ed902e9a11838afcd9988c5b35 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 10:33:28 -0700 Subject: [PATCH 09/18] refactor(vite-app): move providers into app/ package Relocate providers/ to app/providers/ and update the bootstrap import, grouping application code under the app/ namespace. --- example/vite-app/app/__init__.py | 0 example/vite-app/app/providers/__init__.py | 0 example/vite-app/{ => app}/providers/fastapi_provider.py | 0 example/vite-app/bootstrap/application.py | 2 +- 4 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 example/vite-app/app/__init__.py create mode 100644 example/vite-app/app/providers/__init__.py rename example/vite-app/{ => app}/providers/fastapi_provider.py (100%) diff --git a/example/vite-app/app/__init__.py b/example/vite-app/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/vite-app/app/providers/__init__.py b/example/vite-app/app/providers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/vite-app/providers/fastapi_provider.py b/example/vite-app/app/providers/fastapi_provider.py similarity index 100% rename from example/vite-app/providers/fastapi_provider.py rename to example/vite-app/app/providers/fastapi_provider.py diff --git a/example/vite-app/bootstrap/application.py b/example/vite-app/bootstrap/application.py index 1572a5fe..7b66c145 100644 --- a/example/vite-app/bootstrap/application.py +++ b/example/vite-app/bootstrap/application.py @@ -4,7 +4,7 @@ from fastapi_startkit.logging import LogProvider from fastapi_startkit.vite import ViteProvider -from providers.fastapi_provider import FastAPIProvider +from app.providers.fastapi_provider import FastAPIProvider app: Application = Application( base_path=Path(__file__).resolve().parent.parent, From 6217d9413ddfaf2696bd41a9a90d0b6d9d8663fb Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 10:33:33 -0700 Subject: [PATCH 10/18] test(vite-app): add simple HTTP tests for web routes Add HttpTestCase-based tests covering the health endpoint and the index page render (templates resolve from resources/templates with vite globals). Wire up pytest config and dev dependencies. --- example/vite-app/pyproject.toml | 3 ++ example/vite-app/pytest.ini | 3 ++ example/vite-app/tests/__init__.py | 0 example/vite-app/tests/test_case.py | 14 ++++++ example/vite-app/tests/test_web.py | 31 ++++++++++++ example/vite-app/uv.lock | 75 +++++++++++++++++++++++++++-- 6 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 example/vite-app/pytest.ini create mode 100644 example/vite-app/tests/__init__.py create mode 100644 example/vite-app/tests/test_case.py create mode 100644 example/vite-app/tests/test_web.py diff --git a/example/vite-app/pyproject.toml b/example/vite-app/pyproject.toml index d11352a4..1ea54589 100644 --- a/example/vite-app/pyproject.toml +++ b/example/vite-app/pyproject.toml @@ -14,4 +14,7 @@ fastapi-startkit = { path = "../../fastapi_startkit", editable = true } [dependency-groups] dev = [ "dumpdie>=1.5.0", + "httpx>=0.28.1", + "pytest>=9.0.3", + "pytest-asyncio>=1.3.0", ] diff --git a/example/vite-app/pytest.ini b/example/vite-app/pytest.ini new file mode 100644 index 00000000..82bc8d15 --- /dev/null +++ b/example/vite-app/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +pythonpath = . diff --git a/example/vite-app/tests/__init__.py b/example/vite-app/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example/vite-app/tests/test_case.py b/example/vite-app/tests/test_case.py new file mode 100644 index 00000000..c3319011 --- /dev/null +++ b/example/vite-app/tests/test_case.py @@ -0,0 +1,14 @@ +from abc import ABC +from typing import TYPE_CHECKING + +from fastapi_startkit.testing import TestCase as BaseTestCase + +if TYPE_CHECKING: + from fastapi_startkit.application import Application + + +class TestCase(BaseTestCase, ABC): + def get_application(self) -> "Application": + from bootstrap.application import app + + return app diff --git a/example/vite-app/tests/test_web.py b/example/vite-app/tests/test_web.py new file mode 100644 index 00000000..f8718f7f --- /dev/null +++ b/example/vite-app/tests/test_web.py @@ -0,0 +1,31 @@ +from pathlib import Path + +from fastapi_startkit.fastapi.testing import HttpTestCase + +from tests.test_case import TestCase + + +class TestWebRoutes(TestCase, HttpTestCase): + async def test_health_endpoint_returns_ok(self): + response = await self.get("/api/health") + + response.assert_ok() + assert response.json() == {"status": "healthy"} + + async def test_index_page_renders(self): + # vite() needs a built manifest or the dev-server hot file; write the + # hot file (Vite reads public/hot relative to the cwd) so the template's + # vite('resources/js/app.ts') directive resolves in dev mode. + hot_file = Path("public") / "hot" + hot_file.parent.mkdir(parents=True, exist_ok=True) + hot_file.write_text("http://localhost:5173") + try: + response = await self.get("/") + finally: + hot_file.unlink(missing_ok=True) + + response.assert_ok() + body = response.text + assert "FastAPI StartKit" in body + assert "resources/js/app.ts" in body + assert "{{ vite" not in body diff --git a/example/vite-app/uv.lock b/example/vite-app/uv.lock index 47c56698..52df7eb7 100644 --- a/example/vite-app/uv.lock +++ b/example/vite-app/uv.lock @@ -280,7 +280,7 @@ wheels = [ [[package]] name = "fastapi-startkit" -version = "0.26.0" +version = "0.45.0" source = { editable = "../../fastapi_startkit" } dependencies = [ { name = "cleo" }, @@ -305,23 +305,26 @@ vite = [ requires-dist = [ { name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" }, { name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" }, + { name = "anthropic", marker = "extra == 'ai'", specifier = ">=0.49.0" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" }, { name = "cleo", specifier = ">=2.1.0,<3.0.0" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "dotty-dict", specifier = ">=1.3.1" }, { name = "faker", marker = "extra == 'database'", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" }, + { name = "google-generativeai", marker = "extra == 'ai'", specifier = ">=0.8.0" }, { name = "inflection", specifier = ">=0.5.1" }, { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, + { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"] [package.metadata.requires-dev] dev = [ @@ -329,10 +332,12 @@ dev = [ { name = "aiosqlite", specifier = ">=0.22.1" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "faker", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.124.4" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.9.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.38" }, { name = "twine", specifier = ">=6.2.0" }, @@ -494,6 +499,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -599,6 +613,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "pendulum" version = "3.2.0" @@ -642,6 +665,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.13.3" @@ -746,6 +778,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1161,6 +1222,9 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "dumpdie" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] @@ -1170,7 +1234,12 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "dumpdie", specifier = ">=1.5.0" }] +dev = [ + { name = "dumpdie", specifier = ">=1.5.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] [[package]] name = "watchfiles" From 7c0fc933273ef63f8eb0ebf21f66f6cbf4c31936 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 10:34:51 -0700 Subject: [PATCH 11/18] chore: drop accidental .coverage binary and ignore coverage artifacts --- .gitignore | 4 ++++ fastapi_startkit/.coverage | Bin 135168 -> 0 bytes 2 files changed, 4 insertions(+) delete mode 100644 fastapi_startkit/.coverage diff --git a/.gitignore b/.gitignore index ee65e917..5a93aab3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ **/.env **/*.sqlite **/*.vite +**/.coverage +**/.coverage.* +**/htmlcov/ +**/coverage.xml fastapi_startkit/dist fastapi_startkit.github.io.git laravel-repo diff --git a/fastapi_startkit/.coverage b/fastapi_startkit/.coverage deleted file mode 100644 index 43962310e710cee54d1a3fd7dd67c66a7959a8cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 135168 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 VnwQJU4%PqF)w+KheK(D?{|BYq{tEyA From e129d9aea877a2be992b231f6297a0f4b776e5a7 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 12:08:36 -0700 Subject: [PATCH 12/18] test(vite-app): remove hot file workaround from index page test Rely on the built manifest so the vite() directive resolves to production asset URLs, and drop the dev-server hot file setup. Assert against the built /build/assets/ output instead of the source path. Also remove the stale ViteProvider comment in bootstrap. --- example/vite-app/bootstrap/application.py | 2 -- example/vite-app/tests/test_web.py | 15 ++------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/example/vite-app/bootstrap/application.py b/example/vite-app/bootstrap/application.py index 7b66c145..4cde13a8 100644 --- a/example/vite-app/bootstrap/application.py +++ b/example/vite-app/bootstrap/application.py @@ -11,8 +11,6 @@ providers=[ LogProvider, FastAPIProvider, - # ViteProvider auto-binds a Jinja2Templates engine (with the vite() - # globals injected) from resources/templates, its default directory. ViteProvider, ], ) diff --git a/example/vite-app/tests/test_web.py b/example/vite-app/tests/test_web.py index f8718f7f..3cf46835 100644 --- a/example/vite-app/tests/test_web.py +++ b/example/vite-app/tests/test_web.py @@ -1,5 +1,3 @@ -from pathlib import Path - from fastapi_startkit.fastapi.testing import HttpTestCase from tests.test_case import TestCase @@ -13,19 +11,10 @@ async def test_health_endpoint_returns_ok(self): assert response.json() == {"status": "healthy"} async def test_index_page_renders(self): - # vite() needs a built manifest or the dev-server hot file; write the - # hot file (Vite reads public/hot relative to the cwd) so the template's - # vite('resources/js/app.ts') directive resolves in dev mode. - hot_file = Path("public") / "hot" - hot_file.parent.mkdir(parents=True, exist_ok=True) - hot_file.write_text("http://localhost:5173") - try: - response = await self.get("/") - finally: - hot_file.unlink(missing_ok=True) + response = await self.get("/") response.assert_ok() body = response.text assert "FastAPI StartKit" in body - assert "resources/js/app.ts" in body + assert "/build/assets/" in body assert "{{ vite" not in body From 161f1fc5176841a5a307e2ab20acbceb23b6ebf5 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 12:09:18 -0700 Subject: [PATCH 13/18] test(vite-app): rename test_web to test_web_routes Match the routes/web.py module and the TestWebRoutes class it covers. --- example/vite-app/tests/{test_web.py => test_web_routes.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename example/vite-app/tests/{test_web.py => test_web_routes.py} (100%) diff --git a/example/vite-app/tests/test_web.py b/example/vite-app/tests/test_web_routes.py similarity index 100% rename from example/vite-app/tests/test_web.py rename to example/vite-app/tests/test_web_routes.py From b8cc904da3bca5deeb6019e9873aa4df1ffcfc39 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 12:09:43 -0700 Subject: [PATCH 14/18] test(vite-app): drop asset and vite directive assertions Keep the index page test focused on a successful render. --- example/vite-app/tests/test_web_routes.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/example/vite-app/tests/test_web_routes.py b/example/vite-app/tests/test_web_routes.py index 3cf46835..0e33a082 100644 --- a/example/vite-app/tests/test_web_routes.py +++ b/example/vite-app/tests/test_web_routes.py @@ -16,5 +16,3 @@ async def test_index_page_renders(self): response.assert_ok() body = response.text assert "FastAPI StartKit" in body - assert "/build/assets/" in body - assert "{{ vite" not in body From c2d3814e82a69e6f41e8a1a939cc41c76243ea9e Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 12:10:58 -0700 Subject: [PATCH 15/18] test(vite-app): rename TestWebRoutes to TestHomeController --- example/vite-app/tests/test_web_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/vite-app/tests/test_web_routes.py b/example/vite-app/tests/test_web_routes.py index 0e33a082..521c7412 100644 --- a/example/vite-app/tests/test_web_routes.py +++ b/example/vite-app/tests/test_web_routes.py @@ -3,7 +3,7 @@ from tests.test_case import TestCase -class TestWebRoutes(TestCase, HttpTestCase): +class TestHomeController(TestCase, HttpTestCase): async def test_health_endpoint_returns_ok(self): response = await self.get("/api/health") From 990e842bac2b8b68279df893018d3f6e8b044692 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 23 Jun 2026 00:00:28 -0700 Subject: [PATCH 16/18] feat(ai): back the Agent with LangChain/LangGraph, keep the public API intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the per-provider SDK internals of ai/agent.py (_run/_stream over the anthropic/openai/google SDKs) for a single LangChain/LangGraph backend: init_chat_model builds the chat model and create_agent drives the tool loop, with the final AIMessage mapped back to AgentResponse. The user-facing surface is unchanged — prompt/stream/fake/assert_prompted/assert_not_prompted/reset, the lifecycle hooks, and the decorators keep identical signatures. - _build_model() is the seam tests patch to inject a fake chat model. - _build_messages() now renders attachments via Document.to_langchain_block(). - Add Document.to_langchain_block(): inline text, base64 image/file blocks. - Add ai/fakes.py fake_chat_model(): replays scripted AIMessage turns through a GenericFakeChatModel (bind_tools no-op) so the real create_agent loop runs offline; exported from the ai package root. - New optional [langgraph] extra (langchain + langchain-core + langgraph). The 23 tests in tests/ai/test_agent_fake.py stay green and unmodified (fake() short-circuits before the backend). Adds tests/ai/test_agent_langgraph_backend.py exercising the real loop offline: simple reply, full tool-calling loop, usage mapping, attachment blocks, provider mapping, and streaming. --- fastapi_startkit/pyproject.toml | 9 + .../src/fastapi_startkit/ai/__init__.py | 2 + .../src/fastapi_startkit/ai/agent.py | 351 ++++------- .../src/fastapi_startkit/ai/document.py | 13 + .../src/fastapi_startkit/ai/fakes.py | 59 ++ .../tests/ai/test_agent_langgraph_backend.py | 154 +++++ fastapi_startkit/uv.lock | 567 ++++++++++++++++-- 7 files changed, 863 insertions(+), 292 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/fakes.py create mode 100644 fastapi_startkit/tests/ai/test_agent_langgraph_backend.py diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 84f9d113..5b1b05dd 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -53,6 +53,12 @@ ai = [ "google-generativeai>=0.8.0", ] +langgraph = [ + "langchain>=1.0.0", + "langchain-core>=1.0.0", + "langgraph>=1.0.0", +] + [dependency-groups] dev = [ "dumpdie>=1.5.0", @@ -68,6 +74,9 @@ dev = [ "sqlalchemy[asyncio]>=2.0.38", "fastapi[standard]>=0.124.4", "faker>=40.13.0", + "langchain>=1.0.0", + "langchain-core>=1.0.0", + "langgraph>=1.0.0", ] diff --git a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py index 76c04f99..2e0f52dc 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py @@ -22,6 +22,7 @@ from .config import AIConfig, AnthropicConfig, GoogleConfig, OpenAIConfig from .decorators import max_steps, max_tokens, memory, model, provider, timeout, top_p from .document import Document +from .fakes import fake_chat_model from .image import Image, ImageResponse from .image_factory import ImageFactory from .providers.ai_provider import AIProvider @@ -38,6 +39,7 @@ "AudioResponse", "AudioFactory", "Document", + "fake_chat_model", "GoogleConfig", "Image", "ImageFactory", diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 05a7c664..2e8e79b0 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -1,4 +1,16 @@ -"""Agent base class — subclass this and apply decorators to build an AI agent.""" +"""Agent base class — subclass this and apply decorators to build an AI agent. + +The agent runs on LangChain/LangGraph: :meth:`Agent.prompt` builds a chat model +with ``init_chat_model`` and drives a ``create_agent`` loop (tools included), +while :meth:`Agent.stream` streams tokens straight from the model. The public +surface — ``prompt``/``stream``/``fake``/``assert_prompted``/``reset`` plus the +lifecycle hooks and decorators — is provider-agnostic; only the backend changed. + +Real calls need the ``langgraph`` extra plus the relevant provider integration +(e.g. ``langchain-anthropic``). Tests never need them: :meth:`fake` short-circuits +before the backend, and :func:`fastapi_startkit.ai.fakes.fake_chat_model` drives +the full agent loop offline. +""" from __future__ import annotations @@ -38,6 +50,13 @@ class Agent: "google": "gemini-2.0-flash", } + # Map the agent's provider name to the LangChain ``init_chat_model`` provider id. + _LANGCHAIN_PROVIDERS: dict[str, str] = { + "anthropic": "anthropic", + "openai": "openai", + "google": "google_genai", + } + def __init__(self): self._fakes: dict[str, AgentResponse | AgentSnapshot] = {} self._call_log: list[dict] = [] @@ -202,6 +221,16 @@ def _get_provider_options(self, override: dict | None = None) -> dict: options.update(provider_specific) return options + def _resolve_api_key(self, provider_name: str) -> str | None: + """Try Config.get("ai") first, fallback to None (the model reads its env var).""" + try: + from fastapi_startkit.facades.Config import Config # noqa: PLC0415 + + ai_config = Config.get("ai") + return ai_config.providers[provider_name].key or None + except Exception: + return None + def _build_messages( self, message: str, @@ -224,275 +253,97 @@ def _build_messages( if attachments: content: Any = [{"type": "text", "text": message}] for doc in attachments: - if self._provider == "anthropic": - content.append(doc.to_anthropic_block()) - else: - content.append(doc.to_openai_block()) + content.append(doc.to_langchain_block()) history.append({"role": "user", "content": content}) else: history.append({"role": "user", "content": message}) return resolved_system, history - def _run( - self, - message: str, - system: str | None = None, - model: str | None = None, - extra_messages: list[dict] | None = None, - attachments: list[Document] | None = None, - provider_options: dict | None = None, - ) -> AgentResponse: - resolved_system, messages = self._build_messages(message, system, extra_messages, attachments) - resolved_model = self._resolve_model(model) - options = self._get_provider_options(provider_options) - - if self._provider == "anthropic": - return self._run_anthropic(resolved_system, messages, resolved_model, options) - if self._provider == "openai": - return self._run_openai(resolved_system, messages, resolved_model, options) - if self._provider == "google": - return self._run_google(resolved_system, messages, resolved_model, options) - raise ValueError(f"Unsupported provider: {self._provider!r}. Use 'anthropic', 'openai', or 'google'.") + def _build_model(self, model: str | None = None, provider_options: dict | None = None) -> Any: + """Build a LangChain chat model for this agent. - def _stream( - self, - message: str, - system: str | None = None, - model: str | None = None, - provider_options: dict | None = None, - ) -> Iterator[str]: - resolved_system, messages = self._build_messages(message, system) - resolved_model = self._resolve_model(model) - options = self._get_provider_options(provider_options) - - if self._provider == "anthropic": - yield from self._stream_anthropic(resolved_system, messages, resolved_model, options) - elif self._provider == "openai": - yield from self._stream_openai(resolved_system, messages, resolved_model, options) - elif self._provider == "google": - yield from self._stream_google(resolved_system, messages, resolved_model, options) - else: - raise ValueError(f"Unsupported provider: {self._provider!r}. Use 'anthropic', 'openai', or 'google'.") + This is the seam tests patch to inject a fake chat model (see + :func:`fastapi_startkit.ai.fakes.fake_chat_model`). + """ + from langchain.chat_models import init_chat_model # noqa: PLC0415 - # ── Anthropic ────────────────────────────────────────────────────────── + provider = self._LANGCHAIN_PROVIDERS.get(self._provider, self._provider) + kwargs: dict[str, Any] = {"model_provider": provider} - def _run_anthropic( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> AgentResponse: - from anthropic import Anthropic # noqa: PLC0415 - - api_key = self._resolve_api_key("anthropic") - client = Anthropic(api_key=api_key) - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": messages, - **options, - } - if system: - params["system"] = system - - resp = client.messages.create(**params) - content = "".join(b.text for b in resp.content if hasattr(b, "text")) - return AgentResponse( - content=content, - usage={"input": resp.usage.input_tokens, "output": resp.usage.output_tokens}, - raw=resp, - ) + api_key = self._resolve_api_key(self._provider) + if api_key: + kwargs["api_key"] = api_key + if self._max_tokens: + kwargs["max_tokens"] = self._max_tokens + if self._top_p != 1.0: + kwargs["top_p"] = self._top_p + if self._timeout: + kwargs["timeout"] = self._timeout + kwargs.update(self._get_provider_options(provider_options)) - def _stream_anthropic( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> Iterator[str]: - from anthropic import Anthropic # noqa: PLC0415 - - api_key = self._resolve_api_key("anthropic") - client = Anthropic(api_key=api_key) - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": messages, - **options, - } - if system: - params["system"] = system - - with client.messages.stream(**params) as stream: - for text in stream.text_stream: - yield text - - # ── OpenAI ───────────────────────────────────────────────────────────── - - def _run_openai( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> AgentResponse: - from openai import OpenAI # noqa: PLC0415 - - api_key = self._resolve_api_key("openai") - client = OpenAI(api_key=api_key) - all_messages: list[dict] = [] - if system: - all_messages.append({"role": "system", "content": system}) - all_messages.extend(messages) - - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": all_messages, - **options, - } - resp = client.chat.completions.create(**params) - content = resp.choices[0].message.content or "" - return AgentResponse( - content=content, - usage={ - "input": resp.usage.prompt_tokens if resp.usage else 0, - "output": resp.usage.completion_tokens if resp.usage else 0, - }, - raw=resp, - ) + return init_chat_model(self._resolve_model(model), **kwargs) - def _stream_openai( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> Iterator[str]: - from openai import OpenAI # noqa: PLC0415 - - api_key = self._resolve_api_key("openai") - client = OpenAI(api_key=api_key) - all_messages: list[dict] = [] - if system: - all_messages.append({"role": "system", "content": system}) - all_messages.extend(messages) - - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": all_messages, - "stream": True, - **options, - } - for chunk in client.chat.completions.create(**params): - delta = chunk.choices[0].delta.content - if delta: - yield delta + def _to_agent_response(self, result: Any) -> AgentResponse: + """Map a ``create_agent`` invoke result to an AgentResponse.""" + messages = result.get("messages", []) if isinstance(result, dict) else [] + final = messages[-1] if messages else result - def _resolve_api_key(self, provider_name: str) -> str | None: - """Try Config.get("ai") first, fallback to None (SDK reads env var).""" - try: - from fastapi_startkit.facades.Config import Config # noqa: PLC0415 + content = getattr(final, "content", "") + if not isinstance(content, str): + content = str(content) - ai_config = Config.get("ai") - return ai_config.providers[provider_name].key or None - except Exception: - return None + tool_calls = list(getattr(final, "tool_calls", None) or []) - # ── Google ───────────────────────────────────────────────────────────── + usage: dict[str, Any] = {} + meta = getattr(final, "usage_metadata", None) + if meta: + usage = {"input": meta.get("input_tokens", 0), "output": meta.get("output_tokens", 0)} - def _run_google( + return AgentResponse(content=content, tool_calls=tool_calls, usage=usage, raw=result) + + def _run( self, - system: str | None, - messages: list[dict], - model: str, - options: dict, + message: str, + system: str | None = None, + model: str | None = None, + extra_messages: list[dict] | None = None, + attachments: list[Document] | None = None, + provider_options: dict | None = None, ) -> AgentResponse: - import google.generativeai as genai # noqa: PLC0415 + from langchain.agents import create_agent # noqa: PLC0415 - api_key = self._resolve_api_key("google") - if api_key: - genai.configure(api_key=api_key) + resolved_system, history = self._build_messages(message, system, extra_messages, attachments) + chat_model = self._build_model(model, provider_options) - generation_config: dict[str, Any] = {} - if self._max_tokens: - generation_config["max_output_tokens"] = self._max_tokens - if self._top_p != 1.0: - generation_config["top_p"] = self._top_p - generation_config.update(options) + agent_kwargs: dict[str, Any] = {"tools": self.tools()} + if resolved_system: + agent_kwargs["system_prompt"] = resolved_system + schema = self.schema() + if schema is not None: + agent_kwargs["response_format"] = schema - google_model = genai.GenerativeModel( - model_name=model, - system_instruction=system, - generation_config=generation_config if generation_config else None, - ) + agent = create_agent(chat_model, **agent_kwargs) + result = agent.invoke({"messages": history}, {"recursion_limit": self._max_steps * 2 + 1}) + return self._to_agent_response(result) - google_messages = _to_google_messages(messages) - response = google_model.generate_content(google_messages) - content = response.text if hasattr(response, "text") else "" - usage: dict[str, Any] = {} - if hasattr(response, "usage_metadata"): - meta = response.usage_metadata - usage = { - "input": getattr(meta, "prompt_token_count", 0), - "output": getattr(meta, "candidates_token_count", 0), - } - return AgentResponse(content=content, usage=usage, raw=response) - - def _stream_google( + def _stream( self, - system: str | None, - messages: list[dict], - model: str, - options: dict, + message: str, + system: str | None = None, + model: str | None = None, + provider_options: dict | None = None, ) -> Iterator[str]: - import google.generativeai as genai # noqa: PLC0415 - - api_key = self._resolve_api_key("google") - if api_key: - genai.configure(api_key=api_key) - - generation_config: dict[str, Any] = {} - if self._max_tokens: - generation_config["max_output_tokens"] = self._max_tokens - if self._top_p != 1.0: - generation_config["top_p"] = self._top_p - generation_config.update(options) - - google_model = genai.GenerativeModel( - model_name=model, - system_instruction=system, - generation_config=generation_config if generation_config else None, - ) - - google_messages = _to_google_messages(messages) - for chunk in google_model.generate_content(google_messages, stream=True): - if chunk.text: - yield chunk.text - - -# ─── Utilities ───────────────────────────────────────────────────────────────── - - -def _to_google_messages(messages: list[dict]) -> list[dict]: - """ - Convert OpenAI-style messages to Google GenerativeAI content format. - Maps 'assistant' role → 'model'; omits 'system' (handled via system_instruction). - """ - result = [] - for msg in messages: - role = msg.get("role", "user") - content = msg.get("content", "") - if role == "system": - continue # system_instruction is set at model-construction level - google_role = "model" if role == "assistant" else "user" - if isinstance(content, list): - # Multi-part content — extract text parts only - text = " ".join(p.get("text", "") for p in content if isinstance(p, dict) and "text" in p) - result.append({"role": google_role, "parts": [{"text": text}]}) - else: - result.append({"role": google_role, "parts": [{"text": str(content)}]}) - return result + resolved_system, history = self._build_messages(message, system) + chat_model = self._build_model(model, provider_options) + + lc_messages: list[dict] = [] + if resolved_system: + lc_messages.append({"role": "system", "content": resolved_system}) + lc_messages.extend(history) + + for chunk in chat_model.stream(lc_messages): + text = getattr(chunk, "content", "") + if not text: + continue + yield text if isinstance(text, str) else str(text) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/document.py b/fastapi_startkit/src/fastapi_startkit/ai/document.py index 9ac7fdbb..574a5876 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/document.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/document.py @@ -137,3 +137,16 @@ def to_openai_block(self) -> dict: "type": "text", "text": f"[Document: {self.name}]\n{self.content}", } + + def to_langchain_block(self) -> dict: + """Return a LangChain content block for this document. + + Text (or ``text/*`` media) is inlined as a labelled text part; everything + else becomes a base64 ``image``/``file`` block the model reads natively. + """ + is_text = isinstance(self.content, str) or self.media_type.startswith("text/") + if is_text: + text = self.content if isinstance(self.content, str) else self.content.decode("utf-8", "replace") + return {"type": "text", "text": f"[Document: {self.name}]\n{text}"} + block_type = "image" if self.media_type.startswith("image/") else "file" + return {"type": block_type, "base64": self.to_base64(), "mime_type": self.media_type} diff --git a/fastapi_startkit/src/fastapi_startkit/ai/fakes.py b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py new file mode 100644 index 00000000..430f02a9 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py @@ -0,0 +1,59 @@ +"""LangChain test helpers — drive a real agent loop offline, without a provider. + +:func:`fake_chat_model` returns a chat model that replays a scripted sequence of +assistant turns. Inject it into an :class:`~fastapi_startkit.ai.Agent` by patching +``_build_model`` so :meth:`Agent.prompt` runs the genuine ``create_agent`` loop — +tool calls included — with no network. Requires the ``langgraph`` extra:: + + pip install "fastapi-startkit[langgraph]" + +Example — exercise a tool-calling agent end to end:: + + from langchain_core.messages import AIMessage, ToolCall + from fastapi_startkit.ai import fake_chat_model + + model = fake_chat_model([ + AIMessage(content="", tool_calls=[ + ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call"), + ]), + AIMessage(content="Here is a Python Developer role at Shopify."), + ]) + agent = JobAssistant() + agent._build_model = lambda *a, **k: model + response = agent.prompt("find me a python job") + assert response.content == "Here is a Python Developer role at Shopify." +""" + +from __future__ import annotations + +from typing import Any, Iterable + + +def _require_langchain(): + try: + from langchain_core.language_models.fake_chat_models import GenericFakeChatModel + from langchain_core.messages import AIMessage + except ImportError as exc: # pragma: no cover - exercised only without the extra + raise ImportError( + "The agent test harness requires the 'langgraph' extra. " + 'Install it with: pip install "fastapi-startkit[langgraph]"' + ) from exc + return GenericFakeChatModel, AIMessage + + +def fake_chat_model(turns: Iterable[Any]): + """Return a fake chat model that replays ``turns`` in order. + + Each turn is an ``AIMessage`` (which may carry ``tool_calls``) or a ``str`` + (shorthand for ``AIMessage(content=...)``). The scripted turns already encode + the model's decisions, so ``bind_tools`` is a no-op — the bound tool schemas + don't change what the fake says next. + """ + generic_model, ai_message = _require_langchain() + + class _FakeChatModel(generic_model): + def bind_tools(self, tools, **kwargs): + return self + + normalized = [t if isinstance(t, ai_message) else ai_message(content=str(t)) for t in turns] + return _FakeChatModel(messages=iter(normalized)) diff --git a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py new file mode 100644 index 00000000..895d8b08 --- /dev/null +++ b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py @@ -0,0 +1,154 @@ +"""The Agent backend runs on LangGraph (create_agent), tested offline. + +These exercise the real ``_run``/``_stream`` path — no fake() short-circuit — by +injecting a scripted fake chat model through the ``_build_model`` seam. The public +API is unchanged; ``prompt()`` still returns an AgentResponse, ``stream()`` still +yields strings. +""" + +from langchain_core.messages import AIMessage, ToolCall +from langchain_core.tools import tool + +from fastapi_startkit.ai import Document, fake_chat_model +from fastapi_startkit.ai.agent import Agent +from fastapi_startkit.ai.response import AgentResponse + + +def _with_model(agent: Agent, turns) -> Agent: + """Patch the agent's model seam to replay scripted turns offline.""" + model = fake_chat_model(turns) + agent._build_model = lambda *a, **k: model # type: ignore[method-assign] + return agent + + +@tool +def search_jobs(query: str) -> str: + """Search the job board for roles matching the query.""" + return "Python Developer at Shopify" + + +class JobAssistant(Agent): + def messages(self): + return [{"role": "system", "content": "You help users find jobs."}] + + def tools(self): + return [search_jobs] + + +# ─── prompt() drives the create_agent loop ──────────────────────────────────── + + +def test_prompt_returns_agent_response_from_langgraph(): + agent = _with_model(Agent(), [AIMessage(content="hello back")]) + + result = agent.prompt("hi there") + + assert isinstance(result, AgentResponse) + assert result.content == "hello back" + agent.assert_prompted() + + +def test_prompt_runs_a_full_tool_calling_loop(): + agent = _with_model( + JobAssistant(), + [ + AIMessage( + content="", + tool_calls=[ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call")], + ), + AIMessage(content="Here is a Python Developer role at Shopify."), + ], + ) + + result = agent.prompt("find me a python job") + + assert result.content == "Here is a Python Developer role at Shopify." + # The loop ran: user → AI(tool_call) → tool result → AI(final). + assert len(result.raw["messages"]) == 4 + + +def test_prompt_maps_usage_metadata(): + reply = AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18}) + agent = _with_model(Agent(), [reply]) + + result = agent.prompt("anything") + + assert result.usage == {"input": 11, "output": 7} + + +# ─── attachments render as LangChain blocks ─────────────────────────────────── + + +def test_attachments_are_built_as_langchain_blocks(): + agent = Agent() + doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + + _system, history = agent._build_messages("Summarise this report.", attachments=[doc]) + + user_content = history[-1]["content"] + assert user_content[0] == {"type": "text", "text": "Summarise this report."} + assert user_content[1]["type"] == "text" + assert "q3-report.txt" in user_content[1]["text"] + + +def test_binary_attachment_becomes_a_file_block(): + agent = Agent() + doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") + + _system, history = agent._build_messages("Summarise", attachments=[doc]) + + block = history[-1]["content"][1] + assert block["type"] == "file" + assert block["mime_type"] == "application/pdf" + assert block["base64"] == doc.to_base64() + + +def test_prompt_with_attachment_returns_reply(): + agent = _with_model(JobAssistant(), [AIMessage(content="Summarised.")]) + doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + + result = agent.prompt("Summarise this report.", attachments=[doc]) + + assert result.content == "Summarised." + + +# ─── provider mapping + model resolution ────────────────────────────────────── + + +def test_google_provider_maps_to_langchain_google_genai(): + class GoogleAgent(Agent): + _provider = "google" + + assert GoogleAgent._LANGCHAIN_PROVIDERS["google"] == "google_genai" + + +def test_resolve_model_falls_back_to_provider_default(): + assert Agent()._resolve_model() == "claude-sonnet-4-6" + + class OpenAIAgent(Agent): + _provider = "openai" + + assert OpenAIAgent()._resolve_model() == "gpt-4o" + + +# ─── stream() pulls tokens from the model ───────────────────────────────────── + + +def test_stream_yields_tokens_from_the_model(): + agent = _with_model(Agent(), [AIMessage(content="streamed reply")]) + + chunks = list(agent.stream("hello")) + + assert "".join(chunks) == "streamed reply" + agent.assert_prompted(times=1) + + +# ─── fake_chat_model accepts plain strings ──────────────────────────────────── + + +def test_fake_chat_model_accepts_string_shorthand(): + agent = _with_model(Agent(), ["plain string turn"]) + + result = agent.prompt("anything") + + assert result.content == "plain string turn" diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 8b674dea..d6b41d3d 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -623,6 +623,11 @@ inertia = [ { name = "jinja2" }, { name = "markupsafe" }, ] +langgraph = [ + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, +] mysql = [ { name = "aiomysql" }, ] @@ -645,6 +650,9 @@ dev = [ { name = "faker" }, { name = "fastapi", extra = ["standard"] }, { name = "itsdangerous" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -669,6 +677,9 @@ requires-dist = [ { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, + { name = "langchain", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, + { name = "langchain-core", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, + { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, @@ -676,7 +687,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai", "langgraph"] [package.metadata.requires-dev] dev = [ @@ -687,6 +698,9 @@ dev = [ { name = "faker", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.124.4" }, { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "langchain", specifier = ">=1.0.0" }, + { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langgraph", specifier = ">=1.0.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, @@ -1230,6 +1244,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, ] +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + [[package]] name = "keyring" version = "25.7.0" @@ -1247,6 +1282,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "langchain" +version = "1.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a2/91a7197c604a3ce1b774b3c10dd114c3c745c6186a304fc2573b3f94d400/langchain-1.3.11.tar.gz", hash = "sha256:f3cf9cd4d2329b1a03eb8fd92b9d73e4e58a4d52570d67725fc77fbe0f104b32", size = 633374, upload-time = "2026-06-22T23:00:33.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/a4/3a181967294f8876362cc4ba36840d50b8286fa23bb3f5e602b69eb3cb1e/langchain-1.3.11-py3-none-any.whl", hash = "sha256:7ae011f95a09b22feea1e8ae4e43f0b6164aebf4c61b8ad845b45f72ff3a90a2", size = 133639, upload-time = "2026-06-22T23:00:31.619Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/7a/ea09b05bb0cbddfa43bd34fc581357e87fc3f21a751cc0d419688c3106da/langgraph-1.2.6.tar.gz", hash = "sha256:f9b45a34f13930c94d96cdb76277447ad2cc70ec2d18cd2764d7fdadb36cdc1b", size = 714400, upload-time = "2026-06-18T20:58:21.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/32/772db1b00a9fe42f50320d1aa20caefb76e621eff1f7218b9918093d631d/langgraph-1.2.6-py3-none-any.whl", hash = "sha256:1cf94d3ca124f84f77ce408fa1b06c3dee680a8aafffe364a8fd5d7d03eb8695", size = 246132, upload-time = "2026-06-18T20:58:20.335Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, +] + +[[package]] +name = "langsmith" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/34/a77abacdece10440fa04d8b2afd884708a98f62ff271ee79d75bcb8bb9bc/langsmith-0.9.0.tar.gz", hash = "sha256:21b381462ee44713dd5e2163b7db44fb28fde65146aad27aeabd778c464da0f0", size = 4556365, upload-time = "2026-06-22T15:37:18.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/64/d411be633d1c976955a09f6b3c58fbe70592d1370d262d171f7daf7e3793/langsmith-0.9.0-py3-none-any.whl", hash = "sha256:5eeccc36ff956946df8510a2b3b5a87d36c44f11bfb2e5205e9cf03d7b65ec9c", size = 578496, upload-time = "2026-06-22T15:37:16.42Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1393,6 +1554,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" }, ] +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + [[package]] name = "packaging" version = "26.1" @@ -2110,6 +2363,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tqdm" version = "4.68.2" @@ -2205,6 +2467,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uuid-utils" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5a/5da7ae85b38e3eddba0be3e8e4328f90882fe92989728e6fb552963d4c42/uuid_utils-0.16.2.tar.gz", hash = "sha256:fa637e4f314ad5b59ff6d8e809d506443d68bef30bfaecdfcfe02cce689abb2f", size = 42962, upload-time = "2026-06-18T13:36:48.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/07/294b72a572218bf6e92355203b832b3356c58a7e1e0b92a034497d15bef9/uuid_utils-0.16.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6f064dc54c6abecb09eb104d953bfb079f3c395e0d6b18899979f852d1083549", size = 560726, upload-time = "2026-06-18T13:35:21.053Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3c/1095b6ab574a7fa69136d47bab5a43f320a8f00a0ecb96059fd49b1747b2/uuid_utils-0.16.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:dd7aa18db5cc826d482d876a826fee445839701f81f78567e7c74b4458d57a84", size = 288065, upload-time = "2026-06-18T13:35:22.547Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/6404d48fe71def0733c9568d96043b2e1945e2e4205c4eb525db3da42ba3/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc25ad320c9b44c2d3ed33aff4f85b0b277bef4ff79b12c01ee58b52ea44be1d", size = 322946, upload-time = "2026-06-18T13:35:23.648Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/8a009762015a134aa04b5451400e0ec9832ccd598ed4845f9aecb0be6299/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0ca752d51d1004caff65fccffd44b32a26cb099b546e0512cfa09facb683d6c", size = 330186, upload-time = "2026-06-18T13:35:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b0/1613bb98ac11234145aa5bc1de618be536818fef05dec595efb3e2b37097/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8323136bb02355c1b973492ab98b0722206dfdedfb148e4115c35fcdf3889bad", size = 444583, upload-time = "2026-06-18T13:35:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/93/66/83e62c7a152bbbb8b30ac58eaad81f3860ba2fba91a334c50f223f9ce878/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bf8bfdffb22f620635580b17fd178272f30a9841b824b19b935c8db64bf09b6", size = 323064, upload-time = "2026-06-18T13:35:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c1b2faaf3a9d7952f321a9fee3ad74e05b25878bd9b7cd6b0398fe77f279/uuid_utils-0.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:61454f2139424a6cff14eca7849c28b3350f261453b74075aa20fe99592dbb16", size = 347967, upload-time = "2026-06-18T13:35:28.538Z" }, + { url = "https://files.pythonhosted.org/packages/24/d8/cdf79b242e41ae47b7cd617ac5d48f15ce44e81da8000379c757091ae5f8/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:725110434a1d482a639a9ac467a24f1cb531d84ab52e454a13fe145b10b42cae", size = 499187, upload-time = "2026-06-18T13:35:30.042Z" }, + { url = "https://files.pythonhosted.org/packages/be/10/978d5ad82bc0fe7ff02d5be6f1eb83b090849f0a95bf8438593565273b7a/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8197870739a3094990743a80f075fa0b17beafd6c187e5f360e021d90a12a6d1", size = 605696, upload-time = "2026-06-18T13:35:31.289Z" }, + { url = "https://files.pythonhosted.org/packages/3a/28/e382ee44a592e35b80397b493bf3fbbdb8e30a64eaaefc7dabc246aeb253/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e10a02b3a31ed44c7c9a96abde335f5fa222735e73f3081d693414377eb3b016", size = 564975, upload-time = "2026-06-18T13:35:32.419Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d0/f6011dbe4e5d751a8494715e014019cb5b242d8cd6dbec1cfec3d3fb2e81/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd32dbca0792b9683160151dc07fad11b915020eed7c82b43faf0862c2ff06a0", size = 528462, upload-time = "2026-06-18T13:35:33.685Z" }, + { url = "https://files.pythonhosted.org/packages/42/7f/279e6159c37f43feb9dd70218b49a26696cefddaef1db7f4b79895eaf5d5/uuid_utils-0.16.2-cp312-cp312-win32.whl", hash = "sha256:dcdfcab60562d12dd43c1a6f495b1d089e41f0e10fac37d94db285d72b678c23", size = 167047, upload-time = "2026-06-18T13:35:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/47/38/f72f7bed062601448ec2db47351e6c1faccd78fd693bbc6e067299d1fa11/uuid_utils-0.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:97ee6f5e803ea571f5f6da42efc97d8c5a13f121043680177f8470529b94e855", size = 173821, upload-time = "2026-06-18T13:35:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/37/61/8a025284a31c85b7c0c5319e96868c2c09dea3fc5f676c979a4cd4baf2e7/uuid_utils-0.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:72cfd9ff1e8a7c371a044687e77eb873721c4a9f4814e453439bfba595b84303", size = 172206, upload-time = "2026-06-18T13:35:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/3b48859953ee74fc26628ca5d9e5f848209655a0a8c934032fc596035976/uuid_utils-0.16.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c19b7d595d12923da682ed13d313c2333b9ebf214e65a47a24927a8a3a81b191", size = 560753, upload-time = "2026-06-18T13:35:38.531Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1c/77635489de5454f2a25411030f78d31931dbdc0c86114da00adb9b91f120/uuid_utils-0.16.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:909e26fa2451c8db31b9ed1d3c8e4ecf513b6d1619db4205997fe99eb6b4ef4f", size = 288056, upload-time = "2026-06-18T13:35:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0e/8e799537ea458abaefb0f5c3b3b05304d3faf413feb0997605a3f8ae2484/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27271b37fbc6812bb1542c4b8e22ee00223a6bf7f62b1f38d3bcf8e92f6d9acd", size = 323196, upload-time = "2026-06-18T13:35:41.534Z" }, + { url = "https://files.pythonhosted.org/packages/e8/92/4e5b412d4710617fb83ed77b361f5fa6247b99bde2fa6ee07ddf851b59d1/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc4b9d96a2c689d664cf3fc7f7db46b82d2821fb2ce8a4f0798fc0a92c1569f8", size = 330858, upload-time = "2026-06-18T13:35:42.709Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e3/8173202b7cfcfeb4a588c5f8b85d3e2b44973384eb33167ee25c5c78867f/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3bf41b696b0fe808df1b4091c70273a52ea033b0fe97341cd67ecd76d22bb3a", size = 444813, upload-time = "2026-06-18T13:35:43.917Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/c3918356932ce467b11e954d0c93697fb4652cf664957e3d9521f7ece22f/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc329be41bb6534ecb03e50596179ab76c7643ced33d13c66967d5ae1869663", size = 322828, upload-time = "2026-06-18T13:35:45.134Z" }, + { url = "https://files.pythonhosted.org/packages/f0/80/4020556682441b62a25b7d07798812115fca97d417a3498d5af6dce36504/uuid_utils-0.16.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4125bf6ed3ae443c05e140f8585d174b9d647295b12034d5ec94ae2ae38edefa", size = 347909, upload-time = "2026-06-18T13:35:46.364Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/a1e87e268df98f6740af81abf225532c173a971c64df0258c84b630e35a7/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:840b21e609a9b203eee06bdc73e18397154447a9814a8e78d9b68e5104d9802f", size = 499469, upload-time = "2026-06-18T13:35:47.584Z" }, + { url = "https://files.pythonhosted.org/packages/25/75/5a1f297a09556c27d9617c44ab0510de5f3a70120df236f66b9d0fdd1976/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5119bec75f56bd028d97472f72b1ed723a0d60b09a48017dc70a3cb1892ed081", size = 606160, upload-time = "2026-06-18T13:35:48.963Z" }, + { url = "https://files.pythonhosted.org/packages/7c/de/140f1d2a161320d1ac9073a03b9eb31fe35ae70f56f8971ec1fb45c14a44/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9fe600ab7d3d4eb56986e814042c917e728ac92cd8a41f099a6b59b84d8bf9e6", size = 564856, upload-time = "2026-06-18T13:35:50.244Z" }, + { url = "https://files.pythonhosted.org/packages/01/3b/9a5fe6691f8f6d72899cdc2713ffbd845b8c6981eeeab66d98a71b721116/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44020a4532229ccfbba353138539774686350dda71cf4368e257973dd8ba403", size = 528376, upload-time = "2026-06-18T13:35:51.825Z" }, + { url = "https://files.pythonhosted.org/packages/87/ad/47c93dcabd00f6749803a00be361c75d7079c78ad5e67077dee63d30b687/uuid_utils-0.16.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:280d4f1f22dd2e79c1cc31ffc7fc26dc3534ffc114dedcdd29cc8489c5ce9c98", size = 98033, upload-time = "2026-06-18T13:35:53.385Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fd/8de85eeb8dd59354ad46e897ab0d0f0fe6bc48702239a6c9f2613f961c8e/uuid_utils-0.16.2-cp313-cp313-win32.whl", hash = "sha256:4942b26ad12c5187bac52b7fb4685040139ff0df9a19cde33e5025326f6180fc", size = 167054, upload-time = "2026-06-18T13:35:54.495Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/b5ba393fbe5142eb9d5db23d4b9b16dde2a4e1aee6f2fcb7fadef97e419a/uuid_utils-0.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:01f81c71cf2185de0707e9d2f248e17025ba50af0acd3cbf51cd8aea96c2e0be", size = 173481, upload-time = "2026-06-18T13:35:55.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/79/4e5d63d605b13201ae9af6fcc36ec77949cccc99486c430c016d8f8ed274/uuid_utils-0.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:c1dbe65ce6d46c5f645356d64bfb2de7564e2426ca8c9b1a0a401d6f7ae5cc22", size = 172197, upload-time = "2026-06-18T13:35:56.817Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/0e5a0c1e1e3243cf5f12efd2b88a33e63c38b6a79483d3c84b2f5e7265cf/uuid_utils-0.16.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:617955f4b3f649617c0388127d8a257202189d5cc3c720313f8b207df1cdb2a4", size = 566227, upload-time = "2026-06-18T13:35:57.925Z" }, + { url = "https://files.pythonhosted.org/packages/28/b3/2b6f9d6832e939aaf2b2ba89ff70b3994cfa3ae9b14daac3329eb9202ef8/uuid_utils-0.16.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0aa2569908bdb21ccb216cd6bd06cb934351ee65ea7cd5e351e19f633a99b577", size = 290301, upload-time = "2026-06-18T13:35:59.467Z" }, + { url = "https://files.pythonhosted.org/packages/f5/27/8bb31429884b9f340f964ed70b68bfd81cec61f6e6877633f6a014358e78/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4af7673e84e1ec6029f18d3a0408095c471c4e2691b6e46b4e1f0a2051734ba", size = 325409, upload-time = "2026-06-18T13:36:00.786Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/3b59aa97e788ca4fa46e2a3856ef567b51e03fd7fbf27d39ce36e46478b6/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ecadf55ed6b8fb72e7966b52fd02919e7d7bb8e7bffeaf285803b82e774debfb", size = 332071, upload-time = "2026-06-18T13:36:02.043Z" }, + { url = "https://files.pythonhosted.org/packages/1c/21/8c21bf6cf3ce9447b73cee6a38ca63c9bb2f3145259422646bae8e8ddc21/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:026b96b2f1e6b004579e030692d2f6568ccd0b29d40687213c31694abf570c78", size = 447075, upload-time = "2026-06-18T13:36:03.305Z" }, + { url = "https://files.pythonhosted.org/packages/95/43/77e83019effe1a5ab7169a2d4bf1bd654bebd850b81c8a937b96bd6b5c9c/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:273679723e88544dd2de0564ab7f2fddfa2270faf05cabfdf63c275be67ec2a1", size = 325061, upload-time = "2026-06-18T13:36:04.972Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a6/7bf6e0165dc191c09bc4e8c011de5463d64c5a651ed38ad6698bfc552a52/uuid_utils-0.16.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec5b1a338b92d1eb121e9eaf06ae3db1b9a5cd794ce318a475f6dc6f9e89c3a8", size = 350302, upload-time = "2026-06-18T13:36:06.172Z" }, + { url = "https://files.pythonhosted.org/packages/45/66/260836aaef14b8254bc449b3163fedec06ef0a0bba0d6a999c918479b2f9/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e75f9429d4533ce275c98bc68bf47fb237ae7b32c954266dabc5edab0c7d682e", size = 501834, upload-time = "2026-06-18T13:36:07.469Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/84c1542bf8c465b456f742318ad83eace63551e7f603b06c817b726670af/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f3cca9ca5e2c2dfd7b885f0d34c10b993a070d3593f3cdfef785195da36fb0f", size = 607406, upload-time = "2026-06-18T13:36:08.913Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/1024c22657a0c0572c4fd5189fad3127cb46731fb26fad3be1e8a4a64972/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ef8c561fdf88fec205e3d54037824cfe2addce16b509a8d2ecb69daa904cbb7", size = 567623, upload-time = "2026-06-18T13:36:10.14Z" }, + { url = "https://files.pythonhosted.org/packages/15/0e/ad7424a6444e3e108a22781c2e164e82752da5db23ccc5cba8b4470c3164/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e3acb5e1451232381daea01645a98c69de4bb9ad88d77a1f7c1df4d83d54e62", size = 530659, upload-time = "2026-06-18T13:36:11.649Z" }, + { url = "https://files.pythonhosted.org/packages/69/60/cf1666d0dbd6fa869b6de3b85a17254ff0ab10ed286fd59366148bf08e89/uuid_utils-0.16.2-cp314-cp314-win32.whl", hash = "sha256:b5f8e7d0bb2c6e6180176237f92d2e949626e04fcf701c49d73f128e1f64e1d1", size = 169272, upload-time = "2026-06-18T13:36:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5e/111908bdc7287b2589e9a9f10be8e0358844fb4a0554677cbbe0ade49766/uuid_utils-0.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:bf922bad7df257336b594d316a1657df569860bb5389602919001fa6fb17f06e", size = 175435, upload-time = "2026-06-18T13:36:14.114Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/b3bd7415622060dd17d587545e3c037f83dc0dffb8880ac798ca7936f630/uuid_utils-0.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:fad82e6482129c58ba9b00da6c247ab6e767645ab17981599229cce19d7b2ce9", size = 173553, upload-time = "2026-06-18T13:36:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/401acf6fc0e0665dd11a095a28f6d22708c6f8f148c326cfc5b0b1ae9882/uuid_utils-0.16.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e0609e7e906c08386b7f33141254df05dcab24f1c4884150988dc7a287516aca", size = 567548, upload-time = "2026-06-18T13:36:16.848Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/cc2bb8273d414d651acafccc3705a8843c130a541fcce65fbeaac22266ba/uuid_utils-0.16.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9ad2adeb941292fe02e1e5c70b80a5746c45b1b77594506c2a1421455d8384f9", size = 291348, upload-time = "2026-06-18T13:36:18.145Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a8/fdadd7ada0de53dbc03f719da0948cc275abd24d8013a26e42e50d3665c1/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d906c00f965d5c5f4812d0086dc49bf813285ea84c97e8816405200e146f805b", size = 325495, upload-time = "2026-06-18T13:36:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/16/42/e397a1eda06b20dd3a206e3a55b346ff2caad23906586801a87359530864/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a59205fc15463dd0f978f14df14307737e3d4e8ef4aefa29a9d0fa766d84d16b", size = 332301, upload-time = "2026-06-18T13:36:20.747Z" }, + { url = "https://files.pythonhosted.org/packages/46/be/12d3df7bd824e3ce71630c022184a5aecfea92b0a7fa70459542b237777a/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac82500329ffaf2788dac36cf133e1e4e23b6d5e1118274ea6749c3b512f4f1", size = 446760, upload-time = "2026-06-18T13:36:22.198Z" }, + { url = "https://files.pythonhosted.org/packages/f7/10/0c5d1dd6874fa35e2cb66a8499ce303eb8678bef226951182603bd30017d/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d8257329f26905f009aed694bd3b17f334f43748b03134dc7bc99d6c5b4e371", size = 325781, upload-time = "2026-06-18T13:36:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/9ebb8414875e5c14737fa7145a023458c9b15754f1d129cefe7824197256/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e04b5c10c6fcf9d9801084d1e86c9d7ada7eb48fe07ee4ae5e7fe5b1a852db8a", size = 351189, upload-time = "2026-06-18T13:36:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5c/168d1f4d30b33c08365debfe4176c2f713a0940f1f11a64128a186d050c6/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3d4805c4739dd06d539f8f4fa94f5aaf26eca4b3ece1ef134d4ff904c6b08dcf", size = 501866, upload-time = "2026-06-18T13:36:26.31Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8d/003865d5ed5bf82ece80bd61edb2692985f7548051749fd10f34edb16705/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:76632d2e16e26de777851ec07961ceaea14e65167d0603a0b17fb169fa9ca37b", size = 607632, upload-time = "2026-06-18T13:36:27.704Z" }, + { url = "https://files.pythonhosted.org/packages/ea/52/6102f21f28323b27122a6aa3d4cea183b4fc401868c5c40767e1b9f53beb/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c02f85f49c9c2abbf247a8622458c30232332a28711755aa191da5f38015af6", size = 568216, upload-time = "2026-06-18T13:36:29.377Z" }, + { url = "https://files.pythonhosted.org/packages/68/50/644e4e55f47048d12bc20665fac85bc1fecbed9c892acfb91626abf8ad8d/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f668035ea9faa763e8f1ea42040e8439db88cf2517056d47c348a62a257a1d02", size = 531370, upload-time = "2026-06-18T13:36:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5d/d98d99f601d70cc00287dce5aadef9c199912f0d64343962542f35e7db59/uuid_utils-0.16.2-cp314-cp314t-win32.whl", hash = "sha256:62b8841895eff1c0afbaf5f0050411667231160478c8ff9f411742abffd3b619", size = 169424, upload-time = "2026-06-18T13:36:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/a6/af/c0d482bdd637a8a742d3274cec462b770919f032e179216f2fc2851afaf9/uuid_utils-0.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:e9064805881c30dd80a4189a0da7130e3d684de353ea36edd99c1b994bdf429e", size = 175544, upload-time = "2026-06-18T13:36:33.75Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/aff8b0456e8a63672fa89ea9c773f7547a31ff7b596a40f226bf148921a3/uuid_utils-0.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:3324bac95084e63e28553c92fac5a0394c636a76e03e50a7dab0c0bbddf87fa5", size = 173972, upload-time = "2026-06-18T13:36:35.076Z" }, +] + [[package]] name = "uvicorn" version = "0.46.0" @@ -2333,45 +2660,201 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "xxhash" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" }, + { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" }, + { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" }, + { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" }, + { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" }, + { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" }, + { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" }, + { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" }, + { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" }, + { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" }, + { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" }, + { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" }, + { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" }, + { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" }, + { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" }, + { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" }, + { url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" }, + { url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" }, + { url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" }, + { url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" }, + { url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" }, + { url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" }, + { url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" }, + { url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" }, + { url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" }, + { url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" }, + { url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, ] From 478e72eef6eb8c6d5dd5dfdcfcc64da5fc657334 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 23 Jun 2026 01:48:52 -0700 Subject: [PATCH 17/18] refactor(ai): config package + Lab provider/model resolution; wire Agent through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn ai/config.py into a config package and resolve models/providers through a new Lab helper instead of hardcoded dicts on the Agent. - ai/config/: split provider dataclasses (config.py) from the top-level AIConfig (ai.py), add config/__init__.py re-exporting them, and give each provider a models map keyed by modality (default / default_image / default_audio / default_transcribe). Fix the draft's circular import (AIConfig imported the provider configs from the package root mid-init) and the placeholder model values (google text default, elevenlabs models). - AIConfig selects the default provider per modality: default (text), default_image, default_audio, default_transcribe. image.py/audio.py now read default_image/default_audio (was image_provider/audio_provider). - ai/lab.py: Lab(StrEnum) + ModelType resolve the provider, default model, and the ":" URL from Config (google → google_genai). - Agent: _resolve_model() and _build_model() now go through Lab; removed the stale _DEFAULT_MODELS/_LANGCHAIN_PROVIDERS references and the dead _execute_tool() (create_agent runs tools itself). - Tests: drop the _build_model monkeypatch helper; the backend tests now patch the real langchain.chat_models.init_chat_model seam via pytest monkeypatch. Add test_lab.py; update image/audio provider-selection mocks. The 23 tests in tests/ai/test_agent_fake.py stay green and unmodified. --- .../src/fastapi_startkit/ai/__init__.py | 21 +-- .../src/fastapi_startkit/ai/agent.py | 35 ++--- .../src/fastapi_startkit/ai/audio.py | 4 +- .../src/fastapi_startkit/ai/audio_factory.py | 2 +- .../fastapi_startkit/ai/config/__init__.py | 10 ++ .../src/fastapi_startkit/ai/config/ai.py | 24 +++ .../ai/{ => config}/config.py | 47 +++--- .../src/fastapi_startkit/ai/image.py | 4 +- .../src/fastapi_startkit/ai/image_factory.py | 2 +- .../src/fastapi_startkit/ai/lab.py | 69 +++++++++ .../tests/ai/test_agent_langgraph_backend.py | 143 +++++++++--------- fastapi_startkit/tests/ai/test_audio.py | 4 +- fastapi_startkit/tests/ai/test_image.py | 2 +- fastapi_startkit/tests/ai/test_lab.py | 51 +++++++ 14 files changed, 279 insertions(+), 139 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/config/ai.py rename fastapi_startkit/src/fastapi_startkit/ai/{ => config}/config.py (55%) create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/lab.py create mode 100644 fastapi_startkit/tests/ai/test_lab.py diff --git a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py index 2e0f52dc..74117630 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py @@ -1,25 +1,7 @@ -"""FastAPI Startkit AI module. - -Provides a LangGraph-powered declarative API for building AI agents backed -by Anthropic, OpenAI, or Google provider SDKs. - -Also exposes a Laravel-style fluent API for image generation and text-to-speech:: - - from fastapi_startkit.ai import Image, Audio, Document - - image = await Image.of("A donut on a counter").generate() - - # With a photo attachment - doc = await Document.from_url("https://example.com/photo.jpg") - image = await Image.of("Make impressionist").attachments([doc]).generate() - - audio = await Audio.of("Hello world").female().generate() -""" - from .agent import Agent from .audio import Audio, AudioResponse from .audio_factory import AudioFactory -from .config import AIConfig, AnthropicConfig, GoogleConfig, OpenAIConfig +from .config import AIConfig, AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig from .decorators import max_steps, max_tokens, memory, model, provider, timeout, top_p from .document import Document from .fakes import fake_chat_model @@ -39,6 +21,7 @@ "AudioResponse", "AudioFactory", "Document", + "ElevenLabsConfig", "fake_chat_model", "GoogleConfig", "Image", diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 2e8e79b0..61c864a1 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -15,11 +15,14 @@ from __future__ import annotations import fnmatch -from typing import Any, Callable, Iterator, Optional, Type +from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, Type from .document import Document from .response import AgentResponse, AgentSnapshot +if TYPE_CHECKING: + from .lab import Lab + class Agent: """ @@ -44,25 +47,10 @@ class Agent: _top_p: float = 1.0 _memory_backend: str = "" - _DEFAULT_MODELS: dict[str, str] = { - "anthropic": "claude-sonnet-4-6", - "openai": "gpt-4o", - "google": "gemini-2.0-flash", - } - - # Map the agent's provider name to the LangChain ``init_chat_model`` provider id. - _LANGCHAIN_PROVIDERS: dict[str, str] = { - "anthropic": "anthropic", - "openai": "openai", - "google": "google_genai", - } - def __init__(self): self._fakes: dict[str, AgentResponse | AgentSnapshot] = {} self._call_log: list[dict] = [] - # ── Lifecycle — override in subclasses ────────────────────────────────── - def messages(self) -> list[dict]: """Return initial messages / few-shot examples.""" return [] @@ -199,19 +187,17 @@ def build(mw_list: list, fn: Callable) -> Callable: return build(chain, final)(message) - def _execute_tool(self, name: str, inputs: dict) -> Any: - """Find a tool by function name and call it with the given inputs.""" - for tool in self.tools(): - if callable(tool) and tool.__name__ == name: - return tool(**inputs) - raise ValueError(f"Tool {name!r} not found") + def _lab(self) -> "Lab": + from .lab import Lab # noqa: PLC0415 + + return Lab(self._provider) def _resolve_model(self, override: str | None = None) -> str: if override: return override if self._model: return self._model - return self._DEFAULT_MODELS.get(self._provider, "") + return self._lab().get_model() def _get_provider_options(self, override: dict | None = None) -> dict: options = dict(self.provider_options().get(self._provider, {})) @@ -268,8 +254,7 @@ def _build_model(self, model: str | None = None, provider_options: dict | None = """ from langchain.chat_models import init_chat_model # noqa: PLC0415 - provider = self._LANGCHAIN_PROVIDERS.get(self._provider, self._provider) - kwargs: dict[str, Any] = {"model_provider": provider} + kwargs: dict[str, Any] = {"model_provider": self._lab().get_provider_key()} api_key = self._resolve_api_key(self._provider) if api_key: diff --git a/fastapi_startkit/src/fastapi_startkit/ai/audio.py b/fastapi_startkit/src/fastapi_startkit/ai/audio.py index b709daff..a05c9bc7 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/audio.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/audio.py @@ -89,7 +89,7 @@ def _save_sync(self, name: str, disk: str) -> str: class Audio: """Fluent builder for text-to-speech generation. - The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.audio_provider` + The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.default_audio` (env: ``AI_AUDIO_PROVIDER``). Defaults to OpenAI TTS. Usage:: @@ -191,7 +191,7 @@ def _resolve_provider(self) -> "AudioFactory": ai_config = Config.get("ai") if Config is not None else None # type: ignore[union-attr] if ai_config is None: raise RuntimeError("Config not available") - provider_name = ai_config.audio_provider + provider_name = ai_config.default_audio openai_cfg = ai_config.providers.get("openai") if openai_cfg: api_key = openai_cfg.key or None diff --git a/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py b/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py index 14c817b6..3613e5f3 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py @@ -3,7 +3,7 @@ Providers implement the :class:`AudioFactory` ABC so that the :class:`~fastapi_startkit.ai.Audio` builder is not hard-wired to a single vendor. Select the active provider via ``AI_AUDIO_PROVIDER`` in your -``.env`` (or ``AIConfig.audio_provider``). +``.env`` (or ``AIConfig.default_audio``). Supported providers ------------------- diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py new file mode 100644 index 00000000..af215946 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py @@ -0,0 +1,10 @@ +from .ai import AIConfig +from .config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig + +__all__ = [ + "AIConfig", + "AnthropicConfig", + "ElevenLabsConfig", + "GoogleConfig", + "OpenAIConfig", +] diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py new file mode 100644 index 00000000..c2883f55 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from fastapi_startkit.environment import env + +from .config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig + + +@dataclass +class AIConfig: + """Top-level AI configuration — selects the default provider per modality and holds per-provider configs.""" + + default: str = field(default_factory=lambda: env("AI_PROVIDER", "google")) + default_image: str = field(default_factory=lambda: env("AI_DEFAULT_IMAGE_PROVIDER", "openai")) + default_audio: str = field(default_factory=lambda: env("AI_DEFAULT_AUDIO_PROVIDER", "openai")) + default_transcribe: str = field(default_factory=lambda: env("AI_DEFAULT_TRANSCRIBE_PROVIDER", "openai")) + + providers: dict = field( + default_factory=lambda: { + "google": GoogleConfig(), + "openai": OpenAIConfig(), + "anthropic": AnthropicConfig(), + "elevenlabs": ElevenLabsConfig(), + } + ) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config.py b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py similarity index 55% rename from fastapi_startkit/src/fastapi_startkit/ai/config.py rename to fastapi_startkit/src/fastapi_startkit/ai/config/config.py index 16a74206..96bb5c67 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/config.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py @@ -1,4 +1,10 @@ -"""AI configuration dataclasses for the FastAPI Startkit AI module.""" +"""Per-provider AI configuration dataclasses. + +Each provider declares its ``driver``, credentials, and a ``models`` map of the +default model per modality (``default`` = text, plus ``default_image`` / +``default_audio`` / ``default_transcribe`` where the provider supports them). +:class:`~fastapi_startkit.ai.lab.Lab` resolves these via the ``Config`` store. +""" from __future__ import annotations @@ -15,6 +21,12 @@ class AnthropicConfig: key: str = field(default_factory=lambda: env("ANTHROPIC_API_KEY", "")) url: str = field(default_factory=lambda: env("ANTHROPIC_BASE_URL", "https://api.anthropic.com")) + models: dict = field( + default_factory=lambda: { + "default": "claude-sonnet-4-6", + } + ) + @dataclass class OpenAIConfig: @@ -24,6 +36,13 @@ class OpenAIConfig: key: str = field(default_factory=lambda: env("OPENAI_API_KEY", "")) url: str = field(default_factory=lambda: env("OPENAI_BASE_URL", "https://api.openai.com/v1")) + models: dict = field( + default_factory=lambda: { + "default": "gpt-4o", + "default_image": "dall-e-3", + } + ) + @dataclass class GoogleConfig: @@ -32,6 +51,13 @@ class GoogleConfig: driver: str = "google" key: str = field(default_factory=lambda: env("GEMINI_API_KEY", "") or env("GOOGLE_API_KEY", "")) + models: dict = field( + default_factory=lambda: { + "default": "gemini-2.0-flash", + "default_image": "imagen-3.0-generate-002", + } + ) + @dataclass class ElevenLabsConfig: @@ -40,22 +66,9 @@ class ElevenLabsConfig: driver: str = "elevenlabs" key: str = field(default_factory=lambda: env("ELEVENLABS_API_KEY", "")) - -@dataclass -class AIConfig: - """Top-level AI configuration — selects the default provider and holds per-provider configs.""" - - default: str = field(default_factory=lambda: env("AI_PROVIDER", "google")) - - providers: dict = field( + models: dict = field( default_factory=lambda: { - "openai": OpenAIConfig(), - "anthropic": AnthropicConfig(), - "google": GoogleConfig(), - "elevenlabs": ElevenLabsConfig(), + "default_audio": "eleven_multilingual_v2", + "default_transcribe": "scribe_v1", } ) - - # Media-generation provider selection - image_provider: str = field(default_factory=lambda: env("AI_IMAGE_PROVIDER", "openai")) - audio_provider: str = field(default_factory=lambda: env("AI_AUDIO_PROVIDER", "openai")) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/image.py b/fastapi_startkit/src/fastapi_startkit/ai/image.py index c6794293..ec11e28b 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/image.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/image.py @@ -90,7 +90,7 @@ def _save_sync(self, name: str, disk: str) -> str: class Image: """Fluent builder for image generation and editing. - The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.image_provider` + The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.default_image` (env: ``AI_IMAGE_PROVIDER``). Defaults to OpenAI DALL-E. Usage — text to image:: @@ -198,7 +198,7 @@ def _resolve_provider(self) -> "ImageFactory": ai_config = Config.get("ai") if Config is not None else None # type: ignore[union-attr] if ai_config is None: raise RuntimeError("Config not available") - provider_name = ai_config.image_provider + provider_name = ai_config.default_image openai_cfg = ai_config.providers.get("openai") if openai_cfg: api_key = openai_cfg.key or None diff --git a/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py b/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py index b699dafa..b5bf4ba4 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py @@ -3,7 +3,7 @@ Providers implement the :class:`ImageFactory` ABC so that the :class:`~fastapi_startkit.ai.Image` builder is not hard-wired to a single vendor. Select the active provider via ``AI_IMAGE_PROVIDER`` in your -``.env`` (or ``AIConfig.image_provider``). +``.env`` (or ``AIConfig.default_image``). Supported providers ------------------- diff --git a/fastapi_startkit/src/fastapi_startkit/ai/lab.py b/fastapi_startkit/src/fastapi_startkit/ai/lab.py new file mode 100644 index 00000000..f0da5a2c --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/lab.py @@ -0,0 +1,69 @@ +"""Lab — resolve the active provider, model, and LangChain model URL from config. + +The ``ai`` config selects a default provider per modality (``ai.default`` for text, +``ai.default_image`` / ``ai.default_audio`` / ``ai.default_transcribe``) and stores +each provider's default models under ``ai.providers..models``. ``Lab`` reads +those and builds the ``":"`` string LangChain's ``init_chat_model`` +and ``create_agent`` understand. +""" + +from enum import StrEnum + + +def _config(): + from fastapi_startkit import Config # noqa: PLC0415 + + return Config + + +class ModelType(StrEnum): + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + TRANSCRIBE = "transcribe" + + +def _model_key(model_type: ModelType) -> str: + """The key under a provider's ``models`` map for the given modality.""" + return "default" if model_type == ModelType.TEXT else f"default_{model_type.value}" + + +def _provider_field(model_type: ModelType) -> str: + """The ``ai`` config field selecting the default provider for the given modality.""" + return "default" if model_type == ModelType.TEXT else f"default_{model_type.value}" + + +class Lab(StrEnum): + GOOGLE = "google" + OPENAI = "openai" + ANTHROPIC = "anthropic" + ELEVENLABS = "elevenlabs" + + def get_model(self, model: str | None = None, model_type: ModelType = ModelType.TEXT) -> str: + """Return ``model`` if given, else this provider's configured default for the modality.""" + return model or _config().get(f"ai.providers.{self.value}.models.{_model_key(model_type)}") + + def get_provider_key(self) -> str: + """Map this provider to its LangChain ``init_chat_model`` id.""" + return { + "anthropic": "anthropic", + "openai": "openai", + "google": "google_genai", + "elevenlabs": "elevenlabs", + }[self.value] + + @staticmethod + def get_provider(provider: str | None = None, model_type: ModelType = ModelType.TEXT) -> "Lab": + """Resolve a provider name (or the configured default for the modality) to a ``Lab``.""" + provider = provider or _config().get(f"ai.{_provider_field(model_type)}") + return Lab(provider) + + @staticmethod + def get_model_url( + provider: str | None = None, + model: str | None = None, + model_type: ModelType = ModelType.TEXT, + ) -> str: + """Build the ``":"`` string for ``init_chat_model``/``create_agent``.""" + lab = Lab.get_provider(provider, model_type) + return f"{lab.get_provider_key()}:{lab.get_model(model, model_type)}" diff --git a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py index 895d8b08..a3cc5b3d 100644 --- a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py +++ b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py @@ -1,24 +1,41 @@ """The Agent backend runs on LangGraph (create_agent), tested offline. -These exercise the real ``_run``/``_stream`` path — no fake() short-circuit — by -injecting a scripted fake chat model through the ``_build_model`` seam. The public -API is unchanged; ``prompt()`` still returns an AgentResponse, ``stream()`` still -yields strings. +These exercise the real ``_run``/``_stream`` path — no ``fake()`` short-circuit. +Instead of mutating the agent, we patch the single seam the backend uses to build +a model (``langchain.chat_models.init_chat_model``) with pytest's ``monkeypatch`` +and feed it a scripted :func:`fake_chat_model`. The public API is unchanged: +``prompt()`` still returns an AgentResponse and ``stream()`` still yields strings. """ +import langchain.chat_models as chat_models +import pytest from langchain_core.messages import AIMessage, ToolCall from langchain_core.tools import tool from fastapi_startkit.ai import Document, fake_chat_model from fastapi_startkit.ai.agent import Agent +from fastapi_startkit.ai.config import AIConfig from fastapi_startkit.ai.response import AgentResponse +from fastapi_startkit.application import app -def _with_model(agent: Agent, turns) -> Agent: - """Patch the agent's model seam to replay scripted turns offline.""" - model = fake_chat_model(turns) - agent._build_model = lambda *a, **k: model # type: ignore[method-assign] - return agent +@pytest.fixture(autouse=True) +def ai_config(): + container = app() + container.bind("ai", AIConfig()) + container.make("config").set("ai", AIConfig()) + + +@pytest.fixture +def scripted_model(monkeypatch): + """Replace the model the backend builds with a scripted fake, offline.""" + + def install(turns): + model = fake_chat_model(turns) + monkeypatch.setattr(chat_models, "init_chat_model", lambda *a, **k: model) + return model + + return install @tool @@ -38,117 +55,105 @@ def tools(self): # ─── prompt() drives the create_agent loop ──────────────────────────────────── -def test_prompt_returns_agent_response_from_langgraph(): - agent = _with_model(Agent(), [AIMessage(content="hello back")]) +def test_prompt_returns_agent_response(scripted_model): + scripted_model([AIMessage(content="hello back")]) - result = agent.prompt("hi there") + result = Agent().prompt("hi there") assert isinstance(result, AgentResponse) assert result.content == "hello back" - agent.assert_prompted() + Agent().assert_not_prompted() # a fresh instance has its own empty log -def test_prompt_runs_a_full_tool_calling_loop(): - agent = _with_model( - JobAssistant(), +def test_prompt_runs_a_full_tool_calling_loop(scripted_model): + scripted_model( [ AIMessage( content="", tool_calls=[ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call")], ), AIMessage(content="Here is a Python Developer role at Shopify."), - ], + ] ) - result = agent.prompt("find me a python job") + result = JobAssistant().prompt("find me a python job") assert result.content == "Here is a Python Developer role at Shopify." # The loop ran: user → AI(tool_call) → tool result → AI(final). assert len(result.raw["messages"]) == 4 -def test_prompt_maps_usage_metadata(): - reply = AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18}) - agent = _with_model(Agent(), [reply]) +def test_prompt_maps_usage_metadata(scripted_model): + scripted_model([AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18})]) - result = agent.prompt("anything") + result = Agent().prompt("anything") assert result.usage == {"input": 11, "output": 7} -# ─── attachments render as LangChain blocks ─────────────────────────────────── +def test_build_model_passes_langchain_provider_key(monkeypatch): + captured = {} + def fake_init(model, **kwargs): + captured["model"] = model + captured["provider"] = kwargs.get("model_provider") + return fake_chat_model([AIMessage(content="ok")]) -def test_attachments_are_built_as_langchain_blocks(): - agent = Agent() - doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + monkeypatch.setattr(chat_models, "init_chat_model", fake_init) - _system, history = agent._build_messages("Summarise this report.", attachments=[doc]) + class GoogleAgent(Agent): + _provider = "google" - user_content = history[-1]["content"] - assert user_content[0] == {"type": "text", "text": "Summarise this report."} - assert user_content[1]["type"] == "text" - assert "q3-report.txt" in user_content[1]["text"] + GoogleAgent().prompt("hi") + assert captured["provider"] == "google_genai" + assert captured["model"] == "gemini-2.0-flash" -def test_binary_attachment_becomes_a_file_block(): - agent = Agent() - doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") - _system, history = agent._build_messages("Summarise", attachments=[doc]) +# ─── streaming ──────────────────────────────────────────────────────────────── - block = history[-1]["content"][1] - assert block["type"] == "file" - assert block["mime_type"] == "application/pdf" - assert block["base64"] == doc.to_base64() +def test_stream_yields_tokens_from_the_model(scripted_model): + scripted_model([AIMessage(content="streamed reply")]) -def test_prompt_with_attachment_returns_reply(): - agent = _with_model(JobAssistant(), [AIMessage(content="Summarised.")]) - doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + chunks = list(Agent().stream("hello")) - result = agent.prompt("Summarise this report.", attachments=[doc]) + assert "".join(chunks) == "streamed reply" - assert result.content == "Summarised." +# ─── model / message building (unit) ────────────────────────────────────────── -# ─── provider mapping + model resolution ────────────────────────────────────── +def test_resolve_model_falls_back_to_lab_default(): + assert Agent()._resolve_model() == "claude-sonnet-4-6" -def test_google_provider_maps_to_langchain_google_genai(): class GoogleAgent(Agent): _provider = "google" - assert GoogleAgent._LANGCHAIN_PROVIDERS["google"] == "google_genai" + assert GoogleAgent()._resolve_model() == "gemini-2.0-flash" -def test_resolve_model_falls_back_to_provider_default(): - assert Agent()._resolve_model() == "claude-sonnet-4-6" - - class OpenAIAgent(Agent): - _provider = "openai" - - assert OpenAIAgent()._resolve_model() == "gpt-4o" - - -# ─── stream() pulls tokens from the model ───────────────────────────────────── +def test_resolve_model_prefers_explicit_override(): + assert Agent()._resolve_model("my-model") == "my-model" -def test_stream_yields_tokens_from_the_model(): - agent = _with_model(Agent(), [AIMessage(content="streamed reply")]) - - chunks = list(agent.stream("hello")) - - assert "".join(chunks) == "streamed reply" - agent.assert_prompted(times=1) +def test_build_messages_inlines_text_attachment(): + doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + _system, history = Agent()._build_messages("Summarise this report.", attachments=[doc]) -# ─── fake_chat_model accepts plain strings ──────────────────────────────────── + user_content = history[-1]["content"] + assert user_content[0] == {"type": "text", "text": "Summarise this report."} + assert user_content[1]["type"] == "text" + assert "q3-report.txt" in user_content[1]["text"] -def test_fake_chat_model_accepts_string_shorthand(): - agent = _with_model(Agent(), ["plain string turn"]) +def test_build_messages_encodes_binary_attachment_as_file_block(): + doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") - result = agent.prompt("anything") + _system, history = Agent()._build_messages("Summarise", attachments=[doc]) - assert result.content == "plain string turn" + block = history[-1]["content"][1] + assert block["type"] == "file" + assert block["mime_type"] == "application/pdf" + assert block["base64"] == doc.to_base64() diff --git a/fastapi_startkit/tests/ai/test_audio.py b/fastapi_startkit/tests/ai/test_audio.py index 0087de9f..4f14915b 100644 --- a/fastapi_startkit/tests/ai/test_audio.py +++ b/fastapi_startkit/tests/ai/test_audio.py @@ -248,7 +248,7 @@ def test_unknown_voice_passed_through(self): async def test_audio_builder_resolves_google_factory(self): mock_ai_config = MagicMock() - mock_ai_config.audio_provider = "google" + mock_ai_config.default_audio = "google" mock_ai_config.providers = { "google": MagicMock(key="gkey"), "openai": MagicMock(key=""), @@ -327,7 +327,7 @@ def test_direct_voice_id_passed_through(self): async def test_audio_builder_resolves_elevenlabs_factory(self): mock_ai_config = MagicMock() - mock_ai_config.audio_provider = "elevenlabs" + mock_ai_config.default_audio = "elevenlabs" mock_ai_config.providers = { "google": MagicMock(key=""), "openai": MagicMock(key=""), diff --git a/fastapi_startkit/tests/ai/test_image.py b/fastapi_startkit/tests/ai/test_image.py index 4c98c3df..c7ca7a01 100644 --- a/fastapi_startkit/tests/ai/test_image.py +++ b/fastapi_startkit/tests/ai/test_image.py @@ -283,7 +283,7 @@ async def test_edit_raises_not_implemented(self): async def test_image_builder_resolves_google_factory(self): mock_ai_config = MagicMock() - mock_ai_config.image_provider = "google" + mock_ai_config.default_image = "google" mock_ai_config.providers = {"google": MagicMock(key="gkey"), "openai": MagicMock(key="")} with patch("fastapi_startkit.ai.image.Config") as mock_config: diff --git a/fastapi_startkit/tests/ai/test_lab.py b/fastapi_startkit/tests/ai/test_lab.py new file mode 100644 index 00000000..1e344a1e --- /dev/null +++ b/fastapi_startkit/tests/ai/test_lab.py @@ -0,0 +1,51 @@ +"""Lab resolves the active provider, model, and LangChain model URL from config.""" + +import pytest + +from fastapi_startkit.ai.config import AIConfig +from fastapi_startkit.ai.lab import Lab, ModelType +from fastapi_startkit.application import app + + +@pytest.fixture +def ai_config(): + container = app() + container.bind("ai", AIConfig()) + container.make("config").set("ai", AIConfig()) + return container + + +def test_provider_key_maps_google_to_genai(): + assert Lab.GOOGLE.get_provider_key() == "google_genai" + assert Lab.OPENAI.get_provider_key() == "openai" + assert Lab.ANTHROPIC.get_provider_key() == "anthropic" + assert Lab.ELEVENLABS.get_provider_key() == "elevenlabs" + + +def test_get_model_returns_explicit_override(): + # No config needed — an explicit model short-circuits the lookup. + assert Lab.GOOGLE.get_model("custom-model") == "custom-model" + + +def test_get_model_text_default(ai_config): + assert Lab.GOOGLE.get_model() == "gemini-2.0-flash" + assert Lab.ANTHROPIC.get_model() == "claude-sonnet-4-6" + assert Lab.OPENAI.get_model() == "gpt-4o" + + +def test_get_model_image_default(ai_config): + assert Lab.OPENAI.get_model(model_type=ModelType.IMAGE) == "dall-e-3" + assert Lab.GOOGLE.get_model(model_type=ModelType.IMAGE) == "imagen-3.0-generate-002" + + +def test_get_provider_uses_config_default(ai_config): + assert Lab.get_provider().value == "google" + + +def test_get_provider_explicit_wins(): + assert Lab.get_provider("anthropic") is Lab.ANTHROPIC + + +def test_get_model_url_text(ai_config): + assert Lab.get_model_url() == "google_genai:gemini-2.0-flash" + assert Lab.get_model_url("anthropic") == "anthropic:claude-sonnet-4-6" From 2dd8e40d18145fddc98ac54464f9185b2910232d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 23 Jun 2026 12:44:33 -0700 Subject: [PATCH 18/18] =?UTF-8?q?wip(ai):=20drop=20create=5Fagent=20?= =?UTF-8?q?=E2=80=94=20init=5Fchat=5Fmodel=20+=20hand-rolled=20Runner=20to?= =?UTF-8?q?ol=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the LangGraph create_agent backend with a plain init_chat_model call driven by a Runner that resolves and executes tool calls itself. - runner.py: Runner(model, tools, max_steps) binds tools, invokes the model, executes requested tool calls, feeds results back, loops to a final answer; StreamRunner yields content tokens through the same loop. Fully typed. - Agent._run/_stream delegate to Runner/StreamRunner (threading _max_steps); no create_agent. - System message is declarative via instructions()/_instructions — removed the per-call system= and messages= arguments from prompt()/stream(). - Resolve provider/model through Lab directly (dropped the _lab() helper) and import Lab at module top. - Config split into ai/config/{ai,config}.py. KNOWN RED: ai/config/__init__.py is absent, so 'from fastapi_startkit.ai.config import AIConfig' fails — the AI test suite does not collect and AIProvider import breaks. Backend tests also still assume the old create_agent result shape and the tuple return of _build_messages. Follow-ups. --- fastapi_startkit/pyproject.toml | 3 - .../src/fastapi_startkit/ai/__init__.py | 3 +- .../src/fastapi_startkit/ai/agent.py | 156 +++++++----------- .../fastapi_startkit/ai/config/__init__.py | 10 -- .../src/fastapi_startkit/ai/config/ai.py | 2 +- .../src/fastapi_startkit/ai/lab.py | 30 ++-- .../src/fastapi_startkit/ai/runner.py | 80 +++++++++ .../tests/ai/test_agent_langgraph_backend.py | 29 +++- fastapi_startkit/uv.lock | 16 +- 9 files changed, 185 insertions(+), 144 deletions(-) delete mode 100644 fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/runner.py diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 5b1b05dd..36c98c30 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -51,9 +51,6 @@ ai = [ "anthropic>=0.49.0", "openai>=1.0.0", "google-generativeai>=0.8.0", -] - -langgraph = [ "langchain>=1.0.0", "langchain-core>=1.0.0", "langgraph>=1.0.0", diff --git a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py index 74117630..43701077 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py @@ -1,7 +1,8 @@ from .agent import Agent from .audio import Audio, AudioResponse from .audio_factory import AudioFactory -from .config import AIConfig, AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig +from .config.config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig +from .config.ai import AIConfig from .decorators import max_steps, max_tokens, memory, model, provider, timeout, top_p from .document import Document from .fakes import fake_chat_model diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 61c864a1..804dd4a0 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -15,14 +15,12 @@ from __future__ import annotations import fnmatch -from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, Type +from typing import Any, Callable, Iterator, Optional, Type from .document import Document +from .lab import Lab from .response import AgentResponse, AgentSnapshot -if TYPE_CHECKING: - from .lab import Lab - class Agent: """ @@ -30,6 +28,7 @@ class Agent: Class-level configuration (set via decorators or subclass attributes):: + _instructions = "" # the agent's static system instructions _provider = "anthropic" # LLM provider _model = "" # model ID (empty = provider default) _max_steps = 10 # max agentic loop iterations @@ -39,6 +38,7 @@ class Agent: _memory_backend = "" # memory backend name (reserved) """ + _instructions: str = "" _provider: str = "anthropic" _model: str = "" _max_steps: int = 10 @@ -52,9 +52,11 @@ def __init__(self): self._call_log: list[dict] = [] def messages(self) -> list[dict]: - """Return initial messages / few-shot examples.""" return [] + def instructions(self) -> str | None: + return None + def schema(self) -> Optional[Type]: """Return a Pydantic model class for structured output, or None for plain text.""" return None @@ -82,22 +84,18 @@ def after(self, response: AgentResponse) -> AgentResponse: # ── Public API ────────────────────────────────────────────────────────── def prompt( - self, - message: str, - *, - system: str | None = None, - model: str | None = None, - messages: list[dict] | None = None, - attachments: list[Document] | None = None, - provider_options: dict | None = None, + self, + message: str, + *, + model: str | None = None, + attachments: list[Document] | None = None, + provider_options: dict | None = None, ) -> AgentResponse: """Send a prompt and return an AgentResponse.""" message = self.before(message) _run_kwargs = dict( - system=system, model=model, - extra_messages=messages, attachments=attachments, provider_options=provider_options, ) @@ -119,12 +117,11 @@ def _call(msg: str) -> AgentResponse: return self.after(response) def stream( - self, - message: str, - *, - system: str | None = None, - model: str | None = None, - provider_options: dict | None = None, + self, + message: str, + *, + model: str | None = None, + provider_options: dict | None = None, ) -> Iterator[str]: """Stream a response token by token.""" message = self.before(message) @@ -137,7 +134,7 @@ def stream( response = fake yield response.content return - yield from self._stream(message, system=system, model=model, provider_options=provider_options) + yield from self._stream(message, model=model, provider_options=provider_options) def fake(self, patterns: dict[str, AgentResponse | AgentSnapshot]) -> "Agent": """Register fake responses for testing. Keys are glob patterns.""" @@ -187,17 +184,9 @@ def build(mw_list: list, fn: Callable) -> Callable: return build(chain, final)(message) - def _lab(self) -> "Lab": - from .lab import Lab # noqa: PLC0415 - - return Lab(self._provider) - def _resolve_model(self, override: str | None = None) -> str: - if override: - return override - if self._model: - return self._model - return self._lab().get_model() + # Lab.get_model() returns the given model if truthy, else the config default. + return Lab(self._provider).get_model(override or self._model or None) def _get_provider_options(self, override: dict | None = None) -> dict: options = dict(self.provider_options().get(self._provider, {})) @@ -207,44 +196,32 @@ def _get_provider_options(self, override: dict | None = None) -> dict: options.update(provider_specific) return options - def _resolve_api_key(self, provider_name: str) -> str | None: - """Try Config.get("ai") first, fallback to None (the model reads its env var).""" - try: - from fastapi_startkit.facades.Config import Config # noqa: PLC0415 - - ai_config = Config.get("ai") - return ai_config.providers[provider_name].key or None - except Exception: - return None + def _build_instruction(self) -> str | None: + return self._instructions or self.instructions() def _build_messages( - self, - message: str, - system: str | None = None, - extra_messages: list[dict] | None = None, - attachments: list[Document] | None = None, - ) -> tuple[str | None, list[dict]]: - base = self.messages() - - resolved_system = system - if resolved_system is None: - sys_entries = [m for m in base if m.get("role") == "system"] - if sys_entries: - resolved_system = sys_entries[0]["content"] - - history = [m for m in base if m.get("role") != "system"] - if extra_messages: - history.extend(extra_messages) + self, + message: str, + attachments: list[Document] | None = None, + ) -> list[dict]: + messages: list[dict] = [] + + instruction = self._instructions or self.instructions() + if instruction: + messages.append({"role": "system", "content": instruction}) + + messages.extend(self.messages() or []) + + if message: + messages.append({"role": "user", "content": message}) if attachments: content: Any = [{"type": "text", "text": message}] for doc in attachments: content.append(doc.to_langchain_block()) - history.append({"role": "user", "content": content}) - else: - history.append({"role": "user", "content": message}) + messages.append({"role": "user", "content": content}) - return resolved_system, history + return messages def _build_model(self, model: str | None = None, provider_options: dict | None = None) -> Any: """Build a LangChain chat model for this agent. @@ -254,9 +231,10 @@ def _build_model(self, model: str | None = None, provider_options: dict | None = """ from langchain.chat_models import init_chat_model # noqa: PLC0415 - kwargs: dict[str, Any] = {"model_provider": self._lab().get_provider_key()} + lab = Lab(self._provider) + kwargs: dict[str, Any] = {"model_provider": lab.get_provider_key()} - api_key = self._resolve_api_key(self._provider) + api_key = lab.get_api_key() if api_key: kwargs["api_key"] = api_key if self._max_tokens: @@ -288,47 +266,29 @@ def _to_agent_response(self, result: Any) -> AgentResponse: return AgentResponse(content=content, tool_calls=tool_calls, usage=usage, raw=result) def _run( - self, - message: str, - system: str | None = None, - model: str | None = None, - extra_messages: list[dict] | None = None, - attachments: list[Document] | None = None, - provider_options: dict | None = None, + self, + message: str, + model: str | None = None, + attachments: list[Document] | None = None, + provider_options: dict | None = None, ) -> AgentResponse: - from langchain.agents import create_agent # noqa: PLC0415 + from .runner import Runner # noqa: PLC0415 - resolved_system, history = self._build_messages(message, system, extra_messages, attachments) + messages = self._build_messages(message, attachments) chat_model = self._build_model(model, provider_options) - agent_kwargs: dict[str, Any] = {"tools": self.tools()} - if resolved_system: - agent_kwargs["system_prompt"] = resolved_system - schema = self.schema() - if schema is not None: - agent_kwargs["response_format"] = schema - - agent = create_agent(chat_model, **agent_kwargs) - result = agent.invoke({"messages": history}, {"recursion_limit": self._max_steps * 2 + 1}) + result = Runner(chat_model, self.tools(), self._max_steps).run(messages) return self._to_agent_response(result) def _stream( - self, - message: str, - system: str | None = None, - model: str | None = None, - provider_options: dict | None = None, + self, + message: str, + model: str | None = None, + provider_options: dict | None = None, ) -> Iterator[str]: - resolved_system, history = self._build_messages(message, system) - chat_model = self._build_model(model, provider_options) + from .runner import StreamRunner # noqa: PLC0415 - lc_messages: list[dict] = [] - if resolved_system: - lc_messages.append({"role": "system", "content": resolved_system}) - lc_messages.extend(history) + messages = self._build_messages(message) + chat_model = self._build_model(model, provider_options) - for chunk in chat_model.stream(lc_messages): - text = getattr(chunk, "content", "") - if not text: - continue - yield text if isinstance(text, str) else str(text) + yield from StreamRunner(chat_model, self.tools(), self._max_steps).run(messages) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py deleted file mode 100644 index af215946..00000000 --- a/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .ai import AIConfig -from .config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig - -__all__ = [ - "AIConfig", - "AnthropicConfig", - "ElevenLabsConfig", - "GoogleConfig", - "OpenAIConfig", -] diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py index c2883f55..1dda6a48 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py @@ -2,7 +2,7 @@ from fastapi_startkit.environment import env -from .config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig +from fastapi_startkit.ai import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig @dataclass diff --git a/fastapi_startkit/src/fastapi_startkit/ai/lab.py b/fastapi_startkit/src/fastapi_startkit/ai/lab.py index f0da5a2c..b0369fb5 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/lab.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/lab.py @@ -1,19 +1,6 @@ -"""Lab — resolve the active provider, model, and LangChain model URL from config. - -The ``ai`` config selects a default provider per modality (``ai.default`` for text, -``ai.default_image`` / ``ai.default_audio`` / ``ai.default_transcribe``) and stores -each provider's default models under ``ai.providers..models``. ``Lab`` reads -those and builds the ``":"`` string LangChain's ``init_chat_model`` -and ``create_agent`` understand. -""" - from enum import StrEnum - -def _config(): - from fastapi_startkit import Config # noqa: PLC0415 - - return Config +from fastapi_startkit import Config class ModelType(StrEnum): @@ -39,9 +26,12 @@ class Lab(StrEnum): ANTHROPIC = "anthropic" ELEVENLABS = "elevenlabs" + def get_api_key(self) -> str: + """Return this provider's configured API key.""" + return Config.get(f"ai.providers.{self.value}.key") + def get_model(self, model: str | None = None, model_type: ModelType = ModelType.TEXT) -> str: - """Return ``model`` if given, else this provider's configured default for the modality.""" - return model or _config().get(f"ai.providers.{self.value}.models.{_model_key(model_type)}") + return model or Config.get(f"ai.providers.{self.value}.models.{_model_key(model_type)}") def get_provider_key(self) -> str: """Map this provider to its LangChain ``init_chat_model`` id.""" @@ -55,14 +45,14 @@ def get_provider_key(self) -> str: @staticmethod def get_provider(provider: str | None = None, model_type: ModelType = ModelType.TEXT) -> "Lab": """Resolve a provider name (or the configured default for the modality) to a ``Lab``.""" - provider = provider or _config().get(f"ai.{_provider_field(model_type)}") + provider = provider or Config.get(f"ai.{_provider_field(model_type)}") return Lab(provider) @staticmethod def get_model_url( - provider: str | None = None, - model: str | None = None, - model_type: ModelType = ModelType.TEXT, + provider: str | None = None, + model: str | None = None, + model_type: ModelType = ModelType.TEXT, ) -> str: """Build the ``":"`` string for ``init_chat_model``/``create_agent``.""" lab = Lab.get_provider(provider, model_type) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/runner.py b/fastapi_startkit/src/fastapi_startkit/ai/runner.py new file mode 100644 index 00000000..41b707b2 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/runner.py @@ -0,0 +1,80 @@ +"""Runner — drive a chat model through a tool-calling loop, no ``create_agent``. + +The :class:`~fastapi_startkit.ai.agent.Agent` builds a chat model (via +``init_chat_model``) and its tools, then hands them to a Runner. The Runner binds +the tools, invokes the model, executes any tool calls the model requests, feeds +the results back, and repeats until the model answers without calling a tool (or +``max_steps`` is reached). :class:`StreamRunner` does the same while yielding +content tokens as they arrive. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage +from langchain_core.runnables import Runnable +from langchain_core.tools import BaseTool + +# A turn in the running history: a chat message or a plain role/content dict. +Message = BaseMessage | dict[str, Any] + + +class Runner: + """Run a chat model through a tool-calling loop and return the final message.""" + + def __init__( + self, + model: BaseChatModel, + tools: Sequence[BaseTool] | None = None, + max_steps: int = 10, + ) -> None: + self._tools: dict[str, BaseTool] = {tool.name: tool for tool in (tools or [])} + # Bind the tools so the model can request them; an unbound model otherwise. + self.model: Runnable[Any, BaseMessage] = ( + model.bind_tools(list(self._tools.values())) if self._tools else model + ) + self.max_steps = max_steps + + def run(self, messages: Sequence[Message]) -> AIMessage: + history: list[Message] = list(messages) + response: AIMessage = self.model.invoke(history) # type: ignore[assignment] + + for _ in range(self.max_steps): + if not response.tool_calls: + break + history.append(response) + history.extend(self._run_tools(response.tool_calls)) + response = self.model.invoke(history) # type: ignore[assignment] + + return response + + def _run_tools(self, tool_calls: list[dict[str, Any]]) -> list[BaseMessage]: + return [self._resolve_tool(call["name"]).invoke(call) for call in tool_calls] + + def _resolve_tool(self, name: str) -> BaseTool: + try: + return self._tools[name] + except KeyError: + raise ValueError(f"Agent has no tool named {name!r}") from None + + +class StreamRunner(Runner): + """Like :class:`Runner`, but yields content tokens as the model streams them.""" + + def run(self, messages: Sequence[Message]) -> Iterator[str]: # type: ignore[override] + history: list[Message] = list(messages) + + for _ in range(self.max_steps): + gathered: AIMessageChunk | None = None + for chunk in self.model.stream(history): + if chunk.content: + yield chunk.content if isinstance(chunk.content, str) else str(chunk.content) + gathered = chunk if gathered is None else gathered + chunk # type: ignore[operator] + + if gathered is None or not gathered.tool_calls: + return + history.append(gathered) + history.extend(self._run_tools(gathered.tool_calls)) diff --git a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py index a3cc5b3d..a0aff3f8 100644 --- a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py +++ b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py @@ -45,8 +45,7 @@ def search_jobs(query: str) -> str: class JobAssistant(Agent): - def messages(self): - return [{"role": "system", "content": "You help users find jobs."}] + _instructions = "You help users find jobs." def tools(self): return [search_jobs] @@ -137,6 +136,32 @@ def test_resolve_model_prefers_explicit_override(): assert Agent()._resolve_model("my-model") == "my-model" +def test_instructions_come_from_the_attribute_not_messages(): + agent = JobAssistant() + + resolved_system, history = agent._build_messages("find me a job") + + assert resolved_system == "You help users find jobs." + # messages() is conversation-only; no system entry leaks into history. + assert all(m.get("role") != "system" for m in history) + + +def test_instructions_can_be_a_method_override(): + class DynamicAgent(Agent): + def instructions(self) -> str: + return "Computed identity." + + resolved_system, _history = DynamicAgent()._build_messages("hi") + + assert resolved_system == "Computed identity." + + +def test_no_instructions_resolves_to_none(): + resolved_system, _history = Agent()._build_messages("hi") + + assert resolved_system is None + + def test_build_messages_inlines_text_attachment(): doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index d6b41d3d..8e1ecfb6 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -609,6 +609,9 @@ dependencies = [ ai = [ { name = "anthropic" }, { name = "google-generativeai" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, { name = "openai" }, ] database = [ @@ -623,11 +626,6 @@ inertia = [ { name = "jinja2" }, { name = "markupsafe" }, ] -langgraph = [ - { name = "langchain" }, - { name = "langchain-core" }, - { name = "langgraph" }, -] mysql = [ { name = "aiomysql" }, ] @@ -677,9 +675,9 @@ requires-dist = [ { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, - { name = "langchain", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, - { name = "langchain-core", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, - { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, + { name = "langchain", marker = "extra == 'ai'", specifier = ">=1.0.0" }, + { name = "langchain-core", marker = "extra == 'ai'", specifier = ">=1.0.0" }, + { name = "langgraph", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, @@ -687,7 +685,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai", "langgraph"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"] [package.metadata.requires-dev] dev = [