plain 0.70.0__tar.gz → 0.71.0__tar.gz

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 (182) hide show
  1. {plain-0.70.0 → plain-0.71.0}/PKG-INFO +1 -1
  2. {plain-0.70.0 → plain-0.71.0}/plain/CHANGELOG.md +17 -0
  3. {plain-0.70.0 → plain-0.71.0}/plain/csrf/middleware.py +5 -5
  4. {plain-0.70.0 → plain-0.71.0}/plain/forms/forms.py +2 -2
  5. {plain-0.70.0 → plain-0.71.0}/plain/http/README.md +1 -1
  6. {plain-0.70.0 → plain-0.71.0}/plain/http/__init__.py +4 -4
  7. {plain-0.70.0 → plain-0.71.0}/plain/http/request.py +63 -39
  8. {plain-0.70.0 → plain-0.71.0}/plain/internal/files/uploadhandler.py +4 -4
  9. {plain-0.70.0 → plain-0.71.0}/plain/internal/handlers/base.py +6 -6
  10. {plain-0.70.0 → plain-0.71.0}/plain/internal/handlers/exception.py +7 -7
  11. {plain-0.70.0 → plain-0.71.0}/plain/internal/handlers/wsgi.py +3 -3
  12. {plain-0.70.0 → plain-0.71.0}/plain/internal/middleware/headers.py +3 -3
  13. {plain-0.70.0 → plain-0.71.0}/plain/internal/middleware/hosts.py +4 -4
  14. {plain-0.70.0 → plain-0.71.0}/plain/internal/middleware/https.py +4 -4
  15. {plain-0.70.0 → plain-0.71.0}/plain/internal/middleware/slash.py +5 -5
  16. {plain-0.70.0 → plain-0.71.0}/plain/logs/utils.py +2 -2
  17. {plain-0.70.0 → plain-0.71.0}/plain/runtime/__init__.py +3 -3
  18. {plain-0.70.0 → plain-0.71.0}/plain/runtime/global_settings.py +5 -8
  19. {plain-0.70.0 → plain-0.71.0}/plain/test/client.py +3 -3
  20. {plain-0.70.0 → plain-0.71.0}/plain/views/base.py +5 -7
  21. {plain-0.70.0 → plain-0.71.0}/pyproject.toml +1 -1
  22. {plain-0.70.0 → plain-0.71.0}/.gitignore +0 -0
  23. {plain-0.70.0 → plain-0.71.0}/LICENSE +0 -0
  24. {plain-0.70.0 → plain-0.71.0}/README.md +0 -0
  25. {plain-0.70.0 → plain-0.71.0}/plain/AGENTS.md +0 -0
  26. {plain-0.70.0 → plain-0.71.0}/plain/README.md +0 -0
  27. {plain-0.70.0 → plain-0.71.0}/plain/__main__.py +0 -0
  28. {plain-0.70.0 → plain-0.71.0}/plain/assets/README.md +0 -0
  29. {plain-0.70.0 → plain-0.71.0}/plain/assets/__init__.py +0 -0
  30. {plain-0.70.0 → plain-0.71.0}/plain/assets/compile.py +0 -0
  31. {plain-0.70.0 → plain-0.71.0}/plain/assets/finders.py +0 -0
  32. {plain-0.70.0 → plain-0.71.0}/plain/assets/fingerprints.py +0 -0
  33. {plain-0.70.0 → plain-0.71.0}/plain/assets/urls.py +0 -0
  34. {plain-0.70.0 → plain-0.71.0}/plain/assets/views.py +0 -0
  35. {plain-0.70.0 → plain-0.71.0}/plain/chores/README.md +0 -0
  36. {plain-0.70.0 → plain-0.71.0}/plain/chores/__init__.py +0 -0
  37. {plain-0.70.0 → plain-0.71.0}/plain/chores/registry.py +0 -0
  38. {plain-0.70.0 → plain-0.71.0}/plain/cli/README.md +0 -0
  39. {plain-0.70.0 → plain-0.71.0}/plain/cli/__init__.py +0 -0
  40. {plain-0.70.0 → plain-0.71.0}/plain/cli/agent/__init__.py +0 -0
  41. {plain-0.70.0 → plain-0.71.0}/plain/cli/agent/docs.py +0 -0
  42. {plain-0.70.0 → plain-0.71.0}/plain/cli/agent/llmdocs.py +0 -0
  43. {plain-0.70.0 → plain-0.71.0}/plain/cli/agent/md.py +0 -0
  44. {plain-0.70.0 → plain-0.71.0}/plain/cli/agent/prompt.py +0 -0
  45. {plain-0.70.0 → plain-0.71.0}/plain/cli/agent/request.py +0 -0
  46. {plain-0.70.0 → plain-0.71.0}/plain/cli/build.py +0 -0
  47. {plain-0.70.0 → plain-0.71.0}/plain/cli/changelog.py +0 -0
  48. {plain-0.70.0 → plain-0.71.0}/plain/cli/chores.py +0 -0
  49. {plain-0.70.0 → plain-0.71.0}/plain/cli/core.py +0 -0
  50. {plain-0.70.0 → plain-0.71.0}/plain/cli/docs.py +0 -0
  51. {plain-0.70.0 → plain-0.71.0}/plain/cli/formatting.py +0 -0
  52. {plain-0.70.0 → plain-0.71.0}/plain/cli/install.py +0 -0
  53. {plain-0.70.0 → plain-0.71.0}/plain/cli/output.py +0 -0
  54. {plain-0.70.0 → plain-0.71.0}/plain/cli/preflight.py +0 -0
  55. {plain-0.70.0 → plain-0.71.0}/plain/cli/print.py +0 -0
  56. {plain-0.70.0 → plain-0.71.0}/plain/cli/registry.py +0 -0
  57. {plain-0.70.0 → plain-0.71.0}/plain/cli/scaffold.py +0 -0
  58. {plain-0.70.0 → plain-0.71.0}/plain/cli/settings.py +0 -0
  59. {plain-0.70.0 → plain-0.71.0}/plain/cli/shell.py +0 -0
  60. {plain-0.70.0 → plain-0.71.0}/plain/cli/startup.py +0 -0
  61. {plain-0.70.0 → plain-0.71.0}/plain/cli/upgrade.py +0 -0
  62. {plain-0.70.0 → plain-0.71.0}/plain/cli/urls.py +0 -0
  63. {plain-0.70.0 → plain-0.71.0}/plain/cli/utils.py +0 -0
  64. {plain-0.70.0 → plain-0.71.0}/plain/csrf/README.md +0 -0
  65. {plain-0.70.0 → plain-0.71.0}/plain/csrf/views.py +0 -0
  66. {plain-0.70.0 → plain-0.71.0}/plain/debug.py +0 -0
  67. {plain-0.70.0 → plain-0.71.0}/plain/exceptions.py +0 -0
  68. {plain-0.70.0 → plain-0.71.0}/plain/forms/README.md +0 -0
  69. {plain-0.70.0 → plain-0.71.0}/plain/forms/__init__.py +0 -0
  70. {plain-0.70.0 → plain-0.71.0}/plain/forms/boundfield.py +0 -0
  71. {plain-0.70.0 → plain-0.71.0}/plain/forms/exceptions.py +0 -0
  72. {plain-0.70.0 → plain-0.71.0}/plain/forms/fields.py +0 -0
  73. {plain-0.70.0 → plain-0.71.0}/plain/http/cookie.py +0 -0
  74. {plain-0.70.0 → plain-0.71.0}/plain/http/multipartparser.py +0 -0
  75. {plain-0.70.0 → plain-0.71.0}/plain/http/response.py +0 -0
  76. {plain-0.70.0 → plain-0.71.0}/plain/internal/__init__.py +0 -0
  77. {plain-0.70.0 → plain-0.71.0}/plain/internal/files/__init__.py +0 -0
  78. {plain-0.70.0 → plain-0.71.0}/plain/internal/files/base.py +0 -0
  79. {plain-0.70.0 → plain-0.71.0}/plain/internal/files/locks.py +0 -0
  80. {plain-0.70.0 → plain-0.71.0}/plain/internal/files/move.py +0 -0
  81. {plain-0.70.0 → plain-0.71.0}/plain/internal/files/temp.py +0 -0
  82. {plain-0.70.0 → plain-0.71.0}/plain/internal/files/uploadedfile.py +0 -0
  83. {plain-0.70.0 → plain-0.71.0}/plain/internal/files/utils.py +0 -0
  84. {plain-0.70.0 → plain-0.71.0}/plain/internal/handlers/__init__.py +0 -0
  85. {plain-0.70.0 → plain-0.71.0}/plain/internal/middleware/__init__.py +0 -0
  86. {plain-0.70.0 → plain-0.71.0}/plain/json.py +0 -0
  87. {plain-0.70.0 → plain-0.71.0}/plain/logs/README.md +0 -0
  88. {plain-0.70.0 → plain-0.71.0}/plain/logs/__init__.py +0 -0
  89. {plain-0.70.0 → plain-0.71.0}/plain/logs/configure.py +0 -0
  90. {plain-0.70.0 → plain-0.71.0}/plain/logs/debug.py +0 -0
  91. {plain-0.70.0 → plain-0.71.0}/plain/logs/formatters.py +0 -0
  92. {plain-0.70.0 → plain-0.71.0}/plain/logs/loggers.py +0 -0
  93. {plain-0.70.0 → plain-0.71.0}/plain/packages/README.md +0 -0
  94. {plain-0.70.0 → plain-0.71.0}/plain/packages/__init__.py +0 -0
  95. {plain-0.70.0 → plain-0.71.0}/plain/packages/config.py +0 -0
  96. {plain-0.70.0 → plain-0.71.0}/plain/packages/registry.py +0 -0
  97. {plain-0.70.0 → plain-0.71.0}/plain/paginator.py +0 -0
  98. {plain-0.70.0 → plain-0.71.0}/plain/preflight/README.md +0 -0
  99. {plain-0.70.0 → plain-0.71.0}/plain/preflight/__init__.py +0 -0
  100. {plain-0.70.0 → plain-0.71.0}/plain/preflight/checks.py +0 -0
  101. {plain-0.70.0 → plain-0.71.0}/plain/preflight/files.py +0 -0
  102. {plain-0.70.0 → plain-0.71.0}/plain/preflight/registry.py +0 -0
  103. {plain-0.70.0 → plain-0.71.0}/plain/preflight/results.py +0 -0
  104. {plain-0.70.0 → plain-0.71.0}/plain/preflight/security.py +0 -0
  105. {plain-0.70.0 → plain-0.71.0}/plain/preflight/urls.py +0 -0
  106. {plain-0.70.0 → plain-0.71.0}/plain/runtime/README.md +0 -0
  107. {plain-0.70.0 → plain-0.71.0}/plain/runtime/user_settings.py +0 -0
  108. {plain-0.70.0 → plain-0.71.0}/plain/runtime/utils.py +0 -0
  109. {plain-0.70.0 → plain-0.71.0}/plain/signals/README.md +0 -0
  110. {plain-0.70.0 → plain-0.71.0}/plain/signals/__init__.py +0 -0
  111. {plain-0.70.0 → plain-0.71.0}/plain/signals/dispatch/__init__.py +0 -0
  112. {plain-0.70.0 → plain-0.71.0}/plain/signals/dispatch/dispatcher.py +0 -0
  113. {plain-0.70.0 → plain-0.71.0}/plain/signals/dispatch/license.txt +0 -0
  114. {plain-0.70.0 → plain-0.71.0}/plain/signing.py +0 -0
  115. {plain-0.70.0 → plain-0.71.0}/plain/templates/AGENTS.md +0 -0
  116. {plain-0.70.0 → plain-0.71.0}/plain/templates/README.md +0 -0
  117. {plain-0.70.0 → plain-0.71.0}/plain/templates/__init__.py +0 -0
  118. {plain-0.70.0 → plain-0.71.0}/plain/templates/core.py +0 -0
  119. {plain-0.70.0 → plain-0.71.0}/plain/templates/jinja/__init__.py +0 -0
  120. {plain-0.70.0 → plain-0.71.0}/plain/templates/jinja/environments.py +0 -0
  121. {plain-0.70.0 → plain-0.71.0}/plain/templates/jinja/extensions.py +0 -0
  122. {plain-0.70.0 → plain-0.71.0}/plain/templates/jinja/filters.py +0 -0
  123. {plain-0.70.0 → plain-0.71.0}/plain/templates/jinja/globals.py +0 -0
  124. {plain-0.70.0 → plain-0.71.0}/plain/test/README.md +0 -0
  125. {plain-0.70.0 → plain-0.71.0}/plain/test/__init__.py +0 -0
  126. {plain-0.70.0 → plain-0.71.0}/plain/test/encoding.py +0 -0
  127. {plain-0.70.0 → plain-0.71.0}/plain/test/exceptions.py +0 -0
  128. {plain-0.70.0 → plain-0.71.0}/plain/urls/README.md +0 -0
  129. {plain-0.70.0 → plain-0.71.0}/plain/urls/__init__.py +0 -0
  130. {plain-0.70.0 → plain-0.71.0}/plain/urls/converters.py +0 -0
  131. {plain-0.70.0 → plain-0.71.0}/plain/urls/exceptions.py +0 -0
  132. {plain-0.70.0 → plain-0.71.0}/plain/urls/patterns.py +0 -0
  133. {plain-0.70.0 → plain-0.71.0}/plain/urls/resolvers.py +0 -0
  134. {plain-0.70.0 → plain-0.71.0}/plain/urls/routers.py +0 -0
  135. {plain-0.70.0 → plain-0.71.0}/plain/urls/utils.py +0 -0
  136. {plain-0.70.0 → plain-0.71.0}/plain/utils/README.md +0 -0
  137. {plain-0.70.0 → plain-0.71.0}/plain/utils/__init__.py +0 -0
  138. {plain-0.70.0 → plain-0.71.0}/plain/utils/cache.py +0 -0
  139. {plain-0.70.0 → plain-0.71.0}/plain/utils/crypto.py +0 -0
  140. {plain-0.70.0 → plain-0.71.0}/plain/utils/datastructures.py +0 -0
  141. {plain-0.70.0 → plain-0.71.0}/plain/utils/dateparse.py +0 -0
  142. {plain-0.70.0 → plain-0.71.0}/plain/utils/deconstruct.py +0 -0
  143. {plain-0.70.0 → plain-0.71.0}/plain/utils/decorators.py +0 -0
  144. {plain-0.70.0 → plain-0.71.0}/plain/utils/duration.py +0 -0
  145. {plain-0.70.0 → plain-0.71.0}/plain/utils/encoding.py +0 -0
  146. {plain-0.70.0 → plain-0.71.0}/plain/utils/functional.py +0 -0
  147. {plain-0.70.0 → plain-0.71.0}/plain/utils/hashable.py +0 -0
  148. {plain-0.70.0 → plain-0.71.0}/plain/utils/html.py +0 -0
  149. {plain-0.70.0 → plain-0.71.0}/plain/utils/http.py +0 -0
  150. {plain-0.70.0 → plain-0.71.0}/plain/utils/inspect.py +0 -0
  151. {plain-0.70.0 → plain-0.71.0}/plain/utils/ipv6.py +0 -0
  152. {plain-0.70.0 → plain-0.71.0}/plain/utils/itercompat.py +0 -0
  153. {plain-0.70.0 → plain-0.71.0}/plain/utils/module_loading.py +0 -0
  154. {plain-0.70.0 → plain-0.71.0}/plain/utils/regex_helper.py +0 -0
  155. {plain-0.70.0 → plain-0.71.0}/plain/utils/safestring.py +0 -0
  156. {plain-0.70.0 → plain-0.71.0}/plain/utils/text.py +0 -0
  157. {plain-0.70.0 → plain-0.71.0}/plain/utils/timesince.py +0 -0
  158. {plain-0.70.0 → plain-0.71.0}/plain/utils/timezone.py +0 -0
  159. {plain-0.70.0 → plain-0.71.0}/plain/utils/tree.py +0 -0
  160. {plain-0.70.0 → plain-0.71.0}/plain/validators.py +0 -0
  161. {plain-0.70.0 → plain-0.71.0}/plain/views/README.md +0 -0
  162. {plain-0.70.0 → plain-0.71.0}/plain/views/__init__.py +0 -0
  163. {plain-0.70.0 → plain-0.71.0}/plain/views/errors.py +0 -0
  164. {plain-0.70.0 → plain-0.71.0}/plain/views/exceptions.py +0 -0
  165. {plain-0.70.0 → plain-0.71.0}/plain/views/forms.py +0 -0
  166. {plain-0.70.0 → plain-0.71.0}/plain/views/objects.py +0 -0
  167. {plain-0.70.0 → plain-0.71.0}/plain/views/redirect.py +0 -0
  168. {plain-0.70.0 → plain-0.71.0}/plain/views/templates.py +0 -0
  169. {plain-0.70.0 → plain-0.71.0}/plain/wsgi.py +0 -0
  170. {plain-0.70.0 → plain-0.71.0}/tests/.gitignore +0 -0
  171. {plain-0.70.0 → plain-0.71.0}/tests/app/.gitignore +0 -0
  172. {plain-0.70.0 → plain-0.71.0}/tests/app/settings.py +0 -0
  173. {plain-0.70.0 → plain-0.71.0}/tests/app/test/__init__.py +0 -0
  174. {plain-0.70.0 → plain-0.71.0}/tests/app/test/default_settings.py +0 -0
  175. {plain-0.70.0 → plain-0.71.0}/tests/app/urls.py +0 -0
  176. {plain-0.70.0 → plain-0.71.0}/tests/conftest.py +0 -0
  177. {plain-0.70.0 → plain-0.71.0}/tests/test_cli.py +0 -0
  178. {plain-0.70.0 → plain-0.71.0}/tests/test_csrf.py +0 -0
  179. {plain-0.70.0 → plain-0.71.0}/tests/test_http_hosts.py +0 -0
  180. {plain-0.70.0 → plain-0.71.0}/tests/test_logs.py +0 -0
  181. {plain-0.70.0 → plain-0.71.0}/tests/test_runtime.py +0 -0
  182. {plain-0.70.0 → plain-0.71.0}/tests/test_wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.70.0
3
+ Version: 0.71.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,22 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.71.0](https://github.com/dropseed/plain/releases/plain@0.71.0) (2025-09-30)
4
+
5
+ ### What's changed
6
+
7
+ - Renamed `HttpRequest` to `Request` throughout the codebase for consistency and simplicity ([cd46ff20](https://github.com/dropseed/plain/commit/cd46ff2003))
8
+ - Renamed `HttpHeaders` to `RequestHeaders` for naming consistency ([cd46ff20](https://github.com/dropseed/plain/commit/cd46ff2003))
9
+ - Renamed settings: `APP_NAME` → `NAME`, `APP_VERSION` → `VERSION`, `APP_LOG_LEVEL` → `LOG_LEVEL`, `APP_LOG_FORMAT` → `LOG_FORMAT`, `PLAIN_LOG_LEVEL` → `FRAMEWORK_LOG_LEVEL` ([4c5f2166](https://github.com/dropseed/plain/commit/4c5f2166c1))
10
+ - Added `request.get_preferred_type()` method to select the most preferred media type from Accept header ([b105ba4d](https://github.com/dropseed/plain/commit/b105ba4dd0))
11
+ - Moved helper functions in `http/request.py` to be static methods of `QueryDict` ([0e1b0133](https://github.com/dropseed/plain/commit/0e1b0133c5))
12
+
13
+ ### Upgrade instructions
14
+
15
+ - Replace all imports and usage of `HttpRequest` with `Request`
16
+ - Replace all imports and usage of `HttpHeaders` with `RequestHeaders`
17
+ - Update any custom settings that reference `APP_NAME` to `NAME`, `APP_VERSION` to `VERSION`, `APP_LOG_LEVEL` to `LOG_LEVEL`, `APP_LOG_FORMAT` to `LOG_FORMAT`, and `PLAIN_LOG_LEVEL` to `FRAMEWORK_LOG_LEVEL`
18
+ - Configuring these settings via the `PLAIN_` prefixed environment variable will need to be updated accordingly
19
+
3
20
  ## [0.70.0](https://github.com/dropseed/plain/releases/plain@0.70.0) (2025-09-30)
4
21
 
5
22
  ### What's changed
@@ -13,7 +13,7 @@ from .views import CsrfFailureView
13
13
 
14
14
  if TYPE_CHECKING:
15
15
  from plain.http import Response
16
- from plain.http.request import HttpRequest
16
+ from plain.http.request import Request
17
17
 
18
18
  logger = logging.getLogger("plain.security.csrf")
19
19
 
@@ -27,7 +27,7 @@ class CsrfViewMiddleware:
27
27
  like subdomains can have different trust levels and are rejected.
28
28
  """
29
29
 
30
- def __init__(self, get_response: Callable[[HttpRequest], Response]):
30
+ def __init__(self, get_response: Callable[[Request], Response]):
31
31
  self.get_response = get_response
32
32
 
33
33
  # Compile CSRF exempt patterns once for performance
@@ -35,7 +35,7 @@ class CsrfViewMiddleware:
35
35
  re.compile(r) for r in settings.CSRF_EXEMPT_PATHS
36
36
  ]
37
37
 
38
- def __call__(self, request: HttpRequest) -> Response:
38
+ def __call__(self, request: Request) -> Response:
39
39
  allowed, reason = self.should_allow_request(request)
40
40
 
41
41
  if allowed:
@@ -43,7 +43,7 @@ class CsrfViewMiddleware:
43
43
  else:
44
44
  return self.reject(request, reason)
45
45
 
46
- def should_allow_request(self, request: HttpRequest) -> tuple[bool, str]:
46
+ def should_allow_request(self, request: Request) -> tuple[bool, str]:
47
47
  # 1. Allow safe methods (GET, HEAD, OPTIONS)
48
48
  if request.method in ("GET", "HEAD", "OPTIONS"):
49
49
  return True, f"Safe HTTP method: {request.method}"
@@ -128,7 +128,7 @@ class CsrfViewMiddleware:
128
128
  f"Cross-origin request detected - Origin {origin} does not match Host",
129
129
  )
130
130
 
131
- def reject(self, request: HttpRequest, reason: str) -> Response:
131
+ def reject(self, request: Request, reason: str) -> Response:
132
132
  """Reject a request with a 403 Forbidden response."""
133
133
 
134
134
  response = CsrfFailureView.as_view()(request, reason=reason)
@@ -14,7 +14,7 @@ from .exceptions import ValidationError
14
14
  from .fields import Field, FileField
15
15
 
16
16
  if TYPE_CHECKING:
17
- from plain.http import HttpRequest
17
+ from plain.http import Request
18
18
 
19
19
  from .boundfield import BoundField
20
20
 
@@ -70,7 +70,7 @@ class BaseForm:
70
70
  def __init__(
71
71
  self,
72
72
  *,
73
- request: HttpRequest,
73
+ request: Request,
74
74
  auto_id: str | bool = "id_%s",
75
75
  prefix: str | None = None,
76
76
  initial: dict[str, Any] | None = None,
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## Overview
8
8
 
9
- Typically you will interact with [request](request.py#HttpRequest) and [response](response.py#ResponseBase) objects in your views and middleware.
9
+ Typically you will interact with [Request](request.py#Request) and [Response](response.py#ResponseBase) objects in your views and middleware.
10
10
 
11
11
  ```python
12
12
  from plain.views import View
@@ -1,9 +1,9 @@
1
1
  from plain.http.cookie import parse_cookie
2
2
  from plain.http.request import (
3
- HttpHeaders,
4
- HttpRequest,
5
3
  QueryDict,
6
4
  RawPostDataException,
5
+ Request,
6
+ RequestHeaders,
7
7
  UnreadablePostError,
8
8
  )
9
9
  from plain.http.response import (
@@ -26,8 +26,8 @@ from plain.http.response import (
26
26
 
27
27
  __all__ = [
28
28
  "parse_cookie",
29
- "HttpHeaders",
30
- "HttpRequest",
29
+ "Request",
30
+ "RequestHeaders",
31
31
  "QueryDict",
32
32
  "RawPostDataException",
33
33
  "UnreadablePostError",
@@ -47,7 +47,7 @@ class RawPostDataException(Exception):
47
47
  pass
48
48
 
49
49
 
50
- class HttpRequest:
50
+ class Request:
51
51
  """A basic HTTP request."""
52
52
 
53
53
  # The encoding used in GET/POST dicts. None means use default setting.
@@ -89,7 +89,7 @@ class HttpRequest:
89
89
  del obj_dict[attr]
90
90
  return obj_dict
91
91
 
92
- def __deepcopy__(self, memo: dict[int, Any]) -> HttpRequest:
92
+ def __deepcopy__(self, memo: dict[int, Any]) -> Request:
93
93
  obj = copy.copy(self)
94
94
  for attr in self.non_picklable_attrs:
95
95
  if hasattr(self, attr):
@@ -98,18 +98,41 @@ class HttpRequest:
98
98
  return obj
99
99
 
100
100
  @cached_property
101
- def headers(self) -> HttpHeaders:
102
- return HttpHeaders(self.meta)
101
+ def headers(self) -> RequestHeaders:
102
+ return RequestHeaders(self.meta)
103
103
 
104
104
  @cached_property
105
105
  def accepted_types(self) -> list[MediaType]:
106
- """Return a list of MediaType instances."""
107
- return parse_accept_header(self.headers.get("Accept", "*/*"))
106
+ """Return accepted media types sorted by quality value (highest first).
107
+
108
+ When quality values are equal, the original order from the Accept header
109
+ is preserved (as per HTTP spec).
110
+ """
111
+ header = self.headers.get("Accept", "*/*")
112
+ types = [MediaType(token) for token in header.split(",") if token.strip()]
113
+ return sorted(types, key=lambda t: t.quality, reverse=True)
114
+
115
+ def get_preferred_type(self, *media_types: str) -> str | None:
116
+ """Return the most preferred media type from the given options.
117
+
118
+ Checks the Accept header in priority order (by quality value) and returns
119
+ the first matching media type from the provided options.
120
+
121
+ Returns None if none of the options are accepted.
122
+
123
+ Example:
124
+ # Accept: text/html;q=1.0, application/json;q=0.5
125
+ request.get_preferred_type("application/json", "text/html") # Returns "text/html"
126
+ """
127
+ for accepted in self.accepted_types:
128
+ for option in media_types:
129
+ if accepted.match(option):
130
+ return option
131
+ return None
108
132
 
109
133
  def accepts(self, media_type: str) -> bool:
110
- return any(
111
- accepted_type.match(media_type) for accepted_type in self.accepted_types
112
- )
134
+ """Check if the given media type is accepted."""
135
+ return self.get_preferred_type(media_type) is not None
113
136
 
114
137
  def _set_content_type_params(self, meta: dict[str, Any]) -> None:
115
138
  """Set content_type, content_params, and encoding."""
@@ -432,7 +455,7 @@ class HttpRequest:
432
455
  return unsign_cookie_value(key, cookie_value, salt, max_age, default)
433
456
 
434
457
 
435
- class HttpHeaders(CaseInsensitiveMapping):
458
+ class RequestHeaders(CaseInsensitiveMapping):
436
459
  HTTP_PREFIX = "HTTP_"
437
460
  # PEP 333 gives two headers which aren't prepended with HTTP_.
438
461
  UNPREFIXED_HEADERS = {"CONTENT_TYPE", "CONTENT_LENGTH"}
@@ -563,8 +586,8 @@ class QueryDict(MultiValueDict):
563
586
 
564
587
  def __setitem__(self, key: str, value: Any) -> None:
565
588
  self._assert_mutable()
566
- key = bytes_to_text(key, self.encoding)
567
- value = bytes_to_text(value, self.encoding)
589
+ key = self.bytes_to_text(key, self.encoding)
590
+ value = self.bytes_to_text(value, self.encoding)
568
591
  super().__setitem__(key, value)
569
592
 
570
593
  def __delitem__(self, key: str) -> None:
@@ -586,8 +609,8 @@ class QueryDict(MultiValueDict):
586
609
 
587
610
  def setlist(self, key: str, list_: list[Any]) -> None:
588
611
  self._assert_mutable()
589
- key = bytes_to_text(key, self.encoding)
590
- list_ = [bytes_to_text(elt, self.encoding) for elt in list_]
612
+ key = self.bytes_to_text(key, self.encoding)
613
+ list_ = [self.bytes_to_text(elt, self.encoding) for elt in list_]
591
614
  super().setlist(key, list_)
592
615
 
593
616
  def setlistdefault(
@@ -598,8 +621,8 @@ class QueryDict(MultiValueDict):
598
621
 
599
622
  def appendlist(self, key: str, value: Any) -> None:
600
623
  self._assert_mutable()
601
- key = bytes_to_text(key, self.encoding)
602
- value = bytes_to_text(value, self.encoding)
624
+ key = self.bytes_to_text(key, self.encoding)
625
+ value = self.bytes_to_text(value, self.encoding)
603
626
  super().appendlist(key, value)
604
627
 
605
628
  def pop(self, key: str, *args: Any) -> Any:
@@ -616,8 +639,8 @@ class QueryDict(MultiValueDict):
616
639
 
617
640
  def setdefault(self, key: str, default: Any = None) -> Any:
618
641
  self._assert_mutable()
619
- key = bytes_to_text(key, self.encoding)
620
- default = bytes_to_text(default, self.encoding)
642
+ key = self.bytes_to_text(key, self.encoding)
643
+ default = self.bytes_to_text(default, self.encoding)
621
644
  return super().setdefault(key, default)
622
645
 
623
646
  def copy(self) -> QueryDict:
@@ -656,6 +679,23 @@ class QueryDict(MultiValueDict):
656
679
  )
657
680
  return "&".join(output)
658
681
 
682
+ # It's neither necessary nor appropriate to use
683
+ # plain.utils.encoding.force_str() for parsing URLs and form inputs. Thus,
684
+ # this slightly more restricted function, used by QueryDict.
685
+ @staticmethod
686
+ def bytes_to_text(s: Any, encoding: str) -> str:
687
+ """
688
+ Convert bytes objects to strings, using the given encoding. Illegally
689
+ encoded input characters are replaced with Unicode "unknown" codepoint
690
+ (\ufffd).
691
+
692
+ Return any non-bytes objects without change.
693
+ """
694
+ if isinstance(s, bytes):
695
+ return str(s, encoding, "replace")
696
+ else:
697
+ return s
698
+
659
699
 
660
700
  class MediaType:
661
701
  def __init__(self, media_type_raw_line: str | MediaType):
@@ -678,6 +718,11 @@ class MediaType:
678
718
  def is_all_types(self) -> bool:
679
719
  return self.main_type == "*" and self.sub_type == "*"
680
720
 
721
+ @property
722
+ def quality(self) -> float:
723
+ """Return the quality value from the Accept header (default 1.0)."""
724
+ return float(self.params.get("q", 1.0))
725
+
681
726
  def match(self, other: str | MediaType) -> bool:
682
727
  if self.is_all_types:
683
728
  return True
@@ -685,24 +730,3 @@ class MediaType:
685
730
  if self.main_type == other.main_type and self.sub_type in {"*", other.sub_type}:
686
731
  return True
687
732
  return False
688
-
689
-
690
- # It's neither necessary nor appropriate to use
691
- # plain.utils.encoding.force_str() for parsing URLs and form inputs. Thus,
692
- # this slightly more restricted function, used by QueryDict.
693
- def bytes_to_text(s: Any, encoding: str) -> str:
694
- """
695
- Convert bytes objects to strings, using the given encoding. Illegally
696
- encoded input characters are replaced with Unicode "unknown" codepoint
697
- (\ufffd).
698
-
699
- Return any non-bytes objects without change.
700
- """
701
- if isinstance(s, bytes):
702
- return str(s, encoding, "replace")
703
- else:
704
- return s
705
-
706
-
707
- def parse_accept_header(header: str) -> list[MediaType]:
708
- return [MediaType(token) for token in header.split(",") if token.strip()]
@@ -19,7 +19,7 @@ from plain.utils.module_loading import import_string
19
19
  if TYPE_CHECKING:
20
20
  from typing import Any
21
21
 
22
- from plain.http import HttpRequest
22
+ from plain.http import Request
23
23
 
24
24
  __all__ = [
25
25
  "UploadFileException",
@@ -85,7 +85,7 @@ class FileUploadHandler:
85
85
 
86
86
  chunk_size = 64 * 2**10 # : The default chunk size is 64 KB.
87
87
 
88
- def __init__(self, request: HttpRequest | None = None) -> None:
88
+ def __init__(self, request: Request | None = None) -> None:
89
89
  self.file_name = None
90
90
  self.content_type = None
91
91
  self.content_length = None
@@ -265,8 +265,8 @@ def load_handler(path: str, *args: Any, **kwargs: Any) -> FileUploadHandler:
265
265
  Given a path to a handler, return an instance of that handler.
266
266
 
267
267
  E.g.::
268
- >>> from plain.http import HttpRequest
269
- >>> request = HttpRequest()
268
+ >>> from plain.http import Request
269
+ >>> request = Request()
270
270
  >>> load_handler(
271
271
  ... 'plain.internal.files.uploadhandler.TemporaryFileUploadHandler',
272
272
  ... request,
@@ -18,7 +18,7 @@ from .exception import convert_exception_to_response
18
18
  if TYPE_CHECKING:
19
19
  from collections.abc import Callable
20
20
 
21
- from plain.http import HttpRequest, Response
21
+ from plain.http import Request, Response
22
22
  from plain.urls import ResolverMatch
23
23
 
24
24
  logger = logging.getLogger("plain.request")
@@ -43,7 +43,7 @@ tracer = trace.get_tracer("plain")
43
43
 
44
44
 
45
45
  class BaseHandler:
46
- _middleware_chain: Callable[[HttpRequest], Response] | None = None
46
+ _middleware_chain: Callable[[Request], Response] | None = None
47
47
 
48
48
  def load_middleware(self) -> None:
49
49
  """
@@ -72,8 +72,8 @@ class BaseHandler:
72
72
  # as a flag for initialization being complete.
73
73
  self._middleware_chain = handler
74
74
 
75
- def get_response(self, request: HttpRequest) -> Response:
76
- """Return a Response object for the given HttpRequest."""
75
+ def get_response(self, request: Request) -> Response:
76
+ """Return a Response object for the given Request."""
77
77
 
78
78
  span_attributes = {
79
79
  "plain.request.id": request.unique_id,
@@ -124,7 +124,7 @@ class BaseHandler:
124
124
  )
125
125
  return response
126
126
 
127
- def _get_response(self, request: HttpRequest) -> Response:
127
+ def _get_response(self, request: Request) -> Response:
128
128
  """
129
129
  Resolve and call the view, then apply view, exception, and
130
130
  template_response middleware. This method is everything that happens
@@ -141,7 +141,7 @@ class BaseHandler:
141
141
 
142
142
  return response
143
143
 
144
- def resolve_request(self, request: HttpRequest) -> ResolverMatch:
144
+ def resolve_request(self, request: Request) -> ResolverMatch:
145
145
  """
146
146
  Retrieve/set the urlrouter for the request. Return the view resolved,
147
147
  with its args and kwargs.
@@ -23,12 +23,12 @@ from plain.views.errors import ErrorView
23
23
  if TYPE_CHECKING:
24
24
  from collections.abc import Callable
25
25
 
26
- from plain.http import HttpRequest, Response
26
+ from plain.http import Request, Response
27
27
 
28
28
 
29
29
  def convert_exception_to_response(
30
- get_response: Callable[[HttpRequest], Response],
31
- ) -> Callable[[HttpRequest], Response]:
30
+ get_response: Callable[[Request], Response],
31
+ ) -> Callable[[Request], Response]:
32
32
  """
33
33
  Wrap the given get_response callable in exception-to-response conversion.
34
34
 
@@ -43,7 +43,7 @@ def convert_exception_to_response(
43
43
  """
44
44
 
45
45
  @wraps(get_response)
46
- def inner(request: HttpRequest) -> Response:
46
+ def inner(request: Request) -> Response:
47
47
  try:
48
48
  response = get_response(request)
49
49
  except Exception as exc:
@@ -53,7 +53,7 @@ def convert_exception_to_response(
53
53
  return inner
54
54
 
55
55
 
56
- def response_for_exception(request: HttpRequest, exc: Exception) -> Response:
56
+ def response_for_exception(request: Request, exc: Exception) -> Response:
57
57
  if isinstance(exc, Http404):
58
58
  response = get_exception_response(
59
59
  request=request, status_code=404, exception=None
@@ -131,7 +131,7 @@ def response_for_exception(request: HttpRequest, exc: Exception) -> Response:
131
131
 
132
132
 
133
133
  def get_exception_response(
134
- *, request: HttpRequest, status_code: int, exception: Exception | None
134
+ *, request: Request, status_code: int, exception: Exception | None
135
135
  ) -> Response:
136
136
  try:
137
137
  view_class = get_error_view(status_code=status_code, exception=exception)
@@ -149,7 +149,7 @@ def get_exception_response(
149
149
 
150
150
  def get_error_view(
151
151
  *, status_code: int, exception: Exception | None
152
- ) -> Callable[[HttpRequest], Response]:
152
+ ) -> Callable[[Request], Response]:
153
153
  views_by_status = settings.HTTP_ERROR_VIEWS
154
154
  if status_code in views_by_status:
155
155
  view = views_by_status[status_code]
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
7
7
  from urllib.parse import quote
8
8
 
9
9
  from plain import signals
10
- from plain.http import HttpRequest, QueryDict, parse_cookie
10
+ from plain.http import QueryDict, Request, parse_cookie
11
11
  from plain.internal.handlers import base
12
12
  from plain.utils.datastructures import MultiValueDict
13
13
  from plain.utils.regex_helper import _lazy_re_compile
@@ -60,8 +60,8 @@ class LimitedStream(IOBase):
60
60
  return line
61
61
 
62
62
 
63
- class WSGIRequest(HttpRequest):
64
- non_picklable_attrs = HttpRequest.non_picklable_attrs | frozenset(["environ"])
63
+ class WSGIRequest(Request):
64
+ non_picklable_attrs = Request.non_picklable_attrs | frozenset(["environ"])
65
65
  meta_non_picklable_attrs = frozenset(["wsgi.errors", "wsgi.input"])
66
66
 
67
67
  def __init__(self, environ: dict[str, Any]) -> None:
@@ -7,14 +7,14 @@ from plain.runtime import settings
7
7
  if TYPE_CHECKING:
8
8
  from collections.abc import Callable
9
9
 
10
- from plain.http import HttpRequest, Response
10
+ from plain.http import Request, Response
11
11
 
12
12
 
13
13
  class DefaultHeadersMiddleware:
14
- def __init__(self, get_response: Callable[[HttpRequest], Response]) -> None:
14
+ def __init__(self, get_response: Callable[[Request], Response]) -> None:
15
15
  self.get_response = get_response
16
16
 
17
- def __call__(self, request: HttpRequest) -> Response:
17
+ def __call__(self, request: Request) -> Response:
18
18
  response = self.get_response(request)
19
19
 
20
20
  for header, value in settings.DEFAULT_RESPONSE_HEADERS.items():
@@ -4,7 +4,7 @@ import ipaddress
4
4
  import logging
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from plain.http import HttpRequest, ResponseBadRequest
7
+ from plain.http import Request, ResponseBadRequest
8
8
  from plain.runtime import settings
9
9
  from plain.utils.regex_helper import _lazy_re_compile
10
10
 
@@ -29,10 +29,10 @@ class HostValidationMiddleware:
29
29
  host is not allowed.
30
30
  """
31
31
 
32
- def __init__(self, get_response: Callable[[HttpRequest], Response]) -> None:
32
+ def __init__(self, get_response: Callable[[Request], Response]) -> None:
33
33
  self.get_response = get_response
34
34
 
35
- def __call__(self, request: HttpRequest) -> Response:
35
+ def __call__(self, request: Request) -> Response:
36
36
  if not is_host_valid(request):
37
37
  host = request.host
38
38
  msg = f"Invalid HTTP_HOST header: {host!r}."
@@ -55,7 +55,7 @@ class HostValidationMiddleware:
55
55
  return self.get_response(request)
56
56
 
57
57
 
58
- def is_host_valid(request: HttpRequest) -> bool:
58
+ def is_host_valid(request: Request) -> bool:
59
59
  """
60
60
  Check if the host is valid according to ALLOWED_HOSTS settings.
61
61
  """
@@ -8,17 +8,17 @@ from plain.runtime import settings
8
8
  if TYPE_CHECKING:
9
9
  from collections.abc import Callable
10
10
 
11
- from plain.http import HttpRequest, Response
11
+ from plain.http import Request, Response
12
12
 
13
13
 
14
14
  class HttpsRedirectMiddleware:
15
- def __init__(self, get_response: Callable[[HttpRequest], Response]) -> None:
15
+ def __init__(self, get_response: Callable[[Request], Response]) -> None:
16
16
  self.get_response = get_response
17
17
 
18
18
  # Settings for HTTPS
19
19
  self.https_redirect_enabled = settings.HTTPS_REDIRECT_ENABLED
20
20
 
21
- def __call__(self, request: HttpRequest) -> Response:
21
+ def __call__(self, request: Request) -> Response:
22
22
  """
23
23
  Perform a blanket HTTP→HTTPS redirect when enabled.
24
24
  """
@@ -28,7 +28,7 @@ class HttpsRedirectMiddleware:
28
28
 
29
29
  return self.get_response(request)
30
30
 
31
- def maybe_https_redirect(self, request: HttpRequest) -> Response | None:
31
+ def maybe_https_redirect(self, request: Request) -> Response | None:
32
32
  if self.https_redirect_enabled and not request.is_https():
33
33
  return ResponseRedirect(
34
34
  f"https://{request.host}{request.get_full_path()}", status_code=301
@@ -10,15 +10,15 @@ from plain.utils.http import escape_leading_slashes
10
10
  if TYPE_CHECKING:
11
11
  from collections.abc import Callable
12
12
 
13
- from plain.http import HttpRequest, Response
13
+ from plain.http import Request, Response
14
14
  from plain.urls import ResolverMatch
15
15
 
16
16
 
17
17
  class RedirectSlashMiddleware:
18
- def __init__(self, get_response: Callable[[HttpRequest], Response]) -> None:
18
+ def __init__(self, get_response: Callable[[Request], Response]) -> None:
19
19
  self.get_response = get_response
20
20
 
21
- def __call__(self, request: HttpRequest) -> Response:
21
+ def __call__(self, request: Request) -> Response:
22
22
  """
23
23
  Rewrite the URL based on settings.APPEND_SLASH
24
24
  """
@@ -50,7 +50,7 @@ class RedirectSlashMiddleware:
50
50
  except Resolver404:
51
51
  return False
52
52
 
53
- def should_redirect_with_slash(self, request: HttpRequest) -> ResolverMatch | bool:
53
+ def should_redirect_with_slash(self, request: Request) -> ResolverMatch | bool:
54
54
  """
55
55
  Return True if settings.APPEND_SLASH is True and appending a slash to
56
56
  the request path turns an invalid path into a valid one.
@@ -60,7 +60,7 @@ class RedirectSlashMiddleware:
60
60
  return self._is_valid_path(f"{request.path_info}/")
61
61
  return False
62
62
 
63
- def get_full_path_with_slash(self, request: HttpRequest) -> str:
63
+ def get_full_path_with_slash(self, request: Request) -> str:
64
64
  """
65
65
  Return the full path of the request with a trailing slash appended.
66
66
 
@@ -4,7 +4,7 @@ import logging
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
6
  if TYPE_CHECKING:
7
- from plain.http.request import HttpRequest
7
+ from plain.http.request import Request
8
8
  from plain.http.response import ResponseBase
9
9
 
10
10
  request_logger = logging.getLogger("plain.request")
@@ -14,7 +14,7 @@ def log_response(
14
14
  message: str,
15
15
  *args: Any,
16
16
  response: ResponseBase | None = None,
17
- request: HttpRequest | None = None,
17
+ request: Request | None = None,
18
18
  logger: logging.Logger = request_logger,
19
19
  level: str | None = None,
20
20
  exception: BaseException | None = None,
@@ -64,9 +64,9 @@ def setup() -> None:
64
64
  sys.path.insert(0, APP_PATH.parent.as_posix())
65
65
 
66
66
  configure_logging(
67
- plain_log_level=settings.PLAIN_LOG_LEVEL,
68
- app_log_level=settings.APP_LOG_LEVEL,
69
- app_log_format=settings.APP_LOG_FORMAT,
67
+ plain_log_level=settings.FRAMEWORK_LOG_LEVEL,
68
+ app_log_level=settings.LOG_LEVEL,
69
+ app_log_format=settings.LOG_FORMAT,
70
70
  )
71
71
 
72
72
  packages_registry.populate(settings.INSTALLED_PACKAGES)
@@ -3,8 +3,6 @@ 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 os import environ
7
-
8
6
  from .utils import get_app_info_from_pyproject
9
7
 
10
8
  # MARK: Core Settings
@@ -12,8 +10,8 @@ from .utils import get_app_info_from_pyproject
12
10
  DEBUG: bool = False
13
11
 
14
12
  name, version = get_app_info_from_pyproject()
15
- APP_NAME: str = name
16
- APP_VERSION: str = version
13
+ NAME: str = name
14
+ VERSION: str = version
17
15
 
18
16
  # List of strings representing installed packages.
19
17
  INSTALLED_PACKAGES: list[str] = []
@@ -135,11 +133,10 @@ CSRF_TRUSTED_ORIGINS: list[str] = []
135
133
  CSRF_EXEMPT_PATHS: list[str] = []
136
134
 
137
135
  # MARK: Logging
138
- # (Uses some custom env names in addition to PLAIN_ prefixed )
139
136
 
140
- PLAIN_LOG_LEVEL: str = environ.get("PLAIN_LOG_LEVEL", "INFO")
141
- APP_LOG_LEVEL: str = environ.get("APP_LOG_LEVEL", "INFO")
142
- APP_LOG_FORMAT: str = environ.get("APP_LOG_FORMAT", "keyvalue")
137
+ FRAMEWORK_LOG_LEVEL: str = "INFO"
138
+ LOG_LEVEL: str = "INFO"
139
+ LOG_FORMAT: str = "keyvalue"
143
140
 
144
141
  # MARK: Assets
145
142
 
@@ -9,7 +9,7 @@ from io import BytesIO, IOBase
9
9
  from typing import TYPE_CHECKING, Any
10
10
  from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit
11
11
 
12
- from plain.http import HttpHeaders, QueryDict
12
+ from plain.http import QueryDict, RequestHeaders
13
13
  from plain.internal import internalcode
14
14
  from plain.internal.handlers.base import BaseHandler
15
15
  from plain.internal.handlers.wsgi import WSGIRequest
@@ -174,7 +174,7 @@ class RequestFactory:
174
174
  self.cookies: SimpleCookie[str] = SimpleCookie()
175
175
  self.errors = BytesIO()
176
176
  if headers:
177
- self.defaults.update(HttpHeaders.to_wsgi_names(headers))
177
+ self.defaults.update(RequestHeaders.to_wsgi_names(headers))
178
178
 
179
179
  def _base_environ(self, **request: Any) -> dict[str, Any]:
180
180
  """
@@ -417,7 +417,7 @@ class RequestFactory:
417
417
  }
418
418
  )
419
419
  if headers:
420
- extra.update(HttpHeaders.to_wsgi_names(headers))
420
+ extra.update(RequestHeaders.to_wsgi_names(headers))
421
421
  r.update(extra)
422
422
  # If QUERY_STRING is absent or empty, we want to extract it from the URL.
423
423
  if not r.get("QUERY_STRING"):