From c5930bf204cf06ded7c8af0492b152c9a37c56bc Mon Sep 17 00:00:00 2001 From: Lawrence Lane Date: Mon, 6 Jul 2026 16:20:58 -0400 Subject: [PATCH 1/6] test: cover framework adapter contracts --- README.md | 10 +- .../docs/tutorials/flask-integration.md | 50 ++--- .../docs/tutorials/starlette-integration.md | 14 +- src/kida/contrib/__init__.py | 6 +- src/kida/contrib/flask.py | 11 +- src/kida/contrib/starlette.py | 9 +- tests/contrib/test_framework_adapters.py | 180 ++++++++++++++++++ 7 files changed, 229 insertions(+), 51 deletions(-) create mode 100644 tests/contrib/test_framework_adapters.py diff --git a/README.md b/README.md index bb27b15..9b545f3 100644 --- a/README.md +++ b/README.md @@ -303,15 +303,15 @@ small components with constant args can be inlined. Use ```python # Flask -from kida.contrib.flask import KidaFlask -kida = KidaFlask(app) +from kida.contrib.flask import init_kida, render_template +kida_env = init_kida(app) # Starlette / FastAPI -from kida.contrib.starlette import KidaStarlette -templates = KidaStarlette(directory="templates") +from kida.contrib.starlette import KidaTemplates +templates = KidaTemplates(directory="templates") # Django -TEMPLATES = [{"BACKEND": "kida.contrib.django.KidaDjango", ...}] +TEMPLATES = [{"BACKEND": "kida.contrib.django.KidaTemplates", ...}] ``` diff --git a/site/content/docs/tutorials/flask-integration.md b/site/content/docs/tutorials/flask-integration.md index b51e354..02dafe8 100644 --- a/site/content/docs/tutorials/flask-integration.md +++ b/site/content/docs/tutorials/flask-integration.md @@ -35,35 +35,28 @@ pip install flask kida-templates ## Step 2: Configure Kida with Flask -Create a Flask app with Kida templates: +Register Kida on a Flask app. This keeps Flask's Jinja environment intact and +adds a separate Kida environment at `app.extensions["kida"]`: ```python -from flask import Flask, request -from kida import Environment, FileSystemLoader +from flask import Flask +from kida.contrib.flask import init_kida, render_template app = Flask(__name__) +kida_env = init_kida(app) +``` -# Configure Kida environment -kida_env = Environment( - loader=FileSystemLoader("templates/"), - autoescape=True, -) +`init_kida()` uses `app.template_folder` by default. Pass +`template_folder="other-templates"` to override it, or pass Kida +`Environment` keyword arguments such as `cache_size=400`. -# Add Flask-specific globals -kida_env.add_global("url_for", lambda *a, **kw: "#") # Replace with real url_for -kida_env.add_global("request", request) -``` +## Step 3: Use the Render Helper -## Step 3: Create a Render Helper +Import the adapter's `render_template()` inside routes. It reads the Kida +environment from Flask's current app and returns the rendered HTML string: ```python -from flask import make_response - -def render_template(template_name, **context): - """Render a Kida template.""" - template = kida_env.get_template(template_name) - html = template.render(**context) - return make_response(html) +from kida.contrib.flask import render_template ``` ## Step 4: Use in Routes @@ -112,26 +105,17 @@ def user_profile(name): ## Complete Example ```python -from flask import Flask, request, make_response -from kida import Environment, FileSystemLoader +from flask import Flask +from kida.contrib.flask import init_kida, render_template app = Flask(__name__) -# Kida environment with caching -kida_env = Environment( - loader=FileSystemLoader("templates/"), - autoescape=True, +kida_env = init_kida( + app, cache_size=100, auto_reload=app.debug, # Only reload in debug mode ) -def render_template(template_name, **context): - template = kida_env.get_template(template_name) - html = template.render(**context) - response = make_response(html) - response.headers["Content-Type"] = "text/html" - return response - @app.route("/") def home(): return render_template("home.html", title="Home") diff --git a/site/content/docs/tutorials/starlette-integration.md b/site/content/docs/tutorials/starlette-integration.md index 4349756..77f2be8 100644 --- a/site/content/docs/tutorials/starlette-integration.md +++ b/site/content/docs/tutorials/starlette-integration.md @@ -21,7 +21,10 @@ icon: zap # Starlette & FastAPI Integration -Use Kida with Starlette and FastAPI through `kida.contrib.starlette`. The integration provides `KidaTemplates` -- a drop-in replacement for Starlette's `Jinja2Templates` that supports context processors, HTMX metadata, and async rendering. +Use Kida with Starlette and FastAPI through `kida.contrib.starlette`. The +integration provides `KidaTemplates`, a small adapter with a familiar +`TemplateResponse()` API, context processors, HTMX metadata, and direct access +to Kida's separate async rendering APIs. ## Installation @@ -60,7 +63,9 @@ The `KidaTemplates` constructor accepts: | `context_processors` | List of callables that take a request and return a dict | | `**env_kwargs` | Extra keyword arguments passed to `Environment()` | -You must provide either `directory` or `env`, but not both. +Provide a pre-configured `env` when you need custom Kida setup; otherwise a +`directory` is required. If both are supplied, the explicit environment is +used. ### Using a Pre-configured Environment @@ -137,6 +142,11 @@ async def user_profile(request: Request, username: str): The `request` object is automatically added to the template context, so you can access it in templates as `{{ request }}`. +`TemplateResponse()` renders synchronously with `template.render()`. This is +appropriate for ordinary synchronous templates, even when called from an +`async def` endpoint. For templates containing `{% async for %}` or +`{{ await }}`, use `get_template()` with `render_stream_async()` as shown below. + ## Streaming Responses For large pages or real-time content, use Kida's async streaming with Starlette's `StreamingResponse`: diff --git a/src/kida/contrib/__init__.py b/src/kida/contrib/__init__.py index b79f3aa..3fca606 100644 --- a/src/kida/contrib/__init__.py +++ b/src/kida/contrib/__init__.py @@ -3,9 +3,9 @@ Each integration is its own self-contained module — import the one you need directly: - from kida.contrib.flask import KidaFlask - from kida.contrib.django import KidaDjangoBackend - from kida.contrib.starlette import KidaStarlette + from kida.contrib.flask import init_kida, render_template + from kida.contrib.django import KidaTemplates as DjangoKidaTemplates + from kida.contrib.starlette import KidaTemplates as StarletteKidaTemplates This package intentionally does not re-export anything: integrations are optional and may pull in framework-specific imports at module load time. diff --git a/src/kida/contrib/flask.py b/src/kida/contrib/flask.py index ffedfdc..b040181 100644 --- a/src/kida/contrib/flask.py +++ b/src/kida/contrib/flask.py @@ -1,6 +1,7 @@ """Flask integration for Kida. -Provides ``init_kida`` to replace Flask's default Jinja2 engine with Kida. +Provides ``init_kida`` to register a Kida environment and render helper on a +Flask application. Flask's own Jinja environment remains unchanged. Usage:: @@ -32,8 +33,9 @@ def init_kida( ) -> Environment: """Initialize Kida as the template engine for a Flask app. - Replaces Flask's default Jinja2 environment with Kida. Templates - are loaded from the app's template folder. + Registers a Kida environment and render helper on the app. Templates are + loaded from the app's template folder; Flask's Jinja environment is not + replaced. Args: app: Flask application instance. @@ -59,13 +61,12 @@ def init_kida( # Store the environment on the app for access app.extensions["kida"] = env - # Override render_template + # Attach an app-local render helper without changing Flask's Jinja helper. def render_template(template_name: str, **context: Any) -> str: """Render a template using Kida.""" template = env.get_template(template_name) return template.render(**context) - # Monkey-patch Flask's render_template in the app context app.kida_env = env app.kida_render = render_template diff --git a/src/kida/contrib/starlette.py b/src/kida/contrib/starlette.py index e9a20e6..f9681ce 100644 --- a/src/kida/contrib/starlette.py +++ b/src/kida/contrib/starlette.py @@ -1,7 +1,7 @@ """Starlette/FastAPI integration for Kida. -Provides ``KidaTemplates`` — a drop-in replacement for Starlette's -``Jinja2Templates``. +Provides ``KidaTemplates`` — a small adapter with a familiar +``TemplateResponse`` API and direct access to Kida templates. Usage:: @@ -74,7 +74,10 @@ def TemplateResponse( # noqa: N802 — matches Starlette convention headers: dict[str, str] | None = None, media_type: str | None = None, ) -> Any: - """Render a template and return a Starlette Response. + """Synchronously render a template and return a Starlette Response. + + Async templates must be rendered through ``get_template()`` and Kida's + ``render_stream_async()`` API, then wrapped in ``StreamingResponse``. Args: request: Starlette Request object. diff --git a/tests/contrib/test_framework_adapters.py b/tests/contrib/test_framework_adapters.py new file mode 100644 index 0000000..fbd7b4f --- /dev/null +++ b/tests/contrib/test_framework_adapters.py @@ -0,0 +1,180 @@ +"""Contract tests for optional framework adapters. + +The real frameworks are intentionally not test dependencies. Small doubles +prove Kida's adapter behavior and minimal-install import boundary. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import TYPE_CHECKING, cast + +import pytest + +from kida.contrib.django import KidaTemplates as DjangoKidaTemplates +from kida.contrib.flask import init_kida +from kida.contrib.starlette import KidaTemplates as StarletteKidaTemplates +from kida.render_context import get_render_context_required + +if TYPE_CHECKING: + from kida import Environment + + +def test_contrib_modules_import_without_optional_frameworks() -> None: + for module_name in ( + "kida.contrib.flask", + "kida.contrib.django", + "kida.contrib.starlette", + ): + assert importlib.import_module(module_name) is not None + + +def test_flask_init_registers_environment_and_app_render_helper(tmp_path: Path) -> None: + template_dir = tmp_path / "templates" + template_dir.mkdir() + (template_dir / "hello.html").write_text("Hello {{ name }}", encoding="utf-8") + app = SimpleNamespace( + root_path=str(tmp_path), + template_folder="templates", + extensions={}, + ) + + env = init_kida(app, auto_reload=False) + + assert app.extensions["kida"] is env + assert app.kida_env is env + assert app.kida_render("hello.html", name="Kida") == "Hello Kida" + + +def test_flask_render_template_uses_current_app( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + from kida.contrib.flask import render_template + + template_dir = tmp_path / "templates" + template_dir.mkdir() + (template_dir / "hello.html").write_text("Hello {{ name }}", encoding="utf-8") + app = SimpleNamespace( + root_path=str(tmp_path), + template_folder="templates", + extensions={}, + ) + init_kida(app) + + flask_module = ModuleType("flask") + flask_module.current_app = app + monkeypatch.setitem(sys.modules, "flask", flask_module) + + assert render_template("hello.html", name="Flask") == "Hello Flask" + + +def test_django_backend_loads_and_wraps_templates(tmp_path: Path) -> None: + (tmp_path / "hello.html").write_text("Hello {{ name }}", encoding="utf-8") + backend = DjangoKidaTemplates( + { + "DIRS": [tmp_path], + "OPTIONS": {"autoescape": True, "extensions": []}, + } + ) + + template = backend.get_template("hello.html") + request = object() + + assert template.render({"name": "Django"}, request) == "Hello Django" + assert template.origin.name == "hello.html" + assert template.origin.template_name == "hello.html" + with pytest.warns(UserWarning, match=r"from_string\(\) without name="): + inline = backend.from_string("Hi {{ name }}") + assert inline.render({"name": "string"}) == "Hi string" + + +class _FakeTemplate: + def __init__(self) -> None: + self.context: dict[str, object] = {} + self.metadata: dict[str, object] = {} + + def render(self, **context: object) -> str: + self.context = context + render_ctx = get_render_context_required() + self.metadata = { + key: render_ctx.get_meta(key) + for key in ("hx_request", "hx_target", "hx_trigger", "hx_boosted") + } + return f"Hello {context['name']}" + + +class _FakeEnvironment: + def __init__(self, template: _FakeTemplate) -> None: + self.template = template + + def get_template(self, name: str) -> _FakeTemplate: + assert name == "hello.html" + return self.template + + +class _FakeHTMLResponse: + def __init__( + self, + content: str, + status_code: int, + headers: dict[str, str] | None, + media_type: str | None, + ) -> None: + self.content = content + self.status_code = status_code + self.headers = headers + self.media_type = media_type + + +def test_starlette_template_response_contract(monkeypatch: pytest.MonkeyPatch) -> None: + starlette_module = ModuleType("starlette") + starlette_module.__path__ = [] + responses_module = ModuleType("starlette.responses") + responses_module.HTMLResponse = _FakeHTMLResponse + monkeypatch.setitem(sys.modules, "starlette", starlette_module) + monkeypatch.setitem(sys.modules, "starlette.responses", responses_module) + + template = _FakeTemplate() + env = _FakeEnvironment(template) + templates = StarletteKidaTemplates( + env=cast("Environment", env), + context_processors=[lambda request: {"processor": request.user}], + ) + request = SimpleNamespace( + user="Ada", + headers={ + "HX-Request": "true", + "HX-Target": "results", + "HX-Trigger": "search", + "HX-Boosted": "true", + }, + ) + + response = templates.TemplateResponse( + request, + "hello.html", + {"name": "Starlette"}, + status_code=201, + headers={"X-Test": "yes"}, + media_type="text/custom", + ) + + assert isinstance(response, _FakeHTMLResponse) + assert response.content == "Hello Starlette" + assert response.status_code == 201 + assert response.headers == {"X-Test": "yes"} + assert response.media_type == "text/custom" + assert template.context == { + "request": request, + "name": "Starlette", + "processor": "Ada", + } + assert template.metadata == { + "hx_request": True, + "hx_target": "results", + "hx_trigger": "search", + "hx_boosted": True, + } From 94fb65df60a82816a1faed9312ca5b9e23df68a9 Mon Sep 17 00:00:00 2001 From: Lawrence Lane Date: Tue, 7 Jul 2026 09:38:47 -0400 Subject: [PATCH 2/6] docs: add framework component quickstarts --- examples/README.md | 29 ++ examples/django_components/README.md | 18 + examples/django_components/app.py | 88 +++++ .../templates/components.html | 33 ++ examples/fastapi_components/README.md | 18 + examples/fastapi_components/app.py | 74 ++++ .../templates/components.html | 33 ++ examples/flask_components/README.md | 18 + examples/flask_components/app.py | 58 +++ .../templates/components.html | 33 ++ pyproject.toml | 7 + site/content/docs/tutorials/_index.md | 50 +-- .../docs/tutorials/django-integration.md | 247 +++++------- .../docs/tutorials/flask-integration.md | 239 +++++------- .../docs/tutorials/starlette-integration.md | 360 ++++++------------ tests/contrib/test_framework_adapters.py | 116 ++++-- tests/test_docs_install_snippets.py | 19 + tests/test_examples.py | 24 ++ uv.lock | 236 ++++++++++++ 19 files changed, 1098 insertions(+), 602 deletions(-) create mode 100644 examples/django_components/README.md create mode 100644 examples/django_components/app.py create mode 100644 examples/django_components/templates/components.html create mode 100644 examples/fastapi_components/README.md create mode 100644 examples/fastapi_components/app.py create mode 100644 examples/fastapi_components/templates/components.html create mode 100644 examples/flask_components/README.md create mode 100644 examples/flask_components/app.py create mode 100644 examples/flask_components/templates/components.html diff --git a/examples/README.md b/examples/README.md index 3b15420..c5a4631 100644 --- a/examples/README.md +++ b/examples/README.md @@ -125,6 +125,35 @@ buttons inside cards, cards inside pages. cd examples/design_system && python app.py ``` +### `flask_components/` -- Typed Components in Flask + +A real Flask 3.1 app using `kida.contrib.flask`: a typed form component on the +full-page route and `render_block()` for a POST fragment response. Includes a +non-network smoke path used by CI. + +```bash +uv run python examples/flask_components/app.py --smoke +``` + +### `django_components/` -- Typed Components in Django + +A minimal Django 6.0 app registering `kida.contrib.django.KidaTemplates` through +the standard `TEMPLATES` setting. The GET route uses `django.shortcuts.render`; +the POST route returns a Kida block fragment. + +```bash +uv run python examples/django_components/app.py --smoke +``` + +### `fastapi_components/` -- Typed Components in FastAPI + +A FastAPI 0.139 / Starlette 1.3 app using `KidaTemplates.TemplateResponse()` and +an ASGI-tested POST fragment route, without requiring multipart parsing. + +```bash +uv run python examples/fastapi_components/app.py --smoke +``` + ### `fastapi_async/` -- FastAPI Integration `render_stream_async()` with FastAPI's `StreamingResponse` for true streaming HTML diff --git a/examples/django_components/README.md b/examples/django_components/README.md new file mode 100644 index 0000000..35bcf7f --- /dev/null +++ b/examples/django_components/README.md @@ -0,0 +1,18 @@ +# Django Components + +Add typed Kida components to an existing Django app on Python 3.14+: + +```bash +uv add django kida-templates +uv run python app.py +``` + +Open . The `TEMPLATES` setting registers Kida as a +normal Django backend; the GET route uses `django.shortcuts.render`, and the +POST route renders only the `preview` block. + +Run the non-network smoke path with: + +```bash +uv run python app.py --smoke +``` diff --git a/examples/django_components/app.py b/examples/django_components/app.py new file mode 100644 index 0000000..03eb5be --- /dev/null +++ b/examples/django_components/app.py @@ -0,0 +1,88 @@ +"""Typed Kida components inside an existing Django app.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import django +from django.conf import settings +from django.core.management import execute_from_command_line +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.template import engines +from django.test import Client +from django.urls import path + +TEMPLATES_DIR = Path(__file__).parent / "templates" + +if not settings.configured: + settings.configure( + ALLOWED_HOSTS=["localhost", "testserver"], + DEBUG=True, + MIDDLEWARE=[], + ROOT_URLCONF=__name__, + SECRET_KEY="kida-example", + TEMPLATES=[ + { + "BACKEND": "kida.contrib.django.KidaTemplates", + "DIRS": [TEMPLATES_DIR], + "NAME": "kida", + "OPTIONS": {"autoescape": True}, + } + ], + ) + +django.setup() + + +def home(request: HttpRequest) -> HttpResponse: + """Render the full page through Django's configured template backend.""" + return render( + request, + "components.html", + {"title": "First component", "summary": "Edit me"}, + ) + + +def preview(request: HttpRequest) -> HttpResponse: + """Return only the preview block through the configured Kida backend.""" + template = engines["kida"].env.get_template("components.html") + html = template.render_block( + "preview", + title=request.POST.get("title", ""), + summary=request.POST.get("summary", ""), + ) + return HttpResponse(html) + + +urlpatterns = [ + path("", home), + path("preview", preview), +] + + +def smoke() -> None: + """Exercise both routes with Django's real test client.""" + client = Client() + + page = client.get("/") + assert page.status_code == 200 + assert "Component form" in page.content.decode() + + fragment = client.post( + "/preview", + {"title": "", "summary": "Static validation"}, + ) + fragment_html = fragment.content.decode() + assert fragment.status_code == 200 + assert "<Admin>" in fragment_html + assert " +

{{ title }}

+ {% slot %} + +{% enddef %} + +{% def text_field(name: str, label: str, value: str = "") %} + +{% enddef %} + + +Kida Components + +{% block form %} +{% call panel("Component form") %} +
+ {{ text_field("title", "Title", title) }} + {{ text_field("summary", "Summary", summary) }} + +
+{% endcall %} +{% endblock %} + +{% block preview %} +
+

{{ title }}

+

{{ summary }}

+
+{% endblock %} diff --git a/examples/fastapi_components/README.md b/examples/fastapi_components/README.md new file mode 100644 index 0000000..521cfdf --- /dev/null +++ b/examples/fastapi_components/README.md @@ -0,0 +1,18 @@ +# FastAPI Components + +Add typed Kida components to an existing FastAPI app on Python 3.14+: + +```bash +uv add fastapi uvicorn kida-templates +uv run python app.py +``` + +Open . The GET route uses Kida's +`TemplateResponse()` adapter; the POST route renders only the `preview` block +and returns an `HTMLResponse`. + +Run the non-network smoke path with: + +```bash +uv run python app.py --smoke +``` diff --git a/examples/fastapi_components/app.py b/examples/fastapi_components/app.py new file mode 100644 index 0000000..72de1cf --- /dev/null +++ b/examples/fastapi_components/app.py @@ -0,0 +1,74 @@ +"""Typed Kida components inside an existing FastAPI app.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path +from urllib.parse import parse_qs + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from httpx import ASGITransport, AsyncClient + +from kida.contrib.starlette import KidaTemplates + +TEMPLATES_DIR = Path(__file__).parent / "templates" + +app = FastAPI() +templates = KidaTemplates(directory=TEMPLATES_DIR) + + +@app.get("/", response_class=HTMLResponse) +async def home(request: Request) -> HTMLResponse: + """Render the full page with Kida's Starlette/FastAPI adapter.""" + return templates.TemplateResponse( + request, + "components.html", + {"title": "First component", "summary": "Edit me"}, + ) + + +@app.post("/preview", response_class=HTMLResponse) +async def preview(request: Request) -> HTMLResponse: + """Return only the preview block without a multipart dependency.""" + form = parse_qs((await request.body()).decode()) + template = templates.get_template("components.html") + html = template.render_block( + "preview", + title=form.get("title", [""])[0], + summary=form.get("summary", [""])[0], + ) + return HTMLResponse(html) + + +async def _smoke_async() -> None: + """Exercise both routes through FastAPI's ASGI request path.""" + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + page = await client.get("/") + assert page.status_code == 200 + assert "Component form" in page.text + + fragment = await client.post( + "/preview", + data={"title": "", "summary": "Static validation"}, + ) + assert fragment.status_code == 200 + assert "<Admin>" in fragment.text + assert " None: + """Run the asynchronous smoke path from a normal Python process.""" + asyncio.run(_smoke_async()) + print("fastapi_components OK") + + +if __name__ == "__main__": + if "--smoke" in sys.argv: + smoke() + else: + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/examples/fastapi_components/templates/components.html b/examples/fastapi_components/templates/components.html new file mode 100644 index 0000000..43cc725 --- /dev/null +++ b/examples/fastapi_components/templates/components.html @@ -0,0 +1,33 @@ +{% def panel(title: str) %} +
+

{{ title }}

+ {% slot %} +
+{% enddef %} + +{% def text_field(name: str, label: str, value: str = "") %} + +{% enddef %} + + +Kida Components + +{% block form %} +{% call panel("Component form") %} +
+ {{ text_field("title", "Title", title) }} + {{ text_field("summary", "Summary", summary) }} + +
+{% endcall %} +{% endblock %} + +{% block preview %} +
+

{{ title }}

+

{{ summary }}

+
+{% endblock %} diff --git a/examples/flask_components/README.md b/examples/flask_components/README.md new file mode 100644 index 0000000..f885ae7 --- /dev/null +++ b/examples/flask_components/README.md @@ -0,0 +1,18 @@ +# Flask Components + +Add typed Kida components to an existing Flask app on Python 3.14+: + +```bash +uv add flask kida-templates +uv run python app.py +``` + +Open . The GET route renders a typed component with a +default slot; the POST route renders only the `preview` block for an +HTML-over-the-wire update. + +Run the non-network smoke path with: + +```bash +uv run python app.py --smoke +``` diff --git a/examples/flask_components/app.py b/examples/flask_components/app.py new file mode 100644 index 0000000..fc68de6 --- /dev/null +++ b/examples/flask_components/app.py @@ -0,0 +1,58 @@ +"""Typed Kida components inside an existing Flask app.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from flask import Flask, request + +from kida.contrib.flask import init_kida, render_template + +TEMPLATES_DIR = Path(__file__).parent / "templates" + +app = Flask(__name__, template_folder=str(TEMPLATES_DIR)) +kida_env = init_kida(app) + + +@app.get("/") +def home() -> str: + """Render the full page through Flask's current application context.""" + return render_template("components.html", title="First component", summary="Edit me") + + +@app.post("/preview") +def preview() -> str: + """Return only the preview block for an HTML-over-the-wire update.""" + template = kida_env.get_template("components.html") + return template.render_block( + "preview", + title=request.form.get("title", ""), + summary=request.form.get("summary", ""), + ) + + +def smoke() -> None: + """Exercise both the full-page and fragment routes without starting a server.""" + client = app.test_client() + + page = client.get("/") + assert page.status_code == 200 + assert "Component form" in page.get_data(as_text=True) + + fragment = client.post( + "/preview", + data={"title": "", "summary": "Static validation"}, + ) + fragment_html = fragment.get_data(as_text=True) + assert fragment.status_code == 200 + assert "<Admin>" in fragment_html + assert " +

{{ title }}

+ {% slot %} + +{% enddef %} + +{% def text_field(name: str, label: str, value: str = "") %} + +{% enddef %} + + +Kida Components + +{% block form %} +{% call panel("Component form") %} +
+ {{ text_field("title", "Title", title) }} + {{ text_field("summary", "Summary", summary) }} + +
+{% endcall %} +{% endblock %} + +{% block preview %} +
+

{{ title }}

+

{{ summary }}

+
+{% endblock %} diff --git a/pyproject.toml b/pyproject.toml index c7a2099..32b8351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,6 +185,13 @@ dev = [ "hypothesis>=6.100.0", # Property-based testing "jinja2>=3.1.6,<3.2", "milo-cli>=0.2.1", # Saga benchmarks + examples (dev-only) + # Optional framework contracts/examples (dev-only; runtime stays dependency-free) + "flask>=3.1.3,<3.2", + "django>=6.0.6,<6.1", + "fastapi>=0.139,<0.140", + "starlette>=1.3.1,<1.4", + "httpx>=0.28.1,<0.29", + "uvicorn>=0.50.2,<0.51", # Linting & Type Checking "ty>=0.0.11", # Astral type checker (Rust-based) "ruff>=0.15.1", # 0.15.1+ fixes except-parenthesis bug (PEP 758 + as clause) diff --git a/site/content/docs/tutorials/_index.md b/site/content/docs/tutorials/_index.md index e5d9df9..28f51f5 100644 --- a/site/content/docs/tutorials/_index.md +++ b/site/content/docs/tutorials/_index.md @@ -21,13 +21,38 @@ icon: notepad # Tutorials -Step-by-step guides for common Kida usage scenarios, migration paths, and integration -workflows. +Start with the framework you already use, or follow a migration and workflow +guide below. Every framework quickstart targets Python 3.14+ and reaches a +typed component plus fragment response in about ten minutes. :::{cards} :columns: 1 :gap: medium +:::{card} Flask Integration +:icon: globe +:link: /docs/tutorials/flask-integration/ +:description: Add a typed component to Flask +:badge: 10 minutes +Build a form and a named fragment route through Kida's Flask adapter. +:::{/card} + +:::{card} Django Integration +:icon: globe +:link: /docs/tutorials/django-integration/ +:description: Add a typed component to Django +:badge: 10 minutes +Configure Kida as a backend, then render a full form and a named fragment. +:::{/card} + +:::{card} FastAPI & Starlette Integration +:icon: zap +:link: /docs/tutorials/starlette-integration/ +:description: Add a typed component to FastAPI +:badge: 10 minutes +Build a form and fragment endpoint, with Starlette and streaming patterns. +:::{/card} + :::{card} Migrate from Jinja2 :icon: arrow-right :link: /docs/tutorials/migrate-from-jinja2/ @@ -52,27 +77,6 @@ Three fix patterns for the strict_undefined flip, the escape hatch, and the new Use `./` / `../` for co-located partials and `@alias/` for shared libraries so folder moves become zero-edit refactors. :::{/card} -:::{card} Flask Integration -:icon: globe -:link: /docs/tutorials/flask-integration/ -:description: Use Kida with Flask -Set up Kida as Flask's template engine with custom filters and error handling. -:::{/card} - -:::{card} Django Integration -:icon: globe -:link: /docs/tutorials/django-integration/ -:description: Use Kida with Django -Configure Kida as a Django template backend with settings and views. -:::{/card} - -:::{card} Starlette Integration -:icon: zap -:link: /docs/tutorials/starlette-integration/ -:description: Use Kida with Starlette and FastAPI -Async rendering, streaming responses, and HTMX patterns. -:::{/card} - :::{card} Build Custom Filters :icon: filter :link: /docs/tutorials/custom-filters/ diff --git a/site/content/docs/tutorials/django-integration.md b/site/content/docs/tutorials/django-integration.md index 63a105c..54d563b 100644 --- a/site/content/docs/tutorials/django-integration.md +++ b/site/content/docs/tutorials/django-integration.md @@ -1,6 +1,6 @@ --- title: Django Integration -description: Use Kida as a Django template backend with kida.contrib.django +description: Add a typed Kida component and fragment view to Django in ten minutes draft: false weight: 25 lang: en @@ -11,208 +11,171 @@ tags: - framework keywords: - Django + - typed components + - fragment rendering - template backend - - contrib - - integration icon: globe --- # Django Integration -Use Kida as a drop-in Django template backend with `kida.contrib.django`. The integration provides `KidaTemplates` -- a backend class that plugs into Django's `TEMPLATES` setting and works with `django.shortcuts.render`, template loaders, and the Django debug toolbar. +Use Kida through Django's standard template-backend contract, then render a +named fragment from the same template. Kida requires **Python 3.14 or newer**. -## Installation +> Migrating Jinja templates? Read [[docs/get-started/coming-from-jinja2|Coming +> from Jinja2]]. Kida's `{% set %}` is block-scoped, so values do not leak out +> of loops or other blocks. + +## 1. Install ```bash -pip install django kida-templates +uv add django kida-templates ``` -## Django Settings +## 2. Configure the backend -Add the Kida backend to your `TEMPLATES` list in `settings.py`: +Add Kida to `TEMPLATES` in `settings.py`: ```python -# settings.py from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent TEMPLATES = [ { + "NAME": "kida", "BACKEND": "kida.contrib.django.KidaTemplates", "DIRS": [BASE_DIR / "templates"], - "OPTIONS": { - "autoescape": True, - "extensions": [], - }, + "APP_DIRS": False, + "OPTIONS": {"autoescape": True}, }, ] ``` -| Key | Description | -|-----|-------------| -| `BACKEND` | Must be `"kida.contrib.django.KidaTemplates"` | -| `DIRS` | List of directories to search for templates | -| `OPTIONS.autoescape` | Enable HTML autoescaping (default: `True`) | -| `OPTIONS.extensions` | List of Kida extensions to load | - -## Usage in Views +`NAME` makes the backend easy to select explicitly when an application uses +more than one template engine. -Once configured, use Django's standard rendering functions. The `request` object is automatically added to the template context: +## 3. Add full-page and fragment views ```python +from django.http import HttpRequest, HttpResponse +from django.middleware.csrf import get_token from django.shortcuts import render +from django.template import engines -def home(request): - return render(request, "home.html", {"title": "Home"}) - -def user_profile(request, username): - user = get_user(username) - return render(request, "profile.html", {"user": user}) -``` -You can also load templates directly through the backend: +def home(request: HttpRequest) -> HttpResponse: + return render( + request, + "components.html", + { + "title": "First component", + "summary": "Edit me", + "csrf_token": get_token(request), + }, + using="kida", + ) -```python -from django.template import loader -def home(request): - template = loader.get_template("home.html") - html = template.render({"title": "Home"}, request) +def preview(request: HttpRequest) -> HttpResponse: + template = engines["kida"].env.get_template("components.html") + html = template.render_block( + "preview", + title=request.POST.get("title", ""), + summary=request.POST.get("summary", ""), + ) return HttpResponse(html) ``` -Or create templates from strings: +Wire the views normally: ```python -from django.template import engines - -kida = engines["kida"] # Name matches BACKEND path -template = kida.from_string("Hello {{ name }}!") -html = template.render({"name": "World"}) -``` +from django.urls import path -## Template Syntax Differences +from . import views -If you're coming from Django's built-in template language, note these Kida syntax differences: - -| Feature | Django | Kida | -|---------|--------|------| -| Block end tags | `{% endblock %}` | `{% end %}` | -| For loop end | `{% endfor %}` | `{% end %}` | -| If end | `{% endif %}` | `{% end %}` | -| Comments | `{# comment #}` | `{# comment #}` | -| Variable output | `{{ var }}` | `{{ var }}` | -| Filters | `{{ var\|filter }}` | `{{ var \| filter }}` | -| Extends | `{% extends "base.html" %}` | `{% extends "base.html" %}` | - -### Template Example - -**templates/base.html:** - -```kida - - - - {% block title %}My Site{% end %} - - - -
{% block content %}{% end %}
- - +urlpatterns = [ + path("", views.home), + path("preview", views.preview), +] ``` -**templates/home.html:** +The backend wrapper supports Django's normal `render()` path and supplies the +request in template context. Accessing `backend.env` is the supported route to +Kida-specific APIs such as `render_block()`. -```kida -{% extends "base.html" %} +## 4. Create the typed component -{% block title %}{{ title }}{% end %} +Save this as `templates/components.html`: -{% block content %} -

{{ title }}

-

Welcome to the site!

-{% end %} +```kida +{% def panel(title: str) %} +
+

{{ title }}

+ {% slot %} +
+{% enddef %} + +{% def text_field(name: str, label: str, value: str = "") %} + +{% enddef %} + +{% block form %} +{% call panel("Component form") %} +
+ + {{ text_field("title", "Title", title) }} + {{ text_field("summary", "Summary", summary) }} + +
+{% endcall %} +{% endblock %} + +{% block preview %} +
+

{{ title }}

+

{{ summary }}

+
+{% endblock %} ``` -## Custom Filters and Globals - -Access the Kida `Environment` through the backend to register custom filters and globals: - -```python -# templatetags.py (or in your AppConfig.ready()) -from django.template import engines - -def setup_kida(): - backend = engines["kida"] - env = backend.env +Kida validates component calls against their typed signatures. The full view +and fragment view share compilation and escaping behavior; the fragment route +returns only `preview` for HTMX, Turbo, or a similar client. - # Register a custom filter - @env.filter() - def format_datetime(value, fmt="%Y-%m-%d"): - return value.strftime(fmt) +## 5. Run it - # Register a global - env.add_global("SITE_NAME", "My Django Site") +```bash +uv run python manage.py runserver ``` -Use in templates: +The repository includes a minimal, smoke-tested Django configuration at +[`examples/django_components`](https://github.com/lbliii/kida/tree/main/examples/django_components). -```kida -

Published: {{ post.date | format_datetime("%B %d, %Y") }}

-
{{ SITE_NAME }}
-``` +## Register filters and globals -## Complete Example +Use the named backend from application startup code: ```python -# settings.py -from pathlib import Path +from django.template import engines -BASE_DIR = Path(__file__).resolve().parent.parent +env = engines["kida"].env -TEMPLATES = [ - { - "BACKEND": "kida.contrib.django.KidaTemplates", - "DIRS": [BASE_DIR / "templates"], - "OPTIONS": { - "autoescape": True, - }, - }, -] -``` -```python -# views.py -from django.shortcuts import render +@env.filter() +def currency(value: float) -> str: + return f"${value:,.2f}" -def home(request): - return render(request, "home.html", { - "title": "Home", - "items": ["Alpha", "Bravo", "Charlie"], - }) -``` -```kida -{# templates/home.html #} -{% extends "base.html" %} - -{% block title %}{{ title }}{% end %} - -{% block content %} -

{{ title }}

-
    - {% for item in items %} -
  • {{ item }}
  • - {% end %} -
-{% end %} +env.add_global("SITE_NAME", "My Django Site") ``` -## See Also +## Next steps -- [[docs/tutorials/flask-integration|Flask Integration]] -- Flask setup guide -- [[docs/tutorials/starlette-integration|Starlette & FastAPI Integration]] -- Async framework setup -- [[docs/advanced/csp|Content Security Policy]] -- CSP nonce injection -- [[docs/usage/escaping|Escaping]] -- HTML security and autoescaping +- [[docs/tutorials/flask-integration|Flask Integration]] +- [[docs/tutorials/starlette-integration|Starlette & FastAPI Integration]] +- [[docs/usage/escaping|Escaping]] +- [[docs/extending/custom-filters|Custom Filters]] diff --git a/site/content/docs/tutorials/flask-integration.md b/site/content/docs/tutorials/flask-integration.md index 02dafe8..413b9f7 100644 --- a/site/content/docs/tutorials/flask-integration.md +++ b/site/content/docs/tutorials/flask-integration.md @@ -1,6 +1,6 @@ --- title: Flask Integration -description: Use Kida templates with Flask web framework +description: Add a typed Kida component and fragment route to Flask in ten minutes draft: false weight: 20 lang: en @@ -11,203 +11,142 @@ tags: - web keywords: - flask -- fastapi -- web framework +- typed components +- fragment rendering - integration icon: globe --- # Flask Integration -Integrate Kida templates into your Flask application. +Add a typed Kida component, a form, and a fragment response to an existing +Flask application. Kida requires **Python 3.14 or newer**. -## Prerequisites +> Coming from Jinja2? Read [[docs/get-started/coming-from-jinja2|Coming from +> Jinja2]] first. In particular, Kida's `{% set %}` is block-scoped rather than +> leaking into its surrounding scope. -- Python 3.14+ -- Flask installed -- Basic Flask knowledge - -## Step 1: Install Dependencies +## 1. Install ```bash -pip install flask kida-templates +uv add flask kida-templates ``` -## Step 2: Configure Kida with Flask +## 2. Register Kida and add two routes -Register Kida on a Flask app. This keeps Flask's Jinja environment intact and -adds a separate Kida environment at `app.extensions["kida"]`: +`init_kida()` leaves Flask's Jinja environment intact and adds a separate Kida +environment at `app.extensions["kida"]` and `app.kida_env`. ```python -from flask import Flask +from flask import Flask, request + from kida.contrib.flask import init_kida, render_template app = Flask(__name__) kida_env = init_kida(app) -``` -`init_kida()` uses `app.template_folder` by default. Pass -`template_folder="other-templates"` to override it, or pass Kida -`Environment` keyword arguments such as `cache_size=400`. -## Step 3: Use the Render Helper +@app.get("/") +def home() -> str: + return render_template( + "components.html", + title="First component", + summary="Edit me", + ) -Import the adapter's `render_template()` inside routes. It reads the Kida -environment from Flask's current app and returns the rendered HTML string: -```python -from kida.contrib.flask import render_template +@app.post("/preview") +def preview() -> str: + template = kida_env.get_template("components.html") + return template.render_block( + "preview", + title=request.form.get("title", ""), + summary=request.form.get("summary", ""), + ) ``` -## Step 4: Use in Routes +`render_template()` reads Kida from Flask's current application context. Pass +`template_folder=` to `init_kida()` to override `app.template_folder`, or pass +normal `Environment` options such as `cache_size=400`. -```python -@app.route("/") -def home(): - return render_template("home.html", title="Welcome") - -@app.route("/users/") -def user_profile(name): - user = get_user(name) - return render_template("profile.html", user=user) -``` - -## Step 5: Create Templates +## 3. Create the typed component -**templates/base.html:** +Save this as `templates/components.html`: ```kida - - - - {% block title %}My App{% end %} - - - -
{% block content %}{% end %}
- - +{% def panel(title: str) %} +
+

{{ title }}

+ {% slot %} +
+{% enddef %} + +{% def text_field(name: str, label: str, value: str = "") %} + +{% enddef %} + +{% block form %} +{% call panel("Component form") %} +
+ {{ text_field("title", "Title", title) }} + {{ text_field("summary", "Summary", summary) }} + +
+{% endcall %} +{% endblock %} + +{% block preview %} +
+

{{ title }}

+

{{ summary }}

+
+{% endblock %} ``` -**templates/home.html:** - -```kida -{% extends "base.html" %} +The component call is checked against the typed `def` signature. The `/` +route renders the full template; `/preview` renders only the named block for +HTMX, Turbo, or another HTML-over-the-wire client. Both paths use the same +autoescaping rules. -{% block title %}{{ title }}{% end %} +## 4. Run it -{% block content %} -

{{ title }}

-

Welcome to the site!

-{% end %} +```bash +uv run flask --app app run --debug ``` -## Complete Example +The repository includes a runnable, smoke-tested version at +[`examples/flask_components`](https://github.com/lbliii/kida/tree/main/examples/flask_components). -```python -from flask import Flask -from kida.contrib.flask import init_kida, render_template - -app = Flask(__name__) +## Add filters and globals -kida_env = init_kida( - app, - cache_size=100, - auto_reload=app.debug, # Only reload in debug mode -) - -@app.route("/") -def home(): - return render_template("home.html", title="Home") - -@app.route("/about") -def about(): - return render_template("about.html", title="About") - -if __name__ == "__main__": - app.run(debug=True) -``` - -## Custom Filters for Flask +Register application helpers on the environment returned by `init_kida()`: ```python -from datetime import datetime -from flask import url_for as flask_url_for +from flask import url_for -# Add Flask's url_for -kida_env.add_global("url_for", flask_url_for) +kida_env.add_global("url_for", url_for) -# Add custom filters -@kida_env.filter() -def format_datetime(value, format="%Y-%m-%d"): - if isinstance(value, datetime): - return value.strftime(format) - return value @kida_env.filter() -def pluralize(count, singular, plural=None): - if plural is None: - plural = singular + "s" - return singular if count == 1 else plural +def currency(value: float) -> str: + return f"${value:,.2f}" ``` -Use in templates: - -```kida -{{ post.date | format_datetime("%B %d, %Y") }} -{{ count }} {{ count | pluralize("item") }} -``` +## Production setup -## Error Handling +Set `auto_reload=False` outside development and size the template cache for +your application: ```python -from kida import TemplateError - -@app.errorhandler(TemplateError) -def handle_template_error(error): - app.logger.error(f"Template error: {error}") - return render_template("error.html", error=str(error)), 500 -``` - -## Production Configuration - -```python -# Production settings -kida_env = Environment( - loader=FileSystemLoader("templates/"), - autoescape=True, - auto_reload=False, # Don't check for changes - cache_size=400, # Larger cache -) - -# Clear cache on deploy -@app.cli.command() -def clear_cache(): - """Clear template cache.""" - kida_env.clear_cache() - print("Template cache cleared.") -``` - -## FastAPI Integration - -Kida works similarly with FastAPI: - -```python -from fastapi import FastAPI -from fastapi.responses import HTMLResponse -from kida import Environment, FileSystemLoader - -app = FastAPI() -kida_env = Environment(loader=FileSystemLoader("templates/")) - -@app.get("/", response_class=HTMLResponse) -def home(): - template = kida_env.get_template("home.html") - return template.render(title="FastAPI + Kida") +kida_env = init_kida(app, auto_reload=False, cache_size=400) ``` -## Next Steps +## Next steps -- [[docs/extending/custom-filters|Custom Filters]] — Build domain-specific filters -- [[docs/usage/escaping|Escaping]] — HTML security -- [[docs/about/performance|Performance]] — Production optimization +- [[docs/tutorials/django-integration|Django Integration]] +- [[docs/tutorials/starlette-integration|Starlette & FastAPI Integration]] +- [[docs/extending/custom-filters|Custom Filters]] +- [[docs/usage/escaping|Escaping]] diff --git a/site/content/docs/tutorials/starlette-integration.md b/site/content/docs/tutorials/starlette-integration.md index 77f2be8..45c8a0c 100644 --- a/site/content/docs/tutorials/starlette-integration.md +++ b/site/content/docs/tutorials/starlette-integration.md @@ -1,6 +1,6 @@ --- title: Starlette & FastAPI Integration -description: Use Kida with Starlette and FastAPI via kida.contrib.starlette +description: Add a typed Kida component and fragment endpoint to FastAPI in ten minutes draft: false weight: 22 lang: en @@ -13,313 +13,183 @@ tags: keywords: - Starlette - FastAPI - - contrib - - integration - - async + - typed components + - fragment rendering icon: zap --- # Starlette & FastAPI Integration -Use Kida with Starlette and FastAPI through `kida.contrib.starlette`. The -integration provides `KidaTemplates`, a small adapter with a familiar -`TemplateResponse()` API, context processors, HTMX metadata, and direct access -to Kida's separate async rendering APIs. +Use Kida's Starlette adapter from FastAPI to render a typed component, a form, +and a named fragment. Kida requires **Python 3.14 or newer**. -## Installation +> Coming from Jinja2? Read [[docs/get-started/coming-from-jinja2|Coming from +> Jinja2]]. Kida's `{% set %}` is block-scoped and does not leak values out of +> loops or other blocks. + +## FastAPI in ten minutes + +### 1. Install ```bash -pip install starlette kida-templates -# or for FastAPI: -pip install fastapi uvicorn kida-templates +uv add fastapi uvicorn kida-templates ``` -## Starlette Setup +### 2. Configure Kida and add two endpoints ```python -from starlette.applications import Starlette -from starlette.requests import Request -from starlette.routing import Route +from urllib.parse import parse_qs + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse + from kida.contrib.starlette import KidaTemplates +app = FastAPI() templates = KidaTemplates(directory="templates") -async def homepage(request: Request): + +@app.get("/", response_class=HTMLResponse) +async def home(request: Request) -> HTMLResponse: return templates.TemplateResponse( - request, "home.html", {"title": "Home"} + request, + "components.html", + {"title": "First component", "summary": "Edit me"}, ) -app = Starlette(routes=[ - Route("/", homepage), -]) -``` -The `KidaTemplates` constructor accepts: - -| Parameter | Description | -|-----------|-------------| -| `directory` | Path to template directory | -| `env` | Pre-configured Kida `Environment` (use instead of `directory`) | -| `context_processors` | List of callables that take a request and return a dict | -| `**env_kwargs` | Extra keyword arguments passed to `Environment()` | - -Provide a pre-configured `env` when you need custom Kida setup; otherwise a -`directory` is required. If both are supplied, the explicit environment is -used. - -### Using a Pre-configured Environment +@app.post("/preview", response_class=HTMLResponse) +async def preview(request: Request) -> HTMLResponse: + form = parse_qs((await request.body()).decode()) + template = templates.get_template("components.html") + html = template.render_block( + "preview", + title=form.get("title", [""])[0], + summary=form.get("summary", [""])[0], + ) + return HTMLResponse(html) +``` -```python -from kida import Environment, FileSystemLoader +Parsing this small URL-encoded form directly keeps `python-multipart` out of +the example. Applications that already use FastAPI's form dependency can use +`Form()` parameters instead. -env = Environment( - loader=FileSystemLoader("templates"), - autoescape=True, -) +### 3. Create the typed component -# Register custom filters/globals on env first -@env.filter() -def currency(value): - return f"${value:,.2f}" +Save this as `templates/components.html`: -templates = KidaTemplates(env=env) +```kida +{% def panel(title: str) %} +
+

{{ title }}

+ {% slot %} +
+{% enddef %} + +{% def text_field(name: str, label: str, value: str = "") %} + +{% enddef %} + +{% block form %} +{% call panel("Component form") %} +
+ {{ text_field("title", "Title", title) }} + {{ text_field("summary", "Summary", summary) }} + +
+{% endcall %} +{% endblock %} + +{% block preview %} +
+

{{ title }}

+

{{ summary }}

+
+{% endblock %} ``` -### Context Processors - -Context processors run on every `TemplateResponse` and inject additional variables into the template context: +Kida checks each component call against its typed `def` signature. The full +endpoint and fragment endpoint share compilation and autoescaping; the latter +returns only `preview` for HTMX, Turbo, or another HTML-over-the-wire client. -```python -def user_context(request): - return {"current_user": request.state.user} - -def site_context(request): - return {"site_name": "My App"} +### 4. Run it -templates = KidaTemplates( - directory="templates", - context_processors=[user_context, site_context], -) +```bash +uv run uvicorn app:app --reload ``` -Every template rendered through `TemplateResponse` will have access to `current_user` and `site_name` without passing them explicitly. +The repository includes a runnable, smoke-tested version at +[`examples/fastapi_components`](https://github.com/lbliii/kida/tree/main/examples/fastapi_components). -## FastAPI Setup +## Use the adapter with Starlette -The same `KidaTemplates` class works with FastAPI: +The same adapter works directly with Starlette: ```python -from fastapi import FastAPI, Request +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.routing import Route + from kida.contrib.starlette import KidaTemplates -app = FastAPI() templates = KidaTemplates(directory="templates") -@app.get("/") -async def homepage(request: Request): - return templates.TemplateResponse( - request, "home.html", {"title": "Home"} - ) -@app.get("/users/{username}") -async def user_profile(request: Request, username: str): - user = await get_user(username) +async def home(request: Request): return templates.TemplateResponse( - request, "profile.html", {"user": user} + request, + "components.html", + {"title": "First component", "summary": "Edit me"}, ) -``` -### `TemplateResponse` Parameters -| Parameter | Default | Description | -|-----------|---------|-------------| -| `request` | (required) | Starlette/FastAPI `Request` object | -| `name` | (required) | Template name to render | -| `context` | `None` | Dict of template variables | -| `status_code` | `200` | HTTP response status code | -| `headers` | `None` | Additional response headers | -| `media_type` | `None` | Response media type | - -The `request` object is automatically added to the template context, so you can access it in templates as `{{ request }}`. +app = Starlette(routes=[Route("/", home)]) +``` -`TemplateResponse()` renders synchronously with `template.render()`. This is -appropriate for ordinary synchronous templates, even when called from an -`async def` endpoint. For templates containing `{% async for %}` or -`{{ await }}`, use `get_template()` with `render_stream_async()` as shown below. +`KidaTemplates` accepts either `directory=`, or a preconfigured `env=`. It can +also accept `context_processors`, whose callables receive the request and +return context mappings. `TemplateResponse()` adds the request to context and +supports `status_code`, `headers`, and `media_type`. -## Streaming Responses +## Async and streaming templates -For large pages or real-time content, use Kida's async streaming with Starlette's `StreamingResponse`: +`TemplateResponse()` renders ordinary templates synchronously. For templates +that contain `{% async for %}` or `{{ await }}`, use Kida's async stream API: ```python from starlette.responses import StreamingResponse + @app.get("/feed") async def feed(request: Request): template = templates.get_template("feed.html") - async def generate(): + async def chunks(): async for chunk in template.render_stream_async( items=await get_items(), request=request, ): yield chunk - return StreamingResponse(generate(), media_type="text/html") -``` - -Templates can use `{% flush %}` to control chunk boundaries, sending content to the client as soon as key sections are ready. - -## Block Rendering for HTMX - -The `KidaTemplates` integration automatically detects HTMX requests and sets RenderContext metadata. When an HTMX request comes in, the following metadata keys are set: - -- `hx_request` -- `True` if `HX-Request` header is present -- `hx_target` -- Value of `HX-Target` header -- `hx_trigger` -- Value of `HX-Trigger` header -- `hx_boosted` -- `True` if `HX-Boosted` header is `"true"` - -Combine this with `render_block()` to return only the part of the page that HTMX needs: - -```python -from fastapi.responses import HTMLResponse - -@app.get("/items") -async def items_list(request: Request): - items = await get_items() - template = templates.get_template("items.html") - - # If HTMX request, render just the items block - if request.headers.get("HX-Request"): - html = template.render_block("items_list", items=items) - else: - html = template.render(items=items, request=request) - - return HTMLResponse(html) -``` - -```kida -{# templates/items.html #} -{% extends "base.html" %} - -{% block content %} -

Items

- {% block items_list %} -
    - {% for item in items %} -
  • {{ item.name }}
  • - {% end %} -
- {% end %} -{% end %} -``` - -For async streaming of a single block: - -```python -from starlette.responses import StreamingResponse - -@app.get("/items") -async def items_list(request: Request): - template = templates.get_template("items.html") - - async def generate(): - async for chunk in template.render_block_stream_async( - "items_list", items=await get_items() - ): - yield chunk - - return StreamingResponse(generate(), media_type="text/html") -``` - -## Async Templates - -Kida supports native async constructs in templates. Use `render_stream_async()` for templates that contain `{% async for %}` or `{{ await }}` expressions: - -```python -@app.get("/dashboard") -async def dashboard(request: Request): - template = templates.get_template("dashboard.html") - - # For templates with async constructs, use render_stream_async - chunks = [] - async for chunk in template.render_stream_async(request=request): - chunks.append(chunk) - - return HTMLResponse("".join(chunks)) + return StreamingResponse(chunks(), media_type="text/html") ``` -For templates without async constructs, `render_async()` runs the synchronous render in a thread pool so it won't block the event loop: - -```python -@app.get("/about") -async def about(request: Request): - template = templates.get_template("about.html") - html = await template.render_async(title="About", request=request) - return HTMLResponse(html) -``` +Templates can use `{% flush %}` to choose stream boundaries. For a single +async block, use `render_block_stream_async()`. -## Complete Example +## HTMX request metadata -```python -from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse -from kida import Environment, FileSystemLoader -from kida.contrib.starlette import KidaTemplates - -# Configure environment -env = Environment( - loader=FileSystemLoader("templates"), - autoescape=True, -) - -@env.filter() -def format_datetime(value, fmt="%Y-%m-%d"): - return value.strftime(fmt) - -# Context processor -def common_context(request): - return {"site_name": "My App"} - -# Set up templates -app = FastAPI() -templates = KidaTemplates(env=env, context_processors=[common_context]) - -@app.get("/", response_class=HTMLResponse) -async def homepage(request: Request): - return templates.TemplateResponse( - request, "home.html", {"title": "Home"} - ) - -@app.get("/items", response_class=HTMLResponse) -async def items_list(request: Request): - items = await get_items() - template = templates.get_template("items.html") - - if request.headers.get("HX-Request"): - html = template.render_block("items_list", items=items) - return HTMLResponse(html) - - return templates.TemplateResponse( - request, "items.html", {"items": items} - ) -``` - -```kida -{# templates/home.html #} -{% extends "base.html" %} - -{% block title %}{{ title }}{% end %} - -{% block content %} -

{{ title }}

-

Welcome to {{ site_name }}!

-{% end %} -``` +`TemplateResponse()` records `HX-Request`, `HX-Target`, `HX-Trigger`, and +`HX-Boosted` in Kida's render context as `hx_request`, `hx_target`, +`hx_trigger`, and `hx_boosted`. Framework code can combine that metadata with +`render_block()` while keeping render state request-local. -## See Also +## Next steps -- [[docs/tutorials/flask-integration|Flask Integration]] -- Flask setup guide -- [[docs/tutorials/django-integration|Django Integration]] -- Django setup guide -- [[docs/advanced/csp|Content Security Policy]] -- CSP nonce injection -- [[docs/about/performance|Performance]] -- Production optimization +- [[docs/tutorials/flask-integration|Flask Integration]] +- [[docs/tutorials/django-integration|Django Integration]] +- [[docs/usage/framework-integration|Framework Integration APIs]] +- [[docs/usage/escaping|Escaping]] diff --git a/tests/contrib/test_framework_adapters.py b/tests/contrib/test_framework_adapters.py index fbd7b4f..fb1fccd 100644 --- a/tests/contrib/test_framework_adapters.py +++ b/tests/contrib/test_framework_adapters.py @@ -1,7 +1,7 @@ """Contract tests for optional framework adapters. -The real frameworks are intentionally not test dependencies. Small doubles -prove Kida's adapter behavior and minimal-install import boundary. +Current frameworks are dev-only test dependencies. Focused doubles still prove +Kida's minimal-install import boundary and render-context handoff in isolation. """ from __future__ import annotations @@ -71,19 +71,47 @@ def test_flask_render_template_uses_current_app( assert render_template("hello.html", name="Flask") == "Hello Flask" +def test_flask_adapter_with_current_flask(tmp_path: Path) -> None: + from flask import Flask + + template_dir = tmp_path / "templates" + template_dir.mkdir() + (template_dir / "hello.html").write_text("Hello {{ name }}", encoding="utf-8") + app = Flask(__name__, root_path=str(tmp_path), template_folder="templates") + init_kida(app) + + @app.get("/") + def home() -> str: + return app.kida_render("hello.html", name="Flask 3") + + response = app.test_client().get("/") + + assert response.status_code == 200 + assert response.get_data(as_text=True) == "Hello Flask 3" + + def test_django_backend_loads_and_wraps_templates(tmp_path: Path) -> None: + from django.template.utils import EngineHandler + (tmp_path / "hello.html").write_text("Hello {{ name }}", encoding="utf-8") - backend = DjangoKidaTemplates( - { - "DIRS": [tmp_path], - "OPTIONS": {"autoescape": True, "extensions": []}, - } + engines = EngineHandler( + templates=[ + { + "NAME": "kida", + "BACKEND": "kida.contrib.django.KidaTemplates", + "APP_DIRS": False, + "DIRS": [tmp_path], + "OPTIONS": {"autoescape": True, "extensions": []}, + } + ] ) + backend = engines["kida"] + assert isinstance(backend, DjangoKidaTemplates) template = backend.get_template("hello.html") request = object() - assert template.render({"name": "Django"}, request) == "Hello Django" + assert template.render({"name": "Django 6"}, request) == "Hello Django 6" assert template.origin.name == "hello.html" assert template.origin.template_name == "hello.html" with pytest.warns(UserWarning, match=r"from_string\(\) without name="): @@ -91,6 +119,19 @@ def test_django_backend_loads_and_wraps_templates(tmp_path: Path) -> None: assert inline.render({"name": "string"}) == "Hi string" +def test_django_backend_ignores_standard_engine_keys(tmp_path: Path) -> None: + backend = DjangoKidaTemplates( + { + "NAME": "kida", + "APP_DIRS": False, + "DIRS": [tmp_path], + "OPTIONS": {"autoescape": True, "extensions": []}, + } + ) + + assert backend.env is not None + + class _FakeTemplate: def __init__(self) -> None: self.context: dict[str, object] = {} @@ -115,43 +156,34 @@ def get_template(self, name: str) -> _FakeTemplate: return self.template -class _FakeHTMLResponse: - def __init__( - self, - content: str, - status_code: int, - headers: dict[str, str] | None, - media_type: str | None, - ) -> None: - self.content = content - self.status_code = status_code - self.headers = headers - self.media_type = media_type - - -def test_starlette_template_response_contract(monkeypatch: pytest.MonkeyPatch) -> None: - starlette_module = ModuleType("starlette") - starlette_module.__path__ = [] - responses_module = ModuleType("starlette.responses") - responses_module.HTMLResponse = _FakeHTMLResponse - monkeypatch.setitem(sys.modules, "starlette", starlette_module) - monkeypatch.setitem(sys.modules, "starlette.responses", responses_module) +def test_starlette_template_response_contract() -> None: + from starlette.requests import Request + from starlette.responses import HTMLResponse template = _FakeTemplate() env = _FakeEnvironment(template) templates = StarletteKidaTemplates( env=cast("Environment", env), - context_processors=[lambda request: {"processor": request.user}], + context_processors=[lambda request: {"processor": request.state.user}], ) - request = SimpleNamespace( - user="Ada", - headers={ - "HX-Request": "true", - "HX-Target": "results", - "HX-Trigger": "search", - "HX-Boosted": "true", - }, + request = Request( + { + "type": "http", + "method": "GET", + "path": "/", + "query_string": b"", + "headers": [ + (b"hx-request", b"true"), + (b"hx-target", b"results"), + (b"hx-trigger", b"search"), + (b"hx-boosted", b"true"), + ], + "server": ("testserver", 80), + "client": ("testclient", 50000), + "scheme": "http", + } ) + request.state.user = "Ada" response = templates.TemplateResponse( request, @@ -162,11 +194,11 @@ def test_starlette_template_response_contract(monkeypatch: pytest.MonkeyPatch) - media_type="text/custom", ) - assert isinstance(response, _FakeHTMLResponse) - assert response.content == "Hello Starlette" + assert isinstance(response, HTMLResponse) + assert response.body == b"Hello Starlette" assert response.status_code == 201 - assert response.headers == {"X-Test": "yes"} - assert response.media_type == "text/custom" + assert response.headers["X-Test"] == "yes" + assert response.headers["content-type"] == "text/custom; charset=utf-8" assert template.context == { "request": request, "name": "Starlette", diff --git a/tests/test_docs_install_snippets.py b/tests/test_docs_install_snippets.py index a23db25..7a39306 100644 --- a/tests/test_docs_install_snippets.py +++ b/tests/test_docs_install_snippets.py @@ -9,6 +9,11 @@ DOCS_DIR = ROOT_DIR / "site" / "content" / "docs" STALE_INSTALL_PATTERN = re.compile(r"pip install (?:[a-z0-9_-]+ )*kida(?:\[perf\])?(?:\s|$)") +FRAMEWORK_GUIDES = { + "tutorials/flask-integration.md": "flask", + "tutorials/django-integration.md": "django", + "tutorials/starlette-integration.md": "fastapi", +} def test_published_docs_use_distribution_package_name() -> None: @@ -20,3 +25,17 @@ def test_published_docs_use_distribution_package_name() -> None: offenders.append(str(doc_path.relative_to(ROOT_DIR))) assert offenders == [] + + +def test_framework_guides_keep_horizontal_quickstart_contract() -> None: + """Framework entry points retain the 3.14, migration, and fragment path.""" + for relative_path, package in FRAMEWORK_GUIDES.items(): + text = (DOCS_DIR / relative_path).read_text(encoding="utf-8") + + assert "Python 3.14" in text[:800] + assert f"uv add {package}" in text + assert "coming-from-jinja2" in text[:1_200] + assert "block-scoped" in text[:1_200] + assert "{% def " in text + assert '
0, f"Example {example!r} produced no output" +@pytest.mark.parametrize("example", FRAMEWORK_EXAMPLES) +def test_framework_example_smoke(example: str) -> None: + """Run optional-framework examples in isolated processes.""" + example_dir = EXAMPLES_DIR / example + result = subprocess.run( + [sys.executable, "app.py", "--smoke"], + cwd=example_dir, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert f"{example} OK" in result.stdout + + def test_runnable_examples_are_listed_in_readme(): """Every top-level runnable example should be discoverable from examples/README.md.""" readme = (EXAMPLES_DIR / "README.md").read_text(encoding="utf-8") diff --git a/uv.lock b/uv.lock index 2a1bb7c..84ab2b8 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -14,6 +32,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + [[package]] name = "bengal" version = "0.3.2" @@ -54,6 +81,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/4e/b28ad5fcf2e6ef8152738e8982aadeeb24f81035521774a87af69d495c06/bengal_pounce-0.6.0-py3-none-any.whl", hash = "sha256:48a192de4b926aefd1f053aca25271c9b8e6e637e1e617e92b449aca8f72b5e9", size = 210857, upload-time = "2026-04-14T00:27:57.346Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -141,6 +177,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "django" +version = "6.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" }, +] + [[package]] name = "execnet" version = "2.1.2" @@ -150,6 +200,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] +[[package]] +name = "fastapi" +version = "0.139.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, +] + [[package]] name = "filelock" version = "3.25.2" @@ -159,6 +225,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -235,6 +318,15 @@ 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -268,6 +360,10 @@ perf = [ [package.dev-dependencies] dev = [ + { name = "django" }, + { name = "fastapi" }, + { name = "flask" }, + { name = "httpx" }, { name = "hypothesis" }, { name = "jinja2" }, { name = "milo-cli" }, @@ -279,8 +375,10 @@ dev = [ { name = "pytest-timeout" }, { name = "pytest-xdist" }, { name = "ruff" }, + { name = "starlette" }, { name = "towncrier" }, { name = "ty" }, + { name = "uvicorn" }, ] docs = [ { name = "bengal" }, @@ -295,6 +393,10 @@ provides-extras = ["docs", "perf"] [package.metadata.requires-dev] dev = [ + { name = "django", specifier = ">=6.0.6,<6.1" }, + { name = "fastapi", specifier = ">=0.139,<0.140" }, + { name = "flask", specifier = ">=3.1.3,<3.2" }, + { name = "httpx", specifier = ">=0.28.1,<0.29" }, { name = "hypothesis", specifier = ">=6.100.0" }, { name = "jinja2", specifier = ">=3.1.6,<3.2" }, { name = "milo-cli", specifier = ">=0.2.1" }, @@ -306,8 +408,10 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.3.0" }, { name = "pytest-xdist", specifier = ">=3.3.0" }, { name = "ruff", specifier = ">=0.15.1" }, + { name = "starlette", specifier = ">=1.3.1,<1.4" }, { name = "towncrier", specifier = ">=24.0" }, { name = "ty", specifier = ">=0.0.11" }, + { name = "uvicorn", specifier = ">=0.50.2,<0.51" }, ] docs = [{ name = "bengal", specifier = ">=0.3.2" }] @@ -493,6 +597,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -676,6 +836,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "tomli-w" version = "1.2.0" @@ -722,6 +903,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.50.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/f6/cc9aadc0e481344a42095d222bfa764122fb8cfba708d1922917bd8bfb01/uvicorn-0.50.2.tar.gz", hash = "sha256:b92bf03509b82bcb9d49e7335b4fd364518ad021c2dc18b4e6a2fec8c955a0bb", size = 93716, upload-time = "2026-07-06T10:38:31.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/f0/7c228ee10c7ab8fd3a21d06579a6f7c6075c6ce72594a20fb5d2f206ff24/uvicorn-0.50.2-py3-none-any.whl", hash = "sha256:4ae72a385630bcc17a0adb8290f26c993865e0b43a2114c2aab96420172c056a", size = 72846, upload-time = "2026-07-06T10:38:30.543Z" }, +] + [[package]] name = "uvloop" version = "0.22.1" @@ -799,3 +1023,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a3 wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] From 4e8fac2bcb0ccf5038a505d0d6964c53002bc6b5 Mon Sep 17 00:00:00 2001 From: Lawrence Lane Date: Tue, 7 Jul 2026 09:47:37 -0400 Subject: [PATCH 3/6] test: enforce optional adapter imports --- changelog.d/156.added.md | 2 ++ tests/contrib/test_framework_adapters.py | 36 +++++++++++++++++++----- 2 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 changelog.d/156.added.md diff --git a/changelog.d/156.added.md b/changelog.d/156.added.md new file mode 100644 index 0000000..db92f32 --- /dev/null +++ b/changelog.d/156.added.md @@ -0,0 +1,2 @@ +Added current-version Flask, Django, and FastAPI integration guides and +smoke-tested component examples covering full-page and fragment rendering. diff --git a/tests/contrib/test_framework_adapters.py b/tests/contrib/test_framework_adapters.py index fb1fccd..1c7a708 100644 --- a/tests/contrib/test_framework_adapters.py +++ b/tests/contrib/test_framework_adapters.py @@ -6,7 +6,7 @@ from __future__ import annotations -import importlib +import subprocess import sys from pathlib import Path from types import ModuleType, SimpleNamespace @@ -24,12 +24,34 @@ def test_contrib_modules_import_without_optional_frameworks() -> None: - for module_name in ( - "kida.contrib.flask", - "kida.contrib.django", - "kida.contrib.starlette", - ): - assert importlib.import_module(module_name) is not None + script = """ +import importlib +import importlib.abc +import sys + +class BlockFrameworks(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path, target=None): + if fullname.partition('.')[0] in {'django', 'flask', 'starlette'}: + raise ModuleNotFoundError(fullname) + return None + +sys.meta_path.insert(0, BlockFrameworks()) +for name in ( + 'kida.contrib.flask', + 'kida.contrib.django', + 'kida.contrib.starlette', +): + importlib.import_module(name) +""" + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stderr def test_flask_init_registers_environment_and_app_render_helper(tmp_path: Path) -> None: From 8c1d48a5643c4510a1ceaa4c2eeff63879cc65fe Mon Sep 17 00:00:00 2001 From: Lawrence Lane Date: Tue, 7 Jul 2026 09:49:04 -0400 Subject: [PATCH 4/6] test: use current FastAPI ASGI transport --- examples/fastapi_async/test_fastapi_async.py | 46 ++++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/examples/fastapi_async/test_fastapi_async.py b/examples/fastapi_async/test_fastapi_async.py index 455d9ba..ba8d1f7 100644 --- a/examples/fastapi_async/test_fastapi_async.py +++ b/examples/fastapi_async/test_fastapi_async.py @@ -3,46 +3,56 @@ Skips gracefully if fastapi or httpx are not installed. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + import pytest -fastapi = pytest.importorskip("fastapi") -httpx = pytest.importorskip("httpx") +if TYPE_CHECKING: + from collections.abc import AsyncIterator -from starlette.testclient import TestClient # noqa: E402 +pytest.importorskip("fastapi") +httpx = pytest.importorskip("httpx") class TestFastApiAsyncApp: """Verify FastAPI streaming integration with kida.""" @pytest.fixture - def client(self, example_app) -> TestClient: - """Create a test client from the example FastAPI app.""" - return TestClient(example_app.app) - - def test_streaming_endpoint_returns_html(self, client: TestClient) -> None: - response = client.get("/") + async def client(self, example_app) -> AsyncIterator[httpx.AsyncClient]: + """Create an HTTPX client over the example's ASGI transport.""" + transport = httpx.ASGITransport(app=example_app.app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + yield client + + async def test_streaming_endpoint_returns_html(self, client: httpx.AsyncClient) -> None: + response = await client.get("/") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] - def test_streaming_endpoint_has_content(self, client: TestClient) -> None: - response = client.get("/") + async def test_streaming_endpoint_has_content(self, client: httpx.AsyncClient) -> None: + response = await client.get("/") assert "Dashboard" in response.text assert "Revenue" in response.text assert "$1.2M" in response.text - def test_streaming_endpoint_has_all_items(self, client: TestClient) -> None: - response = client.get("/") + async def test_streaming_endpoint_has_all_items(self, client: httpx.AsyncClient) -> None: + response = await client.get("/") assert "Users" in response.text assert "Orders" in response.text - def test_full_endpoint_returns_html(self, client: TestClient) -> None: - response = client.get("/full") + async def test_full_endpoint_returns_html(self, client: httpx.AsyncClient) -> None: + response = await client.get("/full") assert response.status_code == 200 assert "Dashboard" in response.text - def test_both_endpoints_produce_same_content(self, client: TestClient) -> None: - streaming = client.get("/") - full = client.get("/full") + async def test_both_endpoints_produce_same_content(self, client: httpx.AsyncClient) -> None: + streaming = await client.get("/") + full = await client.get("/full") # Both should contain the same data items for item in ["Revenue", "Users", "Orders"]: assert item in streaming.text From d816d118c6056be3d9fbd43ae375bfe8ab7e5a91 Mon Sep 17 00:00:00 2001 From: Lawrence Lane Date: Tue, 7 Jul 2026 09:53:20 -0400 Subject: [PATCH 5/6] docs: refresh FastAPI async example setup --- examples/README.md | 6 +++--- examples/fastapi_async/README.md | 7 ++++--- examples/fastapi_async/app.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/README.md b/examples/README.md index c5a4631..152ce59 100644 --- a/examples/README.md +++ b/examples/README.md @@ -157,11 +157,11 @@ uv run python examples/fastapi_components/app.py --smoke ### `fastapi_async/` -- FastAPI Integration `render_stream_async()` with FastAPI's `StreamingResponse` for true streaming HTML -delivery. Templates with `{% async for %}` consume async data sources while the -response streams to the client. +delivery on Python 3.14+. Templates with `{% async for %}` consume async data +sources while the response streams to the client. ```bash -pip install fastapi uvicorn +uv add fastapi uvicorn kida-templates cd examples/fastapi_async && uvicorn app:app --reload ``` diff --git a/examples/fastapi_async/README.md b/examples/fastapi_async/README.md index 8bdfbf8..fc89438 100644 --- a/examples/fastapi_async/README.md +++ b/examples/fastapi_async/README.md @@ -1,18 +1,19 @@ # FastAPI Integration -`render_stream_async()` with FastAPI's `StreamingResponse` for true streaming HTML delivery. +`render_stream_async()` with FastAPI's `StreamingResponse` for true streaming +HTML delivery on Python 3.14+. ## Run ```bash -pip install fastapi uvicorn +uv add fastapi uvicorn kida-templates cd examples/fastapi_async && uvicorn app:app --reload ``` ## Test ```bash -pytest examples/fastapi_async/ -v +uv run pytest examples/fastapi_async/ -v ``` ## What It Shows diff --git a/examples/fastapi_async/app.py b/examples/fastapi_async/app.py index 72c4de6..ead95fa 100644 --- a/examples/fastapi_async/app.py +++ b/examples/fastapi_async/app.py @@ -83,7 +83,7 @@ async def full() -> StreamingResponse: def main() -> None: if fastapi is None: - print("FastAPI not installed. Install with: pip install fastapi uvicorn") + print("FastAPI not installed. Install with: uv add fastapi uvicorn kida-templates") return print("Run with: uvicorn app:app --reload") print("Endpoints:") From d37b92111b90d83ced65aaf68626e7fb04ec85e7 Mon Sep 17 00:00:00 2001 From: Lawrence Lane Date: Tue, 7 Jul 2026 10:48:45 -0400 Subject: [PATCH 6/6] docs: make adapter snippets self-contained --- src/kida/contrib/flask.py | 4 ++-- src/kida/contrib/starlette.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/kida/contrib/flask.py b/src/kida/contrib/flask.py index b040181..3b0f371 100644 --- a/src/kida/contrib/flask.py +++ b/src/kida/contrib/flask.py @@ -6,7 +6,7 @@ Usage:: from flask import Flask - from kida.contrib.flask import init_kida + from kida.contrib.flask import init_kida, render_template app = Flask(__name__) kida_env = init_kida(app) @@ -31,7 +31,7 @@ def init_kida( template_folder: str | None = None, **env_kwargs: Any, ) -> Environment: - """Initialize Kida as the template engine for a Flask app. + """Initialize Kida rendering for a Flask app without replacing Jinja. Registers a Kida environment and render helper on the app. Templates are loaded from the app's template folder; Flask's Jinja environment is not diff --git a/src/kida/contrib/starlette.py b/src/kida/contrib/starlette.py index f9681ce..3261afc 100644 --- a/src/kida/contrib/starlette.py +++ b/src/kida/contrib/starlette.py @@ -5,7 +5,7 @@ Usage:: - from fastapi import FastAPI + from fastapi import FastAPI, Request from kida.contrib.starlette import KidaTemplates app = FastAPI()