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,59 @@
1
+ """Best-effort desktop notifications, with no extra dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+
9
+ from .utils import LOG
10
+
11
+
12
+ def notify(title: str, message: str, enabled: bool = True) -> bool:
13
+ """Show a desktop notification. Never raises; returns True if it fired."""
14
+ if not enabled:
15
+ return False
16
+ try:
17
+ if sys.platform == "darwin":
18
+ script = (
19
+ f'display notification {_applescript_quote(message)} '
20
+ f'with title {_applescript_quote(title)}'
21
+ )
22
+ subprocess.run(["osascript", "-e", script], capture_output=True, timeout=10)
23
+ return True
24
+ if sys.platform.startswith("win"):
25
+ ps = (
26
+ "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications,"
27
+ " ContentType = WindowsRuntime] > $null; "
28
+ "$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(2); "
29
+ f"$t.GetElementsByTagName('text')[0]"
30
+ f".AppendChild($t.CreateTextNode({_ps_quote(title)})) > $null; "
31
+ f"$t.GetElementsByTagName('text')[1]"
32
+ f".AppendChild($t.CreateTextNode({_ps_quote(message)})) > $null; "
33
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('lambda-watcher')"
34
+ ".Show([Windows.UI.Notifications.ToastNotification]::new($t))"
35
+ )
36
+ subprocess.run(
37
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
38
+ capture_output=True,
39
+ timeout=15,
40
+ )
41
+ return True
42
+ if shutil.which("notify-send"):
43
+ subprocess.run(
44
+ ["notify-send", "-a", "lambda-watcher", title, message],
45
+ capture_output=True,
46
+ timeout=10,
47
+ )
48
+ return True
49
+ except (OSError, subprocess.SubprocessError) as exc:
50
+ LOG.debug("notification failed: %s", exc)
51
+ return False
52
+
53
+
54
+ def _applescript_quote(value: str) -> str:
55
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
56
+
57
+
58
+ def _ps_quote(value: str) -> str:
59
+ return "'" + value.replace("'", "''") + "'"
@@ -0,0 +1,158 @@
1
+ """Rebuild the SQLite index from the manifests on disk.
2
+
3
+ The archive directories are the source of truth, so the index can always be
4
+ thrown away and reconstructed — after a crash, a manual reorganisation, or a
5
+ copy of the store onto another machine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .config import Config
14
+ from .db import Database
15
+ from .store import Store
16
+ from .utils import LOG, utc_now_iso
17
+
18
+
19
+ def _iter_version_dirs(functions_dir: Path):
20
+ for function_dir in sorted(p for p in functions_dir.glob("*") if p.is_dir()):
21
+ versions = function_dir / "versions"
22
+ if not versions.is_dir():
23
+ continue
24
+ for version_dir in sorted(p for p in versions.glob("*") if p.is_dir()):
25
+ yield function_dir, version_dir
26
+
27
+
28
+ def rebuild(cfg: Config) -> dict[str, int]:
29
+ """Drop and repopulate the index. Returns counts for reporting."""
30
+ store = Store(cfg)
31
+ if cfg.db_path.exists():
32
+ backup = cfg.db_path.with_suffix(".db.bak")
33
+ try:
34
+ backup.unlink(missing_ok=True)
35
+ cfg.db_path.replace(backup)
36
+ except OSError as exc:
37
+ LOG.warning("could not back up the old index: %s", exc)
38
+ for suffix in ("-wal", "-shm"):
39
+ Path(str(cfg.db_path) + suffix).unlink(missing_ok=True)
40
+
41
+ db = Database(cfg.db_path)
42
+ stats = {"functions": 0, "versions": 0, "skipped": 0}
43
+ now = utc_now_iso()
44
+ seen_functions: dict[str, int] = {}
45
+
46
+ for function_dir, version_dir in _iter_version_dirs(cfg.functions_dir):
47
+ manifest = store.read_manifest(version_dir)
48
+ if not manifest:
49
+ LOG.warning("no manifest in %s, skipping", version_dir)
50
+ stats["skipped"] += 1
51
+ continue
52
+
53
+ function = manifest.get("function") or {}
54
+ name = function.get("name") or function_dir.name
55
+ slug = function.get("slug") or function_dir.name
56
+ ingested_at = (manifest.get("version") or {}).get("ingested_at") or now
57
+
58
+ if name not in seen_functions:
59
+ seen_functions[name] = db.upsert_function(name, slug, ingested_at)
60
+ stats["functions"] += 1
61
+ function_id = seen_functions[name]
62
+ db.conn.execute(
63
+ "UPDATE functions SET last_seen = MAX(last_seen, ?) WHERE id = ?",
64
+ (ingested_at, function_id),
65
+ )
66
+
67
+ try:
68
+ _insert(db, store, function_id, manifest, version_dir)
69
+ stats["versions"] += 1
70
+ except Exception as exc: # noqa: BLE001
71
+ LOG.warning("could not index %s: %s", version_dir, exc)
72
+ stats["skipped"] += 1
73
+
74
+ db.log_event("reindex", now, detail=stats)
75
+ db.close()
76
+ return stats
77
+
78
+
79
+ def _insert(db: Database, store: Store, function_id: int, manifest: dict[str, Any],
80
+ version_dir: Path) -> None:
81
+ version_meta = manifest.get("version") or {}
82
+ source = manifest.get("source") or {}
83
+ runtime = manifest.get("runtime") or {}
84
+ totals = manifest.get("totals") or {}
85
+ handlers = manifest.get("handlers") or []
86
+
87
+ seq = int(version_meta.get("seq") or 0)
88
+ if not seq:
89
+ # Fall back to the numeric prefix of the directory name.
90
+ seq = int(version_dir.name.split("-")[0])
91
+
92
+ with db.transaction():
93
+ version_id = db.insert_version(
94
+ {
95
+ "function_id": function_id,
96
+ "seq": seq,
97
+ "tree_hash": manifest.get("tree_hash") or version_dir.name,
98
+ "zip_sha256": source.get("zip_sha256"),
99
+ "zip_size": source.get("zip_size"),
100
+ "source_name": source.get("filename"),
101
+ "source_path": source.get("path"),
102
+ "source_mtime": source.get("mtime"),
103
+ "ingested_at": version_meta.get("ingested_at") or utc_now_iso(),
104
+ "dir": store.relative(version_dir),
105
+ "runtime": runtime.get("runtime"),
106
+ "runtime_confidence": runtime.get("confidence"),
107
+ "handler": handlers[0]["handler"] if handlers else None,
108
+ "file_count": totals.get("file_count", 0),
109
+ "total_size": totals.get("total_size", 0),
110
+ "code_file_count": totals.get("code_file_count", 0),
111
+ "code_size": totals.get("code_size", 0),
112
+ "code_lines": totals.get("code_lines", 0),
113
+ "label": version_meta.get("label"),
114
+ }
115
+ )
116
+ db.bulk_insert(
117
+ "files",
118
+ ["version_id", "path", "size", "sha256", "mode", "is_text", "is_vendor", "lang", "lines"],
119
+ [
120
+ (version_id, f["path"], f["size"], f["sha256"], f.get("mode"),
121
+ int(bool(f.get("is_text"))), int(bool(f.get("is_vendor"))),
122
+ f.get("lang"), f.get("lines", 0))
123
+ for f in manifest.get("files", [])
124
+ ],
125
+ )
126
+ db.bulk_insert(
127
+ "deps", ["version_id", "manager", "name", "version", "source", "is_declared"],
128
+ [
129
+ (version_id, d["manager"], d["name"], d.get("version"), d.get("source"),
130
+ int(bool(d.get("is_declared"))))
131
+ for d in manifest.get("dependencies", [])
132
+ ],
133
+ )
134
+ db.bulk_insert(
135
+ "env_vars", ["version_id", "name", "path", "line"],
136
+ [
137
+ (version_id, e["name"], e.get("path"), e.get("line"))
138
+ for e in manifest.get("env_vars", []) if not e.get("is_reserved")
139
+ ],
140
+ )
141
+ db.bulk_insert(
142
+ "services", ["version_id", "service", "path", "line"],
143
+ [(version_id, s["service"], s.get("path"), s.get("line"))
144
+ for s in manifest.get("services", [])],
145
+ )
146
+ db.bulk_insert(
147
+ "findings", ["version_id", "kind", "severity", "path", "line", "detail", "is_vendor"],
148
+ [
149
+ (version_id, f["kind"], f["severity"], f.get("path"), f.get("line"),
150
+ f.get("detail"), int(bool(f.get("is_vendor"))))
151
+ for f in manifest.get("findings", [])
152
+ ],
153
+ )
154
+ if source.get("zip_sha256"):
155
+ db.mark_download_seen(
156
+ source["zip_sha256"], version_meta.get("ingested_at") or utc_now_iso(),
157
+ source.get("filename") or "",
158
+ )