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.
Files changed (195) hide show
  1. plain/CHANGELOG.md +656 -1
  2. plain/README.md +1 -1
  3. plain/assets/compile.py +25 -12
  4. plain/assets/finders.py +24 -17
  5. plain/assets/fingerprints.py +10 -7
  6. plain/assets/urls.py +1 -1
  7. plain/assets/views.py +47 -33
  8. plain/chores/README.md +25 -23
  9. plain/chores/__init__.py +2 -1
  10. plain/chores/core.py +27 -0
  11. plain/chores/registry.py +23 -36
  12. plain/cli/README.md +185 -16
  13. plain/cli/__init__.py +2 -1
  14. plain/cli/agent.py +236 -0
  15. plain/cli/build.py +7 -8
  16. plain/cli/changelog.py +11 -5
  17. plain/cli/chores.py +32 -34
  18. plain/cli/core.py +110 -26
  19. plain/cli/docs.py +52 -11
  20. plain/cli/formatting.py +40 -17
  21. plain/cli/install.py +10 -54
  22. plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
  23. plain/cli/output.py +6 -2
  24. plain/cli/preflight.py +27 -75
  25. plain/cli/print.py +4 -4
  26. plain/cli/registry.py +96 -10
  27. plain/cli/{agent/request.py → request.py} +67 -33
  28. plain/cli/runtime.py +45 -0
  29. plain/cli/scaffold.py +2 -7
  30. plain/cli/server.py +153 -0
  31. plain/cli/settings.py +53 -49
  32. plain/cli/shell.py +15 -12
  33. plain/cli/startup.py +9 -8
  34. plain/cli/upgrade.py +17 -104
  35. plain/cli/urls.py +12 -7
  36. plain/cli/utils.py +3 -3
  37. plain/csrf/README.md +65 -40
  38. plain/csrf/middleware.py +53 -43
  39. plain/debug.py +5 -2
  40. plain/exceptions.py +22 -114
  41. plain/forms/README.md +453 -24
  42. plain/forms/__init__.py +55 -4
  43. plain/forms/boundfield.py +15 -8
  44. plain/forms/exceptions.py +1 -1
  45. plain/forms/fields.py +346 -143
  46. plain/forms/forms.py +75 -45
  47. plain/http/README.md +356 -9
  48. plain/http/__init__.py +41 -26
  49. plain/http/cookie.py +15 -7
  50. plain/http/exceptions.py +65 -0
  51. plain/http/middleware.py +32 -0
  52. plain/http/multipartparser.py +99 -88
  53. plain/http/request.py +362 -250
  54. plain/http/response.py +99 -197
  55. plain/internal/__init__.py +8 -1
  56. plain/internal/files/base.py +35 -19
  57. plain/internal/files/locks.py +19 -11
  58. plain/internal/files/move.py +8 -3
  59. plain/internal/files/temp.py +25 -6
  60. plain/internal/files/uploadedfile.py +47 -28
  61. plain/internal/files/uploadhandler.py +64 -58
  62. plain/internal/files/utils.py +24 -10
  63. plain/internal/handlers/base.py +34 -23
  64. plain/internal/handlers/exception.py +68 -65
  65. plain/internal/handlers/wsgi.py +65 -54
  66. plain/internal/middleware/headers.py +37 -11
  67. plain/internal/middleware/hosts.py +11 -8
  68. plain/internal/middleware/https.py +17 -7
  69. plain/internal/middleware/slash.py +14 -9
  70. plain/internal/reloader.py +77 -0
  71. plain/json.py +2 -1
  72. plain/logs/README.md +161 -62
  73. plain/logs/__init__.py +1 -1
  74. plain/logs/{loggers.py → app.py} +71 -67
  75. plain/logs/configure.py +63 -14
  76. plain/logs/debug.py +17 -6
  77. plain/logs/filters.py +15 -0
  78. plain/logs/formatters.py +7 -4
  79. plain/packages/README.md +105 -23
  80. plain/packages/config.py +15 -7
  81. plain/packages/registry.py +27 -16
  82. plain/paginator.py +31 -21
  83. plain/preflight/README.md +209 -24
  84. plain/preflight/__init__.py +1 -0
  85. plain/preflight/checks.py +3 -1
  86. plain/preflight/files.py +3 -1
  87. plain/preflight/registry.py +26 -11
  88. plain/preflight/results.py +15 -7
  89. plain/preflight/security.py +15 -13
  90. plain/preflight/settings.py +54 -0
  91. plain/preflight/urls.py +4 -1
  92. plain/runtime/README.md +115 -47
  93. plain/runtime/__init__.py +10 -6
  94. plain/runtime/global_settings.py +34 -25
  95. plain/runtime/secret.py +20 -0
  96. plain/runtime/user_settings.py +110 -38
  97. plain/runtime/utils.py +1 -1
  98. plain/server/LICENSE +35 -0
  99. plain/server/README.md +155 -0
  100. plain/server/__init__.py +9 -0
  101. plain/server/app.py +52 -0
  102. plain/server/arbiter.py +555 -0
  103. plain/server/config.py +118 -0
  104. plain/server/errors.py +31 -0
  105. plain/server/glogging.py +292 -0
  106. plain/server/http/__init__.py +12 -0
  107. plain/server/http/body.py +283 -0
  108. plain/server/http/errors.py +155 -0
  109. plain/server/http/message.py +400 -0
  110. plain/server/http/parser.py +70 -0
  111. plain/server/http/unreader.py +88 -0
  112. plain/server/http/wsgi.py +421 -0
  113. plain/server/pidfile.py +92 -0
  114. plain/server/sock.py +240 -0
  115. plain/server/util.py +317 -0
  116. plain/server/workers/__init__.py +6 -0
  117. plain/server/workers/base.py +304 -0
  118. plain/server/workers/sync.py +212 -0
  119. plain/server/workers/thread.py +399 -0
  120. plain/server/workers/workertmp.py +50 -0
  121. plain/signals/README.md +170 -1
  122. plain/signals/__init__.py +0 -1
  123. plain/signals/dispatch/dispatcher.py +49 -27
  124. plain/signing.py +131 -35
  125. plain/skills/README.md +36 -0
  126. plain/skills/plain-docs/SKILL.md +25 -0
  127. plain/skills/plain-install/SKILL.md +26 -0
  128. plain/skills/plain-request/SKILL.md +39 -0
  129. plain/skills/plain-shell/SKILL.md +24 -0
  130. plain/skills/plain-upgrade/SKILL.md +35 -0
  131. plain/templates/README.md +211 -20
  132. plain/templates/jinja/__init__.py +13 -5
  133. plain/templates/jinja/environments.py +5 -4
  134. plain/templates/jinja/extensions.py +12 -5
  135. plain/templates/jinja/filters.py +7 -2
  136. plain/templates/jinja/globals.py +2 -2
  137. plain/test/README.md +184 -22
  138. plain/test/client.py +340 -222
  139. plain/test/encoding.py +9 -6
  140. plain/test/exceptions.py +7 -2
  141. plain/urls/README.md +157 -73
  142. plain/urls/converters.py +18 -15
  143. plain/urls/exceptions.py +2 -2
  144. plain/urls/patterns.py +38 -22
  145. plain/urls/resolvers.py +35 -25
  146. plain/urls/utils.py +5 -1
  147. plain/utils/README.md +250 -3
  148. plain/utils/cache.py +17 -11
  149. plain/utils/crypto.py +21 -5
  150. plain/utils/datastructures.py +89 -56
  151. plain/utils/dateparse.py +9 -6
  152. plain/utils/deconstruct.py +15 -7
  153. plain/utils/decorators.py +5 -1
  154. plain/utils/dotenv.py +373 -0
  155. plain/utils/duration.py +8 -4
  156. plain/utils/encoding.py +14 -7
  157. plain/utils/functional.py +66 -49
  158. plain/utils/hashable.py +5 -1
  159. plain/utils/html.py +36 -22
  160. plain/utils/http.py +16 -9
  161. plain/utils/inspect.py +14 -6
  162. plain/utils/ipv6.py +7 -3
  163. plain/utils/itercompat.py +6 -1
  164. plain/utils/module_loading.py +7 -3
  165. plain/utils/regex_helper.py +37 -23
  166. plain/utils/safestring.py +14 -6
  167. plain/utils/text.py +41 -23
  168. plain/utils/timezone.py +33 -22
  169. plain/utils/tree.py +35 -19
  170. plain/validators.py +94 -52
  171. plain/views/README.md +156 -79
  172. plain/views/__init__.py +0 -1
  173. plain/views/base.py +25 -18
  174. plain/views/errors.py +13 -5
  175. plain/views/exceptions.py +4 -1
  176. plain/views/forms.py +6 -6
  177. plain/views/objects.py +52 -49
  178. plain/views/redirect.py +18 -15
  179. plain/views/templates.py +5 -3
  180. plain/wsgi.py +3 -1
  181. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
  182. plain-0.101.2.dist-info/RECORD +201 -0
  183. {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
  184. plain-0.101.2.dist-info/entry_points.txt +2 -0
  185. plain/AGENTS.md +0 -18
  186. plain/cli/agent/__init__.py +0 -20
  187. plain/cli/agent/docs.py +0 -80
  188. plain/cli/agent/md.py +0 -87
  189. plain/cli/agent/prompt.py +0 -45
  190. plain/csrf/views.py +0 -31
  191. plain/logs/utils.py +0 -46
  192. plain/templates/AGENTS.md +0 -3
  193. plain-0.68.0.dist-info/RECORD +0 -169
  194. plain-0.68.0.dist-info/entry_points.txt +0 -5
  195. {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 Jinja.**
3
+ **Render HTML templates using Jinja2.**
4
4
 
5
5
  - [Overview](#overview)
6
6
  - [Template files](#template-files)
7
- - [Extending Jinja](#extending-jinja)
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/api/) for all of the features available.
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
- In general, templates are used in combination with `TemplateView` or a more specific subclass of it.
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 be located in either a root `app/templates`,
41
- or the `<pkg>/templates` directory of any installed package.
48
+ Template files can live in two locations:
42
49
 
43
- All template directories are "merged" together, allowing you to override templates from other packages. The `app/templates` will take priority, followed by `INSTALLED_PACKAGES` in the order they are defined.
50
+ 1. **`app/templates/`** - Your app's templates (highest priority)
51
+ 2. **`{package}/templates/`** - Templates inside any installed package
44
52
 
45
- ## Extending Jinja
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
- Plain includes a set of default [global variables](./jinja/globals.py) and [filters](./jinja/filters.py). You can register additional extensions, globals, or filters either in a package or in your app. Typically this will be in `app/templates.py` or `<pkg>/templates.py`, which are automatically imported.
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, register_template_extension
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 HTMXJSExtension(InclusionTagExtension):
68
- tags = {"htmx_js"}
69
- template_name = "htmx/js.html"
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
- "DEBUG": settings.DEBUG,
74
- "extensions": kwargs.get("extensions", []),
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
- Templates can also be rendered manually using the [`Template` class](./core.py#Template).
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
- comment_body = Template("comment.md").render({"message": "Hello, world!",})
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(func, name=None):
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
- environment.filters[name or func.__name__] = func
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 finalize_callable_error(obj):
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=finalize_callable_error,
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
- context = self.get_context(context, *args, **kwargs)
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(context)
39
+ return template.render(render_context)
35
40
 
36
- def get_context(self, context, *args, **kwargs):
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
  )
@@ -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(value, timezone=None):
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:
@@ -5,7 +5,7 @@ from plain.urls import reverse
5
5
  from plain.utils import timezone
6
6
 
7
7
 
8
- def asset(url_path):
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": 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
- # Test
1
+ # plain.test
2
2
 
3
- **Testing utilities for Plain.**
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
- This module provides the [`Client`](client.py#Client) and [`RequestFactory`](client.py#RequestFactory) classes to facilitate testing requests and responses.
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 test_client_example():
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
- # Modifying sessions
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
- # Logging in
27
- user = User.query.first()
28
- client.force_login(user)
29
- response = client.get("/protected/")
30
- assert response.status_code == 200
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
- # Logging out
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
- def test_request_factory_example():
38
- request = RequestFactory().get("/")
39
- assert request.method == "GET"
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
- More complete testing utilities are provided by the [`plain.pytest`](/plain-pytest/README.md) package. The [`plain.models`](/plain-models/README.md) package also provides pytest fixtures for database testing.
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).