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 os
 2import subprocess
 3import time
 4from importlib.util import find_spec
 5from pathlib import Path
 6
 7import click
 8from honcho.manager import Manager as HonchoManager
 9
10from plain.runtime import APP_PATH
11
12from .pid import Pid
13from .utils import has_pyproject_toml
14
15try:
16    import tomllib
17except ModuleNotFoundError:
18    import tomli as tomllib
19
20
21class Services:
22    @staticmethod
23    def get_services(root):
24        if not has_pyproject_toml(root):
25            return {}
26
27        with open(Path(root, "pyproject.toml"), "rb") as f:
28            pyproject = tomllib.load(f)
29
30        return (
31            pyproject.get("tool", {})
32            .get("plain", {})
33            .get("dev", {})
34            .get("services", {})
35        )
36
37    def __init__(self):
38        self.manager = HonchoManager()
39
40    def run(self):
41        services = self.get_services(APP_PATH.parent)
42        for name, data in services.items():
43            env = {
44                **os.environ,
45                "PYTHONUNBUFFERED": "true",
46                **data.get("env", {}),
47            }
48            self.manager.add_process(name, data["cmd"], env=env)
49
50        self.manager.loop()
51
52    def __enter__(self):
53        if not self.get_services(APP_PATH.parent):
54            # No-op if no services are defined
55            return
56
57        if Pid().exists():
58            click.secho("Services already running in `plain dev` command", fg="yellow")
59            return
60
61        print("Starting `plain dev services`")
62        self._subprocess = subprocess.Popen(
63            ["plain", "dev", "services"], cwd=APP_PATH.parent
64        )
65
66        if find_spec("plain.models"):
67            time.sleep(0.5)  # Give it a chance to hit on the first try
68            subprocess.check_call(["plain", "models", "db-wait"], env=os.environ)
69        else:
70            # A bit of a hack to wait for the services to start
71            time.sleep(3)
72
73    def __exit__(self, *args):
74        if not hasattr(self, "_subprocess"):
75            return
76
77        self._subprocess.terminate()
78
79        # Flush the buffer so the output doesn't spill over
80        self._subprocess.communicate()