Skip to content

Commit b3c7fb1

Browse files
StuBehanclaude
andcommitted
feat: surface update info via status and daemon startup
Adds a best-effort PyPI update check that's deliberately script-friendly: the network call only fires from `stackvox status` and `stackvox serve` startup. Hooks, CI, `say`, `speak`, `stackvox-say` — every script path stays silent unless the user opts in explicitly. - stackvox/updates.py: cache-backed PyPI fetch - 24h cache at ~/.cache/stackvox/update-check.json - 2s timeout, fails silently on network/parse errors - Auto-skipped when CI/GITHUB_ACTIONS/etc. are set - STACKVOX_NO_UPDATE_CHECK=1 disables entirely - cli._cmd_status: synchronous fetch (acceptable here — `status` is the canonical "is everything OK?" query and the user is at a terminal). Always prints `version: X.Y.Z` plus the upgrade notice if applicable. - daemon.serve: spawns a background thread to do the check at startup and log the notice via the daemon's stderr — high-leverage moment to surface "you should upgrade" because the user's at the terminal. Doesn't block daemon startup. - cli.main: STACKVOX_UPDATE_NOTICE=1 turns on a per-invocation stderr notice for users who want the gh-style behaviour. Off by default. Reads cache only — never fetches on this path. - tests/test_updates.py (new): 24 cases covering version comparison, disable env vars, cache I/O round-trip + corruption, fetch happy/error/disabled paths, cached_update + check_for_update freshness, fallback-to-stale-cache, and format_notice. - tests/test_cli.py: autouse fixture mocks `updates.check_for_update` and `cached_update` so existing tests don't hit PyPI. New cases for status's update line and the opt-in stderr notice. - README: daemon section explains the policy and the two env vars. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b5a3482 commit b3c7fb1

6 files changed

Lines changed: 519 additions & 5 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,13 @@ Daemon mode (keeps the model resident so each subsequent call is instant):
6767

6868
```bash
6969
stackvox serve # foreground; run with `nohup stackvox serve &` to background
70-
stackvox status # is the daemon up?
70+
stackvox status # is the daemon up? also shows version + any pending PyPI update
7171
stackvox say "Hello" # send text to the daemon (fails if not running)
7272
stackvox stop # graceful shutdown
7373
```
7474

75+
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.
76+
7577
## `stackvox-say` (bash helper, ~13ms)
7678

7779
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`:

stackvox/cli.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44

55
import argparse
66
import logging
7+
import os
78
import sys
89
from pathlib import Path
910

1011
import soundfile as sf
1112

12-
from stackvox import daemon
13+
from stackvox import daemon, updates
1314
from stackvox.engine import DEFAULT_LANG, DEFAULT_SPEED, DEFAULT_VOICE, Stackvox
1415

1516

@@ -224,9 +225,18 @@ def _cmd_stop(_: argparse.Namespace) -> int:
224225
def _cmd_status(_: argparse.Namespace) -> int:
225226
if daemon.is_running():
226227
print(f"running (pid {daemon.PID_PATH.read_text().strip()}) on {daemon.SOCKET_PATH}")
227-
return 0
228-
print("stopped")
229-
return 1
228+
rc = 0
229+
else:
230+
print("stopped")
231+
rc = 1
232+
# Status is the canonical "is everything OK?" query — surface update info
233+
# here regardless of running/stopped. Synchronous fetch with 2s timeout.
234+
info = updates.check_for_update()
235+
if info is not None:
236+
print(f"version: {info.current} ({updates.format_notice(info)})")
237+
else:
238+
print(f"version: {updates._current_version()}")
239+
return rc
230240

231241

232242
def _cmd_voices(args: argparse.Namespace) -> int:
@@ -287,6 +297,21 @@ def _cmd_install_helper(args: argparse.Namespace) -> int:
287297
return 0
288298

289299

300+
def _maybe_print_update_notice() -> None:
301+
"""Opt-in stderr notice for the script-friendly default-off case.
302+
303+
Off by default (most stackvox invocations are non-interactive — hooks,
304+
CI, scripts — and pollution there is worse than the missed notice).
305+
Set `STACKVOX_UPDATE_NOTICE=1` to turn it on. Reads cache only; never
306+
fetches from PyPI on this path.
307+
"""
308+
if not os.environ.get("STACKVOX_UPDATE_NOTICE"):
309+
return
310+
info = updates.cached_update()
311+
if info is not None:
312+
print(f"[stackvox] {updates.format_notice(info)}", file=sys.stderr)
313+
314+
290315
def main() -> int:
291316
_configure_logging()
292317
argv = sys.argv[1:]
@@ -297,6 +322,8 @@ def main() -> int:
297322
elif not argv and not sys.stdin.isatty():
298323
argv = ["speak"]
299324

325+
_maybe_print_update_notice()
326+
300327
parser = _build_parser()
301328
args = parser.parse_args(argv)
302329

stackvox/daemon.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import sounddevice as sd
3030

31+
from stackvox import updates
3132
from stackvox.engine import DEFAULT_LANG, DEFAULT_SPEED, DEFAULT_VOICE, Stackvox
3233
from stackvox.paths import pid_path, socket_path
3334

@@ -257,6 +258,22 @@ def is_running() -> bool:
257258
return _pid_alive(pid)
258259

259260

261+
def _check_for_update_async() -> None:
262+
"""Spawn a background thread that checks PyPI for an update and logs it.
263+
264+
Runs at daemon startup, off the critical path. The user is at a terminal
265+
when they `stackvox serve`, so the daemon's stderr is visible — that's
266+
the highest-leverage moment to surface "you should upgrade".
267+
"""
268+
269+
def _worker() -> None:
270+
info = updates.check_for_update()
271+
if info is not None:
272+
logger.info(updates.format_notice(info))
273+
274+
threading.Thread(target=_worker, daemon=True, name="update-check").start()
275+
276+
260277
def serve(voice: str = DEFAULT_VOICE, speed: float = DEFAULT_SPEED, lang: str = DEFAULT_LANG) -> None:
261278
if is_running():
262279
raise RuntimeError(f"daemon already running (pid {PID_PATH.read_text().strip()})")
@@ -270,6 +287,7 @@ def serve(voice: str = DEFAULT_VOICE, speed: float = DEFAULT_SPEED, lang: str =
270287
server.state = state # type: ignore[attr-defined]
271288

272289
PID_PATH.write_text(str(os.getpid()))
290+
_check_for_update_async()
273291

274292
def handle_signal(signum: int, frame: object) -> None:
275293
state.shutdown()

stackvox/updates.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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`)"

tests/test_cli.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ def _stdin_is_a_tty(mocker):
1919
mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
2020

2121

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+
2233
def test_bare_text_routes_to_speak(mocker):
2334
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
2435
mocker.patch.object(cli.sys, "argv", ["stackvox", "hello world"])
@@ -223,6 +234,58 @@ def test_stopped_returns_one(self, mocker, capsys):
223234
assert rc == 1
224235
assert "stopped" in capsys.readouterr().out
225236

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+
226289

227290
class TestCmdVoices:
228291
def test_prints_one_voice_per_line(self, fake_stackvox, capsys):

0 commit comments

Comments
 (0)