# Starlette-Templates Serve Starlette templates with file-based routing, Pydantic forms, and reusable components # Getting Started # Starlette-Templates [Documentation](https://starlette-templates.tycho.engineering) | [PyPI](https://pypi.org/project/starlette-templates/) This package extends [Starlette](https://starlette.dev/) with support for template-driven routing, form handling, and reusable UI components, built on [Jinja2](https://jinja.palletsprojects.com/en/stable/) and [Pydantic](https://docs.pydantic.dev/latest/). **Why does this exist?** Starlette is a toolkit that offers building blocks for web apps. But common tasks like template routing, form validation, and UI reuse require significant boilerplate. This package streamlines those workflows by directly routing URLs to templates, validating form data with Pydantic, and enabling type-safe, reusable UI components built as Jinja templates. This makes applications easier to build, reason about, and scale. ## Features - Serve HTML templates with file-based routing - Pydantic forms with validation and rendering - Reusable UI components using Jinja2 and Pydantic with type and validation safety - Static files with gzip compression and multi-directory support - JSON:API compliant error responses with custom error pages - ETag and Last-Modified headers with 304 Not Modified support ## Installation ``` pip install starlette-templates ``` ## Quick Start ``` from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.routing import Mount from jinja2 import PackageLoader from starlette_templates.routing import TemplateRouter from starlette_templates.middleware import JinjaMiddleware app = Starlette( routes=[ # File-based routing for the "templates" package directory Mount("/", TemplateRouter(debug=True)), ], middleware=[ # Configure Jinja2 environment for template autodiscovery Middleware( JinjaMiddleware, template_loaders=[PackageLoader("mypackage", "templates")], ) ] ) ``` Create `templates/index.html`: ``` My App

Welcome to {{ request.url.hostname }}

``` Visit `http://localhost:8000/` and your template will be rendered automatically. ## Next Steps - Learn about [Applications](https://starlette-templates.tycho.engineering/applications/index.md) for setup and configuration - Learn about [Template Files](https://starlette-templates.tycho.engineering/templates/index.md) for file-based routing - Explore [Static Files](https://starlette-templates.tycho.engineering/static-files/index.md) for serving assets with gzip compression - Explore [Forms](https://starlette-templates.tycho.engineering/forms/index.md) for type-safe form handling - Discover [Components](https://starlette-templates.tycho.engineering/components/index.md) for reusable UI elements - Learn about [Error Handling](https://starlette-templates.tycho.engineering/errors/index.md) for custom exceptions and error pages - Check the [API Reference](https://starlette-templates.tycho.engineering/api/index.md) for detailed documentation ## LLM context This documentation is available as an LLM-friendly markdown file: - [llms.txt](https://starlette-templates.tycho.engineering/llms.txt) - links to documentation pages - [llms-full.txt](https://starlette-templates.tycho.engineering/llms-full.txt) - concatenated full docs in one file # Core Concepts # Applications Use the JinjaMiddleware middleware and TemplateRouter ASGI app to add template support to Starlette applications. ## Package structure A typical Starlette-Templates application is organized as a Python package: ``` myapp/ ├── __init__.py ├── app.py # Main application file ├── templates/ # Template files │ ├── base.html # Base layout │ ├── index.html # Homepage │ ├── about.html # About page │ ├── 404.html # Custom 404 page │ ├── 500.html # Custom 500 page │ ├── partials/ # Reusable fragments │ │ ├── navigation.html │ │ └── footer.html │ └── components/ # Component templates │ ├── alert.html │ └── card.html └── static/ # Static files ├── css/ ├── js/ └── images/ ``` Your `app.py` would typically look like: ``` from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.routing import Mount, Route from starlette.responses import JSONResponse from jinja2 import PackageLoader from importlib.resources import files from starlette_templates.routing import TemplateRouter from starlette_templates.staticfiles import StaticFiles from starlette_templates.middleware import JinjaMiddleware PKG_DIR = files("myapp") async def api(request): return JSONResponse({"message": "API Root"}) app = Starlette( routes=[ # Custom routes first Mount("/api", api, name="api"), # Static files Mount("/static", StaticFiles(directories=[PKG_DIR / "static"]), name="static"), # Template files for remaining routes Mount("/", TemplateRouter()), ], middleware=[ Middleware( JinjaMiddleware, template_loaders=[PackageLoader("myapp", "templates")], ) ] ) ``` ## Basic setup A minimal Starlette Templates application requires two components: 1. JinjaMiddleware - Middleware that configures the Jinja2 environment, making it available on request state 1. TemplateRouter - An ASGI app that serves HTML templates ``` from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.routing import Mount from jinja2 import PackageLoader from starlette_templates.routing import TemplateRouter from starlette_templates.middleware import JinjaMiddleware app = Starlette( routes=[ Mount("/", TemplateRouter()), ], middleware=[ Middleware( JinjaMiddleware, template_loaders=[PackageLoader("mypackage", "templates")], ) ] ) ``` ## Static files Starlette Templates provides enhanced static file handling with gzip compression and multi-directory support using StaticFiles. ``` from starlette.routing import Mount from starlette_templates.staticfiles import StaticFiles app = Starlette( routes=[ Mount("/static", StaticFiles(directories=["static"]), name="static"), Mount("/", TemplateRouter()), ] ) ``` For detailed information about static file handling, including pre-compressed files, multi-directory support, and HTTP caching, see the [Static Files](https://starlette-templates.tycho.engineering/static-files/index.md) guide. ## Custom routes Override the default template routing by defining custom routes. Routes defined before the TemplateRouter mount take precedence: ``` from starlette.routing import Route from starlette.requests import Request from starlette_templates.responses import TemplateResponse async def homepage(request: Request) -> TemplateResponse: return TemplateResponse("home.html", context={"title": "Welcome"}) app = Starlette( routes=[ # Custom route for homepage Route("/", homepage, name="home"), # Template routing for all other routes Mount("/", TemplateRouter()) ], middleware=[ Middleware( JinjaMiddleware, template_loaders=[PackageLoader("myapp", "templates")] ) ] ) ``` ## Middleware configuration ### JinjaMiddleware JinjaMiddleware configures and injects the Jinja2 environment into request state, making it available at `request.state.jinja_env`. ``` from jinja2 import PackageLoader, FileSystemLoader from starlette_templates.middleware import JinjaMiddleware app = Starlette( middleware=[ Middleware( JinjaMiddleware, template_loaders=[ PackageLoader("myapp", "templates"), FileSystemLoader("custom/templates"), ], include_default_loader=True, # Include built-in templates ) ] ) ``` The middleware provides: - Automatic template loader configuration - Built-in Jinja2 globals: `url_for`, `url`, `absurl`, `jsonify` - Request state injection for all templates ### Registering components Register ComponentModel classes to make them available in all templates: ``` from starlette_templates.components.base import ComponentModel class Alert(ComponentModel): template: str = "components/alert.html" message: str variant: str = "info" app = Starlette( middleware=[ Middleware( JinjaMiddleware, template_loaders=[PackageLoader("myapp", "templates")], extra_components=[Alert], # Register component ) ] ) ``` Then use directly in templates: ``` {{ Alert(message="System error", variant="danger") }} ``` ## Context processors Add custom context variables to all templates using context processors: ``` from starlette.requests import Request async def add_user_context(request: Request) -> dict: return {"user": await get_current_user(request)} async def add_site_context(request: Request) -> dict: return { "site_name": "My Site", "year": datetime.now().year, } templates = TemplateRouter( context_processors=[add_user_context, add_site_context] ) ``` Context processors are async functions that receive the [Request](https://starlette.dev/requests/) object and return a dictionary of context variables. ### Route-specific context processors Context processors can also be applied to specific routes using Route objects with path patterns: ``` from starlette.requests import Request from starlette.routing import Route async def add_product_context(request: Request) -> dict: """Context processor that only runs for product pages.""" product_id = request.path_params.get("product_id") return {"product": await get_product(product_id)} async def add_user_context(request: Request) -> dict: """Global context processor that runs for all templates.""" return {"user": await get_current_user(request)} templates = TemplateRouter( context_processors=[ add_user_context, # Runs for all templates # Only runs when path matches /products/{product_id} Route("/products/{product_id}", add_product_context), ] ) ``` Route-specific context processors only execute when the URL path matches the route pattern, allowing you to add context data conditionally based on the request path. Path parameters from the route (like `{product_id}`) are available via `request.path_params`. ## Error handling Starlette Templates provides error handling with custom exceptions, error pages, and JSON:API error responses. Create custom error pages by adding templates like `404.html`, `500.html`, or `error.html` to your template directory. The framework automatically serves these pages for browser requests and returns JSON:API compliant responses for API requests. For detailed information about error handling, custom exceptions, error pages, and JSON:API responses, see the [Error Handling](https://starlette-templates.tycho.engineering/errors/index.md) guide. ## HTTP caching Configure HTTP caching with ETag and Last-Modified headers on TemplateRouter: ``` templates = TemplateRouter( cache_max_age=3600, # Cache for 1 hour gzip_min_size=1024, # Compress files > 1KB ) ``` TemplateRouter automatically: - Generates ETag headers based on file modification time - Sets Last-Modified headers - Returns 304 Not Modified responses when appropriate - Compresses responses over the threshold with gzip ## Markdown content Markdown files can be rendered as HTML inside templates using the `{{ markdown(file) }}` Jinja2 function. The file path is relative to template loaders configured in JinjaMiddleware and converted to HTML using the [Markdown](https://pypi.org/project/Markdown/) Python package. You can also access all template variables and components inside the Markdown content. ``` # Hiking Tips Wear comfortable shoes, carry water, and check the weather. {{ Alert( heading='Need Help?', variant='info', content=markdown('message.md') ) }} ``` Jinja template syntax is parsed before converting Markdown to HTML. Markdown files receive the same context as the parent template. The converted HTML is then injected back into the parent template. Markdown and [PyMdown](https://facelessuser.github.io/pymdown-extensions/) extensions are enabled by default: - [`markdown.extensions.extra`](https://python-markdown.github.io/extensions/extra/) - A collection of useful extensions - [`markdown.extensions.codehilite`](https://python-markdown.github.io/extensions/code_hilite/) - Syntax highlighting for code blocks - [`markdown.extensions.nl2br`](https://python-markdown.github.io/reference/markdown/extensions/nl2br/) - Convert newlines to `
` tags - [`markdown.extensions.sane_lists`](https://python-markdown.github.io/extensions/sane_lists/) - Better handling of lists - [`markdown.extensions.smarty`](https://python-markdown.github.io/extensions/smarty/) - Punctuation substitutions with HTML entities - [`markdown.extensions.toc`](https://python-markdown.github.io/extensions/toc/) - Table of contents generation - [`pymdownx.arithmatex`](https://facelessuser.github.io/pymdown-extensions/extensions/arithmatex/) - LaTeX math support - [`pymdownx.betterem`](https://facelessuser.github.io/pymdown-extensions/extensions/betterem/) - Improved emphasis handling - [`pymdownx.blocks.admonition`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/admonition/) - Admonition blocks - [`pymdownx.blocks.definition`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/definition/) - Definition lists - [`pymdownx.blocks.details`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/details/) - Details/summary blocks - [`pymdownx.blocks.html`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/html/) - Raw HTML block support - [`pymdownx.blocks.tab`](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/tab/) - Tabbed content blocks - [`pymdownx.caret`](https://facelessuser.github.io/pymdown-extensions/extensions/caret/) - Caret (^) for superscript - [`pymdownx.details`](https://facelessuser.github.io/pymdown-extensions/extensions/details/) - Collapsible details blocks - [`pymdownx.extra`](https://facelessuser.github.io/pymdown-extensions/extensions/extra/) - Extra features from PyMdown - [`pymdownx.highlight`](https://facelessuser.github.io/pymdown-extensions/extensions/highlight/) - Syntax highlighting - [`pymdownx.inlinehilite`](https://facelessuser.github.io/pymdown-extensions/extensions/inlinehilite/) - Inline code highlighting - [`pymdownx.magiclink`](https://facelessuser.github.io/pymdown-extensions/extensions/magiclink/) - Auto-linking URLs and emails - [`pymdownx.mark`](https://facelessuser.github.io/pymdown-extensions/extensions/mark/) - Highlight markup - [`pymdownx.pathconverter`](https://facelessuser.github.io/pymdown-extensions/extensions/pathconverter/) - Convert file paths to links - [`pymdownx.progressbar`](https://facelessuser.github.io/pymdown-extensions/extensions/progressbar/) - Progress bars - [`pymdownx.saneheaders`](https://facelessuser.github.io/pymdown-extensions/extensions/saneheaders/) - Better header handling - [`pymdownx.superfences`](https://facelessuser.github.io/pymdown-extensions/extensions/superfences/) - Fenced code blocks - [`pymdownx.tabbed`](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/) - Tabbed content - [`pymdownx.tasklist`](https://facelessuser.github.io/pymdown-extensions/extensions/tasklist/) - Task lists with checkboxes - [`pymdownx.tilde`](https://facelessuser.github.io/pymdown-extensions/extensions/tilde/) - Subscript and strikethrough # Multi-Application Sites A multi-application setup allows you to have multiple independent Starlette applications that can share templates and middleware while maintaining separate routing and configuration. This is useful for large projects that need to be modular or have distinct sections (e.g., a blog, an e-commerce store, and an admin panel) where each section can be developed and maintained independently, even in separate packages, but still share common resources and served by a single main application. Multi-app architecture leverages Starlette's ability to mount sub-applications using the [Mount](https://starlette.dev/routing/) routing class, allowing each sub-application to have its own routes, middleware, and template loaders. This setup provides flexibility through shared templates and scalability through separation of concerns for complex web applications. ## When to use multi-app architecture Multi-app sites are useful when you want to: - Split different parts of your application into distinct modules (e.g., blog, e-commerce, admin) - Reuse common templates, components, and middleware across applications - Potentially deploy sub-applications separately while maintaining consistency - Allow different teams to work on separate applications with clear boundaries - Incrementally add new sections without restructuring existing code ## Project Structure A multi-app project structure that uses shared templates but has separate sub-applications might look like this: ``` myproject/ ├── __init__.py ├── app.py # Main application that mounts sub-apps ├── camping/ # Camping sub-application │ ├── __init__.py │ └── templates/ │ ├── base.html │ ├── index.html │ └── products.html ├── hiking/ # Hiking sub-application │ ├── __init__.py │ └── templates/ │ ├── base.html │ ├── index.html │ └── gear.html ├── templates/ # Shared templates at package level │ ├── nav.html │ ├── footer.html │ └── error.html └── static/ # Shared static files ├── css/ └── js/ ``` This shows a single package example, but you can also have each sub-application (camping, hiking) in its own package with its own templates, dependencies, and codebase. ## Basic Multi-App Setup Here's a complete example of a multi-app setup: ``` from jinja2 import PackageLoader from starlette.routing import Mount from starlette.middleware import Middleware from starlette.applications import Starlette from starlette_templates.routing import TemplateRouter from starlette_templates.middleware import JinjaMiddleware # Create camping sub-application camping = Starlette( routes=[ # Use Mount (not Route) to match all sub-paths # Give a name for URL generation, {{ url('camping', '/') }} Mount("/", TemplateRouter(debug=True), name="camping") ], middleware=[ Middleware( JinjaMiddleware, template_loaders=[ # Load camping-specific templates first PackageLoader("myproject.camping", "templates"), # Add shared templates from package level PackageLoader("myproject", "templates"), ] ) ], debug=True, ) # Create hiking sub-application hiking = Starlette( routes=[ # Use Mount (not Route) to match all sub-paths # Give a name for URL generation, {{ url('hiking', '/') }} Mount("/", TemplateRouter(debug=True), name="hiking") ], middleware=[ Middleware( JinjaMiddleware, template_loaders=[ # Load hiking-specific templates first PackageLoader("myproject.hiking", "templates"), # Add shared templates from package level PackageLoader("myproject", "templates"), ] ) ], debug=True, ) # Main application that mounts sub-applications app = Starlette( routes=[ # Mount sub-applications at desired paths # with names for URL generation Mount("/camping", camping, name="camping"), Mount("/hiking", hiking, name="hiking"), # Serve shared templates at root Mount("/", TemplateRouter(debug=True), name="shared") ], middleware=[ Middleware( JinjaMiddleware, template_loaders=[ # Shared templates from package level PackageLoader("myproject", "templates"), ] ) ], debug=True, ) ``` ## URL Routing in Multi-App Sites When you mount sub-applications, the URL routing works hierarchically: - `/camping/` → `camping` app → `/` → `camping/templates/index.html` - `/camping/products` → `camping` app → `/products` → `camping/templates/products.html` - `/hiking/` → `hiking` app → `/` → `hiking/templates/index.html` - `/hiking/gear` → `hiking` app → `/gear` → `hiking/templates/gear.html` - `/` → root app → `/` → `templates/index.html` The mount path (`/camping`, `/hiking`, `/`) is stripped before passing to the sub-application, so the sub-app sees only the remaining path. ## Template Sharing Sub-applications can share templates in two ways: ### 1. Shared Template Package Add a shared templates package as a fallback loader: ``` Middleware( JinjaMiddleware, template_loaders=[ PackageLoader("myproject.camping", "templates"), # App-specific first PackageLoader("myproject", "templates"), # Shared as fallback ] ) ``` This allows: - App-specific templates override shared ones - Common templates (nav, footer) from package-level templates are reused - Each app can customize shared templates by creating local versions with the same name (e.g., `camping/templates/nav.html` overrides `templates/nav.html`) ### 2. Template Inheritance Shared templates can define base layouts that sub-apps extend: **templates/base.html:** ``` {% block title %}My Site{% endblock %} {% include 'nav.html' %} {% block content %}{% endblock %} {% include 'footer.html' %} ``` **camping/templates/index.html:** ``` {% extends 'base.html' %} {% block title %}Camping{% endblock %} {% block content %}

Welcome to Camping

View Products {% endblock %} ``` ## Debug mode configuration ### Setting debug mode To enable detailed error pages with full tracebacks, code context, and local variables, you need to set `debug=True` on TemplateRouter, not just the Starlette app: ``` routes=[ Mount("/", TemplateRouter(debug=True), name="camping") ] ``` Common Mistake Setting `debug=True` only on `Starlette(debug=True)` is snot enoughs. You must also set `debug=True` on each `TemplateRouter` instance to see detailed error pages. Both are needed because: `Starlette(debug=True)` enables Starlette's debug features `TemplateRouter(debug=True)` enables detailed error pages for template rendering errors TemplateRouter has its own internal error handler that catches exceptions before they reach the Starlette app level, so it needs its own `debug` flag. ### Debug error page features When `TemplateRouter(debug=True)` is set, error pages show: - Exception type and message with HTTP status code - Full traceback with expandable frames - Code context (5 lines before and after each frame) - Local variables for each frame - Request information (method, URL, headers, query params, cookies) - Validation errors with detailed field information (for Pydantic errors) ## Critical distinction Mount vs Route Important: Use Mount, Not Route When using TemplateRouter as a catch-all handler, you must use `Mount`, not `Route`. This is a common source of 404 errors. Using `Route("/", TemplateRouter(...))` only matches the **exact path** `/`: ``` # WRONG Only matches exact path "/" routes=[ Route("/", TemplateRouter(), name="camping") ] ``` This causes: - `/camping/` → Works (matches exact path) - `/camping/products` → 404 Not Found (doesn't match) - `/camping/gear` → 404 Not Found (doesn't match) Using `Mount("/", TemplateRouter(...))` matches `/` and all sub-paths: ``` # CORRECT Matches all paths routes=[ Mount("/", TemplateRouter(), name="camping") ] ``` This makes everything work: - `/camping/` → Works - `/camping/products` → Works - `/camping/gear` → Works - `/camping/anything/else` → Works - Use `Route` for specific endpoints with defined paths: ``` Route("/api/users", users_endpoint) Route("/health", health_check) ``` - Use `Mount` for catch-all handlers like TemplateRouter: ``` Mount("/", TemplateRouter()) Mount("/static", StaticFiles(directories=["static"])) ``` ## URL generation with `url(mount_name, path)` In multi-app sites, you can generate URLs using the `url(mount_name, path)` function in templates. The route names you define in `Mount()` become available: ``` # Route names defined here app = Starlette( routes=[ Mount("/camping", camping, name="camping"), Mount("/hiking", hiking, name="hiking"), ] ) ``` Use in templates: ``` Camping Camping Products Hiking Hiking Gear ``` The `url()` function (alias for `url_for()`) takes: 1. Route name (e.g., `"camping"`) 1. Path within that route (e.g., `"/products"`) ## Custom exception handlers For routes that don't use TemplateFiles, you can add custom exception handlers at the app level: ``` from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import Response from starlette_templates.errors import httpexception_handler, exception_handler async def handle_http_exception(request: Request, exc: HTTPException) -> Response: jinja_env = request.state.jinja_env return await httpexception_handler(request, exc, jinja_env, debug=True) async def handle_exception(request: Request, exc: Exception) -> Response: jinja_env = request.state.jinja_env return await exception_handler(request, exc, jinja_env, debug=True) app = Starlette( routes=[ Mount("/api", api_routes), # Non-TemplateRouter routes Mount("/", TemplateRouter(debug=True)), ], exception_handlers={ HTTPException: handle_http_exception, Exception: handle_exception, }, debug=True, ) ``` Note If all your routes use TemplateRouter, you don't need custom exception handlers. TemplateRouter has its own internal error handler that works with `TemplateRouter(debug=True)`. # Templates TemplateRouter is an ASGI app that serves HTML templates with automatic routing and file resolution. ``` from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.routing import Mount from jinja2 import PackageLoader from starlette_templates.routing import TemplateRouter from starlette_templates.middleware import JinjaMiddleware app = Starlette( routes=[ Mount("/", TemplateRouter()), ], middleware=[ # Required for template autodiscovery Middleware( JinjaMiddleware, template_loaders=[PackageLoader("myapp", "templates")], ) ] ) ``` ### Features - Automatically tries `.html`, `.htm`, `.jinja`, `.jinja2` extensions - Serves `index.html` or `index.htm` for directory requests - HTTP caching with ETag and Last-Modified headers with 304 responses - Automatic gzip compression for responses over 500 bytes - Custom error pages with `404.html`, `500.html`, `error.html` - Custom context variables with context processors ### File Resolution When a request is made, `TemplateRouter` resolves templates in this order: 1. Exact match: `/about` → `about.html` 1. With extensions: `/about` → tries `about.html`, `about.htm`, `about.jinja`, `about.jinja2` 1. Index files: `/blog/` → tries `blog/index.html`, `blog/index.htm` ### Configuration ``` from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.routing import Mount from starlette.requests import Request from jinja2 import PackageLoader from starlette_templates.routing import TemplateRouter from starlette_templates.middleware import JinjaMiddleware async def add_context(request: Request) -> dict: return {"user": await get_current_user(request)} app = Starlette( routes=[ Mount("/", TemplateRouter( context_processors=[add_context], # Add custom context cache_max_age=3600, # 1 hour cache gzip_min_size=500, # Compress files > 500 bytes )), ], middleware=[ # Required for template autodiscovery Middleware( JinjaMiddleware, template_loaders=[PackageLoader("myapp", "templates")], ) ] ) ``` ### Route-Specific Context Processors Add context processors that only run for specific URL patterns using Starlette's `Route`: ``` from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.routing import Mount, Route from starlette.requests import Request from jinja2 import PackageLoader from starlette_templates.routing import TemplateRouter from starlette_templates.middleware import JinjaMiddleware # Global context processor - runs for ALL templates async def add_user_context(request: Request) -> dict: return {"user": await get_current_user(request)} # Route-specific context processors async def add_country_context(request: Request) -> dict: """Only runs for /country/{code} paths.""" code = request.path_params["code"] return {"country": await get_country(code)} async def add_post_context(request: Request) -> dict: """Only runs for /blog/{post_id} paths.""" post_id = request.path_params["post_id"] return {"post": await get_post(post_id)} app = Starlette( routes=[ Mount("/", TemplateRouter( context_processors=[ add_user_context, # Global - runs for all templates Route('/country/{code}', add_country_context), # Only /country/* paths Route('/blog/{post_id}', add_post_context), # Only /blog/* paths ] )), ], middleware=[ Middleware( JinjaMiddleware, template_loaders=[PackageLoader("myapp", "templates")], ) ] ) ``` In your template at `/country/us`: ```

{{ country.name }}

User: {{ user.name }}

``` Route-specific processors: - Have access to path parameters via `request.path_params` - Only execute when their route pattern matches the current request - Can be mixed with global processors in the same list - Use Starlette's existing `Route` class - no new abstractions ## Template Response Use TemplateResponse to render templates in route handlers: ``` from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.routing import Route from starlette.requests import Request from jinja2 import PackageLoader from starlette_templates.responses import TemplateResponse from starlette_templates.middleware import JinjaEnvMiddleware async def homepage(request: Request) -> TemplateResponse: return TemplateResponse( "home.html", context={"title": "Welcome", "items": [1, 2, 3]} ) app = Starlette( routes=[ Route("/", homepage), ], middleware=[ # Required for template autodiscovery Middleware( JinjaMiddleware, template_loaders=[PackageLoader("myapp", "templates")], ) ] ) ``` ### Context Variables TemplateResponse automatically includes: - `request` - The Starlette `Request` object - All context variables from context processors - Any additional context passed to `TemplateResponse` ## Template Context All templates have access to: ### Request Object ```

Current path: {{ request.url.path }}

Host: {{ request.url.hostname }}

Method: {{ request.method }}

User agent: {{ request.headers.get('user-agent') }}

``` ### URL Helpers #### url_for Generate URLs for named routes: ``` Profile ``` #### url Generate URLs for mounted apps: ``` ``` #### absurl Generate absolute URLs: ``` ``` ### JSON Serialization Safely embed Python data in templates with `jsonify`: ``` ``` This properly escapes and serializes Python objects to JSON. ## Custom Filters and Globals Add custom Jinja2 filters and globals through JinjaMiddleware: ``` from starlette.applications import Starlette from starlette.middleware import Middleware from jinja2 import Environment, PackageLoader from starlette_templates.middleware import JinjaMiddleware def setup_jinja_env(env: Environment): # Add custom filter env.filters['uppercase'] = lambda x: x.upper() # Add custom global env.globals['site_name'] = "My Site" app = Starlette( middleware=[ Middleware( JinjaMiddleware, template_loaders=[PackageLoader("myapp", "templates")], # Custom setup function environment_setup=setup_jinja_env, ) ] ) ``` Then use in templates: ``` {{ "hello" | uppercase }} {{ site_name }} ``` ## Template Loaders Configure template loading with [Jinja2 loaders](https://jinja.palletsprojects.com/en/stable/api/#loaders): ### PackageLoader Load templates from a Python package: ``` from starlette.applications import Starlette from starlette.middleware import Middleware from jinja2 import PackageLoader from starlette_templates.middleware import JinjaMiddleware app = Starlette( middleware=[ Middleware( JinjaMiddleware, template_loaders=[ PackageLoader("myapp", "templates"), ] ) ] ) ``` ### FileSystemLoader Load templates from filesystem directories: ``` from starlette.applications import Starlette from starlette.middleware import Middleware from jinja2 import FileSystemLoader from starlette_templates.middleware import JinjaMiddleware app = Starlette( middleware=[ Middleware( JinjaMiddleware, template_loaders=[ FileSystemLoader("custom/templates"), ] ) ] ) ``` ### Multiple Loaders Use multiple loaders with fallback priority: ``` from starlette.applications import Starlette from starlette.middleware import Middleware from jinja2 import PackageLoader, FileSystemLoader from starlette_templates.middleware import JinjaMiddleware app = Starlette( middleware=[ Middleware( JinjaMiddleware, template_loaders=[ PackageLoader("myapp", "templates"), # Check here first FileSystemLoader("custom/templates"), # Then here ], include_default_loader=True, # Then built-in templates ) ] ) ``` ## Error Pages Create custom error pages by adding templates to your template directory: ### 404.html ``` 404 Not Found

{{ status_code }} - {{ error_title }}

{{ error_message }}

Go Home ``` ### 500.html ``` 500 Server Error

{{ status_code }} - {{ error_title }}

{{ error_message }}

{% if structured_errors %}

Details:

{% endif %} ``` ### error.html Generic fallback error page for any error without a specific template: ``` Error

{{ status_code }} - {{ error_title }}

{{ error_message }}

``` ## Template Inheritance Use Jinja2's template inheritance for consistent layouts: ### base.html ``` {% block title %}My Site{% endblock %}
{% block header %}

My Site

{% endblock %}
{% block content %}{% endblock %}
``` ### page.html ``` {% extends "base.html" %} {% block title %}About - {{ super() }}{% endblock %} {% block content %}

About Us

Welcome to our site!

{% endblock %} ``` ## Including Templates Include reusable template fragments: ``` {% include "partials/navigation.html" %} {% include "partials/alert.html" with context %} ``` ## Macros Define reusable template functions with macros: ``` {% macro render_field(field) %}
{% if field.errors %}
{{ field.errors }}
{% endif %}
{% endmacro %} {{ render_field(form.name) }} {{ render_field(form.email) }} ``` # Static Files Starlette-Templates provides static file handling with - Pre-compressed gzip file support - Automatic HTTP caching headers - Multi-directory fallback support - ETag and Last-Modified headers ## StaticFiles StaticFiles serves static files with support for pre-compressed `.gz` files that are automatically decompressed in browsers and multi-directory fallback support. ### Basic Usage ``` from starlette.applications import Starlette from starlette.routing import Mount from starlette_templates.staticfiles import StaticFiles app = Starlette( routes=[ Mount("/static", StaticFiles(directories=["static"]), name="static"), ] ) ``` ### Pre-compressed Files Create pre-compressed versions of your static files for better performance: ``` # Create pre-compressed CSS files gzip -k static/css/bootstrap.css gzip -k static/css/style.css # Create pre-compressed JavaScript files gzip -k static/js/app.js gzip -k static/js/vendor.js ``` This creates `.gz` versions alongside the original files: ``` static/ ├── css/ │ ├── bootstrap.css │ ├── bootstrap.css.gz │ ├── style.css │ └── style.css.gz └── js/ ├── app.js ├── app.js.gz ├── vendor.js └── vendor.js.gz ``` ### Using Pre-compressed Files Reference the `.gz` files in your templates: ``` ``` The browser automatically decompresses the files and applies proper caching headers. ### How It Works When serving a `.gz` file, StaticFiles: 1. Serves the pre-compressed content 1. Sets `Content-Encoding: gzip` header 1. Sets appropriate `Content-Type` based on the original file extension 1. Adds `ETag` and `Last-Modified` headers for caching 1. Returns `304 Not Modified` for cached requests ### Configuration ``` from pathlib import Path static_files = StaticFiles( directories=[Path("static")], packages=None, # Optional: list of package names html=False, # Don't serve HTML files check_dir=True, # Verify directory exists ) ``` ## Multi-Directory Support StaticFiles serves static files from multiple directories with fallback priority, useful for: - Theme overrides - Plugin systems - Framework + application static files - Development vs production assets ### Basic Usage ``` from pathlib import Path from starlette.applications import Starlette from starlette.routing import Mount from starlette_templates.staticfiles import StaticFiles static_files = StaticFiles( directories=[ Path("myapp/static"), # Check here first Path("framework/static"), # Fallback to here Path("vendor/static"), # Then here ] ) app = Starlette( routes=[ Mount("/static", static_files, name="static"), ] ) ``` ### Fallback Priority Files are resolved in order: 1. If `myapp/static/css/style.css` exists, serve it 1. Otherwise, if `framework/static/css/style.css` exists, serve it 1. Otherwise, if `vendor/static/css/style.css` exists, serve it 1. Otherwise, return 404 This allows you to override framework files with your application files. ### Example Directory Structure ``` myapp/ └── static/ └── css/ └── custom.css # Application-specific styles framework/ └── static/ ├── css/ │ ├── base.css # Framework base styles │ └── components.css # Framework components └── js/ └── framework.js # Framework JavaScript vendor/ └── static/ └── js/ ├── jquery.js # Third-party libraries └── bootstrap.js ``` ### Usage in Templates ``` ``` ### Combining Multi-Directory and Gzip Support StaticFiles automatically supports both multi-directory fallback and pre-compressed files: ``` from pathlib import Path from starlette_templates.staticfiles import StaticFiles # StaticFiles automatically supports .gz files and multiple directories static_files = StaticFiles( directories=[ Path("myapp/static"), Path("framework/static"), ] ) ``` Then create compressed versions in any directory: ``` gzip -k myapp/static/css/custom.css gzip -k framework/static/css/base.css ``` ## HTTP Caching StaticFiles supports HTTP caching: ### ETag Headers Automatically generated based on file modification time and size: ``` ETag: "abc123-1234567890" ``` ### Last-Modified Headers Set from file modification time: ``` Last-Modified: Wed, 20 Dec 2023 12:00:00 GMT ``` ### 304 Not Modified When client sends `If-None-Match` or `If-Modified-Since` headers that match: ``` HTTP/1.1 304 Not Modified ETag: "abc123-1234567890" ``` ### Cache-Control Headers Set appropriate cache headers for static files: ``` from starlette.responses import Response # In your static files configuration static_files = StaticFiles( directories=["static"], # Files are served with cache headers ) ``` Response includes: ``` Cache-Control: public, max-age=3600 ``` ## Best Practices ### Pre-compression Pre-compress large static files for production: ``` # Find and compress all CSS and JS files find static -type f \( -name "*.css" -o -name "*.js" \) -exec gzip -k {} \; # Or use a more selective approach gzip -k static/css/bootstrap.css gzip -k static/js/app.bundle.js ``` ### Build Process Integration Integrate compression into your build process: ``` # build.py import gzip import shutil from pathlib import Path def compress_static_files(): static_dir = Path("static") for file in static_dir.rglob("*.css"): with open(file, 'rb') as f_in: with gzip.open(f"{file}.gz", 'wb') as f_out: shutil.copyfileobj(f_in, f_out) for file in static_dir.rglob("*.js"): with open(file, 'rb') as f_in: with gzip.open(f"{file}.gz", 'wb') as f_out: shutil.copyfileobj(f_in, f_out) if __name__ == "__main__": compress_static_files() ``` ### Directory Organization Organize static files by type and priority: ``` static/ ├── css/ │ ├── vendor/ # Third-party CSS │ ├── base/ # Base styles │ └── components/ # Component styles ├── js/ │ ├── vendor/ # Third-party JS │ └── app/ # Application JS ├── img/ │ ├── icons/ │ └── photos/ └── fonts/ ``` ### Development vs Production Use different configurations for development and production: ``` import os from pathlib import Path from starlette_templates.staticfiles import StaticFiles # StaticFiles works for both development and production static_files = StaticFiles(directories=["static"]) # In production, ensure you have .gz files created for better performance ``` ### CDN Integration For production, consider using a CDN with fallback: ``` # In your context processor async def add_static_context(request: Request) -> dict: cdn_url = os.getenv("CDN_URL", "") return { "static_url": cdn_url if cdn_url else request.url_for("static", path="/"), } ``` In templates: ``` ``` ## Troubleshooting ### File Not Found If static files aren't loading: 1. Verify the directory path is correct 1. Check file permissions 1. Ensure the mount path is correct 1. Verify the static files route comes before catch-all routes ``` app = Starlette( routes=[ # Static files BEFORE template files Mount("/static", StaticFiles(directories=["static"]), name="static"), # Template files last Mount("/", TemplateRouter()), ] ) ``` ### Gzip Not Working If pre-compressed files aren't being served: 1. Verify `.gz` files exist 1. Check file naming (must end in `.gz`) 1. Ensure you're using starlette-templates `StaticFiles` (not Starlette's built-in `StaticFiles`) 1. Check browser accepts gzip encoding ### Caching Issues If files aren't being cached: 1. Check browser caching is enabled 1. Verify ETag headers are being sent 1. Clear browser cache for testing 1. Check proxy/CDN configuration doesn't strip cache headers # Forms Starlette-Templates provides type-safe forms with automatic validation and rendering using Pydantic models. The FormModel base class extends Pydantic's `BaseModel` with form-specific functionality. ## Creating forms Define forms using the FormModel base class and field types: ``` from starlette_templates.forms import ( FormModel, TextField, SelectField, SubmitButtonField ) class ContactForm(FormModel): name: str = TextField( label="Your Name", placeholder="Enter your name", required=True, min_length=2, ) category: str = SelectField( default="general", choices={ "general": "General Inquiry", "support": "Technical Support", "sales": "Sales", }, label="Category", ) submit: str = SubmitButtonField(text="Send Message") ``` FormModel supports various field types (see [Available Form Fields](#available-form-fields) below) and validation using Pydantic's validators. The fields, like TextField and SelectField, are [Pydantic model fields](https://docs.pydantic.dev/latest/concepts/fields/) with additional metadata for rendering and validation. These form fields define template components, labels, placeholders, and validation rules, in-addition to the standard Pydantic field behavior. ## Using forms in routes ### Basic usage ``` from starlette.routing import Route from starlette.requests import Request from starlette_templates.responses import TemplateResponse async def contact_page(request: Request) -> TemplateResponse: # Parse form data and validate without raising on error form = await ContactForm.from_request(request, raise_on_error=False) # Check if form is valid and was submitted if form.is_valid(request): # Send email, save to database, etc. await send_contact_email(form.name, form.email, form.category) return TemplateResponse("success.html", context={"form": form}) # Show form (with errors if validation failed) return TemplateResponse("contact.html", context={"form": form}) app = Starlette( routes=[Route("/contact", contact_page, methods=["GET", "POST"])] ) ``` ### Error handling ``` from pydantic import ValidationError async def signup_page(request: Request) -> TemplateResponse: try: # Raise ValidationError on invalid data form = await SignupForm.from_request(request, raise_on_error=True) if form.is_valid(request): # Process valid form user = await create_user(form) return TemplateResponse("welcome.html", context={"user": user}) except ValidationError as e: # Handle validation errors return TemplateResponse( "signup.html", context={"errors": e.errors()}, status_code=400 ) # Show empty form for GET requests form = SignupForm() return TemplateResponse("signup.html", context={"form": form}) ``` ## Rendering forms ### Complete form A FormModel instance can be rendered in templates using the `form()` function or by rendering individual fields. Render the entire form with a single call: ``` {{ form() }} ``` You can pass form attributes like `method` `action`, and `enctype`: ``` {{ form( method="POST", action="/submit", enctype="multipart/form-data") }} ``` ### Individual fields Render individual fields with full control: ```
{{ form.render('name') }} {{ form.render('email') }} {{ form.render('category') }} {{ form.render('subscribe') }} {{ form.render('submit') }}
``` ### Accessing field values Access validated field values directly: ``` {% if form.is_valid(request) %}

Thank you, {{ form.name }}!

We'll contact you at {{ form.email }}

{% endif %} ``` ### Validation errors Validation errors are automatically displayed when you render fields. The FormModel injects validation state and error messages into rendered components. ```
{{ form.render('email') }} {{ form.render('password') }} {{ form.render('submit') }}
``` Check if the form has validation errors: ``` {% if form.has_errors() %}

{{ form.get_error_banner_text() }}

{% endif %}
{{ form.render('email') }} {{ form.render('password') }} {{ form.render('submit') }}
``` The `has_errors()` method returns `True` if there are any validation errors, and `get_error_banner_text()` returns the configured error banner message (default: "Please correct the errors below"). ### How error handling works When FormModel.from_request() is called with `raise_on_error=False`: 1. The form attempts to validate the request data 1. If validation fails, errors are captured internally in `_field_errors` 1. When you call `form.render('field_name')`, the error is automatically injected into the component 1. The component displays the error message with appropriate styling (e.g., Bootstrap's `is-invalid` class) This means you don't need to manually access or display errors - they're handled automatically by the rendering system. ## Available form fields ### TextField TextField - Single-line text input: ``` name: str = TextField( label="Name", placeholder="Enter your name", required=True, min_length=2, max_length=100, ) ``` ### TextAreaField TextAreaField - Multi-line text input: ``` message: str = TextAreaField( label="Message", placeholder="Enter your message", rows=5, required=True, min_length=10, ) ``` ### EmailField EmailField - Email input with validation: ``` email: str = EmailField( label="Email", placeholder="you@example.com", required=True, ) ``` ### IntegerField IntegerField - Numeric input for integers: ``` age: int = IntegerField( label="Age", min_value=18, max_value=120, required=True, ) ``` ### FloatField FloatField - Numeric input for floats: ``` price: float = FloatField( label="Price", min_value=0.0, step=0.01, required=True, ) ``` ### CheckboxField CheckboxField - Boolean checkbox: ``` agree: bool = CheckboxField( default=False, label="I agree to the terms", required=True, ) ``` ### SelectField SelectField - Dropdown select (single or multiple): ``` # Single select country: str = SelectField( label="Country", choices={ "us": "United States", "uk": "United Kingdom", "ca": "Canada", }, default="us", ) # Multiple select interests: list[str] = SelectField( label="Interests", choices={ "tech": "Technology", "music": "Music", "sports": "Sports", }, multiple=True, ) ``` ### DateField DateField - Date picker with Flatpickr: ``` from datetime import date birth_date: date = DateField( label="Birth Date", required=True, ) ``` ### HiddenField HiddenField - Hidden input: ``` user_id: int = HiddenField(default=0) ``` ### SubmitButtonField SubmitButtonField - Submit button: ``` submit: str = SubmitButtonField(text="Submit") ``` ### TagField TagField - Tag input with comma separation: ``` tags: list[str] = TagField( label="Tags", placeholder="Enter tags separated by commas", ) ``` ## Form validation ### Pydantic validators Use [Pydantic validators](https://docs.pydantic.dev/latest/concepts/validators/) for custom validation logic: ``` from pydantic import field_validator, model_validator class RegistrationForm(FormModel): username: str = TextField(label="Username", required=True) password: str = TextField(label="Password", type="password", required=True) confirm_password: str = TextField(label="Confirm Password", type="password", required=True) @field_validator('username') @classmethod def validate_username(cls, v): if len(v) < 3: raise ValueError('Username must be at least 3 characters') if not v.isalnum(): raise ValueError('Username must be alphanumeric') return v @model_validator(mode='after') def validate_passwords(self): if self.password != self.confirm_password: raise ValueError('Passwords do not match') return self ``` ### Custom validation Add custom validation methods: ``` class PaymentForm(FormModel): amount: float = FloatField(label="Amount", required=True) currency: str = SelectField( label="Currency", choices={"usd": "USD", "eur": "EUR", "gbp": "GBP"} ) @field_validator('amount') @classmethod def validate_amount(cls, v): if v <= 0: raise ValueError('Amount must be positive') if v > 10000: raise ValueError('Amount cannot exceed 10,000') return v ``` ## Parsing request data The model_from_request helper combines path parameters, query parameters, and body data: ``` from starlette_templates.forms import model_from_request from pydantic import BaseModel class UserData(BaseModel): name: str email: str age: int async def create_user(request: Request): # Combines path params, query params, and body data # data is a validated UserData instance data = await model_from_request(request, UserData) ``` If path, query, and body parameters overlap, body data takes precedence over query parameters, which take precedence over path parameters. If you are using a FormModel, use FormModel.from_request() instead to get form-specific behavior and validation. ``` from starlette_templates.forms import FormModel class ProfileForm(FormModel): username: str bio: str async def edit_profile(request: Request): # Combines path params, query params, and body data # form is a validated ProfileForm instance form = await ProfileForm.from_request(request) ``` # Components Components in Starlette-Templates are reusable UI elements built with Jinja2 templates and Pydantic models. They provide: - Type safety with Pydantic validation - Reusable, testable UI elements - Template-based rendering - Integration with the Jinja2 environment ## Creating Components Define components using the ComponentModel base class: ``` from starlette_templates.components.base import ComponentModel from pydantic import Field class Alert(ComponentModel): # Path to component template template: str = "components/alert.html" # Component properties with validation message: str = Field(..., description="Alert message") variant: str = Field(default="info", description="Alert variant") dismissible: bool = Field(default=False, description="Show close button") ``` ### Component Template Create the template file (`components/alert.html`): ``` ``` ## Using Components ### In Routes Create component instances and pass them to templates: ``` from starlette.requests import Request from starlette_templates.responses import TemplateResponse async def dashboard(request: Request) -> TemplateResponse: alert = Alert( message="Welcome back!", variant="success", dismissible=True ) return TemplateResponse( "dashboard.html", context={"alert": alert} ) ``` ### In Templates Render components by calling them: ``` {{ alert }} ``` ### Global Registration Register components globally to use them in any template: ``` from starlette.applications import Starlette from starlette.middleware import Middleware from starlette_templates.middleware import JinjaMiddleware app = Starlette( middleware=[ Middleware( JinjaMiddleware, template_loaders=[PackageLoader("myapp", "templates")], extra_components=[Alert], # Register component ) ] ) ``` Then use directly in templates without passing from routes: ``` {{ Alert(message="System error", variant="danger", dismissible=True) }} ``` ## Built-in Form Components Starlette Templates includes Bootstrap-compatible form components: ### Input Input - Text, email, password, number inputs: ``` from starlette_templates.components.forms import Input # In route input_field = Input( name="username", label="Username", type="text", placeholder="Enter username", required=True, ) # In template {{ Input(name="email", label="Email", type="email", required=True) }} ``` ### Textarea Textarea - Multi-line text input: ``` from starlette_templates.components.forms import Textarea textarea = Textarea( name="message", label="Message", rows=5, placeholder="Enter your message", ) ``` ### Select Select - Native select dropdown: ``` from starlette_templates.components.forms import Select select = Select( name="country", label="Country", choices={ "us": "United States", "uk": "United Kingdom", "ca": "Canada", }, selected="us", ) ``` ### Checkbox Checkbox - Checkbox input: ``` from starlette_templates.components.forms import Checkbox checkbox = Checkbox( name="agree", label="I agree to the terms", checked=False, ) ``` ### Radio Radio - Radio button: ``` from starlette_templates.components.forms import Radio # In template {{ Radio(name="plan", label="Basic Plan", value="basic", checked=True) }} {{ Radio(name="plan", label="Pro Plan", value="pro") }} ``` ### Switch Switch - Toggle switch: ``` from starlette_templates.components.forms import Switch switch = Switch( name="notifications", label="Enable notifications", checked=True, ) ``` ### FileInput FileInput - File upload: ``` from starlette_templates.components.forms import FileInput file_input = FileInput( name="avatar", label="Profile Picture", accept="image/*", ) ``` ### Range Range - Range slider: ``` from starlette_templates.components.forms import Range range_slider = Range( name="volume", label="Volume", min_value=0, max_value=100, step=1, value=50, ) ``` ### ChoicesSelect ChoicesSelect - Enhanced select with Choices.js: ``` from starlette_templates.components.forms import ChoicesSelect choices_select = ChoicesSelect( name="tags", label="Tags", choices={ "python": "Python", "javascript": "JavaScript", "go": "Go", }, multiple=True, ) ``` ### DatePicker DatePicker - Date picker with Flatpickr: ``` from starlette_templates.components.forms import DatePicker date_picker = DatePicker( name="birth_date", label="Birth Date", placeholder="Select date", ) ``` ### SubmitButton SubmitButton - Form submit button: ``` from starlette_templates.components.forms import SubmitButton submit_btn = SubmitButton( text="Submit Form", variant="primary", ) ``` ## Component Examples ### Card Component ``` class Card(ComponentModel): template: str = "components/card.html" title: str = Field(..., description="Card title") body: str = Field(..., description="Card body content") footer: str | None = Field(None, description="Card footer") variant: str = Field(default="default", description="Card style variant") ``` Template (`components/card.html`): ```
{{ title }}
{{ body }}
{% if footer %} {% endif %}
``` Usage: ``` {{ Card( title="Welcome", body="This is a card component", footer="Card footer", variant="primary" ) }} ``` ### Badge Component ``` class Badge(ComponentModel): template: str = "components/badge.html" text: str = Field(..., description="Badge text") variant: str = Field(default="primary", description="Badge color") pill: bool = Field(default=False, description="Use pill style") ``` Template (`components/badge.html`): ``` {{ text }} ``` Usage: ``` {{ Badge(text="New", variant="success", pill=True) }} {{ Badge(text="Beta", variant="warning") }} ``` ### Button Component ``` class Button(ComponentModel): template: str = "components/button.html" text: str = Field(..., description="Button text") variant: str = Field(default="primary", description="Button style") size: str = Field(default="md", description="Button size") disabled: bool = Field(default=False, description="Disabled state") type: str = Field(default="button", description="Button type") ``` Template (`components/button.html`): ``` ``` Usage: ``` {{ Button(text="Click Me", variant="primary") }} {{ Button(text="Submit", variant="success", type="submit") }} {{ Button(text="Disabled", disabled=True) }} ``` ### Modal Component ``` class Modal(ComponentModel): template: str = "components/modal.html" id: str = Field(..., description="Modal ID") title: str = Field(..., description="Modal title") body: str = Field(..., description="Modal body content") footer: str | None = Field(None, description="Modal footer") size: str = Field(default="md", description="Modal size (sm, md, lg, xl)") ``` Template (`components/modal.html`): ``` ``` ## Component Validation Components use Pydantic validation to ensure correct usage: ``` class Image(ComponentModel): template: str = "components/image.html" src: str = Field(..., description="Image source URL") alt: str = Field(..., description="Alt text") width: int | None = Field(None, ge=1, description="Image width") height: int | None = Field(None, ge=1, description="Image height") @field_validator('src') @classmethod def validate_src(cls, v): if not v.startswith(('http://', 'https://', '/')): raise ValueError('Invalid image source') return v ``` ## Nested Components Components can render other components: ``` class Alert(ComponentModel): template: str = "components/alert.html" message: str variant: str = "info" class AlertGroup(ComponentModel): template: str = "components/alert_group.html" alerts: list[Alert] = Field(default_factory=list) ``` Template (`components/alert_group.html`): ```
{% for alert in alerts %} {{ alert }} {% endfor %}
``` Usage: ``` alert_group = AlertGroup( alerts=[ Alert(message="Success!", variant="success"), Alert(message="Warning!", variant="warning"), Alert(message="Error!", variant="danger"), ] ) ``` # Error Handling This guide covers error handling, custom exceptions, error pages, and JSON:API error responses. ## Overview Starlette Templates provides comprehensive error handling with: - Custom exception classes with structured error details - Automatic HTML error pages for browser requests - JSON:API compliant error responses for API requests - Custom error page templates - Context processors for error pages ## Custom Exceptions ### AppException AppException is the base exception class for structured error responses. ``` from starlette_templates.errors import AppException, ErrorCode, ErrorSource async def get_user(user_id: int): if not user_exists(user_id): raise AppException( detail=f"User with ID {user_id} not found", status_code=404, code=ErrorCode.NOT_FOUND, source=ErrorSource(parameter="user_id"), meta={"user_id": user_id} ) ``` ### Parameters - **detail** (str, required) - Human-readable explanation of the error - **status_code** (int, default: 500) - HTTP status code - **code** (ErrorCode, optional) - Machine-readable error code - **source** (ErrorSource, optional) - Source of the error - **title** (str, optional) - Short, human-readable error summary - **meta** (dict, optional) - Additional metadata about the error ### ErrorCode ErrorCode provides standard error codes: ``` from starlette_templates.errors import ErrorCode ErrorCode.BAD_REQUEST # 400 ErrorCode.UNAUTHORIZED # 401 ErrorCode.FORBIDDEN # 403 ErrorCode.NOT_FOUND # 404 ErrorCode.METHOD_NOT_ALLOWED # 405 ErrorCode.CONFLICT # 409 ErrorCode.VALIDATION_ERROR # 422 ErrorCode.INTERNAL_SERVER_ERROR # 500 ``` ### ErrorSource ErrorSource identifies where the error occurred: ``` from starlette_templates.errors import ErrorSource # Error in a request parameter ErrorSource(parameter="user_id") # Error in a request header ErrorSource(header="Authorization") # Error in a JSON pointer location ErrorSource(pointer="/data/attributes/email") # Error in a query parameter ErrorSource(parameter="page[limit]") ``` ## Error Pages Create custom error pages by adding templates to your template directory. ### 404.html - Not Found ``` {{ status_code }} - {{ error_title }}

{{ status_code }}

{{ error_title }}

{{ error_message }}

Go Home
``` ### 500.html - Server Error ``` {{ status_code }} - {{ error_title }}

{{ status_code }}

{{ error_title }}

{{ error_message }}

{% if structured_errors %}

Error Details:

{% endif %} {% if request.app.debug %}

Debug Information:

{{ traceback }}
{% endif %}
``` ### error.html - Generic Error Page Generic fallback for any error without a specific template: ``` Error - {{ status_code }}

{{ status_code }}

{{ error_title }}

{{ error_message }}

Go Home
``` ### Available Template Variables All error templates have access to: - `status_code` (int) - HTTP status code - `error_title` (str) - Short error summary - `error_message` (str) - Detailed error explanation - `structured_errors` (list) - List of error objects with structured details - `request` - The Starlette Request object - `traceback` (str) - Stack trace (only in debug mode) ## JSON:API Error Responses For API requests (requests with `Accept: application/json` or `Content-Type: application/json`), errors are automatically returned as JSON:API compliant responses. ### Single Error ``` from starlette_templates.errors import AppException, ErrorCode async def register_user(email: str): raise AppException( detail="Email address is already registered", status_code=409, code=ErrorCode.CONFLICT, source=ErrorSource(pointer="/data/attributes/email"), ) ``` Returns: ``` { "errors": [ { "status": "409", "code": "conflict", "title": "Conflict", "detail": "Email address is already registered", "source": { "pointer": "/data/attributes/email" } } ] } ``` ### Multiple Errors ``` from starlette_templates.errors import AppException, ErrorCode, ErrorSource async def validate_user_input(data: dict): # Validation error with multiple field errors raise AppException( detail="Validation failed", status_code=422, code=ErrorCode.VALIDATION_ERROR, errors=[ { "detail": "Email address is required", "source": ErrorSource(pointer="/data/attributes/email"), "code": "required" }, { "detail": "Password must be at least 8 characters", "source": ErrorSource(pointer="/data/attributes/password"), "code": "min_length" } ] ) ``` Returns: ``` { "errors": [ { "status": "422", "code": "required", "detail": "Email address is required", "source": { "pointer": "/data/attributes/email" } }, { "status": "422", "code": "min_length", "detail": "Password must be at least 8 characters", "source": { "pointer": "/data/attributes/password" } } ] } ``` ### Error Response Format JSON:API error responses follow the [JSON:API specification](https://jsonapi.org/format/#errors): ``` { "errors": [ { "status": "404", "code": "not_found", "title": "Page Not Found", "detail": "The requested resource was not found", "source": { "parameter": "user_id" }, "meta": { "user_id": 123, "timestamp": "2025-12-20T12:00:00Z" } } ] } ``` ## Custom Error Handlers ### Application-level Error Handler Provide a custom error handler for TemplateFiles: ``` from starlette.requests import Request from starlette.responses import Response, JSONResponse from starlette_templates.templating import TemplateFiles import logging logger = logging.getLogger(__name__) async def custom_error_handler(request: Request, exc: Exception) -> Response: # Log the error logger.error( f"Error processing {request.url}: {exc}", exc_info=exc, extra={ "url": str(request.url), "method": request.method, "client": request.client.host if request.client else None, } ) # Send notification for critical errors if isinstance(exc, CriticalError): await send_alert_notification(exc) # Return custom response if "application/json" in request.headers.get("accept", ""): return JSONResponse( {"error": "Something went wrong"}, status_code=500 ) return TemplateResponse( "error.html", context={ "status_code": 500, "error_title": "Internal Server Error", "error_message": "An unexpected error occurred", }, status_code=500 ) templates = TemplateFiles( error_handler=custom_error_handler ) ``` ### Route-level Error Handling Handle errors within specific routes: ``` from starlette.routing import Route from starlette.requests import Request from starlette_templates.forms import FormModel from starlette_templates.responses import TemplateResponse from starlette_templates.errors import AppException, ErrorCode class User(FormModel): user_id: int async def user_profile(request: Request) -> TemplateResponse: user = await User.from_request(request) try: # Fetch user profile, may raise exceptions UserNotFound, PermissionDenied user = await get_user(user.user_id) return TemplateResponse("profile.html", context={"user": user}) except UserNotFound: raise AppException( detail=f"User {user.user_id} not found", status_code=404, code=ErrorCode.NOT_FOUND, ) except PermissionDenied: raise AppException( detail="You don't have permission to view this profile", status_code=403, code=ErrorCode.FORBIDDEN, ) app = Starlette( routes=[ Route("/users/{user_id}", user_profile), ] ) ``` ## Form Validation Errors Handle Pydantic validation errors from forms: ``` from pydantic import ValidationError from starlette.requests import Request from starlette_templates.responses import TemplateResponse from starlette_templates.forms import FormModel async def signup(request: Request) -> TemplateResponse: try: form = await SignupForm.from_request(request, raise_on_error=True) if form.is_valid(request): user = await create_user(form) return TemplateResponse("success.html", context={"user": user}) except ValidationError as e: # Return form with validation errors return TemplateResponse( "signup.html", context={ "errors": e.errors(), "error_message": "Please correct the errors below", }, status_code=400 ) # Show empty form for GET requests return TemplateResponse("signup.html", context={"form": SignupForm()}) ``` ## Error Page Context Processors Add custom context to error pages: ``` from starlette.requests import Request async def add_error_context(request: Request) -> dict: return { "support_email": "support@example.com", "status_page_url": "https://status.example.com", "request_id": request.headers.get("X-Request-ID"), } templates = TemplateFiles( context_processors=[add_error_context] ) ``` Use in error templates: ```

{{ status_code }}

{{ error_message }}

Need help? Contact us at {{ support_email }}

Request ID: {{ request_id }}

Check service status: {{ status_page_url }}

```