plain 0.66.0__py3-none-any.whl → 0.67.0__py3-none-any.whl

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.
plain/CHANGELOG.md CHANGED
@@ -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
@@ -46,136 +46,127 @@ def request(path, method, data, user_id, follow, content_type, headers):
46
46
  click.secho("This command only works when DEBUG=True", fg="red", err=True)
47
47
  return
48
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 = ["*"]
49
+ # Create test client
50
+ client = Client()
52
51
 
53
- try:
54
- # Create test client
55
- client = Client()
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
56
57
 
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()
58
+ User = get_user_model()
86
59
 
87
- # Prepare request data
88
- if data and content_type and "json" in content_type.lower():
60
+ # Get the user
89
61
  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)
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)
94
69
  return
95
70
 
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)
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)
128
89
  return
129
90
 
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:
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:
170
158
  click.secho("Response Body:", fg="yellow", bold=True)
171
- content = response.content.decode("utf-8", errors="replace")
172
- click.echo(content)
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)
173
164
  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
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)
179
170
 
180
171
  except Exception as e:
181
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:
plain/preflight/README.md CHANGED
@@ -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
  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,5 @@
1
1
  plain/AGENTS.md,sha256=5XMGBpJgbCNIpp60DPXB7bpAtFk8FAzqiZke95T965o,1038
2
- plain/CHANGELOG.md,sha256=9wAiAPwm8o5sNxPeriOAjP74g9sNxJRF5EpTTwjogpk,16069
2
+ plain/CHANGELOG.md,sha256=8Ljw7VTQP1cn6sUy_1Pum2cmHsnmeRqbL8oBIWvxw-s,16604
3
3
  plain/README.md,sha256=5BJyKhf0TDanWVbOQyZ3zsi5Lov9xk-LlJYCDWofM6Y,4078
4
4
  plain/__main__.py,sha256=GK39854Lc_LO_JP8DzY9Y2MIQ4cQEl7SXFJy244-lC8,110
5
5
  plain/debug.py,sha256=XdjnXcbPGsi0J2SpHGaLthhYU5AjhBlkHdemaP4sbYY,758
@@ -44,7 +44,7 @@ plain/cli/agent/docs.py,sha256=ubX3ZeRHxVaetLk9fjiN9mJ07GZExC-CHUvQoX2DD7c,2464
44
44
  plain/cli/agent/llmdocs.py,sha256=AUpNDb1xSOsSpzGOiFvpzUe4f7PUGMiR9cI13aVZouo,5038
45
45
  plain/cli/agent/md.py,sha256=7r1II8ckubBFOZNGPASWaPmJdgByWFPINLqIOzRetLQ,2581
46
46
  plain/cli/agent/prompt.py,sha256=rugYyQHV7JDNqGrx3_PPShwwqYlnEVbxw8RsczOo8tg,1253
47
- plain/cli/agent/request.py,sha256=U4acLmpXlbFRjivrPtVv_r8DBts8OXg3m3-qotQxGL4,6547
47
+ plain/cli/agent/request.py,sha256=uUbZoAhSSjOLtzgAAzacvqCS_27H-AvoeqIxpzjUjAA,5800
48
48
  plain/csrf/README.md,sha256=ApWpB-qlEf0LkOKm9Yr-6f_lB9XJEvGFDo_fraw8ghI,2391
49
49
  plain/csrf/middleware.py,sha256=KF8ngFadWdS0MHXC1dTLx-K3VtD6Xs-3RDjFqpiiEjQ,5053
50
50
  plain/csrf/views.py,sha256=HwQqfI6KPelHP9gSXhjfZaTLQic71PKsoZ6DPhr1rKI,572
@@ -75,7 +75,7 @@ plain/internal/handlers/exception.py,sha256=TbPYtgZ7ITJahUKhQWkptHK28Lb4zh_nOviN
75
75
  plain/internal/handlers/wsgi.py,sha256=dgPT29t_F9llB-c5RYU3SHxGuZNaZ83xRjOfuOmtOl8,8209
76
76
  plain/internal/middleware/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
77
77
  plain/internal/middleware/headers.py,sha256=ENIW1Gwat54hv-ejgp2R8QTZm-PlaI7k44WU01YQrNk,964
78
- plain/internal/middleware/hosts.py,sha256=eDrW8ksCVyxZqehA6enNDG_nYOc2c2-CMvwMlfS4n9g,5918
78
+ plain/internal/middleware/hosts.py,sha256=vKdjElDk1-Ju6FUXtm5t0AfPPN-DPhjS31PkRrhM6vc,5799
79
79
  plain/internal/middleware/https.py,sha256=bjysNX6_2l4Rp4DpzF2XYjH-m6gfHNq2x-MN2hHGW78,804
80
80
  plain/internal/middleware/slash.py,sha256=qBTJ-yvOM0d8gUISUC7Fx22LG6aj76uhOiNHjvTkAAs,2840
81
81
  plain/logs/README.md,sha256=rzOHfngjizLgXL21g0svC1Cdya2s_gBA_E-IljtHpy8,4069
@@ -89,16 +89,16 @@ plain/packages/README.md,sha256=iNqMtwFDVNf2TqKUzLKQW5Y4_GsssmdB4cVerzu27Ro,2674
89
89
  plain/packages/__init__.py,sha256=OpQny0xLplPdPpozVUUkrW2gB-IIYyDT1b4zMzOcCC4,160
90
90
  plain/packages/config.py,sha256=2U7b1cp_kqIuLdSeHGCLrSUV77TdfGRsww3PcXOazaA,2910
91
91
  plain/packages/registry.py,sha256=6ogeHZ8t3kBSoLoI7998r0kIbkEPhLGn-7yi-1qVjVo,7969
92
- plain/preflight/README.md,sha256=Ae7ujHmsJhTpRGX0pghBwGD2CG7SIxngenyb5CSbc2M,1721
92
+ plain/preflight/README.md,sha256=6fA6zXmNUtO5xMrVV-0JLyzSJsIIEEGZyFSAW53nYYE,1764
93
93
  plain/preflight/__init__.py,sha256=j4-yPnrM5hmjumrdkBLOQjFHzRHpA6wCjiFpMNBjIqY,619
94
94
  plain/preflight/files.py,sha256=D_pBSwRXpXy2-3FWywweozuxrhIaR8w5hpPA2d6XMPs,522
95
95
  plain/preflight/messages.py,sha256=B6VyXzu7HTJHaPVK4G1L_1HVHG87CT7JPtcDk8QYSeE,2322
96
96
  plain/preflight/registry.py,sha256=vcqzaE1MIneNL_ydKPy_1zrSThnzsrWARSClLCJ-4b8,2331
97
- plain/preflight/security.py,sha256=oxUZBp2M0bpBfUoLYepIxoex2Y90nyjlrL8XU8UTHYY,2438
97
+ plain/preflight/security.py,sha256=71cW35LMOlciAbtWXf50EhKhQQCZ-2LlQgXJ6Zxhxv4,2443
98
98
  plain/preflight/urls.py,sha256=cQ-WnFa_5oztpKdtwhuIGb7pXEml__bHsjs1SWO2YNI,1468
99
99
  plain/runtime/README.md,sha256=sTqXXJkckwqkk9O06XMMSNRokAYjrZBnB50JD36BsYI,4873
100
100
  plain/runtime/__init__.py,sha256=byFYnHrpUCwkpkHtdNhxr9iUdLDCWJjy92HPj30Ilck,2478
101
- plain/runtime/global_settings.py,sha256=tTvx7Z9gtkdy297n20aVhuHz7XKmo7OFU1spuJ22dhQ,5821
101
+ plain/runtime/global_settings.py,sha256=EZ9mdDkwJkXGWDs0bnYnNmpfX49GP30RyU5HgxlrRPc,5872
102
102
  plain/runtime/user_settings.py,sha256=OzMiEkE6ZQ50nxd1WIqirXPiNuMAQULklYHEzgzLWgA,11027
103
103
  plain/runtime/utils.py,sha256=p5IuNTzc7Kq-9Ym7etYnt_xqHw5TioxfSkFeq1bKdgk,832
104
104
  plain/signals/README.md,sha256=XefXqROlDhzw7Z5l_nx6Mhq6n9jjQ-ECGbH0vvhKWYg,272
@@ -161,8 +161,8 @@ plain/views/forms.py,sha256=ESZOXuo6IeYixp1RZvPb94KplkowRiwO2eGJCM6zJI0,2400
161
161
  plain/views/objects.py,sha256=v3Vgvdoc1s0QW6JNWWrO5XXy9zF7vgwndgxX1eOSQoE,4999
162
162
  plain/views/redirect.py,sha256=Xpb3cB7nZYvKgkNqcAxf9Jwm2SWcQ0u2xz4oO5M3vP8,1909
163
163
  plain/views/templates.py,sha256=oAlebEyfES0rzBhfyEJzFmgLkpkbleA6Eip-8zDp-yk,1863
164
- plain-0.66.0.dist-info/METADATA,sha256=Gdvvx2LeKOpOjVbrSmq1S5DLWDT9qGGOmy4v-kOb_y4,4488
165
- plain-0.66.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
166
- plain-0.66.0.dist-info/entry_points.txt,sha256=iGx7EijzXy87htbSv90RhtAcjhSTH_kvE8aeRCn1TRA,129
167
- plain-0.66.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
168
- plain-0.66.0.dist-info/RECORD,,
164
+ plain-0.67.0.dist-info/METADATA,sha256=w_ZT2zv65VtVbl4yh0tKrVw8q9PeDj0cKWeeLm3pPNU,4488
165
+ plain-0.67.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
166
+ plain-0.67.0.dist-info/entry_points.txt,sha256=iGx7EijzXy87htbSv90RhtAcjhSTH_kvE8aeRCn1TRA,129
167
+ plain-0.67.0.dist-info/licenses/LICENSE,sha256=m0D5O7QoH9l5Vz_rrX_9r-C8d9UNr_ciK6Qwac7o6yo,3175
168
+ plain-0.67.0.dist-info/RECORD,,
File without changes