plain 0.78.2__tar.gz → 0.79.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.

Potentially problematic release.


This version of plain might be problematic. Click here for more details.

Files changed (210) hide show
  1. {plain-0.78.2 → plain-0.79.0}/.gitignore +1 -1
  2. {plain-0.78.2 → plain-0.79.0}/PKG-INFO +1 -1
  3. {plain-0.78.2 → plain-0.79.0}/plain/CHANGELOG.md +39 -0
  4. {plain-0.78.2 → plain-0.79.0}/plain/cli/changelog.py +2 -0
  5. {plain-0.78.2 → plain-0.79.0}/plain/cli/upgrade.py +3 -1
  6. {plain-0.78.2 → plain-0.79.0}/plain/csrf/middleware.py +10 -29
  7. {plain-0.78.2 → plain-0.79.0}/plain/http/__init__.py +5 -3
  8. plain-0.79.0/plain/http/middleware.py +32 -0
  9. {plain-0.78.2 → plain-0.79.0}/plain/http/response.py +2 -0
  10. {plain-0.78.2 → plain-0.79.0}/plain/internal/handlers/base.py +4 -13
  11. {plain-0.78.2 → plain-0.79.0}/plain/internal/handlers/exception.py +26 -42
  12. {plain-0.78.2 → plain-0.79.0}/plain/internal/middleware/headers.py +3 -7
  13. {plain-0.78.2 → plain-0.79.0}/plain/internal/middleware/hosts.py +3 -8
  14. {plain-0.78.2 → plain-0.79.0}/plain/internal/middleware/https.py +5 -5
  15. {plain-0.78.2 → plain-0.79.0}/plain/internal/middleware/slash.py +3 -8
  16. {plain-0.78.2 → plain-0.79.0}/plain/runtime/global_settings.py +0 -3
  17. {plain-0.78.2 → plain-0.79.0}/plain/signals/__init__.py +0 -1
  18. {plain-0.78.2 → plain-0.79.0}/plain/test/client.py +7 -28
  19. {plain-0.78.2 → plain-0.79.0}/plain/views/README.md +7 -18
  20. {plain-0.78.2 → plain-0.79.0}/pyproject.toml +1 -7
  21. {plain-0.78.2 → plain-0.79.0}/tests/test_csrf.py +13 -15
  22. plain-0.78.2/plain/csrf/views.py +0 -34
  23. plain-0.78.2/plain/logs/utils.py +0 -56
  24. {plain-0.78.2 → plain-0.79.0}/LICENSE +0 -0
  25. {plain-0.78.2 → plain-0.79.0}/README.md +0 -0
  26. {plain-0.78.2 → plain-0.79.0}/plain/AGENTS.md +0 -0
  27. {plain-0.78.2 → plain-0.79.0}/plain/README.md +0 -0
  28. {plain-0.78.2 → plain-0.79.0}/plain/__main__.py +0 -0
  29. {plain-0.78.2 → plain-0.79.0}/plain/assets/README.md +0 -0
  30. {plain-0.78.2 → plain-0.79.0}/plain/assets/__init__.py +0 -0
  31. {plain-0.78.2 → plain-0.79.0}/plain/assets/compile.py +0 -0
  32. {plain-0.78.2 → plain-0.79.0}/plain/assets/finders.py +0 -0
  33. {plain-0.78.2 → plain-0.79.0}/plain/assets/fingerprints.py +0 -0
  34. {plain-0.78.2 → plain-0.79.0}/plain/assets/urls.py +0 -0
  35. {plain-0.78.2 → plain-0.79.0}/plain/assets/views.py +0 -0
  36. {plain-0.78.2 → plain-0.79.0}/plain/chores/README.md +0 -0
  37. {plain-0.78.2 → plain-0.79.0}/plain/chores/__init__.py +0 -0
  38. {plain-0.78.2 → plain-0.79.0}/plain/chores/core.py +0 -0
  39. {plain-0.78.2 → plain-0.79.0}/plain/chores/registry.py +0 -0
  40. {plain-0.78.2 → plain-0.79.0}/plain/cli/README.md +0 -0
  41. {plain-0.78.2 → plain-0.79.0}/plain/cli/__init__.py +0 -0
  42. {plain-0.78.2 → plain-0.79.0}/plain/cli/agent/__init__.py +0 -0
  43. {plain-0.78.2 → plain-0.79.0}/plain/cli/agent/docs.py +0 -0
  44. {plain-0.78.2 → plain-0.79.0}/plain/cli/agent/llmdocs.py +0 -0
  45. {plain-0.78.2 → plain-0.79.0}/plain/cli/agent/md.py +0 -0
  46. {plain-0.78.2 → plain-0.79.0}/plain/cli/agent/prompt.py +0 -0
  47. {plain-0.78.2 → plain-0.79.0}/plain/cli/agent/request.py +0 -0
  48. {plain-0.78.2 → plain-0.79.0}/plain/cli/build.py +0 -0
  49. {plain-0.78.2 → plain-0.79.0}/plain/cli/chores.py +0 -0
  50. {plain-0.78.2 → plain-0.79.0}/plain/cli/core.py +0 -0
  51. {plain-0.78.2 → plain-0.79.0}/plain/cli/docs.py +0 -0
  52. {plain-0.78.2 → plain-0.79.0}/plain/cli/formatting.py +0 -0
  53. {plain-0.78.2 → plain-0.79.0}/plain/cli/install.py +0 -0
  54. {plain-0.78.2 → plain-0.79.0}/plain/cli/output.py +0 -0
  55. {plain-0.78.2 → plain-0.79.0}/plain/cli/preflight.py +0 -0
  56. {plain-0.78.2 → plain-0.79.0}/plain/cli/print.py +0 -0
  57. {plain-0.78.2 → plain-0.79.0}/plain/cli/registry.py +0 -0
  58. {plain-0.78.2 → plain-0.79.0}/plain/cli/runtime.py +0 -0
  59. {plain-0.78.2 → plain-0.79.0}/plain/cli/scaffold.py +0 -0
  60. {plain-0.78.2 → plain-0.79.0}/plain/cli/server.py +0 -0
  61. {plain-0.78.2 → plain-0.79.0}/plain/cli/settings.py +0 -0
  62. {plain-0.78.2 → plain-0.79.0}/plain/cli/shell.py +0 -0
  63. {plain-0.78.2 → plain-0.79.0}/plain/cli/startup.py +0 -0
  64. {plain-0.78.2 → plain-0.79.0}/plain/cli/urls.py +0 -0
  65. {plain-0.78.2 → plain-0.79.0}/plain/cli/utils.py +0 -0
  66. {plain-0.78.2 → plain-0.79.0}/plain/csrf/README.md +0 -0
  67. {plain-0.78.2 → plain-0.79.0}/plain/debug.py +0 -0
  68. {plain-0.78.2 → plain-0.79.0}/plain/exceptions.py +0 -0
  69. {plain-0.78.2 → plain-0.79.0}/plain/forms/README.md +0 -0
  70. {plain-0.78.2 → plain-0.79.0}/plain/forms/__init__.py +0 -0
  71. {plain-0.78.2 → plain-0.79.0}/plain/forms/boundfield.py +0 -0
  72. {plain-0.78.2 → plain-0.79.0}/plain/forms/exceptions.py +0 -0
  73. {plain-0.78.2 → plain-0.79.0}/plain/forms/fields.py +0 -0
  74. {plain-0.78.2 → plain-0.79.0}/plain/forms/forms.py +0 -0
  75. {plain-0.78.2 → plain-0.79.0}/plain/http/README.md +0 -0
  76. {plain-0.78.2 → plain-0.79.0}/plain/http/cookie.py +0 -0
  77. {plain-0.78.2 → plain-0.79.0}/plain/http/multipartparser.py +0 -0
  78. {plain-0.78.2 → plain-0.79.0}/plain/http/request.py +0 -0
  79. {plain-0.78.2 → plain-0.79.0}/plain/internal/__init__.py +0 -0
  80. {plain-0.78.2 → plain-0.79.0}/plain/internal/files/__init__.py +0 -0
  81. {plain-0.78.2 → plain-0.79.0}/plain/internal/files/base.py +0 -0
  82. {plain-0.78.2 → plain-0.79.0}/plain/internal/files/locks.py +0 -0
  83. {plain-0.78.2 → plain-0.79.0}/plain/internal/files/move.py +0 -0
  84. {plain-0.78.2 → plain-0.79.0}/plain/internal/files/temp.py +0 -0
  85. {plain-0.78.2 → plain-0.79.0}/plain/internal/files/uploadedfile.py +0 -0
  86. {plain-0.78.2 → plain-0.79.0}/plain/internal/files/uploadhandler.py +0 -0
  87. {plain-0.78.2 → plain-0.79.0}/plain/internal/files/utils.py +0 -0
  88. {plain-0.78.2 → plain-0.79.0}/plain/internal/handlers/__init__.py +0 -0
  89. {plain-0.78.2 → plain-0.79.0}/plain/internal/handlers/wsgi.py +0 -0
  90. {plain-0.78.2 → plain-0.79.0}/plain/internal/middleware/__init__.py +0 -0
  91. {plain-0.78.2 → plain-0.79.0}/plain/internal/reloader.py +0 -0
  92. {plain-0.78.2 → plain-0.79.0}/plain/json.py +0 -0
  93. {plain-0.78.2 → plain-0.79.0}/plain/logs/README.md +0 -0
  94. {plain-0.78.2 → plain-0.79.0}/plain/logs/__init__.py +0 -0
  95. {plain-0.78.2 → plain-0.79.0}/plain/logs/configure.py +0 -0
  96. {plain-0.78.2 → plain-0.79.0}/plain/logs/debug.py +0 -0
  97. {plain-0.78.2 → plain-0.79.0}/plain/logs/formatters.py +0 -0
  98. {plain-0.78.2 → plain-0.79.0}/plain/logs/loggers.py +0 -0
  99. {plain-0.78.2 → plain-0.79.0}/plain/packages/README.md +0 -0
  100. {plain-0.78.2 → plain-0.79.0}/plain/packages/__init__.py +0 -0
  101. {plain-0.78.2 → plain-0.79.0}/plain/packages/config.py +0 -0
  102. {plain-0.78.2 → plain-0.79.0}/plain/packages/registry.py +0 -0
  103. {plain-0.78.2 → plain-0.79.0}/plain/paginator.py +0 -0
  104. {plain-0.78.2 → plain-0.79.0}/plain/preflight/README.md +0 -0
  105. {plain-0.78.2 → plain-0.79.0}/plain/preflight/__init__.py +0 -0
  106. {plain-0.78.2 → plain-0.79.0}/plain/preflight/checks.py +0 -0
  107. {plain-0.78.2 → plain-0.79.0}/plain/preflight/files.py +0 -0
  108. {plain-0.78.2 → plain-0.79.0}/plain/preflight/registry.py +0 -0
  109. {plain-0.78.2 → plain-0.79.0}/plain/preflight/results.py +0 -0
  110. {plain-0.78.2 → plain-0.79.0}/plain/preflight/security.py +0 -0
  111. {plain-0.78.2 → plain-0.79.0}/plain/preflight/urls.py +0 -0
  112. {plain-0.78.2 → plain-0.79.0}/plain/runtime/README.md +0 -0
  113. {plain-0.78.2 → plain-0.79.0}/plain/runtime/__init__.py +0 -0
  114. {plain-0.78.2 → plain-0.79.0}/plain/runtime/user_settings.py +0 -0
  115. {plain-0.78.2 → plain-0.79.0}/plain/runtime/utils.py +0 -0
  116. {plain-0.78.2 → plain-0.79.0}/plain/server/LICENSE +0 -0
  117. {plain-0.78.2 → plain-0.79.0}/plain/server/README.md +0 -0
  118. {plain-0.78.2 → plain-0.79.0}/plain/server/__init__.py +0 -0
  119. {plain-0.78.2 → plain-0.79.0}/plain/server/app.py +0 -0
  120. {plain-0.78.2 → plain-0.79.0}/plain/server/arbiter.py +0 -0
  121. {plain-0.78.2 → plain-0.79.0}/plain/server/config.py +0 -0
  122. {plain-0.78.2 → plain-0.79.0}/plain/server/errors.py +0 -0
  123. {plain-0.78.2 → plain-0.79.0}/plain/server/glogging.py +0 -0
  124. {plain-0.78.2 → plain-0.79.0}/plain/server/http/__init__.py +0 -0
  125. {plain-0.78.2 → plain-0.79.0}/plain/server/http/body.py +0 -0
  126. {plain-0.78.2 → plain-0.79.0}/plain/server/http/errors.py +0 -0
  127. {plain-0.78.2 → plain-0.79.0}/plain/server/http/message.py +0 -0
  128. {plain-0.78.2 → plain-0.79.0}/plain/server/http/parser.py +0 -0
  129. {plain-0.78.2 → plain-0.79.0}/plain/server/http/unreader.py +0 -0
  130. {plain-0.78.2 → plain-0.79.0}/plain/server/http/wsgi.py +0 -0
  131. {plain-0.78.2 → plain-0.79.0}/plain/server/pidfile.py +0 -0
  132. {plain-0.78.2 → plain-0.79.0}/plain/server/sock.py +0 -0
  133. {plain-0.78.2 → plain-0.79.0}/plain/server/util.py +0 -0
  134. {plain-0.78.2 → plain-0.79.0}/plain/server/workers/__init__.py +0 -0
  135. {plain-0.78.2 → plain-0.79.0}/plain/server/workers/base.py +0 -0
  136. {plain-0.78.2 → plain-0.79.0}/plain/server/workers/sync.py +0 -0
  137. {plain-0.78.2 → plain-0.79.0}/plain/server/workers/thread.py +0 -0
  138. {plain-0.78.2 → plain-0.79.0}/plain/server/workers/workertmp.py +0 -0
  139. {plain-0.78.2 → plain-0.79.0}/plain/signals/README.md +0 -0
  140. {plain-0.78.2 → plain-0.79.0}/plain/signals/dispatch/__init__.py +0 -0
  141. {plain-0.78.2 → plain-0.79.0}/plain/signals/dispatch/dispatcher.py +0 -0
  142. {plain-0.78.2 → plain-0.79.0}/plain/signals/dispatch/license.txt +0 -0
  143. {plain-0.78.2 → plain-0.79.0}/plain/signing.py +0 -0
  144. {plain-0.78.2 → plain-0.79.0}/plain/templates/AGENTS.md +0 -0
  145. {plain-0.78.2 → plain-0.79.0}/plain/templates/README.md +0 -0
  146. {plain-0.78.2 → plain-0.79.0}/plain/templates/__init__.py +0 -0
  147. {plain-0.78.2 → plain-0.79.0}/plain/templates/core.py +0 -0
  148. {plain-0.78.2 → plain-0.79.0}/plain/templates/jinja/__init__.py +0 -0
  149. {plain-0.78.2 → plain-0.79.0}/plain/templates/jinja/environments.py +0 -0
  150. {plain-0.78.2 → plain-0.79.0}/plain/templates/jinja/extensions.py +0 -0
  151. {plain-0.78.2 → plain-0.79.0}/plain/templates/jinja/filters.py +0 -0
  152. {plain-0.78.2 → plain-0.79.0}/plain/templates/jinja/globals.py +0 -0
  153. {plain-0.78.2 → plain-0.79.0}/plain/test/README.md +0 -0
  154. {plain-0.78.2 → plain-0.79.0}/plain/test/__init__.py +0 -0
  155. {plain-0.78.2 → plain-0.79.0}/plain/test/encoding.py +0 -0
  156. {plain-0.78.2 → plain-0.79.0}/plain/test/exceptions.py +0 -0
  157. {plain-0.78.2 → plain-0.79.0}/plain/urls/README.md +0 -0
  158. {plain-0.78.2 → plain-0.79.0}/plain/urls/__init__.py +0 -0
  159. {plain-0.78.2 → plain-0.79.0}/plain/urls/converters.py +0 -0
  160. {plain-0.78.2 → plain-0.79.0}/plain/urls/exceptions.py +0 -0
  161. {plain-0.78.2 → plain-0.79.0}/plain/urls/patterns.py +0 -0
  162. {plain-0.78.2 → plain-0.79.0}/plain/urls/resolvers.py +0 -0
  163. {plain-0.78.2 → plain-0.79.0}/plain/urls/routers.py +0 -0
  164. {plain-0.78.2 → plain-0.79.0}/plain/urls/utils.py +0 -0
  165. {plain-0.78.2 → plain-0.79.0}/plain/utils/README.md +0 -0
  166. {plain-0.78.2 → plain-0.79.0}/plain/utils/__init__.py +0 -0
  167. {plain-0.78.2 → plain-0.79.0}/plain/utils/cache.py +0 -0
  168. {plain-0.78.2 → plain-0.79.0}/plain/utils/crypto.py +0 -0
  169. {plain-0.78.2 → plain-0.79.0}/plain/utils/datastructures.py +0 -0
  170. {plain-0.78.2 → plain-0.79.0}/plain/utils/dateparse.py +0 -0
  171. {plain-0.78.2 → plain-0.79.0}/plain/utils/deconstruct.py +0 -0
  172. {plain-0.78.2 → plain-0.79.0}/plain/utils/decorators.py +0 -0
  173. {plain-0.78.2 → plain-0.79.0}/plain/utils/duration.py +0 -0
  174. {plain-0.78.2 → plain-0.79.0}/plain/utils/encoding.py +0 -0
  175. {plain-0.78.2 → plain-0.79.0}/plain/utils/functional.py +0 -0
  176. {plain-0.78.2 → plain-0.79.0}/plain/utils/hashable.py +0 -0
  177. {plain-0.78.2 → plain-0.79.0}/plain/utils/html.py +0 -0
  178. {plain-0.78.2 → plain-0.79.0}/plain/utils/http.py +0 -0
  179. {plain-0.78.2 → plain-0.79.0}/plain/utils/inspect.py +0 -0
  180. {plain-0.78.2 → plain-0.79.0}/plain/utils/ipv6.py +0 -0
  181. {plain-0.78.2 → plain-0.79.0}/plain/utils/itercompat.py +0 -0
  182. {plain-0.78.2 → plain-0.79.0}/plain/utils/module_loading.py +0 -0
  183. {plain-0.78.2 → plain-0.79.0}/plain/utils/regex_helper.py +0 -0
  184. {plain-0.78.2 → plain-0.79.0}/plain/utils/safestring.py +0 -0
  185. {plain-0.78.2 → plain-0.79.0}/plain/utils/text.py +0 -0
  186. {plain-0.78.2 → plain-0.79.0}/plain/utils/timesince.py +0 -0
  187. {plain-0.78.2 → plain-0.79.0}/plain/utils/timezone.py +0 -0
  188. {plain-0.78.2 → plain-0.79.0}/plain/utils/tree.py +0 -0
  189. {plain-0.78.2 → plain-0.79.0}/plain/validators.py +0 -0
  190. {plain-0.78.2 → plain-0.79.0}/plain/views/__init__.py +0 -0
  191. {plain-0.78.2 → plain-0.79.0}/plain/views/base.py +0 -0
  192. {plain-0.78.2 → plain-0.79.0}/plain/views/errors.py +0 -0
  193. {plain-0.78.2 → plain-0.79.0}/plain/views/exceptions.py +0 -0
  194. {plain-0.78.2 → plain-0.79.0}/plain/views/forms.py +0 -0
  195. {plain-0.78.2 → plain-0.79.0}/plain/views/objects.py +0 -0
  196. {plain-0.78.2 → plain-0.79.0}/plain/views/redirect.py +0 -0
  197. {plain-0.78.2 → plain-0.79.0}/plain/views/templates.py +0 -0
  198. {plain-0.78.2 → plain-0.79.0}/plain/wsgi.py +0 -0
  199. {plain-0.78.2 → plain-0.79.0}/tests/.gitignore +0 -0
  200. {plain-0.78.2 → plain-0.79.0}/tests/app/.gitignore +0 -0
  201. {plain-0.78.2 → plain-0.79.0}/tests/app/settings.py +0 -0
  202. {plain-0.78.2 → plain-0.79.0}/tests/app/test/__init__.py +0 -0
  203. {plain-0.78.2 → plain-0.79.0}/tests/app/test/default_settings.py +0 -0
  204. {plain-0.78.2 → plain-0.79.0}/tests/app/urls.py +0 -0
  205. {plain-0.78.2 → plain-0.79.0}/tests/conftest.py +0 -0
  206. {plain-0.78.2 → plain-0.79.0}/tests/test_cli.py +0 -0
  207. {plain-0.78.2 → plain-0.79.0}/tests/test_http_hosts.py +0 -0
  208. {plain-0.78.2 → plain-0.79.0}/tests/test_logs.py +0 -0
  209. {plain-0.78.2 → plain-0.79.0}/tests/test_runtime.py +0 -0
  210. {plain-0.78.2 → plain-0.79.0}/tests/test_wsgi.py +0 -0
@@ -1,5 +1,5 @@
1
1
  .venv
2
- .env
2
+ /.env
3
3
  *.egg-info
4
4
  *.py[co]
5
5
  __pycache__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.78.2
3
+ Version: 0.79.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,44 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.79.0](https://github.com/dropseed/plain/releases/plain@0.79.0) (2025-10-22)
4
+
5
+ ### What's changed
6
+
7
+ - Response objects now have an `exception` attribute that stores the exception that caused 5xx errors ([0a243ba89c](https://github.com/dropseed/plain/commit/0a243ba89c))
8
+ - Middleware classes now use an abstract base class `HttpMiddleware` with a `process_request()` method ([b960eed6c6](https://github.com/dropseed/plain/commit/b960eed6c6))
9
+ - CSRF middleware now raises `PermissionDenied` instead of rendering a custom `CsrfFailureView` ([d4b93e59b3](https://github.com/dropseed/plain/commit/d4b93e59b3))
10
+ - The `HTTP_ERROR_VIEWS` setting has been removed ([7a4e3a31f4](https://github.com/dropseed/plain/commit/7a4e3a31f4))
11
+ - Standalone `plain-changelog` and `plain-upgrade` executables have been removed in favor of the built-in commands ([07c3a4c540](https://github.com/dropseed/plain/commit/07c3a4c540))
12
+ - Standalone `plain-build` executable has been removed ([99301ea797](https://github.com/dropseed/plain/commit/99301ea797))
13
+ - Removed automatic logging of all HTTP 400+ status codes for cleaner logs ([c2769d7281](https://github.com/dropseed/plain/commit/c2769d7281))
14
+
15
+ ### Upgrade instructions
16
+
17
+ - If you have custom middleware, inherit from `HttpMiddleware` and rename your `__call__()` method to `process_request()`:
18
+
19
+ ```python
20
+ # Before:
21
+ class MyMiddleware:
22
+ def __init__(self, get_response):
23
+ self.get_response = get_response
24
+
25
+ def __call__(self, request):
26
+ response = self.get_response(request)
27
+ return response
28
+
29
+ # After:
30
+ from plain.http import HttpMiddleware
31
+
32
+ class MyMiddleware(HttpMiddleware):
33
+ def process_request(self, request):
34
+ response = self.get_response(request)
35
+ return response
36
+ ```
37
+
38
+ - Remove any custom `HTTP_ERROR_VIEWS` setting from your configuration - error views are now controlled entirely by exception handlers
39
+ - If you were calling `plain-changelog` or `plain-upgrade` as standalone commands, use `plain changelog` or `plain upgrade` instead
40
+ - If you were calling `plain-build` as a standalone command, use `plain build` instead
41
+
3
42
  ## [0.78.2](https://github.com/dropseed/plain/releases/plain@0.78.2) (2025-10-20)
4
43
 
5
44
  ### What's changed
@@ -7,6 +7,7 @@ from pathlib import Path
7
7
  import click
8
8
 
9
9
  from .output import style_markdown
10
+ from .runtime import without_runtime_setup
10
11
 
11
12
 
12
13
  def parse_version(version_str: str) -> tuple[int, ...]:
@@ -42,6 +43,7 @@ def compare_versions(v1: str, v2: str) -> int:
42
43
  return 0
43
44
 
44
45
 
46
+ @without_runtime_setup
45
47
  @click.command("changelog")
46
48
  @click.argument("package_label")
47
49
  @click.option("--from", "from_version", help="Show entries from this version onwards")
@@ -6,10 +6,12 @@ from pathlib import Path
6
6
  import click
7
7
 
8
8
  from .agent.prompt import prompt_agent
9
+ from .runtime import without_runtime_setup
9
10
 
10
11
  LOCK_FILE = Path("uv.lock")
11
12
 
12
13
 
14
+ @without_runtime_setup
13
15
  @click.command()
14
16
  @click.argument("packages", nargs=-1)
15
17
  @click.option(
@@ -144,7 +146,7 @@ def build_prompt(before_after: dict[str, tuple[str | None, str | None]]) -> str:
144
146
  "## Instructions",
145
147
  "",
146
148
  "1. **Process each package systematically:**",
147
- " - For each package, run: `uv run plain-changelog {package} --from {before} --to {after}`",
149
+ " - For each package, run: `uv run plain changelog {package} --from {before} --to {after}`",
148
150
  " - Read the 'Upgrade instructions' section carefully",
149
151
  " - If it says 'No changes required', skip to the next package",
150
152
  " - Apply any required code changes as specified",
@@ -1,24 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
4
3
  import re
5
4
  from collections.abc import Callable
6
5
  from typing import TYPE_CHECKING
7
6
  from urllib.parse import urlparse
8
7
 
9
- from plain.logs.utils import log_response
8
+ from plain.exceptions import PermissionDenied
9
+ from plain.http import HttpMiddleware
10
10
  from plain.runtime import settings
11
11
 
12
- from .views import CsrfFailureView
13
-
14
12
  if TYPE_CHECKING:
15
- from plain.http import Response
16
- from plain.http.request import Request
17
-
18
- logger = logging.getLogger("plain.security.csrf")
13
+ from plain.http import Request, Response
19
14
 
20
15
 
21
- class CsrfViewMiddleware:
16
+ class CsrfViewMiddleware(HttpMiddleware):
22
17
  """
23
18
  Modern CSRF protection middleware using Sec-Fetch-Site headers and origin validation.
24
19
  Based on Filippo Valsorda's 2025 research (https://words.filippo.io/csrf/).
@@ -28,20 +23,20 @@ class CsrfViewMiddleware:
28
23
  """
29
24
 
30
25
  def __init__(self, get_response: Callable[[Request], Response]):
31
- self.get_response = get_response
26
+ super().__init__(get_response)
32
27
 
33
28
  # Compile CSRF exempt patterns once for performance
34
29
  self.csrf_exempt_patterns: list[re.Pattern[str]] = [
35
30
  re.compile(r) for r in settings.CSRF_EXEMPT_PATHS
36
31
  ]
37
32
 
38
- def __call__(self, request: Request) -> Response:
33
+ def process_request(self, request: Request) -> Response:
39
34
  allowed, reason = self.should_allow_request(request)
40
35
 
41
- if allowed:
42
- return self.get_response(request)
43
- else:
44
- return self.reject(request, reason)
36
+ if not allowed:
37
+ raise PermissionDenied(reason)
38
+
39
+ return self.get_response(request)
45
40
 
46
41
  def should_allow_request(self, request: Request) -> tuple[bool, str]:
47
42
  # 1. Allow safe methods (GET, HEAD, OPTIONS)
@@ -127,17 +122,3 @@ class CsrfViewMiddleware:
127
122
  False,
128
123
  f"Cross-origin request detected - Origin {origin} does not match Host",
129
124
  )
130
-
131
- def reject(self, request: Request, reason: str) -> Response:
132
- """Reject a request with a 403 Forbidden response."""
133
-
134
- response = CsrfFailureView.as_view()(request, reason=reason)
135
- log_response(
136
- "Forbidden (%s): %s",
137
- reason,
138
- request.path,
139
- response=response,
140
- request=request,
141
- logger=logger,
142
- )
143
- return response
@@ -1,12 +1,13 @@
1
- from plain.http.cookie import parse_cookie
2
- from plain.http.request import (
1
+ from .cookie import parse_cookie
2
+ from .middleware import HttpMiddleware
3
+ from .request import (
3
4
  QueryDict,
4
5
  RawPostDataException,
5
6
  Request,
6
7
  RequestHeaders,
7
8
  UnreadablePostError,
8
9
  )
9
- from plain.http.response import (
10
+ from .response import (
10
11
  BadHeaderError,
11
12
  FileResponse,
12
13
  Http404,
@@ -25,6 +26,7 @@ from plain.http.response import (
25
26
  )
26
27
 
27
28
  __all__ = [
29
+ "HttpMiddleware",
28
30
  "parse_cookie",
29
31
  "Request",
30
32
  "RequestHeaders",
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from plain.http import Request, Response
9
+
10
+
11
+ class HttpMiddleware(ABC):
12
+ """
13
+ Abstract base class for HTTP middleware.
14
+
15
+ Subclasses must implement process_request() to handle the request/response cycle.
16
+
17
+ Example:
18
+ class MyMiddleware(HttpMiddleware):
19
+ def process_request(self, request: Request) -> Response:
20
+ # Pre-processing
21
+ response = self.get_response(request)
22
+ # Post-processing
23
+ return response
24
+ """
25
+
26
+ def __init__(self, get_response: Callable[[Request], Response]):
27
+ self.get_response = get_response
28
+
29
+ @abstractmethod
30
+ def process_request(self, request: Request) -> Response:
31
+ """Process the request and return a response. Must be implemented by subclasses."""
32
+ ...
@@ -148,6 +148,8 @@ class ResponseBase:
148
148
  if not 100 <= self.status_code <= 599:
149
149
  raise ValueError("HTTP status code must be an integer from 100 to 599.")
150
150
  self._reason_phrase = reason
151
+ # Exception that caused this response, if any (primarily for 500 errors)
152
+ self.exception: Exception | None = None
151
153
 
152
154
  @property
153
155
  def reason_phrase(self) -> str:
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import logging
4
3
  import types
5
4
  from typing import TYPE_CHECKING
6
5
 
@@ -8,7 +7,6 @@ from opentelemetry import baggage, trace
8
7
  from opentelemetry.semconv.attributes import http_attributes, url_attributes
9
8
 
10
9
  from plain.exceptions import ImproperlyConfigured
11
- from plain.logs.utils import log_response
12
10
  from plain.runtime import settings
13
11
  from plain.urls import get_resolver
14
12
  from plain.utils.module_loading import import_string
@@ -21,8 +19,6 @@ if TYPE_CHECKING:
21
19
  from plain.http import Request, Response, ResponseBase
22
20
  from plain.urls import ResolverMatch
23
21
 
24
- logger = logging.getLogger("plain.request")
25
-
26
22
 
27
23
  # These middleware classes are always used by Plain.
28
24
  BUILTIN_BEFORE_MIDDLEWARE = [
@@ -66,7 +62,7 @@ class BaseHandler:
66
62
  f"Middleware factory {middleware_path} returned None."
67
63
  )
68
64
 
69
- handler = convert_exception_to_response(mw_instance)
65
+ handler = convert_exception_to_response(mw_instance.process_request)
70
66
 
71
67
  # We only assign to this when initialization is complete as it is used
72
68
  # as a flag for initialization being complete.
@@ -117,14 +113,9 @@ class BaseHandler:
117
113
  else trace.StatusCode.ERROR
118
114
  )
119
115
 
120
- if response.status_code >= 400:
121
- log_response(
122
- "%s: %s",
123
- response.reason_phrase,
124
- request.path,
125
- response=response,
126
- request=request,
127
- )
116
+ if response.exception:
117
+ span.record_exception(response.exception)
118
+
128
119
  return response
129
120
 
130
121
  def _get_response(self, request: Request) -> ResponseBase:
@@ -4,7 +4,6 @@ import logging
4
4
  from functools import wraps
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from plain import signals
8
7
  from plain.exceptions import (
9
8
  BadRequest,
10
9
  PermissionDenied,
@@ -15,9 +14,7 @@ from plain.exceptions import (
15
14
  )
16
15
  from plain.http import Http404, ResponseServerError
17
16
  from plain.http.multipartparser import MultiPartParserError
18
- from plain.logs.utils import log_response
19
17
  from plain.runtime import settings
20
- from plain.utils.module_loading import import_string
21
18
  from plain.views.errors import ErrorView
22
19
 
23
20
  if TYPE_CHECKING:
@@ -26,6 +23,9 @@ if TYPE_CHECKING:
26
23
  from plain.http import Request, Response
27
24
 
28
25
 
26
+ request_logger = logging.getLogger("plain.request")
27
+
28
+
29
29
  def convert_exception_to_response(
30
30
  get_response: Callable[[Request], Response],
31
31
  ) -> Callable[[Request], Response]:
@@ -63,37 +63,34 @@ def response_for_exception(request: Request, exc: Exception) -> Response:
63
63
  response = get_exception_response(
64
64
  request=request, status_code=403, exception=exc
65
65
  )
66
- log_response(
66
+ request_logger.warning(
67
67
  "Forbidden (Permission denied): %s",
68
68
  request.path,
69
- response=response,
70
- request=request,
71
- exception=exc,
69
+ extra={"status_code": response.status_code, "request": request},
70
+ exc_info=exc,
72
71
  )
73
72
 
74
73
  elif isinstance(exc, MultiPartParserError):
75
74
  response = get_exception_response(
76
75
  request=request, status_code=400, exception=None
77
76
  )
78
- log_response(
77
+ request_logger.warning(
79
78
  "Bad request (Unable to parse request body): %s",
80
79
  request.path,
81
- response=response,
82
- request=request,
83
- exception=exc,
80
+ extra={"status_code": response.status_code, "request": request},
81
+ exc_info=exc,
84
82
  )
85
83
 
86
84
  elif isinstance(exc, BadRequest):
87
85
  response = get_exception_response(
88
86
  request=request, status_code=400, exception=exc
89
87
  )
90
- log_response(
88
+ request_logger.warning(
91
89
  "%s: %s",
92
90
  str(exc),
93
91
  request.path,
94
- response=response,
95
- request=request,
96
- exception=exc,
92
+ extra={"status_code": response.status_code, "request": request},
93
+ exc_info=exc,
97
94
  )
98
95
  elif isinstance(exc, SuspiciousOperation):
99
96
  if isinstance(exc, RequestDataTooBig | TooManyFieldsSent | TooManyFilesSent):
@@ -106,25 +103,23 @@ def response_for_exception(request: Request, exc: Exception) -> Response:
106
103
  security_logger = logging.getLogger(f"plain.security.{exc.__class__.__name__}")
107
104
  security_logger.error(
108
105
  str(exc),
109
- exc_info=exc,
110
106
  extra={"status_code": 400, "request": request},
107
+ exc_info=exc,
111
108
  )
112
109
  response = get_exception_response(
113
110
  request=request, status_code=400, exception=None
114
111
  )
115
112
 
116
113
  else:
117
- signals.got_request_exception.send(sender=None, request=request)
118
114
  response = get_exception_response(
119
115
  request=request, status_code=500, exception=None
120
116
  )
121
- log_response(
117
+ request_logger.error(
122
118
  "%s: %s",
123
119
  response.reason_phrase,
124
120
  request.path,
125
- response=response,
126
- request=request,
127
- exception=exc,
121
+ extra={"status_code": response.status_code, "request": request},
122
+ exc_info=exc,
128
123
  )
129
124
 
130
125
  return response
@@ -134,29 +129,18 @@ def get_exception_response(
134
129
  *, request: Request, status_code: int, exception: Exception | None
135
130
  ) -> Response:
136
131
  try:
137
- view_class = get_error_view(status_code=status_code, exception=exception)
138
- return view_class(request)
139
- except Exception:
140
- signals.got_request_exception.send(sender=None, request=request)
141
-
132
+ view_class = ErrorView.as_view(status_code=status_code, exception=exception)
133
+ response = view_class(request)
134
+ if response.status_code >= 500 and exception is not None:
135
+ # Attach the exception to the response for logging/observability
136
+ response.exception = exception
137
+ return response
138
+ except Exception as e:
142
139
  # In development mode, re-raise the exception to get a full stack trace
143
140
  if settings.DEBUG:
144
141
  raise
145
142
 
146
143
  # If we can't load the view, return a 500 response
147
- return ResponseServerError()
148
-
149
-
150
- def get_error_view(
151
- *, status_code: int, exception: Exception | None
152
- ) -> Callable[[Request], Response]:
153
- views_by_status = settings.HTTP_ERROR_VIEWS
154
- if status_code in views_by_status:
155
- view = views_by_status[status_code]
156
- if isinstance(view, str):
157
- # Import the view if it's a string
158
- view = import_string(view)
159
- return view.as_view()
160
-
161
- # Create a standard view for any other status code
162
- return ErrorView.as_view(status_code=status_code, exception=exception)
144
+ response = ResponseServerError()
145
+ response.exception = e
146
+ return response
@@ -2,19 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ from plain.http import HttpMiddleware
5
6
  from plain.runtime import settings
6
7
 
7
8
  if TYPE_CHECKING:
8
- from collections.abc import Callable
9
-
10
9
  from plain.http import Request, Response
11
10
 
12
11
 
13
- class DefaultHeadersMiddleware:
14
- def __init__(self, get_response: Callable[[Request], Response]) -> None:
15
- self.get_response = get_response
16
-
17
- def __call__(self, request: Request) -> Response:
12
+ class DefaultHeadersMiddleware(HttpMiddleware):
13
+ def process_request(self, request: Request) -> Response:
18
14
  response = self.get_response(request)
19
15
 
20
16
  for header, value in settings.DEFAULT_RESPONSE_HEADERS.items():
@@ -4,13 +4,11 @@ import ipaddress
4
4
  import logging
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from plain.http import Request, ResponseBadRequest
7
+ from plain.http import HttpMiddleware, Request, ResponseBadRequest
8
8
  from plain.runtime import settings
9
9
  from plain.utils.regex_helper import _lazy_re_compile
10
10
 
11
11
  if TYPE_CHECKING:
12
- from collections.abc import Callable
13
-
14
12
  from plain.http import Response
15
13
 
16
14
  logger = logging.getLogger(__name__)
@@ -20,7 +18,7 @@ host_validation_re = _lazy_re_compile(
20
18
  )
21
19
 
22
20
 
23
- class HostValidationMiddleware:
21
+ class HostValidationMiddleware(HttpMiddleware):
24
22
  """
25
23
  Middleware to validate the Host header against ALLOWED_HOSTS.
26
24
 
@@ -29,10 +27,7 @@ class HostValidationMiddleware:
29
27
  host is not allowed.
30
28
  """
31
29
 
32
- def __init__(self, get_response: Callable[[Request], Response]) -> None:
33
- self.get_response = get_response
34
-
35
- def __call__(self, request: Request) -> Response:
30
+ def process_request(self, request: Request) -> Response:
36
31
  if not is_host_valid(request):
37
32
  host = request.host
38
33
  msg = f"Invalid HTTP_HOST header: {host!r}."
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- from plain.http import ResponseRedirect
5
+ from plain.http import HttpMiddleware, ResponseRedirect
6
6
  from plain.runtime import settings
7
7
 
8
8
  if TYPE_CHECKING:
@@ -11,14 +11,14 @@ if TYPE_CHECKING:
11
11
  from plain.http import Request, Response
12
12
 
13
13
 
14
- class HttpsRedirectMiddleware:
15
- def __init__(self, get_response: Callable[[Request], Response]) -> None:
16
- self.get_response = get_response
14
+ class HttpsRedirectMiddleware(HttpMiddleware):
15
+ def __init__(self, get_response: Callable[[Request], Response]):
16
+ super().__init__(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: Request) -> Response:
21
+ def process_request(self, request: Request) -> Response:
22
22
  """
23
23
  Perform a blanket HTTP→HTTPS redirect when enabled.
24
24
  """
@@ -2,23 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- from plain.http import ResponseRedirect
5
+ from plain.http import HttpMiddleware, ResponseRedirect
6
6
  from plain.runtime import settings
7
7
  from plain.urls import Resolver404, get_resolver
8
8
  from plain.utils.http import escape_leading_slashes
9
9
 
10
10
  if TYPE_CHECKING:
11
- from collections.abc import Callable
12
-
13
11
  from plain.http import Request, Response
14
12
  from plain.urls import ResolverMatch
15
13
 
16
14
 
17
- class RedirectSlashMiddleware:
18
- def __init__(self, get_response: Callable[[Request], Response]) -> None:
19
- self.get_response = get_response
20
-
21
- def __call__(self, request: Request) -> Response:
15
+ class RedirectSlashMiddleware(HttpMiddleware):
16
+ def process_request(self, request: Request) -> Response:
22
17
  """
23
18
  Rewrite the URL based on settings.APPEND_SLASH
24
19
  """
@@ -111,9 +111,6 @@ DATA_UPLOAD_MAX_NUMBER_FILES = 100
111
111
  # (i.e. "/tmp" on *nix systems).
112
112
  FILE_UPLOAD_TEMP_DIR = None
113
113
 
114
- # User-defined overrides for error views by status code
115
- HTTP_ERROR_VIEWS: dict[int, type] = {}
116
-
117
114
  # MARK: Middleware
118
115
 
119
116
  # List of middleware to use. Order is important; in the request phase, these
@@ -2,4 +2,3 @@ from plain.signals.dispatch import Signal
2
2
 
3
3
  request_started = Signal()
4
4
  request_finished = Signal()
5
- got_request_exception = Signal()
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- import sys
5
4
  from http import HTTPStatus
6
5
  from http.cookies import SimpleCookie
7
6
  from io import BytesIO, IOBase
@@ -14,7 +13,7 @@ from plain.internal.handlers.base import BaseHandler
14
13
  from plain.internal.handlers.wsgi import WSGIRequest
15
14
  from plain.json import PlainJSONEncoder
16
15
  from plain.runtime import settings
17
- from plain.signals import got_request_exception, request_started
16
+ from plain.signals import request_started
18
17
  from plain.urls import get_resolver
19
18
  from plain.utils.encoding import force_bytes
20
19
  from plain.utils.functional import SimpleLazyObject
@@ -55,7 +54,6 @@ class ClientResponse:
55
54
  response: ResponseBase,
56
55
  client: Client,
57
56
  request: dict[str, Any],
58
- exc_info: tuple[Any, Any, Any] | None,
59
57
  ):
60
58
  # Store wrapped response in __dict__ directly to avoid __setattr__ recursion
61
59
  object.__setattr__(self, "_response", response)
@@ -66,7 +64,6 @@ class ClientResponse:
66
64
  self.wsgi_request: WSGIRequest
67
65
  self.redirect_chain: list[tuple[str, int]]
68
66
  self.resolver_match: SimpleLazyObject | ResolverMatch
69
- self.exc_info = exc_info
70
67
  # Optional: set by plain.auth if available
71
68
  # self.user: Model
72
69
 
@@ -530,7 +527,6 @@ class Client:
530
527
  self._request_factory = RequestFactory(headers=headers, **defaults)
531
528
  self.handler = ClientHandler()
532
529
  self.raise_request_exception = raise_request_exception
533
- self.exc_info: tuple[Any, Any, Any] | None = None
534
530
  self.extra: dict[str, Any] | None = None
535
531
  self.headers: dict[str, str] | None = None
536
532
 
@@ -553,25 +549,20 @@ class Client:
553
549
  """
554
550
  environ = self._request_factory._base_environ(**request)
555
551
 
556
- # Capture exceptions created by the handler.
557
- exception_uid = f"request-exception-{id(request)}"
558
- got_request_exception.connect(self.store_exc_info, dispatch_uid=exception_uid)
559
- try:
560
- response = self.handler(environ)
561
- finally:
562
- # signals.template_rendered.disconnect(dispatch_uid=signal_uid)
563
- got_request_exception.disconnect(dispatch_uid=exception_uid)
552
+ # Make the request
553
+ response = self.handler(environ)
564
554
 
565
555
  # Wrap the response in ClientResponse for test-specific attributes
566
556
  client_response = ClientResponse(
567
557
  response=response,
568
558
  client=self,
569
559
  request=request,
570
- exc_info=self.exc_info,
571
560
  )
572
561
 
573
- # Check for signaled exceptions and potentially re-raise
574
- self.check_exception()
562
+ # Re-raise the exception if configured to do so
563
+ # Only 5xx errors have response.exception set
564
+ if client_response.exception and self.raise_request_exception:
565
+ raise client_response.exception
575
566
 
576
567
  # If the request had a user, make it available on the response.
577
568
  try:
@@ -901,18 +892,6 @@ class Client:
901
892
 
902
893
  return response
903
894
 
904
- def store_exc_info(self, **kwargs: Any) -> None:
905
- """Store exceptions when they are generated by a view."""
906
- self.exc_info = sys.exc_info()
907
-
908
- def check_exception(self) -> None:
909
- """Check for signaled exceptions and potentially re-raise."""
910
- if self.exc_info:
911
- _, exc_value, _ = self.exc_info
912
- self.exc_info = None
913
- if self.raise_request_exception:
914
- raise exc_value
915
-
916
895
  @property
917
896
  def session(self) -> Any:
918
897
  """Return the current session variables."""