Plain is headed towards 1.0! Subscribe for development updates →

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