Plain is headed towards 1.0! Subscribe for development updates →

plain.dev

A single command that runs everything you need for local development.

Plain dev command example

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, if plain.tailwind is installed
  • Any custom process defined in pyproject.toml at tool.plain.dev.run
  • Necessary services (ex. Postgres) defined in pyproject.toml at tool.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 at tool.plain.pre-commit.run
  • plain code check, if plain.code is installed
  • poetry check --lock, if using Poetry
  • plain preflight --database default
  • plain migrate --check
  • plain makemigrations --dry-run --check
  • plain compile
  • plain test

VS Code debugging

Debug Plain with VS Code

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)