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.
Files changed (54) hide show
  1. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/PKG-INFO +2 -2
  2. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/README.md +1 -1
  3. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/pyproject.toml +1 -1
  4. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/__init__.py +1 -1
  5. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/cli.py +41 -20
  6. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/service.py +167 -33
  7. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/PKG-INFO +2 -2
  8. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_cli.py +39 -0
  9. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_docs.py +5 -0
  10. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_service.py +96 -6
  11. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/LICENSE +0 -0
  12. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/setup.cfg +0 -0
  13. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/__main__.py +0 -0
  14. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/__init__.py +0 -0
  15. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/deps.py +0 -0
  16. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/envvars.py +0 -0
  17. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/handler.py +0 -0
  18. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/inventory.py +0 -0
  19. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/runtime.py +0 -0
  20. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/secrets.py +0 -0
  21. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/analysis/services.py +0 -0
  22. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/config.py +0 -0
  23. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/db.py +0 -0
  24. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/__init__.py +0 -0
  25. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/build.py +0 -0
  26. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/compare.py +0 -0
  27. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/highlight.py +0 -0
  28. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/icons.py +0 -0
  29. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/intraline.py +0 -0
  30. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/render_html.py +0 -0
  31. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/diffing/render_text.py +0 -0
  32. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/extract.py +0 -0
  33. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/gitmirror.py +0 -0
  34. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/identify.py +0 -0
  35. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/ingest.py +0 -0
  36. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/notify.py +0 -0
  37. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/reindex.py +0 -0
  38. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/store.py +0 -0
  39. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/templates.py +0 -0
  40. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/utils.py +0 -0
  41. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher/watcher.py +0 -0
  42. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/SOURCES.txt +0 -0
  43. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/dependency_links.txt +0 -0
  44. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/entry_points.txt +0 -0
  45. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/requires.txt +0 -0
  46. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/src/lambda_watcher.egg-info/top_level.txt +0 -0
  47. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_analysis.py +0 -0
  48. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_diff.py +0 -0
  49. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_extract.py +0 -0
  50. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_identify.py +0 -0
  51. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_ingest.py +0 -0
  52. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_render_html.py +0 -0
  53. {lambda_watcher-0.1.0 → lambda_watcher-0.1.1}/tests/test_utils.py +0 -0
  54. {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.0
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.0 — archiving into ~/.lambda-watcher
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.0 — archiving into ~/.lambda-watcher
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.0"
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"
@@ -1,4 +1,4 @@
1
1
  """Watch a downloads folder for AWS Lambda deployment packages and version them."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.1.1"
4
4
  __all__ = ["__version__"]
@@ -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 ServiceError, ServiceStatus, current_status, get_manager
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 "~/" + str(path.relative_to(Path.home()))
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) -> None:
450
- """Install and start the background watcher, explaining whatever happens."""
451
- manager = get_manager(cfg, _CONFIG_PATH)
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 = manager.install()
469
+ state = install_service(cfg, _CONFIG_PATH)
454
470
  except ServiceError as exc:
455
- _fail(
456
- f"{exc}\n"
457
- " Run [bold]lw watch[/bold] in a terminal instead, or see docs/autostart.md "
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
- if remove:
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
- state = manager.restart()
529
+ stop_service(cfg, _CONFIG_PATH)
510
530
  except ServiceError as exc:
511
531
  _fail(str(exc))
512
- console.print(f"[dim]{state.summary} ({state.manager})[/dim]")
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
- # Subclasses implement these four; `restart` is derived from them.
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
- 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
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
- # ---------------------------------------------------------------- factory
486
- def get_manager(cfg: Config, config_path: Path | None = None) -> Manager:
487
- """The right manager for this machine.
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
- 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.
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
- return SystemdManager(cfg, config_path)
499
- return PidfileManager(cfg, config_path)
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
- manager = get_manager(cfg, config_path)
511
- candidates: list[Manager] = [manager]
512
- if isinstance(manager, PidfileManager) and sys.platform.startswith("linux"):
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.0
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.0 — archiving into ~/.lambda-watcher
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 _p=None: ["/Apps/A & B/lw", "watch"])
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 _p=None: ["/opt/my tools/lw", "watch"])
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 _p=None: [sys.executable, "-c", "import time; time.sleep(30)"],
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 test_liveness_never_signals_a_windows_process(cfg: Config, monkeypatch):
139
- """`os.kill(pid, 0)` is TerminateProcess on Windows, not a probe."""
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
- assert service._pid_alive(4) is False
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