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.
@@ -1,4 +1,4 @@
1
1
  """Watch a downloads folder for AWS Lambda deployment packages and version them."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
4
4
  __all__ = ["__version__"]
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 the usual cause.
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"].replace(f"functions/{old_slug}/", f"functions/{new_slug}/", 1)
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 / Path(version["dir"]).name
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 absence rather than imply
60
- nothing changed. Two of those absences are not refusals but better answers:
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``, or not decodable — and ``skipped_reason`` says which.
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.skipped_reason = "not decodable as text"
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: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;
134
- position: sticky; top: 0; background: var(--bg); padding: 10px 0; z-index: 5;
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 220px; min-width: 140px; padding: 6px 10px; border-radius: 6px;
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
- /* ---- one file -------------------------------------------------------- */
147
- details.file { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px;
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
- details.file[open] { background: var(--bg); }
150
- details.file > summary { cursor: pointer; padding: 8px 12px; display: flex; gap: 10px;
151
- align-items: center; list-style: none; font-size: 13px; border-radius: 7px; }
152
- details.file > summary::-webkit-details-marker { display: none; }
153
- details.file > summary:hover { background: var(--panel); }
154
- /* The header stays put while a long file scrolls past, so the path is still
155
- readable eighty lines into its own diff. */
156
- details.file[open] > summary { position: sticky; top: var(--toolbar-h, 54px); z-index: 3;
157
- background: var(--panel);
158
- border-bottom: 1px solid var(--border); border-radius: 7px 7px 0 0; }
159
- summary .path { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
160
- summary .path .p { font-family: var(--mono); font-size: 12.5px; overflow-wrap: anywhere; }
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
- summary .path .ren { background: var(--sunken); border-radius: 3px; }
164
- summary .path .was { color: var(--faint); }
165
- summary .stat-line { font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap;
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 files = Array.prototype.slice.call(document.querySelectorAll('details.file'));
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 || '').toLowerCase();
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
- if (ok) shown += parseInt(el.getAttribute('data-files') || '1', 10);
442
+ el.classList.toggle('first-shown', ok && first);
443
+ total += covers;
444
+ if (ok) { shown += covers; first = false; }
246
445
  });
247
- var counter = document.getElementById('shown-count');
248
- if (counter) counter.textContent = shown + ' of ' + files.length + ' files shown';
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
- if (search) search.addEventListener('input', apply);
252
- if (vendorToggle) vendorToggle.addEventListener('change', apply);
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
- var toolbar = document.querySelector('.toolbar');
255
- function measure() {
256
- if (!toolbar) return;
257
- document.documentElement.style.setProperty('--toolbar-h', toolbar.offsetHeight + 'px');
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 titled block with its diff table.
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 block is collapsed until someone opens it, so a summary row with
427
- # no ``+`` and no ``−`` is all most readers ever see of this file. The
428
- # note is the difference between "measured differently" and "unchanged".
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": "\u2212"}.get(row.css, "")
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
- parts.append("</details>")
488
- return "\n".join(parts)
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: ``data-path`` holds
539
- every member path so the filter box still finds one by name, and
540
- ``data-vendor`` is set only when the whole move is vendored, which is the
541
- dependency-bump case (``boto3-1.{34.0 → 35.20}.dist-info/``).
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
- ``data-files`` is how many files this block is the *only* entry for, so the
544
- "N of M files shown" counter stays a count of files. The edited members are
545
- left out of it because they follow as blocks of their own and would
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
- f'<details class="file" data-path="{_esc(searchable)}" '
578
- f'data-vendor="{1 if group.is_vendor else 0}" '
579
- f'data-files="{group.moved - group.edited}">'
580
- "<summary>"
581
- '<span class="chip renamed">moved</span>'
582
- f'<span class="path">{icons.file_icon(group.new_dir + "/", "text")}'
583
- f'<span class="p">{title}</span></span>'
584
- f'<span class="stat-line">{stat}</span>'
585
- "</summary>"
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
- <div class="toolbar">
824
- <input type="search" id="filter" placeholder="Filter by path…" autocomplete="off">
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>
@@ -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
- ``functions/<slug>/repo/``, and ``lambda-watcher open`` hands that folder to an
8
- editor.
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
- # Fall back to the numeric prefix of the directory name.
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
- #: Where the mirror used to sit, inside the function directory. Both spellings
37
- #: are migrated: ``git/`` shipped, ``repo/`` was a brief step on the way here.
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
- path = Path(stored_dir)
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 str(path.relative_to(self.cfg.root))
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.2.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=tT0uJbE-dB70mE8yUOIcaFOfw2siK2GZzoi8sB2koLY,133
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=Z2d6qMnt141yTKPI3PhGGELWdmZjYb9u_Vd-tH2ZQ5g,65266
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=5BFLy5-it3p7R4mjOGLIWUhLYCeKwqy58COXShjNaUM,9755
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=bnsc8Gjs26GgMsPE_Nx13dvcRZDn3zdwjoRLoyKZo0g,2797
11
- lambda_watcher/reindex.py,sha256=YeQKSjVgMKvieLDZIUlMnkzCXHbVQ_wb_benXLCfq94,7200
12
- lambda_watcher/service.py,sha256=vZMZVWVaPcnr3LLnzB66MFtFB2o6CmUTCO8QU3YrZoQ,33901
13
- lambda_watcher/store.py,sha256=jmGn9mFxsim48TBcgeLJRGtRl7ReCbSO_qP4PtLCcE0,10168
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=h7WuJs5TnOmBm7WxLsx0Wn8kNR2nzxcSikZ2F8wa10I,14409
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=arvcDdZjVpIJ3D38ksokxoAAkM-VFpTOBDRsM54or1s,45544
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=z57OIqT-td42ZYandzyzYVd8aNncXBmxIWcUhUG3YzQ,41933
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.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
34
- lambda_watcher-0.2.0.dist-info/METADATA,sha256=UvbVE-tk9sON2nkHo2mkrLY4VkSN8ylyvQaMYxA9g78,20876
35
- lambda_watcher-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
36
- lambda_watcher-0.2.0.dist-info/entry_points.txt,sha256=4I-7UgtYETyEeBYbQ2dl3iEQTsPotmJiCeaaxNf-hlA,88
37
- lambda_watcher-0.2.0.dist-info/top_level.txt,sha256=wBObv-hwzhX_dVP1bB4-2OAyUZ2oQM7ZNROzMyRhSnI,15
38
- lambda_watcher-0.2.0.dist-info/RECORD,,
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,,