1import os
2import subprocess
3import sys
4import time
5from importlib.metadata import entry_points
6
7import click
8
9from plain.cli import register_cli
10from plain.runtime import APP_PATH, PLAIN_TEMP_PATH
11
12from .core import ENTRYPOINT_GROUP, DevProcess
13from .services import ServicesProcess
14
15
16class 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 # Check if we're in CI and auto-start is not explicitly enabled
28 if os.environ.get("CI") and os.environ.get("PLAIN_DEV_SERVICES_AUTO") is None:
29 return
30
31 if os.environ.get("PLAIN_DEV_SERVICES_AUTO", "true") not in [
32 "1",
33 "true",
34 "yes",
35 ]:
36 return
37
38 # Don't do anything if it looks like a "services" command is being run explicitly
39 if "dev" in sys.argv:
40 if "logs" in sys.argv or "services" in sys.argv or "--stop" in sys.argv:
41 return
42
43 if not ServicesProcess.get_services(APP_PATH.parent):
44 return
45
46 if ServicesProcess.running_pid():
47 return
48
49 click.secho(
50 "Starting background dev services (terminate with `plain dev --stop`)...",
51 dim=True,
52 )
53
54 subprocess.Popen(
55 [sys.executable, "-m", "plain", "dev", "services", "--start"],
56 start_new_session=True,
57 stdout=subprocess.DEVNULL,
58 stderr=subprocess.DEVNULL,
59 )
60
61 # Give services time to start and retry the check
62 wait_times = [0.5, 1, 1] # First check at 0.5s, then 1s intervals
63 for wait_time in wait_times:
64 time.sleep(wait_time)
65 if ServicesProcess.running_pid():
66 return # Services started successfully
67
68 # Only show error after multiple attempts
69 if not ServicesProcess.running_pid():
70 click.secho(
71 "Failed to start dev services. Here are the logs:",
72 fg="red",
73 )
74 subprocess.run(
75 ["plain", "dev", "logs", "--services"],
76 check=False,
77 )
78 sys.exit(1)
79
80
81@register_cli("dev")
82@click.group(cls=DevGroup, invoke_without_command=True)
83@click.pass_context
84@click.option(
85 "--port",
86 "-p",
87 default="",
88 type=str,
89 help=(
90 "Port to run the web server on. "
91 "If omitted, tries 8443 and picks the next free port."
92 ),
93)
94@click.option(
95 "--hostname",
96 "-h",
97 default=None,
98 type=str,
99 help="Hostname to run the web server on",
100)
101@click.option(
102 "--log-level",
103 "-l",
104 default="",
105 type=click.Choice(["debug", "info", "warning", "error", "critical", ""]),
106 help="Log level",
107)
108@click.option(
109 "--start",
110 is_flag=True,
111 default=False,
112 help="Start in the background",
113)
114@click.option(
115 "--stop",
116 is_flag=True,
117 default=False,
118 help="Stop the background process",
119)
120def cli(ctx, port, hostname, log_level, start, stop):
121 """Start local development"""
122
123 if ctx.invoked_subcommand:
124 return
125
126 if start and stop:
127 raise click.UsageError(
128 "You cannot use both --start and --stop at the same time."
129 )
130
131 os.environ["PLAIN_DEV_SERVICES_AUTO"] = "false"
132
133 dev = DevProcess()
134
135 if stop:
136 if ServicesProcess.running_pid():
137 ServicesProcess().stop_process()
138 click.secho("Services stopped.", fg="green")
139
140 if not dev.running_pid():
141 click.secho("No development server running.", fg="yellow")
142 return
143
144 dev.stop_process()
145 click.secho("Development server stopped.", fg="green")
146 return
147
148 if running_pid := dev.running_pid():
149 click.secho(f"`plain dev` already running (pid={running_pid})", fg="yellow")
150 sys.exit(1)
151
152 if start:
153 args = [sys.executable, "-m", "plain", "dev"]
154 if port:
155 args.extend(["--port", port])
156 if hostname:
157 args.extend(["--hostname", hostname])
158 if log_level:
159 args.extend(["--log-level", log_level])
160
161 result = subprocess.Popen(
162 args=args,
163 start_new_session=True,
164 stdout=subprocess.DEVNULL,
165 stderr=subprocess.DEVNULL,
166 )
167 click.secho(
168 f"Development server started in the background (pid={result.pid}).",
169 fg="green",
170 )
171 return
172
173 dev.setup(port=port, hostname=hostname, log_level=log_level)
174 returncode = dev.run()
175 if returncode:
176 sys.exit(returncode)
177
178
179@cli.command()
180def debug():
181 """Connect to the remote debugger"""
182
183 def _connect():
184 if subprocess.run(["which", "nc"], capture_output=True).returncode == 0:
185 return subprocess.run(["nc", "-C", "localhost", "4444"])
186 else:
187 raise OSError("nc not found")
188
189 result = _connect()
190
191 # Try again once without a message
192 if result.returncode == 1:
193 time.sleep(1)
194 result = _connect()
195
196 # Keep trying...
197 while result.returncode == 1:
198 click.secho(
199 "Failed to connect. Make sure remote pdb is ready. Retrying...", fg="red"
200 )
201 result = _connect()
202 time.sleep(1)
203
204
205@cli.command()
206@click.option("--start", is_flag=True, help="Start in the background")
207@click.option("--stop", is_flag=True, help="Stop the background process")
208def services(start, stop):
209 """Start additional services defined in pyproject.toml"""
210
211 if start and stop:
212 raise click.UsageError(
213 "You cannot use both --start and --stop at the same time."
214 )
215
216 if stop:
217 if not ServicesProcess.running_pid():
218 click.secho("No services running.", fg="yellow")
219 return
220 ServicesProcess().stop_process()
221 click.secho("Services stopped.", fg="green")
222 return
223
224 if running_pid := ServicesProcess.running_pid():
225 click.secho(f"Services already running (pid={running_pid})", fg="yellow")
226 sys.exit(1)
227
228 if start:
229 result = subprocess.Popen(
230 args=[sys.executable, "-m", "plain", "dev", "services"],
231 start_new_session=True,
232 stdout=subprocess.DEVNULL,
233 stderr=subprocess.DEVNULL,
234 )
235 click.secho(
236 f"Services started in the background (pid={result.pid}).", fg="green"
237 )
238 return
239
240 ServicesProcess().run()
241
242
243@cli.command()
244@click.option("--follow", "-f", is_flag=True, help="Follow log output")
245@click.option("--pid", type=int, help="PID to show logs for")
246@click.option("--path", is_flag=True, help="Output log file path")
247@click.option("--services", is_flag=True, help="Show logs for services")
248def logs(follow, pid, path, services):
249 """Show logs from recent plain dev runs."""
250
251 if services:
252 log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "services"
253 else:
254 log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "run"
255
256 if pid:
257 log_path = log_dir / f"{pid}.log"
258 if not log_path.exists():
259 click.secho(f"No log found for pid {pid}", fg="red")
260 return
261 else:
262 logs = sorted(log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime)
263 if not logs:
264 click.secho("No logs found", fg="yellow")
265 return
266 log_path = logs[-1]
267
268 if path:
269 click.echo(str(log_path))
270 return
271
272 if follow:
273 subprocess.run(["tail", "-f", str(log_path)])
274 else:
275 with log_path.open() as f:
276 click.echo(f.read())
277
278
279@cli.command()
280@click.option(
281 "--list", "-l", "show_list", is_flag=True, help="List available entrypoints"
282)
283@click.argument("entrypoint", required=False)
284def entrypoint(show_list, entrypoint):
285 """Entrypoints registered under plain.dev"""
286 if not show_list and not entrypoint:
287 raise click.UsageError("Please provide an entrypoint name or use --list")
288
289 for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
290 if show_list:
291 click.echo(entry_point.name)
292 elif entrypoint == entry_point.name:
293 entry_point.load()()