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 errno
  2import logging
  3import re
  4import socket
  5import sys
  6from pdb import Pdb
  7
  8log = logging.getLogger(__name__)
  9
 10
 11def cry(message, stderr=sys.__stderr__):
 12    log.critical(message)
 13    print(message, file=stderr)
 14    stderr.flush()
 15
 16
 17class LF2CRLF_FileWrapper:
 18    def __init__(self, connection):
 19        self.connection = connection
 20        self.stream = fh = connection.makefile("rw")
 21        self.read = fh.read
 22        self.readline = fh.readline
 23        self.readlines = fh.readlines
 24        self.close = fh.close
 25        self.flush = fh.flush
 26        self.fileno = fh.fileno
 27        if hasattr(fh, "encoding"):
 28            self._send = lambda data: connection.sendall(data.encode(fh.encoding))
 29        else:
 30            self._send = connection.sendall
 31
 32    @property
 33    def encoding(self):
 34        return self.stream.encoding
 35
 36    def __iter__(self):
 37        return self.stream.__iter__()
 38
 39    def write(self, data, nl_rex=re.compile("\r?\n")):
 40        data = nl_rex.sub("\r\n", data)
 41        self._send(data)
 42
 43    def writelines(self, lines, nl_rex=re.compile("\r?\n")):
 44        for line in lines:
 45            self.write(line, nl_rex)
 46
 47
 48class DevPdb(Pdb):
 49    """
 50    This will run pdb as a ephemeral telnet service. Once you connect no one
 51    else can connect. On construction this object will block execution till a
 52    client has connected.
 53
 54    Based on https://github.com/tamentis/rpdb I think ...
 55
 56    To use this::
 57
 58        DevPdb(host='0.0.0.0', port=4444).set_trace()
 59
 60    Then run: telnet 127.0.0.1 4444
 61    """
 62
 63    active_instance = None
 64
 65    def __init__(self, host, port, patch_stdstreams=False, quiet=False):
 66        self._quiet = quiet
 67        listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 68        listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
 69        listen_socket.bind((host, port))
 70        if not self._quiet:
 71            cry(
 72                "DevPdb session open at {}:{}, waiting for connection ...".format(
 73                    *listen_socket.getsockname()
 74                )
 75            )
 76        listen_socket.listen(1)
 77        connection, address = listen_socket.accept()
 78        if not self._quiet:
 79            cry(f"DevPdb accepted connection from {repr(address)}.")
 80        self.handle = LF2CRLF_FileWrapper(connection)
 81        Pdb.__init__(self, completekey="tab", stdin=self.handle, stdout=self.handle)
 82        self.backup = []
 83        if patch_stdstreams:
 84            for name in (
 85                "stderr",
 86                "stdout",
 87                "__stderr__",
 88                "__stdout__",
 89                "stdin",
 90                "__stdin__",
 91            ):
 92                self.backup.append((name, getattr(sys, name)))
 93                setattr(sys, name, self.handle)
 94        DevPdb.active_instance = self
 95
 96    def __restore(self):
 97        if self.backup and not self._quiet:
 98            cry(f"Restoring streams: {self.backup} ...")
 99        for name, fh in self.backup:
100            setattr(sys, name, fh)
101        self.handle.close()
102        DevPdb.active_instance = None
103
104    def do_quit(self, arg):
105        self.__restore()
106        return Pdb.do_quit(self, arg)
107
108    do_q = do_exit = do_quit
109
110    def set_trace(self, frame=None):
111        if frame is None:
112            frame = sys._getframe().f_back
113        try:
114            Pdb.set_trace(self, frame)
115        except OSError as exc:
116            if exc.errno != errno.ECONNRESET:
117                raise
118
119
120def set_trace(
121    frame=None, host="127.0.0.1", port=4444, patch_stdstreams=False, quiet=False
122):
123    """
124    Opens a remote PDB over a host:port.
125    """
126    devpdb = DevPdb(
127        host=host, port=port, patch_stdstreams=patch_stdstreams, quiet=quiet
128    )
129    devpdb.set_trace(frame=frame)