plain 0.66.0__py3-none-any.whl → 0.101.2__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 +684 -0
- plain/README.md +1 -1
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -53
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +236 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +112 -28
- plain/cli/docs.py +52 -11
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +175 -102
- plain/cli/print.py +4 -4
- plain/cli/registry.py +95 -26
- plain/cli/request.py +206 -0
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -13
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +40 -15
- plain/paginator.py +31 -21
- plain/preflight/README.md +208 -23
- plain/preflight/__init__.py +5 -24
- plain/preflight/checks.py +12 -0
- plain/preflight/files.py +19 -13
- plain/preflight/registry.py +80 -58
- plain/preflight/results.py +37 -0
- plain/preflight/security.py +65 -71
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +10 -48
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +43 -33
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/skills/README.md +36 -0
- plain/skills/plain-docs/SKILL.md +25 -0
- plain/skills/plain-install/SKILL.md +26 -0
- plain/skills/plain-request/SKILL.md +39 -0
- plain/skills/plain-shell/SKILL.md +24 -0
- plain/skills/plain-upgrade/SKILL.md +35 -0
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +14 -27
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +56 -40
- plain/urls/resolvers.py +38 -28
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
- plain-0.101.2.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/cli/agent/request.py +0 -181
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/preflight/messages.py +0 -81
- plain/templates/AGENTS.md +0 -3
- plain-0.66.0.dist-info/RECORD +0 -168
- plain-0.66.0.dist-info/entry_points.txt +0 -4
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/cli/request.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from plain.runtime import settings
|
|
9
|
+
from plain.test import Client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.argument("path")
|
|
14
|
+
@click.option(
|
|
15
|
+
"--method",
|
|
16
|
+
default="GET",
|
|
17
|
+
help="HTTP method (GET, POST, PUT, PATCH, DELETE, etc.)",
|
|
18
|
+
)
|
|
19
|
+
@click.option(
|
|
20
|
+
"--data",
|
|
21
|
+
help="Request data (JSON string for POST/PUT/PATCH)",
|
|
22
|
+
)
|
|
23
|
+
@click.option(
|
|
24
|
+
"--user",
|
|
25
|
+
"user_id",
|
|
26
|
+
help="User ID to authenticate as (skips normal authentication)",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--follow/--no-follow",
|
|
30
|
+
default=True,
|
|
31
|
+
help="Follow redirects (default: True)",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--content-type",
|
|
35
|
+
help="Content-Type header for request data",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--header",
|
|
39
|
+
"headers",
|
|
40
|
+
multiple=True,
|
|
41
|
+
help="Additional headers (format: 'Name: Value')",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--no-headers",
|
|
45
|
+
is_flag=True,
|
|
46
|
+
help="Hide response headers from output",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--no-body",
|
|
50
|
+
is_flag=True,
|
|
51
|
+
help="Hide response body from output",
|
|
52
|
+
)
|
|
53
|
+
def request(
|
|
54
|
+
path: str,
|
|
55
|
+
method: str,
|
|
56
|
+
data: str | None,
|
|
57
|
+
user_id: str | None,
|
|
58
|
+
follow: bool,
|
|
59
|
+
content_type: str | None,
|
|
60
|
+
headers: tuple[str, ...],
|
|
61
|
+
no_headers: bool,
|
|
62
|
+
no_body: bool,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Make HTTP requests against the dev database"""
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
# Only allow in DEBUG mode for security
|
|
68
|
+
if not settings.DEBUG:
|
|
69
|
+
click.secho("This command only works when DEBUG=True", fg="red", err=True)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# Create test client
|
|
73
|
+
client = Client()
|
|
74
|
+
|
|
75
|
+
# If user_id provided, force login
|
|
76
|
+
if user_id:
|
|
77
|
+
try:
|
|
78
|
+
# Get the User model using plain.auth utility
|
|
79
|
+
from plain.auth import get_user_model
|
|
80
|
+
|
|
81
|
+
User = get_user_model()
|
|
82
|
+
|
|
83
|
+
# Get the user
|
|
84
|
+
try:
|
|
85
|
+
user = User.query.get(id=user_id)
|
|
86
|
+
client.force_login(user)
|
|
87
|
+
except User.DoesNotExist:
|
|
88
|
+
click.secho(f"User {user_id} not found", fg="red", err=True)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
except Exception as e:
|
|
92
|
+
click.secho(f"Authentication error: {e}", fg="red", err=True)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Parse additional headers
|
|
96
|
+
header_dict = {}
|
|
97
|
+
for header in headers:
|
|
98
|
+
if ":" in header:
|
|
99
|
+
key, value = header.split(":", 1)
|
|
100
|
+
header_dict[key.strip()] = value.strip()
|
|
101
|
+
|
|
102
|
+
# Prepare request data
|
|
103
|
+
if data and content_type and "json" in content_type.lower():
|
|
104
|
+
try:
|
|
105
|
+
# Validate JSON
|
|
106
|
+
json.loads(data)
|
|
107
|
+
except json.JSONDecodeError as e:
|
|
108
|
+
click.secho(f"Invalid JSON data: {e}", fg="red", err=True)
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
# Make the request
|
|
112
|
+
method = method.upper()
|
|
113
|
+
kwargs: dict[str, Any] = {
|
|
114
|
+
"follow": follow,
|
|
115
|
+
}
|
|
116
|
+
if header_dict:
|
|
117
|
+
kwargs["headers"] = header_dict
|
|
118
|
+
|
|
119
|
+
if method in ("POST", "PUT", "PATCH") and data:
|
|
120
|
+
kwargs["data"] = data
|
|
121
|
+
if content_type:
|
|
122
|
+
kwargs["content_type"] = content_type
|
|
123
|
+
|
|
124
|
+
# Call the appropriate client method
|
|
125
|
+
if method == "GET":
|
|
126
|
+
response = client.get(path, **kwargs)
|
|
127
|
+
elif method == "POST":
|
|
128
|
+
response = client.post(path, **kwargs)
|
|
129
|
+
elif method == "PUT":
|
|
130
|
+
response = client.put(path, **kwargs)
|
|
131
|
+
elif method == "PATCH":
|
|
132
|
+
response = client.patch(path, **kwargs)
|
|
133
|
+
elif method == "DELETE":
|
|
134
|
+
response = client.delete(path, **kwargs)
|
|
135
|
+
elif method == "HEAD":
|
|
136
|
+
response = client.head(path, **kwargs)
|
|
137
|
+
elif method == "OPTIONS":
|
|
138
|
+
response = client.options(path, **kwargs)
|
|
139
|
+
elif method == "TRACE":
|
|
140
|
+
response = client.trace(path, **kwargs)
|
|
141
|
+
else:
|
|
142
|
+
click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Display response information
|
|
146
|
+
click.secho("Response:", fg="yellow", bold=True)
|
|
147
|
+
|
|
148
|
+
# Status code
|
|
149
|
+
click.echo(f" Status: {response.status_code}")
|
|
150
|
+
|
|
151
|
+
# Request ID
|
|
152
|
+
click.echo(f" Request ID: {response.wsgi_request.unique_id}")
|
|
153
|
+
|
|
154
|
+
# User
|
|
155
|
+
if response.user:
|
|
156
|
+
click.echo(f" Authenticated user: {response.user}")
|
|
157
|
+
|
|
158
|
+
# URL pattern
|
|
159
|
+
if response.resolver_match:
|
|
160
|
+
match = response.resolver_match
|
|
161
|
+
namespaced_url_name = getattr(match, "namespaced_url_name", None)
|
|
162
|
+
url_name_attr = getattr(match, "url_name", None)
|
|
163
|
+
url_name = namespaced_url_name or url_name_attr
|
|
164
|
+
if url_name:
|
|
165
|
+
click.echo(f" URL pattern: {url_name}")
|
|
166
|
+
|
|
167
|
+
click.echo()
|
|
168
|
+
|
|
169
|
+
# Show headers
|
|
170
|
+
if response.headers and not no_headers:
|
|
171
|
+
click.secho("Response Headers:", fg="yellow", bold=True)
|
|
172
|
+
for key, value in response.headers.items():
|
|
173
|
+
click.echo(f" {key}: {value}")
|
|
174
|
+
click.echo()
|
|
175
|
+
|
|
176
|
+
# Show response content last
|
|
177
|
+
if response.content and not no_body:
|
|
178
|
+
content_type = response.headers.get("Content-Type", "")
|
|
179
|
+
|
|
180
|
+
if "json" in content_type.lower():
|
|
181
|
+
try:
|
|
182
|
+
# The test client adds a json() method to the response
|
|
183
|
+
json_method = getattr(response, "json", None)
|
|
184
|
+
if json_method and callable(json_method):
|
|
185
|
+
json_data: Any = json_method()
|
|
186
|
+
click.secho("Response Body (JSON):", fg="yellow", bold=True)
|
|
187
|
+
click.echo(json.dumps(json_data, indent=2))
|
|
188
|
+
else:
|
|
189
|
+
click.secho("Response Body:", fg="yellow", bold=True)
|
|
190
|
+
click.echo(response.content.decode("utf-8", errors="replace"))
|
|
191
|
+
except Exception:
|
|
192
|
+
click.secho("Response Body:", fg="yellow", bold=True)
|
|
193
|
+
click.echo(response.content.decode("utf-8", errors="replace"))
|
|
194
|
+
elif "html" in content_type.lower():
|
|
195
|
+
click.secho("Response Body (HTML):", fg="yellow", bold=True)
|
|
196
|
+
content = response.content.decode("utf-8", errors="replace")
|
|
197
|
+
click.echo(content)
|
|
198
|
+
else:
|
|
199
|
+
click.secho("Response Body:", fg="yellow", bold=True)
|
|
200
|
+
content = response.content.decode("utf-8", errors="replace")
|
|
201
|
+
click.echo(content)
|
|
202
|
+
elif not no_body:
|
|
203
|
+
click.secho("(No response body)", fg="yellow", dim=True)
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
click.secho(f"Request failed: {e}", fg="red", err=True)
|
plain/cli/runtime.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI runtime utilities.
|
|
3
|
+
|
|
4
|
+
This module provides decorators and utilities for CLI commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
F = TypeVar("F", bound=Callable)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def without_runtime_setup(f: F) -> F:
|
|
14
|
+
"""
|
|
15
|
+
Decorator to mark commands that don't need plain.runtime.setup().
|
|
16
|
+
|
|
17
|
+
Use this for commands that don't access settings or app code,
|
|
18
|
+
particularly for commands that fork (like server) where setup()
|
|
19
|
+
should happen in the worker process, not the parent.
|
|
20
|
+
|
|
21
|
+
Example:
|
|
22
|
+
@without_runtime_setup
|
|
23
|
+
@click.command()
|
|
24
|
+
def server(**options):
|
|
25
|
+
...
|
|
26
|
+
"""
|
|
27
|
+
f.without_runtime_setup = True # dynamic attribute for decorator
|
|
28
|
+
return f
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def common_command(f: F) -> F:
|
|
32
|
+
"""
|
|
33
|
+
Decorator to mark commands as commonly used.
|
|
34
|
+
|
|
35
|
+
Common commands are shown in a separate "Common Commands" section
|
|
36
|
+
in the help output, making them easier to discover.
|
|
37
|
+
|
|
38
|
+
Example:
|
|
39
|
+
@common_command
|
|
40
|
+
@click.command()
|
|
41
|
+
def dev(**options):
|
|
42
|
+
...
|
|
43
|
+
"""
|
|
44
|
+
f.is_common_command = True # dynamic attribute for decorator
|
|
45
|
+
return f
|
plain/cli/scaffold.py
CHANGED
|
@@ -7,13 +7,8 @@ import plain.runtime
|
|
|
7
7
|
|
|
8
8
|
@click.command()
|
|
9
9
|
@click.argument("package_name")
|
|
10
|
-
def create(package_name):
|
|
11
|
-
"""
|
|
12
|
-
Create a new local package.
|
|
13
|
-
|
|
14
|
-
The PACKAGE_NAME is typically a plural noun, like "users" or "posts",
|
|
15
|
-
where you might create a "User" or "Post" model inside of the package.
|
|
16
|
-
"""
|
|
10
|
+
def create(package_name: str) -> None:
|
|
11
|
+
"""Create a new local package"""
|
|
17
12
|
package_dir = plain.runtime.APP_PATH / package_name
|
|
18
13
|
package_dir.mkdir(exist_ok=True)
|
|
19
14
|
|
plain/cli/server.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from plain.cli.runtime import without_runtime_setup
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_workers(ctx: click.Context, param: click.Parameter, value: str) -> int:
|
|
9
|
+
"""Parse workers value - accepts int or 'auto' for CPU count."""
|
|
10
|
+
if value == "auto":
|
|
11
|
+
return os.cpu_count() or 1
|
|
12
|
+
return int(value)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@without_runtime_setup
|
|
16
|
+
@click.command()
|
|
17
|
+
@click.option(
|
|
18
|
+
"--bind",
|
|
19
|
+
"-b",
|
|
20
|
+
multiple=True,
|
|
21
|
+
default=["127.0.0.1:8000"],
|
|
22
|
+
help="Address to bind to (HOST:PORT, can be used multiple times)",
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--threads",
|
|
26
|
+
type=int,
|
|
27
|
+
default=1,
|
|
28
|
+
help="Number of threads per worker",
|
|
29
|
+
show_default=True,
|
|
30
|
+
)
|
|
31
|
+
@click.option(
|
|
32
|
+
"--workers",
|
|
33
|
+
"-w",
|
|
34
|
+
type=str,
|
|
35
|
+
default="1",
|
|
36
|
+
envvar="WEB_CONCURRENCY",
|
|
37
|
+
callback=parse_workers,
|
|
38
|
+
help="Number of worker processes (or 'auto' for CPU count)",
|
|
39
|
+
show_default=True,
|
|
40
|
+
)
|
|
41
|
+
@click.option(
|
|
42
|
+
"--timeout",
|
|
43
|
+
"-t",
|
|
44
|
+
type=int,
|
|
45
|
+
default=30,
|
|
46
|
+
help="Worker timeout in seconds",
|
|
47
|
+
show_default=True,
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--certfile",
|
|
51
|
+
type=click.Path(exists=True),
|
|
52
|
+
help="SSL certificate file",
|
|
53
|
+
)
|
|
54
|
+
@click.option(
|
|
55
|
+
"--keyfile",
|
|
56
|
+
type=click.Path(exists=True),
|
|
57
|
+
help="SSL key file",
|
|
58
|
+
)
|
|
59
|
+
@click.option(
|
|
60
|
+
"--log-level",
|
|
61
|
+
default="info",
|
|
62
|
+
type=click.Choice(["debug", "info", "warning", "error", "critical"]),
|
|
63
|
+
help="Logging level",
|
|
64
|
+
show_default=True,
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--reload",
|
|
68
|
+
is_flag=True,
|
|
69
|
+
help="Restart workers when code changes (dev only)",
|
|
70
|
+
)
|
|
71
|
+
@click.option(
|
|
72
|
+
"--access-log",
|
|
73
|
+
default="-",
|
|
74
|
+
help="Access log file (use '-' for stdout)",
|
|
75
|
+
show_default=True,
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--error-log",
|
|
79
|
+
default="-",
|
|
80
|
+
help="Error log file (use '-' for stderr)",
|
|
81
|
+
show_default=True,
|
|
82
|
+
)
|
|
83
|
+
@click.option(
|
|
84
|
+
"--log-format",
|
|
85
|
+
default="%(asctime)s [%(process)d] [%(levelname)s] %(message)s",
|
|
86
|
+
help="Log format string (applies to both error and access logs)",
|
|
87
|
+
show_default=True,
|
|
88
|
+
)
|
|
89
|
+
@click.option(
|
|
90
|
+
"--access-log-format",
|
|
91
|
+
help="Access log format string (HTTP request details)",
|
|
92
|
+
default='%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"',
|
|
93
|
+
show_default=True,
|
|
94
|
+
)
|
|
95
|
+
@click.option(
|
|
96
|
+
"--max-requests",
|
|
97
|
+
type=int,
|
|
98
|
+
default=0,
|
|
99
|
+
help="Max requests before worker restart (0=disabled)",
|
|
100
|
+
show_default=True,
|
|
101
|
+
)
|
|
102
|
+
@click.option(
|
|
103
|
+
"--pidfile",
|
|
104
|
+
type=click.Path(),
|
|
105
|
+
help="PID file path",
|
|
106
|
+
)
|
|
107
|
+
def server(
|
|
108
|
+
bind: tuple[str, ...],
|
|
109
|
+
threads: int,
|
|
110
|
+
workers: int,
|
|
111
|
+
timeout: int,
|
|
112
|
+
certfile: str | None,
|
|
113
|
+
keyfile: str | None,
|
|
114
|
+
log_level: str,
|
|
115
|
+
reload: bool,
|
|
116
|
+
access_log: str,
|
|
117
|
+
error_log: str,
|
|
118
|
+
log_format: str,
|
|
119
|
+
access_log_format: str,
|
|
120
|
+
max_requests: int,
|
|
121
|
+
pidfile: str | None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Production-ready WSGI server"""
|
|
124
|
+
from plain.runtime import settings
|
|
125
|
+
|
|
126
|
+
# Show settings loaded from environment
|
|
127
|
+
if env_settings := settings.get_env_settings():
|
|
128
|
+
click.secho("Settings from env:", dim=True)
|
|
129
|
+
for name, defn in env_settings:
|
|
130
|
+
click.secho(
|
|
131
|
+
f" {defn.env_var_name} -> {name}={defn.display_value()}", dim=True
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
from plain.server import ServerApplication
|
|
135
|
+
from plain.server.config import Config
|
|
136
|
+
|
|
137
|
+
cfg = Config(
|
|
138
|
+
bind=list(bind),
|
|
139
|
+
threads=threads,
|
|
140
|
+
workers=workers,
|
|
141
|
+
timeout=timeout,
|
|
142
|
+
max_requests=max_requests,
|
|
143
|
+
reload=reload,
|
|
144
|
+
pidfile=pidfile,
|
|
145
|
+
certfile=certfile,
|
|
146
|
+
keyfile=keyfile,
|
|
147
|
+
loglevel=log_level,
|
|
148
|
+
accesslog=access_log,
|
|
149
|
+
errorlog=error_log,
|
|
150
|
+
log_format=log_format,
|
|
151
|
+
access_log_format=access_log_format,
|
|
152
|
+
)
|
|
153
|
+
ServerApplication(cfg=cfg).run()
|
plain/cli/settings.py
CHANGED
|
@@ -3,58 +3,62 @@ import click
|
|
|
3
3
|
import plain.runtime
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
@click.
|
|
6
|
+
@click.group()
|
|
7
|
+
def settings() -> None:
|
|
8
|
+
"""View and inspect settings"""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@settings.command()
|
|
7
13
|
@click.argument("setting_name")
|
|
8
|
-
def
|
|
9
|
-
"""
|
|
14
|
+
def get(setting_name: str) -> None:
|
|
15
|
+
"""Get the value of a specific setting"""
|
|
10
16
|
try:
|
|
11
|
-
|
|
12
|
-
click.echo(
|
|
17
|
+
value = getattr(plain.runtime.settings, setting_name)
|
|
18
|
+
click.echo(value)
|
|
13
19
|
except AttributeError:
|
|
14
20
|
click.secho(f'Setting "{setting_name}" not found', fg="red")
|
|
15
21
|
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
# console = Console()
|
|
60
|
-
# console.print(table)
|
|
23
|
+
@settings.command(name="list")
|
|
24
|
+
def list_settings() -> None:
|
|
25
|
+
"""List all settings with their sources"""
|
|
26
|
+
if not (items := plain.runtime.settings.get_settings()):
|
|
27
|
+
click.echo("No settings configured.")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
# Calculate column widths
|
|
31
|
+
max_name = max(len(name) for name, _ in items)
|
|
32
|
+
max_source = max(len(defn.env_var_name or defn.source) for _, defn in items)
|
|
33
|
+
|
|
34
|
+
# Print header
|
|
35
|
+
header = (
|
|
36
|
+
click.style(f"{'Setting':<{max_name}}", bold=True)
|
|
37
|
+
+ " "
|
|
38
|
+
+ click.style(f"{'Source':<{max_source}}", bold=True)
|
|
39
|
+
+ " "
|
|
40
|
+
+ click.style("Value", bold=True)
|
|
41
|
+
)
|
|
42
|
+
click.echo(header)
|
|
43
|
+
click.secho("-" * (max_name + max_source + 10), dim=True)
|
|
44
|
+
|
|
45
|
+
# Print each setting
|
|
46
|
+
for name, defn in items:
|
|
47
|
+
source_info = defn.env_var_name or defn.source
|
|
48
|
+
value = defn.display_value()
|
|
49
|
+
|
|
50
|
+
# Style based on source
|
|
51
|
+
if defn.source == "env":
|
|
52
|
+
source_styled = click.style(f"{source_info:<{max_source}}", fg="green")
|
|
53
|
+
elif defn.source == "explicit":
|
|
54
|
+
source_styled = click.style(f"{source_info:<{max_source}}", fg="cyan")
|
|
55
|
+
else:
|
|
56
|
+
source_styled = click.style(f"{source_info:<{max_source}}", dim=True)
|
|
57
|
+
|
|
58
|
+
# Style secret values
|
|
59
|
+
if defn.is_secret:
|
|
60
|
+
value_styled = click.style(value, dim=True)
|
|
61
|
+
else:
|
|
62
|
+
value_styled = value
|
|
63
|
+
|
|
64
|
+
click.echo(f"{name:<{max_name}} {source_styled} {value_styled}")
|
plain/cli/shell.py
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import os
|
|
2
4
|
import subprocess
|
|
3
5
|
import sys
|
|
4
6
|
|
|
5
7
|
import click
|
|
6
8
|
|
|
9
|
+
from plain.cli.runtime import common_command
|
|
10
|
+
|
|
7
11
|
|
|
12
|
+
@common_command
|
|
8
13
|
@click.command()
|
|
9
14
|
@click.option(
|
|
10
15
|
"-i",
|
|
@@ -17,11 +22,8 @@ import click
|
|
|
17
22
|
"--command",
|
|
18
23
|
help="Execute the given command and exit.",
|
|
19
24
|
)
|
|
20
|
-
def shell(interface, command):
|
|
21
|
-
"""
|
|
22
|
-
Runs a Python interactive interpreter. Tries to use IPython or
|
|
23
|
-
bpython, if one of them is available.
|
|
24
|
-
"""
|
|
25
|
+
def shell(interface: str | None, command: str | None) -> None:
|
|
26
|
+
"""Interactive Python shell"""
|
|
25
27
|
|
|
26
28
|
if command:
|
|
27
29
|
# Execute the command and exit
|
|
@@ -32,13 +34,14 @@ def shell(interface, command):
|
|
|
32
34
|
sys.exit(result.returncode)
|
|
33
35
|
return
|
|
34
36
|
|
|
37
|
+
interface_list: list[str]
|
|
35
38
|
if interface:
|
|
36
|
-
|
|
39
|
+
interface_list = [interface]
|
|
37
40
|
else:
|
|
38
41
|
|
|
39
|
-
def get_default_interface():
|
|
42
|
+
def get_default_interface() -> list[str]:
|
|
40
43
|
try:
|
|
41
|
-
import IPython # noqa
|
|
44
|
+
import IPython # noqa: F401 # type: ignore[import-not-found]
|
|
42
45
|
|
|
43
46
|
return ["python", "-m", "IPython"]
|
|
44
47
|
except ImportError:
|
|
@@ -46,10 +49,10 @@ def shell(interface, command):
|
|
|
46
49
|
|
|
47
50
|
return ["python"]
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
interface_list = get_default_interface()
|
|
50
53
|
|
|
51
54
|
result = subprocess.run(
|
|
52
|
-
|
|
55
|
+
interface_list,
|
|
53
56
|
env={
|
|
54
57
|
"PYTHONSTARTUP": os.path.join(os.path.dirname(__file__), "startup.py"),
|
|
55
58
|
**os.environ,
|
|
@@ -61,8 +64,8 @@ def shell(interface, command):
|
|
|
61
64
|
|
|
62
65
|
@click.command()
|
|
63
66
|
@click.argument("script", nargs=1, type=click.Path(exists=True))
|
|
64
|
-
def run(script):
|
|
65
|
-
"""
|
|
67
|
+
def run(script: str) -> None:
|
|
68
|
+
"""Execute Python scripts with app context"""
|
|
66
69
|
before_script = "import plain.runtime; plain.runtime.setup()"
|
|
67
70
|
command = f"{before_script}; exec(open('{script}').read())"
|
|
68
71
|
result = subprocess.run(["python", "-c", command])
|
plain/cli/startup.py
CHANGED
|
@@ -3,19 +3,19 @@ import plain.runtime
|
|
|
3
3
|
plain.runtime.setup()
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
def print_bold(s):
|
|
6
|
+
def print_bold(s: str) -> None:
|
|
7
7
|
print("\033[1m", end="")
|
|
8
8
|
print(s)
|
|
9
9
|
print("\033[0m", end="")
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def print_italic(s):
|
|
12
|
+
def print_italic(s: str) -> None:
|
|
13
13
|
print("\x1b[3m", end="")
|
|
14
14
|
print(s)
|
|
15
15
|
print("\x1b[0m", end="")
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def print_dim(s):
|
|
18
|
+
def print_dim(s: str) -> None:
|
|
19
19
|
print("\x1b[2m", end="")
|
|
20
20
|
print(s)
|
|
21
21
|
print("\x1b[0m", end="")
|
|
@@ -29,12 +29,13 @@ if shell_import := plain.runtime.settings.SHELL_IMPORT:
|
|
|
29
29
|
print_bold(f"Importing {shell_import}")
|
|
30
30
|
module = import_module(shell_import)
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
if module.__file__:
|
|
33
|
+
with open(module.__file__) as f:
|
|
34
|
+
contents = f.read()
|
|
35
|
+
for line in contents.splitlines():
|
|
36
|
+
print_dim(f"{line}")
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
print()
|
|
38
39
|
|
|
39
40
|
# Emulate `from module import *`
|
|
40
41
|
names = getattr(
|