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,209 @@
1
+ """On-disk layout of the archive.
2
+
3
+ ::
4
+
5
+ <root>/
6
+ config.yaml
7
+ index.db
8
+ logs/watcher.log
9
+ reports/
10
+ quarantine/ # archives that failed to extract
11
+ repos/
12
+ <slug>/ # optional git mirror, one commit per version
13
+ functions/
14
+ <slug>/
15
+ versions/
16
+ 0001-a1b2c3d4/
17
+ code/ # the extracted tree
18
+ manifest.json # full analysis for this version
19
+ package.zip # the original download (optional)
20
+
21
+ The directories are the source of truth. ``index.db`` is a rebuildable index.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import shutil
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from .config import Config
33
+ from .utils import LOG, rmtree, short_hash
34
+
35
+
36
+ #: Where the mirror used to sit, inside the function directory. Both spellings
37
+ #: are migrated: ``git/`` shipped, ``repo/`` was a brief step on the way here.
38
+ LEGACY_REPO_DIRNAMES = ("git", "repo")
39
+
40
+
41
+ @dataclass
42
+ class VersionPaths:
43
+ root: Path
44
+
45
+ @property
46
+ def code(self) -> Path:
47
+ return self.root / "code"
48
+
49
+ @property
50
+ def manifest(self) -> Path:
51
+ return self.root / "manifest.json"
52
+
53
+ @property
54
+ def package(self) -> Path:
55
+ return self.root / "package.zip"
56
+
57
+
58
+ class Store:
59
+ def __init__(self, cfg: Config) -> None:
60
+ self.cfg = cfg
61
+ cfg.ensure_dirs()
62
+
63
+ # -- paths -----------------------------------------------------------
64
+ def function_dir(self, slug: str) -> Path:
65
+ return self.cfg.functions_dir / slug
66
+
67
+ def versions_dir(self, slug: str) -> Path:
68
+ return self.function_dir(slug) / "versions"
69
+
70
+ def repo_dir(self, slug: str) -> Path:
71
+ """The function's git mirror: a real working tree you can open in an editor.
72
+
73
+ It sits at ``repos/<slug>/`` rather than inside the function directory
74
+ for one blunt reason — an editor names the window after the folder you
75
+ opened, and every function opening as "repo" would be useless. Here the
76
+ folder is called ``order-processor``, which is what you want to read in
77
+ the sidebar. Generated per-function output already lives this way:
78
+ ``reports/<slug>/`` is the same shape.
79
+
80
+ Older archives kept it under the function directory. Those are moved
81
+ here on first access, so a store from a previous version keeps working
82
+ without a reindex.
83
+ """
84
+ repo = self.cfg.repos_dir / slug
85
+ if repo.exists():
86
+ return repo
87
+ for name in LEGACY_REPO_DIRNAMES:
88
+ legacy = self.function_dir(slug) / name
89
+ if not (legacy / ".git").is_dir():
90
+ continue
91
+ try:
92
+ repo.parent.mkdir(parents=True, exist_ok=True)
93
+ legacy.rename(repo)
94
+ LOG.info("moved the git mirror from %s to %s", legacy, repo)
95
+ except OSError as exc:
96
+ LOG.warning("could not move %s to %s: %s", legacy, repo, exc)
97
+ return legacy
98
+ break
99
+ return repo
100
+
101
+ def version_dirname(self, seq: int, tree_hash: str) -> str:
102
+ return f"{seq:04d}-{short_hash(tree_hash)}"
103
+
104
+ def version_paths(self, slug: str, seq: int, tree_hash: str) -> VersionPaths:
105
+ return VersionPaths(self.versions_dir(slug) / self.version_dirname(seq, tree_hash))
106
+
107
+ def resolve_version_dir(self, stored_dir: str) -> Path:
108
+ """Version dirs are stored relative to the root so the store can move."""
109
+ path = Path(stored_dir)
110
+ return path if path.is_absolute() else self.cfg.root / path
111
+
112
+ def relative(self, path: Path) -> str:
113
+ try:
114
+ return str(path.relative_to(self.cfg.root))
115
+ except ValueError:
116
+ return str(path)
117
+
118
+ # -- staging ---------------------------------------------------------
119
+ def new_staging_dir(self, token: str) -> Path:
120
+ """A scratch directory on the same filesystem as the final location."""
121
+ staging = self.cfg.root / ".staging" / token
122
+ rmtree(staging)
123
+ staging.mkdir(parents=True, exist_ok=True)
124
+ return staging
125
+
126
+ def clear_staging(self) -> None:
127
+ rmtree(self.cfg.root / ".staging")
128
+
129
+ # -- manifests -------------------------------------------------------
130
+ def write_manifest(self, paths: VersionPaths, manifest: dict[str, Any]) -> None:
131
+ paths.manifest.parent.mkdir(parents=True, exist_ok=True)
132
+ paths.manifest.write_text(
133
+ json.dumps(manifest, indent=2, sort_keys=False, ensure_ascii=False),
134
+ encoding="utf-8",
135
+ )
136
+
137
+ def read_manifest(self, version_dir: Path) -> dict[str, Any] | None:
138
+ manifest = Path(version_dir) / "manifest.json"
139
+ if not manifest.exists():
140
+ return None
141
+ try:
142
+ return json.loads(manifest.read_text(encoding="utf-8"))
143
+ except (json.JSONDecodeError, OSError) as exc:
144
+ LOG.warning("could not read %s: %s", manifest, exc)
145
+ return None
146
+
147
+ # -- archive handling ------------------------------------------------
148
+ def keep_original(self, zip_path: Path, paths: VersionPaths) -> Path | None:
149
+ """Copy or move the download next to its extracted version."""
150
+ mode = self.cfg.store.on_ingest
151
+ if not self.cfg.store.keep_zip or mode == "leave":
152
+ return None
153
+ paths.root.mkdir(parents=True, exist_ok=True)
154
+ target = paths.package
155
+ try:
156
+ if mode == "move":
157
+ shutil.move(str(zip_path), str(target))
158
+ else:
159
+ shutil.copy2(str(zip_path), str(target))
160
+ except OSError as exc:
161
+ LOG.warning("could not %s %s into the store: %s", mode, zip_path.name, exc)
162
+ return None
163
+ return target
164
+
165
+ def discard_original(self, zip_path: Path) -> None:
166
+ """Delete a download whose content is already archived, in ``move`` mode.
167
+
168
+ Whether this particular file may be removed at all is the caller's
169
+ decision, not this one's: see ``Ingestor.ingest``.
170
+ """
171
+ if self.cfg.store.on_ingest != "move":
172
+ return
173
+ try:
174
+ zip_path.unlink()
175
+ except OSError as exc:
176
+ LOG.warning("could not remove duplicate download %s: %s", zip_path, exc)
177
+
178
+ def quarantine(self, zip_path: Path, reason: str) -> Path | None:
179
+ """Park an archive we could not process, with a note about why."""
180
+ target_dir = self.cfg.quarantine_dir
181
+ target_dir.mkdir(parents=True, exist_ok=True)
182
+ target = target_dir / zip_path.name
183
+ counter = 1
184
+ while target.exists():
185
+ target = target_dir / f"{zip_path.stem}-{counter}{zip_path.suffix}"
186
+ counter += 1
187
+ try:
188
+ shutil.copy2(str(zip_path), str(target))
189
+ target.with_suffix(target.suffix + ".reason.txt").write_text(
190
+ f"{zip_path}\n{reason}\n", encoding="utf-8"
191
+ )
192
+ return target
193
+ except OSError as exc:
194
+ LOG.warning("could not quarantine %s: %s", zip_path, exc)
195
+ return None
196
+
197
+ # -- retention -------------------------------------------------------
198
+ def prune(self, slug: str, keep: int) -> list[Path]:
199
+ """Delete the oldest version directories beyond ``keep``."""
200
+ if keep <= 0:
201
+ return []
202
+ versions = sorted(
203
+ (p for p in self.versions_dir(slug).glob("*") if p.is_dir()), key=lambda p: p.name
204
+ )
205
+ removed: list[Path] = []
206
+ for path in versions[:-keep] if len(versions) > keep else []:
207
+ rmtree(path)
208
+ removed.append(path)
209
+ return removed
@@ -0,0 +1,124 @@
1
+ """The annotated config file written by ``lambda-watcher init``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from .config import DEFAULT_HOME, default_download_dirs
8
+
9
+
10
+ def _yaml_str(value: object) -> str:
11
+ """Quote a value as a YAML scalar.
12
+
13
+ Windows paths are the reason this exists: ``C:\\Users\\you`` inside a
14
+ double-quoted YAML scalar makes ``\\U`` an escape sequence, and the config
15
+ ``init`` had just written could not be read back. JSON string syntax is a
16
+ subset of YAML's double-quoted style, so ``json.dumps`` escapes the
17
+ backslashes correctly on every platform.
18
+ """
19
+ return json.dumps(str(value))
20
+
21
+
22
+ _DIRS = "\n".join(f" - {_yaml_str(d)}" for d in default_download_dirs())
23
+
24
+ DEFAULT_CONFIG_YAML = f"""# lambda-watcher configuration
25
+ # Every setting below is optional; the values shown are the defaults.
26
+
27
+ watch:
28
+ # Folders to watch. Add more if you download from several places.
29
+ dirs:
30
+ {_DIRS}
31
+ extensions: [".zip"]
32
+ # A file must stop changing for this many seconds before it is read, so a
33
+ # half-finished download is never archived.
34
+ stable_seconds: 2.0
35
+ recursive: false
36
+ # Switch on if your Downloads folder is a network share, a VM mount or WSL,
37
+ # where native filesystem events are unreliable.
38
+ force_polling: false
39
+ # Windows reports a file as "modified" when an antivirus scan, the search
40
+ # indexer or OneDrive touches it. Anything whose contents were last written
41
+ # longer ago than this is not treated as a new arrival: the event is ignored,
42
+ # and `on_ingest: move` never deletes it. 0 turns the check off.
43
+ arrival_max_age_seconds: 300
44
+ # Pick up zips that arrived while the watcher was not running.
45
+ scan_on_start: true
46
+ scan_on_start_max_age_hours: 24
47
+
48
+ store:
49
+ root: {_yaml_str(DEFAULT_HOME)}
50
+ # copy = leave the download in place (default)
51
+ # move = take it out of Downloads once archived, keeping that folder clean
52
+ # leave = archive only the extracted tree, never the .zip
53
+ on_ingest: copy
54
+ keep_zip: true
55
+ max_uncompressed_mb: 2048
56
+ max_files: 200000
57
+ # 0 keeps every version forever.
58
+ max_versions_per_function: 0
59
+
60
+ naming:
61
+ # Explicit filename -> function name rules, tried first. `name` may use \\1 etc.
62
+ # rules:
63
+ # - pattern: "^prod[-_](.+?)[-_]deploy"
64
+ # name: "\\\\1"
65
+ # - pattern: "orders"
66
+ # name: "order-processor"
67
+ case_insensitive: true
68
+ infer_from_zip: true
69
+
70
+ analysis:
71
+ scan_secrets: true
72
+ scan_env_vars: true
73
+ scan_aws_services: true
74
+ max_scan_file_kb: 2048
75
+ # Paths treated as third-party rather than your code. Diffs hide these by
76
+ # default and summarise them as dependency changes instead.
77
+ vendor_globs:
78
+ - "node_modules/**"
79
+ - "**/node_modules/**"
80
+ - "**/site-packages/**"
81
+ - "**/*.dist-info/**"
82
+ - "**/*.egg-info/**"
83
+ - "vendor/**"
84
+ - "**/__pycache__/**"
85
+
86
+ diff:
87
+ ignore_vendor: true
88
+ context_lines: 3
89
+ max_diff_file_kb: 512
90
+ max_diff_lines: 2000
91
+ ignore_globs: ["**/*.pyc", "**/*.so", "**/*.map"]
92
+
93
+ git_mirror:
94
+ # Keeps one git repo per function under functions/<name>/repo/, one commit
95
+ # per version, tagged v0001... so `git diff v0002 v0010`, `lw open` and any
96
+ # git GUI just work.
97
+ enabled: true
98
+ author_name: lambda-watcher
99
+ author_email: lambda-watcher@localhost
100
+ include_vendor: true
101
+ tag_prefix: v
102
+
103
+ notify:
104
+ enabled: true
105
+ # Only say something when the code actually differs from the last version.
106
+ only_on_change: true
107
+ # Put what changed in the notification - "2 modified, +24/-5 lines, 1 new env
108
+ # var" - rather than just the file count and size.
109
+ summarise_changes: true
110
+
111
+ report:
112
+ # Render the comparison against the previous version as each one is archived,
113
+ # so the answer to "what changed?" is already written by the time the
114
+ # notification about it appears. Each lands in reports/<function>/, alongside
115
+ # a latest.html that always points at the newest comparison.
116
+ auto_diff: true
117
+ include_vendor: false
118
+
119
+ # What `lw open` launches on a folder. Left empty, it looks for VS Code and
120
+ # friends on PATH. $LAMBDA_WATCHER_EDITOR overrides this.
121
+ editor: ""
122
+
123
+ log_level: INFO
124
+ """
@@ -0,0 +1,314 @@
1
+ """Small shared helpers: hashing, text detection, path matching, formatting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import hashlib
7
+ import logging
8
+ import os
9
+ import re
10
+ import unicodedata
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path, PurePosixPath
13
+
14
+ LOG = logging.getLogger("lambda_watcher")
15
+
16
+ _CHUNK = 1 << 20 # 1 MiB
17
+
18
+ # Extension -> language label, used for reporting and syntax hints.
19
+ LANG_BY_EXT: dict[str, str] = {
20
+ ".py": "python", ".pyi": "python", ".js": "javascript", ".mjs": "javascript",
21
+ ".cjs": "javascript", ".jsx": "javascript", ".ts": "typescript", ".tsx": "typescript",
22
+ ".java": "java", ".kt": "kotlin", ".go": "go", ".rb": "ruby", ".rs": "rust",
23
+ ".cs": "csharp", ".php": "php", ".sh": "shell", ".bash": "shell", ".ps1": "powershell",
24
+ ".json": "json", ".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".ini": "ini",
25
+ ".cfg": "ini", ".xml": "xml", ".html": "html", ".css": "css", ".sql": "sql",
26
+ ".md": "markdown", ".txt": "text", ".csv": "csv", ".env": "dotenv",
27
+ ".jar": "binary", ".so": "binary", ".dll": "binary", ".dylib": "binary",
28
+ ".pyc": "binary", ".zip": "binary", ".png": "binary", ".jpg": "binary",
29
+ ".gz": "binary", ".whl": "binary", ".class": "binary",
30
+ }
31
+
32
+ BINARY_LANGS = {"binary"}
33
+
34
+
35
+ def sha256_file(path: Path) -> str:
36
+ h = hashlib.sha256()
37
+ with path.open("rb") as fh:
38
+ for chunk in iter(lambda: fh.read(_CHUNK), b""):
39
+ h.update(chunk)
40
+ return h.hexdigest()
41
+
42
+
43
+ def sha256_bytes(data: bytes) -> str:
44
+ return hashlib.sha256(data).hexdigest()
45
+
46
+
47
+ def tree_hash(entries: list[tuple[str, str]]) -> str:
48
+ """Content hash of a whole extracted tree.
49
+
50
+ ``entries`` is a list of ``(relative_path, file_sha256)``. Deliberately
51
+ ignores timestamps, file order and zip metadata so that re-downloading an
52
+ unchanged function produces the same hash.
53
+ """
54
+ payload = "\n".join(f"{digest} {path}" for path, digest in sorted(entries))
55
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
56
+
57
+
58
+ def short_hash(digest: str, length: int = 8) -> str:
59
+ return digest[:length]
60
+
61
+
62
+ def is_probably_text(path: Path, sniff_bytes: int = 8192) -> bool:
63
+ """Cheap binary check: NUL bytes or undecodable content means binary."""
64
+ try:
65
+ with path.open("rb") as fh:
66
+ chunk = fh.read(sniff_bytes)
67
+ except OSError:
68
+ return False
69
+ if not chunk:
70
+ return True
71
+ if b"\x00" in chunk:
72
+ return False
73
+ try:
74
+ chunk.decode("utf-8")
75
+ except UnicodeDecodeError:
76
+ # Latin-1 fallback keeps logs and odd encodings readable.
77
+ printable = sum(1 for b in chunk if 32 <= b < 127 or b in (9, 10, 13))
78
+ return printable / len(chunk) > 0.85
79
+ return True
80
+
81
+
82
+ def language_for(path: str) -> str:
83
+ ext = PurePosixPath(path).suffix.lower()
84
+ if ext:
85
+ return LANG_BY_EXT.get(ext, ext.lstrip("."))
86
+ name = PurePosixPath(path).name.lower()
87
+ if name in {"dockerfile", "makefile", "gemfile", "rakefile", "procfile"}:
88
+ return name
89
+ return "text"
90
+
91
+
92
+ def read_text(path: Path, max_bytes: int | None = None) -> str | None:
93
+ """Read a file as text, returning None if it is not decodable."""
94
+ try:
95
+ data = path.read_bytes() if max_bytes is None else path.read_bytes()[:max_bytes]
96
+ except OSError:
97
+ return None
98
+ if b"\x00" in data[:8192]:
99
+ return None
100
+ for encoding in ("utf-8", "utf-8-sig", "latin-1"):
101
+ try:
102
+ return data.decode(encoding)
103
+ except UnicodeDecodeError:
104
+ continue
105
+ return None
106
+
107
+
108
+ def count_lines(path: Path) -> int:
109
+ """Number of lines, counting a trailing partial line."""
110
+ total = 0
111
+ last = b""
112
+ try:
113
+ with path.open("rb") as fh:
114
+ for chunk in iter(lambda: fh.read(_CHUNK), b""):
115
+ total += chunk.count(b"\n")
116
+ last = chunk
117
+ except OSError:
118
+ return 0
119
+ if last and not last.endswith(b"\n"):
120
+ total += 1
121
+ return total
122
+
123
+
124
+ def slugify(name: str, fallback: str = "unnamed") -> str:
125
+ """Filesystem-safe directory name that still resembles the function name."""
126
+ normalised = unicodedata.normalize("NFKD", name)
127
+ ascii_only = normalised.encode("ascii", "ignore").decode("ascii")
128
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", ascii_only).strip("-._")
129
+ slug = re.sub(r"-{2,}", "-", slug)
130
+ if not slug:
131
+ slug = fallback
132
+ # Windows reserves a handful of device names.
133
+ if slug.upper().split(".")[0] in {
134
+ "CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)),
135
+ *(f"LPT{i}" for i in range(1, 10)),
136
+ }:
137
+ slug = f"_{slug}"
138
+ return slug[:120]
139
+
140
+
141
+ # The ref a source-archive directory is named after: `myrepo-1.2.3`,
142
+ # `myrepo-main`, `myrepo-a1b2c3d`. A bare `-v2` is deliberately absent, for the
143
+ # same reason NamingConfig.strip_patterns leaves it alone: it is far more often
144
+ # part of a real name than a version tag.
145
+ _REF_SUFFIX = re.compile(
146
+ r"[-_](?P<ref>"
147
+ r"v?\d+\.\d+(?:\.\d+)?(?:[-.][0-9A-Za-z.]+)?" # 1.2.3, v1.2.3, 1.2.3-rc1
148
+ r"|main|master|develop|trunk"
149
+ r"|[0-9a-f]{7,40}" # commit sha, short or full
150
+ r")$"
151
+ )
152
+
153
+
154
+ def ref_from_dirname(name: str) -> str | None:
155
+ """Pull the version ref out of a source-archive directory name.
156
+
157
+ ``myrepo-1.2.3`` -> ``v1.2.3``; ``myrepo-main`` -> ``main``. Returns None
158
+ when the name carries no ref, which is the common case for a deployment
159
+ package. This only ever produces a label, never an identity, so a name that
160
+ happens to end in something hex-shaped costs a cosmetic mislabel and
161
+ nothing more.
162
+ """
163
+ match = _REF_SUFFIX.search(name.strip())
164
+ if not match:
165
+ return None
166
+ ref = match.group("ref")
167
+ # Normalise a bare `1.2.3` to `v1.2.3`; leave `main` and shas as they are.
168
+ return f"v{ref}" if ref[0].isdigit() else ref
169
+
170
+
171
+ def matches_any(path: str, patterns: list[str]) -> bool:
172
+ """True when a posix-style relative path matches any glob pattern."""
173
+ for pattern in patterns:
174
+ if fnmatch.fnmatch(path, pattern):
175
+ return True
176
+ # ``foo/**`` should also match ``foo/bar`` on platforms where fnmatch
177
+ # does not treat ** specially.
178
+ if pattern.endswith("/**") and (path == pattern[:-3] or path.startswith(pattern[:-2])):
179
+ return True
180
+ if pattern.startswith("**/") and fnmatch.fnmatch(path, pattern[3:]):
181
+ return True
182
+ return False
183
+
184
+
185
+ def human_size(num_bytes: float) -> str:
186
+ step = 1024.0
187
+ for unit in ("B", "KB", "MB", "GB", "TB"):
188
+ if abs(num_bytes) < step:
189
+ return f"{num_bytes:,.0f} {unit}" if unit == "B" else f"{num_bytes:,.1f} {unit}"
190
+ num_bytes /= step
191
+ return f"{num_bytes:,.1f} PB"
192
+
193
+
194
+ def rename_label(old: str, new: str) -> tuple[str, str, str, str]:
195
+ """A rename split into ``(shared prefix, was, is now, shared suffix)``.
196
+
197
+ ``site-packages/boto3-1.34.0.dist-info/METADATA`` becoming
198
+ ``site-packages/boto3-1.35.20.dist-info/METADATA`` is one version number
199
+ moving, but written out in full twice it is 90 characters of near-identical
200
+ path and the reader has to diff it by eye. Naming only the part that moved
201
+ is what git does, and for the same reason.
202
+
203
+ The split lands on separator boundaries, so the middle is always whole path
204
+ segments or whole ``-``/``.``-delimited pieces of a name rather than a cut
205
+ through the middle of a word. When the two paths share nothing, the middle
206
+ is simply both of them entire.
207
+ """
208
+ separators = "/-_."
209
+ head = 0
210
+ for i, (a, b) in enumerate(zip(old, new, strict=False)):
211
+ if a != b:
212
+ break
213
+ if a in separators:
214
+ head = i + 1
215
+ else:
216
+ head = min(len(old), len(new))
217
+
218
+ tail = 0
219
+ for i, (a, b) in enumerate(
220
+ zip(reversed(old[head:]), reversed(new[head:]), strict=False)
221
+ ):
222
+ if a != b:
223
+ break
224
+ if a in separators:
225
+ tail = i + 1
226
+
227
+ stop_old, stop_new = len(old) - tail, len(new) - tail
228
+ return old[:head], old[head:stop_old], new[head:stop_new], old[stop_old:]
229
+
230
+
231
+ def signed(num: int) -> str:
232
+ return f"+{num}" if num > 0 else str(num)
233
+
234
+
235
+ def utc_now_iso() -> str:
236
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
237
+
238
+
239
+ def parse_iso(value: str | None) -> datetime | None:
240
+ if not value:
241
+ return None
242
+ try:
243
+ return datetime.fromisoformat(value)
244
+ except ValueError:
245
+ return None
246
+
247
+
248
+ def format_ts(value: str | None) -> str:
249
+ dt = parse_iso(value)
250
+ if dt is None:
251
+ return "-"
252
+ return dt.astimezone().strftime("%Y-%m-%d %H:%M")
253
+
254
+
255
+ def relative_ts(value: str | None) -> str:
256
+ """"20 minutes ago", "yesterday", "3 weeks ago" — a timestamp read at a glance.
257
+
258
+ The dashboard answers "did it catch my last deploy?", and a wall-clock stamp
259
+ makes the reader do the subtraction themselves. Past a couple of months the
260
+ exact day starts mattering more than the distance, so it falls back to the
261
+ date.
262
+ """
263
+ dt = parse_iso(value)
264
+ if dt is None:
265
+ return "-"
266
+ if dt.tzinfo is None:
267
+ dt = dt.replace(tzinfo=timezone.utc)
268
+ seconds = int((datetime.now(timezone.utc) - dt).total_seconds())
269
+ if seconds < 60:
270
+ return "just now" # also covers a little clock skew
271
+ minutes = seconds // 60
272
+ if minutes < 60:
273
+ return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
274
+ hours = minutes // 60
275
+ if hours < 24:
276
+ return f"{hours} hour{'s' if hours != 1 else ''} ago"
277
+ days = hours // 24
278
+ if days == 1:
279
+ return "yesterday"
280
+ if days < 14:
281
+ return f"{days} days ago"
282
+ if days < 60:
283
+ weeks = days // 7
284
+ return f"{weeks} week{'s' if weeks != 1 else ''} ago"
285
+ return dt.astimezone().strftime("%Y-%m-%d")
286
+
287
+
288
+ def rmtree(path: Path) -> None:
289
+ """Delete a tree, tolerating read-only files (common inside zips)."""
290
+ import shutil
291
+ import stat
292
+
293
+ def _onerror(func, target, _exc): # pragma: no cover - platform specific
294
+ try:
295
+ os.chmod(target, stat.S_IWRITE)
296
+ func(target)
297
+ except OSError:
298
+ pass
299
+
300
+ if path.exists():
301
+ shutil.rmtree(path, onerror=_onerror)
302
+
303
+
304
+ def setup_logging(level: str = "INFO", log_file: Path | None = None) -> logging.Logger:
305
+ LOG.setLevel(getattr(logging, str(level).upper(), logging.INFO))
306
+ LOG.handlers.clear()
307
+ LOG.propagate = False
308
+ fmt = logging.Formatter("%(asctime)s %(levelname)-7s %(message)s", "%Y-%m-%d %H:%M:%S")
309
+ if log_file is not None:
310
+ log_file.parent.mkdir(parents=True, exist_ok=True)
311
+ fh = logging.FileHandler(log_file, encoding="utf-8")
312
+ fh.setFormatter(fmt)
313
+ LOG.addHandler(fh)
314
+ return LOG