plain.dev 0.32.1__py3-none-any.whl → 0.33.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plain/dev/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # plain-dev changelog
2
2
 
3
+ ## [0.33.0](https://github.com/dropseed/plain/releases/plain-dev@0.33.0) (2025-07-18)
4
+
5
+ ### What's changed
6
+
7
+ - Added automatic background startup of dev services when running `plain dev` commands. Services defined in `pyproject.toml` will now start automatically ([0a5ffc6de5](https://github.com/dropseed/plain/commit/0a5ffc6de5)).
8
+ - Added `plain dev logs` command to view output from recent `plain dev` runs. Supports options like `--follow`, `--pid`, `--path`, and `--services` to manage and view different log outputs ([0a5ffc6de5](https://github.com/dropseed/plain/commit/0a5ffc6de5)).
9
+ - Added `--start` and `--stop` flags to both `plain dev` and `plain dev services` commands for running processes in the background. Use `plain dev --start` to launch the dev server in background mode and `plain dev --stop` to terminate it ([0a5ffc6de5](https://github.com/dropseed/plain/commit/0a5ffc6de5)).
10
+ - Improved process management with better PID tracking and graceful shutdown handling for both dev server and services ([0a5ffc6de5](https://github.com/dropseed/plain/commit/0a5ffc6de5)).
11
+ - Improved CLI error handling by using `click.UsageError` instead of manual error printing and `sys.exit()` ([88f06c5184](https://github.com/dropseed/plain/commit/88f06c5184)).
12
+ - Removed `psycopg[binary]` dependency from plain-dev as database drivers should be installed separately based on project needs ([63224001c9](https://github.com/dropseed/plain/commit/63224001c9)).
13
+
14
+ ### Upgrade instructions
15
+
16
+ - No changes required
17
+
3
18
  ## [0.32.1](https://github.com/dropseed/plain/releases/plain-dev@0.32.1) (2025-06-27)
4
19
 
5
20
  ### What's changed
plain/dev/README.md CHANGED
@@ -8,6 +8,7 @@ The `plain.dev` package can be [installed from PyPI](https://pypi.org/project/pl
8
8
 
9
9
  - [`plain dev`](#plain-dev)
10
10
  - [`plain dev services`](#plain-dev-services)
11
+ - [`plain dev logs`](#plain-dev-logs)
11
12
  - [`plain pre-commit`](#plain-pre-commit)
12
13
  - [`plain contrib`](#plain-contrib)
13
14
  - [VS Code debugging](#vscode-debugging)
@@ -44,12 +45,26 @@ Unlike [services](#services), custom processes are _only_ run during `plain dev`
44
45
  ```toml
45
46
  # pyproject.toml
46
47
  [tool.plain.dev.run]
47
- ngrok = {command = "ngrok http $PORT"}
48
+ ngrok = {command = "ngrok http $PORT"}
48
49
  ```
49
50
 
50
51
  ## `plain dev services`
51
52
 
52
53
  Starts your [services](#services) by themselves.
54
+ Logs are stored in `.plain/dev/logs/services/`.
55
+
56
+ ## `plain dev logs`
57
+
58
+ Show output from recent `plain dev` runs.
59
+
60
+ Logs are stored in `.plain/dev/logs/run/`.
61
+
62
+ ```bash
63
+ plain dev logs # print last log
64
+ plain dev logs -f # follow the latest log
65
+ plain dev logs --pid 1234
66
+ plain dev logs --path
67
+ ```
53
68
 
54
69
  ## `plain pre-commit`
55
70
 
plain/dev/cli.py CHANGED
@@ -1,38 +1,75 @@
1
- import importlib
2
- import json
3
- import multiprocessing
4
1
  import os
5
- import platform
6
- import signal
7
- import socket
8
2
  import subprocess
9
3
  import sys
10
4
  import time
11
- import tomllib
12
5
  from importlib.metadata import entry_points
13
- from importlib.util import find_spec
14
- from pathlib import Path
15
6
 
16
7
  import click
17
- from rich.columns import Columns
18
- from rich.console import Console
19
- from rich.text import Text
20
8
 
21
9
  from plain.cli import register_cli
22
10
  from plain.runtime import APP_PATH, PLAIN_TEMP_PATH
23
11
 
24
- from .dev_pid import DevPid
25
- from .mkcert import MkcertManager
26
- from .poncho.manager import Manager as PonchoManager
27
- from .poncho.printer import Printer
28
- from .services import Services, ServicesPid
29
- from .utils import has_pyproject_toml
12
+ from .core import ENTRYPOINT_GROUP, DevProcess
13
+ from .services import ServicesProcess
30
14
 
31
- ENTRYPOINT_GROUP = "plain.dev"
15
+
16
+ class DevGroup(click.Group):
17
+ """Custom group that ensures *services* are running on CLI startup."""
18
+
19
+ def __init__(self, *args, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+ self._auto_start_services()
22
+
23
+ @staticmethod
24
+ def _auto_start_services():
25
+ """Start dev *services* in the background if not already running."""
26
+
27
+ if os.environ.get("PLAIN_DEV_SERVICES_AUTO", "true") not in [
28
+ "1",
29
+ "true",
30
+ "yes",
31
+ ]:
32
+ return
33
+
34
+ # Don't do anything if it looks like a "services" command is being run explicitly
35
+ if "services" in sys.argv or "--stop" in sys.argv:
36
+ return
37
+
38
+ if not ServicesProcess.get_services(APP_PATH.parent):
39
+ return
40
+
41
+ if ServicesProcess.running_pid():
42
+ return
43
+
44
+ click.secho(
45
+ "Starting background dev services (terminate with `plain dev --stop`)...",
46
+ dim=True,
47
+ )
48
+
49
+ subprocess.Popen(
50
+ [sys.executable, "-m", "plain", "dev", "services", "--start"],
51
+ start_new_session=True,
52
+ stdout=subprocess.DEVNULL,
53
+ stderr=subprocess.DEVNULL,
54
+ )
55
+
56
+ time.sleep(0.5) # Give it a moment to start
57
+
58
+ # If it's already dead, show the output and quit
59
+ if not ServicesProcess.running_pid():
60
+ click.secho(
61
+ "Failed to start dev services. Here are the logs:",
62
+ fg="red",
63
+ )
64
+ subprocess.run(
65
+ ["plain", "dev", "logs", "--services"],
66
+ check=False,
67
+ )
68
+ sys.exit(1)
32
69
 
33
70
 
34
71
  @register_cli("dev")
35
- @click.group(invoke_without_command=True)
72
+ @click.group(cls=DevGroup, invoke_without_command=True)
36
73
  @click.pass_context
37
74
  @click.option(
38
75
  "--port",
@@ -40,8 +77,8 @@ ENTRYPOINT_GROUP = "plain.dev"
40
77
  default="",
41
78
  type=str,
42
79
  help=(
43
- "Port to run the web server on. If omitted, tries 8443 and "
44
- "picks the next free port"
80
+ "Port to run the web server on. "
81
+ "If omitted, tries 8443 and picks the next free port."
45
82
  ),
46
83
  )
47
84
  @click.option(
@@ -58,29 +95,73 @@ ENTRYPOINT_GROUP = "plain.dev"
58
95
  type=click.Choice(["debug", "info", "warning", "error", "critical", ""]),
59
96
  help="Log level",
60
97
  )
61
- def cli(ctx, port, hostname, log_level):
98
+ @click.option(
99
+ "--start",
100
+ is_flag=True,
101
+ default=False,
102
+ help="Start in the background",
103
+ )
104
+ @click.option(
105
+ "--stop",
106
+ is_flag=True,
107
+ default=False,
108
+ help="Stop the background process",
109
+ )
110
+ def cli(ctx, port, hostname, log_level, start, stop):
62
111
  """Start local development"""
63
112
 
64
113
  if ctx.invoked_subcommand:
65
114
  return
66
115
 
67
- if DevPid().exists():
68
- click.secho("`plain dev` already running", fg="yellow")
69
- sys.exit(1)
116
+ if start and stop:
117
+ raise click.UsageError(
118
+ "You cannot use both --start and --stop at the same time."
119
+ )
120
+
121
+ os.environ["PLAIN_DEV_SERVICES_AUTO"] = "false"
70
122
 
71
- if not hostname:
72
- project_name = os.path.basename(
73
- os.getcwd()
74
- ) # Use the directory name by default
123
+ dev = DevProcess()
75
124
 
76
- if has_pyproject_toml(APP_PATH.parent):
77
- with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
78
- pyproject = tomllib.load(f)
79
- project_name = pyproject.get("project", {}).get("name", project_name)
125
+ if stop:
126
+ if ServicesProcess.running_pid():
127
+ ServicesProcess().stop_process()
128
+ click.secho("Services stopped.", fg="green")
80
129
 
81
- hostname = f"{project_name}.localhost"
130
+ if not dev.running_pid():
131
+ click.secho("No development server running.", fg="yellow")
132
+ return
133
+
134
+ dev.stop_process()
135
+ click.secho("Development server stopped.", fg="green")
136
+ return
82
137
 
83
- returncode = Dev(port=port, hostname=hostname, log_level=log_level).run()
138
+ if running_pid := dev.running_pid():
139
+ click.secho(f"`plain dev` already running (pid={running_pid})", fg="yellow")
140
+ sys.exit(1)
141
+
142
+ if start:
143
+ args = [sys.executable, "-m", "plain", "dev"]
144
+ if port:
145
+ args.extend(["--port", port])
146
+ if hostname:
147
+ args.extend(["--hostname", hostname])
148
+ if log_level:
149
+ args.extend(["--log-level", log_level])
150
+
151
+ result = subprocess.Popen(
152
+ args=args,
153
+ start_new_session=True,
154
+ stdout=subprocess.DEVNULL,
155
+ stderr=subprocess.DEVNULL,
156
+ )
157
+ click.secho(
158
+ f"Development server started in the background (pid={result.pid}).",
159
+ fg="green",
160
+ )
161
+ return
162
+
163
+ dev.setup(port=port, hostname=hostname, log_level=log_level)
164
+ returncode = dev.run()
84
165
  if returncode:
85
166
  sys.exit(returncode)
86
167
 
@@ -112,13 +193,77 @@ def debug():
112
193
 
113
194
 
114
195
  @cli.command()
115
- def services():
196
+ @click.option("--start", is_flag=True, help="Start in the background")
197
+ @click.option("--stop", is_flag=True, help="Stop the background process")
198
+ def services(start, stop):
116
199
  """Start additional services defined in pyproject.toml"""
117
- _services = Services()
118
- if _services.are_running():
119
- click.secho("Services already running", fg="yellow")
200
+
201
+ if start and stop:
202
+ raise click.UsageError(
203
+ "You cannot use both --start and --stop at the same time."
204
+ )
205
+
206
+ if stop:
207
+ if not ServicesProcess.running_pid():
208
+ click.secho("No services running.", fg="yellow")
209
+ return
210
+ ServicesProcess().stop_process()
211
+ click.secho("Services stopped.", fg="green")
120
212
  return
121
- _services.run()
213
+
214
+ if running_pid := ServicesProcess.running_pid():
215
+ click.secho(f"Services already running (pid={running_pid})", fg="yellow")
216
+ sys.exit(1)
217
+
218
+ if start:
219
+ result = subprocess.Popen(
220
+ args=[sys.executable, "-m", "plain", "dev", "services"],
221
+ start_new_session=True,
222
+ stdout=subprocess.DEVNULL,
223
+ stderr=subprocess.DEVNULL,
224
+ )
225
+ click.secho(
226
+ f"Services started in the background (pid={result.pid}).", fg="green"
227
+ )
228
+ return
229
+
230
+ ServicesProcess().run()
231
+
232
+
233
+ @cli.command()
234
+ @click.option("--follow", "-f", is_flag=True, help="Follow log output")
235
+ @click.option("--pid", type=int, help="PID to show logs for")
236
+ @click.option("--path", is_flag=True, help="Output log file path")
237
+ @click.option("--services", is_flag=True, help="Show logs for services")
238
+ def logs(follow, pid, path, services):
239
+ """Show logs from recent plain dev runs."""
240
+
241
+ if services:
242
+ log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "services"
243
+ else:
244
+ log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "run"
245
+
246
+ if pid:
247
+ log_path = log_dir / f"{pid}.log"
248
+ if not log_path.exists():
249
+ click.secho(f"No log found for pid {pid}", fg="red")
250
+ return
251
+ else:
252
+ logs = sorted(log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime)
253
+ if not logs:
254
+ click.secho("No logs found", fg="yellow")
255
+ return
256
+ log_path = logs[-1]
257
+
258
+ if path:
259
+ click.echo(str(log_path))
260
+ return
261
+
262
+ if follow:
263
+ subprocess.run(["tail", "-f", str(log_path)])
264
+ else:
265
+ with log_path.open() as f:
266
+ click.echo(f.read())
122
267
 
123
268
 
124
269
  @cli.command()
@@ -129,365 +274,10 @@ def services():
129
274
  def entrypoint(show_list, entrypoint):
130
275
  """Entrypoints registered under plain.dev"""
131
276
  if not show_list and not entrypoint:
132
- click.secho("Please provide an entrypoint name or use --list", fg="red")
133
- sys.exit(1)
277
+ raise click.UsageError("Please provide an entrypoint name or use --list")
134
278
 
135
279
  for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
136
280
  if show_list:
137
281
  click.echo(entry_point.name)
138
282
  elif entrypoint == entry_point.name:
139
283
  entry_point.load()()
140
-
141
-
142
- class Dev:
143
- def __init__(self, *, port, hostname, log_level):
144
- self.hostname = hostname
145
- self.log_level = log_level
146
-
147
- self.pid = DevPid()
148
-
149
- if port:
150
- self.port = int(port)
151
- if not self._port_available(self.port):
152
- click.secho(f"Port {self.port} in use", fg="red")
153
- raise SystemExit(1)
154
- else:
155
- self.port = self._find_open_port(8443)
156
- if self.port != 8443:
157
- click.secho(f"Port 8443 in use, using {self.port}", fg="yellow")
158
-
159
- self.ssl_key_path = None
160
- self.ssl_cert_path = None
161
-
162
- self.url = f"https://{self.hostname}:{self.port}"
163
- self.tunnel_url = os.environ.get("PLAIN_DEV_TUNNEL_URL", "")
164
-
165
- self.plain_env = {
166
- "PYTHONUNBUFFERED": "true",
167
- "PLAIN_DEV": "true",
168
- **os.environ,
169
- }
170
-
171
- if log_level:
172
- self.plain_env["PLAIN_LOG_LEVEL"] = log_level.upper()
173
- self.plain_env["APP_LOG_LEVEL"] = log_level.upper()
174
-
175
- self.custom_process_env = {
176
- **self.plain_env,
177
- "PORT": str(self.port),
178
- "PLAIN_DEV_URL": self.url,
179
- }
180
-
181
- if self.tunnel_url:
182
- status_bar = Columns(
183
- [
184
- Text.from_markup(
185
- f"[bold]Tunnel[/bold] [underline][link={self.tunnel_url}]{self.tunnel_url}[/link][/underline]"
186
- ),
187
- Text.from_markup(
188
- f"[dim][bold]Server[/bold] [link={self.url}]{self.url}[/link][/dim]"
189
- ),
190
- Text.from_markup(
191
- "[dim][bold]Ctrl+C[/bold] to stop[/dim]",
192
- justify="right",
193
- ),
194
- ],
195
- expand=True,
196
- )
197
- else:
198
- status_bar = Columns(
199
- [
200
- Text.from_markup(
201
- f"[bold]Server[/bold] [underline][link={self.url}]{self.url}[/link][/underline]"
202
- ),
203
- Text.from_markup(
204
- "[dim][bold]Ctrl+C[/bold] to stop[/dim]", justify="right"
205
- ),
206
- ],
207
- expand=True,
208
- )
209
- self.console = Console(markup=False, highlight=False)
210
- self.console_status = self.console.status(status_bar)
211
-
212
- self.poncho = PonchoManager(printer=Printer(lambda s: self.console.out(s)))
213
-
214
- def _find_open_port(self, start_port):
215
- port = start_port
216
- while not self._port_available(port):
217
- port += 1
218
- return port
219
-
220
- def _port_available(self, port):
221
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
222
- sock.settimeout(0.5)
223
- result = sock.connect_ex(("127.0.0.1", port))
224
- return result != 0
225
-
226
- def run(self):
227
- self.pid.write()
228
- mkcert_manager = MkcertManager()
229
- mkcert_manager.setup_mkcert(install_path=Path.home() / ".plain" / "dev")
230
- self.ssl_cert_path, self.ssl_key_path = mkcert_manager.generate_certs(
231
- domain=self.hostname,
232
- storage_path=Path(PLAIN_TEMP_PATH) / "dev" / "certs",
233
- )
234
-
235
- self.symlink_plain_src()
236
- self.modify_hosts_file()
237
- self.set_allowed_hosts()
238
- self.run_preflight()
239
-
240
- # If we start services ourselves, we should manage the pidfile
241
- services_pid = None
242
-
243
- # Services start first (or are already running from a separate command)
244
- if Services.are_running():
245
- click.secho("Services already running", fg="yellow")
246
- elif services := Services.get_services(APP_PATH.parent):
247
- click.secho("\nStarting services...", italic=True, dim=True)
248
- services_pid = ServicesPid()
249
- services_pid.write()
250
-
251
- for name, data in services.items():
252
- env = {
253
- **os.environ,
254
- "PYTHONUNBUFFERED": "true",
255
- **data.get("env", {}),
256
- }
257
- self.poncho.add_process(name, data["cmd"], env=env)
258
-
259
- # If plain.models is installed (common) then we
260
- # will do a couple extra things before starting all of the app-related
261
- # processes (this way they don't all have to db-wait or anything)
262
- process = None
263
- if find_spec("plain.models") is not None:
264
- # Use a custom signal to tell the main thread to add
265
- # the app processes once the db is ready
266
- signal.signal(signal.SIGUSR1, self.start_app)
267
-
268
- process = multiprocessing.Process(
269
- target=_process_task, args=(self.plain_env,)
270
- )
271
- process.start()
272
-
273
- # If there are no poncho processes, then let this process finish before
274
- # continuing (vs running in parallel)
275
- if self.poncho.num_processes() == 0:
276
- # Wait for the process to finish
277
- process.join()
278
- else:
279
- # Start the app processes immediately
280
- self.start_app(None, None)
281
-
282
- try:
283
- # Start processes we know about and block the main thread
284
- self.poncho.loop()
285
-
286
- # Remove the status bar
287
- self.console_status.stop()
288
- finally:
289
- self.pid.rm()
290
- # Make sure the services pid gets removed if we set it
291
- if services_pid:
292
- services_pid.rm()
293
-
294
- # Make sure the process is terminated if it is still running
295
- if process and process.is_alive():
296
- os.killpg(os.getpgid(process.pid), signal.SIGTERM)
297
- process.join(timeout=3)
298
- if process.is_alive():
299
- os.killpg(os.getpgid(process.pid), signal.SIGKILL)
300
- process.join()
301
-
302
- return self.poncho.returncode
303
-
304
- def start_app(self, signum, frame):
305
- # This runs in the main thread when SIGUSR1 is received
306
- # (or called directly if no thread).
307
- click.secho("\nStarting app...", italic=True, dim=True)
308
-
309
- # Manually start the status bar now so it isn't bungled by
310
- # another thread checking db stuff...
311
- self.console_status.start()
312
-
313
- self.add_gunicorn()
314
- self.add_entrypoints()
315
- self.add_pyproject_run()
316
-
317
- def symlink_plain_src(self):
318
- """Symlink the plain package into .plain so we can look at it easily"""
319
- plain_path = Path(
320
- importlib.util.find_spec("plain.runtime").origin
321
- ).parent.parent
322
- if not PLAIN_TEMP_PATH.exists():
323
- PLAIN_TEMP_PATH.mkdir()
324
-
325
- symlink_path = PLAIN_TEMP_PATH / "src"
326
-
327
- # The symlink is broken
328
- if symlink_path.is_symlink() and not symlink_path.exists():
329
- symlink_path.unlink()
330
-
331
- # The symlink exists but points to the wrong place
332
- if (
333
- symlink_path.is_symlink()
334
- and symlink_path.exists()
335
- and symlink_path.resolve() != plain_path
336
- ):
337
- symlink_path.unlink()
338
-
339
- if plain_path.exists() and not symlink_path.exists():
340
- symlink_path.symlink_to(plain_path)
341
-
342
- def modify_hosts_file(self):
343
- """Modify the hosts file to map the custom domain to 127.0.0.1."""
344
- entry_identifier = "# Added by plain"
345
- hosts_entry = f"127.0.0.1 {self.hostname} {entry_identifier}"
346
-
347
- if platform.system() == "Windows":
348
- hosts_path = Path(r"C:\Windows\System32\drivers\etc\hosts")
349
- try:
350
- with hosts_path.open("r") as f:
351
- content = f.read()
352
-
353
- if hosts_entry in content:
354
- return # Entry already exists; no action needed
355
-
356
- # Entry does not exist; add it
357
- with hosts_path.open("a") as f:
358
- f.write(f"{hosts_entry}\n")
359
- click.secho(f"Added {self.hostname} to {hosts_path}", bold=True)
360
- except PermissionError:
361
- click.secho(
362
- "Permission denied while modifying hosts file. Please run the script as an administrator.",
363
- fg="red",
364
- )
365
- sys.exit(1)
366
- else:
367
- # For macOS and Linux
368
- hosts_path = Path("/etc/hosts")
369
- try:
370
- with hosts_path.open("r") as f:
371
- content = f.read()
372
-
373
- if hosts_entry in content:
374
- return # Entry already exists; no action needed
375
-
376
- # Entry does not exist; append it using sudo
377
- click.secho(
378
- f"Adding {self.hostname} to /etc/hosts file. You may be prompted for your password.\n",
379
- bold=True,
380
- )
381
- cmd = f"echo '{hosts_entry}' | sudo tee -a {hosts_path} >/dev/null"
382
- subprocess.run(cmd, shell=True, check=True)
383
- click.secho(f"Added {self.hostname} to {hosts_path}\n", bold=True)
384
- except PermissionError:
385
- click.secho(
386
- "Permission denied while accessing hosts file.",
387
- fg="red",
388
- )
389
- sys.exit(1)
390
- except subprocess.CalledProcessError:
391
- click.secho(
392
- "Failed to modify hosts file. Please ensure you have sudo privileges.",
393
- fg="red",
394
- )
395
- sys.exit(1)
396
-
397
- def set_allowed_hosts(self):
398
- if "PLAIN_ALLOWED_HOSTS" not in os.environ:
399
- hostnames = [self.hostname]
400
- if self.tunnel_url:
401
- # Add the tunnel URL to the allowed hosts
402
- hostnames.append(self.tunnel_url.split("://")[1])
403
- allowed_hosts = json.dumps(hostnames)
404
- self.plain_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
405
- self.custom_process_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
406
- click.secho(
407
- f"Automatically set PLAIN_ALLOWED_HOSTS={allowed_hosts}", dim=True
408
- )
409
-
410
- def run_preflight(self):
411
- click.echo()
412
- if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
413
- click.secho("Preflight check failed!", fg="red")
414
- sys.exit(1)
415
-
416
- def add_gunicorn(self):
417
- # Watch .env files for reload
418
- extra_watch_files = []
419
- for f in os.listdir(APP_PATH.parent):
420
- if f.startswith(".env"):
421
- # Needs to be absolute or "./" for inotify to work on Linux...
422
- # https://github.com/dropseed/plain/issues/26
423
- extra_watch_files.append(str(Path(APP_PATH.parent) / f))
424
-
425
- reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
426
- gunicorn_cmd = [
427
- "gunicorn",
428
- "--bind",
429
- f"{self.hostname}:{self.port}",
430
- "--certfile",
431
- str(self.ssl_cert_path),
432
- "--keyfile",
433
- str(self.ssl_key_path),
434
- "--threads",
435
- "4",
436
- "--reload",
437
- "plain.wsgi:app",
438
- "--timeout",
439
- "60",
440
- "--log-level",
441
- self.log_level or "info",
442
- "--access-logfile",
443
- "-",
444
- "--error-logfile",
445
- "-",
446
- *reload_extra.split(),
447
- "--access-logformat",
448
- "'\"%(r)s\" status=%(s)s length=%(b)s time=%(M)sms'",
449
- "--log-config-json",
450
- str(Path(__file__).parent / "gunicorn_logging.json"),
451
- ]
452
- gunicorn = " ".join(gunicorn_cmd)
453
-
454
- self.poncho.add_process("plain", gunicorn, env=self.plain_env)
455
-
456
- def add_entrypoints(self):
457
- for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
458
- self.poncho.add_process(
459
- entry_point.name,
460
- f"plain dev entrypoint {entry_point.name}",
461
- env=self.plain_env,
462
- )
463
-
464
- def add_pyproject_run(self):
465
- """Additional processes that only run during `plain dev`."""
466
- if not has_pyproject_toml(APP_PATH.parent):
467
- return
468
-
469
- with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
470
- pyproject = tomllib.load(f)
471
-
472
- run_commands = (
473
- pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
474
- )
475
- for name, data in run_commands.items():
476
- env = {
477
- **self.custom_process_env,
478
- **data.get("env", {}),
479
- }
480
- self.poncho.add_process(name, data["cmd"], env=env)
481
-
482
-
483
- def _process_task(env):
484
- # Make this process the leader of a new group which can be killed together if it doesn't finish
485
- os.setsid()
486
-
487
- subprocess.run(["plain", "models", "db-wait"], env=env, check=True)
488
- subprocess.run(["plain", "migrate", "--backup"], env=env, check=True)
489
-
490
- # preflight with db?
491
-
492
- # Send SIGUSR1 to the parent process so the parent's handler is invoked
493
- os.kill(os.getppid(), signal.SIGUSR1)