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
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
"""Self-contained HTML report for a version diff.
|
|
2
|
+
|
|
3
|
+
No JavaScript frameworks, no CDN, no network: one file you can open, keep,
|
|
4
|
+
attach to a change ticket, or send to a colleague.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import html
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..utils import format_ts, human_size, read_text, rename_label, signed
|
|
17
|
+
from . import icons, intraline
|
|
18
|
+
from .compare import FileChange, VersionDiff
|
|
19
|
+
from .highlight import highlight, highlight_lines, language_of
|
|
20
|
+
|
|
21
|
+
_HUNK = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
22
|
+
|
|
23
|
+
ICON_CSS = icons.css()
|
|
24
|
+
|
|
25
|
+
#: A file's lines as ``(source, highlighted)`` pairs — see `_paint`.
|
|
26
|
+
_Painted = list[tuple[str, str]] | None
|
|
27
|
+
|
|
28
|
+
CSS = """
|
|
29
|
+
:root {
|
|
30
|
+
color-scheme: light dark;
|
|
31
|
+
--bg: #ffffff; --panel: #fafbfc; --sunken: #f1f4f7; --border: #e2e6eb;
|
|
32
|
+
--rule: #eceff3; --text: #12161b; --muted: #5b6672; --faint: #8a939f;
|
|
33
|
+
--accent: #0a58ca; --accent-wash: #eaf1fd; --accent-edge: #cfe0fa;
|
|
34
|
+
--add-bg: #e9f7ee; --add-word: #b4ecc6; --add-gutter: #d5efdd; --add-fg: #0a6634;
|
|
35
|
+
--del-bg: #fdedef; --del-word: #f8c4cb; --del-gutter: #f7d5da; --del-fg: #96162a;
|
|
36
|
+
--warn-bg: #f7ecd2; --warn-fg: #6b4a06;
|
|
37
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
38
|
+
/* Syntax tokens, One Light. Numbers and constants share a colour on purpose:
|
|
39
|
+
both are literal values, and the eye reads them as the same thing. */
|
|
40
|
+
--tk-c: #8b8f97; --tk-k: #a626a4; --tk-s: #50a14f; --tk-n: #986801;
|
|
41
|
+
--tk-t: #986801; --tk-f: #4078f2; --tk-y: #0184bc;
|
|
42
|
+
}
|
|
43
|
+
@media (prefers-color-scheme: dark) {
|
|
44
|
+
:root {
|
|
45
|
+
--bg: #0d1117; --panel: #12171e; --sunken: #1a212a; --border: #262d36;
|
|
46
|
+
--rule: #1e242c; --text: #e3e9ef; --muted: #96a0ac; --faint: #6e7885;
|
|
47
|
+
--accent: #6cb0ff; --accent-wash: #16253c; --accent-edge: #294869;
|
|
48
|
+
--add-bg: #0e2417; --add-word: #1f5c34; --add-gutter: #14311f; --add-fg: #6ddb92;
|
|
49
|
+
--del-bg: #2a1319; --del-word: #6d2029; --del-gutter: #3b181f; --del-fg: #ff949e;
|
|
50
|
+
--warn-bg: #3a2d10; --warn-fg: #e6c169;
|
|
51
|
+
--tk-c: #7f848e; --tk-k: #c678dd; --tk-s: #98c379; --tk-n: #d19a66;
|
|
52
|
+
--tk-t: #d19a66; --tk-f: #61afef; --tk-y: #56b6c2;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
* { box-sizing: border-box; }
|
|
56
|
+
body {
|
|
57
|
+
margin: 0; background: var(--bg); color: var(--text);
|
|
58
|
+
font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
59
|
+
-webkit-font-smoothing: antialiased;
|
|
60
|
+
}
|
|
61
|
+
a { color: var(--accent); }
|
|
62
|
+
.wrap { max-width: 1140px; margin: 0 auto; padding: 32px 24px 96px; }
|
|
63
|
+
|
|
64
|
+
/* ---- header ---------------------------------------------------------- */
|
|
65
|
+
header.top { padding-bottom: 18px; margin-bottom: 24px; border-bottom: 1px solid var(--border); }
|
|
66
|
+
h1 { font-size: 22px; font-weight: 650; margin: 0; letter-spacing: -0.015em;
|
|
67
|
+
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
|
68
|
+
h1 .ver { font-family: var(--mono); font-size: 14px; font-weight: 600; letter-spacing: 0;
|
|
69
|
+
color: var(--accent); font-variant-numeric: tabular-nums;
|
|
70
|
+
background: var(--accent-wash); border: 1px solid var(--accent-edge);
|
|
71
|
+
border-radius: 5px; padding: 2px 8px; white-space: nowrap; }
|
|
72
|
+
h1 .ver .arrow { color: var(--faint); padding: 0 5px; font-weight: 400; }
|
|
73
|
+
.sub { color: var(--muted); font-size: 13px; }
|
|
74
|
+
header.top .sub { margin-top: 9px; }
|
|
75
|
+
|
|
76
|
+
/* A heading that carries a hairline to the end of the measure: the sections
|
|
77
|
+
read as sections without a box drawn round each one. */
|
|
78
|
+
h2 { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: .07em;
|
|
79
|
+
color: var(--muted); margin: 32px 0 12px; display: flex; align-items: center; gap: 12px; }
|
|
80
|
+
h2::after { content: ""; flex: 1; height: 1px; background: var(--rule); }
|
|
81
|
+
|
|
82
|
+
/* ---- summary rail ---------------------------------------------------- */
|
|
83
|
+
/* One panel divided by hairlines rather than six floating cards. These numbers
|
|
84
|
+
are meant to be read across, and separate boxes put a gutter between every
|
|
85
|
+
pair of them. */
|
|
86
|
+
.stats { display: flex; flex-wrap: wrap; border: 1px solid var(--border); border-radius: 10px;
|
|
87
|
+
background: var(--panel); overflow: hidden; }
|
|
88
|
+
.stat { flex: 1 1 150px; padding: 11px 16px; border-left: 1px solid var(--border); min-width: 0; }
|
|
89
|
+
.stat:first-child { border-left: none; }
|
|
90
|
+
.stat .v { font-size: 19px; font-weight: 620; letter-spacing: -0.02em;
|
|
91
|
+
font-variant-numeric: tabular-nums; display: flex; align-items: baseline; gap: 7px; }
|
|
92
|
+
.stat .v .delta { font-size: 12px; font-weight: 500; color: var(--muted);
|
|
93
|
+
letter-spacing: 0; white-space: nowrap; }
|
|
94
|
+
.stat .k { color: var(--muted); font-size: 12px; margin-top: 1px; }
|
|
95
|
+
.stat .k .hint { color: var(--faint); }
|
|
96
|
+
.add { color: var(--add-fg); } .del { color: var(--del-fg); }
|
|
97
|
+
|
|
98
|
+
/* ---- tables ---------------------------------------------------------- */
|
|
99
|
+
.scroll { overflow-x: auto; }
|
|
100
|
+
table.grid { border-collapse: collapse; font-size: 13px; width: 100%; }
|
|
101
|
+
table.grid th { text-align: left; color: var(--faint); font-weight: 600; font-size: 11px;
|
|
102
|
+
text-transform: uppercase; letter-spacing: .06em; padding: 0 20px 6px 0;
|
|
103
|
+
border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
104
|
+
table.grid td { padding: 7px 20px 7px 0; border-bottom: 1px solid var(--rule);
|
|
105
|
+
vertical-align: top; white-space: nowrap; }
|
|
106
|
+
table.grid th:last-child, table.grid td:last-child { padding-right: 0; width: 100%;
|
|
107
|
+
white-space: normal; }
|
|
108
|
+
table.grid tr:last-child td { border-bottom: none; }
|
|
109
|
+
table.grid tbody tr:hover td { background: var(--panel); }
|
|
110
|
+
table.grid td.label { width: 210px; color: var(--muted); }
|
|
111
|
+
.mono { font-family: var(--mono); font-size: 12.5px; }
|
|
112
|
+
.num { font-variant-numeric: tabular-nums; text-align: right; }
|
|
113
|
+
|
|
114
|
+
/* ---- labels ---------------------------------------------------------- */
|
|
115
|
+
/* Two different things wear a label here, so they are drawn differently: a
|
|
116
|
+
`chip` classifies a row, a `tok` *is* a name out of the code. */
|
|
117
|
+
.chip { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10.5px;
|
|
118
|
+
font-weight: 650; text-transform: uppercase; letter-spacing: .04em; white-space: nowrap;
|
|
119
|
+
background: var(--sunken); color: var(--muted); }
|
|
120
|
+
.chip.added { background: var(--add-gutter); color: var(--add-fg); }
|
|
121
|
+
.chip.removed { background: var(--del-gutter); color: var(--del-fg); }
|
|
122
|
+
.chip.modified { background: var(--accent-wash); color: var(--accent); }
|
|
123
|
+
.chip.high { background: var(--del-gutter); color: var(--del-fg); }
|
|
124
|
+
.chip.medium { background: var(--warn-bg); color: var(--warn-fg); }
|
|
125
|
+
.tok { display: inline-block; font-family: var(--mono); font-size: 12px; padding: 1px 7px;
|
|
126
|
+
border-radius: 4px; background: var(--sunken); border: 1px solid var(--border); }
|
|
127
|
+
.tok.added { background: var(--add-bg); border-color: var(--add-gutter); color: var(--add-fg); }
|
|
128
|
+
.tok.removed { background: var(--del-bg); border-color: var(--del-gutter); color: var(--del-fg); }
|
|
129
|
+
|
|
130
|
+
/* ---- toolbar --------------------------------------------------------- */
|
|
131
|
+
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;
|
|
132
|
+
position: sticky; top: 0; background: var(--bg); padding: 10px 0; z-index: 5;
|
|
133
|
+
box-shadow: 0 1px 0 var(--border); }
|
|
134
|
+
.toolbar #shown-count { margin-left: auto; white-space: nowrap; flex: 0 0 auto; color: var(--faint); }
|
|
135
|
+
.toolbar input[type=search] { flex: 1 1 220px; min-width: 140px; padding: 6px 10px; border-radius: 6px;
|
|
136
|
+
border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 13px; }
|
|
137
|
+
.toolbar input[type=search]:focus { outline: none; border-color: var(--accent); }
|
|
138
|
+
.toolbar label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px;
|
|
139
|
+
align-items: center; cursor: pointer; user-select: none; }
|
|
140
|
+
.toolbar button { padding: 6px 11px; border-radius: 6px; border: 1px solid var(--border);
|
|
141
|
+
background: var(--panel); color: var(--muted); font-size: 13px; cursor: pointer; }
|
|
142
|
+
.toolbar button:hover { border-color: var(--faint); color: var(--text); }
|
|
143
|
+
|
|
144
|
+
/* ---- one file -------------------------------------------------------- */
|
|
145
|
+
details.file { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px;
|
|
146
|
+
background: var(--panel); }
|
|
147
|
+
details.file[open] { background: var(--bg); }
|
|
148
|
+
details.file > summary { cursor: pointer; padding: 8px 12px; display: flex; gap: 10px;
|
|
149
|
+
align-items: center; list-style: none; font-size: 13px; border-radius: 7px; }
|
|
150
|
+
details.file > summary::-webkit-details-marker { display: none; }
|
|
151
|
+
details.file > summary:hover { background: var(--panel); }
|
|
152
|
+
/* The header stays put while a long file scrolls past, so the path is still
|
|
153
|
+
readable eighty lines into its own diff. */
|
|
154
|
+
details.file[open] > summary { position: sticky; top: var(--toolbar-h, 54px); z-index: 3;
|
|
155
|
+
background: var(--panel);
|
|
156
|
+
border-bottom: 1px solid var(--border); border-radius: 7px 7px 0 0; }
|
|
157
|
+
summary .path { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
|
158
|
+
summary .path .p { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
|
|
159
|
+
/* The tint hugs the changed part exactly — padding here would open a gap in
|
|
160
|
+
the middle of a path and read as though the name contained a space. */
|
|
161
|
+
summary .path .ren { background: var(--sunken); border-radius: 3px; }
|
|
162
|
+
summary .path .was { color: var(--faint); }
|
|
163
|
+
summary .stat-line { font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap;
|
|
164
|
+
display: flex; gap: 8px; align-items: baseline; color: var(--faint); }
|
|
165
|
+
|
|
166
|
+
/* ---- the diff itself ------------------------------------------------- */
|
|
167
|
+
.diff { overflow-x: auto; border-top: 1px solid var(--border); }
|
|
168
|
+
details.file[open] > .diff { border-top: none; }
|
|
169
|
+
.diff table { border-collapse: collapse; width: 100%; font-family: var(--mono);
|
|
170
|
+
font-size: 12.5px; line-height: 1.5; }
|
|
171
|
+
.diff td { padding: 0 8px; white-space: pre; vertical-align: top; }
|
|
172
|
+
.diff td.ln { width: 1%; min-width: 40px; padding: 0 8px; text-align: right; color: var(--faint);
|
|
173
|
+
user-select: none; background: var(--panel); font-variant-numeric: tabular-nums; }
|
|
174
|
+
.diff td.ln + td.ln { border-right: 1px solid var(--border); }
|
|
175
|
+
/* The sign lives in its own unselectable cell so that copying a block of the
|
|
176
|
+
diff yields the code, not code with markers glued on. It doubles as the
|
|
177
|
+
spine marking how far an added or removed run reaches. */
|
|
178
|
+
.diff td.mark { width: 1%; padding: 0 4px 0 6px; text-align: center; color: var(--faint);
|
|
179
|
+
user-select: none; border-left: 2px solid transparent; }
|
|
180
|
+
.diff td.code { padding-left: 4px; width: 100%; }
|
|
181
|
+
.diff tr.add td.code, .diff tr.add td.mark { background: var(--add-bg); }
|
|
182
|
+
.diff tr.add td.mark { color: var(--add-fg); border-left-color: var(--add-fg); }
|
|
183
|
+
.diff tr.add td.ln { background: var(--add-gutter); color: var(--add-fg); }
|
|
184
|
+
.diff tr.del td.code, .diff tr.del td.mark { background: var(--del-bg); }
|
|
185
|
+
.diff tr.del td.mark { color: var(--del-fg); border-left-color: var(--del-fg); }
|
|
186
|
+
.diff tr.del td.ln { background: var(--del-gutter); color: var(--del-fg); }
|
|
187
|
+
.diff tr.hunk td { background: var(--sunken); color: var(--faint); font-size: 11.5px;
|
|
188
|
+
padding: 4px 10px; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
|
189
|
+
.diff tr.hunk:first-child td { border-top: none; }
|
|
190
|
+
/* Which words of the line actually changed. This sits on top of the row wash,
|
|
191
|
+
so it has to be a step stronger than it in both themes. */
|
|
192
|
+
.wd { border-radius: 3px; }
|
|
193
|
+
tr.add .wd { background: var(--add-word); }
|
|
194
|
+
tr.del .wd { background: var(--del-word); }
|
|
195
|
+
.tk-c { color: var(--tk-c); font-style: italic; }
|
|
196
|
+
.tk-k { color: var(--tk-k); }
|
|
197
|
+
.tk-s { color: var(--tk-s); }
|
|
198
|
+
.tk-n { color: var(--tk-n); }
|
|
199
|
+
.tk-t { color: var(--tk-t); }
|
|
200
|
+
.tk-f { color: var(--tk-f); }
|
|
201
|
+
.tk-y { color: var(--tk-y); }
|
|
202
|
+
.note { color: var(--muted); font-size: 12.5px; padding: 10px 12px; border-top: 1px solid var(--border); }
|
|
203
|
+
.note code { font-family: var(--mono); font-size: 12px; background: var(--sunken);
|
|
204
|
+
padding: 1px 5px; border-radius: 4px; }
|
|
205
|
+
.empty { color: var(--muted); padding: 16px 0; }
|
|
206
|
+
footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border);
|
|
207
|
+
color: var(--faint); font-size: 12px; line-height: 1.7; }
|
|
208
|
+
.hidden { display: none !important; }
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
JS = """
|
|
212
|
+
(function () {
|
|
213
|
+
var search = document.getElementById('filter');
|
|
214
|
+
var vendorToggle = document.getElementById('vendor');
|
|
215
|
+
var files = Array.prototype.slice.call(document.querySelectorAll('details.file'));
|
|
216
|
+
|
|
217
|
+
function apply() {
|
|
218
|
+
var term = (search.value || '').toLowerCase();
|
|
219
|
+
var showVendor = vendorToggle ? vendorToggle.checked : true;
|
|
220
|
+
var shown = 0;
|
|
221
|
+
files.forEach(function (el) {
|
|
222
|
+
var path = (el.getAttribute('data-path') || '').toLowerCase();
|
|
223
|
+
var isVendor = el.getAttribute('data-vendor') === '1';
|
|
224
|
+
var ok = (!term || path.indexOf(term) !== -1) && (showVendor || !isVendor);
|
|
225
|
+
el.classList.toggle('hidden', !ok);
|
|
226
|
+
if (ok) shown++;
|
|
227
|
+
});
|
|
228
|
+
var counter = document.getElementById('shown-count');
|
|
229
|
+
if (counter) counter.textContent = shown + ' of ' + files.length + ' files shown';
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (search) search.addEventListener('input', apply);
|
|
233
|
+
if (vendorToggle) vendorToggle.addEventListener('change', apply);
|
|
234
|
+
|
|
235
|
+
var toolbar = document.querySelector('.toolbar');
|
|
236
|
+
function measure() {
|
|
237
|
+
if (!toolbar) return;
|
|
238
|
+
document.documentElement.style.setProperty('--toolbar-h', toolbar.offsetHeight + 'px');
|
|
239
|
+
}
|
|
240
|
+
window.addEventListener('resize', measure);
|
|
241
|
+
measure();
|
|
242
|
+
|
|
243
|
+
var expand = document.getElementById('expand-all');
|
|
244
|
+
var collapse = document.getElementById('collapse-all');
|
|
245
|
+
if (expand) expand.addEventListener('click', function () {
|
|
246
|
+
files.forEach(function (f) { if (!f.classList.contains('hidden')) f.open = true; });
|
|
247
|
+
});
|
|
248
|
+
if (collapse) collapse.addEventListener('click', function () {
|
|
249
|
+
files.forEach(function (f) { f.open = false; });
|
|
250
|
+
});
|
|
251
|
+
apply();
|
|
252
|
+
})();
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _esc(value: Any) -> str:
|
|
257
|
+
return html.escape("" if value is None else str(value), quote=True)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass
|
|
261
|
+
class _Row:
|
|
262
|
+
css: str
|
|
263
|
+
old_no: str
|
|
264
|
+
new_no: str
|
|
265
|
+
text: str
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _diff_rows(change: FileChange) -> list[_Row]:
|
|
269
|
+
"""Turn unified-diff lines into numbered rows."""
|
|
270
|
+
rows: list[_Row] = []
|
|
271
|
+
old_no = new_no = 0
|
|
272
|
+
for line in change.diff_lines:
|
|
273
|
+
if line.startswith("+++") or line.startswith("---"):
|
|
274
|
+
continue
|
|
275
|
+
hunk = _HUNK.match(line)
|
|
276
|
+
if hunk:
|
|
277
|
+
old_no = int(hunk.group(1))
|
|
278
|
+
new_no = int(hunk.group(3))
|
|
279
|
+
rows.append(_Row("hunk", "", "", line))
|
|
280
|
+
continue
|
|
281
|
+
if line.startswith("+"):
|
|
282
|
+
rows.append(_Row("add", "", str(new_no), line[1:]))
|
|
283
|
+
new_no += 1
|
|
284
|
+
elif line.startswith("-"):
|
|
285
|
+
rows.append(_Row("del", str(old_no), "", line[1:]))
|
|
286
|
+
old_no += 1
|
|
287
|
+
elif line.startswith("\\"):
|
|
288
|
+
rows.append(_Row("meta", "", "", line))
|
|
289
|
+
else:
|
|
290
|
+
rows.append(_Row("ctx", str(old_no), str(new_no), line[1:] if line else ""))
|
|
291
|
+
old_no += 1
|
|
292
|
+
new_no += 1
|
|
293
|
+
return rows
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _intraline(rows: list[_Row]) -> dict[int, list[tuple[int, int]]]:
|
|
297
|
+
"""Character ranges worth marking, keyed by the row they belong to.
|
|
298
|
+
|
|
299
|
+
Only a removed run with an added run immediately after it is considered:
|
|
300
|
+
that is what a rewrite looks like in a unified diff. An added block with
|
|
301
|
+
nothing before it is a line arriving, not a line changing, and has no
|
|
302
|
+
counterpart to compare it against.
|
|
303
|
+
"""
|
|
304
|
+
spans: dict[int, list[tuple[int, int]]] = {}
|
|
305
|
+
at = 0
|
|
306
|
+
while at < len(rows):
|
|
307
|
+
if rows[at].css != "del":
|
|
308
|
+
at += 1
|
|
309
|
+
continue
|
|
310
|
+
start = at
|
|
311
|
+
while at < len(rows) and rows[at].css == "del":
|
|
312
|
+
at += 1
|
|
313
|
+
middle = at
|
|
314
|
+
while at < len(rows) and rows[at].css == "add":
|
|
315
|
+
at += 1
|
|
316
|
+
removed = [r.text for r in rows[start:middle]]
|
|
317
|
+
added = [r.text for r in rows[middle:at]]
|
|
318
|
+
for old_i, new_i in intraline.pair_rows(removed, added):
|
|
319
|
+
before, after = intraline.word_diff(removed[old_i], added[new_i])
|
|
320
|
+
if before or after:
|
|
321
|
+
spans[start + old_i] = before
|
|
322
|
+
spans[middle + new_i] = after
|
|
323
|
+
return spans
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _paint(root: Path | None, path: str | None, lang: str) -> list[tuple[str, str]] | None:
|
|
327
|
+
"""One file as ``(source line, highlighted line)`` pairs, or None if unreadable.
|
|
328
|
+
|
|
329
|
+
The plain half is kept so the caller can prove a row and the line it is about
|
|
330
|
+
to borrow colour from are the same text.
|
|
331
|
+
"""
|
|
332
|
+
if root is None or not path:
|
|
333
|
+
return None
|
|
334
|
+
text = read_text(root / path)
|
|
335
|
+
if text is None:
|
|
336
|
+
return None
|
|
337
|
+
# strict: `highlight_lines` promises one entry per line. If that ever broke,
|
|
338
|
+
# every row below would borrow colour from its neighbour — loud beats subtle.
|
|
339
|
+
return list(zip(text.split("\n"), highlight_lines(text, lang), strict=True))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _row_code(row: _Row, lang: str, old: _Painted, new: _Painted) -> str:
|
|
343
|
+
"""The code cell for one diff row, coloured with the whole file in view.
|
|
344
|
+
|
|
345
|
+
A removed line belongs to the older version, an added or context line to the
|
|
346
|
+
newer one — which is why both sides are painted. Where the file cannot be
|
|
347
|
+
read, or the line the diff quoted is not the line sitting at that number any
|
|
348
|
+
more, this falls back to colouring the row on its own: worse colour on that
|
|
349
|
+
row, never colour borrowed from the wrong line.
|
|
350
|
+
"""
|
|
351
|
+
if row.css == "meta": # `` is diff's note, not the file's
|
|
352
|
+
return _esc(row.text)
|
|
353
|
+
painted, number = (old, row.old_no) if row.css == "del" else (new, row.new_no)
|
|
354
|
+
if painted and number:
|
|
355
|
+
index = int(number) - 1
|
|
356
|
+
if 0 <= index < len(painted):
|
|
357
|
+
source, coloured = painted[index]
|
|
358
|
+
if source == row.text:
|
|
359
|
+
return coloured
|
|
360
|
+
return highlight(row.text, lang)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path | None = None) -> str:
|
|
364
|
+
lang = language_of(change.path, change.lang)
|
|
365
|
+
# A rename is one file, not two. Written out in full twice, the two paths
|
|
366
|
+
# are near-identical and the reader has to diff 90 characters by eye to
|
|
367
|
+
# find the part that moved; so only that part is written twice.
|
|
368
|
+
title = _esc(change.path)
|
|
369
|
+
if change.kind == "renamed" and change.old_path:
|
|
370
|
+
head, was, now, tail = rename_label(change.old_path, change.path)
|
|
371
|
+
title = (
|
|
372
|
+
f'{_esc(head)}<span class="ren"><span class="was">{_esc(was)}</span>'
|
|
373
|
+
f' → {_esc(now)}</span>{_esc(tail)}'
|
|
374
|
+
)
|
|
375
|
+
stat = ""
|
|
376
|
+
if change.added_lines:
|
|
377
|
+
stat += f'<span class="add">+{change.added_lines}</span>'
|
|
378
|
+
if change.removed_lines:
|
|
379
|
+
stat += f'<span class="del">−{change.removed_lines}</span>'
|
|
380
|
+
if change.size_delta:
|
|
381
|
+
stat += f"<span>{signed(change.size_delta)} B</span>"
|
|
382
|
+
|
|
383
|
+
parts = [
|
|
384
|
+
f'<details class="file" data-path="{_esc(change.path)} {_esc(change.old_path or "")}" '
|
|
385
|
+
f'data-vendor="{1 if change.is_vendor else 0}">',
|
|
386
|
+
"<summary>",
|
|
387
|
+
f'<span class="chip {_esc(change.kind)}">{_esc(change.kind)}</span>',
|
|
388
|
+
f'<span class="path">{icons.file_icon(change.path, lang)}'
|
|
389
|
+
f'<span class="p">{title}</span></span>',
|
|
390
|
+
f'<span class="stat-line">{stat}</span>',
|
|
391
|
+
"</summary>",
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
if change.diff_lines:
|
|
395
|
+
old = _paint(a_root, change.old_path or change.path, lang) if change.old else None
|
|
396
|
+
new = _paint(b_root, change.path, lang) if change.new else None
|
|
397
|
+
rows = _diff_rows(change)
|
|
398
|
+
marks = _intraline(rows)
|
|
399
|
+
parts.append('<div class="diff"><table>')
|
|
400
|
+
for index, row in enumerate(rows):
|
|
401
|
+
if row.css == "hunk":
|
|
402
|
+
parts.append(f'<tr class="hunk"><td colspan="4">{_esc(row.text)}</td></tr>')
|
|
403
|
+
continue
|
|
404
|
+
css = f' class="{row.css}"' if row.css in ("add", "del") else ""
|
|
405
|
+
mark = {"add": "+", "del": "\u2212"}.get(row.css, "")
|
|
406
|
+
code = intraline.mark(_row_code(row, lang, old, new), marks.get(index, []))
|
|
407
|
+
parts.append(
|
|
408
|
+
f"<tr{css}>"
|
|
409
|
+
f'<td class="ln">{row.old_no}</td>'
|
|
410
|
+
f'<td class="ln">{row.new_no}</td>'
|
|
411
|
+
f'<td class="mark">{mark}</td>'
|
|
412
|
+
f'<td class="code">{code}</td>'
|
|
413
|
+
"</tr>"
|
|
414
|
+
)
|
|
415
|
+
parts.append("</table></div>")
|
|
416
|
+
if change.truncated:
|
|
417
|
+
parts.append('<div class="note">Diff truncated — raise <code>diff.max_diff_lines</code> to see the rest.</div>')
|
|
418
|
+
else:
|
|
419
|
+
reason = change.skipped_reason or (
|
|
420
|
+
"file is empty" if change.kind in {"added", "removed"} else "no textual change"
|
|
421
|
+
)
|
|
422
|
+
old_size = change.old.size if change.old else 0
|
|
423
|
+
new_size = change.new.size if change.new else 0
|
|
424
|
+
parts.append(
|
|
425
|
+
f'<div class="note">No line diff shown ({_esc(reason)}). '
|
|
426
|
+
f"{human_size(old_size)} → {human_size(new_size)}.</div>"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
parts.append("</details>")
|
|
430
|
+
return "\n".join(parts)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _stats(diff: VersionDiff) -> str:
|
|
434
|
+
"""The handful of numbers worth reading before opening a single file."""
|
|
435
|
+
counts = diff.counts()
|
|
436
|
+
size_a = diff.a_meta.get("total_size", 0)
|
|
437
|
+
size_b = diff.b_meta.get("total_size", 0)
|
|
438
|
+
lines = (
|
|
439
|
+
f'<span class="add">+{diff.total_added_lines}</span>'
|
|
440
|
+
f'<span class="del">\u2212{diff.total_removed_lines}</span>'
|
|
441
|
+
if diff.diffs_computed
|
|
442
|
+
else '<span class="delta">not computed</span>'
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
# (value markup, label, what rides beside the value, what rides after the
|
|
446
|
+
# label). Both asides stay on their own line's baseline, so every cell of
|
|
447
|
+
# the rail is exactly two lines tall whatever it has to say.
|
|
448
|
+
stats: list[tuple[str, str, str, str]] = [
|
|
449
|
+
(str(sum(counts.values())), "files changed", "", ""),
|
|
450
|
+
(lines, "lines", "", ""),
|
|
451
|
+
(str(len(diff.deps)), "dependencies", "", ""),
|
|
452
|
+
(_esc(human_size(size_b)), "package size", f"{signed(size_b - size_a)} B", ""),
|
|
453
|
+
]
|
|
454
|
+
if diff.findings_new:
|
|
455
|
+
stats.append((str(len(diff.findings_new)), "new findings", "", ""))
|
|
456
|
+
if diff.vendor_files_changed:
|
|
457
|
+
stats.append((str(diff.vendor_files_changed), "vendored files", "", "not shown"))
|
|
458
|
+
|
|
459
|
+
cells: list[str] = []
|
|
460
|
+
for value, label, delta, hint in stats:
|
|
461
|
+
beside = f'<span class="delta">{_esc(delta)}</span>' if delta else ""
|
|
462
|
+
after = f' <span class="hint">{_esc(hint)}</span>' if hint else ""
|
|
463
|
+
cells.append(
|
|
464
|
+
f'<div class="stat"><div class="v">{value}{beside}</div>'
|
|
465
|
+
f'<div class="k">{_esc(label)}{after}</div></div>'
|
|
466
|
+
)
|
|
467
|
+
return f'<div class="stats">{"".join(cells)}</div>'
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _dep_table(diff: VersionDiff) -> str:
|
|
471
|
+
if not diff.deps:
|
|
472
|
+
return ""
|
|
473
|
+
rows = []
|
|
474
|
+
for change in diff.deps:
|
|
475
|
+
rows.append(
|
|
476
|
+
"<tr>"
|
|
477
|
+
f'<td><span class="chip {_esc(change.kind)}">{_esc(change.kind)}</span></td>'
|
|
478
|
+
f'<td class="mono">{_esc(change.name)}</td>'
|
|
479
|
+
f'<td class="mono del">{_esc(change.old_version or "—")}</td>'
|
|
480
|
+
f'<td class="mono add">{_esc(change.new_version or "—")}</td>'
|
|
481
|
+
f"<td>{_esc(change.manager)}</td>"
|
|
482
|
+
f'<td>{"declared" if change.is_declared else "installed"}</td>'
|
|
483
|
+
"</tr>"
|
|
484
|
+
)
|
|
485
|
+
return (
|
|
486
|
+
"<h2>Dependencies</h2><div class='scroll'><table class='grid'>"
|
|
487
|
+
"<thead><tr><th></th><th>package</th><th>from</th><th>to</th>"
|
|
488
|
+
"<th>manager</th><th>origin</th></tr></thead>"
|
|
489
|
+
f"<tbody>{''.join(rows)}</tbody></table></div>"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _context_section(diff: VersionDiff) -> str:
|
|
494
|
+
blocks: list[str] = []
|
|
495
|
+
|
|
496
|
+
def listing(label: str, values: list[str], css: str, hint: str = "") -> str:
|
|
497
|
+
# These are identifiers out of the function's own code, so they are set
|
|
498
|
+
# in the same face the diff sets them in — not as prose in a sentence.
|
|
499
|
+
if not values:
|
|
500
|
+
return ""
|
|
501
|
+
chips = " ".join(f'<span class="tok {css}">{_esc(v)}</span>' for v in values)
|
|
502
|
+
hint_html = f'<div class="sub" style="margin-top:6px">{_esc(hint)}</div>' if hint else ""
|
|
503
|
+
return f"<tr><td class='label'>{_esc(label)}</td><td>{chips}{hint_html}</td></tr>"
|
|
504
|
+
|
|
505
|
+
rows = "".join(
|
|
506
|
+
[
|
|
507
|
+
listing(
|
|
508
|
+
"Environment variables added", diff.env_added, "added",
|
|
509
|
+
"These must exist in the function's environment configuration before you deploy.",
|
|
510
|
+
),
|
|
511
|
+
listing("Environment variables removed", diff.env_removed, "removed"),
|
|
512
|
+
listing(
|
|
513
|
+
"AWS services added", diff.services_added, "added",
|
|
514
|
+
"The execution role may need new IAM permissions.",
|
|
515
|
+
),
|
|
516
|
+
listing("AWS services removed", diff.services_removed, "removed"),
|
|
517
|
+
]
|
|
518
|
+
)
|
|
519
|
+
if rows:
|
|
520
|
+
blocks.append(
|
|
521
|
+
f"<h2>Configuration impact</h2><table class='grid'><tbody>{rows}</tbody></table>"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
if diff.runtime_change or diff.handler_change:
|
|
525
|
+
entries = []
|
|
526
|
+
if diff.runtime_change:
|
|
527
|
+
entries.append(
|
|
528
|
+
f"<tr><td class='label'>Runtime</td><td class='mono'>"
|
|
529
|
+
f"{_esc(diff.runtime_change[0])} → {_esc(diff.runtime_change[1])}</td></tr>"
|
|
530
|
+
)
|
|
531
|
+
if diff.handler_change:
|
|
532
|
+
before, after = diff.handler_change
|
|
533
|
+
entries.append(
|
|
534
|
+
f"<tr><td class='label'>Handler</td><td class='mono'>"
|
|
535
|
+
f"{_esc(before or '?')} → {_esc(after or '?')}</td></tr>"
|
|
536
|
+
)
|
|
537
|
+
blocks.append(f"<h2>Entry point</h2><table class='grid'><tbody>{''.join(entries)}</tbody></table>")
|
|
538
|
+
|
|
539
|
+
return "".join(blocks)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _findings_section(diff: VersionDiff) -> str:
|
|
543
|
+
if not diff.findings_new and not diff.findings_fixed:
|
|
544
|
+
return ""
|
|
545
|
+
rows = []
|
|
546
|
+
for finding in diff.findings_new:
|
|
547
|
+
rows.append(
|
|
548
|
+
"<tr>"
|
|
549
|
+
f'<td><span class="chip {_esc(finding["severity"])}">{_esc(finding["severity"])}</span></td>'
|
|
550
|
+
f'<td>{_esc(finding["kind"])}</td>'
|
|
551
|
+
f'<td class="mono">{_esc(finding["path"])}:{_esc(finding["line"])}</td>'
|
|
552
|
+
f'<td class="mono">{_esc(finding["detail"])}</td>'
|
|
553
|
+
"</tr>"
|
|
554
|
+
)
|
|
555
|
+
resolved = (
|
|
556
|
+
f'<div class="sub" style="margin-top:8px">{len(diff.findings_fixed)} finding(s) '
|
|
557
|
+
"present in the older version are gone.</div>"
|
|
558
|
+
if diff.findings_fixed
|
|
559
|
+
else ""
|
|
560
|
+
)
|
|
561
|
+
table = (
|
|
562
|
+
"<div class='scroll'><table class='grid'><thead><tr><th>severity</th><th>kind</th>"
|
|
563
|
+
f"<th>where</th><th>detail</th></tr></thead><tbody>{''.join(rows)}</tbody></table></div>"
|
|
564
|
+
if rows
|
|
565
|
+
else ""
|
|
566
|
+
)
|
|
567
|
+
return f"<h2>New findings</h2>{table}{resolved}"
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
|
|
571
|
+
"""Render the full report as a single HTML document."""
|
|
572
|
+
title = f"{diff.function_name} · v{diff.a_seq:04d} → v{diff.b_seq:04d}"
|
|
573
|
+
a_when = format_ts(diff.a_meta.get("ingested_at"))
|
|
574
|
+
b_when = format_ts(diff.b_meta.get("ingested_at"))
|
|
575
|
+
|
|
576
|
+
file_blocks = "\n".join(_render_file(c, diff.a_root, diff.b_root) for c in diff.files)
|
|
577
|
+
if not diff.files:
|
|
578
|
+
file_blocks = '<div class="empty">No file-level changes between these versions.</div>'
|
|
579
|
+
|
|
580
|
+
vendor_toggle = (
|
|
581
|
+
'<label><input type="checkbox" id="vendor" checked> show vendored files</label>'
|
|
582
|
+
if any(c.is_vendor for c in diff.files)
|
|
583
|
+
else ""
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
return f"""<!DOCTYPE html>
|
|
587
|
+
<html lang="en">
|
|
588
|
+
<head>
|
|
589
|
+
<meta charset="utf-8">
|
|
590
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
591
|
+
<title>{_esc(title)}</title>
|
|
592
|
+
<style>{CSS}{ICON_CSS}</style>
|
|
593
|
+
</head>
|
|
594
|
+
<body>
|
|
595
|
+
{icons.sprite()}
|
|
596
|
+
<div class="wrap">
|
|
597
|
+
<header class="top">
|
|
598
|
+
<h1>{_esc(diff.function_name)}
|
|
599
|
+
<span class="ver">v{diff.a_seq:04d}<span class="arrow">→</span>v{diff.b_seq:04d}</span></h1>
|
|
600
|
+
<div class="sub">
|
|
601
|
+
{_esc(diff.headline())} · v{diff.a_seq:04d} archived {_esc(a_when)},
|
|
602
|
+
v{diff.b_seq:04d} archived {_esc(b_when)}
|
|
603
|
+
</div>
|
|
604
|
+
</header>
|
|
605
|
+
|
|
606
|
+
{_stats(diff)}
|
|
607
|
+
{_dep_table(diff)}
|
|
608
|
+
{_context_section(diff)}
|
|
609
|
+
{_findings_section(diff)}
|
|
610
|
+
|
|
611
|
+
<h2>File changes</h2>
|
|
612
|
+
<div class="toolbar">
|
|
613
|
+
<input type="search" id="filter" placeholder="Filter by path…" autocomplete="off">
|
|
614
|
+
{vendor_toggle}
|
|
615
|
+
<button type="button" id="expand-all">Expand all</button>
|
|
616
|
+
<button type="button" id="collapse-all">Collapse all</button>
|
|
617
|
+
<span class="sub" id="shown-count"></span>
|
|
618
|
+
</div>
|
|
619
|
+
{file_blocks}
|
|
620
|
+
|
|
621
|
+
<footer>
|
|
622
|
+
Generated by {_esc(generated_by)} on {_esc(datetime.now().strftime('%Y-%m-%d %H:%M'))}.
|
|
623
|
+
Content hashes ignore zip timestamps, so re-downloading unchanged code does not create a new version.
|
|
624
|
+
</footer>
|
|
625
|
+
</div>
|
|
626
|
+
<script>{JS}</script>
|
|
627
|
+
</body>
|
|
628
|
+
</html>
|
|
629
|
+
"""
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def write_html(diff: VersionDiff, path: Path, generated_by: str = "lambda-watcher") -> Path:
|
|
633
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
634
|
+
path.write_text(render_html(diff, generated_by), encoding="utf-8")
|
|
635
|
+
return path
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def render_timeline(
|
|
639
|
+
function_name: str,
|
|
640
|
+
versions: list[dict[str, Any]],
|
|
641
|
+
generated_by: str = "lambda-watcher",
|
|
642
|
+
) -> str:
|
|
643
|
+
"""Index page: every archived version of one function, newest first.
|
|
644
|
+
|
|
645
|
+
``versions`` entries carry the per-version stats plus ``diff_href`` /
|
|
646
|
+
``diff_summary`` describing the step from the previous version.
|
|
647
|
+
"""
|
|
648
|
+
rows: list[str] = []
|
|
649
|
+
for entry in versions:
|
|
650
|
+
seq = entry["seq"]
|
|
651
|
+
href = entry.get("diff_href")
|
|
652
|
+
step = (
|
|
653
|
+
f'<a href="{_esc(href)}">{_esc(entry.get("diff_summary") or "view diff")}</a>'
|
|
654
|
+
if href
|
|
655
|
+
else '<span class="sub">first version</span>'
|
|
656
|
+
)
|
|
657
|
+
label = f' <span class="chip">{_esc(entry["label"])}</span>' if entry.get("label") else ""
|
|
658
|
+
rows.append(
|
|
659
|
+
"<tr>"
|
|
660
|
+
f'<td class="mono"><strong>v{seq:04d}</strong>{label}</td>'
|
|
661
|
+
f'<td>{_esc(format_ts(entry.get("ingested_at")))}</td>'
|
|
662
|
+
f'<td class="mono">{_esc(entry.get("runtime") or "?")}</td>'
|
|
663
|
+
f'<td class="mono">{_esc(entry.get("handler") or "?")}</td>'
|
|
664
|
+
f'<td class="num">{entry.get("file_count", 0):,}</td>'
|
|
665
|
+
f'<td class="num">{_esc(human_size(entry.get("total_size", 0)))}</td>'
|
|
666
|
+
f'<td class="mono sub">{_esc(str(entry.get("source_name") or ""))}</td>'
|
|
667
|
+
f"<td>{step}</td>"
|
|
668
|
+
"</tr>"
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
return f"""<!DOCTYPE html>
|
|
672
|
+
<html lang="en">
|
|
673
|
+
<head>
|
|
674
|
+
<meta charset="utf-8">
|
|
675
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
676
|
+
<title>{_esc(function_name)} · version history</title>
|
|
677
|
+
<style>{CSS}</style>
|
|
678
|
+
</head>
|
|
679
|
+
<body>
|
|
680
|
+
<div class="wrap">
|
|
681
|
+
<header class="top">
|
|
682
|
+
<h1>{_esc(function_name)}</h1>
|
|
683
|
+
<div class="sub">{len(versions)} archived version(s) · newest first</div>
|
|
684
|
+
</header>
|
|
685
|
+
<div class="scroll"><table class="grid">
|
|
686
|
+
<thead><tr>
|
|
687
|
+
<th>version</th><th>archived</th><th>runtime</th><th>handler</th>
|
|
688
|
+
<th class="num">files</th><th class="num">size</th>
|
|
689
|
+
<th>downloaded as</th><th>change from previous</th>
|
|
690
|
+
</tr></thead>
|
|
691
|
+
<tbody>{''.join(rows)}</tbody>
|
|
692
|
+
</table></div>
|
|
693
|
+
<footer>Generated by {_esc(generated_by)} on {_esc(datetime.now().strftime('%Y-%m-%d %H:%M'))}.</footer>
|
|
694
|
+
</div>
|
|
695
|
+
</body>
|
|
696
|
+
</html>
|
|
697
|
+
"""
|