plain.dev 0.32.1__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/CHANGELOG.md +15 -0
- plain/dev/README.md +16 -1
- plain/dev/cli.py +187 -397
- plain/dev/contribute/cli.py +1 -2
- plain/dev/core.py +346 -0
- plain/dev/poncho/printer.py +31 -1
- plain/dev/precommit/cli.py +48 -51
- plain/dev/process.py +127 -0
- plain/dev/services.py +9 -67
- {plain_dev-0.32.1.dist-info → plain_dev-0.33.0.dist-info}/METADATA +17 -3
- {plain_dev-0.32.1.dist-info → plain_dev-0.33.0.dist-info}/RECORD +14 -13
- plain/dev/dev_pid.py +0 -36
- {plain_dev-0.32.1.dist-info → plain_dev-0.33.0.dist-info}/WHEEL +0 -0
- {plain_dev-0.32.1.dist-info → plain_dev-0.33.0.dist-info}/entry_points.txt +0 -0
- {plain_dev-0.32.1.dist-info → plain_dev-0.33.0.dist-info}/licenses/LICENSE +0 -0
plain/dev/contribute/cli.py
CHANGED
@@ -96,8 +96,7 @@ def cli(packages, repo, reset, all_packages):
|
|
96
96
|
elif package.startswith("plainx-"):
|
97
97
|
plainx_packages.append(str(repo))
|
98
98
|
else:
|
99
|
-
click.
|
100
|
-
sys.exit(2)
|
99
|
+
raise click.UsageError(f"Unknown package {package}")
|
101
100
|
|
102
101
|
if plain_packages:
|
103
102
|
result = subprocess.run(["uv", "add", "--editable", "--dev"] + plain_packages)
|
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)
|
plain/dev/poncho/printer.py
CHANGED
@@ -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
|
-
|
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-?]*[ -/]*[@-~]")
|
plain/dev/precommit/cli.py
CHANGED
@@ -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,59 +36,58 @@ def cli(install):
|
|
38
36
|
|
39
37
|
pyproject = Path("pyproject.toml")
|
40
38
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
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)
|
82
84
|
|
83
|
-
|
84
|
-
|
85
|
+
if find_spec("plain.pytest"):
|
86
|
+
print_event("Running tests")
|
87
|
+
result = subprocess.run(["plain", "test"])
|
85
88
|
if result.returncode != 0:
|
86
89
|
sys.exit(result.returncode)
|
87
90
|
|
88
|
-
if find_spec("plain.pytest"):
|
89
|
-
print_event("Running tests")
|
90
|
-
result = subprocess.run(["plain", "test"])
|
91
|
-
if result.returncode != 0:
|
92
|
-
sys.exit(result.returncode)
|
93
|
-
|
94
91
|
|
95
92
|
def plain_db_connected():
|
96
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()
|