plain 0.24.1__py3-none-any.whl → 0.26.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/assets/README.md +5 -5
- plain/cli/README.md +2 -4
- plain/cli/cli.py +6 -6
- plain/csrf/middleware.py +0 -1
- plain/exceptions.py +1 -21
- plain/forms/fields.py +1 -1
- plain/forms/forms.py +0 -1
- plain/http/multipartparser.py +0 -2
- plain/http/request.py +18 -41
- plain/internal/files/base.py +1 -29
- plain/internal/handlers/wsgi.py +18 -1
- plain/packages/__init__.py +2 -2
- plain/packages/config.py +48 -160
- plain/packages/registry.py +64 -10
- plain/paginator.py +0 -4
- plain/preflight/urls.py +0 -7
- plain/templates/jinja/__init__.py +18 -13
- plain/urls/resolvers.py +1 -1
- plain/utils/cache.py +0 -202
- plain/utils/encoding.py +0 -105
- plain/utils/functional.py +0 -7
- plain/utils/html.py +1 -276
- plain/utils/http.py +2 -189
- plain/utils/inspect.py +0 -35
- plain/utils/module_loading.py +0 -19
- plain/utils/safestring.py +0 -3
- plain/utils/text.py +0 -253
- plain/validators.py +0 -11
- {plain-0.24.1.dist-info → plain-0.26.0.dist-info}/METADATA +1 -1
- {plain-0.24.1.dist-info → plain-0.26.0.dist-info}/RECORD +33 -37
- plain/utils/_os.py +0 -52
- plain/utils/dateformat.py +0 -330
- plain/utils/dates.py +0 -76
- plain/utils/email.py +0 -12
- {plain-0.24.1.dist-info → plain-0.26.0.dist-info}/WHEEL +0 -0
- {plain-0.24.1.dist-info → plain-0.26.0.dist-info}/entry_points.txt +0 -0
- {plain-0.24.1.dist-info → plain-0.26.0.dist-info}/licenses/LICENSE +0 -0
plain/assets/README.md
CHANGED
@@ -30,7 +30,7 @@ Now in your template you can use the `asset()` function to get the URL:
|
|
30
30
|
|
31
31
|
## Local development
|
32
32
|
|
33
|
-
When you're working with `settings.DEBUG = True`, the assets will be served directly from their original location. You don't need to run `plain
|
33
|
+
When you're working with `settings.DEBUG = True`, the assets will be served directly from their original location. You don't need to run `plain build` or configure anything else.
|
34
34
|
|
35
35
|
|
36
36
|
## Production deployment
|
@@ -38,7 +38,7 @@ When you're working with `settings.DEBUG = True`, the assets will be served dire
|
|
38
38
|
In production, one of your deployment steps should be to compile the assets.
|
39
39
|
|
40
40
|
```bash
|
41
|
-
plain
|
41
|
+
plain build
|
42
42
|
```
|
43
43
|
|
44
44
|
By default, this generates "fingerprinted" and compressed versions of the assets, which are then served by your app. This means that a file like `main.css` will result in two new files, like `main.d0db67b.css` and `main.d0db67b.css.gz`.
|
@@ -61,7 +61,7 @@ url = get_asset_url("css/style.css")
|
|
61
61
|
The generated/copied files are stored in `{repo}/.plain/assets/compiled`. If you need them to be somewhere else, try simply moving them after compilation.
|
62
62
|
|
63
63
|
```bash
|
64
|
-
plain
|
64
|
+
plain build
|
65
65
|
mv .plain/assets/compiled /path/to/your/static
|
66
66
|
```
|
67
67
|
|
@@ -70,7 +70,7 @@ mv .plain/assets/compiled /path/to/your/static
|
|
70
70
|
The steps for this will vary, but the general idea is to compile them, and then upload the compiled assets.
|
71
71
|
|
72
72
|
```bash
|
73
|
-
plain
|
73
|
+
plain build
|
74
74
|
./example-upload-to-cdn-script
|
75
75
|
```
|
76
76
|
|
@@ -86,7 +86,7 @@ ASSETS_BASE_URL = "https://cdn.example.com/"
|
|
86
86
|
|
87
87
|
The default behavior is to fingerprint assets, which is an exact copy of the original file but with a different filename. The originals aren't copied over because you should generally always use this fingerprinted path (that automatically uses longer-lived caching).
|
88
88
|
|
89
|
-
If you need the originals for any reason, you can use `plain
|
89
|
+
If you need the originals for any reason, you can use `plain build --keep-original`, though this will typically be combined with `--no-fingerprint` otherwise the fingerprinted files will still get priority in `{{ asset() }}` template calls.
|
90
90
|
|
91
91
|
|
92
92
|
### What about source maps or imported css files?
|
plain/cli/README.md
CHANGED
@@ -24,13 +24,11 @@ __all__ = [
|
|
24
24
|
]
|
25
25
|
```
|
26
26
|
|
27
|
-
### `plain
|
27
|
+
### `plain build`
|
28
28
|
|
29
29
|
Compile static assets (used in the deploy/production process).
|
30
30
|
|
31
|
-
Automatically runs `plain tailwind
|
32
|
-
|
33
|
-
Automatically runs `npm run compile` if you have a `package.json` with `scripts.compile`.
|
31
|
+
Automatically runs `plain tailwind build` if [plain-tailwind](https://plainframework.com/docs/plain-tailwind/) is installed.
|
34
32
|
|
35
33
|
### `plain run`
|
36
34
|
|
plain/cli/cli.py
CHANGED
@@ -276,8 +276,8 @@ def preflight_checks(package_label, deploy, fail_level, databases):
|
|
276
276
|
default=True,
|
277
277
|
help="Compress the assets",
|
278
278
|
)
|
279
|
-
def
|
280
|
-
"""
|
279
|
+
def build(keep_original, fingerprint, compress):
|
280
|
+
"""Pre-deployment build step (compile assets, css, js, etc.)"""
|
281
281
|
|
282
282
|
if not keep_original and not fingerprint:
|
283
283
|
click.secho(
|
@@ -287,7 +287,7 @@ def compile(keep_original, fingerprint, compress):
|
|
287
287
|
)
|
288
288
|
sys.exit(1)
|
289
289
|
|
290
|
-
# Run user-defined
|
290
|
+
# Run user-defined build commands first
|
291
291
|
pyproject_path = plain.runtime.APP_PATH.parent / "pyproject.toml"
|
292
292
|
if pyproject_path.exists():
|
293
293
|
with pyproject_path.open("rb") as f:
|
@@ -296,7 +296,7 @@ def compile(keep_original, fingerprint, compress):
|
|
296
296
|
for name, data in (
|
297
297
|
pyproject.get("tool", {})
|
298
298
|
.get("plain", {})
|
299
|
-
.get("
|
299
|
+
.get("build", {})
|
300
300
|
.get("run", {})
|
301
301
|
.items()
|
302
302
|
):
|
@@ -307,8 +307,8 @@ def compile(keep_original, fingerprint, compress):
|
|
307
307
|
click.secho(f"Error in {name} (exit {result.returncode})", fg="red")
|
308
308
|
sys.exit(result.returncode)
|
309
309
|
|
310
|
-
# Then run installed package
|
311
|
-
for entry_point in entry_points(group="plain.
|
310
|
+
# Then run installed package build steps (like tailwind, typically should run last...)
|
311
|
+
for entry_point in entry_points(group="plain.build"):
|
312
312
|
click.secho(f"Running {entry_point.name}", bold=True)
|
313
313
|
result = entry_point.load()()
|
314
314
|
print()
|
plain/csrf/middleware.py
CHANGED
plain/exceptions.py
CHANGED
@@ -22,7 +22,7 @@ class PackageRegistryNotReady(Exception):
|
|
22
22
|
class ObjectDoesNotExist(Exception):
|
23
23
|
"""The requested object does not exist"""
|
24
24
|
|
25
|
-
|
25
|
+
pass
|
26
26
|
|
27
27
|
|
28
28
|
class MultipleObjectsReturned(Exception):
|
@@ -86,12 +86,6 @@ class RequestDataTooBig(SuspiciousOperation):
|
|
86
86
|
pass
|
87
87
|
|
88
88
|
|
89
|
-
class RequestAborted(Exception):
|
90
|
-
"""The request was closed before it was completed, or timed out."""
|
91
|
-
|
92
|
-
pass
|
93
|
-
|
94
|
-
|
95
89
|
class BadRequest(Exception):
|
96
90
|
"""The request is malformed and cannot be processed."""
|
97
91
|
|
@@ -104,12 +98,6 @@ class PermissionDenied(Exception):
|
|
104
98
|
pass
|
105
99
|
|
106
100
|
|
107
|
-
class ViewDoesNotExist(Exception):
|
108
|
-
"""The requested view does not exist"""
|
109
|
-
|
110
|
-
pass
|
111
|
-
|
112
|
-
|
113
101
|
class ImproperlyConfigured(Exception):
|
114
102
|
"""Plain is somehow improperly configured"""
|
115
103
|
|
@@ -171,14 +159,6 @@ class ValidationError(Exception):
|
|
171
159
|
self.params = params
|
172
160
|
self.error_list = [self]
|
173
161
|
|
174
|
-
@property
|
175
|
-
def message_dict(self):
|
176
|
-
# Trigger an AttributeError if this ValidationError
|
177
|
-
# doesn't have an error_dict.
|
178
|
-
getattr(self, "error_dict")
|
179
|
-
|
180
|
-
return dict(self)
|
181
|
-
|
182
162
|
@property
|
183
163
|
def messages(self):
|
184
164
|
if hasattr(self, "error_dict"):
|
plain/forms/fields.py
CHANGED
@@ -819,7 +819,7 @@ class ChoiceField(Field):
|
|
819
819
|
for k, v in self.choices:
|
820
820
|
if isinstance(v, list | tuple):
|
821
821
|
# This is an optgroup, so look inside the group for options
|
822
|
-
for k2,
|
822
|
+
for k2, _ in v:
|
823
823
|
if value == k2 or text_value == str(k2):
|
824
824
|
return True
|
825
825
|
else:
|
plain/forms/forms.py
CHANGED
plain/http/multipartparser.py
CHANGED
plain/http/request.py
CHANGED
@@ -5,7 +5,6 @@ from io import BytesIO
|
|
5
5
|
from itertools import chain
|
6
6
|
from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit
|
7
7
|
|
8
|
-
from plain import signing
|
9
8
|
from plain.exceptions import (
|
10
9
|
DisallowedHost,
|
11
10
|
ImproperlyConfigured,
|
@@ -24,12 +23,11 @@ from plain.utils.datastructures import (
|
|
24
23
|
ImmutableList,
|
25
24
|
MultiValueDict,
|
26
25
|
)
|
27
|
-
from plain.utils.encoding import
|
26
|
+
from plain.utils.encoding import iri_to_uri
|
28
27
|
from plain.utils.functional import cached_property
|
29
28
|
from plain.utils.http import is_same_domain, parse_header_parameters
|
30
29
|
from plain.utils.regex_helper import _lazy_re_compile
|
31
30
|
|
32
|
-
RAISE_ERROR = object()
|
33
31
|
host_validation_re = _lazy_re_compile(
|
34
32
|
r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
|
35
33
|
)
|
@@ -177,12 +175,26 @@ class HttpRequest:
|
|
177
175
|
def get_full_path(self, force_append_slash=False):
|
178
176
|
return self._get_full_path(self.path, force_append_slash)
|
179
177
|
|
180
|
-
def get_full_path_info(self, force_append_slash=False):
|
181
|
-
return self._get_full_path(self.path_info, force_append_slash)
|
182
|
-
|
183
178
|
def _get_full_path(self, path, force_append_slash):
|
184
179
|
# RFC 3986 requires query string arguments to be in the ASCII range.
|
185
180
|
# Rather than crash if this doesn't happen, we encode defensively.
|
181
|
+
|
182
|
+
def escape_uri_path(path):
|
183
|
+
"""
|
184
|
+
Escape the unsafe characters from the path portion of a Uniform Resource
|
185
|
+
Identifier (URI).
|
186
|
+
"""
|
187
|
+
# These are the "reserved" and "unreserved" characters specified in RFC
|
188
|
+
# 3986 Sections 2.2 and 2.3:
|
189
|
+
# reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | ","
|
190
|
+
# unreserved = alphanum | mark
|
191
|
+
# mark = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")"
|
192
|
+
# The list of safe characters here is constructed subtracting ";", "=",
|
193
|
+
# and "?" according to RFC 3986 Section 3.3.
|
194
|
+
# The reason for not subtracting and escaping "/" is that we are escaping
|
195
|
+
# the entire path, not a path segment.
|
196
|
+
return quote(path, safe="/:@&+$,-_.!~*'()")
|
197
|
+
|
186
198
|
return "{}{}{}".format(
|
187
199
|
escape_uri_path(path),
|
188
200
|
"/" if force_append_slash and not path.endswith("/") else "",
|
@@ -191,30 +203,6 @@ class HttpRequest:
|
|
191
203
|
else "",
|
192
204
|
)
|
193
205
|
|
194
|
-
def get_signed_cookie(self, key, default=RAISE_ERROR, salt="", max_age=None):
|
195
|
-
"""
|
196
|
-
Attempt to return a signed cookie. If the signature fails or the
|
197
|
-
cookie has expired, raise an exception, unless the `default` argument
|
198
|
-
is provided, in which case return that value.
|
199
|
-
"""
|
200
|
-
try:
|
201
|
-
cookie_value = self.COOKIES[key]
|
202
|
-
except KeyError:
|
203
|
-
if default is not RAISE_ERROR:
|
204
|
-
return default
|
205
|
-
else:
|
206
|
-
raise
|
207
|
-
try:
|
208
|
-
value = signing.get_cookie_signer(salt=key + salt).unsign(
|
209
|
-
cookie_value, max_age=max_age
|
210
|
-
)
|
211
|
-
except signing.BadSignature:
|
212
|
-
if default is not RAISE_ERROR:
|
213
|
-
return default
|
214
|
-
else:
|
215
|
-
raise
|
216
|
-
return value
|
217
|
-
|
218
206
|
def build_absolute_uri(self, location=None):
|
219
207
|
"""
|
220
208
|
Build an absolute URI from the location and the variables available in
|
@@ -469,10 +457,6 @@ class HttpHeaders(CaseInsensitiveMapping):
|
|
469
457
|
return header
|
470
458
|
return f"{cls.HTTP_PREFIX}{header}"
|
471
459
|
|
472
|
-
@classmethod
|
473
|
-
def to_asgi_name(cls, header):
|
474
|
-
return header.replace("-", "_").upper()
|
475
|
-
|
476
460
|
@classmethod
|
477
461
|
def to_wsgi_names(cls, headers):
|
478
462
|
return {
|
@@ -480,13 +464,6 @@ class HttpHeaders(CaseInsensitiveMapping):
|
|
480
464
|
for header_name, value in headers.items()
|
481
465
|
}
|
482
466
|
|
483
|
-
@classmethod
|
484
|
-
def to_asgi_names(cls, headers):
|
485
|
-
return {
|
486
|
-
cls.to_asgi_name(header_name): value
|
487
|
-
for header_name, value in headers.items()
|
488
|
-
}
|
489
|
-
|
490
467
|
|
491
468
|
class QueryDict(MultiValueDict):
|
492
469
|
"""
|
plain/internal/files/base.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import os
|
2
|
-
from io import
|
2
|
+
from io import UnsupportedOperation
|
3
3
|
|
4
4
|
from plain.internal.files.utils import FileProxyMixin
|
5
5
|
from plain.utils.functional import cached_property
|
@@ -118,34 +118,6 @@ class File(FileProxyMixin):
|
|
118
118
|
self.file.close()
|
119
119
|
|
120
120
|
|
121
|
-
class ContentFile(File):
|
122
|
-
"""
|
123
|
-
A File-like object that takes just raw content, rather than an actual file.
|
124
|
-
"""
|
125
|
-
|
126
|
-
def __init__(self, content, name=None):
|
127
|
-
stream_class = StringIO if isinstance(content, str) else BytesIO
|
128
|
-
super().__init__(stream_class(content), name=name)
|
129
|
-
self.size = len(content)
|
130
|
-
|
131
|
-
def __str__(self):
|
132
|
-
return "Raw content"
|
133
|
-
|
134
|
-
def __bool__(self):
|
135
|
-
return True
|
136
|
-
|
137
|
-
def open(self, mode=None):
|
138
|
-
self.seek(0)
|
139
|
-
return self
|
140
|
-
|
141
|
-
def close(self):
|
142
|
-
pass
|
143
|
-
|
144
|
-
def write(self, data):
|
145
|
-
self.__dict__.pop("size", None) # Clear the computed size.
|
146
|
-
return self.file.write(data)
|
147
|
-
|
148
|
-
|
149
121
|
def endswith_cr(line):
|
150
122
|
"""Return True if line (a text or bytestring) ends with '\r'."""
|
151
123
|
return line.endswith("\r" if isinstance(line, str) else b"\r")
|
plain/internal/handlers/wsgi.py
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
import uuid
|
2
2
|
from io import IOBase
|
3
|
+
from urllib.parse import quote
|
3
4
|
|
4
5
|
from plain import signals
|
5
6
|
from plain.http import HttpRequest, QueryDict, parse_cookie
|
6
7
|
from plain.internal.handlers import base
|
7
|
-
from plain.utils.encoding import repercent_broken_unicode
|
8
8
|
from plain.utils.functional import cached_property
|
9
9
|
from plain.utils.regex_helper import _lazy_re_compile
|
10
10
|
|
@@ -161,6 +161,23 @@ def get_path_info(environ):
|
|
161
161
|
"""Return the HTTP request's PATH_INFO as a string."""
|
162
162
|
path_info = get_bytes_from_wsgi(environ, "PATH_INFO", "/")
|
163
163
|
|
164
|
+
def repercent_broken_unicode(path):
|
165
|
+
"""
|
166
|
+
As per RFC 3987 Section 3.2, step three of converting a URI into an IRI,
|
167
|
+
repercent-encode any octet produced that is not part of a strictly legal
|
168
|
+
UTF-8 octet sequence.
|
169
|
+
"""
|
170
|
+
while True:
|
171
|
+
try:
|
172
|
+
path.decode()
|
173
|
+
except UnicodeDecodeError as e:
|
174
|
+
# CVE-2019-14235: A recursion shouldn't be used since the exception
|
175
|
+
# handling uses massive amounts of memory
|
176
|
+
repercent = quote(path[e.start : e.end], safe=b"/#%[]=:;$&()+,!?*@'~")
|
177
|
+
path = path[: e.start] + repercent.encode() + path[e.end :]
|
178
|
+
else:
|
179
|
+
return path
|
180
|
+
|
164
181
|
return repercent_broken_unicode(path_info).decode()
|
165
182
|
|
166
183
|
|
plain/packages/__init__.py
CHANGED
plain/packages/config.py
CHANGED
@@ -1,9 +1,8 @@
|
|
1
|
-
import inspect
|
2
1
|
import os
|
2
|
+
from functools import cached_property
|
3
3
|
from importlib import import_module
|
4
4
|
|
5
5
|
from plain.exceptions import ImproperlyConfigured
|
6
|
-
from plain.utils.module_loading import import_string, module_has_submodule
|
7
6
|
|
8
7
|
CONFIG_MODULE_NAME = "config"
|
9
8
|
|
@@ -13,13 +12,9 @@ class PackageConfig:
|
|
13
12
|
|
14
13
|
migrations_module = "migrations"
|
15
14
|
|
16
|
-
def __init__(self,
|
15
|
+
def __init__(self, name, *, label=""):
|
17
16
|
# Full Python path to the application e.g. 'plain.admin.admin'.
|
18
|
-
self.name =
|
19
|
-
|
20
|
-
# Root module for the application e.g. <module 'plain.admin.admin'
|
21
|
-
# from 'admin/__init__.py'>.
|
22
|
-
self.module = package_module
|
17
|
+
self.name = name
|
23
18
|
|
24
19
|
# Reference to the Packages registry that holds this PackageConfig. Set by the
|
25
20
|
# registry when it registers the PackageConfig instance.
|
@@ -27,168 +22,61 @@ class PackageConfig:
|
|
27
22
|
|
28
23
|
# The following attributes could be defined at the class level in a
|
29
24
|
# subclass, hence the test-and-set pattern.
|
25
|
+
if label and hasattr(self, "label"):
|
26
|
+
raise ImproperlyConfigured(
|
27
|
+
"PackageConfig class should not define a class label attribute and an init label"
|
28
|
+
)
|
29
|
+
|
30
|
+
if label:
|
31
|
+
# Set the label explicitly from the init
|
32
|
+
self.label = label
|
33
|
+
elif not hasattr(self, "label"):
|
34
|
+
# Last component of the Python path to the application e.g. 'admin'.
|
35
|
+
# This value must be unique across a Plain project.
|
36
|
+
self.label = self.name.rpartition(".")[2]
|
30
37
|
|
31
|
-
# Last component of the Python path to the application e.g. 'admin'.
|
32
|
-
# This value must be unique across a Plain project.
|
33
|
-
if not hasattr(self, "label"):
|
34
|
-
self.label = package_name.rpartition(".")[2]
|
35
38
|
if not self.label.isidentifier():
|
36
39
|
raise ImproperlyConfigured(
|
37
40
|
f"The app label '{self.label}' is not a valid Python identifier."
|
38
41
|
)
|
39
42
|
|
40
|
-
# Filesystem path to the application directory e.g.
|
41
|
-
# '/path/to/admin'.
|
42
|
-
if not hasattr(self, "path"):
|
43
|
-
self.path = self._path_from_module(package_module)
|
44
|
-
|
45
43
|
def __repr__(self):
|
46
44
|
return f"<{self.__class__.__name__}: {self.label}>"
|
47
45
|
|
48
|
-
|
49
|
-
|
50
|
-
#
|
51
|
-
#
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
if len(paths) > 1:
|
63
|
-
raise ImproperlyConfigured(
|
64
|
-
f"The app module {module!r} has multiple filesystem locations ({paths!r}); "
|
65
|
-
"you must configure this app with an PackageConfig subclass "
|
66
|
-
"with a 'path' class attribute."
|
67
|
-
)
|
68
|
-
elif not paths:
|
69
|
-
raise ImproperlyConfigured(
|
70
|
-
f"The app module {module!r} has no filesystem location, "
|
71
|
-
"you must configure this app with an PackageConfig subclass "
|
72
|
-
"with a 'path' class attribute."
|
73
|
-
)
|
74
|
-
return paths[0]
|
75
|
-
|
76
|
-
@classmethod
|
77
|
-
def create(cls, entry):
|
78
|
-
"""
|
79
|
-
Factory that creates an app config from an entry in INSTALLED_PACKAGES.
|
80
|
-
"""
|
81
|
-
# create() eventually returns package_config_class(package_name, package_module).
|
82
|
-
package_config_class = None
|
83
|
-
package_name = None
|
84
|
-
package_module = None
|
85
|
-
|
86
|
-
# If import_module succeeds, entry points to the app module.
|
87
|
-
try:
|
88
|
-
package_module = import_module(entry)
|
89
|
-
except Exception:
|
90
|
-
pass
|
91
|
-
else:
|
92
|
-
# If package_module has an packages submodule that defines a single
|
93
|
-
# PackageConfig subclass, use it automatically.
|
94
|
-
# To prevent this, an PackageConfig subclass can declare a class
|
95
|
-
# variable default = False.
|
96
|
-
# If the packages module defines more than one PackageConfig subclass,
|
97
|
-
# the default one can declare default = True.
|
98
|
-
if module_has_submodule(package_module, CONFIG_MODULE_NAME):
|
99
|
-
mod_path = f"{entry}.{CONFIG_MODULE_NAME}"
|
100
|
-
mod = import_module(mod_path)
|
101
|
-
# Check if there's exactly one PackageConfig candidate,
|
102
|
-
# excluding those that explicitly define default = False.
|
103
|
-
package_configs = [
|
104
|
-
(name, candidate)
|
105
|
-
for name, candidate in inspect.getmembers(mod, inspect.isclass)
|
106
|
-
if (
|
107
|
-
issubclass(candidate, cls)
|
108
|
-
and candidate is not cls
|
109
|
-
and getattr(candidate, "default", True)
|
110
|
-
)
|
111
|
-
]
|
112
|
-
if len(package_configs) == 1:
|
113
|
-
package_config_class = package_configs[0][1]
|
46
|
+
@cached_property
|
47
|
+
def path(self):
|
48
|
+
# Filesystem path to the application directory e.g.
|
49
|
+
# '/path/to/admin'.
|
50
|
+
def _path_from_module(module):
|
51
|
+
"""Attempt to determine app's filesystem path from its module."""
|
52
|
+
# See #21874 for extended discussion of the behavior of this method in
|
53
|
+
# various cases.
|
54
|
+
# Convert to list because __path__ may not support indexing.
|
55
|
+
paths = list(getattr(module, "__path__", []))
|
56
|
+
if len(paths) != 1:
|
57
|
+
filename = getattr(module, "__file__", None)
|
58
|
+
if filename is not None:
|
59
|
+
paths = [os.path.dirname(filename)]
|
114
60
|
else:
|
115
|
-
#
|
116
|
-
#
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
package_name = entry
|
135
|
-
|
136
|
-
# If import_string succeeds, entry is an app config class.
|
137
|
-
if package_config_class is None:
|
138
|
-
try:
|
139
|
-
package_config_class = import_string(entry)
|
140
|
-
except Exception:
|
141
|
-
pass
|
142
|
-
# If both import_module and import_string failed, it means that entry
|
143
|
-
# doesn't have a valid value.
|
144
|
-
if package_module is None and package_config_class is None:
|
145
|
-
# If the last component of entry starts with an uppercase letter,
|
146
|
-
# then it was likely intended to be an app config class; if not,
|
147
|
-
# an app module. Provide a nice error message in both cases.
|
148
|
-
mod_path, _, cls_name = entry.rpartition(".")
|
149
|
-
if mod_path and cls_name[0].isupper():
|
150
|
-
# We could simply re-trigger the string import exception, but
|
151
|
-
# we're going the extra mile and providing a better error
|
152
|
-
# message for typos in INSTALLED_PACKAGES.
|
153
|
-
# This may raise ImportError, which is the best exception
|
154
|
-
# possible if the module at mod_path cannot be imported.
|
155
|
-
mod = import_module(mod_path)
|
156
|
-
candidates = [
|
157
|
-
repr(name)
|
158
|
-
for name, candidate in inspect.getmembers(mod, inspect.isclass)
|
159
|
-
if issubclass(candidate, cls) and candidate is not cls
|
160
|
-
]
|
161
|
-
msg = f"Module '{mod_path}' does not contain a '{cls_name}' class."
|
162
|
-
if candidates:
|
163
|
-
msg += " Choices are: {}.".format(", ".join(candidates))
|
164
|
-
raise ImportError(msg)
|
165
|
-
else:
|
166
|
-
# Re-trigger the module import exception.
|
167
|
-
import_module(entry)
|
168
|
-
|
169
|
-
# Check for obvious errors. (This check prevents duck typing, but
|
170
|
-
# it could be removed if it became a problem in practice.)
|
171
|
-
if not issubclass(package_config_class, PackageConfig):
|
172
|
-
raise ImproperlyConfigured(f"'{entry}' isn't a subclass of PackageConfig.")
|
173
|
-
|
174
|
-
# Obtain package name here rather than in PackageClass.__init__ to keep
|
175
|
-
# all error checking for entries in INSTALLED_PACKAGES in one place.
|
176
|
-
if package_name is None:
|
177
|
-
try:
|
178
|
-
package_name = package_config_class.name
|
179
|
-
except AttributeError:
|
180
|
-
raise ImproperlyConfigured(f"'{entry}' must supply a name attribute.")
|
181
|
-
|
182
|
-
# Ensure package_name points to a valid module.
|
183
|
-
try:
|
184
|
-
package_module = import_module(package_name)
|
185
|
-
except ImportError:
|
186
|
-
raise ImproperlyConfigured(
|
187
|
-
f"Cannot import '{package_name}'. Check that '{package_config_class.__module__}.{package_config_class.__qualname__}.name' is correct."
|
188
|
-
)
|
189
|
-
|
190
|
-
# Entry is a path to an app config class.
|
191
|
-
return package_config_class(package_name, package_module)
|
61
|
+
# For unknown reasons, sometimes the list returned by __path__
|
62
|
+
# contains duplicates that must be removed (#25246).
|
63
|
+
paths = list(set(paths))
|
64
|
+
if len(paths) > 1:
|
65
|
+
raise ImproperlyConfigured(
|
66
|
+
f"The app module {module!r} has multiple filesystem locations ({paths!r}); "
|
67
|
+
"you must configure this app with an PackageConfig subclass "
|
68
|
+
"with a 'path' class attribute."
|
69
|
+
)
|
70
|
+
elif not paths:
|
71
|
+
raise ImproperlyConfigured(
|
72
|
+
f"The app module {module!r} has no filesystem location, "
|
73
|
+
"you must configure this app with an PackageConfig subclass "
|
74
|
+
"with a 'path' class attribute."
|
75
|
+
)
|
76
|
+
return paths[0]
|
77
|
+
|
78
|
+
module = import_module(self.name)
|
79
|
+
return _path_from_module(module)
|
192
80
|
|
193
81
|
def ready(self):
|
194
82
|
"""
|