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
  • uv lock --locked, if using uv
  • 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 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)