plain 0.76.0__tar.gz → 0.78.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 (213) hide show
  1. {plain-0.76.0 → plain-0.78.0}/PKG-INFO +2 -1
  2. {plain-0.76.0 → plain-0.78.0}/plain/CHANGELOG.md +46 -0
  3. {plain-0.76.0 → plain-0.78.0}/plain/chores/README.md +24 -22
  4. plain-0.78.0/plain/chores/__init__.py +4 -0
  5. plain-0.78.0/plain/chores/core.py +27 -0
  6. plain-0.78.0/plain/chores/registry.py +49 -0
  7. {plain-0.76.0 → plain-0.78.0}/plain/cli/README.md +26 -0
  8. {plain-0.76.0 → plain-0.78.0}/plain/cli/chores.py +28 -26
  9. {plain-0.76.0 → plain-0.78.0}/plain/cli/server.py +0 -8
  10. plain-0.78.0/plain/internal/reloader.py +77 -0
  11. {plain-0.76.0 → plain-0.78.0}/plain/server/README.md +1 -2
  12. {plain-0.76.0 → plain-0.78.0}/plain/server/arbiter.py +2 -2
  13. {plain-0.76.0 → plain-0.78.0}/plain/server/config.py +6 -6
  14. {plain-0.76.0 → plain-0.78.0}/plain/server/util.py +0 -67
  15. plain-0.78.0/plain/server/workers/__init__.py +6 -0
  16. {plain-0.76.0 → plain-0.78.0}/plain/server/workers/base.py +4 -7
  17. {plain-0.76.0 → plain-0.78.0}/plain/views/base.py +2 -3
  18. {plain-0.76.0 → plain-0.78.0}/pyproject.toml +2 -1
  19. plain-0.76.0/plain/chores/__init__.py +0 -3
  20. plain-0.76.0/plain/chores/registry.py +0 -67
  21. plain-0.76.0/plain/server/reloader.py +0 -158
  22. plain-0.76.0/plain/server/workers/__init__.py +0 -12
  23. {plain-0.76.0 → plain-0.78.0}/.gitignore +0 -0
  24. {plain-0.76.0 → plain-0.78.0}/LICENSE +0 -0
  25. {plain-0.76.0 → plain-0.78.0}/README.md +0 -0
  26. {plain-0.76.0 → plain-0.78.0}/plain/AGENTS.md +0 -0
  27. {plain-0.76.0 → plain-0.78.0}/plain/README.md +0 -0
  28. {plain-0.76.0 → plain-0.78.0}/plain/__main__.py +0 -0
  29. {plain-0.76.0 → plain-0.78.0}/plain/assets/README.md +0 -0
  30. {plain-0.76.0 → plain-0.78.0}/plain/assets/__init__.py +0 -0
  31. {plain-0.76.0 → plain-0.78.0}/plain/assets/compile.py +0 -0
  32. {plain-0.76.0 → plain-0.78.0}/plain/assets/finders.py +0 -0
  33. {plain-0.76.0 → plain-0.78.0}/plain/assets/fingerprints.py +0 -0
  34. {plain-0.76.0 → plain-0.78.0}/plain/assets/urls.py +0 -0
  35. {plain-0.76.0 → plain-0.78.0}/plain/assets/views.py +0 -0
  36. {plain-0.76.0 → plain-0.78.0}/plain/cli/__init__.py +0 -0
  37. {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/__init__.py +0 -0
  38. {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/docs.py +0 -0
  39. {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/llmdocs.py +0 -0
  40. {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/md.py +0 -0
  41. {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/prompt.py +0 -0
  42. {plain-0.76.0 → plain-0.78.0}/plain/cli/agent/request.py +0 -0
  43. {plain-0.76.0 → plain-0.78.0}/plain/cli/build.py +0 -0
  44. {plain-0.76.0 → plain-0.78.0}/plain/cli/changelog.py +0 -0
  45. {plain-0.76.0 → plain-0.78.0}/plain/cli/core.py +0 -0
  46. {plain-0.76.0 → plain-0.78.0}/plain/cli/docs.py +0 -0
  47. {plain-0.76.0 → plain-0.78.0}/plain/cli/formatting.py +0 -0
  48. {plain-0.76.0 → plain-0.78.0}/plain/cli/install.py +0 -0
  49. {plain-0.76.0 → plain-0.78.0}/plain/cli/output.py +0 -0
  50. {plain-0.76.0 → plain-0.78.0}/plain/cli/preflight.py +0 -0
  51. {plain-0.76.0 → plain-0.78.0}/plain/cli/print.py +0 -0
  52. {plain-0.76.0 → plain-0.78.0}/plain/cli/registry.py +0 -0
  53. {plain-0.76.0 → plain-0.78.0}/plain/cli/runtime.py +0 -0
  54. {plain-0.76.0 → plain-0.78.0}/plain/cli/scaffold.py +0 -0
  55. {plain-0.76.0 → plain-0.78.0}/plain/cli/settings.py +0 -0
  56. {plain-0.76.0 → plain-0.78.0}/plain/cli/shell.py +0 -0
  57. {plain-0.76.0 → plain-0.78.0}/plain/cli/startup.py +0 -0
  58. {plain-0.76.0 → plain-0.78.0}/plain/cli/upgrade.py +0 -0
  59. {plain-0.76.0 → plain-0.78.0}/plain/cli/urls.py +0 -0
  60. {plain-0.76.0 → plain-0.78.0}/plain/cli/utils.py +0 -0
  61. {plain-0.76.0 → plain-0.78.0}/plain/csrf/README.md +0 -0
  62. {plain-0.76.0 → plain-0.78.0}/plain/csrf/middleware.py +0 -0
  63. {plain-0.76.0 → plain-0.78.0}/plain/csrf/views.py +0 -0
  64. {plain-0.76.0 → plain-0.78.0}/plain/debug.py +0 -0
  65. {plain-0.76.0 → plain-0.78.0}/plain/exceptions.py +0 -0
  66. {plain-0.76.0 → plain-0.78.0}/plain/forms/README.md +0 -0
  67. {plain-0.76.0 → plain-0.78.0}/plain/forms/__init__.py +0 -0
  68. {plain-0.76.0 → plain-0.78.0}/plain/forms/boundfield.py +0 -0
  69. {plain-0.76.0 → plain-0.78.0}/plain/forms/exceptions.py +0 -0
  70. {plain-0.76.0 → plain-0.78.0}/plain/forms/fields.py +0 -0
  71. {plain-0.76.0 → plain-0.78.0}/plain/forms/forms.py +0 -0
  72. {plain-0.76.0 → plain-0.78.0}/plain/http/README.md +0 -0
  73. {plain-0.76.0 → plain-0.78.0}/plain/http/__init__.py +0 -0
  74. {plain-0.76.0 → plain-0.78.0}/plain/http/cookie.py +0 -0
  75. {plain-0.76.0 → plain-0.78.0}/plain/http/multipartparser.py +0 -0
  76. {plain-0.76.0 → plain-0.78.0}/plain/http/request.py +0 -0
  77. {plain-0.76.0 → plain-0.78.0}/plain/http/response.py +0 -0
  78. {plain-0.76.0 → plain-0.78.0}/plain/internal/__init__.py +0 -0
  79. {plain-0.76.0 → plain-0.78.0}/plain/internal/files/__init__.py +0 -0
  80. {plain-0.76.0 → plain-0.78.0}/plain/internal/files/base.py +0 -0
  81. {plain-0.76.0 → plain-0.78.0}/plain/internal/files/locks.py +0 -0
  82. {plain-0.76.0 → plain-0.78.0}/plain/internal/files/move.py +0 -0
  83. {plain-0.76.0 → plain-0.78.0}/plain/internal/files/temp.py +0 -0
  84. {plain-0.76.0 → plain-0.78.0}/plain/internal/files/uploadedfile.py +0 -0
  85. {plain-0.76.0 → plain-0.78.0}/plain/internal/files/uploadhandler.py +0 -0
  86. {plain-0.76.0 → plain-0.78.0}/plain/internal/files/utils.py +0 -0
  87. {plain-0.76.0 → plain-0.78.0}/plain/internal/handlers/__init__.py +0 -0
  88. {plain-0.76.0 → plain-0.78.0}/plain/internal/handlers/base.py +0 -0
  89. {plain-0.76.0 → plain-0.78.0}/plain/internal/handlers/exception.py +0 -0
  90. {plain-0.76.0 → plain-0.78.0}/plain/internal/handlers/wsgi.py +0 -0
  91. {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/__init__.py +0 -0
  92. {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/headers.py +0 -0
  93. {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/hosts.py +0 -0
  94. {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/https.py +0 -0
  95. {plain-0.76.0 → plain-0.78.0}/plain/internal/middleware/slash.py +0 -0
  96. {plain-0.76.0 → plain-0.78.0}/plain/json.py +0 -0
  97. {plain-0.76.0 → plain-0.78.0}/plain/logs/README.md +0 -0
  98. {plain-0.76.0 → plain-0.78.0}/plain/logs/__init__.py +0 -0
  99. {plain-0.76.0 → plain-0.78.0}/plain/logs/configure.py +0 -0
  100. {plain-0.76.0 → plain-0.78.0}/plain/logs/debug.py +0 -0
  101. {plain-0.76.0 → plain-0.78.0}/plain/logs/formatters.py +0 -0
  102. {plain-0.76.0 → plain-0.78.0}/plain/logs/loggers.py +0 -0
  103. {plain-0.76.0 → plain-0.78.0}/plain/logs/utils.py +0 -0
  104. {plain-0.76.0 → plain-0.78.0}/plain/packages/README.md +0 -0
  105. {plain-0.76.0 → plain-0.78.0}/plain/packages/__init__.py +0 -0
  106. {plain-0.76.0 → plain-0.78.0}/plain/packages/config.py +0 -0
  107. {plain-0.76.0 → plain-0.78.0}/plain/packages/registry.py +0 -0
  108. {plain-0.76.0 → plain-0.78.0}/plain/paginator.py +0 -0
  109. {plain-0.76.0 → plain-0.78.0}/plain/preflight/README.md +0 -0
  110. {plain-0.76.0 → plain-0.78.0}/plain/preflight/__init__.py +0 -0
  111. {plain-0.76.0 → plain-0.78.0}/plain/preflight/checks.py +0 -0
  112. {plain-0.76.0 → plain-0.78.0}/plain/preflight/files.py +0 -0
  113. {plain-0.76.0 → plain-0.78.0}/plain/preflight/registry.py +0 -0
  114. {plain-0.76.0 → plain-0.78.0}/plain/preflight/results.py +0 -0
  115. {plain-0.76.0 → plain-0.78.0}/plain/preflight/security.py +0 -0
  116. {plain-0.76.0 → plain-0.78.0}/plain/preflight/urls.py +0 -0
  117. {plain-0.76.0 → plain-0.78.0}/plain/runtime/README.md +0 -0
  118. {plain-0.76.0 → plain-0.78.0}/plain/runtime/__init__.py +0 -0
  119. {plain-0.76.0 → plain-0.78.0}/plain/runtime/global_settings.py +0 -0
  120. {plain-0.76.0 → plain-0.78.0}/plain/runtime/user_settings.py +0 -0
  121. {plain-0.76.0 → plain-0.78.0}/plain/runtime/utils.py +0 -0
  122. {plain-0.76.0 → plain-0.78.0}/plain/server/LICENSE +0 -0
  123. {plain-0.76.0 → plain-0.78.0}/plain/server/__init__.py +0 -0
  124. {plain-0.76.0 → plain-0.78.0}/plain/server/app.py +0 -0
  125. {plain-0.76.0 → plain-0.78.0}/plain/server/errors.py +0 -0
  126. {plain-0.76.0 → plain-0.78.0}/plain/server/glogging.py +0 -0
  127. {plain-0.76.0 → plain-0.78.0}/plain/server/http/__init__.py +0 -0
  128. {plain-0.76.0 → plain-0.78.0}/plain/server/http/body.py +0 -0
  129. {plain-0.76.0 → plain-0.78.0}/plain/server/http/errors.py +0 -0
  130. {plain-0.76.0 → plain-0.78.0}/plain/server/http/message.py +0 -0
  131. {plain-0.76.0 → plain-0.78.0}/plain/server/http/parser.py +0 -0
  132. {plain-0.76.0 → plain-0.78.0}/plain/server/http/unreader.py +0 -0
  133. {plain-0.76.0 → plain-0.78.0}/plain/server/http/wsgi.py +0 -0
  134. {plain-0.76.0 → plain-0.78.0}/plain/server/pidfile.py +0 -0
  135. {plain-0.76.0 → plain-0.78.0}/plain/server/sock.py +0 -0
  136. {plain-0.76.0 → plain-0.78.0}/plain/server/workers/sync.py +0 -0
  137. /plain-0.76.0/plain/server/workers/gthread.py → /plain-0.78.0/plain/server/workers/thread.py +0 -0
  138. {plain-0.76.0 → plain-0.78.0}/plain/server/workers/workertmp.py +0 -0
  139. {plain-0.76.0 → plain-0.78.0}/plain/signals/README.md +0 -0
  140. {plain-0.76.0 → plain-0.78.0}/plain/signals/__init__.py +0 -0
  141. {plain-0.76.0 → plain-0.78.0}/plain/signals/dispatch/__init__.py +0 -0
  142. {plain-0.76.0 → plain-0.78.0}/plain/signals/dispatch/dispatcher.py +0 -0
  143. {plain-0.76.0 → plain-0.78.0}/plain/signals/dispatch/license.txt +0 -0
  144. {plain-0.76.0 → plain-0.78.0}/plain/signing.py +0 -0
  145. {plain-0.76.0 → plain-0.78.0}/plain/templates/AGENTS.md +0 -0
  146. {plain-0.76.0 → plain-0.78.0}/plain/templates/README.md +0 -0
  147. {plain-0.76.0 → plain-0.78.0}/plain/templates/__init__.py +0 -0
  148. {plain-0.76.0 → plain-0.78.0}/plain/templates/core.py +0 -0
  149. {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/__init__.py +0 -0
  150. {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/environments.py +0 -0
  151. {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/extensions.py +0 -0
  152. {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/filters.py +0 -0
  153. {plain-0.76.0 → plain-0.78.0}/plain/templates/jinja/globals.py +0 -0
  154. {plain-0.76.0 → plain-0.78.0}/plain/test/README.md +0 -0
  155. {plain-0.76.0 → plain-0.78.0}/plain/test/__init__.py +0 -0
  156. {plain-0.76.0 → plain-0.78.0}/plain/test/client.py +0 -0
  157. {plain-0.76.0 → plain-0.78.0}/plain/test/encoding.py +0 -0
  158. {plain-0.76.0 → plain-0.78.0}/plain/test/exceptions.py +0 -0
  159. {plain-0.76.0 → plain-0.78.0}/plain/urls/README.md +0 -0
  160. {plain-0.76.0 → plain-0.78.0}/plain/urls/__init__.py +0 -0
  161. {plain-0.76.0 → plain-0.78.0}/plain/urls/converters.py +0 -0
  162. {plain-0.76.0 → plain-0.78.0}/plain/urls/exceptions.py +0 -0
  163. {plain-0.76.0 → plain-0.78.0}/plain/urls/patterns.py +0 -0
  164. {plain-0.76.0 → plain-0.78.0}/plain/urls/resolvers.py +0 -0
  165. {plain-0.76.0 → plain-0.78.0}/plain/urls/routers.py +0 -0
  166. {plain-0.76.0 → plain-0.78.0}/plain/urls/utils.py +0 -0
  167. {plain-0.76.0 → plain-0.78.0}/plain/utils/README.md +0 -0
  168. {plain-0.76.0 → plain-0.78.0}/plain/utils/__init__.py +0 -0
  169. {plain-0.76.0 → plain-0.78.0}/plain/utils/cache.py +0 -0
  170. {plain-0.76.0 → plain-0.78.0}/plain/utils/crypto.py +0 -0
  171. {plain-0.76.0 → plain-0.78.0}/plain/utils/datastructures.py +0 -0
  172. {plain-0.76.0 → plain-0.78.0}/plain/utils/dateparse.py +0 -0
  173. {plain-0.76.0 → plain-0.78.0}/plain/utils/deconstruct.py +0 -0
  174. {plain-0.76.0 → plain-0.78.0}/plain/utils/decorators.py +0 -0
  175. {plain-0.76.0 → plain-0.78.0}/plain/utils/duration.py +0 -0
  176. {plain-0.76.0 → plain-0.78.0}/plain/utils/encoding.py +0 -0
  177. {plain-0.76.0 → plain-0.78.0}/plain/utils/functional.py +0 -0
  178. {plain-0.76.0 → plain-0.78.0}/plain/utils/hashable.py +0 -0
  179. {plain-0.76.0 → plain-0.78.0}/plain/utils/html.py +0 -0
  180. {plain-0.76.0 → plain-0.78.0}/plain/utils/http.py +0 -0
  181. {plain-0.76.0 → plain-0.78.0}/plain/utils/inspect.py +0 -0
  182. {plain-0.76.0 → plain-0.78.0}/plain/utils/ipv6.py +0 -0
  183. {plain-0.76.0 → plain-0.78.0}/plain/utils/itercompat.py +0 -0
  184. {plain-0.76.0 → plain-0.78.0}/plain/utils/module_loading.py +0 -0
  185. {plain-0.76.0 → plain-0.78.0}/plain/utils/regex_helper.py +0 -0
  186. {plain-0.76.0 → plain-0.78.0}/plain/utils/safestring.py +0 -0
  187. {plain-0.76.0 → plain-0.78.0}/plain/utils/text.py +0 -0
  188. {plain-0.76.0 → plain-0.78.0}/plain/utils/timesince.py +0 -0
  189. {plain-0.76.0 → plain-0.78.0}/plain/utils/timezone.py +0 -0
  190. {plain-0.76.0 → plain-0.78.0}/plain/utils/tree.py +0 -0
  191. {plain-0.76.0 → plain-0.78.0}/plain/validators.py +0 -0
  192. {plain-0.76.0 → plain-0.78.0}/plain/views/README.md +0 -0
  193. {plain-0.76.0 → plain-0.78.0}/plain/views/__init__.py +0 -0
  194. {plain-0.76.0 → plain-0.78.0}/plain/views/errors.py +0 -0
  195. {plain-0.76.0 → plain-0.78.0}/plain/views/exceptions.py +0 -0
  196. {plain-0.76.0 → plain-0.78.0}/plain/views/forms.py +0 -0
  197. {plain-0.76.0 → plain-0.78.0}/plain/views/objects.py +0 -0
  198. {plain-0.76.0 → plain-0.78.0}/plain/views/redirect.py +0 -0
  199. {plain-0.76.0 → plain-0.78.0}/plain/views/templates.py +0 -0
  200. {plain-0.76.0 → plain-0.78.0}/plain/wsgi.py +0 -0
  201. {plain-0.76.0 → plain-0.78.0}/tests/.gitignore +0 -0
  202. {plain-0.76.0 → plain-0.78.0}/tests/app/.gitignore +0 -0
  203. {plain-0.76.0 → plain-0.78.0}/tests/app/settings.py +0 -0
  204. {plain-0.76.0 → plain-0.78.0}/tests/app/test/__init__.py +0 -0
  205. {plain-0.76.0 → plain-0.78.0}/tests/app/test/default_settings.py +0 -0
  206. {plain-0.76.0 → plain-0.78.0}/tests/app/urls.py +0 -0
  207. {plain-0.76.0 → plain-0.78.0}/tests/conftest.py +0 -0
  208. {plain-0.76.0 → plain-0.78.0}/tests/test_cli.py +0 -0
  209. {plain-0.76.0 → plain-0.78.0}/tests/test_csrf.py +0 -0
  210. {plain-0.76.0 → plain-0.78.0}/tests/test_http_hosts.py +0 -0
  211. {plain-0.76.0 → plain-0.78.0}/tests/test_logs.py +0 -0
  212. {plain-0.76.0 → plain-0.78.0}/tests/test_runtime.py +0 -0
  213. {plain-0.76.0 → plain-0.78.0}/tests/test_wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.76.0
3
+ Version: 0.78.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
@@ -9,6 +9,7 @@ Requires-Dist: click>=8.0.0
9
9
  Requires-Dist: jinja2>=3.1.2
10
10
  Requires-Dist: opentelemetry-api>=1.34.1
11
11
  Requires-Dist: opentelemetry-semantic-conventions>=0.55b1
12
+ Requires-Dist: watchfiles>=0.18.0
12
13
  Description-Content-Type: text/markdown
13
14
 
14
15
  # Plain
@@ -1,5 +1,51 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.78.0](https://github.com/dropseed/plain/releases/plain@0.78.0) (2025-10-17)
4
+
5
+ ### What's changed
6
+
7
+ - Chores have been refactored to use abstract base classes instead of decorated functions ([c4466d3c60](https://github.com/dropseed/plain/commit/c4466d3c60))
8
+ - Added `SHELL_IMPORT` setting to customize what gets automatically imported in `plain shell` ([9055f59c08](https://github.com/dropseed/plain/commit/9055f59c08))
9
+ - Views that return `None` now raise `Http404` instead of returning `ResponseNotFound` ([5bb60016eb](https://github.com/dropseed/plain/commit/5bb60016eb))
10
+ - The `plain chores list` command output formatting now matches the `plain jobs list` format ([4b6881a49e](https://github.com/dropseed/plain/commit/4b6881a49e))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Update any chores from decorated functions to class-based chores:
15
+
16
+ ```python
17
+ # Before:
18
+ @register_chore("group")
19
+ def chore_name():
20
+ """Description"""
21
+ return "Done!"
22
+
23
+ # After:
24
+ from plain.chores import Chore, register_chore
25
+
26
+ @register_chore
27
+ class ChoreName(Chore):
28
+ """Description"""
29
+
30
+ def run(self):
31
+ return "Done!"
32
+ ```
33
+
34
+ - Import `Chore` base class from `plain.chores` when creating new chores
35
+
36
+ ## [0.77.0](https://github.com/dropseed/plain/releases/plain@0.77.0) (2025-10-13)
37
+
38
+ ### What's changed
39
+
40
+ - The `plain server --reload` now uses `watchfiles` for improved cross-platform file watching ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
41
+ - Server reloader now watches `.env*` files for changes and triggers automatic reload ([92e95c5032](https://github.com/dropseed/plain/commit/92e95c5032))
42
+ - HTML template additions and deletions now trigger automatic server reload when using `--reload` ([f2f31c288b](https://github.com/dropseed/plain/commit/f2f31c288b))
43
+ - Internal server worker type renamed from "gthread" to "thread" for clarity ([6470748e91](https://github.com/dropseed/plain/commit/6470748e91))
44
+
45
+ ### Upgrade instructions
46
+
47
+ - No changes required
48
+
3
49
  ## [0.76.0](https://github.com/dropseed/plain/releases/plain@0.76.0) (2025-10-12)
4
50
 
5
51
  ### What's changed
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## Overview
10
10
 
11
- Chores are registered functions that can be run at any time to keep an app in a desirable state.
11
+ Chores are registered classes that can be run at any time to keep an app in a desirable state.
12
12
 
13
13
  ![](https://assets.plainframework.com/docs/plain-chores-run.png)
14
14
 
@@ -16,19 +16,19 @@ A good example is the clearing of expired sessions in [`plain.sessions`](/plain-
16
16
 
17
17
  ```python
18
18
  # plain/sessions/chores.py
19
- from plain.chores import register_chore
19
+ from plain.chores import Chore, register_chore
20
20
  from plain.utils import timezone
21
21
 
22
22
  from .models import Session
23
23
 
24
24
 
25
- @register_chore("sessions")
26
- def clear_expired():
27
- """
28
- Delete sessions that have expired.
29
- """
30
- result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
- return f"{result[0]} expired sessions deleted"
25
+ @register_chore
26
+ class ClearExpired(Chore):
27
+ """Delete sessions that have expired."""
28
+
29
+ def run(self):
30
+ result = Session.query.filter(expires_at__lt=timezone.now()).delete()
31
+ return f"{result[0]} expired sessions deleted"
32
32
  ```
33
33
 
34
34
  ## Running chores
@@ -44,27 +44,29 @@ There are several ways you can run chores depending on your needs:
44
44
 
45
45
  ## Writing chores
46
46
 
47
- A chore is a function decorated with `@register_chore(chore_group_name)`. It can write a description as a docstring, and it can return a value that will be printed when the chore is run.
47
+ A chore is a class that inherits from [`Chore`](./core.py#Chore) and implements the `run()` method. Register the chore using the [`@register_chore`](./registry.py#register_chore) decorator. The chore name is the class's qualified name (`__qualname__`), and the description comes from the class docstring.
48
48
 
49
49
  ```python
50
50
  # app/chores.py
51
- from plain.chores import register_chore
51
+ from plain.chores import Chore, register_chore
52
+
52
53
 
54
+ @register_chore
55
+ class ChoreName(Chore):
56
+ """A chore description can go here."""
53
57
 
54
- @register_chore("app")
55
- def chore_name():
56
- """
57
- A chore description can go here
58
- """
59
- # Do a thing!
60
- return "We did it!"
58
+ def run(self):
59
+ # Do a thing!
60
+ return "We did it!"
61
61
  ```
62
62
 
63
+ ### Best practices
64
+
63
65
  A good chore is:
64
66
 
65
- - Fast
66
- - Idempotent
67
- - Recurring
68
- - Stateless
67
+ - **Fast** - Should complete quickly, not block for long periods
68
+ - **Idempotent** - Safe to run multiple times without side effects
69
+ - **Recurring** - Designed to run regularly, not just once
70
+ - **Stateless** - Doesn't rely on external state between runs
69
71
 
70
72
  If chores are written in `app/chores.py` or `{pkg}/chores.py`, then they will be imported automatically and registered.
@@ -0,0 +1,4 @@
1
+ from .core import Chore
2
+ from .registry import register_chore
3
+
4
+ __all__ = ["Chore", "register_chore"]
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+
7
+ class Chore(ABC):
8
+ """
9
+ Abstract base class for chores.
10
+
11
+ Subclasses must implement:
12
+ - run() method
13
+
14
+ Example:
15
+ @register_chore
16
+ class ClearExpired(Chore):
17
+ '''Delete sessions that have expired.'''
18
+
19
+ def run(self):
20
+ # ... implementation
21
+ return "10 sessions deleted"
22
+ """
23
+
24
+ @abstractmethod
25
+ def run(self) -> Any:
26
+ """Run the chore. Must be implemented by subclasses."""
27
+ pass
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from plain.packages import packages_registry
4
+
5
+ from .core import Chore
6
+
7
+
8
+ class ChoresRegistry:
9
+ def __init__(self) -> None:
10
+ self._chores: dict[str, type[Chore]] = {}
11
+
12
+ def register_chore(self, chore_class: type[Chore]) -> None:
13
+ """
14
+ Register a chore class.
15
+
16
+ Args:
17
+ chore_class: A Chore subclass to register
18
+ """
19
+ name = f"{chore_class.__module__}.{chore_class.__qualname__}"
20
+ self._chores[name] = chore_class
21
+
22
+ def import_modules(self) -> None:
23
+ """
24
+ Import modules from installed packages and app to trigger registration.
25
+ """
26
+ packages_registry.autodiscover_modules("chores", include_app=True)
27
+
28
+ def get_chores(self) -> list[type[Chore]]:
29
+ """
30
+ Get all registered chore classes.
31
+ """
32
+ return list(self._chores.values())
33
+
34
+
35
+ chores_registry = ChoresRegistry()
36
+
37
+
38
+ def register_chore(cls: type[Chore]) -> type[Chore]:
39
+ """
40
+ Decorator to register a chore class.
41
+
42
+ Usage:
43
+ @register_chore
44
+ class ClearExpired(Chore):
45
+ def run(self):
46
+ return "Done!"
47
+ """
48
+ chores_registry.register_chore(cls)
49
+ return cls
@@ -4,6 +4,7 @@
4
4
 
5
5
  - [Overview](#overview)
6
6
  - [Adding commands](#adding-commands)
7
+ - [Shell](#shell)
7
8
 
8
9
  ## Overview
9
10
 
@@ -39,3 +40,28 @@ An example command!
39
40
  ```
40
41
 
41
42
  Technically you can register a CLI from anywhere, but typically you will do it in either `app/cli.py` or a package's `<pkg>/cli.py`, as those modules will be autoloaded by Plain.
43
+
44
+ ## Shell
45
+
46
+ The `plain shell` command starts an interactive Python shell with your Plain app already loaded.
47
+
48
+ ### SHELL_IMPORT
49
+
50
+ You can customize what gets imported automatically when the shell starts by setting `SHELL_IMPORT` to a module path in your settings:
51
+
52
+ ```python
53
+ # app/settings.py
54
+ SHELL_IMPORT = "app.shell"
55
+ ```
56
+
57
+ Then create that module with the objects you want available:
58
+
59
+ ```python
60
+ # app/shell.py
61
+ from app.projects.models import Project
62
+ from app.users.models import User
63
+
64
+ __all__ = ["Project", "User"]
65
+ ```
66
+
67
+ Now when you run `plain shell`, those objects will be automatically imported and available.
@@ -13,11 +13,10 @@ def chores() -> None:
13
13
 
14
14
 
15
15
  @chores.command("list")
16
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
17
16
  @click.option(
18
17
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
19
18
  )
20
- def list_chores(group: tuple[str, ...], name: tuple[str, ...]) -> None:
19
+ def list_chores(name: tuple[str, ...]) -> None:
21
20
  """
22
21
  List all registered chores.
23
22
  """
@@ -25,32 +24,33 @@ def list_chores(group: tuple[str, ...], name: tuple[str, ...]) -> None:
25
24
 
26
25
  chores_registry.import_modules()
27
26
 
28
- if group or name:
29
- chores = [
30
- chore
31
- for chore in chores_registry.get_chores()
32
- if (chore.group in group or not group) and (chore.name in name or not name)
27
+ chore_classes = chores_registry.get_chores()
28
+
29
+ if name:
30
+ chore_classes = [
31
+ chore_class
32
+ for chore_class in chore_classes
33
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
33
34
  ]
34
- else:
35
- chores = chores_registry.get_chores()
36
35
 
37
- for chore in chores:
38
- click.secho(f"{chore}", bold=True, nl=False)
39
- if chore.description:
40
- click.echo(f": {chore.description}")
36
+ for chore_class in chore_classes:
37
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
38
+ click.secho(f"{chore_name}", bold=True, nl=False)
39
+ description = chore_class.__doc__.strip() if chore_class.__doc__ else ""
40
+ if description:
41
+ click.secho(f": {description}", dim=True)
41
42
  else:
42
43
  click.echo("")
43
44
 
44
45
 
45
46
  @chores.command("run")
46
- @click.option("--group", default=None, type=str, help="Group to run", multiple=True)
47
47
  @click.option(
48
48
  "--name", default=None, type=str, help="Name of the chore to run", multiple=True
49
49
  )
50
50
  @click.option(
51
51
  "--dry-run", is_flag=True, help="Show what would be done without executing"
52
52
  )
53
- def run_chores(group: tuple[str, ...], name: tuple[str, ...], dry_run: bool) -> None:
53
+ def run_chores(name: tuple[str, ...], dry_run: bool) -> None:
54
54
  """
55
55
  Run the specified chores.
56
56
  """
@@ -58,28 +58,30 @@ def run_chores(group: tuple[str, ...], name: tuple[str, ...], dry_run: bool) ->
58
58
 
59
59
  chores_registry.import_modules()
60
60
 
61
- if group or name:
62
- chores = [
63
- chore
64
- for chore in chores_registry.get_chores()
65
- if (chore.group in group or not group) and (chore.name in name or not name)
61
+ chore_classes = chores_registry.get_chores()
62
+
63
+ if name:
64
+ chore_classes = [
65
+ chore_class
66
+ for chore_class in chore_classes
67
+ if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
66
68
  ]
67
- else:
68
- chores = chores_registry.get_chores()
69
69
 
70
70
  chores_failed = []
71
71
 
72
- for chore in chores:
73
- click.echo(f"{chore.name}:", nl=False)
72
+ for chore_class in chore_classes:
73
+ chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
74
+ click.echo(f"{chore_name}:", nl=False)
74
75
  if dry_run:
75
76
  click.secho(" (dry run)", fg="yellow", nl=False)
76
77
  else:
77
78
  try:
79
+ chore = chore_class()
78
80
  result = chore.run()
79
81
  except Exception:
80
82
  click.secho(" Failed", fg="red")
81
- chores_failed.append(chore)
82
- logger.exception(f"Error running chore {chore.name}")
83
+ chores_failed.append(chore_class)
84
+ logger.exception(f"Error running chore {chore_name}")
83
85
  continue
84
86
 
85
87
  if result is None:
@@ -58,12 +58,6 @@ from plain.cli.runtime import without_runtime_setup
58
58
  is_flag=True,
59
59
  help="Restart workers when code changes (dev only)",
60
60
  )
61
- @click.option(
62
- "--reload-extra-file",
63
- multiple=True,
64
- type=click.Path(exists=True),
65
- help="Additional files to watch for reload (can be used multiple times)",
66
- )
67
61
  @click.option(
68
62
  "--access-log",
69
63
  default="-",
@@ -109,7 +103,6 @@ def server(
109
103
  keyfile: str | None,
110
104
  log_level: str,
111
105
  reload: bool,
112
- reload_extra_file: tuple[str, ...],
113
106
  access_log: str,
114
107
  error_log: str,
115
108
  log_format: str,
@@ -130,7 +123,6 @@ def server(
130
123
  timeout=timeout,
131
124
  max_requests=max_requests,
132
125
  reload=reload,
133
- reload_extra_files=list(reload_extra_file) if reload_extra_file else [],
134
126
  pidfile=pidfile,
135
127
  certfile=certfile,
136
128
  keyfile=keyfile,
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import os.path
5
+ import re
6
+ import sys
7
+ import threading
8
+ from collections.abc import Callable
9
+
10
+ import watchfiles
11
+
12
+ COMPILED_EXT_RE = re.compile(r"py[co]$")
13
+
14
+
15
+ class Reloader(threading.Thread):
16
+ """File change reloader using watchfiles for cross-platform native file watching."""
17
+
18
+ def __init__(self, callback: Callable[[str], None], watch_html: bool) -> None:
19
+ super().__init__()
20
+ self.daemon = True
21
+ self._callback = callback
22
+ self._watch_html = watch_html
23
+
24
+ def get_watch_paths(self) -> set[str]:
25
+ """Get all directories to watch for changes."""
26
+ paths = set()
27
+
28
+ # Get directories from loaded Python modules
29
+ for module in tuple(sys.modules.values()):
30
+ if not hasattr(module, "__file__") or not module.__file__:
31
+ continue
32
+ # Convert .pyc/.pyo to .py and get directory
33
+ file_path = COMPILED_EXT_RE.sub("py", module.__file__)
34
+ dir_path = os.path.dirname(os.path.abspath(file_path))
35
+ if os.path.isdir(dir_path):
36
+ paths.add(dir_path)
37
+
38
+ # Add current working directory for .env files
39
+ cwd = os.getcwd()
40
+ if os.path.isdir(cwd):
41
+ paths.add(cwd)
42
+
43
+ return paths
44
+
45
+ def run(self) -> None:
46
+ """Watch for file changes and trigger callback."""
47
+ watch_paths = self.get_watch_paths()
48
+
49
+ for changes in watchfiles.watch(*watch_paths, rust_timeout=1000):
50
+ for change_type, file_path in changes:
51
+ should_reload = False
52
+ filename = os.path.basename(file_path)
53
+
54
+ # Python files: reload on modify/add
55
+ if change_type in (watchfiles.Change.modified, watchfiles.Change.added):
56
+ if file_path.endswith(".py"):
57
+ should_reload = True
58
+
59
+ # .env files: reload on modify/add/delete
60
+ if change_type in (
61
+ watchfiles.Change.modified,
62
+ watchfiles.Change.added,
63
+ watchfiles.Change.deleted,
64
+ ):
65
+ if filename.startswith(".env"):
66
+ should_reload = True
67
+
68
+ # HTML files: only reload on add/delete (Jinja auto-reloads modifications)
69
+ if self._watch_html and change_type in (
70
+ watchfiles.Change.added,
71
+ watchfiles.Change.deleted,
72
+ ):
73
+ if file_path.endswith(".html"):
74
+ should_reload = True
75
+
76
+ if should_reload:
77
+ self._callback(file_path)
@@ -39,8 +39,7 @@ Common options:
39
39
  - `--workers` / `-w` - Number of worker processes (default: 1, or `$WEB_CONCURRENCY` env var)
40
40
  - `--threads` - Number of threads per worker (default: 1)
41
41
  - `--timeout` / `-t` - Worker timeout in seconds (default: 30)
42
- - `--reload` - Enable auto-reload on code changes (default: False)
43
- - `--reload-extra-file` - Additional files to watch for reloading (can be used multiple times)
42
+ - `--reload` - Enable auto-reload on code changes, including `.env*` files (default: False)
44
43
  - `--certfile` - Path to SSL certificate file
45
44
  - `--keyfile` - Path to SSL key file
46
45
  - `--log-level` - Logging level: debug, info, warning, error, critical (default: info)
@@ -490,7 +490,7 @@ class Arbiter:
490
490
  # Process Child
491
491
  worker.pid = os.getpid()
492
492
  try:
493
- self.log.info("Plain server worker started pid=%s", worker.pid)
493
+ self.log.info("Server worker started pid=%s", worker.pid)
494
494
  worker.init_process()
495
495
  sys.exit(0)
496
496
  except SystemExit:
@@ -506,7 +506,7 @@ class Arbiter:
506
506
  sys.exit(self.WORKER_BOOT_ERROR)
507
507
  sys.exit(-1)
508
508
  finally:
509
- self.log.info("Worker exiting (pid: %s)", worker.pid)
509
+ self.log.info("Server worker exiting (pid: %s)", worker.pid)
510
510
  try:
511
511
  worker.tmp.close()
512
512
  except Exception:
@@ -10,6 +10,8 @@ import os
10
10
  from dataclasses import dataclass
11
11
 
12
12
  from . import util
13
+ from .workers.sync import SyncWorker
14
+ from .workers.thread import ThreadWorker
13
15
 
14
16
 
15
17
  @dataclass
@@ -27,7 +29,6 @@ class Config:
27
29
  timeout: int
28
30
  max_requests: int
29
31
  reload: bool
30
- reload_extra_files: list[str]
31
32
  pidfile: str | None
32
33
  certfile: str | None
33
34
  keyfile: str | None
@@ -41,20 +42,19 @@ class Config:
41
42
  def worker_class_str(self) -> str:
42
43
  # Auto-select based on threads
43
44
  if self.threads > 1:
44
- return "gthread"
45
+ return "thread"
45
46
  return "sync"
46
47
 
47
48
  @property
48
49
  def worker_class(self) -> type:
49
50
  # Auto-select based on threads
50
51
  if self.threads > 1:
51
- uri = "plain.server.workers.gthread.ThreadWorker"
52
+ worker_class = ThreadWorker
52
53
  else:
53
- uri = "plain.server.workers.sync.SyncWorker"
54
+ worker_class = SyncWorker
54
55
 
55
- worker_class = util.load_class(uri)
56
56
  if hasattr(worker_class, "setup"):
57
- worker_class.setup() # type: ignore[call-non-callable] # hasattr check doesn't narrow type
57
+ worker_class.setup()
58
58
  return worker_class
59
59
 
60
60
  @property
@@ -10,8 +10,6 @@ import email.utils
10
10
  import errno
11
11
  import fcntl
12
12
  import html
13
- import importlib
14
- import inspect
15
13
  import io
16
14
  import os
17
15
  import random
@@ -20,14 +18,11 @@ import socket
20
18
  import sys
21
19
  import textwrap
22
20
  import time
23
- import traceback
24
21
  import urllib.parse
25
22
  import warnings
26
23
  from collections.abc import Callable
27
24
  from typing import Any
28
25
 
29
- from .workers import SUPPORTED_WORKERS
30
-
31
26
  # Server and Date aren't technically hop-by-hop
32
27
  # headers, but they are in the purview of the
33
28
  # origin server which the WSGI spec says we should
@@ -44,55 +39,6 @@ hop_headers = set(
44
39
  """.split()
45
40
  )
46
41
 
47
-
48
- def load_class(
49
- uri: str | type,
50
- default: str = "plain.server.workers.sync.SyncWorker",
51
- section: str = "plain.server.workers",
52
- ) -> type:
53
- if inspect.isclass(uri):
54
- return uri # type: ignore[return-value]
55
-
56
- components = uri.split(".") # type: ignore[union-attr]
57
- if len(components) == 1:
58
- # Handle short names like "sync" or "gthread"
59
- if uri.startswith("#"): # type: ignore[union-attr]
60
- uri = uri[1:] # type: ignore[union-attr]
61
-
62
- if uri in SUPPORTED_WORKERS:
63
- components = SUPPORTED_WORKERS[uri].split(".")
64
- else:
65
- exc_msg = f"Worker type {uri!r} not found in SUPPORTED_WORKERS"
66
- raise RuntimeError(exc_msg)
67
-
68
- klass = components.pop(-1)
69
-
70
- try:
71
- mod = importlib.import_module(".".join(components))
72
- except Exception:
73
- exc = traceback.format_exc()
74
- msg = "class uri %r invalid or not found: \n\n[%s]"
75
- raise RuntimeError(msg % (uri, exc))
76
- return getattr(mod, klass)
77
-
78
-
79
- positionals = (
80
- inspect.Parameter.POSITIONAL_ONLY,
81
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
82
- )
83
-
84
-
85
- def get_arity(f: Callable[..., Any]) -> int:
86
- sig = inspect.signature(f)
87
- arity = 0
88
-
89
- for param in sig.parameters.values():
90
- if param.kind in positionals:
91
- arity += 1
92
-
93
- return arity
94
-
95
-
96
42
  if sys.platform.startswith("win"):
97
43
 
98
44
  def _waitfor(
@@ -317,19 +263,6 @@ def has_fileno(obj: Any) -> bool:
317
263
  return True
318
264
 
319
265
 
320
- def warn(msg: str) -> None:
321
- print("!!!", file=sys.stderr)
322
-
323
- lines = msg.splitlines()
324
- for i, line in enumerate(lines):
325
- if i == 0:
326
- line = f"WARNING: {line}"
327
- print(f"!!! {line}", file=sys.stderr)
328
-
329
- print("!!!\n", file=sys.stderr)
330
- sys.stderr.flush()
331
-
332
-
333
266
  def make_fail_app(msg: str | bytes) -> Callable[..., Any]:
334
267
  msg = to_bytestring(msg)
335
268
 
@@ -0,0 +1,6 @@
1
+ #
2
+ #
3
+ # This file is part of gunicorn released under the MIT license.
4
+ # See the LICENSE for more information.
5
+ #
6
+ # Vendored and modified for Plain.