Internal engineering reference for Sillo’s Jinja2-based template system.
Source:
core/sillo/templating/(3 files, ~365 lines) +core/sillo/admin/templating.py(52 lines)
1. Overview and Architecture
Section titled “1. Overview and Architecture”The templating subsystem wraps Jinja2 to provide async template rendering with
auto-escaping, context middleware for injecting shared variables, and a
convenience render() function that returns HTMLResponse objects.
Data Flow
Section titled “Data Flow”sequenceDiagram
participant App as Application Startup
participant Engine as TemplateEngine
participant MW as TemplateContextMiddleware
participant Handler as Route Handler
participant Render as render()
participant Jinja as Jinja2 Environment
App->>Engine: setup_environment(config)
Engine->>Jinja: Create Environment(loader, autoescape, ...)
Engine-->>App: Sets module-level `engine` global
Note over MW,Handler: Per Request
MW->>MW: Build context (default + processor + request vars)
MW->>MW: Store in request.state.template_context
MW->>Handler: call_next()
Handler->>Render: await render("page.html", {"title": "Home"}, request=request)
Render->>Render: Merge context + kwargs
Render->>Render: Inject request, url_for, csrf_token
Render->>Render: Merge middleware context
Render->>Jinja: await template.render_async(**context)
Jinja-->>Render: Rendered HTML string
Render-->>Handler: HTMLResponse(content, status_code, headers)
Module Layout
Section titled “Module Layout”graph TD
subgraph "core/sillo/templating/"
A["__init__.py<br/>135 lines"]
B["middleware.py<br/>106 lines"]
C["utils.py<br/>124 lines"]
end
subgraph "core/sillo/admin/"
D["templating.py<br/>52 lines"]
end
A -->|"TemplateConfig, TemplateEngine, render()"| B
B -->|"TemplateContextMiddleware"| A
A -->|"utility imports"| C
D -.->|"Independent Jinja2 env"| D
File Inventory
Section titled “File Inventory”| File | Path | Lines | Purpose |
|---|---|---|---|
__init__.py | core/sillo/templating/__init__.py | 135 | TemplateConfig, TemplateEngine, render() |
middleware.py | core/sillo/templating/middleware.py | 106 | TemplateContextMiddleware, template_context() |
utils.py | core/sillo/templating/utils.py | 124 | Template utility functions |
templating.py | core/sillo/admin/templating.py | 52 | Admin-specific sync rendering |
2. TemplateConfig
Section titled “2. TemplateConfig”File: core/sillo/templating/__init__.py, line 22
class TemplateConfig: def __init__( self, template_dir: str | Path = "templates", cache_size: int = 100, auto_reload: bool = True, encoding: str = "utf-8", enable_async: bool = True, trim_blocks: bool = True, lstrip_blocks: bool = True, custom_filters: dict[str, Callable[[Any], Any]] = {}, custom_globals: dict[str, Any] = {}, ):Parameters
Section titled “Parameters”| Parameter | Default | Purpose |
|---|---|---|
template_dir | "templates" | Directory containing .html template files |
cache_size | 100 | Number of templates to keep compiled in memory |
auto_reload | True | Reload templates from disk when changed (dev mode) |
encoding | "utf-8" | Template file encoding |
enable_async | True | Use template.render_async() instead of render() |
trim_blocks | True | Remove first newline after block tags |
lstrip_blocks | True | Strip leading whitespace before block tags |
custom_filters | {} | Additional Jinja2 filter functions |
custom_globals | {} | Additional Jinja2 global variables |
Custom Filters and Globals
Section titled “Custom Filters and Globals”config = TemplateConfig( custom_filters={ "currency": lambda v: f"${v:,.2f}", "relative_time": format_relative_time, }, custom_globals={ "app_name": "MyApp", "version": "2.0.0", "now": datetime.now, },)These are merged into the Jinja2 Environment during setup_environment().
Serialisation
Section titled “Serialisation”to_dict() (line 48) returns all config attributes as a plain dictionary,
useful for debugging and logging.
Known issue: custom_filters and custom_globals default to mutable empty
dicts ({}). This is a Python anti-pattern. The same dict instance is shared
across all TemplateConfig instances that don’t override these parameters.
3. TemplateEngine
Section titled “3. TemplateEngine”File: core/sillo/templating/__init__.py, line 62
Module-Level Singleton
Section titled “Module-Level Singleton”# core/sillo/templating/__init__.py, line 19engine: TemplateEngine | None = NoneThe module-level engine global is set by TemplateEngine.setup_environment()
and read by the module-level render() function.
setup_environment(config)
Section titled “setup_environment(config)”# core/sillo/templating/__init__.py, line 65def setup_environment(self, config: TemplateConfig = TemplateConfig()):Steps:
- Stores
configasself.config(line 68). - Creates the template directory if it doesn’t exist (line 70):
Path(config.template_dir).mkdir(parents=True, exist_ok=True). - Constructs a
jinja2.Environment(lines 72-81):self.env = Environment(loader=FileSystemLoader(config.template_dir),autoescape=select_autoescape(["html", "xml"]),cache_size=config.cache_size,auto_reload=config.auto_reload,enable_async=config.enable_async,trim_blocks=config.trim_blocks,lstrip_blocks=config.lstrip_blocks,) - Merges custom filters and globals (lines 83-86).
- Sets the module-level
engineglobal toself(line 87).
render(template_name, context, **kwargs)
Section titled “render(template_name, context, **kwargs)”# core/sillo/templating/__init__.py, line 89async def render( self, template_name: str, context: dict[str, Any] | None = None, **kwargs) -> str:- Merges
contextdict and**kwargsinto a single dict (lines 94-95). - Loads the template via
self.env.get_template(template_name)(line 97). - If
self.config.enable_asyncis True, callsawait template.render_async(**context)(line 99). - Otherwise, calls synchronous
template.render(**context)(line 100).
Note: The engine’s render returns a raw string. The module-level render
function wraps it in an HTMLResponse.
4. Module-Level render()
Section titled “4. Module-Level render()”File: core/sillo/templating/__init__.py, line 103
async def render( template_name: str, context: dict[str, Any] | None = None, status_code: int = 200, headers: dict[str, str] | None = None, request: Request | None = None, **kwargs,) -> HTMLResponse:This is the primary entry point for route handlers.
Context Enrichment
Section titled “Context Enrichment”When a request is provided, the function enriches the context with
request-specific variables:
# core/sillo/templating/__init__.py, lines 116-132final_context = {}if context: final_context.update(context)final_context.update(kwargs)
if request: final_context.setdefault("request", request)
# Inject url_for if available if hasattr(request, "base_app") and hasattr(request.base_app, "url_for"): final_context.setdefault("url_for", request.base_app.url_for)
# Inject CSRF token if available if hasattr(request, "state") and hasattr(request.state, "csrf_token"): final_context.setdefault("csrf_token", request.state.csrf_token)
# Merge middleware-injected context if hasattr(request, "state") and hasattr(request.state, "template_context"): mw_ctx = request.state.template_context if mw_ctx: final_context.update(mw_ctx)Precedence: Explicit context/kwargs > middleware context. The update call
for middleware context runs before the final merge, so explicit values win.
Return Value
Section titled “Return Value”return HTMLResponse( content=await engine.render(template_name, final_context), status_code=status_code, headers=headers,)Error Handling
Section titled “Error Handling”Raises NotImplementedError if the module-level engine is None (i.e.,
setup_environment() was never called).
5. TemplateContextMiddleware
Section titled “5. TemplateContextMiddleware”File: core/sillo/templating/middleware.py, line 13
Purpose
Section titled “Purpose”Injects template context variables into every request, making them available to all templates without explicit passing in each handler.
Constructor
Section titled “Constructor”def __init__( self, default_context: dict[str, Any] | None = None, context_processor: Callable[[Request], Awaitable[dict[str, Any]]] | None = None,):default_context: Static variables available in every template.context_processor: Optional async callable that returns dynamic context based on the current request (e.g., current user, permissions).
Request Processing
Section titled “Request Processing”# core/sillo/templating/middleware.py, line 47async def __call__(self, request, response, call_next):flowchart TD
A["Copy default_context"] --> B{"context_processor set?"}
B -->|Yes| C{"Is async?"}
C -->|Yes| D["await context_processor(request)"]
C -->|No| E["context_processor(request)"]
D --> F["Update context with result"]
E --> F
B -->|No| G["Add request-specific vars"]
F --> G
G --> H["Store in request.state.template_context"]
H --> I["await call_next()"]
Request-specific variables injected:
| Variable | Source | Purpose |
|---|---|---|
request | The request object | Access to request data in templates |
url_for | request.base_app.url_for | URL generation helper |
csrf_token | request.state.csrf_token | CSRF protection token |
Factory Function
Section titled “Factory Function”# core/sillo/templating/middleware.py, line 88def template_context( default_context: dict[str, Any] | None = None, context_processor: Callable[[Request], Awaitable[dict[str, Any]]] | None = None,): return TemplateContextMiddleware(default_context, context_processor)Usage:
app.use(template_context( default_context={"site_name": "MyApp", "version": "2.0"}, context_processor=get_user_context, # async def))6. Utility Functions
Section titled “6. Utility Functions”File: core/sillo/templating/utils.py
static_hash(filepath: str) -> str
Section titled “static_hash(filepath: str) -> str”Line 12. Generates an MD5 content hash for cache-busting static files.
def static_hash(filepath: str) -> str: try: with open(filepath, "rb") as f: return hashlib.md5(f.read()).hexdigest()[:8] except (OSError, IOError): return ""Returns the first 8 hex characters of the MD5 digest, or "" if the file
doesn’t exist. Used in templates as:
<link rel="stylesheet" href="/static/css/app.css?v={{ static_hash('static/css/app.css') }}">format_datetime(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str
Section titled “format_datetime(value: datetime, fmt: str = "%Y-%m-%d %H:%M:%S") -> str”Line 33. Thin wrapper around strftime for use inside Jinja2 templates.
<span>{{ format_datetime(post.created_at, "%B %d, %Y") }}</span>truncate(text: str, length: int = 100, suffix: str = "...") -> str
Section titled “truncate(text: str, length: int = 100, suffix: str = "...") -> str”Line 49. Truncates text to a maximum length, breaking at word boundaries.
def truncate(text: str, length: int = 100, suffix: str = "...") -> str: if len(text) <= length: return text truncated = text[:length] # Find last space to avoid cutting words last_space = truncated.rfind(" ") if last_space > 0: truncated = truncated[:last_space] return truncated + suffixmerge_dicts(*dicts: dict[str, Any]) -> dict[str, Any]
Section titled “merge_dicts(*dicts: dict[str, Any]) -> dict[str, Any]”Line 70. Merge two or more dictionaries. Later dicts take precedence. Returns a new dict without mutating inputs.
get_template_globals() -> dict[str, Any]
Section titled “get_template_globals() -> dict[str, Any]”Line 89. Returns default dictionary of template-global callables:
{ "now": datetime.now, "static_hash": static_hash, "format_datetime": format_datetime, "truncate": truncate,}create_template_dir(template_dir: str | Path | None = None) -> Path
Section titled “create_template_dir(template_dir: str | Path | None = None) -> Path”Line 109. Ensures a template directory exists (creates with parents if
needed). Defaults to "templates". Returns the Path object.
7. Admin Templating
Section titled “7. Admin Templating”File: core/sillo/admin/templating.py, line 29
The admin subsystem has its own independent Jinja2 environment. It does
not use the main sillo.templating module.
Architecture
Section titled “Architecture”# core/sillo/admin/templating.py, lines 22-26_TEMPLATE_DIR = Path(__file__).parent / "templates"_env = Noneif HAS_JINJA2: _env = Environment(loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=True)Key Differences from Main Templating
Section titled “Key Differences from Main Templating”| Aspect | Main (sillo.templating) | Admin (sillo.admin.templating) |
|---|---|---|
| Rendering | Async (render_async) | Sync (render) |
| Configuration | TemplateConfig class | Hard-coded defaults |
| Template location | User-configurable templates/ | admin/templates/ |
| Context middleware | Supported | Not supported |
| Custom filters/globals | Supported | Not supported |
render(name: str, **ctx) -> str
Section titled “render(name: str, **ctx) -> str”# core/sillo/admin/templating.py, line 29def render(name: str, **ctx) -> str: if _env is None: raise ImportError("jinja2 is required for admin: pip install jinja2") return _env.get_template(name).render(**ctx)Admin templates are rendered synchronously because the admin views are ASGI
handlers that call render() directly (not through the middleware chain).
8. Integration Patterns
Section titled “8. Integration Patterns”Basic Setup
Section titled “Basic Setup”from sillo.templating import TemplateConfig, TemplateEngine, renderfrom sillo.templating.middleware import template_context
# At startupconfig = TemplateConfig( template_dir="my_templates", custom_filters={"currency": lambda v: f"${v:,.2f}"},)engine = TemplateEngine()engine.setup_environment(config)
# Register middlewareapp.use(template_context( default_context={"app_name": "MyApp"},))
# In route handlerasync def home(request, response): return await render("home.html", {"title": "Welcome"}, request=request)Dynamic Context Processor
Section titled “Dynamic Context Processor”async def get_user_context(request): """Inject current user into every template.""" user = request.scope.get("session", {}).get("user") if user: return { "current_user": user, "is_authenticated": True, } return {"current_user": None, "is_authenticated": False}
app.use(template_context(context_processor=get_user_context))Template Inheritance
Section titled “Template Inheritance”{# base.html #}<!DOCTYPE html><html><head><title>{% block title %}{{ app_name }}{% endblock %}</title></head><body> {% block content %}{% endblock %}</body></html>
{# home.html #}{% extends "base.html" %}{% block title %}Home - {{ app_name }}{% endblock %}{% block content %}<h1>Welcome, {{ current_user.name if current_user else "Guest" }}</h1>{% endblock %}Custom Filters
Section titled “Custom Filters”from markupsafe import Markup
def format_currency(value, symbol="$"): return f"{symbol}{value:,.2f}"
def nl2br(value): return Markup(value.replace("\n", "<br>"))
config = TemplateConfig( custom_filters={ "currency": format_currency, "nl2br": nl2br, },)9. Testing
Section titled “9. Testing”Testing Template Rendering
Section titled “Testing Template Rendering”import pytestfrom sillo.templating import TemplateEngine, TemplateConfig, engine as global_engine
@pytest.fixturedef template_engine(tmp_path): templates_dir = tmp_path / "templates" templates_dir.mkdir() (templates_dir / "test.html").write_text("<h1>{{ title }}</h1>")
config = TemplateConfig(template_dir=str(templates_dir)) eng = TemplateEngine() eng.setup_environment(config) yield eng
async def test_render_template(template_engine): result = await template_engine.render("test.html", {"title": "Hello"}) assert result == "<h1>Hello</h1>"Testing Context Middleware
Section titled “Testing Context Middleware”from sillo.templating.middleware import TemplateContextMiddleware
async def test_context_middleware(): default_ctx = {"app_name": "Test"} mw = TemplateContextMiddleware(default_context=default_ctx)
# Mock request with state request = MagicMock() request.state.template_context = {} request.state.csrf_token = "token123" request.base_app.url_for = lambda name: f"/{name}"
response = MagicMock() call_next = AsyncMock(return_value=response)
await mw(request, response, call_next) assert request.state.template_context["app_name"] == "Test" assert request.state.template_context["csrf_token"] == "token123"Testing Utility Functions
Section titled “Testing Utility Functions”from sillo.templating.utils import truncate, static_hash, format_datetimefrom datetime import datetime
def test_truncate_short_text(): assert truncate("Hello", length=100) == "Hello"
def test_truncate_at_word_boundary(): result = truncate("Hello World Foo Bar", length=12) assert result == "Hello World..."
def test_format_datetime(): dt = datetime(2024, 1, 15, 10, 30, 0) assert format_datetime(dt) == "2024-01-15 10:30:00" assert format_datetime(dt, "%B %d") == "January 15"
def test_static_hash_nonexistent(): assert static_hash("/nonexistent/file.css") == ""Testing with TestClient
Section titled “Testing with TestClient”from sillo.testclient import TestClient
def test_template_renders_in_response(): with TestClient(app) as client: resp = client.get("/") assert resp.status_code == 200 assert "<h1>Welcome</h1>" in resp.text assert "csrf_token" not in resp.text # CSRF in form, not body