This release is a pre-release and may not be stable for production use.
Starlette-Templates
Starlette-Templates serves Jinja templates, Markdown, and static files. Templates in shortcodes/ become Jinja tags and templates in fragments/ become HTMX fragments that re-render themselves when a handler reports a change.
pip install starlette-templates
Quick start
This site holds one page, one shortcode, and one fragment:
app.py
templates/
index.jinja
shortcodes/
note.jinja
fragments/
clock.jinja
app.py mounts the site and registers two functions: one that supplies the data of the fragment, and one that
handles the button:
from datetime import datetime
from importlib.resources import files
from jinja2 import FileSystemLoader
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from starlette.requests import Request
from starlette_templates import HTMXResponse, StaticFiles
PKG_DIR = files(__package__)
async def clock(request: Request) -> dict:
"""the context function of the clock fragment"""
return {"now": datetime.now().strftime("%H:%M:%S")}
async def refresh(request: Request) -> HTMXResponse:
"""the handler of the refresh button"""
return HTMXResponse("Refreshed.", trigger="clock.tick")
app = Starlette(
routes=[
Route("/refresh", refresh, methods=["POST"]),
# The catch-all mount goes last.
Mount(
"/",
StaticFiles(
loader=FileSystemLoader(PKG_DIR / "templates"),
html=True,
fragments=[clock],
),
name="site",
),
]
)
fragments takes context functions: async functions that take the request and return the variables for the Jinja template of the same name. They run each time the fragment renders, so the fragment always shows current data.
templates/fragments/clock.jinja renders the fragment. Its first line lists the triggers that re-render it:
{% set triggers = ["clock.tick"] -%}
<span id="clock">{{ now }}</span>
Triggers are named events. A template lists the triggers that re-render it, and a handler sends one with
HTMXResponse(..., trigger=...). The name is the only coupling between the two: the handler names no fragment and no
DOM id.
One name, three places. The fragment is named clock because the function is named clock. The template must be
fragments/clock.jinja, and the root element it renders must carry id="clock":
async def clock(request) → the name of the fragment
templates/fragments/clock.jinja → the template of that name
<span id="clock"> → the element htmx swaps
The id is what htmx replaces on the page. A mismatched id raises when the fragment renders, rather than swapping into nothing.
templates/shortcodes/note.jinja is a snippet. It uses inner, so the tag takes a body:
<div class="note note-{{ kind }}">{{ inner }}</div>
templates/index.jinja calls both of them as tags:
<!DOCTYPE html>
<html>
<head><script src="https://unpkg.com/htmx.org@2"></script></head>
<body>
<p>The time is {% clock %}.</p>
{% note kind="tip" %}
The button below never names the clock.
{% endnote %}
<button hx-post="/refresh" hx-target="#flash">Refresh</button>
<div id="flash"></div>
</body>
</html>
Run it with uvicorn app:app --reload and open http://localhost:8000/. A click posts to refresh, which answers with
Refreshed. and sends clock.tick. The clock lists that trigger, so it renders and rides back in the same response:
$ curl -X POST localhost:8000/refresh
Refreshed.<span id="clock" hx-swap-oob="true">14:32:07</span>
htmx swaps that span into the element with the same id, so the clock updates although the handler never named it. To add a second fragment, you write another template and another function.
Static files
StaticFiles is a raw ASGI app that serves the directories behind a Jinja2 loader. A .jinja or .j2 file renders as a
template, and every other file is served unchanged with ETag and Last-Modified support.
Only loader is required. The call below passes everything it takes:
from jinja2 import ChoiceLoader, FileSystemLoader, PackageLoader
from starlette.routing import Route
from starlette_templates import StaticFiles
import weather # a module of fragment context functions
static = StaticFiles(
# A ChoiceLoader checks each loader in turn, so a file in your app can
# override a file that a framework or a theme ships.
loader=ChoiceLoader([
FileSystemLoader("site"), # checked first
PackageLoader("mytheme", "site"), # fallback
]),
html=True, # serve the index page for a directory URL, and a 404 page on a miss
check_dir=True, # raise on the first request when a served directory is missing
follow_symlink=False, # keep a symlinked path inside the served directory
max_age=3600, # Cache-Control for plain files; None omits the header
template_cache_control=None, # Cache-Control for rendered templates; the default is none
global_vars={"site_name": "Weather"}, # more template globals
filters={"money": money}, # more Jinja filters
extensions=["jinja2.ext.i18n"], # your own Jinja extensions
fragments=weather, # context functions for the htmx fragments
page_context=[Route("/weather/{code}", station)], # variables for one page
query_runner=SqliteRunner("weather.db"), # runs the named SQL queries
)
With html=True, a directory URL serves the index page from that directory, and a directory URL without a trailing
slash redirects to add one. A request that matches no file falls back to a 404 page when one exists.
Extensionless URLs work as well. A page may be spelled .jinja, .j2, .html, .html.jinja, or .html.j2, and a
request for /about finds any of those spellings.
Template globals
Every rendered template has these, and so does Markdown rendered through include_markdown():
{# request — the Starlette Request. Read auth state from request.user. #}
<p>Current path: {{ request.url.path }}</p>
{# url_for — a full URL from a path relative to the site root #}
<a href="{{ url_for('/search', query_params={'q': 'llamas'}) }}">Search</a>
{# jsonify — JSON safe to embed in HTML. It handles datetime, Decimal, and Pydantic models. #}
<script type="application/json">{{ jsonify(dict(request.query_params)) }}</script>
{# include_markdown — a Markdown file rendered to safe HTML. The file may contain Jinja. #}
<article>{{ include_markdown('intro.md') }}</article>
{# queries, fetch(), fetch_one(), fetch_value() — named SQL queries, below #}
Add your own with the global_vars and filters arguments.
Markdown in a template
A {% markdown %} block writes Markdown inside the HTML, with no separate file:
<article class="prose">
{% markdown %}
# Welcome
Text with **bold** and a [link]({{ url_for('/about') }}).
{% note kind="tip" %}Shortcodes work here too.{% endnote %}
- one
- two
{% endmarkdown %}
</article>
The body is Jinja first and Markdown second, so variables, filters, shortcodes, and fragment tags all work in it. The block strips its own indentation before the conversion, so a block laid out to match the HTML around it stays out of a code block. Autoescape runs on the body, so a value cannot inject markup of its own.
Page context
A page often needs data of its own. Each route in page_context names a URL and an async function, and the function
returns the variables that the page at that URL renders with:
from jinja2 import FileSystemLoader
from starlette.routing import Route
from starlette_templates import StaticFiles
async def site_wide(request):
return {"site_name": "Weather", "year": 2026}
async def one_station(request):
code = request.path_params["code"] # the route's convertors fill path_params
return {"station": await load_station(code)}
static = StaticFiles(
loader=FileSystemLoader("site"),
page_context=[
Route("/{path:path}", site_wide), # every page
Route("/weather/{code}", one_station), # and this one as well
],
)
site/weather/{code}.jinja then writes those variables like any others:
<h1>{{ station.name }} — {{ site_name }}</h1>
Every route that matches contributes, in declaration order, so a later route wins a repeated name. Nothing calls the
function as an endpoint: StaticFiles uses the route for its path alone. Every shortcode the page calls reads these
variables too, and so does every {% markdown %} block and every Markdown file the page includes.
Named SQL queries
A template declares a named query with the {% sql %} tag. Declaring it produces no output. The query runs when the
template calls fetch() on it:
{% sql stations from weather %}
SELECT name, elevation FROM stations WHERE country = :country
{% endsql %}
{% sql totals from weather %}
SELECT count(*) FROM stations WHERE country = :country
{% endsql %}
<p>{{ fetch_value(queries.totals, 0) }} stations.</p>
<ul>
{% for s in fetch(queries.stations) %}
<li>{{ s.name }} at {{ s.elevation }}m</li>
{% endfor %}
</ul>
The three helpers differ in what they return:
{{ fetch(queries.stations) }} {# every row, as a list of dicts #}
{{ fetch_one(queries.stations) }} {# the first row, or the default #}
{{ fetch_value(queries.totals, 0) }} {# the first column of the first row, such as a COUNT #}
Markdown declares a query as a fenced code block instead, and the {% sql %} tag works there too:
```sql totals from weather
SELECT count(*) FROM stations
```
There are {{ fetch_value(queries.totals) }} stations.
The query string of the request supplies the :name placeholders, so ?country=US supplies :country. The runner
binds them out of band and never interpolates them.
Supply a runner that implements the QueryRunner protocol. This one uses aiosqlite
and runs every query against a single SQLite file:
import aiosqlite
from jinja2 import FileSystemLoader
from starlette_templates import StaticFiles
from starlette_templates.staticfiles import Query, Row
class SqliteRunner:
def __init__(self, path: str) -> None:
self.path = path
async def run(self, query: Query, params: dict) -> list[Row]:
async with aiosqlite.connect(self.path) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(query.sql, params) as cursor:
return [dict(row) for row in await cursor.fetchall()]
static = StaticFiles(loader=FileSystemLoader("site"), query_runner=SqliteRunner("weather.db"))
A runner that serves several databases reads query.database to pick the connection, which this one ignores. Without a
runner, fetch() returns an empty list, so a site with no database still serves.
Shortcodes
A shortcodes/ folder under a served directory needs no registration. Every template file in it becomes a Jinja tag
named after the stem of the file, slugified with underscores:
shortcodes/
youtube.html {% youtube %}
onboarding-wizard-modal.html {% onboarding_wizard_modal %}
note.html {% note %} ... {% endnote %}
The template decides the form of the tag. When the template uses the inner variable, the shortcode is paired and
the caller closes it with {% end<name> %}. Otherwise it is void and takes no end tag:
{# shortcodes/youtube.html — no `inner`, so the tag is void #}
<iframe src="https://www.youtube.com/embed/{{ id }}" title="{{ title }}"></iframe>
{# shortcodes/note.html — uses `inner`, so the tag is paired #}
<div class="note note-{{ kind }}">{{ inner }}</div>
A page calls them like this, and so does a Markdown file included with include_markdown():
{% youtube id="dQw4w9WgXcQ" title="Never gonna give you up" %}
{% note kind="warning" %}
Do not feed the llamas after midnight.
{% endnote %}
A shortcode template renders with the keyword arguments from the call site, which win over the calling context. It also
renders with the full context of the calling page. A paired shortcode gets inner as well, the rendered body.
Discovery happens once, when you build the app. A file whose name cannot become a usable tag raises a ValueError
instead of being skipped: a name that normalizes to nothing, a non-identifier such as 3d.html, a reserved Jinja tag
such as for.html, a collision with another tag, or a shadowed end tag such as endnote.html next to note.html.
To use shortcodes outside StaticFiles, build the extension yourself:
from jinja2 import Environment, FileSystemLoader
from starlette_templates.shortcodes import shortcode_extension
env = Environment(
loader=FileSystemLoader("templates"),
extensions=[shortcode_extension("shortcodes")],
)
HTMX fragments
One click often makes several parts of a page wrong at once. When a shopper adds an item to a cart, the badge, the total, and the mini cart all have to change.
A fragment is three things that share one name: an async context function, a template under fragments/ of that name,
and the id of the root element that the template renders. Here that name is cart_badge:
# store.py — one function per fragment, named after the fragment
async def cart_badge(request):
return {"count": await request.app.state.cart.count()}
async def cart_total(request):
return {"total": await request.app.state.cart.total()}
{# templates/fragments/cart_badge.html.jinja — the name is the stem of the file #}
{% set triggers = ["cart.changed"] -%}
<span id="cart_badge" class="badge">{{ count }}</span>
Triggers are named events. cart_badge lists cart.changed, so it re-renders whenever a handler sends that trigger.
Several fragments can list the same trigger, and one fragment can list several.
A template file may be spelled .html, .jinja, .j2, .html.jinja, or .html.j2, and the whole suffix comes off to
leave the name. A template with no context function of its name, and a template that lists no triggers, both raise when
you build the app. An id that does not match the name raises when the fragment renders.
statics = StaticFiles(loader=FileSystemLoader(TEMPLATES), html=True, fragments=store)
A page drops a fragment in wherever it wants one. Each is a Jinja tag of its own name:
<header>
{% cart_badge %}
{% cart_total %}
</header>
The handler changes one thing and sends the trigger that says what happened. It names no fragments and no DOM ids:
async def add_to_cart(request):
await request.app.state.cart.add(request.path_params["sku"])
return HTMXResponse("Added.", trigger="cart.changed")
HTMXResponse renders every fragment that lists cart.changed. They render concurrently, and each rides back in the
same response as an htmx out-of-band swap:
$ curl -X POST localhost:8000/cart/add/boot
Added.
<span id="cart_badge" class="badge" hx-swap-oob="true">1</span>
<span id="cart_total" class="total" hx-swap-oob="true">$129.00</span>
<ul id="mini_cart" class="mini-cart" hx-swap-oob="true">...</ul>
The response finds the fragments through the site mounted in the app, so you install no middleware and the site hands nothing to the handler. A trigger that no fragment lists leaves the response untouched.
A slow fragment fetches itself
A slow fragment delays the response to the click. Add {% set pull = true %} to its template:
{# templates/fragments/recommendations.html.jinja #}
{% set triggers = ["cart.changed"] -%}
{% set pull = true -%}
<div id="recommendations" class="recs">
{% for item in suggestions %}<li>{{ item.name }}</li>{% endfor %}
</div>
Nothing else changes. The page still writes {% recommendations %}, and the handler still names no fragments. The tag
now emits the markup that re-fetches the fragment from the /fragment/<name> URL that the site already answers:
<div hx-get="/fragment/recommendations" hx-trigger="cart.changed from:body" hx-swap="outerHTML">
<div id="recommendations" class="recs">...</div>
</div>
The fragment stays out of the response to the click, and the HX-Trigger header survives so the browser asks for it
separately. You pay an extra round trip and keep the slow fragment off the critical path.
Where the context functions come from
A module is the common source. The fragments argument takes any of these, or a sequence that mixes them:
fragments=store # a module: each function by its own name
fragments={"cart_badge": badge_for_header} # a mapping: name a fragment something else
fragments=cart_badge # one function, by its __name__
fragments=[store, checkout, {"mini_cart": mini}] # any mix of the three
To send a trigger from a response of another class, install HTMXMiddleware. It does the same work for any response
that carries an HX-Trigger header, at the cost of buffering it.
The htmx cascade example is a runnable cart with five fragments and two routes, and no handler in it names a fragment.
HTML in Python
The ht factory builds Element trees in Python. Use it for fragments, email bodies, or HTML you want to return
without a template file:
from starlette_templates import ht
card = ht.div(
ht.h1("Hello World"),
ht.p("Built in Python."),
id="card",
classes=["container", "content"],
style={"color": "red"},
)
html = ht.render_element(card)
Document is an Element subclass that renders a full HTML document. It is also an ASGI app, so a handler can return
one directly:
from starlette.applications import Starlette
from starlette.routing import Route
from starlette_templates import Document, ht
async def homepage(request):
return Document(ht.h1("Welcome"), page_title="Home")
app = Starlette(routes=[Route("/", homepage)])
Release files for starlette-templates 0.0.1a9
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| starlette_templates-0.0.1a9.tar.gz | 92.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| starlette_templates-0.0.1a9-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:156.3 kB
Release files / starlette_templates-0.0.1a9.tar.gz
| Download URL | starlette_templates-0.0.1a9.tar.gz |
|---|---|
| Size | 92.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a71a805a9beb21252f4beafff83abff1b451fe1e7b4709ec4cdf2722a95c22eb
|
|
BLAKE2b-256 checksum How to use checksums |
81c84930e01b2f5f1f331c385a63e68dbaa0e4272f93898a7216ec0d2916da83
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.10
|
Release files / starlette_templates-0.0.1a9-py3-none-any.whl
| Download URL | starlette_templates-0.0.1a9-py3-none-any.whl |
|---|---|
| Size | 64.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bdf36a5a8f26dd63d5c7ee5f413d9a6f1893229c5ada4f2ad47b260ab0186836
|
|
BLAKE2b-256 checksum How to use checksums |
a1d762a911d3606c6a308dd16b66bd2d426962552e203cdaa4e8cf292dd625d8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.10
|