plain 0.68.0__py3-none-any.whl → 0.103.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/CHANGELOG.md +684 -1
- plain/README.md +1 -1
- plain/agents/.claude/rules/plain.md +88 -0
- plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
- plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
- 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 +234 -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 +98 -21
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
- 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/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.103.0.dist-info}/METADATA +4 -2
- plain-0.103.0.dist-info/RECORD +198 -0
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
- plain-0.103.0.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.103.0.dist-info}/licenses/LICENSE +0 -0
plain/test/encoding.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import mimetypes
|
|
2
4
|
import os
|
|
5
|
+
from typing import Any
|
|
3
6
|
|
|
4
7
|
from plain.runtime import settings
|
|
5
8
|
from plain.utils.encoding import force_bytes
|
|
6
9
|
from plain.utils.itercompat import is_iterable
|
|
7
10
|
|
|
8
11
|
|
|
9
|
-
def encode_multipart(boundary, data):
|
|
12
|
+
def encode_multipart(boundary: str, data: dict[str, Any]) -> bytes:
|
|
10
13
|
"""
|
|
11
14
|
Encode multipart POST data from a dictionary of form values.
|
|
12
15
|
|
|
@@ -14,13 +17,13 @@ def encode_multipart(boundary, data):
|
|
|
14
17
|
as content. If the value is a file, the contents of the file will be sent
|
|
15
18
|
as an application/octet-stream; otherwise, str(value) will be sent.
|
|
16
19
|
"""
|
|
17
|
-
lines = []
|
|
20
|
+
lines: list[bytes] = []
|
|
18
21
|
|
|
19
|
-
def to_bytes(s):
|
|
22
|
+
def to_bytes(s: str) -> bytes:
|
|
20
23
|
return force_bytes(s, settings.DEFAULT_CHARSET)
|
|
21
24
|
|
|
22
25
|
# Not by any means perfect, but good enough for our purposes.
|
|
23
|
-
def is_file(thing):
|
|
26
|
+
def is_file(thing: Any) -> bool:
|
|
24
27
|
return hasattr(thing, "read") and callable(thing.read)
|
|
25
28
|
|
|
26
29
|
# Each bit of the multipart form data could be either a form value or a
|
|
@@ -68,8 +71,8 @@ def encode_multipart(boundary, data):
|
|
|
68
71
|
return b"\r\n".join(lines)
|
|
69
72
|
|
|
70
73
|
|
|
71
|
-
def encode_file(boundary, key, file):
|
|
72
|
-
def to_bytes(s):
|
|
74
|
+
def encode_file(boundary: str, key: str, file: Any) -> list[bytes]:
|
|
75
|
+
def to_bytes(s: str) -> bytes:
|
|
73
76
|
return force_bytes(s, settings.DEFAULT_CHARSET)
|
|
74
77
|
|
|
75
78
|
# file.name might not be a string. For example, it's an int for
|
plain/test/exceptions.py
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
1
6
|
class RedirectCycleError(Exception):
|
|
2
7
|
"""The test client has been asked to follow a redirect loop."""
|
|
3
8
|
|
|
4
|
-
def __init__(self, message, last_response):
|
|
9
|
+
def __init__(self, message: str, last_response: Any) -> None:
|
|
5
10
|
super().__init__(message)
|
|
6
11
|
self.last_response = last_response
|
|
7
|
-
self.redirect_chain = last_response.redirect_chain
|
|
12
|
+
self.redirect_chain: list[tuple[str, int]] = last_response.redirect_chain
|
plain/urls/README.md
CHANGED
|
@@ -1,27 +1,68 @@
|
|
|
1
1
|
# URLs
|
|
2
2
|
|
|
3
|
-
**Route requests to views.**
|
|
3
|
+
**Route incoming requests to views based on URL patterns.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
|
+
- [Defining paths](#defining-paths)
|
|
7
|
+
- [Including sub-routers](#including-sub-routers)
|
|
8
|
+
- [Path converters](#path-converters)
|
|
9
|
+
- [Built-in converters](#built-in-converters)
|
|
10
|
+
- [Custom converters](#custom-converters)
|
|
6
11
|
- [Reversing URLs](#reversing-urls)
|
|
7
|
-
- [
|
|
8
|
-
- [
|
|
12
|
+
- [In templates](#in-templates)
|
|
13
|
+
- [In Python code](#in-python-code)
|
|
14
|
+
- [Lazy reverse](#lazy-reverse)
|
|
15
|
+
- [Regex patterns](#regex-patterns)
|
|
16
|
+
- [FAQs](#faqs)
|
|
17
|
+
- [Installation](#installation)
|
|
9
18
|
|
|
10
19
|
## Overview
|
|
11
20
|
|
|
12
|
-
|
|
21
|
+
You define URL routing by creating a `Router` class with a list of URL patterns. Each pattern maps a URL path to a view.
|
|
13
22
|
|
|
14
|
-
|
|
23
|
+
```python
|
|
24
|
+
# app/urls.py
|
|
25
|
+
from plain.urls import Router, path
|
|
26
|
+
from . import views
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AppRouter(Router):
|
|
30
|
+
namespace = ""
|
|
31
|
+
urls = [
|
|
32
|
+
path("", views.HomeView),
|
|
33
|
+
path("about/", views.AboutView, name="about"),
|
|
34
|
+
path("contact/", views.ContactView, name="contact"),
|
|
35
|
+
]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The `URLS_ROUTER` setting in your `app/settings.py` tells Plain which router handles incoming requests:
|
|
15
39
|
|
|
16
40
|
```python
|
|
17
41
|
# app/settings.py
|
|
18
42
|
URLS_ROUTER = "app.urls.AppRouter"
|
|
19
43
|
```
|
|
20
44
|
|
|
21
|
-
|
|
45
|
+
When a request comes in, Plain matches the URL against your patterns in order and calls the corresponding view.
|
|
46
|
+
|
|
47
|
+
## Defining paths
|
|
48
|
+
|
|
49
|
+
Use `path()` to map a URL pattern to a view class:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
path("about/", views.AboutView, name="about")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The `name` parameter is optional but required if you want to reverse the URL later. You can pass the view class directly (Plain calls `as_view()` for you) or call `as_view()` yourself to pass arguments:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
path("dashboard/", views.DashboardView.as_view(template_name="custom.html"), name="dashboard")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Including sub-routers
|
|
62
|
+
|
|
63
|
+
Use `include()` to nest routers under a URL prefix. This keeps your URL configuration modular.
|
|
22
64
|
|
|
23
65
|
```python
|
|
24
|
-
# app/urls.py
|
|
25
66
|
from plain.urls import Router, path, include
|
|
26
67
|
from plain.admin.urls import AdminRouter
|
|
27
68
|
from . import views
|
|
@@ -31,127 +72,170 @@ class AppRouter(Router):
|
|
|
31
72
|
namespace = ""
|
|
32
73
|
urls = [
|
|
33
74
|
include("admin/", AdminRouter),
|
|
34
|
-
|
|
35
|
-
path("", views.HomeView),
|
|
75
|
+
include("api/", ApiRouter),
|
|
76
|
+
path("", views.HomeView),
|
|
36
77
|
]
|
|
37
78
|
```
|
|
38
79
|
|
|
39
|
-
|
|
80
|
+
Each included router has its own `namespace` that prefixes URL names. For example, if `AdminRouter` has `namespace = "admin"` and a URL named `"dashboard"`, you reverse it as `"admin:dashboard"`.
|
|
40
81
|
|
|
41
|
-
|
|
82
|
+
You can also include a list of patterns directly without creating a separate router class:
|
|
42
83
|
|
|
43
|
-
```
|
|
44
|
-
|
|
84
|
+
```python
|
|
85
|
+
include("api/", [
|
|
86
|
+
path("users/", views.UsersAPIView, name="users"),
|
|
87
|
+
path("posts/", views.PostsAPIView, name="posts"),
|
|
88
|
+
])
|
|
45
89
|
```
|
|
46
90
|
|
|
47
|
-
|
|
91
|
+
## Path converters
|
|
48
92
|
|
|
49
|
-
|
|
50
|
-
from plain.urls import reverse
|
|
93
|
+
Capture dynamic segments from URLs using angle bracket syntax:
|
|
51
94
|
|
|
52
|
-
|
|
95
|
+
```python
|
|
96
|
+
path("user/<int:user_id>/", views.UserView, name="user")
|
|
97
|
+
path("post/<slug:post_slug>/", views.PostView, name="post")
|
|
53
98
|
```
|
|
54
99
|
|
|
55
|
-
|
|
100
|
+
Captured values are available in your view as `self.url_kwargs`:
|
|
56
101
|
|
|
57
102
|
```python
|
|
58
|
-
|
|
103
|
+
class UserView(View):
|
|
104
|
+
def get(self):
|
|
105
|
+
user_id = self.url_kwargs["user_id"] # Already converted to int
|
|
106
|
+
# ...
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Built-in converters
|
|
59
110
|
|
|
60
|
-
|
|
111
|
+
| Converter | Matches | Python type |
|
|
112
|
+
| --------- | ------------------------------------------------------- | ----------- |
|
|
113
|
+
| `str` | Any non-empty string excluding `/` (default) | `str` |
|
|
114
|
+
| `int` | Zero or positive integers | `int` |
|
|
115
|
+
| `slug` | ASCII letters, numbers, hyphens, underscores | `str` |
|
|
116
|
+
| `uuid` | UUID format like `075194d3-6885-417e-a8a8-6c931e272f00` | `uuid.UUID` |
|
|
117
|
+
| `path` | Any non-empty string including `/` | `str` |
|
|
118
|
+
|
|
119
|
+
When no converter is specified, `str` is used:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
path("search/<query>/", views.SearchView) # Same as <str:query>
|
|
61
123
|
```
|
|
62
124
|
|
|
63
|
-
|
|
125
|
+
### Custom converters
|
|
64
126
|
|
|
65
|
-
|
|
127
|
+
You can register your own converters using [`register_converter()`](./converters.py#register_converter). A converter class needs a `regex` attribute and `to_python()` / `to_url()` methods:
|
|
66
128
|
|
|
67
129
|
```python
|
|
68
|
-
|
|
69
|
-
from plain.urls import Router, path
|
|
70
|
-
from . import views
|
|
130
|
+
from plain.urls import register_converter
|
|
71
131
|
|
|
72
132
|
|
|
73
|
-
class
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
133
|
+
class YearConverter:
|
|
134
|
+
regex = "[0-9]{4}"
|
|
135
|
+
|
|
136
|
+
def to_python(self, value):
|
|
137
|
+
return int(value)
|
|
138
|
+
|
|
139
|
+
def to_url(self, value):
|
|
140
|
+
return str(value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
register_converter(YearConverter, "year")
|
|
79
144
|
```
|
|
80
145
|
|
|
81
|
-
|
|
146
|
+
Then use it in your patterns:
|
|
82
147
|
|
|
83
148
|
```python
|
|
84
|
-
|
|
85
|
-
|
|
149
|
+
path("archive/<year:year>/", views.ArchiveView, name="archive")
|
|
150
|
+
```
|
|
86
151
|
|
|
152
|
+
## Reversing URLs
|
|
87
153
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
154
|
+
### In templates
|
|
155
|
+
|
|
156
|
+
Use the `url()` function to generate URLs by name:
|
|
157
|
+
|
|
158
|
+
```html
|
|
159
|
+
<a href="{{ url('about') }}">About</a>
|
|
160
|
+
<a href="{{ url('user', user_id=42) }}">User Profile</a>
|
|
161
|
+
<a href="{{ url('admin:dashboard') }}">Admin Dashboard</a>
|
|
93
162
|
```
|
|
94
163
|
|
|
95
|
-
|
|
164
|
+
### In Python code
|
|
165
|
+
|
|
166
|
+
Use `reverse()` to generate URLs programmatically:
|
|
96
167
|
|
|
97
168
|
```python
|
|
98
169
|
from plain.urls import reverse
|
|
99
170
|
|
|
100
|
-
url = reverse("
|
|
171
|
+
url = reverse("about") # "/about/"
|
|
172
|
+
url = reverse("user", user_id=42) # "/user/42/"
|
|
173
|
+
url = reverse("admin:dashboard") # "/admin/dashboard/"
|
|
101
174
|
```
|
|
102
175
|
|
|
103
|
-
|
|
176
|
+
If the URL name does not exist or the arguments do not match, `reverse()` raises [`NoReverseMatch`](./exceptions.py#NoReverseMatch).
|
|
177
|
+
|
|
178
|
+
### Lazy reverse
|
|
179
|
+
|
|
180
|
+
Use `reverse_lazy()` when you need a URL at module load time (such as in class attributes or default arguments):
|
|
104
181
|
|
|
105
182
|
```python
|
|
106
|
-
from plain.urls import
|
|
107
|
-
from . import views
|
|
183
|
+
from plain.urls import reverse_lazy
|
|
108
184
|
|
|
109
185
|
|
|
110
|
-
class
|
|
111
|
-
|
|
112
|
-
urls = [
|
|
113
|
-
path("user/<int:user_id>/", views.UserView, name="user"),
|
|
114
|
-
path("search/<str:query>/", views.SearchView, name="search"),
|
|
115
|
-
path("post/<slug:post_slug>/", views.PostView, name="post"),
|
|
116
|
-
path("document/<uuid:uuid>/", views.DocumentView, name="document"),
|
|
117
|
-
path("path/<path:subpath>/", views.PathView, name="path"),
|
|
118
|
-
]
|
|
186
|
+
class MyView(View):
|
|
187
|
+
success_url = reverse_lazy("home")
|
|
119
188
|
```
|
|
120
189
|
|
|
121
|
-
|
|
190
|
+
The URL is not resolved until it is actually used as a string.
|
|
191
|
+
|
|
192
|
+
## Regex patterns
|
|
122
193
|
|
|
123
|
-
|
|
194
|
+
For complex matching that path converters cannot handle, you can use regular expressions:
|
|
124
195
|
|
|
125
196
|
```python
|
|
126
|
-
|
|
127
|
-
from plain.urls import
|
|
128
|
-
from .views import AssetView
|
|
197
|
+
import re
|
|
198
|
+
from plain.urls import path
|
|
129
199
|
|
|
200
|
+
path(re.compile(r"^articles/(?P<year>[0-9]{4})/$"), views.ArticleView, name="article")
|
|
201
|
+
```
|
|
130
202
|
|
|
131
|
-
|
|
132
|
-
"""
|
|
133
|
-
The router for serving static assets.
|
|
203
|
+
Named groups become keyword arguments. Unnamed groups become positional arguments accessible via `self.url_args`.
|
|
134
204
|
|
|
135
|
-
|
|
136
|
-
"""
|
|
205
|
+
## FAQs
|
|
137
206
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
207
|
+
#### Why does my URL pattern need a trailing slash?
|
|
208
|
+
|
|
209
|
+
By default, Plain's `APPEND_SLASH` setting redirects URLs without a trailing slash to URLs with one. Define your patterns with trailing slashes to match this behavior. If you prefer URLs without trailing slashes, set `APPEND_SLASH = False` in your settings.
|
|
210
|
+
|
|
211
|
+
#### How do I debug URL routing issues?
|
|
212
|
+
|
|
213
|
+
Check that your URL patterns are in the correct order. Plain matches patterns top to bottom and uses the first match. More specific patterns should come before general ones.
|
|
214
|
+
|
|
215
|
+
#### Can I access URL arguments as positional args instead of kwargs?
|
|
143
216
|
|
|
144
|
-
|
|
217
|
+
If you use regex patterns with unnamed groups (no `?P<name>`), values are passed as positional arguments in `self.url_args`. Named groups always populate `self.url_kwargs`.
|
|
218
|
+
|
|
219
|
+
## Installation
|
|
220
|
+
|
|
221
|
+
The `plain.urls` module is included with Plain by default. No additional installation is required.
|
|
222
|
+
|
|
223
|
+
To set up URL routing, create a router in `app/urls.py` and point to it in your settings:
|
|
145
224
|
|
|
146
225
|
```python
|
|
147
|
-
|
|
148
|
-
|
|
226
|
+
# app/settings.py
|
|
227
|
+
URLS_ROUTER = "app.urls.AppRouter"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
# app/urls.py
|
|
232
|
+
from plain.urls import Router, path
|
|
233
|
+
from . import views
|
|
149
234
|
|
|
150
235
|
|
|
151
236
|
class AppRouter(Router):
|
|
152
237
|
namespace = ""
|
|
153
238
|
urls = [
|
|
154
|
-
|
|
155
|
-
# Your other URLs here...
|
|
239
|
+
path("", views.HomeView),
|
|
156
240
|
]
|
|
157
241
|
```
|
plain/urls/converters.py
CHANGED
|
@@ -1,34 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import functools
|
|
2
4
|
import uuid
|
|
5
|
+
from typing import Any
|
|
3
6
|
|
|
4
7
|
|
|
5
8
|
class IntConverter:
|
|
6
9
|
regex = "[0-9]+"
|
|
7
10
|
|
|
8
|
-
def to_python(self, value):
|
|
11
|
+
def to_python(self, value: str) -> int:
|
|
9
12
|
return int(value)
|
|
10
13
|
|
|
11
|
-
def to_url(self, value):
|
|
14
|
+
def to_url(self, value: int) -> str:
|
|
12
15
|
return str(value)
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
class StringConverter:
|
|
16
19
|
regex = "[^/]+"
|
|
17
20
|
|
|
18
|
-
def to_python(self, value):
|
|
21
|
+
def to_python(self, value: str) -> str:
|
|
19
22
|
return value
|
|
20
23
|
|
|
21
|
-
def to_url(self, value):
|
|
24
|
+
def to_url(self, value: str) -> str:
|
|
22
25
|
return value
|
|
23
26
|
|
|
24
27
|
|
|
25
28
|
class UUIDConverter:
|
|
26
29
|
regex = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
|
27
30
|
|
|
28
|
-
def to_python(self, value):
|
|
31
|
+
def to_python(self, value: str) -> uuid.UUID:
|
|
29
32
|
return uuid.UUID(value)
|
|
30
33
|
|
|
31
|
-
def to_url(self, value):
|
|
34
|
+
def to_url(self, value: uuid.UUID) -> str:
|
|
32
35
|
return str(value)
|
|
33
36
|
|
|
34
37
|
|
|
@@ -40,7 +43,7 @@ class PathConverter(StringConverter):
|
|
|
40
43
|
regex = ".+"
|
|
41
44
|
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
_DEFAULT_CONVERTERS = {
|
|
44
47
|
"int": IntConverter(),
|
|
45
48
|
"path": PathConverter(),
|
|
46
49
|
"slug": SlugConverter(),
|
|
@@ -49,18 +52,18 @@ DEFAULT_CONVERTERS = {
|
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
_REGISTERED_CONVERTERS: dict[str, Any] = {}
|
|
53
56
|
|
|
54
57
|
|
|
55
|
-
def register_converter(converter, type_name):
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
def register_converter(converter: type, type_name: str) -> None:
|
|
59
|
+
_REGISTERED_CONVERTERS[type_name] = converter()
|
|
60
|
+
_get_converters.cache_clear()
|
|
58
61
|
|
|
59
62
|
|
|
60
63
|
@functools.cache
|
|
61
|
-
def
|
|
62
|
-
return {**
|
|
64
|
+
def _get_converters() -> dict[str, Any]:
|
|
65
|
+
return {**_DEFAULT_CONVERTERS, **_REGISTERED_CONVERTERS}
|
|
63
66
|
|
|
64
67
|
|
|
65
|
-
def
|
|
66
|
-
return
|
|
68
|
+
def _get_converter(raw_converter: str) -> Any:
|
|
69
|
+
return _get_converters()[raw_converter]
|
plain/urls/exceptions.py
CHANGED
plain/urls/patterns.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import re
|
|
2
4
|
import string
|
|
5
|
+
from typing import Any
|
|
3
6
|
|
|
4
7
|
from plain.exceptions import ImproperlyConfigured
|
|
5
8
|
from plain.internal import internalcode
|
|
@@ -7,12 +10,16 @@ from plain.preflight import PreflightResult
|
|
|
7
10
|
from plain.runtime import settings
|
|
8
11
|
from plain.utils.regex_helper import _lazy_re_compile
|
|
9
12
|
|
|
10
|
-
from .converters import
|
|
13
|
+
from .converters import _get_converter
|
|
11
14
|
|
|
12
15
|
|
|
13
16
|
@internalcode
|
|
14
17
|
class CheckURLMixin:
|
|
15
|
-
|
|
18
|
+
# Expected to be set by subclasses
|
|
19
|
+
regex: re.Pattern[str]
|
|
20
|
+
name: str | None
|
|
21
|
+
|
|
22
|
+
def describe(self) -> str:
|
|
16
23
|
"""
|
|
17
24
|
Format the URL pattern for display in warning messages.
|
|
18
25
|
"""
|
|
@@ -21,7 +28,7 @@ class CheckURLMixin:
|
|
|
21
28
|
description += f" [name='{self.name}']"
|
|
22
29
|
return description
|
|
23
30
|
|
|
24
|
-
def _check_pattern_startswith_slash(self):
|
|
31
|
+
def _check_pattern_startswith_slash(self) -> list[PreflightResult]:
|
|
25
32
|
"""
|
|
26
33
|
Check that the pattern does not begin with a forward slash.
|
|
27
34
|
"""
|
|
@@ -44,14 +51,14 @@ class CheckURLMixin:
|
|
|
44
51
|
|
|
45
52
|
|
|
46
53
|
class RegexPattern(CheckURLMixin):
|
|
47
|
-
def __init__(self, regex, name=None, is_endpoint=False):
|
|
54
|
+
def __init__(self, regex: str, name: str | None = None, is_endpoint: bool = False):
|
|
48
55
|
self._regex = regex
|
|
49
56
|
self._is_endpoint = is_endpoint
|
|
50
57
|
self.name = name
|
|
51
|
-
self.converters = {}
|
|
58
|
+
self.converters: dict[str, Any] = {}
|
|
52
59
|
self.regex = self._compile(str(regex))
|
|
53
60
|
|
|
54
|
-
def match(self, path):
|
|
61
|
+
def match(self, path: str) -> tuple[str, tuple[Any, ...], dict[str, Any]] | None:
|
|
55
62
|
match = (
|
|
56
63
|
self.regex.fullmatch(path)
|
|
57
64
|
if self._is_endpoint and self.regex.pattern.endswith("$")
|
|
@@ -67,14 +74,14 @@ class RegexPattern(CheckURLMixin):
|
|
|
67
74
|
return path[match.end() :], args, kwargs
|
|
68
75
|
return None
|
|
69
76
|
|
|
70
|
-
def preflight(self):
|
|
77
|
+
def preflight(self) -> list[PreflightResult]:
|
|
71
78
|
warnings = []
|
|
72
79
|
warnings.extend(self._check_pattern_startswith_slash())
|
|
73
80
|
if not self._is_endpoint:
|
|
74
81
|
warnings.extend(self._check_include_trailing_dollar())
|
|
75
82
|
return warnings
|
|
76
83
|
|
|
77
|
-
def _check_include_trailing_dollar(self):
|
|
84
|
+
def _check_include_trailing_dollar(self) -> list[PreflightResult]:
|
|
78
85
|
regex_pattern = self.regex.pattern
|
|
79
86
|
if regex_pattern.endswith("$") and not regex_pattern.endswith(r"\$"):
|
|
80
87
|
return [
|
|
@@ -87,7 +94,7 @@ class RegexPattern(CheckURLMixin):
|
|
|
87
94
|
else:
|
|
88
95
|
return []
|
|
89
96
|
|
|
90
|
-
def _compile(self, regex):
|
|
97
|
+
def _compile(self, regex: str) -> re.Pattern[str]:
|
|
91
98
|
"""Compile and return the given regular expression."""
|
|
92
99
|
try:
|
|
93
100
|
return re.compile(regex)
|
|
@@ -96,7 +103,7 @@ class RegexPattern(CheckURLMixin):
|
|
|
96
103
|
f'"{regex}" is not a valid regular expression: {e}'
|
|
97
104
|
) from e
|
|
98
105
|
|
|
99
|
-
def __str__(self):
|
|
106
|
+
def __str__(self) -> str:
|
|
100
107
|
return str(self._regex)
|
|
101
108
|
|
|
102
109
|
|
|
@@ -105,7 +112,9 @@ _PATH_PARAMETER_COMPONENT_RE = _lazy_re_compile(
|
|
|
105
112
|
)
|
|
106
113
|
|
|
107
114
|
|
|
108
|
-
def _route_to_regex(
|
|
115
|
+
def _route_to_regex(
|
|
116
|
+
route: str, is_endpoint: bool = False
|
|
117
|
+
) -> tuple[str, dict[str, Any]]:
|
|
109
118
|
"""
|
|
110
119
|
Convert a path pattern into a regular expression. Return the regular
|
|
111
120
|
expression and a dictionary mapping the capture names to the converters.
|
|
@@ -138,7 +147,7 @@ def _route_to_regex(route, is_endpoint=False):
|
|
|
138
147
|
# If a converter isn't specified, the default is `str`.
|
|
139
148
|
raw_converter = "str"
|
|
140
149
|
try:
|
|
141
|
-
converter =
|
|
150
|
+
converter = _get_converter(raw_converter)
|
|
142
151
|
except KeyError as e:
|
|
143
152
|
raise ImproperlyConfigured(
|
|
144
153
|
f"URL route {original_route!r} uses invalid converter {raw_converter!r}."
|
|
@@ -151,14 +160,14 @@ def _route_to_regex(route, is_endpoint=False):
|
|
|
151
160
|
|
|
152
161
|
|
|
153
162
|
class RoutePattern(CheckURLMixin):
|
|
154
|
-
def __init__(self, route, name=None, is_endpoint=False):
|
|
163
|
+
def __init__(self, route: str, name: str | None = None, is_endpoint: bool = False):
|
|
155
164
|
self._route = route
|
|
156
165
|
self._is_endpoint = is_endpoint
|
|
157
166
|
self.name = name
|
|
158
167
|
self.converters = _route_to_regex(str(route), is_endpoint)[1]
|
|
159
168
|
self.regex = self._compile(str(route))
|
|
160
169
|
|
|
161
|
-
def match(self, path):
|
|
170
|
+
def match(self, path: str) -> tuple[str, tuple[()], dict[str, Any]] | None:
|
|
162
171
|
match = self.regex.search(path)
|
|
163
172
|
if match:
|
|
164
173
|
# RoutePattern doesn't allow non-named groups so args are ignored.
|
|
@@ -172,7 +181,7 @@ class RoutePattern(CheckURLMixin):
|
|
|
172
181
|
return path[match.end() :], (), kwargs
|
|
173
182
|
return None
|
|
174
183
|
|
|
175
|
-
def preflight(self):
|
|
184
|
+
def preflight(self) -> list[PreflightResult]:
|
|
176
185
|
warnings = self._check_pattern_startswith_slash()
|
|
177
186
|
route = self._route
|
|
178
187
|
if "(?P<" in route or route.startswith("^") or route.endswith("$"):
|
|
@@ -187,28 +196,34 @@ class RoutePattern(CheckURLMixin):
|
|
|
187
196
|
)
|
|
188
197
|
return warnings
|
|
189
198
|
|
|
190
|
-
def _compile(self, route):
|
|
199
|
+
def _compile(self, route: str) -> re.Pattern[str]:
|
|
191
200
|
return re.compile(_route_to_regex(route, self._is_endpoint)[0])
|
|
192
201
|
|
|
193
|
-
def __str__(self):
|
|
202
|
+
def __str__(self) -> str:
|
|
194
203
|
return str(self._route)
|
|
195
204
|
|
|
196
205
|
|
|
197
206
|
class URLPattern:
|
|
198
|
-
def __init__(
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
pattern: RegexPattern | RoutePattern,
|
|
211
|
+
view: Any,
|
|
212
|
+
name: str | None = None,
|
|
213
|
+
):
|
|
199
214
|
self.pattern = pattern
|
|
200
215
|
self.view = view
|
|
201
216
|
self.name = name
|
|
202
217
|
|
|
203
|
-
def __repr__(self):
|
|
218
|
+
def __repr__(self) -> str:
|
|
204
219
|
return f"<{self.__class__.__name__} {self.pattern.describe()}>"
|
|
205
220
|
|
|
206
|
-
def preflight(self):
|
|
221
|
+
def preflight(self) -> list[PreflightResult]:
|
|
207
222
|
warnings = self._check_pattern_name()
|
|
208
223
|
warnings.extend(self.pattern.preflight())
|
|
209
224
|
return warnings
|
|
210
225
|
|
|
211
|
-
def _check_pattern_name(self):
|
|
226
|
+
def _check_pattern_name(self) -> list[PreflightResult]:
|
|
212
227
|
"""
|
|
213
228
|
Check that the pattern name does not contain a colon.
|
|
214
229
|
"""
|
|
@@ -223,7 +238,7 @@ class URLPattern:
|
|
|
223
238
|
else:
|
|
224
239
|
return []
|
|
225
240
|
|
|
226
|
-
def resolve(self, path):
|
|
241
|
+
def resolve(self, path: str) -> Any:
|
|
227
242
|
match = self.pattern.match(path)
|
|
228
243
|
if match:
|
|
229
244
|
new_path, args, captured_kwargs = match
|
|
@@ -236,3 +251,4 @@ class URLPattern:
|
|
|
236
251
|
url_name=self.pattern.name,
|
|
237
252
|
route=str(self.pattern),
|
|
238
253
|
)
|
|
254
|
+
return None
|