AI / Agents

Skills
llms.txt
llms-full.txt

Developers

Michael Kennedy

Author

Community

Full license MIT

Meta

Requires: Python >=3.10
Provides-Extra: dev, examples

chameleon-robyn

Note: This project is very much in the alpha and early stages of development. Feel free to use it, but no promises on API stability for a few versions.

Use Chameleon page templates in your Robyn web applications. If you’ve used Chameleon with Pyramid, Flask, or FastAPI, you’ll feel right at home - same .pt templates, same TAL/TALES/METAL expressions, now running on Robyn’s blazing-fast Rust runtime.

Documentation

Full documentation, including a browsable API reference, lives at mkennedy.codes/docs/chameleon-robyn. This README covers the same ground in guide form if you’d rather keep reading here.

Installation

uv pip install chameleon-robyn

This pulls in chameleon and robyn as dependencies.

Quick start

1. Set up your project structure:

my_app/
├── app.py
└── templates/
    └── home/
        └── index.pt

2. Create a template (templates/home/index.pt):

<!DOCTYPE html>
<html>
<body>
    <h1>Hello, ${name}!</h1>
    <ul>
        <li tal:repeat="item items">${item}</li>
    </ul>
</body>
</html>

3. Wire it up (app.py):

from pathlib import Path
from robyn import Robyn
import chameleon_robyn

app = Robyn(__file__)

## Point Chameleon at your templates folder (do this once at startup)
template_folder = (Path(__file__).resolve().parent / 'templates').as_posix()
chameleon_robyn.global_init(template_folder, auto_reload=True)

@app.get('/')
@chameleon_robyn.template('home/index.pt')
async def index(request):
    return {'name': 'World', 'items': ['Robyn', 'Chameleon', 'Python']}

app.start(host='127.0.0.1', port=8000)

That’s it. Your handler returns a dict, the @template decorator renders it through the Chameleon template and wraps the result in a Robyn Response.

The @template decorator

The decorator is the main way you’ll use this package. It intercepts your handler’s return value and renders it through a Chameleon template.

Explicit template path

@app.get('/episodes')
@chameleon_robyn.template('episodes/list.pt')
def episode_list(request):
    return {'episodes': get_all_episodes()}

Auto-naming (convention over configuration)

If you omit the template path, it’s derived from the module and function name. A function called index in a module called home_views looks for home_views/index.html first and, if that file doesn’t exist, uses home_views/index.pt:

## Looks for templates/home_views/index.pt
@app.get('/')
@chameleon_robyn.template()
def index(request):
    return {'message': 'Hello!'}

You can even skip the parentheses:

@app.get('/')
@chameleon_robyn.template
def index(request):
    return {'message': 'Hello!'}

Async handlers - just works

@app.get('/dashboard')
@chameleon_robyn.template('dashboard.pt')
async def dashboard(request):
    stats = await fetch_stats_from_db()
    return {'stats': stats}

Custom status codes and content types

## Return a 201 after creating a resource
@app.post('/items')
@chameleon_robyn.template('items/created.pt', status_code=201)
async def create_item(request):
    item = await save_item(request.json())
    return {'item': item}

## Serve an XML feed
@app.get('/feed.xml')
@chameleon_robyn.template('feed.xml', content_type='application/xml')
def xml_feed(request):
    return {'episodes': get_recent_episodes()}

Response pass-through (redirects, errors, etc.)

If your handler returns a Robyn Response object directly, the decorator passes it through untouched. This is how you handle redirects, custom error responses, or anything that shouldn’t be rendered through a template:

from robyn import Response, Headers

@app.get('/old-page')
@chameleon_robyn.template('home/index.pt')
def old_page(request):
    # This Response is returned as-is - no template rendering
    return Response(
        status_code=302,
        description='',
        headers=Headers({'Location': '/new-page'}),
    )

Modifying the response after rendering (__response_callback__)

Sometimes you want template rendering and a tweak to the final Response - the classic case is setting a session cookie after a login. Put a callable in your model under the reserved __response_callback__ key and the decorator invokes it with the freshly built Response after rendering:

from robyn import Response

@app.post('/account/login')
@chameleon_robyn.template('account/welcome.pt')
async def login(request):
    user = await authenticate(request)
    if not user:
        return chameleon_robyn.response('account/login_failed.pt', status_code=401)

    token = create_session_token(user)

    def set_session_cookie(resp: Response):
        resp.headers.append('Set-Cookie', f'session={token}; HttpOnly; Path=/')

    return {
        'user': user,
        '__response_callback__': set_session_cookie,
    }

A few details:

  • The key is popped before rendering, so it never reaches your template (and your dict isn’t mutated - returning a shared model is fine).
  • Mutate the Response in place; the callback’s return value is ignored.
  • The value must be callable - anything else raises ChameleonRobynException.

Friendly 404 pages

Call not_found() from any decorated handler to render a 404 page:

@app.get('/episodes/:episode_id')
@chameleon_robyn.template('episodes/detail.pt')
async def episode_detail(request, episode_id: int):
    episode = await get_episode(episode_id)
    if not episode:
        chameleon_robyn.not_found()  # Renders errors/404.pt with status 404

    return {'episode': episode}

By default it renders errors/404.pt, but you can specify any template:

chameleon_robyn.not_found(four04template_file='errors/custom_404.pt')

The 404 template receives a message variable describing the 404, which you can render with ${message} if you want.

Lower-level API

Sometimes you need to render a template outside of a decorator - in middleware, error handlers, or helper functions.

render() - get an HTML string

html = chameleon_robyn.render('emails/welcome.pt', username='Michael')
## Returns a string, no Response wrapping

response() - get a Robyn Response

resp = chameleon_robyn.response('errors/500.pt', status_code=500, error=str(e))
## Returns a fully-formed Robyn Response object

ChameleonTemplate class (Robyn’s TemplateInterface)

If you prefer Robyn’s built-in template pattern, ChameleonTemplate implements the same render_template() interface as Robyn’s JinjaTemplate (via a structural protocol, so importing chameleon_robyn never pulls in Jinja2):

from chameleon_robyn import ChameleonTemplate

chameleon = ChameleonTemplate('templates/', auto_reload=True)

@app.get('/page')
def page(request):
    return chameleon.render_template('page.pt', title='Hello')

This is a standalone class with its own template loader - it doesn’t require global_init().

global_init() reference

chameleon_robyn.global_init(
    template_folder,              # Path to your templates directory (required)
    auto_reload=False,            # Watch for template changes - use True in development
    cache_init=True,              # Skip re-initialization if already called
    restricted_namespace=True,    # Set to False if using Alpine.js, htmx @attributes, etc.
)

Alpine.js / htmx compatibility

Chameleon’s default namespace rules reject attributes like @click or :class because they look like XML namespace prefixes. If you’re using Alpine.js, htmx, or similar libraries, set restricted_namespace=False:

chameleon_robyn.global_init(template_folder, restricted_namespace=False)

This tells Chameleon to allow any attribute syntax in your templates.

Works with chameleon-partials

chameleon-partials provides render_partial() for rendering sub-templates (think: reusable components). It works alongside chameleon-robyn with no extra configuration - just initialize both at startup:

import chameleon_partials
import chameleon_robyn

chameleon_robyn.global_init(template_folder, auto_reload=dev_mode)
chameleon_partials.register_extensions(template_folder, auto_reload=dev_mode)

Then pass render_partial through your template context:

import chameleon_partials

@app.get('/')
@chameleon_robyn.template('home/index.pt')
def index(request):
    return {
        'render_partial': chameleon_partials.render_partial,
        'episodes': get_episodes(),
    }

And use it in your templates with tal:replace="structure ...":

<div tal:repeat="ep episodes">
    <div tal:replace="structure render_partial('shared/episode_card.pt', ep=ep)" />
</div>

Note: use tal:replace="structure ..." (not ${structure ...}) when rendering partials. The structure keyword tells Chameleon to insert the HTML without escaping, and tal:replace swaps out the placeholder element entirely.

Example apps

The repo includes two example apps you can run to see everything in action.

Setup

Clone the repo and install with the extras you need:

git clone https://github.com/mikeckennedy/chameleon-robyn.git
cd chameleon-robyn
python -m venv venv
source venv/bin/activate      # Windows: venv\Scripts\activate

## Install the package with example dependencies
pip install -e ".[examples]"

example/ - Core features

A Robyn app demonstrating the @template decorator, sync and async handlers, METAL macro layout inheritance, response pass-through redirects, a __response_callback__ cookie example, friendly 404 pages, search with query parameters, and an XML feed with a custom content type.

python example/app.py
## Open http://127.0.0.1:5555

example-partials/ - With chameleon-partials

The same app, but refactored to use chameleon-partials for reusable template components. The episode card markup lives in a single shared/episode_card.pt partial that’s shared across the episodes list and search results pages.

python example-partials/app.py
## Open http://127.0.0.1:5555

Compare the two to see how partials reduce template duplication - the episode list and search results templates go from inline card markup to a single tal:replace call each.

Template language cheat sheet

Chameleon uses TAL (Template Attribute Language) - a few patterns to get you going:

<!-- Variable substitution -->
<h1>${title}</h1>
<p>${user.name}</p>

<!-- HTML-safe output (skip auto-escaping) -->
<div tal:replace="structure rich_html_content" />

<!-- Conditionals -->
<div tal:condition="show_banner">Special offer!</div>
<div tal:condition="not items">Nothing here yet.</div>

<!-- Loops -->
<li tal:repeat="item items">${item.name} - $${item.price}</li>

<!-- Repeat with index -->
<tr tal:repeat="row data" class="${'odd' if repeat.row.odd else 'even'}">
    <td>${repeat.row.number}. ${row.title}</td>
</tr>

<!-- Attributes -->
<a href="/items/${item.id}" class="${'active' if is_current else ''}">
    ${item.name}
</a>

<!-- Layout inheritance with METAL macros -->
<!-- layout.pt -->
<html>
<body>
    <main metal:define-slot="content">Default content</main>
</body>
</html>

<!-- page.pt -->
<metal:block use-macro="load: shared/layout.pt">
<div metal:fill-slot="content">
    <h1>My page content here</h1>
</div>
</metal:block>

Full docs: chameleon.readthedocs.io

License

MIT