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