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.
- chameleon_robyn-0.1.0/.gitignore +220 -0
- chameleon_robyn-0.1.0/LICENSE +21 -0
- chameleon_robyn-0.1.0/PKG-INFO +370 -0
- chameleon_robyn-0.1.0/README.md +342 -0
- chameleon_robyn-0.1.0/chameleon_robyn/__init__.py +36 -0
- chameleon_robyn-0.1.0/chameleon_robyn/engine.py +196 -0
- chameleon_robyn-0.1.0/chameleon_robyn/exceptions.py +13 -0
- chameleon_robyn-0.1.0/pyproject.toml +75 -0
|
@@ -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
|
+
]
|