1import os
2import subprocess
3import sys
4import time
5import tomllib
6from pathlib import Path
7from typing import Any
8
9import click
10
11from plain.runtime import APP_PATH, PLAIN_TEMP_PATH
12
13from .process import Supervisor
14from .utils import has_pyproject_toml
15
16
17def auto_start_services() -> None:
18 """Start dev *services* in the background if not already running."""
19
20 # Check if we're in CI and auto-start is not explicitly enabled
21 if os.environ.get("CI") and os.environ.get("DEV_SERVICES_AUTO") is None:
22 return
23
24 if os.environ.get("DEV_SERVICES_AUTO", "true") not in [
25 "1",
26 "true",
27 "yes",
28 ]:
29 return
30
31 # Only auto-start services for commands that need the database/runtime
32 service_commands = {
33 "postgres",
34 "dev",
35 "migrations",
36 "preflight",
37 "request",
38 "run",
39 "shell",
40 "test",
41 }
42 if not (service_commands & set(sys.argv)):
43 return
44
45 # Don't do anything if it looks like a "services" command is being run explicitly
46 if "dev" in sys.argv:
47 if "logs" in sys.argv or "services" in sys.argv or "--stop" in sys.argv:
48 return
49
50 if not ServicesSupervisor.get_services(APP_PATH.parent):
51 return
52
53 # Cheap pre-check to avoid a needless spawn; the spawned supervisor's
54 # acquire() is the real guarantee against double-starting.
55 if ServicesSupervisor.running_pid():
56 return
57
58 click.secho(
59 "Starting background dev services (terminate with `plain dev --stop`)...",
60 dim=True,
61 )
62
63 ServicesSupervisor.spawn_background()
64
65 # Give services time to start and retry the check
66 wait_times = [0.5, 1, 1] # First check at 0.5s, then 1s intervals
67 for wait_time in wait_times:
68 time.sleep(wait_time)
69 if ServicesSupervisor.running_pid():
70 return # Services started successfully
71
72 # Only show error after multiple attempts
73 if not ServicesSupervisor.running_pid():
74 click.secho(
75 "Failed to start dev services. Here are the logs:",
76 fg="red",
77 )
78 subprocess.run(
79 ["plain", "dev", "logs", "--services"],
80 check=False,
81 )
82 sys.exit(1)
83
84
85class ServicesSupervisor(Supervisor):
86 pidfile = PLAIN_TEMP_PATH / "dev" / "services.pid"
87 log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "services"
88 background_command = ["dev", "services"]
89 display_name = "Services"
90
91 @staticmethod
92 def get_services(root: str | Path) -> dict[str, Any]:
93 if not has_pyproject_toml(root):
94 return {}
95
96 with open(Path(root, "pyproject.toml"), "rb") as f:
97 pyproject = tomllib.load(f)
98
99 return (
100 pyproject.get("tool", {})
101 .get("plain", {})
102 .get("dev", {})
103 .get("services", {})
104 )
105
106 def run(self) -> None:
107 if not self.acquire():
108 click.secho(self.already_running_message(self.read_pidfile()), fg="yellow")
109 return
110
111 self.prepare_log()
112 self.init_poncho(print)
113
114 assert self.poncho is not None, "poncho should be initialized"
115
116 try:
117 services = self.get_services(APP_PATH.parent)
118 for name, data in services.items():
119 env = {
120 **os.environ,
121 "PYTHONUNBUFFERED": "true",
122 "FORCE_COLOR": "1",
123 **data.get("env", {}),
124 }
125 self.poncho.add_process(name, data["cmd"], env=env)
126
127 self.poncho.loop()
128 finally:
129 self.release()
130 self.close()