plain 0.13.1__py3-none-any.whl → 0.14.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.
Files changed (55) hide show
  1. plain/cli/README.md +2 -2
  2. plain/cli/cli.py +7 -11
  3. plain/cli/packages.py +7 -3
  4. plain/cli/startup.py +2 -2
  5. plain/csrf/middleware.py +1 -0
  6. plain/exceptions.py +2 -1
  7. plain/forms/forms.py +5 -5
  8. plain/http/multipartparser.py +5 -5
  9. plain/http/request.py +5 -5
  10. plain/http/response.py +19 -14
  11. plain/internal/files/locks.py +1 -0
  12. plain/internal/files/move.py +1 -2
  13. plain/internal/files/uploadhandler.py +1 -0
  14. plain/internal/files/utils.py +3 -3
  15. plain/internal/handlers/base.py +3 -7
  16. plain/internal/handlers/exception.py +1 -3
  17. plain/internal/handlers/wsgi.py +1 -1
  18. plain/internal/middleware/slash.py +5 -8
  19. plain/packages/config.py +9 -15
  20. plain/packages/registry.py +12 -12
  21. plain/paginator.py +1 -2
  22. plain/preflight/messages.py +3 -10
  23. plain/preflight/registry.py +2 -2
  24. plain/preflight/urls.py +4 -4
  25. plain/runtime/global_settings.py +1 -0
  26. plain/runtime/user_settings.py +6 -6
  27. plain/signing.py +4 -4
  28. plain/test/client.py +22 -21
  29. plain/urls/base.py +1 -1
  30. plain/urls/conf.py +2 -1
  31. plain/urls/resolvers.py +20 -33
  32. plain/utils/cache.py +1 -0
  33. plain/utils/crypto.py +2 -1
  34. plain/utils/datastructures.py +2 -2
  35. plain/utils/dateformat.py +13 -12
  36. plain/utils/dateparse.py +1 -1
  37. plain/utils/decorators.py +1 -1
  38. plain/utils/html.py +7 -7
  39. plain/utils/http.py +8 -8
  40. plain/utils/ipv6.py +1 -1
  41. plain/utils/module_loading.py +2 -2
  42. plain/utils/regex_helper.py +7 -6
  43. plain/utils/text.py +7 -7
  44. plain/utils/timesince.py +1 -1
  45. plain/utils/timezone.py +5 -5
  46. plain/validators.py +1 -3
  47. plain/views/forms.py +4 -4
  48. plain/views/objects.py +2 -2
  49. {plain-0.13.1.dist-info → plain-0.14.0.dist-info}/METADATA +7 -12
  50. {plain-0.13.1.dist-info → plain-0.14.0.dist-info}/RECORD +56 -57
  51. {plain-0.13.1.dist-info → plain-0.14.0.dist-info}/WHEEL +1 -1
  52. plain-0.14.0.dist-info/entry_points.txt +2 -0
  53. plain/utils/termcolors.py +0 -221
  54. plain-0.13.1.dist-info/entry_points.txt +0 -3
  55. {plain-0.13.1.dist-info → plain-0.14.0.dist-info/licenses}/LICENSE +0 -0
@@ -40,19 +40,12 @@ class CheckMessage:
40
40
  obj = self.obj._meta.label
41
41
  else:
42
42
  obj = str(self.obj)
43
- id = "(%s) " % self.id if self.id else ""
44
- hint = "\n\tHINT: %s" % self.hint if self.hint else ""
43
+ id = f"({self.id}) " if self.id else ""
44
+ hint = f"\n\tHINT: {self.hint}" if self.hint else ""
45
45
  return f"{obj}: {id}{self.msg}{hint}"
46
46
 
47
47
  def __repr__(self):
48
- return "<{}: level={!r}, msg={!r}, hint={!r}, obj={!r}, id={!r}>".format(
49
- self.__class__.__name__,
50
- self.level,
51
- self.msg,
52
- self.hint,
53
- self.obj,
54
- self.id,
55
- )
48
+ return f"<{self.__class__.__name__}: level={self.level!r}, msg={self.msg!r}, hint={self.hint!r}, obj={self.obj!r}, id={self.id!r}>"
56
49
 
57
50
  def is_serious(self, level=ERROR):
58
51
  return self.level >= level
@@ -54,8 +54,8 @@ class CheckRegistry:
54
54
  new_errors = check(package_configs=package_configs, databases=databases)
55
55
  if not is_iterable(new_errors):
56
56
  raise TypeError(
57
- "The function %r did not return a list. All functions "
58
- "registered with the checks registry must return a list." % check,
57
+ f"The function {check!r} did not return a list. All functions "
58
+ "registered with the checks registry must return a list.",
59
59
  )
60
60
  errors.extend(new_errors)
61
61
  return errors
plain/preflight/urls.py CHANGED
@@ -46,8 +46,8 @@ def check_url_namespaces_unique(package_configs, **kwargs):
46
46
  for namespace in non_unique_namespaces:
47
47
  errors.append(
48
48
  Warning(
49
- "URL namespace '{}' isn't unique. You may not be able to reverse "
50
- "all URLs in this namespace".format(namespace),
49
+ f"URL namespace '{namespace}' isn't unique. You may not be able to reverse "
50
+ "all URLs in this namespace",
51
51
  id="urls.W005",
52
52
  )
53
53
  )
@@ -92,8 +92,8 @@ def get_warning_for_invalid_pattern(pattern):
92
92
 
93
93
  return [
94
94
  Error(
95
- "Your URL pattern {!r} is invalid. Ensure that urlpatterns is a list "
96
- "of path() and/or re_path() instances.".format(pattern),
95
+ f"Your URL pattern {pattern!r} is invalid. Ensure that urlpatterns is a list "
96
+ "of path() and/or re_path() instances.",
97
97
  hint=hint,
98
98
  id="urls.E004",
99
99
  )
@@ -2,6 +2,7 @@
2
2
  Default Plain settings. Override these with settings in the module pointed to
3
3
  by the PLAIN_SETTINGS_MODULE environment variable.
4
4
  """
5
+
5
6
  from pathlib import Path
6
7
 
7
8
  from plain.runtime import APP_PATH as default_app_path
@@ -4,6 +4,7 @@ import os
4
4
  import time
5
5
  import types
6
6
  import typing
7
+ from importlib.util import find_spec
7
8
  from pathlib import Path
8
9
 
9
10
  from plain.exceptions import ImproperlyConfigured
@@ -101,12 +102,11 @@ class Settings:
101
102
 
102
103
  def _load_default_settings(self, settings_module):
103
104
  for entry in getattr(settings_module, "INSTALLED_PACKAGES", []):
104
- try:
105
- if isinstance(entry, PackageConfig):
106
- app_settings = entry.module.default_settings
107
- else:
108
- app_settings = importlib.import_module(f"{entry}.default_settings")
109
- except ModuleNotFoundError:
105
+ if isinstance(entry, PackageConfig):
106
+ app_settings = entry.module.default_settings
107
+ elif find_spec(f"{entry}.default_settings"):
108
+ app_settings = importlib.import_module(f"{entry}.default_settings")
109
+ else:
110
110
  continue
111
111
 
112
112
  self._load_module_settings(app_settings)
plain/signing.py CHANGED
@@ -196,8 +196,8 @@ class Signer:
196
196
 
197
197
  if _SEP_UNSAFE.match(self.sep):
198
198
  raise ValueError(
199
- "Unsafe Signer separator: %r (cannot be empty or consist of "
200
- "only A-z0-9-_=)" % sep,
199
+ f"Unsafe Signer separator: {sep!r} (cannot be empty or consist of "
200
+ "only A-z0-9-_=)",
201
201
  )
202
202
 
203
203
  def signature(self, value, key=None):
@@ -209,12 +209,12 @@ class Signer:
209
209
 
210
210
  def unsign(self, signed_value):
211
211
  if self.sep not in signed_value:
212
- raise BadSignature('No "%s" found in value' % self.sep)
212
+ raise BadSignature(f'No "{self.sep}" found in value')
213
213
  value, sig = signed_value.rsplit(self.sep, 1)
214
214
  for key in [self.key, *self.fallback_keys]:
215
215
  if constant_time_compare(sig, self.signature(value, key)):
216
216
  return value
217
- raise BadSignature('Signature "%s" does not match' % sig)
217
+ raise BadSignature(f'Signature "{sig}" does not match')
218
218
 
219
219
  def sign_object(self, obj, serializer=JSONSerializer, compress=False):
220
220
  """
plain/test/client.py CHANGED
@@ -5,7 +5,6 @@ import sys
5
5
  from functools import partial
6
6
  from http import HTTPStatus
7
7
  from http.cookies import SimpleCookie
8
- from importlib import import_module
9
8
  from io import BytesIO, IOBase
10
9
  from itertools import chain
11
10
  from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit
@@ -21,6 +20,7 @@ from plain.utils.encoding import force_bytes
21
20
  from plain.utils.functional import SimpleLazyObject
22
21
  from plain.utils.http import urlencode
23
22
  from plain.utils.itercompat import is_iterable
23
+ from plain.utils.module_loading import import_string
24
24
  from plain.utils.regex_helper import _lazy_re_compile
25
25
 
26
26
  __all__ = (
@@ -33,7 +33,7 @@ __all__ = (
33
33
 
34
34
 
35
35
  BOUNDARY = "BoUnDaRyStRiNg"
36
- MULTIPART_CONTENT = "multipart/form-data; boundary=%s" % BOUNDARY
36
+ MULTIPART_CONTENT = f"multipart/form-data; boundary={BOUNDARY}"
37
37
  CONTENT_TYPE_RE = _lazy_re_compile(r".*; charset=([\w-]+);?")
38
38
  # Structured suffix spec: https://tools.ietf.org/html/rfc6838#section-4.2.8
39
39
  JSON_CONTENT_TYPE_RE = _lazy_re_compile(r"^application\/(.+\+)?json")
@@ -218,8 +218,8 @@ def encode_multipart(boundary, data):
218
218
  for key, value in data.items():
219
219
  if value is None:
220
220
  raise TypeError(
221
- "Cannot encode None for key '%s' as POST data. Did you mean "
222
- "to pass an empty string or omit the value?" % key
221
+ f"Cannot encode None for key '{key}' as POST data. Did you mean "
222
+ "to pass an empty string or omit the value?"
223
223
  )
224
224
  elif is_file(value):
225
225
  lines.extend(encode_file(boundary, key, value))
@@ -231,8 +231,8 @@ def encode_multipart(boundary, data):
231
231
  lines.extend(
232
232
  to_bytes(val)
233
233
  for val in [
234
- "--%s" % boundary,
235
- 'Content-Disposition: form-data; name="%s"' % key,
234
+ f"--{boundary}",
235
+ f'Content-Disposition: form-data; name="{key}"',
236
236
  "",
237
237
  item,
238
238
  ]
@@ -241,8 +241,8 @@ def encode_multipart(boundary, data):
241
241
  lines.extend(
242
242
  to_bytes(val)
243
243
  for val in [
244
- "--%s" % boundary,
245
- 'Content-Disposition: form-data; name="%s"' % key,
244
+ f"--{boundary}",
245
+ f'Content-Disposition: form-data; name="{key}"',
246
246
  "",
247
247
  value,
248
248
  ]
@@ -250,7 +250,7 @@ def encode_multipart(boundary, data):
250
250
 
251
251
  lines.extend(
252
252
  [
253
- to_bytes("--%s--" % boundary),
253
+ to_bytes(f"--{boundary}--"),
254
254
  b"",
255
255
  ]
256
256
  )
@@ -277,11 +277,11 @@ def encode_file(boundary, key, file):
277
277
  content_type = "application/octet-stream"
278
278
  filename = filename or key
279
279
  return [
280
- to_bytes("--%s" % boundary),
280
+ to_bytes(f"--{boundary}"),
281
281
  to_bytes(
282
282
  f'Content-Disposition: form-data; name="{key}"; filename="{filename}"'
283
283
  ),
284
- to_bytes("Content-Type: %s" % content_type),
284
+ to_bytes(f"Content-Type: {content_type}"),
285
285
  b"",
286
286
  to_bytes(file.read()),
287
287
  ]
@@ -562,11 +562,11 @@ class ClientMixin:
562
562
  @property
563
563
  def session(self):
564
564
  """Return the current session variables."""
565
- engine = import_module(settings.SESSION_ENGINE)
565
+ Session = import_string(settings.SESSION_CLASS)
566
566
  cookie = self.cookies.get(settings.SESSION_COOKIE_NAME)
567
567
  if cookie:
568
- return engine.SessionStore(cookie.value)
569
- session = engine.SessionStore()
568
+ return Session(cookie.value)
569
+ session = Session()
570
570
  session.save()
571
571
  self.cookies[settings.SESSION_COOKIE_NAME] = session.session_key
572
572
  return session
@@ -582,8 +582,8 @@ class ClientMixin:
582
582
  if self.session:
583
583
  request.session = self.session
584
584
  else:
585
- engine = import_module(settings.SESSION_ENGINE)
586
- request.session = engine.SessionStore()
585
+ Session = import_string(settings.SESSION_CLASS)
586
+ request.session = Session()
587
587
  login(request, user)
588
588
  # Save the session values.
589
589
  request.session.save()
@@ -608,8 +608,8 @@ class ClientMixin:
608
608
  request.session = self.session
609
609
  request.user = get_user(request)
610
610
  else:
611
- engine = import_module(settings.SESSION_ENGINE)
612
- request.session = engine.SessionStore()
611
+ Session = import_string(settings.SESSION_CLASS)
612
+ request.session = Session()
613
613
  logout(request)
614
614
  self.cookies = SimpleCookie()
615
615
 
@@ -617,8 +617,9 @@ class ClientMixin:
617
617
  if not hasattr(response, "_json"):
618
618
  if not JSON_CONTENT_TYPE_RE.match(response.get("Content-Type")):
619
619
  raise ValueError(
620
- 'Content-Type header is "%s", not "application/json"'
621
- % response.get("Content-Type")
620
+ 'Content-Type header is "{}", not "application/json"'.format(
621
+ response.get("Content-Type")
622
+ )
622
623
  )
623
624
  response._json = json.loads(
624
625
  response.content.decode(response.charset), **extra
@@ -670,7 +671,7 @@ class Client(ClientMixin, RequestFactory):
670
671
  environ = self._base_environ(**request)
671
672
 
672
673
  # Capture exceptions created by the handler.
673
- exception_uid = "request-exception-%s" % id(request)
674
+ exception_uid = f"request-exception-{id(request)}"
674
675
  got_request_exception.connect(self.store_exc_info, dispatch_uid=exception_uid)
675
676
  try:
676
677
  response = self.handler(environ)
plain/urls/base.py CHANGED
@@ -69,7 +69,7 @@ def reverse(viewname, urlconf=None, args=None, kwargs=None, using_namespace=None
69
69
  )
70
70
  )
71
71
  else:
72
- raise NoReverseMatch("%s is not a registered namespace" % key)
72
+ raise NoReverseMatch(f"{key} is not a registered namespace")
73
73
  if ns_pattern:
74
74
  resolver = get_ns_resolver(
75
75
  ns_pattern, resolver, tuple(ns_converters.items())
plain/urls/conf.py CHANGED
@@ -1,4 +1,5 @@
1
1
  """Functions for use in URLsconfs."""
2
+
2
3
  from functools import partial
3
4
 
4
5
  from plain.exceptions import ImproperlyConfigured
@@ -24,7 +25,7 @@ def include(arg, namespace=None):
24
25
  "provides a namespace."
25
26
  )
26
27
  raise ImproperlyConfigured(
27
- "Passing a %d-tuple to include() is not supported. Pass a "
28
+ "Passing a %d-tuple to include() is not supported. Pass a " # noqa: UP031
28
29
  "2-tuple containing the list of patterns and default_namespace, and "
29
30
  "provide the namespace argument to include() instead." % len(arg)
30
31
  )
plain/urls/resolvers.py CHANGED
@@ -5,6 +5,7 @@ URLResolver is the main class here. Its resolve() method takes a URL (as
5
5
  a string) and returns a ResolverMatch object which provides access to all
6
6
  attributes of the resolved URL match.
7
7
  """
8
+
8
9
  import functools
9
10
  import inspect
10
11
  import re
@@ -145,11 +146,9 @@ class CheckURLMixin:
145
146
  "/"
146
147
  ):
147
148
  warning = Warning(
148
- "Your URL pattern {} has a route beginning with a '/'. Remove this "
149
+ f"Your URL pattern {self.describe()} has a route beginning with a '/'. Remove this "
149
150
  "slash as it is unnecessary. If this pattern is targeted in an "
150
- "include(), ensure the include() pattern has a trailing '/'.".format(
151
- self.describe()
152
- ),
151
+ "include(), ensure the include() pattern has a trailing '/'.",
153
152
  id="urls.W002",
154
153
  )
155
154
  return [warning]
@@ -194,9 +193,9 @@ class RegexPattern(CheckURLMixin):
194
193
  if regex_pattern.endswith("$") and not regex_pattern.endswith(r"\$"):
195
194
  return [
196
195
  Warning(
197
- "Your URL pattern {} uses include with a route ending with a '$'. "
196
+ f"Your URL pattern {self.describe()} uses include with a route ending with a '$'. "
198
197
  "Remove the dollar from the route to avoid problems including "
199
- "URLs.".format(self.describe()),
198
+ "URLs.",
200
199
  id="urls.W001",
201
200
  )
202
201
  ]
@@ -238,16 +237,16 @@ def _route_to_regex(route, is_endpoint=False):
238
237
  break
239
238
  elif not set(match.group()).isdisjoint(string.whitespace):
240
239
  raise ImproperlyConfigured(
241
- "URL route '%s' cannot contain whitespace in angle brackets "
242
- "<…>." % original_route
240
+ f"URL route '{original_route}' cannot contain whitespace in angle brackets "
241
+ "<…>."
243
242
  )
244
243
  parts.append(re.escape(route[: match.start()]))
245
244
  route = route[match.end() :]
246
245
  parameter = match["parameter"]
247
246
  if not parameter.isidentifier():
248
247
  raise ImproperlyConfigured(
249
- "URL route '{}' uses parameter name {!r} which isn't a valid "
250
- "Python identifier.".format(original_route, parameter)
248
+ f"URL route '{original_route}' uses parameter name {parameter!r} which isn't a valid "
249
+ "Python identifier."
251
250
  )
252
251
  raw_converter = match["converter"]
253
252
  if raw_converter is None:
@@ -257,9 +256,7 @@ def _route_to_regex(route, is_endpoint=False):
257
256
  converter = get_converter(raw_converter)
258
257
  except KeyError as e:
259
258
  raise ImproperlyConfigured(
260
- "URL route {!r} uses invalid converter {!r}.".format(
261
- original_route, raw_converter
262
- )
259
+ f"URL route {original_route!r} uses invalid converter {raw_converter!r}."
263
260
  ) from e
264
261
  converters[parameter] = converter
265
262
  parts.append("(?P<" + parameter + ">" + converter.regex + ")")
@@ -297,9 +294,9 @@ class RoutePattern(CheckURLMixin):
297
294
  if "(?P<" in route or route.startswith("^") or route.endswith("$"):
298
295
  warnings.append(
299
296
  Warning(
300
- "Your URL pattern {} has a route that contains '(?P<', begins "
297
+ f"Your URL pattern {self.describe()} has a route that contains '(?P<', begins "
301
298
  "with a '^', or ends with a '$'. This was likely an oversight "
302
- "when migrating to plain.urls.path().".format(self.describe()),
299
+ "when migrating to plain.urls.path().",
303
300
  id="2_0.W001",
304
301
  )
305
302
  )
@@ -334,8 +331,8 @@ class URLPattern:
334
331
  """
335
332
  if self.pattern.name is not None and ":" in self.pattern.name:
336
333
  warning = Warning(
337
- "Your URL pattern {} has a name including a ':'. Remove the colon, to "
338
- "avoid ambiguous namespace references.".format(self.pattern.describe()),
334
+ f"Your URL pattern {self.pattern.describe()} has a name including a ':'. Remove the colon, to "
335
+ "avoid ambiguous namespace references.",
339
336
  id="urls.W003",
340
337
  )
341
338
  return [warning]
@@ -349,12 +346,8 @@ class URLPattern:
349
346
  if inspect.isclass(view) and issubclass(view, View):
350
347
  return [
351
348
  Error(
352
- "Your URL pattern {} has an invalid view, pass {}.as_view() "
353
- "instead of {}.".format(
354
- self.pattern.describe(),
355
- view.__name__,
356
- view.__name__,
357
- ),
349
+ f"Your URL pattern {self.pattern.describe()} has an invalid view, pass {view.__name__}.as_view() "
350
+ f"instead of {view.__name__}.",
358
351
  id="urls.E009",
359
352
  )
360
353
  ]
@@ -422,16 +415,10 @@ class URLResolver:
422
415
  def __repr__(self):
423
416
  if isinstance(self.urlconf_name, list) and self.urlconf_name:
424
417
  # Don't bother to output the whole list, it can be huge
425
- urlconf_repr = "<%s list>" % self.urlconf_name[0].__class__.__name__
418
+ urlconf_repr = f"<{self.urlconf_name[0].__class__.__name__} list>"
426
419
  else:
427
420
  urlconf_repr = repr(self.urlconf_name)
428
- return "<{} {} ({}:{}) {}>".format(
429
- self.__class__.__name__,
430
- urlconf_repr,
431
- self.default_namespace,
432
- self.namespace,
433
- self.pattern.describe(),
434
- )
421
+ return f"<{self.__class__.__name__} {urlconf_repr} ({self.default_namespace}:{self.namespace}) {self.pattern.describe()}>"
435
422
 
436
423
  def check(self):
437
424
  messages = []
@@ -714,10 +701,10 @@ class URLResolver:
714
701
  if args:
715
702
  arg_msg = f"arguments '{args}'"
716
703
  elif kwargs:
717
- arg_msg = "keyword arguments '%s'" % kwargs
704
+ arg_msg = f"keyword arguments '{kwargs}'"
718
705
  else:
719
706
  arg_msg = "no arguments"
720
- msg = "Reverse for '%s' with %s not found. %d pattern(s) tried: %s" % (
707
+ msg = "Reverse for '%s' with %s not found. %d pattern(s) tried: %s" % ( # noqa: UP031
721
708
  lookup_view_s,
722
709
  arg_msg,
723
710
  len(patterns),
plain/utils/cache.py CHANGED
@@ -14,6 +14,7 @@ cache keys to prevent delivery of wrong content.
14
14
  An example: i18n middleware would need to distinguish caches by the
15
15
  "Accept-language" header.
16
16
  """
17
+
17
18
  import time
18
19
  from collections import defaultdict
19
20
  from hashlib import md5
plain/utils/crypto.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Plain's standard crypto functions and utilities.
3
3
  """
4
+
4
5
  import hashlib
5
6
  import hmac
6
7
  import secrets
@@ -32,7 +33,7 @@ def salted_hmac(key_salt, value, secret=None, *, algorithm="sha1"):
32
33
  hasher = getattr(hashlib, algorithm)
33
34
  except AttributeError as e:
34
35
  raise InvalidAlgorithm(
35
- "%r is not an algorithm accepted by the hashlib module." % algorithm
36
+ f"{algorithm!r} is not an algorithm accepted by the hashlib module."
36
37
  ) from e
37
38
  # We need to generate a derived key from our base key. We can do this by
38
39
  # passing the key_salt and our base key through a pseudo-random function.
@@ -198,7 +198,7 @@ class MultiValueDict(dict):
198
198
  def update(self, *args, **kwargs):
199
199
  """Extend rather than replace existing key lists."""
200
200
  if len(args) > 1:
201
- raise TypeError("update expected at most 1 argument, got %d" % len(args))
201
+ raise TypeError("update expected at most 1 argument, got %d" % len(args)) # noqa: UP031
202
202
  if args:
203
203
  arg = args[0]
204
204
  if isinstance(arg, MultiValueDict):
@@ -340,6 +340,6 @@ class CaseInsensitiveMapping(Mapping):
340
340
  )
341
341
  if not isinstance(elem[0], str):
342
342
  raise ValueError(
343
- "Element key %r invalid, only strings are allowed" % elem[0]
343
+ f"Element key {elem[0]!r} invalid, only strings are allowed"
344
344
  )
345
345
  yield elem
plain/utils/dateformat.py CHANGED
@@ -10,6 +10,7 @@ Usage:
10
10
  7th October 2003 11:39
11
11
  >>>
12
12
  """
13
+
13
14
  import calendar
14
15
  from datetime import date, datetime, time
15
16
  from email.utils import format_datetime as format_datetime_rfc5322
@@ -42,7 +43,7 @@ class Formatter:
42
43
  if type(self.data) is date and hasattr(TimeFormat, piece):
43
44
  raise TypeError(
44
45
  "The format for date objects may not contain "
45
- "time-related format specifiers (found '%s')." % piece
46
+ f"time-related format specifiers (found '{piece}')."
46
47
  )
47
48
  pieces.append(str(getattr(self, piece)()))
48
49
  elif piece:
@@ -103,7 +104,7 @@ class TimeFormat(Formatter):
103
104
  """
104
105
  hour = self.data.hour % 12 or 12
105
106
  minute = self.data.minute
106
- return "%d:%02d" % (hour, minute) if minute else hour
107
+ return "%d:%02d" % (hour, minute) if minute else hour # noqa: UP031
107
108
 
108
109
  def g(self):
109
110
  "Hour, 12-hour format without leading zeros; i.e. '1' to '12'"
@@ -115,15 +116,15 @@ class TimeFormat(Formatter):
115
116
 
116
117
  def h(self):
117
118
  "Hour, 12-hour format; i.e. '01' to '12'"
118
- return "%02d" % (self.data.hour % 12 or 12)
119
+ return "%02d" % (self.data.hour % 12 or 12) # noqa: UP031
119
120
 
120
121
  def H(self):
121
122
  "Hour, 24-hour format; i.e. '00' to '23'"
122
- return "%02d" % self.data.hour
123
+ return "%02d" % self.data.hour # noqa: UP031
123
124
 
124
125
  def i(self):
125
126
  "Minutes; i.e. '00' to '59'"
126
- return "%02d" % self.data.minute
127
+ return "%02d" % self.data.minute # noqa: UP031
127
128
 
128
129
  def O(self): # NOQA: E743, E741
129
130
  """
@@ -138,7 +139,7 @@ class TimeFormat(Formatter):
138
139
  seconds = offset.days * 86400 + offset.seconds
139
140
  sign = "-" if seconds < 0 else "+"
140
141
  seconds = abs(seconds)
141
- return "%s%02d%02d" % (sign, seconds // 3600, (seconds // 60) % 60)
142
+ return "%s%02d%02d" % (sign, seconds // 3600, (seconds // 60) % 60) # noqa: UP031
142
143
 
143
144
  def P(self):
144
145
  """
@@ -155,7 +156,7 @@ class TimeFormat(Formatter):
155
156
 
156
157
  def s(self):
157
158
  "Seconds; i.e. '00' to '59'"
158
- return "%02d" % self.data.second
159
+ return "%02d" % self.data.second # noqa: UP031
159
160
 
160
161
  def T(self):
161
162
  """
@@ -170,7 +171,7 @@ class TimeFormat(Formatter):
170
171
 
171
172
  def u(self):
172
173
  "Microseconds; i.e. '000000' to '999999'"
173
- return "%06d" % self.data.microsecond
174
+ return "%06d" % self.data.microsecond # noqa: UP031
174
175
 
175
176
  def Z(self):
176
177
  """
@@ -206,7 +207,7 @@ class DateFormat(TimeFormat):
206
207
 
207
208
  def d(self):
208
209
  "Day of the month, 2 digits with leading zeros; i.e. '01' to '31'"
209
- return "%02d" % self.data.day
210
+ return "%02d" % self.data.day # noqa: UP031
210
211
 
211
212
  def D(self):
212
213
  "Day of the week, textual, 3 letters; e.g. 'Fri'"
@@ -240,7 +241,7 @@ class DateFormat(TimeFormat):
240
241
 
241
242
  def m(self):
242
243
  "Month; i.e. '01' to '12'"
243
- return "%02d" % self.data.month
244
+ return "%02d" % self.data.month # noqa: UP031
244
245
 
245
246
  def M(self):
246
247
  "Month, textual, 3 letters; e.g. 'Jan'"
@@ -306,11 +307,11 @@ class DateFormat(TimeFormat):
306
307
 
307
308
  def y(self):
308
309
  """Year, 2 digits with leading zeros; e.g. '99'."""
309
- return "%02d" % (self.data.year % 100)
310
+ return "%02d" % (self.data.year % 100) # noqa: UP031
310
311
 
311
312
  def Y(self):
312
313
  """Year, 4 digits with leading zeros; e.g. '1999'."""
313
- return "%04d" % self.data.year
314
+ return "%04d" % self.data.year # noqa: UP031
314
315
 
315
316
  def z(self):
316
317
  """Day of the year, i.e. 1 to 366."""
plain/utils/dateparse.py CHANGED
@@ -118,7 +118,7 @@ def parse_datetime(value):
118
118
  kw["microsecond"] = kw["microsecond"] and kw["microsecond"].ljust(6, "0")
119
119
  tzinfo = kw.pop("tzinfo")
120
120
  if tzinfo == "Z":
121
- tzinfo = datetime.timezone.utc
121
+ tzinfo = datetime.UTC
122
122
  elif tzinfo is not None:
123
123
  offset_mins = int(tzinfo[-2:]) if len(tzinfo) > 3 else 0
124
124
  offset = 60 * int(tzinfo[1:3]) + offset_mins
plain/utils/decorators.py CHANGED
@@ -86,5 +86,5 @@ def method_decorator(decorator, name=""):
86
86
  update_wrapper(_dec, decorator)
87
87
  # Change the name to aid debugging.
88
88
  obj = decorator if hasattr(decorator, "__name__") else decorator.__class__
89
- _dec.__name__ = "method_decorator(%s)" % obj.__name__
89
+ _dec.__name__ = f"method_decorator({obj.__name__})"
90
90
  return _dec
plain/utils/html.py CHANGED
@@ -43,7 +43,7 @@ _js_escapes = {
43
43
  }
44
44
 
45
45
  # Escape every ASCII character with a value less than 32.
46
- _js_escapes.update((ord("%c" % z), "\\u%04X" % z) for z in range(32))
46
+ _js_escapes.update((ord("%c" % z), f"\\u{z:04X}") for z in range(32)) # noqa: UP031
47
47
 
48
48
 
49
49
  @keep_lazy(SafeString)
@@ -132,9 +132,9 @@ def linebreaks(value, autoescape=False):
132
132
  value = normalize_newlines(value)
133
133
  paras = re.split("\n{2,}", str(value))
134
134
  if autoescape:
135
- paras = ["<p>%s</p>" % escape(p).replace("\n", "<br>") for p in paras]
135
+ paras = ["<p>{}</p>".format(escape(p).replace("\n", "<br>")) for p in paras]
136
136
  else:
137
- paras = ["<p>%s</p>" % p.replace("\n", "<br>") for p in paras]
137
+ paras = ["<p>{}</p>".format(p.replace("\n", "<br>")) for p in paras]
138
138
  return "\n\n".join(paras)
139
139
 
140
140
 
@@ -148,10 +148,10 @@ class MLStripper(HTMLParser):
148
148
  self.fed.append(d)
149
149
 
150
150
  def handle_entityref(self, name):
151
- self.fed.append("&%s;" % name)
151
+ self.fed.append(f"&{name};")
152
152
 
153
153
  def handle_charref(self, name):
154
- self.fed.append("&#%s;" % name)
154
+ self.fed.append(f"&#{name};")
155
155
 
156
156
  def get_data(self):
157
157
  return "".join(self.fed)
@@ -293,7 +293,7 @@ class Urlizer:
293
293
  if self.simple_url_re.match(middle):
294
294
  url = smart_urlquote(html.unescape(middle))
295
295
  elif self.simple_url_2_re.match(middle):
296
- url = smart_urlquote("http://%s" % html.unescape(middle))
296
+ url = smart_urlquote(f"http://{html.unescape(middle)}")
297
297
  elif ":" not in middle and self.is_email_simple(middle):
298
298
  local, domain = middle.rsplit("@", 1)
299
299
  try:
@@ -328,7 +328,7 @@ class Urlizer:
328
328
  def trim_url(self, x, *, limit):
329
329
  if limit is None or len(x) <= limit:
330
330
  return x
331
- return "%s…" % x[: max(0, limit - 1)]
331
+ return f"{x[: max(0, limit - 1)]}…"
332
332
 
333
333
  def trim_punctuation(self, word):
334
334
  """