plain 0.66.0__tar.gz → 0.68.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. {plain-0.66.0 → plain-0.68.0}/PKG-INFO +1 -1
  2. {plain-0.66.0 → plain-0.68.0}/plain/CHANGELOG.md +29 -0
  3. {plain-0.66.0 → plain-0.68.0}/plain/chores/registry.py +1 -18
  4. plain-0.68.0/plain/cli/agent/request.py +172 -0
  5. {plain-0.66.0 → plain-0.68.0}/plain/cli/core.py +2 -2
  6. plain-0.68.0/plain/cli/preflight.py +247 -0
  7. {plain-0.66.0 → plain-0.68.0}/plain/cli/registry.py +1 -18
  8. {plain-0.66.0 → plain-0.68.0}/plain/internal/middleware/hosts.py +0 -5
  9. {plain-0.66.0 → plain-0.68.0}/plain/packages/registry.py +14 -0
  10. {plain-0.66.0 → plain-0.68.0}/plain/preflight/README.md +5 -5
  11. plain-0.68.0/plain/preflight/__init__.py +16 -0
  12. plain-0.68.0/plain/preflight/checks.py +10 -0
  13. plain-0.68.0/plain/preflight/files.py +23 -0
  14. plain-0.68.0/plain/preflight/registry.py +79 -0
  15. plain-0.68.0/plain/preflight/results.py +29 -0
  16. plain-0.68.0/plain/preflight/security.py +81 -0
  17. plain-0.68.0/plain/preflight/urls.py +13 -0
  18. {plain-0.66.0 → plain-0.68.0}/plain/runtime/global_settings.py +9 -8
  19. {plain-0.66.0 → plain-0.68.0}/plain/templates/jinja/__init__.py +2 -23
  20. {plain-0.66.0 → plain-0.68.0}/plain/urls/patterns.py +21 -21
  21. {plain-0.66.0 → plain-0.68.0}/plain/urls/resolvers.py +4 -4
  22. {plain-0.66.0 → plain-0.68.0}/pyproject.toml +2 -1
  23. {plain-0.66.0 → plain-0.68.0}/tests/app/settings.py +0 -1
  24. {plain-0.66.0 → plain-0.68.0}/tests/test_csrf.py +26 -43
  25. {plain-0.66.0 → plain-0.68.0}/tests/test_http_hosts.py +1 -26
  26. plain-0.66.0/plain/cli/agent/request.py +0 -181
  27. plain-0.66.0/plain/cli/preflight.py +0 -126
  28. plain-0.66.0/plain/preflight/__init__.py +0 -36
  29. plain-0.66.0/plain/preflight/files.py +0 -19
  30. plain-0.66.0/plain/preflight/messages.py +0 -81
  31. plain-0.66.0/plain/preflight/registry.py +0 -72
  32. plain-0.66.0/plain/preflight/security.py +0 -89
  33. plain-0.66.0/plain/preflight/urls.py +0 -54
  34. {plain-0.66.0 → plain-0.68.0}/.gitignore +0 -0
  35. {plain-0.66.0 → plain-0.68.0}/LICENSE +0 -0
  36. {plain-0.66.0 → plain-0.68.0}/README.md +0 -0
  37. {plain-0.66.0 → plain-0.68.0}/plain/AGENTS.md +0 -0
  38. {plain-0.66.0 → plain-0.68.0}/plain/README.md +0 -0
  39. {plain-0.66.0 → plain-0.68.0}/plain/__main__.py +0 -0
  40. {plain-0.66.0 → plain-0.68.0}/plain/assets/README.md +0 -0
  41. {plain-0.66.0 → plain-0.68.0}/plain/assets/__init__.py +0 -0
  42. {plain-0.66.0 → plain-0.68.0}/plain/assets/compile.py +0 -0
  43. {plain-0.66.0 → plain-0.68.0}/plain/assets/finders.py +0 -0
  44. {plain-0.66.0 → plain-0.68.0}/plain/assets/fingerprints.py +0 -0
  45. {plain-0.66.0 → plain-0.68.0}/plain/assets/urls.py +0 -0
  46. {plain-0.66.0 → plain-0.68.0}/plain/assets/views.py +0 -0
  47. {plain-0.66.0 → plain-0.68.0}/plain/chores/README.md +0 -0
  48. {plain-0.66.0 → plain-0.68.0}/plain/chores/__init__.py +0 -0
  49. {plain-0.66.0 → plain-0.68.0}/plain/cli/README.md +0 -0
  50. {plain-0.66.0 → plain-0.68.0}/plain/cli/__init__.py +0 -0
  51. {plain-0.66.0 → plain-0.68.0}/plain/cli/agent/__init__.py +0 -0
  52. {plain-0.66.0 → plain-0.68.0}/plain/cli/agent/docs.py +0 -0
  53. {plain-0.66.0 → plain-0.68.0}/plain/cli/agent/llmdocs.py +0 -0
  54. {plain-0.66.0 → plain-0.68.0}/plain/cli/agent/md.py +0 -0
  55. {plain-0.66.0 → plain-0.68.0}/plain/cli/agent/prompt.py +0 -0
  56. {plain-0.66.0 → plain-0.68.0}/plain/cli/build.py +0 -0
  57. {plain-0.66.0 → plain-0.68.0}/plain/cli/changelog.py +0 -0
  58. {plain-0.66.0 → plain-0.68.0}/plain/cli/chores.py +0 -0
  59. {plain-0.66.0 → plain-0.68.0}/plain/cli/docs.py +0 -0
  60. {plain-0.66.0 → plain-0.68.0}/plain/cli/formatting.py +0 -0
  61. {plain-0.66.0 → plain-0.68.0}/plain/cli/install.py +0 -0
  62. {plain-0.66.0 → plain-0.68.0}/plain/cli/output.py +0 -0
  63. {plain-0.66.0 → plain-0.68.0}/plain/cli/print.py +0 -0
  64. {plain-0.66.0 → plain-0.68.0}/plain/cli/scaffold.py +0 -0
  65. {plain-0.66.0 → plain-0.68.0}/plain/cli/settings.py +0 -0
  66. {plain-0.66.0 → plain-0.68.0}/plain/cli/shell.py +0 -0
  67. {plain-0.66.0 → plain-0.68.0}/plain/cli/startup.py +0 -0
  68. {plain-0.66.0 → plain-0.68.0}/plain/cli/upgrade.py +0 -0
  69. {plain-0.66.0 → plain-0.68.0}/plain/cli/urls.py +0 -0
  70. {plain-0.66.0 → plain-0.68.0}/plain/cli/utils.py +0 -0
  71. {plain-0.66.0 → plain-0.68.0}/plain/csrf/README.md +0 -0
  72. {plain-0.66.0 → plain-0.68.0}/plain/csrf/middleware.py +0 -0
  73. {plain-0.66.0 → plain-0.68.0}/plain/csrf/views.py +0 -0
  74. {plain-0.66.0 → plain-0.68.0}/plain/debug.py +0 -0
  75. {plain-0.66.0 → plain-0.68.0}/plain/exceptions.py +0 -0
  76. {plain-0.66.0 → plain-0.68.0}/plain/forms/README.md +0 -0
  77. {plain-0.66.0 → plain-0.68.0}/plain/forms/__init__.py +0 -0
  78. {plain-0.66.0 → plain-0.68.0}/plain/forms/boundfield.py +0 -0
  79. {plain-0.66.0 → plain-0.68.0}/plain/forms/exceptions.py +0 -0
  80. {plain-0.66.0 → plain-0.68.0}/plain/forms/fields.py +0 -0
  81. {plain-0.66.0 → plain-0.68.0}/plain/forms/forms.py +0 -0
  82. {plain-0.66.0 → plain-0.68.0}/plain/http/README.md +0 -0
  83. {plain-0.66.0 → plain-0.68.0}/plain/http/__init__.py +0 -0
  84. {plain-0.66.0 → plain-0.68.0}/plain/http/cookie.py +0 -0
  85. {plain-0.66.0 → plain-0.68.0}/plain/http/multipartparser.py +0 -0
  86. {plain-0.66.0 → plain-0.68.0}/plain/http/request.py +0 -0
  87. {plain-0.66.0 → plain-0.68.0}/plain/http/response.py +0 -0
  88. {plain-0.66.0 → plain-0.68.0}/plain/internal/__init__.py +0 -0
  89. {plain-0.66.0 → plain-0.68.0}/plain/internal/files/__init__.py +0 -0
  90. {plain-0.66.0 → plain-0.68.0}/plain/internal/files/base.py +0 -0
  91. {plain-0.66.0 → plain-0.68.0}/plain/internal/files/locks.py +0 -0
  92. {plain-0.66.0 → plain-0.68.0}/plain/internal/files/move.py +0 -0
  93. {plain-0.66.0 → plain-0.68.0}/plain/internal/files/temp.py +0 -0
  94. {plain-0.66.0 → plain-0.68.0}/plain/internal/files/uploadedfile.py +0 -0
  95. {plain-0.66.0 → plain-0.68.0}/plain/internal/files/uploadhandler.py +0 -0
  96. {plain-0.66.0 → plain-0.68.0}/plain/internal/files/utils.py +0 -0
  97. {plain-0.66.0 → plain-0.68.0}/plain/internal/handlers/__init__.py +0 -0
  98. {plain-0.66.0 → plain-0.68.0}/plain/internal/handlers/base.py +0 -0
  99. {plain-0.66.0 → plain-0.68.0}/plain/internal/handlers/exception.py +0 -0
  100. {plain-0.66.0 → plain-0.68.0}/plain/internal/handlers/wsgi.py +0 -0
  101. {plain-0.66.0 → plain-0.68.0}/plain/internal/middleware/__init__.py +0 -0
  102. {plain-0.66.0 → plain-0.68.0}/plain/internal/middleware/headers.py +0 -0
  103. {plain-0.66.0 → plain-0.68.0}/plain/internal/middleware/https.py +0 -0
  104. {plain-0.66.0 → plain-0.68.0}/plain/internal/middleware/slash.py +0 -0
  105. {plain-0.66.0 → plain-0.68.0}/plain/json.py +0 -0
  106. {plain-0.66.0 → plain-0.68.0}/plain/logs/README.md +0 -0
  107. {plain-0.66.0 → plain-0.68.0}/plain/logs/__init__.py +0 -0
  108. {plain-0.66.0 → plain-0.68.0}/plain/logs/configure.py +0 -0
  109. {plain-0.66.0 → plain-0.68.0}/plain/logs/debug.py +0 -0
  110. {plain-0.66.0 → plain-0.68.0}/plain/logs/formatters.py +0 -0
  111. {plain-0.66.0 → plain-0.68.0}/plain/logs/loggers.py +0 -0
  112. {plain-0.66.0 → plain-0.68.0}/plain/logs/utils.py +0 -0
  113. {plain-0.66.0 → plain-0.68.0}/plain/packages/README.md +0 -0
  114. {plain-0.66.0 → plain-0.68.0}/plain/packages/__init__.py +0 -0
  115. {plain-0.66.0 → plain-0.68.0}/plain/packages/config.py +0 -0
  116. {plain-0.66.0 → plain-0.68.0}/plain/paginator.py +0 -0
  117. {plain-0.66.0 → plain-0.68.0}/plain/runtime/README.md +0 -0
  118. {plain-0.66.0 → plain-0.68.0}/plain/runtime/__init__.py +0 -0
  119. {plain-0.66.0 → plain-0.68.0}/plain/runtime/user_settings.py +0 -0
  120. {plain-0.66.0 → plain-0.68.0}/plain/runtime/utils.py +0 -0
  121. {plain-0.66.0 → plain-0.68.0}/plain/signals/README.md +0 -0
  122. {plain-0.66.0 → plain-0.68.0}/plain/signals/__init__.py +0 -0
  123. {plain-0.66.0 → plain-0.68.0}/plain/signals/dispatch/__init__.py +0 -0
  124. {plain-0.66.0 → plain-0.68.0}/plain/signals/dispatch/dispatcher.py +0 -0
  125. {plain-0.66.0 → plain-0.68.0}/plain/signals/dispatch/license.txt +0 -0
  126. {plain-0.66.0 → plain-0.68.0}/plain/signing.py +0 -0
  127. {plain-0.66.0 → plain-0.68.0}/plain/templates/AGENTS.md +0 -0
  128. {plain-0.66.0 → plain-0.68.0}/plain/templates/README.md +0 -0
  129. {plain-0.66.0 → plain-0.68.0}/plain/templates/__init__.py +0 -0
  130. {plain-0.66.0 → plain-0.68.0}/plain/templates/core.py +0 -0
  131. {plain-0.66.0 → plain-0.68.0}/plain/templates/jinja/environments.py +0 -0
  132. {plain-0.66.0 → plain-0.68.0}/plain/templates/jinja/extensions.py +0 -0
  133. {plain-0.66.0 → plain-0.68.0}/plain/templates/jinja/filters.py +0 -0
  134. {plain-0.66.0 → plain-0.68.0}/plain/templates/jinja/globals.py +0 -0
  135. {plain-0.66.0 → plain-0.68.0}/plain/test/README.md +0 -0
  136. {plain-0.66.0 → plain-0.68.0}/plain/test/__init__.py +0 -0
  137. {plain-0.66.0 → plain-0.68.0}/plain/test/client.py +0 -0
  138. {plain-0.66.0 → plain-0.68.0}/plain/test/encoding.py +0 -0
  139. {plain-0.66.0 → plain-0.68.0}/plain/test/exceptions.py +0 -0
  140. {plain-0.66.0 → plain-0.68.0}/plain/urls/README.md +0 -0
  141. {plain-0.66.0 → plain-0.68.0}/plain/urls/__init__.py +0 -0
  142. {plain-0.66.0 → plain-0.68.0}/plain/urls/converters.py +0 -0
  143. {plain-0.66.0 → plain-0.68.0}/plain/urls/exceptions.py +0 -0
  144. {plain-0.66.0 → plain-0.68.0}/plain/urls/routers.py +0 -0
  145. {plain-0.66.0 → plain-0.68.0}/plain/urls/utils.py +0 -0
  146. {plain-0.66.0 → plain-0.68.0}/plain/utils/README.md +0 -0
  147. {plain-0.66.0 → plain-0.68.0}/plain/utils/__init__.py +0 -0
  148. {plain-0.66.0 → plain-0.68.0}/plain/utils/cache.py +0 -0
  149. {plain-0.66.0 → plain-0.68.0}/plain/utils/crypto.py +0 -0
  150. {plain-0.66.0 → plain-0.68.0}/plain/utils/datastructures.py +0 -0
  151. {plain-0.66.0 → plain-0.68.0}/plain/utils/dateparse.py +0 -0
  152. {plain-0.66.0 → plain-0.68.0}/plain/utils/deconstruct.py +0 -0
  153. {plain-0.66.0 → plain-0.68.0}/plain/utils/decorators.py +0 -0
  154. {plain-0.66.0 → plain-0.68.0}/plain/utils/duration.py +0 -0
  155. {plain-0.66.0 → plain-0.68.0}/plain/utils/encoding.py +0 -0
  156. {plain-0.66.0 → plain-0.68.0}/plain/utils/functional.py +0 -0
  157. {plain-0.66.0 → plain-0.68.0}/plain/utils/hashable.py +0 -0
  158. {plain-0.66.0 → plain-0.68.0}/plain/utils/html.py +0 -0
  159. {plain-0.66.0 → plain-0.68.0}/plain/utils/http.py +0 -0
  160. {plain-0.66.0 → plain-0.68.0}/plain/utils/inspect.py +0 -0
  161. {plain-0.66.0 → plain-0.68.0}/plain/utils/ipv6.py +0 -0
  162. {plain-0.66.0 → plain-0.68.0}/plain/utils/itercompat.py +0 -0
  163. {plain-0.66.0 → plain-0.68.0}/plain/utils/module_loading.py +0 -0
  164. {plain-0.66.0 → plain-0.68.0}/plain/utils/regex_helper.py +0 -0
  165. {plain-0.66.0 → plain-0.68.0}/plain/utils/safestring.py +0 -0
  166. {plain-0.66.0 → plain-0.68.0}/plain/utils/text.py +0 -0
  167. {plain-0.66.0 → plain-0.68.0}/plain/utils/timesince.py +0 -0
  168. {plain-0.66.0 → plain-0.68.0}/plain/utils/timezone.py +0 -0
  169. {plain-0.66.0 → plain-0.68.0}/plain/utils/tree.py +0 -0
  170. {plain-0.66.0 → plain-0.68.0}/plain/validators.py +0 -0
  171. {plain-0.66.0 → plain-0.68.0}/plain/views/README.md +0 -0
  172. {plain-0.66.0 → plain-0.68.0}/plain/views/__init__.py +0 -0
  173. {plain-0.66.0 → plain-0.68.0}/plain/views/base.py +0 -0
  174. {plain-0.66.0 → plain-0.68.0}/plain/views/errors.py +0 -0
  175. {plain-0.66.0 → plain-0.68.0}/plain/views/exceptions.py +0 -0
  176. {plain-0.66.0 → plain-0.68.0}/plain/views/forms.py +0 -0
  177. {plain-0.66.0 → plain-0.68.0}/plain/views/objects.py +0 -0
  178. {plain-0.66.0 → plain-0.68.0}/plain/views/redirect.py +0 -0
  179. {plain-0.66.0 → plain-0.68.0}/plain/views/templates.py +0 -0
  180. {plain-0.66.0 → plain-0.68.0}/plain/wsgi.py +0 -0
  181. {plain-0.66.0 → plain-0.68.0}/tests/.gitignore +0 -0
  182. {plain-0.66.0 → plain-0.68.0}/tests/app/.gitignore +0 -0
  183. {plain-0.66.0 → plain-0.68.0}/tests/app/test/__init__.py +0 -0
  184. {plain-0.66.0 → plain-0.68.0}/tests/app/test/default_settings.py +0 -0
  185. {plain-0.66.0 → plain-0.68.0}/tests/app/urls.py +0 -0
  186. {plain-0.66.0 → plain-0.68.0}/tests/conftest.py +0 -0
  187. {plain-0.66.0 → plain-0.68.0}/tests/test_cli.py +0 -0
  188. {plain-0.66.0 → plain-0.68.0}/tests/test_logs.py +0 -0
  189. {plain-0.66.0 → plain-0.68.0}/tests/test_runtime.py +0 -0
  190. {plain-0.66.0 → plain-0.68.0}/tests/test_wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.66.0
3
+ Version: 0.68.0
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE
@@ -1,5 +1,34 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.68.0](https://github.com/dropseed/plain/releases/plain@0.68.0) (2025-09-25)
4
+
5
+ ### What's changed
6
+
7
+ - Major refactor of the preflight check system with new CLI commands and improved output ([b0b610d461](https://github.com/dropseed/plain/commit/b0b610d461))
8
+ - Preflight checks now use descriptive IDs instead of numeric codes ([cd96c97b25](https://github.com/dropseed/plain/commit/cd96c97b25))
9
+ - Unified preflight error messages and hints into a single `fix` field ([c7cde12149](https://github.com/dropseed/plain/commit/c7cde12149))
10
+ - Added `plain-upgrade` as a standalone command for upgrading Plain packages ([42f2eed80c](https://github.com/dropseed/plain/commit/42f2eed80c))
11
+
12
+ ### Upgrade instructions
13
+
14
+ - Use `plain preflight check` instead of `plain preflight` to run all checks
15
+ - Custom preflight checks should be class based, extending `PreflightCheck` and implementing the `run()` method
16
+ - Preflight checks need to be registered with a custom name (ex. `@register_check("app.my_custom_check")`) and optionally with `deploy=True` if it should run in only in deploy mode
17
+ - Preflight results should use `PreflightResult` (optionally with `warning=True`) instead of `preflight.Warning` or `preflight.Error`
18
+ - Preflight result IDs should be descriptive strings (e.g., `models.lazy_reference_resolution_failed`) instead of numeric codes
19
+ - `PREFLIGHT_SILENCED_CHECKS` setting has been replaced with `PREFLIGHT_SILENCED_RESULTS` which should contain a list of result IDs to silence. `PREFLIGHT_SILENCED_CHECKS` now silences entire checks by name.
20
+
21
+ ## [0.67.0](https://github.com/dropseed/plain/releases/plain@0.67.0) (2025-09-22)
22
+
23
+ ### What's changed
24
+
25
+ - `ALLOWED_HOSTS` now defaults to `[]` (empty list) which allows all hosts, making it easier for development setups ([d3cb7712b9](https://github.com/dropseed/plain/commit/d3cb7712b9))
26
+ - Empty `ALLOWED_HOSTS` in production now triggers a preflight error instead of a warning to ensure proper security configuration ([d3cb7712b9](https://github.com/dropseed/plain/commit/d3cb7712b9))
27
+
28
+ ### Upgrade instructions
29
+
30
+ - No changes required
31
+
3
32
  ## [0.66.0](https://github.com/dropseed/plain/releases/plain@0.66.0) (2025-09-22)
4
33
 
5
34
  ### What's changed
@@ -1,6 +1,3 @@
1
- from importlib import import_module
2
- from importlib.util import find_spec
3
-
4
1
  from plain.packages import packages_registry
5
2
 
6
3
 
@@ -35,21 +32,7 @@ class ChoresRegistry:
35
32
  """
36
33
  Import modules from installed packages and app to trigger registration.
37
34
  """
38
- # Import from installed packages
39
- for package_config in packages_registry.get_package_configs():
40
- import_name = f"{package_config.name}.chores"
41
- try:
42
- import_module(import_name)
43
- except ModuleNotFoundError:
44
- pass
45
-
46
- # Import from app
47
- import_name = "app.chores"
48
- if find_spec(import_name):
49
- try:
50
- import_module(import_name)
51
- except ModuleNotFoundError:
52
- pass
35
+ packages_registry.autodiscover_modules("chores", include_app=True)
53
36
 
54
37
  def get_chores(self):
55
38
  """
@@ -0,0 +1,172 @@
1
+ import json
2
+
3
+ import click
4
+
5
+ from plain.runtime import settings
6
+ from plain.test import Client
7
+
8
+
9
+ @click.command()
10
+ @click.argument("path")
11
+ @click.option(
12
+ "--method",
13
+ default="GET",
14
+ help="HTTP method (GET, POST, PUT, PATCH, DELETE, etc.)",
15
+ )
16
+ @click.option(
17
+ "--data",
18
+ help="Request data (JSON string for POST/PUT/PATCH)",
19
+ )
20
+ @click.option(
21
+ "--user",
22
+ "user_id",
23
+ help="User ID to authenticate as (skips normal authentication)",
24
+ )
25
+ @click.option(
26
+ "--follow/--no-follow",
27
+ default=True,
28
+ help="Follow redirects (default: True)",
29
+ )
30
+ @click.option(
31
+ "--content-type",
32
+ help="Content-Type header for request data",
33
+ )
34
+ @click.option(
35
+ "--header",
36
+ "headers",
37
+ multiple=True,
38
+ help="Additional headers (format: 'Name: Value')",
39
+ )
40
+ def request(path, method, data, user_id, follow, content_type, headers):
41
+ """Make an HTTP request using the test client against the development database."""
42
+
43
+ try:
44
+ # Only allow in DEBUG mode for security
45
+ if not settings.DEBUG:
46
+ click.secho("This command only works when DEBUG=True", fg="red", err=True)
47
+ return
48
+
49
+ # Create test client
50
+ client = Client()
51
+
52
+ # If user_id provided, force login
53
+ if user_id:
54
+ try:
55
+ # Get the User model using plain.auth utility
56
+ from plain.auth import get_user_model
57
+
58
+ User = get_user_model()
59
+
60
+ # Get the user
61
+ try:
62
+ user = User.query.get(id=user_id)
63
+ client.force_login(user)
64
+ click.secho(
65
+ f"Authenticated as user {user_id}", fg="green", dim=True
66
+ )
67
+ except User.DoesNotExist:
68
+ click.secho(f"User {user_id} not found", fg="red", err=True)
69
+ return
70
+
71
+ except Exception as e:
72
+ click.secho(f"Authentication error: {e}", fg="red", err=True)
73
+ return
74
+
75
+ # Parse additional headers
76
+ header_dict = {}
77
+ for header in headers:
78
+ if ":" in header:
79
+ key, value = header.split(":", 1)
80
+ header_dict[key.strip()] = value.strip()
81
+
82
+ # Prepare request data
83
+ if data and content_type and "json" in content_type.lower():
84
+ try:
85
+ # Validate JSON
86
+ json.loads(data)
87
+ except json.JSONDecodeError as e:
88
+ click.secho(f"Invalid JSON data: {e}", fg="red", err=True)
89
+ return
90
+
91
+ # Make the request
92
+ method = method.upper()
93
+ kwargs = {
94
+ "path": path,
95
+ "follow": follow,
96
+ "headers": header_dict or None,
97
+ }
98
+
99
+ if method in ("POST", "PUT", "PATCH") and data:
100
+ kwargs["data"] = data
101
+ if content_type:
102
+ kwargs["content_type"] = content_type
103
+
104
+ # Call the appropriate client method
105
+ if method == "GET":
106
+ response = client.get(**kwargs)
107
+ elif method == "POST":
108
+ response = client.post(**kwargs)
109
+ elif method == "PUT":
110
+ response = client.put(**kwargs)
111
+ elif method == "PATCH":
112
+ response = client.patch(**kwargs)
113
+ elif method == "DELETE":
114
+ response = client.delete(**kwargs)
115
+ elif method == "HEAD":
116
+ response = client.head(**kwargs)
117
+ elif method == "OPTIONS":
118
+ response = client.options(**kwargs)
119
+ elif method == "TRACE":
120
+ response = client.trace(**kwargs)
121
+ else:
122
+ click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
123
+ return
124
+
125
+ # Display response information
126
+ click.secho(
127
+ f"HTTP {response.status_code}",
128
+ fg="green" if response.status_code < 400 else "red",
129
+ bold=True,
130
+ )
131
+
132
+ # Show additional response info first
133
+ if hasattr(response, "user"):
134
+ click.secho(f"Authenticated user: {response.user}", fg="blue", dim=True)
135
+
136
+ if hasattr(response, "resolver_match") and response.resolver_match:
137
+ match = response.resolver_match
138
+ url_name = match.namespaced_url_name or match.url_name or "unnamed"
139
+ click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
140
+
141
+ # Show headers
142
+ if response.headers:
143
+ click.secho("Response Headers:", fg="yellow", bold=True)
144
+ for key, value in response.headers.items():
145
+ click.echo(f" {key}: {value}")
146
+ click.echo()
147
+
148
+ # Show response content last
149
+ if response.content:
150
+ content_type = response.headers.get("Content-Type", "")
151
+
152
+ if "json" in content_type.lower():
153
+ try:
154
+ json_data = response.json()
155
+ click.secho("Response Body (JSON):", fg="yellow", bold=True)
156
+ click.echo(json.dumps(json_data, indent=2))
157
+ except Exception:
158
+ click.secho("Response Body:", fg="yellow", bold=True)
159
+ click.echo(response.content.decode("utf-8", errors="replace"))
160
+ elif "html" in content_type.lower():
161
+ click.secho("Response Body (HTML):", fg="yellow", bold=True)
162
+ content = response.content.decode("utf-8", errors="replace")
163
+ click.echo(content)
164
+ else:
165
+ click.secho("Response Body:", fg="yellow", bold=True)
166
+ content = response.content.decode("utf-8", errors="replace")
167
+ click.echo(content)
168
+ else:
169
+ click.secho("(No response body)", fg="yellow", dim=True)
170
+
171
+ except Exception as e:
172
+ click.secho(f"Request failed: {e}", fg="red", err=True)
@@ -13,7 +13,7 @@ from .chores import chores
13
13
  from .docs import docs
14
14
  from .formatting import PlainContext
15
15
  from .install import install
16
- from .preflight import preflight_checks
16
+ from .preflight import preflight_cli
17
17
  from .registry import cli_registry
18
18
  from .scaffold import create
19
19
  from .settings import setting
@@ -30,7 +30,7 @@ def plain_cli():
30
30
 
31
31
  plain_cli.add_command(agent)
32
32
  plain_cli.add_command(docs)
33
- plain_cli.add_command(preflight_checks)
33
+ plain_cli.add_command(preflight_cli)
34
34
  plain_cli.add_command(create)
35
35
  plain_cli.add_command(chores)
36
36
  plain_cli.add_command(build)
@@ -0,0 +1,247 @@
1
+ import json
2
+ import sys
3
+
4
+ import click
5
+
6
+ from plain import preflight
7
+ from plain.packages import packages_registry
8
+ from plain.preflight.registry import checks_registry
9
+ from plain.runtime import settings
10
+
11
+
12
+ @click.group("preflight")
13
+ def preflight_cli():
14
+ """Run or manage preflight checks."""
15
+ pass
16
+
17
+
18
+ @preflight_cli.command("check")
19
+ @click.option(
20
+ "--deploy",
21
+ is_flag=True,
22
+ help="Check deployment settings.",
23
+ )
24
+ @click.option(
25
+ "--format",
26
+ default="text",
27
+ type=click.Choice(["text", "json"]),
28
+ help="Output format (default: text)",
29
+ )
30
+ @click.option(
31
+ "--quiet",
32
+ is_flag=True,
33
+ help="Hide progress output and warnings, only show errors.",
34
+ )
35
+ def check_command(deploy, format, quiet):
36
+ """
37
+ Use the system check framework to validate entire Plain project.
38
+ Exit with error code if any errors are found. Warnings do not cause failure.
39
+ """
40
+ # Auto-discover and load preflight checks
41
+ packages_registry.autodiscover_modules("preflight", include_app=True)
42
+
43
+ if not quiet:
44
+ click.secho("Running preflight checks...", dim=True, italic=True, err=True)
45
+
46
+ total_checks = 0
47
+ passed_checks = 0
48
+ check_results = []
49
+
50
+ # Run checks and collect results
51
+ for check_class, check_name, issues in preflight.run_checks(
52
+ include_deploy_checks=deploy,
53
+ ):
54
+ total_checks += 1
55
+
56
+ # Filter out silenced issues
57
+ visible_issues = [issue for issue in issues if not issue.is_silenced()]
58
+
59
+ # For text format, show real-time progress
60
+ if format == "text":
61
+ if not quiet:
62
+ # Print check name without newline
63
+ click.echo("Check:", nl=False, err=True)
64
+ click.secho(f"{check_name} ", bold=True, nl=False, err=True)
65
+
66
+ # Determine status icon based on issue severity
67
+ if not visible_issues:
68
+ # No issues - passed
69
+ if not quiet:
70
+ click.secho("✔", fg="green", err=True)
71
+ passed_checks += 1
72
+ else:
73
+ # Has issues - determine icon based on highest severity
74
+ has_errors = any(not issue.warning for issue in visible_issues)
75
+ if not quiet:
76
+ if has_errors:
77
+ click.secho("✗", fg="red", err=True)
78
+ else:
79
+ click.secho("⚠", fg="yellow", err=True)
80
+
81
+ # Print issues with simple indentation
82
+ issues_to_show = (
83
+ visible_issues
84
+ if not quiet
85
+ else [issue for issue in visible_issues if not issue.warning]
86
+ )
87
+ for i, issue in enumerate(issues_to_show):
88
+ issue_color = "red" if not issue.warning else "yellow"
89
+ issue_type = "ERROR" if not issue.warning else "WARNING"
90
+
91
+ if quiet:
92
+ # In quiet mode, show check name once, then issues
93
+ if i == 0:
94
+ click.secho(f"{check_name}:", err=True)
95
+ # Show ID and fix on separate lines with same indentation
96
+ click.secho(
97
+ f" [{issue_type}] {issue.id}:",
98
+ fg=issue_color,
99
+ bold=True,
100
+ err=True,
101
+ nl=False,
102
+ )
103
+ click.secho(f" {issue.fix}", err=True, dim=True)
104
+ else:
105
+ # Show ID and fix on separate lines with same indentation
106
+ click.secho(
107
+ f" [{issue_type}] {issue.id}: ",
108
+ fg=issue_color,
109
+ bold=True,
110
+ err=True,
111
+ nl=False,
112
+ )
113
+ click.secho(f"{issue.fix}", err=True, dim=True)
114
+ else:
115
+ # For JSON format, just count passed checks
116
+ if not visible_issues:
117
+ passed_checks += 1
118
+
119
+ check_results.append((check_class, check_name, issues))
120
+
121
+ # Output results based on format
122
+
123
+ # Get all issues from check_results instead of maintaining separate list
124
+ all_issues = [issue for _, _, issues in check_results for issue in issues]
125
+ # Errors (non-warnings) cause preflight to fail
126
+ has_errors = any(
127
+ not issue.warning and not issue.is_silenced() for issue in all_issues
128
+ )
129
+
130
+ if format == "json":
131
+ # Build JSON output
132
+ results = {"passed": not has_errors, "checks": []}
133
+
134
+ for check_class, check_name, issues in check_results:
135
+ visible_issues = [issue for issue in issues if not issue.is_silenced()]
136
+
137
+ check_result = {
138
+ "name": check_name,
139
+ "passed": len(visible_issues) == 0,
140
+ "issues": [],
141
+ }
142
+
143
+ for issue in visible_issues:
144
+ issue_data = {
145
+ "id": issue.id,
146
+ "warning": issue.warning,
147
+ "fix": issue.fix,
148
+ "obj": str(issue.obj) if issue.obj is not None else None,
149
+ }
150
+ check_result["issues"].append(issue_data)
151
+
152
+ results["checks"].append(check_result)
153
+
154
+ click.echo(json.dumps(results, indent=2))
155
+ else:
156
+ # Text format summary
157
+ if not quiet:
158
+ click.echo()
159
+
160
+ # Calculate warning and error counts
161
+ warning_count = sum(
162
+ 1
163
+ for _, _, issues in check_results
164
+ if issues
165
+ and not any(
166
+ not issue.warning for issue in issues if not issue.is_silenced()
167
+ )
168
+ )
169
+ error_count = sum(
170
+ 1
171
+ for _, _, issues in check_results
172
+ if issues
173
+ and any(not issue.warning for issue in issues if not issue.is_silenced())
174
+ )
175
+
176
+ # Build colored summary parts
177
+ summary_parts = []
178
+
179
+ if passed_checks > 0:
180
+ summary_parts.append(click.style(f"{passed_checks} passed", fg="green"))
181
+
182
+ if warning_count > 0:
183
+ summary_parts.append(click.style(f"{warning_count} warnings", fg="yellow"))
184
+
185
+ if error_count > 0:
186
+ summary_parts.append(click.style(f"{error_count} errors", fg="red"))
187
+
188
+ # Show checkmark if successful (no errors)
189
+ if not has_errors:
190
+ icon = click.style("✔ ", fg="green")
191
+ summary_color = "green"
192
+ else:
193
+ icon = ""
194
+ summary_color = None
195
+
196
+ summary_text = ", ".join(summary_parts) if summary_parts else "no issues"
197
+
198
+ click.secho(f"{icon}{summary_text}", fg=summary_color, err=True)
199
+
200
+ # Exit with error if there are any errors (not warnings)
201
+ if has_errors:
202
+ sys.exit(1)
203
+
204
+
205
+ @preflight_cli.command("list")
206
+ def list_checks():
207
+ """List all available preflight checks."""
208
+ packages_registry.autodiscover_modules("preflight", include_app=True)
209
+
210
+ regular = []
211
+ deployment = []
212
+ silenced_checks = settings.PREFLIGHT_SILENCED_CHECKS
213
+
214
+ for name, (check_class, deploy) in sorted(checks_registry.checks.items()):
215
+ # Use class docstring as description
216
+ description = check_class.__doc__ or "No description"
217
+ # Get first line of docstring
218
+ description = description.strip().split("\n")[0]
219
+
220
+ is_silenced = name in silenced_checks
221
+ if deploy:
222
+ deployment.append((name, description, is_silenced))
223
+ else:
224
+ regular.append((name, description, is_silenced))
225
+
226
+ if regular:
227
+ click.echo("Regular checks:")
228
+ for name, description, is_silenced in regular:
229
+ silenced_text = (
230
+ click.style(" (silenced)", fg="red", dim=True) if is_silenced else ""
231
+ )
232
+ click.echo(
233
+ f" {click.style(name)}: {click.style(description, dim=True)}{silenced_text}"
234
+ )
235
+
236
+ if deployment:
237
+ click.echo("\nDeployment checks:")
238
+ for name, description, is_silenced in deployment:
239
+ silenced_text = (
240
+ click.style(" (silenced)", fg="red", dim=True) if is_silenced else ""
241
+ )
242
+ click.echo(
243
+ f" {click.style(name)}: {click.style(description, dim=True)}{silenced_text}"
244
+ )
245
+
246
+ if not regular and not deployment:
247
+ click.echo("No preflight checks found.")
@@ -1,6 +1,3 @@
1
- from importlib import import_module
2
- from importlib.util import find_spec
3
-
4
1
  from plain.packages import packages_registry
5
2
 
6
3
 
@@ -18,21 +15,7 @@ class CLIRegistry:
18
15
  """
19
16
  Import modules from installed packages and app to trigger registration.
20
17
  """
21
- # Import from installed packages
22
- for package_config in packages_registry.get_package_configs():
23
- import_name = f"{package_config.name}.cli"
24
- try:
25
- import_module(import_name)
26
- except ModuleNotFoundError:
27
- pass
28
-
29
- # Import from app
30
- import_name = "app.cli"
31
- if find_spec(import_name):
32
- try:
33
- import_module(import_name)
34
- except ModuleNotFoundError:
35
- pass
18
+ packages_registry.autodiscover_modules("cli", include_app=True)
36
19
 
37
20
  def get_commands(self):
38
21
  """
@@ -159,7 +159,6 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
159
159
  Check that the host looks valid and matches a host or host pattern in the
160
160
  given list of ``allowed_hosts``. Supported patterns:
161
161
 
162
- - ``*`` matches anything
163
162
  - ``.example.com`` matches a domain and all its subdomains
164
163
  (e.g. ``example.com`` and ``sub.example.com``)
165
164
  - ``example.com`` matches exactly that domain
@@ -176,10 +175,6 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
176
175
  host_ip = parse_ip_address(host)
177
176
 
178
177
  for pattern in allowed_hosts:
179
- # Wildcard matches everything
180
- if pattern == "*":
181
- return True
182
-
183
178
  # Check CIDR notation patterns using walrus operator
184
179
  if network := parse_cidr_pattern(pattern):
185
180
  if host_ip and host_ip in network:
@@ -2,6 +2,7 @@ import sys
2
2
  import threading
3
3
  from collections import Counter
4
4
  from importlib import import_module
5
+ from importlib.util import find_spec
5
6
 
6
7
  from plain.exceptions import ImproperlyConfigured, PackageRegistryNotReady
7
8
 
@@ -188,6 +189,19 @@ class PackagesRegistry:
188
189
 
189
190
  return package_config
190
191
 
192
+ def autodiscover_modules(self, module_name: str, *, include_app: bool) -> None:
193
+ def _import_if_exists(name):
194
+ if find_spec(name):
195
+ import_module(name)
196
+
197
+ # Load from all packages
198
+ for package_config in self.get_package_configs():
199
+ _import_if_exists(f"{package_config.name}.{module_name}")
200
+
201
+ # Load from app if requested
202
+ if include_app:
203
+ _import_if_exists(f"app.{module_name}")
204
+
191
205
 
192
206
  packages_registry = PackagesRegistry(installed_packages=None)
193
207
 
@@ -13,7 +13,7 @@
13
13
  Preflight checks help identify issues with your settings or environment before running your application.
14
14
 
15
15
  ```bash
16
- plain preflight
16
+ plain preflight check
17
17
  ```
18
18
 
19
19
  ## Development
@@ -22,10 +22,10 @@ If you use [`plain.dev`](/plain-dev/README.md) for local development, the Plain
22
22
 
23
23
  ## Deployment
24
24
 
25
- The `plain preflight` command should often be part of your deployment process. Make sure to add the `--deploy` flag to the command to run checks that are only relevant in a production environment.
25
+ The `plain preflight check` command should often be part of your deployment process. Make sure to add the `--deploy` flag to the command to run checks that are only relevant in a production environment.
26
26
 
27
27
  ```bash
28
- plain preflight --deploy
28
+ plain preflight check --deploy
29
29
  ```
30
30
 
31
31
  ## Custom preflight checks
@@ -51,11 +51,11 @@ def custom_deploy_check(package_configs, **kwargs):
51
51
 
52
52
  ## Silencing preflight checks
53
53
 
54
- The `settings.PREFLIGHT_SILENCED_CHECKS` setting can be used to silence individual checks by their ID (ex. `security.W020`).
54
+ The `settings.PREFLIGHT_SILENCED_CHECKS` setting can be used to silence individual checks by their ID (ex. `security.E020`).
55
55
 
56
56
  ```python
57
57
  # app/settings.py
58
58
  PREFLIGHT_SILENCED_CHECKS = [
59
- "security.W020",
59
+ "security.E020", # Allow empty ALLOWED_HOSTS in deployment
60
60
  ]
61
61
  ```
@@ -0,0 +1,16 @@
1
+ from .checks import PreflightCheck
2
+ from .registry import register_check, run_checks
3
+ from .results import PreflightResult
4
+
5
+ # Import these to force registration of checks
6
+ import plain.preflight.files # NOQA isort:skip
7
+ import plain.preflight.security # NOQA isort:skip
8
+ import plain.preflight.urls # NOQA isort:skip
9
+
10
+
11
+ __all__ = [
12
+ "PreflightCheck",
13
+ "PreflightResult",
14
+ "register_check",
15
+ "run_checks",
16
+ ]
@@ -0,0 +1,10 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class PreflightCheck(ABC):
5
+ """Base class for all preflight checks."""
6
+
7
+ @abstractmethod
8
+ def run(self):
9
+ """Must return a list of Warning/Error results."""
10
+ raise NotImplementedError
@@ -0,0 +1,23 @@
1
+ from pathlib import Path
2
+
3
+ from plain.runtime import settings
4
+
5
+ from .checks import PreflightCheck
6
+ from .registry import register_check
7
+ from .results import PreflightResult
8
+
9
+
10
+ @register_check("files.upload_temp_dir")
11
+ class CheckSettingFileUploadTempDir(PreflightCheck):
12
+ """Validates that the FILE_UPLOAD_TEMP_DIR setting points to an existing directory."""
13
+
14
+ def run(self):
15
+ setting = settings.FILE_UPLOAD_TEMP_DIR
16
+ if setting and not Path(setting).is_dir():
17
+ return [
18
+ PreflightResult(
19
+ fix=f"FILE_UPLOAD_TEMP_DIR points to nonexistent directory '{setting}'. Create the directory or update the setting.",
20
+ id="files.upload_temp_dir_nonexistent",
21
+ )
22
+ ]
23
+ return []