CLI
The plain
CLI loads commands from Plain itself, and any INSTALLED_PACKAGES
.
Commands are written using Click) (one of Plain's few dependencies), which has been one of those most popular CLI frameworks in Python for a long time now.
Built-in commands
plain shell
Open a Python shell with the Plain loaded.
To auto-load models or run other code at shell launch,
create an app/shell.py
and it will be imported automatically.
# app/shell.py
from organizations.models import Organization
__all__ = [
"Organization",
]
plain compile
Compile static assets (used in the deploy/production process).
Automatically runs plain tailwind compile
if plain-tailwind is installed.
Automatically runs npm run compile
if you have a package.json
with scripts.compile
.
plain run
Run a Python script in the context of your app.
plain preflight
Run preflight checks to ensure your app is ready to run.
plain create
Create a new local package.
plain setting
View the runtime value of a named setting.
Adding commands
Add an app/cli.py
You can add "root" commands to your app by defining a cli
function in app/cli.py
.
import click
@click.group()
def cli():
pass
@cli.command()
def custom_command():
click.echo("An app command!")
Then you can run the command with plain
.
$ plain custom-command
An app command!
Add CLI commands to your local packages
Any package in INSTALLED_PACKAGES
can define CLI commands by creating a cli.py
in the root of the package.
In cli.py
, create a command or group of commands named cli
.
import click
@click.group()
def cli():
pass
@cli.command()
def hello():
click.echo("Hello, world!")
Plain will use the name of the package in the CLI, then any commands you defined.
$ plain <pkg> hello
Hello, world!
Add CLI commands to published packages
Some packages, like plain-dev,
never show up in INSTALLED_PACKAGES
but still have CLI commands.
These are detected via Python entry points.
An example with pyproject.toml
and UV:
# pyproject.toml
[project.entry-points."plain.cli"]
"dev" = "plain.dev:cli"
"pre-commit" = "plain.dev.precommit:cli"
"contrib" = "plain.dev.contribute:cli"
1import importlib
2from importlib.metadata import entry_points
3from importlib.util import find_spec
4
5import click
6
7from plain.packages import packages
8
9
10class InstalledPackagesGroup(click.Group):
11 """
12 Packages in INSTALLED_PACKAGES with a cli.py module
13 will be discovered automatically.
14 """
15
16 PLAIN_APPS_PREFIX = "plain."
17 MODULE_NAME = "cli"
18
19 def list_commands(self, ctx):
20 packages_with_commands = []
21
22 # Get installed packages with a cli.py module
23 for app in packages.get_package_configs():
24 if not find_spec(f"{app.name}.{self.MODULE_NAME}"):
25 continue
26
27 cli_name = app.name
28
29 if cli_name.startswith(self.PLAIN_APPS_PREFIX):
30 cli_name = cli_name[len(self.PLAIN_APPS_PREFIX) :]
31
32 packages_with_commands.append(cli_name)
33
34 return packages_with_commands
35
36 def get_command(self, ctx, name):
37 # Try it as plain.x and just x (we don't know ahead of time which it is, but prefer plain.x)
38 for n in [self.PLAIN_APPS_PREFIX + name, name]:
39 if not find_spec(n):
40 # plain.<name> doesn't exist at all
41 continue
42
43 if not find_spec(f"{n}.{self.MODULE_NAME}"):
44 continue
45
46 cli = importlib.import_module(f"{n}.{self.MODULE_NAME}")
47
48 # Get the app's cli.py group
49 try:
50 return cli.cli
51 except AttributeError:
52 continue
53
54
55class EntryPointGroup(click.Group):
56 """
57 Python packages can be added to the Plain CLI
58 via the plain_cli entrypoint in their setup.py.
59
60 This is intended for packages that don't go in INSTALLED_PACKAGES.
61 """
62
63 ENTRYPOINT_NAME = "plain.cli"
64
65 def list_commands(self, ctx):
66 rv = []
67
68 for entry_point in entry_points().select(group=self.ENTRYPOINT_NAME):
69 rv.append(entry_point.name)
70
71 rv.sort()
72 return rv
73
74 def get_command(self, ctx, name):
75 for entry_point in entry_points().select(group=self.ENTRYPOINT_NAME):
76 if entry_point.name == name:
77 return entry_point.load()