lambda-watcher 0.1.0__tar.gz → 0.1.1__tar.gz
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-0.1.0 → lambda_watcher-0.1.1}/PKG-INFO +2 -2
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/README.md +1 -1
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/pyproject.toml +1 -1
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/__init__.py +1 -1
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/cli.py +41 -20
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/service.py +167 -33
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/PKG-INFO +2 -2
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_cli.py +39 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_docs.py +5 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_service.py +96 -6
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/LICENSE +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/setup.cfg +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/__main__.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/__init__.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/deps.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/envvars.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/handler.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/inventory.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/runtime.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/secrets.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/services.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/config.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/db.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/__init__.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/build.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/compare.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/highlight.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/icons.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/intraline.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/render_html.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/render_text.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/extract.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/gitmirror.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/identify.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/ingest.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/notify.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/reindex.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/store.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/templates.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/utils.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/watcher.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/SOURCES.txt +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/dependency_links.txt +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/entry_points.txt +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/requires.txt +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/top_level.txt +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_analysis.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_diff.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_extract.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_identify.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_ingest.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_render_html.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_utils.py +0 -0
- {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_watcher.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-watcher
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions.
|
|
5
5
|
Author: Lambda Watcher contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -45,7 +45,7 @@ to how you work. You keep downloading zips; it does the rest.
|
|
|
45
45
|
|
|
46
46
|
```
|
|
47
47
|
$ lambda-watcher watch
|
|
48
|
-
lambda-watcher 0.1.
|
|
48
|
+
lambda-watcher 0.1.1 — archiving into ~/.lambda-watcher
|
|
49
49
|
watching ~/Downloads. Press Ctrl-C to stop.
|
|
50
50
|
new order-processor v0001 order-processor.zip — archived a new version
|
|
51
51
|
new order-processor v0002 order-processor (1).zip — 2 added, 2 modified, 3 renamed, 52 vendored
|
|
@@ -14,7 +14,7 @@ to how you work. You keep downloading zips; it does the rest.
|
|
|
14
14
|
|
|
15
15
|
```
|
|
16
16
|
$ lambda-watcher watch
|
|
17
|
-
lambda-watcher 0.1.
|
|
17
|
+
lambda-watcher 0.1.1 — archiving into ~/.lambda-watcher
|
|
18
18
|
watching ~/Downloads. Press Ctrl-C to stop.
|
|
19
19
|
new order-processor v0001 order-processor.zip — archived a new version
|
|
20
20
|
new order-processor v0002 order-processor (1).zip — 2 added, 2 modified, 3 renamed, 52 vendored
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lambda-watcher"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -27,7 +27,13 @@ from .diffing.render_html import render_timeline, write_html
|
|
|
27
27
|
from .diffing.render_text import render as render_diff
|
|
28
28
|
from .gitmirror import git_available
|
|
29
29
|
from .ingest import Ingestor
|
|
30
|
-
from .service import
|
|
30
|
+
from .service import (
|
|
31
|
+
ServiceError,
|
|
32
|
+
ServiceStatus,
|
|
33
|
+
current_status,
|
|
34
|
+
install_service,
|
|
35
|
+
stop_service,
|
|
36
|
+
)
|
|
31
37
|
from .store import Store
|
|
32
38
|
from .utils import format_ts, human_size, relative_ts, rmtree, setup_logging, slugify
|
|
33
39
|
|
|
@@ -268,9 +274,14 @@ def _main(
|
|
|
268
274
|
|
|
269
275
|
# --------------------------------------------------------------- dashboard
|
|
270
276
|
def _home_relative(path: Path) -> str:
|
|
271
|
-
"""``~/Downloads`` rather than ``/Users/someone/Downloads``, where it applies.
|
|
277
|
+
"""``~/Downloads`` rather than ``/Users/someone/Downloads``, where it applies.
|
|
278
|
+
|
|
279
|
+
The separator is the platform's, not a hardcoded slash: pasting ``~/`` onto
|
|
280
|
+
a Windows relative path produced ``~/.lambda-watcher\\config.yaml``, which
|
|
281
|
+
is a path from neither operating system.
|
|
282
|
+
"""
|
|
272
283
|
try:
|
|
273
|
-
return "
|
|
284
|
+
return f"~{os.sep}{path.relative_to(Path.home())}"
|
|
274
285
|
except ValueError:
|
|
275
286
|
return str(path)
|
|
276
287
|
|
|
@@ -446,17 +457,23 @@ def _offer_backfill(cfg: Config, yes: bool) -> None:
|
|
|
446
457
|
|
|
447
458
|
|
|
448
459
|
# ------------------------------------------------------------------ service
|
|
449
|
-
def _start_service(cfg: Config) ->
|
|
450
|
-
"""Install and start the background watcher, explaining whatever happens.
|
|
451
|
-
|
|
460
|
+
def _start_service(cfg: Config) -> bool:
|
|
461
|
+
"""Install and start the background watcher, explaining whatever happens.
|
|
462
|
+
|
|
463
|
+
Returns whether it got one. Not being able to install a service is a
|
|
464
|
+
disappointment, not a catastrophe — everything else `setup` did still
|
|
465
|
+
stands, and `lw watch` still works — so this reports and lets the caller
|
|
466
|
+
decide whether that is fatal.
|
|
467
|
+
"""
|
|
452
468
|
try:
|
|
453
|
-
state =
|
|
469
|
+
state = install_service(cfg, _CONFIG_PATH)
|
|
454
470
|
except ServiceError as exc:
|
|
455
|
-
|
|
456
|
-
f"{exc}\n"
|
|
457
|
-
"
|
|
458
|
-
"for the manual recipe."
|
|
471
|
+
err_console.print(
|
|
472
|
+
f" [yellow]![/yellow] could not install a background watcher: {exc}\n"
|
|
473
|
+
" [dim]Run [bold]lw watch[/bold] in a terminal instead, or see docs/autostart.md "
|
|
474
|
+
"for the manual recipe.[/dim]"
|
|
459
475
|
)
|
|
476
|
+
return False
|
|
460
477
|
if state.running:
|
|
461
478
|
console.print(f" [green]●[/green] watching in the background [dim]{state.manager}[/dim]")
|
|
462
479
|
else:
|
|
@@ -469,13 +486,21 @@ def _start_service(cfg: Config) -> None:
|
|
|
469
486
|
" [dim]no systemd user session here, so this will not come back after a reboot; "
|
|
470
487
|
"run [bold]lw start[/bold] again, or see docs/autostart.md[/dim]"
|
|
471
488
|
)
|
|
489
|
+
elif state.manager == "startup-folder":
|
|
490
|
+
console.print(
|
|
491
|
+
" [dim]registering a scheduled task was refused, so this starts from your "
|
|
492
|
+
"Startup folder instead — it will come back at logon, but nothing will "
|
|
493
|
+
"restart it if it crashes[/dim]"
|
|
494
|
+
)
|
|
495
|
+
return True
|
|
472
496
|
|
|
473
497
|
|
|
474
498
|
@app.command(rich_help_panel="Watching")
|
|
475
499
|
def start() -> None:
|
|
476
500
|
"""Watch in the background, now and after every reboot."""
|
|
477
501
|
cfg = _cfg()
|
|
478
|
-
_start_service(cfg)
|
|
502
|
+
if not _start_service(cfg):
|
|
503
|
+
raise typer.Exit(1)
|
|
479
504
|
|
|
480
505
|
|
|
481
506
|
@app.command(rich_help_panel="Watching")
|
|
@@ -486,12 +511,8 @@ def stop(
|
|
|
486
511
|
) -> None:
|
|
487
512
|
"""Stop the background watcher."""
|
|
488
513
|
cfg = _cfg()
|
|
489
|
-
manager = get_manager(cfg, _CONFIG_PATH)
|
|
490
514
|
try:
|
|
491
|
-
|
|
492
|
-
manager.uninstall()
|
|
493
|
-
else:
|
|
494
|
-
manager.stop()
|
|
515
|
+
stop_service(cfg, _CONFIG_PATH, remove=remove)
|
|
495
516
|
except ServiceError as exc:
|
|
496
517
|
_fail(str(exc))
|
|
497
518
|
console.print(
|
|
@@ -504,12 +525,12 @@ def stop(
|
|
|
504
525
|
def restart() -> None:
|
|
505
526
|
"""Stop and start the background watcher — use it after editing the config."""
|
|
506
527
|
cfg = _cfg()
|
|
507
|
-
manager = get_manager(cfg, _CONFIG_PATH)
|
|
508
528
|
try:
|
|
509
|
-
|
|
529
|
+
stop_service(cfg, _CONFIG_PATH)
|
|
510
530
|
except ServiceError as exc:
|
|
511
531
|
_fail(str(exc))
|
|
512
|
-
|
|
532
|
+
if not _start_service(cfg):
|
|
533
|
+
raise typer.Exit(1)
|
|
513
534
|
|
|
514
535
|
|
|
515
536
|
# ----------------------------------------------------------------- checkup
|
|
@@ -19,6 +19,7 @@ that is exactly where a filesystem watcher gets used.
|
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
22
|
+
import getpass
|
|
22
23
|
import os
|
|
23
24
|
import shutil
|
|
24
25
|
import signal
|
|
@@ -66,7 +67,7 @@ class ServiceStatus:
|
|
|
66
67
|
|
|
67
68
|
|
|
68
69
|
# --------------------------------------------------------------- command
|
|
69
|
-
def watch_argv(config_path: Path | None = None) -> list[str]:
|
|
70
|
+
def watch_argv(config_path: Path | None = None, windowless: bool = False) -> list[str]:
|
|
70
71
|
"""The command a service manager should run, as absolute argv.
|
|
71
72
|
|
|
72
73
|
A unit file is read by a daemon with no shell, no virtualenv activation and
|
|
@@ -76,6 +77,15 @@ def watch_argv(config_path: Path | None = None) -> list[str]:
|
|
|
76
77
|
but the console script reads far better in a file a human may open, so it
|
|
77
78
|
wins whenever we can find it next to the running interpreter.
|
|
78
79
|
"""
|
|
80
|
+
if windowless:
|
|
81
|
+
# A launcher that runs at logon must not flash a console window, and
|
|
82
|
+
# only the interpreter has a windowless build - the console script does
|
|
83
|
+
# not.
|
|
84
|
+
argv = [_python_for_service(), "-m", "lambda_watcher"]
|
|
85
|
+
if config_path is not None:
|
|
86
|
+
argv += ["--config", str(Path(config_path).expanduser().resolve())]
|
|
87
|
+
return [*argv, "watch"]
|
|
88
|
+
|
|
79
89
|
argv: list[str] = []
|
|
80
90
|
script_dir = Path(sys.executable).parent
|
|
81
91
|
suffixes = (".exe", "") if sys.platform.startswith("win") else ("",)
|
|
@@ -149,25 +159,23 @@ class Manager:
|
|
|
149
159
|
"""One platform's way of running something at login and keeping it up."""
|
|
150
160
|
|
|
151
161
|
name = "none"
|
|
162
|
+
#: Launchers that run at logon with no console attached set this.
|
|
163
|
+
windowless = False
|
|
152
164
|
|
|
153
165
|
def __init__(self, cfg: Config, config_path: Path | None = None) -> None:
|
|
154
166
|
self.cfg = cfg
|
|
155
167
|
self.config_path = config_path
|
|
156
168
|
|
|
157
|
-
#
|
|
169
|
+
# Every manager implements these four.
|
|
158
170
|
def install(self) -> ServiceStatus: raise NotImplementedError
|
|
159
171
|
def uninstall(self) -> None: raise NotImplementedError
|
|
160
172
|
def stop(self) -> None: raise NotImplementedError
|
|
161
173
|
def status(self) -> ServiceStatus: raise NotImplementedError
|
|
162
174
|
|
|
163
|
-
def restart(self) -> ServiceStatus:
|
|
164
|
-
self.stop()
|
|
165
|
-
return self.install()
|
|
166
|
-
|
|
167
175
|
# -- shared helpers --------------------------------------------------
|
|
168
176
|
@property
|
|
169
177
|
def argv(self) -> list[str]:
|
|
170
|
-
return watch_argv(self.config_path)
|
|
178
|
+
return watch_argv(self.config_path, windowless=self.windowless)
|
|
171
179
|
|
|
172
180
|
@property
|
|
173
181
|
def log_path(self) -> Path:
|
|
@@ -347,9 +355,13 @@ class SchtasksManager(Manager):
|
|
|
347
355
|
def install(self) -> ServiceStatus:
|
|
348
356
|
self._prepare_logs()
|
|
349
357
|
command = " ".join(_win_quote(a) for a in self.argv)
|
|
358
|
+
# /RU is not decoration. With ONLOGON and no /RU the task fires for *any*
|
|
359
|
+
# user who logs on, which is a machine-wide change and needs an elevated
|
|
360
|
+
# prompt - "ERROR: Access is denied." on an ordinary account. Naming the
|
|
361
|
+
# current user scopes the trigger to them, which they are allowed to do.
|
|
350
362
|
_run([
|
|
351
363
|
"schtasks", "/Create", "/TN", SCHEDULED_TASK, "/TR", command,
|
|
352
|
-
"/SC", "ONLOGON", "/F", "/RL", "LIMITED",
|
|
364
|
+
"/SC", "ONLOGON", "/RU", getpass.getuser(), "/F", "/RL", "LIMITED",
|
|
353
365
|
], check=True)
|
|
354
366
|
# ONLOGON only fires at the *next* logon, so start it once by hand to
|
|
355
367
|
# make `lw start` mean what it says.
|
|
@@ -400,8 +412,8 @@ class PidfileManager(Manager):
|
|
|
400
412
|
proc = subprocess.Popen(
|
|
401
413
|
self.argv,
|
|
402
414
|
stdout=handle, stderr=handle, stdin=subprocess.DEVNULL,
|
|
403
|
-
start_new_session=True,
|
|
404
415
|
env={**os.environ, **service_environment(self.cfg)},
|
|
416
|
+
**_detach_kwargs(),
|
|
405
417
|
)
|
|
406
418
|
except OSError as exc:
|
|
407
419
|
handle.close()
|
|
@@ -419,12 +431,7 @@ class PidfileManager(Manager):
|
|
|
419
431
|
pid = self._recorded_pid()
|
|
420
432
|
if pid is None:
|
|
421
433
|
return
|
|
422
|
-
|
|
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
|
|
434
|
+
_terminate(pid)
|
|
428
435
|
self.pid_path.unlink(missing_ok=True)
|
|
429
436
|
|
|
430
437
|
def status(self) -> ServiceStatus:
|
|
@@ -458,6 +465,12 @@ def _pid_alive(pid: int) -> bool:
|
|
|
458
465
|
caller that outlives the watcher (the test suite, a `lw status` in the same
|
|
459
466
|
session) from reporting a stopped watcher as running.
|
|
460
467
|
"""
|
|
468
|
+
if sys.platform.startswith("win"):
|
|
469
|
+
# `os.kill(pid, 0)` on Windows is TerminateProcess with an exit code of
|
|
470
|
+
# zero, not a probe, so asking whether a process is alive would end it.
|
|
471
|
+
# tasklist only looks.
|
|
472
|
+
proc = _run(["tasklist", "/FI", f"PID eq {pid}", "/NH", "/FO", "CSV"])
|
|
473
|
+
return f'"{pid}"' in proc.stdout
|
|
461
474
|
try:
|
|
462
475
|
reaped, _status = os.waitpid(pid, os.WNOHANG)
|
|
463
476
|
if reaped == pid:
|
|
@@ -466,11 +479,6 @@ def _pid_alive(pid: int) -> bool:
|
|
|
466
479
|
pass # not ours; nothing to reap
|
|
467
480
|
except (AttributeError, OSError):
|
|
468
481
|
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
482
|
try:
|
|
475
483
|
os.kill(pid, 0)
|
|
476
484
|
except ProcessLookupError:
|
|
@@ -482,21 +490,138 @@ def _pid_alive(pid: int) -> bool:
|
|
|
482
490
|
return True
|
|
483
491
|
|
|
484
492
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
""
|
|
493
|
+
def _terminate(pid: int) -> None:
|
|
494
|
+
"""Ask a process to stop, by whatever means the platform offers."""
|
|
495
|
+
if sys.platform.startswith("win"):
|
|
496
|
+
_run(["taskkill", "/PID", str(pid), "/T", "/F"])
|
|
497
|
+
return
|
|
498
|
+
try:
|
|
499
|
+
os.kill(pid, signal.SIGTERM)
|
|
500
|
+
except ProcessLookupError:
|
|
501
|
+
pass
|
|
502
|
+
except OSError as exc:
|
|
503
|
+
raise ServiceError(f"could not stop pid {pid}: {exc}") from exc
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _detach_kwargs() -> dict[str, object]:
|
|
507
|
+
"""Popen arguments that outlive this process, per platform."""
|
|
508
|
+
if sys.platform.startswith("win"):
|
|
509
|
+
flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
|
|
510
|
+
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
|
|
511
|
+
)
|
|
512
|
+
return {"creationflags": flags}
|
|
513
|
+
return {"start_new_session": True}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class StartupFolderManager(PidfileManager):
|
|
517
|
+
"""Windows fallback: a launcher in this user's own Startup folder.
|
|
518
|
+
|
|
519
|
+
An account that may not register a scheduled task can still drop a file in
|
|
520
|
+
its own Startup folder — the oldest per-user autostart Windows has, and the
|
|
521
|
+
one that needs no privileges whatsoever. It is strictly less capable than
|
|
522
|
+
Task Scheduler (nothing restarts the watcher if it crashes), which is why it
|
|
523
|
+
is the second choice rather than the first.
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
name = "startup-folder"
|
|
527
|
+
windowless = True
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def script_path(self) -> Path:
|
|
531
|
+
appdata = os.environ.get("APPDATA")
|
|
532
|
+
base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
|
|
533
|
+
return (
|
|
534
|
+
base / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup"
|
|
535
|
+
/ "lambda-watcher.cmd"
|
|
536
|
+
)
|
|
488
537
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
538
|
+
def install(self) -> ServiceStatus:
|
|
539
|
+
state = super().install() # running now, pid recorded
|
|
540
|
+
self.script_path.parent.mkdir(parents=True, exist_ok=True)
|
|
541
|
+
self.script_path.write_text(self._script(), encoding="utf-8")
|
|
542
|
+
state.unit_path = self.script_path
|
|
543
|
+
state.detail = "starts at logon from your Startup folder"
|
|
544
|
+
return state
|
|
545
|
+
|
|
546
|
+
def uninstall(self) -> None:
|
|
547
|
+
self.script_path.unlink(missing_ok=True)
|
|
548
|
+
super().uninstall()
|
|
549
|
+
|
|
550
|
+
def status(self) -> ServiceStatus:
|
|
551
|
+
state = super().status()
|
|
552
|
+
if self.script_path.exists():
|
|
553
|
+
state.installed = True
|
|
554
|
+
state.unit_path = self.script_path
|
|
555
|
+
if state.running:
|
|
556
|
+
state.detail = "starts at logon from your Startup folder"
|
|
557
|
+
return state
|
|
558
|
+
|
|
559
|
+
def _script(self) -> str:
|
|
560
|
+
command = " ".join(_cmd_quote(a) for a in self.argv)
|
|
561
|
+
env = "".join(f'set "{k}={v}"\n' for k, v in service_environment(self.cfg).items())
|
|
562
|
+
# `start ""` detaches, so the cmd window this script runs in closes at
|
|
563
|
+
# once instead of waiting on a watcher that never exits.
|
|
564
|
+
return f'@echo off\r\n{env}start "" {command}\r\n'
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ---------------------------------------------------------------- factory
|
|
568
|
+
def manager_chain(cfg: Config, config_path: Path | None = None) -> list[Manager]:
|
|
569
|
+
"""Every manager this machine might use, best first.
|
|
570
|
+
|
|
571
|
+
A chain rather than a single choice because the first one can refuse at the
|
|
572
|
+
moment of use, not at the moment of selection: a Windows account without the
|
|
573
|
+
right to register a scheduled task only finds out when schtasks says "Access
|
|
574
|
+
is denied". Whatever the platform, the last entry always works, so `lw start`
|
|
575
|
+
ends with a watcher running.
|
|
492
576
|
"""
|
|
493
577
|
if sys.platform == "darwin" and shutil.which("launchctl"):
|
|
494
|
-
return LaunchdManager(cfg, config_path)
|
|
578
|
+
return [LaunchdManager(cfg, config_path)]
|
|
495
579
|
if sys.platform.startswith("win"):
|
|
496
|
-
return SchtasksManager(cfg, config_path)
|
|
580
|
+
return [SchtasksManager(cfg, config_path), StartupFolderManager(cfg, config_path)]
|
|
581
|
+
chain: list[Manager] = []
|
|
497
582
|
if sys.platform.startswith(("linux", "freebsd")) and SystemdManager.available():
|
|
498
|
-
|
|
499
|
-
|
|
583
|
+
chain.append(SystemdManager(cfg, config_path))
|
|
584
|
+
chain.append(PidfileManager(cfg, config_path))
|
|
585
|
+
return chain
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def get_manager(cfg: Config, config_path: Path | None = None) -> Manager:
|
|
589
|
+
"""The manager this machine would prefer to use."""
|
|
590
|
+
return manager_chain(cfg, config_path)[0]
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def install_service(cfg: Config, config_path: Path | None = None) -> ServiceStatus:
|
|
594
|
+
"""Install the best background watcher this machine will actually accept.
|
|
595
|
+
|
|
596
|
+
Falls through the chain rather than reporting the first refusal, because a
|
|
597
|
+
refusal is usually about privileges rather than about the machine being
|
|
598
|
+
unable: the returned status names which manager took it, so the caller can
|
|
599
|
+
say what the user ended up with.
|
|
600
|
+
"""
|
|
601
|
+
refusals: list[str] = []
|
|
602
|
+
for manager in manager_chain(cfg, config_path):
|
|
603
|
+
try:
|
|
604
|
+
return manager.install()
|
|
605
|
+
except ServiceError as exc:
|
|
606
|
+
LOG.debug("%s refused: %s", manager.name, exc)
|
|
607
|
+
refusals.append(f"{manager.name}: {exc}")
|
|
608
|
+
raise ServiceError("; ".join(refusals) or "no service manager available")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def stop_service(cfg: Config, config_path: Path | None = None, remove: bool = False) -> None:
|
|
612
|
+
"""Stop whichever manager is actually holding the watcher.
|
|
613
|
+
|
|
614
|
+
The one that installed it is not necessarily the one this machine would pick
|
|
615
|
+
today — a scheduled task registered before the account lost that right, say
|
|
616
|
+
— so every candidate is asked.
|
|
617
|
+
"""
|
|
618
|
+
for manager in manager_chain(cfg, config_path):
|
|
619
|
+
try:
|
|
620
|
+
if not manager.status().installed:
|
|
621
|
+
continue
|
|
622
|
+
manager.uninstall() if remove else manager.stop()
|
|
623
|
+
except ServiceError as exc:
|
|
624
|
+
LOG.debug("%s could not be stopped: %s", manager.name, exc)
|
|
500
625
|
|
|
501
626
|
|
|
502
627
|
def current_status(cfg: Config, config_path: Path | None = None) -> ServiceStatus:
|
|
@@ -507,9 +632,13 @@ def current_status(cfg: Config, config_path: Path | None = None) -> ServiceStatu
|
|
|
507
632
|
machine without a user bus, say), so every manager that could plausibly know
|
|
508
633
|
something gets asked before we report "not installed".
|
|
509
634
|
"""
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
635
|
+
chain = manager_chain(cfg, config_path)
|
|
636
|
+
manager = chain[0]
|
|
637
|
+
candidates = list(chain)
|
|
638
|
+
if sys.platform.startswith("linux") and not any(
|
|
639
|
+
isinstance(c, SystemdManager) for c in candidates
|
|
640
|
+
):
|
|
641
|
+
# A unit written while a user session existed outlives the session.
|
|
513
642
|
candidates.append(SystemdManager(cfg, config_path))
|
|
514
643
|
for candidate in candidates:
|
|
515
644
|
try:
|
|
@@ -543,6 +672,11 @@ def _sh_quote(value: str) -> str:
|
|
|
543
672
|
return f'"{escaped}"'
|
|
544
673
|
|
|
545
674
|
|
|
675
|
+
def _cmd_quote(value: str) -> str:
|
|
676
|
+
"""Quote one argument inside a .cmd script — plain quotes, unlike /TR."""
|
|
677
|
+
return f'"{value}"' if " " in value else value
|
|
678
|
+
|
|
679
|
+
|
|
546
680
|
def _win_quote(value: str) -> str:
|
|
547
681
|
"""Quote one argument inside a schtasks /TR command string.
|
|
548
682
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-watcher
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions.
|
|
5
5
|
Author: Lambda Watcher contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -45,7 +45,7 @@ to how you work. You keep downloading zips; it does the rest.
|
|
|
45
45
|
|
|
46
46
|
```
|
|
47
47
|
$ lambda-watcher watch
|
|
48
|
-
lambda-watcher 0.1.
|
|
48
|
+
lambda-watcher 0.1.1 — archiving into ~/.lambda-watcher
|
|
49
49
|
watching ~/Downloads. Press Ctrl-C to stop.
|
|
50
50
|
new order-processor v0001 order-processor.zip — archived a new version
|
|
51
51
|
new order-processor v0002 order-processor (1).zip — 2 added, 2 modified, 3 renamed, 52 vendored
|
|
@@ -98,6 +98,45 @@ def test_status_names_the_archive_and_what_to_do_with_it(archived: Path):
|
|
|
98
98
|
assert 'lw diff "order-processor"' in output
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def test_setup_survives_a_machine_that_will_not_take_a_service(
|
|
102
|
+
home: Path, downloads: Path, tmp_path: Path, monkeypatch
|
|
103
|
+
):
|
|
104
|
+
"""Everything else setup did still stands; a refused service is not fatal."""
|
|
105
|
+
from lambda_watcher import service
|
|
106
|
+
|
|
107
|
+
def refuse(*_a, **_k):
|
|
108
|
+
raise service.ServiceError("ERROR: Access is denied.")
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(cli, "install_service", refuse)
|
|
111
|
+
config = _config_watching(tmp_path / "config.yaml", downloads)
|
|
112
|
+
result = runner.invoke(app, ["--config", str(config), "setup"])
|
|
113
|
+
assert result.exit_code == 0, "the archive was set up; only the service was refused"
|
|
114
|
+
assert config.exists()
|
|
115
|
+
assert "could not install a background watcher" in result.output
|
|
116
|
+
assert "lw watch" in result.output, "it has to name the way forward"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_start_still_fails_loudly_when_no_service_can_be_installed(home: Path, monkeypatch):
|
|
120
|
+
"""`setup` shrugs it off, but `start` has one job."""
|
|
121
|
+
from lambda_watcher import service
|
|
122
|
+
|
|
123
|
+
def refuse(*_a, **_k):
|
|
124
|
+
raise service.ServiceError("nothing doing")
|
|
125
|
+
|
|
126
|
+
monkeypatch.setattr(cli, "install_service", refuse)
|
|
127
|
+
assert runner.invoke(app, ["start"]).exit_code == 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_paths_are_shortened_with_the_platforms_own_separator(monkeypatch, tmp_path: Path):
|
|
131
|
+
"""`~/` pasted onto a Windows relative path produced `~/.lambda-watcher\\config.yaml`."""
|
|
132
|
+
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path))
|
|
133
|
+
import os
|
|
134
|
+
|
|
135
|
+
assert cli._home_relative(tmp_path / "a" / "b") == f"~{os.sep}a{os.sep}b"
|
|
136
|
+
outside = Path("/somewhere/else") if os.sep == "/" else Path("D:/elsewhere")
|
|
137
|
+
assert cli._home_relative(outside) == str(outside)
|
|
138
|
+
|
|
139
|
+
|
|
101
140
|
def test_setup_writes_a_config_and_reports_the_watch_folder(
|
|
102
141
|
home: Path, downloads: Path, tmp_path: Path
|
|
103
142
|
):
|
|
@@ -45,6 +45,11 @@ _VARIABLE = [
|
|
|
45
45
|
# `doctor` reports the machine's free space, which is not a property of the
|
|
46
46
|
# tool at all: the page quotes whatever the run that produced it saw.
|
|
47
47
|
(re.compile(r"^(disk free)\s+\S+\s+.*$"), r"\1 <this machine>"),
|
|
48
|
+
# The banner carries the version, which moves with every release while the
|
|
49
|
+
# captures around it do not. Pinning it here would make a version bump a
|
|
50
|
+
# documentation edit, and the captures are demonstrating the watcher's
|
|
51
|
+
# output rather than which release printed it.
|
|
52
|
+
(re.compile(r"^lambda-watcher \d+\.\d+\.\d+\S*(?= — archiving into)"), "lambda-watcher <version>"),
|
|
48
53
|
]
|
|
49
54
|
|
|
50
55
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import plistlib
|
|
6
|
+
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
import time
|
|
8
9
|
from pathlib import Path
|
|
@@ -12,12 +13,17 @@ import pytest
|
|
|
12
13
|
from lambda_watcher import service
|
|
13
14
|
from lambda_watcher.config import Config
|
|
14
15
|
from lambda_watcher.service import (
|
|
16
|
+
ServiceError,
|
|
17
|
+
ServiceStatus,
|
|
15
18
|
LaunchdManager,
|
|
16
19
|
PidfileManager,
|
|
17
20
|
SchtasksManager,
|
|
21
|
+
StartupFolderManager,
|
|
18
22
|
SystemdManager,
|
|
19
23
|
current_status,
|
|
20
24
|
get_manager,
|
|
25
|
+
install_service,
|
|
26
|
+
manager_chain,
|
|
21
27
|
service_environment,
|
|
22
28
|
watch_argv,
|
|
23
29
|
)
|
|
@@ -59,14 +65,14 @@ def test_the_launch_agent_is_a_readable_plist(cfg: Config):
|
|
|
59
65
|
|
|
60
66
|
|
|
61
67
|
def test_the_launch_agent_escapes_a_path_that_needs_it(cfg: Config, monkeypatch):
|
|
62
|
-
monkeypatch.setattr(service, "watch_argv", lambda
|
|
68
|
+
monkeypatch.setattr(service, "watch_argv", lambda *_a, **_k: ["/Apps/A & B/lw", "watch"])
|
|
63
69
|
parsed = plistlib.loads(LaunchdManager(cfg)._plist(Path("/tmp/x.log")).encode("utf-8"))
|
|
64
70
|
assert parsed["ProgramArguments"][0] == "/Apps/A & B/lw"
|
|
65
71
|
|
|
66
72
|
|
|
67
73
|
def test_the_systemd_unit_quotes_an_executable_with_a_space(cfg: Config, monkeypatch):
|
|
68
74
|
"""systemd unquotes ExecStart itself, so a space has to be inside quotes."""
|
|
69
|
-
monkeypatch.setattr(service, "watch_argv", lambda
|
|
75
|
+
monkeypatch.setattr(service, "watch_argv", lambda *_a, **_k: ["/opt/my tools/lw", "watch"])
|
|
70
76
|
unit = SystemdManager(cfg)._unit()
|
|
71
77
|
assert 'ExecStart="/opt/my tools/lw" watch' in unit
|
|
72
78
|
assert "WantedBy=default.target" in unit
|
|
@@ -95,13 +101,79 @@ def test_linux_without_a_systemd_user_session_still_gets_a_watcher(cfg: Config,
|
|
|
95
101
|
assert isinstance(get_manager(cfg), PidfileManager)
|
|
96
102
|
|
|
97
103
|
|
|
104
|
+
def test_windows_gets_a_fallback_when_the_task_scheduler_refuses(cfg: Config, monkeypatch):
|
|
105
|
+
"""An ordinary account may not register a scheduled task; it still gets a watcher."""
|
|
106
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
107
|
+
chain = [m.name for m in manager_chain(cfg)]
|
|
108
|
+
assert chain == ["schtasks", "startup-folder"]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_the_scheduled_task_is_scoped_to_this_user(cfg: Config, monkeypatch):
|
|
112
|
+
"""ONLOGON with no /RU fires for *any* user, which needs elevation to register."""
|
|
113
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
114
|
+
monkeypatch.setattr(service.getpass, "getuser", lambda: "someone")
|
|
115
|
+
calls = _fake_run(monkeypatch)
|
|
116
|
+
SchtasksManager(cfg).install()
|
|
117
|
+
create = next(c for c in calls if "/Create" in c)
|
|
118
|
+
assert create[create.index("/RU") + 1] == "someone"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_a_refused_manager_falls_through_to_one_that_works(cfg: Config, monkeypatch):
|
|
122
|
+
monkeypatch.setattr(
|
|
123
|
+
SchtasksManager, "install",
|
|
124
|
+
lambda _self: (_ for _ in ()).throw(ServiceError("ERROR: Access is denied.")),
|
|
125
|
+
)
|
|
126
|
+
monkeypatch.setattr(
|
|
127
|
+
StartupFolderManager, "install",
|
|
128
|
+
lambda self: ServiceStatus(self.name, installed=True, running=True),
|
|
129
|
+
)
|
|
130
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
131
|
+
assert install_service(cfg).manager == "startup-folder"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_when_everything_refuses_the_reasons_are_all_reported(cfg: Config, monkeypatch):
|
|
135
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
136
|
+
for manager in (SchtasksManager, StartupFolderManager):
|
|
137
|
+
monkeypatch.setattr(
|
|
138
|
+
manager, "install",
|
|
139
|
+
lambda _self, name=manager.name: (_ for _ in ()).throw(ServiceError(f"{name} said no")),
|
|
140
|
+
)
|
|
141
|
+
with pytest.raises(ServiceError) as caught:
|
|
142
|
+
install_service(cfg)
|
|
143
|
+
assert "schtasks said no" in str(caught.value)
|
|
144
|
+
assert "startup-folder said no" in str(caught.value)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_the_startup_launcher_detaches_and_shows_no_console(cfg: Config, monkeypatch):
|
|
148
|
+
monkeypatch.setattr(sys, "platform", "win32")
|
|
149
|
+
monkeypatch.setenv("APPDATA", "C:\\Users\\someone\\AppData\\Roaming")
|
|
150
|
+
manager = StartupFolderManager(cfg)
|
|
151
|
+
assert manager.script_path.name == "lambda-watcher.cmd"
|
|
152
|
+
assert manager.script_path.parent.name == "Startup"
|
|
153
|
+
|
|
154
|
+
script = manager._script()
|
|
155
|
+
assert script.startswith("@echo off")
|
|
156
|
+
# `start ""` lets the launching cmd window close instead of waiting forever.
|
|
157
|
+
assert 'start ""' in script
|
|
158
|
+
# -m, not the console script: only the interpreter has a windowless build.
|
|
159
|
+
assert "-m lambda_watcher" in script
|
|
160
|
+
assert script.endswith("\r\n"), "a .cmd file wants CRLF"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_the_windowless_command_avoids_the_console_script():
|
|
164
|
+
plain = watch_argv()
|
|
165
|
+
quiet = watch_argv(windowless=True)
|
|
166
|
+
assert plain[-1] == quiet[-1] == "watch"
|
|
167
|
+
assert quiet[1:3] == ["-m", "lambda_watcher"]
|
|
168
|
+
|
|
169
|
+
|
|
98
170
|
# ------------------------------------------------------------------ lifecycle
|
|
99
171
|
@pytest.fixture
|
|
100
172
|
def idle_watcher(monkeypatch):
|
|
101
173
|
"""Stand in for `lw watch` with a process that just sits there."""
|
|
102
174
|
monkeypatch.setattr(
|
|
103
175
|
service, "watch_argv",
|
|
104
|
-
lambda
|
|
176
|
+
lambda *_a, **_k: [sys.executable, "-c", "import time; time.sleep(30)"],
|
|
105
177
|
)
|
|
106
178
|
|
|
107
179
|
|
|
@@ -135,11 +207,29 @@ def test_starting_twice_does_not_start_a_second_watcher(cfg: Config, idle_watche
|
|
|
135
207
|
manager.uninstall()
|
|
136
208
|
|
|
137
209
|
|
|
138
|
-
def
|
|
139
|
-
"""
|
|
210
|
+
def _fake_run(monkeypatch, stdout: str = "", returncode: int = 0) -> list[list[str]]:
|
|
211
|
+
"""Record what would have been run, and answer with canned output."""
|
|
212
|
+
calls: list[list[str]] = []
|
|
213
|
+
|
|
214
|
+
def run(argv, check=False):
|
|
215
|
+
calls.append(argv)
|
|
216
|
+
return subprocess.CompletedProcess(argv, returncode, stdout=stdout, stderr="")
|
|
217
|
+
|
|
218
|
+
monkeypatch.setattr(service, "_run", run)
|
|
219
|
+
return calls
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.parametrize(
|
|
223
|
+
("listing", "alive"),
|
|
224
|
+
[('"python.exe","4","Console","1","9,000 K"\n', True), ("INFO: No tasks are running.\n", False)],
|
|
225
|
+
)
|
|
226
|
+
def test_windows_liveness_asks_tasklist_instead_of_signalling(monkeypatch, listing, alive):
|
|
227
|
+
"""`os.kill(pid, 0)` is TerminateProcess on Windows — asking would end it."""
|
|
140
228
|
monkeypatch.setattr(sys, "platform", "win32")
|
|
141
229
|
monkeypatch.setattr(service.os, "kill", lambda *_a: pytest.fail("signalled a live process"))
|
|
142
|
-
|
|
230
|
+
calls = _fake_run(monkeypatch, stdout=listing)
|
|
231
|
+
assert service._pid_alive(4) is alive
|
|
232
|
+
assert calls[0][0] == "tasklist"
|
|
143
233
|
|
|
144
234
|
|
|
145
235
|
def test_a_stale_pidfile_reads_as_not_running(cfg: Config):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|