plain 0.66.0__py3-none-any.whl → 0.101.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plain/CHANGELOG.md +684 -0
- plain/README.md +1 -1
- plain/assets/compile.py +25 -12
- plain/assets/finders.py +24 -17
- plain/assets/fingerprints.py +10 -7
- plain/assets/urls.py +1 -1
- plain/assets/views.py +47 -33
- plain/chores/README.md +25 -23
- plain/chores/__init__.py +2 -1
- plain/chores/core.py +27 -0
- plain/chores/registry.py +23 -53
- plain/cli/README.md +185 -16
- plain/cli/__init__.py +2 -1
- plain/cli/agent.py +236 -0
- plain/cli/build.py +7 -8
- plain/cli/changelog.py +11 -5
- plain/cli/chores.py +32 -34
- plain/cli/core.py +112 -28
- plain/cli/docs.py +52 -11
- plain/cli/formatting.py +40 -17
- plain/cli/install.py +10 -54
- plain/cli/{agent/llmdocs.py → llmdocs.py} +21 -9
- plain/cli/output.py +6 -2
- plain/cli/preflight.py +175 -102
- plain/cli/print.py +4 -4
- plain/cli/registry.py +95 -26
- plain/cli/request.py +206 -0
- plain/cli/runtime.py +45 -0
- plain/cli/scaffold.py +2 -7
- plain/cli/server.py +153 -0
- plain/cli/settings.py +53 -49
- plain/cli/shell.py +15 -12
- plain/cli/startup.py +9 -8
- plain/cli/upgrade.py +17 -104
- plain/cli/urls.py +12 -7
- plain/cli/utils.py +3 -3
- plain/csrf/README.md +65 -40
- plain/csrf/middleware.py +53 -43
- plain/debug.py +5 -2
- plain/exceptions.py +22 -114
- plain/forms/README.md +453 -24
- plain/forms/__init__.py +55 -4
- plain/forms/boundfield.py +15 -8
- plain/forms/exceptions.py +1 -1
- plain/forms/fields.py +346 -143
- plain/forms/forms.py +75 -45
- plain/http/README.md +356 -9
- plain/http/__init__.py +41 -26
- plain/http/cookie.py +15 -7
- plain/http/exceptions.py +65 -0
- plain/http/middleware.py +32 -0
- plain/http/multipartparser.py +99 -88
- plain/http/request.py +362 -250
- plain/http/response.py +99 -197
- plain/internal/__init__.py +8 -1
- plain/internal/files/base.py +35 -19
- plain/internal/files/locks.py +19 -11
- plain/internal/files/move.py +8 -3
- plain/internal/files/temp.py +25 -6
- plain/internal/files/uploadedfile.py +47 -28
- plain/internal/files/uploadhandler.py +64 -58
- plain/internal/files/utils.py +24 -10
- plain/internal/handlers/base.py +34 -23
- plain/internal/handlers/exception.py +68 -65
- plain/internal/handlers/wsgi.py +65 -54
- plain/internal/middleware/headers.py +37 -11
- plain/internal/middleware/hosts.py +11 -13
- plain/internal/middleware/https.py +17 -7
- plain/internal/middleware/slash.py +14 -9
- plain/internal/reloader.py +77 -0
- plain/json.py +2 -1
- plain/logs/README.md +161 -62
- plain/logs/__init__.py +1 -1
- plain/logs/{loggers.py → app.py} +71 -67
- plain/logs/configure.py +63 -14
- plain/logs/debug.py +17 -6
- plain/logs/filters.py +15 -0
- plain/logs/formatters.py +7 -4
- plain/packages/README.md +105 -23
- plain/packages/config.py +15 -7
- plain/packages/registry.py +40 -15
- plain/paginator.py +31 -21
- plain/preflight/README.md +208 -23
- plain/preflight/__init__.py +5 -24
- plain/preflight/checks.py +12 -0
- plain/preflight/files.py +19 -13
- plain/preflight/registry.py +80 -58
- plain/preflight/results.py +37 -0
- plain/preflight/security.py +65 -71
- plain/preflight/settings.py +54 -0
- plain/preflight/urls.py +10 -48
- plain/runtime/README.md +115 -47
- plain/runtime/__init__.py +10 -6
- plain/runtime/global_settings.py +43 -33
- plain/runtime/secret.py +20 -0
- plain/runtime/user_settings.py +110 -38
- plain/runtime/utils.py +1 -1
- plain/server/LICENSE +35 -0
- plain/server/README.md +155 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +155 -0
- plain/server/http/message.py +400 -0
- plain/server/http/parser.py +70 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +92 -0
- plain/server/sock.py +240 -0
- plain/server/util.py +317 -0
- plain/server/workers/__init__.py +6 -0
- plain/server/workers/base.py +304 -0
- plain/server/workers/sync.py +212 -0
- plain/server/workers/thread.py +399 -0
- plain/server/workers/workertmp.py +50 -0
- plain/signals/README.md +170 -1
- plain/signals/__init__.py +0 -1
- plain/signals/dispatch/dispatcher.py +49 -27
- plain/signing.py +131 -35
- plain/skills/README.md +36 -0
- plain/skills/plain-docs/SKILL.md +25 -0
- plain/skills/plain-install/SKILL.md +26 -0
- plain/skills/plain-request/SKILL.md +39 -0
- plain/skills/plain-shell/SKILL.md +24 -0
- plain/skills/plain-upgrade/SKILL.md +35 -0
- plain/templates/README.md +211 -20
- plain/templates/jinja/__init__.py +14 -27
- plain/templates/jinja/environments.py +5 -4
- plain/templates/jinja/extensions.py +12 -5
- plain/templates/jinja/filters.py +7 -2
- plain/templates/jinja/globals.py +2 -2
- plain/test/README.md +184 -22
- plain/test/client.py +340 -222
- plain/test/encoding.py +9 -6
- plain/test/exceptions.py +7 -2
- plain/urls/README.md +157 -73
- plain/urls/converters.py +18 -15
- plain/urls/exceptions.py +2 -2
- plain/urls/patterns.py +56 -40
- plain/urls/resolvers.py +38 -28
- plain/urls/utils.py +5 -1
- plain/utils/README.md +250 -3
- plain/utils/cache.py +17 -11
- plain/utils/crypto.py +21 -5
- plain/utils/datastructures.py +89 -56
- plain/utils/dateparse.py +9 -6
- plain/utils/deconstruct.py +15 -7
- plain/utils/decorators.py +5 -1
- plain/utils/dotenv.py +373 -0
- plain/utils/duration.py +8 -4
- plain/utils/encoding.py +14 -7
- plain/utils/functional.py +66 -49
- plain/utils/hashable.py +5 -1
- plain/utils/html.py +36 -22
- plain/utils/http.py +16 -9
- plain/utils/inspect.py +14 -6
- plain/utils/ipv6.py +7 -3
- plain/utils/itercompat.py +6 -1
- plain/utils/module_loading.py +7 -3
- plain/utils/regex_helper.py +37 -23
- plain/utils/safestring.py +14 -6
- plain/utils/text.py +41 -23
- plain/utils/timezone.py +33 -22
- plain/utils/tree.py +35 -19
- plain/validators.py +94 -52
- plain/views/README.md +156 -79
- plain/views/__init__.py +0 -1
- plain/views/base.py +25 -18
- plain/views/errors.py +13 -5
- plain/views/exceptions.py +4 -1
- plain/views/forms.py +6 -6
- plain/views/objects.py +52 -49
- plain/views/redirect.py +18 -15
- plain/views/templates.py +5 -3
- plain/wsgi.py +3 -1
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/METADATA +4 -2
- plain-0.101.2.dist-info/RECORD +201 -0
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/WHEEL +1 -1
- plain-0.101.2.dist-info/entry_points.txt +2 -0
- plain/AGENTS.md +0 -18
- plain/cli/agent/__init__.py +0 -20
- plain/cli/agent/docs.py +0 -80
- plain/cli/agent/md.py +0 -87
- plain/cli/agent/prompt.py +0 -45
- plain/cli/agent/request.py +0 -181
- plain/csrf/views.py +0 -31
- plain/logs/utils.py +0 -46
- plain/preflight/messages.py +0 -81
- plain/templates/AGENTS.md +0 -3
- plain-0.66.0.dist-info/RECORD +0 -168
- plain-0.66.0.dist-info/entry_points.txt +0 -4
- {plain-0.66.0.dist-info → plain-0.101.2.dist-info}/licenses/LICENSE +0 -0
plain/chores/registry.py
CHANGED
|
@@ -1,59 +1,33 @@
|
|
|
1
|
-
from
|
|
2
|
-
from importlib.util import find_spec
|
|
1
|
+
from __future__ import annotations
|
|
3
2
|
|
|
4
3
|
from plain.packages import packages_registry
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
class Chore:
|
|
8
|
-
def __init__(self, *, group, func):
|
|
9
|
-
self.group = group
|
|
10
|
-
self.func = func
|
|
11
|
-
self.name = f"{group}.{func.__name__}"
|
|
12
|
-
self.description = func.__doc__.strip() if func.__doc__ else ""
|
|
13
|
-
|
|
14
|
-
def __str__(self):
|
|
15
|
-
return self.name
|
|
16
|
-
|
|
17
|
-
def run(self):
|
|
18
|
-
"""
|
|
19
|
-
Run the chore.
|
|
20
|
-
"""
|
|
21
|
-
return self.func()
|
|
5
|
+
from .core import Chore
|
|
22
6
|
|
|
23
7
|
|
|
24
8
|
class ChoresRegistry:
|
|
25
|
-
def __init__(self):
|
|
26
|
-
self._chores = {}
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._chores: dict[str, type[Chore]] = {}
|
|
27
11
|
|
|
28
|
-
def register_chore(self,
|
|
12
|
+
def register_chore(self, chore_class: type[Chore]) -> None:
|
|
29
13
|
"""
|
|
30
|
-
Register a chore
|
|
14
|
+
Register a chore class.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
chore_class: A Chore subclass to register
|
|
31
18
|
"""
|
|
32
|
-
|
|
19
|
+
name = f"{chore_class.__module__}.{chore_class.__qualname__}"
|
|
20
|
+
self._chores[name] = chore_class
|
|
33
21
|
|
|
34
|
-
def import_modules(self):
|
|
22
|
+
def import_modules(self) -> None:
|
|
35
23
|
"""
|
|
36
24
|
Import modules from installed packages and app to trigger registration.
|
|
37
25
|
"""
|
|
38
|
-
|
|
39
|
-
for package_config in packages_registry.get_package_configs():
|
|
40
|
-
import_name = f"{package_config.name}.chores"
|
|
41
|
-
try:
|
|
42
|
-
import_module(import_name)
|
|
43
|
-
except ModuleNotFoundError:
|
|
44
|
-
pass
|
|
45
|
-
|
|
46
|
-
# Import from app
|
|
47
|
-
import_name = "app.chores"
|
|
48
|
-
if find_spec(import_name):
|
|
49
|
-
try:
|
|
50
|
-
import_module(import_name)
|
|
51
|
-
except ModuleNotFoundError:
|
|
52
|
-
pass
|
|
26
|
+
packages_registry.autodiscover_modules("chores", include_app=True)
|
|
53
27
|
|
|
54
|
-
def get_chores(self):
|
|
28
|
+
def get_chores(self) -> list[type[Chore]]:
|
|
55
29
|
"""
|
|
56
|
-
Get all registered
|
|
30
|
+
Get all registered chore classes.
|
|
57
31
|
"""
|
|
58
32
|
return list(self._chores.values())
|
|
59
33
|
|
|
@@ -61,19 +35,15 @@ class ChoresRegistry:
|
|
|
61
35
|
chores_registry = ChoresRegistry()
|
|
62
36
|
|
|
63
37
|
|
|
64
|
-
def register_chore(
|
|
38
|
+
def register_chore(cls: type[Chore]) -> type[Chore]:
|
|
65
39
|
"""
|
|
66
|
-
|
|
40
|
+
Decorator to register a chore class.
|
|
67
41
|
|
|
68
42
|
Usage:
|
|
69
|
-
@register_chore
|
|
70
|
-
|
|
71
|
-
|
|
43
|
+
@register_chore
|
|
44
|
+
class ClearExpired(Chore):
|
|
45
|
+
def run(self):
|
|
46
|
+
return "Done!"
|
|
72
47
|
"""
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
chore = Chore(group=group, func=func)
|
|
76
|
-
chores_registry.register_chore(chore)
|
|
77
|
-
return func
|
|
78
|
-
|
|
79
|
-
return wrapper
|
|
48
|
+
chores_registry.register_chore(cls)
|
|
49
|
+
return cls
|
plain/cli/README.md
CHANGED
|
@@ -1,41 +1,210 @@
|
|
|
1
|
-
#
|
|
1
|
+
# plain.cli
|
|
2
2
|
|
|
3
|
-
**The `plain`
|
|
3
|
+
**The `plain` command-line interface and tools for adding custom commands.**
|
|
4
4
|
|
|
5
5
|
- [Overview](#overview)
|
|
6
6
|
- [Adding commands](#adding-commands)
|
|
7
|
+
- [Register a command group](#register-a-command-group)
|
|
8
|
+
- [Register a shortcut command](#register-a-shortcut-command)
|
|
9
|
+
- [Mark commands as common](#mark-commands-as-common)
|
|
10
|
+
- [Shell](#shell)
|
|
11
|
+
- [Run a script with app context](#run-a-script-with-app-context)
|
|
12
|
+
- [SHELL_IMPORT](#shell_import)
|
|
13
|
+
- [Built-in commands](#built-in-commands)
|
|
14
|
+
- [FAQs](#faqs)
|
|
15
|
+
- [Installation](#installation)
|
|
7
16
|
|
|
8
17
|
## Overview
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
(one of Plain's few dependencies),
|
|
12
|
-
which has been one of those most popular CLI frameworks in Python for a long time.
|
|
19
|
+
The `plain` CLI provides commands for running your app, managing databases, starting shells, and more. You can also add your own commands using the [`register_cli`](./registry.py#register_cli) decorator.
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
The [`register_cli`](./registry.py#register_cli) decorator can be used to add your own commands to the `plain` CLI.
|
|
21
|
+
Commands are written using [Click](https://click.palletsprojects.com/), a popular Python CLI framework that is one of Plain's few dependencies.
|
|
17
22
|
|
|
18
23
|
```python
|
|
19
24
|
import click
|
|
20
25
|
from plain.cli import register_cli
|
|
21
26
|
|
|
22
27
|
|
|
23
|
-
@register_cli("
|
|
28
|
+
@register_cli("hello")
|
|
29
|
+
@click.command()
|
|
30
|
+
def cli():
|
|
31
|
+
"""Say hello"""
|
|
32
|
+
click.echo("Hello from my custom command!")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
After defining this command, you can run it with `plain hello`:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
$ plain hello
|
|
39
|
+
Hello from my custom command!
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Adding commands
|
|
43
|
+
|
|
44
|
+
You can register commands from anywhere, but Plain will automatically import `cli.py` modules from your app and installed packages. The most common locations are:
|
|
45
|
+
|
|
46
|
+
- `app/cli.py` for app-specific commands
|
|
47
|
+
- `<package>/cli.py` for package-specific commands
|
|
48
|
+
|
|
49
|
+
### Register a command group
|
|
50
|
+
|
|
51
|
+
Use [`@register_cli`](./registry.py#register_cli) with a Click group to create subcommands:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
@register_cli("users")
|
|
24
55
|
@click.group()
|
|
25
56
|
def cli():
|
|
26
|
-
"""
|
|
57
|
+
"""User management commands"""
|
|
27
58
|
pass
|
|
28
59
|
|
|
60
|
+
|
|
61
|
+
@cli.command()
|
|
62
|
+
@click.argument("email")
|
|
63
|
+
def create(email):
|
|
64
|
+
"""Create a new user"""
|
|
65
|
+
click.echo(f"Creating user: {email}")
|
|
66
|
+
|
|
67
|
+
|
|
29
68
|
@cli.command()
|
|
30
|
-
def
|
|
31
|
-
|
|
69
|
+
def list():
|
|
70
|
+
"""List all users"""
|
|
71
|
+
click.echo("Listing users...")
|
|
32
72
|
```
|
|
33
73
|
|
|
34
|
-
|
|
74
|
+
This creates `plain users create` and `plain users list` commands.
|
|
75
|
+
|
|
76
|
+
### Register a shortcut command
|
|
77
|
+
|
|
78
|
+
Some commands are used frequently enough to warrant a top-level shortcut. You can indicate that a command is a shortcut for a subcommand by passing `shortcut_for`:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
@register_cli("migrate", shortcut_for="models")
|
|
82
|
+
@click.command()
|
|
83
|
+
def migrate():
|
|
84
|
+
"""Run database migrations"""
|
|
85
|
+
# ...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
This makes `plain migrate` available as a shortcut for `plain models migrate`. The shortcut relationship is shown in help output.
|
|
89
|
+
|
|
90
|
+
### Mark commands as common
|
|
91
|
+
|
|
92
|
+
Use the [`common_command`](./runtime.py#common_command) decorator to highlight frequently used commands in help output:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from plain.cli import register_cli
|
|
96
|
+
from plain.cli.runtime import common_command
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@register_cli("dev")
|
|
100
|
+
@common_command
|
|
101
|
+
@click.command()
|
|
102
|
+
def dev():
|
|
103
|
+
"""Start development server"""
|
|
104
|
+
# ...
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Common commands appear in a separate "Common Commands" section when running `plain --help`.
|
|
108
|
+
|
|
109
|
+
## Shell
|
|
110
|
+
|
|
111
|
+
The `plain shell` command starts an interactive Python shell with your Plain app already loaded.
|
|
35
112
|
|
|
36
113
|
```bash
|
|
37
|
-
$ plain
|
|
38
|
-
An example command!
|
|
114
|
+
$ plain shell
|
|
39
115
|
```
|
|
40
116
|
|
|
41
|
-
|
|
117
|
+
If you have IPython installed, it will be used automatically. You can also specify an interface explicitly:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
$ plain shell --interface ipython
|
|
121
|
+
$ plain shell --interface bpython
|
|
122
|
+
$ plain shell --interface python
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
For one-off commands, use the `-c` flag:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
$ plain shell -c "from app.users.models import User; print(User.query.count())"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Run a script with app context
|
|
132
|
+
|
|
133
|
+
The `plain run` command executes a Python script with your app context already set up:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
$ plain run scripts/import_data.py
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
This is useful for one-off scripts that need access to your models and settings.
|
|
140
|
+
|
|
141
|
+
### SHELL_IMPORT
|
|
142
|
+
|
|
143
|
+
Customize what gets imported automatically when the shell starts by setting `SHELL_IMPORT` in your settings:
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
# app/settings.py
|
|
147
|
+
SHELL_IMPORT = "app.shell"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Then create that module with the objects you want available:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
# app/shell.py
|
|
154
|
+
from app.projects.models import Project
|
|
155
|
+
from app.users.models import User
|
|
156
|
+
|
|
157
|
+
__all__ = ["Project", "User"]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Now when you run `plain shell`, those objects will be automatically imported and available.
|
|
161
|
+
|
|
162
|
+
## Built-in commands
|
|
163
|
+
|
|
164
|
+
Plain includes several built-in commands:
|
|
165
|
+
|
|
166
|
+
| Command | Description |
|
|
167
|
+
| --------------------- | ---------------------------------------- |
|
|
168
|
+
| `plain shell` | Interactive Python shell |
|
|
169
|
+
| `plain run <script>` | Execute a Python script with app context |
|
|
170
|
+
| `plain server` | Production-ready WSGI server |
|
|
171
|
+
| `plain preflight` | Validation checks before deployment |
|
|
172
|
+
| `plain create <name>` | Create a new local package |
|
|
173
|
+
| `plain settings` | View current settings |
|
|
174
|
+
| `plain urls` | List all URL patterns |
|
|
175
|
+
| `plain docs` | View package documentation |
|
|
176
|
+
| `plain build` | Run build commands |
|
|
177
|
+
| `plain install` | Install package dependencies |
|
|
178
|
+
| `plain upgrade` | Upgrade Plain packages |
|
|
179
|
+
|
|
180
|
+
Additional commands are added by installed packages (like `plain models migrate` from plain.models).
|
|
181
|
+
|
|
182
|
+
## FAQs
|
|
183
|
+
|
|
184
|
+
#### How do I run commands that don't need the app to be set up?
|
|
185
|
+
|
|
186
|
+
Use the [`without_runtime_setup`](./runtime.py#without_runtime_setup) decorator for commands that don't need access to settings or app code. This is useful for commands that fork processes (like `server`) where setup should happen in the worker process:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from plain.cli.runtime import without_runtime_setup
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@without_runtime_setup
|
|
193
|
+
@click.command()
|
|
194
|
+
def server():
|
|
195
|
+
"""Start the server"""
|
|
196
|
+
# Setup happens in the worker process, not here
|
|
197
|
+
# ...
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
#### Where should I put my custom commands?
|
|
201
|
+
|
|
202
|
+
Put app-specific commands in `app/cli.py`. Plain will automatically import this module. If you're building a reusable package, put commands in `<package>/cli.py`.
|
|
203
|
+
|
|
204
|
+
#### Can I use argparse instead of Click?
|
|
205
|
+
|
|
206
|
+
No, Plain's CLI is built on Click and the registration system expects Click commands. However, Click is well-documented and provides a better developer experience than argparse for most use cases.
|
|
207
|
+
|
|
208
|
+
## Installation
|
|
209
|
+
|
|
210
|
+
The CLI is included with Plain. No additional installation is required.
|
plain/cli/__init__.py
CHANGED
plain/cli/agent.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import pkgutil
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_packages_with_skills() -> dict[str, list[Path]]:
|
|
13
|
+
"""Get dict mapping package names to lists of skill directory paths.
|
|
14
|
+
|
|
15
|
+
Each skill is a directory containing a SKILL.md file.
|
|
16
|
+
"""
|
|
17
|
+
skills_dirs: dict[str, list[Path]] = {}
|
|
18
|
+
|
|
19
|
+
# Check for plain.* subpackages (including core plain)
|
|
20
|
+
try:
|
|
21
|
+
import plain
|
|
22
|
+
|
|
23
|
+
# Check core plain package (namespace package)
|
|
24
|
+
plain_spec = importlib.util.find_spec("plain")
|
|
25
|
+
if plain_spec and plain_spec.submodule_search_locations:
|
|
26
|
+
# For namespace packages, check all search locations
|
|
27
|
+
for location in plain_spec.submodule_search_locations:
|
|
28
|
+
plain_path = Path(location)
|
|
29
|
+
skills_dir = plain_path / "skills"
|
|
30
|
+
if skills_dir.exists() and skills_dir.is_dir():
|
|
31
|
+
# Find subdirectories that contain SKILL.md
|
|
32
|
+
skill_dirs = [
|
|
33
|
+
d
|
|
34
|
+
for d in skills_dir.iterdir()
|
|
35
|
+
if d.is_dir() and (d / "SKILL.md").exists()
|
|
36
|
+
]
|
|
37
|
+
if skill_dirs:
|
|
38
|
+
skills_dirs["plain"] = skill_dirs
|
|
39
|
+
break # Use the first one found
|
|
40
|
+
|
|
41
|
+
# Check other plain.* subpackages
|
|
42
|
+
if hasattr(plain, "__path__"):
|
|
43
|
+
for importer, modname, ispkg in pkgutil.iter_modules(
|
|
44
|
+
plain.__path__, "plain."
|
|
45
|
+
):
|
|
46
|
+
if ispkg:
|
|
47
|
+
try:
|
|
48
|
+
spec = importlib.util.find_spec(modname)
|
|
49
|
+
if spec and spec.origin:
|
|
50
|
+
package_path = Path(spec.origin).parent
|
|
51
|
+
# Look for skills/ directory at package root
|
|
52
|
+
skills_dir = package_path / "skills"
|
|
53
|
+
if skills_dir.exists() and skills_dir.is_dir():
|
|
54
|
+
# Find subdirectories that contain SKILL.md
|
|
55
|
+
skill_dirs = [
|
|
56
|
+
d
|
|
57
|
+
for d in skills_dir.iterdir()
|
|
58
|
+
if d.is_dir() and (d / "SKILL.md").exists()
|
|
59
|
+
]
|
|
60
|
+
if skill_dirs:
|
|
61
|
+
skills_dirs[modname] = skill_dirs
|
|
62
|
+
except Exception:
|
|
63
|
+
continue
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
return skills_dirs
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _get_skill_destinations() -> list[Path]:
|
|
71
|
+
"""Get list of skill directories to install to based on what's present."""
|
|
72
|
+
cwd = Path.cwd()
|
|
73
|
+
destinations = []
|
|
74
|
+
|
|
75
|
+
# Check for Claude (.claude/ directory)
|
|
76
|
+
if (cwd / ".claude").exists():
|
|
77
|
+
destinations.append(cwd / ".claude" / "skills")
|
|
78
|
+
|
|
79
|
+
# Check for Codex (.codex/ directory)
|
|
80
|
+
if (cwd / ".codex").exists():
|
|
81
|
+
destinations.append(cwd / ".codex" / "skills")
|
|
82
|
+
|
|
83
|
+
return destinations
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _install_skills_to(
|
|
87
|
+
dest_skills_dir: Path, skills_by_package: dict[str, list[Path]]
|
|
88
|
+
) -> tuple[int, int]:
|
|
89
|
+
"""Install skills to a destination directory. Returns (installed_count, removed_count)."""
|
|
90
|
+
dest_skills_dir.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
|
|
92
|
+
# Collect all source skill names
|
|
93
|
+
source_skill_names: set[str] = set()
|
|
94
|
+
for skill_dirs in skills_by_package.values():
|
|
95
|
+
for skill_dir in skill_dirs:
|
|
96
|
+
source_skill_names.add(skill_dir.name)
|
|
97
|
+
|
|
98
|
+
installed_count = 0
|
|
99
|
+
removed_count = 0
|
|
100
|
+
|
|
101
|
+
# Remove orphaned plain-* skills (exist in dest but not in source)
|
|
102
|
+
# Only remove skills with plain- prefix to preserve user-created skills
|
|
103
|
+
if dest_skills_dir.exists():
|
|
104
|
+
for dest_dir in dest_skills_dir.iterdir():
|
|
105
|
+
if (
|
|
106
|
+
dest_dir.is_dir()
|
|
107
|
+
and dest_dir.name.startswith("plain-")
|
|
108
|
+
and dest_dir.name not in source_skill_names
|
|
109
|
+
):
|
|
110
|
+
shutil.rmtree(dest_dir)
|
|
111
|
+
removed_count += 1
|
|
112
|
+
|
|
113
|
+
for pkg_name in sorted(skills_by_package.keys()):
|
|
114
|
+
for skill_dir in skills_by_package[pkg_name]:
|
|
115
|
+
dest_dir = dest_skills_dir / skill_dir.name
|
|
116
|
+
source_skill_file = skill_dir / "SKILL.md"
|
|
117
|
+
|
|
118
|
+
# Check if we need to copy (mtime checking)
|
|
119
|
+
if dest_dir.exists():
|
|
120
|
+
dest_skill_file = dest_dir / "SKILL.md"
|
|
121
|
+
if dest_skill_file.exists():
|
|
122
|
+
source_mtime = source_skill_file.stat().st_mtime
|
|
123
|
+
dest_mtime = dest_skill_file.stat().st_mtime
|
|
124
|
+
if source_mtime <= dest_mtime:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Copy the entire skill directory
|
|
128
|
+
if dest_dir.exists():
|
|
129
|
+
shutil.rmtree(dest_dir)
|
|
130
|
+
shutil.copytree(skill_dir, dest_dir)
|
|
131
|
+
installed_count += 1
|
|
132
|
+
|
|
133
|
+
return installed_count, removed_count
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _setup_session_hook(dest_dir: Path) -> None:
|
|
137
|
+
"""Create or update settings.json with SessionStart hook."""
|
|
138
|
+
settings_file = dest_dir / "settings.json"
|
|
139
|
+
|
|
140
|
+
# Load existing settings or start fresh
|
|
141
|
+
if settings_file.exists():
|
|
142
|
+
settings = json.loads(settings_file.read_text())
|
|
143
|
+
else:
|
|
144
|
+
settings = {}
|
|
145
|
+
|
|
146
|
+
# Ensure hooks structure exists
|
|
147
|
+
if "hooks" not in settings:
|
|
148
|
+
settings["hooks"] = {}
|
|
149
|
+
|
|
150
|
+
# Define the Plain hook - calls the agent context command directly
|
|
151
|
+
plain_hook = {
|
|
152
|
+
"matcher": "startup|resume",
|
|
153
|
+
"hooks": [
|
|
154
|
+
{
|
|
155
|
+
"type": "command",
|
|
156
|
+
"command": "uv run plain agent context 2>/dev/null || true",
|
|
157
|
+
}
|
|
158
|
+
],
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Get existing SessionStart hooks, remove any existing plain hook
|
|
162
|
+
session_hooks = settings["hooks"].get("SessionStart", [])
|
|
163
|
+
session_hooks = [h for h in session_hooks if "plain agent" not in str(h)]
|
|
164
|
+
# Also remove old plain-context.md hooks for migration
|
|
165
|
+
session_hooks = [h for h in session_hooks if "plain-context.md" not in str(h)]
|
|
166
|
+
session_hooks.append(plain_hook)
|
|
167
|
+
settings["hooks"]["SessionStart"] = session_hooks
|
|
168
|
+
|
|
169
|
+
settings_file.write_text(json.dumps(settings, indent=2) + "\n")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@click.group()
|
|
173
|
+
def agent() -> None:
|
|
174
|
+
"""AI agent integration for Plain projects"""
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@agent.command()
|
|
179
|
+
def context() -> None:
|
|
180
|
+
"""Output Plain framework context for AI agents"""
|
|
181
|
+
click.echo("This is a Plain project. Use the /plain-* skills for common tasks.")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@agent.command()
|
|
185
|
+
def install() -> None:
|
|
186
|
+
"""Install skills and hooks to agent directories"""
|
|
187
|
+
skills_by_package = _get_packages_with_skills()
|
|
188
|
+
|
|
189
|
+
if not skills_by_package:
|
|
190
|
+
click.echo("No skills found in installed packages.")
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
# Find destinations based on what agent directories exist
|
|
194
|
+
destinations = _get_skill_destinations()
|
|
195
|
+
|
|
196
|
+
if not destinations:
|
|
197
|
+
click.secho(
|
|
198
|
+
"No agent directories found (.claude/ or .codex/)",
|
|
199
|
+
fg="yellow",
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Install to each destination
|
|
204
|
+
for dest in destinations:
|
|
205
|
+
installed_count, removed_count = _install_skills_to(dest, skills_by_package)
|
|
206
|
+
|
|
207
|
+
parent_dir = dest.parent # .claude/ or .codex/
|
|
208
|
+
|
|
209
|
+
# Setup hook only for Claude (Codex uses a different config format)
|
|
210
|
+
if parent_dir.name == ".claude":
|
|
211
|
+
_setup_session_hook(parent_dir)
|
|
212
|
+
|
|
213
|
+
parts = []
|
|
214
|
+
if installed_count > 0:
|
|
215
|
+
parts.append(f"installed {installed_count} skills")
|
|
216
|
+
if removed_count > 0:
|
|
217
|
+
parts.append(f"removed {removed_count} skills")
|
|
218
|
+
if parent_dir.name == ".claude":
|
|
219
|
+
parts.append("updated hooks")
|
|
220
|
+
if parts:
|
|
221
|
+
click.echo(f"Agent: {', '.join(parts)} in {parent_dir}/")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@agent.command()
|
|
225
|
+
def skills() -> None:
|
|
226
|
+
"""List available skills from installed packages"""
|
|
227
|
+
skills_by_package = _get_packages_with_skills()
|
|
228
|
+
|
|
229
|
+
if not skills_by_package:
|
|
230
|
+
click.echo("No skills found in installed packages.")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
click.echo("Available skills:")
|
|
234
|
+
for pkg_name in sorted(skills_by_package.keys()):
|
|
235
|
+
for skill_dir in skills_by_package[pkg_name]:
|
|
236
|
+
click.echo(f" - {skill_dir.name} (from {pkg_name})")
|
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):
|