plain.templates 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,27 @@
1
+ .venv
2
+ /.env
3
+ *.egg-info
4
+ *.py[co]
5
+ __pycache__
6
+ .pytest_cache
7
+ *.DS_Store
8
+ *.swp
9
+ *.swo
10
+
11
+ /*.code-workspace
12
+
13
+ # Test apps
14
+ plain*/tests/.plain
15
+
16
+ # Agent scratch files
17
+ /scratch
18
+
19
+ # Plain temp dirs
20
+ .plain
21
+
22
+ .vscode
23
+ /.claude/settings.local.json
24
+ /.claude/skills/announcements/
25
+ /CLAUDE.local.md
26
+ /.benchmarks
27
+ .claude/worktrees
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2023, Dropseed, LLC
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,407 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.templates
3
+ Version: 0.1.0
4
+ Summary: Render HTML templates using Jinja2.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: jinja2>=3.1.2
10
+ Requires-Dist: plain<1.0.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # plain.templates
14
+
15
+ **Render HTML templates using Jinja2.**
16
+
17
+ - [Overview](#overview)
18
+ - [Template files](#template-files)
19
+ - [Template-rendering views](#template-rendering-views)
20
+ - [TemplateView](#templateview)
21
+ - [FormView](#formview)
22
+ - [DetailView, CreateView, UpdateView, DeleteView, ListView](#object-views)
23
+ - [Template context](#template-context)
24
+ - [Built-in globals](#built-in-globals)
25
+ - [Built-in filters](#built-in-filters)
26
+ - [Custom globals and filters](#custom-globals-and-filters)
27
+ - [Custom template extensions](#custom-template-extensions)
28
+ - [Rendering templates manually](#rendering-templates-manually)
29
+ - [Custom Jinja environment](#custom-jinja-environment)
30
+ - [Forms](#forms)
31
+ - [FAQs](#faqs)
32
+ - [Installation](#installation)
33
+
34
+ ## Overview
35
+
36
+ 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.
37
+
38
+ Templates are typically used with [`TemplateView`](./views.py#TemplateView) or one of its subclasses (see [Template-rendering views](#template-rendering-views)).
39
+
40
+ ```python
41
+ # app/views.py
42
+ from plain.templates.views import TemplateView
43
+
44
+
45
+ class ExampleView(TemplateView):
46
+ template_name = "example.html"
47
+
48
+ def get_template_context(self):
49
+ context = super().get_template_context()
50
+ context["message"] = "Hello, world!"
51
+ return context
52
+ ```
53
+
54
+ ```html
55
+ <!-- app/templates/example.html -->
56
+ {% extends "base.html" %}
57
+
58
+ {% block content %}
59
+ <h1>{{ message }}</h1>
60
+ {% endblock %}
61
+ ```
62
+
63
+ ## Template files
64
+
65
+ Template files can live in two locations:
66
+
67
+ 1. **`app/templates/`** - Your app's templates (highest priority)
68
+ 2. **`{package}/templates/`** - Templates inside any installed package
69
+
70
+ 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/`.
71
+
72
+ ## Template-rendering views
73
+
74
+ `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.
75
+
76
+ ### TemplateView
77
+
78
+ [`TemplateView`](./views.py#TemplateView) renders a Jinja template:
79
+
80
+ ```python
81
+ from plain.templates.views import TemplateView
82
+
83
+
84
+ class ExampleView(TemplateView):
85
+ template_name = "example.html"
86
+
87
+ def get_template_context(self):
88
+ context = super().get_template_context()
89
+ context["message"] = "Hello, world!"
90
+ return context
91
+ ```
92
+
93
+ For simple pages that don't need custom context, configure `TemplateView` directly in your URL routes:
94
+
95
+ ```python
96
+ from plain.templates.views import TemplateView
97
+ from plain.urls import path, Router
98
+
99
+
100
+ class AppRouter(Router):
101
+ routes = [
102
+ path("/example/", TemplateView.as_view(template_name="example.html")),
103
+ ]
104
+ ```
105
+
106
+ ### FormView
107
+
108
+ [`FormView`](./views.py#FormView) handles displaying and processing [forms](../../../plain/plain/forms/README.md). The form is automatically available in your template as `form`:
109
+
110
+ ```python
111
+ from plain.templates.views import FormView
112
+ from .forms import ExampleForm
113
+
114
+
115
+ class ExampleView(FormView):
116
+ template_name = "example.html"
117
+ form_class = ExampleForm
118
+ success_url = "."
119
+
120
+ def form_valid(self, form):
121
+ return super().form_valid(form)
122
+ ```
123
+
124
+ ### Object views
125
+
126
+ [`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()`:
127
+
128
+ ```python
129
+ from plain.templates.views import DetailView
130
+
131
+
132
+ class ExampleDetailView(DetailView):
133
+ template_name = "detail.html"
134
+
135
+ def get_object(self):
136
+ return MyObjectClass.query.get(
137
+ id=self.url_kwargs["id"],
138
+ user=self.request.user,
139
+ )
140
+ ```
141
+
142
+ The single object is exposed in templates as `object`; list views expose `objects`. Set `context_object_name` for a more descriptive name.
143
+
144
+ ## Template context
145
+
146
+ When using `TemplateView`, you pass data to templates by overriding `get_template_context()`:
147
+
148
+ ```python
149
+ from plain.templates.views import TemplateView
150
+
151
+
152
+ class ProductView(TemplateView):
153
+ template_name = "product.html"
154
+
155
+ def get_template_context(self):
156
+ context = super().get_template_context()
157
+ context["product"] = Product.objects.get(id=self.url_kwargs["id"])
158
+ context["related_products"] = Product.objects.filter(category=context["product"].category)[:5]
159
+ return context
160
+ ```
161
+
162
+ The context is then available in your template:
163
+
164
+ ```html
165
+ <h1>{{ product.name }}</h1>
166
+ <ul>
167
+ {% for item in related_products %}
168
+ <li>{{ item.name }}</li>
169
+ {% endfor %}
170
+ </ul>
171
+ ```
172
+
173
+ ## Built-in globals
174
+
175
+ Plain provides several [global functions](./jinja/globals.py) available in all templates:
176
+
177
+ | Global | Description |
178
+ | ---------------------------- | ---------------------------------- |
179
+ | `asset(path)` | Returns the URL for a static asset |
180
+ | `url(name, *args, **kwargs)` | Reverses a URL by name |
181
+ | `Paginator` | The Paginator class for pagination |
182
+ | `now()` | Returns the current datetime |
183
+ | `timedelta` | The timedelta class for date math |
184
+ | `localtime(dt)` | Converts a datetime to local time |
185
+
186
+ ```html
187
+ <link rel="stylesheet" href="{{ asset('css/style.css') }}">
188
+ <a href="{{ url('product_detail', id=product.id) }}">View</a>
189
+ <p>Generated at {{ now() }}</p>
190
+ ```
191
+
192
+ ## Built-in filters
193
+
194
+ Plain includes several [filters](./jinja/filters.py) for common operations:
195
+
196
+ | Filter | Description |
197
+ | ----------------------------- | ------------------------------------ |
198
+ | `strftime(format)` | Formats a datetime |
199
+ | `strptime(format)` | Parses a string to datetime |
200
+ | `fromtimestamp(ts)` | Creates datetime from timestamp |
201
+ | `fromisoformat(s)` | Creates datetime from ISO string |
202
+ | `localtime(tz)` | Converts to local timezone |
203
+ | `timeuntil` | Human-readable time until a date |
204
+ | `timesince` | Human-readable time since a date |
205
+ | `json_script(id)` | Outputs JSON safely in a script tag |
206
+ | `islice(stop)` | Slices iterables (useful for dicts) |
207
+ | `pluralize(singular, plural)` | Returns plural suffix based on count |
208
+
209
+ ```html
210
+ <p>Posted {{ post.created_at|timesince }} ago</p>
211
+ <p>{{ items|length }} item{{ items|length|pluralize }}</p>
212
+ <p>{{ 5 }} ox{{ 5|pluralize("en") }}</p>
213
+ {{ data|json_script("page-data") }}
214
+ ```
215
+
216
+ ## Custom globals and filters
217
+
218
+ 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.
219
+
220
+ ```python
221
+ # app/templates.py
222
+ from plain.templates import register_template_filter, register_template_global
223
+
224
+
225
+ @register_template_filter
226
+ def camel_case(value):
227
+ """Convert a string to CamelCase."""
228
+ return value.replace("_", " ").title().replace(" ", "")
229
+
230
+
231
+ @register_template_global
232
+ def app_version():
233
+ """Return the current app version."""
234
+ return "1.0.0"
235
+ ```
236
+
237
+ Now you can use these in templates:
238
+
239
+ ```html
240
+ <p>{{ "my_variable"|camel_case }}</p> <!-- outputs: MyVariable -->
241
+ <footer>Version {{ app_version() }}</footer>
242
+ ```
243
+
244
+ You can also register non-callable values as globals by providing a name:
245
+
246
+ ```python
247
+ from plain.templates import register_template_global
248
+
249
+ register_template_global("1.0.0", name="APP_VERSION")
250
+ ```
251
+
252
+ ## Custom template extensions
253
+
254
+ 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.
255
+
256
+ ```python
257
+ # app/templates.py
258
+ from plain.templates import register_template_extension
259
+ from plain.templates.jinja.extensions import InclusionTagExtension
260
+ from plain.runtime import settings
261
+
262
+
263
+ @register_template_extension
264
+ class AlertExtension(InclusionTagExtension):
265
+ tags = {"alert"}
266
+ template_name = "components/alert.html"
267
+
268
+ def get_context(self, context, *args, **kwargs):
269
+ return {
270
+ "message": args[0] if args else "",
271
+ "type": kwargs.get("type", "info"),
272
+ }
273
+ ```
274
+
275
+ ```html
276
+ <!-- app/templates/components/alert.html -->
277
+ <div class="alert alert-{{ type }}">{{ message }}</div>
278
+ ```
279
+
280
+ ```html
281
+ <!-- Usage in any template -->
282
+ {% alert "Something happened!" type="warning" %}
283
+ ```
284
+
285
+ ## Rendering templates manually
286
+
287
+ You can render templates outside of views using the [`Template`](./core.py#Template) class.
288
+
289
+ ```python
290
+ from plain.templates import Template
291
+
292
+ html = Template("email/welcome.html").render({
293
+ "user_name": "Alice",
294
+ "activation_url": "https://example.com/activate/abc123",
295
+ })
296
+ ```
297
+
298
+ If the template file doesn't exist, a [`TemplateFileMissing`](./core.py#TemplateFileMissing) exception is raised.
299
+
300
+ ## Custom Jinja environment
301
+
302
+ By default, Plain uses a [`DefaultEnvironment`](./jinja/environments.py#DefaultEnvironment) that configures Jinja2 with sensible defaults:
303
+
304
+ - **Autoescaping** enabled for security
305
+ - **StrictUndefined** so undefined variables raise errors
306
+ - **Auto-reload** in debug mode
307
+ - **Loop controls** extension (`break`, `continue`)
308
+ - **Debug** extension
309
+
310
+ You can customize the environment by creating your own class and pointing to it in settings:
311
+
312
+ ```python
313
+ # app/jinja.py
314
+ from plain.templates.jinja.environments import DefaultEnvironment
315
+
316
+
317
+ class CustomEnvironment(DefaultEnvironment):
318
+ def __init__(self):
319
+ super().__init__()
320
+ # Add your customizations here
321
+ self.globals["CUSTOM_SETTING"] = "value"
322
+ ```
323
+
324
+ ```python
325
+ # app/settings.py
326
+ TEMPLATES_JINJA_ENVIRONMENT = "app.jinja.CustomEnvironment"
327
+ ```
328
+
329
+ ## FAQs
330
+
331
+ #### Why am I getting "undefined variable" errors?
332
+
333
+ 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()`.
334
+
335
+ #### Why does my template show an error about a callable?
336
+
337
+ 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:
338
+
339
+ ```html
340
+ <!-- Wrong -->
341
+ {{ user.get_full_name }}
342
+
343
+ <!-- Correct -->
344
+ {{ user.get_full_name() }}
345
+ ```
346
+
347
+ #### How do I use Jinja's loop controls?
348
+
349
+ Plain enables the `loopcontrols` extension by default, so you can use `break` and `continue` in loops:
350
+
351
+ ```html
352
+ {% for item in items %}
353
+ {% if item.skip %}
354
+ {% continue %}
355
+ {% endif %}
356
+ {% if item.stop %}
357
+ {% break %}
358
+ {% endif %}
359
+ <p>{{ item.name }}</p>
360
+ {% endfor %}
361
+ ```
362
+
363
+ #### Where can I learn more about Jinja2?
364
+
365
+ The [Jinja2 documentation](https://jinja.palletsprojects.com/en/stable/) covers all the template syntax, including conditionals, loops, macros, and inheritance.
366
+
367
+ ## Forms
368
+
369
+ Forms are rendered manually using the bound field attributes:
370
+
371
+ ```html
372
+ <form method="post">
373
+ <div>
374
+ <label for="{{ form.email.html_id }}">Email</label>
375
+ <input
376
+ type="email"
377
+ name="{{ form.email.html_name }}"
378
+ id="{{ form.email.html_id }}"
379
+ value="{{ form.email.value }}"
380
+ >
381
+ {% for error in form.email.errors %}
382
+ <p>{{ error }}</p>
383
+ {% endfor %}
384
+ </div>
385
+ <button type="submit">Submit</button>
386
+ </form>
387
+ ```
388
+
389
+ Each bound field provides: `html_name`, `html_id`, `value`, `errors`, `field`, `initial`.
390
+
391
+ ## Installation
392
+
393
+ Install the `plain.templates` package:
394
+
395
+ ```bash
396
+ uv add plain.templates
397
+ ```
398
+
399
+ Then add it to `INSTALLED_PACKAGES`:
400
+
401
+ ```python
402
+ # app/settings.py
403
+ INSTALLED_PACKAGES = [
404
+ "plain.templates",
405
+ # ...
406
+ ]
407
+ ```
@@ -0,0 +1 @@
1
+ ./plain/templates/README.md
@@ -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.