stackvox 0.3.1__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.
- {stackvox-0.3.1/stackvox.egg-info → stackvox-0.5.0}/PKG-INFO +40 -3
- {stackvox-0.3.1 → stackvox-0.5.0}/README.md +38 -2
- {stackvox-0.3.1 → stackvox-0.5.0}/pyproject.toml +9 -5
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox/cli.py +43 -14
- stackvox-0.5.0/stackvox/config.py +80 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox/daemon.py +22 -1
- stackvox-0.5.0/stackvox/updates.py +184 -0
- {stackvox-0.3.1 → stackvox-0.5.0/stackvox.egg-info}/PKG-INFO +40 -3
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox.egg-info/SOURCES.txt +5 -1
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox.egg-info/requires.txt +3 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/tests/test_cli.py +63 -0
- stackvox-0.5.0/tests/test_config.py +110 -0
- stackvox-0.5.0/tests/test_updates.py +220 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/LICENSE +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/NOTICE +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/setup.cfg +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox/__init__.py +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox/__main__.py +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox/data/stackvox-say +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox/engine.py +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox/paths.py +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox/py.typed +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox.egg-info/dependency_links.txt +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox.egg-info/entry_points.txt +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/stackvox.egg-info/top_level.txt +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/tests/test_daemon.py +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/tests/test_daemon_protocol.py +0 -0
- {stackvox-0.3.1 → stackvox-0.5.0}/tests/test_engine.py +0 -0
- {stackvox-0.3.1 → 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.
|
|
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
|
|
@@ -21,6 +21,7 @@ Requires-Dist: kokoro-onnx>=0.4.0
|
|
|
21
21
|
Requires-Dist: soundfile>=0.12.1
|
|
22
22
|
Requires-Dist: sounddevice>=0.4.6
|
|
23
23
|
Requires-Dist: numpy>=1.24
|
|
24
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
24
25
|
Provides-Extra: dev
|
|
25
26
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
27
|
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
@@ -40,6 +41,8 @@ Dynamic: license-file
|
|
|
40
41
|
|
|
41
42
|
Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx). Apache 2.0 model, ~340MB, CPU real-time, plays straight to system audio. Designed to be importable as a Python library, drivable as a CLI, or poked via a unix socket for ~13ms speech requests from shell scripts.
|
|
42
43
|
|
|
44
|
+
🔊 **Hear it:** [docs/demo.wav](./docs/demo.wav) — five seconds of two voices speaking the tagline (`af_heart` then `bf_emma`).
|
|
45
|
+
|
|
43
46
|
## Install
|
|
44
47
|
|
|
45
48
|
From PyPI — recommended for most users:
|
|
@@ -96,15 +99,32 @@ stackvox completion bash > ~/.stackvox-completion.bash
|
|
|
96
99
|
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
97
100
|
```
|
|
98
101
|
|
|
99
|
-
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
stackvox reads per-user defaults from a TOML file, so you don't need to repeat `--voice bf_emma --speed 1.1` on every invocation. Set values in `~/.config/stackvox/config.toml` (or `$XDG_CONFIG_HOME/stackvox/config.toml`, or wherever `STACKVOX_CONFIG` points):
|
|
105
|
+
|
|
106
|
+
```toml
|
|
107
|
+
[defaults]
|
|
108
|
+
voice = "bf_emma"
|
|
109
|
+
speed = 1.1
|
|
110
|
+
lang = "en-gb"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
CLI flags always win over config values, and config values always win over the built-in defaults. A missing file is fine — built-ins apply. A malformed file logs a warning and is ignored.
|
|
114
|
+
|
|
115
|
+
## Daemon mode
|
|
116
|
+
|
|
117
|
+
Keeps the model resident so each subsequent call is instant:
|
|
100
118
|
|
|
101
119
|
```bash
|
|
102
120
|
stackvox serve # foreground; run with `nohup stackvox serve &` to background
|
|
103
|
-
stackvox status # is the daemon up?
|
|
121
|
+
stackvox status # is the daemon up? also shows version + any pending PyPI update
|
|
104
122
|
stackvox say "Hello" # send text to the daemon (fails if not running)
|
|
105
123
|
stackvox stop # graceful shutdown
|
|
106
124
|
```
|
|
107
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
|
+
|
|
108
128
|
## `stackvox-say` (bash helper, ~13ms)
|
|
109
129
|
|
|
110
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`:
|
|
@@ -211,6 +231,23 @@ Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [
|
|
|
211
231
|
|
|
212
232
|
Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
|
|
213
233
|
|
|
234
|
+
## How does this compare to other TTS?
|
|
235
|
+
|
|
236
|
+
stackvox is a fairly opinionated narrow slice of the TTS space. Here's where it sits next to the obvious neighbours:
|
|
237
|
+
|
|
238
|
+
| Tool | Offline? | Quality | Latency (typical) | License | Best for |
|
|
239
|
+
|---|---|---|---|---|---|
|
|
240
|
+
| **stackvox** (Kokoro-82M) | ✅ | High (24kHz, 50+ voices, 9 languages) | ~300ms in-process · ~13ms via daemon helper | Apache 2.0 | Local apps, shell hooks, anything that wants natural voice without the cloud |
|
|
241
|
+
| macOS `say` | ✅ | OK | ~50ms | macOS only | macOS-only scripts, "good enough" voice |
|
|
242
|
+
| `espeak-ng` | ✅ | Robotic | ~10ms | GPL-3.0 | Accessibility, screen readers, embedded |
|
|
243
|
+
| [Piper](https://github.com/rhasspy/piper) | ✅ | High | ~100ms | MIT | Similar use-case to stackvox; ONNX-based, more voices in some languages |
|
|
244
|
+
| [Coqui TTS](https://github.com/coqui-ai/TTS) | ✅ | Very high (research models) | seconds | MPL-2.0 | Research, fine-tuning, voice cloning |
|
|
245
|
+
| OpenAI / ElevenLabs / etc. | ❌ | Highest | network-bound | Proprietary | Production apps that can pay per-call and accept network dependency |
|
|
246
|
+
|
|
247
|
+
Where stackvox tries to be different from Piper specifically: a **resident daemon + bash helper** path that gets you sub-15ms speech requests from shell scripts (CI hooks, terminal notifications, status announcements) without paying Python's startup cost on every call. That's basically the point — voice quality alone wouldn't be enough to switch off Piper, but the IPC story makes a difference for shell-driven workflows.
|
|
248
|
+
|
|
249
|
+
Pick stackvox if you want **good voices, fully offline, with a fast shell-friendly API**.
|
|
250
|
+
|
|
214
251
|
## License & attributions
|
|
215
252
|
|
|
216
253
|
stackvox itself is licensed under the **Apache License, Version 2.0** — see [`LICENSE`](./LICENSE). Third-party attributions are collected in [`NOTICE`](./NOTICE); the summary below is informational.
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx). Apache 2.0 model, ~340MB, CPU real-time, plays straight to system audio. Designed to be importable as a Python library, drivable as a CLI, or poked via a unix socket for ~13ms speech requests from shell scripts.
|
|
9
9
|
|
|
10
|
+
🔊 **Hear it:** [docs/demo.wav](./docs/demo.wav) — five seconds of two voices speaking the tagline (`af_heart` then `bf_emma`).
|
|
11
|
+
|
|
10
12
|
## Install
|
|
11
13
|
|
|
12
14
|
From PyPI — recommended for most users:
|
|
@@ -63,15 +65,32 @@ stackvox completion bash > ~/.stackvox-completion.bash
|
|
|
63
65
|
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
64
66
|
```
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
## Configuration
|
|
69
|
+
|
|
70
|
+
stackvox reads per-user defaults from a TOML file, so you don't need to repeat `--voice bf_emma --speed 1.1` on every invocation. Set values in `~/.config/stackvox/config.toml` (or `$XDG_CONFIG_HOME/stackvox/config.toml`, or wherever `STACKVOX_CONFIG` points):
|
|
71
|
+
|
|
72
|
+
```toml
|
|
73
|
+
[defaults]
|
|
74
|
+
voice = "bf_emma"
|
|
75
|
+
speed = 1.1
|
|
76
|
+
lang = "en-gb"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
CLI flags always win over config values, and config values always win over the built-in defaults. A missing file is fine — built-ins apply. A malformed file logs a warning and is ignored.
|
|
80
|
+
|
|
81
|
+
## Daemon mode
|
|
82
|
+
|
|
83
|
+
Keeps the model resident so each subsequent call is instant:
|
|
67
84
|
|
|
68
85
|
```bash
|
|
69
86
|
stackvox serve # foreground; run with `nohup stackvox serve &` to background
|
|
70
|
-
stackvox status # is the daemon up?
|
|
87
|
+
stackvox status # is the daemon up? also shows version + any pending PyPI update
|
|
71
88
|
stackvox say "Hello" # send text to the daemon (fails if not running)
|
|
72
89
|
stackvox stop # graceful shutdown
|
|
73
90
|
```
|
|
74
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
|
+
|
|
75
94
|
## `stackvox-say` (bash helper, ~13ms)
|
|
76
95
|
|
|
77
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`:
|
|
@@ -178,6 +197,23 @@ Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [
|
|
|
178
197
|
|
|
179
198
|
Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
|
|
180
199
|
|
|
200
|
+
## How does this compare to other TTS?
|
|
201
|
+
|
|
202
|
+
stackvox is a fairly opinionated narrow slice of the TTS space. Here's where it sits next to the obvious neighbours:
|
|
203
|
+
|
|
204
|
+
| Tool | Offline? | Quality | Latency (typical) | License | Best for |
|
|
205
|
+
|---|---|---|---|---|---|
|
|
206
|
+
| **stackvox** (Kokoro-82M) | ✅ | High (24kHz, 50+ voices, 9 languages) | ~300ms in-process · ~13ms via daemon helper | Apache 2.0 | Local apps, shell hooks, anything that wants natural voice without the cloud |
|
|
207
|
+
| macOS `say` | ✅ | OK | ~50ms | macOS only | macOS-only scripts, "good enough" voice |
|
|
208
|
+
| `espeak-ng` | ✅ | Robotic | ~10ms | GPL-3.0 | Accessibility, screen readers, embedded |
|
|
209
|
+
| [Piper](https://github.com/rhasspy/piper) | ✅ | High | ~100ms | MIT | Similar use-case to stackvox; ONNX-based, more voices in some languages |
|
|
210
|
+
| [Coqui TTS](https://github.com/coqui-ai/TTS) | ✅ | Very high (research models) | seconds | MPL-2.0 | Research, fine-tuning, voice cloning |
|
|
211
|
+
| OpenAI / ElevenLabs / etc. | ❌ | Highest | network-bound | Proprietary | Production apps that can pay per-call and accept network dependency |
|
|
212
|
+
|
|
213
|
+
Where stackvox tries to be different from Piper specifically: a **resident daemon + bash helper** path that gets you sub-15ms speech requests from shell scripts (CI hooks, terminal notifications, status announcements) without paying Python's startup cost on every call. That's basically the point — voice quality alone wouldn't be enough to switch off Piper, but the IPC story makes a difference for shell-driven workflows.
|
|
214
|
+
|
|
215
|
+
Pick stackvox if you want **good voices, fully offline, with a fast shell-friendly API**.
|
|
216
|
+
|
|
181
217
|
## License & attributions
|
|
182
218
|
|
|
183
219
|
stackvox itself is licensed under the **Apache License, Version 2.0** — see [`LICENSE`](./LICENSE). Third-party attributions are collected in [`NOTICE`](./NOTICE); the summary below is informational.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "stackvox"
|
|
7
|
-
version = "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"
|
|
@@ -24,6 +24,7 @@ dependencies = [
|
|
|
24
24
|
"soundfile>=0.12.1",
|
|
25
25
|
"sounddevice>=0.4.6",
|
|
26
26
|
"numpy>=1.24",
|
|
27
|
+
"tomli>=2.0;python_version<'3.11'",
|
|
27
28
|
]
|
|
28
29
|
|
|
29
30
|
[project.optional-dependencies]
|
|
@@ -106,15 +107,18 @@ warn_redundant_casts = true
|
|
|
106
107
|
warn_return_any = true
|
|
107
108
|
check_untyped_defs = true
|
|
108
109
|
no_implicit_optional = true
|
|
110
|
+
disallow_untyped_defs = true
|
|
109
111
|
|
|
110
112
|
[[tool.mypy.overrides]]
|
|
111
|
-
# Third-party deps without inline type stubs.
|
|
112
|
-
|
|
113
|
+
# Third-party deps without inline type stubs (or, for `tomli`, a 3.10-only
|
|
114
|
+
# fallback that's not installed on the typechecker's Python).
|
|
115
|
+
module = ["kokoro_onnx", "sounddevice", "soundfile", "tomli"]
|
|
113
116
|
ignore_missing_imports = true
|
|
114
117
|
|
|
115
118
|
[[tool.mypy.overrides]]
|
|
116
|
-
# Tests
|
|
117
|
-
#
|
|
119
|
+
# Tests rely on pytest-mock's `mocker` (Any-heavy) and pytest fixtures with
|
|
120
|
+
# implicit Any-typed kwargs. Strict untyped-def bans buy little here, so
|
|
121
|
+
# tests stay on the looser config.
|
|
118
122
|
module = "tests.*"
|
|
119
123
|
disallow_untyped_defs = false
|
|
120
124
|
|
|
@@ -4,13 +4,14 @@ 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 daemon
|
|
13
|
-
from stackvox.engine import
|
|
13
|
+
from stackvox import config, daemon, updates
|
|
14
|
+
from stackvox.engine import Stackvox
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def _configure_logging() -> None:
|
|
@@ -103,18 +104,20 @@ WELCOME_LINES = [
|
|
|
103
104
|
]
|
|
104
105
|
|
|
105
106
|
|
|
106
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
107
|
+
def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentParser:
|
|
108
|
+
if defaults is None:
|
|
109
|
+
defaults = config.Defaults()
|
|
107
110
|
parser = argparse.ArgumentParser(prog="stackvox", description="Kokoro-82M TTS")
|
|
108
111
|
sub = parser.add_subparsers(dest="cmd")
|
|
109
112
|
|
|
110
113
|
p_speak = sub.add_parser("speak", help="Synthesize and play in-process (loads model each run)")
|
|
111
|
-
_add_voice_args(p_speak)
|
|
114
|
+
_add_voice_args(p_speak, defaults)
|
|
112
115
|
p_speak.add_argument("text", nargs="?")
|
|
113
116
|
p_speak.add_argument("--file", type=Path)
|
|
114
117
|
p_speak.add_argument("--out", type=Path, help="Write wav instead of playing")
|
|
115
118
|
|
|
116
119
|
p_say = sub.add_parser("say", help="Send text to daemon (fast; fails if daemon not running)")
|
|
117
|
-
_add_voice_args(p_say)
|
|
120
|
+
_add_voice_args(p_say, defaults)
|
|
118
121
|
p_say.add_argument("text", nargs="?")
|
|
119
122
|
p_say.add_argument("--file", type=Path)
|
|
120
123
|
p_say.add_argument(
|
|
@@ -122,7 +125,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
122
125
|
)
|
|
123
126
|
|
|
124
127
|
p_serve = sub.add_parser("serve", help="Run the daemon in the foreground")
|
|
125
|
-
_add_voice_args(p_serve)
|
|
128
|
+
_add_voice_args(p_serve, defaults)
|
|
126
129
|
|
|
127
130
|
sub.add_parser("stop", help="Stop the running daemon")
|
|
128
131
|
sub.add_parser("status", help="Print daemon status")
|
|
@@ -146,10 +149,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
146
149
|
return parser
|
|
147
150
|
|
|
148
151
|
|
|
149
|
-
def _add_voice_args(parser: argparse.ArgumentParser) -> None:
|
|
150
|
-
parser.add_argument("--voice", default=
|
|
151
|
-
parser.add_argument("--speed", type=float, default=
|
|
152
|
-
parser.add_argument("--lang", default=
|
|
152
|
+
def _add_voice_args(parser: argparse.ArgumentParser, defaults: config.Defaults) -> None:
|
|
153
|
+
parser.add_argument("--voice", default=defaults.voice)
|
|
154
|
+
parser.add_argument("--speed", type=float, default=defaults.speed)
|
|
155
|
+
parser.add_argument("--lang", default=defaults.lang)
|
|
153
156
|
|
|
154
157
|
|
|
155
158
|
def _read_text(args: argparse.Namespace) -> str | None:
|
|
@@ -224,9 +227,18 @@ def _cmd_stop(_: argparse.Namespace) -> int:
|
|
|
224
227
|
def _cmd_status(_: argparse.Namespace) -> int:
|
|
225
228
|
if daemon.is_running():
|
|
226
229
|
print(f"running (pid {daemon.PID_PATH.read_text().strip()}) on {daemon.SOCKET_PATH}")
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
230
242
|
|
|
231
243
|
|
|
232
244
|
def _cmd_voices(args: argparse.Namespace) -> int:
|
|
@@ -287,6 +299,21 @@ def _cmd_install_helper(args: argparse.Namespace) -> int:
|
|
|
287
299
|
return 0
|
|
288
300
|
|
|
289
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
|
+
|
|
290
317
|
def main() -> int:
|
|
291
318
|
_configure_logging()
|
|
292
319
|
argv = sys.argv[1:]
|
|
@@ -297,7 +324,9 @@ def main() -> int:
|
|
|
297
324
|
elif not argv and not sys.stdin.isatty():
|
|
298
325
|
argv = ["speak"]
|
|
299
326
|
|
|
300
|
-
|
|
327
|
+
_maybe_print_update_notice()
|
|
328
|
+
|
|
329
|
+
parser = _build_parser(config.load_defaults())
|
|
301
330
|
args = parser.parse_args(argv)
|
|
302
331
|
|
|
303
332
|
if not args.cmd:
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""User config file: per-user defaults for voice / speed / lang.
|
|
2
|
+
|
|
3
|
+
Lives at `$XDG_CONFIG_HOME/stackvox/config.toml` (falling back to
|
|
4
|
+
`~/.config/stackvox/config.toml`), or wherever `STACKVOX_CONFIG` points if set.
|
|
5
|
+
A missing file is fine — defaults from `stackvox.engine` apply. A malformed
|
|
6
|
+
file logs a warning and is otherwise ignored.
|
|
7
|
+
|
|
8
|
+
File format::
|
|
9
|
+
|
|
10
|
+
[defaults]
|
|
11
|
+
voice = "bf_emma"
|
|
12
|
+
speed = 1.1
|
|
13
|
+
lang = "en-gb"
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
if sys.version_info >= (3, 11):
|
|
25
|
+
import tomllib
|
|
26
|
+
else: # pragma: no cover - covered by 3.10 CI
|
|
27
|
+
import tomli as tomllib
|
|
28
|
+
|
|
29
|
+
from stackvox.engine import DEFAULT_LANG, DEFAULT_SPEED, DEFAULT_VOICE
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Defaults:
|
|
36
|
+
"""Resolved default values for synthesis parameters."""
|
|
37
|
+
|
|
38
|
+
voice: str = DEFAULT_VOICE
|
|
39
|
+
speed: float = DEFAULT_SPEED
|
|
40
|
+
lang: str = DEFAULT_LANG
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def config_path() -> Path:
|
|
44
|
+
"""Resolve where the config file lives.
|
|
45
|
+
|
|
46
|
+
Honours `STACKVOX_CONFIG` first; otherwise XDG (`$XDG_CONFIG_HOME` →
|
|
47
|
+
`~/.config`).
|
|
48
|
+
"""
|
|
49
|
+
override = os.environ.get("STACKVOX_CONFIG")
|
|
50
|
+
if override:
|
|
51
|
+
return Path(override).expanduser()
|
|
52
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
53
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
54
|
+
return base / "stackvox" / "config.toml"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_defaults(path: Path | None = None) -> Defaults:
|
|
58
|
+
"""Read the config file and return resolved defaults.
|
|
59
|
+
|
|
60
|
+
Missing file → built-in defaults. Malformed file → warning logged,
|
|
61
|
+
built-in defaults used. Per-key fallback so a config that only sets
|
|
62
|
+
`voice` keeps the built-in `speed` and `lang`.
|
|
63
|
+
"""
|
|
64
|
+
p = path or config_path()
|
|
65
|
+
if not p.is_file():
|
|
66
|
+
return Defaults()
|
|
67
|
+
try:
|
|
68
|
+
data = tomllib.loads(p.read_text(encoding="utf-8"))
|
|
69
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
70
|
+
logger.warning("ignoring malformed stackvox config at %s: %s", p, exc)
|
|
71
|
+
return Defaults()
|
|
72
|
+
section = data.get("defaults", {})
|
|
73
|
+
if not isinstance(section, dict):
|
|
74
|
+
logger.warning("config %s: [defaults] must be a table; ignoring", p)
|
|
75
|
+
return Defaults()
|
|
76
|
+
return Defaults(
|
|
77
|
+
voice=str(section.get("voice", DEFAULT_VOICE)),
|
|
78
|
+
speed=float(section.get("speed", DEFAULT_SPEED)),
|
|
79
|
+
lang=str(section.get("lang", DEFAULT_LANG)),
|
|
80
|
+
)
|
|
@@ -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
|
|
|
@@ -130,7 +131,10 @@ def _start_device_watcher() -> None:
|
|
|
130
131
|
|
|
131
132
|
last_device = [_read_default_device()]
|
|
132
133
|
|
|
133
|
-
def _on_device_change(obj_id: int, n: int, addrs, data) -> int:
|
|
134
|
+
def _on_device_change(obj_id: int, n: int, addrs: Any, data: Any) -> int:
|
|
135
|
+
# `addrs` is a ctypes.POINTER(_PropAddr); `data` is a c_void_p — neither
|
|
136
|
+
# is needed because we already know which property fired (only one is
|
|
137
|
+
# registered) and we re-read the device ID directly.
|
|
134
138
|
try:
|
|
135
139
|
current = _read_default_device()
|
|
136
140
|
if current and current != last_device[0]:
|
|
@@ -257,6 +261,22 @@ def is_running() -> bool:
|
|
|
257
261
|
return _pid_alive(pid)
|
|
258
262
|
|
|
259
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
|
+
|
|
260
280
|
def serve(voice: str = DEFAULT_VOICE, speed: float = DEFAULT_SPEED, lang: str = DEFAULT_LANG) -> None:
|
|
261
281
|
if is_running():
|
|
262
282
|
raise RuntimeError(f"daemon already running (pid {PID_PATH.read_text().strip()})")
|
|
@@ -270,6 +290,7 @@ def serve(voice: str = DEFAULT_VOICE, speed: float = DEFAULT_SPEED, lang: str =
|
|
|
270
290
|
server.state = state # type: ignore[attr-defined]
|
|
271
291
|
|
|
272
292
|
PID_PATH.write_text(str(os.getpid()))
|
|
293
|
+
_check_for_update_async()
|
|
273
294
|
|
|
274
295
|
def handle_signal(signum: int, frame: object) -> None:
|
|
275
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.
|
|
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
|
|
@@ -21,6 +21,7 @@ Requires-Dist: kokoro-onnx>=0.4.0
|
|
|
21
21
|
Requires-Dist: soundfile>=0.12.1
|
|
22
22
|
Requires-Dist: sounddevice>=0.4.6
|
|
23
23
|
Requires-Dist: numpy>=1.24
|
|
24
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
24
25
|
Provides-Extra: dev
|
|
25
26
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
27
|
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
@@ -40,6 +41,8 @@ Dynamic: license-file
|
|
|
40
41
|
|
|
41
42
|
Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [kokoro-onnx](https://github.com/thewh1teagle/kokoro-onnx). Apache 2.0 model, ~340MB, CPU real-time, plays straight to system audio. Designed to be importable as a Python library, drivable as a CLI, or poked via a unix socket for ~13ms speech requests from shell scripts.
|
|
42
43
|
|
|
44
|
+
🔊 **Hear it:** [docs/demo.wav](./docs/demo.wav) — five seconds of two voices speaking the tagline (`af_heart` then `bf_emma`).
|
|
45
|
+
|
|
43
46
|
## Install
|
|
44
47
|
|
|
45
48
|
From PyPI — recommended for most users:
|
|
@@ -96,15 +99,32 @@ stackvox completion bash > ~/.stackvox-completion.bash
|
|
|
96
99
|
echo 'source ~/.stackvox-completion.bash' >> ~/.bashrc
|
|
97
100
|
```
|
|
98
101
|
|
|
99
|
-
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
stackvox reads per-user defaults from a TOML file, so you don't need to repeat `--voice bf_emma --speed 1.1` on every invocation. Set values in `~/.config/stackvox/config.toml` (or `$XDG_CONFIG_HOME/stackvox/config.toml`, or wherever `STACKVOX_CONFIG` points):
|
|
105
|
+
|
|
106
|
+
```toml
|
|
107
|
+
[defaults]
|
|
108
|
+
voice = "bf_emma"
|
|
109
|
+
speed = 1.1
|
|
110
|
+
lang = "en-gb"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
CLI flags always win over config values, and config values always win over the built-in defaults. A missing file is fine — built-ins apply. A malformed file logs a warning and is ignored.
|
|
114
|
+
|
|
115
|
+
## Daemon mode
|
|
116
|
+
|
|
117
|
+
Keeps the model resident so each subsequent call is instant:
|
|
100
118
|
|
|
101
119
|
```bash
|
|
102
120
|
stackvox serve # foreground; run with `nohup stackvox serve &` to background
|
|
103
|
-
stackvox status # is the daemon up?
|
|
121
|
+
stackvox status # is the daemon up? also shows version + any pending PyPI update
|
|
104
122
|
stackvox say "Hello" # send text to the daemon (fails if not running)
|
|
105
123
|
stackvox stop # graceful shutdown
|
|
106
124
|
```
|
|
107
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
|
+
|
|
108
128
|
## `stackvox-say` (bash helper, ~13ms)
|
|
109
129
|
|
|
110
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`:
|
|
@@ -211,6 +231,23 @@ Model weights (`kokoro-v1.0.onnx`, ~340 MB) and voices are downloaded from the [
|
|
|
211
231
|
|
|
212
232
|
Security issues themselves should not be filed as public GitHub issues — see [`SECURITY.md`](./SECURITY.md) for the disclosure process.
|
|
213
233
|
|
|
234
|
+
## How does this compare to other TTS?
|
|
235
|
+
|
|
236
|
+
stackvox is a fairly opinionated narrow slice of the TTS space. Here's where it sits next to the obvious neighbours:
|
|
237
|
+
|
|
238
|
+
| Tool | Offline? | Quality | Latency (typical) | License | Best for |
|
|
239
|
+
|---|---|---|---|---|---|
|
|
240
|
+
| **stackvox** (Kokoro-82M) | ✅ | High (24kHz, 50+ voices, 9 languages) | ~300ms in-process · ~13ms via daemon helper | Apache 2.0 | Local apps, shell hooks, anything that wants natural voice without the cloud |
|
|
241
|
+
| macOS `say` | ✅ | OK | ~50ms | macOS only | macOS-only scripts, "good enough" voice |
|
|
242
|
+
| `espeak-ng` | ✅ | Robotic | ~10ms | GPL-3.0 | Accessibility, screen readers, embedded |
|
|
243
|
+
| [Piper](https://github.com/rhasspy/piper) | ✅ | High | ~100ms | MIT | Similar use-case to stackvox; ONNX-based, more voices in some languages |
|
|
244
|
+
| [Coqui TTS](https://github.com/coqui-ai/TTS) | ✅ | Very high (research models) | seconds | MPL-2.0 | Research, fine-tuning, voice cloning |
|
|
245
|
+
| OpenAI / ElevenLabs / etc. | ❌ | Highest | network-bound | Proprietary | Production apps that can pay per-call and accept network dependency |
|
|
246
|
+
|
|
247
|
+
Where stackvox tries to be different from Piper specifically: a **resident daemon + bash helper** path that gets you sub-15ms speech requests from shell scripts (CI hooks, terminal notifications, status announcements) without paying Python's startup cost on every call. That's basically the point — voice quality alone wouldn't be enough to switch off Piper, but the IPC story makes a difference for shell-driven workflows.
|
|
248
|
+
|
|
249
|
+
Pick stackvox if you want **good voices, fully offline, with a fast shell-friendly API**.
|
|
250
|
+
|
|
214
251
|
## License & attributions
|
|
215
252
|
|
|
216
253
|
stackvox itself is licensed under the **Apache License, Version 2.0** — see [`LICENSE`](./LICENSE). Third-party attributions are collected in [`NOTICE`](./NOTICE); the summary below is informational.
|
|
@@ -5,10 +5,12 @@ pyproject.toml
|
|
|
5
5
|
stackvox/__init__.py
|
|
6
6
|
stackvox/__main__.py
|
|
7
7
|
stackvox/cli.py
|
|
8
|
+
stackvox/config.py
|
|
8
9
|
stackvox/daemon.py
|
|
9
10
|
stackvox/engine.py
|
|
10
11
|
stackvox/paths.py
|
|
11
12
|
stackvox/py.typed
|
|
13
|
+
stackvox/updates.py
|
|
12
14
|
stackvox.egg-info/PKG-INFO
|
|
13
15
|
stackvox.egg-info/SOURCES.txt
|
|
14
16
|
stackvox.egg-info/dependency_links.txt
|
|
@@ -17,7 +19,9 @@ stackvox.egg-info/requires.txt
|
|
|
17
19
|
stackvox.egg-info/top_level.txt
|
|
18
20
|
stackvox/data/stackvox-say
|
|
19
21
|
tests/test_cli.py
|
|
22
|
+
tests/test_config.py
|
|
20
23
|
tests/test_daemon.py
|
|
21
24
|
tests/test_daemon_protocol.py
|
|
22
25
|
tests/test_engine.py
|
|
23
|
-
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,110 @@
|
|
|
1
|
+
"""Config loader tests — pure file/env logic, no engine touched."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from stackvox import config
|
|
8
|
+
from stackvox.engine import DEFAULT_LANG, DEFAULT_SPEED, DEFAULT_VOICE
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestConfigPath:
|
|
12
|
+
def test_stackvox_config_env_takes_priority(self, monkeypatch, tmp_path):
|
|
13
|
+
monkeypatch.setenv("STACKVOX_CONFIG", str(tmp_path / "elsewhere.toml"))
|
|
14
|
+
assert config.config_path() == tmp_path / "elsewhere.toml"
|
|
15
|
+
|
|
16
|
+
def test_xdg_config_home_when_set(self, monkeypatch, tmp_path):
|
|
17
|
+
monkeypatch.delenv("STACKVOX_CONFIG", raising=False)
|
|
18
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
19
|
+
assert config.config_path() == tmp_path / "xdg" / "stackvox" / "config.toml"
|
|
20
|
+
|
|
21
|
+
def test_falls_back_to_home_dotconfig(self, monkeypatch):
|
|
22
|
+
monkeypatch.delenv("STACKVOX_CONFIG", raising=False)
|
|
23
|
+
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
assert config.config_path() == Path.home() / ".config" / "stackvox" / "config.toml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestLoadDefaults:
|
|
30
|
+
def test_missing_file_returns_built_in_defaults(self, tmp_path):
|
|
31
|
+
actual = config.load_defaults(tmp_path / "absent.toml")
|
|
32
|
+
assert actual == config.Defaults()
|
|
33
|
+
assert actual.voice == DEFAULT_VOICE
|
|
34
|
+
assert actual.speed == DEFAULT_SPEED
|
|
35
|
+
assert actual.lang == DEFAULT_LANG
|
|
36
|
+
|
|
37
|
+
def test_full_config_overrides_all_three(self, tmp_path):
|
|
38
|
+
path = tmp_path / "config.toml"
|
|
39
|
+
path.write_text('[defaults]\nvoice = "bf_emma"\nspeed = 1.25\nlang = "en-gb"\n', encoding="utf-8")
|
|
40
|
+
actual = config.load_defaults(path)
|
|
41
|
+
assert actual.voice == "bf_emma"
|
|
42
|
+
assert actual.speed == 1.25
|
|
43
|
+
assert actual.lang == "en-gb"
|
|
44
|
+
|
|
45
|
+
def test_partial_config_keeps_built_in_for_missing_keys(self, tmp_path):
|
|
46
|
+
path = tmp_path / "config.toml"
|
|
47
|
+
path.write_text('[defaults]\nvoice = "bf_emma"\n', encoding="utf-8")
|
|
48
|
+
actual = config.load_defaults(path)
|
|
49
|
+
assert actual.voice == "bf_emma"
|
|
50
|
+
# speed and lang come from the engine defaults.
|
|
51
|
+
assert actual.speed == DEFAULT_SPEED
|
|
52
|
+
assert actual.lang == DEFAULT_LANG
|
|
53
|
+
|
|
54
|
+
def test_empty_file_returns_built_in_defaults(self, tmp_path):
|
|
55
|
+
path = tmp_path / "config.toml"
|
|
56
|
+
path.write_text("", encoding="utf-8")
|
|
57
|
+
assert config.load_defaults(path) == config.Defaults()
|
|
58
|
+
|
|
59
|
+
def test_malformed_toml_logs_warning_and_returns_defaults(self, tmp_path, caplog):
|
|
60
|
+
path = tmp_path / "config.toml"
|
|
61
|
+
path.write_text("this is not valid = toml\n[defaults\nvoice =", encoding="utf-8")
|
|
62
|
+
with caplog.at_level(logging.WARNING, logger="stackvox.config"):
|
|
63
|
+
actual = config.load_defaults(path)
|
|
64
|
+
assert actual == config.Defaults()
|
|
65
|
+
assert any("malformed stackvox config" in r.message for r in caplog.records)
|
|
66
|
+
|
|
67
|
+
def test_defaults_section_must_be_a_table(self, tmp_path, caplog):
|
|
68
|
+
"""`defaults = "string"` is parseable TOML but the wrong shape — log and ignore."""
|
|
69
|
+
path = tmp_path / "config.toml"
|
|
70
|
+
path.write_text('defaults = "not-a-table"\n', encoding="utf-8")
|
|
71
|
+
with caplog.at_level(logging.WARNING, logger="stackvox.config"):
|
|
72
|
+
actual = config.load_defaults(path)
|
|
73
|
+
assert actual == config.Defaults()
|
|
74
|
+
assert any("must be a table" in r.message for r in caplog.records)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestCLIPicksUpConfig:
|
|
78
|
+
"""Smoke test: argparse defaults reflect the user's config file."""
|
|
79
|
+
|
|
80
|
+
def test_voice_default_comes_from_config(self, mocker, monkeypatch, tmp_path):
|
|
81
|
+
path = tmp_path / "config.toml"
|
|
82
|
+
path.write_text('[defaults]\nvoice = "bf_emma"\nspeed = 1.3\n', encoding="utf-8")
|
|
83
|
+
monkeypatch.setenv("STACKVOX_CONFIG", str(path))
|
|
84
|
+
|
|
85
|
+
from stackvox import cli
|
|
86
|
+
|
|
87
|
+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
88
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "hello"])
|
|
89
|
+
mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
|
|
90
|
+
|
|
91
|
+
assert cli.main() == 0
|
|
92
|
+
args = speak.call_args.args[0]
|
|
93
|
+
assert args.voice == "bf_emma"
|
|
94
|
+
assert args.speed == 1.3
|
|
95
|
+
# Lang wasn't in config; should fall through to built-in default.
|
|
96
|
+
assert args.lang == DEFAULT_LANG
|
|
97
|
+
|
|
98
|
+
def test_explicit_flag_overrides_config(self, mocker, monkeypatch, tmp_path):
|
|
99
|
+
path = tmp_path / "config.toml"
|
|
100
|
+
path.write_text('[defaults]\nvoice = "bf_emma"\n', encoding="utf-8")
|
|
101
|
+
monkeypatch.setenv("STACKVOX_CONFIG", str(path))
|
|
102
|
+
|
|
103
|
+
from stackvox import cli
|
|
104
|
+
|
|
105
|
+
speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
|
|
106
|
+
mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "--voice", "af_sarah", "hello"])
|
|
107
|
+
mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
|
|
108
|
+
|
|
109
|
+
assert cli.main() == 0
|
|
110
|
+
assert speak.call_args.args[0].voice == "af_sarah"
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|