chameleon_robyn 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,220 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+
204
+ # Ruff stuff:
205
+ .ruff_cache/
206
+
207
+ # PyPI configuration file
208
+ .pypirc
209
+
210
+ # Marimo
211
+ marimo/_static/
212
+ marimo/_lsp/
213
+ __marimo__/
214
+
215
+ # Streamlit
216
+ .streamlit/secrets.toml
217
+
218
+ venv
219
+ .venv
220
+ .claude/settings.local.json
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Kennedy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,370 @@
1
+ Metadata-Version: 2.4
2
+ Name: chameleon_robyn
3
+ Version: 0.1.0
4
+ Summary: Adds integration of the Chameleon template language to Robyn.
5
+ Project-URL: Homepage, https://github.com/mikeckennedy/chameleon-robyn
6
+ Author-email: Michael Kennedy <michael@talkpython.fm>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: Chameleon,Robyn,integration,template
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: chameleon
21
+ Requires-Dist: robyn>=0.60
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio; extra == 'dev'
25
+ Provides-Extra: examples
26
+ Requires-Dist: chameleon-partials; extra == 'examples'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # chameleon-robyn
30
+
31
+ > **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.
32
+
33
+ Use [Chameleon](https://chameleon.readthedocs.io/) page templates in your [Robyn](https://robyn.tech/) 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.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ uv pip install chameleon-robyn
39
+ ```
40
+
41
+ This pulls in `chameleon` and `robyn` as dependencies.
42
+
43
+ ## Quick start
44
+
45
+ **1. Set up your project structure:**
46
+
47
+ ```
48
+ my_app/
49
+ ├── app.py
50
+ └── templates/
51
+ └── home/
52
+ └── index.pt
53
+ ```
54
+
55
+ **2. Create a template** (`templates/home/index.pt`):
56
+
57
+ ```xml
58
+ <!DOCTYPE html>
59
+ <html>
60
+ <body>
61
+ <h1>Hello, ${name}!</h1>
62
+ <ul>
63
+ <li tal:repeat="item items">${item}</li>
64
+ </ul>
65
+ </body>
66
+ </html>
67
+ ```
68
+
69
+ **3. Wire it up** (`app.py`):
70
+
71
+ ```python
72
+ from pathlib import Path
73
+ from robyn import Robyn
74
+ import chameleon_robyn
75
+
76
+ app = Robyn(__file__)
77
+
78
+ # Point Chameleon at your templates folder (do this once at startup)
79
+ template_folder = (Path(__file__).resolve().parent / 'templates').as_posix()
80
+ chameleon_robyn.global_init(template_folder, auto_reload=True)
81
+
82
+ @app.get('/')
83
+ @chameleon_robyn.template('home/index.pt')
84
+ async def index(request):
85
+ return {'name': 'World', 'items': ['Robyn', 'Chameleon', 'Python']}
86
+
87
+ app.start(host='127.0.0.1', port=8000)
88
+ ```
89
+
90
+ 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`.
91
+
92
+ ## The `@template` decorator
93
+
94
+ 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.
95
+
96
+ ### Explicit template path
97
+
98
+ ```python
99
+ @app.get('/episodes')
100
+ @chameleon_robyn.template('episodes/list.pt')
101
+ def episode_list(request):
102
+ return {'episodes': get_all_episodes()}
103
+ ```
104
+
105
+ ### Auto-naming (convention over configuration)
106
+
107
+ 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.pt` (falling back to `home_views/index.html`):
108
+
109
+ ```python
110
+ # Looks for templates/home_views/index.pt
111
+ @app.get('/')
112
+ @chameleon_robyn.template()
113
+ def index(request):
114
+ return {'message': 'Hello!'}
115
+ ```
116
+
117
+ You can even skip the parentheses:
118
+
119
+ ```python
120
+ @app.get('/')
121
+ @chameleon_robyn.template
122
+ def index(request):
123
+ return {'message': 'Hello!'}
124
+ ```
125
+
126
+ ### Async handlers - just works
127
+
128
+ ```python
129
+ @app.get('/dashboard')
130
+ @chameleon_robyn.template('dashboard.pt')
131
+ async def dashboard(request):
132
+ stats = await fetch_stats_from_db()
133
+ return {'stats': stats}
134
+ ```
135
+
136
+ ### Custom status codes and content types
137
+
138
+ ```python
139
+ # Return a 201 after creating a resource
140
+ @app.post('/items')
141
+ @chameleon_robyn.template('items/created.pt', status_code=201)
142
+ async def create_item(request):
143
+ item = await save_item(request.json())
144
+ return {'item': item}
145
+
146
+ # Serve an XML feed
147
+ @app.get('/feed.xml')
148
+ @chameleon_robyn.template('feed.xml', content_type='application/xml')
149
+ def xml_feed(request):
150
+ return {'episodes': get_recent_episodes()}
151
+ ```
152
+
153
+ ### Response pass-through (redirects, errors, etc.)
154
+
155
+ 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:
156
+
157
+ ```python
158
+ from robyn import Response, Headers
159
+
160
+ @app.get('/old-page')
161
+ @chameleon_robyn.template('home/index.pt')
162
+ def old_page(request):
163
+ # This Response is returned as-is - no template rendering
164
+ return Response(
165
+ status_code=302,
166
+ description='',
167
+ headers=Headers({'Location': '/new-page'}),
168
+ )
169
+ ```
170
+
171
+ ## Friendly 404 pages
172
+
173
+ Call `not_found()` from any decorated handler to render a 404 page:
174
+
175
+ ```python
176
+ @app.get('/episodes/:episode_id')
177
+ @chameleon_robyn.template('episodes/detail.pt')
178
+ async def episode_detail(request, episode_id: int):
179
+ episode = await get_episode(episode_id)
180
+ if not episode:
181
+ chameleon_robyn.not_found() # Renders errors/404.pt with status 404
182
+
183
+ return {'episode': episode}
184
+ ```
185
+
186
+ By default it renders `errors/404.pt`, but you can specify any template:
187
+
188
+ ```python
189
+ chameleon_robyn.not_found(four04template_file='errors/custom_404.pt')
190
+ ```
191
+
192
+ ## Lower-level API
193
+
194
+ Sometimes you need to render a template outside of a decorator - in middleware, error handlers, or helper functions.
195
+
196
+ ### `render()` - get an HTML string
197
+
198
+ ```python
199
+ html = chameleon_robyn.render('emails/welcome.pt', username='Michael')
200
+ # Returns a string, no Response wrapping
201
+ ```
202
+
203
+ ### `response()` - get a Robyn Response
204
+
205
+ ```python
206
+ resp = chameleon_robyn.response('errors/500.pt', status_code=500, error=str(e))
207
+ # Returns a fully-formed Robyn Response object
208
+ ```
209
+
210
+ ## `ChameleonTemplate` class (Robyn's TemplateInterface)
211
+
212
+ If you prefer Robyn's built-in template pattern, `ChameleonTemplate` implements `TemplateInterface` - the same abstract base class that Robyn's `JinjaTemplate` uses:
213
+
214
+ ```python
215
+ from chameleon_robyn import ChameleonTemplate
216
+
217
+ chameleon = ChameleonTemplate('templates/', auto_reload=True)
218
+
219
+ @app.get('/page')
220
+ def page(request):
221
+ return chameleon.render_template('page.pt', title='Hello')
222
+ ```
223
+
224
+ This is a standalone class with its own template loader - it doesn't require `global_init()`.
225
+
226
+ ## `global_init()` reference
227
+
228
+ ```python
229
+ chameleon_robyn.global_init(
230
+ template_folder, # Path to your templates directory (required)
231
+ auto_reload=False, # Watch for template changes - use True in development
232
+ cache_init=True, # Skip re-initialization if already called
233
+ restricted_namespace=True, # Set to False if using Alpine.js, htmx @attributes, etc.
234
+ )
235
+ ```
236
+
237
+ ### Alpine.js / htmx compatibility
238
+
239
+ 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`:
240
+
241
+ ```python
242
+ chameleon_robyn.global_init(template_folder, restricted_namespace=False)
243
+ ```
244
+
245
+ This tells Chameleon to allow any attribute syntax in your templates.
246
+
247
+ ## Works with chameleon-partials
248
+
249
+ [chameleon-partials](https://github.com/mikeckennedy/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:
250
+
251
+ ```python
252
+ import chameleon_partials
253
+ import chameleon_robyn
254
+
255
+ chameleon_robyn.global_init(template_folder, auto_reload=dev_mode)
256
+ chameleon_partials.register_extensions(template_folder, auto_reload=dev_mode)
257
+ ```
258
+
259
+ Then pass `render_partial` through your template context:
260
+
261
+ ```python
262
+ import chameleon_partials
263
+
264
+ @app.get('/')
265
+ @chameleon_robyn.template('home/index.pt')
266
+ def index(request):
267
+ return {
268
+ 'render_partial': chameleon_partials.render_partial,
269
+ 'episodes': get_episodes(),
270
+ }
271
+ ```
272
+
273
+ And use it in your templates with `tal:replace="structure ..."`:
274
+
275
+ ```xml
276
+ <div tal:repeat="ep episodes">
277
+ <div tal:replace="structure render_partial('shared/episode_card.pt', ep=ep)" />
278
+ </div>
279
+ ```
280
+
281
+ 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.
282
+
283
+ ## Example apps
284
+
285
+ The repo includes two example apps you can run to see everything in action.
286
+
287
+ ### Setup
288
+
289
+ Clone the repo and install with the extras you need:
290
+
291
+ ```bash
292
+ git clone https://github.com/mikeckennedy/chameleon-robyn.git
293
+ cd chameleon-robyn
294
+ python -m venv venv
295
+ source venv/bin/activate # Windows: venv\Scripts\activate
296
+
297
+ # Install the package with example dependencies
298
+ pip install -e ".[examples]"
299
+ ```
300
+
301
+ ### `example/` - Core features
302
+
303
+ A Robyn app demonstrating the `@template` decorator, sync and async handlers, METAL macro layout inheritance, response pass-through redirects, friendly 404 pages, search with query parameters, and an XML feed with a custom content type.
304
+
305
+ ```bash
306
+ python example/app.py
307
+ # Open http://127.0.0.1:5555
308
+ ```
309
+
310
+ ### `example-partials/` - With chameleon-partials
311
+
312
+ The same app, but refactored to use [chameleon-partials](https://github.com/mikeckennedy/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.
313
+
314
+ ```bash
315
+ python example-partials/app.py
316
+ # Open http://127.0.0.1:5555
317
+ ```
318
+
319
+ 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.
320
+
321
+ ## Template language cheat sheet
322
+
323
+ Chameleon uses TAL (Template Attribute Language) - a few patterns to get you going:
324
+
325
+ ```xml
326
+ <!-- Variable substitution -->
327
+ <h1>${title}</h1>
328
+ <p>${user.name}</p>
329
+
330
+ <!-- HTML-safe output (skip auto-escaping) -->
331
+ <div tal:replace="structure rich_html_content" />
332
+
333
+ <!-- Conditionals -->
334
+ <div tal:condition="show_banner">Special offer!</div>
335
+ <div tal:condition="not items">Nothing here yet.</div>
336
+
337
+ <!-- Loops -->
338
+ <li tal:repeat="item items">${item.name} - $${item.price}</li>
339
+
340
+ <!-- Repeat with index -->
341
+ <tr tal:repeat="row data" class="${'odd' if repeat.row.odd else 'even'}">
342
+ <td>${repeat.row.number}. ${row.title}</td>
343
+ </tr>
344
+
345
+ <!-- Attributes -->
346
+ <a href="/items/${item.id}" class="${'active' if is_current else ''}">
347
+ ${item.name}
348
+ </a>
349
+
350
+ <!-- Layout inheritance with METAL macros -->
351
+ <!-- layout.pt -->
352
+ <html>
353
+ <body>
354
+ <main metal:define-slot="content">Default content</main>
355
+ </body>
356
+ </html>
357
+
358
+ <!-- page.pt -->
359
+ <metal:block use-macro="load: shared/layout.pt">
360
+ <div metal:fill-slot="content">
361
+ <h1>My page content here</h1>
362
+ </div>
363
+ </metal:block>
364
+ ```
365
+
366
+ Full docs: [chameleon.readthedocs.io](https://chameleon.readthedocs.io/)
367
+
368
+ ## License
369
+
370
+ MIT
@@ -0,0 +1,342 @@
1
+ # chameleon-robyn
2
+
3
+ > **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.
4
+
5
+ Use [Chameleon](https://chameleon.readthedocs.io/) page templates in your [Robyn](https://robyn.tech/) 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.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ uv pip install chameleon-robyn
11
+ ```
12
+
13
+ This pulls in `chameleon` and `robyn` as dependencies.
14
+
15
+ ## Quick start
16
+
17
+ **1. Set up your project structure:**
18
+
19
+ ```
20
+ my_app/
21
+ ├── app.py
22
+ └── templates/
23
+ └── home/
24
+ └── index.pt
25
+ ```
26
+
27
+ **2. Create a template** (`templates/home/index.pt`):
28
+
29
+ ```xml
30
+ <!DOCTYPE html>
31
+ <html>
32
+ <body>
33
+ <h1>Hello, ${name}!</h1>
34
+ <ul>
35
+ <li tal:repeat="item items">${item}</li>
36
+ </ul>
37
+ </body>
38
+ </html>
39
+ ```
40
+
41
+ **3. Wire it up** (`app.py`):
42
+
43
+ ```python
44
+ from pathlib import Path
45
+ from robyn import Robyn
46
+ import chameleon_robyn
47
+
48
+ app = Robyn(__file__)
49
+
50
+ # Point Chameleon at your templates folder (do this once at startup)
51
+ template_folder = (Path(__file__).resolve().parent / 'templates').as_posix()
52
+ chameleon_robyn.global_init(template_folder, auto_reload=True)
53
+
54
+ @app.get('/')
55
+ @chameleon_robyn.template('home/index.pt')
56
+ async def index(request):
57
+ return {'name': 'World', 'items': ['Robyn', 'Chameleon', 'Python']}
58
+
59
+ app.start(host='127.0.0.1', port=8000)
60
+ ```
61
+
62
+ 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`.
63
+
64
+ ## The `@template` decorator
65
+
66
+ 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.
67
+
68
+ ### Explicit template path
69
+
70
+ ```python
71
+ @app.get('/episodes')
72
+ @chameleon_robyn.template('episodes/list.pt')
73
+ def episode_list(request):
74
+ return {'episodes': get_all_episodes()}
75
+ ```
76
+
77
+ ### Auto-naming (convention over configuration)
78
+
79
+ 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.pt` (falling back to `home_views/index.html`):
80
+
81
+ ```python
82
+ # Looks for templates/home_views/index.pt
83
+ @app.get('/')
84
+ @chameleon_robyn.template()
85
+ def index(request):
86
+ return {'message': 'Hello!'}
87
+ ```
88
+
89
+ You can even skip the parentheses:
90
+
91
+ ```python
92
+ @app.get('/')
93
+ @chameleon_robyn.template
94
+ def index(request):
95
+ return {'message': 'Hello!'}
96
+ ```
97
+
98
+ ### Async handlers - just works
99
+
100
+ ```python
101
+ @app.get('/dashboard')
102
+ @chameleon_robyn.template('dashboard.pt')
103
+ async def dashboard(request):
104
+ stats = await fetch_stats_from_db()
105
+ return {'stats': stats}
106
+ ```
107
+
108
+ ### Custom status codes and content types
109
+
110
+ ```python
111
+ # Return a 201 after creating a resource
112
+ @app.post('/items')
113
+ @chameleon_robyn.template('items/created.pt', status_code=201)
114
+ async def create_item(request):
115
+ item = await save_item(request.json())
116
+ return {'item': item}
117
+
118
+ # Serve an XML feed
119
+ @app.get('/feed.xml')
120
+ @chameleon_robyn.template('feed.xml', content_type='application/xml')
121
+ def xml_feed(request):
122
+ return {'episodes': get_recent_episodes()}
123
+ ```
124
+
125
+ ### Response pass-through (redirects, errors, etc.)
126
+
127
+ 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:
128
+
129
+ ```python
130
+ from robyn import Response, Headers
131
+
132
+ @app.get('/old-page')
133
+ @chameleon_robyn.template('home/index.pt')
134
+ def old_page(request):
135
+ # This Response is returned as-is - no template rendering
136
+ return Response(
137
+ status_code=302,
138
+ description='',
139
+ headers=Headers({'Location': '/new-page'}),
140
+ )
141
+ ```
142
+
143
+ ## Friendly 404 pages
144
+
145
+ Call `not_found()` from any decorated handler to render a 404 page:
146
+
147
+ ```python
148
+ @app.get('/episodes/:episode_id')
149
+ @chameleon_robyn.template('episodes/detail.pt')
150
+ async def episode_detail(request, episode_id: int):
151
+ episode = await get_episode(episode_id)
152
+ if not episode:
153
+ chameleon_robyn.not_found() # Renders errors/404.pt with status 404
154
+
155
+ return {'episode': episode}
156
+ ```
157
+
158
+ By default it renders `errors/404.pt`, but you can specify any template:
159
+
160
+ ```python
161
+ chameleon_robyn.not_found(four04template_file='errors/custom_404.pt')
162
+ ```
163
+
164
+ ## Lower-level API
165
+
166
+ Sometimes you need to render a template outside of a decorator - in middleware, error handlers, or helper functions.
167
+
168
+ ### `render()` - get an HTML string
169
+
170
+ ```python
171
+ html = chameleon_robyn.render('emails/welcome.pt', username='Michael')
172
+ # Returns a string, no Response wrapping
173
+ ```
174
+
175
+ ### `response()` - get a Robyn Response
176
+
177
+ ```python
178
+ resp = chameleon_robyn.response('errors/500.pt', status_code=500, error=str(e))
179
+ # Returns a fully-formed Robyn Response object
180
+ ```
181
+
182
+ ## `ChameleonTemplate` class (Robyn's TemplateInterface)
183
+
184
+ If you prefer Robyn's built-in template pattern, `ChameleonTemplate` implements `TemplateInterface` - the same abstract base class that Robyn's `JinjaTemplate` uses:
185
+
186
+ ```python
187
+ from chameleon_robyn import ChameleonTemplate
188
+
189
+ chameleon = ChameleonTemplate('templates/', auto_reload=True)
190
+
191
+ @app.get('/page')
192
+ def page(request):
193
+ return chameleon.render_template('page.pt', title='Hello')
194
+ ```
195
+
196
+ This is a standalone class with its own template loader - it doesn't require `global_init()`.
197
+
198
+ ## `global_init()` reference
199
+
200
+ ```python
201
+ chameleon_robyn.global_init(
202
+ template_folder, # Path to your templates directory (required)
203
+ auto_reload=False, # Watch for template changes - use True in development
204
+ cache_init=True, # Skip re-initialization if already called
205
+ restricted_namespace=True, # Set to False if using Alpine.js, htmx @attributes, etc.
206
+ )
207
+ ```
208
+
209
+ ### Alpine.js / htmx compatibility
210
+
211
+ 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`:
212
+
213
+ ```python
214
+ chameleon_robyn.global_init(template_folder, restricted_namespace=False)
215
+ ```
216
+
217
+ This tells Chameleon to allow any attribute syntax in your templates.
218
+
219
+ ## Works with chameleon-partials
220
+
221
+ [chameleon-partials](https://github.com/mikeckennedy/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:
222
+
223
+ ```python
224
+ import chameleon_partials
225
+ import chameleon_robyn
226
+
227
+ chameleon_robyn.global_init(template_folder, auto_reload=dev_mode)
228
+ chameleon_partials.register_extensions(template_folder, auto_reload=dev_mode)
229
+ ```
230
+
231
+ Then pass `render_partial` through your template context:
232
+
233
+ ```python
234
+ import chameleon_partials
235
+
236
+ @app.get('/')
237
+ @chameleon_robyn.template('home/index.pt')
238
+ def index(request):
239
+ return {
240
+ 'render_partial': chameleon_partials.render_partial,
241
+ 'episodes': get_episodes(),
242
+ }
243
+ ```
244
+
245
+ And use it in your templates with `tal:replace="structure ..."`:
246
+
247
+ ```xml
248
+ <div tal:repeat="ep episodes">
249
+ <div tal:replace="structure render_partial('shared/episode_card.pt', ep=ep)" />
250
+ </div>
251
+ ```
252
+
253
+ 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.
254
+
255
+ ## Example apps
256
+
257
+ The repo includes two example apps you can run to see everything in action.
258
+
259
+ ### Setup
260
+
261
+ Clone the repo and install with the extras you need:
262
+
263
+ ```bash
264
+ git clone https://github.com/mikeckennedy/chameleon-robyn.git
265
+ cd chameleon-robyn
266
+ python -m venv venv
267
+ source venv/bin/activate # Windows: venv\Scripts\activate
268
+
269
+ # Install the package with example dependencies
270
+ pip install -e ".[examples]"
271
+ ```
272
+
273
+ ### `example/` - Core features
274
+
275
+ A Robyn app demonstrating the `@template` decorator, sync and async handlers, METAL macro layout inheritance, response pass-through redirects, friendly 404 pages, search with query parameters, and an XML feed with a custom content type.
276
+
277
+ ```bash
278
+ python example/app.py
279
+ # Open http://127.0.0.1:5555
280
+ ```
281
+
282
+ ### `example-partials/` - With chameleon-partials
283
+
284
+ The same app, but refactored to use [chameleon-partials](https://github.com/mikeckennedy/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.
285
+
286
+ ```bash
287
+ python example-partials/app.py
288
+ # Open http://127.0.0.1:5555
289
+ ```
290
+
291
+ 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.
292
+
293
+ ## Template language cheat sheet
294
+
295
+ Chameleon uses TAL (Template Attribute Language) - a few patterns to get you going:
296
+
297
+ ```xml
298
+ <!-- Variable substitution -->
299
+ <h1>${title}</h1>
300
+ <p>${user.name}</p>
301
+
302
+ <!-- HTML-safe output (skip auto-escaping) -->
303
+ <div tal:replace="structure rich_html_content" />
304
+
305
+ <!-- Conditionals -->
306
+ <div tal:condition="show_banner">Special offer!</div>
307
+ <div tal:condition="not items">Nothing here yet.</div>
308
+
309
+ <!-- Loops -->
310
+ <li tal:repeat="item items">${item.name} - $${item.price}</li>
311
+
312
+ <!-- Repeat with index -->
313
+ <tr tal:repeat="row data" class="${'odd' if repeat.row.odd else 'even'}">
314
+ <td>${repeat.row.number}. ${row.title}</td>
315
+ </tr>
316
+
317
+ <!-- Attributes -->
318
+ <a href="/items/${item.id}" class="${'active' if is_current else ''}">
319
+ ${item.name}
320
+ </a>
321
+
322
+ <!-- Layout inheritance with METAL macros -->
323
+ <!-- layout.pt -->
324
+ <html>
325
+ <body>
326
+ <main metal:define-slot="content">Default content</main>
327
+ </body>
328
+ </html>
329
+
330
+ <!-- page.pt -->
331
+ <metal:block use-macro="load: shared/layout.pt">
332
+ <div metal:fill-slot="content">
333
+ <h1>My page content here</h1>
334
+ </div>
335
+ </metal:block>
336
+ ```
337
+
338
+ Full docs: [chameleon.readthedocs.io](https://chameleon.readthedocs.io/)
339
+
340
+ ## License
341
+
342
+ MIT
@@ -0,0 +1,36 @@
1
+ """chameleon-robyn - Adds integration of the Chameleon template language to Robyn."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _get_version
5
+
6
+ from .engine import (
7
+ ChameleonTemplate,
8
+ clear,
9
+ global_init, # noqa: I001
10
+ not_found,
11
+ render,
12
+ response,
13
+ template,
14
+ )
15
+ from .exceptions import (
16
+ ChameleonRobynException,
17
+ ChameleonRobynNotFoundException,
18
+ )
19
+
20
+ try:
21
+ __version__ = _get_version('chameleon_robyn')
22
+ except PackageNotFoundError:
23
+ __version__ = '0.0.0'
24
+
25
+ __author__ = 'Michael Kennedy <michael@talkpython.fm>'
26
+ __all__ = [
27
+ 'template',
28
+ 'global_init',
29
+ 'clear',
30
+ 'not_found',
31
+ 'render',
32
+ 'response',
33
+ 'ChameleonTemplate',
34
+ 'ChameleonRobynException',
35
+ 'ChameleonRobynNotFoundException',
36
+ ]
@@ -0,0 +1,196 @@
1
+ import inspect
2
+ import os
3
+ from functools import wraps
4
+ from typing import Any, Callable, Optional, Protocol, Union, runtime_checkable
5
+
6
+ from chameleon import PageTemplate, PageTemplateLoader
7
+ from robyn import Headers, Response, status_codes
8
+
9
+ from chameleon_robyn.exceptions import (
10
+ ChameleonRobynException,
11
+ ChameleonRobynNotFoundException,
12
+ )
13
+
14
+ __templates: Optional[PageTemplateLoader] = None
15
+ template_path: Optional[str] = None
16
+
17
+
18
+ def global_init(template_folder: str, auto_reload=False, cache_init=True, restricted_namespace=True):
19
+ """
20
+ Initialize the Chameleon template engine.
21
+
22
+ Args:
23
+ template_folder: Path to the template directory
24
+ auto_reload: Whether to auto-reload templates on change
25
+ cache_init: Whether to cache initialization (skip if already initialized)
26
+ restricted_namespace: If True, only TAL/METAL/i18n namespaces are allowed.
27
+ If False, allows attribute-based JS frameworks like Alpine.js
28
+ to use shorthand syntax (@click, :class, etc.)
29
+ """
30
+ global __templates, template_path
31
+
32
+ if __templates and cache_init:
33
+ return
34
+
35
+ if not template_folder:
36
+ msg = 'The template_folder must be specified.'
37
+ raise ChameleonRobynException(msg)
38
+
39
+ if not os.path.isdir(template_folder):
40
+ msg = f"The specified template folder must be a folder, it's not: {template_folder}"
41
+ raise ChameleonRobynException(msg)
42
+
43
+ template_path = template_folder
44
+ __templates = PageTemplateLoader(
45
+ template_folder,
46
+ auto_reload=auto_reload,
47
+ restricted_namespace=restricted_namespace,
48
+ )
49
+
50
+
51
+ def clear():
52
+ global __templates, template_path
53
+ __templates = None
54
+ template_path = None
55
+
56
+
57
+ def render(template_file: str, **template_data: dict) -> str:
58
+ if not __templates:
59
+ raise ChameleonRobynException('You must call global_init() before rendering templates.')
60
+
61
+ page: PageTemplate = __templates[template_file]
62
+ return page.render(encoding='utf-8', **template_data)
63
+
64
+
65
+ def response(template_file: str, content_type='text/html', status_code=200, **template_data) -> Response:
66
+ html = render(template_file, **template_data)
67
+ return Response(
68
+ status_code=status_code,
69
+ description=html,
70
+ headers=Headers({'Content-Type': f'{content_type}; charset=utf-8'}),
71
+ )
72
+
73
+
74
+ def template(
75
+ template_file: Optional[Union[Callable, str]] = None,
76
+ content_type: str = 'text/html',
77
+ status_code: int = 200,
78
+ ):
79
+ """
80
+ Decorate a Robyn view method to render an HTML response.
81
+
82
+ :param template_file: Optional, the Chameleon template file (path relative to template folder, *.pt).
83
+ :param content_type: The mimetype response (defaults to text/html).
84
+ :param status_code: Default status code for responses.
85
+ :return: Decorator for Robyn route handlers
86
+ """
87
+
88
+ wrapped_function = None
89
+ if callable(template_file):
90
+ wrapped_function = template_file
91
+ template_file = None
92
+
93
+ def response_inner(f):
94
+ nonlocal template_file
95
+ global template_path
96
+
97
+ if not template_path:
98
+ template_path = 'templates'
99
+
100
+ if not template_file:
101
+ module = f.__module__
102
+ if '.' in module:
103
+ module = module.split('.')[-1]
104
+ view = f.__name__
105
+ template_file = f'{module}/{view}.html'
106
+
107
+ if not os.path.exists(os.path.join(template_path, template_file)):
108
+ template_file = f'{module}/{view}.pt'
109
+
110
+ @wraps(f)
111
+ def sync_view_method(*args, **kwargs) -> Response:
112
+ try:
113
+ response_val = f(*args, **kwargs)
114
+ return __render_response(template_file, response_val, content_type, status_code)
115
+ except ChameleonRobynNotFoundException as nfe:
116
+ return __render_response(nfe.template_file, {}, 'text/html', 404)
117
+
118
+ @wraps(f)
119
+ async def async_view_method(*args, **kwargs) -> Response:
120
+ try:
121
+ response_val = await f(*args, **kwargs)
122
+ return __render_response(template_file, response_val, content_type, status_code)
123
+ except ChameleonRobynNotFoundException as nfe:
124
+ return __render_response(nfe.template_file, {}, 'text/html', 404)
125
+
126
+ if inspect.iscoroutinefunction(f):
127
+ return async_view_method
128
+ else:
129
+ return sync_view_method
130
+
131
+ return response_inner(wrapped_function) if wrapped_function else response_inner
132
+
133
+
134
+ def __is_response(resp) -> bool:
135
+ return isinstance(resp, Response)
136
+
137
+
138
+ def __render_response(template_file: str, response_val: Any, content_type: str, status_code: int = 200) -> Response:
139
+ if __is_response(response_val):
140
+ return response_val
141
+
142
+ if template_file and not isinstance(response_val, dict):
143
+ msg = f'Invalid return type {type(response_val)}, we expected a dict or Response as the return value.'
144
+ raise ChameleonRobynException(msg)
145
+
146
+ model = response_val
147
+
148
+ html = render(template_file, **model)
149
+ return Response(
150
+ status_code=status_code,
151
+ description=html,
152
+ headers=Headers({'Content-Type': f'{content_type}; charset=utf-8'}),
153
+ )
154
+
155
+
156
+ def not_found(four04template_file: str = 'errors/404.pt'):
157
+ msg = 'The URL resulted in a 404 response.'
158
+
159
+ if four04template_file and four04template_file.strip():
160
+ raise ChameleonRobynNotFoundException(msg, four04template_file)
161
+ else:
162
+ raise ChameleonRobynNotFoundException(msg)
163
+
164
+
165
+ @runtime_checkable
166
+ class TemplateInterface(Protocol):
167
+ """Protocol matching Robyn's TemplateInterface without importing Jinja2."""
168
+
169
+ def render_template(self, *args, **kwargs) -> Response: ...
170
+
171
+
172
+ class ChameleonTemplate(TemplateInterface):
173
+ """Chameleon template engine implementing Robyn's TemplateInterface."""
174
+
175
+ def __init__(
176
+ self,
177
+ directory: str,
178
+ auto_reload=False,
179
+ encoding='utf-8',
180
+ restricted_namespace=True,
181
+ ):
182
+ self.loader = PageTemplateLoader(
183
+ directory,
184
+ auto_reload=auto_reload,
185
+ restricted_namespace=restricted_namespace,
186
+ )
187
+ self.encoding = encoding
188
+
189
+ def render_template(self, template_name: str, **kwargs) -> Response:
190
+ page: PageTemplate = self.loader[template_name]
191
+ rendered = page.render(encoding=self.encoding, **kwargs)
192
+ return Response(
193
+ status_code=status_codes.HTTP_200_OK,
194
+ description=rendered,
195
+ headers=Headers({'Content-Type': 'text/html; charset=utf-8'}),
196
+ )
@@ -0,0 +1,13 @@
1
+ from typing import Optional
2
+
3
+
4
+ class ChameleonRobynException(Exception):
5
+ pass
6
+
7
+
8
+ class ChameleonRobynNotFoundException(ChameleonRobynException):
9
+ def __init__(self, message: Optional[str] = None, four04template_file: str = 'errors/404.pt'):
10
+ super().__init__(message)
11
+
12
+ self.template_file: str = four04template_file
13
+ self.message: Optional[str] = message
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "chameleon_robyn"
3
+ version = "0.1.0"
4
+ description = "Adds integration of the Chameleon template language to Robyn."
5
+ readme = { file = "README.md", content-type = "text/markdown" }
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Michael Kennedy", email = "michael@talkpython.fm" }
10
+ ]
11
+ keywords = ["Robyn", "Chameleon", "template", "integration"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Programming Language :: Python :: 3.15"
22
+ ]
23
+ dependencies = [
24
+ "robyn>=0.60",
25
+ "Chameleon"
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest",
31
+ "pytest-asyncio",
32
+ ]
33
+ examples = [
34
+ "chameleon-partials",
35
+ ]
36
+
37
+ [project.urls]
38
+ "Homepage" = "https://github.com/mikeckennedy/chameleon-robyn"
39
+
40
+
41
+ [build-system]
42
+ requires = ["hatchling>=1.21.0", "hatch-vcs>=0.3.0"]
43
+ build-backend = "hatchling.build"
44
+
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ packages = ["chameleon_robyn"]
51
+ exclude = [
52
+ "/.github",
53
+ "/dist",
54
+ "/tests",
55
+ "/example",
56
+ "/example-partials",
57
+ "/venv",
58
+ "/.venv",
59
+ "ruff.toml",
60
+ "requirements-dev.txt",
61
+ ]
62
+
63
+ [tool.hatch.build.targets.wheel]
64
+ packages = ["chameleon_robyn"]
65
+ exclude = [
66
+ "/.github",
67
+ "/dist",
68
+ "/tests",
69
+ "/example",
70
+ "/example-partials",
71
+ "/venv",
72
+ "/.venv",
73
+ "ruff.toml",
74
+ "requirements-dev.txt",
75
+ ]