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
plain/cli/README.md CHANGED
@@ -108,11 +108,11 @@ Some packages, like [plain-dev](https://plainframework.com/docs/plain-dev/),
108
108
  never show up in `INSTALLED_PACKAGES` but still have CLI commands.
109
109
  These are detected via Python entry points.
110
110
 
111
- An example with `pyproject.toml` and Poetry:
111
+ An example with `pyproject.toml` and UV:
112
112
 
113
113
  ```toml
114
114
  # pyproject.toml
115
- [tool.poetry.plugins."plain.cli"]
115
+ [project.entry-points."plain.cli"]
116
116
  "dev" = "plain.dev:cli"
117
117
  "pre-commit" = "plain.dev.precommit:cli"
118
118
  "contrib" = "plain.dev.contribute:cli"
plain/cli/cli.py CHANGED
@@ -232,10 +232,10 @@ def preflight_checks(package_label, deploy, fail_level, databases):
232
232
  if visible_issue_count == 0
233
233
  else "1 issue"
234
234
  if visible_issue_count == 1
235
- else "%s issues" % visible_issue_count,
235
+ else f"{visible_issue_count} issues",
236
236
  len(all_issues) - visible_issue_count,
237
237
  )
238
- msg = click.style("SystemCheckError: %s" % header, fg="red") + body + footer
238
+ msg = click.style(f"SystemCheckError: {header}", fg="red") + body + footer
239
239
  raise click.ClickException(msg)
240
240
  else:
241
241
  if visible_issue_count:
@@ -245,7 +245,7 @@ def preflight_checks(package_label, deploy, fail_level, databases):
245
245
  if visible_issue_count == 0
246
246
  else "1 issue"
247
247
  if visible_issue_count == 1
248
- else "%s issues" % visible_issue_count,
248
+ else f"{visible_issue_count} issues",
249
249
  len(all_issues) - visible_issue_count,
250
250
  )
251
251
  msg = header + body + footer
@@ -422,21 +422,17 @@ class AppCLIGroup(click.Group):
422
422
  MODULE_NAME = "app.cli"
423
423
 
424
424
  def list_commands(self, ctx):
425
- try:
426
- find_spec(self.MODULE_NAME)
425
+ if find_spec(self.MODULE_NAME):
427
426
  return ["app"]
428
- except ModuleNotFoundError:
427
+ else:
429
428
  return []
430
429
 
431
430
  def get_command(self, ctx, name):
432
431
  if name != "app":
433
432
  return
434
433
 
435
- try:
436
- cli = importlib.import_module(self.MODULE_NAME)
437
- return cli.cli
438
- except ModuleNotFoundError:
439
- return
434
+ cli = importlib.import_module(self.MODULE_NAME)
435
+ return cli.cli
440
436
 
441
437
 
442
438
  class PlainCommandCollection(click.CommandCollection):
plain/cli/packages.py CHANGED
@@ -36,11 +36,15 @@ class InstalledPackagesGroup(click.Group):
36
36
  def get_command(self, ctx, name):
37
37
  # Try it as plain.x and just x (we don't know ahead of time which it is, but prefer plain.x)
38
38
  for n in [self.PLAIN_APPS_PREFIX + name, name]:
39
- try:
40
- cli = importlib.import_module(f"{n}.{self.MODULE_NAME}")
41
- except ModuleNotFoundError:
39
+ if not find_spec(n):
40
+ # plain.<name> doesn't exist at all
41
+ continue
42
+
43
+ if not find_spec(f"{n}.{self.MODULE_NAME}"):
42
44
  continue
43
45
 
46
+ cli = importlib.import_module(f"{n}.{self.MODULE_NAME}")
47
+
44
48
  # Get the app's cli.py group
45
49
  try:
46
50
  return cli.cli
plain/cli/startup.py CHANGED
@@ -10,9 +10,9 @@ def _print_bold(s):
10
10
 
11
11
 
12
12
  def _print_italic(s):
13
- print("\x1B[3m", end="")
13
+ print("\x1b[3m", end="")
14
14
  print(s)
15
- print("\x1B[0m", end="")
15
+ print("\x1b[0m", end="")
16
16
 
17
17
 
18
18
  _print_bold("\n⬣ Welcome to the Plain shell! ⬣")
plain/csrf/middleware.py CHANGED
@@ -4,6 +4,7 @@ Cross Site Request Forgery Middleware.
4
4
  This module provides a middleware that implements protection
5
5
  against request forgeries from other sites.
6
6
  """
7
+
7
8
  import logging
8
9
  import string
9
10
  from collections import defaultdict
plain/exceptions.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Global Plain exception and warning classes.
3
3
  """
4
+
4
5
  import operator
5
6
 
6
7
  from plain.utils.hashable import make_hashable
@@ -209,7 +210,7 @@ class ValidationError(Exception):
209
210
  return repr(list(self))
210
211
 
211
212
  def __repr__(self):
212
- return "ValidationError(%s)" % self
213
+ return f"ValidationError({self})"
213
214
 
214
215
  def __eq__(self, other):
215
216
  if not isinstance(other, ValidationError):
plain/forms/forms.py CHANGED
@@ -172,7 +172,7 @@ class BaseForm:
172
172
  if not isinstance(error, ValidationError):
173
173
  raise TypeError(
174
174
  "The argument `error` must be an instance of "
175
- "`ValidationError`, not `%s`." % type(error).__name__
175
+ f"`ValidationError`, not `{type(error).__name__}`."
176
176
  )
177
177
 
178
178
  if hasattr(error, "error_dict"):
@@ -221,9 +221,9 @@ class BaseForm:
221
221
  self._post_clean()
222
222
 
223
223
  def _field_data_value(self, field, html_name):
224
- if hasattr(self, "parse_%s" % html_name):
224
+ if hasattr(self, f"parse_{html_name}"):
225
225
  # Allow custom parsing from form data/files at the form level
226
- return getattr(self, "parse_%s" % html_name)()
226
+ return getattr(self, f"parse_{html_name}")()
227
227
 
228
228
  return field.value_from_form_data(self.data, self.files, html_name)
229
229
 
@@ -242,8 +242,8 @@ class BaseForm:
242
242
  else:
243
243
  value = field.clean(value)
244
244
  self.cleaned_data[name] = value
245
- if hasattr(self, "clean_%s" % name):
246
- value = getattr(self, "clean_%s" % name)()
245
+ if hasattr(self, f"clean_{name}"):
246
+ value = getattr(self, f"clean_{name}")()
247
247
  self.cleaned_data[name] = value
248
248
  except ValidationError as e:
249
249
  self.add_error(name, e)
@@ -4,6 +4,7 @@ Multi-part parsing for file uploads.
4
4
  Exposes one class, ``MultiPartParser``, which feeds chunks of uploaded data to
5
5
  file upload handlers for processing.
6
6
  """
7
+
7
8
  import base64
8
9
  import binascii
9
10
  import collections
@@ -70,14 +71,13 @@ class MultiPartParser:
70
71
  # Content-Type should contain multipart and the boundary information.
71
72
  content_type = META.get("CONTENT_TYPE", "")
72
73
  if not content_type.startswith("multipart/"):
73
- raise MultiPartParserError("Invalid Content-Type: %s" % content_type)
74
+ raise MultiPartParserError(f"Invalid Content-Type: {content_type}")
74
75
 
75
76
  try:
76
77
  content_type.encode("ascii")
77
78
  except UnicodeEncodeError:
78
79
  raise MultiPartParserError(
79
- "Invalid non-ASCII Content-Type in multipart: %s"
80
- % force_str(content_type)
80
+ f"Invalid non-ASCII Content-Type in multipart: {force_str(content_type)}"
81
81
  )
82
82
 
83
83
  # Parse the header to get the boundary to split the parts.
@@ -85,7 +85,7 @@ class MultiPartParser:
85
85
  boundary = opts.get("boundary")
86
86
  if not boundary or not self.boundary_re.fullmatch(boundary):
87
87
  raise MultiPartParserError(
88
- "Invalid boundary in multipart: %s" % force_str(boundary)
88
+ f"Invalid boundary in multipart: {force_str(boundary)}"
89
89
  )
90
90
 
91
91
  # Content-Length should contain the length of the body we are about
@@ -97,7 +97,7 @@ class MultiPartParser:
97
97
 
98
98
  if content_length < 0:
99
99
  # This means we shouldn't continue...raise an error.
100
- raise MultiPartParserError("Invalid content length: %r" % content_length)
100
+ raise MultiPartParserError(f"Invalid content length: {content_length!r}")
101
101
 
102
102
  self._boundary = boundary.encode("ascii")
103
103
  self._input_data = input_data
plain/http/request.py CHANGED
@@ -81,7 +81,7 @@ class HttpRequest:
81
81
 
82
82
  def __repr__(self):
83
83
  if self.method is None or not self.get_full_path():
84
- return "<%s>" % self.__class__.__name__
84
+ return f"<{self.__class__.__name__}>"
85
85
  return f"<{self.__class__.__name__}: {self.method} {self.get_full_path()!r}>"
86
86
 
87
87
  def __getstate__(self):
@@ -157,9 +157,9 @@ class HttpRequest:
157
157
  if domain and validate_host(domain, allowed_hosts):
158
158
  return host
159
159
  else:
160
- msg = "Invalid HTTP_HOST header: %r." % host
160
+ msg = f"Invalid HTTP_HOST header: {host!r}."
161
161
  if domain:
162
- msg += " You may need to add %r to ALLOWED_HOSTS." % domain
162
+ msg += f" You may need to add {domain!r} to ALLOWED_HOSTS."
163
163
  else:
164
164
  msg += (
165
165
  " The domain name provided is not valid according to RFC 1034/1035."
@@ -227,7 +227,7 @@ class HttpRequest:
227
227
  if location is None:
228
228
  # Make it an absolute url (but schemeless and domainless) for the
229
229
  # edge case that the path starts with '//'.
230
- location = "//%s" % self.get_full_path()
230
+ location = f"//{self.get_full_path()}"
231
231
  else:
232
232
  # Coerce lazy locations.
233
233
  location = str(location)
@@ -671,7 +671,7 @@ class MediaType:
671
671
  params_str = "".join(f"; {k}={v}" for k, v in self.params.items())
672
672
  return "{}{}{}".format(
673
673
  self.main_type,
674
- ("/%s" % self.sub_type) if self.sub_type else "",
674
+ (f"/{self.sub_type}") if self.sub_type else "",
675
675
  params_str,
676
676
  )
677
677
 
plain/http/response.py CHANGED
@@ -7,6 +7,7 @@ import re
7
7
  import sys
8
8
  import time
9
9
  from email.header import Header
10
+ from functools import cached_property
10
11
  from http.client import responses
11
12
  from http.cookies import SimpleCookie
12
13
  from urllib.parse import urlparse
@@ -72,7 +73,7 @@ class ResponseHeaders(CaseInsensitiveMapping):
72
73
  if mime_encode:
73
74
  value = Header(value, "utf-8", maxlinelen=sys.maxsize).encode()
74
75
  else:
75
- e.reason += ", HTTP response headers must be in %s format" % charset
76
+ e.reason += f", HTTP response headers must be in {charset} format"
76
77
  raise
77
78
  return value
78
79
 
@@ -181,7 +182,7 @@ class ResponseBase:
181
182
  @property
182
183
  def _content_type_for_repr(self):
183
184
  return (
184
- ', "%s"' % self.headers["Content-Type"]
185
+ ', "{}"'.format(self.headers["Content-Type"])
185
186
  if "Content-Type" in self.headers
186
187
  else ""
187
188
  )
@@ -236,8 +237,8 @@ class ResponseBase:
236
237
  if expires is not None:
237
238
  if isinstance(expires, datetime.datetime):
238
239
  if timezone.is_naive(expires):
239
- expires = timezone.make_aware(expires, datetime.timezone.utc)
240
- delta = expires - datetime.datetime.now(tz=datetime.timezone.utc)
240
+ expires = timezone.make_aware(expires, datetime.UTC)
241
+ delta = expires - datetime.datetime.now(tz=datetime.UTC)
241
242
  # Add one second so the date matches exactly (a fraction of
242
243
  # time gets lost between converting to a timedelta and
243
244
  # then the date string).
@@ -332,14 +333,14 @@ class ResponseBase:
332
333
  signals.request_finished.send(sender=self._handler_class)
333
334
 
334
335
  def write(self, content):
335
- raise OSError("This %s instance is not writable" % self.__class__.__name__)
336
+ raise OSError(f"This {self.__class__.__name__} instance is not writable")
336
337
 
337
338
  def flush(self):
338
339
  pass
339
340
 
340
341
  def tell(self):
341
342
  raise OSError(
342
- "This %s instance cannot tell its position" % self.__class__.__name__
343
+ f"This {self.__class__.__name__} instance cannot tell its position"
343
344
  )
344
345
 
345
346
  # These methods partially implement a stream-like object interface.
@@ -355,7 +356,7 @@ class ResponseBase:
355
356
  return False
356
357
 
357
358
  def writelines(self, lines):
358
- raise OSError("This %s instance is not writable" % self.__class__.__name__)
359
+ raise OSError(f"This {self.__class__.__name__} instance is not writable")
359
360
 
360
361
 
361
362
  class Response(ResponseBase):
@@ -390,7 +391,7 @@ class Response(ResponseBase):
390
391
  return obj_dict
391
392
 
392
393
  def __repr__(self):
393
- return "<%(cls)s status_code=%(status_code)d%(content_type)s>" % {
394
+ return "<%(cls)s status_code=%(status_code)d%(content_type)s>" % { # noqa: UP031
394
395
  "cls": self.__class__.__name__,
395
396
  "status_code": self.status_code,
396
397
  "content_type": self._content_type_for_repr,
@@ -423,6 +424,10 @@ class Response(ResponseBase):
423
424
  # Create a list of properly encoded bytestrings to support write().
424
425
  self._container = [content]
425
426
 
427
+ @cached_property
428
+ def text(self):
429
+ return self.content.decode(self.charset or "utf-8")
430
+
426
431
  def __iter__(self):
427
432
  return iter(self._container)
428
433
 
@@ -461,7 +466,7 @@ class StreamingResponse(ResponseBase):
461
466
  self.streaming_content = streaming_content
462
467
 
463
468
  def __repr__(self):
464
- return "<%(cls)s status_code=%(status_code)d%(content_type)s>" % {
469
+ return "<%(cls)s status_code=%(status_code)d%(content_type)s>" % { # noqa: UP031
465
470
  "cls": self.__class__.__qualname__,
466
471
  "status_code": self.status_code,
467
472
  "content_type": self._content_type_for_repr,
@@ -470,8 +475,8 @@ class StreamingResponse(ResponseBase):
470
475
  @property
471
476
  def content(self):
472
477
  raise AttributeError(
473
- "This %s instance has no `content` attribute. Use "
474
- "`streaming_content` instead." % self.__class__.__name__
478
+ f"This {self.__class__.__name__} instance has no `content` attribute. Use "
479
+ "`streaming_content` instead."
475
480
  )
476
481
 
477
482
  @property
@@ -586,14 +591,14 @@ class ResponseRedirectBase(Response):
586
591
  parsed = urlparse(str(redirect_to))
587
592
  if parsed.scheme and parsed.scheme not in self.allowed_schemes:
588
593
  raise DisallowedRedirect(
589
- "Unsafe redirect to URL with protocol '%s'" % parsed.scheme
594
+ f"Unsafe redirect to URL with protocol '{parsed.scheme}'"
590
595
  )
591
596
 
592
597
  url = property(lambda self: self["Location"])
593
598
 
594
599
  def __repr__(self):
595
600
  return (
596
- '<%(cls)s status_code=%(status_code)d%(content_type)s, url="%(url)s">'
601
+ '<%(cls)s status_code=%(status_code)d%(content_type)s, url="%(url)s">' # noqa: UP031
597
602
  % {
598
603
  "cls": self.__class__.__name__,
599
604
  "status_code": self.status_code,
@@ -661,7 +666,7 @@ class ResponseNotAllowed(Response):
661
666
  self["Allow"] = ", ".join(permitted_methods)
662
667
 
663
668
  def __repr__(self):
664
- return "<%(cls)s [%(methods)s] status_code=%(status_code)d%(content_type)s>" % {
669
+ return "<%(cls)s [%(methods)s] status_code=%(status_code)d%(content_type)s>" % { # noqa: UP031
665
670
  "cls": self.__class__.__name__,
666
671
  "status_code": self.status_code,
667
672
  "content_type": self._content_type_for_repr,
@@ -16,6 +16,7 @@ Example Usage::
16
16
  ... locks.lock(f, locks.LOCK_EX)
17
17
  ... f.write('Plain')
18
18
  """
19
+
19
20
  import os
20
21
 
21
22
  __all__ = ("LOCK_EX", "LOCK_SH", "LOCK_NB", "lock", "unlock")
@@ -46,8 +46,7 @@ def file_move_safe(
46
46
  try:
47
47
  if not allow_overwrite and os.access(new_file_name, os.F_OK):
48
48
  raise FileExistsError(
49
- "Destination file %s exists and allow_overwrite is False."
50
- % new_file_name
49
+ f"Destination file {new_file_name} exists and allow_overwrite is False."
51
50
  )
52
51
 
53
52
  os.rename(old_file_name, new_file_name)
@@ -1,6 +1,7 @@
1
1
  """
2
2
  Base file upload handler classes, and the built-in concrete subclasses
3
3
  """
4
+
4
5
  import os
5
6
  from io import BytesIO
6
7
 
@@ -7,7 +7,7 @@ from plain.exceptions import SuspiciousFileOperation
7
7
  def validate_file_name(name, allow_relative_path=False):
8
8
  # Remove potentially dangerous names
9
9
  if os.path.basename(name) in {"", ".", ".."}:
10
- raise SuspiciousFileOperation("Could not derive file name from '%s'" % name)
10
+ raise SuspiciousFileOperation(f"Could not derive file name from '{name}'")
11
11
 
12
12
  if allow_relative_path:
13
13
  # Use PurePosixPath() because this branch is checked only in
@@ -16,10 +16,10 @@ def validate_file_name(name, allow_relative_path=False):
16
16
  path = pathlib.PurePosixPath(name)
17
17
  if path.is_absolute() or ".." in path.parts:
18
18
  raise SuspiciousFileOperation(
19
- "Detected path traversal attempt in '%s'" % name
19
+ f"Detected path traversal attempt in '{name}'"
20
20
  )
21
21
  elif name != os.path.basename(name):
22
- raise SuspiciousFileOperation("File name '%s' includes path elements" % name)
22
+ raise SuspiciousFileOperation(f"File name '{name}' includes path elements")
23
23
 
24
24
  return name
25
25
 
@@ -45,7 +45,7 @@ class BaseHandler:
45
45
 
46
46
  if mw_instance is None:
47
47
  raise ImproperlyConfigured(
48
- "Middleware factory %s returned None." % middleware_path
48
+ f"Middleware factory {middleware_path} returned None."
49
49
  )
50
50
 
51
51
  if hasattr(mw_instance, "process_view"):
@@ -126,14 +126,10 @@ class BaseHandler:
126
126
  if isinstance(callback, types.FunctionType): # FBV
127
127
  name = f"The view {callback.__module__}.{callback.__name__}"
128
128
  else: # CBV
129
- name = "The view {}.{}.__call__".format(
130
- callback.__module__,
131
- callback.__class__.__name__,
132
- )
129
+ name = f"The view {callback.__module__}.{callback.__class__.__name__}.__call__"
133
130
  if response is None:
134
131
  raise ValueError(
135
- "%s didn't return a Response object. It returned None "
136
- "instead." % name
132
+ f"{name} didn't return a Response object. It returned None " "instead."
137
133
  )
138
134
 
139
135
 
@@ -85,9 +85,7 @@ def response_for_exception(request, exc):
85
85
 
86
86
  # The request logger receives events for any problematic request
87
87
  # The security logger receives events for all SuspiciousOperations
88
- security_logger = logging.getLogger(
89
- "plain.security.%s" % exc.__class__.__name__
90
- )
88
+ security_logger = logging.getLogger(f"plain.security.{exc.__class__.__name__}")
91
89
  security_logger.error(
92
90
  str(exc),
93
91
  exc_info=exc,
@@ -138,7 +138,7 @@ class WSGIHandler(base.BaseHandler):
138
138
 
139
139
  response._handler_class = self.__class__
140
140
 
141
- status = "%d %s" % (response.status_code, response.reason_phrase)
141
+ status = "%d %s" % (response.status_code, response.reason_phrase) # noqa: UP031
142
142
  response_headers = [
143
143
  *response.items(),
144
144
  *(("Set-Cookie", c.output(header="")) for c in response.cookies.values()),
@@ -34,7 +34,7 @@ class RedirectSlashMiddleware:
34
34
  if settings.APPEND_SLASH and not request.path_info.endswith("/"):
35
35
  urlconf = getattr(request, "urlconf", None)
36
36
  if not is_valid_path(request.path_info, urlconf):
37
- match = is_valid_path("%s/" % request.path_info, urlconf)
37
+ match = is_valid_path(f"{request.path_info}/", urlconf)
38
38
  if match:
39
39
  view = match.func
40
40
  return getattr(view, "should_append_slash", True)
@@ -52,13 +52,10 @@ class RedirectSlashMiddleware:
52
52
  new_path = escape_leading_slashes(new_path)
53
53
  if settings.DEBUG and request.method in ("POST", "PUT", "PATCH"):
54
54
  raise RuntimeError(
55
- "You called this URL via {method}, but the URL doesn't end "
55
+ f"You called this URL via {request.method}, but the URL doesn't end "
56
56
  "in a slash and you have APPEND_SLASH set. Plain can't "
57
- "redirect to the slash URL while maintaining {method} data. "
58
- "Change your form to point to {url} (note the trailing "
59
- "slash), or set APPEND_SLASH=False in your Plain settings.".format(
60
- method=request.method,
61
- url=request.get_host() + new_path,
62
- )
57
+ f"redirect to the slash URL while maintaining {request.method} data. "
58
+ f"Change your form to point to {request.get_host() + new_path} (note the trailing "
59
+ "slash), or set APPEND_SLASH=False in your Plain settings."
63
60
  )
64
61
  return new_path
plain/packages/config.py CHANGED
@@ -35,7 +35,7 @@ class PackageConfig:
35
35
  self.label = package_name.rpartition(".")[2]
36
36
  if not self.label.isidentifier():
37
37
  raise ImproperlyConfigured(
38
- "The app label '%s' is not a valid Python identifier." % self.label
38
+ f"The app label '{self.label}' is not a valid Python identifier."
39
39
  )
40
40
 
41
41
  # Filesystem path to the application directory e.g.
@@ -71,15 +71,15 @@ class PackageConfig:
71
71
  paths = list(set(paths))
72
72
  if len(paths) > 1:
73
73
  raise ImproperlyConfigured(
74
- "The app module {!r} has multiple filesystem locations ({!r}); "
74
+ f"The app module {module!r} has multiple filesystem locations ({paths!r}); "
75
75
  "you must configure this app with an PackageConfig subclass "
76
- "with a 'path' class attribute.".format(module, paths)
76
+ "with a 'path' class attribute."
77
77
  )
78
78
  elif not paths:
79
79
  raise ImproperlyConfigured(
80
- "The app module %r has no filesystem location, "
80
+ f"The app module {module!r} has no filesystem location, "
81
81
  "you must configure this app with an PackageConfig subclass "
82
- "with a 'path' class attribute." % module
82
+ "with a 'path' class attribute."
83
83
  )
84
84
  return paths[0]
85
85
 
@@ -170,7 +170,7 @@ class PackageConfig:
170
170
  ]
171
171
  msg = f"Module '{mod_path}' does not contain a '{cls_name}' class."
172
172
  if candidates:
173
- msg += " Choices are: %s." % ", ".join(candidates)
173
+ msg += " Choices are: {}.".format(", ".join(candidates))
174
174
  raise ImportError(msg)
175
175
  else:
176
176
  # Re-trigger the module import exception.
@@ -179,9 +179,7 @@ class PackageConfig:
179
179
  # Check for obvious errors. (This check prevents duck typing, but
180
180
  # it could be removed if it became a problem in practice.)
181
181
  if not issubclass(package_config_class, PackageConfig):
182
- raise ImproperlyConfigured(
183
- "'%s' isn't a subclass of PackageConfig." % entry
184
- )
182
+ raise ImproperlyConfigured(f"'{entry}' isn't a subclass of PackageConfig.")
185
183
 
186
184
  # Obtain package name here rather than in PackageClass.__init__ to keep
187
185
  # all error checking for entries in INSTALLED_PACKAGES in one place.
@@ -189,18 +187,14 @@ class PackageConfig:
189
187
  try:
190
188
  package_name = package_config_class.name
191
189
  except AttributeError:
192
- raise ImproperlyConfigured("'%s' must supply a name attribute." % entry)
190
+ raise ImproperlyConfigured(f"'{entry}' must supply a name attribute.")
193
191
 
194
192
  # Ensure package_name points to a valid module.
195
193
  try:
196
194
  package_module = import_module(package_name)
197
195
  except ImportError:
198
196
  raise ImproperlyConfigured(
199
- "Cannot import '{}'. Check that '{}.{}.name' is correct.".format(
200
- package_name,
201
- package_config_class.__module__,
202
- package_config_class.__qualname__,
203
- )
197
+ f"Cannot import '{package_name}'. Check that '{package_config_class.__module__}.{package_config_class.__qualname__}.name' is correct."
204
198
  )
205
199
 
206
200
  # Entry is a path to an app config class.
@@ -90,7 +90,7 @@ class Packages:
90
90
  if package_config.label in self.package_configs:
91
91
  raise ImproperlyConfigured(
92
92
  "Package labels aren't unique, "
93
- "duplicates: %s" % package_config.label
93
+ f"duplicates: {package_config.label}"
94
94
  )
95
95
 
96
96
  self.package_configs[package_config.label] = package_config
@@ -103,8 +103,9 @@ class Packages:
103
103
  duplicates = [name for name, count in counts.most_common() if count > 1]
104
104
  if duplicates:
105
105
  raise ImproperlyConfigured(
106
- "Package names aren't unique, "
107
- "duplicates: %s" % ", ".join(duplicates)
106
+ "Package names aren't unique, " "duplicates: {}".format(
107
+ ", ".join(duplicates)
108
+ )
108
109
  )
109
110
 
110
111
  self.packages_ready = True
@@ -154,10 +155,10 @@ class Packages:
154
155
  try:
155
156
  return self.package_configs[package_label]
156
157
  except KeyError:
157
- message = "No installed app with label '%s'." % package_label
158
+ message = f"No installed app with label '{package_label}'."
158
159
  for package_config in self.get_package_configs():
159
160
  if package_config.name == package_label:
160
- message += " Did you mean '%s'?" % package_config.label
161
+ message += f" Did you mean '{package_config.label}'?"
161
162
  break
162
163
  raise LookupError(message)
163
164
 
@@ -223,17 +224,15 @@ class Packages:
223
224
  and model.__module__ == app_models[model_name].__module__
224
225
  ):
225
226
  warnings.warn(
226
- "Model '{}.{}' was already registered. Reloading models is not "
227
+ f"Model '{package_label}.{model_name}' was already registered. Reloading models is not "
227
228
  "advised as it can lead to inconsistencies, most notably with "
228
- "related models.".format(package_label, model_name),
229
+ "related models.",
229
230
  RuntimeWarning,
230
231
  stacklevel=2,
231
232
  )
232
233
  else:
233
234
  raise RuntimeError(
234
- "Conflicting '{}' models in application '{}': {} and {}.".format(
235
- model_name, package_label, app_models[model_name], model
236
- )
235
+ f"Conflicting '{model_name}' models in application '{package_label}': {app_models[model_name]} and {model}."
237
236
  )
238
237
  app_models[model_name] = model
239
238
  self.do_pending_operations(model)
@@ -321,8 +320,9 @@ class Packages:
321
320
  }
322
321
  if not available.issubset(installed):
323
322
  raise ValueError(
324
- "Available packages isn't a subset of installed packages, extra packages: %s"
325
- % ", ".join(available - installed)
323
+ "Available packages isn't a subset of installed packages, extra packages: {}".format(
324
+ ", ".join(available - installed)
325
+ )
326
326
  )
327
327
 
328
328
  self.stored_package_configs.append(self.package_configs)
plain/paginator.py CHANGED
@@ -142,8 +142,7 @@ class Page(collections.abc.Sequence):
142
142
  def __getitem__(self, index):
143
143
  if not isinstance(index, int | slice):
144
144
  raise TypeError(
145
- "Page indices must be integers or slices, not %s."
146
- % type(index).__name__
145
+ f"Page indices must be integers or slices, not {type(index).__name__}."
147
146
  )
148
147
  # The object_list is converted to a list so that if it was a QuerySet
149
148
  # it won't be a database hit per __getitem__.