lambda-watcher 0.1.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.
- lambda_watcher/__init__.py +4 -0
- lambda_watcher/__main__.py +4 -0
- lambda_watcher/analysis/__init__.py +115 -0
- lambda_watcher/analysis/deps.py +291 -0
- lambda_watcher/analysis/envvars.py +80 -0
- lambda_watcher/analysis/handler.py +111 -0
- lambda_watcher/analysis/inventory.py +118 -0
- lambda_watcher/analysis/runtime.py +117 -0
- lambda_watcher/analysis/secrets.py +178 -0
- lambda_watcher/analysis/services.py +76 -0
- lambda_watcher/cli.py +1406 -0
- lambda_watcher/config.py +324 -0
- lambda_watcher/db.py +466 -0
- lambda_watcher/diffing/__init__.py +14 -0
- lambda_watcher/diffing/build.py +51 -0
- lambda_watcher/diffing/compare.py +525 -0
- lambda_watcher/diffing/highlight.py +312 -0
- lambda_watcher/diffing/icons.py +132 -0
- lambda_watcher/diffing/intraline.py +162 -0
- lambda_watcher/diffing/render_html.py +697 -0
- lambda_watcher/diffing/render_text.py +198 -0
- lambda_watcher/extract.py +227 -0
- lambda_watcher/gitmirror.py +151 -0
- lambda_watcher/identify.py +201 -0
- lambda_watcher/ingest.py +480 -0
- lambda_watcher/notify.py +59 -0
- lambda_watcher/reindex.py +158 -0
- lambda_watcher/service.py +553 -0
- lambda_watcher/store.py +209 -0
- lambda_watcher/templates.py +124 -0
- lambda_watcher/utils.py +314 -0
- lambda_watcher/watcher.py +241 -0
- lambda_watcher-0.1.0.dist-info/METADATA +409 -0
- lambda_watcher-0.1.0.dist-info/RECORD +38 -0
- lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
- lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
- lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
- lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Register the watcher with the platform's service manager.
|
|
2
|
+
|
|
3
|
+
``lambda-watcher watch`` is a foreground process, and a tool whose whole promise
|
|
4
|
+
is *"you keep downloading zips, it does the rest"* cannot also ask you to
|
|
5
|
+
remember to start it. This module turns the recipes that used to live only in
|
|
6
|
+
`docs/autostart.md` into something ``lw start`` can carry out itself: render the
|
|
7
|
+
unit file, hand it to launchd / systemd / Task Scheduler, and answer whether the
|
|
8
|
+
thing is actually running.
|
|
9
|
+
|
|
10
|
+
Everything here installs a *user* service — no sudo, no system-wide daemon. The
|
|
11
|
+
watcher reads one person's Downloads folder and writes one person's archive, so
|
|
12
|
+
it has no business running as root.
|
|
13
|
+
|
|
14
|
+
Managers are picked by platform, and the choice can fail over: Linux prefers a
|
|
15
|
+
systemd user unit but falls back to a plain detached process with a pidfile,
|
|
16
|
+
because WSL and minimal containers frequently have no systemd user session and
|
|
17
|
+
that is exactly where a filesystem watcher gets used.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .config import Config
|
|
31
|
+
from .utils import LOG
|
|
32
|
+
|
|
33
|
+
#: Identifiers registered with the OS. Changing either of these orphans the
|
|
34
|
+
#: service someone already installed, so they are constants, not config.
|
|
35
|
+
LAUNCHD_LABEL = "com.lambdawatcher"
|
|
36
|
+
SYSTEMD_UNIT = "lambda-watcher"
|
|
37
|
+
SCHEDULED_TASK = "lambda-watcher"
|
|
38
|
+
|
|
39
|
+
#: How long any service-manager subprocess gets before we give up on it.
|
|
40
|
+
_TIMEOUT = 30
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ServiceError(RuntimeError):
|
|
44
|
+
"""A service manager refused to do something, with a reason worth showing."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ServiceStatus:
|
|
49
|
+
"""What the platform says about our service right now."""
|
|
50
|
+
|
|
51
|
+
manager: str # launchd | systemd | schtasks | pidfile | none
|
|
52
|
+
installed: bool = False
|
|
53
|
+
running: bool = False
|
|
54
|
+
unit_path: Path | None = None
|
|
55
|
+
log_path: Path | None = None
|
|
56
|
+
pid: int | None = None
|
|
57
|
+
detail: str = ""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def summary(self) -> str:
|
|
61
|
+
if self.running:
|
|
62
|
+
return "running"
|
|
63
|
+
if self.installed:
|
|
64
|
+
return "installed, not running"
|
|
65
|
+
return "not installed"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --------------------------------------------------------------- command
|
|
69
|
+
def watch_argv(config_path: Path | None = None) -> list[str]:
|
|
70
|
+
"""The command a service manager should run, as absolute argv.
|
|
71
|
+
|
|
72
|
+
A unit file is read by a daemon with no shell, no virtualenv activation and
|
|
73
|
+
frequently no PATH worth the name, so every element has to be absolute.
|
|
74
|
+
``python -m lambda_watcher`` is the dependable form — it works from a venv,
|
|
75
|
+
a pipx install, a uv tool install and a plain ``pip install --user`` alike —
|
|
76
|
+
but the console script reads far better in a file a human may open, so it
|
|
77
|
+
wins whenever we can find it next to the running interpreter.
|
|
78
|
+
"""
|
|
79
|
+
argv: list[str] = []
|
|
80
|
+
script_dir = Path(sys.executable).parent
|
|
81
|
+
suffixes = (".exe", "") if sys.platform.startswith("win") else ("",)
|
|
82
|
+
for name in ("lambda-watcher", "lw"):
|
|
83
|
+
for suffix in suffixes:
|
|
84
|
+
candidate = script_dir / f"{name}{suffix}"
|
|
85
|
+
if candidate.exists():
|
|
86
|
+
argv = [str(candidate)]
|
|
87
|
+
break
|
|
88
|
+
if argv:
|
|
89
|
+
break
|
|
90
|
+
if not argv:
|
|
91
|
+
# No console script beside the interpreter: an editable checkout run
|
|
92
|
+
# through `python -m`, or a layout that puts scripts elsewhere.
|
|
93
|
+
argv = [_python_for_service(), "-m", "lambda_watcher"]
|
|
94
|
+
|
|
95
|
+
if config_path is not None:
|
|
96
|
+
argv += ["--config", str(Path(config_path).expanduser().resolve())]
|
|
97
|
+
argv.append("watch")
|
|
98
|
+
return argv
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _python_for_service() -> str:
|
|
102
|
+
"""``sys.executable``, preferring the console-free build on Windows.
|
|
103
|
+
|
|
104
|
+
A scheduled task pointed at ``python.exe`` flashes a console window at every
|
|
105
|
+
logon; ``pythonw.exe`` is the same interpreter without one.
|
|
106
|
+
"""
|
|
107
|
+
executable = Path(sys.executable)
|
|
108
|
+
if sys.platform.startswith("win"):
|
|
109
|
+
windowless = executable.with_name("pythonw.exe")
|
|
110
|
+
if windowless.exists():
|
|
111
|
+
return str(windowless)
|
|
112
|
+
return str(executable)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def service_environment(cfg: Config) -> dict[str, str]:
|
|
116
|
+
"""Environment the service needs that a login session would have supplied.
|
|
117
|
+
|
|
118
|
+
Service managers start with an environment stripped almost bare, so anything
|
|
119
|
+
the user set in a shell profile is gone by the time the watcher runs. Only
|
|
120
|
+
the variables that would silently point the service at a *different archive*
|
|
121
|
+
than the one the user is looking at are worth baking in.
|
|
122
|
+
"""
|
|
123
|
+
env: dict[str, str] = {}
|
|
124
|
+
for name in ("LAMBDA_WATCHER_HOME", "LAMBDA_WATCHER_CONFIG", "LAMBDA_WATCHER_LOG_LEVEL"):
|
|
125
|
+
value = os.environ.get(name)
|
|
126
|
+
if value:
|
|
127
|
+
env[name] = value
|
|
128
|
+
return env
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _run(argv: list[str], check: bool = False) -> subprocess.CompletedProcess[str]:
|
|
132
|
+
LOG.debug("service: %s", " ".join(argv))
|
|
133
|
+
try:
|
|
134
|
+
proc = subprocess.run(
|
|
135
|
+
argv, capture_output=True, text=True, timeout=_TIMEOUT, check=False
|
|
136
|
+
)
|
|
137
|
+
except FileNotFoundError as exc:
|
|
138
|
+
raise ServiceError(f"{argv[0]} is not installed") from exc
|
|
139
|
+
except subprocess.SubprocessError as exc:
|
|
140
|
+
raise ServiceError(f"{argv[0]} did not finish: {exc}") from exc
|
|
141
|
+
if check and proc.returncode != 0:
|
|
142
|
+
detail = (proc.stderr or proc.stdout or "").strip()
|
|
143
|
+
raise ServiceError(f"{' '.join(argv[:2])} failed: {detail or proc.returncode}")
|
|
144
|
+
return proc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------- managers
|
|
148
|
+
class Manager:
|
|
149
|
+
"""One platform's way of running something at login and keeping it up."""
|
|
150
|
+
|
|
151
|
+
name = "none"
|
|
152
|
+
|
|
153
|
+
def __init__(self, cfg: Config, config_path: Path | None = None) -> None:
|
|
154
|
+
self.cfg = cfg
|
|
155
|
+
self.config_path = config_path
|
|
156
|
+
|
|
157
|
+
# Subclasses implement these four; `restart` is derived from them.
|
|
158
|
+
def install(self) -> ServiceStatus: raise NotImplementedError
|
|
159
|
+
def uninstall(self) -> None: raise NotImplementedError
|
|
160
|
+
def stop(self) -> None: raise NotImplementedError
|
|
161
|
+
def status(self) -> ServiceStatus: raise NotImplementedError
|
|
162
|
+
|
|
163
|
+
def restart(self) -> ServiceStatus:
|
|
164
|
+
self.stop()
|
|
165
|
+
return self.install()
|
|
166
|
+
|
|
167
|
+
# -- shared helpers --------------------------------------------------
|
|
168
|
+
@property
|
|
169
|
+
def argv(self) -> list[str]:
|
|
170
|
+
return watch_argv(self.config_path)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def log_path(self) -> Path:
|
|
174
|
+
return self.cfg.log_dir / "service.log"
|
|
175
|
+
|
|
176
|
+
def _prepare_logs(self) -> Path:
|
|
177
|
+
self.cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
return self.log_path
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class LaunchdManager(Manager):
|
|
182
|
+
"""macOS user agent. Loaded per-user, started at login, restarted on crash."""
|
|
183
|
+
|
|
184
|
+
name = "launchd"
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def unit_path(self) -> Path:
|
|
188
|
+
return Path("~/Library/LaunchAgents").expanduser() / f"{LAUNCHD_LABEL}.plist"
|
|
189
|
+
|
|
190
|
+
def install(self) -> ServiceStatus:
|
|
191
|
+
log = self._prepare_logs()
|
|
192
|
+
self.unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
self.unit_path.write_text(self._plist(log), encoding="utf-8")
|
|
194
|
+
# An already-loaded agent has to come out before the new definition goes
|
|
195
|
+
# in; launchd will not re-read a plist for a label it already knows.
|
|
196
|
+
_run(["launchctl", "unload", str(self.unit_path)])
|
|
197
|
+
_run(["launchctl", "load", "-w", str(self.unit_path)], check=True)
|
|
198
|
+
return self.status()
|
|
199
|
+
|
|
200
|
+
def uninstall(self) -> None:
|
|
201
|
+
if self.unit_path.exists():
|
|
202
|
+
_run(["launchctl", "unload", "-w", str(self.unit_path)])
|
|
203
|
+
self.unit_path.unlink()
|
|
204
|
+
|
|
205
|
+
def stop(self) -> None:
|
|
206
|
+
if self.unit_path.exists():
|
|
207
|
+
# `unload` rather than `stop`: KeepAlive would restart it instantly.
|
|
208
|
+
_run(["launchctl", "unload", str(self.unit_path)])
|
|
209
|
+
|
|
210
|
+
def status(self) -> ServiceStatus:
|
|
211
|
+
state = ServiceStatus(self.name, unit_path=self.unit_path, log_path=self.log_path)
|
|
212
|
+
state.installed = self.unit_path.exists()
|
|
213
|
+
if not state.installed:
|
|
214
|
+
return state
|
|
215
|
+
proc = _run(["launchctl", "list", LAUNCHD_LABEL])
|
|
216
|
+
if proc.returncode != 0:
|
|
217
|
+
state.detail = "registered but not loaded"
|
|
218
|
+
return state
|
|
219
|
+
for line in proc.stdout.splitlines():
|
|
220
|
+
if '"PID"' in line:
|
|
221
|
+
digits = "".join(c for c in line.split("=")[-1] if c.isdigit())
|
|
222
|
+
if digits:
|
|
223
|
+
state.pid = int(digits)
|
|
224
|
+
state.running = state.pid is not None
|
|
225
|
+
return state
|
|
226
|
+
|
|
227
|
+
def _plist(self, log: Path) -> str:
|
|
228
|
+
args = "\n".join(f" <string>{_xml(a)}</string>" for a in self.argv)
|
|
229
|
+
env = service_environment(self.cfg)
|
|
230
|
+
env_block = ""
|
|
231
|
+
if env:
|
|
232
|
+
pairs = "\n".join(
|
|
233
|
+
f" <key>{_xml(k)}</key>\n <string>{_xml(v)}</string>"
|
|
234
|
+
for k, v in env.items()
|
|
235
|
+
)
|
|
236
|
+
env_block = f"\n <key>EnvironmentVariables</key>\n <dict>\n{pairs}\n </dict>\n"
|
|
237
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
238
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
239
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
240
|
+
<plist version="1.0">
|
|
241
|
+
<dict>
|
|
242
|
+
<key>Label</key>
|
|
243
|
+
<string>{LAUNCHD_LABEL}</string>
|
|
244
|
+
|
|
245
|
+
<key>ProgramArguments</key>
|
|
246
|
+
<array>
|
|
247
|
+
{args}
|
|
248
|
+
</array>
|
|
249
|
+
|
|
250
|
+
<key>RunAtLoad</key>
|
|
251
|
+
<true/>
|
|
252
|
+
<key>KeepAlive</key>
|
|
253
|
+
<true/>
|
|
254
|
+
{env_block}
|
|
255
|
+
<key>StandardOutPath</key>
|
|
256
|
+
<string>{_xml(str(log))}</string>
|
|
257
|
+
<key>StandardErrorPath</key>
|
|
258
|
+
<string>{_xml(str(log))}</string>
|
|
259
|
+
</dict>
|
|
260
|
+
</plist>
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class SystemdManager(Manager):
|
|
265
|
+
"""Linux systemd *user* unit — no root, enabled for the login session."""
|
|
266
|
+
|
|
267
|
+
name = "systemd"
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def unit_path(self) -> Path:
|
|
271
|
+
return Path("~/.config/systemd/user").expanduser() / f"{SYSTEMD_UNIT}.service"
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def available() -> bool:
|
|
275
|
+
"""True when there is a systemd user session to talk to.
|
|
276
|
+
|
|
277
|
+
`systemctl` being on PATH is not enough: WSL images ship it while
|
|
278
|
+
running no user manager at all, and every call there fails with
|
|
279
|
+
"Failed to connect to bus".
|
|
280
|
+
"""
|
|
281
|
+
if not shutil.which("systemctl"):
|
|
282
|
+
return False
|
|
283
|
+
try:
|
|
284
|
+
proc = subprocess.run(
|
|
285
|
+
["systemctl", "--user", "show-environment"],
|
|
286
|
+
capture_output=True, text=True, timeout=10, check=False,
|
|
287
|
+
)
|
|
288
|
+
except (OSError, subprocess.SubprocessError):
|
|
289
|
+
return False
|
|
290
|
+
return proc.returncode == 0
|
|
291
|
+
|
|
292
|
+
def install(self) -> ServiceStatus:
|
|
293
|
+
self._prepare_logs()
|
|
294
|
+
self.unit_path.parent.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
self.unit_path.write_text(self._unit(), encoding="utf-8")
|
|
296
|
+
_run(["systemctl", "--user", "daemon-reload"], check=True)
|
|
297
|
+
_run(["systemctl", "--user", "enable", "--now", SYSTEMD_UNIT], check=True)
|
|
298
|
+
return self.status()
|
|
299
|
+
|
|
300
|
+
def uninstall(self) -> None:
|
|
301
|
+
if self.unit_path.exists():
|
|
302
|
+
_run(["systemctl", "--user", "disable", "--now", SYSTEMD_UNIT])
|
|
303
|
+
self.unit_path.unlink()
|
|
304
|
+
_run(["systemctl", "--user", "daemon-reload"])
|
|
305
|
+
|
|
306
|
+
def stop(self) -> None:
|
|
307
|
+
_run(["systemctl", "--user", "stop", SYSTEMD_UNIT])
|
|
308
|
+
|
|
309
|
+
def status(self) -> ServiceStatus:
|
|
310
|
+
state = ServiceStatus(self.name, unit_path=self.unit_path)
|
|
311
|
+
state.installed = self.unit_path.exists()
|
|
312
|
+
if not state.installed:
|
|
313
|
+
return state
|
|
314
|
+
state.running = _run(["systemctl", "--user", "is-active", SYSTEMD_UNIT]).stdout.strip() == "active"
|
|
315
|
+
proc = _run(["systemctl", "--user", "show", SYSTEMD_UNIT, "--property=MainPID", "--value"])
|
|
316
|
+
pid = proc.stdout.strip()
|
|
317
|
+
if pid.isdigit() and int(pid) > 0:
|
|
318
|
+
state.pid = int(pid)
|
|
319
|
+
state.detail = "journalctl --user -u lambda-watcher -f"
|
|
320
|
+
return state
|
|
321
|
+
|
|
322
|
+
def _unit(self) -> str:
|
|
323
|
+
exec_start = " ".join(_sh_quote(a) for a in self.argv)
|
|
324
|
+
env_lines = "".join(
|
|
325
|
+
f'Environment="{k}={v}"\n' for k, v in service_environment(self.cfg).items()
|
|
326
|
+
)
|
|
327
|
+
return f"""[Unit]
|
|
328
|
+
Description=Watch Downloads for AWS Lambda deployment packages
|
|
329
|
+
After=default.target
|
|
330
|
+
|
|
331
|
+
[Service]
|
|
332
|
+
Type=simple
|
|
333
|
+
ExecStart={exec_start}
|
|
334
|
+
{env_lines}Restart=on-failure
|
|
335
|
+
RestartSec=10
|
|
336
|
+
|
|
337
|
+
[Install]
|
|
338
|
+
WantedBy=default.target
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class SchtasksManager(Manager):
|
|
343
|
+
"""Windows Task Scheduler entry, triggered at logon."""
|
|
344
|
+
|
|
345
|
+
name = "schtasks"
|
|
346
|
+
|
|
347
|
+
def install(self) -> ServiceStatus:
|
|
348
|
+
self._prepare_logs()
|
|
349
|
+
command = " ".join(_win_quote(a) for a in self.argv)
|
|
350
|
+
_run([
|
|
351
|
+
"schtasks", "/Create", "/TN", SCHEDULED_TASK, "/TR", command,
|
|
352
|
+
"/SC", "ONLOGON", "/F", "/RL", "LIMITED",
|
|
353
|
+
], check=True)
|
|
354
|
+
# ONLOGON only fires at the *next* logon, so start it once by hand to
|
|
355
|
+
# make `lw start` mean what it says.
|
|
356
|
+
_run(["schtasks", "/Run", "/TN", SCHEDULED_TASK])
|
|
357
|
+
return self.status()
|
|
358
|
+
|
|
359
|
+
def uninstall(self) -> None:
|
|
360
|
+
_run(["schtasks", "/End", "/TN", SCHEDULED_TASK])
|
|
361
|
+
_run(["schtasks", "/Delete", "/TN", SCHEDULED_TASK, "/F"])
|
|
362
|
+
|
|
363
|
+
def stop(self) -> None:
|
|
364
|
+
_run(["schtasks", "/End", "/TN", SCHEDULED_TASK])
|
|
365
|
+
|
|
366
|
+
def status(self) -> ServiceStatus:
|
|
367
|
+
state = ServiceStatus(self.name, log_path=self.log_path)
|
|
368
|
+
proc = _run(["schtasks", "/Query", "/TN", SCHEDULED_TASK, "/FO", "LIST", "/V"])
|
|
369
|
+
if proc.returncode != 0:
|
|
370
|
+
return state
|
|
371
|
+
state.installed = True
|
|
372
|
+
for line in proc.stdout.splitlines():
|
|
373
|
+
if line.lower().startswith("status:"):
|
|
374
|
+
state.running = line.split(":", 1)[1].strip().lower() == "running"
|
|
375
|
+
return state
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class PidfileManager(Manager):
|
|
379
|
+
"""Last-resort manager: a detached process tracked by a pidfile.
|
|
380
|
+
|
|
381
|
+
This is what WSL and systemd-less Linux get. It genuinely keeps the watcher
|
|
382
|
+
running in the background and survives the terminal closing, but nothing
|
|
383
|
+
restarts it after a reboot — callers are expected to say so.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
name = "pidfile"
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def pid_path(self) -> Path:
|
|
390
|
+
return self.cfg.root / "watcher.pid"
|
|
391
|
+
|
|
392
|
+
def install(self) -> ServiceStatus:
|
|
393
|
+
existing = self.status()
|
|
394
|
+
if existing.running:
|
|
395
|
+
return existing
|
|
396
|
+
log = self._prepare_logs()
|
|
397
|
+
self.cfg.root.mkdir(parents=True, exist_ok=True)
|
|
398
|
+
handle = open(log, "a", encoding="utf-8") # noqa: SIM115 - owned by the child
|
|
399
|
+
try:
|
|
400
|
+
proc = subprocess.Popen(
|
|
401
|
+
self.argv,
|
|
402
|
+
stdout=handle, stderr=handle, stdin=subprocess.DEVNULL,
|
|
403
|
+
start_new_session=True,
|
|
404
|
+
env={**os.environ, **service_environment(self.cfg)},
|
|
405
|
+
)
|
|
406
|
+
except OSError as exc:
|
|
407
|
+
handle.close()
|
|
408
|
+
raise ServiceError(f"could not start the watcher: {exc}") from exc
|
|
409
|
+
finally:
|
|
410
|
+
handle.close()
|
|
411
|
+
self.pid_path.write_text(str(proc.pid), encoding="utf-8")
|
|
412
|
+
return self.status()
|
|
413
|
+
|
|
414
|
+
def uninstall(self) -> None:
|
|
415
|
+
self.stop()
|
|
416
|
+
self.pid_path.unlink(missing_ok=True)
|
|
417
|
+
|
|
418
|
+
def stop(self) -> None:
|
|
419
|
+
pid = self._recorded_pid()
|
|
420
|
+
if pid is None:
|
|
421
|
+
return
|
|
422
|
+
try:
|
|
423
|
+
os.kill(pid, signal.SIGTERM)
|
|
424
|
+
except ProcessLookupError:
|
|
425
|
+
pass
|
|
426
|
+
except OSError as exc:
|
|
427
|
+
raise ServiceError(f"could not stop pid {pid}: {exc}") from exc
|
|
428
|
+
self.pid_path.unlink(missing_ok=True)
|
|
429
|
+
|
|
430
|
+
def status(self) -> ServiceStatus:
|
|
431
|
+
state = ServiceStatus(self.name, log_path=self.log_path)
|
|
432
|
+
pid = self._recorded_pid()
|
|
433
|
+
if pid is None:
|
|
434
|
+
return state
|
|
435
|
+
state.installed = True
|
|
436
|
+
state.pid = pid
|
|
437
|
+
state.running = _pid_alive(pid)
|
|
438
|
+
if not state.running:
|
|
439
|
+
state.detail = "the recorded process is gone"
|
|
440
|
+
else:
|
|
441
|
+
state.detail = "started in the background; will not survive a reboot"
|
|
442
|
+
return state
|
|
443
|
+
|
|
444
|
+
def _recorded_pid(self) -> int | None:
|
|
445
|
+
try:
|
|
446
|
+
text = self.pid_path.read_text(encoding="utf-8").strip()
|
|
447
|
+
except OSError:
|
|
448
|
+
return None
|
|
449
|
+
return int(text) if text.isdigit() else None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _pid_alive(pid: int) -> bool:
|
|
453
|
+
"""Is that process still running?
|
|
454
|
+
|
|
455
|
+
A process this one started and then signalled stays visible to ``kill(0)``
|
|
456
|
+
as a zombie until somebody collects its exit status, and normally nobody
|
|
457
|
+
does — ``lw stop`` exits immediately afterwards. Reaping first keeps a
|
|
458
|
+
caller that outlives the watcher (the test suite, a `lw status` in the same
|
|
459
|
+
session) from reporting a stopped watcher as running.
|
|
460
|
+
"""
|
|
461
|
+
try:
|
|
462
|
+
reaped, _status = os.waitpid(pid, os.WNOHANG)
|
|
463
|
+
if reaped == pid:
|
|
464
|
+
return False
|
|
465
|
+
except ChildProcessError:
|
|
466
|
+
pass # not ours; nothing to reap
|
|
467
|
+
except (AttributeError, OSError):
|
|
468
|
+
pass # no usable waitpid here
|
|
469
|
+
if sys.platform.startswith("win"):
|
|
470
|
+
# `os.kill(pid, 0)` on Windows is TerminateProcess with an exit code of
|
|
471
|
+
# zero, not a probe. Nothing selects PidfileManager there, and this
|
|
472
|
+
# would be a spectacular way to find out otherwise.
|
|
473
|
+
return False
|
|
474
|
+
try:
|
|
475
|
+
os.kill(pid, 0)
|
|
476
|
+
except ProcessLookupError:
|
|
477
|
+
return False
|
|
478
|
+
except PermissionError:
|
|
479
|
+
return True # alive, just not ours to signal
|
|
480
|
+
except OSError:
|
|
481
|
+
return False
|
|
482
|
+
return True
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ---------------------------------------------------------------- factory
|
|
486
|
+
def get_manager(cfg: Config, config_path: Path | None = None) -> Manager:
|
|
487
|
+
"""The right manager for this machine.
|
|
488
|
+
|
|
489
|
+
Order is per-platform preference with a working fallback, never an error:
|
|
490
|
+
somebody on WSL should still get a background watcher out of ``lw start``,
|
|
491
|
+
just one that is honest about not surviving a reboot.
|
|
492
|
+
"""
|
|
493
|
+
if sys.platform == "darwin" and shutil.which("launchctl"):
|
|
494
|
+
return LaunchdManager(cfg, config_path)
|
|
495
|
+
if sys.platform.startswith("win"):
|
|
496
|
+
return SchtasksManager(cfg, config_path)
|
|
497
|
+
if sys.platform.startswith(("linux", "freebsd")) and SystemdManager.available():
|
|
498
|
+
return SystemdManager(cfg, config_path)
|
|
499
|
+
return PidfileManager(cfg, config_path)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def current_status(cfg: Config, config_path: Path | None = None) -> ServiceStatus:
|
|
503
|
+
"""Best-effort status that never raises — safe for a dashboard to call.
|
|
504
|
+
|
|
505
|
+
A manager whose own unit is absent may still be shadowed by one installed
|
|
506
|
+
under a different manager (a systemd unit written before the user moved to a
|
|
507
|
+
machine without a user bus, say), so every manager that could plausibly know
|
|
508
|
+
something gets asked before we report "not installed".
|
|
509
|
+
"""
|
|
510
|
+
manager = get_manager(cfg, config_path)
|
|
511
|
+
candidates: list[Manager] = [manager]
|
|
512
|
+
if isinstance(manager, PidfileManager) and sys.platform.startswith("linux"):
|
|
513
|
+
candidates.append(SystemdManager(cfg, config_path))
|
|
514
|
+
for candidate in candidates:
|
|
515
|
+
try:
|
|
516
|
+
state = candidate.status()
|
|
517
|
+
except ServiceError as exc:
|
|
518
|
+
LOG.debug("status via %s failed: %s", candidate.name, exc)
|
|
519
|
+
continue
|
|
520
|
+
if state.installed:
|
|
521
|
+
return state
|
|
522
|
+
return ServiceStatus(manager.name, log_path=manager.log_path)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ------------------------------------------------------------------ quoting
|
|
526
|
+
def _xml(value: str) -> str:
|
|
527
|
+
return (
|
|
528
|
+
value.replace("&", "&").replace("<", "<")
|
|
529
|
+
.replace(">", ">").replace('"', """)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _sh_quote(value: str) -> str:
|
|
534
|
+
"""Quote one systemd ExecStart argument.
|
|
535
|
+
|
|
536
|
+
systemd does its own unquoting rather than handing the line to a shell, so
|
|
537
|
+
this is deliberately not `shlex.quote`: only double quotes and backslashes
|
|
538
|
+
need escaping, and a path with a space has to come out as one quoted token.
|
|
539
|
+
"""
|
|
540
|
+
if value and not any(c in value for c in ' \t"\\\''):
|
|
541
|
+
return value
|
|
542
|
+
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
543
|
+
return f'"{escaped}"'
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _win_quote(value: str) -> str:
|
|
547
|
+
"""Quote one argument inside a schtasks /TR command string.
|
|
548
|
+
|
|
549
|
+
NTFS forbids ``"`` in a path, so wrapping anything containing a space is the
|
|
550
|
+
whole job. The escape is ``\\"`` because /TR's value is itself a quoted
|
|
551
|
+
argument by the time the task scheduler reads it.
|
|
552
|
+
"""
|
|
553
|
+
return f'\\"{value}\\"' if " " in value else value
|