plain 0.65.1__tar.gz → 0.67.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 (182) hide show
  1. {plain-0.65.1 → plain-0.67.0}/PKG-INFO +1 -1
  2. {plain-0.65.1 → plain-0.67.0}/plain/CHANGELOG.md +25 -0
  3. plain-0.67.0/plain/cli/agent/request.py +172 -0
  4. {plain-0.65.1 → plain-0.67.0}/plain/csrf/middleware.py +5 -6
  5. {plain-0.65.1 → plain-0.67.0}/plain/exceptions.py +0 -6
  6. {plain-0.65.1 → plain-0.67.0}/plain/http/request.py +17 -35
  7. {plain-0.65.1 → plain-0.67.0}/plain/internal/handlers/base.py +2 -5
  8. {plain-0.65.1/plain/http → plain-0.67.0/plain/internal/middleware}/hosts.py +59 -18
  9. {plain-0.65.1 → plain-0.67.0}/plain/internal/middleware/https.py +1 -2
  10. {plain-0.65.1 → plain-0.67.0}/plain/internal/middleware/slash.py +1 -1
  11. {plain-0.65.1 → plain-0.67.0}/plain/preflight/README.md +2 -2
  12. {plain-0.65.1 → plain-0.67.0}/plain/preflight/security.py +3 -3
  13. {plain-0.65.1 → plain-0.67.0}/plain/runtime/global_settings.py +3 -2
  14. {plain-0.65.1 → plain-0.67.0}/pyproject.toml +1 -1
  15. {plain-0.65.1 → plain-0.67.0}/tests/test_csrf.py +26 -43
  16. {plain-0.65.1 → plain-0.67.0}/tests/test_http_hosts.py +2 -27
  17. {plain-0.65.1 → plain-0.67.0}/tests/test_wsgi.py +0 -1
  18. plain-0.65.1/plain/cli/agent/request.py +0 -181
  19. {plain-0.65.1 → plain-0.67.0}/.gitignore +0 -0
  20. {plain-0.65.1 → plain-0.67.0}/LICENSE +0 -0
  21. {plain-0.65.1 → plain-0.67.0}/README.md +0 -0
  22. {plain-0.65.1 → plain-0.67.0}/plain/AGENTS.md +0 -0
  23. {plain-0.65.1 → plain-0.67.0}/plain/README.md +0 -0
  24. {plain-0.65.1 → plain-0.67.0}/plain/__main__.py +0 -0
  25. {plain-0.65.1 → plain-0.67.0}/plain/assets/README.md +0 -0
  26. {plain-0.65.1 → plain-0.67.0}/plain/assets/__init__.py +0 -0
  27. {plain-0.65.1 → plain-0.67.0}/plain/assets/compile.py +0 -0
  28. {plain-0.65.1 → plain-0.67.0}/plain/assets/finders.py +0 -0
  29. {plain-0.65.1 → plain-0.67.0}/plain/assets/fingerprints.py +0 -0
  30. {plain-0.65.1 → plain-0.67.0}/plain/assets/urls.py +0 -0
  31. {plain-0.65.1 → plain-0.67.0}/plain/assets/views.py +0 -0
  32. {plain-0.65.1 → plain-0.67.0}/plain/chores/README.md +0 -0
  33. {plain-0.65.1 → plain-0.67.0}/plain/chores/__init__.py +0 -0
  34. {plain-0.65.1 → plain-0.67.0}/plain/chores/registry.py +0 -0
  35. {plain-0.65.1 → plain-0.67.0}/plain/cli/README.md +0 -0
  36. {plain-0.65.1 → plain-0.67.0}/plain/cli/__init__.py +0 -0
  37. {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/__init__.py +0 -0
  38. {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/docs.py +0 -0
  39. {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/llmdocs.py +0 -0
  40. {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/md.py +0 -0
  41. {plain-0.65.1 → plain-0.67.0}/plain/cli/agent/prompt.py +0 -0
  42. {plain-0.65.1 → plain-0.67.0}/plain/cli/build.py +0 -0
  43. {plain-0.65.1 → plain-0.67.0}/plain/cli/changelog.py +0 -0
  44. {plain-0.65.1 → plain-0.67.0}/plain/cli/chores.py +0 -0
  45. {plain-0.65.1 → plain-0.67.0}/plain/cli/core.py +0 -0
  46. {plain-0.65.1 → plain-0.67.0}/plain/cli/docs.py +0 -0
  47. {plain-0.65.1 → plain-0.67.0}/plain/cli/formatting.py +0 -0
  48. {plain-0.65.1 → plain-0.67.0}/plain/cli/install.py +0 -0
  49. {plain-0.65.1 → plain-0.67.0}/plain/cli/output.py +0 -0
  50. {plain-0.65.1 → plain-0.67.0}/plain/cli/preflight.py +0 -0
  51. {plain-0.65.1 → plain-0.67.0}/plain/cli/print.py +0 -0
  52. {plain-0.65.1 → plain-0.67.0}/plain/cli/registry.py +0 -0
  53. {plain-0.65.1 → plain-0.67.0}/plain/cli/scaffold.py +0 -0
  54. {plain-0.65.1 → plain-0.67.0}/plain/cli/settings.py +0 -0
  55. {plain-0.65.1 → plain-0.67.0}/plain/cli/shell.py +0 -0
  56. {plain-0.65.1 → plain-0.67.0}/plain/cli/startup.py +0 -0
  57. {plain-0.65.1 → plain-0.67.0}/plain/cli/upgrade.py +0 -0
  58. {plain-0.65.1 → plain-0.67.0}/plain/cli/urls.py +0 -0
  59. {plain-0.65.1 → plain-0.67.0}/plain/cli/utils.py +0 -0
  60. {plain-0.65.1 → plain-0.67.0}/plain/csrf/README.md +0 -0
  61. {plain-0.65.1 → plain-0.67.0}/plain/csrf/views.py +0 -0
  62. {plain-0.65.1 → plain-0.67.0}/plain/debug.py +0 -0
  63. {plain-0.65.1 → plain-0.67.0}/plain/forms/README.md +0 -0
  64. {plain-0.65.1 → plain-0.67.0}/plain/forms/__init__.py +0 -0
  65. {plain-0.65.1 → plain-0.67.0}/plain/forms/boundfield.py +0 -0
  66. {plain-0.65.1 → plain-0.67.0}/plain/forms/exceptions.py +0 -0
  67. {plain-0.65.1 → plain-0.67.0}/plain/forms/fields.py +0 -0
  68. {plain-0.65.1 → plain-0.67.0}/plain/forms/forms.py +0 -0
  69. {plain-0.65.1 → plain-0.67.0}/plain/http/README.md +0 -0
  70. {plain-0.65.1 → plain-0.67.0}/plain/http/__init__.py +0 -0
  71. {plain-0.65.1 → plain-0.67.0}/plain/http/cookie.py +0 -0
  72. {plain-0.65.1 → plain-0.67.0}/plain/http/multipartparser.py +0 -0
  73. {plain-0.65.1 → plain-0.67.0}/plain/http/response.py +0 -0
  74. {plain-0.65.1 → plain-0.67.0}/plain/internal/__init__.py +0 -0
  75. {plain-0.65.1 → plain-0.67.0}/plain/internal/files/__init__.py +0 -0
  76. {plain-0.65.1 → plain-0.67.0}/plain/internal/files/base.py +0 -0
  77. {plain-0.65.1 → plain-0.67.0}/plain/internal/files/locks.py +0 -0
  78. {plain-0.65.1 → plain-0.67.0}/plain/internal/files/move.py +0 -0
  79. {plain-0.65.1 → plain-0.67.0}/plain/internal/files/temp.py +0 -0
  80. {plain-0.65.1 → plain-0.67.0}/plain/internal/files/uploadedfile.py +0 -0
  81. {plain-0.65.1 → plain-0.67.0}/plain/internal/files/uploadhandler.py +0 -0
  82. {plain-0.65.1 → plain-0.67.0}/plain/internal/files/utils.py +0 -0
  83. {plain-0.65.1 → plain-0.67.0}/plain/internal/handlers/__init__.py +0 -0
  84. {plain-0.65.1 → plain-0.67.0}/plain/internal/handlers/exception.py +0 -0
  85. {plain-0.65.1 → plain-0.67.0}/plain/internal/handlers/wsgi.py +0 -0
  86. {plain-0.65.1 → plain-0.67.0}/plain/internal/middleware/__init__.py +0 -0
  87. {plain-0.65.1 → plain-0.67.0}/plain/internal/middleware/headers.py +0 -0
  88. {plain-0.65.1 → plain-0.67.0}/plain/json.py +0 -0
  89. {plain-0.65.1 → plain-0.67.0}/plain/logs/README.md +0 -0
  90. {plain-0.65.1 → plain-0.67.0}/plain/logs/__init__.py +0 -0
  91. {plain-0.65.1 → plain-0.67.0}/plain/logs/configure.py +0 -0
  92. {plain-0.65.1 → plain-0.67.0}/plain/logs/debug.py +0 -0
  93. {plain-0.65.1 → plain-0.67.0}/plain/logs/formatters.py +0 -0
  94. {plain-0.65.1 → plain-0.67.0}/plain/logs/loggers.py +0 -0
  95. {plain-0.65.1 → plain-0.67.0}/plain/logs/utils.py +0 -0
  96. {plain-0.65.1 → plain-0.67.0}/plain/packages/README.md +0 -0
  97. {plain-0.65.1 → plain-0.67.0}/plain/packages/__init__.py +0 -0
  98. {plain-0.65.1 → plain-0.67.0}/plain/packages/config.py +0 -0
  99. {plain-0.65.1 → plain-0.67.0}/plain/packages/registry.py +0 -0
  100. {plain-0.65.1 → plain-0.67.0}/plain/paginator.py +0 -0
  101. {plain-0.65.1 → plain-0.67.0}/plain/preflight/__init__.py +0 -0
  102. {plain-0.65.1 → plain-0.67.0}/plain/preflight/files.py +0 -0
  103. {plain-0.65.1 → plain-0.67.0}/plain/preflight/messages.py +0 -0
  104. {plain-0.65.1 → plain-0.67.0}/plain/preflight/registry.py +0 -0
  105. {plain-0.65.1 → plain-0.67.0}/plain/preflight/urls.py +0 -0
  106. {plain-0.65.1 → plain-0.67.0}/plain/runtime/README.md +0 -0
  107. {plain-0.65.1 → plain-0.67.0}/plain/runtime/__init__.py +0 -0
  108. {plain-0.65.1 → plain-0.67.0}/plain/runtime/user_settings.py +0 -0
  109. {plain-0.65.1 → plain-0.67.0}/plain/runtime/utils.py +0 -0
  110. {plain-0.65.1 → plain-0.67.0}/plain/signals/README.md +0 -0
  111. {plain-0.65.1 → plain-0.67.0}/plain/signals/__init__.py +0 -0
  112. {plain-0.65.1 → plain-0.67.0}/plain/signals/dispatch/__init__.py +0 -0
  113. {plain-0.65.1 → plain-0.67.0}/plain/signals/dispatch/dispatcher.py +0 -0
  114. {plain-0.65.1 → plain-0.67.0}/plain/signals/dispatch/license.txt +0 -0
  115. {plain-0.65.1 → plain-0.67.0}/plain/signing.py +0 -0
  116. {plain-0.65.1 → plain-0.67.0}/plain/templates/AGENTS.md +0 -0
  117. {plain-0.65.1 → plain-0.67.0}/plain/templates/README.md +0 -0
  118. {plain-0.65.1 → plain-0.67.0}/plain/templates/__init__.py +0 -0
  119. {plain-0.65.1 → plain-0.67.0}/plain/templates/core.py +0 -0
  120. {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/__init__.py +0 -0
  121. {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/environments.py +0 -0
  122. {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/extensions.py +0 -0
  123. {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/filters.py +0 -0
  124. {plain-0.65.1 → plain-0.67.0}/plain/templates/jinja/globals.py +0 -0
  125. {plain-0.65.1 → plain-0.67.0}/plain/test/README.md +0 -0
  126. {plain-0.65.1 → plain-0.67.0}/plain/test/__init__.py +0 -0
  127. {plain-0.65.1 → plain-0.67.0}/plain/test/client.py +0 -0
  128. {plain-0.65.1 → plain-0.67.0}/plain/test/encoding.py +0 -0
  129. {plain-0.65.1 → plain-0.67.0}/plain/test/exceptions.py +0 -0
  130. {plain-0.65.1 → plain-0.67.0}/plain/urls/README.md +0 -0
  131. {plain-0.65.1 → plain-0.67.0}/plain/urls/__init__.py +0 -0
  132. {plain-0.65.1 → plain-0.67.0}/plain/urls/converters.py +0 -0
  133. {plain-0.65.1 → plain-0.67.0}/plain/urls/exceptions.py +0 -0
  134. {plain-0.65.1 → plain-0.67.0}/plain/urls/patterns.py +0 -0
  135. {plain-0.65.1 → plain-0.67.0}/plain/urls/resolvers.py +0 -0
  136. {plain-0.65.1 → plain-0.67.0}/plain/urls/routers.py +0 -0
  137. {plain-0.65.1 → plain-0.67.0}/plain/urls/utils.py +0 -0
  138. {plain-0.65.1 → plain-0.67.0}/plain/utils/README.md +0 -0
  139. {plain-0.65.1 → plain-0.67.0}/plain/utils/__init__.py +0 -0
  140. {plain-0.65.1 → plain-0.67.0}/plain/utils/cache.py +0 -0
  141. {plain-0.65.1 → plain-0.67.0}/plain/utils/crypto.py +0 -0
  142. {plain-0.65.1 → plain-0.67.0}/plain/utils/datastructures.py +0 -0
  143. {plain-0.65.1 → plain-0.67.0}/plain/utils/dateparse.py +0 -0
  144. {plain-0.65.1 → plain-0.67.0}/plain/utils/deconstruct.py +0 -0
  145. {plain-0.65.1 → plain-0.67.0}/plain/utils/decorators.py +0 -0
  146. {plain-0.65.1 → plain-0.67.0}/plain/utils/duration.py +0 -0
  147. {plain-0.65.1 → plain-0.67.0}/plain/utils/encoding.py +0 -0
  148. {plain-0.65.1 → plain-0.67.0}/plain/utils/functional.py +0 -0
  149. {plain-0.65.1 → plain-0.67.0}/plain/utils/hashable.py +0 -0
  150. {plain-0.65.1 → plain-0.67.0}/plain/utils/html.py +0 -0
  151. {plain-0.65.1 → plain-0.67.0}/plain/utils/http.py +0 -0
  152. {plain-0.65.1 → plain-0.67.0}/plain/utils/inspect.py +0 -0
  153. {plain-0.65.1 → plain-0.67.0}/plain/utils/ipv6.py +0 -0
  154. {plain-0.65.1 → plain-0.67.0}/plain/utils/itercompat.py +0 -0
  155. {plain-0.65.1 → plain-0.67.0}/plain/utils/module_loading.py +0 -0
  156. {plain-0.65.1 → plain-0.67.0}/plain/utils/regex_helper.py +0 -0
  157. {plain-0.65.1 → plain-0.67.0}/plain/utils/safestring.py +0 -0
  158. {plain-0.65.1 → plain-0.67.0}/plain/utils/text.py +0 -0
  159. {plain-0.65.1 → plain-0.67.0}/plain/utils/timesince.py +0 -0
  160. {plain-0.65.1 → plain-0.67.0}/plain/utils/timezone.py +0 -0
  161. {plain-0.65.1 → plain-0.67.0}/plain/utils/tree.py +0 -0
  162. {plain-0.65.1 → plain-0.67.0}/plain/validators.py +0 -0
  163. {plain-0.65.1 → plain-0.67.0}/plain/views/README.md +0 -0
  164. {plain-0.65.1 → plain-0.67.0}/plain/views/__init__.py +0 -0
  165. {plain-0.65.1 → plain-0.67.0}/plain/views/base.py +0 -0
  166. {plain-0.65.1 → plain-0.67.0}/plain/views/errors.py +0 -0
  167. {plain-0.65.1 → plain-0.67.0}/plain/views/exceptions.py +0 -0
  168. {plain-0.65.1 → plain-0.67.0}/plain/views/forms.py +0 -0
  169. {plain-0.65.1 → plain-0.67.0}/plain/views/objects.py +0 -0
  170. {plain-0.65.1 → plain-0.67.0}/plain/views/redirect.py +0 -0
  171. {plain-0.65.1 → plain-0.67.0}/plain/views/templates.py +0 -0
  172. {plain-0.65.1 → plain-0.67.0}/plain/wsgi.py +0 -0
  173. {plain-0.65.1 → plain-0.67.0}/tests/.gitignore +0 -0
  174. {plain-0.65.1 → plain-0.67.0}/tests/app/.gitignore +0 -0
  175. {plain-0.65.1 → plain-0.67.0}/tests/app/settings.py +0 -0
  176. {plain-0.65.1 → plain-0.67.0}/tests/app/test/__init__.py +0 -0
  177. {plain-0.65.1 → plain-0.67.0}/tests/app/test/default_settings.py +0 -0
  178. {plain-0.65.1 → plain-0.67.0}/tests/app/urls.py +0 -0
  179. {plain-0.65.1 → plain-0.67.0}/tests/conftest.py +0 -0
  180. {plain-0.65.1 → plain-0.67.0}/tests/test_cli.py +0 -0
  181. {plain-0.65.1 → plain-0.67.0}/tests/test_logs.py +0 -0
  182. {plain-0.65.1 → plain-0.67.0}/tests/test_runtime.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.65.1
3
+ Version: 0.67.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,30 @@
1
1
  # plain changelog
2
2
 
3
+ ## [0.67.0](https://github.com/dropseed/plain/releases/plain@0.67.0) (2025-09-22)
4
+
5
+ ### What's changed
6
+
7
+ - `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))
8
+ - 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))
9
+
10
+ ### Upgrade instructions
11
+
12
+ - No changes required
13
+
14
+ ## [0.66.0](https://github.com/dropseed/plain/releases/plain@0.66.0) (2025-09-22)
15
+
16
+ ### What's changed
17
+
18
+ - Host validation moved to dedicated middleware and `ALLOWED_HOSTS` setting is now required ([6a4b7be](https://github.com/dropseed/plain/commit/6a4b7be220))
19
+ - Changed `request.get_port()` method to `request.port` cached property ([544f3e1](https://github.com/dropseed/plain/commit/544f3e19f8))
20
+ - Removed internal `request._get_full_path()` method ([50cdb58](https://github.com/dropseed/plain/commit/50cdb58d4e))
21
+
22
+ ### Upgrade instructions
23
+
24
+ - Add `ALLOWED_HOSTS` setting to your configuration if not already present (required for host validation)
25
+ - Replace any usage of `request.get_host()` with `request.host`
26
+ - Replace any usage of `request.get_port()` with `request.port`
27
+
3
28
  ## [0.65.1](https://github.com/dropseed/plain/releases/plain@0.65.1) (2025-09-22)
4
29
 
5
30
  ### What's changed
@@ -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)
@@ -2,7 +2,6 @@ import logging
2
2
  import re
3
3
  from urllib.parse import urlparse
4
4
 
5
- from plain.exceptions import DisallowedHost
6
5
  from plain.logs.utils import log_response
7
6
  from plain.runtime import settings
8
7
 
@@ -81,8 +80,8 @@ class CsrfViewMiddleware:
81
80
  if origin == "null":
82
81
  return False, "Cross-origin request detected - null Origin header"
83
82
 
84
- try:
85
- if (parsed_origin := urlparse(origin)) and (host := request.get_host()):
83
+ if (parsed_origin := urlparse(origin)) and (host := request.host):
84
+ try:
86
85
  # Scheme-agnostic host:port comparison
87
86
  origin_host = parsed_origin.hostname
88
87
  origin_port = parsed_origin.port or (
@@ -97,7 +96,7 @@ class CsrfViewMiddleware:
97
96
  # Use a fake scheme since we only care about host parsing
98
97
  parsed_host = urlparse(f"http://{host}")
99
98
  request_host = parsed_host.hostname or host
100
- request_port = request.get_port()
99
+ request_port = request.port
101
100
 
102
101
  # Compare hostname and port (scheme-agnostic)
103
102
  # Both origin_host and request_host are normalized by urlparse (IPv6 brackets stripped)
@@ -110,8 +109,8 @@ class CsrfViewMiddleware:
110
109
  True,
111
110
  f"Same-origin request - Origin {origin} matches Host {host}",
112
111
  )
113
- except (ValueError, DisallowedHost):
114
- pass
112
+ except ValueError:
113
+ pass
115
114
 
116
115
  # Origin present but doesn't match host
117
116
  return (
@@ -67,12 +67,6 @@ class SuspiciousFileOperation(SuspiciousOperation):
67
67
  pass
68
68
 
69
69
 
70
- class DisallowedHost(SuspiciousOperation):
71
- """HTTP_HOST header contains invalid value"""
72
-
73
- pass
74
-
75
-
76
70
  class TooManyFieldsSent(SuspiciousOperation):
77
71
  """
78
72
  The number of fields in a GET or POST request exceeded
@@ -8,7 +8,6 @@ from itertools import chain
8
8
  from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlsplit
9
9
 
10
10
  from plain.exceptions import (
11
- DisallowedHost,
12
11
  ImproperlyConfigured,
13
12
  RequestDataTooBig,
14
13
  TooManyFieldsSent,
@@ -29,8 +28,6 @@ from plain.utils.datastructures import (
29
28
  from plain.utils.encoding import iri_to_uri
30
29
  from plain.utils.http import parse_header_parameters
31
30
 
32
- from .hosts import split_domain_port, validate_host
33
-
34
31
 
35
32
  class UnreadablePostError(OSError):
36
33
  pass
@@ -123,10 +120,13 @@ class HttpRequest:
123
120
  else:
124
121
  self.encoding = self.content_params["charset"]
125
122
 
126
- def _get_raw_host(self):
123
+ @cached_property
124
+ def host(self):
127
125
  """
128
- Return the HTTP host using the environment or request headers. Skip
129
- allowed hosts protection, so may return an insecure host.
126
+ Return the HTTP host using the environment or request headers.
127
+
128
+ Host validation is performed by HostValidationMiddleware, so this
129
+ property can safely return the host without any validation.
130
130
  """
131
131
  # We try three options, in order of decreasing preference.
132
132
  if settings.USE_X_FORWARDED_HOST and ("HTTP_X_FORWARDED_HOST" in self.meta):
@@ -136,34 +136,13 @@ class HttpRequest:
136
136
  else:
137
137
  # Reconstruct the host using the algorithm from PEP 333.
138
138
  host = self.meta["SERVER_NAME"]
139
- server_port = self.get_port()
139
+ server_port = self.port
140
140
  if server_port != ("443" if self.is_https() else "80"):
141
141
  host = f"{host}:{server_port}"
142
142
  return host
143
143
 
144
- def get_host(self):
145
- """Return the HTTP host using the environment or request headers."""
146
- host = self._get_raw_host()
147
-
148
- # Allow variants of localhost if ALLOWED_HOSTS is empty and DEBUG=True.
149
- allowed_hosts = settings.ALLOWED_HOSTS
150
- if settings.DEBUG and not allowed_hosts:
151
- allowed_hosts = [".localhost", "127.0.0.1", "[::1]"]
152
-
153
- domain, port = split_domain_port(host)
154
- if domain and validate_host(domain, allowed_hosts):
155
- return host
156
- else:
157
- msg = f"Invalid HTTP_HOST header: {host!r}."
158
- if domain:
159
- msg += f" You may need to add {domain!r} to ALLOWED_HOSTS."
160
- else:
161
- msg += (
162
- " The domain name provided is not valid according to RFC 1034/1035."
163
- )
164
- raise DisallowedHost(msg)
165
-
166
- def get_port(self):
144
+ @cached_property
145
+ def port(self):
167
146
  """Return the port number for the request as a string."""
168
147
  if settings.USE_X_FORWARDED_PORT and "HTTP_X_FORWARDED_PORT" in self.meta:
169
148
  port = self.meta["HTTP_X_FORWARDED_PORT"]
@@ -172,9 +151,12 @@ class HttpRequest:
172
151
  return str(port)
173
152
 
174
153
  def get_full_path(self, force_append_slash=False):
175
- return self._get_full_path(self.path, force_append_slash)
154
+ """
155
+ Return the full path for the request, including query string.
176
156
 
177
- def _get_full_path(self, path, force_append_slash):
157
+ If force_append_slash is True, append a trailing slash if the path
158
+ doesn't already end with one.
159
+ """
178
160
  # RFC 3986 requires query string arguments to be in the ASCII range.
179
161
  # Rather than crash if this doesn't happen, we encode defensively.
180
162
 
@@ -195,8 +177,8 @@ class HttpRequest:
195
177
  return quote(path, safe="/:@&+$,-_.!~*'()")
196
178
 
197
179
  return "{}{}{}".format(
198
- escape_uri_path(path),
199
- "/" if force_append_slash and not path.endswith("/") else "",
180
+ escape_uri_path(self.path),
181
+ "/" if force_append_slash and not self.path.endswith("/") else "",
200
182
  ("?" + iri_to_uri(self.meta.get("QUERY_STRING", "")))
201
183
  if self.meta.get("QUERY_STRING", "")
202
184
  else "",
@@ -220,7 +202,7 @@ class HttpRequest:
220
202
  location = str(location)
221
203
  bits = urlsplit(location)
222
204
  if not (bits.scheme and bits.netloc):
223
- current_scheme_host = f"{self.scheme}://{self.get_host()}"
205
+ current_scheme_host = f"{self.scheme}://{self.host}"
224
206
 
225
207
  # Handle the simple, most common case. If the location is absolute
226
208
  # and a scheme or host (netloc) isn't provided, skip an expensive
@@ -4,7 +4,7 @@ import types
4
4
  from opentelemetry import baggage, trace
5
5
  from opentelemetry.semconv.attributes import http_attributes, url_attributes
6
6
 
7
- from plain.exceptions import DisallowedHost, ImproperlyConfigured
7
+ from plain.exceptions import ImproperlyConfigured
8
8
  from plain.logs.utils import log_response
9
9
  from plain.runtime import settings
10
10
  from plain.urls import get_resolver
@@ -17,6 +17,7 @@ logger = logging.getLogger("plain.request")
17
17
 
18
18
  # These middleware classes are always used by Plain.
19
19
  BUILTIN_BEFORE_MIDDLEWARE = [
20
+ "plain.internal.middleware.hosts.HostValidationMiddleware", # Validate Host header first
20
21
  "plain.internal.middleware.headers.DefaultHeadersMiddleware", # Runs after response, to set missing headers
21
22
  "plain.internal.middleware.https.HttpsRedirectMiddleware", # Runs before response, to redirect to HTTPS quickly
22
23
  "plain.csrf.middleware.CsrfViewMiddleware", # Runs before and after get_response...
@@ -78,10 +79,6 @@ class BaseHandler:
78
79
  except KeyError:
79
80
  # Missing required WSGI environment variables (e.g. in tests)
80
81
  pass
81
- except DisallowedHost:
82
- # Invalid host header - skip URL_FULL for telemetry but let the
83
- # exception be handled normally by middleware for proper 400 response
84
- pass
85
82
 
86
83
  # Add query string if present
87
84
  if query_string := request.meta.get("QUERY_STRING"):
@@ -1,19 +1,65 @@
1
- """
2
- Host validation utilities for ALLOWED_HOSTS functionality.
3
-
4
- This module provides functions for validating hosts against allowed patterns,
5
- including domain patterns, wildcards, and CIDR notation for IP ranges.
6
- """
7
-
8
1
  import ipaddress
2
+ import logging
9
3
 
4
+ from plain.http import HttpRequest, ResponseBadRequest
5
+ from plain.runtime import settings
10
6
  from plain.utils.regex_helper import _lazy_re_compile
11
7
 
8
+ logger = logging.getLogger(__name__)
9
+
12
10
  host_validation_re = _lazy_re_compile(
13
11
  r"^([a-z0-9.-]+|\[[a-f0-9]*:[a-f0-9\.:]+\])(:[0-9]+)?$"
14
12
  )
15
13
 
16
14
 
15
+ class HostValidationMiddleware:
16
+ """
17
+ Middleware to validate the Host header against ALLOWED_HOSTS.
18
+
19
+ This middleware should run first to ensure all subsequent code can trust
20
+ that the host header is valid. Returns a 400 Bad Request response if the
21
+ host is not allowed.
22
+ """
23
+
24
+ def __init__(self, get_response):
25
+ self.get_response = get_response
26
+
27
+ def __call__(self, request):
28
+ if not is_host_valid(request):
29
+ host = request.host
30
+ msg = f"Invalid HTTP_HOST header: {host!r}."
31
+
32
+ domain, _ = split_domain_port(host)
33
+ if domain:
34
+ msg += f" You may need to add {domain!r} to ALLOWED_HOSTS."
35
+ else:
36
+ msg += (
37
+ " The domain name provided is not valid according to RFC 1034/1035."
38
+ )
39
+
40
+ logger.warning(
41
+ msg,
42
+ extra={"status_code": 400, "request": request},
43
+ )
44
+
45
+ return ResponseBadRequest()
46
+
47
+ return self.get_response(request)
48
+
49
+
50
+ def is_host_valid(request: HttpRequest) -> bool:
51
+ """
52
+ Check if the host is valid according to ALLOWED_HOSTS settings.
53
+ """
54
+ allowed_hosts = settings.ALLOWED_HOSTS
55
+
56
+ if not allowed_hosts:
57
+ return True # Allow all hosts if ALLOWED_HOSTS is empty
58
+
59
+ domain, _ = split_domain_port(request.host)
60
+ return bool(domain) and validate_host(domain, allowed_hosts)
61
+
62
+
17
63
  def split_domain_port(host: str) -> tuple[str, str]:
18
64
  """
19
65
  Return a (domain, port) tuple from a given host.
@@ -36,7 +82,7 @@ def split_domain_port(host: str) -> tuple[str, str]:
36
82
  return domain, port
37
83
 
38
84
 
39
- def _is_same_domain(host: str, pattern: str) -> bool:
85
+ def is_same_domain(host: str, pattern: str) -> bool:
40
86
  """
41
87
  Return ``True`` if the host is either an exact match or a match
42
88
  to the wildcard pattern.
@@ -56,7 +102,7 @@ def _is_same_domain(host: str, pattern: str) -> bool:
56
102
  )
57
103
 
58
104
 
59
- def _parse_ip_address(
105
+ def parse_ip_address(
60
106
  host: str,
61
107
  ) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
62
108
  """
@@ -75,7 +121,7 @@ def _parse_ip_address(
75
121
  return None
76
122
 
77
123
 
78
- def _parse_cidr_pattern(
124
+ def parse_cidr_pattern(
79
125
  pattern: str,
80
126
  ) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None:
81
127
  """
@@ -113,7 +159,6 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
113
159
  Check that the host looks valid and matches a host or host pattern in the
114
160
  given list of ``allowed_hosts``. Supported patterns:
115
161
 
116
- - ``*`` matches anything
117
162
  - ``.example.com`` matches a domain and all its subdomains
118
163
  (e.g. ``example.com`` and ``sub.example.com``)
119
164
  - ``example.com`` matches exactly that domain
@@ -127,21 +172,17 @@ def validate_host(host: str, allowed_hosts: list[str]) -> bool:
127
172
  Return ``True`` for a valid host, ``False`` otherwise.
128
173
  """
129
174
  # Parse the host as an IP address if possible
130
- host_ip = _parse_ip_address(host)
175
+ host_ip = parse_ip_address(host)
131
176
 
132
177
  for pattern in allowed_hosts:
133
- # Wildcard matches everything
134
- if pattern == "*":
135
- return True
136
-
137
178
  # Check CIDR notation patterns using walrus operator
138
- if network := _parse_cidr_pattern(pattern):
179
+ if network := parse_cidr_pattern(pattern):
139
180
  if host_ip and host_ip in network:
140
181
  return True
141
182
  continue
142
183
 
143
184
  # For non-CIDR patterns, use existing domain matching logic
144
- if _is_same_domain(host, pattern):
185
+ if is_same_domain(host, pattern):
145
186
  return True
146
187
 
147
188
  return False
@@ -21,7 +21,6 @@ class HttpsRedirectMiddleware:
21
21
 
22
22
  def maybe_https_redirect(self, request):
23
23
  if self.https_redirect_enabled and not request.is_https():
24
- host = request.get_host()
25
24
  return ResponseRedirect(
26
- f"https://{host}{request.get_full_path()}", status_code=301
25
+ f"https://{request.host}{request.get_full_path()}", status_code=301
27
26
  )
@@ -65,7 +65,7 @@ class RedirectSlashMiddleware:
65
65
  f"You called this URL via {request.method}, but the URL doesn't end "
66
66
  "in a slash and you have APPEND_SLASH set. Plain can't "
67
67
  f"redirect to the slash URL while maintaining {request.method} data. "
68
- f"Change your form to point to {request.get_host() + new_path} (note the trailing "
68
+ f"Change your form to point to {request.host + new_path} (note the trailing "
69
69
  "slash), or set APPEND_SLASH=False in your Plain settings."
70
70
  )
71
71
  return new_path
@@ -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
  ```
@@ -1,7 +1,7 @@
1
1
  from plain.exceptions import ImproperlyConfigured
2
2
  from plain.runtime import settings
3
3
 
4
- from .messages import Warning
4
+ from .messages import Error, Warning
5
5
  from .registry import register_check
6
6
 
7
7
  SECRET_KEY_MIN_LENGTH = 50
@@ -81,9 +81,9 @@ def check_allowed_hosts(package_configs, **kwargs):
81
81
  []
82
82
  if settings.ALLOWED_HOSTS
83
83
  else [
84
- Warning(
84
+ Error(
85
85
  "ALLOWED_HOSTS must not be empty in deployment.",
86
- id="security.W020",
86
+ id="security.E020",
87
87
  )
88
88
  ]
89
89
  )
@@ -23,8 +23,9 @@ URLS_ROUTER: str
23
23
  # MARK: HTTP and Security
24
24
 
25
25
  # Hosts/domain names that are valid for this site.
26
- # "*" matches anything, ".example.com" matches example.com and all subdomains
27
- # "192.168.1.0/24" matches IP addresses in that CIDR range
26
+ # - An empty list [] allows all hosts (useful for development).
27
+ # - ".example.com" matches example.com and all subdomains
28
+ # - "192.168.1.0/24" matches IP addresses in that CIDR range
28
29
  ALLOWED_HOSTS: list[str] = []
29
30
 
30
31
  # Default headers for all responses.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain"
3
- version = "0.65.1"
3
+ version = "0.67.0"
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"