plain 0.68.1__py3-none-any.whl → 0.70.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/AGENTS.md +1 -1
- plain/CHANGELOG.md +23 -0
- plain/assets/compile.py +20 -7
- plain/assets/finders.py +15 -11
- plain/assets/fingerprints.py +6 -5
- plain/assets/urls.py +1 -1
- plain/assets/views.py +23 -17
- plain/chores/registry.py +14 -9
- plain/cli/agent/__init__.py +1 -1
- plain/cli/agent/docs.py +7 -6
- plain/cli/agent/llmdocs.py +18 -8
- plain/cli/agent/md.py +19 -14
- plain/cli/agent/prompt.py +1 -1
- plain/cli/agent/request.py +37 -17
- plain/cli/build.py +2 -2
- plain/cli/changelog.py +8 -4
- plain/cli/chores.py +4 -4
- plain/cli/core.py +8 -5
- plain/cli/docs.py +2 -2
- plain/cli/formatting.py +10 -7
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +3 -3
- plain/cli/print.py +1 -1
- plain/cli/registry.py +10 -6
- plain/cli/scaffold.py +1 -1
- plain/cli/settings.py +1 -1
- plain/cli/shell.py +10 -7
- plain/cli/startup.py +3 -3
- plain/cli/urls.py +10 -4
- plain/cli/utils.py +2 -2
- plain/csrf/middleware.py +15 -5
- plain/csrf/views.py +11 -8
- plain/debug.py +5 -2
- plain/exceptions.py +20 -51
- plain/forms/__init__.py +1 -1
- plain/forms/boundfield.py +14 -7
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +139 -97
- plain/forms/forms.py +55 -39
- plain/http/cookie.py +15 -7
- plain/http/multipartparser.py +50 -30
- plain/http/request.py +97 -73
- plain/http/response.py +99 -80
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +34 -18
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +23 -5
- plain/internal/files/uploadedfile.py +42 -26
- plain/internal/files/uploadhandler.py +48 -27
- plain/internal/files/utils.py +13 -6
- plain/internal/handlers/base.py +20 -6
- plain/internal/handlers/exception.py +19 -5
- plain/internal/handlers/wsgi.py +30 -18
- plain/internal/middleware/headers.py +11 -2
- plain/internal/middleware/hosts.py +10 -2
- plain/internal/middleware/https.py +13 -3
- plain/internal/middleware/slash.py +15 -5
- plain/json.py +2 -1
- plain/logs/configure.py +3 -1
- plain/logs/debug.py +16 -5
- plain/logs/formatters.py +6 -3
- plain/logs/loggers.py +56 -52
- plain/logs/utils.py +19 -9
- plain/packages/config.py +14 -6
- plain/packages/registry.py +27 -12
- plain/paginator.py +31 -21
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +25 -10
- plain/preflight/results.py +10 -4
- plain/preflight/security.py +7 -5
- plain/preflight/urls.py +4 -1
- plain/runtime/__init__.py +4 -3
- plain/runtime/global_settings.py +1 -1
- plain/runtime/user_settings.py +26 -17
- plain/runtime/utils.py +1 -1
- plain/signals/dispatch/dispatcher.py +39 -17
- plain/signing.py +49 -30
- plain/templates/jinja/__init__.py +13 -5
- plain/templates/jinja/environments.py +4 -3
- plain/templates/jinja/extensions.py +9 -3
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +1 -1
- plain/test/client.py +246 -174
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +10 -2
- plain/urls/converters.py +13 -10
- plain/urls/patterns.py +32 -20
- plain/urls/resolvers.py +32 -22
- plain/urls/utils.py +5 -1
- plain/utils/cache.py +14 -8
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +84 -54
- plain/utils/dateparse.py +10 -7
- plain/utils/deconstruct.py +12 -4
- plain/utils/decorators.py +5 -1
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +62 -47
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +21 -14
- 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 +23 -13
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +34 -18
- plain/utils/timezone.py +30 -19
- plain/utils/tree.py +31 -18
- plain/validators.py +71 -44
- plain/views/base.py +16 -6
- plain/views/errors.py +11 -4
- plain/views/exceptions.py +4 -1
- plain/views/objects.py +27 -17
- plain/views/redirect.py +14 -10
- plain/views/templates.py +1 -1
- plain/wsgi.py +3 -1
- {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/METADATA +1 -1
- plain-0.70.0.dist-info/RECORD +169 -0
- plain-0.68.1.dist-info/RECORD +0 -169
- {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/WHEEL +0 -0
- {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/entry_points.txt +0 -0
- {plain-0.68.1.dist-info → plain-0.70.0.dist-info}/licenses/LICENSE +0 -0
plain/logs/utils.py
CHANGED
@@ -1,17 +1,24 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import logging
|
4
|
+
from typing import TYPE_CHECKING, Any
|
5
|
+
|
6
|
+
if TYPE_CHECKING:
|
7
|
+
from plain.http.request import HttpRequest
|
8
|
+
from plain.http.response import ResponseBase
|
2
9
|
|
3
10
|
request_logger = logging.getLogger("plain.request")
|
4
11
|
|
5
12
|
|
6
13
|
def log_response(
|
7
|
-
message,
|
8
|
-
*args,
|
9
|
-
response=None,
|
10
|
-
request=None,
|
11
|
-
logger=request_logger,
|
12
|
-
level=None,
|
13
|
-
exception=None,
|
14
|
-
):
|
14
|
+
message: str,
|
15
|
+
*args: Any,
|
16
|
+
response: ResponseBase | None = None,
|
17
|
+
request: HttpRequest | None = None,
|
18
|
+
logger: logging.Logger = request_logger,
|
19
|
+
level: str | None = None,
|
20
|
+
exception: BaseException | None = None,
|
21
|
+
) -> None:
|
15
22
|
"""
|
16
23
|
Log errors based on Response status.
|
17
24
|
|
@@ -19,6 +26,9 @@ def log_response(
|
|
19
26
|
is given as a keyword argument). The Response status_code and the
|
20
27
|
request are passed to the logger's extra parameter.
|
21
28
|
"""
|
29
|
+
if response is None:
|
30
|
+
return
|
31
|
+
|
22
32
|
# Check if the response has already been logged. Multiple requests to log
|
23
33
|
# the same response can be received in some cases, e.g., when the
|
24
34
|
# response is the result of an exception and is logged when the exception
|
@@ -43,4 +53,4 @@ def log_response(
|
|
43
53
|
},
|
44
54
|
exc_info=exception,
|
45
55
|
)
|
46
|
-
response._has_been_logged = True
|
56
|
+
response._has_been_logged = True # type: ignore[attr-defined]
|
plain/packages/config.py
CHANGED
@@ -1,9 +1,16 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import os
|
2
4
|
from functools import cached_property
|
3
5
|
from importlib import import_module
|
6
|
+
from types import ModuleType
|
7
|
+
from typing import TYPE_CHECKING
|
4
8
|
|
5
9
|
from plain.exceptions import ImproperlyConfigured
|
6
10
|
|
11
|
+
if TYPE_CHECKING:
|
12
|
+
from plain.packages.registry import PackagesRegistry
|
13
|
+
|
7
14
|
CONFIG_MODULE_NAME = "config"
|
8
15
|
|
9
16
|
|
@@ -12,13 +19,13 @@ class PackageConfig:
|
|
12
19
|
|
13
20
|
package_label: str
|
14
21
|
|
15
|
-
def __init__(self, name):
|
22
|
+
def __init__(self, name: str):
|
16
23
|
# Full Python path to the application e.g. 'plain.admin.admin'.
|
17
24
|
self.name = name
|
18
25
|
|
19
26
|
# Reference to the Packages registry that holds this PackageConfig. Set by the
|
20
27
|
# registry when it registers the PackageConfig instance.
|
21
|
-
self.
|
28
|
+
self.packages: PackagesRegistry | None = None
|
22
29
|
|
23
30
|
if not hasattr(self, "package_label"):
|
24
31
|
# Last component of the Python path to the application e.g. 'admin'.
|
@@ -30,14 +37,14 @@ class PackageConfig:
|
|
30
37
|
f"The app label '{self.package_label}' is not a valid Python identifier."
|
31
38
|
)
|
32
39
|
|
33
|
-
def __repr__(self):
|
40
|
+
def __repr__(self) -> str:
|
34
41
|
return f"<{self.__class__.__name__}: {self.package_label}>"
|
35
42
|
|
36
43
|
@cached_property
|
37
|
-
def path(self):
|
44
|
+
def path(self) -> str:
|
38
45
|
# Filesystem path to the application directory e.g.
|
39
46
|
# '/path/to/admin'.
|
40
|
-
def _path_from_module(module):
|
47
|
+
def _path_from_module(module: ModuleType) -> str:
|
41
48
|
"""Attempt to determine app's filesystem path from its module."""
|
42
49
|
# See #21874 for extended discussion of the behavior of this method in
|
43
50
|
# various cases.
|
@@ -68,7 +75,8 @@ class PackageConfig:
|
|
68
75
|
module = import_module(self.name)
|
69
76
|
return _path_from_module(module)
|
70
77
|
|
71
|
-
def ready(self):
|
78
|
+
def ready(self) -> None:
|
72
79
|
"""
|
73
80
|
Override this method in subclasses to run code when Plain starts.
|
74
81
|
"""
|
82
|
+
return None
|
plain/packages/registry.py
CHANGED
@@ -1,13 +1,20 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import sys
|
2
4
|
import threading
|
3
5
|
from collections import Counter
|
6
|
+
from collections.abc import Iterable
|
4
7
|
from importlib import import_module
|
5
8
|
from importlib.util import find_spec
|
9
|
+
from typing import TYPE_CHECKING
|
6
10
|
|
7
11
|
from plain.exceptions import ImproperlyConfigured, PackageRegistryNotReady
|
8
12
|
|
9
13
|
from .config import PackageConfig
|
10
14
|
|
15
|
+
if TYPE_CHECKING:
|
16
|
+
pass
|
17
|
+
|
11
18
|
CONFIG_MODULE_NAME = "config"
|
12
19
|
|
13
20
|
|
@@ -18,7 +25,7 @@ class PackagesRegistry:
|
|
18
25
|
It also keeps track of models, e.g. to provide reverse relations.
|
19
26
|
"""
|
20
27
|
|
21
|
-
def __init__(self, installed_packages=()):
|
28
|
+
def __init__(self, installed_packages: Iterable[str | PackageConfig] | None = ()):
|
22
29
|
# installed_packages is set to None when creating the main registry
|
23
30
|
# because it cannot be populated at that point. Other registries must
|
24
31
|
# provide a list of installed packages and are populated immediately.
|
@@ -28,7 +35,7 @@ class PackagesRegistry:
|
|
28
35
|
raise RuntimeError("You must supply an installed_packages argument.")
|
29
36
|
|
30
37
|
# Mapping of labels to PackageConfig instances for installed packages.
|
31
|
-
self.package_configs = {}
|
38
|
+
self.package_configs: dict[str, PackageConfig] = {}
|
32
39
|
|
33
40
|
# Whether the registry is populated.
|
34
41
|
self.packages_ready = self.ready = False
|
@@ -41,7 +48,9 @@ class PackagesRegistry:
|
|
41
48
|
if installed_packages is not None:
|
42
49
|
self.populate(installed_packages)
|
43
50
|
|
44
|
-
def populate(
|
51
|
+
def populate(
|
52
|
+
self, installed_packages: Iterable[str | PackageConfig] | None = None
|
53
|
+
) -> None:
|
45
54
|
"""
|
46
55
|
Load application configurations and models.
|
47
56
|
|
@@ -67,6 +76,9 @@ class PackagesRegistry:
|
|
67
76
|
self.loading = True
|
68
77
|
|
69
78
|
# Phase 1: initialize app configs and import app modules.
|
79
|
+
if installed_packages is None:
|
80
|
+
return
|
81
|
+
|
70
82
|
for entry in installed_packages:
|
71
83
|
if isinstance(entry, PackageConfig):
|
72
84
|
# Some instances of the registry pass in the
|
@@ -92,9 +104,10 @@ class PackagesRegistry:
|
|
92
104
|
entry_config = self.register_config(auto_package_config)
|
93
105
|
|
94
106
|
# Make sure we have the same number of configs as we have installed packages
|
95
|
-
|
107
|
+
installed_packages_list = list(installed_packages)
|
108
|
+
if len(self.package_configs) != len(installed_packages_list):
|
96
109
|
raise ImproperlyConfigured(
|
97
|
-
f"The number of installed packages ({len(
|
110
|
+
f"The number of installed packages ({len(installed_packages_list)}) does not match the number of "
|
98
111
|
f"registered configs ({len(self.package_configs)})."
|
99
112
|
)
|
100
113
|
|
@@ -118,7 +131,7 @@ class PackagesRegistry:
|
|
118
131
|
|
119
132
|
self.ready = True
|
120
133
|
|
121
|
-
def check_packages_ready(self):
|
134
|
+
def check_packages_ready(self) -> None:
|
122
135
|
"""Raise an exception if all packages haven't been imported yet."""
|
123
136
|
if not self.packages_ready:
|
124
137
|
from plain.runtime import settings
|
@@ -129,12 +142,12 @@ class PackagesRegistry:
|
|
129
142
|
settings.INSTALLED_PACKAGES
|
130
143
|
raise PackageRegistryNotReady("Packages aren't loaded yet.")
|
131
144
|
|
132
|
-
def get_package_configs(self):
|
145
|
+
def get_package_configs(self) -> Iterable[PackageConfig]:
|
133
146
|
"""Import applications and return an iterable of app configs."""
|
134
147
|
self.check_packages_ready()
|
135
148
|
return self.package_configs.values()
|
136
149
|
|
137
|
-
def get_package_config(self, package_label):
|
150
|
+
def get_package_config(self, package_label: str) -> PackageConfig:
|
138
151
|
"""
|
139
152
|
Import applications and returns an app config for the given label.
|
140
153
|
|
@@ -151,7 +164,7 @@ class PackagesRegistry:
|
|
151
164
|
break
|
152
165
|
raise LookupError(message)
|
153
166
|
|
154
|
-
def get_containing_package_config(self, object_name):
|
167
|
+
def get_containing_package_config(self, object_name: str) -> PackageConfig | None:
|
155
168
|
"""
|
156
169
|
Look for an app config containing a given object.
|
157
170
|
|
@@ -169,8 +182,9 @@ class PackagesRegistry:
|
|
169
182
|
candidates.append(package_config)
|
170
183
|
if candidates:
|
171
184
|
return sorted(candidates, key=lambda ac: -len(ac.name))[0]
|
185
|
+
return None
|
172
186
|
|
173
|
-
def register_config(self, package_config):
|
187
|
+
def register_config(self, package_config: PackageConfig) -> PackageConfig:
|
174
188
|
"""
|
175
189
|
Add a config to the registry.
|
176
190
|
|
@@ -190,9 +204,10 @@ class PackagesRegistry:
|
|
190
204
|
return package_config
|
191
205
|
|
192
206
|
def autodiscover_modules(self, module_name: str, *, include_app: bool) -> None:
|
193
|
-
def _import_if_exists(name):
|
207
|
+
def _import_if_exists(name: str) -> None:
|
194
208
|
if find_spec(name):
|
195
209
|
import_module(name)
|
210
|
+
return None
|
196
211
|
|
197
212
|
# Load from all packages
|
198
213
|
for package_config in self.get_package_configs():
|
@@ -206,7 +221,7 @@ class PackagesRegistry:
|
|
206
221
|
packages_registry = PackagesRegistry(installed_packages=None)
|
207
222
|
|
208
223
|
|
209
|
-
def register_config(package_config_class):
|
224
|
+
def register_config(package_config_class: type[PackageConfig]) -> type[PackageConfig]:
|
210
225
|
"""A decorator to register a PackageConfig subclass."""
|
211
226
|
module_name = package_config_class.__module__
|
212
227
|
|
plain/paginator.py
CHANGED
@@ -1,8 +1,12 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
import collections.abc
|
2
4
|
import inspect
|
3
5
|
import warnings
|
6
|
+
from collections.abc import Iterator
|
4
7
|
from functools import cached_property
|
5
8
|
from math import ceil
|
9
|
+
from typing import Any
|
6
10
|
|
7
11
|
from plain.utils.inspect import method_has_no_args
|
8
12
|
|
@@ -24,18 +28,24 @@ class EmptyPage(InvalidPage):
|
|
24
28
|
|
25
29
|
|
26
30
|
class Paginator:
|
27
|
-
def __init__(
|
31
|
+
def __init__(
|
32
|
+
self,
|
33
|
+
object_list: Any,
|
34
|
+
per_page: int,
|
35
|
+
orphans: int = 0,
|
36
|
+
allow_empty_first_page: bool = True,
|
37
|
+
) -> None:
|
28
38
|
self.object_list = object_list
|
29
39
|
self._check_object_list_is_ordered()
|
30
40
|
self.per_page = int(per_page)
|
31
41
|
self.orphans = int(orphans)
|
32
42
|
self.allow_empty_first_page = allow_empty_first_page
|
33
43
|
|
34
|
-
def __iter__(self):
|
44
|
+
def __iter__(self) -> Iterator[Page]:
|
35
45
|
for page_number in self.page_range:
|
36
46
|
yield self.page(page_number)
|
37
47
|
|
38
|
-
def validate_number(self, number):
|
48
|
+
def validate_number(self, number: Any) -> int:
|
39
49
|
"""Validate the given 1-based page number."""
|
40
50
|
try:
|
41
51
|
if isinstance(number, float) and not number.is_integer():
|
@@ -49,7 +59,7 @@ class Paginator:
|
|
49
59
|
raise EmptyPage("That page contains no results")
|
50
60
|
return number
|
51
61
|
|
52
|
-
def get_page(self, number):
|
62
|
+
def get_page(self, number: Any) -> Page:
|
53
63
|
"""
|
54
64
|
Return a valid page, even if the page argument isn't a number or isn't
|
55
65
|
in range.
|
@@ -62,7 +72,7 @@ class Paginator:
|
|
62
72
|
number = self.num_pages
|
63
73
|
return self.page(number)
|
64
74
|
|
65
|
-
def page(self, number):
|
75
|
+
def page(self, number: Any) -> Page:
|
66
76
|
"""Return a Page object for the given 1-based page number."""
|
67
77
|
number = self.validate_number(number)
|
68
78
|
bottom = (number - 1) * self.per_page
|
@@ -71,7 +81,7 @@ class Paginator:
|
|
71
81
|
top = self.count
|
72
82
|
return self._get_page(self.object_list[bottom:top], number, self)
|
73
83
|
|
74
|
-
def _get_page(self, *args, **kwargs):
|
84
|
+
def _get_page(self, *args: Any, **kwargs: Any) -> Page:
|
75
85
|
"""
|
76
86
|
Return an instance of a single page.
|
77
87
|
|
@@ -81,7 +91,7 @@ class Paginator:
|
|
81
91
|
return Page(*args, **kwargs)
|
82
92
|
|
83
93
|
@cached_property
|
84
|
-
def count(self):
|
94
|
+
def count(self) -> int:
|
85
95
|
"""Return the total number of objects, across all pages."""
|
86
96
|
c = getattr(self.object_list, "count", None)
|
87
97
|
if callable(c) and not inspect.isbuiltin(c) and method_has_no_args(c):
|
@@ -89,7 +99,7 @@ class Paginator:
|
|
89
99
|
return len(self.object_list)
|
90
100
|
|
91
101
|
@cached_property
|
92
|
-
def num_pages(self):
|
102
|
+
def num_pages(self) -> int:
|
93
103
|
"""Return the total number of pages."""
|
94
104
|
if self.count == 0 and not self.allow_empty_first_page:
|
95
105
|
return 0
|
@@ -97,14 +107,14 @@ class Paginator:
|
|
97
107
|
return ceil(hits / self.per_page)
|
98
108
|
|
99
109
|
@property
|
100
|
-
def page_range(self):
|
110
|
+
def page_range(self) -> range:
|
101
111
|
"""
|
102
112
|
Return a 1-based range of pages for iterating through within
|
103
113
|
a template for loop.
|
104
114
|
"""
|
105
115
|
return range(1, self.num_pages + 1)
|
106
116
|
|
107
|
-
def _check_object_list_is_ordered(self):
|
117
|
+
def _check_object_list_is_ordered(self) -> None:
|
108
118
|
"""
|
109
119
|
Warn if self.object_list is unordered (typically a QuerySet).
|
110
120
|
"""
|
@@ -124,18 +134,18 @@ class Paginator:
|
|
124
134
|
|
125
135
|
|
126
136
|
class Page(collections.abc.Sequence):
|
127
|
-
def __init__(self, object_list, number, paginator):
|
137
|
+
def __init__(self, object_list: Any, number: int, paginator: Paginator) -> None:
|
128
138
|
self.object_list = object_list
|
129
139
|
self.number = number
|
130
140
|
self.paginator = paginator
|
131
141
|
|
132
|
-
def __repr__(self):
|
142
|
+
def __repr__(self) -> str:
|
133
143
|
return f"<Page {self.number} of {self.paginator.num_pages}>"
|
134
144
|
|
135
|
-
def __len__(self):
|
145
|
+
def __len__(self) -> int:
|
136
146
|
return len(self.object_list)
|
137
147
|
|
138
|
-
def __getitem__(self, index):
|
148
|
+
def __getitem__(self, index: int | slice) -> Any:
|
139
149
|
if not isinstance(index, int | slice):
|
140
150
|
raise TypeError(
|
141
151
|
f"Page indices must be integers or slices, not {type(index).__name__}."
|
@@ -146,22 +156,22 @@ class Page(collections.abc.Sequence):
|
|
146
156
|
self.object_list = list(self.object_list)
|
147
157
|
return self.object_list[index]
|
148
158
|
|
149
|
-
def has_next(self):
|
159
|
+
def has_next(self) -> bool:
|
150
160
|
return self.number < self.paginator.num_pages
|
151
161
|
|
152
|
-
def has_previous(self):
|
162
|
+
def has_previous(self) -> bool:
|
153
163
|
return self.number > 1
|
154
164
|
|
155
|
-
def has_other_pages(self):
|
165
|
+
def has_other_pages(self) -> bool:
|
156
166
|
return self.has_previous() or self.has_next()
|
157
167
|
|
158
|
-
def next_page_number(self):
|
168
|
+
def next_page_number(self) -> int:
|
159
169
|
return self.paginator.validate_number(self.number + 1)
|
160
170
|
|
161
|
-
def previous_page_number(self):
|
171
|
+
def previous_page_number(self) -> int:
|
162
172
|
return self.paginator.validate_number(self.number - 1)
|
163
173
|
|
164
|
-
def start_index(self):
|
174
|
+
def start_index(self) -> int:
|
165
175
|
"""
|
166
176
|
Return the 1-based index of the first object on this page,
|
167
177
|
relative to total objects in the paginator.
|
@@ -171,7 +181,7 @@ class Page(collections.abc.Sequence):
|
|
171
181
|
return 0
|
172
182
|
return (self.paginator.per_page * (self.number - 1)) + 1
|
173
183
|
|
174
|
-
def end_index(self):
|
184
|
+
def end_index(self) -> int:
|
175
185
|
"""
|
176
186
|
Return the 1-based index of the last object on this page,
|
177
187
|
relative to total objects found (hits).
|
plain/preflight/checks.py
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
from abc import ABC, abstractmethod
|
2
4
|
|
3
5
|
|
@@ -5,6 +7,6 @@ class PreflightCheck(ABC):
|
|
5
7
|
"""Base class for all preflight checks."""
|
6
8
|
|
7
9
|
@abstractmethod
|
8
|
-
def run(self):
|
10
|
+
def run(self) -> list:
|
9
11
|
"""Must return a list of Warning/Error results."""
|
10
12
|
raise NotImplementedError
|
plain/preflight/files.py
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
from pathlib import Path
|
2
4
|
|
3
5
|
from plain.runtime import settings
|
@@ -11,7 +13,7 @@ from .results import PreflightResult
|
|
11
13
|
class CheckSettingFileUploadTempDir(PreflightCheck):
|
12
14
|
"""Validates that the FILE_UPLOAD_TEMP_DIR setting points to an existing directory."""
|
13
15
|
|
14
|
-
def run(self):
|
16
|
+
def run(self) -> list[PreflightResult]:
|
15
17
|
setting = settings.FILE_UPLOAD_TEMP_DIR
|
16
18
|
if setting and not Path(setting).is_dir():
|
17
19
|
return [
|
plain/preflight/registry.py
CHANGED
@@ -1,11 +1,24 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from collections.abc import Callable, Generator
|
4
|
+
from typing import Any, TypeVar
|
5
|
+
|
1
6
|
from plain.runtime import settings
|
2
7
|
|
8
|
+
from .results import PreflightResult
|
3
9
|
|
4
|
-
|
5
|
-
def __init__(self):
|
6
|
-
self.checks = {} # name -> (check_class, deploy)
|
10
|
+
T = TypeVar("T")
|
7
11
|
|
8
|
-
|
12
|
+
|
13
|
+
class CheckRegistry:
|
14
|
+
def __init__(self) -> None:
|
15
|
+
self.checks: dict[
|
16
|
+
str, tuple[type[Any], bool]
|
17
|
+
] = {} # name -> (check_class, deploy)
|
18
|
+
|
19
|
+
def register_check(
|
20
|
+
self, check_class: type[Any], name: str, deploy: bool = False
|
21
|
+
) -> None:
|
9
22
|
"""Register a check class with a unique name."""
|
10
23
|
if name in self.checks:
|
11
24
|
raise ValueError(f"Check {name} already registered")
|
@@ -13,8 +26,8 @@ class CheckRegistry:
|
|
13
26
|
|
14
27
|
def run_checks(
|
15
28
|
self,
|
16
|
-
include_deploy_checks=False,
|
17
|
-
):
|
29
|
+
include_deploy_checks: bool = False,
|
30
|
+
) -> Generator[tuple[type[Any], str, list[PreflightResult]]]:
|
18
31
|
"""
|
19
32
|
Run all registered checks and yield (check_class, name, results) tuples.
|
20
33
|
"""
|
@@ -42,9 +55,11 @@ class CheckRegistry:
|
|
42
55
|
results = check.run()
|
43
56
|
yield check_class, name, results
|
44
57
|
|
45
|
-
def get_checks(
|
58
|
+
def get_checks(
|
59
|
+
self, include_deploy_checks: bool = False
|
60
|
+
) -> list[tuple[type[Any], str]]:
|
46
61
|
"""Get list of (check_class, name) tuples."""
|
47
|
-
result = []
|
62
|
+
result: list[tuple[type[Any], str]] = []
|
48
63
|
for name, (check_class, deploy) in self.checks.items():
|
49
64
|
if deploy and not include_deploy_checks:
|
50
65
|
continue
|
@@ -55,7 +70,7 @@ class CheckRegistry:
|
|
55
70
|
checks_registry = CheckRegistry()
|
56
71
|
|
57
72
|
|
58
|
-
def register_check(name: str, *, deploy: bool = False):
|
73
|
+
def register_check(name: str, *, deploy: bool = False) -> Callable[[type[T]], type[T]]:
|
59
74
|
"""
|
60
75
|
Decorator to register a check class.
|
61
76
|
|
@@ -69,7 +84,7 @@ def register_check(name: str, *, deploy: bool = False):
|
|
69
84
|
pass
|
70
85
|
"""
|
71
86
|
|
72
|
-
def wrapper(cls):
|
87
|
+
def wrapper(cls: type[T]) -> type[T]:
|
73
88
|
checks_registry.register_check(cls, name=name, deploy=deploy)
|
74
89
|
return cls
|
75
90
|
|
plain/preflight/results.py
CHANGED
@@ -1,20 +1,26 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Any
|
4
|
+
|
1
5
|
from plain.runtime import settings
|
2
6
|
|
3
7
|
|
4
8
|
class PreflightResult:
|
5
|
-
def __init__(
|
9
|
+
def __init__(
|
10
|
+
self, *, fix: str, id: str, obj: Any = None, warning: bool = False
|
11
|
+
) -> None:
|
6
12
|
self.fix = fix
|
7
13
|
self.obj = obj
|
8
14
|
self.id = id
|
9
15
|
self.warning = warning
|
10
16
|
|
11
|
-
def __eq__(self, other):
|
17
|
+
def __eq__(self, other: object) -> bool:
|
12
18
|
return isinstance(other, self.__class__) and all(
|
13
19
|
getattr(self, attr) == getattr(other, attr)
|
14
20
|
for attr in ["fix", "obj", "id", "warning"]
|
15
21
|
)
|
16
22
|
|
17
|
-
def __str__(self):
|
23
|
+
def __str__(self) -> str:
|
18
24
|
if self.obj is None:
|
19
25
|
obj = ""
|
20
26
|
elif hasattr(self.obj, "_meta") and hasattr(self.obj._meta, "label"):
|
@@ -25,5 +31,5 @@ class PreflightResult:
|
|
25
31
|
id_part = f"({self.id}) " if self.id else ""
|
26
32
|
return f"{obj}: {id_part}{self.fix}"
|
27
33
|
|
28
|
-
def is_silenced(self):
|
34
|
+
def is_silenced(self) -> bool:
|
29
35
|
return self.id and self.id in settings.PREFLIGHT_SILENCED_RESULTS
|
plain/preflight/security.py
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
from plain.runtime import settings
|
2
4
|
|
3
5
|
from .checks import PreflightCheck
|
@@ -8,7 +10,7 @@ SECRET_KEY_MIN_LENGTH = 50
|
|
8
10
|
SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
|
9
11
|
|
10
12
|
|
11
|
-
def _check_secret_key(secret_key):
|
13
|
+
def _check_secret_key(secret_key: str) -> bool:
|
12
14
|
return (
|
13
15
|
len(set(secret_key)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS
|
14
16
|
and len(secret_key) >= SECRET_KEY_MIN_LENGTH
|
@@ -19,7 +21,7 @@ def _check_secret_key(secret_key):
|
|
19
21
|
class CheckSecretKey(PreflightCheck):
|
20
22
|
"""Validates that SECRET_KEY is long and random enough for security."""
|
21
23
|
|
22
|
-
def run(self):
|
24
|
+
def run(self) -> list[PreflightResult]:
|
23
25
|
if not _check_secret_key(settings.SECRET_KEY):
|
24
26
|
return [
|
25
27
|
PreflightResult(
|
@@ -36,7 +38,7 @@ class CheckSecretKey(PreflightCheck):
|
|
36
38
|
class CheckSecretKeyFallbacks(PreflightCheck):
|
37
39
|
"""Validates that SECRET_KEY_FALLBACKS are long and random enough for security."""
|
38
40
|
|
39
|
-
def run(self):
|
41
|
+
def run(self) -> list[PreflightResult]:
|
40
42
|
errors = []
|
41
43
|
for index, key in enumerate(settings.SECRET_KEY_FALLBACKS):
|
42
44
|
if not _check_secret_key(key):
|
@@ -55,7 +57,7 @@ class CheckSecretKeyFallbacks(PreflightCheck):
|
|
55
57
|
class CheckDebug(PreflightCheck):
|
56
58
|
"""Ensures DEBUG is False in production deployment."""
|
57
59
|
|
58
|
-
def run(self):
|
60
|
+
def run(self) -> list[PreflightResult]:
|
59
61
|
if settings.DEBUG:
|
60
62
|
return [
|
61
63
|
PreflightResult(
|
@@ -70,7 +72,7 @@ class CheckDebug(PreflightCheck):
|
|
70
72
|
class CheckAllowedHosts(PreflightCheck):
|
71
73
|
"""Ensures ALLOWED_HOSTS is not empty in production deployment."""
|
72
74
|
|
73
|
-
def run(self):
|
75
|
+
def run(self) -> list[PreflightResult]:
|
74
76
|
if not settings.ALLOWED_HOSTS:
|
75
77
|
return [
|
76
78
|
PreflightResult(
|
plain/preflight/urls.py
CHANGED
@@ -1,12 +1,15 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
1
3
|
from .checks import PreflightCheck
|
2
4
|
from .registry import register_check
|
5
|
+
from .results import PreflightResult
|
3
6
|
|
4
7
|
|
5
8
|
@register_check("urls.config")
|
6
9
|
class CheckUrlConfig(PreflightCheck):
|
7
10
|
"""Validates the URL configuration for common issues."""
|
8
11
|
|
9
|
-
def run(self):
|
12
|
+
def run(self) -> list[PreflightResult]:
|
10
13
|
from plain.urls import get_resolver
|
11
14
|
|
12
15
|
resolver = get_resolver()
|
plain/runtime/__init__.py
CHANGED
@@ -2,6 +2,7 @@ import importlib.metadata
|
|
2
2
|
import sys
|
3
3
|
from importlib.metadata import entry_points
|
4
4
|
from pathlib import Path
|
5
|
+
from typing import Self
|
5
6
|
|
6
7
|
from plain.logs.configure import configure_logging
|
7
8
|
from plain.packages import packages_registry
|
@@ -32,7 +33,7 @@ class SetupError(RuntimeError):
|
|
32
33
|
pass
|
33
34
|
|
34
35
|
|
35
|
-
def setup():
|
36
|
+
def setup() -> None:
|
36
37
|
"""
|
37
38
|
Configure the settings (this happens as a side effect of accessing the
|
38
39
|
first setting), configure logging and populate the app registry.
|
@@ -77,11 +78,11 @@ class SettingsReference(str):
|
|
77
78
|
the value in memory but serializes to a settings.NAME attribute reference.
|
78
79
|
"""
|
79
80
|
|
80
|
-
def __new__(self, setting_name):
|
81
|
+
def __new__(self, setting_name: str) -> Self:
|
81
82
|
value = getattr(settings, setting_name)
|
82
83
|
return str.__new__(self, value)
|
83
84
|
|
84
|
-
def __init__(self, setting_name):
|
85
|
+
def __init__(self, setting_name: str):
|
85
86
|
self.setting_name = setting_name
|
86
87
|
|
87
88
|
|
plain/runtime/global_settings.py
CHANGED