plain.templates 0.1.0__py3-none-any.whl
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.
- plain/templates/CHANGELOG.md +16 -0
- plain/templates/README.md +395 -0
- plain/templates/__init__.py +17 -0
- plain/templates/config.py +6 -0
- plain/templates/core.py +40 -0
- plain/templates/default_settings.py +2 -0
- plain/templates/jinja/__init__.py +78 -0
- plain/templates/jinja/environments.py +64 -0
- plain/templates/jinja/extensions.py +46 -0
- plain/templates/jinja/filters.py +55 -0
- plain/templates/jinja/globals.py +16 -0
- plain/templates/views.py +292 -0
- plain_templates-0.1.0.dist-info/METADATA +407 -0
- plain_templates-0.1.0.dist-info/RECORD +16 -0
- plain_templates-0.1.0.dist-info/WHEEL +4 -0
- plain_templates-0.1.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# plain-templates changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0](https://github.com/dropseed/plain/releases/plain-templates@0.1.0) (2026-05-12)
|
|
4
|
+
|
|
5
|
+
### What's changed
|
|
6
|
+
|
|
7
|
+
- First release. `plain.templates` is now a separate package, extracted from `plain` core ([19b622a7ca](https://github.com/dropseed/plain/commit/19b622a7ca)). It owns:
|
|
8
|
+
- The Jinja2 engine, `DefaultEnvironment`, the `TEMPLATES_JINJA_ENVIRONMENT` setting
|
|
9
|
+
- `Template` and `TemplateFileMissing`
|
|
10
|
+
- `register_template_global`, `register_template_filter`, `register_template_extension`
|
|
11
|
+
- The template-rendering view bases: `TemplateView`, `FormView`, `DetailView`, `CreateView`, `UpdateView`, `DeleteView`, `ListView` at `plain.templates.views`
|
|
12
|
+
- The `{status_code}.html` error rendering wired through `plain` core's framework default exception handler (lazy import — degrades to plain text if this package isn't installed)
|
|
13
|
+
|
|
14
|
+
### Upgrade instructions
|
|
15
|
+
|
|
16
|
+
- Install the package and add it to `INSTALLED_PACKAGES` — see the [`plain` 0.143.0 release notes](../../../plain/plain/CHANGELOG.md) for the full migration.
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# plain.templates
|
|
2
|
+
|
|
3
|
+
**Render HTML templates using Jinja2.**
|
|
4
|
+
|
|
5
|
+
- [Overview](#overview)
|
|
6
|
+
- [Template files](#template-files)
|
|
7
|
+
- [Template-rendering views](#template-rendering-views)
|
|
8
|
+
- [TemplateView](#templateview)
|
|
9
|
+
- [FormView](#formview)
|
|
10
|
+
- [DetailView, CreateView, UpdateView, DeleteView, ListView](#object-views)
|
|
11
|
+
- [Template context](#template-context)
|
|
12
|
+
- [Built-in globals](#built-in-globals)
|
|
13
|
+
- [Built-in filters](#built-in-filters)
|
|
14
|
+
- [Custom globals and filters](#custom-globals-and-filters)
|
|
15
|
+
- [Custom template extensions](#custom-template-extensions)
|
|
16
|
+
- [Rendering templates manually](#rendering-templates-manually)
|
|
17
|
+
- [Custom Jinja environment](#custom-jinja-environment)
|
|
18
|
+
- [Forms](#forms)
|
|
19
|
+
- [FAQs](#faqs)
|
|
20
|
+
- [Installation](#installation)
|
|
21
|
+
|
|
22
|
+
## Overview
|
|
23
|
+
|
|
24
|
+
Plain uses Jinja2 for template rendering. You can refer to the [Jinja documentation](https://jinja.palletsprojects.com/en/stable/) for all of the features available.
|
|
25
|
+
|
|
26
|
+
Templates are typically used with [`TemplateView`](./views.py#TemplateView) or one of its subclasses (see [Template-rendering views](#template-rendering-views)).
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
# app/views.py
|
|
30
|
+
from plain.templates.views import TemplateView
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExampleView(TemplateView):
|
|
34
|
+
template_name = "example.html"
|
|
35
|
+
|
|
36
|
+
def get_template_context(self):
|
|
37
|
+
context = super().get_template_context()
|
|
38
|
+
context["message"] = "Hello, world!"
|
|
39
|
+
return context
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```html
|
|
43
|
+
<!-- app/templates/example.html -->
|
|
44
|
+
{% extends "base.html" %}
|
|
45
|
+
|
|
46
|
+
{% block content %}
|
|
47
|
+
<h1>{{ message }}</h1>
|
|
48
|
+
{% endblock %}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Template files
|
|
52
|
+
|
|
53
|
+
Template files can live in two locations:
|
|
54
|
+
|
|
55
|
+
1. **`app/templates/`** - Your app's templates (highest priority)
|
|
56
|
+
2. **`{package}/templates/`** - Templates inside any installed package
|
|
57
|
+
|
|
58
|
+
All template directories are merged together, so you can override templates from installed packages by creating a file with the same name in `app/templates/`.
|
|
59
|
+
|
|
60
|
+
## Template-rendering views
|
|
61
|
+
|
|
62
|
+
`plain.templates.views` ships the view classes that render templates. The base [`View`](../../../plain/plain/views/README.md) class lives in core `plain.views` and doesn't know about templates — install `plain.templates` to use any of these.
|
|
63
|
+
|
|
64
|
+
### TemplateView
|
|
65
|
+
|
|
66
|
+
[`TemplateView`](./views.py#TemplateView) renders a Jinja template:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from plain.templates.views import TemplateView
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ExampleView(TemplateView):
|
|
73
|
+
template_name = "example.html"
|
|
74
|
+
|
|
75
|
+
def get_template_context(self):
|
|
76
|
+
context = super().get_template_context()
|
|
77
|
+
context["message"] = "Hello, world!"
|
|
78
|
+
return context
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For simple pages that don't need custom context, configure `TemplateView` directly in your URL routes:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from plain.templates.views import TemplateView
|
|
85
|
+
from plain.urls import path, Router
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AppRouter(Router):
|
|
89
|
+
routes = [
|
|
90
|
+
path("/example/", TemplateView.as_view(template_name="example.html")),
|
|
91
|
+
]
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### FormView
|
|
95
|
+
|
|
96
|
+
[`FormView`](./views.py#FormView) handles displaying and processing [forms](../../../plain/plain/forms/README.md). The form is automatically available in your template as `form`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from plain.templates.views import FormView
|
|
100
|
+
from .forms import ExampleForm
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ExampleView(FormView):
|
|
104
|
+
template_name = "example.html"
|
|
105
|
+
form_class = ExampleForm
|
|
106
|
+
success_url = "."
|
|
107
|
+
|
|
108
|
+
def form_valid(self, form):
|
|
109
|
+
return super().form_valid(form)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Object views
|
|
113
|
+
|
|
114
|
+
[`DetailView`](./views.py#DetailView), [`CreateView`](./views.py#CreateView), [`UpdateView`](./views.py#UpdateView), [`DeleteView`](./views.py#DeleteView), and [`ListView`](./views.py#ListView) provide standard CRUD scaffolding. Each requires you to implement `get_object()` or `get_objects()`:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from plain.templates.views import DetailView
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ExampleDetailView(DetailView):
|
|
121
|
+
template_name = "detail.html"
|
|
122
|
+
|
|
123
|
+
def get_object(self):
|
|
124
|
+
return MyObjectClass.query.get(
|
|
125
|
+
id=self.url_kwargs["id"],
|
|
126
|
+
user=self.request.user,
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The single object is exposed in templates as `object`; list views expose `objects`. Set `context_object_name` for a more descriptive name.
|
|
131
|
+
|
|
132
|
+
## Template context
|
|
133
|
+
|
|
134
|
+
When using `TemplateView`, you pass data to templates by overriding `get_template_context()`:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from plain.templates.views import TemplateView
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ProductView(TemplateView):
|
|
141
|
+
template_name = "product.html"
|
|
142
|
+
|
|
143
|
+
def get_template_context(self):
|
|
144
|
+
context = super().get_template_context()
|
|
145
|
+
context["product"] = Product.objects.get(id=self.url_kwargs["id"])
|
|
146
|
+
context["related_products"] = Product.objects.filter(category=context["product"].category)[:5]
|
|
147
|
+
return context
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The context is then available in your template:
|
|
151
|
+
|
|
152
|
+
```html
|
|
153
|
+
<h1>{{ product.name }}</h1>
|
|
154
|
+
<ul>
|
|
155
|
+
{% for item in related_products %}
|
|
156
|
+
<li>{{ item.name }}</li>
|
|
157
|
+
{% endfor %}
|
|
158
|
+
</ul>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Built-in globals
|
|
162
|
+
|
|
163
|
+
Plain provides several [global functions](./jinja/globals.py) available in all templates:
|
|
164
|
+
|
|
165
|
+
| Global | Description |
|
|
166
|
+
| ---------------------------- | ---------------------------------- |
|
|
167
|
+
| `asset(path)` | Returns the URL for a static asset |
|
|
168
|
+
| `url(name, *args, **kwargs)` | Reverses a URL by name |
|
|
169
|
+
| `Paginator` | The Paginator class for pagination |
|
|
170
|
+
| `now()` | Returns the current datetime |
|
|
171
|
+
| `timedelta` | The timedelta class for date math |
|
|
172
|
+
| `localtime(dt)` | Converts a datetime to local time |
|
|
173
|
+
|
|
174
|
+
```html
|
|
175
|
+
<link rel="stylesheet" href="{{ asset('css/style.css') }}">
|
|
176
|
+
<a href="{{ url('product_detail', id=product.id) }}">View</a>
|
|
177
|
+
<p>Generated at {{ now() }}</p>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Built-in filters
|
|
181
|
+
|
|
182
|
+
Plain includes several [filters](./jinja/filters.py) for common operations:
|
|
183
|
+
|
|
184
|
+
| Filter | Description |
|
|
185
|
+
| ----------------------------- | ------------------------------------ |
|
|
186
|
+
| `strftime(format)` | Formats a datetime |
|
|
187
|
+
| `strptime(format)` | Parses a string to datetime |
|
|
188
|
+
| `fromtimestamp(ts)` | Creates datetime from timestamp |
|
|
189
|
+
| `fromisoformat(s)` | Creates datetime from ISO string |
|
|
190
|
+
| `localtime(tz)` | Converts to local timezone |
|
|
191
|
+
| `timeuntil` | Human-readable time until a date |
|
|
192
|
+
| `timesince` | Human-readable time since a date |
|
|
193
|
+
| `json_script(id)` | Outputs JSON safely in a script tag |
|
|
194
|
+
| `islice(stop)` | Slices iterables (useful for dicts) |
|
|
195
|
+
| `pluralize(singular, plural)` | Returns plural suffix based on count |
|
|
196
|
+
|
|
197
|
+
```html
|
|
198
|
+
<p>Posted {{ post.created_at|timesince }} ago</p>
|
|
199
|
+
<p>{{ items|length }} item{{ items|length|pluralize }}</p>
|
|
200
|
+
<p>{{ 5 }} ox{{ 5|pluralize("en") }}</p>
|
|
201
|
+
{{ data|json_script("page-data") }}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Custom globals and filters
|
|
205
|
+
|
|
206
|
+
You can register your own globals and filters in `app/templates.py` (or `{package}/templates.py`). These files are automatically imported when the template environment loads.
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# app/templates.py
|
|
210
|
+
from plain.templates import register_template_filter, register_template_global
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@register_template_filter
|
|
214
|
+
def camel_case(value):
|
|
215
|
+
"""Convert a string to CamelCase."""
|
|
216
|
+
return value.replace("_", " ").title().replace(" ", "")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@register_template_global
|
|
220
|
+
def app_version():
|
|
221
|
+
"""Return the current app version."""
|
|
222
|
+
return "1.0.0"
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Now you can use these in templates:
|
|
226
|
+
|
|
227
|
+
```html
|
|
228
|
+
<p>{{ "my_variable"|camel_case }}</p> <!-- outputs: MyVariable -->
|
|
229
|
+
<footer>Version {{ app_version() }}</footer>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
You can also register non-callable values as globals by providing a name:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
from plain.templates import register_template_global
|
|
236
|
+
|
|
237
|
+
register_template_global("1.0.0", name="APP_VERSION")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Custom template extensions
|
|
241
|
+
|
|
242
|
+
For more complex template features, you can create Jinja extensions. The [`InclusionTagExtension`](./jinja/extensions.py#InclusionTagExtension) base class makes it easy to create custom tags that render their own templates.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
# app/templates.py
|
|
246
|
+
from plain.templates import register_template_extension
|
|
247
|
+
from plain.templates.jinja.extensions import InclusionTagExtension
|
|
248
|
+
from plain.runtime import settings
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@register_template_extension
|
|
252
|
+
class AlertExtension(InclusionTagExtension):
|
|
253
|
+
tags = {"alert"}
|
|
254
|
+
template_name = "components/alert.html"
|
|
255
|
+
|
|
256
|
+
def get_context(self, context, *args, **kwargs):
|
|
257
|
+
return {
|
|
258
|
+
"message": args[0] if args else "",
|
|
259
|
+
"type": kwargs.get("type", "info"),
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
```html
|
|
264
|
+
<!-- app/templates/components/alert.html -->
|
|
265
|
+
<div class="alert alert-{{ type }}">{{ message }}</div>
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
```html
|
|
269
|
+
<!-- Usage in any template -->
|
|
270
|
+
{% alert "Something happened!" type="warning" %}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Rendering templates manually
|
|
274
|
+
|
|
275
|
+
You can render templates outside of views using the [`Template`](./core.py#Template) class.
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from plain.templates import Template
|
|
279
|
+
|
|
280
|
+
html = Template("email/welcome.html").render({
|
|
281
|
+
"user_name": "Alice",
|
|
282
|
+
"activation_url": "https://example.com/activate/abc123",
|
|
283
|
+
})
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
If the template file doesn't exist, a [`TemplateFileMissing`](./core.py#TemplateFileMissing) exception is raised.
|
|
287
|
+
|
|
288
|
+
## Custom Jinja environment
|
|
289
|
+
|
|
290
|
+
By default, Plain uses a [`DefaultEnvironment`](./jinja/environments.py#DefaultEnvironment) that configures Jinja2 with sensible defaults:
|
|
291
|
+
|
|
292
|
+
- **Autoescaping** enabled for security
|
|
293
|
+
- **StrictUndefined** so undefined variables raise errors
|
|
294
|
+
- **Auto-reload** in debug mode
|
|
295
|
+
- **Loop controls** extension (`break`, `continue`)
|
|
296
|
+
- **Debug** extension
|
|
297
|
+
|
|
298
|
+
You can customize the environment by creating your own class and pointing to it in settings:
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
# app/jinja.py
|
|
302
|
+
from plain.templates.jinja.environments import DefaultEnvironment
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class CustomEnvironment(DefaultEnvironment):
|
|
306
|
+
def __init__(self):
|
|
307
|
+
super().__init__()
|
|
308
|
+
# Add your customizations here
|
|
309
|
+
self.globals["CUSTOM_SETTING"] = "value"
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
# app/settings.py
|
|
314
|
+
TEMPLATES_JINJA_ENVIRONMENT = "app.jinja.CustomEnvironment"
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## FAQs
|
|
318
|
+
|
|
319
|
+
#### Why am I getting "undefined variable" errors?
|
|
320
|
+
|
|
321
|
+
Plain uses Jinja's `StrictUndefined` mode, which raises an error when you reference a variable that doesn't exist in the context. This helps catch typos and missing data early. Make sure you're passing all required variables in `get_template_context()`.
|
|
322
|
+
|
|
323
|
+
#### Why does my template show an error about a callable?
|
|
324
|
+
|
|
325
|
+
Plain's template environment prevents accidentally rendering callables (functions, methods) directly. If you see an error like "X is callable, did you forget parentheses?", you probably need to add `()` to call the function:
|
|
326
|
+
|
|
327
|
+
```html
|
|
328
|
+
<!-- Wrong -->
|
|
329
|
+
{{ user.get_full_name }}
|
|
330
|
+
|
|
331
|
+
<!-- Correct -->
|
|
332
|
+
{{ user.get_full_name() }}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
#### How do I use Jinja's loop controls?
|
|
336
|
+
|
|
337
|
+
Plain enables the `loopcontrols` extension by default, so you can use `break` and `continue` in loops:
|
|
338
|
+
|
|
339
|
+
```html
|
|
340
|
+
{% for item in items %}
|
|
341
|
+
{% if item.skip %}
|
|
342
|
+
{% continue %}
|
|
343
|
+
{% endif %}
|
|
344
|
+
{% if item.stop %}
|
|
345
|
+
{% break %}
|
|
346
|
+
{% endif %}
|
|
347
|
+
<p>{{ item.name }}</p>
|
|
348
|
+
{% endfor %}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
#### Where can I learn more about Jinja2?
|
|
352
|
+
|
|
353
|
+
The [Jinja2 documentation](https://jinja.palletsprojects.com/en/stable/) covers all the template syntax, including conditionals, loops, macros, and inheritance.
|
|
354
|
+
|
|
355
|
+
## Forms
|
|
356
|
+
|
|
357
|
+
Forms are rendered manually using the bound field attributes:
|
|
358
|
+
|
|
359
|
+
```html
|
|
360
|
+
<form method="post">
|
|
361
|
+
<div>
|
|
362
|
+
<label for="{{ form.email.html_id }}">Email</label>
|
|
363
|
+
<input
|
|
364
|
+
type="email"
|
|
365
|
+
name="{{ form.email.html_name }}"
|
|
366
|
+
id="{{ form.email.html_id }}"
|
|
367
|
+
value="{{ form.email.value }}"
|
|
368
|
+
>
|
|
369
|
+
{% for error in form.email.errors %}
|
|
370
|
+
<p>{{ error }}</p>
|
|
371
|
+
{% endfor %}
|
|
372
|
+
</div>
|
|
373
|
+
<button type="submit">Submit</button>
|
|
374
|
+
</form>
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
Each bound field provides: `html_name`, `html_id`, `value`, `errors`, `field`, `initial`.
|
|
378
|
+
|
|
379
|
+
## Installation
|
|
380
|
+
|
|
381
|
+
Install the `plain.templates` package:
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
uv add plain.templates
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Then add it to `INSTALLED_PACKAGES`:
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
# app/settings.py
|
|
391
|
+
INSTALLED_PACKAGES = [
|
|
392
|
+
"plain.templates",
|
|
393
|
+
# ...
|
|
394
|
+
]
|
|
395
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .core import Template, TemplateFileMissing
|
|
2
|
+
from .jinja import (
|
|
3
|
+
register_template_extension,
|
|
4
|
+
register_template_filter,
|
|
5
|
+
register_template_global,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Template",
|
|
10
|
+
"TemplateFileMissing",
|
|
11
|
+
# Technically these are jinja-specific,
|
|
12
|
+
# but expected to be used pretty frequently so
|
|
13
|
+
# the shorter import is handy.
|
|
14
|
+
"register_template_extension",
|
|
15
|
+
"register_template_filter",
|
|
16
|
+
"register_template_global",
|
|
17
|
+
]
|
plain/templates/core.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import jinja2
|
|
2
|
+
from opentelemetry import trace
|
|
3
|
+
from opentelemetry.semconv.attributes.code_attributes import (
|
|
4
|
+
CODE_FUNCTION_NAME,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from .jinja import environment
|
|
8
|
+
|
|
9
|
+
tracer = trace.get_tracer("plain.templates")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TemplateFileMissing(Exception):
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
if self.args:
|
|
15
|
+
return f"Template file {self.args[0]} not found"
|
|
16
|
+
else:
|
|
17
|
+
return "Template file not found"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Template:
|
|
21
|
+
def __init__(self, filename: str) -> None:
|
|
22
|
+
self.filename = filename
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
self._jinja_template = environment.get_template(filename)
|
|
26
|
+
except jinja2.TemplateNotFound:
|
|
27
|
+
raise TemplateFileMissing(filename)
|
|
28
|
+
|
|
29
|
+
def render(self, context: dict) -> str:
|
|
30
|
+
with tracer.start_as_current_span(
|
|
31
|
+
f"render {self.filename}",
|
|
32
|
+
kind=trace.SpanKind.INTERNAL,
|
|
33
|
+
attributes={
|
|
34
|
+
CODE_FUNCTION_NAME: f"{self.__class__.__module__}.{self.__class__.__qualname__}.render",
|
|
35
|
+
"template.filename": self.filename,
|
|
36
|
+
"template.engine": "jinja2",
|
|
37
|
+
},
|
|
38
|
+
):
|
|
39
|
+
result = self._jinja_template.render(context)
|
|
40
|
+
return result
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from plain.packages import packages_registry
|
|
7
|
+
from plain.runtime import settings
|
|
8
|
+
from plain.utils.functional import LazyObject
|
|
9
|
+
from plain.utils.module_loading import import_string
|
|
10
|
+
|
|
11
|
+
from .environments import DefaultEnvironment, get_template_dirs
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JinjaEnvironment(LazyObject):
|
|
15
|
+
def _setup(self) -> None:
|
|
16
|
+
environment_setting = settings.TEMPLATES_JINJA_ENVIRONMENT
|
|
17
|
+
|
|
18
|
+
if isinstance(environment_setting, str):
|
|
19
|
+
env = import_string(environment_setting)()
|
|
20
|
+
else:
|
|
21
|
+
env = environment_setting()
|
|
22
|
+
|
|
23
|
+
# We have to set _wrapped before we trigger the autoloading of "register" commands
|
|
24
|
+
self._wrapped = env
|
|
25
|
+
|
|
26
|
+
# Autoload template helpers using the registry method
|
|
27
|
+
packages_registry.autodiscover_modules("templates", include_app=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
environment = JinjaEnvironment()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def register_template_extension(extension_class: type) -> type:
|
|
34
|
+
environment.add_extension(extension_class)
|
|
35
|
+
return extension_class
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_template_global(value: Any, name: str | None = None) -> Any:
|
|
39
|
+
"""
|
|
40
|
+
Adds a global to the Jinja environment.
|
|
41
|
+
|
|
42
|
+
Can be used as a decorator on a function:
|
|
43
|
+
|
|
44
|
+
@register_template_global
|
|
45
|
+
def my_global():
|
|
46
|
+
return "Hello, world!"
|
|
47
|
+
|
|
48
|
+
Or as a function:
|
|
49
|
+
|
|
50
|
+
register_template_global("Hello, world!", name="my_global")
|
|
51
|
+
"""
|
|
52
|
+
if callable(value):
|
|
53
|
+
environment.globals[name or value.__name__] = value
|
|
54
|
+
elif name:
|
|
55
|
+
environment.globals[name] = value
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError("name must be provided if value is not callable")
|
|
58
|
+
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def register_template_filter(
|
|
63
|
+
func: Callable[..., Any], name: str | None = None
|
|
64
|
+
) -> Callable[..., Any]:
|
|
65
|
+
"""Adds a filter to the Jinja environment."""
|
|
66
|
+
filter_name = name if name is not None else func.__name__ # ty: ignore[unresolved-attribute]
|
|
67
|
+
environment.filters[filter_name] = func
|
|
68
|
+
return func
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"environment",
|
|
73
|
+
"DefaultEnvironment",
|
|
74
|
+
"get_template_dirs",
|
|
75
|
+
"register_template_extension",
|
|
76
|
+
"register_template_filter",
|
|
77
|
+
"register_template_global",
|
|
78
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from jinja2 import Environment, StrictUndefined
|
|
6
|
+
from jinja2.loaders import FileSystemLoader
|
|
7
|
+
|
|
8
|
+
from plain.packages import packages_registry
|
|
9
|
+
from plain.runtime import settings
|
|
10
|
+
|
|
11
|
+
from .filters import default_filters
|
|
12
|
+
from .globals import default_globals
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _finalize_callable_error(obj: Any) -> Any:
|
|
16
|
+
"""Prevent direct rendering of a callable (likely just forgotten ()) by raising a TypeError"""
|
|
17
|
+
if callable(obj):
|
|
18
|
+
raise TypeError(f"{obj} is callable, did you forget parentheses?")
|
|
19
|
+
|
|
20
|
+
# TODO find a way to prevent <object representation> from being rendered
|
|
21
|
+
# if obj.__class__.__str__ is object.__str__:
|
|
22
|
+
# raise TypeError(f"{obj} does not have a __str__ method")
|
|
23
|
+
|
|
24
|
+
return obj
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_template_dirs() -> tuple[Path, ...]:
|
|
28
|
+
jinja_templates = Path(__file__).parent / "templates"
|
|
29
|
+
app_templates = settings.path.parent / "templates"
|
|
30
|
+
return (jinja_templates, app_templates) + _get_app_template_dirs()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@functools.lru_cache
|
|
34
|
+
def _get_app_template_dirs() -> tuple[Path, ...]:
|
|
35
|
+
"""
|
|
36
|
+
Return an iterable of paths of directories to load app templates from.
|
|
37
|
+
|
|
38
|
+
dirname is the name of the subdirectory containing templates inside
|
|
39
|
+
installed applications.
|
|
40
|
+
"""
|
|
41
|
+
dirname = "templates"
|
|
42
|
+
template_dirs = [
|
|
43
|
+
Path(package_config.path) / dirname
|
|
44
|
+
for package_config in packages_registry.get_package_configs()
|
|
45
|
+
if package_config.path and (Path(package_config.path) / dirname).is_dir()
|
|
46
|
+
]
|
|
47
|
+
# Immutable return value because it will be cached and shared by callers.
|
|
48
|
+
return tuple(template_dirs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DefaultEnvironment(Environment):
|
|
52
|
+
def __init__(self):
|
|
53
|
+
super().__init__(
|
|
54
|
+
loader=FileSystemLoader(get_template_dirs()),
|
|
55
|
+
autoescape=True,
|
|
56
|
+
auto_reload=settings.DEBUG,
|
|
57
|
+
undefined=StrictUndefined,
|
|
58
|
+
finalize=_finalize_callable_error,
|
|
59
|
+
extensions=["jinja2.ext.loopcontrols", "jinja2.ext.debug"],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Load the top-level defaults
|
|
63
|
+
self.globals.update(default_globals) # ty: ignore[no-matching-overload]
|
|
64
|
+
self.filters.update(default_filters)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from jinja2 import nodes
|
|
6
|
+
from jinja2.ext import Extension
|
|
7
|
+
from jinja2.runtime import Context
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InclusionTagExtension(Extension):
|
|
11
|
+
"""Intended to be subclassed"""
|
|
12
|
+
|
|
13
|
+
# tags = {'inclusion_tag'}
|
|
14
|
+
tags: set[str]
|
|
15
|
+
template_name: str
|
|
16
|
+
|
|
17
|
+
def parse(self, parser: Any) -> nodes.Node:
|
|
18
|
+
lineno = next(parser.stream).lineno
|
|
19
|
+
args: list[nodes.Expr] = [
|
|
20
|
+
nodes.DerivedContextReference(),
|
|
21
|
+
]
|
|
22
|
+
kwargs = []
|
|
23
|
+
while parser.stream.current.type != "block_end":
|
|
24
|
+
if parser.stream.current.type == "name":
|
|
25
|
+
key = parser.stream.current.value
|
|
26
|
+
parser.stream.skip()
|
|
27
|
+
parser.stream.expect("assign")
|
|
28
|
+
value = parser.parse_expression()
|
|
29
|
+
kwargs.append(nodes.Keyword(key, value))
|
|
30
|
+
else:
|
|
31
|
+
args.append(parser.parse_expression())
|
|
32
|
+
|
|
33
|
+
call = self.call_method("_render", args=args, kwargs=kwargs, lineno=lineno)
|
|
34
|
+
return nodes.CallBlock(call, [], [], []).set_lineno(lineno)
|
|
35
|
+
|
|
36
|
+
def _render(self, context: Context, *args: Any, **kwargs: Any) -> str:
|
|
37
|
+
render_context = self.get_context(context, *args, **kwargs)
|
|
38
|
+
template = self.environment.get_template(self.template_name)
|
|
39
|
+
return template.render(render_context)
|
|
40
|
+
|
|
41
|
+
def get_context(
|
|
42
|
+
self, context: Context, *args: Any, **kwargs: Any
|
|
43
|
+
) -> Context | dict[str, Any]:
|
|
44
|
+
raise NotImplementedError(
|
|
45
|
+
"You need to implement the `get_context` method in your subclass."
|
|
46
|
+
)
|