Skip to content

Commit f9dc286

Browse files
committed
Add plain server command, replace gunicorn
1 parent fb8a00c commit f9dc286

File tree

5 files changed

+185
-43
lines changed

5 files changed

+185
-43
lines changed

plain-dev/plain/dev/core.py

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def run(self) -> int:
167167
# another thread checking db stuff...
168168
self.console_status.start()
169169

170-
self.add_gunicorn()
170+
self.add_server()
171171
self.add_entrypoints()
172172
self.add_pyproject_run()
173173

@@ -271,18 +271,16 @@ def run_preflight(self) -> None:
271271
click.secho("Preflight check failed!", fg="red")
272272
sys.exit(1)
273273

274-
def add_gunicorn(self) -> None:
275-
# Watch .env files for reload
276-
extra_watch_files = []
277-
for f in os.listdir(APP_PATH.parent):
278-
if f.startswith(".env"):
279-
# Needs to be absolute or "./" for inotify to work on Linux...
280-
# https://github.com/dropseed/plain/issues/26
281-
extra_watch_files.append(str(Path(APP_PATH.parent) / f))
282-
283-
reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
284-
gunicorn_cmd = [
285-
"gunicorn",
274+
def add_server(self) -> None:
275+
"""Add the Plain HTTP server process."""
276+
# Build the server command using plain's internal server
277+
# Note: We can't use reload here because watchfiles is handled at a higher level
278+
# The server command will use gunicorn's built-in reload capability
279+
server_cmd = [
280+
sys.executable,
281+
"-m",
282+
"plain",
283+
"server",
286284
"--bind",
287285
f"{self.hostname}:{self.port}",
288286
"--certfile",
@@ -291,25 +289,37 @@ def add_gunicorn(self) -> None:
291289
str(self.ssl_key_path),
292290
"--threads",
293291
"4",
294-
"--reload",
295-
"plain.wsgi:app",
296292
"--timeout",
297293
"60",
298294
"--log-level",
299295
self.log_level or "info",
300-
"--access-logfile",
301-
"-",
302-
"--error-logfile",
303-
"-",
304-
*reload_extra.split(),
305-
"--access-logformat",
306-
"'\"%(r)s\" status=%(s)s length=%(b)s time=%(M)sms'",
307-
"--log-config-json",
308-
str(Path(__file__).parent / "gunicorn_logging.json"),
296+
"--reload", # Enable auto-reload for development
309297
]
310-
gunicorn = " ".join(gunicorn_cmd)
311298

312-
self.poncho.add_process("plain", gunicorn, env=self.plain_env)
299+
# Watch .env files for reload
300+
extra_watch_files = []
301+
for f in os.listdir(APP_PATH.parent):
302+
if f.startswith(".env"):
303+
# Needs to be absolute or "./" for inotify to work on Linux...
304+
# https://github.com/dropseed/plain/issues/26
305+
extra_watch_files.append(str(Path(APP_PATH.parent) / f))
306+
307+
# Add extra watch files
308+
for watch_file in extra_watch_files:
309+
server_cmd.extend(["--reload-extra-file", watch_file])
310+
311+
# Add logging configuration
312+
server_cmd.extend(
313+
[
314+
"--log-format",
315+
"'[%(levelname)s] %(message)s'",
316+
"--access-log-format",
317+
"'\"%(r)s\" status=%(s)s length=%(b)s time=%(M)sms'",
318+
]
319+
)
320+
321+
server = " ".join(server_cmd)
322+
self.poncho.add_process("plain", server, env=self.plain_env)
313323

314324
def add_entrypoints(self) -> None:
315325
for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):

plain-dev/pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ dependencies = [
1010
"plain<1.0.0",
1111
"click>=8.0.0",
1212
"python-dotenv~=1.0.0",
13-
"gunicorn>20",
1413
"requests>=2.0.0",
1514
"rich",
1615
"inotify",

plain/plain/cli/runtime.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44
This module provides decorators and utilities for CLI commands.
55
"""
66

7+
from collections.abc import Callable
8+
from typing import TypeVar
79

8-
def without_runtime_setup(f):
10+
F = TypeVar("F", bound=Callable)
11+
12+
13+
def without_runtime_setup(f: F) -> F:
914
"""
1015
Decorator to mark commands that don't need plain.runtime.setup().
1116
@@ -19,5 +24,5 @@ def without_runtime_setup(f):
1924
def server(**options):
2025
...
2126
"""
22-
f.without_runtime_setup = True
27+
f.without_runtime_setup = True # type: ignore[attr-defined] # dynamic attribute for decorator
2328
return f

plain/plain/cli/server.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import click
2+
3+
from plain.cli.runtime import without_runtime_setup
4+
5+
6+
@without_runtime_setup
7+
@click.command()
8+
@click.option(
9+
"--bind",
10+
"-b",
11+
multiple=True,
12+
default=["127.0.0.1:8000"],
13+
help="Address to bind to (HOST:PORT, can be used multiple times)",
14+
)
15+
@click.option(
16+
"--threads",
17+
type=int,
18+
default=1,
19+
help="Number of threads per worker",
20+
show_default=True,
21+
)
22+
@click.option(
23+
"--workers",
24+
"-w",
25+
type=int,
26+
default=1,
27+
help="Number of worker processes",
28+
show_default=True,
29+
)
30+
@click.option(
31+
"--timeout",
32+
"-t",
33+
type=int,
34+
default=30,
35+
help="Worker timeout in seconds",
36+
show_default=True,
37+
)
38+
@click.option(
39+
"--certfile",
40+
type=click.Path(exists=True),
41+
help="SSL certificate file",
42+
)
43+
@click.option(
44+
"--keyfile",
45+
type=click.Path(exists=True),
46+
help="SSL key file",
47+
)
48+
@click.option(
49+
"--log-level",
50+
default="info",
51+
type=click.Choice(["debug", "info", "warning", "error", "critical"]),
52+
help="Logging level",
53+
show_default=True,
54+
)
55+
@click.option(
56+
"--reload",
57+
is_flag=True,
58+
help="Restart workers when code changes (dev only)",
59+
)
60+
@click.option(
61+
"--reload-extra-file",
62+
multiple=True,
63+
type=click.Path(exists=True),
64+
help="Additional files to watch for reload (can be used multiple times)",
65+
)
66+
@click.option(
67+
"--access-log",
68+
default="-",
69+
help="Access log file (use '-' for stdout)",
70+
show_default=True,
71+
)
72+
@click.option(
73+
"--error-log",
74+
default="-",
75+
help="Error log file (use '-' for stderr)",
76+
show_default=True,
77+
)
78+
@click.option(
79+
"--log-format",
80+
default="%(asctime)s [%(process)d] [%(levelname)s] %(message)s",
81+
help="Log format string (applies to both error and access logs)",
82+
show_default=True,
83+
)
84+
@click.option(
85+
"--access-log-format",
86+
help="Access log format string (HTTP request details)",
87+
default='%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"',
88+
show_default=True,
89+
)
90+
@click.option(
91+
"--max-requests",
92+
type=int,
93+
default=0,
94+
help="Max requests before worker restart (0=disabled)",
95+
show_default=True,
96+
)
97+
@click.option(
98+
"--pidfile",
99+
type=click.Path(),
100+
help="PID file path",
101+
)
102+
def server(
103+
bind: tuple[str, ...],
104+
threads: int,
105+
workers: int,
106+
timeout: int,
107+
certfile: str | None,
108+
keyfile: str | None,
109+
log_level: str,
110+
reload: bool,
111+
reload_extra_file: tuple[str, ...],
112+
access_log: str,
113+
error_log: str,
114+
log_format: str,
115+
access_log_format: str,
116+
max_requests: int,
117+
pidfile: str | None,
118+
) -> None:
119+
"""
120+
Run a production-ready WSGI server.
121+
"""
122+
from plain.server import ServerApplication
123+
from plain.server.config import Config
124+
125+
cfg = Config(
126+
bind=list(bind),
127+
threads=threads,
128+
workers=workers,
129+
timeout=timeout,
130+
max_requests=max_requests,
131+
reload=reload,
132+
reload_extra_files=list(reload_extra_file) if reload_extra_file else [],
133+
pidfile=pidfile,
134+
certfile=certfile,
135+
keyfile=keyfile,
136+
loglevel=log_level,
137+
accesslog=access_log,
138+
errorlog=error_log,
139+
log_format=log_format,
140+
access_log_format=access_log_format,
141+
)
142+
ServerApplication(cfg=cfg).run()

uv.lock

Lines changed: 0 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)