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.
@@ -4,11 +4,14 @@ import threading
4
4
  import warnings
5
5
  from collections import Counter, defaultdict
6
6
  from functools import partial
7
+ from importlib import import_module
7
8
 
8
9
  from plain.exceptions import ImproperlyConfigured, PackageRegistryNotReady
9
10
 
10
11
  from .config import PackageConfig
11
12
 
13
+ CONFIG_MODULE_NAME = "config"
14
+
12
15
 
13
16
  class PackagesRegistry:
14
17
  """
@@ -82,17 +85,35 @@ class PackagesRegistry:
82
85
  # Phase 1: initialize app configs and import app modules.
83
86
  for entry in installed_packages:
84
87
  if isinstance(entry, PackageConfig):
85
- package_config = entry
88
+ # Some instances of the registry pass in the
89
+ # PackageConfig directly...
90
+ self.register_config(entry)
86
91
  else:
87
- package_config = PackageConfig.create(entry)
88
- if package_config.label in self.package_configs:
89
- raise ImproperlyConfigured(
90
- "Package labels aren't unique, "
91
- f"duplicates: {package_config.label}"
92
- )
93
-
94
- self.package_configs[package_config.label] = package_config
95
- package_config.packages_registry = self
92
+ try:
93
+ import_module(f"{entry}.{CONFIG_MODULE_NAME}")
94
+ except ModuleNotFoundError:
95
+ pass
96
+
97
+ # The config for the package should now be registered, if it existed.
98
+ # And if it didn't, now we can auto generate one.
99
+ entry_config = None
100
+ for config in self.package_configs.values():
101
+ if config.name == entry:
102
+ entry_config = config
103
+ break
104
+
105
+ if not entry_config:
106
+ # Use PackageConfig class as-is, without any customization.
107
+ entry_config = self.register_config(
108
+ PackageConfig, module_name=entry
109
+ )
110
+
111
+ # Make sure we have the same number of configs as we have installed packages
112
+ if len(self.package_configs) != len(installed_packages):
113
+ raise ImproperlyConfigured(
114
+ f"The number of installed packages ({len(installed_packages)}) does not match the number of "
115
+ f"registered configs ({len(self.package_configs)})."
116
+ )
96
117
 
97
118
  # Check for duplicate app names.
98
119
  counts = Counter(
@@ -372,5 +393,38 @@ class PackagesRegistry:
372
393
  for function in self._pending_operations.pop(key, []):
373
394
  function(model)
374
395
 
396
+ def register_config(self, package_config, module_name=""):
397
+ """
398
+ Add a config to the registry.
399
+
400
+ Typically used as a decorator on a PackageConfig subclass. Example:
401
+
402
+ @register_config
403
+ class Config(PackageConfig):
404
+ pass
405
+ """
406
+ if not module_name:
407
+ module_name = package_config.__module__
408
+
409
+ # If it is in .config like expected, return the parent module name
410
+ if module_name.endswith(f".{CONFIG_MODULE_NAME}"):
411
+ module_name = module_name[: -len(CONFIG_MODULE_NAME) - 1]
412
+
413
+ if isinstance(package_config, type) and issubclass(
414
+ package_config, PackageConfig
415
+ ):
416
+ # A class was passed, so init it
417
+ package_config = package_config(module_name)
418
+
419
+ if package_config.label in self.package_configs:
420
+ raise ImproperlyConfigured(
421
+ f"Package labels aren't unique, duplicates: {package_config.label}"
422
+ )
423
+ self.package_configs[package_config.label] = package_config
424
+ package_config.packages = self
425
+
426
+ return package_config
427
+
375
428
 
376
429
  packages_registry = PackagesRegistry(installed_packages=None)
430
+ register_config = packages_registry.register_config
plain/paginator.py CHANGED
@@ -24,10 +24,6 @@ class EmptyPage(InvalidPage):
24
24
 
25
25
 
26
26
  class Paginator:
27
- # Translators: String used to replace omitted page numbers in elided page
28
- # range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10].
29
- ELLIPSIS = "…"
30
-
31
27
  def __init__(self, object_list, per_page, orphans=0, allow_empty_first_page=True):
32
28
  self.object_list = object_list
33
29
  self._check_object_list_is_ordered()
plain/preflight/urls.py CHANGED
@@ -98,10 +98,3 @@ def get_warning_for_invalid_pattern(pattern):
98
98
  id="urls.E004",
99
99
  )
100
100
  ]
101
-
102
-
103
- def E006(name):
104
- return Error(
105
- f"The {name} setting must end with a slash.",
106
- id="urls.E006",
107
- )
@@ -3,7 +3,7 @@ from importlib import import_module
3
3
  from plain.packages import packages_registry
4
4
  from plain.runtime import settings
5
5
  from plain.utils.functional import LazyObject
6
- from plain.utils.module_loading import import_string, module_has_submodule
6
+ from plain.utils.module_loading import import_string
7
7
 
8
8
  from .environments import DefaultEnvironment, get_template_dirs
9
9
 
@@ -24,20 +24,25 @@ class JinjaEnvironment(LazyObject):
24
24
  # We have to set _wrapped before we trigger the autoloading of "register" commands
25
25
  self._wrapped = env
26
26
 
27
- def _maybe_import_module(name):
28
- if name not in self._imported_modules:
29
- import_module(name)
30
- self._imported_modules.add(name)
31
-
32
27
  for package_config in packages_registry.get_package_configs():
33
- if module_has_submodule(package_config.module, "templates"):
34
- # Allow this to fail in case there are import errors inside of their file
35
- _maybe_import_module(f"{package_config.name}.templates")
36
-
37
- app = import_module("app")
38
- if module_has_submodule(app, "templates"):
39
28
  # Allow this to fail in case there are import errors inside of their file
40
- _maybe_import_module("app.templates")
29
+ import_name = f"{package_config.name}.templates"
30
+ if import_name in self._imported_modules:
31
+ continue
32
+ try:
33
+ import_module(import_name)
34
+ self._imported_modules.add(import_name)
35
+ except ModuleNotFoundError:
36
+ pass
37
+
38
+ # Allow this to fail in case there are import errors inside of their file
39
+ import_name = "app.templates"
40
+ if import_name not in self._imported_modules:
41
+ try:
42
+ import_module(import_name)
43
+ self._imported_modules.add(import_name)
44
+ except ModuleNotFoundError:
45
+ pass
41
46
 
42
47
 
43
48
  environment = JinjaEnvironment()
plain/urls/resolvers.py CHANGED
@@ -207,7 +207,7 @@ class URLResolver:
207
207
  else:
208
208
  for name in url_pattern.reverse_dict:
209
209
  for (
210
- matches,
210
+ _,
211
211
  pat,
212
212
  converters,
213
213
  ) in url_pattern.reverse_dict.getlist(name):
plain/utils/cache.py CHANGED
@@ -15,14 +15,8 @@ An example: i18n middleware would need to distinguish caches by the
15
15
  "Accept-language" header.
16
16
  """
17
17
 
18
- import time
19
18
  from collections import defaultdict
20
- from hashlib import md5
21
19
 
22
- from plain.http import Response, ResponseNotModified
23
- from plain.logs import log_response
24
- from plain.runtime import settings
25
- from plain.utils.http import http_date, parse_etags, parse_http_date_safe, quote_etag
26
20
  from plain.utils.regex_helper import _lazy_re_compile
27
21
 
28
22
  cc_delim_re = _lazy_re_compile(r"\s*,\s*")
@@ -97,202 +91,6 @@ def patch_cache_control(response, **kwargs):
97
91
  response.headers["Cache-Control"] = cc
98
92
 
99
93
 
100
- def get_max_age(response):
101
- """
102
- Return the max-age from the response Cache-Control header as an integer,
103
- or None if it wasn't found or wasn't an integer.
104
- """
105
- if "Cache-Control" not in response.headers:
106
- return
107
- cc = dict(
108
- _to_tuple(el) for el in cc_delim_re.split(response.headers["Cache-Control"])
109
- )
110
- try:
111
- return int(cc["max-age"])
112
- except (ValueError, TypeError, KeyError):
113
- pass
114
-
115
-
116
- def set_response_etag(response):
117
- if not response.streaming and response.content:
118
- response.headers["ETag"] = quote_etag(
119
- md5(response.content, usedforsecurity=False).hexdigest(),
120
- )
121
- return response
122
-
123
-
124
- def _precondition_failed(request):
125
- response = Response(status=412)
126
- log_response(
127
- "Precondition Failed: %s",
128
- request.path,
129
- response=response,
130
- request=request,
131
- )
132
- return response
133
-
134
-
135
- def _not_modified(request, response=None):
136
- new_response = ResponseNotModified()
137
- if response:
138
- # Preserve the headers required by RFC 9110 Section 15.4.5, as well as
139
- # Last-Modified.
140
- for header in (
141
- "Cache-Control",
142
- "Content-Location",
143
- "Date",
144
- "ETag",
145
- "Expires",
146
- "Last-Modified",
147
- "Vary",
148
- ):
149
- if header in response.headers:
150
- new_response.headers[header] = response.headers[header]
151
-
152
- # Preserve cookies as per the cookie specification: "If a proxy server
153
- # receives a response which contains a Set-cookie header, it should
154
- # propagate the Set-cookie header to the client, regardless of whether
155
- # the response was 304 (Not Modified) or 200 (OK).
156
- # https://curl.haxx.se/rfc/cookie_spec.html
157
- new_response.cookies = response.cookies
158
- return new_response
159
-
160
-
161
- def get_conditional_response(request, etag=None, last_modified=None, response=None):
162
- # Only return conditional responses on successful requests.
163
- if response and not (200 <= response.status_code < 300):
164
- return response
165
-
166
- # Get HTTP request headers.
167
- if_match_etags = parse_etags(request.META.get("HTTP_IF_MATCH", ""))
168
- if_unmodified_since = request.META.get("HTTP_IF_UNMODIFIED_SINCE")
169
- if_unmodified_since = if_unmodified_since and parse_http_date_safe(
170
- if_unmodified_since
171
- )
172
- if_none_match_etags = parse_etags(request.META.get("HTTP_IF_NONE_MATCH", ""))
173
- if_modified_since = request.META.get("HTTP_IF_MODIFIED_SINCE")
174
- if_modified_since = if_modified_since and parse_http_date_safe(if_modified_since)
175
-
176
- # Evaluation of request preconditions below follows RFC 9110 Section
177
- # 13.2.2.
178
- # Step 1: Test the If-Match precondition.
179
- if if_match_etags and not _if_match_passes(etag, if_match_etags):
180
- return _precondition_failed(request)
181
-
182
- # Step 2: Test the If-Unmodified-Since precondition.
183
- if (
184
- not if_match_etags
185
- and if_unmodified_since
186
- and not _if_unmodified_since_passes(last_modified, if_unmodified_since)
187
- ):
188
- return _precondition_failed(request)
189
-
190
- # Step 3: Test the If-None-Match precondition.
191
- if if_none_match_etags and not _if_none_match_passes(etag, if_none_match_etags):
192
- if request.method in ("GET", "HEAD"):
193
- return _not_modified(request, response)
194
- else:
195
- return _precondition_failed(request)
196
-
197
- # Step 4: Test the If-Modified-Since precondition.
198
- if (
199
- not if_none_match_etags
200
- and if_modified_since
201
- and not _if_modified_since_passes(last_modified, if_modified_since)
202
- and request.method in ("GET", "HEAD")
203
- ):
204
- return _not_modified(request, response)
205
-
206
- # Step 5: Test the If-Range precondition (not supported).
207
- # Step 6: Return original response since there isn't a conditional response.
208
- return response
209
-
210
-
211
- def _if_match_passes(target_etag, etags):
212
- """
213
- Test the If-Match comparison as defined in RFC 9110 Section 13.1.1.
214
- """
215
- if not target_etag:
216
- # If there isn't an ETag, then there can't be a match.
217
- return False
218
- elif etags == ["*"]:
219
- # The existence of an ETag means that there is "a current
220
- # representation for the target resource", even if the ETag is weak,
221
- # so there is a match to '*'.
222
- return True
223
- elif target_etag.startswith("W/"):
224
- # A weak ETag can never strongly match another ETag.
225
- return False
226
- else:
227
- # Since the ETag is strong, this will only return True if there's a
228
- # strong match.
229
- return target_etag in etags
230
-
231
-
232
- def _if_unmodified_since_passes(last_modified, if_unmodified_since):
233
- """
234
- Test the If-Unmodified-Since comparison as defined in RFC 9110 Section
235
- 13.1.4.
236
- """
237
- return last_modified and last_modified <= if_unmodified_since
238
-
239
-
240
- def _if_none_match_passes(target_etag, etags):
241
- """
242
- Test the If-None-Match comparison as defined in RFC 9110 Section 13.1.2.
243
- """
244
- if not target_etag:
245
- # If there isn't an ETag, then there isn't a match.
246
- return True
247
- elif etags == ["*"]:
248
- # The existence of an ETag means that there is "a current
249
- # representation for the target resource", so there is a match to '*'.
250
- return False
251
- else:
252
- # The comparison should be weak, so look for a match after stripping
253
- # off any weak indicators.
254
- target_etag = target_etag.strip("W/")
255
- etags = (etag.strip("W/") for etag in etags)
256
- return target_etag not in etags
257
-
258
-
259
- def _if_modified_since_passes(last_modified, if_modified_since):
260
- """
261
- Test the If-Modified-Since comparison as defined in RFC 9110 Section
262
- 13.1.3.
263
- """
264
- return not last_modified or last_modified > if_modified_since
265
-
266
-
267
- def patch_response_headers(response, cache_timeout=None):
268
- """
269
- Add HTTP caching headers to the given Response: Expires and
270
- Cache-Control.
271
-
272
- Each header is only added if it isn't already set.
273
-
274
- cache_timeout is in seconds. The CACHE_MIDDLEWARE_SECONDS setting is used
275
- by default.
276
- """
277
- if cache_timeout is None:
278
- cache_timeout = settings.CACHE_MIDDLEWARE_SECONDS
279
- if cache_timeout < 0:
280
- cache_timeout = 0 # Can't have max-age negative
281
- if "Expires" not in response.headers:
282
- response.headers["Expires"] = http_date(time.time() + cache_timeout)
283
- patch_cache_control(response, max_age=cache_timeout)
284
-
285
-
286
- def add_never_cache_headers(response):
287
- """
288
- Add headers to a response to indicate that a page should never be cached.
289
- """
290
- patch_response_headers(response, cache_timeout=-1)
291
- patch_cache_control(
292
- response, no_cache=True, no_store=True, must_revalidate=True, private=True
293
- )
294
-
295
-
296
94
  def patch_vary_headers(response, newheaders):
297
95
  """
298
96
  Add (or update) the "Vary" header in the given Response object.
plain/utils/encoding.py CHANGED
@@ -1,6 +1,4 @@
1
- import codecs
2
1
  import datetime
3
- import locale
4
2
  from decimal import Decimal
5
3
  from types import NoneType
6
4
  from urllib.parse import quote
@@ -127,109 +125,6 @@ _hextobyte.update(
127
125
  )
128
126
 
129
127
 
130
- def uri_to_iri(uri):
131
- """
132
- Convert a Uniform Resource Identifier(URI) into an Internationalized
133
- Resource Identifier(IRI).
134
-
135
- This is the algorithm from RFC 3987 Section 3.2, excluding step 4.
136
-
137
- Take an URI in ASCII bytes (e.g. '/I%20%E2%99%A5%20Plain/') and return
138
- a string containing the encoded result (e.g. '/I%20♥%20Plain/').
139
- """
140
- if uri is None:
141
- return uri
142
- uri = force_bytes(uri)
143
- # Fast selective unquote: First, split on '%' and then starting with the
144
- # second block, decode the first 2 bytes if they represent a hex code to
145
- # decode. The rest of the block is the part after '%AB', not containing
146
- # any '%'. Add that to the output without further processing.
147
- bits = uri.split(b"%")
148
- if len(bits) == 1:
149
- iri = uri
150
- else:
151
- parts = [bits[0]]
152
- append = parts.append
153
- hextobyte = _hextobyte
154
- for item in bits[1:]:
155
- hex = item[:2]
156
- if hex in hextobyte:
157
- append(hextobyte[item[:2]])
158
- append(item[2:])
159
- else:
160
- append(b"%")
161
- append(item)
162
- iri = b"".join(parts)
163
- return repercent_broken_unicode(iri).decode()
164
-
165
-
166
- def escape_uri_path(path):
167
- """
168
- Escape the unsafe characters from the path portion of a Uniform Resource
169
- Identifier (URI).
170
- """
171
- # These are the "reserved" and "unreserved" characters specified in RFC
172
- # 3986 Sections 2.2 and 2.3:
173
- # reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | ","
174
- # unreserved = alphanum | mark
175
- # mark = "-" | "_" | "." | "!" | "~" | "*" | "'" | "(" | ")"
176
- # The list of safe characters here is constructed subtracting ";", "=",
177
- # and "?" according to RFC 3986 Section 3.3.
178
- # The reason for not subtracting and escaping "/" is that we are escaping
179
- # the entire path, not a path segment.
180
- return quote(path, safe="/:@&+$,-_.!~*'()")
181
-
182
-
183
128
  def punycode(domain):
184
129
  """Return the Punycode of the given domain if it's non-ASCII."""
185
130
  return domain.encode("idna").decode("ascii")
186
-
187
-
188
- def repercent_broken_unicode(path):
189
- """
190
- As per RFC 3987 Section 3.2, step three of converting a URI into an IRI,
191
- repercent-encode any octet produced that is not part of a strictly legal
192
- UTF-8 octet sequence.
193
- """
194
- while True:
195
- try:
196
- path.decode()
197
- except UnicodeDecodeError as e:
198
- # CVE-2019-14235: A recursion shouldn't be used since the exception
199
- # handling uses massive amounts of memory
200
- repercent = quote(path[e.start : e.end], safe=b"/#%[]=:;$&()+,!?*@'~")
201
- path = path[: e.start] + repercent.encode() + path[e.end :]
202
- else:
203
- return path
204
-
205
-
206
- def filepath_to_uri(path):
207
- """Convert a file system path to a URI portion that is suitable for
208
- inclusion in a URL.
209
-
210
- Encode certain chars that would normally be recognized as special chars
211
- for URIs. Do not encode the ' character, as it is a valid character
212
- within URIs. See the encodeURIComponent() JavaScript function for details.
213
- """
214
- if path is None:
215
- return path
216
- # I know about `os.sep` and `os.altsep` but I want to leave
217
- # some flexibility for hardcoding separators.
218
- return quote(str(path).replace("\\", "/"), safe="/~!*()'")
219
-
220
-
221
- def get_system_encoding():
222
- """
223
- The encoding for the character type functions. Fallback to 'ascii' if the
224
- #encoding is unsupported by Python or could not be determined. See tickets
225
- #10335 and #5846.
226
- """
227
- try:
228
- encoding = locale.getlocale()[1] or "ascii"
229
- codecs.lookup(encoding)
230
- except Exception:
231
- encoding = "ascii"
232
- return encoding
233
-
234
-
235
- DEFAULT_LOCALE_ENCODING = get_system_encoding()
plain/utils/functional.py CHANGED
@@ -205,13 +205,6 @@ def _lazy_proxy_unpickle(func, args, kwargs, *resultclasses):
205
205
  return lazy(func, *resultclasses)(*args, **kwargs)
206
206
 
207
207
 
208
- def lazystr(text):
209
- """
210
- Shortcut for the common case of a lazy callable that returns str.
211
- """
212
- return lazy(str, str)(text)
213
-
214
-
215
208
  def keep_lazy(*resultclasses):
216
209
  """
217
210
  A decorator that allows a function to be called with one or more lazy