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,241 @@
|
|
|
1
|
+
"""The Downloads-folder watcher.
|
|
2
|
+
|
|
3
|
+
Filesystem events land on the observer thread and are queued; a single worker
|
|
4
|
+
thread does the real work, so a slow ingest never makes us miss an event. Every
|
|
5
|
+
candidate file is waited on until it stops growing, because browsers rename a
|
|
6
|
+
``.crdownload`` into place only at the very end — and some do not.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import queue
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
from watchdog.events import (
|
|
20
|
+
DirMovedEvent,
|
|
21
|
+
FileCreatedEvent,
|
|
22
|
+
FileModifiedEvent,
|
|
23
|
+
FileMovedEvent,
|
|
24
|
+
FileSystemEvent,
|
|
25
|
+
FileSystemEventHandler,
|
|
26
|
+
)
|
|
27
|
+
from watchdog.observers import Observer
|
|
28
|
+
from watchdog.observers.polling import PollingObserver
|
|
29
|
+
|
|
30
|
+
from .config import Config
|
|
31
|
+
from .db import Database
|
|
32
|
+
from .ingest import IngestResult, Ingestor, recently_written, wait_until_stable
|
|
33
|
+
from .utils import LOG
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: Queue reasons that mean "this file just landed here". The startup scan is
|
|
37
|
+
#: deliberately absent: it sweeps files that were already sitting in the folder,
|
|
38
|
+
#: and cannot tell an overnight download from a zip something merely touched, so
|
|
39
|
+
#: what it finds is archived but never cleared out of the watched folder.
|
|
40
|
+
ARRIVAL_REASONS = frozenset({"created", "moved", "modified", "manual"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class _Job:
|
|
45
|
+
path: Path
|
|
46
|
+
reason: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Handler(FileSystemEventHandler):
|
|
50
|
+
"""Translates watchdog events into ingest jobs."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
enqueue: Callable[[Path, str], None],
|
|
55
|
+
is_candidate: Callable[[Path], bool],
|
|
56
|
+
arrival_max_age: float = 0.0,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.enqueue = enqueue
|
|
59
|
+
self.is_candidate = is_candidate
|
|
60
|
+
self.arrival_max_age = arrival_max_age
|
|
61
|
+
|
|
62
|
+
def _maybe(self, raw_path: str | bytes, reason: str, *, require_recent: bool = False) -> None:
|
|
63
|
+
path = Path(raw_path.decode() if isinstance(raw_path, bytes) else raw_path)
|
|
64
|
+
if not self.is_candidate(path):
|
|
65
|
+
return
|
|
66
|
+
if require_recent and not self._recently_written(path):
|
|
67
|
+
LOG.debug("ignoring %s event for %s: nothing was written to it", reason, path.name)
|
|
68
|
+
return
|
|
69
|
+
self.enqueue(path, reason)
|
|
70
|
+
|
|
71
|
+
def _recently_written(self, path: Path) -> bool:
|
|
72
|
+
try:
|
|
73
|
+
mtime = path.stat().st_mtime
|
|
74
|
+
except OSError:
|
|
75
|
+
return False
|
|
76
|
+
return recently_written(mtime, self.arrival_max_age)
|
|
77
|
+
|
|
78
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
79
|
+
if isinstance(event, FileCreatedEvent):
|
|
80
|
+
self._maybe(event.src_path, "created")
|
|
81
|
+
|
|
82
|
+
def on_moved(self, event: FileSystemEvent) -> None:
|
|
83
|
+
# Chrome/Edge finish a download by renaming "foo.zip.crdownload" -> "foo.zip".
|
|
84
|
+
if isinstance(event, FileMovedEvent) and not isinstance(event, DirMovedEvent):
|
|
85
|
+
self._maybe(event.dest_path, "moved")
|
|
86
|
+
|
|
87
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
88
|
+
# Some browsers write in place without a rename, so these events matter.
|
|
89
|
+
# But Windows also raises them when nothing was written: watchdog asks
|
|
90
|
+
# ReadDirectoryChangesW for attribute, security and last-access changes
|
|
91
|
+
# too, so an antivirus sweep, the search indexer or OneDrive dehydrating
|
|
92
|
+
# a folder re-announces every zip in it at once. mtime is the filter.
|
|
93
|
+
if isinstance(event, FileModifiedEvent):
|
|
94
|
+
self._maybe(event.src_path, "modified", require_recent=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Watcher:
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
cfg: Config,
|
|
101
|
+
db: Database,
|
|
102
|
+
ingestor: Ingestor | None = None,
|
|
103
|
+
on_result: Callable[[IngestResult], None] | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
self.cfg = cfg
|
|
106
|
+
self.db = db
|
|
107
|
+
self.ingestor = ingestor or Ingestor(cfg, db)
|
|
108
|
+
self.on_result = on_result
|
|
109
|
+
self._queue: queue.Queue[_Job | None] = queue.Queue()
|
|
110
|
+
self._pending: set[str] = set()
|
|
111
|
+
self._pending_lock = threading.Lock()
|
|
112
|
+
self._stop = threading.Event()
|
|
113
|
+
self._observer = None
|
|
114
|
+
self._worker: threading.Thread | None = None
|
|
115
|
+
|
|
116
|
+
# -- queueing --------------------------------------------------------
|
|
117
|
+
def enqueue(self, path: Path, reason: str = "manual") -> None:
|
|
118
|
+
key = str(path)
|
|
119
|
+
with self._pending_lock:
|
|
120
|
+
if key in self._pending:
|
|
121
|
+
return # already queued; the stability wait covers late writes
|
|
122
|
+
self._pending.add(key)
|
|
123
|
+
LOG.debug("queued %s (%s)", path.name, reason)
|
|
124
|
+
self._queue.put(_Job(path, reason))
|
|
125
|
+
|
|
126
|
+
def _work(self) -> None:
|
|
127
|
+
while not self._stop.is_set():
|
|
128
|
+
try:
|
|
129
|
+
job = self._queue.get(timeout=0.5)
|
|
130
|
+
except queue.Empty:
|
|
131
|
+
continue
|
|
132
|
+
if job is None:
|
|
133
|
+
break
|
|
134
|
+
try:
|
|
135
|
+
self._process(job)
|
|
136
|
+
except Exception: # noqa: BLE001 - the worker must never die
|
|
137
|
+
LOG.exception("failed to process %s", job.path)
|
|
138
|
+
finally:
|
|
139
|
+
with self._pending_lock:
|
|
140
|
+
self._pending.discard(str(job.path))
|
|
141
|
+
self._queue.task_done()
|
|
142
|
+
|
|
143
|
+
def _process(self, job: _Job) -> None:
|
|
144
|
+
if not job.path.exists():
|
|
145
|
+
return
|
|
146
|
+
if not wait_until_stable(
|
|
147
|
+
job.path, self.cfg.watch.stable_seconds, self.cfg.watch.max_wait_seconds
|
|
148
|
+
):
|
|
149
|
+
return
|
|
150
|
+
if not job.path.exists(): # moved or deleted while we waited
|
|
151
|
+
return
|
|
152
|
+
result = self.ingestor.ingest(job.path, just_downloaded=job.reason in ARRIVAL_REASONS)
|
|
153
|
+
if self.on_result:
|
|
154
|
+
try:
|
|
155
|
+
self.on_result(result)
|
|
156
|
+
except Exception: # noqa: BLE001
|
|
157
|
+
LOG.exception("result callback failed")
|
|
158
|
+
|
|
159
|
+
# -- startup scan ----------------------------------------------------
|
|
160
|
+
def initial_scan(self) -> int:
|
|
161
|
+
"""Queue matching files that are already sitting in the watched folders."""
|
|
162
|
+
if not self.cfg.watch.scan_on_start:
|
|
163
|
+
return 0
|
|
164
|
+
max_age = self.cfg.watch.scan_on_start_max_age_hours
|
|
165
|
+
cutoff = None
|
|
166
|
+
if max_age > 0:
|
|
167
|
+
cutoff = datetime.now(timezone.utc).timestamp() - max_age * 3600
|
|
168
|
+
|
|
169
|
+
candidates: list[tuple[float, Path]] = []
|
|
170
|
+
for directory in self.cfg.watch_dirs():
|
|
171
|
+
if not directory.exists():
|
|
172
|
+
LOG.warning("watch directory does not exist: %s", directory)
|
|
173
|
+
continue
|
|
174
|
+
entries = directory.rglob("*") if self.cfg.watch.recursive else directory.iterdir()
|
|
175
|
+
for path in entries:
|
|
176
|
+
try:
|
|
177
|
+
if not path.is_file() or not self.ingestor.is_candidate(path):
|
|
178
|
+
continue
|
|
179
|
+
mtime = path.stat().st_mtime
|
|
180
|
+
if cutoff is not None and mtime < cutoff:
|
|
181
|
+
continue
|
|
182
|
+
except OSError:
|
|
183
|
+
continue
|
|
184
|
+
candidates.append((mtime, path))
|
|
185
|
+
|
|
186
|
+
# Oldest first, so catching up produces the same version order as
|
|
187
|
+
# watching live would have.
|
|
188
|
+
for _, path in sorted(candidates, key=lambda item: item[0]):
|
|
189
|
+
self.enqueue(path, "startup-scan")
|
|
190
|
+
if candidates:
|
|
191
|
+
LOG.info("startup scan queued %d existing file(s)", len(candidates))
|
|
192
|
+
return len(candidates)
|
|
193
|
+
|
|
194
|
+
# -- lifecycle -------------------------------------------------------
|
|
195
|
+
def start(self) -> None:
|
|
196
|
+
directories = [d for d in self.cfg.watch_dirs()]
|
|
197
|
+
existing = [d for d in directories if d.exists()]
|
|
198
|
+
if not existing:
|
|
199
|
+
raise FileNotFoundError(
|
|
200
|
+
"none of the configured watch directories exist: "
|
|
201
|
+
+ ", ".join(str(d) for d in directories)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
observer_cls = PollingObserver if self.cfg.watch.force_polling else Observer
|
|
205
|
+
kwargs = {"timeout": self.cfg.watch.polling_interval} if self.cfg.watch.force_polling else {}
|
|
206
|
+
self._observer = observer_cls(**kwargs) # type: ignore[operator]
|
|
207
|
+
handler = _Handler(
|
|
208
|
+
self.enqueue, self.ingestor.is_candidate, self.cfg.watch.arrival_max_age_seconds
|
|
209
|
+
)
|
|
210
|
+
for directory in existing:
|
|
211
|
+
self._observer.schedule(handler, str(directory), recursive=self.cfg.watch.recursive)
|
|
212
|
+
LOG.info("watching %s", directory)
|
|
213
|
+
|
|
214
|
+
self._worker = threading.Thread(target=self._work, name="lw-ingest", daemon=True)
|
|
215
|
+
self._worker.start()
|
|
216
|
+
self._observer.start()
|
|
217
|
+
self.initial_scan()
|
|
218
|
+
|
|
219
|
+
def stop(self, timeout: float = 10.0) -> None:
|
|
220
|
+
self._stop.set()
|
|
221
|
+
if self._observer is not None:
|
|
222
|
+
self._observer.stop()
|
|
223
|
+
self._observer.join(timeout=timeout)
|
|
224
|
+
self._queue.put(None)
|
|
225
|
+
if self._worker is not None:
|
|
226
|
+
self._worker.join(timeout=timeout)
|
|
227
|
+
|
|
228
|
+
def wait_forever(self) -> None:
|
|
229
|
+
try:
|
|
230
|
+
while not self._stop.is_set():
|
|
231
|
+
time.sleep(0.5)
|
|
232
|
+
except KeyboardInterrupt:
|
|
233
|
+
LOG.info("stopping on keyboard interrupt")
|
|
234
|
+
|
|
235
|
+
def drain(self, timeout: float = 60.0) -> None:
|
|
236
|
+
"""Block until the queue is empty. Used by tests and `backfill`."""
|
|
237
|
+
deadline = time.monotonic() + timeout
|
|
238
|
+
while time.monotonic() < deadline:
|
|
239
|
+
if self._queue.unfinished_tasks == 0:
|
|
240
|
+
return
|
|
241
|
+
time.sleep(0.1)
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lambda-watcher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions.
|
|
5
|
+
Author: Lambda Watcher contributors
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/utkarsh5026/lambwatch
|
|
8
|
+
Project-URL: Documentation, https://utkarsh5026.github.io/lambwatch/
|
|
9
|
+
Project-URL: Issues, https://github.com/utkarsh5026/lambwatch/issues
|
|
10
|
+
Keywords: aws,lambda,diff,watchdog,versioning,backup
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Version Control
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: watchdog>=4.0
|
|
20
|
+
Requires-Dist: typer>=0.12
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Requires-Dist: PyYAML>=6.0
|
|
23
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.15; extra == "dev"
|
|
28
|
+
Provides-Extra: lint
|
|
29
|
+
Requires-Dist: ruff>=0.15; extra == "lint"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# lambda-watcher
|
|
33
|
+
|
|
34
|
+
Watch your Downloads folder for AWS Lambda deployment zips, archive every
|
|
35
|
+
version automatically, and review what actually changed between any two of
|
|
36
|
+
them.
|
|
37
|
+
|
|
38
|
+
If your deploy ritual is *"download the current function zip as a backup, then
|
|
39
|
+
push the new one"*, you end up with a folder of near-identical archives and no
|
|
40
|
+
practical way to answer **"what changed between the 2nd one and the 10th?"**.
|
|
41
|
+
This tool turns that pile into a queryable, diffable history — with no change
|
|
42
|
+
to how you work. You keep downloading zips; it does the rest.
|
|
43
|
+
|
|
44
|
+
**[How it works, step by step →](https://utkarsh5026.github.io/lambwatch/)**
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
$ lambda-watcher watch
|
|
48
|
+
lambda-watcher 0.1.0 — archiving into ~/.lambda-watcher
|
|
49
|
+
watching ~/Downloads. Press Ctrl-C to stop.
|
|
50
|
+
new order-processor v0001 order-processor.zip — archived a new version
|
|
51
|
+
new order-processor v0002 order-processor (1).zip — 2 added, 2 modified, 3 renamed, 52 vendored
|
|
52
|
+
+24/-5 lines · new: 1 env var, 1 AWS service, 3 secrets
|
|
53
|
+
report: ~/.lambda-watcher/reports/order-processor/v0001-v0002.html
|
|
54
|
+
unchanged order-processor v0002 order-processor (2).zip — identical to version 2
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That third line is the one that matters: the same code, downloaded again, is
|
|
58
|
+
recorded as `unchanged` rather than becoming a bogus version 3.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## What it does
|
|
63
|
+
|
|
64
|
+
Every time a `.zip` lands in your Downloads folder, lambda-watcher:
|
|
65
|
+
|
|
66
|
+
1. **waits** until the download has actually finished (no half-written files);
|
|
67
|
+
2. **works out which Lambda it is** from the filename, a sidecar
|
|
68
|
+
`get-function` JSON, or the archive's contents — `order-processor.zip`,
|
|
69
|
+
`order-processor (1).zip` and `order-processor-2026-03-01.zip` all land
|
|
70
|
+
under the same function;
|
|
71
|
+
3. **extracts** it safely (path traversal, zip bombs and encrypted archives are
|
|
72
|
+
refused, not trusted);
|
|
73
|
+
4. **hashes the content, not the file** — re-downloading unchanged code is
|
|
74
|
+
recorded as `unchanged` instead of creating a bogus new version;
|
|
75
|
+
5. **analyses** it: runtime, handler, dependencies (declared *and* the versions
|
|
76
|
+
actually vendored in the zip), environment variables the code reads, AWS
|
|
77
|
+
services it calls, and hardcoded credentials;
|
|
78
|
+
6. **archives** it as version *N* with a `manifest.json`, indexes it in SQLite,
|
|
79
|
+
and commits it to a per-function git repo tagged `v0004`.
|
|
80
|
+
|
|
81
|
+
Then you review:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
lw diff order-processor # last two versions, in the terminal
|
|
85
|
+
lw diff order-processor --from 2 --to 10 # any two versions
|
|
86
|
+
lw diff order-processor --html --open # a shareable HTML report
|
|
87
|
+
lw report order-processor # the whole history, browsable
|
|
88
|
+
lw open order-processor # the whole archive, in your editor
|
|
89
|
+
lw git order-processor log -p # or just use git
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Why the diffs are actually readable
|
|
93
|
+
|
|
94
|
+
A raw `diff -r` between two Lambda zips is unusable: thousands of vendored
|
|
95
|
+
dependency files drown three lines of real change. lambda-watcher fixes that by
|
|
96
|
+
answering the questions you actually have, in order:
|
|
97
|
+
|
|
98
|
+
| | |
|
|
99
|
+
|---|---|
|
|
100
|
+
| **Your code, separated from theirs** | `node_modules/`, `site-packages/` and friends are classified as vendored and hidden by default. Three changed files, not 3,000. |
|
|
101
|
+
| **Dependencies as versions, not files** | A `boto3` upgrade shows as `boto3 1.34.0 → 1.35.20`, parsed from the `dist-info` actually shipped in the zip — not 400 changed files. |
|
|
102
|
+
| **Config impact, called out** | A new `os.environ["QUEUE_URL"]` is flagged as *"this must exist in the function's environment before you deploy"*. A new `boto3.client("sqs")` is flagged as *"the execution role may need new IAM permissions"*. These are the changes that break a deploy and never show up in a file diff. |
|
|
103
|
+
| **Renames survive edits** | A file that moved *and* changed is shown as one rename with a diff, not an unrelated add plus delete. |
|
|
104
|
+
| **Secrets are diffed too** | An AWS key or Stripe token that appears between v7 and v8 gets its own section. Values are stored redacted; the secret itself never enters the index. |
|
|
105
|
+
|
|
106
|
+
Concretely — the two versions above, where a `diff -rq` reports 61 changed
|
|
107
|
+
files and 56 of them are `site-packages/`:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
$ lambda-watcher diff order-processor
|
|
111
|
+
╭──────────────────────────────────────────────────────────────────────╮
|
|
112
|
+
│ order-processor v0001 → v0002 │
|
|
113
|
+
│ 2 added 2 modified 3 renamed 52 vendored (hidden) +24 / -5 lines │
|
|
114
|
+
╰──────────────────────────────────────────────────────────────────────╯
|
|
115
|
+
size 8.1 KB → 8.9 KB (+782 B)
|
|
116
|
+
|
|
117
|
+
Dependencies
|
|
118
|
+
manager package from to origin
|
|
119
|
+
+ pip pydantic — 2.9.0 installed
|
|
120
|
+
~ pip boto3 1.34.0 1.35.20 installed
|
|
121
|
+
~ pip botocore 1.34.0 1.35.20 installed
|
|
122
|
+
|
|
123
|
+
Env vars added: QUEUE_URL
|
|
124
|
+
AWS services added: sqs
|
|
125
|
+
↑ these need to exist in the function's environment configuration
|
|
126
|
+
|
|
127
|
+
New findings
|
|
128
|
+
high aws-access-key-id config.py:6 AKIA…LE (20 chars)
|
|
129
|
+
high stripe-key config.py:7 sk_l…dc (32 chars)
|
|
130
|
+
low debug-flag config.py:8 DEBUG = True
|
|
131
|
+
|
|
132
|
+
Files
|
|
133
|
+
path + − size
|
|
134
|
+
~ lambda_function.py 9 2 +322
|
|
135
|
+
~ requirements.txt 2 1 +17
|
|
136
|
+
+ config.py 10 +235
|
|
137
|
+
+ helpers/__init__.py
|
|
138
|
+
→ {db → helpers/db}.py 1 +33
|
|
139
|
+
→ site-packages/boto3-1.{34.0 → 35.20}.dist-info/METADATA 1 1 +1
|
|
140
|
+
→ site-packages/botocore-1.{34.0 → 35.20}.dist-info/METADATA 1 1 +1
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The 52 vendored files became three version numbers, `db.py` moving into a
|
|
144
|
+
package is one rename rather than a delete plus an add, and the new environment
|
|
145
|
+
variable, the new AWS service and the three secret findings are changes that a
|
|
146
|
+
file diff cannot express at all.
|
|
147
|
+
|
|
148
|
+
That capture is not illustrative — it is the output of
|
|
149
|
+
[`docs/examples/build_demo.py`](docs/examples/build_demo.py), which builds the
|
|
150
|
+
two zips and runs the real pipeline over them. Run it yourself:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
.venv/bin/python docs/examples/build_demo.py
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The same comparison as a shareable HTML page — `--html --open` on any diff, or
|
|
157
|
+
`report` for the whole history — is published from this repository:
|
|
158
|
+
**[see the generated report](https://utkarsh5026.github.io/lambwatch/examples/report/v0001-v0002.html)**.
|
|
159
|
+
|
|
160
|
+
## Install
|
|
161
|
+
|
|
162
|
+
Python 3.10+.
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
uv tool install lambda-watcher # or: pipx install lambda-watcher
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Either one puts `lambda-watcher` — and the shorter alias `lw`, used throughout
|
|
169
|
+
this README — on your `PATH` in its own environment. Plain `pip install
|
|
170
|
+
lambda-watcher` works too if you would rather manage the environment yourself.
|
|
171
|
+
|
|
172
|
+
<details>
|
|
173
|
+
<summary>From a checkout instead</summary>
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
git clone https://github.com/utkarsh5026/lambwatch.git
|
|
177
|
+
cd lambwatch
|
|
178
|
+
python3 -m venv .venv
|
|
179
|
+
.venv/bin/pip install -e .
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
</details>
|
|
183
|
+
|
|
184
|
+
## Quick start
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
lw setup
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
That is the whole thing. `setup` writes a config you can edit, finds your
|
|
191
|
+
downloads folder, offers to import any zips already sitting in it, and starts
|
|
192
|
+
the watcher in the background — as a launchd agent on macOS, a systemd user
|
|
193
|
+
service on Linux, a scheduled task on Windows — so it comes back after a
|
|
194
|
+
reboot without you thinking about it.
|
|
195
|
+
|
|
196
|
+
Then just keep downloading zips. When you want to know what happened:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
lw # is it running, and what has it caught?
|
|
200
|
+
lw diff order-processor # what changed in the last version
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Every new version also writes its own comparison to
|
|
204
|
+
`~/.lambda-watcher/reports/<function>/latest.html` as it is archived, so the
|
|
205
|
+
answer is a bookmark rather than a command.
|
|
206
|
+
|
|
207
|
+
<details>
|
|
208
|
+
<summary>Running it by hand, or setting it up piece by piece</summary>
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
lw watch # run in the foreground instead; Ctrl-C stops it
|
|
212
|
+
lw start # install and start the background watcher
|
|
213
|
+
lw stop # stop it (--remove also unregisters it)
|
|
214
|
+
lw restart # after editing the config
|
|
215
|
+
lw doctor # check the config, watch folders, store, git and disk space
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Already have a folder of old backups? Import them oldest-first so the version
|
|
219
|
+
numbers match real history:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
lw backfill ~/Downloads/lambda-backups --dry-run # check the names first
|
|
223
|
+
lw backfill ~/Downloads/lambda-backups
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
If your platform's service manager is unavailable — WSL without a systemd user
|
|
227
|
+
session, say — `lw start` falls back to a plain background process and tells
|
|
228
|
+
you it will not survive a reboot. [docs/autostart.md](docs/autostart.md) has
|
|
229
|
+
the manual recipes.
|
|
230
|
+
|
|
231
|
+
</details>
|
|
232
|
+
|
|
233
|
+
## Commands
|
|
234
|
+
|
|
235
|
+
| Command | What it does |
|
|
236
|
+
|---|---|
|
|
237
|
+
| `setup` | Config, background watcher and any history already on disk, in one go. `--no-service` skips the background watcher, `--yes` takes every default. |
|
|
238
|
+
| `status` | Is it running, and what has it archived? Also what bare `lw` prints. |
|
|
239
|
+
| `start` / `stop` | Register the background watcher with the OS, or stop it. `stop --remove` unregisters it too. |
|
|
240
|
+
| `restart` | Stop and start it — use after editing the config. |
|
|
241
|
+
| `watch` | Watch the download folders in the foreground. `--once` processes what is already there and exits. |
|
|
242
|
+
| `ingest FILE...` | Archive specific zips by hand. `--as NAME` overrides the detected function, `--label` annotates the version. |
|
|
243
|
+
| `backfill DIR` | Import a folder of old downloads, oldest first. `--dry-run` shows the names it would assign. |
|
|
244
|
+
| `ls` | Every function archived so far. |
|
|
245
|
+
| `versions FN` | Every archived version of one function. |
|
|
246
|
+
| `show FN [V]` | Runtime, handler, dependencies, env vars, services and findings for one version. `--files`, `--json`. |
|
|
247
|
+
| `diff FN` | Compare two versions. Defaults to the last two. `--from`/`--to`, `--html`, `--open`, `--vendor`, `--no-patch`, `--json`. |
|
|
248
|
+
| `report FN` | Build a browsable HTML history: an index plus a diff for every step. |
|
|
249
|
+
| `export FN [V]` | Get a version back out as a deployable zip (`--zip`) or a plain folder (`--tree`). |
|
|
250
|
+
| `open FN [V]` | Open the function's mirror in your editor — every version in one folder, with history. Name a version to open just its files. |
|
|
251
|
+
| `git FN ...` | Run git inside that function's mirror repo: `lw git order-processor log --oneline`. |
|
|
252
|
+
| `rename OLD NEW` | Fix a misidentified name. `--alias FRAGMENT` remembers the mapping for next time. |
|
|
253
|
+
| `merge SRC DST` | Combine two entries that are really the same Lambda, renumbering by archive time. |
|
|
254
|
+
| `label FN V TEXT` | Annotate a version, e.g. `label order-processor 7 "prod deploy 2026-03-01"`. |
|
|
255
|
+
| `search TERM` | Search filenames and dependencies across everything archived. |
|
|
256
|
+
| `log` | Recent activity, including downloads that were skipped and why. |
|
|
257
|
+
| `path FN [V]` | Print a path, for `cd "$(lw path order-processor 7)"`. |
|
|
258
|
+
| `rm FN` | Delete a function and everything archived for it. |
|
|
259
|
+
| `reindex` | Rebuild the SQLite index from the manifests on disk. |
|
|
260
|
+
| `doctor` | Check the config, watch folders, store, git and disk space. |
|
|
261
|
+
|
|
262
|
+
Version arguments accept `7`, `v7`, `latest`, `first`, or `-1` / `-2` counting
|
|
263
|
+
back from the newest.
|
|
264
|
+
|
|
265
|
+
## Where things are kept
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
~/.lambda-watcher/
|
|
269
|
+
├── config.yaml
|
|
270
|
+
├── index.db # rebuildable index (see `reindex`)
|
|
271
|
+
├── logs/watcher.log
|
|
272
|
+
├── reports/ # generated HTML
|
|
273
|
+
├── quarantine/ # archives that failed, with a reason file
|
|
274
|
+
├── repos/
|
|
275
|
+
│ └── order-processor/ # git mirror: one commit per version, tagged v0001…
|
|
276
|
+
└── functions/
|
|
277
|
+
└── order-processor/
|
|
278
|
+
└── versions/
|
|
279
|
+
├── 0001-bd9f77c8/
|
|
280
|
+
│ ├── code/ # the extracted tree
|
|
281
|
+
│ ├── manifest.json # the full analysis
|
|
282
|
+
│ └── package.zip # the original download
|
|
283
|
+
└── 0002-73d375ad/
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The directories are the source of truth. `index.db` is a cache you can delete
|
|
287
|
+
and rebuild with `lw reindex`, and the whole store is portable —
|
|
288
|
+
copy it to another machine and reindex.
|
|
289
|
+
|
|
290
|
+
### The git mirror
|
|
291
|
+
|
|
292
|
+
Each function gets its own git repository at `repos/<name>/`, whose working tree
|
|
293
|
+
is the latest version, with one commit per archived version tagged `v0001`,
|
|
294
|
+
`v0002`, … The folder is named after the function on purpose: it is what an
|
|
295
|
+
editor shows as the workspace root, so an open window says `order-processor`
|
|
296
|
+
rather than something generic. Every tool you already know works on it:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
lw open order-processor # VS Code, on the whole repo
|
|
300
|
+
|
|
301
|
+
cd "$(lw path order-processor --repo)"
|
|
302
|
+
git diff v0002 v0010 # the diff you originally wanted
|
|
303
|
+
git log --oneline --stat
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`open` finds VS Code, Cursor, Windsurf, VSCodium, Zed or Sublime on your `PATH`
|
|
307
|
+
— set `editor:` in the config (or `LAMBDA_WATCHER_EDITOR`, or `--editor`) to
|
|
308
|
+
name a different one. What you get is a folder of real files, not a diff: the
|
|
309
|
+
sidebar reads `order-processor`, and the editor's own file tree, search, Source
|
|
310
|
+
Control panel and timeline all work, with every earlier version a tag away. Name
|
|
311
|
+
a version — `lw open order-processor 3` — to open that version's
|
|
312
|
+
files on their own instead.
|
|
313
|
+
|
|
314
|
+
One repo per function is the point: your 2nd and 10th version of *one* Lambda
|
|
315
|
+
sit next to each other, with no other function's history in the way.
|
|
316
|
+
|
|
317
|
+
## Configuration
|
|
318
|
+
|
|
319
|
+
`lw init` writes an annotated `~/.lambda-watcher/config.yaml`.
|
|
320
|
+
Everything is optional. The settings worth knowing:
|
|
321
|
+
|
|
322
|
+
```yaml
|
|
323
|
+
watch:
|
|
324
|
+
dirs: ["~/Downloads"] # add more if you download from several places
|
|
325
|
+
stable_seconds: 2.0 # how long a file must stop changing before it is read
|
|
326
|
+
force_polling: false # turn on for network shares, VM mounts, WSL
|
|
327
|
+
arrival_max_age_seconds: 300 # ignore "modified" events for files older than this
|
|
328
|
+
|
|
329
|
+
store:
|
|
330
|
+
on_ingest: copy # copy | move | leave
|
|
331
|
+
# `move` takes the zip out of Downloads once archived
|
|
332
|
+
strip_wrapper_dir: true # lift a lone `myrepo-1.2.3/` wrapper to the root, so a
|
|
333
|
+
# source archive's ref does not read as a full rewrite
|
|
334
|
+
max_versions_per_function: 0 # 0 keeps everything
|
|
335
|
+
|
|
336
|
+
naming:
|
|
337
|
+
rules: # explicit filename → function name mappings
|
|
338
|
+
- pattern: "^prod[-_](.+?)[-_]deploy"
|
|
339
|
+
name: '\1'
|
|
340
|
+
|
|
341
|
+
diff:
|
|
342
|
+
ignore_vendor: true # hide vendored dependency files in diffs
|
|
343
|
+
context_lines: 3
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Set `LAMBDA_WATCHER_HOME` to relocate the whole archive, or
|
|
347
|
+
`LAMBDA_WATCHER_CONFIG` to point at a different config file — useful for
|
|
348
|
+
keeping work and personal archives separate.
|
|
349
|
+
|
|
350
|
+
### When it guesses the wrong name
|
|
351
|
+
|
|
352
|
+
The filename is a guess, and `lw log` records which strategy was
|
|
353
|
+
used and how confident it was. Two commands fix any mistake:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
lw rename unknown-a1b2c3d4 order-processor --alias "a1b2c3d4"
|
|
357
|
+
lw merge order-processor-old order-processor
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
`--alias` teaches it permanently: any future download whose filename contains
|
|
361
|
+
that fragment maps straight to the right function.
|
|
362
|
+
|
|
363
|
+
## Notes and limits
|
|
364
|
+
|
|
365
|
+
- **Only the code is archived.** A deployment package does not contain the
|
|
366
|
+
function's configuration — memory, timeout, environment variable *values*,
|
|
367
|
+
IAM role, layers or triggers. lambda-watcher infers what it can from the code
|
|
368
|
+
(which env vars are read, which services are called) and flags it, but if you
|
|
369
|
+
want the real configuration archived too, save
|
|
370
|
+
`aws lambda get-function --function-name X > X.json` next to the zip: it is
|
|
371
|
+
picked up as a naming hint, and it is a genuinely useful thing to keep.
|
|
372
|
+
- **Secret scanning is a tripwire, not a security tool.** It catches the
|
|
373
|
+
obvious cases — an AWS key, a private key block, a live Stripe token — and it
|
|
374
|
+
skips placeholders. Treat a finding as a prompt to look, not a verdict.
|
|
375
|
+
- **Layers are separate functions in AWS**, and they are downloaded separately;
|
|
376
|
+
they will be archived as their own entries.
|
|
377
|
+
- **Source archives work too.** A zip from GitHub (or npm, or `git archive`)
|
|
378
|
+
wraps everything in a directory named after the ref — `myrepo-1.2.3/` — and
|
|
379
|
+
names the file the same way. Both are handled: the wrapper is lifted to the
|
|
380
|
+
root so a re-download diffs as an edit rather than a total rewrite, the ref
|
|
381
|
+
becomes the version's label, and the ref is stripped from the name so
|
|
382
|
+
`myrepo-1.2.3.zip`, `myrepo-main.zip` and `myrepo-a1b2c3d.zip` all land as
|
|
383
|
+
versions of one `myrepo`. A trailing `-v2` is still left alone: it is part of
|
|
384
|
+
a name far more often than it is a tag.
|
|
385
|
+
- **A filesystem event is not proof that a file was written.** Windows reports
|
|
386
|
+
a zip as modified when an antivirus scan, the search indexer or OneDrive so
|
|
387
|
+
much as touches it — watchdog asks the OS for attribute and last-access
|
|
388
|
+
changes too — so a background sweep re-announces every zip in the folder at
|
|
389
|
+
once. Events claiming a write to a file nothing has written to are ignored
|
|
390
|
+
(`watch.arrival_max_age_seconds`), and `store.on_ingest: move` only clears
|
|
391
|
+
out a download the watcher saw arrive: a zip that a startup scan or a
|
|
392
|
+
`backfill` merely found is archived where it lies, never deleted.
|
|
393
|
+
- Large vendored packages make for large archives. `store.on_ingest: leave`
|
|
394
|
+
and `store.keep_zip: false` trade the original zips for disk space, and
|
|
395
|
+
`store.max_versions_per_function` caps history.
|
|
396
|
+
|
|
397
|
+
## Development
|
|
398
|
+
|
|
399
|
+
```bash
|
|
400
|
+
.venv/bin/pip install -e ".[dev]"
|
|
401
|
+
.venv/bin/python -m pytest
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The design decisions behind the analysis and diff layers are written up in
|
|
405
|
+
[docs/design.md](docs/design.md).
|
|
406
|
+
|
|
407
|
+
## Licence
|
|
408
|
+
|
|
409
|
+
Apache 2.0. See [LICENSE](LICENSE).
|