plain 0.66.0__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.66.0 → plain-0.67.0}/PKG-INFO +1 -1
  2. {plain-0.66.0 → plain-0.67.0}/plain/CHANGELOG.md +11 -0
  3. plain-0.67.0/plain/cli/agent/request.py +172 -0
  4. {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/hosts.py +0 -5
  5. {plain-0.66.0 → plain-0.67.0}/plain/preflight/README.md +2 -2
  6. {plain-0.66.0 → plain-0.67.0}/plain/preflight/security.py +3 -3
  7. {plain-0.66.0 → plain-0.67.0}/plain/runtime/global_settings.py +4 -3
  8. {plain-0.66.0 → plain-0.67.0}/pyproject.toml +1 -1
  9. {plain-0.66.0 → plain-0.67.0}/tests/app/settings.py +0 -1
  10. {plain-0.66.0 → plain-0.67.0}/tests/test_csrf.py +26 -43
  11. {plain-0.66.0 → plain-0.67.0}/tests/test_http_hosts.py +1 -26
  12. plain-0.66.0/plain/cli/agent/request.py +0 -181
  13. {plain-0.66.0 → plain-0.67.0}/.gitignore +0 -0
  14. {plain-0.66.0 → plain-0.67.0}/LICENSE +0 -0
  15. {plain-0.66.0 → plain-0.67.0}/README.md +0 -0
  16. {plain-0.66.0 → plain-0.67.0}/plain/AGENTS.md +0 -0
  17. {plain-0.66.0 → plain-0.67.0}/plain/README.md +0 -0
  18. {plain-0.66.0 → plain-0.67.0}/plain/__main__.py +0 -0
  19. {plain-0.66.0 → plain-0.67.0}/plain/assets/README.md +0 -0
  20. {plain-0.66.0 → plain-0.67.0}/plain/assets/__init__.py +0 -0
  21. {plain-0.66.0 → plain-0.67.0}/plain/assets/compile.py +0 -0
  22. {plain-0.66.0 → plain-0.67.0}/plain/assets/finders.py +0 -0
  23. {plain-0.66.0 → plain-0.67.0}/plain/assets/fingerprints.py +0 -0
  24. {plain-0.66.0 → plain-0.67.0}/plain/assets/urls.py +0 -0
  25. {plain-0.66.0 → plain-0.67.0}/plain/assets/views.py +0 -0
  26. {plain-0.66.0 → plain-0.67.0}/plain/chores/README.md +0 -0
  27. {plain-0.66.0 → plain-0.67.0}/plain/chores/__init__.py +0 -0
  28. {plain-0.66.0 → plain-0.67.0}/plain/chores/registry.py +0 -0
  29. {plain-0.66.0 → plain-0.67.0}/plain/cli/README.md +0 -0
  30. {plain-0.66.0 → plain-0.67.0}/plain/cli/__init__.py +0 -0
  31. {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/__init__.py +0 -0
  32. {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/docs.py +0 -0
  33. {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/llmdocs.py +0 -0
  34. {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/md.py +0 -0
  35. {plain-0.66.0 → plain-0.67.0}/plain/cli/agent/prompt.py +0 -0
  36. {plain-0.66.0 → plain-0.67.0}/plain/cli/build.py +0 -0
  37. {plain-0.66.0 → plain-0.67.0}/plain/cli/changelog.py +0 -0
  38. {plain-0.66.0 → plain-0.67.0}/plain/cli/chores.py +0 -0
  39. {plain-0.66.0 → plain-0.67.0}/plain/cli/core.py +0 -0
  40. {plain-0.66.0 → plain-0.67.0}/plain/cli/docs.py +0 -0
  41. {plain-0.66.0 → plain-0.67.0}/plain/cli/formatting.py +0 -0
  42. {plain-0.66.0 → plain-0.67.0}/plain/cli/install.py +0 -0
  43. {plain-0.66.0 → plain-0.67.0}/plain/cli/output.py +0 -0
  44. {plain-0.66.0 → plain-0.67.0}/plain/cli/preflight.py +0 -0
  45. {plain-0.66.0 → plain-0.67.0}/plain/cli/print.py +0 -0
  46. {plain-0.66.0 → plain-0.67.0}/plain/cli/registry.py +0 -0
  47. {plain-0.66.0 → plain-0.67.0}/plain/cli/scaffold.py +0 -0
  48. {plain-0.66.0 → plain-0.67.0}/plain/cli/settings.py +0 -0
  49. {plain-0.66.0 → plain-0.67.0}/plain/cli/shell.py +0 -0
  50. {plain-0.66.0 → plain-0.67.0}/plain/cli/startup.py +0 -0
  51. {plain-0.66.0 → plain-0.67.0}/plain/cli/upgrade.py +0 -0
  52. {plain-0.66.0 → plain-0.67.0}/plain/cli/urls.py +0 -0
  53. {plain-0.66.0 → plain-0.67.0}/plain/cli/utils.py +0 -0
  54. {plain-0.66.0 → plain-0.67.0}/plain/csrf/README.md +0 -0
  55. {plain-0.66.0 → plain-0.67.0}/plain/csrf/middleware.py +0 -0
  56. {plain-0.66.0 → plain-0.67.0}/plain/csrf/views.py +0 -0
  57. {plain-0.66.0 → plain-0.67.0}/plain/debug.py +0 -0
  58. {plain-0.66.0 → plain-0.67.0}/plain/exceptions.py +0 -0
  59. {plain-0.66.0 → plain-0.67.0}/plain/forms/README.md +0 -0
  60. {plain-0.66.0 → plain-0.67.0}/plain/forms/__init__.py +0 -0
  61. {plain-0.66.0 → plain-0.67.0}/plain/forms/boundfield.py +0 -0
  62. {plain-0.66.0 → plain-0.67.0}/plain/forms/exceptions.py +0 -0
  63. {plain-0.66.0 → plain-0.67.0}/plain/forms/fields.py +0 -0
  64. {plain-0.66.0 → plain-0.67.0}/plain/forms/forms.py +0 -0
  65. {plain-0.66.0 → plain-0.67.0}/plain/http/README.md +0 -0
  66. {plain-0.66.0 → plain-0.67.0}/plain/http/__init__.py +0 -0
  67. {plain-0.66.0 → plain-0.67.0}/plain/http/cookie.py +0 -0
  68. {plain-0.66.0 → plain-0.67.0}/plain/http/multipartparser.py +0 -0
  69. {plain-0.66.0 → plain-0.67.0}/plain/http/request.py +0 -0
  70. {plain-0.66.0 → plain-0.67.0}/plain/http/response.py +0 -0
  71. {plain-0.66.0 → plain-0.67.0}/plain/internal/__init__.py +0 -0
  72. {plain-0.66.0 → plain-0.67.0}/plain/internal/files/__init__.py +0 -0
  73. {plain-0.66.0 → plain-0.67.0}/plain/internal/files/base.py +0 -0
  74. {plain-0.66.0 → plain-0.67.0}/plain/internal/files/locks.py +0 -0
  75. {plain-0.66.0 → plain-0.67.0}/plain/internal/files/move.py +0 -0
  76. {plain-0.66.0 → plain-0.67.0}/plain/internal/files/temp.py +0 -0
  77. {plain-0.66.0 → plain-0.67.0}/plain/internal/files/uploadedfile.py +0 -0
  78. {plain-0.66.0 → plain-0.67.0}/plain/internal/files/uploadhandler.py +0 -0
  79. {plain-0.66.0 → plain-0.67.0}/plain/internal/files/utils.py +0 -0
  80. {plain-0.66.0 → plain-0.67.0}/plain/internal/handlers/__init__.py +0 -0
  81. {plain-0.66.0 → plain-0.67.0}/plain/internal/handlers/base.py +0 -0
  82. {plain-0.66.0 → plain-0.67.0}/plain/internal/handlers/exception.py +0 -0
  83. {plain-0.66.0 → plain-0.67.0}/plain/internal/handlers/wsgi.py +0 -0
  84. {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/__init__.py +0 -0
  85. {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/headers.py +0 -0
  86. {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/https.py +0 -0
  87. {plain-0.66.0 → plain-0.67.0}/plain/internal/middleware/slash.py +0 -0
  88. {plain-0.66.0 → plain-0.67.0}/plain/json.py +0 -0
  89. {plain-0.66.0 → plain-0.67.0}/plain/logs/README.md +0 -0
  90. {plain-0.66.0 → plain-0.67.0}/plain/logs/__init__.py +0 -0
  91. {plain-0.66.0 → plain-0.67.0}/plain/logs/configure.py +0 -0
  92. {plain-0.66.0 → plain-0.67.0}/plain/logs/debug.py +0 -0
  93. {plain-0.66.0 → plain-0.67.0}/plain/logs/formatters.py +0 -0
  94. {plain-0.66.0 → plain-0.67.0}/plain/logs/loggers.py +0 -0
  95. {plain-0.66.0 → plain-0.67.0}/plain/logs/utils.py +0 -0
  96. {plain-0.66.0 → plain-0.67.0}/plain/packages/README.md +0 -0
  97. {plain-0.66.0 → plain-0.67.0}/plain/packages/__init__.py +0 -0
  98. {plain-0.66.0 → plain-0.67.0}/plain/packages/config.py +0 -0
  99. {plain-0.66.0 → plain-0.67.0}/plain/packages/registry.py +0 -0
  100. {plain-0.66.0 → plain-0.67.0}/plain/paginator.py +0 -0
  101. {plain-0.66.0 → plain-0.67.0}/plain/preflight/__init__.py +0 -0
  102. {plain-0.66.0 → plain-0.67.0}/plain/preflight/files.py +0 -0
  103. {plain-0.66.0 → plain-0.67.0}/plain/preflight/messages.py +0 -0
  104. {plain-0.66.0 → plain-0.67.0}/plain/preflight/registry.py +0 -0
  105. {plain-0.66.0 → plain-0.67.0}/plain/preflight/urls.py +0 -0
  106. {plain-0.66.0 → plain-0.67.0}/plain/runtime/README.md +0 -0
  107. {plain-0.66.0 → plain-0.67.0}/plain/runtime/__init__.py +0 -0
  108. {plain-0.66.0 → plain-0.67.0}/plain/runtime/user_settings.py +0 -0
  109. {plain-0.66.0 → plain-0.67.0}/plain/runtime/utils.py +0 -0
  110. {plain-0.66.0 → plain-0.67.0}/plain/signals/README.md +0 -0
  111. {plain-0.66.0 → plain-0.67.0}/plain/signals/__init__.py +0 -0
  112. {plain-0.66.0 → plain-0.67.0}/plain/signals/dispatch/__init__.py +0 -0
  113. {plain-0.66.0 → plain-0.67.0}/plain/signals/dispatch/dispatcher.py +0 -0
  114. {plain-0.66.0 → plain-0.67.0}/plain/signals/dispatch/license.txt +0 -0
  115. {plain-0.66.0 → plain-0.67.0}/plain/signing.py +0 -0
  116. {plain-0.66.0 → plain-0.67.0}/plain/templates/AGENTS.md +0 -0
  117. {plain-0.66.0 → plain-0.67.0}/plain/templates/README.md +0 -0
  118. {plain-0.66.0 → plain-0.67.0}/plain/templates/__init__.py +0 -0
  119. {plain-0.66.0 → plain-0.67.0}/plain/templates/core.py +0 -0
  120. {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/__init__.py +0 -0
  121. {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/environments.py +0 -0
  122. {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/extensions.py +0 -0
  123. {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/filters.py +0 -0
  124. {plain-0.66.0 → plain-0.67.0}/plain/templates/jinja/globals.py +0 -0
  125. {plain-0.66.0 → plain-0.67.0}/plain/test/README.md +0 -0
  126. {plain-0.66.0 → plain-0.67.0}/plain/test/__init__.py +0 -0
  127. {plain-0.66.0 → plain-0.67.0}/plain/test/client.py +0 -0
  128. {plain-0.66.0 → plain-0.67.0}/plain/test/encoding.py +0 -0
  129. {plain-0.66.0 → plain-0.67.0}/plain/test/exceptions.py +0 -0
  130. {plain-0.66.0 → plain-0.67.0}/plain/urls/README.md +0 -0
  131. {plain-0.66.0 → plain-0.67.0}/plain/urls/__init__.py +0 -0
  132. {plain-0.66.0 → plain-0.67.0}/plain/urls/converters.py +0 -0
  133. {plain-0.66.0 → plain-0.67.0}/plain/urls/exceptions.py +0 -0
  134. {plain-0.66.0 → plain-0.67.0}/plain/urls/patterns.py +0 -0
  135. {plain-0.66.0 → plain-0.67.0}/plain/urls/resolvers.py +0 -0
  136. {plain-0.66.0 → plain-0.67.0}/plain/urls/routers.py +0 -0
  137. {plain-0.66.0 → plain-0.67.0}/plain/urls/utils.py +0 -0
  138. {plain-0.66.0 → plain-0.67.0}/plain/utils/README.md +0 -0
  139. {plain-0.66.0 → plain-0.67.0}/plain/utils/__init__.py +0 -0
  140. {plain-0.66.0 → plain-0.67.0}/plain/utils/cache.py +0 -0
  141. {plain-0.66.0 → plain-0.67.0}/plain/utils/crypto.py +0 -0
  142. {plain-0.66.0 → plain-0.67.0}/plain/utils/datastructures.py +0 -0
  143. {plain-0.66.0 → plain-0.67.0}/plain/utils/dateparse.py +0 -0
  144. {plain-0.66.0 → plain-0.67.0}/plain/utils/deconstruct.py +0 -0
  145. {plain-0.66.0 → plain-0.67.0}/plain/utils/decorators.py +0 -0
  146. {plain-0.66.0 → plain-0.67.0}/plain/utils/duration.py +0 -0
  147. {plain-0.66.0 → plain-0.67.0}/plain/utils/encoding.py +0 -0
  148. {plain-0.66.0 → plain-0.67.0}/plain/utils/functional.py +0 -0
  149. {plain-0.66.0 → plain-0.67.0}/plain/utils/hashable.py +0 -0
  150. {plain-0.66.0 → plain-0.67.0}/plain/utils/html.py +0 -0
  151. {plain-0.66.0 → plain-0.67.0}/plain/utils/http.py +0 -0
  152. {plain-0.66.0 → plain-0.67.0}/plain/utils/inspect.py +0 -0
  153. {plain-0.66.0 → plain-0.67.0}/plain/utils/ipv6.py +0 -0
  154. {plain-0.66.0 → plain-0.67.0}/plain/utils/itercompat.py +0 -0
  155. {plain-0.66.0 → plain-0.67.0}/plain/utils/module_loading.py +0 -0
  156. {plain-0.66.0 → plain-0.67.0}/plain/utils/regex_helper.py +0 -0
  157. {plain-0.66.0 → plain-0.67.0}/plain/utils/safestring.py +0 -0
  158. {plain-0.66.0 → plain-0.67.0}/plain/utils/text.py +0 -0
  159. {plain-0.66.0 → plain-0.67.0}/plain/utils/timesince.py +0 -0
  160. {plain-0.66.0 → plain-0.67.0}/plain/utils/timezone.py +0 -0
  161. {plain-0.66.0 → plain-0.67.0}/plain/utils/tree.py +0 -0
  162. {plain-0.66.0 → plain-0.67.0}/plain/validators.py +0 -0
  163. {plain-0.66.0 → plain-0.67.0}/plain/views/README.md +0 -0
  164. {plain-0.66.0 → plain-0.67.0}/plain/views/__init__.py +0 -0
  165. {plain-0.66.0 → plain-0.67.0}/plain/views/base.py +0 -0
  166. {plain-0.66.0 → plain-0.67.0}/plain/views/errors.py +0 -0
  167. {plain-0.66.0 → plain-0.67.0}/plain/views/exceptions.py +0 -0
  168. {plain-0.66.0 → plain-0.67.0}/plain/views/forms.py +0 -0
  169. {plain-0.66.0 → plain-0.67.0}/plain/views/objects.py +0 -0
  170. {plain-0.66.0 → plain-0.67.0}/plain/views/redirect.py +0 -0
  171. {plain-0.66.0 → plain-0.67.0}/plain/views/templates.py +0 -0
  172. {plain-0.66.0 → plain-0.67.0}/plain/wsgi.py +0 -0
  173. {plain-0.66.0 → plain-0.67.0}/tests/.gitignore +0 -0
  174. {plain-0.66.0 → plain-0.67.0}/tests/app/.gitignore +0 -0
  175. {plain-0.66.0 → plain-0.67.0}/tests/app/test/__init__.py +0 -0
  176. {plain-0.66.0 → plain-0.67.0}/tests/app/test/default_settings.py +0 -0
  177. {plain-0.66.0 → plain-0.67.0}/tests/app/urls.py +0 -0
  178. {plain-0.66.0 → plain-0.67.0}/tests/conftest.py +0 -0
  179. {plain-0.66.0 → plain-0.67.0}/tests/test_cli.py +0 -0
  180. {plain-0.66.0 → plain-0.67.0}/tests/test_logs.py +0 -0
  181. {plain-0.66.0 → plain-0.67.0}/tests/test_runtime.py +0 -0
  182. {plain-0.66.0 → plain-0.67.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.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,16 @@
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
+
3
14
  ## [0.66.0](https://github.com/dropseed/plain/releases/plain@0.66.0) (2025-09-22)
4
15
 
5
16
  ### 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)
@@ -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:
@@ -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,9 +23,10 @@ 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
28
- ALLOWED_HOSTS: list[str]
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
29
+ ALLOWED_HOSTS: list[str] = []
29
30
 
30
31
  # Default headers for all responses.
31
32
  DEFAULT_RESPONSE_HEADERS = {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain"
3
- version = "0.66.0"
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"
@@ -1,4 +1,3 @@
1
- ALLOWED_HOSTS = []
2
1
  SECRET_KEY = "secret"
3
2
  DEBUG = True
4
3
 
@@ -115,67 +115,50 @@ def test_old_browser_fallback(headers):
115
115
 
116
116
 
117
117
  @pytest.mark.parametrize(
118
- ("origin", "expected_allowed", "expected_reason_contains", "setup_allowed_hosts"),
118
+ ("origin", "expected_allowed", "expected_reason_contains"),
119
119
  [
120
120
  # Origin matches host - should be allowed
121
121
  (
122
122
  "https://testserver",
123
123
  True,
124
124
  "Same-origin request - Origin https://testserver matches Host testserver",
125
- True,
126
125
  ),
127
126
  (
128
127
  "https://testserver:443",
129
128
  True,
130
129
  "Same-origin request - Origin https://testserver:443 matches Host testserver",
131
- True,
132
130
  ),
133
131
  # Various rejection cases
134
- ("null", False, "Cross-origin request detected - null Origin header", False),
135
- ("https://attacker.com", False, "does not match Host", False),
136
- ("https://sub.testserver", False, "does not match Host", False),
137
- ("https://example.com:8080", False, "does not match Host", False),
138
- ("http://example.com", False, "does not match Host", False),
132
+ ("null", False, "Cross-origin request detected - null Origin header"),
133
+ ("https://attacker.com", False, "does not match Host"),
134
+ ("https://sub.testserver", False, "does not match Host"),
135
+ ("https://example.com:8080", False, "does not match Host"),
136
+ ("http://example.com", False, "does not match Host"),
139
137
  ],
140
138
  )
141
- def test_origin_host_comparison(
142
- origin, expected_allowed, expected_reason_contains, setup_allowed_hosts
143
- ):
139
+ def test_origin_host_comparison(origin, expected_allowed, expected_reason_contains):
144
140
  """Test Origin vs Host header comparison scenarios."""
145
- from plain.runtime import settings
146
-
147
- original_allowed_hosts = None
148
- if setup_allowed_hosts:
149
- # Temporarily modify ALLOWED_HOSTS to include testserver
150
- original_allowed_hosts = settings.ALLOWED_HOSTS
151
- settings.ALLOWED_HOSTS = [*settings.ALLOWED_HOSTS, "testserver"]
152
-
153
- try:
154
- rf = RequestFactory()
155
- csrf_middleware = CsrfViewMiddleware(lambda request: None)
141
+ rf = RequestFactory()
142
+ csrf_middleware = CsrfViewMiddleware(lambda request: None)
156
143
 
157
- # Configure request based on the origin
158
- request_kwargs = {"headers": {"Origin": origin}}
159
- if origin == "https://testserver:443":
160
- request_kwargs.update(
161
- {
162
- "SERVER_NAME": "testserver",
163
- "SERVER_PORT": "443",
164
- "secure": True,
165
- }
166
- )
167
- elif origin == "https://testserver":
168
- request_kwargs["secure"] = True
169
-
170
- request = rf.post("/test/", **request_kwargs)
171
- allowed, reason = csrf_middleware.should_allow_request(request)
144
+ # Configure request based on the origin
145
+ request_kwargs = {"headers": {"Origin": origin}}
146
+ if origin == "https://testserver:443":
147
+ request_kwargs.update(
148
+ {
149
+ "SERVER_NAME": "testserver",
150
+ "SERVER_PORT": "443",
151
+ "secure": True,
152
+ }
153
+ )
154
+ elif origin == "https://testserver":
155
+ request_kwargs["secure"] = True
156
+
157
+ request = rf.post("/test/", **request_kwargs)
158
+ allowed, reason = csrf_middleware.should_allow_request(request)
172
159
 
173
- assert allowed is expected_allowed
174
- assert expected_reason_contains in reason
175
- finally:
176
- if original_allowed_hosts is not None:
177
- # Restore original ALLOWED_HOSTS
178
- settings.ALLOWED_HOSTS = original_allowed_hosts
160
+ assert allowed is expected_allowed
161
+ assert expected_reason_contains in reason
179
162
 
180
163
 
181
164
  def test_invalid_origin_url():
@@ -57,12 +57,6 @@ def test_split_domain_port(host, expected_domain, expected_port):
57
57
  @pytest.mark.parametrize(
58
58
  ("host", "allowed_hosts", "expected"),
59
59
  [
60
- # Wildcard matching
61
- ("example.com", ["*"], True),
62
- ("sub.example.com", ["*"], True),
63
- ("127.0.0.1", ["*"], True),
64
- ("[::1]", ["*"], True),
65
- ("anything.at.all", ["*"], True),
66
60
  # Subdomain pattern matching
67
61
  ("example.com", [".example.com"], True),
68
62
  ("sub.example.com", [".example.com"], True),
@@ -84,7 +78,7 @@ def test_split_domain_port(host, expected_domain, expected_port):
84
78
  ("127.0.0.1", ["example.com", ".api.example.com", "127.0.0.1"], True),
85
79
  ("sub.example.com", ["example.com", ".api.example.com", "127.0.0.1"], False),
86
80
  ("other.com", ["example.com", ".api.example.com", "127.0.0.1"], False),
87
- # Literal asterisk pattern (not treated as wildcard)
81
+ # Literal asterisk pattern (treated as literal string, not wildcard)
88
82
  ("*.test.com", ["*.test.com"], True),
89
83
  ("anything.test.com", ["*.test.com"], False),
90
84
  ("api.test.com", ["*.test.com"], False),
@@ -121,22 +115,6 @@ def test_split_domain_port(host, expected_domain, expected_port):
121
115
  ),
122
116
  ("example.com", [".api.example.com", ".staging.example.com"], False),
123
117
  ("www.example.com", [".api.example.com", ".staging.example.com"], False),
124
- # Wildcard overrides other patterns
125
- (
126
- "anything.com",
127
- ["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
128
- True,
129
- ),
130
- (
131
- "random.domain.org",
132
- ["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
133
- True,
134
- ),
135
- (
136
- "192.168.1.100",
137
- ["*", "exact.com", ".subdomain.com", "127.0.0.1", "[::1]"],
138
- True,
139
- ),
140
118
  # Edge cases
141
119
  ("", ["example.com"], False),
142
120
  ("example .com", ["example.com"], False),
@@ -196,9 +174,6 @@ def test_validate_host(host, allowed_hosts, expected):
196
174
  ("192.168.1.0/24", ["192.168.1.0/24"], False), # Literal match of CIDR string
197
175
  ("192.168.1.100", ["192.168.1.0/999"], False), # Invalid CIDR
198
176
  ("192.168.1.100", ["192.168.1.0/"], False), # Invalid CIDR
199
- # CIDR with wildcard - wildcard should take precedence
200
- ("anything.com", ["*", "192.168.1.0/24"], True),
201
- ("172.16.0.1", ["*", "192.168.1.0/24"], True),
202
177
  # Edge cases
203
178
  ("0.0.0.0", ["0.0.0.0/0"], True), # Match all IPv4
204
179
  ("255.255.255.255", ["0.0.0.0/0"], True),
@@ -1,181 +0,0 @@
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
- # Temporarily add testserver to ALLOWED_HOSTS so the test client can make requests
50
- original_allowed_hosts = settings.ALLOWED_HOSTS
51
- settings.ALLOWED_HOSTS = ["*"]
52
-
53
- try:
54
- # Create test client
55
- client = Client()
56
-
57
- # If user_id provided, force login
58
- if user_id:
59
- try:
60
- # Get the User model using plain.auth utility
61
- from plain.auth import get_user_model
62
-
63
- User = get_user_model()
64
-
65
- # Get the user
66
- try:
67
- user = User.query.get(id=user_id)
68
- client.force_login(user)
69
- click.secho(
70
- f"Authenticated as user {user_id}", fg="green", dim=True
71
- )
72
- except User.DoesNotExist:
73
- click.secho(f"User {user_id} not found", fg="red", err=True)
74
- return
75
-
76
- except Exception as e:
77
- click.secho(f"Authentication error: {e}", fg="red", err=True)
78
- return
79
-
80
- # Parse additional headers
81
- header_dict = {}
82
- for header in headers:
83
- if ":" in header:
84
- key, value = header.split(":", 1)
85
- header_dict[key.strip()] = value.strip()
86
-
87
- # Prepare request data
88
- if data and content_type and "json" in content_type.lower():
89
- try:
90
- # Validate JSON
91
- json.loads(data)
92
- except json.JSONDecodeError as e:
93
- click.secho(f"Invalid JSON data: {e}", fg="red", err=True)
94
- return
95
-
96
- # Make the request
97
- method = method.upper()
98
- kwargs = {
99
- "path": path,
100
- "follow": follow,
101
- "headers": header_dict or None,
102
- }
103
-
104
- if method in ("POST", "PUT", "PATCH") and data:
105
- kwargs["data"] = data
106
- if content_type:
107
- kwargs["content_type"] = content_type
108
-
109
- # Call the appropriate client method
110
- if method == "GET":
111
- response = client.get(**kwargs)
112
- elif method == "POST":
113
- response = client.post(**kwargs)
114
- elif method == "PUT":
115
- response = client.put(**kwargs)
116
- elif method == "PATCH":
117
- response = client.patch(**kwargs)
118
- elif method == "DELETE":
119
- response = client.delete(**kwargs)
120
- elif method == "HEAD":
121
- response = client.head(**kwargs)
122
- elif method == "OPTIONS":
123
- response = client.options(**kwargs)
124
- elif method == "TRACE":
125
- response = client.trace(**kwargs)
126
- else:
127
- click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
128
- return
129
-
130
- # Display response information
131
- click.secho(
132
- f"HTTP {response.status_code}",
133
- fg="green" if response.status_code < 400 else "red",
134
- bold=True,
135
- )
136
-
137
- # Show additional response info first
138
- if hasattr(response, "user"):
139
- click.secho(f"Authenticated user: {response.user}", fg="blue", dim=True)
140
-
141
- if hasattr(response, "resolver_match") and response.resolver_match:
142
- match = response.resolver_match
143
- url_name = match.namespaced_url_name or match.url_name or "unnamed"
144
- click.secho(f"URL pattern matched: {url_name}", fg="blue", dim=True)
145
-
146
- # Show headers
147
- if response.headers:
148
- click.secho("Response Headers:", fg="yellow", bold=True)
149
- for key, value in response.headers.items():
150
- click.echo(f" {key}: {value}")
151
- click.echo()
152
-
153
- # Show response content last
154
- if response.content:
155
- content_type = response.headers.get("Content-Type", "")
156
-
157
- if "json" in content_type.lower():
158
- try:
159
- json_data = response.json()
160
- click.secho("Response Body (JSON):", fg="yellow", bold=True)
161
- click.echo(json.dumps(json_data, indent=2))
162
- except Exception:
163
- click.secho("Response Body:", fg="yellow", bold=True)
164
- click.echo(response.content.decode("utf-8", errors="replace"))
165
- elif "html" in content_type.lower():
166
- click.secho("Response Body (HTML):", fg="yellow", bold=True)
167
- content = response.content.decode("utf-8", errors="replace")
168
- click.echo(content)
169
- else:
170
- click.secho("Response Body:", fg="yellow", bold=True)
171
- content = response.content.decode("utf-8", errors="replace")
172
- click.echo(content)
173
- else:
174
- click.secho("(No response body)", fg="yellow", dim=True)
175
-
176
- finally:
177
- # Restore original ALLOWED_HOSTS
178
- settings.ALLOWED_HOSTS = original_allowed_hosts
179
-
180
- except Exception as e:
181
- click.secho(f"Request failed: {e}", fg="red", err=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes