plain 0.68.0__py3-none-any.whl → 0.103.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 +684 -1
- plain/README.md +1 -1
- plain/agents/.claude/rules/plain.md +88 -0
- plain/agents/.claude/skills/plain-install/SKILL.md +26 -0
- plain/agents/.claude/skills/plain-upgrade/SKILL.md +35 -0
- 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 +234 -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 +98 -21
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +45 -26
- 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/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.103.0.dist-info}/METADATA +4 -2
- plain-0.103.0.dist-info/RECORD +198 -0
- {plain-0.68.0.dist-info → plain-0.103.0.dist-info}/WHEEL +1 -1
- plain-0.103.0.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.103.0.dist-info}/licenses/LICENSE +0 -0
plain/cli/build.py
CHANGED
|
@@ -9,6 +9,7 @@ import click
|
|
|
9
9
|
|
|
10
10
|
import plain.runtime
|
|
11
11
|
from plain.assets.compile import compile_assets, get_compiled_path
|
|
12
|
+
from plain.cli.print import print_event
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
@click.command()
|
|
@@ -33,8 +34,8 @@ from plain.assets.compile import compile_assets, get_compiled_path
|
|
|
33
34
|
default=True,
|
|
34
35
|
help="Compress the assets",
|
|
35
36
|
)
|
|
36
|
-
def build(keep_original, fingerprint, compress):
|
|
37
|
-
"""Pre-deployment build step
|
|
37
|
+
def build(keep_original: bool, fingerprint: bool, compress: bool) -> None:
|
|
38
|
+
"""Pre-deployment build step for assets and static files"""
|
|
38
39
|
|
|
39
40
|
if not keep_original and not fingerprint:
|
|
40
41
|
raise click.UsageError(
|
|
@@ -54,18 +55,16 @@ def build(keep_original, fingerprint, compress):
|
|
|
54
55
|
.get("run", {})
|
|
55
56
|
.items()
|
|
56
57
|
):
|
|
57
|
-
|
|
58
|
+
print_event(f"{name}...")
|
|
58
59
|
result = subprocess.run(data["cmd"], shell=True)
|
|
59
|
-
print()
|
|
60
60
|
if result.returncode:
|
|
61
61
|
click.secho(f"Error in {name} (exit {result.returncode})", fg="red")
|
|
62
62
|
sys.exit(result.returncode)
|
|
63
63
|
|
|
64
64
|
# Then run installed package build steps (like tailwind, typically should run last...)
|
|
65
65
|
for entry_point in entry_points(group="plain.build"):
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
print()
|
|
66
|
+
print_event(f"{entry_point.name}...")
|
|
67
|
+
entry_point.load()()
|
|
69
68
|
|
|
70
69
|
# Compile our assets
|
|
71
70
|
target_dir = get_compiled_path()
|
|
@@ -79,7 +78,7 @@ def build(keep_original, fingerprint, compress):
|
|
|
79
78
|
total_compiled = 0
|
|
80
79
|
|
|
81
80
|
for url_path, resolved_url_path, compiled_paths in compile_assets(
|
|
82
|
-
target_dir=target_dir,
|
|
81
|
+
target_dir=str(target_dir),
|
|
83
82
|
keep_original=keep_original,
|
|
84
83
|
fingerprint=fingerprint,
|
|
85
84
|
compress=compress,
|
plain/cli/changelog.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import re
|
|
2
4
|
from importlib.util import find_spec
|
|
3
5
|
from pathlib import Path
|
|
@@ -5,9 +7,10 @@ from pathlib import Path
|
|
|
5
7
|
import click
|
|
6
8
|
|
|
7
9
|
from .output import style_markdown
|
|
10
|
+
from .runtime import without_runtime_setup
|
|
8
11
|
|
|
9
12
|
|
|
10
|
-
def parse_version(version_str):
|
|
13
|
+
def parse_version(version_str: str) -> tuple[int, ...]:
|
|
11
14
|
"""Parse a version string into a tuple of integers for comparison."""
|
|
12
15
|
# Remove 'v' prefix if present and split by dots
|
|
13
16
|
clean_version = version_str.lstrip("v")
|
|
@@ -22,7 +25,7 @@ def parse_version(version_str):
|
|
|
22
25
|
return tuple(parts)
|
|
23
26
|
|
|
24
27
|
|
|
25
|
-
def compare_versions(v1, v2):
|
|
28
|
+
def compare_versions(v1: str, v2: str) -> int:
|
|
26
29
|
"""Compare two version strings. Returns -1 if v1 < v2, 0 if equal, 1 if v1 > v2."""
|
|
27
30
|
parsed_v1 = parse_version(v1)
|
|
28
31
|
parsed_v2 = parse_version(v2)
|
|
@@ -40,12 +43,15 @@ def compare_versions(v1, v2):
|
|
|
40
43
|
return 0
|
|
41
44
|
|
|
42
45
|
|
|
46
|
+
@without_runtime_setup
|
|
43
47
|
@click.command("changelog")
|
|
44
48
|
@click.argument("package_label")
|
|
45
49
|
@click.option("--from", "from_version", help="Show entries from this version onwards")
|
|
46
50
|
@click.option("--to", "to_version", help="Show entries up to this version")
|
|
47
|
-
def changelog(
|
|
48
|
-
|
|
51
|
+
def changelog(
|
|
52
|
+
package_label: str, from_version: str | None, to_version: str | None
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Show changelog for a package"""
|
|
49
55
|
module_name = package_label.replace("-", ".")
|
|
50
56
|
spec = find_spec(module_name)
|
|
51
57
|
if not spec:
|
|
@@ -85,7 +91,7 @@ def changelog(package_label, from_version, to_version):
|
|
|
85
91
|
if current_version is not None:
|
|
86
92
|
entries.append((current_version, current_lines))
|
|
87
93
|
|
|
88
|
-
def version_found(version):
|
|
94
|
+
def version_found(version: str) -> bool:
|
|
89
95
|
return any(compare_versions(v, version) == 0 for v, _ in entries)
|
|
90
96
|
|
|
91
97
|
if from_version and not version_found(from_version):
|
plain/cli/chores.py
CHANGED
|
@@ -7,79 +7,77 @@ logger = logging.getLogger("plain.chores")
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
@click.group()
|
|
10
|
-
def chores():
|
|
10
|
+
def chores() -> None:
|
|
11
11
|
"""Routine maintenance tasks"""
|
|
12
12
|
pass
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
@chores.command("list")
|
|
16
|
-
@click.option("--group", default=None, type=str, help="Group to run", multiple=True)
|
|
17
16
|
@click.option(
|
|
18
17
|
"--name", default=None, type=str, help="Name of the chore to run", multiple=True
|
|
19
18
|
)
|
|
20
|
-
def list_chores(
|
|
21
|
-
"""
|
|
22
|
-
List all registered chores.
|
|
23
|
-
"""
|
|
19
|
+
def list_chores(name: tuple[str, ...]) -> None:
|
|
20
|
+
"""List all registered chores"""
|
|
24
21
|
from plain.chores.registry import chores_registry
|
|
25
22
|
|
|
26
23
|
chores_registry.import_modules()
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
chore_classes = chores_registry.get_chores()
|
|
26
|
+
|
|
27
|
+
if name:
|
|
28
|
+
chore_classes = [
|
|
29
|
+
chore_class
|
|
30
|
+
for chore_class in chore_classes
|
|
31
|
+
if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
|
|
33
32
|
]
|
|
34
|
-
else:
|
|
35
|
-
chores = chores_registry.get_chores()
|
|
36
33
|
|
|
37
|
-
for
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
for chore_class in chore_classes:
|
|
35
|
+
chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
|
|
36
|
+
click.secho(f"{chore_name}", bold=True, nl=False)
|
|
37
|
+
description = chore_class.__doc__.strip() if chore_class.__doc__ else ""
|
|
38
|
+
if description:
|
|
39
|
+
click.secho(f": {description}", dim=True)
|
|
41
40
|
else:
|
|
42
41
|
click.echo("")
|
|
43
42
|
|
|
44
43
|
|
|
45
44
|
@chores.command("run")
|
|
46
|
-
@click.option("--group", default=None, type=str, help="Group to run", multiple=True)
|
|
47
45
|
@click.option(
|
|
48
46
|
"--name", default=None, type=str, help="Name of the chore to run", multiple=True
|
|
49
47
|
)
|
|
50
48
|
@click.option(
|
|
51
49
|
"--dry-run", is_flag=True, help="Show what would be done without executing"
|
|
52
50
|
)
|
|
53
|
-
def run_chores(
|
|
54
|
-
"""
|
|
55
|
-
Run the specified chores.
|
|
56
|
-
"""
|
|
51
|
+
def run_chores(name: tuple[str, ...], dry_run: bool) -> None:
|
|
52
|
+
"""Run specified chores"""
|
|
57
53
|
from plain.chores.registry import chores_registry
|
|
58
54
|
|
|
59
55
|
chores_registry.import_modules()
|
|
60
56
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
chore_classes = chores_registry.get_chores()
|
|
58
|
+
|
|
59
|
+
if name:
|
|
60
|
+
chore_classes = [
|
|
61
|
+
chore_class
|
|
62
|
+
for chore_class in chore_classes
|
|
63
|
+
if f"{chore_class.__module__}.{chore_class.__qualname__}" in name
|
|
66
64
|
]
|
|
67
|
-
else:
|
|
68
|
-
chores = chores_registry.get_chores()
|
|
69
65
|
|
|
70
66
|
chores_failed = []
|
|
71
67
|
|
|
72
|
-
for
|
|
73
|
-
|
|
68
|
+
for chore_class in chore_classes:
|
|
69
|
+
chore_name = f"{chore_class.__module__}.{chore_class.__qualname__}"
|
|
70
|
+
click.echo(f"{chore_name}:", nl=False)
|
|
74
71
|
if dry_run:
|
|
75
|
-
click.
|
|
72
|
+
click.secho(" (dry run)", fg="yellow", nl=False)
|
|
76
73
|
else:
|
|
77
74
|
try:
|
|
75
|
+
chore = chore_class()
|
|
78
76
|
result = chore.run()
|
|
79
77
|
except Exception:
|
|
80
78
|
click.secho(" Failed", fg="red")
|
|
81
|
-
chores_failed.append(
|
|
82
|
-
logger.exception(f"Error running chore {
|
|
79
|
+
chores_failed.append(chore_class)
|
|
80
|
+
logger.exception(f"Error running chore {chore_name}")
|
|
83
81
|
continue
|
|
84
82
|
|
|
85
83
|
if result is None:
|
plain/cli/core.py
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import traceback
|
|
4
|
+
from typing import Any
|
|
2
5
|
|
|
3
6
|
import click
|
|
4
7
|
from click.core import Command, Context
|
|
@@ -15,8 +18,10 @@ from .formatting import PlainContext
|
|
|
15
18
|
from .install import install
|
|
16
19
|
from .preflight import preflight_cli
|
|
17
20
|
from .registry import cli_registry
|
|
21
|
+
from .request import request
|
|
18
22
|
from .scaffold import create
|
|
19
|
-
from .
|
|
23
|
+
from .server import server
|
|
24
|
+
from .settings import settings
|
|
20
25
|
from .shell import run, shell
|
|
21
26
|
from .upgrade import upgrade
|
|
22
27
|
from .urls import urls
|
|
@@ -24,12 +29,13 @@ from .utils import utils
|
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
@click.group()
|
|
27
|
-
def plain_cli():
|
|
32
|
+
def plain_cli() -> None:
|
|
28
33
|
pass
|
|
29
34
|
|
|
30
35
|
|
|
31
|
-
plain_cli.add_command(agent)
|
|
32
36
|
plain_cli.add_command(docs)
|
|
37
|
+
plain_cli.add_command(request)
|
|
38
|
+
plain_cli.add_command(agent)
|
|
33
39
|
plain_cli.add_command(preflight_cli)
|
|
34
40
|
plain_cli.add_command(create)
|
|
35
41
|
plain_cli.add_command(chores)
|
|
@@ -37,11 +43,12 @@ plain_cli.add_command(build)
|
|
|
37
43
|
plain_cli.add_command(utils)
|
|
38
44
|
plain_cli.add_command(urls)
|
|
39
45
|
plain_cli.add_command(changelog)
|
|
40
|
-
plain_cli.add_command(
|
|
46
|
+
plain_cli.add_command(settings)
|
|
41
47
|
plain_cli.add_command(shell)
|
|
42
48
|
plain_cli.add_command(run)
|
|
43
49
|
plain_cli.add_command(install)
|
|
44
50
|
plain_cli.add_command(upgrade)
|
|
51
|
+
plain_cli.add_command(server)
|
|
45
52
|
|
|
46
53
|
|
|
47
54
|
class CLIRegistryGroup(click.Group):
|
|
@@ -49,42 +56,49 @@ class CLIRegistryGroup(click.Group):
|
|
|
49
56
|
Click Group that exposes commands from the CLI registry.
|
|
50
57
|
"""
|
|
51
58
|
|
|
52
|
-
def __init__(self, *args, **kwargs):
|
|
59
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
53
60
|
super().__init__(*args, **kwargs)
|
|
54
61
|
cli_registry.import_modules()
|
|
55
62
|
|
|
56
|
-
def list_commands(self, ctx):
|
|
63
|
+
def list_commands(self, ctx: Context) -> list[str]:
|
|
57
64
|
return sorted(cli_registry.get_commands().keys())
|
|
58
65
|
|
|
59
|
-
def get_command(self, ctx,
|
|
66
|
+
def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
|
|
60
67
|
commands = cli_registry.get_commands()
|
|
61
|
-
return commands.get(
|
|
68
|
+
return commands.get(cmd_name)
|
|
62
69
|
|
|
63
70
|
|
|
64
71
|
class PlainCommandCollection(click.CommandCollection):
|
|
65
72
|
context_class = PlainContext
|
|
66
73
|
|
|
67
|
-
def __init__(self, *args, **kwargs):
|
|
68
|
-
|
|
74
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
75
|
+
# Start with only built-in commands (no setup needed)
|
|
76
|
+
sources = [plain_cli]
|
|
77
|
+
|
|
78
|
+
super().__init__(*args, **kwargs)
|
|
79
|
+
self.sources = sources
|
|
80
|
+
self._registry_group = None
|
|
81
|
+
self._setup_attempted = False
|
|
82
|
+
|
|
83
|
+
def _ensure_registry_loaded(self) -> None:
|
|
84
|
+
"""Lazy load the registry group (requires setup)."""
|
|
85
|
+
if self._registry_group is not None or self._setup_attempted:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
self._setup_attempted = True
|
|
69
89
|
|
|
70
90
|
try:
|
|
71
91
|
plain.runtime.setup()
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
plain_cli,
|
|
76
|
-
]
|
|
92
|
+
self._registry_group = CLIRegistryGroup()
|
|
93
|
+
# Add registry group to sources
|
|
94
|
+
self.sources.insert(0, self._registry_group)
|
|
77
95
|
except plain.runtime.AppPathNotFound:
|
|
78
|
-
# Allow
|
|
96
|
+
# Allow built-in commands to work regardless of being in a valid app
|
|
79
97
|
click.secho(
|
|
80
98
|
"Plain `app` directory not found. Some commands may be missing.",
|
|
81
99
|
fg="yellow",
|
|
82
100
|
err=True,
|
|
83
101
|
)
|
|
84
|
-
|
|
85
|
-
sources = [
|
|
86
|
-
plain_cli,
|
|
87
|
-
]
|
|
88
102
|
except ImproperlyConfigured as e:
|
|
89
103
|
# Show what was configured incorrectly and exit
|
|
90
104
|
click.secho(
|
|
@@ -92,7 +106,6 @@ class PlainCommandCollection(click.CommandCollection):
|
|
|
92
106
|
fg="red",
|
|
93
107
|
err=True,
|
|
94
108
|
)
|
|
95
|
-
|
|
96
109
|
exit(1)
|
|
97
110
|
except Exception as e:
|
|
98
111
|
# Show the exception and exit
|
|
@@ -105,19 +118,90 @@ class PlainCommandCollection(click.CommandCollection):
|
|
|
105
118
|
fg="red",
|
|
106
119
|
err=True,
|
|
107
120
|
)
|
|
108
|
-
|
|
109
121
|
exit(1)
|
|
110
122
|
|
|
111
|
-
super().__init__(*args, **kwargs)
|
|
112
|
-
|
|
113
|
-
self.sources = sources
|
|
114
|
-
|
|
115
123
|
def get_command(self, ctx: Context, cmd_name: str) -> Command | None:
|
|
124
|
+
# Try built-in commands first
|
|
116
125
|
cmd = super().get_command(ctx, cmd_name)
|
|
126
|
+
|
|
127
|
+
if cmd is None:
|
|
128
|
+
# Command not found in built-ins, try registry (requires setup)
|
|
129
|
+
self._ensure_registry_loaded()
|
|
130
|
+
cmd = super().get_command(ctx, cmd_name)
|
|
131
|
+
elif not getattr(cmd, "without_runtime_setup", False):
|
|
132
|
+
# Command found but needs setup - ensure registry is loaded
|
|
133
|
+
self._ensure_registry_loaded()
|
|
134
|
+
|
|
117
135
|
if cmd:
|
|
118
136
|
# Pass the formatting down to subcommands automatically
|
|
119
137
|
cmd.context_class = self.context_class
|
|
120
138
|
return cmd
|
|
121
139
|
|
|
140
|
+
def list_commands(self, ctx: Context) -> list[str]:
|
|
141
|
+
# For help listing, we need to show registry commands too
|
|
142
|
+
self._ensure_registry_loaded()
|
|
143
|
+
return super().list_commands(ctx)
|
|
144
|
+
|
|
145
|
+
def format_commands(self, ctx: Context, formatter: Any) -> None:
|
|
146
|
+
"""Format commands with separate sections for common, core, and package commands."""
|
|
147
|
+
self._ensure_registry_loaded()
|
|
148
|
+
|
|
149
|
+
# Get all commands from both sources, tracking their source
|
|
150
|
+
commands = []
|
|
151
|
+
for source_index, source in enumerate(self.sources):
|
|
152
|
+
for name in source.list_commands(ctx):
|
|
153
|
+
cmd = source.get_command(ctx, name)
|
|
154
|
+
if cmd is not None:
|
|
155
|
+
# source_index 0 = plain_cli (core), 1+ = registry (packages)
|
|
156
|
+
commands.append((name, cmd, source_index))
|
|
157
|
+
|
|
158
|
+
if not commands:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Get metadata from the registry (for shortcuts)
|
|
162
|
+
shortcuts_metadata = cli_registry.get_shortcuts()
|
|
163
|
+
|
|
164
|
+
# Separate commands into common, core, and package
|
|
165
|
+
common_commands = []
|
|
166
|
+
core_commands = []
|
|
167
|
+
package_commands = []
|
|
168
|
+
|
|
169
|
+
for name, cmd, source_index in commands:
|
|
170
|
+
help_text = cmd.get_short_help_str(limit=200)
|
|
171
|
+
|
|
172
|
+
# Check if command is marked as common via decorator
|
|
173
|
+
is_common = getattr(cmd, "is_common_command", False)
|
|
174
|
+
|
|
175
|
+
if is_common:
|
|
176
|
+
# This is a common command
|
|
177
|
+
# Add arrow notation if it's also a shortcut
|
|
178
|
+
if name in shortcuts_metadata:
|
|
179
|
+
shortcut_for = shortcuts_metadata[name].shortcut_for
|
|
180
|
+
if shortcut_for:
|
|
181
|
+
alias_info = click.style(f"(→ {shortcut_for})", italic=True)
|
|
182
|
+
help_text = f"{help_text} {alias_info}"
|
|
183
|
+
common_commands.append((name, help_text))
|
|
184
|
+
elif source_index == 0:
|
|
185
|
+
# Package command (from registry, inserted at index 0)
|
|
186
|
+
package_commands.append((name, help_text))
|
|
187
|
+
else:
|
|
188
|
+
# Core command (from plain_cli, at index 1)
|
|
189
|
+
core_commands.append((name, help_text))
|
|
190
|
+
|
|
191
|
+
# Write common commands section if any exist
|
|
192
|
+
if common_commands:
|
|
193
|
+
with formatter.section("Common Commands"):
|
|
194
|
+
formatter.write_dl(sorted(common_commands))
|
|
195
|
+
|
|
196
|
+
# Write core commands section if any exist
|
|
197
|
+
if core_commands:
|
|
198
|
+
with formatter.section("Core Commands"):
|
|
199
|
+
formatter.write_dl(sorted(core_commands))
|
|
200
|
+
|
|
201
|
+
# Write package commands section if any exist
|
|
202
|
+
if package_commands:
|
|
203
|
+
with formatter.section("Package Commands"):
|
|
204
|
+
formatter.write_dl(sorted(package_commands))
|
|
205
|
+
|
|
122
206
|
|
|
123
207
|
cli = PlainCommandCollection()
|
plain/cli/docs.py
CHANGED
|
@@ -1,38 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import importlib.util
|
|
2
4
|
from pathlib import Path
|
|
3
5
|
|
|
4
6
|
import click
|
|
5
7
|
|
|
6
|
-
from .
|
|
8
|
+
from .llmdocs import LLMDocs
|
|
9
|
+
|
|
10
|
+
# All known official Plain packages: pip name -> short description
|
|
11
|
+
KNOWN_PACKAGES = {
|
|
12
|
+
"plain": "Web framework core",
|
|
13
|
+
"plain-admin": "Backend admin interface",
|
|
14
|
+
"plain-api": "Class-based API views",
|
|
15
|
+
"plain-auth": "User authentication and authorization",
|
|
16
|
+
"plain-cache": "Database-backed cache with optional expiration",
|
|
17
|
+
"plain-code": "Preconfigured code formatting and linting",
|
|
18
|
+
"plain-dev": "Local development server with auto-reload",
|
|
19
|
+
"plain-elements": "HTML template components",
|
|
20
|
+
"plain-email": "Send email",
|
|
21
|
+
"plain-esbuild": "Build JavaScript with esbuild",
|
|
22
|
+
"plain-flags": "Feature flags via database models",
|
|
23
|
+
"plain-htmx": "HTMX integration for templates and views",
|
|
24
|
+
"plain-jobs": "Background jobs with a database-driven queue",
|
|
25
|
+
"plain-loginlink": "Link-based authentication",
|
|
26
|
+
"plain-models": "Model data and store it in a database",
|
|
27
|
+
"plain-oauth": "OAuth provider login",
|
|
28
|
+
"plain-observer": "On-page telemetry and observability",
|
|
29
|
+
"plain-pages": "Serve static pages, markdown, and assets",
|
|
30
|
+
"plain-pageviews": "Client-side pageview tracking",
|
|
31
|
+
"plain-passwords": "Password authentication",
|
|
32
|
+
"plain-pytest": "Test with pytest",
|
|
33
|
+
"plain-redirection": "URL redirection with admin and logging",
|
|
34
|
+
"plain-scan": "Test for production best practices",
|
|
35
|
+
"plain-sessions": "Database-backed sessions",
|
|
36
|
+
"plain-start": "Bootstrap a new project from templates",
|
|
37
|
+
"plain-support": "Support forms for your application",
|
|
38
|
+
"plain-tailwind": "Tailwind CSS without JavaScript or npm",
|
|
39
|
+
"plain-toolbar": "Debug toolbar",
|
|
40
|
+
"plain-tunnel": "Remote access to local dev server",
|
|
41
|
+
"plain-vendor": "Vendor CDN scripts and styles",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _normalize_module(module: str) -> str:
|
|
46
|
+
"""Normalize a module string to dotted form (e.g. plain-models -> plain.models)."""
|
|
47
|
+
module = module.replace("-", ".")
|
|
48
|
+
if not module.startswith("plain"):
|
|
49
|
+
module = f"plain.{module}"
|
|
50
|
+
return module
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _pip_package_name(module: str) -> str:
|
|
54
|
+
"""Convert a dotted module name to a pip package name (e.g. plain.models -> plain-models)."""
|
|
55
|
+
return module.replace(".", "-")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_installed(module: str) -> bool:
|
|
59
|
+
"""Check if a dotted module name is installed."""
|
|
60
|
+
try:
|
|
61
|
+
return importlib.util.find_spec(module) is not None
|
|
62
|
+
except (ModuleNotFoundError, ValueError):
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _online_docs_url(pip_name: str) -> str:
|
|
67
|
+
"""Return the online documentation URL for a package."""
|
|
68
|
+
module = pip_name.replace("-", ".")
|
|
69
|
+
return f"https://plainframework.com/docs/{pip_name}/{module.replace('.', '/')}/"
|
|
7
70
|
|
|
8
71
|
|
|
9
72
|
@click.command()
|
|
10
|
-
@click.option("--
|
|
73
|
+
@click.option("--symbols", is_flag=True, help="Show symbolicated API surface only")
|
|
74
|
+
@click.option("--list", "show_list", is_flag=True, help="List available packages")
|
|
11
75
|
@click.argument("module", default="")
|
|
12
|
-
def docs(module,
|
|
76
|
+
def docs(module: str, symbols: bool, show_list: bool) -> None:
|
|
77
|
+
"""Show documentation for a package"""
|
|
78
|
+
if show_list:
|
|
79
|
+
for pip_name in sorted(KNOWN_PACKAGES):
|
|
80
|
+
description = KNOWN_PACKAGES[pip_name]
|
|
81
|
+
dotted = pip_name.replace("-", ".")
|
|
82
|
+
installed = _is_installed(dotted)
|
|
83
|
+
status = " (installed)" if installed else ""
|
|
84
|
+
click.echo(f" {pip_name}{status} — {description}")
|
|
85
|
+
return
|
|
86
|
+
|
|
13
87
|
if not module:
|
|
14
88
|
raise click.UsageError(
|
|
15
|
-
"You must specify a module.
|
|
89
|
+
"You must specify a module. Use --list to see available packages."
|
|
16
90
|
)
|
|
17
91
|
|
|
18
|
-
|
|
19
|
-
module = module.replace("-", ".")
|
|
92
|
+
module = _normalize_module(module)
|
|
20
93
|
|
|
21
|
-
#
|
|
22
|
-
if not module.startswith("plain"):
|
|
23
|
-
module = f"plain.{module}"
|
|
24
|
-
|
|
25
|
-
# Get the README.md file for the module
|
|
94
|
+
# Get the module path
|
|
26
95
|
spec = importlib.util.find_spec(module)
|
|
27
|
-
if not spec:
|
|
28
|
-
|
|
96
|
+
if not spec or not spec.origin:
|
|
97
|
+
pip_name = _pip_package_name(module)
|
|
98
|
+
if pip_name in KNOWN_PACKAGES:
|
|
99
|
+
msg = (
|
|
100
|
+
f"{module} is not installed.\n\n"
|
|
101
|
+
f" Online docs: {_online_docs_url(pip_name)}"
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
msg = f"Module {module} not found. Use --list to see available packages."
|
|
105
|
+
raise click.UsageError(msg)
|
|
29
106
|
|
|
30
107
|
module_path = Path(spec.origin).parent
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
108
|
+
|
|
109
|
+
llm_docs = LLMDocs([module_path])
|
|
110
|
+
llm_docs.load()
|
|
111
|
+
llm_docs.print(
|
|
112
|
+
relative_to=module_path.parent,
|
|
113
|
+
include_docs=not symbols,
|
|
114
|
+
include_symbols=symbols,
|
|
115
|
+
)
|
plain/cli/formatting.py
CHANGED
|
@@ -1,24 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import os
|
|
4
|
+
from typing import Any
|
|
2
5
|
|
|
3
6
|
import click
|
|
4
7
|
from click.formatting import iter_rows, measure_table, term_len, wrap_text
|
|
5
8
|
|
|
6
9
|
|
|
7
10
|
class PlainHelpFormatter(click.HelpFormatter):
|
|
8
|
-
def write_heading(self, heading):
|
|
9
|
-
styled_heading = click.style(heading,
|
|
11
|
+
def write_heading(self, heading: str) -> None:
|
|
12
|
+
styled_heading = click.style(heading, dim=True)
|
|
10
13
|
self.write(f"{'':>{self.current_indent}}{styled_heading}\n")
|
|
11
14
|
|
|
12
|
-
def write_usage(
|
|
13
|
-
|
|
15
|
+
def write_usage( # type: ignore[override]
|
|
16
|
+
self, prog: str, args: str = "", prefix: str = "Usage: "
|
|
17
|
+
) -> None:
|
|
18
|
+
prefix_styled = click.style(prefix, dim=True)
|
|
14
19
|
super().write_usage(prog, args, prefix=prefix_styled)
|
|
15
20
|
|
|
16
|
-
def write_dl(
|
|
21
|
+
def write_dl( # type: ignore[override]
|
|
17
22
|
self,
|
|
18
|
-
rows,
|
|
19
|
-
col_max=
|
|
20
|
-
col_spacing=2,
|
|
21
|
-
):
|
|
23
|
+
rows: list[tuple[str, str]],
|
|
24
|
+
col_max: int = 20,
|
|
25
|
+
col_spacing: int = 2,
|
|
26
|
+
) -> None:
|
|
22
27
|
"""Writes a definition list into the buffer. This is how options
|
|
23
28
|
and commands are usually formatted.
|
|
24
29
|
|
|
@@ -51,10 +56,15 @@ class PlainHelpFormatter(click.HelpFormatter):
|
|
|
51
56
|
lines = wrapped_text.splitlines()
|
|
52
57
|
|
|
53
58
|
if lines:
|
|
54
|
-
|
|
59
|
+
# Dim the description text
|
|
60
|
+
first_line_styled = click.style(lines[0], dim=True)
|
|
61
|
+
self.write(f"{first_line_styled}\n")
|
|
55
62
|
|
|
56
63
|
for line in lines[1:]:
|
|
57
|
-
|
|
64
|
+
line_styled = click.style(line, dim=True)
|
|
65
|
+
self.write(
|
|
66
|
+
f"{'':>{first_col + self.current_indent}}{line_styled}\n"
|
|
67
|
+
)
|
|
58
68
|
else:
|
|
59
69
|
self.write("\n")
|
|
60
70
|
|
|
@@ -62,12 +72,25 @@ class PlainHelpFormatter(click.HelpFormatter):
|
|
|
62
72
|
class PlainContext(click.Context):
|
|
63
73
|
formatter_class = PlainHelpFormatter
|
|
64
74
|
|
|
65
|
-
def __init__(self, *args, **kwargs):
|
|
75
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
76
|
+
# Set a wider max_content_width for help text (default is 80)
|
|
77
|
+
# This allows descriptions to fit more comfortably on one line
|
|
78
|
+
if "max_content_width" not in kwargs:
|
|
79
|
+
kwargs["max_content_width"] = 140
|
|
80
|
+
|
|
66
81
|
super().__init__(*args, **kwargs)
|
|
67
82
|
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
# Follow CLICOLOR standard (http://bixense.com/clicolors/)
|
|
84
|
+
# Priority: NO_COLOR > CLICOLOR_FORCE/FORCE_COLOR > CI detection > CLICOLOR > isatty
|
|
85
|
+
if os.getenv("NO_COLOR") or os.getenv("PYTEST_CURRENT_TEST"):
|
|
86
|
+
self.color = False
|
|
87
|
+
elif os.getenv("CLICOLOR_FORCE") or os.getenv("FORCE_COLOR"):
|
|
88
|
+
self.color = True
|
|
89
|
+
elif os.getenv("CI"):
|
|
90
|
+
# Enable colors in CI/deployment environments even without TTY
|
|
91
|
+
# This matches behavior of modern tools like uv (via Rust's anstyle)
|
|
73
92
|
self.color = True
|
|
93
|
+
elif os.getenv("CLICOLOR"):
|
|
94
|
+
# CLICOLOR=1 means use colors only if TTY (Click's default behavior)
|
|
95
|
+
pass # Let Click handle it with isatty check
|
|
96
|
+
# Otherwise use Click's default behavior (isatty check)
|