Plain is headed towards 1.0! Subscribe for development updates →

  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()()