plain 0.68.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 +656 -1
- 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 -36
- 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 +110 -26
- 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 +27 -75
- plain/cli/print.py +4 -4
- plain/cli/registry.py +96 -10
- plain/cli/{agent/request.py → request.py} +67 -33
- 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 -8
- 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 +27 -16
- plain/paginator.py +31 -21
- plain/preflight/README.md +209 -24
- plain/preflight/__init__.py +1 -0
- plain/preflight/checks.py +3 -1
- plain/preflight/files.py +3 -1
- plain/preflight/registry.py +26 -11
- plain/preflight/results.py +15 -7
- plain/preflight/security.py +15 -13
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +4 -1
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +34 -25
- 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 +13 -5
- 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 +38 -22
- plain/urls/resolvers.py +35 -25
- 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.68.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.68.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/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/templates/AGENTS.md +0 -3
- plain-0.68.0.dist-info/RECORD +0 -169
- plain-0.68.0.dist-info/entry_points.txt +0 -5
- {plain-0.68.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import ast
|
|
2
4
|
from pathlib import Path
|
|
3
5
|
|
|
@@ -7,10 +9,10 @@ import click
|
|
|
7
9
|
class LLMDocs:
|
|
8
10
|
"""Generates LLM-friendly documentation."""
|
|
9
11
|
|
|
10
|
-
def __init__(self, paths):
|
|
12
|
+
def __init__(self, paths: list[Path]):
|
|
11
13
|
self.paths = paths
|
|
12
14
|
|
|
13
|
-
def load(self):
|
|
15
|
+
def load(self) -> None:
|
|
14
16
|
self.docs = set()
|
|
15
17
|
self.sources = set()
|
|
16
18
|
|
|
@@ -24,7 +26,7 @@ class LLMDocs:
|
|
|
24
26
|
self.docs.add(path)
|
|
25
27
|
|
|
26
28
|
# Exclude "migrations" code from plain apps, except for plain/models/migrations
|
|
27
|
-
# Also exclude CHANGELOG.md
|
|
29
|
+
# Also exclude CHANGELOG.md, AGENTS.md, and skills directory
|
|
28
30
|
self.docs = {
|
|
29
31
|
doc
|
|
30
32
|
for doc in self.docs
|
|
@@ -33,6 +35,7 @@ class LLMDocs:
|
|
|
33
35
|
and "/plain/models/migrations/" not in str(doc)
|
|
34
36
|
)
|
|
35
37
|
and doc.name not in ("CHANGELOG.md", "AGENTS.md")
|
|
38
|
+
and "/skills/" not in str(doc)
|
|
36
39
|
}
|
|
37
40
|
self.sources = {
|
|
38
41
|
source
|
|
@@ -42,12 +45,13 @@ class LLMDocs:
|
|
|
42
45
|
and "/plain/models/migrations/" not in str(source)
|
|
43
46
|
)
|
|
44
47
|
and source.name != "cli.py"
|
|
48
|
+
and "/skills/" not in str(source)
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
self.docs = sorted(self.docs)
|
|
48
52
|
self.sources = sorted(self.sources)
|
|
49
53
|
|
|
50
|
-
def display_path(self, path):
|
|
54
|
+
def display_path(self, path: Path) -> Path:
|
|
51
55
|
if "plain" in path.parts:
|
|
52
56
|
root_index = path.parts.index("plain")
|
|
53
57
|
elif "plainx" in path.parts:
|
|
@@ -58,7 +62,7 @@ class LLMDocs:
|
|
|
58
62
|
plain_root = Path(*path.parts[: root_index + 1])
|
|
59
63
|
return path.relative_to(plain_root.parent)
|
|
60
64
|
|
|
61
|
-
def print(self, relative_to=None):
|
|
65
|
+
def print(self, relative_to: Path | None = None) -> None:
|
|
62
66
|
for doc in self.docs:
|
|
63
67
|
if relative_to:
|
|
64
68
|
display_path = doc.relative_to(relative_to)
|
|
@@ -81,7 +85,7 @@ class LLMDocs:
|
|
|
81
85
|
click.echo()
|
|
82
86
|
|
|
83
87
|
@staticmethod
|
|
84
|
-
def symbolicate(file_path: Path):
|
|
88
|
+
def symbolicate(file_path: Path) -> str:
|
|
85
89
|
if "internal" in str(file_path).split("/"):
|
|
86
90
|
return ""
|
|
87
91
|
|
|
@@ -89,8 +93,16 @@ class LLMDocs:
|
|
|
89
93
|
|
|
90
94
|
parsed = ast.parse(source)
|
|
91
95
|
|
|
92
|
-
def should_skip(node):
|
|
93
|
-
if isinstance(node, ast.ClassDef
|
|
96
|
+
def should_skip(node: ast.AST) -> bool:
|
|
97
|
+
if isinstance(node, ast.ClassDef):
|
|
98
|
+
if any(
|
|
99
|
+
isinstance(d, ast.Name) and d.id == "internalcode"
|
|
100
|
+
for d in node.decorator_list
|
|
101
|
+
):
|
|
102
|
+
return True
|
|
103
|
+
if node.name.startswith("_"):
|
|
104
|
+
return True
|
|
105
|
+
elif isinstance(node, ast.FunctionDef):
|
|
94
106
|
if any(
|
|
95
107
|
isinstance(d, ast.Name) and d.id == "internalcode"
|
|
96
108
|
for d in node.decorator_list
|
|
@@ -104,7 +116,7 @@ class LLMDocs:
|
|
|
104
116
|
return True
|
|
105
117
|
return False
|
|
106
118
|
|
|
107
|
-
def process_node(node, indent=0):
|
|
119
|
+
def process_node(node: ast.AST, indent: int = 0) -> list[str]:
|
|
108
120
|
lines = []
|
|
109
121
|
prefix = " " * indent
|
|
110
122
|
|
plain/cli/output.py
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
|
|
1
5
|
import click
|
|
2
6
|
|
|
3
7
|
|
|
4
|
-
def style_markdown(content):
|
|
8
|
+
def style_markdown(content: str) -> str:
|
|
5
9
|
return "".join(iterate_markdown(content))
|
|
6
10
|
|
|
7
11
|
|
|
8
|
-
def iterate_markdown(content):
|
|
12
|
+
def iterate_markdown(content: str) -> Iterator[str]:
|
|
9
13
|
"""
|
|
10
14
|
Iterator that does basic markdown for a Click pager.
|
|
11
15
|
|
plain/cli/preflight.py
CHANGED
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import sys
|
|
3
|
+
from typing import Any
|
|
3
4
|
|
|
4
5
|
import click
|
|
5
6
|
|
|
6
7
|
from plain import preflight
|
|
8
|
+
from plain.cli.runtime import common_command
|
|
7
9
|
from plain.packages import packages_registry
|
|
8
|
-
from plain.preflight.registry import checks_registry
|
|
9
|
-
from plain.runtime import settings
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
@
|
|
13
|
-
|
|
14
|
-
"""Run or manage preflight checks."""
|
|
15
|
-
pass
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@preflight_cli.command("check")
|
|
12
|
+
@common_command
|
|
13
|
+
@click.command("preflight")
|
|
19
14
|
@click.option(
|
|
20
15
|
"--deploy",
|
|
21
16
|
is_flag=True,
|
|
22
|
-
help="
|
|
17
|
+
help="Include deployment checks.",
|
|
23
18
|
)
|
|
24
19
|
@click.option(
|
|
25
20
|
"--format",
|
|
@@ -32,16 +27,18 @@ def preflight_cli():
|
|
|
32
27
|
is_flag=True,
|
|
33
28
|
help="Hide progress output and warnings, only show errors.",
|
|
34
29
|
)
|
|
35
|
-
def
|
|
36
|
-
"""
|
|
37
|
-
Use
|
|
38
|
-
|
|
39
|
-
""
|
|
30
|
+
def preflight_cli(deploy: bool, format: str, quiet: bool) -> None:
|
|
31
|
+
"""Validation checks before deployment"""
|
|
32
|
+
# Use stderr for progress messages only in JSON mode (keeps stdout clean for parsing)
|
|
33
|
+
# In text mode, send all output to stdout (so success doesn't appear in error logs)
|
|
34
|
+
use_stderr = format == "json"
|
|
35
|
+
|
|
40
36
|
# Auto-discover and load preflight checks
|
|
41
37
|
packages_registry.autodiscover_modules("preflight", include_app=True)
|
|
42
|
-
|
|
43
38
|
if not quiet:
|
|
44
|
-
click.secho(
|
|
39
|
+
click.secho(
|
|
40
|
+
"Running preflight checks...", dim=True, italic=True, err=use_stderr
|
|
41
|
+
)
|
|
45
42
|
|
|
46
43
|
total_checks = 0
|
|
47
44
|
passed_checks = 0
|
|
@@ -60,23 +57,23 @@ def check_command(deploy, format, quiet):
|
|
|
60
57
|
if format == "text":
|
|
61
58
|
if not quiet:
|
|
62
59
|
# Print check name without newline
|
|
63
|
-
click.echo("Check:", nl=False, err=
|
|
64
|
-
click.secho(f"{check_name} ", bold=True, nl=False, err=
|
|
60
|
+
click.echo("Check:", nl=False, err=use_stderr)
|
|
61
|
+
click.secho(f"{check_name} ", bold=True, nl=False, err=use_stderr)
|
|
65
62
|
|
|
66
63
|
# Determine status icon based on issue severity
|
|
67
64
|
if not visible_issues:
|
|
68
65
|
# No issues - passed
|
|
69
66
|
if not quiet:
|
|
70
|
-
click.secho("✔", fg="green", err=
|
|
67
|
+
click.secho("✔", fg="green", err=use_stderr)
|
|
71
68
|
passed_checks += 1
|
|
72
69
|
else:
|
|
73
70
|
# Has issues - determine icon based on highest severity
|
|
74
71
|
has_errors = any(not issue.warning for issue in visible_issues)
|
|
75
72
|
if not quiet:
|
|
76
73
|
if has_errors:
|
|
77
|
-
click.secho("✗", fg="red", err=
|
|
74
|
+
click.secho("✗", fg="red", err=use_stderr)
|
|
78
75
|
else:
|
|
79
|
-
click.secho("⚠", fg="yellow", err=
|
|
76
|
+
click.secho("⚠", fg="yellow", err=use_stderr)
|
|
80
77
|
|
|
81
78
|
# Print issues with simple indentation
|
|
82
79
|
issues_to_show = (
|
|
@@ -91,26 +88,26 @@ def check_command(deploy, format, quiet):
|
|
|
91
88
|
if quiet:
|
|
92
89
|
# In quiet mode, show check name once, then issues
|
|
93
90
|
if i == 0:
|
|
94
|
-
click.secho(f"{check_name}:", err=
|
|
91
|
+
click.secho(f"{check_name}:", err=use_stderr)
|
|
95
92
|
# Show ID and fix on separate lines with same indentation
|
|
96
93
|
click.secho(
|
|
97
94
|
f" [{issue_type}] {issue.id}:",
|
|
98
95
|
fg=issue_color,
|
|
99
96
|
bold=True,
|
|
100
|
-
err=
|
|
97
|
+
err=use_stderr,
|
|
101
98
|
nl=False,
|
|
102
99
|
)
|
|
103
|
-
click.secho(f" {issue.fix}", err=
|
|
100
|
+
click.secho(f" {issue.fix}", err=use_stderr, dim=True)
|
|
104
101
|
else:
|
|
105
102
|
# Show ID and fix on separate lines with same indentation
|
|
106
103
|
click.secho(
|
|
107
104
|
f" [{issue_type}] {issue.id}: ",
|
|
108
105
|
fg=issue_color,
|
|
109
106
|
bold=True,
|
|
110
|
-
err=
|
|
107
|
+
err=use_stderr,
|
|
111
108
|
nl=False,
|
|
112
109
|
)
|
|
113
|
-
click.secho(f"{issue.fix}", err=
|
|
110
|
+
click.secho(f"{issue.fix}", err=use_stderr, dim=True)
|
|
114
111
|
else:
|
|
115
112
|
# For JSON format, just count passed checks
|
|
116
113
|
if not visible_issues:
|
|
@@ -129,12 +126,12 @@ def check_command(deploy, format, quiet):
|
|
|
129
126
|
|
|
130
127
|
if format == "json":
|
|
131
128
|
# Build JSON output
|
|
132
|
-
results = {"passed": not has_errors, "checks": []}
|
|
129
|
+
results: dict[str, Any] = {"passed": not has_errors, "checks": []}
|
|
133
130
|
|
|
134
131
|
for check_class, check_name, issues in check_results:
|
|
135
132
|
visible_issues = [issue for issue in issues if not issue.is_silenced()]
|
|
136
133
|
|
|
137
|
-
check_result = {
|
|
134
|
+
check_result: dict[str, Any] = {
|
|
138
135
|
"name": check_name,
|
|
139
136
|
"passed": len(visible_issues) == 0,
|
|
140
137
|
"issues": [],
|
|
@@ -195,53 +192,8 @@ def check_command(deploy, format, quiet):
|
|
|
195
192
|
|
|
196
193
|
summary_text = ", ".join(summary_parts) if summary_parts else "no issues"
|
|
197
194
|
|
|
198
|
-
click.secho(f"{icon}{summary_text}", fg=summary_color, err=
|
|
195
|
+
click.secho(f"{icon}{summary_text}", fg=summary_color, err=use_stderr)
|
|
199
196
|
|
|
200
197
|
# Exit with error if there are any errors (not warnings)
|
|
201
198
|
if has_errors:
|
|
202
199
|
sys.exit(1)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
@preflight_cli.command("list")
|
|
206
|
-
def list_checks():
|
|
207
|
-
"""List all available preflight checks."""
|
|
208
|
-
packages_registry.autodiscover_modules("preflight", include_app=True)
|
|
209
|
-
|
|
210
|
-
regular = []
|
|
211
|
-
deployment = []
|
|
212
|
-
silenced_checks = settings.PREFLIGHT_SILENCED_CHECKS
|
|
213
|
-
|
|
214
|
-
for name, (check_class, deploy) in sorted(checks_registry.checks.items()):
|
|
215
|
-
# Use class docstring as description
|
|
216
|
-
description = check_class.__doc__ or "No description"
|
|
217
|
-
# Get first line of docstring
|
|
218
|
-
description = description.strip().split("\n")[0]
|
|
219
|
-
|
|
220
|
-
is_silenced = name in silenced_checks
|
|
221
|
-
if deploy:
|
|
222
|
-
deployment.append((name, description, is_silenced))
|
|
223
|
-
else:
|
|
224
|
-
regular.append((name, description, is_silenced))
|
|
225
|
-
|
|
226
|
-
if regular:
|
|
227
|
-
click.echo("Regular checks:")
|
|
228
|
-
for name, description, is_silenced in regular:
|
|
229
|
-
silenced_text = (
|
|
230
|
-
click.style(" (silenced)", fg="red", dim=True) if is_silenced else ""
|
|
231
|
-
)
|
|
232
|
-
click.echo(
|
|
233
|
-
f" {click.style(name)}: {click.style(description, dim=True)}{silenced_text}"
|
|
234
|
-
)
|
|
235
|
-
|
|
236
|
-
if deployment:
|
|
237
|
-
click.echo("\nDeployment checks:")
|
|
238
|
-
for name, description, is_silenced in deployment:
|
|
239
|
-
silenced_text = (
|
|
240
|
-
click.style(" (silenced)", fg="red", dim=True) if is_silenced else ""
|
|
241
|
-
)
|
|
242
|
-
click.echo(
|
|
243
|
-
f" {click.style(name)}: {click.style(description, dim=True)}{silenced_text}"
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
if not regular and not deployment:
|
|
247
|
-
click.echo("No preflight checks found.")
|
plain/cli/print.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import click
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
def print_event(msg, newline=True):
|
|
5
|
-
arrow = click.style("-->", fg=214, bold=True)
|
|
6
|
-
message =
|
|
4
|
+
def print_event(msg: str, newline: bool = True) -> None:
|
|
5
|
+
arrow = click.style("-->", fg=214, bold=True, dim=True)
|
|
6
|
+
message = click.style(msg, dim=True)
|
|
7
7
|
if not newline:
|
|
8
8
|
message += " "
|
|
9
|
-
click.
|
|
9
|
+
click.echo(f"{arrow} {message}", nl=newline)
|
plain/cli/registry.py
CHANGED
|
@@ -1,45 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, NamedTuple
|
|
4
|
+
|
|
1
5
|
from plain.packages import packages_registry
|
|
2
6
|
|
|
3
7
|
|
|
8
|
+
class CommandMetadata(NamedTuple):
|
|
9
|
+
"""Metadata about a registered command."""
|
|
10
|
+
|
|
11
|
+
cmd: Any
|
|
12
|
+
shortcut_for: str | None = None
|
|
13
|
+
is_common: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
4
16
|
class CLIRegistry:
|
|
5
|
-
def __init__(self):
|
|
6
|
-
self._commands = {}
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self._commands: dict[str, CommandMetadata] = {}
|
|
7
19
|
|
|
8
|
-
def register_command(
|
|
20
|
+
def register_command(
|
|
21
|
+
self,
|
|
22
|
+
cmd: Any,
|
|
23
|
+
name: str,
|
|
24
|
+
shortcut_for: str | None = None,
|
|
25
|
+
is_common: bool = False,
|
|
26
|
+
) -> None:
|
|
9
27
|
"""
|
|
10
28
|
Register a CLI command or group with the specified name.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
cmd: The click command or group to register
|
|
32
|
+
name: The name to register the command under
|
|
33
|
+
shortcut_for: Optional parent command this is a shortcut for (e.g., "models" for migrate)
|
|
34
|
+
is_common: Whether this is a commonly used command to show in the "Common Commands" section
|
|
11
35
|
"""
|
|
12
|
-
self._commands[name] =
|
|
36
|
+
self._commands[name] = CommandMetadata(
|
|
37
|
+
cmd=cmd, shortcut_for=shortcut_for, is_common=is_common
|
|
38
|
+
)
|
|
13
39
|
|
|
14
|
-
def import_modules(self):
|
|
40
|
+
def import_modules(self) -> None:
|
|
15
41
|
"""
|
|
16
42
|
Import modules from installed packages and app to trigger registration.
|
|
17
43
|
"""
|
|
18
44
|
packages_registry.autodiscover_modules("cli", include_app=True)
|
|
19
45
|
|
|
20
|
-
def get_commands(self):
|
|
46
|
+
def get_commands(self) -> dict[str, Any]:
|
|
21
47
|
"""
|
|
22
|
-
Get all registered commands.
|
|
48
|
+
Get all registered commands (just the command objects, not metadata).
|
|
49
|
+
"""
|
|
50
|
+
return {name: metadata.cmd for name, metadata in self._commands.items()}
|
|
51
|
+
|
|
52
|
+
def get_commands_with_metadata(self) -> dict[str, CommandMetadata]:
|
|
53
|
+
"""
|
|
54
|
+
Get all registered commands with their metadata.
|
|
23
55
|
"""
|
|
24
56
|
return self._commands
|
|
25
57
|
|
|
58
|
+
def get_shortcuts(self) -> dict[str, CommandMetadata]:
|
|
59
|
+
"""
|
|
60
|
+
Get only commands that are shortcuts.
|
|
61
|
+
"""
|
|
62
|
+
return {
|
|
63
|
+
name: metadata
|
|
64
|
+
for name, metadata in self._commands.items()
|
|
65
|
+
if metadata.shortcut_for
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
def get_common_commands(self) -> dict[str, CommandMetadata]:
|
|
69
|
+
"""
|
|
70
|
+
Get only commands that are marked as common.
|
|
71
|
+
"""
|
|
72
|
+
return {
|
|
73
|
+
name: metadata
|
|
74
|
+
for name, metadata in self._commands.items()
|
|
75
|
+
if metadata.is_common
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def get_regular_commands(self) -> dict[str, CommandMetadata]:
|
|
79
|
+
"""
|
|
80
|
+
Get only commands that are not common.
|
|
81
|
+
"""
|
|
82
|
+
return {
|
|
83
|
+
name: metadata
|
|
84
|
+
for name, metadata in self._commands.items()
|
|
85
|
+
if not metadata.is_common
|
|
86
|
+
}
|
|
87
|
+
|
|
26
88
|
|
|
27
89
|
cli_registry = CLIRegistry()
|
|
28
90
|
|
|
29
91
|
|
|
30
|
-
def register_cli(
|
|
92
|
+
def register_cli(
|
|
93
|
+
name: str, shortcut_for: str | None = None, common: bool = False
|
|
94
|
+
) -> Any:
|
|
31
95
|
"""
|
|
32
96
|
Register a CLI command or group with the given name.
|
|
33
97
|
|
|
98
|
+
Args:
|
|
99
|
+
name: The name to register the command under
|
|
100
|
+
shortcut_for: Optional parent command this is a shortcut for.
|
|
101
|
+
For example, @register_cli("migrate", shortcut_for="models")
|
|
102
|
+
indicates that "plain migrate" is a shortcut for "plain models migrate"
|
|
103
|
+
common: Whether this is a commonly used command to show in the "Common Commands" section
|
|
104
|
+
|
|
34
105
|
Usage:
|
|
106
|
+
# Register a regular command group
|
|
35
107
|
@register_cli("users")
|
|
36
108
|
@click.group()
|
|
37
109
|
def users_cli():
|
|
38
110
|
pass
|
|
111
|
+
|
|
112
|
+
# Register a shortcut command
|
|
113
|
+
@register_cli("migrate", shortcut_for="models", common=True)
|
|
114
|
+
@click.command()
|
|
115
|
+
def migrate():
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
# Register a common command
|
|
119
|
+
@register_cli("dev", common=True)
|
|
120
|
+
@click.command()
|
|
121
|
+
def dev():
|
|
122
|
+
pass
|
|
39
123
|
"""
|
|
40
124
|
|
|
41
|
-
def wrapper(cmd):
|
|
42
|
-
cli_registry.register_command(
|
|
125
|
+
def wrapper(cmd: Any) -> Any:
|
|
126
|
+
cli_registry.register_command(
|
|
127
|
+
cmd, name, shortcut_for=shortcut_for, is_common=common
|
|
128
|
+
)
|
|
43
129
|
return cmd
|
|
44
130
|
|
|
45
131
|
return wrapper
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
4
|
+
from typing import Any
|
|
2
5
|
|
|
3
6
|
import click
|
|
4
7
|
|
|
@@ -37,8 +40,28 @@ from plain.test import Client
|
|
|
37
40
|
multiple=True,
|
|
38
41
|
help="Additional headers (format: 'Name: Value')",
|
|
39
42
|
)
|
|
40
|
-
|
|
41
|
-
""
|
|
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"""
|
|
42
65
|
|
|
43
66
|
try:
|
|
44
67
|
# Only allow in DEBUG mode for security
|
|
@@ -61,9 +84,6 @@ def request(path, method, data, user_id, follow, content_type, headers):
|
|
|
61
84
|
try:
|
|
62
85
|
user = User.query.get(id=user_id)
|
|
63
86
|
client.force_login(user)
|
|
64
|
-
click.secho(
|
|
65
|
-
f"Authenticated as user {user_id}", fg="green", dim=True
|
|
66
|
-
)
|
|
67
87
|
except User.DoesNotExist:
|
|
68
88
|
click.secho(f"User {user_id} not found", fg="red", err=True)
|
|
69
89
|
return
|
|
@@ -90,11 +110,11 @@ def request(path, method, data, user_id, follow, content_type, headers):
|
|
|
90
110
|
|
|
91
111
|
# Make the request
|
|
92
112
|
method = method.upper()
|
|
93
|
-
kwargs = {
|
|
94
|
-
"path": path,
|
|
113
|
+
kwargs: dict[str, Any] = {
|
|
95
114
|
"follow": follow,
|
|
96
|
-
"headers": header_dict or None,
|
|
97
115
|
}
|
|
116
|
+
if header_dict:
|
|
117
|
+
kwargs["headers"] = header_dict
|
|
98
118
|
|
|
99
119
|
if method in ("POST", "PUT", "PATCH") and data:
|
|
100
120
|
kwargs["data"] = data
|
|
@@ -103,57 +123,71 @@ def request(path, method, data, user_id, follow, content_type, headers):
|
|
|
103
123
|
|
|
104
124
|
# Call the appropriate client method
|
|
105
125
|
if method == "GET":
|
|
106
|
-
response = client.get(**kwargs)
|
|
126
|
+
response = client.get(path, **kwargs)
|
|
107
127
|
elif method == "POST":
|
|
108
|
-
response = client.post(**kwargs)
|
|
128
|
+
response = client.post(path, **kwargs)
|
|
109
129
|
elif method == "PUT":
|
|
110
|
-
response = client.put(**kwargs)
|
|
130
|
+
response = client.put(path, **kwargs)
|
|
111
131
|
elif method == "PATCH":
|
|
112
|
-
response = client.patch(**kwargs)
|
|
132
|
+
response = client.patch(path, **kwargs)
|
|
113
133
|
elif method == "DELETE":
|
|
114
|
-
response = client.delete(**kwargs)
|
|
134
|
+
response = client.delete(path, **kwargs)
|
|
115
135
|
elif method == "HEAD":
|
|
116
|
-
response = client.head(**kwargs)
|
|
136
|
+
response = client.head(path, **kwargs)
|
|
117
137
|
elif method == "OPTIONS":
|
|
118
|
-
response = client.options(**kwargs)
|
|
138
|
+
response = client.options(path, **kwargs)
|
|
119
139
|
elif method == "TRACE":
|
|
120
|
-
response = client.trace(**kwargs)
|
|
140
|
+
response = client.trace(path, **kwargs)
|
|
121
141
|
else:
|
|
122
142
|
click.secho(f"Unsupported HTTP method: {method}", fg="red", err=True)
|
|
123
143
|
return
|
|
124
144
|
|
|
125
145
|
# 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
|
-
)
|
|
146
|
+
click.secho("Response:", fg="yellow", bold=True)
|
|
131
147
|
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
click.secho(f"Authenticated user: {response.user}", fg="blue", dim=True)
|
|
148
|
+
# Status code
|
|
149
|
+
click.echo(f" Status: {response.status_code}")
|
|
135
150
|
|
|
136
|
-
|
|
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:
|
|
137
160
|
match = response.resolver_match
|
|
138
|
-
|
|
139
|
-
|
|
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()
|
|
140
168
|
|
|
141
169
|
# Show headers
|
|
142
|
-
if response.headers:
|
|
170
|
+
if response.headers and not no_headers:
|
|
143
171
|
click.secho("Response Headers:", fg="yellow", bold=True)
|
|
144
172
|
for key, value in response.headers.items():
|
|
145
173
|
click.echo(f" {key}: {value}")
|
|
146
174
|
click.echo()
|
|
147
175
|
|
|
148
176
|
# Show response content last
|
|
149
|
-
if response.content:
|
|
177
|
+
if response.content and not no_body:
|
|
150
178
|
content_type = response.headers.get("Content-Type", "")
|
|
151
179
|
|
|
152
180
|
if "json" in content_type.lower():
|
|
153
181
|
try:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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"))
|
|
157
191
|
except Exception:
|
|
158
192
|
click.secho("Response Body:", fg="yellow", bold=True)
|
|
159
193
|
click.echo(response.content.decode("utf-8", errors="replace"))
|
|
@@ -165,7 +199,7 @@ def request(path, method, data, user_id, follow, content_type, headers):
|
|
|
165
199
|
click.secho("Response Body:", fg="yellow", bold=True)
|
|
166
200
|
content = response.content.decode("utf-8", errors="replace")
|
|
167
201
|
click.echo(content)
|
|
168
|
-
|
|
202
|
+
elif not no_body:
|
|
169
203
|
click.secho("(No response body)", fg="yellow", dim=True)
|
|
170
204
|
|
|
171
205
|
except Exception as e:
|
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
|