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.
Files changed (38) hide show
  1. lambda_watcher/__init__.py +4 -0
  2. lambda_watcher/__main__.py +4 -0
  3. lambda_watcher/analysis/__init__.py +115 -0
  4. lambda_watcher/analysis/deps.py +291 -0
  5. lambda_watcher/analysis/envvars.py +80 -0
  6. lambda_watcher/analysis/handler.py +111 -0
  7. lambda_watcher/analysis/inventory.py +118 -0
  8. lambda_watcher/analysis/runtime.py +117 -0
  9. lambda_watcher/analysis/secrets.py +178 -0
  10. lambda_watcher/analysis/services.py +76 -0
  11. lambda_watcher/cli.py +1406 -0
  12. lambda_watcher/config.py +324 -0
  13. lambda_watcher/db.py +466 -0
  14. lambda_watcher/diffing/__init__.py +14 -0
  15. lambda_watcher/diffing/build.py +51 -0
  16. lambda_watcher/diffing/compare.py +525 -0
  17. lambda_watcher/diffing/highlight.py +312 -0
  18. lambda_watcher/diffing/icons.py +132 -0
  19. lambda_watcher/diffing/intraline.py +162 -0
  20. lambda_watcher/diffing/render_html.py +697 -0
  21. lambda_watcher/diffing/render_text.py +198 -0
  22. lambda_watcher/extract.py +227 -0
  23. lambda_watcher/gitmirror.py +151 -0
  24. lambda_watcher/identify.py +201 -0
  25. lambda_watcher/ingest.py +480 -0
  26. lambda_watcher/notify.py +59 -0
  27. lambda_watcher/reindex.py +158 -0
  28. lambda_watcher/service.py +553 -0
  29. lambda_watcher/store.py +209 -0
  30. lambda_watcher/templates.py +124 -0
  31. lambda_watcher/utils.py +314 -0
  32. lambda_watcher/watcher.py +241 -0
  33. lambda_watcher-0.1.0.dist-info/METADATA +409 -0
  34. lambda_watcher-0.1.0.dist-info/RECORD +38 -0
  35. lambda_watcher-0.1.0.dist-info/WHEEL +5 -0
  36. lambda_watcher-0.1.0.dist-info/entry_points.txt +3 -0
  37. lambda_watcher-0.1.0.dist-info/licenses/LICENSE +201 -0
  38. lambda_watcher-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,525 @@
1
+ """Compute a structured diff between two archived versions.
2
+
3
+ The output is deliberately layered, because "what changed" is rarely a
4
+ question about lines of text:
5
+
6
+ * the headline (runtime, handler, size, file counts)
7
+ * dependency changes, which explain most of the file churn in a zip
8
+ * environment variables and AWS services the code now needs
9
+ * new security findings
10
+ * and only then the per-file line diffs, first-party code first
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import difflib
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from collections.abc import Iterable
19
+ from typing import Any
20
+
21
+ from ..config import DiffConfig
22
+ from ..utils import matches_any, read_text
23
+
24
+
25
+ @dataclass
26
+ class FileRecord:
27
+ """One file as recorded in the index."""
28
+
29
+ path: str
30
+ size: int
31
+ sha256: str
32
+ is_text: bool
33
+ is_vendor: bool
34
+ lang: str
35
+ lines: int
36
+ mode: int = 0o644
37
+
38
+ @classmethod
39
+ def from_row(cls, row: Any) -> FileRecord:
40
+ return cls(
41
+ path=row["path"], size=int(row["size"]), sha256=row["sha256"],
42
+ is_text=bool(row["is_text"]), is_vendor=bool(row["is_vendor"]),
43
+ lang=row["lang"] or "text", lines=int(row["lines"]), mode=int(row["mode"] or 0o644),
44
+ )
45
+
46
+
47
+ @dataclass
48
+ class FileChange:
49
+ kind: str # added | removed | modified | renamed | mode-changed
50
+ path: str
51
+ old_path: str | None = None
52
+ old: FileRecord | None = None
53
+ new: FileRecord | None = None
54
+ diff_lines: list[str] = field(default_factory=list)
55
+ added_lines: int = 0
56
+ removed_lines: int = 0
57
+ truncated: bool = False
58
+ binary: bool = False
59
+ skipped_reason: str | None = None
60
+
61
+ @property
62
+ def is_vendor(self) -> bool:
63
+ record = self.new or self.old
64
+ return bool(record and record.is_vendor)
65
+
66
+ @property
67
+ def size_delta(self) -> int:
68
+ return (self.new.size if self.new else 0) - (self.old.size if self.old else 0)
69
+
70
+ @property
71
+ def lang(self) -> str:
72
+ record = self.new or self.old
73
+ return record.lang if record else "text"
74
+
75
+ def as_dict(self) -> dict:
76
+ return {
77
+ "kind": self.kind,
78
+ "path": self.path,
79
+ "old_path": self.old_path,
80
+ "added_lines": self.added_lines,
81
+ "removed_lines": self.removed_lines,
82
+ "size_delta": self.size_delta,
83
+ "binary": self.binary,
84
+ "truncated": self.truncated,
85
+ "is_vendor": self.is_vendor,
86
+ }
87
+
88
+
89
+ @dataclass
90
+ class DepChange:
91
+ kind: str # added | removed | changed
92
+ manager: str
93
+ name: str
94
+ old_version: str | None = None
95
+ new_version: str | None = None
96
+ is_declared: bool = False
97
+
98
+ def as_dict(self) -> dict:
99
+ return {
100
+ "kind": self.kind, "manager": self.manager, "name": self.name,
101
+ "old_version": self.old_version, "new_version": self.new_version,
102
+ "is_declared": self.is_declared,
103
+ }
104
+
105
+
106
+ @dataclass
107
+ class VersionDiff:
108
+ function_name: str
109
+ a_seq: int
110
+ b_seq: int
111
+ a_meta: dict[str, Any] = field(default_factory=dict)
112
+ b_meta: dict[str, Any] = field(default_factory=dict)
113
+
114
+ #: The two version directories the comparison read. Renderers that want to
115
+ #: colour a file need the file, not just the lines the diff quoted from it —
116
+ #: a docstring is only a docstring if you can see where it opened.
117
+ a_root: Path | None = None
118
+ b_root: Path | None = None
119
+
120
+ files: list[FileChange] = field(default_factory=list)
121
+ vendor_files_changed: int = 0
122
+ unchanged_files: int = 0
123
+
124
+ deps: list[DepChange] = field(default_factory=list)
125
+ env_added: list[str] = field(default_factory=list)
126
+ env_removed: list[str] = field(default_factory=list)
127
+ services_added: list[str] = field(default_factory=list)
128
+ services_removed: list[str] = field(default_factory=list)
129
+ findings_new: list[dict] = field(default_factory=list)
130
+ findings_fixed: list[dict] = field(default_factory=list)
131
+
132
+ runtime_change: tuple[str, str] | None = None
133
+ handler_change: tuple[str | None, str | None] | None = None
134
+ #: False when the caller asked for a summary only, so line counts are unknown
135
+ #: rather than zero.
136
+ diffs_computed: bool = True
137
+
138
+ # -- summary helpers -------------------------------------------------
139
+ def counts(self) -> dict[str, int]:
140
+ counts = {"added": 0, "removed": 0, "modified": 0, "renamed": 0, "mode-changed": 0}
141
+ for change in self.files:
142
+ counts[change.kind] = counts.get(change.kind, 0) + 1
143
+ return counts
144
+
145
+ @property
146
+ def total_added_lines(self) -> int:
147
+ return sum(c.added_lines for c in self.files)
148
+
149
+ @property
150
+ def total_removed_lines(self) -> int:
151
+ return sum(c.removed_lines for c in self.files)
152
+
153
+ @property
154
+ def is_empty(self) -> bool:
155
+ return not (
156
+ self.files or self.deps or self.env_added or self.env_removed
157
+ or self.services_added or self.services_removed or self.runtime_change
158
+ or self.handler_change or self.vendor_files_changed
159
+ )
160
+
161
+ def headline(self) -> str:
162
+ counts = self.counts()
163
+ parts = [f"{counts[k]} {k}" for k in ("added", "removed", "modified", "renamed") if counts[k]]
164
+ if self.vendor_files_changed:
165
+ parts.append(f"{self.vendor_files_changed} vendored")
166
+ if not parts:
167
+ return "no file changes"
168
+ return ", ".join(parts)
169
+
170
+ def impact_line(self) -> str:
171
+ """What the file counts cannot say: lines moved, and what a deploy now needs.
172
+
173
+ ``headline`` answers "how much changed"; this answers "what does that
174
+ mean for me". Empty when there is nothing of the sort to report, so
175
+ callers can leave the line out rather than print a blank one.
176
+ """
177
+ parts: list[str] = []
178
+ if self.diffs_computed and (self.total_added_lines or self.total_removed_lines):
179
+ parts.append(f"+{self.total_added_lines}/-{self.total_removed_lines} lines")
180
+ arrivals = [
181
+ f"{count} {noun}{'s' if count != 1 else ''}"
182
+ for count, noun in (
183
+ (len(self.env_added), "env var"),
184
+ (len(self.services_added), "AWS service"),
185
+ (len(self.findings_new), "secret"),
186
+ )
187
+ if count
188
+ ]
189
+ if arrivals:
190
+ parts.append("new: " + ", ".join(arrivals))
191
+ return " · ".join(parts)
192
+
193
+ def summary_line(self) -> str:
194
+ """Both halves at once, for somewhere with room for one line and no more."""
195
+ impact = self.impact_line()
196
+ return f"{self.headline()} · {impact}" if impact else self.headline()
197
+
198
+ def as_dict(self) -> dict:
199
+ return {
200
+ "function": self.function_name,
201
+ "from": self.a_seq,
202
+ "to": self.b_seq,
203
+ "counts": self.counts(),
204
+ "lines": {"added": self.total_added_lines, "removed": self.total_removed_lines},
205
+ "vendor_files_changed": self.vendor_files_changed,
206
+ "files": [c.as_dict() for c in self.files],
207
+ "dependencies": [d.as_dict() for d in self.deps],
208
+ "env_vars": {"added": self.env_added, "removed": self.env_removed},
209
+ "services": {"added": self.services_added, "removed": self.services_removed},
210
+ "runtime_change": self.runtime_change,
211
+ "handler_change": self.handler_change,
212
+ "findings_new": self.findings_new,
213
+ "findings_fixed": self.findings_fixed,
214
+ }
215
+
216
+
217
+ def _index(records: Iterable[FileRecord]) -> dict[str, FileRecord]:
218
+ return {r.path: r for r in records}
219
+
220
+ # Pairing every candidate against every other is quadratic, so cap the work.
221
+ _MAX_RENAME_CANDIDATES = 150
222
+ _RENAME_THRESHOLD = 0.55
223
+
224
+
225
+ def _similarity_renames(
226
+ added: list[str],
227
+ removed: list[str],
228
+ old: dict[str, FileRecord],
229
+ new: dict[str, FileRecord],
230
+ old_root: Path,
231
+ new_root: Path,
232
+ cfg: DiffConfig,
233
+ ) -> dict[str, str]:
234
+ """Pair up added/removed files that look like the same file, moved and edited."""
235
+ if not added or not removed:
236
+ return {}
237
+ if len(added) > _MAX_RENAME_CANDIDATES or len(removed) > _MAX_RENAME_CANDIDATES:
238
+ return {}
239
+
240
+ max_bytes = cfg.max_diff_file_kb * 1024
241
+ cache: dict[tuple[str, str], list[str] | None] = {}
242
+
243
+ def lines_of(root: Path, record: FileRecord, side: str) -> list[str] | None:
244
+ key = (side, record.path)
245
+ if key not in cache:
246
+ if not record.is_text or record.size > max_bytes:
247
+ cache[key] = None
248
+ else:
249
+ text = read_text(root / record.path)
250
+ cache[key] = text.splitlines() if text is not None else None
251
+ return cache[key]
252
+
253
+ pairs: list[tuple[float, str, str]] = []
254
+ for new_path in added:
255
+ new_record = new[new_path]
256
+ new_lines = lines_of(new_root, new_record, "new")
257
+ if not new_lines:
258
+ continue
259
+ new_base = new_path.rsplit("/", 1)[-1]
260
+ for old_path in removed:
261
+ old_record = old[old_path]
262
+ if old_record.lang != new_record.lang:
263
+ continue
264
+ # Sizes an order of magnitude apart are not the same file.
265
+ if not (0.2 <= (new_record.size + 1) / (old_record.size + 1) <= 5):
266
+ continue
267
+ old_lines = lines_of(old_root, old_record, "old")
268
+ if not old_lines:
269
+ continue
270
+ matcher = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
271
+ if matcher.quick_ratio() < _RENAME_THRESHOLD:
272
+ continue
273
+ ratio = matcher.ratio()
274
+ if ratio < _RENAME_THRESHOLD:
275
+ continue
276
+ # A matching filename is strong corroboration for a plain move.
277
+ if old_path.rsplit("/", 1)[-1] == new_base:
278
+ ratio = min(1.0, ratio + 0.15)
279
+ pairs.append((ratio, new_path, old_path))
280
+
281
+ # Greedily take the strongest pairings first, one use per file.
282
+ pairs.sort(reverse=True)
283
+ used_old: set[str] = set()
284
+ used_new: set[str] = set()
285
+ matches: dict[str, str] = {}
286
+ for _ratio, new_path, old_path in pairs:
287
+ if new_path in used_new or old_path in used_old:
288
+ continue
289
+ matches[new_path] = old_path
290
+ used_new.add(new_path)
291
+ used_old.add(old_path)
292
+ return matches
293
+
294
+
295
+
296
+ def _unified_diff(
297
+ old_root: Path, new_root: Path, old: FileRecord | None, new: FileRecord | None, cfg: DiffConfig
298
+ ) -> tuple[list[str], int, int, bool, str | None]:
299
+ """Line diff for one file. Returns (lines, added, removed, truncated, skip_reason)."""
300
+ max_bytes = cfg.max_diff_file_kb * 1024
301
+ for record in (old, new):
302
+ if record is None:
303
+ continue
304
+ if not record.is_text:
305
+ return [], 0, 0, False, "binary"
306
+ if record.size > max_bytes:
307
+ return [], 0, 0, False, f"file larger than {cfg.max_diff_file_kb} KB"
308
+
309
+ old_text = read_text(old_root / old.path) if old else ""
310
+ new_text = read_text(new_root / new.path) if new else ""
311
+ if old_text is None or new_text is None:
312
+ return [], 0, 0, False, "not decodable as text"
313
+
314
+ old_lines = old_text.splitlines(keepends=True)
315
+ new_lines = new_text.splitlines(keepends=True)
316
+ diff = list(
317
+ difflib.unified_diff(
318
+ old_lines,
319
+ new_lines,
320
+ fromfile=f"a/{old.path}" if old else "/dev/null",
321
+ tofile=f"b/{new.path}" if new else "/dev/null",
322
+ n=cfg.context_lines,
323
+ )
324
+ )
325
+ added = sum(1 for line in diff if line.startswith("+") and not line.startswith("+++"))
326
+ removed = sum(1 for line in diff if line.startswith("-") and not line.startswith("---"))
327
+
328
+ truncated = False
329
+ if len(diff) > cfg.max_diff_lines:
330
+ diff = diff[: cfg.max_diff_lines]
331
+ truncated = True
332
+ return [line.rstrip("\n") for line in diff], added, removed, truncated, None
333
+
334
+
335
+ def _diff_deps(old_rows: list[Any], new_rows: list[Any]) -> list[DepChange]:
336
+ def key(row: Any) -> tuple[str, str, int]:
337
+ return (row["manager"], row["name"].lower(), int(row["is_declared"]))
338
+
339
+ old_map = {key(r): r for r in old_rows}
340
+ new_map = {key(r): r for r in new_rows}
341
+ changes: list[DepChange] = []
342
+
343
+ for k, row in new_map.items():
344
+ previous = old_map.get(k)
345
+ if previous is None:
346
+ changes.append(
347
+ DepChange("added", row["manager"], row["name"], None, row["version"], bool(k[2]))
348
+ )
349
+ elif (previous["version"] or "") != (row["version"] or ""):
350
+ changes.append(
351
+ DepChange("changed", row["manager"], row["name"], previous["version"],
352
+ row["version"], bool(k[2]))
353
+ )
354
+ for k, row in old_map.items():
355
+ if k not in new_map:
356
+ changes.append(
357
+ DepChange("removed", row["manager"], row["name"], row["version"], None, bool(k[2]))
358
+ )
359
+
360
+ # A package usually appears twice - once declared in a manifest, once
361
+ # installed in the zip. When both tell the same story, show one row.
362
+ collapsed: list[DepChange] = []
363
+ by_identity: dict[tuple[str, str, str, str | None, str | None], list[DepChange]] = {}
364
+ for change in changes:
365
+ key = (change.manager, change.name.lower(), change.kind, change.old_version, change.new_version)
366
+ by_identity.setdefault(key, []).append(change)
367
+ for group in by_identity.values():
368
+ if len(group) > 1:
369
+ # Keep the installed row: it is what actually shipped.
370
+ installed = next((c for c in group if not c.is_declared), group[0])
371
+ collapsed.append(installed)
372
+ else:
373
+ collapsed.append(group[0])
374
+
375
+ order = {"added": 0, "changed": 1, "removed": 2}
376
+ collapsed.sort(key=lambda c: (order[c.kind], c.manager, c.name.lower()))
377
+ return collapsed
378
+
379
+
380
+ def _finding_key(row: Any) -> tuple:
381
+ return (row["kind"], row["path"], row["detail"])
382
+
383
+
384
+ def compare_versions(
385
+ function_name: str,
386
+ a_seq: int,
387
+ b_seq: int,
388
+ a_files: list[Any],
389
+ b_files: list[Any],
390
+ a_root: Path,
391
+ b_root: Path,
392
+ cfg: DiffConfig,
393
+ a_deps: list[Any] | None = None,
394
+ b_deps: list[Any] | None = None,
395
+ a_env: list[Any] | None = None,
396
+ b_env: list[Any] | None = None,
397
+ a_services: list[Any] | None = None,
398
+ b_services: list[Any] | None = None,
399
+ a_findings: list[Any] | None = None,
400
+ b_findings: list[Any] | None = None,
401
+ a_meta: dict | None = None,
402
+ b_meta: dict | None = None,
403
+ include_vendor: bool | None = None,
404
+ compute_diffs: bool = True,
405
+ ) -> VersionDiff:
406
+ """Compare two indexed versions, reading file contents from disk."""
407
+ show_vendor = (not cfg.ignore_vendor) if include_vendor is None else include_vendor
408
+
409
+ old = _index(FileRecord.from_row(r) for r in a_files)
410
+ new = _index(FileRecord.from_row(r) for r in b_files)
411
+
412
+ result = VersionDiff(function_name, a_seq, b_seq, a_meta or {}, b_meta or {}, a_root, b_root)
413
+ result.diffs_computed = compute_diffs
414
+
415
+ added_paths = [p for p in new if p not in old]
416
+ removed_paths = [p for p in old if p not in new]
417
+ common_paths = [p for p in new if p in old]
418
+
419
+ # Rename detection: identical content under a different path.
420
+ removed_by_hash: dict[str, list[str]] = {}
421
+ for path in removed_paths:
422
+ removed_by_hash.setdefault(old[path].sha256, []).append(path)
423
+
424
+ renames: dict[str, str] = {} # new path -> old path
425
+ for path in added_paths:
426
+ bucket = removed_by_hash.get(new[path].sha256)
427
+ if bucket:
428
+ renames[path] = bucket.pop(0)
429
+
430
+ # Files that moved *and* changed are the interesting case: a rename plus an
431
+ # edit reads far better than an unrelated add and delete.
432
+ leftover_added = [p for p in added_paths if p not in renames]
433
+ leftover_removed = [p for p in removed_paths if p not in set(renames.values())]
434
+ renames.update(
435
+ _similarity_renames(leftover_added, leftover_removed, old, new, a_root, b_root, cfg)
436
+ )
437
+ renamed_old = set(renames.values())
438
+
439
+ changes: list[FileChange] = []
440
+
441
+ for path in sorted(added_paths):
442
+ record = new[path]
443
+ if path in renames:
444
+ changes.append(
445
+ FileChange("renamed", path, renames[path], old[renames[path]], record)
446
+ )
447
+ continue
448
+ if record.is_vendor and not show_vendor:
449
+ result.vendor_files_changed += 1
450
+ continue
451
+ changes.append(FileChange("added", path, None, None, record))
452
+
453
+ for path in sorted(removed_paths):
454
+ if path in renamed_old:
455
+ continue
456
+ record = old[path]
457
+ if record.is_vendor and not show_vendor:
458
+ result.vendor_files_changed += 1
459
+ continue
460
+ changes.append(FileChange("removed", path, None, record, None))
461
+
462
+ for path in sorted(common_paths):
463
+ before, after = old[path], new[path]
464
+ if before.sha256 == after.sha256:
465
+ if before.mode != after.mode:
466
+ changes.append(FileChange("mode-changed", path, None, before, after))
467
+ else:
468
+ result.unchanged_files += 1
469
+ continue
470
+ if after.is_vendor and not show_vendor:
471
+ result.vendor_files_changed += 1
472
+ continue
473
+ changes.append(FileChange("modified", path, None, before, after))
474
+
475
+ # Fill in the line diffs.
476
+ if compute_diffs:
477
+ for change in changes:
478
+ if change.kind == "mode-changed":
479
+ continue
480
+ if matches_any(change.path, cfg.ignore_globs):
481
+ change.skipped_reason = "ignored by config"
482
+ continue
483
+ lines, plus, minus, truncated, reason = _unified_diff(
484
+ a_root, b_root, change.old, change.new, cfg
485
+ )
486
+ change.diff_lines = lines
487
+ change.added_lines = plus
488
+ change.removed_lines = minus
489
+ change.truncated = truncated
490
+ change.skipped_reason = reason
491
+ change.binary = reason == "binary"
492
+
493
+ # First-party code first, then vendored; alphabetical within each group.
494
+ kind_order = {"modified": 0, "added": 1, "renamed": 2, "removed": 3, "mode-changed": 4}
495
+ changes.sort(key=lambda c: (c.is_vendor, kind_order.get(c.kind, 9), c.path))
496
+ result.files = changes
497
+
498
+ if a_deps is not None and b_deps is not None:
499
+ result.deps = _diff_deps(a_deps, b_deps)
500
+
501
+ if a_env is not None and b_env is not None:
502
+ old_env = {r["name"] for r in a_env}
503
+ new_env = {r["name"] for r in b_env}
504
+ result.env_added = sorted(new_env - old_env)
505
+ result.env_removed = sorted(old_env - new_env)
506
+
507
+ if a_services is not None and b_services is not None:
508
+ old_services = {r["service"] for r in a_services}
509
+ new_services = {r["service"] for r in b_services}
510
+ result.services_added = sorted(new_services - old_services)
511
+ result.services_removed = sorted(old_services - new_services)
512
+
513
+ if a_findings is not None and b_findings is not None:
514
+ old_keys = {_finding_key(r) for r in a_findings}
515
+ new_keys = {_finding_key(r) for r in b_findings}
516
+ result.findings_new = [dict(r) for r in b_findings if _finding_key(r) not in old_keys]
517
+ result.findings_fixed = [dict(r) for r in a_findings if _finding_key(r) not in new_keys]
518
+
519
+ if a_meta and b_meta:
520
+ if a_meta.get("runtime") != b_meta.get("runtime"):
521
+ result.runtime_change = (a_meta.get("runtime") or "?", b_meta.get("runtime") or "?")
522
+ if a_meta.get("handler") != b_meta.get("handler"):
523
+ result.handler_change = (a_meta.get("handler"), b_meta.get("handler"))
524
+
525
+ return result