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