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
lambda_watcher/config.py
ADDED
|
@@ -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
|