Hotwire (Turbo) integration for FastAPI — turbo-stream responses, frame detection, block rendering, flash, and test helpers.
Project description
fastapi-hotwire
fastapi-hotwire brings Hotwire's Turbo protocol to FastAPI. Render targeted DOM updates with <turbo-stream> responses, return individual Jinja blocks for <turbo-frame> requests, queue session-backed flash messages, and validate forms in place — all from your existing FastAPI handlers, with no JSON layer or client-side framework. Ships with pytest helpers and a Protocol-based design so you can swap template engines or session backends.
Install
pip install fastapi-hotwire
# or
uv add fastapi-hotwire
For the Pydantic-backed validation-error stream:
pip install "fastapi-hotwire[forms]"
Quickstart
from fastapi import FastAPI, Form, Request
from fastapi_hotwire import HotwireTemplates, TurboStreamResponse, streams
app = FastAPI()
templates = HotwireTemplates(directory="templates", flashes=False)
todos: list[dict] = []
@app.get("/")
def index(request: Request):
return templates.TemplateResponse(request, "index.html", {"todos": todos})
@app.post("/todos")
def create(request: Request, text: str = Form(...)):
todo = {"id": len(todos) + 1, "text": text}
todos.append(todo)
return templates.render_stream(
request, "index.html", "todo_row",
action="append", target="todos", todo=todo,
)
@app.post("/todos/{todo_id}/delete")
def delete(todo_id: int):
todos[:] = [t for t in todos if t["id"] != todo_id]
return TurboStreamResponse(streams.remove(target=f"todo-{todo_id}"))
A complete runnable version of this example lives in examples/minimal/.
What's in the box
| Module | What it does |
|---|---|
TurboStreamResponse |
A Response subclass with Content-Type: text/vnd.turbo-stream.html. |
streams |
Pure-function builders for <turbo-stream> actions (append, prepend, replace, update, remove, before, after, refresh). |
TurboContext |
A FastAPI dependency that summarizes how the current request relates to Turbo (frame? stream? top-level visit?). |
HotwireTemplates |
A Jinja2Templates wrapper that adds render_block(...) and render_stream(...), plus an automatic flashes context processor. |
flash / get_flashed |
Session-backed flash messages with both a redirect-style and a Hotwire-native turbo-stream flow. |
forms |
A Pydantic ValidationError → turbo-stream renderer for in-place form validation. |
testing |
pytest assertions and request helpers (assert_turbo_stream, parse_streams, assert_turbo_frame, turbo_frame_request, turbo_stream_request). |
TurboStreamResponse
from fastapi_hotwire import TurboStreamResponse, streams
@app.post("/items")
def create():
return TurboStreamResponse([
streams.append(item_html, target="items"),
streams.update(counter_html, target="item-count"),
])
Pass a single string, a list of strings, or None. The class also works as response_class=TurboStreamResponse so OpenAPI documents the media type.
streams
Each builder returns a markupsafe.Markup so it composes safely with Jinja templates:
from fastapi_hotwire import streams
streams.append("<li>...</li>", target="todos")
streams.replace(form_html, target="contact-form")
streams.remove(target="todo-42")
streams.refresh() # Turbo 8 page-refresh
The html argument is interpolated verbatim into the <template> envelope. It must be safe HTML (Jinja autoescaped output is safe). Attribute values (target=, targets=) are HTML-escaped automatically.
TurboContext
from typing import Annotated
from fastapi import Depends
from fastapi_hotwire import TurboContext, turbo_context
@app.post("/items")
async def create(turbo: Annotated[TurboContext, Depends(turbo_context)]):
if turbo.is_frame:
return frame_response(...)
if turbo.accepts_stream:
return stream_response(...)
return full_page_response(...)
Fields: is_frame, frame_id, accepts_stream, is_visit.
HotwireTemplates
from fastapi_hotwire import HotwireTemplates
templates = HotwireTemplates(directory="templates")
@app.get("/items/{id}")
def item_frame(request: Request, id: int):
# Render only the {% block item %} of items.html — useful for
# responding to a <turbo-frame src="..."> request.
return templates.render_block(request, "items.html", "item", item=load(id))
@app.post("/items")
def create(request: Request, text: str = Form(...)):
item = save(text)
# Render the {% block item %} as a turbo-stream that appends to #items.
return templates.render_stream(
request, "items.html", "item",
action="append", target="items", item=item,
)
The flashes context processor is registered automatically; pass flashes=False to opt out.
flash
from fastapi_hotwire import flash, get_flashed
# 1. Classic post-redirect-get flow:
@app.post("/save")
def save(request: Request):
flash(request, "Saved", category="success")
return RedirectResponse("/", status_code=303)
# 2. Hotwire-native: respond with a stream that appends to #flash without redirecting:
@app.post("/save")
def save(request: Request):
return flash.stream(request, "Saved", category="success")
Templates rendered through HotwireTemplates automatically receive the queued flashes list.
A complete runnable example lives in examples/flash/.
forms
from fastapi_hotwire.forms import validation_error_stream
# Render Pydantic validation errors as a turbo-stream that replaces
# only the form's block — no full-page reload, no scroll loss.
try:
Contact.model_validate(form_data)
except ValidationError as exc:
return validation_error_stream(
exc, templates=templates, template="contact.html",
block="form", target="contact-form", request=request,
)
testing
from fastapi_hotwire.testing import (
assert_turbo_stream, parse_streams, assert_turbo_frame,
turbo_frame_request, turbo_stream_request,
)
def test_create_appends_a_row(client):
resp = client.post("/todos", data={"text": "ship"})
assert_turbo_stream(resp)
actions = parse_streams(resp)
assert actions[0].action == "append"
assert actions[0].target == "todos"
Pluggability
fastapi_hotwire.protocols defines the integration seams:
TemplateRenderer— anything implementingrender(name, context) -> str.BlockRenderer— anything implementingrender_block(name, block, context) -> str. The defaultJinja2BlockRendereris one implementation.SessionLike— anyMutableMapping[str, Any]that hangs offrequest.session(StarletteSessionMiddleware,starsessions, an in-memorydict).
You don't need to use the bundled Jinja2 / Starlette code paths to use this library.
Examples
Two full runnable examples live under examples/:
examples/minimal/— A todo list with turbo-stream append + remove. The simplest possible integration.examples/flash/— Session-backed flash messages, with both a PRG and a Hotwire-native flow.
Run either with uv run uvicorn app:app --reload from inside the example directory.
Non-goals
fastapi-hotwire deliberately does not:
- Push to clients via WebSocket / SSE — Hotwire's broadcast /
turbo_stream_frompatterns belong in app code with your message bus of choice. - Bundle a Stimulus JavaScript distribution — load Stimulus the way you load any other JS.
- Inject logging / observability / tracing — those are application concerns, not library concerns.
- Replace
request.url_for(...)with anything more magical.
This list will not grow.
Contributing
See CONTRIBUTING.md. Issues and PRs welcome — please file an issue first for anything bigger than a typo so we can align on scope.
This project follows the Contributor Covenant Code of Conduct.
License
MIT © 2026 Pyre
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastapi_hotwire-0.3.0.tar.gz.
File metadata
- Download URL: fastapi_hotwire-0.3.0.tar.gz
- Upload date:
- Size: 62.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
90f0b5dd2c5f7598f9975e800f59a581a07c2705b1f81f1126afdb3d972d901a
|
|
| MD5 |
8a803ee1c5b7b37ac941a87a98110358
|
|
| BLAKE2b-256 |
88ebf9a5ba4047a04b4c43f300c5ef1cd862bf787d63802c650dc4e8aef82366
|
Provenance
The following attestation bundles were made for fastapi_hotwire-0.3.0.tar.gz:
Publisher:
release.yml on socialpyre/fastapi-hotwire
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fastapi_hotwire-0.3.0.tar.gz -
Subject digest:
90f0b5dd2c5f7598f9975e800f59a581a07c2705b1f81f1126afdb3d972d901a - Sigstore transparency entry: 1545495248
- Sigstore integration time:
-
Permalink:
socialpyre/fastapi-hotwire@477df8b6d901a180d85565913ace4bc71ec5bdc1 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/socialpyre
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@477df8b6d901a180d85565913ace4bc71ec5bdc1 -
Trigger Event:
push
-
Statement type:
File details
Details for the file fastapi_hotwire-0.3.0-py3-none-any.whl.
File metadata
- Download URL: fastapi_hotwire-0.3.0-py3-none-any.whl
- Upload date:
- Size: 18.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
87b65b7dc7654b63c67f3eded03403b5811a4abbf7b2ad73ddc01a084b153e3f
|
|
| MD5 |
5b79a7435ae511df5d53b5acc94799fd
|
|
| BLAKE2b-256 |
cbf9e4b74ecc453751c78c4576320804775ab350477eb594cefd9c4cce0bd13f
|
Provenance
The following attestation bundles were made for fastapi_hotwire-0.3.0-py3-none-any.whl:
Publisher:
release.yml on socialpyre/fastapi-hotwire
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fastapi_hotwire-0.3.0-py3-none-any.whl -
Subject digest:
87b65b7dc7654b63c67f3eded03403b5811a4abbf7b2ad73ddc01a084b153e3f - Sigstore transparency entry: 1545495334
- Sigstore integration time:
-
Permalink:
socialpyre/fastapi-hotwire@477df8b6d901a180d85565913ace4bc71ec5bdc1 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/socialpyre
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@477df8b6d901a180d85565913ace4bc71ec5bdc1 -
Trigger Event:
push
-
Statement type: