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