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 installeduv lock --locked
, if using uvplain 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 importlib
2import json
3import os
4import platform
5import subprocess
6import sys
7import time
8import tomllib
9from importlib.metadata import entry_points
10from importlib.util import find_spec
11from pathlib import Path
12
13import click
14from rich.columns import Columns
15from rich.console import Console
16from rich.text import Text
17
18from plain.runtime import APP_PATH, settings
19
20from .db import cli as db_cli
21from .mkcert import MkcertManager
22from .pid import Pid
23from .poncho.manager import Manager as PonchoManager
24from .poncho.printer import Printer
25from .services import Services
26from .utils import has_pyproject_toml
27
28ENTRYPOINT_GROUP = "plain.dev"
29
30
31@click.group(invoke_without_command=True)
32@click.pass_context
33@click.option(
34 "--port",
35 "-p",
36 default=8443,
37 type=int,
38 help="Port to run the web server on",
39)
40@click.option(
41 "--hostname",
42 "-h",
43 default=None,
44 type=str,
45 help="Hostname to run the web server on",
46)
47@click.option(
48 "--log-level",
49 "-l",
50 default="info",
51 type=click.Choice(["debug", "info", "warning", "error", "critical"]),
52 help="Log level",
53)
54def cli(ctx, port, hostname, log_level):
55 """Start local development"""
56
57 if ctx.invoked_subcommand:
58 return
59
60 if not hostname:
61 project_name = os.path.basename(
62 os.getcwd()
63 ) # Use the directory name by default
64
65 if has_pyproject_toml(APP_PATH.parent):
66 with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
67 pyproject = tomllib.load(f)
68 project_name = pyproject.get("project", {}).get("name", project_name)
69
70 hostname = f"{project_name}.localhost"
71
72 returncode = Dev(port=port, hostname=hostname, log_level=log_level).run()
73 if returncode:
74 sys.exit(returncode)
75
76
77@cli.command()
78def debug():
79 """Connect to the remote debugger"""
80
81 def _connect():
82 if subprocess.run(["which", "nc"], capture_output=True).returncode == 0:
83 return subprocess.run(["nc", "-C", "localhost", "4444"])
84 else:
85 raise OSError("nc not found")
86
87 result = _connect()
88
89 # Try again once without a message
90 if result.returncode == 1:
91 time.sleep(1)
92 result = _connect()
93
94 # Keep trying...
95 while result.returncode == 1:
96 click.secho(
97 "Failed to connect. Make sure remote pdb is ready. Retrying...", fg="red"
98 )
99 result = _connect()
100 time.sleep(1)
101
102
103@cli.command()
104def services():
105 """Start additional services defined in pyproject.toml"""
106 Services().run()
107
108
109@cli.command()
110@click.option(
111 "--list", "-l", "show_list", is_flag=True, help="List available entrypoints"
112)
113@click.argument("entrypoint", required=False)
114def entrypoint(show_list, entrypoint):
115 """Entrypoints registered under plain.dev"""
116 if not show_list and not entrypoint:
117 click.secho("Please provide an entrypoint name or use --list", fg="red")
118 sys.exit(1)
119
120 for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
121 if show_list:
122 click.echo(entry_point.name)
123 elif entrypoint == entry_point.name:
124 entry_point.load()()
125
126
127class Dev:
128 def __init__(self, *, port, hostname, log_level):
129 self.port = port
130 self.hostname = hostname
131 self.log_level = log_level
132
133 self.ssl_key_path = None
134 self.ssl_cert_path = None
135
136 self.url = f"https://{self.hostname}:{self.port}"
137 self.tunnel_url = os.environ.get("PLAIN_DEV_TUNNEL_URL", "")
138
139 self.plain_env = {
140 "PYTHONUNBUFFERED": "true",
141 "PLAIN_DEV": "true",
142 "PLAIN_LOG_LEVEL": self.log_level.upper(),
143 "APP_LOG_LEVEL": self.log_level.upper(),
144 **os.environ,
145 }
146 self.custom_process_env = {
147 **self.plain_env,
148 "PORT": str(self.port),
149 "PLAIN_DEV_URL": self.url,
150 }
151
152 self.console = Console(markup=False, highlight=False)
153 self.poncho = PonchoManager(printer=Printer(lambda s: self.console.out(s)))
154
155 def run(self):
156 pid = Pid()
157 pid.write()
158
159 try:
160 mkcert_manager = MkcertManager()
161 mkcert_manager.setup_mkcert(install_path=Path.home() / ".plain" / "dev")
162 self.ssl_cert_path, self.ssl_key_path = mkcert_manager.generate_certs(
163 domain=self.hostname,
164 storage_path=Path(settings.PLAIN_TEMP_PATH) / "dev" / "certs",
165 )
166
167 self.symlink_plain_src()
168 self.modify_hosts_file()
169 self.set_csrf_and_allowed_hosts()
170 self.run_preflight()
171
172 # Processes for poncho to run simultaneously
173 self.add_gunicorn()
174 self.add_entrypoints()
175 self.add_pyproject_run()
176 self.add_services()
177
178 click.secho("\nStarting dev...", italic=True, dim=True)
179
180 if self.tunnel_url:
181 status_bar = Columns(
182 [
183 Text.from_markup(
184 f"[bold]Tunnel[/bold] [underline][link={self.tunnel_url}]{self.tunnel_url}[/link][/underline]"
185 ),
186 Text.from_markup(
187 f"[dim][bold]Server[/bold] [link={self.url}]{self.url}[/link][/dim]"
188 ),
189 Text.from_markup(
190 "[dim][bold]Ctrl+C[/bold] to stop[/dim]",
191 justify="right",
192 ),
193 ],
194 expand=True,
195 )
196 else:
197 status_bar = Columns(
198 [
199 Text.from_markup(
200 f"[bold]Server[/bold] [underline][link={self.url}]{self.url}[/link][/underline]"
201 ),
202 Text.from_markup(
203 "[dim][bold]Ctrl+C[/bold] to stop[/dim]", justify="right"
204 ),
205 ],
206 expand=True,
207 )
208
209 with self.console.status(status_bar):
210 self.poncho.loop()
211
212 return self.poncho.returncode
213 finally:
214 pid.rm()
215
216 def symlink_plain_src(self):
217 """Symlink the plain package into .plain so we can look at it easily"""
218 plain_path = Path(
219 importlib.util.find_spec("plain.runtime").origin
220 ).parent.parent
221 if not settings.PLAIN_TEMP_PATH.exists():
222 settings.PLAIN_TEMP_PATH.mkdir()
223 src_path = settings.PLAIN_TEMP_PATH / "src"
224 if plain_path.exists() and not src_path.exists():
225 src_path.symlink_to(plain_path)
226
227 def modify_hosts_file(self):
228 """Modify the hosts file to map the custom domain to 127.0.0.1."""
229 entry_identifier = "# Added by plain"
230 hosts_entry = f"127.0.0.1 {self.hostname} {entry_identifier}"
231
232 if platform.system() == "Windows":
233 hosts_path = Path(r"C:\Windows\System32\drivers\etc\hosts")
234 try:
235 with hosts_path.open("r") as f:
236 content = f.read()
237
238 if hosts_entry in content:
239 return # Entry already exists; no action needed
240
241 # Entry does not exist; add it
242 with hosts_path.open("a") as f:
243 f.write(f"{hosts_entry}\n")
244 click.secho(f"Added {self.hostname} to {hosts_path}", bold=True)
245 except PermissionError:
246 click.secho(
247 "Permission denied while modifying hosts file. Please run the script as an administrator.",
248 fg="red",
249 )
250 sys.exit(1)
251 else:
252 # For macOS and Linux
253 hosts_path = Path("/etc/hosts")
254 try:
255 with hosts_path.open("r") as f:
256 content = f.read()
257
258 if hosts_entry in content:
259 return # Entry already exists; no action needed
260
261 # Entry does not exist; append it using sudo
262 click.secho(
263 f"Adding {self.hostname} to /etc/hosts file. You may be prompted for your password.\n",
264 bold=True,
265 )
266 cmd = f"echo '{hosts_entry}' | sudo tee -a {hosts_path} >/dev/null"
267 subprocess.run(cmd, shell=True, check=True)
268 click.secho(f"Added {self.hostname} to {hosts_path}\n", bold=True)
269 except PermissionError:
270 click.secho(
271 "Permission denied while accessing hosts file.",
272 fg="red",
273 )
274 sys.exit(1)
275 except subprocess.CalledProcessError:
276 click.secho(
277 "Failed to modify hosts file. Please ensure you have sudo privileges.",
278 fg="red",
279 )
280 sys.exit(1)
281
282 def set_csrf_and_allowed_hosts(self):
283 csrf_trusted_origins = json.dumps(
284 [
285 self.url,
286 ]
287 )
288 allowed_hosts = json.dumps([self.hostname])
289
290 # Set environment variables
291 self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
292 self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
293
294 self.plain_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
295 self.custom_process_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
296
297 click.secho(
298 f"Automatically set PLAIN_ALLOWED_HOSTS={allowed_hosts} PLAIN_CSRF_TRUSTED_ORIGINS={csrf_trusted_origins}",
299 dim=True,
300 )
301
302 def run_preflight(self):
303 click.echo()
304 if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
305 click.secho("Preflight check failed!", fg="red")
306 sys.exit(1)
307
308 def add_gunicorn(self):
309 plain_db_installed = find_spec("plain.models") is not None
310
311 # Watch .env files for reload
312 extra_watch_files = []
313 for f in os.listdir(APP_PATH.parent):
314 if f.startswith(".env"):
315 extra_watch_files.append(f)
316
317 reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
318 gunicorn_cmd = [
319 "gunicorn",
320 "--bind",
321 f"{self.hostname}:{self.port}",
322 "--certfile",
323 str(self.ssl_cert_path),
324 "--keyfile",
325 str(self.ssl_key_path),
326 "--reload",
327 "plain.wsgi:app",
328 "--timeout",
329 "60",
330 "--log-level",
331 self.log_level,
332 "--access-logfile",
333 "-",
334 "--error-logfile",
335 "-",
336 *reload_extra.split(),
337 "--access-logformat",
338 "'\"%(r)s\" status=%(s)s length=%(b)s time=%(M)sms'",
339 "--log-config-json",
340 str(Path(__file__).parent / "gunicorn_logging.json"),
341 ]
342 gunicorn = " ".join(gunicorn_cmd)
343
344 if plain_db_installed:
345 runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
346 else:
347 runserver_cmd = gunicorn
348
349 if "WEB_CONCURRENCY" not in self.plain_env:
350 # Default to two workers to prevent lockups
351 self.plain_env["WEB_CONCURRENCY"] = "2"
352
353 self.poncho.add_process("plain", runserver_cmd, env=self.plain_env)
354
355 def add_entrypoints(self):
356 for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
357 self.poncho.add_process(
358 entry_point.name,
359 f"plain dev entrypoint {entry_point.name}",
360 env=self.plain_env,
361 )
362
363 def add_pyproject_run(self):
364 """Additional processes that only run during `plain dev`."""
365 if not has_pyproject_toml(APP_PATH.parent):
366 return
367
368 with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
369 pyproject = tomllib.load(f)
370
371 run_commands = (
372 pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
373 )
374 for name, data in run_commands.items():
375 env = {
376 **self.custom_process_env,
377 **data.get("env", {}),
378 }
379 self.poncho.add_process(name, data["cmd"], env=env)
380
381 def add_services(self):
382 """Services are things that also run during tests (like a database), and are critical for the app to function."""
383 services = Services.get_services(APP_PATH.parent)
384 for name, data in services.items():
385 env = {
386 **os.environ,
387 "PYTHONUNBUFFERED": "true",
388 **data.get("env", {}),
389 }
390 self.poncho.add_process(name, data["cmd"], env=env)
391
392
393cli.add_command(db_cli)