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