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/store.py
ADDED
|
@@ -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
|
+
"""
|
lambda_watcher/utils.py
ADDED
|
@@ -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
|