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/chores/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
## Overview
|
|
10
10
|
|
|
11
|
-
Chores are registered
|
|
11
|
+
Chores are registered classes that can be run at any time to keep an app in a desirable state.
|
|
12
12
|
|
|
13
13
|

|
|
14
14
|
|
|
@@ -16,19 +16,19 @@ A good example is the clearing of expired sessions in [`plain.sessions`](/plain-
|
|
|
16
16
|
|
|
17
17
|
```python
|
|
18
18
|
# plain/sessions/chores.py
|
|
19
|
-
from plain.chores import register_chore
|
|
19
|
+
from plain.chores import Chore, register_chore
|
|
20
20
|
from plain.utils import timezone
|
|
21
21
|
|
|
22
22
|
from .models import Session
|
|
23
23
|
|
|
24
24
|
|
|
25
|
-
@register_chore
|
|
26
|
-
|
|
27
|
-
"""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
@register_chore
|
|
26
|
+
class ClearExpired(Chore):
|
|
27
|
+
"""Delete sessions that have expired."""
|
|
28
|
+
|
|
29
|
+
def run(self):
|
|
30
|
+
result = Session.query.filter(expires_at__lt=timezone.now()).delete()
|
|
31
|
+
return f"{result[0]} expired sessions deleted"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## Running chores
|
|
@@ -38,33 +38,35 @@ The `plain chores run` command will execute all registered chores. When and how
|
|
|
38
38
|
There are several ways you can run chores depending on your needs:
|
|
39
39
|
|
|
40
40
|
- on deploy
|
|
41
|
-
- as a [`plain.
|
|
41
|
+
- as a [`plain.jobs` scheduled job](/plain-jobs/plain/jobs/README.md#scheduled-jobs)
|
|
42
42
|
- as a cron job (using any cron-like system where your app is hosted)
|
|
43
43
|
- manually as needed
|
|
44
44
|
|
|
45
45
|
## Writing chores
|
|
46
46
|
|
|
47
|
-
A chore is a
|
|
47
|
+
A chore is a class that inherits from [`Chore`](./core.py#Chore) and implements the `run()` method. Register the chore using the [`@register_chore`](./registry.py#register_chore) decorator. The chore name is the class's qualified name (`__qualname__`), and the description comes from the class docstring.
|
|
48
48
|
|
|
49
49
|
```python
|
|
50
50
|
# app/chores.py
|
|
51
|
-
from plain.chores import register_chore
|
|
51
|
+
from plain.chores import Chore, register_chore
|
|
52
|
+
|
|
52
53
|
|
|
54
|
+
@register_chore
|
|
55
|
+
class ChoreName(Chore):
|
|
56
|
+
"""A chore description can go here."""
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
A chore description can go here
|
|
58
|
-
"""
|
|
59
|
-
# Do a thing!
|
|
60
|
-
return "We did it!"
|
|
58
|
+
def run(self):
|
|
59
|
+
# Do a thing!
|
|
60
|
+
return "We did it!"
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
### Best practices
|
|
64
|
+
|
|
63
65
|
A good chore is:
|
|
64
66
|
|
|
65
|
-
- Fast
|
|
66
|
-
- Idempotent
|
|
67
|
-
- Recurring
|
|
68
|
-
- Stateless
|
|
67
|
+
- **Fast** - Should complete quickly, not block for long periods
|
|
68
|
+
- **Idempotent** - Safe to run multiple times without side effects
|
|
69
|
+
- **Recurring** - Designed to run regularly, not just once
|
|
70
|
+
- **Stateless** - Doesn't rely on external state between runs
|
|
69
71
|
|
|
70
72
|
If chores are written in `app/chores.py` or `{pkg}/chores.py`, then they will be imported automatically and registered.
|
plain/chores/__init__.py
CHANGED
plain/chores/core.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Chore(ABC):
|
|
8
|
+
"""
|
|
9
|
+
Abstract base class for chores.
|
|
10
|
+
|
|
11
|
+
Subclasses must implement:
|
|
12
|
+
- run() method
|
|
13
|
+
|
|
14
|
+
Example:
|
|
15
|
+
@register_chore
|
|
16
|
+
class ClearExpired(Chore):
|
|
17
|
+
'''Delete sessions that have expired.'''
|
|
18
|
+
|
|
19
|
+
def run(self):
|
|
20
|
+
# ... implementation
|
|
21
|
+
return "10 sessions deleted"
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def run(self) -> Any:
|
|
26
|
+
"""Run the chore. Must be implemented by subclasses."""
|
|
27
|
+
pass
|
plain/chores/registry.py
CHANGED
|
@@ -1,42 +1,33 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
1
|
+
from __future__ import annotations
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
def __init__(self, *, group, func):
|
|
6
|
-
self.group = group
|
|
7
|
-
self.func = func
|
|
8
|
-
self.name = f"{group}.{func.__name__}"
|
|
9
|
-
self.description = func.__doc__.strip() if func.__doc__ else ""
|
|
3
|
+
from plain.packages import packages_registry
|
|
10
4
|
|
|
11
|
-
|
|
12
|
-
return self.name
|
|
13
|
-
|
|
14
|
-
def run(self):
|
|
15
|
-
"""
|
|
16
|
-
Run the chore.
|
|
17
|
-
"""
|
|
18
|
-
return self.func()
|
|
5
|
+
from .core import Chore
|
|
19
6
|
|
|
20
7
|
|
|
21
8
|
class ChoresRegistry:
|
|
22
|
-
def __init__(self):
|
|
23
|
-
self._chores = {}
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._chores: dict[str, type[Chore]] = {}
|
|
24
11
|
|
|
25
|
-
def register_chore(self,
|
|
12
|
+
def register_chore(self, chore_class: type[Chore]) -> None:
|
|
26
13
|
"""
|
|
27
|
-
Register a chore
|
|
14
|
+
Register a chore class.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
chore_class: A Chore subclass to register
|
|
28
18
|
"""
|
|
29
|
-
|
|
19
|
+
name = f"{chore_class.__module__}.{chore_class.__qualname__}"
|
|
20
|
+
self._chores[name] = chore_class
|
|
30
21
|
|
|
31
|
-
def import_modules(self):
|
|
22
|
+
def import_modules(self) -> None:
|
|
32
23
|
"""
|
|
33
24
|
Import modules from installed packages and app to trigger registration.
|
|
34
25
|
"""
|
|
35
26
|
packages_registry.autodiscover_modules("chores", include_app=True)
|
|
36
27
|
|
|
37
|
-
def get_chores(self):
|
|
28
|
+
def get_chores(self) -> list[type[Chore]]:
|
|
38
29
|
"""
|
|
39
|
-
Get all registered
|
|
30
|
+
Get all registered chore classes.
|
|
40
31
|
"""
|
|
41
32
|
return list(self._chores.values())
|
|
42
33
|
|
|
@@ -44,19 +35,15 @@ class ChoresRegistry:
|
|
|
44
35
|
chores_registry = ChoresRegistry()
|
|
45
36
|
|
|
46
37
|
|
|
47
|
-
def register_chore(
|
|
38
|
+
def register_chore(cls: type[Chore]) -> type[Chore]:
|
|
48
39
|
"""
|
|
49
|
-
|
|
40
|
+
Decorator to register a chore class.
|
|
50
41
|
|
|
51
42
|
Usage:
|
|
52
|
-
@register_chore
|
|
53
|
-
|
|
54
|
-
|
|
43
|
+
@register_chore
|
|
44
|
+
class ClearExpired(Chore):
|
|
45
|
+
def run(self):
|
|
46
|
+
return "Done!"
|
|
55
47
|
"""
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
chore = Chore(group=group, func=func)
|
|
59
|
-
chores_registry.register_chore(chore)
|
|
60
|
-
return func
|
|
61
|
-
|
|
62
|
-
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,234 @@
|
|
|
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_agent_dirs() -> list[Path]:
|
|
13
|
+
"""Get list of agents/.claude/ directories from installed plain.* packages."""
|
|
14
|
+
agent_dirs: list[Path] = []
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import plain
|
|
18
|
+
|
|
19
|
+
# Check core plain package (namespace package)
|
|
20
|
+
plain_spec = importlib.util.find_spec("plain")
|
|
21
|
+
if plain_spec and plain_spec.submodule_search_locations:
|
|
22
|
+
for location in plain_spec.submodule_search_locations:
|
|
23
|
+
agent_dir = Path(location) / "agents" / ".claude"
|
|
24
|
+
if agent_dir.exists() and agent_dir.is_dir():
|
|
25
|
+
agent_dirs.append(agent_dir)
|
|
26
|
+
break
|
|
27
|
+
|
|
28
|
+
# Check other plain.* subpackages
|
|
29
|
+
if hasattr(plain, "__path__"):
|
|
30
|
+
for importer, modname, ispkg in pkgutil.iter_modules(
|
|
31
|
+
plain.__path__, "plain."
|
|
32
|
+
):
|
|
33
|
+
if ispkg:
|
|
34
|
+
try:
|
|
35
|
+
spec = importlib.util.find_spec(modname)
|
|
36
|
+
if spec and spec.origin:
|
|
37
|
+
agent_dir = Path(spec.origin).parent / "agents" / ".claude"
|
|
38
|
+
if agent_dir.exists() and agent_dir.is_dir():
|
|
39
|
+
agent_dirs.append(agent_dir)
|
|
40
|
+
except Exception:
|
|
41
|
+
continue
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
return agent_dirs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _install_agent_dir(source_dir: Path, dest_dir: Path) -> tuple[int, int]:
|
|
49
|
+
"""Copy contents of a source agents/.claude/ dir to the project's .claude/ dir.
|
|
50
|
+
|
|
51
|
+
Handles skills/ subdirectories and rules/ files.
|
|
52
|
+
Returns (installed_count, removed_count) for reporting.
|
|
53
|
+
"""
|
|
54
|
+
installed_count = 0
|
|
55
|
+
|
|
56
|
+
# Copy skills (directories containing SKILL.md)
|
|
57
|
+
source_skills = source_dir / "skills"
|
|
58
|
+
if source_skills.exists():
|
|
59
|
+
dest_skills = dest_dir / "skills"
|
|
60
|
+
dest_skills.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
for skill_dir in source_skills.iterdir():
|
|
62
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
63
|
+
dest_skill = dest_skills / skill_dir.name
|
|
64
|
+
# Check mtime to skip unchanged
|
|
65
|
+
if dest_skill.exists():
|
|
66
|
+
source_mtime = (skill_dir / "SKILL.md").stat().st_mtime
|
|
67
|
+
dest_mtime = (
|
|
68
|
+
(dest_skill / "SKILL.md").stat().st_mtime
|
|
69
|
+
if (dest_skill / "SKILL.md").exists()
|
|
70
|
+
else 0
|
|
71
|
+
)
|
|
72
|
+
if source_mtime <= dest_mtime:
|
|
73
|
+
continue
|
|
74
|
+
shutil.rmtree(dest_skill)
|
|
75
|
+
shutil.copytree(skill_dir, dest_skill)
|
|
76
|
+
installed_count += 1
|
|
77
|
+
|
|
78
|
+
# Copy rules (individual .md files)
|
|
79
|
+
source_rules = source_dir / "rules"
|
|
80
|
+
if source_rules.exists():
|
|
81
|
+
dest_rules = dest_dir / "rules"
|
|
82
|
+
dest_rules.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
for rule_file in source_rules.iterdir():
|
|
84
|
+
if rule_file.is_file() and rule_file.suffix == ".md":
|
|
85
|
+
dest_rule = dest_rules / rule_file.name
|
|
86
|
+
# Check mtime to skip unchanged
|
|
87
|
+
if dest_rule.exists():
|
|
88
|
+
if rule_file.stat().st_mtime <= dest_rule.stat().st_mtime:
|
|
89
|
+
continue
|
|
90
|
+
shutil.copy2(rule_file, dest_rule)
|
|
91
|
+
installed_count += 1
|
|
92
|
+
|
|
93
|
+
return installed_count, 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cleanup_orphans(dest_dir: Path, agent_dirs: list[Path]) -> int:
|
|
97
|
+
"""Remove plain* items from .claude/ that no longer exist in any source package."""
|
|
98
|
+
removed_count = 0
|
|
99
|
+
|
|
100
|
+
# Collect all source skill and rule names
|
|
101
|
+
source_skills: set[str] = set()
|
|
102
|
+
source_rules: set[str] = set()
|
|
103
|
+
for agent_dir in agent_dirs:
|
|
104
|
+
skills_dir = agent_dir / "skills"
|
|
105
|
+
if skills_dir.exists():
|
|
106
|
+
for d in skills_dir.iterdir():
|
|
107
|
+
if d.is_dir() and (d / "SKILL.md").exists():
|
|
108
|
+
source_skills.add(d.name)
|
|
109
|
+
rules_dir = agent_dir / "rules"
|
|
110
|
+
if rules_dir.exists():
|
|
111
|
+
for f in rules_dir.iterdir():
|
|
112
|
+
if f.is_file() and f.suffix == ".md":
|
|
113
|
+
source_rules.add(f.name)
|
|
114
|
+
|
|
115
|
+
# Remove orphaned skills
|
|
116
|
+
dest_skills = dest_dir / "skills"
|
|
117
|
+
if dest_skills.exists():
|
|
118
|
+
for dest in dest_skills.iterdir():
|
|
119
|
+
if (
|
|
120
|
+
dest.is_dir()
|
|
121
|
+
and dest.name.startswith("plain")
|
|
122
|
+
and dest.name not in source_skills
|
|
123
|
+
):
|
|
124
|
+
shutil.rmtree(dest)
|
|
125
|
+
removed_count += 1
|
|
126
|
+
|
|
127
|
+
# Remove orphaned rules
|
|
128
|
+
dest_rules = dest_dir / "rules"
|
|
129
|
+
if dest_rules.exists():
|
|
130
|
+
for dest in dest_rules.iterdir():
|
|
131
|
+
if (
|
|
132
|
+
dest.is_file()
|
|
133
|
+
and dest.name.startswith("plain")
|
|
134
|
+
and dest.suffix == ".md"
|
|
135
|
+
and dest.name not in source_rules
|
|
136
|
+
):
|
|
137
|
+
dest.unlink()
|
|
138
|
+
removed_count += 1
|
|
139
|
+
|
|
140
|
+
return removed_count
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _cleanup_session_hook(dest_dir: Path) -> None:
|
|
144
|
+
"""Remove the old plain agent context SessionStart hook from settings.json."""
|
|
145
|
+
settings_file = dest_dir / "settings.json"
|
|
146
|
+
|
|
147
|
+
if not settings_file.exists():
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
settings = json.loads(settings_file.read_text())
|
|
151
|
+
|
|
152
|
+
hooks = settings.get("hooks", {})
|
|
153
|
+
session_hooks = hooks.get("SessionStart", [])
|
|
154
|
+
|
|
155
|
+
# Remove any plain agent or plain-context.md hooks
|
|
156
|
+
session_hooks = [h for h in session_hooks if "plain agent" not in str(h)]
|
|
157
|
+
session_hooks = [h for h in session_hooks if "plain-context.md" not in str(h)]
|
|
158
|
+
|
|
159
|
+
if session_hooks:
|
|
160
|
+
hooks["SessionStart"] = session_hooks
|
|
161
|
+
else:
|
|
162
|
+
hooks.pop("SessionStart", None)
|
|
163
|
+
|
|
164
|
+
if hooks:
|
|
165
|
+
settings["hooks"] = hooks
|
|
166
|
+
else:
|
|
167
|
+
settings.pop("hooks", None)
|
|
168
|
+
|
|
169
|
+
if settings:
|
|
170
|
+
settings_file.write_text(json.dumps(settings, indent=2) + "\n")
|
|
171
|
+
else:
|
|
172
|
+
settings_file.unlink()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@click.group()
|
|
176
|
+
def agent() -> None:
|
|
177
|
+
"""AI agent integration for Plain projects"""
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@agent.command()
|
|
182
|
+
def install() -> None:
|
|
183
|
+
"""Install skills and rules to agent directories"""
|
|
184
|
+
cwd = Path.cwd()
|
|
185
|
+
claude_dir = cwd / ".claude"
|
|
186
|
+
|
|
187
|
+
if not claude_dir.exists():
|
|
188
|
+
click.secho("No .claude/ directory found.", fg="yellow")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
agent_dirs = _get_agent_dirs()
|
|
192
|
+
|
|
193
|
+
# Clean up orphaned plain-* items
|
|
194
|
+
removed_count = _cleanup_orphans(claude_dir, agent_dirs)
|
|
195
|
+
|
|
196
|
+
# Install from each package
|
|
197
|
+
total_installed = 0
|
|
198
|
+
for source_dir in agent_dirs:
|
|
199
|
+
installed, _ = _install_agent_dir(source_dir, claude_dir)
|
|
200
|
+
total_installed += installed
|
|
201
|
+
|
|
202
|
+
# Clean up old session hook
|
|
203
|
+
_cleanup_session_hook(claude_dir)
|
|
204
|
+
|
|
205
|
+
parts = []
|
|
206
|
+
if total_installed > 0:
|
|
207
|
+
parts.append(f"installed {total_installed}")
|
|
208
|
+
if removed_count > 0:
|
|
209
|
+
parts.append(f"removed {removed_count}")
|
|
210
|
+
click.echo(f"Agent: {', '.join(parts)} in .claude/") if parts else click.echo(
|
|
211
|
+
"Agent: up to date"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@agent.command()
|
|
216
|
+
def skills() -> None:
|
|
217
|
+
"""List available skills from installed packages"""
|
|
218
|
+
agent_dirs = _get_agent_dirs()
|
|
219
|
+
|
|
220
|
+
skill_names = []
|
|
221
|
+
for agent_dir in agent_dirs:
|
|
222
|
+
skills_dir = agent_dir / "skills"
|
|
223
|
+
if skills_dir.exists():
|
|
224
|
+
for d in skills_dir.iterdir():
|
|
225
|
+
if d.is_dir() and (d / "SKILL.md").exists():
|
|
226
|
+
skill_names.append(d.name)
|
|
227
|
+
|
|
228
|
+
if not skill_names:
|
|
229
|
+
click.echo("No skills found in installed packages.")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
click.echo("Available skills:")
|
|
233
|
+
for name in sorted(skill_names):
|
|
234
|
+
click.echo(f" - {name}")
|