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 platform
  4import subprocess
  5import sys
  6from importlib.metadata import entry_points
  7from importlib.util import find_spec
  8from pathlib import Path
  9
 10import click
 11import tomllib
 12
 13from plain.runtime import APP_PATH, settings
 14
 15from .db import cli as db_cli
 16from .mkcert import MkcertManager
 17from .pid import Pid
 18from .poncho.manager import Manager as PonchoManager
 19from .services import Services
 20from .utils import has_pyproject_toml
 21
 22ENTRYPOINT_GROUP = "plain.dev"
 23
 24
 25@click.group(invoke_without_command=True)
 26@click.pass_context
 27@click.option(
 28    "--port",
 29    "-p",
 30    default=8443,
 31    type=int,
 32    help="Port to run the web server on",
 33    envvar="PORT",
 34)
 35def cli(ctx, port):
 36    """Start local development"""
 37
 38    if ctx.invoked_subcommand:
 39        return
 40
 41    returncode = Dev(port=port).run()
 42    if returncode:
 43        sys.exit(returncode)
 44
 45
 46@cli.command()
 47def services():
 48    """Start additional services defined in pyproject.toml"""
 49    Services().run()
 50
 51
 52@cli.command()
 53@click.option(
 54    "--list", "-l", "show_list", is_flag=True, help="List available entrypoints"
 55)
 56@click.argument("entrypoint", required=False)
 57def entrypoint(show_list, entrypoint):
 58    f"""Entrypoints registered under {ENTRYPOINT_GROUP}"""
 59    if not show_list and not entrypoint:
 60        click.secho("Please provide an entrypoint name or use --list", fg="red")
 61        sys.exit(1)
 62
 63    for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
 64        if show_list:
 65            click.echo(entry_point.name)
 66        elif entrypoint == entry_point.name:
 67            entry_point.load()()
 68
 69
 70class Dev:
 71    def __init__(self, *, port):
 72        self.poncho = PonchoManager()
 73        self.port = port
 74        self.plain_env = {
 75            **os.environ,
 76            "PYTHONUNBUFFERED": "true",
 77        }
 78        self.custom_process_env = {
 79            **self.plain_env,
 80            "PORT": str(self.port),
 81            "PYTHONPATH": os.path.join(APP_PATH.parent, "app"),
 82        }
 83        self.project_name = os.path.basename(os.getcwd())
 84        self.domain = f"{self.project_name}.localhost"
 85        self.ssl_cert_path = None
 86        self.ssl_key_path = None
 87
 88    def run(self):
 89        pid = Pid()
 90        pid.write()
 91
 92        try:
 93            mkcert_manager = MkcertManager()
 94            mkcert_manager.setup_mkcert(install_path=Path.home() / ".plain" / "dev")
 95            self.ssl_cert_path, self.ssl_key_path = mkcert_manager.generate_certs(
 96                domain=self.domain,
 97                storage_path=Path(settings.PLAIN_TEMP_PATH) / "dev" / "certs",
 98            )
 99            self.modify_hosts_file()
100            self.set_csrf_trusted_origins()
101            self.set_allowed_hosts()
102            self.run_preflight()
103
104            # Processes for poncho to run simultaneously
105            self.add_gunicorn()
106            self.add_entrypoints()
107            self.add_pyproject_run()
108            self.add_services()
109
110            # Output the clickable link before starting the manager loop
111            url = f"https://{self.domain}:{self.port}/"
112            click.secho(
113                f"\nYour application is running at: {click.style(url, fg='green', underline=True)}\n",
114                bold=True,
115            )
116
117            self.poncho.loop()
118
119            return self.poncho.returncode
120        finally:
121            pid.rm()
122
123    def modify_hosts_file(self):
124        """Modify the hosts file to map the custom domain to 127.0.0.1."""
125        entry_identifier = "# Added by plain"
126        hosts_entry = f"127.0.0.1 {self.domain}  {entry_identifier}"
127
128        if platform.system() == "Windows":
129            hosts_path = Path(r"C:\Windows\System32\drivers\etc\hosts")
130            try:
131                with hosts_path.open("r") as f:
132                    content = f.read()
133
134                if hosts_entry in content:
135                    return  # Entry already exists; no action needed
136
137                # Entry does not exist; add it
138                with hosts_path.open("a") as f:
139                    f.write(f"{hosts_entry}\n")
140                click.secho(f"Added {self.domain} to {hosts_path}", bold=True)
141            except PermissionError:
142                click.secho(
143                    "Permission denied while modifying hosts file. Please run the script as an administrator.",
144                    fg="red",
145                )
146                sys.exit(1)
147        else:
148            # For macOS and Linux
149            hosts_path = Path("/etc/hosts")
150            try:
151                with hosts_path.open("r") as f:
152                    content = f.read()
153
154                if hosts_entry in content:
155                    return  # Entry already exists; no action needed
156
157                # Entry does not exist; append it using sudo
158                click.secho(
159                    "Modifying /etc/hosts file. You may be prompted for your password.",
160                    bold=True,
161                )
162                cmd = f"echo '{hosts_entry}' | sudo tee -a {hosts_path} >/dev/null"
163                subprocess.run(cmd, shell=True, check=True)
164                click.secho(f"Added {self.domain} to {hosts_path}", bold=True)
165            except PermissionError:
166                click.secho(
167                    "Permission denied while accessing hosts file.",
168                    fg="red",
169                )
170                sys.exit(1)
171            except subprocess.CalledProcessError:
172                click.secho(
173                    "Failed to modify hosts file. Please ensure you have sudo privileges.",
174                    fg="red",
175                )
176                sys.exit(1)
177
178    def set_csrf_trusted_origins(self):
179        csrf_trusted_origins = json.dumps(
180            [
181                f"https://{self.domain}:{self.port}",
182            ]
183        )
184
185        click.secho(
186            f"Automatically set PLAIN_CSRF_TRUSTED_ORIGINS={click.style(csrf_trusted_origins, underline=True)}",
187            bold=True,
188        )
189
190        # Set environment variables
191        self.plain_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
192        self.custom_process_env["PLAIN_CSRF_TRUSTED_ORIGINS"] = csrf_trusted_origins
193
194    def set_allowed_hosts(self):
195        allowed_hosts = json.dumps([self.domain])
196
197        click.secho(
198            f"Automatically set PLAIN_ALLOWED_HOSTS={click.style(allowed_hosts, underline=True)}",
199            bold=True,
200        )
201
202        # Set environment variables
203        self.plain_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
204        self.custom_process_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
205
206    def run_preflight(self):
207        if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
208            click.secho("Preflight check failed!", fg="red")
209            sys.exit(1)
210
211    def add_gunicorn(self):
212        plain_db_installed = find_spec("plain.models") is not None
213
214        # Watch .env files for reload
215        extra_watch_files = []
216        for f in os.listdir(APP_PATH.parent):
217            if f.startswith(".env"):
218                extra_watch_files.append(f)
219
220        reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
221        gunicorn_cmd = [
222            "gunicorn",
223            "--bind",
224            f"{self.domain}:{self.port}",
225            "--certfile",
226            str(self.ssl_cert_path),
227            "--keyfile",
228            str(self.ssl_key_path),
229            "--reload",
230            "plain.wsgi:app",
231            "--timeout",
232            "60",
233            "--access-logfile",
234            "-",
235            "--error-logfile",
236            "-",
237            *reload_extra.split(),
238            "--access-logformat",
239            "'\"%(r)s\" status=%(s)s length=%(b)s dur=%(M)sms'",
240        ]
241        gunicorn = " ".join(gunicorn_cmd)
242
243        if plain_db_installed:
244            runserver_cmd = f"plain models db-wait && plain migrate && {gunicorn}"
245        else:
246            runserver_cmd = gunicorn
247
248        if "WEB_CONCURRENCY" not in self.plain_env:
249            # Default to two workers to prevent lockups
250            self.plain_env["WEB_CONCURRENCY"] = "2"
251
252        self.poncho.add_process("plain", runserver_cmd, env=self.plain_env)
253
254    def add_entrypoints(self):
255        for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
256            self.poncho.add_process(
257                entry_point.name,
258                f"plain dev entrypoint {entry_point.name}",
259                env=self.plain_env,
260            )
261
262    def add_pyproject_run(self):
263        if not has_pyproject_toml(APP_PATH.parent):
264            return
265
266        with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
267            pyproject = tomllib.load(f)
268
269        run_commands = (
270            pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
271        )
272        for name, data in run_commands.items():
273            env = {
274                **self.custom_process_env,
275                **data.get("env", {}),
276            }
277            self.poncho.add_process(name, data["cmd"], env=env)
278
279    def add_services(self):
280        services = Services.get_services(APP_PATH.parent)
281        for name, data in services.items():
282            env = {
283                **os.environ,
284                "PYTHONUNBUFFERED": "true",
285                **data.get("env", {}),
286            }
287            self.poncho.add_process(name, data["cmd"], env=env)
288
289
290cli.add_command(db_cli)