lambda-watcher 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/PKG-INFO +1 -1
  2. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/pyproject.toml +1 -1
  3. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/__init__.py +1 -1
  4. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/cli.py +30 -6
  5. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/compare.py +42 -5
  6. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/render_html.py +6 -0
  7. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/gitmirror.py +5 -3
  8. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/notify.py +2 -1
  9. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/reindex.py +8 -1
  10. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/service.py +11 -2
  11. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/store.py +53 -5
  12. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/utils.py +46 -0
  13. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/PKG-INFO +1 -1
  14. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/SOURCES.txt +1 -0
  15. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_cli.py +47 -0
  16. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_diff.py +45 -0
  17. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_ingest.py +30 -0
  18. lambda_watcher-0.2.1/tests/test_no_window.py +87 -0
  19. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/LICENSE +0 -0
  20. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/README.md +0 -0
  21. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/setup.cfg +0 -0
  22. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/__main__.py +0 -0
  23. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/__init__.py +0 -0
  24. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/deps.py +0 -0
  25. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/envvars.py +0 -0
  26. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/handler.py +0 -0
  27. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/inventory.py +0 -0
  28. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/runtime.py +0 -0
  29. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/secrets.py +0 -0
  30. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/services.py +0 -0
  31. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/config.py +0 -0
  32. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/db.py +0 -0
  33. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/__init__.py +0 -0
  34. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/build.py +0 -0
  35. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/highlight.py +0 -0
  36. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/icons.py +0 -0
  37. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/intraline.py +0 -0
  38. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/render_text.py +0 -0
  39. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/extract.py +0 -0
  40. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/identify.py +0 -0
  41. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/ingest.py +0 -0
  42. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/templates.py +0 -0
  43. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/watcher.py +0 -0
  44. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/dependency_links.txt +0 -0
  45. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/entry_points.txt +0 -0
  46. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/requires.txt +0 -0
  47. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/top_level.txt +0 -0
  48. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_analysis.py +0 -0
  49. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_docs.py +0 -0
  50. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_extract.py +0 -0
  51. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_identify.py +0 -0
  52. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_render_html.py +0 -0
  53. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_service.py +0 -0
  54. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_utils.py +0 -0
  55. {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_watcher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-watcher
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lambda-watcher"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Watch your Downloads folder for AWS Lambda deployment zips, archive them as versions, analyse them, and diff any two versions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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.2.1"
4
4
  __all__ = ["__version__"]
@@ -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:
@@ -482,6 +482,12 @@ def _render_file(change: FileChange, a_root: Path | None = None, b_root: Path |
482
482
  # check.
483
483
  note += (' Indentation, line endings or blank lines only — '
484
484
  '<code>lw diff --whitespace</code> shows it anyway.')
485
+ elif change.missing:
486
+ # The one reason here that is not about the file: nothing is wrong
487
+ # with it, the archive just does not have it where the index says.
488
+ note += (' The index lists this file but the version directory does not '
489
+ 'hold it — <code>lw reindex</code> rebuilds the index from what '
490
+ 'is on disk.')
485
491
  parts.append(f'<div class="note">{note}</div>')
486
492
 
487
493
  parts.append("</details>")
@@ -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()}")
@@ -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"):
@@ -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():
@@ -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
@@ -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
 
@@ -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.2.1
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
@@ -46,6 +46,7 @@ tests/test_docs.py
46
46
  tests/test_extract.py
47
47
  tests/test_identify.py
48
48
  tests/test_ingest.py
49
+ tests/test_no_window.py
49
50
  tests/test_render_html.py
50
51
  tests/test_service.py
51
52
  tests/test_utils.py
@@ -547,3 +547,50 @@ def test_commands_leave_no_open_database_handles(archived: Path):
547
547
  cli_result = _run(*args)
548
548
  assert cli_result.exit_code == 0
549
549
  assert not cli._OPEN_DBS, f"`{' '.join(args)}` left the index open"
550
+
551
+
552
+ # ------------------------------------------- an archive that came from another machine
553
+ # Everything below is one bug seen from two sides: `versions.dir` used to be stored
554
+ # with the platform's own separator, so an index written on Windows named nothing at
555
+ # all anywhere else. The diff still printed - the index knows which files changed -
556
+ # but with no line in it, and every file labelled as though its bytes were the
557
+ # problem. `lw rename` had the same crack: it patched the stored path as a string
558
+ # and the `functions/<slug>/` it looked for was never there to be replaced.
559
+ def _windowsify(archive: Path) -> list[str]:
560
+ """Rewrite every stored version dir the way an older release on Windows would."""
561
+ import sqlite3
562
+
563
+ conn = sqlite3.connect(archive / "index.db")
564
+ with conn:
565
+ for vid, stored in conn.execute("SELECT id, dir FROM versions").fetchall():
566
+ conn.execute("UPDATE versions SET dir = ? WHERE id = ?",
567
+ (stored.replace("/", "\\"), vid))
568
+ dirs = [row[0] for row in conn.execute("SELECT dir FROM versions")]
569
+ conn.close()
570
+ return dirs
571
+
572
+
573
+ def test_an_index_written_on_windows_still_diffs_here(archived: Path):
574
+ assert all("\\" in d for d in _windowsify(archived))
575
+ output = _run("diff", "order-processor").output
576
+ assert "QUEUE_URL" in output # the index half, which never broke
577
+ assert "sqs.send_message" in output # the line diff, which did
578
+ assert "missing from the archive" not in output
579
+
580
+
581
+ def test_rename_repoints_an_index_written_on_windows(archived: Path):
582
+ _windowsify(archived)
583
+ _run("rename", "order-processor", "OrderProcessorProd")
584
+ assert (archived / "functions" / "OrderProcessorProd").exists()
585
+ output = _run("diff", "OrderProcessorProd").output
586
+ assert "sqs.send_message" in output
587
+ assert "missing from the archive" not in output
588
+
589
+
590
+ def test_a_deleted_version_directory_says_what_to_type(archived: Path):
591
+ """The archive really is incomplete — so say so, and name the command that fixes it."""
592
+ for version_dir in (archived / "functions" / "order-processor" / "versions").glob("0001-*"):
593
+ rmtree(version_dir / "code")
594
+ result = _run("diff", "order-processor")
595
+ assert "missing from the archive" in result.output
596
+ assert "lw reindex" in result.output
@@ -706,3 +706,48 @@ def test_the_html_report_shows_the_changed_runs(cfg, db, ingestor: Ingestor, mak
706
706
  assert "diffed by word" in html
707
707
  assert "lw diff --whitespace" in html # every note names the way out
708
708
  assert '<div class="diff">' not in html # neither file got a line table
709
+
710
+
711
+ # ------------------------------------------- an absent file is not an encoding problem
712
+ # `read_text` answers None both for bytes that are not text and for a file it could
713
+ # not open, and the diff used to call every one of them "not decodable as text". The
714
+ # index is derived from disk, so in practice that message only ever meant the file
715
+ # was gone - and it sent the reader hunting for an encoding that was never wrong.
716
+ def test_a_file_the_archive_no_longer_holds_is_named_missing(cfg, db, ingestor: Ingestor,
717
+ make_zip):
718
+ ingestor.ingest(make_zip("fn.zip", {"lambda_function.py": PY_V1}))
719
+ ingestor.ingest(make_zip("fn.zip", {"lambda_function.py": PY_V2}))
720
+
721
+ store = Store(cfg)
722
+ first = db.get_version(int(db.get_function("fn")["id"]), 1)
723
+ (store.resolve_version_dir(first["dir"]) / "code" / "lambda_function.py").unlink()
724
+
725
+ change = next(c for c in _diff(cfg, db, ingestor).files if c.path == "lambda_function.py")
726
+ assert change.missing
727
+ assert change.skipped_reason == "missing from the archive"
728
+ assert change.line_count_note == "missing from the archive"
729
+ assert not change.diff_lines
730
+
731
+
732
+ def test_a_readable_file_is_never_reported_as_missing(cfg, db, ingestor: Ingestor, make_zip):
733
+ """The other half of the pair: a file that is there still diffs as it always did."""
734
+ ingestor.ingest(make_zip("fn.zip", {"lambda_function.py": PY_V1}))
735
+ ingestor.ingest(make_zip("fn.zip", {"lambda_function.py": PY_V2}))
736
+
737
+ change = next(c for c in _diff(cfg, db, ingestor).files if c.path == "lambda_function.py")
738
+ assert not change.missing
739
+ assert change.skipped_reason is None
740
+ assert change.diff_lines
741
+
742
+
743
+ def test_the_missing_file_is_explained_in_the_html_report(cfg, db, ingestor: Ingestor, make_zip):
744
+ ingestor.ingest(make_zip("fn.zip", {"lambda_function.py": PY_V1}))
745
+ ingestor.ingest(make_zip("fn.zip", {"lambda_function.py": PY_V2}))
746
+
747
+ store = Store(cfg)
748
+ first = db.get_version(int(db.get_function("fn")["id"]), 1)
749
+ (store.resolve_version_dir(first["dir"]) / "code" / "lambda_function.py").unlink()
750
+
751
+ html = render_html(_diff(cfg, db, ingestor))
752
+ assert "missing from the archive" in html
753
+ assert "lw reindex" in html
@@ -199,3 +199,33 @@ def test_downloads_at_different_refs_group_into_one_project(ingestor: Ingestor,
199
199
  second = ingestor.ingest(make_zip("myrepo-1.3.0.zip", {"myrepo-1.3.0/app.py": PY_V2}))
200
200
  assert (first.function_name, first.seq) == ("myrepo", 1)
201
201
  assert (second.function_name, second.seq) == ("myrepo", 2)
202
+
203
+
204
+ # ------------------------------------------- the index has to outlive the machine
205
+ # `versions.dir` is stored relative to the archive root so the whole archive can be
206
+ # copied elsewhere. That only works if the separator is one every platform reads:
207
+ # Windows resolves `functions/fn/versions/0001-a1b2c3d4` perfectly well, while Linux
208
+ # and macOS read `functions\fn\versions\0001-a1b2c3d4` as one oddly-named file and
209
+ # find no version at all.
210
+ def test_the_stored_version_dir_is_posix_on_every_platform(ingestor: Ingestor, make_zip, db):
211
+ ingestor.ingest(make_zip("fn.zip", {"lambda_function.py": PY_V1}))
212
+ row = db.list_versions(int(db.get_function("fn")["id"]))[0]
213
+ assert "\\" not in row["dir"]
214
+ assert row["dir"].startswith("functions/fn/versions/")
215
+
216
+
217
+ def test_a_version_dir_written_on_windows_still_resolves(cfg, db, ingestor: Ingestor, make_zip):
218
+ """An archive an older release wrote on Windows, opened anywhere else."""
219
+ from lambda_watcher.store import Store
220
+
221
+ result = ingestor.ingest(make_zip("fn.zip", {"lambda_function.py": PY_V1}))
222
+ row = db.list_versions(int(db.get_function("fn")["id"]))[0]
223
+ db.conn.execute(
224
+ "UPDATE versions SET dir = ? WHERE id = ?",
225
+ (row["dir"].replace("/", "\\"), row["id"]),
226
+ )
227
+ reread = db.list_versions(int(db.get_function("fn")["id"]))[0]
228
+
229
+ resolved = Store(cfg).resolve_version_dir(reread["dir"])
230
+ assert resolved == result.version_dir
231
+ assert (resolved / "code" / "lambda_function.py").is_file()
@@ -0,0 +1,87 @@
1
+ """Nothing the background watcher spawns may flash a console window on Windows.
2
+
3
+ The watcher runs with no console of its own, so Windows hands a brand new one to
4
+ every console program it starts unless ``CREATE_NO_WINDOW`` says otherwise. The
5
+ symptom is only visible on Windows and only while a zip is being archived, so
6
+ these tests fake the flag rather than the platform: that keeps the wiring pinned
7
+ on the Linux leg, where it would otherwise go unchecked until a user complained.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import sys
14
+
15
+ import pytest
16
+
17
+ from lambda_watcher import gitmirror, notify as notify_mod, service
18
+ from lambda_watcher.utils import no_window_kwargs
19
+
20
+ WINDOWS_FLAG = 0x08000000
21
+
22
+
23
+ @pytest.fixture
24
+ def windows_flag(monkeypatch):
25
+ """Give this interpreter the flag that only the Windows build defines."""
26
+ monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", WINDOWS_FLAG, raising=False)
27
+ return WINDOWS_FLAG
28
+
29
+
30
+ @pytest.fixture
31
+ def recorded(monkeypatch):
32
+ """Record the keywords of every ``subprocess.run`` and start nothing.
33
+
34
+ Every module here does a plain ``import subprocess``, so patching the one
35
+ module object covers all of them.
36
+ """
37
+ calls: list[dict] = []
38
+
39
+ def fake_run(argv, **kwargs):
40
+ """Stand in for a command that succeeded silently."""
41
+ calls.append(kwargs)
42
+ return subprocess.CompletedProcess(argv, 0, "", "")
43
+
44
+ monkeypatch.setattr(subprocess, "run", fake_run)
45
+ return calls
46
+
47
+
48
+ def test_the_flag_is_only_asked_for_where_it_exists(monkeypatch):
49
+ monkeypatch.delattr(subprocess, "CREATE_NO_WINDOW", raising=False)
50
+ assert no_window_kwargs() == {}
51
+
52
+
53
+ def test_the_flag_is_passed_through_when_the_platform_has_it(windows_flag):
54
+ assert no_window_kwargs() == {"creationflags": windows_flag}
55
+
56
+
57
+ @pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows-only flag")
58
+ def test_real_windows_suppresses_the_window():
59
+ assert no_window_kwargs()["creationflags"] == subprocess.CREATE_NO_WINDOW
60
+
61
+
62
+ def test_the_git_mirror_never_opens_a_console(tmp_path, windows_flag, recorded):
63
+ # Archiving one version runs five of these back to back.
64
+ gitmirror._run(tmp_path, "status", "--porcelain", check=False)
65
+ assert recorded[0]["creationflags"] == windows_flag
66
+
67
+
68
+ def test_the_toast_notification_never_opens_a_console(
69
+ monkeypatch, windows_flag, recorded
70
+ ):
71
+ monkeypatch.setattr(sys, "platform", "win32")
72
+ assert notify_mod.notify("lambda-watcher", "order-processor v3")
73
+ assert recorded[0]["creationflags"] == windows_flag
74
+
75
+
76
+ def test_the_service_manager_never_opens_a_console(windows_flag, recorded):
77
+ service._run(["schtasks", "/Query", "/TN", "lambda-watcher"])
78
+ assert recorded[0]["creationflags"] == windows_flag
79
+
80
+
81
+ def test_the_interactive_git_passthrough_keeps_its_terminal(
82
+ tmp_path, windows_flag, recorded
83
+ ):
84
+ # `lw git my-fn log` is watched by the person who typed it: suppressing the
85
+ # window would take the pager and the colour with it.
86
+ gitmirror.passthrough(tmp_path, ["log", "--oneline"])
87
+ assert "creationflags" not in recorded[0]
File without changes
File without changes
File without changes