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
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,18 +1,51 @@
|
|
|
1
1
|
import importlib.util
|
|
2
|
+
import pkgutil
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
|
|
4
5
|
import click
|
|
5
6
|
|
|
7
|
+
from .llmdocs import LLMDocs
|
|
6
8
|
from .output import iterate_markdown
|
|
7
9
|
|
|
8
10
|
|
|
9
11
|
@click.command()
|
|
10
|
-
@click.option("--open")
|
|
12
|
+
@click.option("--open", is_flag=True, help="Open the README in your default editor")
|
|
13
|
+
@click.option("--source", is_flag=True, help="Include symbolicated source code")
|
|
14
|
+
@click.option("--list", "show_list", is_flag=True, help="List available packages")
|
|
11
15
|
@click.argument("module", default="")
|
|
12
|
-
def docs(module, open):
|
|
16
|
+
def docs(module: str, open: bool, source: bool, show_list: bool) -> None:
|
|
17
|
+
"""Show documentation for a package"""
|
|
18
|
+
if show_list:
|
|
19
|
+
# List available packages
|
|
20
|
+
available_packages = []
|
|
21
|
+
try:
|
|
22
|
+
import plain
|
|
23
|
+
|
|
24
|
+
# Check core plain package (namespace package)
|
|
25
|
+
plain_spec = importlib.util.find_spec("plain")
|
|
26
|
+
if plain_spec and plain_spec.submodule_search_locations:
|
|
27
|
+
available_packages.append("plain")
|
|
28
|
+
|
|
29
|
+
# Check other plain.* subpackages
|
|
30
|
+
if hasattr(plain, "__path__"):
|
|
31
|
+
for importer, modname, ispkg in pkgutil.iter_modules(
|
|
32
|
+
plain.__path__, "plain."
|
|
33
|
+
):
|
|
34
|
+
if ispkg:
|
|
35
|
+
available_packages.append(modname)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
if available_packages:
|
|
40
|
+
for pkg in sorted(available_packages):
|
|
41
|
+
click.echo(f"- {pkg}")
|
|
42
|
+
else:
|
|
43
|
+
click.echo("No packages found.")
|
|
44
|
+
return
|
|
45
|
+
|
|
13
46
|
if not module:
|
|
14
47
|
raise click.UsageError(
|
|
15
|
-
"You must specify a module.
|
|
48
|
+
"You must specify a module. Use --list to see available packages."
|
|
16
49
|
)
|
|
17
50
|
|
|
18
51
|
# Convert hyphens to dots (e.g., plain-models -> plain.models)
|
|
@@ -22,17 +55,25 @@ def docs(module, open):
|
|
|
22
55
|
if not module.startswith("plain"):
|
|
23
56
|
module = f"plain.{module}"
|
|
24
57
|
|
|
25
|
-
# Get the
|
|
58
|
+
# Get the module path
|
|
26
59
|
spec = importlib.util.find_spec(module)
|
|
27
|
-
if not spec:
|
|
60
|
+
if not spec or not spec.origin:
|
|
28
61
|
raise click.UsageError(f"Module {module} not found")
|
|
29
62
|
|
|
30
63
|
module_path = Path(spec.origin).parent
|
|
31
|
-
readme_path = module_path / "README.md"
|
|
32
|
-
if not readme_path.exists():
|
|
33
|
-
raise click.UsageError(f"README.md not found for {module}")
|
|
34
64
|
|
|
35
|
-
if
|
|
36
|
-
|
|
65
|
+
if source:
|
|
66
|
+
# Output with symbolicated source
|
|
67
|
+
source_docs = LLMDocs([module_path])
|
|
68
|
+
source_docs.load()
|
|
69
|
+
source_docs.print(relative_to=module_path.parent)
|
|
37
70
|
else:
|
|
38
|
-
|
|
71
|
+
# Human-readable README output
|
|
72
|
+
readme_path = module_path / "README.md"
|
|
73
|
+
if not readme_path.exists():
|
|
74
|
+
raise click.UsageError(f"README.md not found for {module}")
|
|
75
|
+
|
|
76
|
+
if open:
|
|
77
|
+
click.launch(str(readme_path))
|
|
78
|
+
else:
|
|
79
|
+
click.echo_via_pager(iterate_markdown(readme_path.read_text()))
|
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)
|
plain/cli/install.py
CHANGED
|
@@ -3,28 +3,11 @@ import sys
|
|
|
3
3
|
|
|
4
4
|
import click
|
|
5
5
|
|
|
6
|
-
from .agent.prompt import prompt_agent
|
|
7
|
-
|
|
8
6
|
|
|
9
7
|
@click.command()
|
|
10
8
|
@click.argument("packages", nargs=-1, required=True)
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
envvar="PLAIN_AGENT_COMMAND",
|
|
14
|
-
help="Run command with generated prompt",
|
|
15
|
-
)
|
|
16
|
-
@click.option(
|
|
17
|
-
"--print",
|
|
18
|
-
"print_only",
|
|
19
|
-
is_flag=True,
|
|
20
|
-
help="Print the prompt without running the agent",
|
|
21
|
-
)
|
|
22
|
-
def install(
|
|
23
|
-
packages: tuple[str, ...],
|
|
24
|
-
agent_command: str | None = None,
|
|
25
|
-
print_only: bool = False,
|
|
26
|
-
) -> None:
|
|
27
|
-
"""Install Plain packages with the help of an agent."""
|
|
9
|
+
def install(packages: tuple[str, ...]) -> None:
|
|
10
|
+
"""Install Plain packages"""
|
|
28
11
|
# Validate all package names
|
|
29
12
|
invalid_packages = [pkg for pkg in packages if not pkg.startswith("plain")]
|
|
30
13
|
if invalid_packages:
|
|
@@ -33,14 +16,14 @@ def install(
|
|
|
33
16
|
"This command is only for Plain framework packages."
|
|
34
17
|
)
|
|
35
18
|
|
|
36
|
-
# Install all packages
|
|
19
|
+
# Install all packages
|
|
37
20
|
if len(packages) == 1:
|
|
38
|
-
click.secho(f"Installing {packages[0]}...", bold=True
|
|
21
|
+
click.secho(f"Installing {packages[0]}...", bold=True)
|
|
39
22
|
else:
|
|
40
|
-
click.secho(f"Installing {len(packages)} packages...", bold=True
|
|
23
|
+
click.secho(f"Installing {len(packages)} packages...", bold=True)
|
|
41
24
|
for pkg in packages:
|
|
42
|
-
click.secho(f" - {pkg}"
|
|
43
|
-
click.echo(
|
|
25
|
+
click.secho(f" - {pkg}")
|
|
26
|
+
click.echo()
|
|
44
27
|
|
|
45
28
|
install_cmd = ["uv", "add"] + list(packages)
|
|
46
29
|
result = subprocess.run(install_cmd, check=False, stderr=sys.stderr)
|
|
@@ -48,35 +31,8 @@ def install(
|
|
|
48
31
|
if result.returncode != 0:
|
|
49
32
|
raise click.ClickException("Failed to install packages")
|
|
50
33
|
|
|
51
|
-
click.echo(
|
|
34
|
+
click.echo()
|
|
52
35
|
if len(packages) == 1:
|
|
53
|
-
click.secho(f"
|
|
36
|
+
click.secho(f"{packages[0]} installed successfully", fg="green")
|
|
54
37
|
else:
|
|
55
|
-
click.secho(
|
|
56
|
-
f"✓ {len(packages)} packages installed successfully", fg="green", err=True
|
|
57
|
-
)
|
|
58
|
-
click.echo(err=True)
|
|
59
|
-
|
|
60
|
-
# Build the prompt for the agent to complete setup
|
|
61
|
-
lines = [
|
|
62
|
-
f"Complete the setup for the following Plain packages that were just installed: {', '.join(packages)}",
|
|
63
|
-
"",
|
|
64
|
-
"## Instructions",
|
|
65
|
-
"",
|
|
66
|
-
"For each package:",
|
|
67
|
-
"1. Run `uv run plain docs <package>` and read the installation instructions",
|
|
68
|
-
"2. If the docs point out that it is a --dev tool, move it to the dev dependencies in pyproject.toml: `uv remove <package> && uv add <package> --dev`",
|
|
69
|
-
"3. Go through the installation instructions and complete any code modifications that are needed",
|
|
70
|
-
"",
|
|
71
|
-
"DO NOT commit any changes",
|
|
72
|
-
"",
|
|
73
|
-
"Report back with:",
|
|
74
|
-
"- Whether the setup completed successfully",
|
|
75
|
-
"- Any manual steps that the user will need to complete",
|
|
76
|
-
"- Any issues or errors encountered",
|
|
77
|
-
]
|
|
78
|
-
|
|
79
|
-
prompt = "\n".join(lines)
|
|
80
|
-
success = prompt_agent(prompt, agent_command, print_only)
|
|
81
|
-
if not success:
|
|
82
|
-
raise click.Abort()
|
|
38
|
+
click.secho(f"{len(packages)} packages installed successfully", fg="green")
|