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.
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/PKG-INFO +1 -1
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/pyproject.toml +1 -1
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/__init__.py +1 -1
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/cli.py +30 -6
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/compare.py +42 -5
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/render_html.py +6 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/gitmirror.py +5 -3
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/notify.py +2 -1
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/reindex.py +8 -1
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/service.py +11 -2
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/store.py +53 -5
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/utils.py +46 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/PKG-INFO +1 -1
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/SOURCES.txt +1 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_cli.py +47 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_diff.py +45 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_ingest.py +30 -0
- lambda_watcher-0.2.1/tests/test_no_window.py +87 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/LICENSE +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/README.md +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/setup.cfg +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/__main__.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/__init__.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/deps.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/envvars.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/handler.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/inventory.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/runtime.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/secrets.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/analysis/services.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/config.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/db.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/__init__.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/build.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/highlight.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/icons.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/intraline.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/diffing/render_text.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/extract.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/identify.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/ingest.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/templates.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher/watcher.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/dependency_links.txt +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/entry_points.txt +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/requires.txt +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/top_level.txt +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_analysis.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_docs.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_extract.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_identify.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_render_html.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_service.py +0 -0
- {lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/tests/test_utils.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -11,7 +11,7 @@ import sys
|
|
|
11
11
|
import webbrowser
|
|
12
12
|
import zipfile
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
|
-
from pathlib import Path
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
15
|
from typing import NoReturn, Optional
|
|
16
16
|
|
|
17
17
|
import typer
|
|
@@ -36,7 +36,7 @@ from .service import (
|
|
|
36
36
|
install_service,
|
|
37
37
|
stop_service,
|
|
38
38
|
)
|
|
39
|
-
from .store import Store
|
|
39
|
+
from .store import Store, posix_stored_dir
|
|
40
40
|
from .utils import format_ts, human_size, relative_ts, rmtree, setup_logging, slugify
|
|
41
41
|
|
|
42
42
|
#: Index connections opened by the running command; see `_close_open_dbs`.
|
|
@@ -187,6 +187,19 @@ def _version_or_fail(db: Database, function_id: int, seq: int):
|
|
|
187
187
|
return row
|
|
188
188
|
|
|
189
189
|
|
|
190
|
+
def _stored_dirname(stored_dir: str) -> str:
|
|
191
|
+
"""The last segment of a version's stored path: ``0007-a1b2c3d4``.
|
|
192
|
+
|
|
193
|
+
Goes through :func:`~lambda_watcher.store.posix_stored_dir` first, so it
|
|
194
|
+
reads the same answer out of a value an older release wrote on Windows —
|
|
195
|
+
where ``Path(...).name`` on Linux returns the whole backslash-joined string
|
|
196
|
+
instead of the directory name, and the caller then builds a path to nothing.
|
|
197
|
+
"""
|
|
198
|
+
# back-compat: `posix_stored_dir` is the whole point of this helper existing
|
|
199
|
+
# rather than callers reaching for `Path(...).name` themselves.
|
|
200
|
+
return PurePosixPath(posix_stored_dir(stored_dir)).name
|
|
201
|
+
|
|
202
|
+
|
|
190
203
|
def _build_diff(db: Database, store: Store, cfg: Config, function_row, a_seq: int, b_seq: int,
|
|
191
204
|
include_vendor: bool | None, compute_diffs: bool = True):
|
|
192
205
|
"""Resolve two versions and build the diff between them.
|
|
@@ -194,7 +207,10 @@ def _build_diff(db: Database, store: Store, cfg: Config, function_row, a_seq: in
|
|
|
194
207
|
Warns, rather than fails, when a version's extracted code is missing from
|
|
195
208
|
disk: the index still knows what changed at the file level, so the summary
|
|
196
209
|
and dependency layers are worth printing even though the line diffs come out
|
|
197
|
-
empty. Deleting an archive directory by hand is
|
|
210
|
+
empty. Deleting an archive directory by hand is one cause; an index pointing
|
|
211
|
+
somewhere the directory no longer is, after the archive was moved or copied
|
|
212
|
+
from another machine, is the other — which is why the warning names
|
|
213
|
+
``lw reindex``, the command that rebuilds the index from what is on disk.
|
|
198
214
|
"""
|
|
199
215
|
a = _version_or_fail(db, function_row["id"], a_seq)
|
|
200
216
|
b = _version_or_fail(db, function_row["id"], b_seq)
|
|
@@ -203,7 +219,8 @@ def _build_diff(db: Database, store: Store, cfg: Config, function_row, a_seq: in
|
|
|
203
219
|
if not path.exists():
|
|
204
220
|
err_console.print(
|
|
205
221
|
f"[yellow]warning:[/yellow] code for v{seq:04d} is missing at {path}; "
|
|
206
|
-
"line diffs for it will be empty"
|
|
222
|
+
"line diffs for it will be empty. If the archive was moved or copied "
|
|
223
|
+
"here, `lw reindex` re-points the index at what is on disk."
|
|
207
224
|
)
|
|
208
225
|
return diff_from_index(db, store, cfg.diff, function_row["name"], a, b,
|
|
209
226
|
include_vendor=include_vendor, compute_diffs=compute_diffs)
|
|
@@ -1128,8 +1145,15 @@ def rename(
|
|
|
1128
1145
|
if new_dir.exists():
|
|
1129
1146
|
_fail(f"{new_dir} already exists on disk; move it aside first")
|
|
1130
1147
|
old_dir.rename(new_dir)
|
|
1148
|
+
# back-compat: re-point the index by rebuilding each path from where the
|
|
1149
|
+
# directory now is, rather than by patching the stored string. A
|
|
1150
|
+
# `functions/<old slug>/` replacement silently matched nothing in an
|
|
1151
|
+
# archive an older release wrote on Windows, where the separator is a
|
|
1152
|
+
# backslash — and a version left pointing at the pre-rename path reads
|
|
1153
|
+
# as missing from then on, which is a diff with no lines in it.
|
|
1154
|
+
new_versions = store.versions_dir(new_slug)
|
|
1131
1155
|
for version in db.list_versions(int(row["id"])):
|
|
1132
|
-
updated = version["dir"]
|
|
1156
|
+
updated = store.relative(new_versions / _stored_dirname(version["dir"]))
|
|
1133
1157
|
db.conn.execute("UPDATE versions SET dir = ? WHERE id = ?", (updated, version["id"]))
|
|
1134
1158
|
if old_slug != new_slug:
|
|
1135
1159
|
# The mirror lives outside the function directory, so it does not move
|
|
@@ -1209,7 +1233,7 @@ def merge(
|
|
|
1209
1233
|
stored = store.resolve_version_dir(version["dir"])
|
|
1210
1234
|
if stored.exists():
|
|
1211
1235
|
continue
|
|
1212
|
-
candidate = dst_versions /
|
|
1236
|
+
candidate = dst_versions / _stored_dirname(version["dir"])
|
|
1213
1237
|
if candidate.exists():
|
|
1214
1238
|
db.conn.execute(
|
|
1215
1239
|
"UPDATE versions SET dir = ? WHERE id = ?",
|
|
@@ -38,7 +38,15 @@ class FileRecord:
|
|
|
38
38
|
|
|
39
39
|
@classmethod
|
|
40
40
|
def from_row(cls, row: Any) -> FileRecord:
|
|
41
|
-
"""Build a record from an index row, filling in defaults for null columns.
|
|
41
|
+
"""Build a record from an index row, filling in defaults for null columns.
|
|
42
|
+
|
|
43
|
+
The defaults are not decoration: a row reindexed from a manifest written
|
|
44
|
+
before ``lang`` and ``mode`` were recorded has NULL in those columns, and
|
|
45
|
+
the diff would rather call the file ``text`` at 644 than fail on it.
|
|
46
|
+
"""
|
|
47
|
+
# back-compat: `or "text"` / `or 0o644` cover columns an older manifest
|
|
48
|
+
# cannot fill. Drop them and reindexing an old archive builds rows that
|
|
49
|
+
# crash the diff instead of one that is merely vaguer than it could be.
|
|
42
50
|
return cls(
|
|
43
51
|
path=row["path"], size=int(row["size"]), sha256=row["sha256"],
|
|
44
52
|
is_text=bool(row["is_text"]), is_vendor=bool(row["is_vendor"]),
|
|
@@ -56,8 +64,9 @@ class FileChange:
|
|
|
56
64
|
|
|
57
65
|
``diff_lines`` may be empty even for a real modification —
|
|
58
66
|
``skipped_reason`` then says why (binary, too large, ignored by config,
|
|
59
|
-
whitespace only), so the renderer can explain the
|
|
60
|
-
nothing changed. Two of those absences are not
|
|
67
|
+
whitespace only, missing from the archive), so the renderer can explain the
|
|
68
|
+
absence rather than imply nothing changed. Two of those absences are not
|
|
69
|
+
refusals but better answers:
|
|
61
70
|
a whitespace-only change is fully described by its label, and a file with no
|
|
62
71
|
usable lines is described by ``word_edits`` instead. See :func:`_fill_diff`.
|
|
63
72
|
"""
|
|
@@ -87,6 +96,12 @@ class FileChange:
|
|
|
87
96
|
#: The changed runs of a ``long_lines`` file, with enough text either side to
|
|
88
97
|
#: place them. Empty for every ordinary file.
|
|
89
98
|
word_edits: list[WordEdit] = field(default_factory=list)
|
|
99
|
+
#: True when the index lists the file but the archived version directory does
|
|
100
|
+
#: not hold it, so there was nothing to diff. Unlike every other reason a
|
|
101
|
+
#: hunk is absent, this one is not about the file at all — the archive is
|
|
102
|
+
#: incomplete, or the index is pointing at the wrong place — so the
|
|
103
|
+
#: renderers say what to do about it rather than only what happened.
|
|
104
|
+
missing: bool = False
|
|
90
105
|
|
|
91
106
|
@property
|
|
92
107
|
def is_vendor(self) -> bool:
|
|
@@ -146,6 +161,7 @@ class FileChange:
|
|
|
146
161
|
"is_vendor": self.is_vendor,
|
|
147
162
|
"whitespace_only": self.whitespace_only,
|
|
148
163
|
"long_lines": self.long_lines,
|
|
164
|
+
"missing": self.missing,
|
|
149
165
|
"word_edits": [e.as_dict() for e in self.word_edits],
|
|
150
166
|
}
|
|
151
167
|
|
|
@@ -764,6 +780,21 @@ def _whitespace_key(text: str) -> tuple[str, ...]:
|
|
|
764
780
|
return tuple(" ".join(line.split()) for line in text.splitlines() if line.strip())
|
|
765
781
|
|
|
766
782
|
|
|
783
|
+
def _absent(root: Path, record: FileRecord | None) -> bool:
|
|
784
|
+
"""True when the index lists this file but the archive no longer holds it.
|
|
785
|
+
|
|
786
|
+
:func:`~lambda_watcher.utils.read_text` answers None both for bytes that are
|
|
787
|
+
not text and for a file it could not open at all, and the second is by far
|
|
788
|
+
the likelier of the two here: the index is derived from disk, so a file it
|
|
789
|
+
lists was readable when it was written. A version directory that has been
|
|
790
|
+
deleted, moved, or restored under a path the index does not know about is
|
|
791
|
+
what actually produces it — see :meth:`lambda_watcher.store.Store.relative`.
|
|
792
|
+
Telling the reader "not decodable as text" for that sends them looking for an
|
|
793
|
+
encoding problem in a file that is simply not there.
|
|
794
|
+
"""
|
|
795
|
+
return record is not None and not (root / record.path).is_file()
|
|
796
|
+
|
|
797
|
+
|
|
767
798
|
def _fill_diff(change: FileChange, old_root: Path, new_root: Path, cfg: DiffConfig) -> None:
|
|
768
799
|
"""Work out what changed inside one file and write it onto the change.
|
|
769
800
|
|
|
@@ -792,7 +823,10 @@ def _fill_diff(change: FileChange, old_root: Path, new_root: Path, cfg: DiffConf
|
|
|
792
823
|
Everything else, from :func:`difflib.unified_diff`.
|
|
793
824
|
|
|
794
825
|
Before any of that, a file can be refused outright — binary, over
|
|
795
|
-
``max_diff_file_kb``,
|
|
826
|
+
``max_diff_file_kb``, not decodable, or missing from the archive — and
|
|
827
|
+
``skipped_reason`` says which. The last of those is not a property of the
|
|
828
|
+
file's bytes but of the archive around it, so it is told apart by
|
|
829
|
+
:func:`_absent` rather than lumped in with the encodings.
|
|
796
830
|
Line counts stay at zero on every route but the last: the counts describe a
|
|
797
831
|
hunk, and where there is no hunk ``+3/-3`` is the noise the route exists to
|
|
798
832
|
remove.
|
|
@@ -813,7 +847,10 @@ def _fill_diff(change: FileChange, old_root: Path, new_root: Path, cfg: DiffConf
|
|
|
813
847
|
old_text = read_text(old_root / old.path) if old else ""
|
|
814
848
|
new_text = read_text(new_root / new.path) if new else ""
|
|
815
849
|
if old_text is None or new_text is None:
|
|
816
|
-
change.
|
|
850
|
+
change.missing = _absent(old_root, old) or _absent(new_root, new)
|
|
851
|
+
change.skipped_reason = (
|
|
852
|
+
"missing from the archive" if change.missing else "not decodable as text"
|
|
853
|
+
)
|
|
817
854
|
return
|
|
818
855
|
|
|
819
856
|
if old_text and new_text and old_text != new_text:
|
|
@@ -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
|
-
``
|
|
8
|
-
|
|
7
|
+
``repos/<slug>/`` — see :meth:`lambda_watcher.store.Store.repo_dir`, which also
|
|
8
|
+
moves the mirrors older releases kept inside the function directory — and
|
|
9
|
+
``lw open`` hands that folder to an editor.
|
|
9
10
|
"""
|
|
10
11
|
|
|
11
12
|
from __future__ import annotations
|
|
@@ -17,7 +18,7 @@ from dataclasses import dataclass
|
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
|
|
19
20
|
from .config import GitMirrorConfig
|
|
20
|
-
from .utils import LOG, matches_any, rmtree
|
|
21
|
+
from .utils import LOG, matches_any, no_window_kwargs, rmtree
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class GitUnavailable(RuntimeError):
|
|
@@ -83,6 +84,7 @@ def _run(repo: Path, *args: str, check: bool = True, env_extra: dict[str, str] |
|
|
|
83
84
|
text=True,
|
|
84
85
|
env=_git_env(env_extra),
|
|
85
86
|
timeout=300,
|
|
87
|
+
**no_window_kwargs(),
|
|
86
88
|
)
|
|
87
89
|
if check and proc.returncode != 0:
|
|
88
90
|
raise RuntimeError(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.stdout.strip()}")
|
|
@@ -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
|
-
#
|
|
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
|
-
#:
|
|
37
|
-
#: are migrated: ``git/`` shipped,
|
|
36
|
+
#: back-compat: where the mirror used to sit, inside the function directory.
|
|
37
|
+
#: Both spellings are migrated by :meth:`Store.repo_dir`: ``git/`` shipped,
|
|
38
|
+
#: ``repo/`` was a brief step on the way here. Goes when no archive written
|
|
39
|
+
#: before the move to ``repos/<slug>/`` can still be opened, which for a tool
|
|
40
|
+
#: people leave running unattended is not a date anyone can name.
|
|
38
41
|
LEGACY_REPO_DIRNAMES = ("git", "repo")
|
|
39
42
|
|
|
40
43
|
|
|
44
|
+
def posix_stored_dir(stored_dir: str) -> str:
|
|
45
|
+
"""A stored version-directory path with its separators made posix.
|
|
46
|
+
|
|
47
|
+
``functions\\order-processor\\versions\\0001-a1b2c3d4`` →
|
|
48
|
+
``functions/order-processor/versions/0001-a1b2c3d4``. Releases before this
|
|
49
|
+
one built the value with :func:`str` on a :class:`~pathlib.Path`, so an
|
|
50
|
+
archive first written on Windows holds backslashes — four directories there,
|
|
51
|
+
one oddly-named file anywhere else. Reading such an archive on Linux or macOS
|
|
52
|
+
finds no version directory at all, and every file in every diff comes back
|
|
53
|
+
missing.
|
|
54
|
+
|
|
55
|
+
The translation is unambiguous rather than a guess: these paths are built
|
|
56
|
+
from ``functions/``, a :func:`~lambda_watcher.utils.slugify` output (only
|
|
57
|
+
``A-Za-z0-9._-`` survives it), ``versions/`` and
|
|
58
|
+
:meth:`Store.version_dirname`'s ``NNNN-`` plus hex, so no segment can
|
|
59
|
+
legitimately contain a backslash. Written by :meth:`Store.relative`, read by
|
|
60
|
+
:meth:`Store.resolve_version_dir`.
|
|
61
|
+
|
|
62
|
+
Goes when no index written by a release that stored the platform's own
|
|
63
|
+
separator can still be in use — so, in practice, it stays.
|
|
64
|
+
"""
|
|
65
|
+
# back-compat: an index written on Windows before `Store.relative` wrote
|
|
66
|
+
# posix. Drop this and such an archive resolves no version at all: the diff
|
|
67
|
+
# still prints, with no lines in it and every file blamed for its encoding.
|
|
68
|
+
return stored_dir.replace("\\", "/")
|
|
69
|
+
|
|
70
|
+
|
|
41
71
|
@dataclass
|
|
42
72
|
class VersionPaths:
|
|
43
73
|
"""The three paths that make up one archived version directory.
|
|
@@ -108,6 +138,10 @@ class Store:
|
|
|
108
138
|
repo = self.cfg.repos_dir / slug
|
|
109
139
|
if repo.exists():
|
|
110
140
|
return repo
|
|
141
|
+
# back-compat: relocate a mirror an older release left inside the
|
|
142
|
+
# function directory. Without this the mirror reads as missing, and
|
|
143
|
+
# `lw open`, `lw git` and `lw diff --mirror` all report there is no
|
|
144
|
+
# history to show for a function that has one.
|
|
111
145
|
for name in LEGACY_REPO_DIRNAMES:
|
|
112
146
|
legacy = self.function_dir(slug) / name
|
|
113
147
|
if not (legacy / ".git").is_dir():
|
|
@@ -136,18 +170,32 @@ class Store:
|
|
|
136
170
|
return VersionPaths(self.versions_dir(slug) / self.version_dirname(seq, tree_hash))
|
|
137
171
|
|
|
138
172
|
def resolve_version_dir(self, stored_dir: str) -> Path:
|
|
139
|
-
"""Version dirs are stored relative to the root so the store can move.
|
|
140
|
-
|
|
173
|
+
"""Version dirs are stored relative to the root so the store can move.
|
|
174
|
+
|
|
175
|
+
Separators are normalised on the way in by :func:`posix_stored_dir`, so
|
|
176
|
+
an archive an older release wrote on Windows still resolves after it is
|
|
177
|
+
copied to another machine — without that, every version reads as missing
|
|
178
|
+
and every file in a diff comes back unreadable.
|
|
179
|
+
"""
|
|
180
|
+
path = Path(posix_stored_dir(stored_dir))
|
|
141
181
|
return path if path.is_absolute() else self.cfg.root / path
|
|
142
182
|
|
|
143
183
|
def relative(self, path: Path) -> str:
|
|
144
184
|
"""A path rewritten relative to the archive root, for storing and display.
|
|
145
185
|
|
|
186
|
+
Posix-separated on every platform — ``functions/order-processor/versions/
|
|
187
|
+
0001-a1b2c3d4``, never ``functions\\order-processor\\...`` — because this
|
|
188
|
+
is what goes into ``index.db`` and into ``manifest.json``, and both are
|
|
189
|
+
meant to survive the archive being copied to another machine. Windows
|
|
190
|
+
reads a ``/``-separated path perfectly well; Linux and macOS do not read
|
|
191
|
+
a ``\\``-separated one at all. :func:`posix_stored_dir` translates what
|
|
192
|
+
earlier releases already wrote.
|
|
193
|
+
|
|
146
194
|
Falls back to the absolute path when it lies outside the root, which is
|
|
147
195
|
better than raising in the middle of writing a manifest.
|
|
148
196
|
"""
|
|
149
197
|
try:
|
|
150
|
-
return
|
|
198
|
+
return path.relative_to(self.cfg.root).as_posix()
|
|
151
199
|
except ValueError:
|
|
152
200
|
return str(path)
|
|
153
201
|
|
|
@@ -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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lambda_watcher-0.2.0 → lambda_watcher-0.2.1}/src/lambda_watcher.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|