plain 0.68.0__py3-none-any.whl → 0.101.2__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/CHANGELOG.md +656 -1
- plain/README.md +1 -1
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -36
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +236 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +110 -26
- plain/cli/docs.py +52 -11
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +27 -75
- plain/cli/print.py +4 -4
- plain/cli/registry.py +96 -10
- plain/cli/{agent/request.py → request.py} +67 -33
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -8
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +27 -16
- plain/paginator.py +31 -21
- plain/preflight/README.md +209 -24
- plain/preflight/__init__.py +1 -0
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +26 -11
- plain/preflight/results.py +15 -7
- plain/preflight/security.py +15 -13
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +4 -1
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +34 -25
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/skills/README.md +36 -0
- plain/skills/plain-docs/SKILL.md +25 -0
- plain/skills/plain-install/SKILL.md +26 -0
- plain/skills/plain-request/SKILL.md +39 -0
- plain/skills/plain-shell/SKILL.md +24 -0
- plain/skills/plain-upgrade/SKILL.md +35 -0
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +38 -22
- plain/urls/resolvers.py +35 -25
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
- plain-0.101.2.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/templates/AGENTS.md +0 -3
- plain-0.68.0.dist-info/RECORD +0 -169
- plain-0.68.0.dist-info/entry_points.txt +0 -5
- {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/templates/README.md
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
# Templates
|
|
2
2
|
|
|
3
|
-
**Render HTML templates using
|
|
3
|
+
**Render HTML templates using Jinja2.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
6
|
- [Template files](#template-files)
|
|
7
|
-
- [
|
|
7
|
+
- [Template context](#template-context)
|
|
8
|
+
- [Built-in globals](#built-in-globals)
|
|
9
|
+
- [Built-in filters](#built-in-filters)
|
|
10
|
+
- [Custom globals and filters](#custom-globals-and-filters)
|
|
11
|
+
- [Custom template extensions](#custom-template-extensions)
|
|
8
12
|
- [Rendering templates manually](#rendering-templates-manually)
|
|
13
|
+
- [Custom Jinja environment](#custom-jinja-environment)
|
|
14
|
+
- [FAQs](#faqs)
|
|
15
|
+
- [Installation](#installation)
|
|
9
16
|
|
|
10
17
|
## Overview
|
|
11
18
|
|
|
12
|
-
Plain uses Jinja2 for template rendering. You can refer to the [Jinja documentation](https://jinja.palletsprojects.com/en/stable/
|
|
19
|
+
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.
|
|
13
20
|
|
|
14
|
-
|
|
21
|
+
Templates are typically used with `TemplateView` or one of its subclasses.
|
|
15
22
|
|
|
16
23
|
```python
|
|
24
|
+
# app/views.py
|
|
17
25
|
from plain.views import TemplateView
|
|
18
26
|
|
|
19
27
|
|
|
@@ -27,7 +35,7 @@ class ExampleView(TemplateView):
|
|
|
27
35
|
```
|
|
28
36
|
|
|
29
37
|
```html
|
|
30
|
-
<!-- example.html -->
|
|
38
|
+
<!-- app/templates/example.html -->
|
|
31
39
|
{% extends "base.html" %}
|
|
32
40
|
|
|
33
41
|
{% block content %}
|
|
@@ -37,50 +45,233 @@ class ExampleView(TemplateView):
|
|
|
37
45
|
|
|
38
46
|
## Template files
|
|
39
47
|
|
|
40
|
-
Template files can
|
|
41
|
-
or the `<pkg>/templates` directory of any installed package.
|
|
48
|
+
Template files can live in two locations:
|
|
42
49
|
|
|
43
|
-
|
|
50
|
+
1. **`app/templates/`** - Your app's templates (highest priority)
|
|
51
|
+
2. **`{package}/templates/`** - Templates inside any installed package
|
|
44
52
|
|
|
45
|
-
|
|
53
|
+
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/`.
|
|
46
54
|
|
|
47
|
-
|
|
55
|
+
## Template context
|
|
56
|
+
|
|
57
|
+
When using `TemplateView`, you pass data to templates by overriding `get_template_context()`.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
class ProductView(TemplateView):
|
|
61
|
+
template_name = "product.html"
|
|
62
|
+
|
|
63
|
+
def get_template_context(self):
|
|
64
|
+
context = super().get_template_context()
|
|
65
|
+
context["product"] = Product.objects.get(id=self.url_kwargs["id"])
|
|
66
|
+
context["related_products"] = Product.objects.filter(category=context["product"].category)[:5]
|
|
67
|
+
return context
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The context is then available in your template:
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<h1>{{ product.name }}</h1>
|
|
74
|
+
<ul>
|
|
75
|
+
{% for item in related_products %}
|
|
76
|
+
<li>{{ item.name }}</li>
|
|
77
|
+
{% endfor %}
|
|
78
|
+
</ul>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Built-in globals
|
|
82
|
+
|
|
83
|
+
Plain provides several [global functions](./jinja/globals.py) available in all templates:
|
|
84
|
+
|
|
85
|
+
| Global | Description |
|
|
86
|
+
| ---------------------------- | ---------------------------------- |
|
|
87
|
+
| `asset(path)` | Returns the URL for a static asset |
|
|
88
|
+
| `url(name, *args, **kwargs)` | Reverses a URL by name |
|
|
89
|
+
| `Paginator` | The Paginator class for pagination |
|
|
90
|
+
| `now()` | Returns the current datetime |
|
|
91
|
+
| `timedelta` | The timedelta class for date math |
|
|
92
|
+
| `localtime(dt)` | Converts a datetime to local time |
|
|
93
|
+
|
|
94
|
+
```html
|
|
95
|
+
<link rel="stylesheet" href="{{ asset('css/style.css') }}">
|
|
96
|
+
<a href="{{ url('product_detail', id=product.id) }}">View</a>
|
|
97
|
+
<p>Generated at {{ now() }}</p>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Built-in filters
|
|
101
|
+
|
|
102
|
+
Plain includes several [filters](./jinja/filters.py) for common operations:
|
|
103
|
+
|
|
104
|
+
| Filter | Description |
|
|
105
|
+
| ----------------------------- | ------------------------------------ |
|
|
106
|
+
| `strftime(format)` | Formats a datetime |
|
|
107
|
+
| `strptime(format)` | Parses a string to datetime |
|
|
108
|
+
| `fromtimestamp(ts)` | Creates datetime from timestamp |
|
|
109
|
+
| `fromisoformat(s)` | Creates datetime from ISO string |
|
|
110
|
+
| `localtime(tz)` | Converts to local timezone |
|
|
111
|
+
| `timeuntil` | Human-readable time until a date |
|
|
112
|
+
| `timesince` | Human-readable time since a date |
|
|
113
|
+
| `json_script(id)` | Outputs JSON safely in a script tag |
|
|
114
|
+
| `islice(stop)` | Slices iterables (useful for dicts) |
|
|
115
|
+
| `pluralize(singular, plural)` | Returns plural suffix based on count |
|
|
116
|
+
|
|
117
|
+
```html
|
|
118
|
+
<p>Posted {{ post.created_at|timesince }} ago</p>
|
|
119
|
+
<p>{{ items|length }} item{{ items|length|pluralize }}</p>
|
|
120
|
+
<p>{{ 5 }} ox{{ 5|pluralize("en") }}</p>
|
|
121
|
+
{{ data|json_script("page-data") }}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Custom globals and filters
|
|
125
|
+
|
|
126
|
+
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.
|
|
48
127
|
|
|
49
128
|
```python
|
|
50
129
|
# app/templates.py
|
|
51
|
-
from plain.templates import register_template_filter, register_template_global
|
|
52
|
-
from plain.templates.jinja.extensions import InclusionTagExtension
|
|
53
|
-
from plain.runtime import settings
|
|
130
|
+
from plain.templates import register_template_filter, register_template_global
|
|
54
131
|
|
|
55
132
|
|
|
56
133
|
@register_template_filter
|
|
57
134
|
def camel_case(value):
|
|
135
|
+
"""Convert a string to CamelCase."""
|
|
58
136
|
return value.replace("_", " ").title().replace(" ", "")
|
|
59
137
|
|
|
60
138
|
|
|
61
139
|
@register_template_global
|
|
62
140
|
def app_version():
|
|
141
|
+
"""Return the current app version."""
|
|
63
142
|
return "1.0.0"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Now you can use these in templates:
|
|
146
|
+
|
|
147
|
+
```html
|
|
148
|
+
<p>{{ "my_variable"|camel_case }}</p> <!-- outputs: MyVariable -->
|
|
149
|
+
<footer>Version {{ app_version() }}</footer>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
You can also register non-callable values as globals by providing a name:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from plain.templates import register_template_global
|
|
156
|
+
|
|
157
|
+
register_template_global("1.0.0", name="APP_VERSION")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Custom template extensions
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# app/templates.py
|
|
166
|
+
from plain.templates import register_template_extension
|
|
167
|
+
from plain.templates.jinja.extensions import InclusionTagExtension
|
|
168
|
+
from plain.runtime import settings
|
|
64
169
|
|
|
65
170
|
|
|
66
171
|
@register_template_extension
|
|
67
|
-
class
|
|
68
|
-
tags = {"
|
|
69
|
-
template_name = "
|
|
172
|
+
class AlertExtension(InclusionTagExtension):
|
|
173
|
+
tags = {"alert"}
|
|
174
|
+
template_name = "components/alert.html"
|
|
70
175
|
|
|
71
176
|
def get_context(self, context, *args, **kwargs):
|
|
72
177
|
return {
|
|
73
|
-
"
|
|
74
|
-
"
|
|
178
|
+
"message": args[0] if args else "",
|
|
179
|
+
"type": kwargs.get("type", "info"),
|
|
75
180
|
}
|
|
76
181
|
```
|
|
77
182
|
|
|
183
|
+
```html
|
|
184
|
+
<!-- app/templates/components/alert.html -->
|
|
185
|
+
<div class="alert alert-{{ type }}">{{ message }}</div>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```html
|
|
189
|
+
<!-- Usage in any template -->
|
|
190
|
+
{% alert "Something happened!" type="warning" %}
|
|
191
|
+
```
|
|
192
|
+
|
|
78
193
|
## Rendering templates manually
|
|
79
194
|
|
|
80
|
-
|
|
195
|
+
You can render templates outside of views using the [`Template`](./core.py#Template) class.
|
|
81
196
|
|
|
82
197
|
```python
|
|
83
198
|
from plain.templates import Template
|
|
84
199
|
|
|
85
|
-
|
|
200
|
+
html = Template("email/welcome.html").render({
|
|
201
|
+
"user_name": "Alice",
|
|
202
|
+
"activation_url": "https://example.com/activate/abc123",
|
|
203
|
+
})
|
|
86
204
|
```
|
|
205
|
+
|
|
206
|
+
If the template file doesn't exist, a [`TemplateFileMissing`](./core.py#TemplateFileMissing) exception is raised.
|
|
207
|
+
|
|
208
|
+
## Custom Jinja environment
|
|
209
|
+
|
|
210
|
+
By default, Plain uses a [`DefaultEnvironment`](./jinja/environments.py#DefaultEnvironment) that configures Jinja2 with sensible defaults:
|
|
211
|
+
|
|
212
|
+
- **Autoescaping** enabled for security
|
|
213
|
+
- **StrictUndefined** so undefined variables raise errors
|
|
214
|
+
- **Auto-reload** in debug mode
|
|
215
|
+
- **Loop controls** extension (`break`, `continue`)
|
|
216
|
+
- **Debug** extension
|
|
217
|
+
|
|
218
|
+
You can customize the environment by creating your own class and pointing to it in settings:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
# app/jinja.py
|
|
222
|
+
from plain.templates.jinja.environments import DefaultEnvironment
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class CustomEnvironment(DefaultEnvironment):
|
|
226
|
+
def __init__(self):
|
|
227
|
+
super().__init__()
|
|
228
|
+
# Add your customizations here
|
|
229
|
+
self.globals["CUSTOM_SETTING"] = "value"
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
# app/settings.py
|
|
234
|
+
TEMPLATES_JINJA_ENVIRONMENT = "app.jinja.CustomEnvironment"
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## FAQs
|
|
238
|
+
|
|
239
|
+
#### Why am I getting "undefined variable" errors?
|
|
240
|
+
|
|
241
|
+
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()`.
|
|
242
|
+
|
|
243
|
+
#### Why does my template show an error about a callable?
|
|
244
|
+
|
|
245
|
+
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:
|
|
246
|
+
|
|
247
|
+
```html
|
|
248
|
+
<!-- Wrong -->
|
|
249
|
+
{{ user.get_full_name }}
|
|
250
|
+
|
|
251
|
+
<!-- Correct -->
|
|
252
|
+
{{ user.get_full_name() }}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
#### How do I use Jinja's loop controls?
|
|
256
|
+
|
|
257
|
+
Plain enables the `loopcontrols` extension by default, so you can use `break` and `continue` in loops:
|
|
258
|
+
|
|
259
|
+
```html
|
|
260
|
+
{% for item in items %}
|
|
261
|
+
{% if item.skip %}
|
|
262
|
+
{% continue %}
|
|
263
|
+
{% endif %}
|
|
264
|
+
{% if item.stop %}
|
|
265
|
+
{% break %}
|
|
266
|
+
{% endif %}
|
|
267
|
+
<p>{{ item.name }}</p>
|
|
268
|
+
{% endfor %}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
#### Where can I learn more about Jinja2?
|
|
272
|
+
|
|
273
|
+
The [Jinja2 documentation](https://jinja.palletsprojects.com/en/stable/) covers all the template syntax, including conditionals, loops, macros, and inheritance.
|
|
274
|
+
|
|
275
|
+
## Installation
|
|
276
|
+
|
|
277
|
+
The `plain.templates` module is included with Plain by default. No additional installation is required.
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
1
6
|
from plain.packages import packages_registry
|
|
2
7
|
from plain.runtime import settings
|
|
3
8
|
from plain.utils.functional import LazyObject
|
|
@@ -7,7 +12,7 @@ from .environments import DefaultEnvironment, get_template_dirs
|
|
|
7
12
|
|
|
8
13
|
|
|
9
14
|
class JinjaEnvironment(LazyObject):
|
|
10
|
-
def _setup(self):
|
|
15
|
+
def _setup(self) -> None:
|
|
11
16
|
environment_setting = settings.TEMPLATES_JINJA_ENVIRONMENT
|
|
12
17
|
|
|
13
18
|
if isinstance(environment_setting, str):
|
|
@@ -25,12 +30,12 @@ class JinjaEnvironment(LazyObject):
|
|
|
25
30
|
environment = JinjaEnvironment()
|
|
26
31
|
|
|
27
32
|
|
|
28
|
-
def register_template_extension(extension_class):
|
|
33
|
+
def register_template_extension(extension_class: type) -> type:
|
|
29
34
|
environment.add_extension(extension_class)
|
|
30
35
|
return extension_class
|
|
31
36
|
|
|
32
37
|
|
|
33
|
-
def register_template_global(value, name=None):
|
|
38
|
+
def register_template_global(value: Any, name: str | None = None) -> Any:
|
|
34
39
|
"""
|
|
35
40
|
Adds a global to the Jinja environment.
|
|
36
41
|
|
|
@@ -54,9 +59,12 @@ def register_template_global(value, name=None):
|
|
|
54
59
|
return value
|
|
55
60
|
|
|
56
61
|
|
|
57
|
-
def register_template_filter(
|
|
62
|
+
def register_template_filter(
|
|
63
|
+
func: Callable[..., Any], name: str | None = None
|
|
64
|
+
) -> Callable[..., Any]:
|
|
58
65
|
"""Adds a filter to the Jinja environment."""
|
|
59
|
-
|
|
66
|
+
filter_name = name if name is not None else func.__name__ # type: ignore[attr-defined]
|
|
67
|
+
environment.filters[filter_name] = func
|
|
60
68
|
return func
|
|
61
69
|
|
|
62
70
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import functools
|
|
2
2
|
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
3
4
|
|
|
4
5
|
from jinja2 import Environment, StrictUndefined
|
|
5
6
|
from jinja2.loaders import FileSystemLoader
|
|
@@ -11,7 +12,7 @@ from .filters import default_filters
|
|
|
11
12
|
from .globals import default_globals
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
def
|
|
15
|
+
def _finalize_callable_error(obj: Any) -> Any:
|
|
15
16
|
"""Prevent direct rendering of a callable (likely just forgotten ()) by raising a TypeError"""
|
|
16
17
|
if callable(obj):
|
|
17
18
|
raise TypeError(f"{obj} is callable, did you forget parentheses?")
|
|
@@ -23,14 +24,14 @@ def finalize_callable_error(obj):
|
|
|
23
24
|
return obj
|
|
24
25
|
|
|
25
26
|
|
|
26
|
-
def get_template_dirs():
|
|
27
|
+
def get_template_dirs() -> tuple[Path, ...]:
|
|
27
28
|
jinja_templates = Path(__file__).parent / "templates"
|
|
28
29
|
app_templates = settings.path.parent / "templates"
|
|
29
30
|
return (jinja_templates, app_templates) + _get_app_template_dirs()
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
@functools.lru_cache
|
|
33
|
-
def _get_app_template_dirs():
|
|
34
|
+
def _get_app_template_dirs() -> tuple[Path, ...]:
|
|
34
35
|
"""
|
|
35
36
|
Return an iterable of paths of directories to load app templates from.
|
|
36
37
|
|
|
@@ -54,7 +55,7 @@ class DefaultEnvironment(Environment):
|
|
|
54
55
|
autoescape=True,
|
|
55
56
|
auto_reload=settings.DEBUG,
|
|
56
57
|
undefined=StrictUndefined,
|
|
57
|
-
finalize=
|
|
58
|
+
finalize=_finalize_callable_error,
|
|
58
59
|
extensions=["jinja2.ext.loopcontrols", "jinja2.ext.debug"],
|
|
59
60
|
)
|
|
60
61
|
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
1
5
|
from jinja2 import nodes
|
|
2
6
|
from jinja2.ext import Extension
|
|
7
|
+
from jinja2.runtime import Context
|
|
3
8
|
|
|
4
9
|
|
|
5
10
|
class InclusionTagExtension(Extension):
|
|
@@ -9,7 +14,7 @@ class InclusionTagExtension(Extension):
|
|
|
9
14
|
tags: set[str]
|
|
10
15
|
template_name: str
|
|
11
16
|
|
|
12
|
-
def parse(self, parser):
|
|
17
|
+
def parse(self, parser: Any) -> nodes.Node:
|
|
13
18
|
lineno = next(parser.stream).lineno
|
|
14
19
|
args = [
|
|
15
20
|
nodes.DerivedContextReference(),
|
|
@@ -28,12 +33,14 @@ class InclusionTagExtension(Extension):
|
|
|
28
33
|
call = self.call_method("_render", args=args, kwargs=kwargs, lineno=lineno)
|
|
29
34
|
return nodes.CallBlock(call, [], [], []).set_lineno(lineno)
|
|
30
35
|
|
|
31
|
-
def _render(self, context, *args, **kwargs):
|
|
32
|
-
|
|
36
|
+
def _render(self, context: Context, *args: Any, **kwargs: Any) -> str:
|
|
37
|
+
render_context = self.get_context(context, *args, **kwargs)
|
|
33
38
|
template = self.environment.get_template(self.template_name)
|
|
34
|
-
return template.render(
|
|
39
|
+
return template.render(render_context)
|
|
35
40
|
|
|
36
|
-
def get_context(
|
|
41
|
+
def get_context(
|
|
42
|
+
self, context: Context, *args: Any, **kwargs: Any
|
|
43
|
+
) -> Context | dict[str, Any]:
|
|
37
44
|
raise NotImplementedError(
|
|
38
45
|
"You need to implement the `get_context` method in your subclass."
|
|
39
46
|
)
|
plain/templates/jinja/filters.py
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import datetime
|
|
2
4
|
from itertools import islice
|
|
5
|
+
from typing import Any
|
|
3
6
|
|
|
4
7
|
from plain.utils.html import json_script
|
|
5
8
|
from plain.utils.timesince import timesince, timeuntil
|
|
6
9
|
from plain.utils.timezone import localtime
|
|
7
10
|
|
|
8
11
|
|
|
9
|
-
def localtime_filter(
|
|
12
|
+
def localtime_filter(
|
|
13
|
+
value: datetime.datetime | None, timezone: Any = None
|
|
14
|
+
) -> datetime.datetime:
|
|
10
15
|
"""Converts a datetime to local time in a template."""
|
|
11
16
|
if not value:
|
|
12
17
|
# Without this, we get the current localtime
|
|
@@ -15,7 +20,7 @@ def localtime_filter(value, timezone=None):
|
|
|
15
20
|
return localtime(value, timezone)
|
|
16
21
|
|
|
17
22
|
|
|
18
|
-
def pluralize_filter(value, singular="", plural="s"):
|
|
23
|
+
def pluralize_filter(value: Any, singular: str = "", plural: str = "s") -> str:
|
|
19
24
|
"""Returns plural suffix based on the value count.
|
|
20
25
|
|
|
21
26
|
Usage:
|
plain/templates/jinja/globals.py
CHANGED
|
@@ -5,7 +5,7 @@ from plain.urls import reverse
|
|
|
5
5
|
from plain.utils import timezone
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
def
|
|
8
|
+
def _asset(url_path: str) -> str:
|
|
9
9
|
# An explicit callable we can control, but also delay the import of asset.urls->views->templates
|
|
10
10
|
# for circular import reasons
|
|
11
11
|
from plain.assets.urls import get_asset_url
|
|
@@ -14,7 +14,7 @@ def asset(url_path):
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
default_globals = {
|
|
17
|
-
"asset":
|
|
17
|
+
"asset": _asset,
|
|
18
18
|
"url": reverse,
|
|
19
19
|
"Paginator": Paginator,
|
|
20
20
|
"now": timezone.now,
|
plain/test/README.md
CHANGED
|
@@ -1,42 +1,204 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plain.test
|
|
2
2
|
|
|
3
|
-
**Testing utilities for
|
|
3
|
+
**Testing utilities for making HTTP requests and inspecting responses.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
|
+
- [Making requests](#making-requests)
|
|
7
|
+
- [GET requests](#get-requests)
|
|
8
|
+
- [POST requests](#post-requests)
|
|
9
|
+
- [Other HTTP methods](#other-http-methods)
|
|
10
|
+
- [Following redirects](#following-redirects)
|
|
11
|
+
- [Custom headers](#custom-headers)
|
|
12
|
+
- [Inspecting responses](#inspecting-responses)
|
|
13
|
+
- [JSON responses](#json-responses)
|
|
14
|
+
- [Response attributes](#response-attributes)
|
|
15
|
+
- [Authentication](#authentication)
|
|
16
|
+
- [Sessions](#sessions)
|
|
17
|
+
- [RequestFactory](#requestfactory)
|
|
18
|
+
- [FAQs](#faqs)
|
|
19
|
+
- [What is the difference between Client and RequestFactory?](#what-is-the-difference-between-client-and-requestfactory)
|
|
20
|
+
- [How do I test file uploads?](#how-do-i-test-file-uploads)
|
|
21
|
+
- [How do I disable exception raising?](#how-do-i-disable-exception-raising)
|
|
22
|
+
- [Installation](#installation)
|
|
6
23
|
|
|
7
24
|
## Overview
|
|
8
25
|
|
|
9
|
-
|
|
26
|
+
You can test your Plain views using the [`Client`](./client.py#Client) class. It simulates HTTP requests and returns responses, allowing you to verify status codes, content, and behavior without running a real server.
|
|
10
27
|
|
|
11
28
|
```python
|
|
12
29
|
from plain.test import Client
|
|
13
30
|
|
|
14
31
|
|
|
15
|
-
def
|
|
32
|
+
def test_homepage():
|
|
16
33
|
client = Client()
|
|
17
|
-
|
|
18
|
-
# Getting responses
|
|
19
34
|
response = client.get("/")
|
|
20
35
|
assert response.status_code == 200
|
|
36
|
+
```
|
|
21
37
|
|
|
22
|
-
|
|
23
|
-
client.session["example"] = "value"
|
|
24
|
-
assert client.session["example"] == "value"
|
|
38
|
+
The client maintains cookies and session state across requests, so you can test multi-step flows like login and logout.
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
## Making requests
|
|
41
|
+
|
|
42
|
+
### GET requests
|
|
43
|
+
|
|
44
|
+
Pass query parameters using the `data` argument.
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
response = client.get("/search/", data={"q": "hello"})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### POST requests
|
|
51
|
+
|
|
52
|
+
Send form data by default.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
response = client.post("/submit/", data={"name": "Alice", "email": "alice@example.com"})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Send JSON by setting the content type.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
response = client.post(
|
|
62
|
+
"/api/users/",
|
|
63
|
+
data={"name": "Alice"},
|
|
64
|
+
content_type="application/json",
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Other HTTP methods
|
|
69
|
+
|
|
70
|
+
The client supports all standard HTTP methods: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, and `trace`.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
response = client.put("/api/users/1/", data={"name": "Bob"}, content_type="application/json")
|
|
74
|
+
response = client.patch("/api/users/1/", data={"name": "Bob"}, content_type="application/json")
|
|
75
|
+
response = client.delete("/api/users/1/")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Following redirects
|
|
79
|
+
|
|
80
|
+
Set `follow=True` to automatically follow redirect responses.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
response = client.get("/old-url/", follow=True)
|
|
84
|
+
assert response.status_code == 200 # Final destination
|
|
85
|
+
assert response.redirect_chain == [("/new-url/", 302)]
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Custom headers
|
|
89
|
+
|
|
90
|
+
Pass headers using the `headers` argument.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
response = client.get("/api/", headers={"Authorization": "Bearer token123"})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
You can also set default headers when creating the client.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
client = Client(headers={"Accept-Language": "en-US"})
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Inspecting responses
|
|
103
|
+
|
|
104
|
+
### JSON responses
|
|
105
|
+
|
|
106
|
+
Parse JSON response content using the `json()` method.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
response = client.get("/api/users/")
|
|
110
|
+
data = response.json()
|
|
111
|
+
assert data["users"][0]["name"] == "Alice"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Response attributes
|
|
115
|
+
|
|
116
|
+
The [`ClientResponse`](./client.py#ClientResponse) wrapper provides access to:
|
|
117
|
+
|
|
118
|
+
- `status_code` - HTTP status code (200, 404, etc.)
|
|
119
|
+
- `content` - Response body as bytes
|
|
120
|
+
- `headers` - Response headers
|
|
121
|
+
- `cookies` - Cookies set by the response
|
|
122
|
+
- `wsgi_request` - The original request object
|
|
123
|
+
- `resolver_match` - URL resolver match information
|
|
124
|
+
- `redirect_chain` - List of redirects when using `follow=True`
|
|
125
|
+
|
|
126
|
+
## Authentication
|
|
127
|
+
|
|
128
|
+
You can log in a user without going through the login flow using `force_login`. This requires `plain.auth` to be installed.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
user = User.objects.get(username="alice")
|
|
132
|
+
client.force_login(user)
|
|
133
|
+
response = client.get("/dashboard/")
|
|
134
|
+
assert response.status_code == 200
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Log out using the `logout` method.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
client.logout()
|
|
141
|
+
response = client.get("/dashboard/")
|
|
142
|
+
assert response.status_code == 302 # Redirected to login
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Sessions
|
|
146
|
+
|
|
147
|
+
Access session data using the `session` property. This requires `plain.sessions` to be installed.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
client.session["cart_id"] = "abc123"
|
|
151
|
+
response = client.get("/cart/")
|
|
152
|
+
assert "abc123" in response.content.decode()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## RequestFactory
|
|
31
156
|
|
|
32
|
-
|
|
33
|
-
client.logout()
|
|
34
|
-
response = client.get("/protected/")
|
|
35
|
-
assert response.status_code == 302
|
|
157
|
+
Use [`RequestFactory`](./client.py#RequestFactory) to create request objects directly without going through the WSGI handler. This is useful for testing views in isolation.
|
|
36
158
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
159
|
+
```python
|
|
160
|
+
from plain.test import RequestFactory
|
|
161
|
+
|
|
162
|
+
rf = RequestFactory()
|
|
163
|
+
request = rf.get("/hello/")
|
|
164
|
+
response = my_view(request)
|
|
165
|
+
assert response.status_code == 200
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The factory supports the same HTTP methods as the client: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, and `trace`.
|
|
169
|
+
|
|
170
|
+
## FAQs
|
|
171
|
+
|
|
172
|
+
#### What is the difference between Client and RequestFactory?
|
|
173
|
+
|
|
174
|
+
The `Client` executes requests through the full middleware stack and maintains state (cookies, sessions) between requests. Use it for integration tests.
|
|
175
|
+
|
|
176
|
+
The `RequestFactory` creates request objects without executing them. Use it for unit testing individual views in isolation.
|
|
177
|
+
|
|
178
|
+
#### How do I test file uploads?
|
|
179
|
+
|
|
180
|
+
Pass file-like objects in the `data` dictionary.
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from io import BytesIO
|
|
184
|
+
|
|
185
|
+
file = BytesIO(b"file contents")
|
|
186
|
+
file.name = "test.txt"
|
|
187
|
+
response = client.post("/upload/", data={"file": file})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### How do I disable exception raising?
|
|
191
|
+
|
|
192
|
+
By default, the client raises exceptions that occur during request processing. Set `raise_request_exception=False` to capture them on the response instead.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
client = Client(raise_request_exception=False)
|
|
196
|
+
response = client.get("/broken/")
|
|
197
|
+
assert response.status_code == 500
|
|
40
198
|
```
|
|
41
199
|
|
|
42
|
-
|
|
200
|
+
## Installation
|
|
201
|
+
|
|
202
|
+
The `plain.test` module is included with the `plain` package. No additional installation is required.
|
|
203
|
+
|
|
204
|
+
For additional testing utilities like pytest fixtures and browser testing, see [`plain.pytest`](/plain-pytest/README.md).
|