plain 0.77.0__tar.gz → 0.78.1__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 (211) hide show
  1. {plain-0.77.0 → plain-0.78.1}/PKG-INFO +1 -1
  2. {plain-0.77.0 → plain-0.78.1}/plain/CHANGELOG.md +43 -0
  3. {plain-0.77.0 → plain-0.78.1}/plain/chores/README.md +24 -22
  4. plain-0.78.1/plain/chores/__init__.py +4 -0
  5. plain-0.78.1/plain/chores/core.py +27 -0
  6. plain-0.78.1/plain/chores/registry.py +49 -0
  7. {plain-0.77.0 → plain-0.78.1}/plain/cli/README.md +26 -0
  8. {plain-0.77.0 → plain-0.78.1}/plain/cli/chores.py +28 -26
  9. {plain-0.77.0 → plain-0.78.1}/plain/logs/configure.py +1 -2
  10. {plain-0.77.0 → plain-0.78.1}/plain/views/base.py +2 -3
  11. {plain-0.77.0 → plain-0.78.1}/pyproject.toml +1 -1
  12. plain-0.77.0/plain/chores/__init__.py +0 -3
  13. plain-0.77.0/plain/chores/registry.py +0 -67
  14. {plain-0.77.0 → plain-0.78.1}/.gitignore +0 -0
  15. {plain-0.77.0 → plain-0.78.1}/LICENSE +0 -0
  16. {plain-0.77.0 → plain-0.78.1}/README.md +0 -0
  17. {plain-0.77.0 → plain-0.78.1}/plain/AGENTS.md +0 -0
  18. {plain-0.77.0 → plain-0.78.1}/plain/README.md +0 -0
  19. {plain-0.77.0 → plain-0.78.1}/plain/__main__.py +0 -0
  20. {plain-0.77.0 → plain-0.78.1}/plain/assets/README.md +0 -0
  21. {plain-0.77.0 → plain-0.78.1}/plain/assets/__init__.py +0 -0
  22. {plain-0.77.0 → plain-0.78.1}/plain/assets/compile.py +0 -0
  23. {plain-0.77.0 → plain-0.78.1}/plain/assets/finders.py +0 -0
  24. {plain-0.77.0 → plain-0.78.1}/plain/assets/fingerprints.py +0 -0
  25. {plain-0.77.0 → plain-0.78.1}/plain/assets/urls.py +0 -0
  26. {plain-0.77.0 → plain-0.78.1}/plain/assets/views.py +0 -0
  27. {plain-0.77.0 → plain-0.78.1}/plain/cli/__init__.py +0 -0
  28. {plain-0.77.0 → plain-0.78.1}/plain/cli/agent/__init__.py +0 -0
  29. {plain-0.77.0 → plain-0.78.1}/plain/cli/agent/docs.py +0 -0
  30. {plain-0.77.0 → plain-0.78.1}/plain/cli/agent/llmdocs.py +0 -0
  31. {plain-0.77.0 → plain-0.78.1}/plain/cli/agent/md.py +0 -0
  32. {plain-0.77.0 → plain-0.78.1}/plain/cli/agent/prompt.py +0 -0
  33. {plain-0.77.0 → plain-0.78.1}/plain/cli/agent/request.py +0 -0
  34. {plain-0.77.0 → plain-0.78.1}/plain/cli/build.py +0 -0
  35. {plain-0.77.0 → plain-0.78.1}/plain/cli/changelog.py +0 -0
  36. {plain-0.77.0 → plain-0.78.1}/plain/cli/core.py +0 -0
  37. {plain-0.77.0 → plain-0.78.1}/plain/cli/docs.py +0 -0
  38. {plain-0.77.0 → plain-0.78.1}/plain/cli/formatting.py +0 -0
  39. {plain-0.77.0 → plain-0.78.1}/plain/cli/install.py +0 -0
  40. {plain-0.77.0 → plain-0.78.1}/plain/cli/output.py +0 -0
  41. {plain-0.77.0 → plain-0.78.1}/plain/cli/preflight.py +0 -0
  42. {plain-0.77.0 → plain-0.78.1}/plain/cli/print.py +0 -0
  43. {plain-0.77.0 → plain-0.78.1}/plain/cli/registry.py +0 -0
  44. {plain-0.77.0 → plain-0.78.1}/plain/cli/runtime.py +0 -0
  45. {plain-0.77.0 → plain-0.78.1}/plain/cli/scaffold.py +0 -0
  46. {plain-0.77.0 → plain-0.78.1}/plain/cli/server.py +0 -0
  47. {plain-0.77.0 → plain-0.78.1}/plain/cli/settings.py +0 -0
  48. {plain-0.77.0 → plain-0.78.1}/plain/cli/shell.py +0 -0
  49. {plain-0.77.0 → plain-0.78.1}/plain/cli/startup.py +0 -0
  50. {plain-0.77.0 → plain-0.78.1}/plain/cli/upgrade.py +0 -0
  51. {plain-0.77.0 → plain-0.78.1}/plain/cli/urls.py +0 -0
  52. {plain-0.77.0 → plain-0.78.1}/plain/cli/utils.py +0 -0
  53. {plain-0.77.0 → plain-0.78.1}/plain/csrf/README.md +0 -0
  54. {plain-0.77.0 → plain-0.78.1}/plain/csrf/middleware.py +0 -0
  55. {plain-0.77.0 → plain-0.78.1}/plain/csrf/views.py +0 -0
  56. {plain-0.77.0 → plain-0.78.1}/plain/debug.py +0 -0
  57. {plain-0.77.0 → plain-0.78.1}/plain/exceptions.py +0 -0
  58. {plain-0.77.0 → plain-0.78.1}/plain/forms/README.md +0 -0
  59. {plain-0.77.0 → plain-0.78.1}/plain/forms/__init__.py +0 -0
  60. {plain-0.77.0 → plain-0.78.1}/plain/forms/boundfield.py +0 -0
  61. {plain-0.77.0 → plain-0.78.1}/plain/forms/exceptions.py +0 -0
  62. {plain-0.77.0 → plain-0.78.1}/plain/forms/fields.py +0 -0
  63. {plain-0.77.0 → plain-0.78.1}/plain/forms/forms.py +0 -0
  64. {plain-0.77.0 → plain-0.78.1}/plain/http/README.md +0 -0
  65. {plain-0.77.0 → plain-0.78.1}/plain/http/__init__.py +0 -0
  66. {plain-0.77.0 → plain-0.78.1}/plain/http/cookie.py +0 -0
  67. {plain-0.77.0 → plain-0.78.1}/plain/http/multipartparser.py +0 -0
  68. {plain-0.77.0 → plain-0.78.1}/plain/http/request.py +0 -0
  69. {plain-0.77.0 → plain-0.78.1}/plain/http/response.py +0 -0
  70. {plain-0.77.0 → plain-0.78.1}/plain/internal/__init__.py +0 -0
  71. {plain-0.77.0 → plain-0.78.1}/plain/internal/files/__init__.py +0 -0
  72. {plain-0.77.0 → plain-0.78.1}/plain/internal/files/base.py +0 -0
  73. {plain-0.77.0 → plain-0.78.1}/plain/internal/files/locks.py +0 -0
  74. {plain-0.77.0 → plain-0.78.1}/plain/internal/files/move.py +0 -0
  75. {plain-0.77.0 → plain-0.78.1}/plain/internal/files/temp.py +0 -0
  76. {plain-0.77.0 → plain-0.78.1}/plain/internal/files/uploadedfile.py +0 -0
  77. {plain-0.77.0 → plain-0.78.1}/plain/internal/files/uploadhandler.py +0 -0
  78. {plain-0.77.0 → plain-0.78.1}/plain/internal/files/utils.py +0 -0
  79. {plain-0.77.0 → plain-0.78.1}/plain/internal/handlers/__init__.py +0 -0
  80. {plain-0.77.0 → plain-0.78.1}/plain/internal/handlers/base.py +0 -0
  81. {plain-0.77.0 → plain-0.78.1}/plain/internal/handlers/exception.py +0 -0
  82. {plain-0.77.0 → plain-0.78.1}/plain/internal/handlers/wsgi.py +0 -0
  83. {plain-0.77.0 → plain-0.78.1}/plain/internal/middleware/__init__.py +0 -0
  84. {plain-0.77.0 → plain-0.78.1}/plain/internal/middleware/headers.py +0 -0
  85. {plain-0.77.0 → plain-0.78.1}/plain/internal/middleware/hosts.py +0 -0
  86. {plain-0.77.0 → plain-0.78.1}/plain/internal/middleware/https.py +0 -0
  87. {plain-0.77.0 → plain-0.78.1}/plain/internal/middleware/slash.py +0 -0
  88. {plain-0.77.0 → plain-0.78.1}/plain/internal/reloader.py +0 -0
  89. {plain-0.77.0 → plain-0.78.1}/plain/json.py +0 -0
  90. {plain-0.77.0 → plain-0.78.1}/plain/logs/README.md +0 -0
  91. {plain-0.77.0 → plain-0.78.1}/plain/logs/__init__.py +0 -0
  92. {plain-0.77.0 → plain-0.78.1}/plain/logs/debug.py +0 -0
  93. {plain-0.77.0 → plain-0.78.1}/plain/logs/formatters.py +0 -0
  94. {plain-0.77.0 → plain-0.78.1}/plain/logs/loggers.py +0 -0
  95. {plain-0.77.0 → plain-0.78.1}/plain/logs/utils.py +0 -0
  96. {plain-0.77.0 → plain-0.78.1}/plain/packages/README.md +0 -0
  97. {plain-0.77.0 → plain-0.78.1}/plain/packages/__init__.py +0 -0
  98. {plain-0.77.0 → plain-0.78.1}/plain/packages/config.py +0 -0
  99. {plain-0.77.0 → plain-0.78.1}/plain/packages/registry.py +0 -0
  100. {plain-0.77.0 → plain-0.78.1}/plain/paginator.py +0 -0
  101. {plain-0.77.0 → plain-0.78.1}/plain/preflight/README.md +0 -0
  102. {plain-0.77.0 → plain-0.78.1}/plain/preflight/__init__.py +0 -0
  103. {plain-0.77.0 → plain-0.78.1}/plain/preflight/checks.py +0 -0
  104. {plain-0.77.0 → plain-0.78.1}/plain/preflight/files.py +0 -0
  105. {plain-0.77.0 → plain-0.78.1}/plain/preflight/registry.py +0 -0
  106. {plain-0.77.0 → plain-0.78.1}/plain/preflight/results.py +0 -0
  107. {plain-0.77.0 → plain-0.78.1}/plain/preflight/security.py +0 -0
  108. {plain-0.77.0 → plain-0.78.1}/plain/preflight/urls.py +0 -0
  109. {plain-0.77.0 → plain-0.78.1}/plain/runtime/README.md +0 -0
  110. {plain-0.77.0 → plain-0.78.1}/plain/runtime/__init__.py +0 -0
  111. {plain-0.77.0 → plain-0.78.1}/plain/runtime/global_settings.py +0 -0
  112. {plain-0.77.0 → plain-0.78.1}/plain/runtime/user_settings.py +0 -0
  113. {plain-0.77.0 → plain-0.78.1}/plain/runtime/utils.py +0 -0
  114. {plain-0.77.0 → plain-0.78.1}/plain/server/LICENSE +0 -0
  115. {plain-0.77.0 → plain-0.78.1}/plain/server/README.md +0 -0
  116. {plain-0.77.0 → plain-0.78.1}/plain/server/__init__.py +0 -0
  117. {plain-0.77.0 → plain-0.78.1}/plain/server/app.py +0 -0
  118. {plain-0.77.0 → plain-0.78.1}/plain/server/arbiter.py +0 -0
  119. {plain-0.77.0 → plain-0.78.1}/plain/server/config.py +0 -0
  120. {plain-0.77.0 → plain-0.78.1}/plain/server/errors.py +0 -0
  121. {plain-0.77.0 → plain-0.78.1}/plain/server/glogging.py +0 -0
  122. {plain-0.77.0 → plain-0.78.1}/plain/server/http/__init__.py +0 -0
  123. {plain-0.77.0 → plain-0.78.1}/plain/server/http/body.py +0 -0
  124. {plain-0.77.0 → plain-0.78.1}/plain/server/http/errors.py +0 -0
  125. {plain-0.77.0 → plain-0.78.1}/plain/server/http/message.py +0 -0
  126. {plain-0.77.0 → plain-0.78.1}/plain/server/http/parser.py +0 -0
  127. {plain-0.77.0 → plain-0.78.1}/plain/server/http/unreader.py +0 -0
  128. {plain-0.77.0 → plain-0.78.1}/plain/server/http/wsgi.py +0 -0
  129. {plain-0.77.0 → plain-0.78.1}/plain/server/pidfile.py +0 -0
  130. {plain-0.77.0 → plain-0.78.1}/plain/server/sock.py +0 -0
  131. {plain-0.77.0 → plain-0.78.1}/plain/server/util.py +0 -0
  132. {plain-0.77.0 → plain-0.78.1}/plain/server/workers/__init__.py +0 -0
  133. {plain-0.77.0 → plain-0.78.1}/plain/server/workers/base.py +0 -0
  134. {plain-0.77.0 → plain-0.78.1}/plain/server/workers/sync.py +0 -0
  135. {plain-0.77.0 → plain-0.78.1}/plain/server/workers/thread.py +0 -0
  136. {plain-0.77.0 → plain-0.78.1}/plain/server/workers/workertmp.py +0 -0
  137. {plain-0.77.0 → plain-0.78.1}/plain/signals/README.md +0 -0
  138. {plain-0.77.0 → plain-0.78.1}/plain/signals/__init__.py +0 -0
  139. {plain-0.77.0 → plain-0.78.1}/plain/signals/dispatch/__init__.py +0 -0
  140. {plain-0.77.0 → plain-0.78.1}/plain/signals/dispatch/dispatcher.py +0 -0
  141. {plain-0.77.0 → plain-0.78.1}/plain/signals/dispatch/license.txt +0 -0
  142. {plain-0.77.0 → plain-0.78.1}/plain/signing.py +0 -0
  143. {plain-0.77.0 → plain-0.78.1}/plain/templates/AGENTS.md +0 -0
  144. {plain-0.77.0 → plain-0.78.1}/plain/templates/README.md +0 -0
  145. {plain-0.77.0 → plain-0.78.1}/plain/templates/__init__.py +0 -0
  146. {plain-0.77.0 → plain-0.78.1}/plain/templates/core.py +0 -0
  147. {plain-0.77.0 → plain-0.78.1}/plain/templates/jinja/__init__.py +0 -0
  148. {plain-0.77.0 → plain-0.78.1}/plain/templates/jinja/environments.py +0 -0
  149. {plain-0.77.0 → plain-0.78.1}/plain/templates/jinja/extensions.py +0 -0
  150. {plain-0.77.0 → plain-0.78.1}/plain/templates/jinja/filters.py +0 -0
  151. {plain-0.77.0 → plain-0.78.1}/plain/templates/jinja/globals.py +0 -0
  152. {plain-0.77.0 → plain-0.78.1}/plain/test/README.md +0 -0
  153. {plain-0.77.0 → plain-0.78.1}/plain/test/__init__.py +0 -0
  154. {plain-0.77.0 → plain-0.78.1}/plain/test/client.py +0 -0
  155. {plain-0.77.0 → plain-0.78.1}/plain/test/encoding.py +0 -0
  156. {plain-0.77.0 → plain-0.78.1}/plain/test/exceptions.py +0 -0
  157. {plain-0.77.0 → plain-0.78.1}/plain/urls/README.md +0 -0
  158. {plain-0.77.0 → plain-0.78.1}/plain/urls/__init__.py +0 -0
  159. {plain-0.77.0 → plain-0.78.1}/plain/urls/converters.py +0 -0
  160. {plain-0.77.0 → plain-0.78.1}/plain/urls/exceptions.py +0 -0
  161. {plain-0.77.0 → plain-0.78.1}/plain/urls/patterns.py +0 -0
  162. {plain-0.77.0 → plain-0.78.1}/plain/urls/resolvers.py +0 -0
  163. {plain-0.77.0 → plain-0.78.1}/plain/urls/routers.py +0 -0
  164. {plain-0.77.0 → plain-0.78.1}/plain/urls/utils.py +0 -0
  165. {plain-0.77.0 → plain-0.78.1}/plain/utils/README.md +0 -0
  166. {plain-0.77.0 → plain-0.78.1}/plain/utils/__init__.py +0 -0
  167. {plain-0.77.0 → plain-0.78.1}/plain/utils/cache.py +0 -0
  168. {plain-0.77.0 → plain-0.78.1}/plain/utils/crypto.py +0 -0
  169. {plain-0.77.0 → plain-0.78.1}/plain/utils/datastructures.py +0 -0
  170. {plain-0.77.0 → plain-0.78.1}/plain/utils/dateparse.py +0 -0
  171. {plain-0.77.0 → plain-0.78.1}/plain/utils/deconstruct.py +0 -0
  172. {plain-0.77.0 → plain-0.78.1}/plain/utils/decorators.py +0 -0
  173. {plain-0.77.0 → plain-0.78.1}/plain/utils/duration.py +0 -0
  174. {plain-0.77.0 → plain-0.78.1}/plain/utils/encoding.py +0 -0
  175. {plain-0.77.0 → plain-0.78.1}/plain/utils/functional.py +0 -0
  176. {plain-0.77.0 → plain-0.78.1}/plain/utils/hashable.py +0 -0
  177. {plain-0.77.0 → plain-0.78.1}/plain/utils/html.py +0 -0
  178. {plain-0.77.0 → plain-0.78.1}/plain/utils/http.py +0 -0
  179. {plain-0.77.0 → plain-0.78.1}/plain/utils/inspect.py +0 -0
  180. {plain-0.77.0 → plain-0.78.1}/plain/utils/ipv6.py +0 -0
  181. {plain-0.77.0 → plain-0.78.1}/plain/utils/itercompat.py +0 -0
  182. {plain-0.77.0 → plain-0.78.1}/plain/utils/module_loading.py +0 -0
  183. {plain-0.77.0 → plain-0.78.1}/plain/utils/regex_helper.py +0 -0
  184. {plain-0.77.0 → plain-0.78.1}/plain/utils/safestring.py +0 -0
  185. {plain-0.77.0 → plain-0.78.1}/plain/utils/text.py +0 -0
  186. {plain-0.77.0 → plain-0.78.1}/plain/utils/timesince.py +0 -0
  187. {plain-0.77.0 → plain-0.78.1}/plain/utils/timezone.py +0 -0
  188. {plain-0.77.0 → plain-0.78.1}/plain/utils/tree.py +0 -0
  189. {plain-0.77.0 → plain-0.78.1}/plain/validators.py +0 -0
  190. {plain-0.77.0 → plain-0.78.1}/plain/views/README.md +0 -0
  191. {plain-0.77.0 → plain-0.78.1}/plain/views/__init__.py +0 -0
  192. {plain-0.77.0 → plain-0.78.1}/plain/views/errors.py +0 -0
  193. {plain-0.77.0 → plain-0.78.1}/plain/views/exceptions.py +0 -0
  194. {plain-0.77.0 → plain-0.78.1}/plain/views/forms.py +0 -0
  195. {plain-0.77.0 → plain-0.78.1}/plain/views/objects.py +0 -0
  196. {plain-0.77.0 → plain-0.78.1}/plain/views/redirect.py +0 -0
  197. {plain-0.77.0 → plain-0.78.1}/plain/views/templates.py +0 -0
  198. {plain-0.77.0 → plain-0.78.1}/plain/wsgi.py +0 -0
  199. {plain-0.77.0 → plain-0.78.1}/tests/.gitignore +0 -0
  200. {plain-0.77.0 → plain-0.78.1}/tests/app/.gitignore +0 -0
  201. {plain-0.77.0 → plain-0.78.1}/tests/app/settings.py +0 -0
  202. {plain-0.77.0 → plain-0.78.1}/tests/app/test/__init__.py +0 -0
  203. {plain-0.77.0 → plain-0.78.1}/tests/app/test/default_settings.py +0 -0
  204. {plain-0.77.0 → plain-0.78.1}/tests/app/urls.py +0 -0
  205. {plain-0.77.0 → plain-0.78.1}/tests/conftest.py +0 -0
  206. {plain-0.77.0 → plain-0.78.1}/tests/test_cli.py +0 -0
  207. {plain-0.77.0 → plain-0.78.1}/tests/test_csrf.py +0 -0
  208. {plain-0.77.0 → plain-0.78.1}/tests/test_http_hosts.py +0 -0
  209. {plain-0.77.0 → plain-0.78.1}/tests/test_logs.py +0 -0
  210. {plain-0.77.0 → plain-0.78.1}/tests/test_runtime.py +0 -0
  211. {plain-0.77.0 → plain-0.78.1}/tests/test_wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.77.0
3
+ Version: 0.78.1
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,48 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.78.1](https://github.com/dropseed/plain/releases/plain@0.78.1) (2025-10-17)
4
+
5
+ ### What's changed
6
+
7
+ - Fixed job worker logging by using `getLogger` instead of directly instantiating `Logger` for the plain logger ([dd675666b9](https://github.com/dropseed/plain/commit/dd675666b9))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required
12
+
13
+ ## [0.78.0](https://github.com/dropseed/plain/releases/plain@0.78.0) (2025-10-17)
14
+
15
+ ### What's changed
16
+
17
+ - Chores have been refactored to use abstract base classes instead of decorated functions ([c4466d3c60](https://github.com/dropseed/plain/commit/c4466d3c60))
18
+ - Added `SHELL_IMPORT` setting to customize what gets automatically imported in `plain shell` ([9055f59c08](https://github.com/dropseed/plain/commit/9055f59c08))
19
+ - Views that return `None` now raise `Http404` instead of returning `ResponseNotFound` ([5bb60016eb](https://github.com/dropseed/plain/commit/5bb60016eb))
20
+ - The `plain chores list` command output formatting now matches the `plain jobs list` format ([4b6881a49e](https://github.com/dropseed/plain/commit/4b6881a49e))
21
+
22
+ ### Upgrade instructions
23
+
24
+ - Update any chores from decorated functions to class-based chores:
25
+
26
+ ```python
27
+ # Before:
28
+ @register_chore("group")
29
+ def chore_name():
30
+ """Description"""
31
+ return "Done!"
32
+
33
+ # After:
34
+ from plain.chores import Chore, register_chore
35
+
36
+ @register_chore
37
+ class ChoreName(Chore):
38
+ """Description"""
39
+
40
+ def run(self):
41
+ return "Done!"
42
+ ```
43
+
44
+ - Import `Chore` base class from `plain.chores` when creating new chores
45
+
3
46
  ## [0.77.0](https://github.com/dropseed/plain/releases/plain@0.77.0) (2025-10-13)
4
47
 
5
48
  ### 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:
@@ -7,13 +7,12 @@ def configure_logging(
7
7
  *, plain_log_level: int | str, app_log_level: int | str, app_log_format: str
8
8
  ) -> None:
9
9
  # Create and configure the plain logger (uses standard Logger, not AppLogger)
10
- plain_logger = logging.Logger("plain")
10
+ plain_logger = logging.getLogger("plain")
11
11
  plain_logger.setLevel(plain_log_level)
12
12
  plain_handler = logging.StreamHandler()
13
13
  plain_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
14
14
  plain_logger.addHandler(plain_handler)
15
15
  plain_logger.propagate = False
16
- logging.root.manager.loggerDict["plain"] = plain_logger
17
16
 
18
17
  # Configure the existing app_logger
19
18
  from .loggers import app_logger
@@ -12,12 +12,12 @@ from opentelemetry.semconv._incubating.attributes.code_attributes import (
12
12
  )
13
13
 
14
14
  from plain.http import (
15
+ Http404,
15
16
  JsonResponse,
16
17
  Request,
17
18
  Response,
18
19
  ResponseBase,
19
20
  ResponseNotAllowed,
20
- ResponseNotFound,
21
21
  )
22
22
  from plain.utils.decorators import classonlymethod
23
23
 
@@ -110,8 +110,7 @@ class View:
110
110
  return Response(status_code=value)
111
111
 
112
112
  if value is None:
113
- # TODO raise 404 instead?
114
- return ResponseNotFound()
113
+ raise Http404
115
114
 
116
115
  status_code = 200
117
116
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain"
3
- version = "0.77.0"
3
+ version = "0.78.1"
4
4
  description = "A web framework for building products with Python."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  readme = "README.md"
@@ -1,3 +0,0 @@
1
- from .registry import register_chore
2
-
3
- __all__ = ["register_chore"]
@@ -1,67 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from types import FunctionType
4
- from typing import Any
5
-
6
- from plain.packages import packages_registry
7
-
8
-
9
- class Chore:
10
- def __init__(self, *, group: str, func: FunctionType):
11
- self.group = group
12
- self.func = func
13
- self.name = f"{group}.{func.__name__}"
14
- self.description = func.__doc__.strip() if func.__doc__ else ""
15
-
16
- def __str__(self) -> str:
17
- return self.name
18
-
19
- def run(self) -> Any:
20
- """
21
- Run the chore.
22
- """
23
- return self.func()
24
-
25
-
26
- class ChoresRegistry:
27
- def __init__(self):
28
- self._chores: dict[FunctionType, Chore] = {}
29
-
30
- def register_chore(self, chore: Chore) -> None:
31
- """
32
- Register a chore with the specified name.
33
- """
34
- self._chores[chore.func] = chore
35
-
36
- def import_modules(self) -> None:
37
- """
38
- Import modules from installed packages and app to trigger registration.
39
- """
40
- packages_registry.autodiscover_modules("chores", include_app=True)
41
-
42
- def get_chores(self) -> list[Chore]:
43
- """
44
- Get all registered chores.
45
- """
46
- return list(self._chores.values())
47
-
48
-
49
- chores_registry = ChoresRegistry()
50
-
51
-
52
- def register_chore(group: str) -> Any:
53
- """
54
- Register a chore with a given group.
55
-
56
- Usage:
57
- @register_chore("clear_expired")
58
- def clear_expired():
59
- pass
60
- """
61
-
62
- def wrapper(func: FunctionType) -> FunctionType:
63
- chore = Chore(group=group, func=func)
64
- chores_registry.register_chore(chore)
65
- return func
66
-
67
- return wrapper
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes