Server-rendered HTML with Jinja2 — engine setup, the global-singleton design, autoescaping, context injection, and the caching and reload settings that matter in production.
Templating
Section titled “Templating”sillo provides a powerful templating system built on top of Jinja2, offering features like template inheritance, context management, custom filters, and more.
Prerequisites
Section titled “Prerequisites”Before using templating features, you need to install the required dependencies:
uv add "sillo[templating]"Quick Start
Section titled “Quick Start”from sillo import silloAppfrom sillo.templating import render,TemplateEngine
app = silloApp()engine = TemplateEngine()engine.setup_environment()
@app.get("/")async def home(request, response): return await render("home.html", {"title": "Welcome"}, request=request)Customizing Default Configuration
Section titled “Customizing Default Configuration”There are several ways to customize the templating system:
1. Using setup_environment
Section titled “1. Using setup_environment”The simplest way is to set template options in your app configuration:
from sillo.templating import TemplateConfig
template_config = TemplateConfig( template_dir = "templates")
engine.setup_environment(template_config)2. Runtime Configuration Updates
Section titled “2. Runtime Configuration Updates”You can update configuration at runtime:
from sillo.templating import TemplateEnginefrom pathlib import Path
engine = TemplateEngine()engine.setup_environment()
engine.env.filters["custom"] = my_custom_filter
# Add new globalsengine.env.globals.update({ "api_version": "2.0", "debug": True})
engine.config.template_dir = Path("new_templates")engine.setup_environment(engine.config)Configuration Options
Section titled “Configuration Options”The TemplateConfig class supports the following options:
| Option | Type | Default | Description |
|---|---|---|---|
| template_dir | str/Path | ”templates” | Template directory path |
| cache_size | int | 100 | Maximum templates to cache |
| auto_reload | bool | True | Reload changed templates |
| encoding | str | ”utf-8” | Template file encoding |
| enable_async | bool | True | Enable async rendering |
| trim_blocks | bool | True | Strip first newline after block |
| lstrip_blocks | bool | True | Strip leading spaces and tabs |
| custom_filters | Dict[str, callable] | {} | Custom template filters |
| custom_globals | Dict[str, Any] | {} | Global template variables |
Template Inheritance
Section titled “Template Inheritance”Base template (base.html):
<!DOCTYPE html><html> <head> <title>{% block title %}{% endblock %}</title> </head> <body> <nav>{% block nav %}{% endblock %}</nav> <main>{% block content %}{% endblock %}</main> <footer>{% block footer %}{% endblock %}</footer> </body></html>Child template:
{% extends "base.html" %} {% block title %}Welcome{% endblock %} {% blockcontent %}<h1>{{ title }}</h1>{{ content }} {% endblock %}Context Middleware
Section titled “Context Middleware”Add global and request-specific context to your templates:
from sillo.templating.middleware import template_context
async def user_context(request): return { "user": await get_current_user(request), "messages": await get_flash_messages(request) }
app.use(template_context( default_context={ "version": "1.0.0", "nav_links": [...] }, context_processor=user_context))Utility Functions
Section titled “Utility Functions”The templating system includes several utility functions:
from sillo.templating import TemplateConfigfrom sillo.templating.utils import ( format_datetime, merge_dicts, static_hash, truncate,)
config = TemplateConfig( custom_filters={"truncate": truncate, "format_datetime": format_datetime}, custom_globals={"static_hash": static_hash, "merge_dicts": merge_dicts},){{ long_text|truncate(100) }}{{ date|format_datetime("%Y-%m-%d") }}<link rel="stylesheet" href="/static/style.css?v={{ static_hash('static/style.css') }}">Best Practices
Section titled “Best Practices”-
Template Organization
- Keep templates in a dedicated directory
- Use meaningful names and subdirectories
- Follow a consistent naming convention
-
Context Management
- Use middleware for global context
- Keep context processors focused and lightweight
- Cache expensive context operations
-
Performance
- Enable template caching in production
- Use async rendering for I/O operations
- Minimize template complexity
-
Security
- HTML escaping is enabled by default
- Use
|safefilter carefully - Validate user input before rendering
API Reference
Section titled “API Reference”Render Function
Section titled “Render Function”async def render( template_name: str, context: Dict[str, Any] = None, status_code: int = 200, headers: Dict[str, str] = None, request: Request = None, **kwargs) -> ResponseTemplate Context Middleware
Section titled “Template Context Middleware”def template_context( default_context: Optional[Dict[str, Any]] = None, context_processor: Optional[ Callable[[Request], Awaitable[Dict[str, Any]]] ] = None) -> TemplateContextMiddlewareUtility Functions
Section titled “Utility Functions”def truncate(text: str, length: int = 100, suffix: str = "...") -> strdef format_datetime(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> strdef static_hash(filepath: str) -> strdef merge_dicts(*dicts: Dict[str, Any]) -> Dict[str, Any]Advanced Templating
Section titled “Advanced Templating”This guide covers advanced features and patterns for the sillo templating system.
Custom Filters
Section titled “Custom Filters”Create and register custom template filters:
from sillo.templating import TemplateConfig
def markdown_to_html(text: str) -> str: import markdown return markdown.markdown(text)
def currency(value: float, symbol: str = "$") -> str: return f"{symbol}{value:,.2f}"
config = TemplateConfig( custom_filters={ "markdown": markdown_to_html, "currency": currency })Usage in templates:
{{ post.content|markdown }} {{ product.price|currency("€") }}Macros
Section titled “Macros”Create reusable template components:
{# macros/forms.html #} {% macro input(name, value='', type='text', label='') %}<div class="form-group"> {% if label %} <label for="{{ name }}">{{ label }}</label> {% endif %} <input type="{{ type }}" name="{{ name }}" value="{{ value }}" id="{{ name }}" /></div>{% endmacro %} {# Usage in templates #} {% from "macros/forms.html" import input%}<form method="post"> {{ input('username', label='Username') }} {{ input('password', type='password', label='Password') }}</form>Async Template Functions
Section titled “Async Template Functions”Create async template functions for database queries or API calls:
from sillo.templating import TemplateConfigfrom functools import partial
async def get_user_posts(user_id: int): # Async database query return await db.query("SELECT * FROM posts WHERE user_id = $1", user_id)
config = TemplateConfig( custom_globals={ "get_posts": get_user_posts })Usage in templates:
{% set posts = await get_posts(user.id) %} {% for post in posts %}<article>{{ post.title }}</article>{% endfor %}Context Processors
Section titled “Context Processors”Advanced context processor patterns:
from typing import Dict, Anyfrom sillo.templating.middleware import template_context# Use your preferred caching library for memoization
class ContextBuilder: def __init__(self): self.processors = []
def add(self, processor): self.processors.append(processor) return self
async def build(self, request) -> Dict[str, Any]: context = {} for proc in self.processors: context.update(await proc(request)) return context
# Optional: add caching with your preferred libraryasync def get_site_stats(request): return { "total_users": await db.count("users"), "total_posts": await db.count("posts") }
async def get_user_data(request): if user := await get_current_user(request): return { "user": user, "notifications": await get_user_notifications(user.id) } return {}
# Combine processorscontext_builder = ( ContextBuilder() .add(get_site_stats) .add(get_user_data))
app.use(template_context( context_processor=context_builder.build))Template Caching
Section titled “Template Caching”from datetime import datetime, timedeltafrom sillo.templating import TemplateConfig
_cache = {}
def cache_get(key: str): item = _cache.get(key) if not item: return None value, expires_at = item if datetime.utcnow() > expires_at: _cache.pop(key, None) return None return value
def cache_set(key: str, value, ttl: int = 300): _cache[key] = (value, datetime.utcnow() + timedelta(seconds=ttl))
config = TemplateConfig( custom_globals={ "cache_get": cache_get, "cache_set": cache_set, })Usage in templates:
{% set cache_key = 'sidebar_' + user.id %}{% set cached = cache_get(cache_key) %}{% if cached %} {{ cached }}{% else %} {% set content %} <aside>...</aside> {% endset %} {{ cache_set(cache_key, content, ttl=300) }} {{ content }}{% endif %}⚠️ Error Handling
Section titled “⚠️ Error Handling”Custom error templates and handling:
from sillo.templating import renderfrom jinja2 import TemplateError
@app.add_exception_handler(404)async def not_found(request, response, exc): return await render( "errors/404.html", {"path": request.url.path}, status_code=404 )
@app.add_exception_handler(TemplateError)async def template_error(request, response, exc): return await render( "errors/template.html", { "error": str(exc), "template": exc.template_name, "lineno": exc.lineno }, status_code=500 )Testing Templates
Section titled “Testing Templates”Write tests for your templates:
import pytestfrom sillo.templating import TemplateEngine, TemplateConfigfrom sillo.testclient import TestClient
@pytest.fixturedef template_engine(): config = TemplateConfig(template_dir="tests/templates") engine = TemplateEngine() engine.setup_environment(config) return engine
async def test_template_rendering(template_engine): content = await template_engine.render( "welcome.html", {"name": "User"} ) assert "Welcome, User!" in content
async def test_template_filters(template_engine): content = await template_engine.render( "product.html", {"price": 99.99} ) assert "€99.99" in content
async def test_context_middleware(client: TestClient): response = await client.get("/") assert response.status_code == 200 assert "Welcome, Test User!" in response.textHow the engine is wired
Section titled “How the engine is wired”TemplateEngine is not per-application. setup_environment() assigns
the instance to a module-level global in sillo.templating, and the
render() function reads that global:
engine: Union["TemplateEngine", None] = None
class TemplateEngine: def setup_environment(self, config=TemplateConfig()): global engine ... engine = selfThree things follow from that design.
render() fails until setup runs. Calling it first raises
NotImplementedError: Template Engine Has not been set. Call
setup_environment() at import time or in a startup hook, before any
request can arrive.
There is one engine per process. Two applications in one process
share it, and the second setup_environment() call silently replaces the
first — including its template directory. If you mount two apps that both
render templates, they must share a template root or use separate
processes.
Reconfiguring is global and immediate. engine.setup_environment(new_config)
swaps the environment for every in-flight and future render. That is fine
at startup and a race in production.
Autoescaping
Section titled “Autoescaping”The environment is built with select_autoescape(["html", "xml"]).
Variables rendered into .html and .xml templates are escaped;
variables in any other extension are not.
<p>{{ user_supplied }}</p>Hello {{ user_supplied }}That is correct behaviour for plain-text output and a trap when a
template is named email.j2 or page.tmpl but produces HTML. Name HTML
templates .html, always.
Never reach for |safe on anything a user can influence. It disables
escaping for that expression and is the standard way cross-site scripting
gets into an otherwise careful codebase. When you genuinely need to
render stored HTML — a rich-text field, a Markdown render — sanitize it
with a real parser first. See the
HTML helpers guide for why the built-in
sanitize_html is not sufficient for that job.
Context injection
Section titled “Context injection”render(..., request=request) populates three variables automatically,
each with setdefault so your own value always wins:
| Variable | Source | Present when |
|---|---|---|
request | the request object | request= is passed |
url_for | request.base_app.url_for | the app exposes it |
csrf_token | request.state.csrf_token | CSRF middleware has run |
It then merges request.state.template_context, which is what
TemplateContextMiddleware populates. The merge happens after
setdefault, so middleware context overwrites the per-call context —
worth knowing if a variable is mysteriously not what you passed.
from sillo.templating.middleware import template_context
async def user_context(request): return {"current_user": await get_current_user(request)}
app.use(template_context( default_context={"site_name": "Example", "version": "2.1.0"}, context_processor=user_context,))The processor runs on every request, including ones that render nothing. Keep it cheap: a database query in a context processor is a query added to every static-file request that passes through the middleware stack.
Forgetting request=request is the single most common templating bug.
Without it there is no csrf_token, so form posts start failing CSRF
validation, and no url_for, so link generation raises UndefinedError.
Production settings
Section titled “Production settings”Two config values behave very differently in development and production.
auto_reload=True stats every template file on every render to detect
changes. That is what makes editing a template take effect without a
restart, and it is a filesystem call per render per template — including
every {% include %} and {% extends %}. Turn it off in production.
cache_size=100 caps how many compiled templates are held. An
application with more than a hundred templates will evict and recompile
constantly at the default. Set it above your template count, or to -1
for an unbounded cache, which is safe because the number of templates is
fixed at deploy time.
import os
is_dev = os.getenv("ENV") == "development"
config = TemplateConfig( template_dir="templates", auto_reload=is_dev, cache_size=-1 if not is_dev else 100, enable_async=True,)engine.setup_environment(config)enable_async=True compiles templates for render_async, which is what
lets a template await a coroutine in an expression. Leave it on — sillo’s
render() calls render_async when it is set, and the synchronous
fallback blocks the event loop for the duration of the render.
What not to do
Section titled “What not to do”Do not call render() before setup_environment(). It raises
NotImplementedError.
Do not run two apps with different template roots in one process. The engine is a module-level global.
Do not give an HTML template a non-.html extension. Autoescaping is
selected by suffix.
Do not use |safe on user-influenced values. That is the XSS.
Do not forget request=request. You lose csrf_token and
url_for.
Do not query the database in a context processor without checking which requests it runs for.
Do not leave auto_reload=True in production. It stats files on
every render.
Do not leave cache_size below your template count. You will
recompile on every request.
Performance notes
Section titled “Performance notes”Template compilation is expensive; rendering a compiled template is not. The cache is what stands between you and paying compilation on every request, so sizing it correctly matters more than any other setting here.
trim_blocks and lstrip_blocks are both on by default. They affect
whitespace in the output, not speed — but they change rendered output, so
turning them off later will alter every page.
Inheritance chains are resolved per render. A template that extends a base that includes four partials is five cache lookups, which is cheap when cached and five compilations when not.
For pages that are expensive to build and rarely change, cache the rendered HTML rather than tuning the template engine. See Cache.