lambda-watcher 0.2.0__py3-none-any.whl → 0.3.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 +1 -1
- lambda_watcher/cli.py +30 -6
- lambda_watcher/diffing/compare.py +42 -5
- lambda_watcher/diffing/render_html.py +433 -97
- lambda_watcher/gitmirror.py +5 -3
- lambda_watcher/notify.py +2 -1
- lambda_watcher/reindex.py +8 -1
- lambda_watcher/service.py +11 -2
- lambda_watcher/store.py +53 -5
- lambda_watcher/utils.py +46 -0
- {lambda_watcher-0.2.0.dist-info → lambda_watcher-0.3.0.dist-info}/METADATA +1 -1
- {lambda_watcher-0.2.0.dist-info → lambda_watcher-0.3.0.dist-info}/RECORD +16 -16
- {lambda_watcher-0.2.0.dist-info → lambda_watcher-0.3.0.dist-info}/WHEEL +0 -0
- {lambda_watcher-0.2.0.dist-info → lambda_watcher-0.3.0.dist-info}/entry_points.txt +0 -0
- {lambda_watcher-0.2.0.dist-info → lambda_watcher-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {lambda_watcher-0.2.0.dist-info → lambda_watcher-0.3.0.dist-info}/top_level.txt +0 -0
lambda_watcher/__init__.py
CHANGED
lambda_watcher/cli.py
CHANGED
|
@@ -11,7 +11,7 @@ import sys
|
|
|
11
11
|
import webbrowser
|
|
12
12
|
import zipfile
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
|
-
from pathlib import Path
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
15
|
from typing import NoReturn, Optional
|
|
16
16
|
|
|
17
17
|
import typer
|
|
@@ -36,7 +36,7 @@ from .service import (
|
|
|
36
36
|
install_service,
|
|
37
37
|
stop_service,
|
|
38
38
|
)
|
|
39
|
-
from .store import Store
|
|
39
|
+
from .store import Store, posix_stored_dir
|
|
40
40
|
from .utils import format_ts, human_size, relative_ts, rmtree, setup_logging, slugify
|
|
41
41
|
|
|
42
42
|
#: Index connections opened by the running command; see `_close_open_dbs`.
|
|
@@ -187,6 +187,19 @@ def _version_or_fail(db: Database, function_id: int, seq: int):
|
|
|
187
187
|
return row
|
|
188
188
|
|
|
189
189
|
|
|
190
|
+
def _stored_dirname(stored_dir: str) -> str:
|
|
191
|
+
"""The last segment of a version's stored path: ``0007-a1b2c3d4``.
|
|
192
|
+
|
|
193
|
+
Goes through :func:`~lambda_watcher.store.posix_stored_dir` first, so it
|
|
194
|
+
reads the same answer out of a value an older release wrote on Windows —
|
|
195
|
+
where ``Path(...).name`` on Linux returns the whole backslash-joined string
|
|
196
|
+
instead of the directory name, and the caller then builds a path to nothing.
|
|
197
|
+
"""
|
|
198
|
+
# back-compat: `posix_stored_dir` is the whole point of this helper existing
|
|
199
|
+
# rather than callers reaching for `Path(...).name` themselves.
|
|
200
|
+
return PurePosixPath(posix_stored_dir(stored_dir)).name
|
|
201
|
+
|
|
202
|
+
|
|
190
203
|
def _build_diff(db: Database, store: Store, cfg: Config, function_row, a_seq: int, b_seq: int,
|
|
191
204
|
include_vendor: bool | None, compute_diffs: bool = True):
|
|
192
205
|
"""Resolve two versions and build the diff between them.
|
|
@@ -194,7 +207,10 @@ def _build_diff(db: Database, store: Store, cfg: Config, function_row, a_seq: in
|
|
|
194
207
|
Warns, rather than fails, when a version's extracted code is missing from
|
|
195
208
|
disk: the index still knows what changed at the file level, so the summary
|
|
196
209
|
and dependency layers are worth printing even though the line diffs come out
|
|
197
|
-
empty. Deleting an archive directory by hand is
|
|
210
|
+
empty. Deleting an archive directory by hand is one cause; an index pointing
|
|
211
|
+
somewhere the directory no longer is, after the archive was moved or copied
|
|
212
|
+
from another machine, is the other — which is why the warning names
|
|
213
|
+
``lw reindex``, the command that rebuilds the index from what is on disk.
|
|
198
214
|
"""
|
|
199
215
|
a = _version_or_fail(db, function_row["id"], a_seq)
|
|
200
216
|
b = _version_or_fail(db, function_row["id"], b_seq)
|
|
@@ -203,7 +219,8 @@ def _build_diff(db: Database, store: Store, cfg: Config, function_row, a_seq: in
|
|
|
203
219
|
if not path.exists():
|
|
204
220
|
err_console.print(
|
|
205
221
|
f"[yellow]warning:[/yellow] code for v{seq:04d} is missing at {path}; "
|
|
206
|
-
"line diffs for it will be empty"
|
|
222
|
+
"line diffs for it will be empty. If the archive was moved or copied "
|
|
223
|
+
"here, `lw reindex` re-points the index at what is on disk."
|
|
207
224
|
)
|
|
208
225
|
return diff_from_index(db, store, cfg.diff, function_row["name"], a, b,
|
|
209
226
|
include_vendor=include_vendor, compute_diffs=compute_diffs)
|
|
@@ -1128,8 +1145,15 @@ def rename(
|
|
|
1128
1145
|
if new_dir.exists():
|
|
1129
1146
|
_fail(f"{new_dir} already exists on disk; move it aside first")
|
|
1130
1147
|
old_dir.rename(new_dir)
|
|
1148
|
+
# back-compat: re-point the index by rebuilding each path from where the
|
|
1149
|
+
# directory now is, rather than by patching the stored string. A
|
|
1150
|
+
# `functions/<old slug>/` replacement silently matched nothing in an
|
|
1151
|
+
# archive an older release wrote on Windows, where the separator is a
|
|
1152
|
+
# backslash — and a version left pointing at the pre-rename path reads
|
|
1153
|
+
# as missing from then on, which is a diff with no lines in it.
|
|
1154
|
+
new_versions = store.versions_dir(new_slug)
|
|
1131
1155
|
for version in db.list_versions(int(row["id"])):
|
|
1132
|
-
updated = version["dir"]
|
|
1156
|
+
updated = store.relative(new_versions / _stored_dirname(version["dir"]))
|
|
1133
1157
|
db.conn.execute("UPDATE versions SET dir = ? WHERE id = ?", (updated, version["id"]))
|
|
1134
1158
|
if old_slug != new_slug:
|
|
1135
1159
|
# The mirror lives outside the function directory, so it does not move
|
|
@@ -1209,7 +1233,7 @@ def merge(
|
|
|
1209
1233
|
stored = store.resolve_version_dir(version["dir"])
|
|
1210
1234
|
if stored.exists():
|
|
1211
1235
|
continue
|
|
1212
|
-
candidate = dst_versions /
|
|
1236
|
+
candidate = dst_versions / _stored_dirname(version["dir"])
|
|
1213
1237
|
if candidate.exists():
|
|
1214
1238
|
db.conn.execute(
|
|
1215
1239
|
"UPDATE versions SET dir = ? WHERE id = ?",
|
|
@@ -38,7 +38,15 @@ class FileRecord:
|
|
|
38
38
|
|
|
39
39
|
@classmethod
|
|
40
40
|
def from_row(cls, row: Any) -> FileRecord:
|
|
41
|
-
"""Build a record from an index row, filling in defaults for null columns.
|
|
41
|
+
"""Build a record from an index row, filling in defaults for null columns.
|
|
42
|
+
|
|
43
|
+
The defaults are not decoration: a row reindexed from a manifest written
|
|
44
|
+
before ``lang`` and ``mode`` were recorded has NULL in those columns, and
|
|
45
|
+
the diff would rather call the file ``text`` at 644 than fail on it.
|
|
46
|
+
"""
|
|
47
|
+
# back-compat: `or "text"` / `or 0o644` cover columns an older manifest
|
|
48
|
+
# cannot fill. Drop them and reindexing an old archive builds rows that
|
|
49
|
+
# crash the diff instead of one that is merely vaguer than it could be.
|
|
42
50
|
return cls(
|
|
43
51
|
path=row["path"], size=int(row["size"]), sha256=row["sha256"],
|
|
44
52
|
is_text=bool(row["is_text"]), is_vendor=bool(row["is_vendor"]),
|
|
@@ -56,8 +64,9 @@ class FileChange:
|
|
|
56
64
|
|
|
57
65
|
``diff_lines`` may be empty even for a real modification —
|
|
58
66
|
``skipped_reason`` then says why (binary, too large, ignored by config,
|
|
59
|
-
whitespace only), so the renderer can explain the
|
|
60
|
-
nothing changed. Two of those absences are not
|
|
67
|
+
whitespace only, missing from the archive), so the renderer can explain the
|
|
68
|
+
absence rather than imply nothing changed. Two of those absences are not
|
|
69
|
+
refusals but better answers:
|
|
61
70
|
a whitespace-only change is fully described by its label, and a file with no
|
|
62
71
|
usable lines is described by ``word_edits`` instead. See :func:`_fill_diff`.
|
|
63
72
|
"""
|
|
@@ -87,6 +96,12 @@ class FileChange:
|
|
|
87
96
|
#: The changed runs of a ``long_lines`` file, with enough text either side to
|
|
88
97
|
#: place them. Empty for every ordinary file.
|
|
89
98
|
word_edits: list[WordEdit] = field(default_factory=list)
|
|
99
|
+
#: True when the index lists the file but the archived version directory does
|
|
100
|
+
#: not hold it, so there was nothing to diff. Unlike every other reason a
|
|
101
|
+
#: hunk is absent, this one is not about the file at all — the archive is
|
|
102
|
+
#: incomplete, or the index is pointing at the wrong place — so the
|
|
103
|
+
#: renderers say what to do about it rather than only what happened.
|
|
104
|
+
missing: bool = False
|
|
90
105
|
|
|
91
106
|
@property
|
|
92
107
|
def is_vendor(self) -> bool:
|
|
@@ -146,6 +161,7 @@ class FileChange:
|
|
|
146
161
|
"is_vendor": self.is_vendor,
|
|
147
162
|
"whitespace_only": self.whitespace_only,
|
|
148
163
|
"long_lines": self.long_lines,
|
|
164
|
+
"missing": self.missing,
|
|
149
165
|
"word_edits": [e.as_dict() for e in self.word_edits],
|
|
150
166
|
}
|
|
151
167
|
|
|
@@ -764,6 +780,21 @@ def _whitespace_key(text: str) -> tuple[str, ...]:
|
|
|
764
780
|
return tuple(" ".join(line.split()) for line in text.splitlines() if line.strip())
|
|
765
781
|
|
|
766
782
|
|
|
783
|
+
def _absent(root: Path, record: FileRecord | None) -> bool:
|
|
784
|
+
"""True when the index lists this file but the archive no longer holds it.
|
|
785
|
+
|
|
786
|
+
:func:`~lambda_watcher.utils.read_text` answers None both for bytes that are
|
|
787
|
+
not text and for a file it could not open at all, and the second is by far
|
|
788
|
+
the likelier of the two here: the index is derived from disk, so a file it
|
|
789
|
+
lists was readable when it was written. A version directory that has been
|
|
790
|
+
deleted, moved, or restored under a path the index does not know about is
|
|
791
|
+
what actually produces it — see :meth:`lambda_watcher.store.Store.relative`.
|
|
792
|
+
Telling the reader "not decodable as text" for that sends them looking for an
|
|
793
|
+
encoding problem in a file that is simply not there.
|
|
794
|
+
"""
|
|
795
|
+
return record is not None and not (root / record.path).is_file()
|
|
796
|
+
|
|
797
|
+
|
|
767
798
|
def _fill_diff(change: FileChange, old_root: Path, new_root: Path, cfg: DiffConfig) -> None:
|
|
768
799
|
"""Work out what changed inside one file and write it onto the change.
|
|
769
800
|
|
|
@@ -792,7 +823,10 @@ def _fill_diff(change: FileChange, old_root: Path, new_root: Path, cfg: DiffConf
|
|
|
792
823
|
Everything else, from :func:`difflib.unified_diff`.
|
|
793
824
|
|
|
794
825
|
Before any of that, a file can be refused outright — binary, over
|
|
795
|
-
``max_diff_file_kb``,
|
|
826
|
+
``max_diff_file_kb``, not decodable, or missing from the archive — and
|
|
827
|
+
``skipped_reason`` says which. The last of those is not a property of the
|
|
828
|
+
file's bytes but of the archive around it, so it is told apart by
|
|
829
|
+
:func:`_absent` rather than lumped in with the encodings.
|
|
796
830
|
Line counts stay at zero on every route but the last: the counts describe a
|
|
797
831
|
hunk, and where there is no hunk ``+3/-3`` is the noise the route exists to
|
|
798
832
|
remove.
|
|
@@ -813,7 +847,10 @@ def _fill_diff(change: FileChange, old_root: Path, new_root: Path, cfg: DiffConf
|
|
|
813
847
|
old_text = read_text(old_root / old.path) if old else ""
|
|
814
848
|
new_text = read_text(new_root / new.path) if new else ""
|
|
815
849
|
if old_text is None or new_text is None:
|
|
816
|
-
change.
|
|
850
|
+
change.missing = _absent(old_root, old) or _absent(new_root, new)
|
|
851
|
+
change.skipped_reason = (
|
|
852
|
+
"missing from the archive" if change.missing else "not decodable as text"
|
|
853
|
+
)
|
|
817
854
|
return
|
|
818
855
|
|
|
819
856
|
if old_text and new_text and old_text != new_text:
|
|
@@ -130,44 +130,52 @@ table.grid td.label { width: 210px; color: var(--muted); }
|
|
|
130
130
|
.tok.removed { background: var(--del-bg); border-color: var(--del-gutter); color: var(--del-fg); }
|
|
131
131
|
|
|
132
132
|
/* ---- toolbar --------------------------------------------------------- */
|
|
133
|
-
.toolbar { display: flex; gap:
|
|
134
|
-
position: sticky; top: 0; background: var(--bg); padding: 10px 0; z-index:
|
|
133
|
+
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px;
|
|
134
|
+
position: sticky; top: 0; background: var(--bg); padding: 10px 0; z-index: 6;
|
|
135
135
|
box-shadow: 0 1px 0 var(--border); }
|
|
136
136
|
.toolbar #shown-count { margin-left: auto; white-space: nowrap; flex: 0 0 auto; color: var(--faint); }
|
|
137
|
-
.toolbar input[type=search] { flex: 1 1
|
|
137
|
+
.toolbar input[type=search] { flex: 1 1 240px; min-width: 150px; padding: 7px 11px; border-radius: 7px;
|
|
138
138
|
border: 1px solid var(--border); background: var(--panel); color: var(--text); font-size: 13px; }
|
|
139
|
-
.toolbar input[type=search]:focus { outline: none; border-color: var(--accent);
|
|
139
|
+
.toolbar input[type=search]:focus { outline: none; border-color: var(--accent);
|
|
140
|
+
box-shadow: 0 0 0 3px var(--accent-wash); }
|
|
140
141
|
.toolbar label { color: var(--muted); font-size: 13px; display: inline-flex; gap: 6px;
|
|
141
142
|
align-items: center; cursor: pointer; user-select: none; }
|
|
142
|
-
.toolbar button { padding: 6px 11px; border-radius: 6px; border: 1px solid var(--border);
|
|
143
|
-
background: var(--panel); color: var(--muted); font-size: 13px; cursor: pointer; }
|
|
144
|
-
.toolbar button:hover { border-color: var(--faint); color: var(--text); }
|
|
145
143
|
|
|
146
|
-
/* ----
|
|
147
|
-
|
|
144
|
+
/* ---- the file list --------------------------------------------------- */
|
|
145
|
+
/* One panel of rows rather than a stack of cards, because the list is an index
|
|
146
|
+
now: the diff it points at opens beside it instead of pushing the rest of
|
|
147
|
+
the list down the page. A row is a button, since that is what it does. */
|
|
148
|
+
.files { border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
|
|
148
149
|
background: var(--panel); }
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
150
|
+
.file { border-top: 1px solid var(--rule); }
|
|
151
|
+
/* Transparent rather than absent so every row is the same height, whichever
|
|
152
|
+
one the filter left at the top. */
|
|
153
|
+
.file:first-child, .file.first-shown { border-top-color: transparent; }
|
|
154
|
+
.row { display: flex; gap: 10px; align-items: center; width: 100%; padding: 9px 14px;
|
|
155
|
+
font: inherit; color: var(--text); text-align: left; background: none; border: 0;
|
|
156
|
+
cursor: pointer; }
|
|
157
|
+
.row:hover { background: var(--sunken); }
|
|
158
|
+
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
|
|
159
|
+
/* The chevron points where the diff will appear: to the side, not downwards.
|
|
160
|
+
Drawn in CSS because a vendored diff runs to thousands of rows, and each one
|
|
161
|
+
would otherwise carry its own copy of the glyph. */
|
|
162
|
+
.row::after { content: ""; flex: 0 0 auto; width: 6px; height: 6px; margin-left: 2px;
|
|
163
|
+
border: 1.6px solid var(--faint); border-left: 0; border-bottom: 0;
|
|
164
|
+
transform: rotate(45deg); transition: transform .15s ease, border-color .15s ease; }
|
|
165
|
+
.row:hover::after { border-color: var(--accent); transform: translateX(2px) rotate(45deg); }
|
|
166
|
+
.file.active .row { background: var(--accent-wash); box-shadow: inset 3px 0 0 var(--accent); }
|
|
167
|
+
.file.active .row::after { border-color: var(--accent); transform: translateX(2px) rotate(45deg); }
|
|
168
|
+
.row .path { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
|
169
|
+
.row .path .p { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
|
|
161
170
|
/* The tint hugs the changed part exactly — padding here would open a gap in
|
|
162
171
|
the middle of a path and read as though the name contained a space. */
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
.path .ren { background: var(--sunken); border-radius: 3px; }
|
|
173
|
+
.path .was { color: var(--faint); }
|
|
174
|
+
.stat-line { font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap;
|
|
166
175
|
display: flex; gap: 8px; align-items: baseline; color: var(--faint); }
|
|
167
176
|
|
|
168
177
|
/* ---- the diff itself ------------------------------------------------- */
|
|
169
178
|
.diff { overflow-x: auto; border-top: 1px solid var(--border); }
|
|
170
|
-
details.file[open] > .diff { border-top: none; }
|
|
171
179
|
.diff table { border-collapse: collapse; width: 100%; font-family: var(--mono);
|
|
172
180
|
font-size: 12.5px; line-height: 1.5; }
|
|
173
181
|
.diff td { padding: 0 8px; white-space: pre; vertical-align: top; }
|
|
@@ -224,49 +232,273 @@ footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border
|
|
|
224
232
|
.moved-list { margin: 0; padding: 10px 16px 12px 34px; list-style: disc;
|
|
225
233
|
color: var(--muted); font-size: 12px; line-height: 1.9; }
|
|
226
234
|
.moved-list .hint { color: var(--faint); }
|
|
235
|
+
/* ---- the sheet ------------------------------------------------------- */
|
|
236
|
+
/* Where the diff lands when a row is clicked. Code wants width, and a block
|
|
237
|
+
that opened downwards spent the page's widest dimension on the file list it
|
|
238
|
+
had just pushed out of view; this keeps the list where it was, so the next
|
|
239
|
+
file is one click away rather than one scroll back.
|
|
240
|
+
|
|
241
|
+
A wide window docks the sheet and the page makes room beside it. A narrow
|
|
242
|
+
one slides it over the page with a scrim, because a laptop in portrait has
|
|
243
|
+
no room to dock and half a diff is worse than a covered list. */
|
|
244
|
+
:root { --sheet-w: 760px; }
|
|
245
|
+
.scrim { position: fixed; inset: 0; z-index: 30; background: rgba(8, 13, 20, .44);
|
|
246
|
+
opacity: 0; visibility: hidden; transition: opacity .26s ease, visibility 0s linear .26s; }
|
|
247
|
+
body.sheet-open .scrim { opacity: 1; visibility: visible; transition: opacity .26s ease; }
|
|
248
|
+
.sheet { position: fixed; top: 0; right: 0; bottom: 0; z-index: 40;
|
|
249
|
+
width: min(100%, var(--sheet-w)); display: flex; flex-direction: column;
|
|
250
|
+
background: var(--bg); border-left: 1px solid var(--border);
|
|
251
|
+
box-shadow: -24px 0 60px -30px rgba(6, 11, 18, .5);
|
|
252
|
+
/* `visibility` rather than `display`, so the sheet can animate out and still
|
|
253
|
+
leave nothing behind for the keyboard to land on while it is shut. It turns
|
|
254
|
+
visible on the same frame it is asked to — a transition would leave it
|
|
255
|
+
unfocusable for the length of the slide, which is exactly when the script
|
|
256
|
+
is moving focus into it — and back to hidden only once the slide is over. */
|
|
257
|
+
transform: translateX(100%); visibility: hidden;
|
|
258
|
+
transition: transform .26s cubic-bezier(.22, .61, .36, 1), visibility 0s linear .26s; }
|
|
259
|
+
body.sheet-open .sheet { transform: none; visibility: visible;
|
|
260
|
+
transition: transform .26s cubic-bezier(.22, .61, .36, 1); }
|
|
261
|
+
.sheet-head { padding: 12px 14px 11px; border-bottom: 1px solid var(--border);
|
|
262
|
+
background: var(--panel); display: flex; flex-direction: column; gap: 7px; }
|
|
263
|
+
.sheet-bar { display: flex; align-items: center; gap: 10px; }
|
|
264
|
+
.sheet-title { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
|
|
265
|
+
.sheet-title .path { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
266
|
+
.sheet-title .p { font-family: var(--mono); font-size: 13px; font-weight: 600;
|
|
267
|
+
overflow-wrap: anywhere; }
|
|
268
|
+
/* Wrapping rather than clipping: a file whose counts are a sentence — "missing
|
|
269
|
+
from the archive" — is exactly the one whose reader needs to read them. */
|
|
270
|
+
.sheet-sub { display: flex; align-items: baseline; gap: 12px; min-height: 18px;
|
|
271
|
+
flex-wrap: wrap; color: var(--faint); font-size: 12px; }
|
|
272
|
+
.sheet-sub .ver { margin-left: auto; font-family: var(--mono); white-space: nowrap; }
|
|
273
|
+
.sheet-nav { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; }
|
|
274
|
+
.sheet-nav .pos { color: var(--faint); font-size: 12px; padding: 0 3px;
|
|
275
|
+
font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
276
|
+
.iconbtn { display: inline-flex; align-items: center; justify-content: center;
|
|
277
|
+
width: 28px; height: 28px; padding: 0; border-radius: 7px; border: 1px solid transparent;
|
|
278
|
+
background: none; color: var(--muted); cursor: pointer; }
|
|
279
|
+
.iconbtn:hover:not(:disabled) { background: var(--sunken); border-color: var(--border);
|
|
280
|
+
color: var(--text); }
|
|
281
|
+
.iconbtn:disabled { opacity: .3; cursor: default; }
|
|
282
|
+
.iconbtn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
283
|
+
.iconbtn svg { width: 16px; height: 16px; fill: none; stroke: currentColor;
|
|
284
|
+
stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
|
285
|
+
.iconbtn.close { margin-left: 5px; }
|
|
286
|
+
.sheet-body { flex: 1; overflow: auto; overscroll-behavior: contain; }
|
|
287
|
+
.sheet-body:focus { outline: none; }
|
|
288
|
+
/* The panel's own top rule would double the header's. */
|
|
289
|
+
.sheet-body > .body > :first-child { border-top: none; }
|
|
290
|
+
/* One file wants sixty columns and the next wants two hundred, and which one
|
|
291
|
+
is on screen is not something the page can know — so the edge is draggable
|
|
292
|
+
wherever there is a pointer and room to dock. */
|
|
293
|
+
.grip { position: absolute; left: 0; top: 0; bottom: 0; width: 11px; display: none;
|
|
294
|
+
cursor: col-resize; }
|
|
295
|
+
.grip::before { content: ""; position: absolute; left: 4px; top: 50%; width: 3px; height: 44px;
|
|
296
|
+
margin-top: -22px; border-radius: 3px; background: var(--border); transition: background .15s; }
|
|
297
|
+
.grip:hover::before, body.dragging .grip::before { background: var(--accent); }
|
|
298
|
+
body.dragging { user-select: none; cursor: col-resize; }
|
|
299
|
+
body.dragging, body.dragging .sheet { transition: none; }
|
|
300
|
+
|
|
301
|
+
@media (min-width: 1280px) {
|
|
302
|
+
:root { --sheet-w: min(50vw, 960px); }
|
|
303
|
+
.scrim { display: none; }
|
|
304
|
+
.grip { display: block; }
|
|
305
|
+
body { transition: padding-right .26s cubic-bezier(.22, .61, .36, 1); }
|
|
306
|
+
body.sheet-open { padding-right: min(var(--sheet-w), 76vw); }
|
|
307
|
+
}
|
|
308
|
+
/* Nothing to dock into: the sheet is over the page, so the page stops scrolling
|
|
309
|
+
underneath it. */
|
|
310
|
+
@media (max-width: 1279.98px) {
|
|
311
|
+
body.sheet-open { overflow: hidden; }
|
|
312
|
+
}
|
|
313
|
+
@media (prefers-reduced-motion: reduce) {
|
|
314
|
+
body, .sheet, .scrim, .row::after { transition: none; }
|
|
315
|
+
}
|
|
316
|
+
/* On paper there is no clicking, so every diff is printed under its own row and
|
|
317
|
+
the chrome that only answers a pointer is left out. */
|
|
318
|
+
@media print {
|
|
319
|
+
.toolbar, .scrim, .sheet-nav, .grip { display: none !important; }
|
|
320
|
+
.sheet { position: static; transform: none; visibility: visible; width: auto;
|
|
321
|
+
border-left: 0; box-shadow: none; }
|
|
322
|
+
body.sheet-open { padding-right: 0; overflow: visible; }
|
|
323
|
+
.file .body[hidden] { display: block; }
|
|
324
|
+
}
|
|
227
325
|
.hidden { display: none !important; }
|
|
228
326
|
"""
|
|
229
327
|
|
|
328
|
+
#: What the page does once it is open: narrow the list, and move one file's diff
|
|
329
|
+
#: into the sheet and back out again. Moving rather than copying is the whole
|
|
330
|
+
#: design — the diff exists once, so the sheet cannot drift from the row, and
|
|
331
|
+
#: closing it leaves the page exactly as it was rendered. One ``<script>`` at the
|
|
332
|
+
#: end of the body, so everything it binds to is already parsed.
|
|
230
333
|
JS = """
|
|
231
334
|
(function () {
|
|
232
335
|
var search = document.getElementById('filter');
|
|
233
336
|
var vendorToggle = document.getElementById('vendor');
|
|
234
|
-
var
|
|
337
|
+
var counter = document.getElementById('shown-count');
|
|
338
|
+
var files = Array.prototype.slice.call(document.querySelectorAll('.files .file'));
|
|
339
|
+
var scrim = document.getElementById('scrim');
|
|
340
|
+
var host = document.getElementById('sheet-body');
|
|
341
|
+
var titleBox = document.getElementById('sheet-title');
|
|
342
|
+
var statBox = document.getElementById('sheet-stat');
|
|
343
|
+
var posBox = document.getElementById('sheet-pos');
|
|
344
|
+
var prevBtn = document.getElementById('sheet-prev');
|
|
345
|
+
var nextBtn = document.getElementById('sheet-next');
|
|
346
|
+
var closeBtn = document.getElementById('sheet-close');
|
|
347
|
+
var grip = document.getElementById('sheet-grip');
|
|
348
|
+
var openFile = null; // whose diff is sitting in the sheet right now
|
|
349
|
+
var opener = null; // the row that put it there, to hand focus back to
|
|
350
|
+
|
|
351
|
+
function listed() {
|
|
352
|
+
return files.filter(function (el) { return !el.classList.contains('hidden'); });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function empty(node) {
|
|
356
|
+
while (node && node.firstChild) { node.removeChild(node.firstChild); }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The sheet header says the same things the row does, so it is built from a
|
|
360
|
+
// copy of the row rather than from a second set of markup on every file.
|
|
361
|
+
function copyInto(node, source) {
|
|
362
|
+
empty(node);
|
|
363
|
+
if (node && source) { node.appendChild(source.cloneNode(true)); }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function position() {
|
|
367
|
+
var rows = listed();
|
|
368
|
+
var at = openFile ? rows.indexOf(openFile) : -1;
|
|
369
|
+
if (posBox) { posBox.textContent = at < 0 ? '' : (at + 1) + ' / ' + rows.length; }
|
|
370
|
+
if (prevBtn) { prevBtn.disabled = at <= 0; }
|
|
371
|
+
if (nextBtn) { nextBtn.disabled = at < 0 || at >= rows.length - 1; }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// The diff is moved into the sheet rather than copied, so this puts it back
|
|
375
|
+
// where it came from. One diff exists at a time, wherever it is showing.
|
|
376
|
+
function park() {
|
|
377
|
+
if (!openFile) { return; }
|
|
378
|
+
var body = host.firstElementChild;
|
|
379
|
+
if (body) { body.hidden = true; openFile.appendChild(body); }
|
|
380
|
+
openFile.classList.remove('active');
|
|
381
|
+
var row = openFile.querySelector('.row');
|
|
382
|
+
if (row) { row.setAttribute('aria-expanded', 'false'); }
|
|
383
|
+
openFile = null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function show(file, takeFocus) {
|
|
387
|
+
var row = file && file.querySelector('.row');
|
|
388
|
+
if (!row || !host) { return; }
|
|
389
|
+
park();
|
|
390
|
+
openFile = file;
|
|
391
|
+
file.classList.add('active');
|
|
392
|
+
row.setAttribute('aria-expanded', 'true');
|
|
393
|
+
var body = file.querySelector('.body');
|
|
394
|
+
if (body) { body.hidden = false; host.appendChild(body); }
|
|
395
|
+
host.scrollTop = 0;
|
|
396
|
+
empty(titleBox);
|
|
397
|
+
['.chip', '.path'].forEach(function (part) {
|
|
398
|
+
var found = row.querySelector(part);
|
|
399
|
+
if (found && titleBox) { titleBox.appendChild(found.cloneNode(true)); }
|
|
400
|
+
});
|
|
401
|
+
copyInto(statBox, row.querySelector('.stat-line'));
|
|
402
|
+
document.body.classList.add('sheet-open');
|
|
403
|
+
position();
|
|
404
|
+
if (takeFocus) { host.focus(); }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function close() {
|
|
408
|
+
park();
|
|
409
|
+
document.body.classList.remove('sheet-open');
|
|
410
|
+
empty(titleBox);
|
|
411
|
+
empty(statBox);
|
|
412
|
+
position();
|
|
413
|
+
if (opener) { opener.focus(); opener = null; }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function step(delta) {
|
|
417
|
+
var rows = listed();
|
|
418
|
+
var at = openFile ? rows.indexOf(openFile) : -1;
|
|
419
|
+
var next = at < 0 ? null : rows[at + delta];
|
|
420
|
+
if (next) {
|
|
421
|
+
// Focus stays on the button that is walking the list, so the next press
|
|
422
|
+
// lands on it too.
|
|
423
|
+
show(next, false);
|
|
424
|
+
opener = next.querySelector('.row');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
235
427
|
|
|
236
428
|
function apply() {
|
|
237
|
-
var term = (search.value
|
|
429
|
+
var term = (search ? search.value : '').toLowerCase();
|
|
238
430
|
var showVendor = vendorToggle ? vendorToggle.checked : true;
|
|
239
431
|
var shown = 0;
|
|
432
|
+
var total = 0;
|
|
433
|
+
var first = true;
|
|
240
434
|
files.forEach(function (el) {
|
|
241
435
|
var path = (el.getAttribute('data-path') || '').toLowerCase();
|
|
242
436
|
var isVendor = el.getAttribute('data-vendor') === '1';
|
|
243
437
|
var ok = (!term || path.indexOf(term) !== -1) && (showVendor || !isVendor);
|
|
438
|
+
// A move block stands for every file it folded up, so both halves of the
|
|
439
|
+
// count are sums of `data-files` rather than counts of rows.
|
|
440
|
+
var covers = parseInt(el.getAttribute('data-files') || '1', 10);
|
|
244
441
|
el.classList.toggle('hidden', !ok);
|
|
245
|
-
|
|
442
|
+
el.classList.toggle('first-shown', ok && first);
|
|
443
|
+
total += covers;
|
|
444
|
+
if (ok) { shown += covers; first = false; }
|
|
246
445
|
});
|
|
247
|
-
|
|
248
|
-
|
|
446
|
+
if (counter) { counter.textContent = shown + ' of ' + total + ' files shown'; }
|
|
447
|
+
// Leaving the sheet open on a file the filter just took off the list would
|
|
448
|
+
// show a diff with no row to close it from.
|
|
449
|
+
if (openFile && openFile.classList.contains('hidden')) { close(); } else { position(); }
|
|
249
450
|
}
|
|
250
451
|
|
|
251
|
-
|
|
252
|
-
|
|
452
|
+
files.forEach(function (file) {
|
|
453
|
+
var row = file.querySelector('.row');
|
|
454
|
+
if (!row) { return; }
|
|
455
|
+
row.addEventListener('click', function () {
|
|
456
|
+
if (file === openFile) { close(); return; }
|
|
457
|
+
opener = row;
|
|
458
|
+
show(file, true);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
if (search) { search.addEventListener('input', apply); }
|
|
463
|
+
if (vendorToggle) { vendorToggle.addEventListener('change', apply); }
|
|
464
|
+
if (closeBtn) { closeBtn.addEventListener('click', close); }
|
|
465
|
+
if (scrim) { scrim.addEventListener('click', close); }
|
|
466
|
+
if (prevBtn) { prevBtn.addEventListener('click', function () { step(-1); }); }
|
|
467
|
+
if (nextBtn) { nextBtn.addEventListener('click', function () { step(1); }); }
|
|
468
|
+
|
|
469
|
+
document.addEventListener('keydown', function (ev) {
|
|
470
|
+
if (!openFile || ev.ctrlKey || ev.metaKey || ev.altKey) { return; }
|
|
471
|
+
if (ev.key === 'Escape') { close(); return; }
|
|
472
|
+
var tag = (ev.target.tagName || '').toLowerCase();
|
|
473
|
+
if (tag === 'input' || tag === 'textarea') { return; }
|
|
474
|
+
if (ev.key === 'j') { step(1); ev.preventDefault(); }
|
|
475
|
+
if (ev.key === 'k') { step(-1); ev.preventDefault(); }
|
|
476
|
+
});
|
|
253
477
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
478
|
+
if (grip && window.PointerEvent) {
|
|
479
|
+
grip.addEventListener('pointerdown', function (down) {
|
|
480
|
+
down.preventDefault();
|
|
481
|
+
grip.setPointerCapture(down.pointerId);
|
|
482
|
+
document.body.classList.add('dragging');
|
|
483
|
+
function drag(move) {
|
|
484
|
+
// Clamped so a drag can neither shut the sheet nor bury the list
|
|
485
|
+
// behind it, whichever direction it is thrown in.
|
|
486
|
+
var width = Math.min(Math.max(window.innerWidth - move.clientX, 380),
|
|
487
|
+
Math.max(window.innerWidth - 320, 380));
|
|
488
|
+
document.documentElement.style.setProperty('--sheet-w', Math.round(width) + 'px');
|
|
489
|
+
}
|
|
490
|
+
function drop() {
|
|
491
|
+
document.body.classList.remove('dragging');
|
|
492
|
+
grip.removeEventListener('pointermove', drag);
|
|
493
|
+
grip.removeEventListener('pointerup', drop);
|
|
494
|
+
grip.removeEventListener('pointercancel', drop);
|
|
495
|
+
}
|
|
496
|
+
grip.addEventListener('pointermove', drag);
|
|
497
|
+
grip.addEventListener('pointerup', drop);
|
|
498
|
+
grip.addEventListener('pointercancel', drop);
|
|
499
|
+
});
|
|
258
500
|
}
|
|
259
|
-
window.addEventListener('resize', measure);
|
|
260
|
-
measure();
|
|
261
501
|
|
|
262
|
-
var expand = document.getElementById('expand-all');
|
|
263
|
-
var collapse = document.getElementById('collapse-all');
|
|
264
|
-
if (expand) expand.addEventListener('click', function () {
|
|
265
|
-
files.forEach(function (f) { if (!f.classList.contains('hidden')) f.open = true; });
|
|
266
|
-
});
|
|
267
|
-
if (collapse) collapse.addEventListener('click', function () {
|
|
268
|
-
files.forEach(function (f) { f.open = false; });
|
|
269
|
-
});
|
|
270
502
|
apply();
|
|
271
503
|
})();
|
|
272
504
|
"""
|
|
@@ -392,8 +624,46 @@ def _row_code(row: _Row, lang: str, old: _Painted, new: _Painted) -> str:
|
|
|
392
624
|
return highlight(row.text, lang)
|
|
393
625
|
|
|
394
626
|
|
|
627
|
+
def _file_block(
|
|
628
|
+
*,
|
|
629
|
+
path_key: str,
|
|
630
|
+
chip_class: str,
|
|
631
|
+
chip_text: str,
|
|
632
|
+
icon: str,
|
|
633
|
+
title: str,
|
|
634
|
+
stat: str,
|
|
635
|
+
body: str,
|
|
636
|
+
is_vendor: bool,
|
|
637
|
+
files: int | None = None,
|
|
638
|
+
) -> str:
|
|
639
|
+
"""One row of the file list, carrying the diff the sheet opens when it is clicked.
|
|
640
|
+
|
|
641
|
+
The row is what the reader scans — kind, icon, path, counts — and ``body``
|
|
642
|
+
is what opens beside it. The body ships inside the row's own element rather
|
|
643
|
+
than in a second list keyed by path, so a block stays one self-contained
|
|
644
|
+
thing to filter, hide or print; the script moves that element into the sheet
|
|
645
|
+
and back out again, which is why no diff is ever on the page twice.
|
|
646
|
+
|
|
647
|
+
``path_key`` is what the filter box matches on: a file's own path and the
|
|
648
|
+
one it was renamed from, or every member path of a move. ``files`` is how
|
|
649
|
+
many files the block is the only entry for, left off when that is one.
|
|
650
|
+
"""
|
|
651
|
+
extra = f' data-files="{files}"' if files is not None else ""
|
|
652
|
+
return (
|
|
653
|
+
f'<article class="file" data-path="{_esc(path_key)}" '
|
|
654
|
+
f'data-vendor="{1 if is_vendor else 0}"{extra}>'
|
|
655
|
+
'<button class="row" type="button" aria-expanded="false" aria-controls="sheet">'
|
|
656
|
+
f'<span class="chip {_esc(chip_class)}">{_esc(chip_text)}</span>'
|
|
657
|
+
f'<span class="path">{icon}<span class="p">{title}</span></span>'
|
|
658
|
+
f'<span class="stat-line">{stat}</span>'
|
|
659
|
+
"</button>"
|
|
660
|
+
f'<div class="body" hidden>{body}</div>'
|
|
661
|
+
"</article>"
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
395
665
|
def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path | None = None) -> str:
|
|
396
|
-
"""Render one file's change as a
|
|
666
|
+
"""Render one file's change as a list row and the diff table behind it.
|
|
397
667
|
|
|
398
668
|
A rename is titled as one file with only the moved part written twice
|
|
399
669
|
(``boto3-{1.34.0 → 1.35.20}.dist-info/METADATA``), rather than as two
|
|
@@ -423,22 +693,12 @@ def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path |
|
|
|
423
693
|
if change.size_delta:
|
|
424
694
|
stat += f"<span>{signed(change.size_delta)} B</span>"
|
|
425
695
|
if note := change.line_count_note:
|
|
426
|
-
# The
|
|
427
|
-
#
|
|
428
|
-
#
|
|
696
|
+
# The diff is behind a click, so a row with no ``+`` and no ``−`` is all
|
|
697
|
+
# most readers ever see of this file. The note is the difference between
|
|
698
|
+
# "measured differently" and "unchanged".
|
|
429
699
|
stat += f'<span class="skipped">{_esc(note)}</span>'
|
|
430
700
|
|
|
431
|
-
parts = [
|
|
432
|
-
f'<details class="file" data-path="{_esc(change.path)} {_esc(change.old_path or "")}" '
|
|
433
|
-
f'data-vendor="{1 if change.is_vendor else 0}">',
|
|
434
|
-
"<summary>",
|
|
435
|
-
f'<span class="chip {_esc(change.kind)}">{_esc(change.kind)}</span>',
|
|
436
|
-
f'<span class="path">{icons.file_icon(change.path, lang)}'
|
|
437
|
-
f'<span class="p">{title}</span></span>',
|
|
438
|
-
f'<span class="stat-line">{stat}</span>',
|
|
439
|
-
"</summary>",
|
|
440
|
-
]
|
|
441
|
-
|
|
701
|
+
parts: list[str] = []
|
|
442
702
|
if change.diff_lines:
|
|
443
703
|
old = _paint(a_root, change.old_path or change.path, lang) if change.old else None
|
|
444
704
|
new = _paint(b_root, change.path, lang) if change.new else None
|
|
@@ -450,7 +710,7 @@ def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path |
|
|
|
450
710
|
parts.append(f'<tr class="hunk"><td colspan="4">{_esc(row.text)}</td></tr>')
|
|
451
711
|
continue
|
|
452
712
|
css = f' class="{row.css}"' if row.css in ("add", "del") else ""
|
|
453
|
-
mark = {"add": "+", "del": "
|
|
713
|
+
mark = {"add": "+", "del": "−"}.get(row.css, "")
|
|
454
714
|
code = intraline.mark(_row_code(row, lang, old, new), marks.get(index, []))
|
|
455
715
|
parts.append(
|
|
456
716
|
f"<tr{css}>"
|
|
@@ -482,10 +742,24 @@ def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path |
|
|
|
482
742
|
# check.
|
|
483
743
|
note += (' Indentation, line endings or blank lines only — '
|
|
484
744
|
'<code>lw diff --whitespace</code> shows it anyway.')
|
|
745
|
+
elif change.missing:
|
|
746
|
+
# The one reason here that is not about the file: nothing is wrong
|
|
747
|
+
# with it, the archive just does not have it where the index says.
|
|
748
|
+
note += (' The index lists this file but the version directory does not '
|
|
749
|
+
'hold it — <code>lw reindex</code> rebuilds the index from what '
|
|
750
|
+
'is on disk.')
|
|
485
751
|
parts.append(f'<div class="note">{note}</div>')
|
|
486
752
|
|
|
487
|
-
|
|
488
|
-
|
|
753
|
+
return _file_block(
|
|
754
|
+
path_key=f'{change.path} {change.old_path or ""}',
|
|
755
|
+
chip_class=change.kind,
|
|
756
|
+
chip_text=change.kind,
|
|
757
|
+
icon=icons.file_icon(change.path, lang),
|
|
758
|
+
title=title,
|
|
759
|
+
stat=stat,
|
|
760
|
+
body="\n".join(parts),
|
|
761
|
+
is_vendor=change.is_vendor,
|
|
762
|
+
)
|
|
489
763
|
|
|
490
764
|
|
|
491
765
|
def _render_word_edits(change: FileChange) -> str:
|
|
@@ -535,15 +809,14 @@ def _render_move(group: MoveGroup) -> str:
|
|
|
535
809
|
files, so nothing is hidden that expanding will not show. The alternative
|
|
536
810
|
is twenty blocks whose titles differ only in the filename at the end.
|
|
537
811
|
|
|
538
|
-
Kept searchable and filterable like any file block:
|
|
539
|
-
every member path
|
|
540
|
-
|
|
541
|
-
|
|
812
|
+
Kept searchable and filterable like any file block: the filter box matches
|
|
813
|
+
on every member path, and the block counts as vendored only when the whole
|
|
814
|
+
move is, which is the dependency-bump case
|
|
815
|
+
(``boto3-1.{34.0 → 35.20}.dist-info/``).
|
|
542
816
|
|
|
543
|
-
|
|
544
|
-
"N of M files shown" counter stays a count of files
|
|
545
|
-
|
|
546
|
-
otherwise be counted twice.
|
|
817
|
+
The file count it reports to :func:`_file_block` leaves out the edited
|
|
818
|
+
members, so the "N of M files shown" counter stays a count of files: those
|
|
819
|
+
members follow as blocks of their own and would otherwise be counted twice.
|
|
547
820
|
"""
|
|
548
821
|
head, was, now, tail = rename_label(*group.display_dirs)
|
|
549
822
|
title = (
|
|
@@ -573,18 +846,16 @@ def _render_move(group: MoveGroup) -> str:
|
|
|
573
846
|
+ "</li>"
|
|
574
847
|
for c in group.members
|
|
575
848
|
)
|
|
576
|
-
return (
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
"
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
f'<
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
f'<ul class="moved-list">{rows}</ul>'
|
|
587
|
-
"</details>"
|
|
849
|
+
return _file_block(
|
|
850
|
+
path_key=searchable,
|
|
851
|
+
chip_class="renamed",
|
|
852
|
+
chip_text="moved",
|
|
853
|
+
icon=icons.file_icon(group.new_dir + "/", "text"),
|
|
854
|
+
title=title,
|
|
855
|
+
stat=stat,
|
|
856
|
+
body=f'<ul class="moved-list">{rows}</ul>',
|
|
857
|
+
is_vendor=group.is_vendor,
|
|
858
|
+
files=group.moved - group.edited,
|
|
588
859
|
)
|
|
589
860
|
|
|
590
861
|
|
|
@@ -767,8 +1038,68 @@ def _findings_section(diff: VersionDiff) -> str:
|
|
|
767
1038
|
return f"<h2>New findings</h2>{table}{resolved}"
|
|
768
1039
|
|
|
769
1040
|
|
|
1041
|
+
#: Without the script the sheet can never open, so the diffs sit under their own
|
|
1042
|
+
#: rows instead and the controls that would do nothing are taken off the page.
|
|
1043
|
+
NOSCRIPT = """
|
|
1044
|
+
.toolbar, .sheet, .scrim { display: none; }
|
|
1045
|
+
.row { cursor: default; }
|
|
1046
|
+
.row::after { display: none; }
|
|
1047
|
+
.file .body[hidden] { display: block; }
|
|
1048
|
+
"""
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _nav_button(ident: str, label: str, glyph: str, extra: str = "") -> str:
|
|
1052
|
+
"""One of the sheet's three controls: a 16×16 stroked glyph with a spoken name.
|
|
1053
|
+
|
|
1054
|
+
``label`` names the keystroke as well as the action (``Next file (j)``),
|
|
1055
|
+
because a shortcut on a page with no menu has nowhere else to be found.
|
|
1056
|
+
"""
|
|
1057
|
+
return (
|
|
1058
|
+
f'<button type="button" class="iconbtn{extra}" id="{ident}" '
|
|
1059
|
+
f'title="{_esc(label)}" aria-label="{_esc(label)}">'
|
|
1060
|
+
f'<svg viewBox="0 0 16 16" aria-hidden="true"><path d="{glyph}"/></svg></button>'
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _sheet(diff: VersionDiff) -> str:
|
|
1065
|
+
"""The panel a file's diff opens into, and the scrim behind it on a narrow window.
|
|
1066
|
+
|
|
1067
|
+
One frame per page rather than one per file: the script moves the clicked
|
|
1068
|
+
file's diff into it, so what is written here is everything around that diff
|
|
1069
|
+
— the path, its counts, which two versions are being compared, and the
|
|
1070
|
+
controls that walk the list without going back to it.
|
|
1071
|
+
|
|
1072
|
+
The heading is left empty because it is filled from the row that was
|
|
1073
|
+
clicked; see the ``show`` function in :data:`JS`.
|
|
1074
|
+
"""
|
|
1075
|
+
return (
|
|
1076
|
+
'<div class="scrim" id="scrim"></div>'
|
|
1077
|
+
'<aside class="sheet" id="sheet" role="dialog" aria-labelledby="sheet-title">'
|
|
1078
|
+
'<div class="grip" id="sheet-grip" title="Drag to resize"></div>'
|
|
1079
|
+
'<header class="sheet-head"><div class="sheet-bar">'
|
|
1080
|
+
'<div class="sheet-title" id="sheet-title"></div>'
|
|
1081
|
+
'<div class="sheet-nav">'
|
|
1082
|
+
+ _nav_button("sheet-prev", "Previous file (k)", "m4.5 9.75 3.5-3.5 3.5 3.5")
|
|
1083
|
+
+ '<span class="pos" id="sheet-pos"></span>'
|
|
1084
|
+
+ _nav_button("sheet-next", "Next file (j)", "m4.5 6.25 3.5 3.5 3.5-3.5")
|
|
1085
|
+
+ _nav_button("sheet-close", "Close (Esc)", "m4.5 4.5 7 7m0-7-7 7", " close")
|
|
1086
|
+
+ "</div></div>"
|
|
1087
|
+
'<div class="sheet-sub"><span id="sheet-stat"></span>'
|
|
1088
|
+
f'<span class="ver">v{diff.a_seq:04d} → v{diff.b_seq:04d}</span>'
|
|
1089
|
+
"</div></header>"
|
|
1090
|
+
'<div class="sheet-body" id="sheet-body" tabindex="-1"></div>'
|
|
1091
|
+
"</aside>"
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
|
|
770
1095
|
def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
|
|
771
|
-
"""Render the full report as a single HTML document.
|
|
1096
|
+
"""Render the full report as a single HTML document.
|
|
1097
|
+
|
|
1098
|
+
What the reader gets is a summary they can take in without scrolling, then
|
|
1099
|
+
a list of every changed file. Clicking one opens its diff in the sheet
|
|
1100
|
+
beside the list rather than underneath it — see :func:`_sheet` — so the
|
|
1101
|
+
list keeps its place and the code gets the width it wants.
|
|
1102
|
+
"""
|
|
772
1103
|
title = f"{diff.function_name} · v{diff.a_seq:04d} → v{diff.b_seq:04d}"
|
|
773
1104
|
a_when = format_ts(diff.a_meta.get("ingested_at"))
|
|
774
1105
|
b_when = format_ts(diff.b_meta.get("ingested_at"))
|
|
@@ -784,15 +1115,24 @@ def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
|
|
|
784
1115
|
blocks.extend(
|
|
785
1116
|
_render_file(c, diff.a_root, diff.b_root) for c in row.edited_members
|
|
786
1117
|
)
|
|
787
|
-
file_blocks = "\n".join(blocks)
|
|
788
|
-
if not diff.files:
|
|
789
|
-
file_blocks = '<div class="empty">No file-level changes between these versions.</div>'
|
|
790
|
-
|
|
791
1118
|
vendor_toggle = (
|
|
792
1119
|
'<label><input type="checkbox" id="vendor" checked> show vendored files</label>'
|
|
793
1120
|
if any(c.is_vendor for c in diff.files)
|
|
794
1121
|
else ""
|
|
795
1122
|
)
|
|
1123
|
+
# Two versions with the same tree have nothing to filter and nothing to
|
|
1124
|
+
# open, so that page is one sentence: a search box above it would only
|
|
1125
|
+
# offer to narrow an empty list.
|
|
1126
|
+
toolbar = listing = ""
|
|
1127
|
+
if diff.files:
|
|
1128
|
+
toolbar = (
|
|
1129
|
+
'<div class="toolbar">'
|
|
1130
|
+
'<input type="search" id="filter" placeholder="Filter by path…" autocomplete="off">'
|
|
1131
|
+
f'{vendor_toggle}<span class="sub" id="shown-count"></span></div>'
|
|
1132
|
+
)
|
|
1133
|
+
listing = '<div class="files">{}</div>'.format("\n".join(blocks))
|
|
1134
|
+
else:
|
|
1135
|
+
listing = '<div class="empty">No file-level changes between these versions.</div>'
|
|
796
1136
|
|
|
797
1137
|
return f"""<!DOCTYPE html>
|
|
798
1138
|
<html lang="en">
|
|
@@ -801,6 +1141,7 @@ def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
|
|
|
801
1141
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
802
1142
|
<title>{_esc(title)}</title>
|
|
803
1143
|
<style>{CSS}{ICON_CSS}</style>
|
|
1144
|
+
<noscript><style>{NOSCRIPT}</style></noscript>
|
|
804
1145
|
</head>
|
|
805
1146
|
<body>
|
|
806
1147
|
{icons.sprite()}
|
|
@@ -820,20 +1161,15 @@ def render_html(diff: VersionDiff, generated_by: str = "lambda-watcher") -> str:
|
|
|
820
1161
|
{_findings_section(diff)}
|
|
821
1162
|
|
|
822
1163
|
<h2>File changes</h2>
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
{vendor_toggle}
|
|
826
|
-
<button type="button" id="expand-all">Expand all</button>
|
|
827
|
-
<button type="button" id="collapse-all">Collapse all</button>
|
|
828
|
-
<span class="sub" id="shown-count"></span>
|
|
829
|
-
</div>
|
|
830
|
-
{file_blocks}
|
|
1164
|
+
{toolbar}
|
|
1165
|
+
{listing}
|
|
831
1166
|
|
|
832
1167
|
<footer>
|
|
833
1168
|
Generated by {_esc(generated_by)} on {_esc(datetime.now().strftime('%Y-%m-%d %H:%M'))}.
|
|
834
1169
|
Content hashes ignore zip timestamps, so re-downloading unchanged code does not create a new version.
|
|
835
1170
|
</footer>
|
|
836
1171
|
</div>
|
|
1172
|
+
{_sheet(diff)}
|
|
837
1173
|
<script>{JS}</script>
|
|
838
1174
|
</body>
|
|
839
1175
|
</html>
|
lambda_watcher/gitmirror.py
CHANGED
|
@@ -4,8 +4,9 @@ This is the shortest path to a review workflow you already know: every version
|
|
|
4
4
|
is a commit tagged ``v0007``, so ``git diff v0002 v0010``, ``git log -p``, VS
|
|
5
5
|
Code's diff viewer and any git GUI all work on a single function's history
|
|
6
6
|
without ten unrelated Lambdas mixed into the same repo. It lives at
|
|
7
|
-
``
|
|
8
|
-
|
|
7
|
+
``repos/<slug>/`` — see :meth:`lambda_watcher.store.Store.repo_dir`, which also
|
|
8
|
+
moves the mirrors older releases kept inside the function directory — and
|
|
9
|
+
``lw open`` hands that folder to an editor.
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from __future__ import annotations
|
|
@@ -17,7 +18,7 @@ from dataclasses import dataclass
|
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
|
|
19
20
|
from .config import GitMirrorConfig
|
|
20
|
-
from .utils import LOG, matches_any, rmtree
|
|
21
|
+
from .utils import LOG, matches_any, no_window_kwargs, rmtree
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class GitUnavailable(RuntimeError):
|
|
@@ -83,6 +84,7 @@ def _run(repo: Path, *args: str, check: bool = True, env_extra: dict[str, str] |
|
|
|
83
84
|
text=True,
|
|
84
85
|
env=_git_env(env_extra),
|
|
85
86
|
timeout=300,
|
|
87
|
+
**no_window_kwargs(),
|
|
86
88
|
)
|
|
87
89
|
if check and proc.returncode != 0:
|
|
88
90
|
raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}")
|
lambda_watcher/notify.py
CHANGED
|
@@ -6,7 +6,7 @@ import shutil
|
|
|
6
6
|
import subprocess
|
|
7
7
|
import sys
|
|
8
8
|
|
|
9
|
-
from .utils import LOG
|
|
9
|
+
from .utils import LOG, no_window_kwargs
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def notify(title: str, message: str, enabled: bool = True) -> bool:
|
|
@@ -37,6 +37,7 @@ def notify(title: str, message: str, enabled: bool = True) -> bool:
|
|
|
37
37
|
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps],
|
|
38
38
|
capture_output=True,
|
|
39
39
|
timeout=15,
|
|
40
|
+
**no_window_kwargs(),
|
|
40
41
|
)
|
|
41
42
|
return True
|
|
42
43
|
if shutil.which("notify-send"):
|
lambda_watcher/reindex.py
CHANGED
|
@@ -95,6 +95,10 @@ def _insert(db: Database, store: Store, function_id: int, manifest: dict[str, An
|
|
|
95
95
|
with no recorded sequence number falls back to the numeric prefix of its
|
|
96
96
|
directory name, which is where the sequence came from in the first place.
|
|
97
97
|
"""
|
|
98
|
+
# back-compat: every section is fetched with a default rather than indexed,
|
|
99
|
+
# so a manifest written before that section existed rebuilds as a version
|
|
100
|
+
# missing one facet instead of raising and costing the whole archive its
|
|
101
|
+
# index. Each analyser added since shipped is one more reason this stays.
|
|
98
102
|
version_meta = manifest.get("version") or {}
|
|
99
103
|
source = manifest.get("source") or {}
|
|
100
104
|
runtime = manifest.get("runtime") or {}
|
|
@@ -103,7 +107,10 @@ def _insert(db: Database, store: Store, function_id: int, manifest: dict[str, An
|
|
|
103
107
|
|
|
104
108
|
seq = int(version_meta.get("seq") or 0)
|
|
105
109
|
if not seq:
|
|
106
|
-
#
|
|
110
|
+
# back-compat: a manifest from before the sequence number was recorded
|
|
111
|
+
# in it. The number is still in the directory name it was used to build
|
|
112
|
+
# (`0007-a1b2c3d4`), so recover it there rather than skipping the
|
|
113
|
+
# version and silently rebuilding a shorter history than the archive has.
|
|
107
114
|
seq = int(version_dir.name.split("-")[0])
|
|
108
115
|
|
|
109
116
|
with db.transaction():
|
lambda_watcher/service.py
CHANGED
|
@@ -30,7 +30,7 @@ from pathlib import Path
|
|
|
30
30
|
from typing import TypedDict
|
|
31
31
|
|
|
32
32
|
from .config import Config
|
|
33
|
-
from .utils import LOG
|
|
33
|
+
from .utils import LOG, no_window_kwargs
|
|
34
34
|
|
|
35
35
|
#: Identifiers registered with the OS. Changing either of these orphans the
|
|
36
36
|
#: service someone already installed, so they are constants, not config.
|
|
@@ -120,6 +120,11 @@ def _python_for_service() -> str:
|
|
|
120
120
|
|
|
121
121
|
A scheduled task pointed at ``python.exe`` flashes a console window at every
|
|
122
122
|
logon; ``pythonw.exe`` is the same interpreter without one.
|
|
123
|
+
|
|
124
|
+
This settles the watcher's own window and nothing else. Having no console is
|
|
125
|
+
exactly what makes Windows give a *new* one to each ``git`` and
|
|
126
|
+
``powershell`` the watcher then spawns, which is
|
|
127
|
+
:func:`lambda_watcher.utils.no_window_kwargs`' problem.
|
|
123
128
|
"""
|
|
124
129
|
executable = Path(sys.executable)
|
|
125
130
|
if sys.platform.startswith("win"):
|
|
@@ -157,7 +162,8 @@ def _run(argv: list[str], check: bool = False) -> subprocess.CompletedProcess[st
|
|
|
157
162
|
LOG.debug("service: %s", " ".join(argv))
|
|
158
163
|
try:
|
|
159
164
|
proc = subprocess.run(
|
|
160
|
-
argv, capture_output=True, text=True, timeout=_TIMEOUT, check=False
|
|
165
|
+
argv, capture_output=True, text=True, timeout=_TIMEOUT, check=False,
|
|
166
|
+
**no_window_kwargs(),
|
|
161
167
|
)
|
|
162
168
|
except FileNotFoundError as exc:
|
|
163
169
|
raise ServiceError(f"{argv[0]} is not installed") from exc
|
|
@@ -628,6 +634,9 @@ class _DetachKwargs(TypedDict, total=False):
|
|
|
628
634
|
annotation. Naming the two keys costs a class and buys a checked call.
|
|
629
635
|
|
|
630
636
|
``total=False`` because each platform returns exactly one of them.
|
|
637
|
+
|
|
638
|
+
:class:`lambda_watcher.utils.NoWindowKwargs` is the same trick for the
|
|
639
|
+
keyword that keeps a *captured* child from opening a console window.
|
|
631
640
|
"""
|
|
632
641
|
|
|
633
642
|
creationflags: int
|
lambda_watcher/store.py
CHANGED
|
@@ -33,11 +33,41 @@ from .config import Config
|
|
|
33
33
|
from .utils import LOG, rmtree, short_hash
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
#:
|
|
37
|
-
#: are migrated: ``git/`` shipped,
|
|
36
|
+
#: back-compat: where the mirror used to sit, inside the function directory.
|
|
37
|
+
#: Both spellings are migrated by :meth:`Store.repo_dir`: ``git/`` shipped,
|
|
38
|
+
#: ``repo/`` was a brief step on the way here. Goes when no archive written
|
|
39
|
+
#: before the move to ``repos/<slug>/`` can still be opened, which for a tool
|
|
40
|
+
#: people leave running unattended is not a date anyone can name.
|
|
38
41
|
LEGACY_REPO_DIRNAMES = ("git", "repo")
|
|
39
42
|
|
|
40
43
|
|
|
44
|
+
def posix_stored_dir(stored_dir: str) -> str:
|
|
45
|
+
"""A stored version-directory path with its separators made posix.
|
|
46
|
+
|
|
47
|
+
``functions\\order-processor\\versions\\0001-a1b2c3d4`` →
|
|
48
|
+
``functions/order-processor/versions/0001-a1b2c3d4``. Releases before this
|
|
49
|
+
one built the value with :func:`str` on a :class:`~pathlib.Path`, so an
|
|
50
|
+
archive first written on Windows holds backslashes — four directories there,
|
|
51
|
+
one oddly-named file anywhere else. Reading such an archive on Linux or macOS
|
|
52
|
+
finds no version directory at all, and every file in every diff comes back
|
|
53
|
+
missing.
|
|
54
|
+
|
|
55
|
+
The translation is unambiguous rather than a guess: these paths are built
|
|
56
|
+
from ``functions/``, a :func:`~lambda_watcher.utils.slugify` output (only
|
|
57
|
+
``A-Za-z0-9._-`` survives it), ``versions/`` and
|
|
58
|
+
:meth:`Store.version_dirname`'s ``NNNN-`` plus hex, so no segment can
|
|
59
|
+
legitimately contain a backslash. Written by :meth:`Store.relative`, read by
|
|
60
|
+
:meth:`Store.resolve_version_dir`.
|
|
61
|
+
|
|
62
|
+
Goes when no index written by a release that stored the platform's own
|
|
63
|
+
separator can still be in use — so, in practice, it stays.
|
|
64
|
+
"""
|
|
65
|
+
# back-compat: an index written on Windows before `Store.relative` wrote
|
|
66
|
+
# posix. Drop this and such an archive resolves no version at all: the diff
|
|
67
|
+
# still prints, with no lines in it and every file blamed for its encoding.
|
|
68
|
+
return stored_dir.replace("\\", "/")
|
|
69
|
+
|
|
70
|
+
|
|
41
71
|
@dataclass
|
|
42
72
|
class VersionPaths:
|
|
43
73
|
"""The three paths that make up one archived version directory.
|
|
@@ -108,6 +138,10 @@ class Store:
|
|
|
108
138
|
repo = self.cfg.repos_dir / slug
|
|
109
139
|
if repo.exists():
|
|
110
140
|
return repo
|
|
141
|
+
# back-compat: relocate a mirror an older release left inside the
|
|
142
|
+
# function directory. Without this the mirror reads as missing, and
|
|
143
|
+
# `lw open`, `lw git` and `lw diff --mirror` all report there is no
|
|
144
|
+
# history to show for a function that has one.
|
|
111
145
|
for name in LEGACY_REPO_DIRNAMES:
|
|
112
146
|
legacy = self.function_dir(slug) / name
|
|
113
147
|
if not (legacy / ".git").is_dir():
|
|
@@ -136,18 +170,32 @@ class Store:
|
|
|
136
170
|
return VersionPaths(self.versions_dir(slug) / self.version_dirname(seq, tree_hash))
|
|
137
171
|
|
|
138
172
|
def resolve_version_dir(self, stored_dir: str) -> Path:
|
|
139
|
-
"""Version dirs are stored relative to the root so the store can move.
|
|
140
|
-
|
|
173
|
+
"""Version dirs are stored relative to the root so the store can move.
|
|
174
|
+
|
|
175
|
+
Separators are normalised on the way in by :func:`posix_stored_dir`, so
|
|
176
|
+
an archive an older release wrote on Windows still resolves after it is
|
|
177
|
+
copied to another machine — without that, every version reads as missing
|
|
178
|
+
and every file in a diff comes back unreadable.
|
|
179
|
+
"""
|
|
180
|
+
path = Path(posix_stored_dir(stored_dir))
|
|
141
181
|
return path if path.is_absolute() else self.cfg.root / path
|
|
142
182
|
|
|
143
183
|
def relative(self, path: Path) -> str:
|
|
144
184
|
"""A path rewritten relative to the archive root, for storing and display.
|
|
145
185
|
|
|
186
|
+
Posix-separated on every platform — ``functions/order-processor/versions/
|
|
187
|
+
0001-a1b2c3d4``, never ``functions\\order-processor\\...`` — because this
|
|
188
|
+
is what goes into ``index.db`` and into ``manifest.json``, and both are
|
|
189
|
+
meant to survive the archive being copied to another machine. Windows
|
|
190
|
+
reads a ``/``-separated path perfectly well; Linux and macOS do not read
|
|
191
|
+
a ``\\``-separated one at all. :func:`posix_stored_dir` translates what
|
|
192
|
+
earlier releases already wrote.
|
|
193
|
+
|
|
146
194
|
Falls back to the absolute path when it lies outside the root, which is
|
|
147
195
|
better than raising in the middle of writing a manifest.
|
|
148
196
|
"""
|
|
149
197
|
try:
|
|
150
|
-
return
|
|
198
|
+
return path.relative_to(self.cfg.root).as_posix()
|
|
151
199
|
except ValueError:
|
|
152
200
|
return str(path)
|
|
153
201
|
|
lambda_watcher/utils.py
CHANGED
|
@@ -7,9 +7,11 @@ import hashlib
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
import subprocess
|
|
10
11
|
import unicodedata
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
13
|
from pathlib import Path, PurePosixPath
|
|
14
|
+
from typing import TypedDict
|
|
13
15
|
|
|
14
16
|
LOG = logging.getLogger("lambda_watcher")
|
|
15
17
|
|
|
@@ -362,6 +364,50 @@ def rmtree(path: Path) -> None:
|
|
|
362
364
|
shutil.rmtree(path, onerror=_onerror)
|
|
363
365
|
|
|
364
366
|
|
|
367
|
+
class NoWindowKwargs(TypedDict, total=False):
|
|
368
|
+
"""The ``subprocess`` keyword that suppresses a child's console window.
|
|
369
|
+
|
|
370
|
+
A ``dict[str, int]`` would say the same thing at runtime and nothing at all
|
|
371
|
+
to a type checker: splatting one into ``subprocess.run`` makes every keyword
|
|
372
|
+
it accepts look like it might be receiving an ``int``. Naming the one key
|
|
373
|
+
costs a class and buys a checked call — the same trade, and the same shape,
|
|
374
|
+
as :class:`lambda_watcher.service._DetachKwargs`.
|
|
375
|
+
|
|
376
|
+
``total=False`` because every platform but Windows contributes no keys.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
creationflags: int
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def no_window_kwargs() -> NoWindowKwargs:
|
|
383
|
+
"""``subprocess`` keywords that stop a child flashing a console window open.
|
|
384
|
+
|
|
385
|
+
On Windows a console program inherits its parent's console, and is handed a
|
|
386
|
+
brand new one when the parent has none. The watcher deliberately has none —
|
|
387
|
+
``pythonw.exe``, ``DETACHED_PROCESS``, a hidden scheduled task; see
|
|
388
|
+
:func:`lambda_watcher.service.watch_argv` — so every ``git`` and
|
|
389
|
+
``powershell`` it shells out to during an ingest opens a console window on
|
|
390
|
+
screen for as long as it runs. Archiving one new version fires five git
|
|
391
|
+
commands and a notification, so a single downloaded zip reads to the user as
|
|
392
|
+
a burst of black rectangles flickering over whatever they were doing.
|
|
393
|
+
|
|
394
|
+
``CREATE_NO_WINDOW`` is what suppresses that. Capturing the child's output
|
|
395
|
+
does not: the console is allocated at process creation whatever the handles
|
|
396
|
+
later point at, which is why ``capture_output=True`` at every one of those
|
|
397
|
+
call sites never helped.
|
|
398
|
+
|
|
399
|
+
Empty on every other platform, since the flag is Windows-only, so callers
|
|
400
|
+
splat it unconditionally rather than branching on the platform themselves.
|
|
401
|
+
|
|
402
|
+
Only for children whose output is captured. A command that inherits the
|
|
403
|
+
user's terminal on purpose — :func:`lambda_watcher.gitmirror.passthrough`,
|
|
404
|
+
which backs ``lw git my-fn log`` — must *not* pass this: no window means no
|
|
405
|
+
pager and no colour for a command the user typed and is watching.
|
|
406
|
+
"""
|
|
407
|
+
flag = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
408
|
+
return {"creationflags": flag} if flag else {}
|
|
409
|
+
|
|
410
|
+
|
|
365
411
|
def setup_logging(level: str = "INFO", log_file: Path | None = None) -> logging.Logger:
|
|
366
412
|
"""Point the shared ``lambda_watcher`` logger at a level and optional file.
|
|
367
413
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lambda-watcher
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions.
|
|
5
5
|
Author: Lambda Watcher contributors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
lambda_watcher/__init__.py,sha256=
|
|
1
|
+
lambda_watcher/__init__.py,sha256=_mZZjOdLL-dBMHCC3OYWDP0nYkLcuzgdBW9PdgOvEs0,133
|
|
2
2
|
lambda_watcher/__main__.py,sha256=vsLRaYJej6D2A1T-YVbqSoA3fnvOUkJo7YmPWQLesO0,95
|
|
3
|
-
lambda_watcher/cli.py,sha256=
|
|
3
|
+
lambda_watcher/cli.py,sha256=KuZ8SfxRqP5yTVPEoEYi6Kux1NifGMnfmEQdXSDbn44,66815
|
|
4
4
|
lambda_watcher/config.py,sha256=M5nVaR4pwvg25i1vRvowPsdwNNo8tyf0YiEeGBdUuIg,17048
|
|
5
5
|
lambda_watcher/db.py,sha256=gJMmEhKIN4Gba26-u5blk7CydxiCqmUfBnpE6Jdy3Tk,25359
|
|
6
6
|
lambda_watcher/extract.py,sha256=_xMyGEdprooleMI2i6uTcl7fpRkuJcu3916PGLlKAbQ,9094
|
|
7
|
-
lambda_watcher/gitmirror.py,sha256=
|
|
7
|
+
lambda_watcher/gitmirror.py,sha256=zuzOM3WDCfmWfs11GzqRMuvU1m1dP5xTaEw4ynFs2JM,9917
|
|
8
8
|
lambda_watcher/identify.py,sha256=8YyMEq4tyb-ksQkbKuYKB53mcdcbnpcdm8L6CPuFmdQ,7903
|
|
9
9
|
lambda_watcher/ingest.py,sha256=0fmlPfV4Pz6aMFyQLN_yi9fSPvEd6I4AJVK16Y2ClNQ,22813
|
|
10
|
-
lambda_watcher/notify.py,sha256=
|
|
11
|
-
lambda_watcher/reindex.py,sha256=
|
|
12
|
-
lambda_watcher/service.py,sha256=
|
|
13
|
-
lambda_watcher/store.py,sha256=
|
|
10
|
+
lambda_watcher/notify.py,sha256=VB_N4V1TNQhF9_71jq-FtVX9ydRms5FudAtT-ngFGpc,2853
|
|
11
|
+
lambda_watcher/reindex.py,sha256=q24GYCL39UOwhy0SURCJO6PHYmVKI6XyZIMUVnfCHZ0,7763
|
|
12
|
+
lambda_watcher/service.py,sha256=JaJr46Ep45u-Ix4gSG-FYYnavZHRuXf246rLbHpDcQE,34366
|
|
13
|
+
lambda_watcher/store.py,sha256=keU3S9fsyLwyprCM_Xc3YAoe3vZkJBmg8UW7IlVo-5Y,12952
|
|
14
14
|
lambda_watcher/templates.py,sha256=H65OWVZODnKGmD73BRg5FYQQ5t5b1coC2AuZ-mU1Oi4,6638
|
|
15
|
-
lambda_watcher/utils.py,sha256=
|
|
15
|
+
lambda_watcher/utils.py,sha256=CK7SF1YDuT5hHQnf636PUvGPyK1bwWjAdBEExpbL_0g,16655
|
|
16
16
|
lambda_watcher/watcher.py,sha256=fdAiFRKqkJotlcrsVHhRmWjHKK1iL3duxtNTa3abyzU,13553
|
|
17
17
|
lambda_watcher/analysis/__init__.py,sha256=eRlG_pwfHwNDgv0hi26YXOYTFX9cVO-zOKxwMFsz79E,6237
|
|
18
18
|
lambda_watcher/analysis/deps.py,sha256=1UBwqfI8Ju4z8-orqVfCbhzc76fW-tCXNoYcNZZUrdo,15143
|
|
@@ -24,15 +24,15 @@ lambda_watcher/analysis/secrets.py,sha256=LPhC6vqSdoNYhk1erzeUi-l4m1jEbrFVbiMvz4
|
|
|
24
24
|
lambda_watcher/analysis/services.py,sha256=O-j6pmx-RWUTCR5mIXT0Fm2svp3tbhjMXMi1sn4gdfQ,3452
|
|
25
25
|
lambda_watcher/diffing/__init__.py,sha256=oAqyVDL7Qvl8ZwyU40XuC5jSEqe7S522YJrcm7khO9g,324
|
|
26
26
|
lambda_watcher/diffing/build.py,sha256=DaznYxgpvtWrDvCTY2rLRkENsOzI7uIcFic3aqiCzPY,1938
|
|
27
|
-
lambda_watcher/diffing/compare.py,sha256=
|
|
27
|
+
lambda_watcher/diffing/compare.py,sha256=UThw2OrjlNxrMr3cjKPXUFJT7GrjWHcgNaDXmUwmtXY,47703
|
|
28
28
|
lambda_watcher/diffing/highlight.py,sha256=-ez73AKhFG96YWKcwGReuaNWsDPnPQUJNkPR7Q48frU,14920
|
|
29
29
|
lambda_watcher/diffing/icons.py,sha256=aPPHEmwC0SpiC_pIhhMVZ3hn9nlyz6kXf_2l7IKX8LA,6144
|
|
30
30
|
lambda_watcher/diffing/intraline.py,sha256=xndL6x2zNY86xmdHLypKb3o31BwOJESMG5yK3nKvkXI,13954
|
|
31
|
-
lambda_watcher/diffing/render_html.py,sha256=
|
|
31
|
+
lambda_watcher/diffing/render_html.py,sha256=ls8Lk37iAVGY1BY-WTtm0c3IXxEL7KwoqKxgMC2pKIk,57898
|
|
32
32
|
lambda_watcher/diffing/render_text.py,sha256=_zfILEMSAD3qU7nX2fKm7oJXS7AMxOnKqCt7YWWMrEc,16794
|
|
33
|
-
lambda_watcher-0.
|
|
34
|
-
lambda_watcher-0.
|
|
35
|
-
lambda_watcher-0.
|
|
36
|
-
lambda_watcher-0.
|
|
37
|
-
lambda_watcher-0.
|
|
38
|
-
lambda_watcher-0.
|
|
33
|
+
lambda_watcher-0.3.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
34
|
+
lambda_watcher-0.3.0.dist-info/METADATA,sha256=gXchMnwXprT3PMapEOdEIAZmr1eAlM2Ce1H_cZ_SuLI,20876
|
|
35
|
+
lambda_watcher-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
36
|
+
lambda_watcher-0.3.0.dist-info/entry_points.txt,sha256=4I-7UgtYETyEeBYbQ2dl3iEQTsPotmJiCeaaxNf-hlA,88
|
|
37
|
+
lambda_watcher-0.3.0.dist-info/top_level.txt,sha256=wBObv-hwzhX_dVP1bB4-2OAyUZ2oQM7ZNROzMyRhSnI,15
|
|
38
|
+
lambda_watcher-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|