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 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)
69
70
71@register_cli("dev")
72@click.group(cls=DevGroup, invoke_without_command=True)
73@click.pass_context
74@click.option(
75 "--port",
76 "-p",
77 default="",
78 type=str,
79 help=(
80 "Port to run the web server on. "
81 "If omitted, tries 8443 and picks the next free port."
82 ),
83)
84@click.option(
85 "--hostname",
86 "-h",
87 default=None,
88 type=str,
89 help="Hostname to run the web server on",
90)
91@click.option(
92 "--log-level",
93 "-l",
94 default="",
95 type=click.Choice(["debug", "info", "warning", "error", "critical", ""]),
96 help="Log level",
97)
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)
110def cli(ctx, port, hostname, log_level, start, stop):
111 """Start local development"""
112
113 if ctx.invoked_subcommand:
114 return
115
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"
122
123 dev = DevProcess()
124
125 if stop:
126 if ServicesProcess.running_pid():
127 ServicesProcess().stop_process()
128 click.secho("Services stopped.", fg="green")
129
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
137
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()
165 if returncode:
166 sys.exit(returncode)
167
168
169@cli.command()
170def debug():
171 """Connect to the remote debugger"""
172
173 def _connect():
174 if subprocess.run(["which", "nc"], capture_output=True).returncode == 0:
175 return subprocess.run(["nc", "-C", "localhost", "4444"])
176 else:
177 raise OSError("nc not found")
178
179 result = _connect()
180
181 # Try again once without a message
182 if result.returncode == 1:
183 time.sleep(1)
184 result = _connect()
185
186 # Keep trying...
187 while result.returncode == 1:
188 click.secho(
189 "Failed to connect. Make sure remote pdb is ready. Retrying...", fg="red"
190 )
191 result = _connect()
192 time.sleep(1)
193
194
195@cli.command()
196@click.option("--start", is_flag=True, help="Start in the background")
197@click.option("--stop", is_flag=True, help="Stop the background process")
198def services(start, stop):
199 """Start additional services defined in pyproject.toml"""
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")
212 return
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")
238def 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())
267
268
269@cli.command()
270@click.option(
271 "--list", "-l", "show_list", is_flag=True, help="List available entrypoints"
272)
273@click.argument("entrypoint", required=False)
274def entrypoint(show_list, entrypoint):
275 """Entrypoints registered under plain.dev"""
276 if not show_list and not entrypoint:
277 raise click.UsageError("Please provide an entrypoint name or use --list")
278
279 for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
280 if show_list:
281 click.echo(entry_point.name)
282 elif entrypoint == entry_point.name:
283 entry_point.load()()