plain 0.86.2__tar.gz → 0.97.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 (227) hide show
  1. {plain-0.86.2 → plain-0.97.0}/.gitignore +2 -0
  2. {plain-0.86.2 → plain-0.97.0}/PKG-INFO +1 -1
  3. {plain-0.86.2 → plain-0.97.0}/plain/CHANGELOG.md +194 -0
  4. {plain-0.86.2 → plain-0.97.0}/plain/assets/views.py +25 -17
  5. {plain-0.86.2 → plain-0.97.0}/plain/cli/build.py +4 -5
  6. {plain-0.86.2 → plain-0.97.0}/plain/cli/core.py +8 -6
  7. plain-0.97.0/plain/cli/docs.py +79 -0
  8. {plain-0.86.2 → plain-0.97.0}/plain/cli/formatting.py +4 -2
  9. plain-0.97.0/plain/cli/install.py +38 -0
  10. {plain-0.86.2/plain/cli/agent → plain-0.97.0/plain/cli}/llmdocs.py +3 -1
  11. {plain-0.86.2 → plain-0.97.0}/plain/cli/preflight.py +3 -2
  12. plain-0.97.0/plain/cli/print.py +9 -0
  13. {plain-0.86.2/plain/cli/agent → plain-0.97.0/plain/cli}/request.py +2 -2
  14. {plain-0.86.2 → plain-0.97.0}/plain/cli/runtime.py +2 -2
  15. {plain-0.86.2 → plain-0.97.0}/plain/cli/server.py +10 -0
  16. plain-0.97.0/plain/cli/settings.py +64 -0
  17. {plain-0.86.2 → plain-0.97.0}/plain/cli/shell.py +1 -1
  18. plain-0.97.0/plain/cli/skills.py +153 -0
  19. {plain-0.86.2 → plain-0.97.0}/plain/cli/startup.py +6 -5
  20. plain-0.97.0/plain/cli/upgrade.py +81 -0
  21. {plain-0.86.2 → plain-0.97.0}/plain/csrf/README.md +2 -0
  22. {plain-0.86.2 → plain-0.97.0}/plain/csrf/middleware.py +2 -3
  23. {plain-0.86.2 → plain-0.97.0}/plain/exceptions.py +2 -63
  24. plain-0.97.0/plain/forms/__init__.py +59 -0
  25. {plain-0.86.2 → plain-0.97.0}/plain/forms/boundfield.py +1 -1
  26. {plain-0.86.2 → plain-0.97.0}/plain/forms/fields.py +229 -73
  27. {plain-0.86.2 → plain-0.97.0}/plain/forms/forms.py +21 -9
  28. plain-0.97.0/plain/http/__init__.py +64 -0
  29. plain-0.97.0/plain/http/exceptions.py +65 -0
  30. {plain-0.86.2 → plain-0.97.0}/plain/http/multipartparser.py +35 -50
  31. {plain-0.86.2 → plain-0.97.0}/plain/http/request.py +213 -160
  32. {plain-0.86.2 → plain-0.97.0}/plain/http/response.py +12 -46
  33. {plain-0.86.2 → plain-0.97.0}/plain/internal/files/base.py +1 -1
  34. {plain-0.86.2 → plain-0.97.0}/plain/internal/files/temp.py +2 -1
  35. {plain-0.86.2 → plain-0.97.0}/plain/internal/files/uploadedfile.py +10 -7
  36. {plain-0.86.2 → plain-0.97.0}/plain/internal/files/uploadhandler.py +23 -38
  37. {plain-0.86.2 → plain-0.97.0}/plain/internal/files/utils.py +12 -5
  38. {plain-0.86.2 → plain-0.97.0}/plain/internal/handlers/base.py +8 -5
  39. {plain-0.86.2 → plain-0.97.0}/plain/internal/handlers/exception.py +30 -25
  40. {plain-0.86.2 → plain-0.97.0}/plain/internal/handlers/wsgi.py +35 -39
  41. {plain-0.86.2 → plain-0.97.0}/plain/internal/middleware/hosts.py +2 -2
  42. {plain-0.86.2 → plain-0.97.0}/plain/internal/middleware/https.py +2 -2
  43. {plain-0.86.2 → plain-0.97.0}/plain/internal/middleware/slash.py +2 -2
  44. {plain-0.86.2 → plain-0.97.0}/plain/logs/app.py +21 -21
  45. {plain-0.86.2 → plain-0.97.0}/plain/logs/debug.py +1 -1
  46. {plain-0.86.2 → plain-0.97.0}/plain/logs/formatters.py +1 -1
  47. {plain-0.86.2 → plain-0.97.0}/plain/preflight/__init__.py +1 -0
  48. {plain-0.86.2 → plain-0.97.0}/plain/preflight/results.py +1 -1
  49. plain-0.97.0/plain/preflight/settings.py +54 -0
  50. {plain-0.86.2 → plain-0.97.0}/plain/runtime/__init__.py +2 -0
  51. {plain-0.86.2 → plain-0.97.0}/plain/runtime/global_settings.py +22 -12
  52. plain-0.97.0/plain/runtime/secret.py +20 -0
  53. {plain-0.86.2 → plain-0.97.0}/plain/runtime/user_settings.py +77 -16
  54. {plain-0.86.2 → plain-0.97.0}/plain/server/arbiter.py +9 -9
  55. {plain-0.86.2 → plain-0.97.0}/plain/server/glogging.py +1 -1
  56. {plain-0.86.2 → plain-0.97.0}/plain/server/http/body.py +2 -2
  57. {plain-0.86.2 → plain-0.97.0}/plain/server/http/errors.py +7 -2
  58. {plain-0.86.2 → plain-0.97.0}/plain/server/http/message.py +3 -2
  59. {plain-0.86.2 → plain-0.97.0}/plain/server/http/parser.py +3 -2
  60. {plain-0.86.2 → plain-0.97.0}/plain/server/http/wsgi.py +1 -1
  61. {plain-0.86.2 → plain-0.97.0}/plain/server/pidfile.py +2 -1
  62. {plain-0.86.2 → plain-0.97.0}/plain/server/sock.py +22 -1
  63. {plain-0.86.2 → plain-0.97.0}/plain/server/util.py +5 -1
  64. {plain-0.86.2 → plain-0.97.0}/plain/server/workers/base.py +13 -11
  65. {plain-0.86.2 → plain-0.97.0}/plain/server/workers/sync.py +5 -3
  66. {plain-0.86.2 → plain-0.97.0}/plain/server/workers/thread.py +14 -8
  67. {plain-0.86.2 → plain-0.97.0}/plain/signing.py +82 -5
  68. plain-0.97.0/plain/skills/plain-docs/SKILL.md +25 -0
  69. plain-0.97.0/plain/skills/plain-install/SKILL.md +26 -0
  70. plain-0.97.0/plain/skills/plain-principles/SKILL.md +27 -0
  71. plain-0.97.0/plain/skills/plain-request/SKILL.md +32 -0
  72. plain-0.97.0/plain/skills/plain-shell/SKILL.md +24 -0
  73. plain-0.97.0/plain/skills/plain-upgrade/SKILL.md +35 -0
  74. {plain-0.86.2 → plain-0.97.0}/plain/templates/jinja/extensions.py +6 -5
  75. {plain-0.86.2 → plain-0.97.0}/plain/test/client.py +14 -12
  76. {plain-0.86.2 → plain-0.97.0}/plain/test/exceptions.py +3 -6
  77. plain-0.97.0/plain/urls/exceptions.py +9 -0
  78. {plain-0.86.2 → plain-0.97.0}/plain/urls/patterns.py +4 -0
  79. {plain-0.86.2 → plain-0.97.0}/plain/urls/resolvers.py +1 -1
  80. {plain-0.86.2 → plain-0.97.0}/plain/utils/cache.py +5 -5
  81. {plain-0.86.2 → plain-0.97.0}/plain/utils/datastructures.py +11 -8
  82. {plain-0.86.2 → plain-0.97.0}/plain/utils/dateparse.py +3 -3
  83. {plain-0.86.2 → plain-0.97.0}/plain/utils/deconstruct.py +3 -3
  84. {plain-0.86.2 → plain-0.97.0}/plain/utils/functional.py +4 -2
  85. {plain-0.86.2 → plain-0.97.0}/plain/utils/html.py +3 -3
  86. {plain-0.86.2 → plain-0.97.0}/plain/utils/http.py +1 -1
  87. {plain-0.86.2 → plain-0.97.0}/plain/utils/inspect.py +1 -1
  88. {plain-0.86.2 → plain-0.97.0}/plain/utils/regex_helper.py +2 -2
  89. {plain-0.86.2 → plain-0.97.0}/plain/utils/safestring.py +1 -1
  90. {plain-0.86.2 → plain-0.97.0}/plain/utils/text.py +2 -0
  91. {plain-0.86.2 → plain-0.97.0}/plain/utils/timezone.py +5 -5
  92. {plain-0.86.2 → plain-0.97.0}/plain/utils/tree.py +8 -5
  93. {plain-0.86.2 → plain-0.97.0}/plain/validators.py +27 -12
  94. {plain-0.86.2 → plain-0.97.0}/plain/views/__init__.py +0 -1
  95. {plain-0.86.2 → plain-0.97.0}/plain/views/base.py +12 -12
  96. {plain-0.86.2 → plain-0.97.0}/plain/views/errors.py +1 -1
  97. {plain-0.86.2 → plain-0.97.0}/plain/views/forms.py +6 -6
  98. {plain-0.86.2 → plain-0.97.0}/plain/views/objects.py +36 -43
  99. {plain-0.86.2 → plain-0.97.0}/plain/views/redirect.py +11 -12
  100. {plain-0.86.2 → plain-0.97.0}/plain/views/templates.py +4 -2
  101. {plain-0.86.2 → plain-0.97.0}/pyproject.toml +1 -1
  102. {plain-0.86.2 → plain-0.97.0}/tests/test_csrf.py +6 -5
  103. {plain-0.86.2 → plain-0.97.0}/tests/test_logs.py +10 -5
  104. plain-0.86.2/plain/AGENTS.md +0 -18
  105. plain-0.86.2/plain/cli/agent/__init__.py +0 -17
  106. plain-0.86.2/plain/cli/agent/docs.py +0 -81
  107. plain-0.86.2/plain/cli/agent/md.py +0 -106
  108. plain-0.86.2/plain/cli/agent/prompt.py +0 -45
  109. plain-0.86.2/plain/cli/docs.py +0 -39
  110. plain-0.86.2/plain/cli/install.py +0 -82
  111. plain-0.86.2/plain/cli/print.py +0 -9
  112. plain-0.86.2/plain/cli/settings.py +0 -60
  113. plain-0.86.2/plain/cli/upgrade.py +0 -170
  114. plain-0.86.2/plain/forms/__init__.py +0 -8
  115. plain-0.86.2/plain/http/__init__.py +0 -51
  116. plain-0.86.2/plain/templates/AGENTS.md +0 -3
  117. plain-0.86.2/plain/urls/exceptions.py +0 -9
  118. {plain-0.86.2 → plain-0.97.0}/LICENSE +0 -0
  119. {plain-0.86.2 → plain-0.97.0}/README.md +0 -0
  120. {plain-0.86.2 → plain-0.97.0}/plain/README.md +0 -0
  121. {plain-0.86.2 → plain-0.97.0}/plain/__main__.py +0 -0
  122. {plain-0.86.2 → plain-0.97.0}/plain/assets/README.md +0 -0
  123. {plain-0.86.2 → plain-0.97.0}/plain/assets/__init__.py +0 -0
  124. {plain-0.86.2 → plain-0.97.0}/plain/assets/compile.py +0 -0
  125. {plain-0.86.2 → plain-0.97.0}/plain/assets/finders.py +0 -0
  126. {plain-0.86.2 → plain-0.97.0}/plain/assets/fingerprints.py +0 -0
  127. {plain-0.86.2 → plain-0.97.0}/plain/assets/urls.py +0 -0
  128. {plain-0.86.2 → plain-0.97.0}/plain/chores/README.md +0 -0
  129. {plain-0.86.2 → plain-0.97.0}/plain/chores/__init__.py +0 -0
  130. {plain-0.86.2 → plain-0.97.0}/plain/chores/core.py +0 -0
  131. {plain-0.86.2 → plain-0.97.0}/plain/chores/registry.py +0 -0
  132. {plain-0.86.2 → plain-0.97.0}/plain/cli/README.md +0 -0
  133. {plain-0.86.2 → plain-0.97.0}/plain/cli/__init__.py +0 -0
  134. {plain-0.86.2 → plain-0.97.0}/plain/cli/changelog.py +0 -0
  135. {plain-0.86.2 → plain-0.97.0}/plain/cli/chores.py +0 -0
  136. {plain-0.86.2 → plain-0.97.0}/plain/cli/output.py +0 -0
  137. {plain-0.86.2 → plain-0.97.0}/plain/cli/registry.py +0 -0
  138. {plain-0.86.2 → plain-0.97.0}/plain/cli/scaffold.py +0 -0
  139. {plain-0.86.2 → plain-0.97.0}/plain/cli/urls.py +0 -0
  140. {plain-0.86.2 → plain-0.97.0}/plain/cli/utils.py +0 -0
  141. {plain-0.86.2 → plain-0.97.0}/plain/debug.py +0 -0
  142. {plain-0.86.2 → plain-0.97.0}/plain/forms/README.md +0 -0
  143. {plain-0.86.2 → plain-0.97.0}/plain/forms/exceptions.py +0 -0
  144. {plain-0.86.2 → plain-0.97.0}/plain/http/README.md +0 -0
  145. {plain-0.86.2 → plain-0.97.0}/plain/http/cookie.py +0 -0
  146. {plain-0.86.2 → plain-0.97.0}/plain/http/middleware.py +0 -0
  147. {plain-0.86.2 → plain-0.97.0}/plain/internal/__init__.py +0 -0
  148. {plain-0.86.2 → plain-0.97.0}/plain/internal/files/__init__.py +0 -0
  149. {plain-0.86.2 → plain-0.97.0}/plain/internal/files/locks.py +0 -0
  150. {plain-0.86.2 → plain-0.97.0}/plain/internal/files/move.py +0 -0
  151. {plain-0.86.2 → plain-0.97.0}/plain/internal/handlers/__init__.py +0 -0
  152. {plain-0.86.2 → plain-0.97.0}/plain/internal/middleware/__init__.py +0 -0
  153. {plain-0.86.2 → plain-0.97.0}/plain/internal/middleware/headers.py +0 -0
  154. {plain-0.86.2 → plain-0.97.0}/plain/internal/reloader.py +0 -0
  155. {plain-0.86.2 → plain-0.97.0}/plain/json.py +0 -0
  156. {plain-0.86.2 → plain-0.97.0}/plain/logs/README.md +0 -0
  157. {plain-0.86.2 → plain-0.97.0}/plain/logs/__init__.py +0 -0
  158. {plain-0.86.2 → plain-0.97.0}/plain/logs/configure.py +0 -0
  159. {plain-0.86.2 → plain-0.97.0}/plain/logs/filters.py +0 -0
  160. {plain-0.86.2 → plain-0.97.0}/plain/packages/README.md +0 -0
  161. {plain-0.86.2 → plain-0.97.0}/plain/packages/__init__.py +0 -0
  162. {plain-0.86.2 → plain-0.97.0}/plain/packages/config.py +0 -0
  163. {plain-0.86.2 → plain-0.97.0}/plain/packages/registry.py +0 -0
  164. {plain-0.86.2 → plain-0.97.0}/plain/paginator.py +0 -0
  165. {plain-0.86.2 → plain-0.97.0}/plain/preflight/README.md +0 -0
  166. {plain-0.86.2 → plain-0.97.0}/plain/preflight/checks.py +0 -0
  167. {plain-0.86.2 → plain-0.97.0}/plain/preflight/files.py +0 -0
  168. {plain-0.86.2 → plain-0.97.0}/plain/preflight/registry.py +0 -0
  169. {plain-0.86.2 → plain-0.97.0}/plain/preflight/security.py +0 -0
  170. {plain-0.86.2 → plain-0.97.0}/plain/preflight/urls.py +0 -0
  171. {plain-0.86.2 → plain-0.97.0}/plain/runtime/README.md +0 -0
  172. {plain-0.86.2 → plain-0.97.0}/plain/runtime/utils.py +0 -0
  173. {plain-0.86.2 → plain-0.97.0}/plain/server/LICENSE +0 -0
  174. {plain-0.86.2 → plain-0.97.0}/plain/server/README.md +0 -0
  175. {plain-0.86.2 → plain-0.97.0}/plain/server/__init__.py +0 -0
  176. {plain-0.86.2 → plain-0.97.0}/plain/server/app.py +0 -0
  177. {plain-0.86.2 → plain-0.97.0}/plain/server/config.py +0 -0
  178. {plain-0.86.2 → plain-0.97.0}/plain/server/errors.py +0 -0
  179. {plain-0.86.2 → plain-0.97.0}/plain/server/http/__init__.py +0 -0
  180. {plain-0.86.2 → plain-0.97.0}/plain/server/http/unreader.py +0 -0
  181. {plain-0.86.2 → plain-0.97.0}/plain/server/workers/__init__.py +0 -0
  182. {plain-0.86.2 → plain-0.97.0}/plain/server/workers/workertmp.py +0 -0
  183. {plain-0.86.2 → plain-0.97.0}/plain/signals/README.md +0 -0
  184. {plain-0.86.2 → plain-0.97.0}/plain/signals/__init__.py +0 -0
  185. {plain-0.86.2 → plain-0.97.0}/plain/signals/dispatch/__init__.py +0 -0
  186. {plain-0.86.2 → plain-0.97.0}/plain/signals/dispatch/dispatcher.py +0 -0
  187. {plain-0.86.2 → plain-0.97.0}/plain/signals/dispatch/license.txt +0 -0
  188. {plain-0.86.2 → plain-0.97.0}/plain/templates/README.md +0 -0
  189. {plain-0.86.2 → plain-0.97.0}/plain/templates/__init__.py +0 -0
  190. {plain-0.86.2 → plain-0.97.0}/plain/templates/core.py +0 -0
  191. {plain-0.86.2 → plain-0.97.0}/plain/templates/jinja/__init__.py +0 -0
  192. {plain-0.86.2 → plain-0.97.0}/plain/templates/jinja/environments.py +0 -0
  193. {plain-0.86.2 → plain-0.97.0}/plain/templates/jinja/filters.py +0 -0
  194. {plain-0.86.2 → plain-0.97.0}/plain/templates/jinja/globals.py +0 -0
  195. {plain-0.86.2 → plain-0.97.0}/plain/test/README.md +0 -0
  196. {plain-0.86.2 → plain-0.97.0}/plain/test/__init__.py +0 -0
  197. {plain-0.86.2 → plain-0.97.0}/plain/test/encoding.py +0 -0
  198. {plain-0.86.2 → plain-0.97.0}/plain/urls/README.md +0 -0
  199. {plain-0.86.2 → plain-0.97.0}/plain/urls/__init__.py +0 -0
  200. {plain-0.86.2 → plain-0.97.0}/plain/urls/converters.py +0 -0
  201. {plain-0.86.2 → plain-0.97.0}/plain/urls/routers.py +0 -0
  202. {plain-0.86.2 → plain-0.97.0}/plain/urls/utils.py +0 -0
  203. {plain-0.86.2 → plain-0.97.0}/plain/utils/README.md +0 -0
  204. {plain-0.86.2 → plain-0.97.0}/plain/utils/__init__.py +0 -0
  205. {plain-0.86.2 → plain-0.97.0}/plain/utils/crypto.py +0 -0
  206. {plain-0.86.2 → plain-0.97.0}/plain/utils/decorators.py +0 -0
  207. {plain-0.86.2 → plain-0.97.0}/plain/utils/duration.py +0 -0
  208. {plain-0.86.2 → plain-0.97.0}/plain/utils/encoding.py +0 -0
  209. {plain-0.86.2 → plain-0.97.0}/plain/utils/hashable.py +0 -0
  210. {plain-0.86.2 → plain-0.97.0}/plain/utils/ipv6.py +0 -0
  211. {plain-0.86.2 → plain-0.97.0}/plain/utils/itercompat.py +0 -0
  212. {plain-0.86.2 → plain-0.97.0}/plain/utils/module_loading.py +0 -0
  213. {plain-0.86.2 → plain-0.97.0}/plain/utils/timesince.py +0 -0
  214. {plain-0.86.2 → plain-0.97.0}/plain/views/README.md +0 -0
  215. {plain-0.86.2 → plain-0.97.0}/plain/views/exceptions.py +0 -0
  216. {plain-0.86.2 → plain-0.97.0}/plain/wsgi.py +0 -0
  217. {plain-0.86.2 → plain-0.97.0}/tests/.gitignore +0 -0
  218. {plain-0.86.2 → plain-0.97.0}/tests/app/.gitignore +0 -0
  219. {plain-0.86.2 → plain-0.97.0}/tests/app/settings.py +0 -0
  220. {plain-0.86.2 → plain-0.97.0}/tests/app/test/__init__.py +0 -0
  221. {plain-0.86.2 → plain-0.97.0}/tests/app/test/default_settings.py +0 -0
  222. {plain-0.86.2 → plain-0.97.0}/tests/app/urls.py +0 -0
  223. {plain-0.86.2 → plain-0.97.0}/tests/conftest.py +0 -0
  224. {plain-0.86.2 → plain-0.97.0}/tests/test_cli.py +0 -0
  225. {plain-0.86.2 → plain-0.97.0}/tests/test_http_hosts.py +0 -0
  226. {plain-0.86.2 → plain-0.97.0}/tests/test_runtime.py +0 -0
  227. {plain-0.86.2 → plain-0.97.0}/tests/test_wsgi.py +0 -0
@@ -5,6 +5,8 @@
5
5
  __pycache__
6
6
  *.DS_Store
7
7
 
8
+ /*.code-workspace
9
+
8
10
  # Test apps
9
11
  plain*/tests/.plain
10
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.86.2
3
+ Version: 0.97.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-Expression: BSD-3-Clause
@@ -1,5 +1,199 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.97.0](https://github.com/dropseed/plain/releases/plain@0.97.0) (2026-01-13)
4
+
5
+ ### What's changed
6
+
7
+ - HTTP exceptions (`NotFoundError404`, `ForbiddenError403`, `BadRequestError400`, and `SuspiciousOperationError400` variants) moved from `plain.exceptions` to `plain.http.exceptions` and are now exported from `plain.http` ([b61f909e29](https://github.com/dropseed/plain/commit/b61f909e29))
8
+
9
+ ### Upgrade instructions
10
+
11
+ - Update imports of HTTP exceptions from `plain.exceptions` to `plain.http`:
12
+
13
+ ```python
14
+ # Before
15
+ from plain.exceptions import NotFoundError404, ForbiddenError403, BadRequestError400
16
+
17
+ # After
18
+ from plain.http import NotFoundError404, ForbiddenError403, BadRequestError400
19
+ ```
20
+
21
+ ## [0.96.0](https://github.com/dropseed/plain/releases/plain@0.96.0) (2026-01-13)
22
+
23
+ ### What's changed
24
+
25
+ - Response classes renamed for consistency: `ResponseRedirect` → `RedirectResponse`, `ResponseNotModified` → `NotModifiedResponse`, `ResponseNotAllowed` → `NotAllowedResponse` ([fad5bf28b0](https://github.com/dropseed/plain/commit/fad5bf28b0))
26
+ - Redundant response classes removed: `ResponseNotFound`, `ResponseForbidden`, `ResponseBadRequest`, `ResponseGone`, `ResponseServerError` - use `Response(status_code=X)` instead ([fad5bf28b0](https://github.com/dropseed/plain/commit/fad5bf28b0))
27
+ - HTTP exceptions renamed to include status code suffix: `Http404` → `NotFoundError404`, `PermissionDenied` → `ForbiddenError403`, `BadRequest` → `BadRequestError400`, `SuspiciousOperation` → `SuspiciousOperationError400` ([5a1f020f52](https://github.com/dropseed/plain/commit/5a1f020f52))
28
+ - Added `Secret[T]` type annotation for masking sensitive settings like `SECRET_KEY` in CLI output ([8713dc08b0](https://github.com/dropseed/plain/commit/8713dc08b0))
29
+ - Added `ENV_SETTINGS_PREFIXES` setting to configure which environment variable prefixes are checked for settings (defaults to `["PLAIN_"]`) ([8713dc08b0](https://github.com/dropseed/plain/commit/8713dc08b0))
30
+ - New `plain settings list` and `plain settings get` CLI commands for viewing settings with their sources ([8713dc08b0](https://github.com/dropseed/plain/commit/8713dc08b0))
31
+ - Added preflight check for unused environment variables matching configured prefixes ([8713dc08b0](https://github.com/dropseed/plain/commit/8713dc08b0))
32
+ - Renamed `request.meta` to `request.environ` for clarity ([786b95bef8](https://github.com/dropseed/plain/commit/786b95bef8))
33
+ - Added `request.query_string` and `request.content_length` properties ([786b95bef8](https://github.com/dropseed/plain/commit/786b95bef8), [76dfd477d2](https://github.com/dropseed/plain/commit/76dfd477d2))
34
+ - Renamed X-Forwarded settings: `USE_X_FORWARDED_HOST` → `HTTP_X_FORWARDED_HOST`, `USE_X_FORWARDED_PORT` → `HTTP_X_FORWARDED_PORT`, `USE_X_FORWARDED_FOR` → `HTTP_X_FORWARDED_FOR` ([22f241a55c](https://github.com/dropseed/plain/commit/22f241a55c))
35
+ - Changed `HTTPS_PROXY_HEADER` from a tuple to a string format (e.g., `"X-Forwarded-Proto: https"`) ([7ac2a431b6](https://github.com/dropseed/plain/commit/7ac2a431b6))
36
+
37
+ ### Upgrade instructions
38
+
39
+ - Replace Response class imports and usages:
40
+ - `ResponseRedirect` → `RedirectResponse`
41
+ - `ResponseNotModified` → `NotModifiedResponse`
42
+ - `ResponseNotAllowed` → `NotAllowedResponse`
43
+ - `ResponseNotFound` → `Response(status_code=404)`
44
+ - `ResponseForbidden` → `Response(status_code=403)`
45
+ - `ResponseBadRequest` → `Response(status_code=400)`
46
+ - `ResponseGone` → `Response(status_code=410)`
47
+ - `ResponseServerError` → `Response(status_code=500)`
48
+ - Replace exception imports and usages:
49
+ - `Http404` → `NotFoundError404`
50
+ - `PermissionDenied` → `ForbiddenError403`
51
+ - `BadRequest` → `BadRequestError400`
52
+ - `SuspiciousOperation` → `SuspiciousOperationError400`
53
+ - `SuspiciousMultipartForm` → `SuspiciousMultipartFormError400`
54
+ - `SuspiciousFileOperation` → `SuspiciousFileOperationError400`
55
+ - `TooManyFieldsSent` → `TooManyFieldsSentError400`
56
+ - `TooManyFilesSent` → `TooManyFilesSentError400`
57
+ - `RequestDataTooBig` → `RequestDataTooBigError400`
58
+ - Replace `request.meta` with `request.environ`
59
+ - Rename X-Forwarded settings in your configuration:
60
+ - `USE_X_FORWARDED_HOST` → `HTTP_X_FORWARDED_HOST`
61
+ - `USE_X_FORWARDED_PORT` → `HTTP_X_FORWARDED_PORT`
62
+ - `USE_X_FORWARDED_FOR` → `HTTP_X_FORWARDED_FOR`
63
+ - Update `HTTPS_PROXY_HEADER` from tuple format to string format:
64
+
65
+ ```python
66
+ # Before
67
+ HTTPS_PROXY_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
68
+
69
+ # After
70
+ HTTPS_PROXY_HEADER = "X-Forwarded-Proto: https"
71
+ ```
72
+
73
+ - Replace `plain setting <name>` command with `plain settings get <name>`
74
+
75
+ ## [0.95.0](https://github.com/dropseed/plain/releases/plain@0.95.0) (2025-12-22)
76
+
77
+ ### What's changed
78
+
79
+ - Improved thread worker server shutdown behavior with `cancel_futures=True` for faster and cleaner process termination ([72d0620](https://github.com/dropseed/plain/commit/72d0620094))
80
+
81
+ ### Upgrade instructions
82
+
83
+ - No changes required
84
+
85
+ ## [0.94.0](https://github.com/dropseed/plain/releases/plain@0.94.0) (2025-12-12)
86
+
87
+ ### What's changed
88
+
89
+ - `FormFieldMissingError` exceptions are now automatically converted to HTTP 400 Bad Request responses with a warning log instead of causing a 500 error ([b38f6e5](https://github.com/dropseed/plain/commit/b38f6e50db))
90
+
91
+ ### Upgrade instructions
92
+
93
+ - No changes required
94
+
95
+ ## [0.93.1](https://github.com/dropseed/plain/releases/plain@0.93.1) (2025-12-09)
96
+
97
+ ### What's changed
98
+
99
+ - Added type annotation for `request.unique_id` attribute to improve IDE support and type checking ([23af501](https://github.com/dropseed/plain/commit/23af501d09))
100
+
101
+ ### Upgrade instructions
102
+
103
+ - No changes required
104
+
105
+ ## [0.93.0](https://github.com/dropseed/plain/releases/plain@0.93.0) (2025-12-04)
106
+
107
+ ### What's changed
108
+
109
+ - Improved type annotations across forms, HTTP handling, logging, and other core modules for better IDE support and type checking ([ac1eeb0](https://github.com/dropseed/plain/commit/ac1eeb0ea0))
110
+ - Internal refactor of `TimestampSigner` to use composition instead of inheritance from `Signer`, maintaining the same public API ([ac1eeb0](https://github.com/dropseed/plain/commit/ac1eeb0ea0))
111
+
112
+ ### Upgrade instructions
113
+
114
+ - No changes required
115
+
116
+ ## [0.92.0](https://github.com/dropseed/plain/releases/plain@0.92.0) (2025-12-01)
117
+
118
+ ### What's changed
119
+
120
+ - Added `request.client_ip` property to get the client's IP address, with support for `X-Forwarded-For` header when behind a trusted proxy ([cb0bc5d](https://github.com/dropseed/plain/commit/cb0bc5d08f))
121
+ - Added `USE_X_FORWARDED_FOR` setting to enable reading client IP from `X-Forwarded-For` header ([cb0bc5d](https://github.com/dropseed/plain/commit/cb0bc5d08f))
122
+ - Improved `print_event` CLI output styling with dimmed text for less visual noise ([b09edfd](https://github.com/dropseed/plain/commit/b09edfd2a1))
123
+
124
+ ### Upgrade instructions
125
+
126
+ - No changes required
127
+
128
+ ## [0.91.0](https://github.com/dropseed/plain/releases/plain@0.91.0) (2025-11-24)
129
+
130
+ ### What's changed
131
+
132
+ - Request body parsing refactored: the `request.data` attribute has been replaced with `request.json_data` and `request.form_data` for explicit content-type handling ([90332a9](https://github.com/dropseed/plain/commit/90332a9c21))
133
+ - `QueryDict` now has proper type annotations for `get()`, `pop()`, `getlist()`, and `__getitem__()` methods that reflect string return types ([90332a9](https://github.com/dropseed/plain/commit/90332a9c21))
134
+ - Forms now automatically select between `json_data` and `form_data` based on request content-type ([90332a9](https://github.com/dropseed/plain/commit/90332a9c21))
135
+ - View mixins `ObjectTemplateViewMixin` removed in favor of class inheritance for better typing - `UpdateView` and `DeleteView` now inherit from `DetailView` ([569afd6](https://github.com/dropseed/plain/commit/569afd606d))
136
+ - `AppLogger` context logging now uses a `context` dict parameter instead of `**kwargs` for better type checking ([581b406](https://github.com/dropseed/plain/commit/581b4060d3))
137
+ - Removed erroneous `AuthViewMixin` export from `plain.views` ([334bbb6](https://github.com/dropseed/plain/commit/334bbb6e7a))
138
+
139
+ ### Upgrade instructions
140
+
141
+ - Replace `request.data` with the appropriate method:
142
+ - For JSON requests: use `request.json_data` (returns a dict, raises `BadRequest` for invalid JSON)
143
+ - For form data: use `request.form_data` (returns a `QueryDict`)
144
+ - Update `app_logger` calls that pass context as kwargs to use the `context` parameter:
145
+
146
+ ```python
147
+ # Before
148
+ app_logger.info("Message", user_id=123, action="login")
149
+
150
+ # After
151
+ app_logger.info("Message", context={"user_id": 123, "action": "login"})
152
+ ```
153
+
154
+ ## [0.90.0](https://github.com/dropseed/plain/releases/plain@0.90.0) (2025-11-20)
155
+
156
+ ### What's changed
157
+
158
+ - Improved type annotations in `timezone.py`: `is_aware()` and `is_naive()` now accept both `datetime` and `time` objects for more flexible type checking ([a43145e](https://github.com/dropseed/plain/commit/a43145e697))
159
+ - Enhanced type annotations in view classes: `convert_value_to_response()` and handler result variables now use more explicit type hints for better IDE support ([dc4454e](https://github.com/dropseed/plain/commit/dc4454e196))
160
+ - Fixed type errors in forms and server workers: URL field now handles bytes properly, and worker wait_fds has explicit type annotation ([fc98d66](https://github.com/dropseed/plain/commit/fc98d666d4))
161
+
162
+ ### Upgrade instructions
163
+
164
+ - No changes required
165
+
166
+ ## [0.89.0](https://github.com/dropseed/plain/releases/plain@0.89.0) (2025-11-14)
167
+
168
+ ### What's changed
169
+
170
+ - Improved type annotations in view classes: `url_args`, `url_kwargs`, and various template/form context dictionaries now have more specific type hints for better IDE support and type checking ([83bcb95](https://github.com/dropseed/plain/commit/83bcb95b09))
171
+
172
+ ### Upgrade instructions
173
+
174
+ - No changes required
175
+
176
+ ## [0.88.0](https://github.com/dropseed/plain/releases/plain@0.88.0) (2025-11-13)
177
+
178
+ ### What's changed
179
+
180
+ - The `plain.forms` module now uses explicit imports instead of wildcard imports, improving IDE autocomplete and type checking support ([eff36f3](https://github.com/dropseed/plain/commit/eff36f31e8e15f84e11164a44c833aeab096ffbd))
181
+
182
+ ### Upgrade instructions
183
+
184
+ - No changes required
185
+
186
+ ## [0.87.0](https://github.com/dropseed/plain/releases/plain@0.87.0) (2025-11-12)
187
+
188
+ ### What's changed
189
+
190
+ - Internal classes now use abstract base classes with `@abstractmethod` decorators instead of raising `NotImplementedError`, improving type checking and IDE support ([91b329a](https://github.com/dropseed/plain/commit/91b329a8adb477031c4358e638b12f35f19bb85d), [81b5f88](https://github.com/dropseed/plain/commit/81b5f88a4bd39785f6b19c3c00c0ed23a36fb72f), [d2e2423](https://github.com/dropseed/plain/commit/d2e24235f497a92f45d5a21fc83d802897c2dec0), [61e7b5a](https://github.com/dropseed/plain/commit/61e7b5a0c8675aaaf65f0a626ff7959a786dca7f))
191
+ - Updated to latest version of `ty` type checker and fixed type errors and warnings throughout the codebase ([f4dbcef](https://github.com/dropseed/plain/commit/f4dbcefa929058be517cb1d4ab35bd73a89f26b8))
192
+
193
+ ### Upgrade instructions
194
+
195
+ - No changes required
196
+
3
197
  ## [0.86.2](https://github.com/dropseed/plain/releases/plain@0.86.2) (2025-11-11)
4
198
 
5
199
  ### What's changed
@@ -8,12 +8,13 @@ from io import BytesIO
8
8
 
9
9
  from plain.http import (
10
10
  FileResponse,
11
- Http404,
11
+ NotFoundError404,
12
+ NotModifiedResponse,
13
+ RedirectResponse,
12
14
  Response,
13
- ResponseNotModified,
14
- ResponseRedirect,
15
15
  StreamingResponse,
16
16
  )
17
+ from plain.http.response import ResponseHeaders
17
18
  from plain.runtime import settings
18
19
  from plain.urls import reverse
19
20
  from plain.views import View
@@ -34,12 +35,15 @@ class AssetView(View):
34
35
  # Allow a path to be passed in AssetView.as_view(path="...")
35
36
  self.asset_path = asset_path
36
37
 
37
- def get_url_path(self) -> str:
38
+ def get_url_path(self) -> str | None:
38
39
  return self.asset_path or self.url_kwargs["path"]
39
40
 
40
- def get(self) -> Response | FileResponse:
41
+ def get(self) -> Response | FileResponse | StreamingResponse:
41
42
  url_path = self.get_url_path()
42
43
 
44
+ if not url_path:
45
+ raise NotFoundError404("Asset path not found")
46
+
43
47
  # Make a trailing slash work, but we don't expect it
44
48
  url_path = url_path.rstrip("/")
45
49
 
@@ -52,7 +56,11 @@ class AssetView(View):
52
56
  if redirect_response := self.get_redirect_response(url_path):
53
57
  return redirect_response
54
58
 
59
+ # check_asset_path validates and raises if path is invalid
60
+ # After this point, absolute_path is guaranteed to be a valid str
55
61
  self.check_asset_path(absolute_path)
62
+ # Type guard: absolute_path is now str (check_asset_path raises if None/invalid)
63
+ assert absolute_path is not None
56
64
 
57
65
  if encoded_path := self.get_encoded_path(absolute_path):
58
66
  absolute_path = encoded_path
@@ -80,7 +88,7 @@ class AssetView(View):
80
88
 
81
89
  # Make sure we don't try to escape the compiled assests path
82
90
  if not os.path.commonpath([compiled_path, asset_path]) == compiled_path:
83
- raise Http404("Asset not found")
91
+ raise NotFoundError404("Asset not found")
84
92
 
85
93
  return asset_path
86
94
 
@@ -93,13 +101,13 @@ class AssetView(View):
93
101
 
94
102
  def check_asset_path(self, path: str | None) -> None:
95
103
  if not path:
96
- raise Http404("Asset not found")
104
+ raise NotFoundError404("Asset not found")
97
105
 
98
106
  if not os.path.exists(path):
99
- raise Http404("Asset not found")
107
+ raise NotFoundError404("Asset not found")
100
108
 
101
109
  if os.path.isdir(path):
102
- raise Http404("Asset is a directory")
110
+ raise NotFoundError404("Asset is a directory")
103
111
 
104
112
  @functools.cache
105
113
  def get_last_modified(self, path: str) -> str | None:
@@ -117,7 +125,7 @@ class AssetView(View):
117
125
  try:
118
126
  mtime = os.path.getmtime(path)
119
127
  except OSError:
120
- mtime = None
128
+ mtime = 0.0
121
129
 
122
130
  timestamp = int(mtime)
123
131
  size = self.get_size(path)
@@ -127,7 +135,7 @@ class AssetView(View):
127
135
  def get_size(self, path: str) -> int:
128
136
  return os.path.getsize(path)
129
137
 
130
- def update_headers(self, headers: dict, path: str) -> dict:
138
+ def update_headers(self, headers: ResponseHeaders, path: str) -> ResponseHeaders:
131
139
  headers.setdefault("Access-Control-Allow-Origin", "*")
132
140
 
133
141
  # Always vary on Accept-Encoding
@@ -206,7 +214,7 @@ class AssetView(View):
206
214
  return gzip_path
207
215
  return None
208
216
 
209
- def get_redirect_response(self, path: str) -> ResponseRedirect | None:
217
+ def get_redirect_response(self, path: str) -> RedirectResponse | None:
210
218
  """If the asset is not found, try to redirect to the fingerprinted path"""
211
219
  fingerprinted_url_path = get_fingerprinted_url_path(path)
212
220
 
@@ -219,19 +227,19 @@ class AssetView(View):
219
227
 
220
228
  namespace = AssetsRouter.namespace
221
229
 
222
- return ResponseRedirect(
230
+ return RedirectResponse(
223
231
  redirect_to=reverse(f"{namespace}:asset", fingerprinted_url_path),
224
232
  headers={
225
233
  "Cache-Control": "max-age=60", # Can cache this for a short time, but the fingerprinted path can change
226
234
  },
227
235
  )
228
236
 
229
- def get_conditional_response(self, path: str) -> ResponseNotModified | None:
237
+ def get_conditional_response(self, path: str) -> NotModifiedResponse | None:
230
238
  """
231
239
  Support conditional requests (HTTP 304 response) based on ETag and Last-Modified headers.
232
240
  """
233
241
  if self.request.headers.get("If-None-Match") == self.get_etag(path):
234
- response = ResponseNotModified()
242
+ response = NotModifiedResponse()
235
243
  response.headers = self.update_headers(response.headers, path)
236
244
  return response
237
245
 
@@ -243,7 +251,7 @@ class AssetView(View):
243
251
  and last_modified
244
252
  and if_modified_since >= last_modified
245
253
  ):
246
- response = ResponseNotModified()
254
+ response = NotModifiedResponse()
247
255
  response.headers = self.update_headers(response.headers, path)
248
256
  return response
249
257
  return None
@@ -272,7 +280,7 @@ class AssetView(View):
272
280
  status_code=416, headers=[("Content-Range", f"bytes */{file_size}")]
273
281
  )
274
282
 
275
- end = min(end, file_size - 1)
283
+ end = int(min(end, file_size - 1))
276
284
 
277
285
  with open(path, "rb") as f:
278
286
  f.seek(start)
@@ -9,6 +9,7 @@ import click
9
9
 
10
10
  import plain.runtime
11
11
  from plain.assets.compile import compile_assets, get_compiled_path
12
+ from plain.cli.print import print_event
12
13
 
13
14
 
14
15
  @click.command()
@@ -54,18 +55,16 @@ def build(keep_original: bool, fingerprint: bool, compress: bool) -> None:
54
55
  .get("run", {})
55
56
  .items()
56
57
  ):
57
- click.secho(f"Running {name} from pyproject.toml", bold=True)
58
+ print_event(f"{name}...")
58
59
  result = subprocess.run(data["cmd"], shell=True)
59
- print()
60
60
  if result.returncode:
61
61
  click.secho(f"Error in {name} (exit {result.returncode})", fg="red")
62
62
  sys.exit(result.returncode)
63
63
 
64
64
  # Then run installed package build steps (like tailwind, typically should run last...)
65
65
  for entry_point in entry_points(group="plain.build"):
66
- click.secho(f"Running {entry_point.name}", bold=True)
67
- result = entry_point.load()()
68
- print()
66
+ print_event(f"{entry_point.name}...")
67
+ entry_point.load()()
69
68
 
70
69
  # Compile our assets
71
70
  target_dir = get_compiled_path()
@@ -9,7 +9,6 @@ from click.core import Command, Context
9
9
  import plain.runtime
10
10
  from plain.exceptions import ImproperlyConfigured
11
11
 
12
- from .agent import agent
13
12
  from .build import build
14
13
  from .changelog import changelog
15
14
  from .chores import chores
@@ -18,10 +17,12 @@ from .formatting import PlainContext
18
17
  from .install import install
19
18
  from .preflight import preflight_cli
20
19
  from .registry import cli_registry
20
+ from .request import request
21
21
  from .scaffold import create
22
22
  from .server import server
23
- from .settings import setting
23
+ from .settings import settings
24
24
  from .shell import run, shell
25
+ from .skills import skills
25
26
  from .upgrade import upgrade
26
27
  from .urls import urls
27
28
  from .utils import utils
@@ -32,8 +33,9 @@ def plain_cli() -> None:
32
33
  pass
33
34
 
34
35
 
35
- plain_cli.add_command(agent)
36
36
  plain_cli.add_command(docs)
37
+ plain_cli.add_command(request)
38
+ plain_cli.add_command(skills)
37
39
  plain_cli.add_command(preflight_cli)
38
40
  plain_cli.add_command(create)
39
41
  plain_cli.add_command(chores)
@@ -41,7 +43,7 @@ plain_cli.add_command(build)
41
43
  plain_cli.add_command(utils)
42
44
  plain_cli.add_command(urls)
43
45
  plain_cli.add_command(changelog)
44
- plain_cli.add_command(setting)
46
+ plain_cli.add_command(settings)
45
47
  plain_cli.add_command(shell)
46
48
  plain_cli.add_command(run)
47
49
  plain_cli.add_command(install)
@@ -61,9 +63,9 @@ class CLIRegistryGroup(click.Group):
61
63
  def list_commands(self, ctx: Context) -> list[str]:
62
64
  return sorted(cli_registry.get_commands().keys())
63
65
 
64
- def get_command(self, ctx: Context, name: str) -> Command | None:
66
+ def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
65
67
  commands = cli_registry.get_commands()
66
- return commands.get(name)
68
+ return commands.get(cmd_name)
67
69
 
68
70
 
69
71
  class PlainCommandCollection(click.CommandCollection):
@@ -0,0 +1,79 @@
1
+ import importlib.util
2
+ import pkgutil
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from .llmdocs import LLMDocs
8
+ from .output import iterate_markdown
9
+
10
+
11
+ @click.command()
12
+ @click.option("--open", is_flag=True, help="Open the README in your default editor")
13
+ @click.option("--source", is_flag=True, help="Include symbolicated source code")
14
+ @click.option("--list", "show_list", is_flag=True, help="List available packages")
15
+ @click.argument("module", default="")
16
+ def docs(module: str, open: bool, source: bool, show_list: bool) -> None:
17
+ """Show documentation for a package"""
18
+ if show_list:
19
+ # List available packages
20
+ available_packages = []
21
+ try:
22
+ import plain
23
+
24
+ # Check core plain package (namespace package)
25
+ plain_spec = importlib.util.find_spec("plain")
26
+ if plain_spec and plain_spec.submodule_search_locations:
27
+ available_packages.append("plain")
28
+
29
+ # Check other plain.* subpackages
30
+ if hasattr(plain, "__path__"):
31
+ for importer, modname, ispkg in pkgutil.iter_modules(
32
+ plain.__path__, "plain."
33
+ ):
34
+ if ispkg:
35
+ available_packages.append(modname)
36
+ except Exception:
37
+ pass
38
+
39
+ if available_packages:
40
+ for pkg in sorted(available_packages):
41
+ click.echo(f"- {pkg}")
42
+ else:
43
+ click.echo("No packages found.")
44
+ return
45
+
46
+ if not module:
47
+ raise click.UsageError(
48
+ "You must specify a module. Use --list to see available packages."
49
+ )
50
+
51
+ # Convert hyphens to dots (e.g., plain-models -> plain.models)
52
+ module = module.replace("-", ".")
53
+
54
+ # Automatically prefix if we need to
55
+ if not module.startswith("plain"):
56
+ module = f"plain.{module}"
57
+
58
+ # Get the module path
59
+ spec = importlib.util.find_spec(module)
60
+ if not spec or not spec.origin:
61
+ raise click.UsageError(f"Module {module} not found")
62
+
63
+ module_path = Path(spec.origin).parent
64
+
65
+ if source:
66
+ # Output with symbolicated source
67
+ source_docs = LLMDocs([module_path])
68
+ source_docs.load()
69
+ source_docs.print(relative_to=module_path.parent)
70
+ else:
71
+ # Human-readable README output
72
+ readme_path = module_path / "README.md"
73
+ if not readme_path.exists():
74
+ raise click.UsageError(f"README.md not found for {module}")
75
+
76
+ if open:
77
+ click.launch(str(readme_path))
78
+ else:
79
+ click.echo_via_pager(iterate_markdown(readme_path.read_text()))
@@ -12,11 +12,13 @@ class PlainHelpFormatter(click.HelpFormatter):
12
12
  styled_heading = click.style(heading, dim=True)
13
13
  self.write(f"{'':>{self.current_indent}}{styled_heading}\n")
14
14
 
15
- def write_usage(self, prog: str, args: str = "", prefix: str = "Usage: ") -> None:
15
+ def write_usage( # type: ignore[override]
16
+ self, prog: str, args: str = "", prefix: str = "Usage: "
17
+ ) -> None:
16
18
  prefix_styled = click.style(prefix, dim=True)
17
19
  super().write_usage(prog, args, prefix=prefix_styled)
18
20
 
19
- def write_dl(
21
+ def write_dl( # type: ignore[override]
20
22
  self,
21
23
  rows: list[tuple[str, str]],
22
24
  col_max: int = 20,
@@ -0,0 +1,38 @@
1
+ import subprocess
2
+ import sys
3
+
4
+ import click
5
+
6
+
7
+ @click.command()
8
+ @click.argument("packages", nargs=-1, required=True)
9
+ def install(packages: tuple[str, ...]) -> None:
10
+ """Install Plain packages"""
11
+ # Validate all package names
12
+ invalid_packages = [pkg for pkg in packages if not pkg.startswith("plain")]
13
+ if invalid_packages:
14
+ raise click.UsageError(
15
+ f"The following packages do not start with 'plain': {', '.join(invalid_packages)}\n"
16
+ "This command is only for Plain framework packages."
17
+ )
18
+
19
+ # Install all packages
20
+ if len(packages) == 1:
21
+ click.secho(f"Installing {packages[0]}...", bold=True)
22
+ else:
23
+ click.secho(f"Installing {len(packages)} packages...", bold=True)
24
+ for pkg in packages:
25
+ click.secho(f" - {pkg}")
26
+ click.echo()
27
+
28
+ install_cmd = ["uv", "add"] + list(packages)
29
+ result = subprocess.run(install_cmd, check=False, stderr=sys.stderr)
30
+
31
+ if result.returncode != 0:
32
+ raise click.ClickException("Failed to install packages")
33
+
34
+ click.echo()
35
+ if len(packages) == 1:
36
+ click.secho(f"{packages[0]} installed successfully", fg="green")
37
+ else:
38
+ click.secho(f"{len(packages)} packages installed successfully", fg="green")
@@ -26,7 +26,7 @@ class LLMDocs:
26
26
  self.docs.add(path)
27
27
 
28
28
  # Exclude "migrations" code from plain apps, except for plain/models/migrations
29
- # Also exclude CHANGELOG.md and AGENTS.md
29
+ # Also exclude CHANGELOG.md, AGENTS.md, and skills directory
30
30
  self.docs = {
31
31
  doc
32
32
  for doc in self.docs
@@ -35,6 +35,7 @@ class LLMDocs:
35
35
  and "/plain/models/migrations/" not in str(doc)
36
36
  )
37
37
  and doc.name not in ("CHANGELOG.md", "AGENTS.md")
38
+ and "/skills/" not in str(doc)
38
39
  }
39
40
  self.sources = {
40
41
  source
@@ -44,6 +45,7 @@ class LLMDocs:
44
45
  and "/plain/models/migrations/" not in str(source)
45
46
  )
46
47
  and source.name != "cli.py"
48
+ and "/skills/" not in str(source)
47
49
  }
48
50
 
49
51
  self.docs = sorted(self.docs)
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import sys
3
+ from typing import Any
3
4
 
4
5
  import click
5
6
 
@@ -125,12 +126,12 @@ def preflight_cli(deploy: bool, format: str, quiet: bool) -> None:
125
126
 
126
127
  if format == "json":
127
128
  # Build JSON output
128
- results = {"passed": not has_errors, "checks": []}
129
+ results: dict[str, Any] = {"passed": not has_errors, "checks": []}
129
130
 
130
131
  for check_class, check_name, issues in check_results:
131
132
  visible_issues = [issue for issue in issues if not issue.is_silenced()]
132
133
 
133
- check_result = {
134
+ check_result: dict[str, Any] = {
134
135
  "name": check_name,
135
136
  "passed": len(visible_issues) == 0,
136
137
  "issues": [],
@@ -0,0 +1,9 @@
1
+ import click
2
+
3
+
4
+ def print_event(msg: str, newline: bool = True) -> None:
5
+ arrow = click.style("-->", fg=214, bold=True, dim=True)
6
+ message = click.style(msg, dim=True)
7
+ if not newline:
8
+ message += " "
9
+ click.echo(f"{arrow} {message}", nl=newline)
@@ -49,7 +49,7 @@ def request(
49
49
  content_type: str | None,
50
50
  headers: tuple[str, ...],
51
51
  ) -> None:
52
- """Make an HTTP test request against the dev database"""
52
+ """Make HTTP requests against the dev database"""
53
53
 
54
54
  try:
55
55
  # Only allow in DEBUG mode for security
@@ -98,7 +98,7 @@ def request(
98
98
 
99
99
  # Make the request
100
100
  method = method.upper()
101
- kwargs: dict[str, object] = {
101
+ kwargs: dict[str, Any] = {
102
102
  "follow": follow,
103
103
  }
104
104
  if header_dict:
@@ -24,7 +24,7 @@ def without_runtime_setup(f: F) -> F:
24
24
  def server(**options):
25
25
  ...
26
26
  """
27
- f.without_runtime_setup = True # type: ignore[attr-defined] # dynamic attribute for decorator
27
+ f.without_runtime_setup = True # dynamic attribute for decorator
28
28
  return f
29
29
 
30
30
 
@@ -41,5 +41,5 @@ def common_command(f: F) -> F:
41
41
  def dev(**options):
42
42
  ...
43
43
  """
44
- f.is_common_command = True # type: ignore[attr-defined] # dynamic attribute for decorator
44
+ f.is_common_command = True # dynamic attribute for decorator
45
45
  return f