plain.dev
A single command that runs everything you need for local development.
The plain.dev
package can be installed from PyPI, and does not need to be added to INSTALLED_PACKAGES
.
plain dev
The plain dev
command does several things:
- Sets
PLAIN_CSRF_TRUSTED_ORIGINS
to localhost by default - Runs
plain preflight
to check for any issues - Executes any pending model migrations
- Starts
gunicorn
with--reload
- Runs
plain tailwind compile --watch
, ifplain.tailwind
is installed - Any custom process defined in
pyproject.toml
attool.plain.dev.run
- Necessary services (ex. Postgres) defined in
pyproject.toml
attool.plain.dev.services
Services
Use services to define databases or other processes that your app needs to be functional. The services will be started automatically in plain dev
, but also in plain pre-commit
(so preflight and tests have a database).
Ultimately, how you run your development database is up to you. But a recommended starting point is to use Docker:
# pyproject.toml
[tool.plain.dev.services]
postgres = {cmd = "docker run --name app-postgres --rm -p 54321:5432 -v $(pwd)/.plain/dev/pgdata:/var/lib/postgresql/data -e POSTGRES_PASSWORD=postgres postgres:15 postgres"}
Custom processes
Unlike services, custom processes are only run during plain dev
. This is a good place to run something like ngrok or a Plain worker, which you might need to use your local site, but don't need running for executing tests, for example.
# pyproject.toml
[tool.plain.dev.run]
ngrok = {command = "ngrok http $PORT"}
plain dev services
Starts your services by themselves.
plain pre-commit
A built-in pre-commit hook that can be installed with plain pre-commit --install
.
Runs:
- Custom commands defined in
pyproject.toml
attool.plain.pre-commit.run
plain code check
, ifplain.code
is installedpoetry check --lock
, if using Poetryplain preflight --database default
plain migrate --check
plain makemigrations --dry-run --check
plain compile
plain test
VS Code debugging
Since plain dev
runs multiple processes at once, the regular pdb debuggers don't quite work.
Instead, we include microsoft/debugpy and an attach
function to make it even easier to use VS Code's debugger.
First, import and run the debug.attach()
function:
class HomeView(TemplateView):
template_name = "home.html"
def get_template_context(self):
context = super().get_template_context()
# Make sure the debugger is attached (will need to be if runserver reloads)
from plain.dev import debug; debug.attach()
# Add a breakpoint (or use the gutter in VS Code to add one)
breakpoint()
return context
When you load the page, you'll see "Waiting for debugger to attach...".
You can then run the VS Code debugger and attach to an existing Python process, at localhost:5678.
1import json
2import os
3import subprocess
4import sys
5from importlib.util import find_spec
6from pathlib import Path
7
8import click
9from honcho.manager import Manager as HonchoManager
10
11from plain.runtime import APP_PATH
12
13from .db import cli as db_cli
14from .pid import Pid
15from .services import Services
16from .utils import has_pyproject_toml, plainpackage_installed
17
18try:
19 import tomllib
20except ModuleNotFoundError:
21 import tomli as tomllib
22
23
24@click.group(invoke_without_command=True)
25@click.pass_context
26@click.option(
27 "--port",
28 "-p",
29 default=8000,
30 type=int,
31 help="Port to run the web server on",
32 envvar="PORT",
33)
34def cli(ctx, port):
35 """Start local development"""
36
37 if ctx.invoked_subcommand:
38 return
39
40 returncode = Dev(port=port).run()
41 if returncode:
42 sys.exit(returncode)
43
44
45@cli.command()
46def services():
47 """Start additional services defined in pyproject.toml"""
48 Services().run()
49
50
51class Dev:
52 def __init__(self, *, port):
53 self.manager = HonchoManager()
54 self.port = port
55 self.plain_env = {
56 **os.environ,
57 "PYTHONUNBUFFERED": "true",
58 }
59 self.custom_process_env = {
60 **self.plain_env,
61 "PORT": str(self.port),
62 "PYTHONPATH": os.path.join(APP_PATH.parent, "app"),
63 }
64
65 def run(self):
66 pid = Pid()
67 pid.write()
68
69 try:
70 self.add_csrf_trusted_origins()
71 self.run_preflight()
72 self.add_gunicorn()
73 self.add_tailwind()
74 self.add_pyproject_run()
75 self.add_services()
76
77 self.manager.loop()
78
79 return self.manager.returncode
80 finally:
81 pid.rm()
82
83 def add_csrf_trusted_origins(self):
84 if "PLAIN_CSRF_TRUSTED_ORIGINS" in os.environ:
85 return
86
87 csrf_trusted_origins = json.dumps(
88 [f"http://localhost:{self.port}", f"http://127.0.0.1:{self.port}"]
89 )
90
91 click.secho(
92 f"Automatically set PLAIN_CSRF_TRUSTED_ORIGINS={click.style(csrf_trusted_origins, underline=True)}",
93 bold=True,
94 )
95
96 # Set BASE_URL for plain and custom processes
97 self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
98 self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
99
100 def run_preflight(self):
101 if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
102 click.secho("Preflight check failed!", fg="red")
103 sys.exit(1)
104
105 def add_gunicorn(self):
106 plain_db_installed = find_spec("plain.models") is not None
107
108 # TODO not necessarily watching the right .env...
109 # could return path from env.load?
110 extra_watch_files = []
111 for f in os.listdir(APP_PATH.parent):
112 if f.startswith(".env"):
113 # Will include some extra, but good enough for now
114 extra_watch_files.append(f)
115
116 reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
117 gunicorn = f"gunicorn --bind 127.0.0.1:{self.port} --reload plain.wsgi:app --timeout 60 --access-logfile - --error-logfile - {reload_extra} --access-logformat '\"%(r)s\" status=%(s)s length=%(b)s dur=%(M)sms'"
118
119 if plain_db_installed:
120 runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
121 else:
122 runserver_cmd = gunicorn
123
124 if "WEB_CONCURRENCY" not in self.plain_env:
125 # Default to two workers so request log etc are less
126 # likely to get locked up
127 self.plain_env["WEB_CONCURRENCY"] = "2"
128
129 self.manager.add_process("plain", runserver_cmd, env=self.plain_env)
130
131 def add_tailwind(self):
132 if not plainpackage_installed("tailwind"):
133 return
134
135 self.manager.add_process("tailwind", "plain tailwind compile --watch")
136
137 def add_pyproject_run(self):
138 if not has_pyproject_toml(APP_PATH.parent):
139 return
140
141 with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
142 pyproject = tomllib.load(f)
143
144 for name, data in (
145 pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
146 ).items():
147 env = {
148 **self.custom_process_env,
149 **data.get("env", {}),
150 }
151 self.manager.add_process(name, data["cmd"], env=env)
152
153 def add_services(self):
154 services = Services.get_services(APP_PATH.parent)
155 for name, data in services.items():
156 env = {
157 **os.environ,
158 "PYTHONUNBUFFERED": "true",
159 **data.get("env", {}),
160 }
161 self.manager.add_process(name, data["cmd"], env=env)
162
163
164cli.add_command(db_cli)