plain.dev 0.32.0__py3-none-any.whl → 0.33.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plain/dev/core.py ADDED
@@ -0,0 +1,346 @@
1
+ import json
2
+ import os
3
+ import platform
4
+ import socket
5
+ import subprocess
6
+ import sys
7
+ import tomllib
8
+ from importlib.metadata import entry_points
9
+ from importlib.util import find_spec
10
+ from pathlib import Path
11
+
12
+ import click
13
+ from rich.columns import Columns
14
+ from rich.console import Console
15
+ from rich.text import Text
16
+
17
+ from plain.runtime import APP_PATH, PLAIN_TEMP_PATH
18
+
19
+ from .mkcert import MkcertManager
20
+ from .process import ProcessManager
21
+ from .utils import has_pyproject_toml
22
+
23
+ ENTRYPOINT_GROUP = "plain.dev"
24
+
25
+
26
+ class DevProcess(ProcessManager):
27
+ pidfile = PLAIN_TEMP_PATH / "dev" / "dev.pid"
28
+ log_dir = PLAIN_TEMP_PATH / "dev" / "logs" / "run"
29
+
30
+ def setup(self, *, port, hostname, log_level):
31
+ if not hostname:
32
+ project_name = os.path.basename(
33
+ os.getcwd()
34
+ ) # Use directory name by default
35
+
36
+ if has_pyproject_toml(APP_PATH.parent):
37
+ with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
38
+ pyproject = tomllib.load(f)
39
+ project_name = pyproject.get("project", {}).get(
40
+ "name", project_name
41
+ )
42
+
43
+ hostname = f"{project_name}.localhost"
44
+
45
+ self.hostname = hostname
46
+ self.log_level = log_level
47
+
48
+ self.pid_value = self.pid
49
+ self.prepare_log()
50
+
51
+ if port:
52
+ self.port = int(port)
53
+ if not self._port_available(self.port):
54
+ click.secho(f"Port {self.port} in use", fg="red")
55
+ raise SystemExit(1)
56
+ else:
57
+ self.port = self._find_open_port(8443)
58
+ if self.port != 8443:
59
+ click.secho(f"Port 8443 in use, using {self.port}", fg="yellow")
60
+
61
+ self.ssl_key_path = None
62
+ self.ssl_cert_path = None
63
+
64
+ self.url = f"https://{self.hostname}:{self.port}"
65
+ self.tunnel_url = os.environ.get("PLAIN_DEV_TUNNEL_URL", "")
66
+
67
+ self.plain_env = {
68
+ "PYTHONUNBUFFERED": "true",
69
+ "PLAIN_DEV": "true",
70
+ **os.environ,
71
+ }
72
+
73
+ if log_level:
74
+ self.plain_env["PLAIN_LOG_LEVEL"] = log_level.upper()
75
+ self.plain_env["APP_LOG_LEVEL"] = log_level.upper()
76
+
77
+ self.custom_process_env = {
78
+ **self.plain_env,
79
+ "PORT": str(self.port),
80
+ "PLAIN_DEV_URL": self.url,
81
+ }
82
+
83
+ if self.tunnel_url:
84
+ status_bar = Columns(
85
+ [
86
+ Text.from_markup(
87
+ f"[bold]Tunnel[/bold] [underline][link={self.tunnel_url}]{self.tunnel_url}[/link][/underline]"
88
+ ),
89
+ Text.from_markup(
90
+ f"[dim][bold]Server[/bold] [link={self.url}]{self.url}[/link][/dim]"
91
+ ),
92
+ Text.from_markup(
93
+ "[dim][bold]Ctrl+C[/bold] to stop[/dim]",
94
+ justify="right",
95
+ ),
96
+ ],
97
+ expand=True,
98
+ )
99
+ else:
100
+ status_bar = Columns(
101
+ [
102
+ Text.from_markup(
103
+ f"[bold]Server[/bold] [underline][link={self.url}]{self.url}[/link][/underline]"
104
+ ),
105
+ Text.from_markup(
106
+ "[dim][bold]Ctrl+C[/bold] to stop[/dim]", justify="right"
107
+ ),
108
+ ],
109
+ expand=True,
110
+ )
111
+ self.console = Console(markup=False, highlight=False)
112
+ self.console_status = self.console.status(status_bar)
113
+
114
+ self.init_poncho(self.console.out)
115
+
116
+ def _find_open_port(self, start_port):
117
+ port = start_port
118
+ while not self._port_available(port):
119
+ port += 1
120
+ return port
121
+
122
+ def _port_available(self, port):
123
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
124
+ sock.settimeout(0.5)
125
+ result = sock.connect_ex(("127.0.0.1", port))
126
+ return result != 0
127
+
128
+ def run(self):
129
+ self.write_pidfile()
130
+ mkcert_manager = MkcertManager()
131
+ mkcert_manager.setup_mkcert(install_path=Path.home() / ".plain" / "dev")
132
+ self.ssl_cert_path, self.ssl_key_path = mkcert_manager.generate_certs(
133
+ domain=self.hostname,
134
+ storage_path=Path(PLAIN_TEMP_PATH) / "dev" / "certs",
135
+ )
136
+
137
+ self.symlink_plain_src()
138
+ self.modify_hosts_file()
139
+ self.set_allowed_hosts()
140
+
141
+ click.secho("→ Running preflight checks... ", dim=True, nl=False)
142
+ self.run_preflight()
143
+
144
+ # if ServicesProcess.running_pid():
145
+ # self.poncho.add_process(
146
+ # "services",
147
+ # f"{sys.executable} -m plain dev logs --services --follow",
148
+ # )
149
+
150
+ if find_spec("plain.models"):
151
+ click.secho("→ Waiting for database... ", dim=True, nl=False)
152
+ subprocess.run(
153
+ [sys.executable, "-m", "plain", "models", "db-wait"],
154
+ env=self.plain_env,
155
+ check=True,
156
+ )
157
+ click.secho("→ Running migrations...", dim=True)
158
+ subprocess.run(
159
+ [sys.executable, "-m", "plain", "migrate", "--backup"],
160
+ env=self.plain_env,
161
+ check=True,
162
+ )
163
+
164
+ click.secho("\n→ Starting app...", dim=True)
165
+
166
+ # Manually start the status bar now so it isn't bungled by
167
+ # another thread checking db stuff...
168
+ self.console_status.start()
169
+
170
+ self.add_gunicorn()
171
+ self.add_entrypoints()
172
+ self.add_pyproject_run()
173
+
174
+ try:
175
+ # Start processes we know about and block the main thread
176
+ self.poncho.loop()
177
+
178
+ # Remove the status bar
179
+ self.console_status.stop()
180
+ finally:
181
+ self.rm_pidfile()
182
+ self.close()
183
+
184
+ return self.poncho.returncode
185
+
186
+ def symlink_plain_src(self):
187
+ """Symlink the plain package into .plain so we can look at it easily"""
188
+ plain_path = Path(find_spec("plain.runtime").origin).parent.parent
189
+ if not PLAIN_TEMP_PATH.exists():
190
+ PLAIN_TEMP_PATH.mkdir()
191
+
192
+ symlink_path = PLAIN_TEMP_PATH / "src"
193
+
194
+ # The symlink is broken
195
+ if symlink_path.is_symlink() and not symlink_path.exists():
196
+ symlink_path.unlink()
197
+
198
+ # The symlink exists but points to the wrong place
199
+ if (
200
+ symlink_path.is_symlink()
201
+ and symlink_path.exists()
202
+ and symlink_path.resolve() != plain_path
203
+ ):
204
+ symlink_path.unlink()
205
+
206
+ if plain_path.exists() and not symlink_path.exists():
207
+ symlink_path.symlink_to(plain_path)
208
+
209
+ def modify_hosts_file(self):
210
+ """Modify the hosts file to map the custom domain to 127.0.0.1."""
211
+ entry_identifier = "# Added by plain"
212
+ hosts_entry = f"127.0.0.1 {self.hostname} {entry_identifier}"
213
+
214
+ if platform.system() == "Windows":
215
+ hosts_path = Path(r"C:\Windows\System32\drivers\etc\hosts")
216
+ try:
217
+ with hosts_path.open("r") as f:
218
+ content = f.read()
219
+
220
+ if hosts_entry in content:
221
+ return # Entry already exists; no action needed
222
+
223
+ # Entry does not exist; add it
224
+ with hosts_path.open("a") as f:
225
+ f.write(f"{hosts_entry}\n")
226
+ click.secho(f"Added {self.hostname} to {hosts_path}", bold=True)
227
+ except PermissionError:
228
+ click.secho(
229
+ "Permission denied while modifying hosts file. Please run the script as an administrator.",
230
+ fg="red",
231
+ )
232
+ sys.exit(1)
233
+ else:
234
+ # For macOS and Linux
235
+ hosts_path = Path("/etc/hosts")
236
+ try:
237
+ with hosts_path.open("r") as f:
238
+ content = f.read()
239
+
240
+ if hosts_entry in content:
241
+ return # Entry already exists; no action needed
242
+
243
+ # Entry does not exist; append it using sudo
244
+ click.secho(
245
+ f"Adding {self.hostname} to /etc/hosts file. You may be prompted for your password.\n",
246
+ bold=True,
247
+ )
248
+ cmd = f"echo '{hosts_entry}' | sudo tee -a {hosts_path} >/dev/null"
249
+ subprocess.run(cmd, shell=True, check=True)
250
+ click.secho(f"Added {self.hostname} to {hosts_path}\n", bold=True)
251
+ except PermissionError:
252
+ click.secho(
253
+ "Permission denied while accessing hosts file.",
254
+ fg="red",
255
+ )
256
+ sys.exit(1)
257
+ except subprocess.CalledProcessError:
258
+ click.secho(
259
+ "Failed to modify hosts file. Please ensure you have sudo privileges.",
260
+ fg="red",
261
+ )
262
+ sys.exit(1)
263
+
264
+ def set_allowed_hosts(self):
265
+ if "PLAIN_ALLOWED_HOSTS" not in os.environ:
266
+ hostnames = [self.hostname]
267
+ if self.tunnel_url:
268
+ # Add the tunnel URL to the allowed hosts
269
+ hostnames.append(self.tunnel_url.split("://")[1])
270
+ allowed_hosts = json.dumps(hostnames)
271
+ self.plain_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
272
+ self.custom_process_env["PLAIN_ALLOWED_HOSTS"] = allowed_hosts
273
+ click.secho(
274
+ f"Automatically set PLAIN_ALLOWED_HOSTS={allowed_hosts}", dim=True
275
+ )
276
+
277
+ def run_preflight(self):
278
+ if subprocess.run(["plain", "preflight"], env=self.plain_env).returncode:
279
+ click.secho("Preflight check failed!", fg="red")
280
+ sys.exit(1)
281
+
282
+ def add_gunicorn(self):
283
+ # Watch .env files for reload
284
+ extra_watch_files = []
285
+ for f in os.listdir(APP_PATH.parent):
286
+ if f.startswith(".env"):
287
+ # Needs to be absolute or "./" for inotify to work on Linux...
288
+ # https://github.com/dropseed/plain/issues/26
289
+ extra_watch_files.append(str(Path(APP_PATH.parent) / f))
290
+
291
+ reload_extra = " ".join(f"--reload-extra-file {f}" for f in extra_watch_files)
292
+ gunicorn_cmd = [
293
+ "gunicorn",
294
+ "--bind",
295
+ f"{self.hostname}:{self.port}",
296
+ "--certfile",
297
+ str(self.ssl_cert_path),
298
+ "--keyfile",
299
+ str(self.ssl_key_path),
300
+ "--threads",
301
+ "4",
302
+ "--reload",
303
+ "plain.wsgi:app",
304
+ "--timeout",
305
+ "60",
306
+ "--log-level",
307
+ self.log_level or "info",
308
+ "--access-logfile",
309
+ "-",
310
+ "--error-logfile",
311
+ "-",
312
+ *reload_extra.split(),
313
+ "--access-logformat",
314
+ "'\"%(r)s\" status=%(s)s length=%(b)s time=%(M)sms'",
315
+ "--log-config-json",
316
+ str(Path(__file__).parent / "gunicorn_logging.json"),
317
+ ]
318
+ gunicorn = " ".join(gunicorn_cmd)
319
+
320
+ self.poncho.add_process("plain", gunicorn, env=self.plain_env)
321
+
322
+ def add_entrypoints(self):
323
+ for entry_point in entry_points().select(group=ENTRYPOINT_GROUP):
324
+ self.poncho.add_process(
325
+ entry_point.name,
326
+ f"plain dev entrypoint {entry_point.name}",
327
+ env=self.plain_env,
328
+ )
329
+
330
+ def add_pyproject_run(self):
331
+ """Additional processes that only run during `plain dev`."""
332
+ if not has_pyproject_toml(APP_PATH.parent):
333
+ return
334
+
335
+ with open(Path(APP_PATH.parent, "pyproject.toml"), "rb") as f:
336
+ pyproject = tomllib.load(f)
337
+
338
+ run_commands = (
339
+ pyproject.get("tool", {}).get("plain", {}).get("dev", {}).get("run", {})
340
+ )
341
+ for name, data in run_commands.items():
342
+ env = {
343
+ **self.custom_process_env,
344
+ **data.get("env", {}),
345
+ }
346
+ self.poncho.add_process(name, data["cmd"], env=env)
@@ -1,4 +1,6 @@
1
+ import re
1
2
  from collections import namedtuple
3
+ from pathlib import Path
2
4
 
3
5
  Message = namedtuple("Message", "type data time name color")
4
6
 
@@ -17,12 +19,19 @@ class Printer:
17
19
  width=0,
18
20
  color=True,
19
21
  prefix=True,
22
+ log_file=None,
20
23
  ):
21
24
  self.print_func = print_func
22
25
  self.time_format = time_format
23
26
  self.width = width
24
27
  self.color = color
25
28
  self.prefix = prefix
29
+ if log_file is not None:
30
+ log_path = Path(log_file)
31
+ log_path.parent.mkdir(parents=True, exist_ok=True)
32
+ self.log_file = log_path.open("w", encoding="utf-8")
33
+ else:
34
+ self.log_file = None
26
35
 
27
36
  def write(self, message):
28
37
  if message.type != "line":
@@ -49,7 +58,23 @@ class Printer:
49
58
  if self.color and message.color:
50
59
  prefix = _color_string(message.color, prefix)
51
60
 
52
- self.print_func(prefix + line)
61
+ formatted = prefix + line
62
+
63
+ # Send original (possibly ANSI-coloured) string to stdout.
64
+ self.print_func(formatted)
65
+
66
+ # Strip ANSI escape sequences before persisting to disk so the log
67
+ # file contains plain text only. This avoids leftover control
68
+ # codes (e.g. hidden-cursor) that can confuse terminals when the
69
+ # log is displayed later via `plain dev logs`.
70
+ if self.log_file is not None:
71
+ plain = _ANSI_RE.sub("", formatted)
72
+ self.log_file.write(plain + "\n")
73
+ self.log_file.flush()
74
+
75
+ def close(self):
76
+ if self.log_file and hasattr(self.log_file, "close"):
77
+ self.log_file.close()
53
78
 
54
79
 
55
80
  def _color_string(color, s):
@@ -57,3 +82,8 @@ def _color_string(color, s):
57
82
  return f"\033[{code}m"
58
83
 
59
84
  return f"{_ansi(0)}{_ansi(color)}{s}{_ansi(0)}"
85
+
86
+
87
+ # Regex that matches ANSI escape sequences (e.g. colour codes, cursor control
88
+ # sequences, etc.). Adapted from ECMA-48 / VT100 patterns.
89
+ _ANSI_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
@@ -10,8 +10,6 @@ import click
10
10
  from plain.cli import register_cli
11
11
  from plain.cli.print import print_event
12
12
 
13
- from ..services import Services
14
-
15
13
 
16
14
  def install_git_hook():
17
15
  hook_path = os.path.join(".git", "hooks", "pre-commit")
@@ -38,60 +36,58 @@ def cli(install):
38
36
 
39
37
  pyproject = Path("pyproject.toml")
40
38
 
41
- with Services():
42
- if pyproject.exists():
43
- with open(pyproject, "rb") as f:
44
- pyproject = tomllib.load(f)
45
- for name, data in (
46
- pyproject.get("tool", {})
47
- .get("plain", {})
48
- .get("pre-commit", {})
49
- .get("run", {})
50
- ).items():
51
- cmd = data["cmd"]
52
- print_event(f"Custom: {name} -> {cmd}")
53
- result = subprocess.run(cmd, shell=True)
54
- if result.returncode != 0:
55
- sys.exit(result.returncode)
56
-
57
- # Run this first since it's probably the most likely to fail
58
- if find_spec("plain.code"):
59
- check_short("Running plain code checks", "plain", "code", "check")
60
-
61
- if Path("uv.lock").exists():
62
- check_short("Checking uv.lock", "uv", "lock", "--check")
63
-
64
- if plain_db_connected():
65
- check_short(
66
- "Running preflight checks",
67
- "plain",
68
- "preflight",
69
- "--database",
70
- "default",
71
- )
72
- check_short("Checking Plain migrations", "plain", "migrate", "--check")
73
- check_short(
74
- "Checking for Plain models missing migrations",
75
- "plain",
76
- "makemigrations",
77
- "--dry-run",
78
- "--check",
79
- )
80
- else:
81
- check_short("Running Plain checks (without database)", "plain", "preflight")
82
- click.secho("--> Skipping migration checks", bold=True, fg="yellow")
39
+ if pyproject.exists():
40
+ with open(pyproject, "rb") as f:
41
+ pyproject = tomllib.load(f)
42
+ for name, data in (
43
+ pyproject.get("tool", {})
44
+ .get("plain", {})
45
+ .get("pre-commit", {})
46
+ .get("run", {})
47
+ ).items():
48
+ cmd = data["cmd"]
49
+ print_event(f"Custom: {name} -> {cmd}")
50
+ result = subprocess.run(cmd, shell=True)
51
+ if result.returncode != 0:
52
+ sys.exit(result.returncode)
53
+
54
+ # Run this first since it's probably the most likely to fail
55
+ if find_spec("plain.code"):
56
+ check_short("Running plain code checks", "plain", "code", "check")
57
+
58
+ if Path("uv.lock").exists():
59
+ check_short("Checking uv.lock", "uv", "lock", "--check")
60
+
61
+ if plain_db_connected():
62
+ check_short(
63
+ "Running preflight checks",
64
+ "plain",
65
+ "preflight",
66
+ "--database",
67
+ )
68
+ check_short("Checking Plain migrations", "plain", "migrate", "--check")
69
+ check_short(
70
+ "Checking for Plain models missing migrations",
71
+ "plain",
72
+ "makemigrations",
73
+ "--dry-run",
74
+ "--check",
75
+ )
76
+ else:
77
+ check_short("Running Plain checks (without database)", "plain", "preflight")
78
+ click.secho("--> Skipping migration checks", bold=True, fg="yellow")
79
+
80
+ print_event("Running plain build")
81
+ result = subprocess.run(["plain", "build"])
82
+ if result.returncode != 0:
83
+ sys.exit(result.returncode)
83
84
 
84
- print_event("Running plain build")
85
- result = subprocess.run(["plain", "build"])
85
+ if find_spec("plain.pytest"):
86
+ print_event("Running tests")
87
+ result = subprocess.run(["plain", "test"])
86
88
  if result.returncode != 0:
87
89
  sys.exit(result.returncode)
88
90
 
89
- if find_spec("plain.pytest"):
90
- print_event("Running tests")
91
- result = subprocess.run(["plain", "test"])
92
- if result.returncode != 0:
93
- sys.exit(result.returncode)
94
-
95
91
 
96
92
  def plain_db_connected():
97
93
  result = subprocess.run(
plain/dev/process.py ADDED
@@ -0,0 +1,127 @@
1
+ import os
2
+ import time
3
+ from pathlib import Path
4
+
5
+ from .poncho.manager import Manager as PonchoManager
6
+ from .poncho.printer import Printer
7
+
8
+
9
+ class ProcessManager:
10
+ pidfile: Path
11
+ log_dir: Path
12
+
13
+ def __init__(self):
14
+ self.pid = os.getpid()
15
+ self.log_path: Path | None = None
16
+ self.printer: Printer | None = None
17
+ self.poncho: PonchoManager | None = None
18
+
19
+ # ------------------------------------------------------------------
20
+ # Class-level pidfile helpers (usable without instantiation)
21
+ # ------------------------------------------------------------------
22
+ @classmethod
23
+ def read_pidfile(cls) -> int | None:
24
+ """Return the PID recorded in *cls.pidfile* (or ``None``)."""
25
+ if not cls.pidfile.exists():
26
+ return None
27
+
28
+ try:
29
+ return int(cls.pidfile.read_text())
30
+ except (ValueError, OSError):
31
+ # Corrupted pidfile – remove it so we don't keep trying.
32
+ cls.rm_pidfile()
33
+ return None
34
+
35
+ @classmethod
36
+ def rm_pidfile(cls) -> None:
37
+ if cls.pidfile and cls.pidfile.exists():
38
+ cls.pidfile.unlink(missing_ok=True) # Python 3.8+
39
+
40
+ @classmethod
41
+ def running_pid(cls) -> int | None:
42
+ """Return a *running* PID or ``None`` if the process is not alive."""
43
+ pid = cls.read_pidfile()
44
+ if pid is None:
45
+ return None
46
+
47
+ try:
48
+ os.kill(pid, 0) # Does not kill – merely checks for existence.
49
+ except OSError:
50
+ cls.rm_pidfile()
51
+ return None
52
+
53
+ return pid
54
+
55
+ def write_pidfile(self) -> None:
56
+ """Create/overwrite the pidfile for *this* process."""
57
+ self.pidfile.parent.mkdir(parents=True, exist_ok=True)
58
+ with self.pidfile.open("w+", encoding="utf-8") as f:
59
+ f.write(str(self.pid))
60
+
61
+ def stop_process(self) -> None:
62
+ """Terminate the process recorded in the pidfile, if it is running."""
63
+ pid = self.read_pidfile()
64
+ if pid is None:
65
+ return
66
+
67
+ # Try graceful termination first (SIGTERM)…
68
+ try:
69
+ os.kill(pid, 15)
70
+ except OSError:
71
+ # Process already gone – ensure we clean up.
72
+ self.rm_pidfile()
73
+ self.close()
74
+ return
75
+
76
+ timeout = 10 # seconds
77
+ start = time.time()
78
+ while time.time() - start < timeout:
79
+ try:
80
+ os.kill(pid, 0)
81
+ except OSError:
82
+ break # Process has exited.
83
+ time.sleep(0.1)
84
+
85
+ else: # Still running – force kill.
86
+ try:
87
+ os.kill(pid, 9)
88
+ except OSError:
89
+ pass
90
+
91
+ self.rm_pidfile()
92
+ self.close()
93
+
94
+ # ------------------------------------------------------------------
95
+ # Logging / Poncho helpers (unchanged)
96
+ # ------------------------------------------------------------------
97
+ def prepare_log(self) -> Path:
98
+ """Create the log directory and return a path for *this* run."""
99
+ self.log_dir.mkdir(parents=True, exist_ok=True)
100
+
101
+ # Keep the 5 most recent log files.
102
+ logs = sorted(
103
+ self.log_dir.glob("*.log"),
104
+ key=lambda p: p.stat().st_mtime,
105
+ reverse=True,
106
+ )
107
+ for old in logs[5:]:
108
+ old.unlink(missing_ok=True)
109
+
110
+ self.log_path = self.log_dir / f"{self.pid}.log"
111
+ return self.log_path
112
+
113
+ def init_poncho(self, print_func) -> PonchoManager: # noqa: D401
114
+ """Return a :class:`~plain.dev.poncho.manager.Manager` instance."""
115
+ if self.log_path is None:
116
+ self.prepare_log()
117
+
118
+ self.printer = Printer(print_func, log_file=self.log_path)
119
+ self.poncho = PonchoManager(printer=self.printer)
120
+ return self.poncho
121
+
122
+ # ------------------------------------------------------------------
123
+ # Cleanup
124
+ # ------------------------------------------------------------------
125
+ def close(self) -> None:
126
+ if self.printer:
127
+ self.printer.close()