stackvox 0.4.0__tar.gz → 0.5.0__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 (29) hide show
  1. {stackvox-0.4.0/stackvox.egg-info → stackvox-0.5.0}/PKG-INFO +4 -2
  2. {stackvox-0.4.0 → stackvox-0.5.0}/README.md +3 -1
  3. {stackvox-0.4.0 → stackvox-0.5.0}/pyproject.toml +1 -1
  4. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox/cli.py +31 -4
  5. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox/daemon.py +18 -0
  6. stackvox-0.5.0/stackvox/updates.py +184 -0
  7. {stackvox-0.4.0 → stackvox-0.5.0/stackvox.egg-info}/PKG-INFO +4 -2
  8. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox.egg-info/SOURCES.txt +3 -1
  9. {stackvox-0.4.0 → stackvox-0.5.0}/tests/test_cli.py +63 -0
  10. stackvox-0.5.0/tests/test_updates.py +220 -0
  11. {stackvox-0.4.0 → stackvox-0.5.0}/LICENSE +0 -0
  12. {stackvox-0.4.0 → stackvox-0.5.0}/NOTICE +0 -0
  13. {stackvox-0.4.0 → stackvox-0.5.0}/setup.cfg +0 -0
  14. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox/__init__.py +0 -0
  15. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox/__main__.py +0 -0
  16. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox/config.py +0 -0
  17. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox/data/stackvox-say +0 -0
  18. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox/engine.py +0 -0
  19. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox/paths.py +0 -0
  20. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox/py.typed +0 -0
  21. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox.egg-info/dependency_links.txt +0 -0
  22. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox.egg-info/entry_points.txt +0 -0
  23. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox.egg-info/requires.txt +0 -0
  24. {stackvox-0.4.0 → stackvox-0.5.0}/stackvox.egg-info/top_level.txt +0 -0
  25. {stackvox-0.4.0 → stackvox-0.5.0}/tests/test_config.py +0 -0
  26. {stackvox-0.4.0 → stackvox-0.5.0}/tests/test_daemon.py +0 -0
  27. {stackvox-0.4.0 → stackvox-0.5.0}/tests/test_daemon_protocol.py +0 -0
  28. {stackvox-0.4.0 → stackvox-0.5.0}/tests/test_engine.py +0 -0
  29. {stackvox-0.4.0 → stackvox-0.5.0}/tests/test_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -118,11 +118,13 @@ Keeps the model resident so each subsequent call is instant:
118
118
 
119
119
  ```bash
120
120
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
121
- stackvox status # is the daemon up?
121
+ stackvox status # is the daemon up? also shows version + any pending PyPI update
122
122
  stackvox say "Hello" # send text to the daemon (fails if not running)
123
123
  stackvox stop # graceful shutdown
124
124
  ```
125
125
 
126
+ stackvox checks PyPI for newer versions but only at two moments — when you run `stackvox status` and at daemon startup. The script-heavy paths (`say`, `speak`, `stackvox-say`, hooks, CI) never make a network call. To see notices on every invocation set `STACKVOX_UPDATE_NOTICE=1`. To disable the check entirely set `STACKVOX_NO_UPDATE_CHECK=1`. The check is auto-skipped when common CI env vars (`CI`, `GITHUB_ACTIONS`, etc.) are set so build logs stay clean.
127
+
126
128
  ## `stackvox-say` (bash helper, ~13ms)
127
129
 
128
130
  When you want minimum latency from shell scripts (hooks, CI steps, etc.), skip the Python client and use the bash helper — it talks directly to the daemon's unix socket via `nc`:
@@ -84,11 +84,13 @@ Keeps the model resident so each subsequent call is instant:
84
84
 
85
85
  ```bash
86
86
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
87
- stackvox status # is the daemon up?
87
+ stackvox status # is the daemon up? also shows version + any pending PyPI update
88
88
  stackvox say "Hello" # send text to the daemon (fails if not running)
89
89
  stackvox stop # graceful shutdown
90
90
  ```
91
91
 
92
+ stackvox checks PyPI for newer versions but only at two moments — when you run `stackvox status` and at daemon startup. The script-heavy paths (`say`, `speak`, `stackvox-say`, hooks, CI) never make a network call. To see notices on every invocation set `STACKVOX_UPDATE_NOTICE=1`. To disable the check entirely set `STACKVOX_NO_UPDATE_CHECK=1`. The check is auto-skipped when common CI env vars (`CI`, `GITHUB_ACTIONS`, etc.) are set so build logs stay clean.
93
+
92
94
  ## `stackvox-say` (bash helper, ~13ms)
93
95
 
94
96
  When you want minimum latency from shell scripts (hooks, CI steps, etc.), skip the Python client and use the bash helper — it talks directly to the daemon's unix socket via `nc`:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,12 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import logging
7
+ import os
7
8
  import sys
8
9
  from pathlib import Path
9
10
 
10
11
  import soundfile as sf
11
12
 
12
- from stackvox import config, daemon
13
+ from stackvox import config, daemon, updates
13
14
  from stackvox.engine import Stackvox
14
15
 
15
16
 
@@ -226,9 +227,18 @@ def _cmd_stop(_: argparse.Namespace) -> int:
226
227
  def _cmd_status(_: argparse.Namespace) -> int:
227
228
  if daemon.is_running():
228
229
  print(f"running (pid {daemon.PID_PATH.read_text().strip()}) on {daemon.SOCKET_PATH}")
229
- return 0
230
- print("stopped")
231
- return 1
230
+ rc = 0
231
+ else:
232
+ print("stopped")
233
+ rc = 1
234
+ # Status is the canonical "is everything OK?" query — surface update info
235
+ # here regardless of running/stopped. Synchronous fetch with 2s timeout.
236
+ info = updates.check_for_update()
237
+ if info is not None:
238
+ print(f"version: {info.current} ({updates.format_notice(info)})")
239
+ else:
240
+ print(f"version: {updates._current_version()}")
241
+ return rc
232
242
 
233
243
 
234
244
  def _cmd_voices(args: argparse.Namespace) -> int:
@@ -289,6 +299,21 @@ def _cmd_install_helper(args: argparse.Namespace) -> int:
289
299
  return 0
290
300
 
291
301
 
302
+ def _maybe_print_update_notice() -> None:
303
+ """Opt-in stderr notice for the script-friendly default-off case.
304
+
305
+ Off by default (most stackvox invocations are non-interactive — hooks,
306
+ CI, scripts — and pollution there is worse than the missed notice).
307
+ Set `STACKVOX_UPDATE_NOTICE=1` to turn it on. Reads cache only; never
308
+ fetches from PyPI on this path.
309
+ """
310
+ if not os.environ.get("STACKVOX_UPDATE_NOTICE"):
311
+ return
312
+ info = updates.cached_update()
313
+ if info is not None:
314
+ print(f"[stackvox] {updates.format_notice(info)}", file=sys.stderr)
315
+
316
+
292
317
  def main() -> int:
293
318
  _configure_logging()
294
319
  argv = sys.argv[1:]
@@ -299,6 +324,8 @@ def main() -> int:
299
324
  elif not argv and not sys.stdin.isatty():
300
325
  argv = ["speak"]
301
326
 
327
+ _maybe_print_update_notice()
328
+
302
329
  parser = _build_parser(config.load_defaults())
303
330
  args = parser.parse_args(argv)
304
331
 
@@ -28,6 +28,7 @@ from typing import Any
28
28
 
29
29
  import sounddevice as sd
30
30
 
31
+ from stackvox import updates
31
32
  from stackvox.engine import DEFAULT_LANG, DEFAULT_SPEED, DEFAULT_VOICE, Stackvox
32
33
  from stackvox.paths import pid_path, socket_path
33
34
 
@@ -260,6 +261,22 @@ def is_running() -> bool:
260
261
  return _pid_alive(pid)
261
262
 
262
263
 
264
+ def _check_for_update_async() -> None:
265
+ """Spawn a background thread that checks PyPI for an update and logs it.
266
+
267
+ Runs at daemon startup, off the critical path. The user is at a terminal
268
+ when they `stackvox serve`, so the daemon's stderr is visible — that's
269
+ the highest-leverage moment to surface "you should upgrade".
270
+ """
271
+
272
+ def _worker() -> None:
273
+ info = updates.check_for_update()
274
+ if info is not None:
275
+ logger.info(updates.format_notice(info))
276
+
277
+ threading.Thread(target=_worker, daemon=True, name="update-check").start()
278
+
279
+
263
280
  def serve(voice: str = DEFAULT_VOICE, speed: float = DEFAULT_SPEED, lang: str = DEFAULT_LANG) -> None:
264
281
  if is_running():
265
282
  raise RuntimeError(f"daemon already running (pid {PID_PATH.read_text().strip()})")
@@ -273,6 +290,7 @@ def serve(voice: str = DEFAULT_VOICE, speed: float = DEFAULT_SPEED, lang: str =
273
290
  server.state = state # type: ignore[attr-defined]
274
291
 
275
292
  PID_PATH.write_text(str(os.getpid()))
293
+ _check_for_update_async()
276
294
 
277
295
  def handle_signal(signum: int, frame: object) -> None:
278
296
  state.shutdown()
@@ -0,0 +1,184 @@
1
+ """Best-effort PyPI update check.
2
+
3
+ Surfaces "a newer stackvox is on PyPI" without polluting the script paths
4
+ that make up most of stackvox's invocations. The actual fetch is opt-in
5
+ (only `stackvox status` and the daemon's startup trigger it); everywhere
6
+ else reads from a 24h cache.
7
+
8
+ Cache schema at `~/.cache/stackvox/update-check.json`::
9
+
10
+ {"checked_at": "2026-04-30T13:42:00+00:00", "latest": "0.4.0"}
11
+
12
+ Disable entirely with `STACKVOX_NO_UPDATE_CHECK=1`. Auto-skipped when any
13
+ common CI env var is set so build logs stay clean.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ import urllib.error
22
+ import urllib.request
23
+ from dataclasses import dataclass
24
+ from datetime import datetime, timedelta, timezone
25
+ from importlib.metadata import PackageNotFoundError
26
+ from importlib.metadata import version as _pkg_version
27
+ from pathlib import Path
28
+
29
+ from stackvox.paths import cache_dir
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _current_version() -> str:
35
+ """Read our own installed version. Late-bound so importing this module
36
+ early in the package init chain doesn't trip a circular import."""
37
+ try:
38
+ return _pkg_version("stackvox")
39
+ except PackageNotFoundError:
40
+ return "0.0.0+unknown"
41
+
42
+
43
+ PYPI_JSON_URL = "https://pypi.org/pypi/stackvox/json"
44
+ CACHE_TTL = timedelta(hours=24)
45
+ FETCH_TIMEOUT_SECONDS = 2.0
46
+
47
+ # Env vars set in common CI environments. Presence of any disables the check.
48
+ _CI_ENV_VARS = ("CI", "GITHUB_ACTIONS", "BUILDKITE", "CIRCLECI", "GITLAB_CI", "TRAVIS")
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class UpdateInfo:
53
+ current: str
54
+ latest: str
55
+
56
+ @property
57
+ def is_outdated(self) -> bool:
58
+ return _is_newer(self.latest, self.current)
59
+
60
+
61
+ def cache_path() -> Path:
62
+ return cache_dir() / "update-check.json"
63
+
64
+
65
+ def is_disabled() -> bool:
66
+ """Whether the update check should be skipped at all."""
67
+ if os.environ.get("STACKVOX_NO_UPDATE_CHECK"):
68
+ return True
69
+ return any(os.environ.get(v) for v in _CI_ENV_VARS)
70
+
71
+
72
+ def _is_newer(latest: str, current: str) -> bool:
73
+ """Compare two PEP-440-ish dotted version strings.
74
+
75
+ Handles the X.Y.Z and X.Y.Z+suffix forms stackvox actually publishes.
76
+ Non-integer segments (e.g. an `rc1` tail) sort below numeric ones so
77
+ `0.4.0` > `0.4.0rc1`, but two distinct pre-release labels compare
78
+ equal — fine for stackvox since we don't ship alphas/betas.
79
+ """
80
+
81
+ def _key(v: str) -> tuple[int, ...]:
82
+ head = v.split("+", 1)[0] # drop +local
83
+ parts: list[int] = []
84
+ for piece in head.split("."):
85
+ try:
86
+ parts.append(int(piece))
87
+ except ValueError:
88
+ parts.append(-1)
89
+ return tuple(parts)
90
+
91
+ return _key(latest) > _key(current)
92
+
93
+
94
+ def fetch_latest_version(timeout: float = FETCH_TIMEOUT_SECONDS) -> str | None:
95
+ """Hit PyPI's JSON API for the latest stackvox version.
96
+
97
+ Returns None on any network or parse error — this is best-effort.
98
+ """
99
+ if is_disabled():
100
+ return None
101
+ try:
102
+ ua = f"stackvox/{_current_version()} update-check"
103
+ req = urllib.request.Request(PYPI_JSON_URL, headers={"User-Agent": ua})
104
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - constant URL
105
+ payload = json.loads(resp.read().decode("utf-8"))
106
+ version = payload.get("info", {}).get("version")
107
+ if isinstance(version, str):
108
+ return version
109
+ return None
110
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
111
+ logger.debug("update check failed: %s", exc)
112
+ return None
113
+
114
+
115
+ def write_cache(latest: str, *, now: datetime | None = None) -> None:
116
+ """Persist the most recent successful check."""
117
+ path = cache_path()
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+ payload = {
120
+ "checked_at": (now or datetime.now(timezone.utc)).isoformat(),
121
+ "latest": latest,
122
+ }
123
+ path.write_text(json.dumps(payload), encoding="utf-8")
124
+
125
+
126
+ def read_cache() -> tuple[datetime, str] | None:
127
+ """Return (timestamp, latest) from the cache file, or None if absent/broken."""
128
+ path = cache_path()
129
+ if not path.is_file():
130
+ return None
131
+ try:
132
+ payload = json.loads(path.read_text(encoding="utf-8"))
133
+ when = datetime.fromisoformat(payload["checked_at"])
134
+ latest = payload["latest"]
135
+ if not isinstance(latest, str):
136
+ return None
137
+ return when, latest
138
+ except (OSError, ValueError, KeyError) as exc:
139
+ logger.debug("ignoring malformed update-check cache: %s", exc)
140
+ return None
141
+
142
+
143
+ def cached_update(*, now: datetime | None = None) -> UpdateInfo | None:
144
+ """Read the cache and return UpdateInfo for an unmet upgrade, else None."""
145
+ if is_disabled():
146
+ return None
147
+ entry = read_cache()
148
+ if entry is None:
149
+ return None
150
+ _checked_at, latest = entry
151
+ info = UpdateInfo(current=_current_version(), latest=latest)
152
+ return info if info.is_outdated else None
153
+
154
+
155
+ def check_for_update(*, now: datetime | None = None) -> UpdateInfo | None:
156
+ """Fetch from PyPI if the cache is stale, then return any pending update.
157
+
158
+ Synchronous; safe to call from the foreground only when ~2s of network
159
+ latency is acceptable (e.g. `stackvox status`). For the daemon startup
160
+ path, call this from a background thread.
161
+ """
162
+ if is_disabled():
163
+ return None
164
+ now = now or datetime.now(timezone.utc)
165
+ entry = read_cache()
166
+ if entry is None or (now - entry[0]) > CACHE_TTL:
167
+ latest = fetch_latest_version()
168
+ if latest is not None:
169
+ write_cache(latest, now=now)
170
+ else:
171
+ # On fetch failure, fall back to whatever's already cached (which
172
+ # may be None, in which case we just give up silently).
173
+ if entry is None:
174
+ return None
175
+ latest = entry[1]
176
+ else:
177
+ latest = entry[1]
178
+ info = UpdateInfo(current=_current_version(), latest=latest)
179
+ return info if info.is_outdated else None
180
+
181
+
182
+ def format_notice(info: UpdateInfo) -> str:
183
+ """Single-line user-facing message describing the available upgrade."""
184
+ return f"update available: {info.current} → {info.latest} (run `pipx upgrade stackvox`)"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -118,11 +118,13 @@ Keeps the model resident so each subsequent call is instant:
118
118
 
119
119
  ```bash
120
120
  stackvox serve # foreground; run with `nohup stackvox serve &` to background
121
- stackvox status # is the daemon up?
121
+ stackvox status # is the daemon up? also shows version + any pending PyPI update
122
122
  stackvox say "Hello" # send text to the daemon (fails if not running)
123
123
  stackvox stop # graceful shutdown
124
124
  ```
125
125
 
126
+ stackvox checks PyPI for newer versions but only at two moments — when you run `stackvox status` and at daemon startup. The script-heavy paths (`say`, `speak`, `stackvox-say`, hooks, CI) never make a network call. To see notices on every invocation set `STACKVOX_UPDATE_NOTICE=1`. To disable the check entirely set `STACKVOX_NO_UPDATE_CHECK=1`. The check is auto-skipped when common CI env vars (`CI`, `GITHUB_ACTIONS`, etc.) are set so build logs stay clean.
127
+
126
128
  ## `stackvox-say` (bash helper, ~13ms)
127
129
 
128
130
  When you want minimum latency from shell scripts (hooks, CI steps, etc.), skip the Python client and use the bash helper — it talks directly to the daemon's unix socket via `nc`:
@@ -10,6 +10,7 @@ stackvox/daemon.py
10
10
  stackvox/engine.py
11
11
  stackvox/paths.py
12
12
  stackvox/py.typed
13
+ stackvox/updates.py
13
14
  stackvox.egg-info/PKG-INFO
14
15
  stackvox.egg-info/SOURCES.txt
15
16
  stackvox.egg-info/dependency_links.txt
@@ -22,4 +23,5 @@ tests/test_config.py
22
23
  tests/test_daemon.py
23
24
  tests/test_daemon_protocol.py
24
25
  tests/test_engine.py
25
- tests/test_paths.py
26
+ tests/test_paths.py
27
+ tests/test_updates.py
@@ -19,6 +19,17 @@ def _stdin_is_a_tty(mocker):
19
19
  mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
20
20
 
21
21
 
22
+ @pytest.fixture(autouse=True)
23
+ def _no_network_update_check(mocker):
24
+ """Block the PyPI update check from making real HTTP requests in tests.
25
+
26
+ Tests that care about update-notice behaviour opt in by patching
27
+ `cli.updates.cached_update` / `cli.updates.check_for_update` themselves.
28
+ """
29
+ mocker.patch.object(cli.updates, "check_for_update", return_value=None)
30
+ mocker.patch.object(cli.updates, "cached_update", return_value=None)
31
+
32
+
22
33
  def test_bare_text_routes_to_speak(mocker):
23
34
  speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
24
35
  mocker.patch.object(cli.sys, "argv", ["stackvox", "hello world"])
@@ -223,6 +234,58 @@ class TestCmdStatus:
223
234
  assert rc == 1
224
235
  assert "stopped" in capsys.readouterr().out
225
236
 
237
+ def test_status_prints_update_notice_when_outdated(self, mocker, capsys):
238
+ mocker.patch.object(cli.daemon, "is_running", return_value=False)
239
+ mocker.patch.object(
240
+ cli.updates,
241
+ "check_for_update",
242
+ return_value=cli.updates.UpdateInfo(current="0.3.1", latest="0.4.0"),
243
+ )
244
+ cli._cmd_status(_ns())
245
+ out = capsys.readouterr().out
246
+ assert "0.3.1" in out
247
+ assert "0.4.0" in out
248
+ assert "pipx upgrade" in out
249
+
250
+ def test_status_prints_plain_version_when_up_to_date(self, mocker, capsys):
251
+ mocker.patch.object(cli.daemon, "is_running", return_value=False)
252
+ mocker.patch.object(cli.updates, "check_for_update", return_value=None)
253
+ mocker.patch.object(cli.updates, "_current_version", return_value="0.4.0")
254
+ cli._cmd_status(_ns())
255
+ out = capsys.readouterr().out
256
+ assert "version: 0.4.0" in out
257
+ assert "pipx upgrade" not in out
258
+
259
+
260
+ class TestUpdateNotice:
261
+ def test_silent_by_default(self, mocker, monkeypatch, capsys):
262
+ monkeypatch.delenv("STACKVOX_UPDATE_NOTICE", raising=False)
263
+ mocker.patch.object(
264
+ cli.updates,
265
+ "cached_update",
266
+ return_value=cli.updates.UpdateInfo(current="0.3.1", latest="0.4.0"),
267
+ )
268
+ cli._maybe_print_update_notice()
269
+ assert capsys.readouterr().err == ""
270
+
271
+ def test_prints_to_stderr_when_opted_in(self, mocker, monkeypatch, capsys):
272
+ monkeypatch.setenv("STACKVOX_UPDATE_NOTICE", "1")
273
+ mocker.patch.object(
274
+ cli.updates,
275
+ "cached_update",
276
+ return_value=cli.updates.UpdateInfo(current="0.3.1", latest="0.4.0"),
277
+ )
278
+ cli._maybe_print_update_notice()
279
+ err = capsys.readouterr().err
280
+ assert "0.3.1" in err
281
+ assert "0.4.0" in err
282
+
283
+ def test_silent_when_no_update_even_with_opt_in(self, mocker, monkeypatch, capsys):
284
+ monkeypatch.setenv("STACKVOX_UPDATE_NOTICE", "1")
285
+ mocker.patch.object(cli.updates, "cached_update", return_value=None)
286
+ cli._maybe_print_update_notice()
287
+ assert capsys.readouterr().err == ""
288
+
226
289
 
227
290
  class TestCmdVoices:
228
291
  def test_prints_one_voice_per_line(self, fake_stackvox, capsys):
@@ -0,0 +1,220 @@
1
+ """Update-check module tests — no real network, no real cache file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timedelta, timezone
7
+
8
+ import pytest
9
+
10
+ from stackvox import updates
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def _isolated_cache(monkeypatch, tmp_path):
15
+ """Redirect the cache dir into tmp_path so we don't touch ~/.cache."""
16
+ monkeypatch.setenv("STACKVOX_CACHE_DIR", str(tmp_path))
17
+ # Some tests also need the disable-checks env vars unset.
18
+ for var in (
19
+ "STACKVOX_NO_UPDATE_CHECK",
20
+ "CI",
21
+ "GITHUB_ACTIONS",
22
+ "BUILDKITE",
23
+ "CIRCLECI",
24
+ "GITLAB_CI",
25
+ "TRAVIS",
26
+ ):
27
+ monkeypatch.delenv(var, raising=False)
28
+
29
+
30
+ # -----------------------------------------------------------------------------
31
+ # version comparison
32
+
33
+
34
+ class TestIsNewer:
35
+ @pytest.mark.parametrize(
36
+ "latest,current,expected",
37
+ [
38
+ ("0.4.0", "0.3.1", True),
39
+ ("0.3.2", "0.3.1", True),
40
+ ("1.0.0", "0.99.99", True),
41
+ ("0.3.1", "0.3.1", False),
42
+ ("0.3.0", "0.3.1", False),
43
+ ("0.2.9", "0.3.0", False),
44
+ # Local-version suffixes (`+local`) shouldn't fool the comparison.
45
+ ("0.3.1+dev", "0.3.1", False),
46
+ ],
47
+ )
48
+ def test_dotted(self, latest, current, expected):
49
+ assert updates._is_newer(latest, current) is expected
50
+
51
+
52
+ # -----------------------------------------------------------------------------
53
+ # disable / opt-out behaviour
54
+
55
+
56
+ class TestIsDisabled:
57
+ def test_off_when_no_env_vars_set(self):
58
+ assert updates.is_disabled() is False
59
+
60
+ def test_explicit_disable(self, monkeypatch):
61
+ monkeypatch.setenv("STACKVOX_NO_UPDATE_CHECK", "1")
62
+ assert updates.is_disabled() is True
63
+
64
+ @pytest.mark.parametrize("var", ["CI", "GITHUB_ACTIONS", "BUILDKITE"])
65
+ def test_skipped_in_common_ci(self, monkeypatch, var):
66
+ monkeypatch.setenv(var, "true")
67
+ assert updates.is_disabled() is True
68
+
69
+
70
+ # -----------------------------------------------------------------------------
71
+ # cache I/O
72
+
73
+
74
+ class TestCache:
75
+ def test_read_returns_none_when_file_absent(self):
76
+ assert updates.read_cache() is None
77
+
78
+ def test_round_trip(self):
79
+ when = datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
80
+ updates.write_cache("0.5.0", now=when)
81
+ entry = updates.read_cache()
82
+ assert entry is not None
83
+ ts, latest = entry
84
+ assert latest == "0.5.0"
85
+ assert ts == when
86
+
87
+ def test_corrupt_cache_yields_none(self, tmp_path):
88
+ path = updates.cache_path()
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ path.write_text("not valid json", encoding="utf-8")
91
+ assert updates.read_cache() is None
92
+
93
+ def test_missing_keys_yield_none(self):
94
+ path = updates.cache_path()
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+ path.write_text(json.dumps({"checked_at": "2026-04-30T00:00:00+00:00"}), encoding="utf-8")
97
+ assert updates.read_cache() is None
98
+
99
+
100
+ # -----------------------------------------------------------------------------
101
+ # fetch
102
+
103
+
104
+ class TestFetchLatestVersion:
105
+ def test_returns_version_from_pypi_json(self, mocker):
106
+ body = json.dumps({"info": {"version": "0.5.0"}}).encode("utf-8")
107
+ fake_resp = mocker.MagicMock()
108
+ fake_resp.read.return_value = body
109
+ fake_resp.__enter__ = mocker.MagicMock(return_value=fake_resp)
110
+ fake_resp.__exit__ = mocker.MagicMock(return_value=False)
111
+ mocker.patch.object(updates.urllib.request, "urlopen", return_value=fake_resp)
112
+
113
+ assert updates.fetch_latest_version() == "0.5.0"
114
+
115
+ def test_returns_none_on_network_error(self, mocker):
116
+ mocker.patch.object(
117
+ updates.urllib.request, "urlopen", side_effect=updates.urllib.error.URLError("nope")
118
+ )
119
+ assert updates.fetch_latest_version() is None
120
+
121
+ def test_returns_none_on_malformed_json(self, mocker):
122
+ fake_resp = mocker.MagicMock()
123
+ fake_resp.read.return_value = b"not json at all"
124
+ fake_resp.__enter__ = mocker.MagicMock(return_value=fake_resp)
125
+ fake_resp.__exit__ = mocker.MagicMock(return_value=False)
126
+ mocker.patch.object(updates.urllib.request, "urlopen", return_value=fake_resp)
127
+ assert updates.fetch_latest_version() is None
128
+
129
+ def test_skips_when_disabled(self, monkeypatch, mocker):
130
+ monkeypatch.setenv("STACKVOX_NO_UPDATE_CHECK", "1")
131
+ urlopen = mocker.patch.object(updates.urllib.request, "urlopen")
132
+ assert updates.fetch_latest_version() is None
133
+ urlopen.assert_not_called()
134
+
135
+
136
+ # -----------------------------------------------------------------------------
137
+ # higher-level cached_update / check_for_update
138
+
139
+
140
+ class TestCachedUpdate:
141
+ def test_returns_none_when_no_cache(self):
142
+ assert updates.cached_update() is None
143
+
144
+ def test_returns_none_when_disabled(self, monkeypatch):
145
+ updates.write_cache("99.0.0")
146
+ monkeypatch.setenv("STACKVOX_NO_UPDATE_CHECK", "1")
147
+ assert updates.cached_update() is None
148
+
149
+ def test_returns_info_when_cache_says_newer(self, mocker):
150
+ # Force a known "current" version regardless of installed metadata.
151
+ mocker.patch.object(updates, "_current_version", return_value="0.3.1")
152
+ updates.write_cache("0.4.0")
153
+ info = updates.cached_update()
154
+ assert info is not None
155
+ assert info.current == "0.3.1"
156
+ assert info.latest == "0.4.0"
157
+ assert info.is_outdated is True
158
+
159
+ def test_returns_none_when_already_on_latest(self, mocker):
160
+ mocker.patch.object(updates, "_current_version", return_value="0.4.0")
161
+ updates.write_cache("0.4.0")
162
+ assert updates.cached_update() is None
163
+
164
+
165
+ class TestCheckForUpdate:
166
+ def test_uses_cache_when_fresh(self, mocker):
167
+ mocker.patch.object(updates, "_current_version", return_value="0.3.1")
168
+ # Cache from 1 hour ago — well within the 24h TTL.
169
+ when = datetime.now(timezone.utc) - timedelta(hours=1)
170
+ updates.write_cache("0.5.0", now=when)
171
+ fetch = mocker.patch.object(updates, "fetch_latest_version")
172
+
173
+ info = updates.check_for_update()
174
+
175
+ assert info is not None
176
+ assert info.latest == "0.5.0"
177
+ # Should not have fired the network call.
178
+ fetch.assert_not_called()
179
+
180
+ def test_refetches_when_cache_stale(self, mocker):
181
+ mocker.patch.object(updates, "_current_version", return_value="0.3.1")
182
+ when = datetime.now(timezone.utc) - timedelta(days=2)
183
+ updates.write_cache("0.4.0", now=when)
184
+ fetch = mocker.patch.object(updates, "fetch_latest_version", return_value="0.5.0")
185
+
186
+ info = updates.check_for_update()
187
+
188
+ assert info is not None
189
+ assert info.latest == "0.5.0"
190
+ fetch.assert_called_once()
191
+
192
+ def test_falls_back_to_stale_cache_on_fetch_failure(self, mocker):
193
+ mocker.patch.object(updates, "_current_version", return_value="0.3.1")
194
+ when = datetime.now(timezone.utc) - timedelta(days=2)
195
+ updates.write_cache("0.4.0", now=when)
196
+ mocker.patch.object(updates, "fetch_latest_version", return_value=None)
197
+
198
+ info = updates.check_for_update()
199
+
200
+ assert info is not None
201
+ assert info.latest == "0.4.0"
202
+
203
+ def test_returns_none_when_no_cache_and_fetch_fails(self, mocker):
204
+ mocker.patch.object(updates, "fetch_latest_version", return_value=None)
205
+ assert updates.check_for_update() is None
206
+
207
+ def test_returns_none_when_disabled(self, monkeypatch, mocker):
208
+ monkeypatch.setenv("STACKVOX_NO_UPDATE_CHECK", "1")
209
+ fetch = mocker.patch.object(updates, "fetch_latest_version")
210
+ assert updates.check_for_update() is None
211
+ fetch.assert_not_called()
212
+
213
+
214
+ class TestFormatNotice:
215
+ def test_includes_versions_and_upgrade_command(self):
216
+ info = updates.UpdateInfo(current="0.3.1", latest="0.4.0")
217
+ msg = updates.format_notice(info)
218
+ assert "0.3.1" in msg
219
+ assert "0.4.0" in msg
220
+ assert "pipx upgrade stackvox" in msg
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