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 platform
4import subprocess
5import sys
6from importlib.metadata import entry_points
7from importlib.util import find_spec
8from pathlib import Path
9
10import click
11import tomllib
12
13from plain.runtime import APP_PATH, settings
14
15from .db import cli as db_cli
16from .mkcert import MkcertManager
17from .pid import Pid
18from .poncho.manager import Manager as PonchoManager
19from .services import Services
20from .utils import has_pyproject_toml
21
22ENTRYPOINT_GROUP = "plain.dev"
23
24
25@click.group(invoke_without_command=True)
26@click.pass_context
27@click.option(
28 "--port",
29 "-p",
30 default=8443,
31 type=int,
32 help="Port to run the web server on",
33 envvar="PORT",
34)
35def cli(ctx, port):
36 """Start local development"""
37
38 if ctx.invoked_subcommand:
39 return
40
41 returncode = Dev(port=port).run()
42 if returncode:
43 sys.exit(returncode)
44
45
46@cli.command()
47def services():
48 """Start additional services defined in pyproject.toml"""
49 Services().run()
50
51
52@cli.command()
53@click.option(
54 "--list", "-l", "show_list", is_flag=True, help="List available entrypoints"
55)
56@click.argument("entrypoint", required=False)
57def entrypoint(show_list, entrypoint):
58 f"""Entrypoints registered under {ENTRYPOINT_GROUP}"""
59 if not show_list and not entrypoint:
60 click.secho("Please provide an entrypoint name or use --list", fg="red")
61 sys.exit(1)
62
63 for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
64 if show_list:
65 click.echo(entry_point.name)
66 elif entrypoint == entry_point.name:
67 entry_point.load()()
68
69
70class Dev:
71 def __init__(self, *, port):
72 self.poncho = PonchoManager()
73 self.port = port
74 self.plain_env = {
75 **os.environ,
76 "PYTHONUNBUFFERED": "true",
77 }
78 self.custom_process_env = {
79 **self.plain_env,
80 "PORT": str(self.port),
81 "PYTHONPATH": os.path.join(APP_PATH.parent, "app"),
82 }
83 self.project_name = os.path.basename(os.getcwd())
84 self.domain = f"{self.project_name}.localhost"
85 self.ssl_cert_path = None
86 self.ssl_key_path = None
87
88 def run(self):
89 pid = Pid()
90 pid.write()
91
92 try:
93 mkcert_manager = MkcertManager()
94 mkcert_manager.setup_mkcert(install_path=Path.home() / ".plain" / "dev")
95 self.ssl_cert_path, self.ssl_key_path = mkcert_manager.generate_certs(
96 domain=self.domain,
97 storage_path=Path(settings.PLAIN_TEMP_PATH) / "dev" / "certs",
98 )
99 self.modify_hosts_file()
100 self.set_csrf_trusted_origins()
101 self.set_allowed_hosts()
102 self.run_preflight()
103
104 # Processes for poncho to run simultaneously
105 self.add_gunicorn()
106 self.add_entrypoints()
107 self.add_pyproject_run()
108 self.add_services()
109
110 # Output the clickable link before starting the manager loop
111 url = f"https://{self.domain}:{self.port}/"
112 click.secho(
113 f"\nYour application is running at: {click.style(url, fg='green', underline=True)}\n",
114 bold=True,
115 )
116
117 self.poncho.loop()
118
119 return self.poncho.returncode
120 finally:
121 pid.rm()
122
123 def modify_hosts_file(self):
124 """Modify the hosts file to map the custom domain to 127.0.0.1."""
125 entry_identifier = "# Added by plain"
126 hosts_entry = f"127.0.0.1 {self.domain} {entry_identifier}"
127
128 if platform.system() == "Windows":
129 hosts_path = Path(r"C:\Windows\System32\drivers\etc\hosts")
130 try:
131 with hosts_path.open("r") as f:
132 content = f.read()
133
134 if hosts_entry in content:
135 return # Entry already exists; no action needed
136
137 # Entry does not exist; add it
138 with hosts_path.open("a") as f:
139 f.write(f"{hosts_entry}\n")
140 click.secho(f"Added {self.domain} to {hosts_path}", bold=True)
141 except PermissionError:
142 click.secho(
143 "Permission denied while modifying hosts file. Please run the script as an administrator.",
144 fg="red",
145 )
146 sys.exit(1)
147 else:
148 # For macOS and Linux
149 hosts_path = Path("/etc/hosts")
150 try:
151 with hosts_path.open("r") as f:
152 content = f.read()
153
154 if hosts_entry in content:
155 return # Entry already exists; no action needed
156
157 # Entry does not exist; append it using sudo
158 click.secho(
159 "Modifying /etc/hosts file. You may be prompted for your password.",
160 bold=True,
161 )
162 cmd = f"echo '{hosts_entry}' | sudo tee -a {hosts_path} >/dev/null"
163 subprocess.run(cmd, shell=True, check=True)
164 click.secho(f"Added {self.domain} to {hosts_path}", bold=True)
165 except PermissionError:
166 click.secho(
167 "Permission denied while accessing hosts file.",
168 fg="red",
169 )
170 sys.exit(1)
171 except subprocess.CalledProcessError:
172 click.secho(
173 "Failed to modify hosts file. Please ensure you have sudo privileges.",
174 fg="red",
175 )
176 sys.exit(1)
177
178 def set_csrf_trusted_origins(self):
179 csrf_trusted_origins = json.dumps(
180 [
181 f"https://{self.domain}:{self.port}",
182 ]
183 )
184
185 click.secho(
186 f"Automatically set PLAIN_CSRF_TRUSTED_ORIGINS={click.style(csrf_trusted_origins, underline=True)}",
187 bold=True,
188 )
189
190 # Set environment variables
191 self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
192 self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
193
194 def set_allowed_hosts(self):
195 allowed_hosts = json.dumps([self.domain])
196
197 click.secho(
198 f"Automatically set PLAIN_ALLOWED_HOSTS={click.style(allowed_hosts, underline=True)}",
199 bold=True,
200 )
201
202 # Set environment variables
203 self.plain_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
204 self.custom_process_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
205
206 def run_preflight(self):
207 if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
208 click.secho("Preflight check failed!", fg="red")
209 sys.exit(1)
210
211 def add_gunicorn(self):
212 plain_db_installed = find_spec("plain.models") is not None
213
214 # Watch .env files for reload
215 extra_watch_files = []
216 for f in os.listdir(APP_PATH.parent):
217 if f.startswith(".env"):
218 extra_watch_files.append(f)
219
220 reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
221 gunicorn_cmd = [
222 "gunicorn",
223 "--bind",
224 f"{self.domain}:{self.port}",
225 "--certfile",
226 str(self.ssl_cert_path),
227 "--keyfile",
228 str(self.ssl_key_path),
229 "--reload",
230 "plain.wsgi:app",
231 "--timeout",
232 "60",
233 "--access-logfile",
234 "-",
235 "--error-logfile",
236 "-",
237 *reload_extra.split(),
238 "--access-logformat",
239 "'\"%(r)s\" status=%(s)s length=%(b)s dur=%(M)sms'",
240 ]
241 gunicorn = " ".join(gunicorn_cmd)
242
243 if plain_db_installed:
244 runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
245 else:
246 runserver_cmd = gunicorn
247
248 if "WEB_CONCURRENCY" not in self.plain_env:
249 # Default to two workers to prevent lockups
250 self.plain_env["WEB_CONCURRENCY"] = "2"
251
252 self.poncho.add_process("plain", runserver_cmd, env=self.plain_env)
253
254 def add_entrypoints(self):
255 for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
256 self.poncho.add_process(
257 entry_point.name,
258 f"plain dev entrypoint {entry_point.name}",
259 env=self.plain_env,
260 )
261
262 def add_pyproject_run(self):
263 if not has_pyproject_toml(APP_PATH.parent):
264 return
265
266 with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
267 pyproject = tomllib.load(f)
268
269 run_commands = (
270 pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
271 )
272 for name, data in run_commands.items():
273 env = {
274 **self.custom_process_env,
275 **data.get("env", {}),
276 }
277 self.poncho.add_process(name, data["cmd"], env=env)
278
279 def add_services(self):
280 services = Services.get_services(APP_PATH.parent)
281 for name, data in services.items():
282 env = {
283 **os.environ,
284 "PYTHONUNBUFFERED": "true",
285 **data.get("env", {}),
286 }
287 self.poncho.add_process(name, data["cmd"], env=env)
288
289
290cli.add_command(db_cli)