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 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 compile` or configure anything else.
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 compile
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 compile
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 compile
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 compile --keep-original`, though this will typically be combined with `--no-fingerprint` otherwise the fingerprinted files will still get priority in `{{ asset() }}` template calls.
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 compile`
27
+ ### `plain build`
28
28
 
29
29
  Compile static assets (used in the deploy/production process).
30
30
 
31
- Automatically runs `plain tailwind compile` if [plain-tailwind](https://plainframework.com/docs/plain-tailwind/) is installed.
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 compile(keep_original, fingerprint, compress):
280
- """Compile static assets"""
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 compile commands first
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("compile", {})
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 compile steps (like tailwind, typically should run last...)
311
- for entry_point in entry_points(group="plain.assets.compile"):
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
@@ -42,7 +42,6 @@ REASON_INVALID_CHARACTERS = "has invalid characters"
42
42
  CSRF_SECRET_LENGTH = 32
43
43
  CSRF_TOKEN_LENGTH = 2 * CSRF_SECRET_LENGTH
44
44
  CSRF_ALLOWED_CHARS = string.ascii_letters + string.digits
45
- CSRF_SESSION_KEY = "_csrftoken"
46
45
 
47
46
 
48
47
  def _get_new_csrf_string():
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
- silent_variable_failure = True
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, v2 in v:
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
@@ -53,7 +53,6 @@ class BaseForm:
53
53
  class.
54
54
  """
55
55
 
56
- field_order = None
57
56
  prefix = None
58
57
 
59
58
  def __init__(
@@ -399,8 +399,6 @@ class MultiPartParser:
399
399
  return None
400
400
  return file_name
401
401
 
402
- IE_sanitize = sanitize_file_name
403
-
404
402
  def _close_files(self):
405
403
  # Free up all file handles.
406
404
  # FIXME: this currently assumes that upload handlers store the file as 'file'
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 escape_uri_path, iri_to_uri
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
  """
@@ -1,5 +1,5 @@
1
1
  import os
2
- from io import BytesIO, StringIO, UnsupportedOperation
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")
@@ -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
 
@@ -1,4 +1,4 @@
1
1
  from .config import PackageConfig
2
- from .registry import packages_registry
2
+ from .registry import packages_registry, register_config
3
3
 
4
- __all__ = ["PackageConfig", "packages_registry"]
4
+ __all__ = ["PackageConfig", "packages_registry", "register_config"]
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, package_name, package_module):
15
+ def __init__(self, name, *, label=""):
17
16
  # Full Python path to the application e.g. 'plain.admin.admin'.
18
- self.name = package_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
- def _path_from_module(self, module):
49
- """Attempt to determine app's filesystem path from its module."""
50
- # See #21874 for extended discussion of the behavior of this method in
51
- # various cases.
52
- # Convert to list because __path__ may not support indexing.
53
- paths = list(getattr(module, "__path__", []))
54
- if len(paths) != 1:
55
- filename = getattr(module, "__file__", None)
56
- if filename is not None:
57
- paths = [os.path.dirname(filename)]
58
- else:
59
- # For unknown reasons, sometimes the list returned by __path__
60
- # contains duplicates that must be removed (#25246).
61
- paths = list(set(paths))
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
- # Check if there's exactly one PackageConfig subclass,
116
- # among those that explicitly define default = True.
117
- package_configs = [
118
- (name, candidate)
119
- for name, candidate in package_configs
120
- if getattr(candidate, "default", False)
121
- ]
122
- if len(package_configs) > 1:
123
- candidates = [repr(name) for name, _ in package_configs]
124
- raise RuntimeError(
125
- "{!r} declares more than one default PackageConfig: "
126
- "{}.".format(mod_path, ", ".join(candidates))
127
- )
128
- elif len(package_configs) == 1:
129
- package_config_class = package_configs[0][1]
130
-
131
- # Use the default app config class if we didn't find anything.
132
- if package_config_class is None:
133
- package_config_class = cls
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
  """