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,324 @@
1
+ """Configuration loading for lambda-watcher.
2
+
3
+ Config lives in a single YAML file (default ``~/.lambda-watcher/config.yaml``).
4
+ Every field has a working default, so the tool runs with no config at all.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import sys
12
+ from dataclasses import dataclass, field, fields, is_dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ DEFAULT_HOME = Path(os.environ.get("LAMBDA_WATCHER_HOME", "~/.lambda-watcher")).expanduser()
19
+ CONFIG_ENV_VAR = "LAMBDA_WATCHER_CONFIG"
20
+
21
+
22
+ def default_download_dirs() -> list[str]:
23
+ """Best guess at the user's downloads folder for the current platform."""
24
+ candidates: list[Path] = []
25
+ if sys.platform.startswith("linux"):
26
+ # Respect XDG user dirs when present (handles localised folder names).
27
+ xdg = Path("~/.config/user-dirs.dirs").expanduser()
28
+ if xdg.exists():
29
+ try:
30
+ text = xdg.read_text(encoding="utf-8", errors="replace")
31
+ m = re.search(r'^XDG_DOWNLOAD_DIR="(.+)"', text, re.MULTILINE)
32
+ if m:
33
+ raw = m.group(1).replace("$HOME", str(Path.home()))
34
+ candidates.append(Path(raw))
35
+ except OSError:
36
+ pass
37
+ candidates.append(Path("~/Downloads").expanduser())
38
+ seen: list[str] = []
39
+ for c in candidates:
40
+ s = str(c)
41
+ if s not in seen:
42
+ seen.append(s)
43
+ return seen
44
+
45
+
46
+ @dataclass
47
+ class WatchConfig:
48
+ """How the filesystem watcher behaves."""
49
+
50
+ dirs: list[str] = field(default_factory=default_download_dirs)
51
+ #: Only files with these suffixes are considered.
52
+ extensions: list[str] = field(default_factory=lambda: [".zip"])
53
+ #: Suffixes browsers use for in-flight downloads; never ingested directly.
54
+ partial_suffixes: list[str] = field(
55
+ default_factory=lambda: [".crdownload", ".part", ".download", ".tmp", ".partial", ".opdownload"]
56
+ )
57
+ #: A file must keep the same size/mtime for this long before it is ingested.
58
+ stable_seconds: float = 2.0
59
+ #: Give up waiting for a file to settle after this long.
60
+ max_wait_seconds: float = 900.0
61
+ #: Watch sub-directories of the watched folders too.
62
+ recursive: bool = False
63
+ #: Use polling instead of native OS events (needed on network/WSL mounts).
64
+ force_polling: bool = False
65
+ polling_interval: float = 2.0
66
+ #: How recently a file must have been written to still count as an arrival.
67
+ #: Windows raises "modified" events for antivirus scans, search indexing and
68
+ #: cloud-sync attribute changes as well as for real writes, so those events
69
+ #: are ignored for anything older than this - and a file this old is never
70
+ #: deleted from the watched folder by ``store.on_ingest: move``.
71
+ #: 0 disables the check.
72
+ arrival_max_age_seconds: float = 300.0
73
+ #: Ingest matching files already present when the watcher starts.
74
+ scan_on_start: bool = True
75
+ #: Ignore files older than this at startup scan (0 disables the cutoff).
76
+ scan_on_start_max_age_hours: float = 24.0
77
+
78
+
79
+ @dataclass
80
+ class StoreConfig:
81
+ """Where archived versions live and what gets kept."""
82
+
83
+ root: str = str(DEFAULT_HOME)
84
+ #: ``copy`` keeps the download in place, ``move`` clears it out of Downloads,
85
+ #: ``leave`` archives nothing but the extracted tree.
86
+ on_ingest: str = "copy"
87
+ #: Keep the original .zip alongside the extracted tree.
88
+ keep_zip: bool = True
89
+ #: Lift a lone wrapping directory's contents to the root of the version.
90
+ #: Source archives (GitHub, npm, `git archive`) name that directory after
91
+ #: the ref, so leaving it in place makes every download look like a total
92
+ #: rewrite. Turn off to archive trees exactly as the zip laid them out.
93
+ strip_wrapper_dir: bool = True
94
+ #: Refuse archives whose uncompressed size exceeds this (zip-bomb guard).
95
+ max_uncompressed_mb: int = 2048
96
+ max_files: int = 200_000
97
+ #: Delete versions beyond this count per function (0 = keep everything).
98
+ max_versions_per_function: int = 0
99
+
100
+
101
+ @dataclass
102
+ class NamingConfig:
103
+ """Turning a downloaded filename into a Lambda function name."""
104
+
105
+ #: Explicit rules, applied first. ``pattern`` is a regex matched against the
106
+ #: filename; ``name`` may reference groups, e.g. ``\\1``.
107
+ rules: list[dict[str, str]] = field(default_factory=list)
108
+ #: Regexes stripped from the stem, in order, before falling back to it.
109
+ #: Order matters: the most specific suffixes are removed first. A trailing
110
+ #: "-2"/"-v2" is deliberately NOT stripped, because it is usually part of a
111
+ #: real function name; `lambda-watcher rename --merge` fixes the rare miss.
112
+ strip_patterns: list[str] = field(
113
+ default_factory=lambda: [
114
+ r"\s*\(\d+\)$", # Chrome/Firefox "name (1).zip"
115
+ r"[-_][0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", # uuid
116
+ r"[-_]\d{4}-\d{2}-\d{2}([-_T]\d{2}[-_:]?\d{2}([-_:]?\d{2})?)?$", # 2026-01-15 stamps
117
+ r"[-_]\d{8}([-_T]\d{4,6})?$", # 20260115-1030 stamps
118
+ r"[-_][0-9a-fA-F]{16,64}$", # hex blobs / code sha
119
+ r"[-_]\d{10,13}$", # epoch stamps
120
+ r"[-_](copy|final|backup|bak|old|new|latest)$",
121
+ # A source archive is named after the ref it was cut from, so one
122
+ # repo downloaded at three refs is still one project. These sit
123
+ # last: anything date- or epoch-shaped is claimed above first.
124
+ r"[-_](main|master|develop|trunk|HEAD)$",
125
+ r"[-_]v?\d+\.\d+(\.\d+)?([-.][A-Za-z0-9.]+)?$", # 1.2.3, v1.2.3, 2.0.0-rc1
126
+ # Short commit sha. The lookahead demands at least one digit, so
127
+ # English words that happen to be all-hex ("defaced", "effaced")
128
+ # keep their place on the end of a name.
129
+ r"[-_](?=[0-9a-f]*\d)[0-9a-f]{7,12}$",
130
+ ]
131
+ )
132
+ #: Case-insensitive matching when resolving an existing function.
133
+ case_insensitive: bool = True
134
+ #: If the stem yields nothing useful, look inside the zip for a single top
135
+ #: level directory and use that as the name.
136
+ infer_from_zip: bool = True
137
+
138
+
139
+ @dataclass
140
+ class AnalysisConfig:
141
+ """What the analysers look at."""
142
+
143
+ scan_secrets: bool = True
144
+ scan_env_vars: bool = True
145
+ scan_aws_services: bool = True
146
+ #: Files larger than this are hashed but not read for text analysis.
147
+ max_scan_file_kb: int = 2048
148
+ #: Path prefixes/globs treated as vendored dependencies rather than your code.
149
+ vendor_globs: list[str] = field(
150
+ default_factory=lambda: [
151
+ "node_modules/**",
152
+ "**/node_modules/**",
153
+ "**/site-packages/**",
154
+ "**/dist-info/**",
155
+ "**/*.dist-info/**",
156
+ "**/*.egg-info/**",
157
+ "vendor/**",
158
+ "**/__pycache__/**",
159
+ ".venv/**",
160
+ "venv/**",
161
+ ]
162
+ )
163
+
164
+
165
+ @dataclass
166
+ class DiffConfig:
167
+ """Defaults for the diff commands."""
168
+
169
+ #: Skip vendored files in diffs unless asked for.
170
+ ignore_vendor: bool = True
171
+ #: Unified-diff context lines.
172
+ context_lines: int = 3
173
+ #: Files bigger than this are reported as changed without a line diff.
174
+ max_diff_file_kb: int = 512
175
+ #: Diffs longer than this many lines are truncated in reports.
176
+ max_diff_lines: int = 2000
177
+ #: Paths never diffed (still tracked for add/remove).
178
+ ignore_globs: list[str] = field(default_factory=lambda: ["**/*.pyc", "**/*.so", "**/*.map"])
179
+
180
+
181
+ @dataclass
182
+ class GitMirrorConfig:
183
+ """Optional per-function git repository, one commit per version."""
184
+
185
+ enabled: bool = True
186
+ author_name: str = "lambda-watcher"
187
+ author_email: str = "lambda-watcher@localhost"
188
+ #: Include vendored files in the mirror (off keeps repos small and readable).
189
+ include_vendor: bool = True
190
+ tag_prefix: str = "v"
191
+
192
+
193
+ @dataclass
194
+ class NotifyConfig:
195
+ enabled: bool = True
196
+ #: Only notify when the new version differs from the previous one.
197
+ only_on_change: bool = True
198
+ #: Say what changed, not just that something did. A notification reading
199
+ #: "2 modified, +24/-5 lines, 1 env var" is worth glancing at; one reading
200
+ #: "34 files, 8.9 KB" is bookkeeping.
201
+ summarise_changes: bool = True
202
+
203
+
204
+ @dataclass
205
+ class ReportConfig:
206
+ """HTML written without anyone asking for it.
207
+
208
+ Once the watcher runs in the background, nobody is looking at a terminal at
209
+ the moment a version lands. Rendering the comparison right then means the
210
+ notification can point at a page that already exists, and the answer to
211
+ "what changed?" is a bookmark rather than a command.
212
+ """
213
+
214
+ #: Render the diff against the previous version as each one is archived.
215
+ auto_diff: bool = True
216
+ #: Include vendored dependency files in those automatic diffs.
217
+ include_vendor: bool = False
218
+
219
+
220
+ @dataclass
221
+ class Config:
222
+ watch: WatchConfig = field(default_factory=WatchConfig)
223
+ store: StoreConfig = field(default_factory=StoreConfig)
224
+ naming: NamingConfig = field(default_factory=NamingConfig)
225
+ analysis: AnalysisConfig = field(default_factory=AnalysisConfig)
226
+ diff: DiffConfig = field(default_factory=DiffConfig)
227
+ git_mirror: GitMirrorConfig = field(default_factory=GitMirrorConfig)
228
+ notify: NotifyConfig = field(default_factory=NotifyConfig)
229
+ report: ReportConfig = field(default_factory=ReportConfig)
230
+ #: Command `open` launches on a folder. Empty means "find one on PATH".
231
+ editor: str = ""
232
+ log_level: str = "INFO"
233
+
234
+ # -- derived paths ---------------------------------------------------
235
+ @property
236
+ def root(self) -> Path:
237
+ return Path(self.store.root).expanduser()
238
+
239
+ @property
240
+ def db_path(self) -> Path:
241
+ return self.root / "index.db"
242
+
243
+ @property
244
+ def functions_dir(self) -> Path:
245
+ return self.root / "functions"
246
+
247
+ @property
248
+ def log_dir(self) -> Path:
249
+ return self.root / "logs"
250
+
251
+ @property
252
+ def reports_dir(self) -> Path:
253
+ return self.root / "reports"
254
+
255
+ @property
256
+ def repos_dir(self) -> Path:
257
+ return self.root / "repos"
258
+
259
+ @property
260
+ def quarantine_dir(self) -> Path:
261
+ return self.root / "quarantine"
262
+
263
+ def ensure_dirs(self) -> None:
264
+ for path in (self.root, self.functions_dir, self.log_dir, self.reports_dir):
265
+ path.mkdir(parents=True, exist_ok=True)
266
+
267
+ def watch_dirs(self) -> list[Path]:
268
+ return [Path(d).expanduser() for d in self.watch.dirs]
269
+
270
+
271
+ def _from_dict(cls: type, data: dict[str, Any]) -> Any:
272
+ """Build a (possibly nested) dataclass from a plain dict, ignoring unknowns."""
273
+ kwargs: dict[str, Any] = {}
274
+ known = {f.name: f for f in fields(cls)}
275
+ for key, value in (data or {}).items():
276
+ f = known.get(key)
277
+ if f is None:
278
+ continue
279
+ if is_dataclass(f.type) and isinstance(value, dict):
280
+ kwargs[key] = _from_dict(f.type, value)
281
+ elif isinstance(value, dict) and hasattr(f.type, "__dataclass_fields__"):
282
+ kwargs[key] = _from_dict(f.type, value)
283
+ else:
284
+ kwargs[key] = value
285
+ return cls(**kwargs)
286
+
287
+
288
+ def default_config_path() -> Path:
289
+ env = os.environ.get(CONFIG_ENV_VAR)
290
+ if env:
291
+ return Path(env).expanduser()
292
+ return DEFAULT_HOME / "config.yaml"
293
+
294
+
295
+ def load_config(path: Path | str | None = None) -> Config:
296
+ """Load config from YAML, falling back to defaults for anything absent."""
297
+ cfg_path = Path(path).expanduser() if path else default_config_path()
298
+ data: dict[str, Any] = {}
299
+ if cfg_path.exists():
300
+ loaded = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
301
+ if not isinstance(loaded, dict):
302
+ raise ValueError(f"{cfg_path} must contain a YAML mapping")
303
+ data = loaded
304
+
305
+ cfg = Config(
306
+ watch=_from_dict(WatchConfig, data.get("watch", {})),
307
+ store=_from_dict(StoreConfig, data.get("store", {})),
308
+ naming=_from_dict(NamingConfig, data.get("naming", {})),
309
+ analysis=_from_dict(AnalysisConfig, data.get("analysis", {})),
310
+ diff=_from_dict(DiffConfig, data.get("diff", {})),
311
+ git_mirror=_from_dict(GitMirrorConfig, data.get("git_mirror", {})),
312
+ notify=_from_dict(NotifyConfig, data.get("notify", {})),
313
+ report=_from_dict(ReportConfig, data.get("report", {})),
314
+ editor=data.get("editor", ""),
315
+ log_level=data.get("log_level", "INFO"),
316
+ )
317
+ # Environment overrides make it easy to run one-off commands elsewhere.
318
+ if os.environ.get("LAMBDA_WATCHER_HOME"):
319
+ cfg.store.root = str(Path(os.environ["LAMBDA_WATCHER_HOME"]).expanduser())
320
+ if os.environ.get("LAMBDA_WATCHER_LOG_LEVEL"):
321
+ cfg.log_level = os.environ["LAMBDA_WATCHER_LOG_LEVEL"]
322
+ if os.environ.get("LAMBDA_WATCHER_EDITOR"):
323
+ cfg.editor = os.environ["LAMBDA_WATCHER_EDITOR"]
324
+ return cfg