Adds integration of the Chameleon template language to FastAPI.
Project description
fastapi-chameleon
Adds integration of the Chameleon template language to FastAPI. If you are interested in Jinja instead, see the sister project: github.com/AGeekInside/fastapi-jinja.
Documentation: full docs and a per-function API reference live at mkennedy.codes/docs/fastapi-chameleon. An llms.txt index is available for AI coding tools.
Features
- One decorator turns a FastAPI view into a server-rendered HTML page: return a
dict, get a rendered template. - Sync and async views are both fully supported.
fastapi.Responsepass-through: return aResponse(redirect, JSON, etc.) from a decorated view and the template is skipped entirely.- Friendly error pages:
not_found()renders a custom 404 page,generic_error()renders any template with any status code. - Template name inference: leave the template name off and it's derived from the module and function name.
- Dev mode:
auto_reload=Truepicks up template edits without restarting the server. - Fully typed: ships inline type hints with a
py.typedmarker (PEP 561). The decorator usesParamSpec-based overloads andfunctools.wraps, so a decorated view keeps its exact parameter signature — FastAPI's dependency injection and type checkers like ty and pyrefly keep working. - Tiny dependency footprint: just
fastapiandchameleon.
Installation
pip install fastapi-chameleon
Quick start
A minimal but complete app — two files.
main.py
from pathlib import Path
import fastapi
import uvicorn
import fastapi_chameleon
app = fastapi.FastAPI()
# Point the engine at your template folder (do this before views are registered).
BASE_DIR = Path(__file__).resolve().parent
fastapi_chameleon.global_init(str(BASE_DIR / 'templates'), auto_reload=True)
@app.get('/')
@fastapi_chameleon.template('index.pt')
def hello_world():
return {'message': "Let's go Chameleon and FastAPI!"}
if __name__ == '__main__':
uvicorn.run(app, host='127.0.0.1', port=8000)
templates/index.pt
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>Hello world</h1>
<p>Your message is <strong>${message}</strong></p>
</body>
</html>
Run it with python main.py (or uvicorn main:app) and visit http://127.0.0.1:8000. The dict returned from the view becomes the template's variables: {'message': ...} renders into ${message}.
Chameleon templates are plain HTML5 with ${expr} interpolation plus the full TAL attribute language (tal:repeat, tal:content, and friends) in either .pt or .html files.
Note the decorator order: the route decorator (@app.get(...)) goes on the outside, and @fastapi_chameleon.template(...) is applied directly to the view function.
Usage
Project layout
Create a folder within your web app to hold the templates, such as:
├── main.py
├── views.py
│
└── templates
├── home
│ └── index.pt
├── errors
│ └── 404.pt
└── shared
└── layout.pt
In the app startup, tell the library about the folder you wish to use:
from pathlib import Path
import fastapi_chameleon
dev_mode = True
BASE_DIR = Path(__file__).resolve().parent
template_folder = str(BASE_DIR / 'templates')
fastapi_chameleon.global_init(template_folder, auto_reload=dev_mode)
global_init() validates the folder (it raises FastAPIChameleonException if the path is empty or not an existing directory) and is idempotent by default: a second call is a no-op while templates are already initialized. Pass cache_init=False to force re-initialization (handy in tests).
Order matters: call
global_init()before importing/registering your view modules. Template name inference (below) resolves at decoration time; if the engine isn't initialized yet, the path silently defaults totemplates/relative to the current working directory, which may not be what you want. If you always pass explicit template names, this is much less of a concern.
Decorating views
Then just decorate the FastAPI view methods (works on sync and async methods):
@router.post('/')
@fastapi_chameleon.template('home/index.pt')
async def home_post(request: Request):
form = await request.form()
vm = PersonViewModel(**form)
return vm.dict() # {'first': 'Michael', 'last': 'Kennedy', ...}
The view method should return a dict to be passed as variables/values to the template.
If a fastapi.Response is returned, the template is skipped and the response along with status_code and other values is directly passed through. This is common for redirects and error responses not meant for this page template:
@router.post('/account/login')
@fastapi_chameleon.template('account/login.pt')
async def login(request: Request):
user = await try_login(request)
if user:
return fastapi.responses.RedirectResponse('/account', status_code=302)
return {'error': 'Invalid login'} # re-render the form with an error
Returning anything other than a dict or a fastapi.Response raises FastAPIChameleonException.
The decorator also accepts a mimetype for non-HTML output, e.g. @fastapi_chameleon.template('seo/sitemap.pt', mimetype='application/xml').
Three ways to use the decorator
@fastapi_chameleon.template('home/index.pt') # explicit template file
@fastapi_chameleon.template() # inferred template name
@fastapi_chameleon.template # bare form, also inferred
When no template name is given, it's derived from where the view lives:
- The file is
{module}/{function_name}under the template folder, wheremoduleis the last segment of the view's dotted module name. - An
.htmlfile is preferred; if it doesn't exist,.ptis the fallback. - Example:
def index()inviews/home.pyresolves totemplates/home/index.html, falling back totemplates/home/index.pt.
This resolution happens once at import time, so there is zero per-request filesystem overhead.
A few error behaviors worth knowing:
- Calling a decorated view without ever calling
global_init()raisesFastAPIChameleonExceptionat request time. - Referencing a template file that doesn't exist raises
ValueError(from Chameleon's loader) when the view is called.
Friendly 404s and errors
A common technique for user-friendly sites is to use a custom HTML page for 404 responses. This is especially important in FastAPI because FastAPI returns a 404 response + JSON by default. This library has support for friendly 404 pages using the fastapi_chameleon.not_found() function.
Here's an example:
@router.get('/catalog/item/{item_id}')
@fastapi_chameleon.template('catalog/item.pt')
async def item(item_id: int):
item = service.get_item_by_id(item_id)
if not item:
fastapi_chameleon.not_found()
return item.dict()
This will render a 404 response using the template file templates/errors/404.pt. You can specify another template to use for the response, but it's not required:
fastapi_chameleon.not_found(four04template_file='errors/custom_404.pt')
not_found() works by raising an exception, so execution stops right there — code after the call never affects the response. The 404 template is rendered with an empty model.
Because the decorator is what catches the exception, you can call not_found() or generic_error() anywhere beneath a decorated view — deep in a service or data-access layer works fine. The flip side: calling them from a route that is not decorated with @fastapi_chameleon.template (or from middleware/dependencies) leaves the exception unhandled and FastAPI will return a 500 instead of your error page.
If you need to return errors other than Not Found (status code 404), you can use a more generic function: fastapi_chameleon.generic_error(). It lets you render any error template with any status code:
@router.get('/catalog/item/{item_id}')
@fastapi_chameleon.template('catalog/item.pt')
async def item(item_id: int):
item = service.get_item_by_id(item_id)
if not item:
fastapi_chameleon.generic_error('errors/unauthorized.pt',
fastapi.status.HTTP_401_UNAUTHORIZED)
return item.dict()
You can also pass data into the error template via the optional template_data dict:
fastapi_chameleon.generic_error('errors/500.pt', 500,
template_data={'detail': 'Something went sideways.'})
Note that error pages are always rendered as text/html, regardless of the mimetype passed to the @template decorator.
Manual rendering with response()
If you need full manual control — say, a non-200 status code or a non-HTML mimetype — without going through the decorator, use response():
import fastapi_chameleon
@router.get('/report')
def report():
return fastapi_chameleon.response('reports/summary.pt',
status_code=202,
title='Monthly summary')
It renders the template with the keyword arguments as the model and wraps the result in a fastapi.Response with your chosen mimetype (default 'text/html') and status_code (default 200).
API reference
Full, per-function docs are at mkennedy.codes/docs/fastapi-chameleon. The summary below mirrors the public surface.
Everything public is importable straight from fastapi_chameleon:
__all__ = ['template', 'global_init', 'not_found', 'response', 'generic_error']
| Function | Signature | Purpose |
|---|---|---|
global_init |
global_init(template_folder: str, auto_reload: bool = False, cache_init: bool = True) -> None |
Initialize the template engine once at startup. No-op if already initialized (unless cache_init=False). |
template |
template(template_file=None, mimetype='text/html') |
Decorator for view functions. Usable bare, with empty parens, or with an explicit template path. |
response |
response(template_file: str, mimetype: str = 'text/html', status_code: int = 200, **template_data) -> fastapi.Response |
Render a template and wrap it in a Response with full manual control. |
not_found |
not_found(four04template_file: str = 'errors/404.pt') -> NoReturn |
Abort the view and render a friendly 404 page (always raises). |
generic_error |
generic_error(template_file: str, status_code: int, template_data: Optional[dict] = None) -> NoReturn |
Abort the view and render any error template with any status code (always raises). |
Two more functions live in fastapi_chameleon.engine (not exported at package level):
| Function | Signature | Purpose |
|---|---|---|
engine.render |
render(template_file: str, **template_data) -> str |
Render a template directly to an HTML string. |
engine.clear |
clear() -> None |
Reset the cached loader and template path — the test-isolation hook. |
Exceptions, in fastapi_chameleon.exceptions:
FastAPIChameleonException(Exception)— base class; also raised for badglobal_initinput, missing init at render time, and invalid view return types.FastAPIChameleonNotFoundException— raised bynot_found(); carries.template_fileand.message.FastAPIChameleonGenericException— raised bygeneric_error(); carries.template_file,.status_code,.message, and.template_data.
Dev mode, caching, and performance
auto_reloaddefaults toFalse: Chameleon caches compiled templates for production performance. Setauto_reload=Trueduring development to pick up template edits without restarting.- Engine state is a single module-global template loader per process. Call
global_init()once before serving requests; after that the loader is read-only.
Testing your views
Decorated views remain plain callables — no TestClient required. Call them directly (or via asyncio.run() for async views) and inspect the returned fastapi.Response:
# conftest.py
from pathlib import Path
import pytest
import fastapi_chameleon as fc
@pytest.fixture
def test_templates_path(pytestconfig):
return Path(pytestconfig.rootdir, 'tests', 'templates')
@pytest.fixture
def setup_global_template(test_templates_path):
fc.global_init(str(test_templates_path))
yield
fc.engine.clear() # don't leak engine state between tests
# test_views.py
# index_view is any view function decorated with @fastapi_chameleon.template(...)
def test_index_renders(setup_global_template):
resp = index_view()
assert resp.status_code == 200
assert 'Hello' in resp.body.decode('utf-8')
This is exactly the pattern this project's own test suite uses.
Example app
A small, runnable FastAPI app showing sync and async views lives in the example/ folder:
cd example
python example_app.py
Then visit http://127.0.0.1:8000 (and /async for the async view). Note that the example calls global_init() at runtime (from main(), via an add_chameleon() helper) rather than at import time, so run it with python example_app.py rather than via the uvicorn CLI.
Requirements
- Python 3.10+ (supports up through 3.14)
fastapichameleon
That's the entire runtime dependency list.
Contributing
PRs and issues are welcome at github.com/mikeckennedy/fastapi-chameleon.
git clone https://github.com/mikeckennedy/fastapi-chameleon.git
cd fastapi-chameleon
python -m venv venv && source venv/bin/activate
pip install -e ".[dev]" # pytest + ty + pyrefly
pytest
Code style is enforced with Ruff (ruff.toml: 120-character lines, single quotes), and the package is type-checked with ty and pyrefly. Please run the full check before submitting:
ruff check .
ty check fastapi_chameleon
pyrefly check fastapi_chameleon
pytest
(The requirements-dev.txt file additionally pulls in the docs toolchain — great-docs, uvicorn, twine — for building the documentation site.)
License
MIT — see LICENSE.
Created by Michael Kennedy of Talk Python.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastapi_chameleon-0.1.18.tar.gz.
File metadata
- Download URL: fastapi_chameleon-0.1.18.tar.gz
- Upload date:
- Size: 634.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.20 {"installer":{"name":"uv","version":"0.11.20","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c5d2d07a7375a0b3826bf8cb5b7e3c9188e388a3268111d00e7d29faa24129ee
|
|
| MD5 |
7b403705bef2b4f5326423a249c832e8
|
|
| BLAKE2b-256 |
86880c100a934ac033e60936953183ee0738d91b45ce113c0fbd42cecf62b38a
|
File details
Details for the file fastapi_chameleon-0.1.18-py3-none-any.whl.
File metadata
- Download URL: fastapi_chameleon-0.1.18-py3-none-any.whl
- Upload date:
- Size: 13.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.20 {"installer":{"name":"uv","version":"0.11.20","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c6f95ae8753d1cc0ed81a900f4b67e6c12fc80b349f7cb07e00d0c05568767a3
|
|
| MD5 |
b55643020be475b55bda531e6356ee9f
|
|
| BLAKE2b-256 |
6b84c9dbf557b6866b6b13a2a0f03a64520be8c040cb796d8327ce1fdb0ba603
|