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 platform
 2import shutil
 3import subprocess
 4import sys
 5import urllib.request
 6
 7import click
 8
 9
10class MkcertManager:
11    def __init__(self):
12        self.mkcert_bin = None
13
14    def setup_mkcert(self, install_path):
15        """Set up mkcert by checking if it's installed or downloading the binary and installing the local CA."""
16        if mkcert_path := shutil.which("mkcert"):
17            # mkcert is already installed somewhere
18            self.mkcert_bin = mkcert_path
19        else:
20            self.mkcert_bin = install_path / "mkcert"
21            install_path.mkdir(parents=True, exist_ok=True)
22            if not self.mkcert_bin.exists():
23                system = platform.system()
24                arch = platform.machine()
25
26                # Map platform.machine() to mkcert's expected architecture strings
27                arch_map = {
28                    "x86_64": "amd64",
29                    "amd64": "amd64",
30                    "AMD64": "amd64",
31                    "arm64": "arm64",
32                    "aarch64": "arm64",
33                }
34                arch = arch_map.get(
35                    arch.lower(), "amd64"
36                )  # Default to amd64 if unknown
37
38                if system == "Darwin":
39                    os_name = "darwin"
40                elif system == "Linux":
41                    os_name = "linux"
42                elif system == "Windows":
43                    os_name = "windows"
44                else:
45                    click.secho("Unsupported OS", fg="red")
46                    sys.exit(1)
47
48                mkcert_url = f"https://dl.filippo.io/mkcert/latest?for={os_name}/{arch}"
49                click.secho(f"Downloading mkcert from {mkcert_url}...", bold=True)
50                urllib.request.urlretrieve(mkcert_url, self.mkcert_bin)
51                self.mkcert_bin.chmod(0o755)
52            self.mkcert_bin = str(self.mkcert_bin)  # Convert Path object to string
53
54        if not self.is_mkcert_ca_installed():
55            click.secho(
56                "Installing mkcert local CA. You may be prompted for your password.",
57                bold=True,
58            )
59            subprocess.run([self.mkcert_bin, "-install"], check=True)
60
61    def is_mkcert_ca_installed(self):
62        """Check if mkcert local CA is already installed using mkcert -check."""
63        try:
64            result = subprocess.run([self.mkcert_bin, "-check"], capture_output=True)
65            output = result.stdout.decode() + result.stderr.decode()
66            if "The local CA is not installed" in output:
67                return False
68            return True
69        except Exception as e:
70            click.secho(f"Error checking mkcert CA installation: {e}", fg="red")
71            return False
72
73    def generate_certs(self, domain, storage_path):
74        cert_path = storage_path / f"{domain}-cert.pem"
75        key_path = storage_path / f"{domain}-key.pem"
76
77        if cert_path.exists() and key_path.exists():
78            return cert_path, key_path
79
80        storage_path.mkdir(parents=True, exist_ok=True)
81
82        # Generate SSL certificates using mkcert
83        click.secho(f"Generating SSL certificates for {domain}...", bold=True)
84        subprocess.run(
85            [
86                self.mkcert_bin,
87                "-cert-file",
88                str(cert_path),
89                "-key-file",
90                str(key_path),
91                domain,
92            ],
93            check=True,
94        )
95
96        return cert_path, key_path