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/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
- - [URL args and kwargs](#url-args-and-kwargs)
8
- - [Package routers](#package-routers)
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
- URLs are typically the "entrypoint" to your app. Virtually all request handling up to this point happens behind the scenes, and then you decide how to route specific URL patterns to your views.
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
- The `URLS_ROUTER` is the primary router that handles all incoming requests. It is defined in your `app/settings.py` file. This will typically point to a `Router` class in your `app.urls` module.
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
- The root router often has an empty namespace (`""`) and some combination of individual paths and sub-routers.
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
- path("about/", views.AboutView, name="about"), # A named URL
35
- path("", views.HomeView), # An unnamed URL
75
+ include("api/", ApiRouter),
76
+ path("", views.HomeView),
36
77
  ]
37
78
  ```
38
79
 
39
- ## Reversing URLs
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
- In templates, you will use the `{{ url("<url name>") }}` function to look up full URLs by name.
82
+ You can also include a list of patterns directly without creating a separate router class:
42
83
 
43
- ```html
44
- <a href="{{ url('about') }}">About</a>
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
- And the same can be done in Python code with the `reverse` (or `reverse_lazy`) function.
91
+ ## Path converters
48
92
 
49
- ```python
50
- from plain.urls import reverse
93
+ Capture dynamic segments from URLs using angle bracket syntax:
51
94
 
52
- url = reverse("about")
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
- A URL path has to include a `name` attribute if you want to reverse it. The router's `namespace` will be used as a prefix to the URL name.
100
+ Captured values are available in your view as `self.url_kwargs`:
56
101
 
57
102
  ```python
58
- from plain.urls import reverse
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
- url = reverse("admin:dashboard")
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
- ## URL args and kwargs
125
+ ### Custom converters
64
126
 
65
- URL patterns can include arguments and keyword arguments.
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
- # app/urls.py
69
- from plain.urls import Router, path
70
- from . import views
130
+ from plain.urls import register_converter
71
131
 
72
132
 
73
- class AppRouter(Router):
74
- namespace = ""
75
- urls = [
76
- path("user/<int:user_id>/", views.UserView, name="user"),
77
- path("search/<str:query>/", views.SearchView, name="search"),
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
- These will be accessible inside the view as `self.url_args` and `self.url_kwargs`.
146
+ Then use it in your patterns:
82
147
 
83
148
  ```python
84
- # app/views.py
85
- from plain.views import View
149
+ path("archive/<year:year>/", views.ArchiveView, name="archive")
150
+ ```
86
151
 
152
+ ## Reversing URLs
87
153
 
88
- class SearchView(View):
89
- def get(self):
90
- query = self.url_kwargs["query"]
91
- print(f"Searching for {query}")
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
- To reverse a URL with args or kwargs, simply pass them in the `reverse` function.
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("search", query="example")
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
- There are a handful of built-in [converters](converters.py#DEFAULT_CONVERTERS) that can be used in URL patterns.
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 Router, path
107
- from . import views
183
+ from plain.urls import reverse_lazy
108
184
 
109
185
 
110
- class AppRouter(Router):
111
- namespace = ""
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
- ## Package routers
190
+ The URL is not resolved until it is actually used as a string.
191
+
192
+ ## Regex patterns
122
193
 
123
- Installed packages will often provide a URL router to include in your root URL router.
194
+ For complex matching that path converters cannot handle, you can use regular expressions:
124
195
 
125
196
  ```python
126
- # plain/assets/urls.py
127
- from plain.urls import Router, path
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
- class AssetsRouter(Router):
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
- Include this router in your app router if you are serving assets yourself.
136
- """
205
+ ## FAQs
137
206
 
138
- namespace = "assets"
139
- urls = [
140
- path("<path:path>", AssetView, name="asset"),
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
- Import the package's router and `include` it at any path you choose.
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
- from plain.urls import include, Router
148
- from plain.assets.urls import AssetsRouter
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
- include("assets/", AssetsRouter),
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
- DEFAULT_CONVERTERS = {
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
- REGISTERED_CONVERTERS = {}
55
+ _REGISTERED_CONVERTERS: dict[str, Any] = {}
53
56
 
54
57
 
55
- def register_converter(converter, type_name):
56
- REGISTERED_CONVERTERS[type_name] = converter()
57
- get_converters.cache_clear()
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 get_converters():
62
- return {**DEFAULT_CONVERTERS, **REGISTERED_CONVERTERS}
64
+ def _get_converters() -> dict[str, Any]:
65
+ return {**_DEFAULT_CONVERTERS, **_REGISTERED_CONVERTERS}
63
66
 
64
67
 
65
- def get_converter(raw_converter):
66
- return get_converters()[raw_converter]
68
+ def _get_converter(raw_converter: str) -> Any:
69
+ return _get_converters()[raw_converter]
plain/urls/exceptions.py CHANGED
@@ -1,7 +1,7 @@
1
- from plain.http import Http404
1
+ from plain.http import NotFoundError404
2
2
 
3
3
 
4
- class Resolver404(Http404):
4
+ class Resolver404(NotFoundError404):
5
5
  pass
6
6
 
7
7
 
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 get_converter
13
+ from .converters import _get_converter
11
14
 
12
15
 
13
16
  @internalcode
14
17
  class CheckURLMixin:
15
- def describe(self):
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(route, is_endpoint=False):
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 = get_converter(raw_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__(self, *, pattern, view, name=None):
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