stackvox 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {stackvox-0.3.0/stackvox.egg-info → stackvox-0.3.1}/PKG-INFO +12 -3
  2. {stackvox-0.3.0 → stackvox-0.3.1}/README.md +11 -2
  3. {stackvox-0.3.0 → stackvox-0.3.1}/pyproject.toml +7 -6
  4. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/cli.py +69 -2
  5. {stackvox-0.3.0 → stackvox-0.3.1/stackvox.egg-info}/PKG-INFO +12 -3
  6. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox.egg-info/SOURCES.txt +3 -1
  7. stackvox-0.3.1/tests/test_cli.py +292 -0
  8. stackvox-0.3.1/tests/test_daemon.py +138 -0
  9. stackvox-0.3.1/tests/test_engine.py +241 -0
  10. stackvox-0.3.0/tests/test_cli.py +0 -91
  11. {stackvox-0.3.0 → stackvox-0.3.1}/LICENSE +0 -0
  12. {stackvox-0.3.0 → stackvox-0.3.1}/NOTICE +0 -0
  13. {stackvox-0.3.0 → stackvox-0.3.1}/setup.cfg +0 -0
  14. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/__init__.py +0 -0
  15. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/__main__.py +0 -0
  16. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/daemon.py +0 -0
  17. {stackvox-0.3.0/bin → stackvox-0.3.1/stackvox/data}/stackvox-say +0 -0
  18. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/engine.py +0 -0
  19. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/paths.py +0 -0
  20. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox/py.typed +0 -0
  21. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox.egg-info/dependency_links.txt +0 -0
  22. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox.egg-info/entry_points.txt +0 -0
  23. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox.egg-info/requires.txt +0 -0
  24. {stackvox-0.3.0 → stackvox-0.3.1}/stackvox.egg-info/top_level.txt +0 -0
  25. {stackvox-0.3.0 → stackvox-0.3.1}/tests/test_daemon_protocol.py +0 -0
  26. {stackvox-0.3.0 → stackvox-0.3.1}/tests/test_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -45,11 +45,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
45
45
  From PyPI — recommended for most users:
46
46
 
47
47
  ```bash
48
- pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
48
+ pipx install stackvox # `stackvox` CLI on PATH
49
49
  # or
50
- pip install stackvox # use as a library
50
+ pip install stackvox # use as a library
51
51
  ```
52
52
 
53
+ If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
54
+
55
+ ```bash
56
+ stackvox install-helper # copies bash helper to ~/.local/bin
57
+ # use --prefix DIR to install elsewhere
58
+ ```
59
+
60
+ This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.
61
+
53
62
  From git, if you want an unreleased commit:
54
63
 
55
64
  ```bash
@@ -12,11 +12,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
12
12
  From PyPI — recommended for most users:
13
13
 
14
14
  ```bash
15
- pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
15
+ pipx install stackvox # `stackvox` CLI on PATH
16
16
  # or
17
- pip install stackvox # use as a library
17
+ pip install stackvox # use as a library
18
18
  ```
19
19
 
20
+ If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
21
+
22
+ ```bash
23
+ stackvox install-helper # copies bash helper to ~/.local/bin
24
+ # use --prefix DIR to install elsewhere
25
+ ```
26
+
27
+ This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.
28
+
20
29
  From git, if you want an unreleased commit:
21
30
 
22
31
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -45,15 +45,16 @@ Issues = "https://github.com/StackOneHQ/stackvox/issues"
45
45
  [project.scripts]
46
46
  stackvox = "stackvox.cli:main"
47
47
 
48
- [tool.setuptools]
49
- script-files = ["bin/stackvox-say"]
50
-
51
48
  [tool.setuptools.packages.find]
52
49
  include = ["stackvox*"]
53
50
 
54
51
  [tool.setuptools.package-data]
55
- # Ship the PEP 561 marker so downstream type checkers trust our inline hints.
56
- stackvox = ["py.typed"]
52
+ # Ship the PEP 561 marker so downstream type checkers trust our inline hints,
53
+ # and the bash helper as package data — extracted onto PATH by
54
+ # `stackvox install-helper`. (We deliberately don't use [tool.setuptools]
55
+ # script-files = [...]: it's discouraged, has no PEP 621 equivalent, and
56
+ # doesn't round-trip through hatchling/uv if we ever migrate build backends.)
57
+ stackvox = ["py.typed", "data/stackvox-say"]
57
58
 
58
59
  [tool.ruff]
59
60
  line-length = 110
@@ -21,7 +21,19 @@ def _configure_logging() -> None:
21
21
  )
22
22
 
23
23
 
24
- SUBCOMMANDS = {"serve", "stop", "status", "say", "speak", "voices", "welcome", "completion"}
24
+ SUBCOMMANDS = {
25
+ "serve",
26
+ "stop",
27
+ "status",
28
+ "say",
29
+ "speak",
30
+ "voices",
31
+ "welcome",
32
+ "completion",
33
+ "install-helper",
34
+ }
35
+
36
+ DEFAULT_HELPER_PREFIX = Path.home() / ".local" / "bin"
25
37
 
26
38
  _BASH_COMPLETION = r"""# stackvox bash completion. Install with one of:
27
39
  # eval "$(stackvox completion bash)" # current shell
@@ -34,7 +46,7 @@ _stackvox_completion() {
34
46
  subcommand="${COMP_WORDS[1]:-}"
35
47
 
36
48
  if [[ ${COMP_CWORD} -eq 1 ]]; then
37
- COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion" -- "$cur") )
49
+ COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") )
38
50
  return 0
39
51
  fi
40
52
 
@@ -43,6 +55,10 @@ _stackvox_completion() {
43
55
  COMPREPLY=( $(compgen -f -- "$cur") )
44
56
  return 0
45
57
  ;;
58
+ --prefix)
59
+ COMPREPLY=( $(compgen -d -- "$cur") )
60
+ return 0
61
+ ;;
46
62
  --speed)
47
63
  COMPREPLY=( $(compgen -W "0.8 0.9 1.0 1.1 1.2 1.5" -- "$cur") )
48
64
  return 0
@@ -66,6 +82,9 @@ _stackvox_completion() {
66
82
  completion)
67
83
  COMPREPLY=( $(compgen -W "bash" -- "$cur") )
68
84
  ;;
85
+ install-helper)
86
+ COMPREPLY=( $(compgen -W "--prefix --help" -- "$cur") )
87
+ ;;
69
88
  *)
70
89
  COMPREPLY=( $(compgen -W "--help" -- "$cur") )
71
90
  ;;
@@ -113,6 +132,17 @@ def _build_parser() -> argparse.ArgumentParser:
113
132
  p_completion = sub.add_parser("completion", help="Print a shell completion script")
114
133
  p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for")
115
134
 
135
+ p_install_helper = sub.add_parser(
136
+ "install-helper",
137
+ help="Copy the stackvox-say bash helper onto PATH (default: ~/.local/bin)",
138
+ )
139
+ p_install_helper.add_argument(
140
+ "--prefix",
141
+ type=Path,
142
+ default=DEFAULT_HELPER_PREFIX,
143
+ help=f"Install directory (default: {DEFAULT_HELPER_PREFIX})",
144
+ )
145
+
116
146
  return parser
117
147
 
118
148
 
@@ -221,6 +251,42 @@ def _cmd_completion(args: argparse.Namespace) -> int:
221
251
  return 1
222
252
 
223
253
 
254
+ def _cmd_install_helper(args: argparse.Namespace) -> int:
255
+ """Copy the bundled `stackvox-say` bash helper onto PATH.
256
+
257
+ The helper is shipped as package data (`stackvox/data/stackvox-say`) rather
258
+ than installed automatically by setuptools — `script-files` is discouraged
259
+ and doesn't round-trip through modern build backends. This subcommand is
260
+ the explicit one-time install step.
261
+ """
262
+ import os
263
+ import shutil
264
+ import stat
265
+ from importlib.resources import as_file, files
266
+
267
+ prefix = Path(args.prefix).expanduser()
268
+ prefix.mkdir(parents=True, exist_ok=True)
269
+ dest = prefix / "stackvox-say"
270
+
271
+ src = files("stackvox").joinpath("data/stackvox-say")
272
+ with as_file(src) as src_path:
273
+ shutil.copy2(src_path, dest)
274
+
275
+ # Ensure executable bits regardless of how the wheel preserved them.
276
+ dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
277
+
278
+ print(f"Installed stackvox-say to {dest}", file=sys.stderr)
279
+
280
+ path_dirs = os.environ.get("PATH", "").split(os.pathsep)
281
+ if str(prefix) not in path_dirs:
282
+ print(
283
+ f"Note: {prefix} is not on your PATH. Either add it to PATH or invoke "
284
+ f"the helper via its full path: {dest}",
285
+ file=sys.stderr,
286
+ )
287
+ return 0
288
+
289
+
224
290
  def main() -> int:
225
291
  _configure_logging()
226
292
  argv = sys.argv[1:]
@@ -247,6 +313,7 @@ def main() -> int:
247
313
  "voices": _cmd_voices,
248
314
  "welcome": _cmd_welcome,
249
315
  "completion": _cmd_completion,
316
+ "install-helper": _cmd_install_helper,
250
317
  }
251
318
  return handlers[args.cmd](args)
252
319
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -45,11 +45,20 @@ Offline TTS using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) via [k
45
45
  From PyPI — recommended for most users:
46
46
 
47
47
  ```bash
48
- pipx install stackvox # global CLI (`stackvox` and `stackvox-say` on PATH)
48
+ pipx install stackvox # `stackvox` CLI on PATH
49
49
  # or
50
- pip install stackvox # use as a library
50
+ pip install stackvox # use as a library
51
51
  ```
52
52
 
53
+ If you want the low-latency bash helper (`stackvox-say`) for shell scripts and hooks, install it on PATH after installing the package:
54
+
55
+ ```bash
56
+ stackvox install-helper # copies bash helper to ~/.local/bin
57
+ # use --prefix DIR to install elsewhere
58
+ ```
59
+
60
+ This is a one-time step. The helper is shipped as package data rather than as an automatic install script — explicit beats magical, and it keeps stackvox compatible with modern build backends. Skip it if you only ever use the Python `stackvox say` client.
61
+
53
62
  From git, if you want an unreleased commit:
54
63
 
55
64
  ```bash
@@ -2,7 +2,6 @@ LICENSE
2
2
  NOTICE
3
3
  README.md
4
4
  pyproject.toml
5
- bin/stackvox-say
6
5
  stackvox/__init__.py
7
6
  stackvox/__main__.py
8
7
  stackvox/cli.py
@@ -16,6 +15,9 @@ stackvox.egg-info/dependency_links.txt
16
15
  stackvox.egg-info/entry_points.txt
17
16
  stackvox.egg-info/requires.txt
18
17
  stackvox.egg-info/top_level.txt
18
+ stackvox/data/stackvox-say
19
19
  tests/test_cli.py
20
+ tests/test_daemon.py
20
21
  tests/test_daemon_protocol.py
22
+ tests/test_engine.py
21
23
  tests/test_paths.py
@@ -0,0 +1,292 @@
1
+ """CLI argument parsing — no engine loaded, no audio played."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ import pytest
8
+
9
+ from stackvox import cli
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def _stdin_is_a_tty(mocker):
14
+ """Default tests to a TTY stdin so the new piped-stdin shortcut is off.
15
+
16
+ Tests that exercise the piped path opt in by patching isatty to False
17
+ and providing a real stdin StringIO.
18
+ """
19
+ mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
20
+
21
+
22
+ def test_bare_text_routes_to_speak(mocker):
23
+ speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
24
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "hello world"])
25
+ assert cli.main() == 0
26
+ args = speak.call_args.args[0]
27
+ assert args.cmd == "speak"
28
+ assert args.text == "hello world"
29
+
30
+
31
+ def test_speak_subcommand_parses_voice_and_speed(mocker):
32
+ speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
33
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "--voice", "bf_emma", "--speed", "1.2", "hi"])
34
+ assert cli.main() == 0
35
+ args = speak.call_args.args[0]
36
+ assert args.voice == "bf_emma"
37
+ assert args.speed == pytest.approx(1.2)
38
+ assert args.text == "hi"
39
+
40
+
41
+ def test_no_subcommand_prints_help(mocker, capsys):
42
+ mocker.patch.object(cli.sys, "argv", ["stackvox"])
43
+ assert cli.main() == 1
44
+ captured = capsys.readouterr()
45
+ assert "usage: stackvox" in captured.out
46
+
47
+
48
+ def test_unknown_subcommand_treated_as_speak_text(mocker):
49
+ """`stackvox foo` is treated as `stackvox speak foo`, not an error."""
50
+ speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
51
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "hello"])
52
+ cli.main()
53
+ assert speak.call_args.args[0].text == "hello"
54
+
55
+
56
+ def test_piped_stdin_with_no_args_routes_to_speak(mocker):
57
+ """`echo hi | stackvox` should default to speak."""
58
+ speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
59
+ mocker.patch.object(cli.sys, "argv", ["stackvox"])
60
+ mocker.patch.object(cli.sys.stdin, "isatty", return_value=False)
61
+ mocker.patch.object(cli.sys, "stdin", io.StringIO("hello from stdin\n"))
62
+ assert cli.main() == 0
63
+ assert speak.call_args.args[0].cmd == "speak"
64
+
65
+
66
+ def test_read_text_prefers_file_then_positional_then_stdin(mocker, tmp_path):
67
+ """_read_text precedence: --file > positional > piped stdin."""
68
+ file_arg = tmp_path / "src.txt"
69
+ file_arg.write_text("from-file", encoding="utf-8")
70
+
71
+ # Both --file and positional present → file wins.
72
+ args = mocker.MagicMock(file=file_arg, text="from-positional")
73
+ assert cli._read_text(args) == "from-file"
74
+
75
+ # No --file, positional present → positional wins.
76
+ args = mocker.MagicMock(file=None, text="from-positional")
77
+ assert cli._read_text(args) == "from-positional"
78
+
79
+ # No --file, no positional, stdin piped → stdin wins.
80
+ mocker.patch.object(cli.sys.stdin, "isatty", return_value=False)
81
+ mocker.patch.object(cli.sys, "stdin", io.StringIO("from-stdin"))
82
+ args = mocker.MagicMock(file=None, text=None)
83
+ assert cli._read_text(args) == "from-stdin"
84
+
85
+
86
+ def test_completion_bash_emits_complete_script(mocker, capsys):
87
+ mocker.patch.object(cli.sys, "argv", ["stackvox", "completion", "bash"])
88
+ assert cli.main() == 0
89
+ captured = capsys.readouterr()
90
+ assert "_stackvox_completion()" in captured.out
91
+ assert "complete -F _stackvox_completion stackvox" in captured.out
92
+
93
+
94
+ # --- Subcommand handlers ----------------------------------------------------
95
+ #
96
+ # Each subcommand handler is exercised directly with a constructed Namespace
97
+ # so we don't have to reason about argparse behavior for every test. The
98
+ # Stackvox class and the daemon module's surface are mocked out.
99
+
100
+
101
+ @pytest.fixture
102
+ def fake_stackvox(mocker):
103
+ return mocker.patch.object(cli, "Stackvox")
104
+
105
+
106
+ def _ns(**kwargs):
107
+ """Build a minimal argparse.Namespace for handler tests."""
108
+ import argparse
109
+
110
+ return argparse.Namespace(**kwargs)
111
+
112
+
113
+ class TestCmdSpeak:
114
+ def test_with_text_calls_engine_speak(self, fake_stackvox):
115
+ rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hello", out=None))
116
+ assert rc == 0
117
+ fake_stackvox.return_value.speak.assert_called_once_with("hello")
118
+
119
+ def test_with_out_writes_wav_instead_of_playing(self, fake_stackvox, mocker, tmp_path):
120
+ import numpy as np
121
+
122
+ fake_stackvox.return_value.synthesize.return_value = (
123
+ np.zeros(10, dtype=np.float32),
124
+ 24000,
125
+ )
126
+ sf_write = mocker.patch.object(cli.sf, "write")
127
+ out = tmp_path / "out.wav"
128
+
129
+ rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", out=out))
130
+
131
+ assert rc == 0
132
+ sf_write.assert_called_once()
133
+ fake_stackvox.return_value.speak.assert_not_called()
134
+
135
+ def test_blank_input_returns_error(self, fake_stackvox, capsys):
136
+ rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text=" ", out=None))
137
+ assert rc == 1
138
+ assert "provide text" in capsys.readouterr().err
139
+ fake_stackvox.return_value.speak.assert_not_called()
140
+
141
+
142
+ class TestCmdSay:
143
+ def test_returns_zero_when_daemon_accepts(self, mocker):
144
+ mocker.patch.object(cli.daemon, "say", return_value=(True, "ok"))
145
+ rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", fallback_say=False))
146
+ assert rc == 0
147
+
148
+ def test_returns_two_when_daemon_unreachable_and_no_fallback(self, mocker, capsys):
149
+ mocker.patch.object(cli.daemon, "say", return_value=(False, "daemon not running"))
150
+ rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", fallback_say=False))
151
+ assert rc == 2
152
+ assert "daemon not running" in capsys.readouterr().err
153
+
154
+ def test_fallback_say_shells_out_on_macos(self, mocker):
155
+ mocker.patch.object(cli.daemon, "say", return_value=(False, "daemon not running"))
156
+ # Must use which because cli imports inside the function body.
157
+ mocker.patch("shutil.which", return_value="/usr/bin/say")
158
+ run = mocker.patch("subprocess.run")
159
+ rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", fallback_say=True))
160
+ assert rc == 0
161
+ run.assert_called_once()
162
+ assert run.call_args.args[0][0] == "say"
163
+
164
+ def test_fallback_say_without_say_binary_returns_two(self, mocker, capsys):
165
+ mocker.patch.object(cli.daemon, "say", return_value=(False, "daemon not running"))
166
+ mocker.patch("shutil.which", return_value=None)
167
+ rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hi", fallback_say=True))
168
+ assert rc == 2
169
+
170
+ def test_blank_input_returns_one(self, capsys):
171
+ rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="", fallback_say=False))
172
+ assert rc == 1
173
+
174
+
175
+ class TestCmdServe:
176
+ def test_propagates_serve_args(self, mocker):
177
+ serve = mocker.patch.object(cli.daemon, "serve")
178
+ rc = cli._cmd_serve(_ns(voice="bf_emma", speed=1.1, lang="en-gb"))
179
+ assert rc == 0
180
+ serve.assert_called_once_with(voice="bf_emma", speed=1.1, lang="en-gb")
181
+
182
+ def test_returns_one_when_daemon_already_running(self, mocker, capsys):
183
+ mocker.patch.object(cli.daemon, "serve", side_effect=RuntimeError("daemon already running"))
184
+ rc = cli._cmd_serve(_ns(voice="af_sarah", speed=1.0, lang="en-us"))
185
+ assert rc == 1
186
+ assert "already running" in capsys.readouterr().err
187
+
188
+
189
+ class TestCmdStop:
190
+ def test_returns_zero_when_daemon_already_stopped(self, mocker, capsys):
191
+ mocker.patch.object(cli.daemon, "is_running", return_value=False)
192
+ stop = mocker.patch.object(cli.daemon, "stop")
193
+ rc = cli._cmd_stop(_ns())
194
+ assert rc == 0
195
+ stop.assert_not_called()
196
+ assert "not running" in capsys.readouterr().err
197
+
198
+ def test_calls_daemon_stop_when_running(self, mocker):
199
+ mocker.patch.object(cli.daemon, "is_running", return_value=True)
200
+ mocker.patch.object(cli.daemon, "stop", return_value=(True, "ok"))
201
+ assert cli._cmd_stop(_ns()) == 0
202
+
203
+
204
+ class TestCmdStatus:
205
+ def test_running_prints_pid_and_returns_zero(self, mocker, capsys):
206
+ mocker.patch.object(cli.daemon, "is_running", return_value=True)
207
+ # PID_PATH and SOCKET_PATH are read at module level — mock as needed.
208
+ pid_path = mocker.MagicMock()
209
+ pid_path.read_text.return_value = "12345\n"
210
+ mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
211
+ mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
212
+
213
+ rc = cli._cmd_status(_ns())
214
+
215
+ assert rc == 0
216
+ out = capsys.readouterr().out
217
+ assert "running" in out
218
+ assert "12345" in out
219
+
220
+ def test_stopped_returns_one(self, mocker, capsys):
221
+ mocker.patch.object(cli.daemon, "is_running", return_value=False)
222
+ rc = cli._cmd_status(_ns())
223
+ assert rc == 1
224
+ assert "stopped" in capsys.readouterr().out
225
+
226
+
227
+ class TestCmdVoices:
228
+ def test_prints_one_voice_per_line(self, fake_stackvox, capsys):
229
+ fake_stackvox.return_value.voices.return_value = ["af_sarah", "bf_emma"]
230
+ rc = cli._cmd_voices(_ns())
231
+ assert rc == 0
232
+ assert capsys.readouterr().out.split() == ["af_sarah", "bf_emma"]
233
+
234
+
235
+ class TestCmdWelcome:
236
+ def test_calls_speak_sequence_with_welcome_lines(self, fake_stackvox):
237
+ cli._cmd_welcome(_ns())
238
+ fake_stackvox.return_value.speak_sequence.assert_called_once()
239
+ lines = fake_stackvox.return_value.speak_sequence.call_args.args[0]
240
+ # All WELCOME_LINES rows present.
241
+ assert len(lines) == len(cli.WELCOME_LINES)
242
+ # Every entry has text/voice/lang.
243
+ for entry in lines:
244
+ assert {"text", "voice", "lang"} <= entry.keys()
245
+
246
+
247
+ class TestCmdCompletion:
248
+ def test_unsupported_shell_returns_one(self, capsys):
249
+ # argparse choices=["bash"] means we'd never reach here normally;
250
+ # exercise the defensive branch directly.
251
+ rc = cli._cmd_completion(_ns(shell="fish"))
252
+ assert rc == 1
253
+ assert "unsupported shell" in capsys.readouterr().err
254
+
255
+
256
+ class TestCmdInstallHelper:
257
+ def test_copies_helper_to_prefix_with_exec_bit(self, tmp_path):
258
+ prefix = tmp_path / "bin"
259
+ rc = cli._cmd_install_helper(_ns(prefix=prefix))
260
+
261
+ assert rc == 0
262
+ dest = prefix / "stackvox-say"
263
+ assert dest.exists()
264
+ # First line should be the bash shebang from the bundled script.
265
+ assert dest.read_text(encoding="utf-8").startswith("#!/bin/bash")
266
+ # Owner-execute bit is set.
267
+ import stat as st
268
+
269
+ assert dest.stat().st_mode & st.S_IXUSR
270
+
271
+ def test_creates_prefix_dir_if_missing(self, tmp_path):
272
+ prefix = tmp_path / "deep" / "nested" / "bin"
273
+ assert not prefix.exists()
274
+ rc = cli._cmd_install_helper(_ns(prefix=prefix))
275
+ assert rc == 0
276
+ assert (prefix / "stackvox-say").is_file()
277
+
278
+ def test_warns_when_prefix_not_on_path(self, tmp_path, mocker, capsys):
279
+ prefix = tmp_path / "out-of-path"
280
+ # Force PATH to a value that definitely doesn't include `prefix`.
281
+ mocker.patch.dict("os.environ", {"PATH": "/usr/bin:/bin"}, clear=False)
282
+ cli._cmd_install_helper(_ns(prefix=prefix))
283
+ err = capsys.readouterr().err
284
+ assert "not on your PATH" in err
285
+
286
+ def test_no_warning_when_prefix_on_path(self, tmp_path, mocker, capsys):
287
+ prefix = tmp_path / "on-path"
288
+ prefix.mkdir(parents=True)
289
+ # Put `prefix` on PATH so the warning shouldn't fire.
290
+ mocker.patch.dict("os.environ", {"PATH": f"{prefix}:/usr/bin"}, clear=False)
291
+ cli._cmd_install_helper(_ns(prefix=prefix))
292
+ assert "not on your PATH" not in capsys.readouterr().err
@@ -0,0 +1,138 @@
1
+ """Daemon tests for non-protocol surface — pid/socket helpers, send/say/stop/ping
2
+ client, _refresh_audio_devices, and the cross-platform watcher entry point.
3
+ The protocol/handler tests live in test_daemon_protocol.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+ import pytest
11
+
12
+ from stackvox import daemon
13
+
14
+
15
+ class TestPidAlive:
16
+ def test_self_pid_is_alive(self):
17
+ assert daemon._pid_alive(os.getpid()) is True
18
+
19
+ def test_unused_pid_is_not_alive(self):
20
+ # PID 0 is reserved on macOS/Linux and never owned by a real process.
21
+ # `os.kill(0, 0)` is special-cased (signals the whole process group),
22
+ # so use a high PID we can be confident is unused.
23
+ assert daemon._pid_alive(2**31 - 1) is False
24
+
25
+
26
+ class TestIsRunning:
27
+ def test_returns_false_when_pid_file_missing(self, mocker, tmp_path):
28
+ mocker.patch.object(daemon, "PID_PATH", tmp_path / "missing.pid")
29
+ assert daemon.is_running() is False
30
+
31
+ def test_returns_false_when_pid_file_unreadable(self, mocker, tmp_path):
32
+ pid = tmp_path / "garbage.pid"
33
+ pid.write_text("not-a-number")
34
+ mocker.patch.object(daemon, "PID_PATH", pid)
35
+ assert daemon.is_running() is False
36
+
37
+ def test_returns_true_when_pid_alive(self, mocker, tmp_path):
38
+ pid = tmp_path / "live.pid"
39
+ pid.write_text(str(os.getpid()))
40
+ mocker.patch.object(daemon, "PID_PATH", pid)
41
+ assert daemon.is_running() is True
42
+
43
+ def test_returns_false_when_pid_dead(self, mocker, tmp_path):
44
+ pid = tmp_path / "dead.pid"
45
+ pid.write_text(str(2**31 - 1))
46
+ mocker.patch.object(daemon, "PID_PATH", pid)
47
+ assert daemon.is_running() is False
48
+
49
+
50
+ class TestSendHelpers:
51
+ """The thin send/say/stop/ping wrappers around the unix-socket protocol."""
52
+
53
+ def test_send_returns_failure_when_socket_missing(self, mocker, tmp_path):
54
+ mocker.patch.object(daemon, "SOCKET_PATH", tmp_path / "nope.sock")
55
+ ok, resp = daemon.send({"command": "ping"})
56
+ assert ok is False
57
+ assert resp == "daemon not running"
58
+
59
+ def test_say_includes_only_supplied_overrides(self, mocker):
60
+ send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
61
+ daemon.say("hello", voice="bf_emma")
62
+ payload = send.call_args.args[0]
63
+ assert payload == {"text": "hello", "voice": "bf_emma"}
64
+
65
+ def test_say_with_all_overrides(self, mocker):
66
+ send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
67
+ daemon.say("hi", voice="af_sarah", speed=1.5, lang="en-us")
68
+ assert send.call_args.args[0] == {
69
+ "text": "hi",
70
+ "voice": "af_sarah",
71
+ "speed": 1.5,
72
+ "lang": "en-us",
73
+ }
74
+
75
+ def test_say_without_overrides_passes_only_text(self, mocker):
76
+ send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
77
+ daemon.say("hi")
78
+ assert send.call_args.args[0] == {"text": "hi"}
79
+
80
+ def test_stop_sends_command_stop(self, mocker):
81
+ send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
82
+ daemon.stop()
83
+ assert send.call_args.args[0] == {"command": "stop"}
84
+
85
+ def test_ping_uses_short_timeout(self, mocker):
86
+ send = mocker.patch.object(daemon, "send", return_value=(True, "ok"))
87
+ daemon.ping()
88
+ assert send.call_args.kwargs["timeout"] == daemon.PING_TIMEOUT_SECONDS
89
+
90
+
91
+ class TestRefreshAudioDevices:
92
+ def test_calls_terminate_then_initialize(self, mocker):
93
+ terminate = mocker.patch.object(daemon.sd, "_terminate")
94
+ initialize = mocker.patch.object(daemon.sd, "_initialize")
95
+
96
+ daemon._refresh_audio_devices()
97
+
98
+ terminate.assert_called_once()
99
+ initialize.assert_called_once()
100
+
101
+ def test_swallows_and_logs_errors(self, mocker, caplog):
102
+ """A failure in the audio reset must not propagate out — the worker
103
+ retries on the next request."""
104
+ import logging
105
+
106
+ mocker.patch.object(daemon.sd, "_terminate", side_effect=RuntimeError("portaudio gone"))
107
+ mocker.patch.object(daemon.sd, "_initialize")
108
+
109
+ with caplog.at_level(logging.ERROR, logger="stackvox.daemon"):
110
+ daemon._refresh_audio_devices() # must not raise
111
+
112
+ assert any("failed to refresh audio devices" in r.message for r in caplog.records)
113
+
114
+
115
+ class TestStartDeviceWatcher:
116
+ def test_no_op_on_non_darwin(self, mocker):
117
+ """On Linux/Windows the function returns immediately; nothing should
118
+ be loaded from CoreAudio and no thread should be started."""
119
+ mocker.patch.object(daemon.sys, "platform", "linux")
120
+ cdll = mocker.patch.object(daemon.ctypes, "CDLL")
121
+
122
+ daemon._start_device_watcher()
123
+
124
+ cdll.assert_not_called()
125
+
126
+ @pytest.mark.skipif(
127
+ not hasattr(daemon.sys, "platform") or daemon.sys.platform != "darwin",
128
+ reason="macOS-only behaviour",
129
+ )
130
+ def test_disabled_when_coreaudio_unavailable(self, mocker, caplog):
131
+ """If CDLL fails to load CoreAudio (unusual on real macOS but possible
132
+ in stripped environments), the watcher should bail without raising."""
133
+ import logging
134
+
135
+ mocker.patch.object(daemon.ctypes, "CDLL", side_effect=OSError("no coreaudio"))
136
+ with caplog.at_level(logging.DEBUG, logger="stackvox.daemon"):
137
+ daemon._start_device_watcher() # must not raise
138
+ assert any("CoreAudio unavailable" in r.message for r in caplog.records)
@@ -0,0 +1,241 @@
1
+ """Engine tests — no real model load, no audio device.
2
+
3
+ We mock at two boundaries: `Kokoro` (so `Stackvox.__init__` doesn't pull the
4
+ model) and `sounddevice` (so `speak()` doesn't try to talk to PortAudio).
5
+ This lets us verify call-shape and side effects without any of the heavy
6
+ runtime dependencies.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+
13
+ import numpy as np
14
+ import pytest
15
+
16
+ from stackvox import engine
17
+
18
+
19
+ @pytest.fixture
20
+ def fake_ensure_models(mocker, tmp_path):
21
+ """Short-circuit _ensure_models so Stackvox.__init__ skips the download."""
22
+ model = tmp_path / "kokoro.onnx"
23
+ voices = tmp_path / "voices.bin"
24
+ model.touch()
25
+ voices.touch()
26
+ return mocker.patch.object(engine, "_ensure_models", return_value=(model, voices))
27
+
28
+
29
+ @pytest.fixture
30
+ def fake_kokoro(mocker, fake_ensure_models):
31
+ """Mock Kokoro on top of fake_ensure_models — most tests want both."""
32
+ return mocker.patch.object(engine, "Kokoro")
33
+
34
+
35
+ @pytest.fixture
36
+ def fake_audio(mocker):
37
+ """Mock the sounddevice surface used by Stackvox."""
38
+ mocker.patch.object(engine.sd, "play")
39
+ mocker.patch.object(engine.sd, "wait")
40
+ mocker.patch.object(engine.sd, "stop")
41
+
42
+
43
+ @pytest.fixture(autouse=True)
44
+ def reset_default():
45
+ """Each test starts with no module-level singleton."""
46
+ engine._default = None
47
+ yield
48
+ engine._default = None
49
+
50
+
51
+ class TestStackvoxInit:
52
+ def test_stores_voice_speed_lang(self, fake_kokoro):
53
+ tts = engine.Stackvox(voice="bf_emma", speed=1.2, lang="en-gb")
54
+ assert tts.voice == "bf_emma"
55
+ assert tts.speed == 1.2
56
+ assert tts.lang == "en-gb"
57
+
58
+ def test_uses_custom_cache_dir(self, fake_kokoro, fake_ensure_models, tmp_path):
59
+ custom = tmp_path / "custom-cache"
60
+ engine.Stackvox(cache_dir=custom)
61
+ fake_ensure_models.assert_called_once_with(custom)
62
+
63
+ def test_falls_back_to_default_cache_dir(self, fake_kokoro, fake_ensure_models, mocker, tmp_path):
64
+ default = tmp_path / "default"
65
+ mocker.patch.object(engine, "_default_cache_dir", return_value=default)
66
+ engine.Stackvox()
67
+ fake_ensure_models.assert_called_once_with(default)
68
+
69
+
70
+ class TestSynthesize:
71
+ def test_passes_engine_defaults_to_kokoro(self, fake_kokoro):
72
+ samples = np.zeros(10, dtype=np.float32)
73
+ fake_kokoro.return_value.create.return_value = (samples, 24000)
74
+
75
+ tts = engine.Stackvox(voice="af_sarah", speed=1.0, lang="en-us")
76
+ result_samples, result_sr = tts.synthesize("hi")
77
+
78
+ fake_kokoro.return_value.create.assert_called_once_with(
79
+ "hi", voice="af_sarah", speed=1.0, lang="en-us"
80
+ )
81
+ assert result_sr == 24000
82
+ assert np.array_equal(result_samples, samples)
83
+
84
+ def test_per_call_overrides_take_priority(self, fake_kokoro):
85
+ fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
86
+ tts = engine.Stackvox(voice="af_sarah", speed=1.0, lang="en-us")
87
+ tts.synthesize("hi", voice="bf_emma", speed=1.5, lang="en-gb")
88
+ fake_kokoro.return_value.create.assert_called_once_with(
89
+ "hi", voice="bf_emma", speed=1.5, lang="en-gb"
90
+ )
91
+
92
+ def test_speed_zero_override_is_respected(self, fake_kokoro):
93
+ """speed=0 is falsy but a valid override; ensure it's not silently dropped."""
94
+ fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
95
+ tts = engine.Stackvox(speed=1.0)
96
+ tts.synthesize("hi", speed=0.5)
97
+ kwargs = fake_kokoro.return_value.create.call_args.kwargs
98
+ assert kwargs["speed"] == 0.5
99
+
100
+
101
+ class TestSpeak:
102
+ def test_blocking_calls_play_and_wait(self, fake_kokoro, fake_audio):
103
+ fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
104
+ tts = engine.Stackvox()
105
+ tts.speak("hi")
106
+ engine.sd.play.assert_called_once()
107
+ engine.sd.wait.assert_called_once()
108
+
109
+ def test_non_blocking_skips_wait(self, fake_kokoro, fake_audio):
110
+ fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
111
+ tts = engine.Stackvox()
112
+ tts.speak("hi", blocking=False)
113
+ engine.sd.play.assert_called_once()
114
+ engine.sd.wait.assert_not_called()
115
+
116
+ def test_stop_calls_sounddevice_stop(self, fake_kokoro, fake_audio):
117
+ engine.Stackvox().stop()
118
+ engine.sd.stop.assert_called_once()
119
+
120
+
121
+ class TestVoices:
122
+ def test_returns_sorted_voice_ids(self, fake_kokoro):
123
+ fake_kokoro.return_value.get_voices.return_value = ["bf_emma", "af_sarah", "am_adam"]
124
+ tts = engine.Stackvox()
125
+ assert tts.voices() == ["af_sarah", "am_adam", "bf_emma"]
126
+
127
+
128
+ class TestSpeakSequence:
129
+ def test_empty_lines_returns_early(self, fake_kokoro, fake_audio):
130
+ engine.Stackvox().speak_sequence([])
131
+ engine.sd.play.assert_not_called()
132
+
133
+ def test_concatenates_segments_with_optional_gap(self, fake_kokoro, fake_audio):
134
+ a = np.array([1.0, 1.0, 1.0], dtype=np.float32)
135
+ b = np.array([2.0, 2.0], dtype=np.float32)
136
+ fake_kokoro.return_value.create.side_effect = [(a, 100), (b, 100)]
137
+
138
+ tts = engine.Stackvox()
139
+ tts.speak_sequence([{"text": "first"}, {"text": "second"}], gap_seconds=0.05, concurrent=False)
140
+
141
+ played = engine.sd.play.call_args.args[0]
142
+ # Length: 3 + (100 * 0.05) silence + 2 = 10 samples.
143
+ assert len(played) == 3 + 5 + 2
144
+ # First 3 samples are `a`, last 2 are `b`, middle 5 are silence.
145
+ np.testing.assert_array_equal(played[:3], a)
146
+ np.testing.assert_array_equal(played[-2:], b)
147
+ np.testing.assert_array_equal(played[3:8], np.zeros(5, dtype=np.float32))
148
+
149
+
150
+ class TestModuleLevelHelpers:
151
+ def test_speak_reuses_module_singleton(self, fake_kokoro, fake_audio):
152
+ fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
153
+ engine.speak("first")
154
+ engine.speak("second")
155
+ # Stackvox class only instantiated once → Kokoro only constructed once.
156
+ assert fake_kokoro.call_count == 1
157
+
158
+ def test_synthesize_reuses_module_singleton(self, fake_kokoro):
159
+ fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
160
+ engine.synthesize("first")
161
+ engine.synthesize("second")
162
+ assert fake_kokoro.call_count == 1
163
+
164
+ def test_get_default_is_thread_safe(self, fake_kokoro):
165
+ """Concurrent first calls must not double-instantiate Stackvox.
166
+
167
+ Without the lock around _get_default, multiple threads calling
168
+ speak()/synthesize() at process start can both observe `_default is
169
+ None` and each construct a Stackvox (each loading the 340 MB model).
170
+ """
171
+ fake_kokoro.return_value.create.return_value = (np.zeros(10), 24000)
172
+ barrier = threading.Barrier(8)
173
+
174
+ def race():
175
+ barrier.wait()
176
+ engine._get_default()
177
+
178
+ threads = [threading.Thread(target=race) for _ in range(8)]
179
+ for t in threads:
180
+ t.start()
181
+ for t in threads:
182
+ t.join()
183
+
184
+ assert fake_kokoro.call_count == 1
185
+
186
+
187
+ class TestDownloadProgress:
188
+ def test_reporthook_prints_percentage_when_size_known(self, mocker, capsys, tmp_path):
189
+ """The hook should print updating progress lines while downloading."""
190
+ captured_hook = {}
191
+
192
+ def fake_urlretrieve(url, dest, reporthook=None):
193
+ captured_hook["fn"] = reporthook
194
+ # Simulate three chunks of a 1000-byte download.
195
+ if reporthook:
196
+ reporthook(0, 100, 1000)
197
+ reporthook(5, 100, 1000)
198
+ reporthook(10, 100, 1000)
199
+
200
+ mocker.patch.object(engine.urllib.request, "urlretrieve", side_effect=fake_urlretrieve)
201
+ engine._download_with_progress("http://example/m.bin", tmp_path / "m.bin")
202
+
203
+ err = capsys.readouterr().err
204
+ assert "downloading m.bin" in err
205
+ assert "100%" in err
206
+
207
+ def test_unknown_size_does_not_print(self, mocker, capsys, tmp_path):
208
+ """totalsize <= 0 means Content-Length absent; hook should be a no-op."""
209
+
210
+ def fake_urlretrieve(url, dest, reporthook=None):
211
+ if reporthook:
212
+ reporthook(0, 100, -1)
213
+
214
+ mocker.patch.object(engine.urllib.request, "urlretrieve", side_effect=fake_urlretrieve)
215
+ engine._download_with_progress("http://example/m.bin", tmp_path / "m.bin")
216
+
217
+ # No percentage should have been emitted because total size was unknown.
218
+ assert "downloading" not in capsys.readouterr().err
219
+
220
+
221
+ class TestEnsureModels:
222
+ def test_skips_files_that_already_exist(self, mocker, tmp_path):
223
+ download = mocker.patch.object(engine, "_download_with_progress")
224
+ (tmp_path / "kokoro-v1.0.onnx").touch()
225
+ (tmp_path / "voices-v1.0.bin").touch()
226
+
227
+ engine._ensure_models(tmp_path)
228
+
229
+ download.assert_not_called()
230
+
231
+ def test_downloads_only_missing_files(self, mocker, tmp_path):
232
+ download = mocker.patch.object(engine, "_download_with_progress")
233
+ # Only the model exists; voices needs downloading.
234
+ (tmp_path / "kokoro-v1.0.onnx").touch()
235
+
236
+ engine._ensure_models(tmp_path)
237
+
238
+ # Exactly one download, for the voices file.
239
+ assert download.call_count == 1
240
+ url_arg, dest_arg = download.call_args.args
241
+ assert dest_arg.name == "voices-v1.0.bin"
@@ -1,91 +0,0 @@
1
- """CLI argument parsing — no engine loaded, no audio played."""
2
-
3
- from __future__ import annotations
4
-
5
- import io
6
-
7
- import pytest
8
-
9
- from stackvox import cli
10
-
11
-
12
- @pytest.fixture(autouse=True)
13
- def _stdin_is_a_tty(mocker):
14
- """Default tests to a TTY stdin so the new piped-stdin shortcut is off.
15
-
16
- Tests that exercise the piped path opt in by patching isatty to False
17
- and providing a real stdin StringIO.
18
- """
19
- mocker.patch.object(cli.sys.stdin, "isatty", return_value=True)
20
-
21
-
22
- def test_bare_text_routes_to_speak(mocker):
23
- speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
24
- mocker.patch.object(cli.sys, "argv", ["stackvox", "hello world"])
25
- assert cli.main() == 0
26
- args = speak.call_args.args[0]
27
- assert args.cmd == "speak"
28
- assert args.text == "hello world"
29
-
30
-
31
- def test_speak_subcommand_parses_voice_and_speed(mocker):
32
- speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
33
- mocker.patch.object(cli.sys, "argv", ["stackvox", "speak", "--voice", "bf_emma", "--speed", "1.2", "hi"])
34
- assert cli.main() == 0
35
- args = speak.call_args.args[0]
36
- assert args.voice == "bf_emma"
37
- assert args.speed == pytest.approx(1.2)
38
- assert args.text == "hi"
39
-
40
-
41
- def test_no_subcommand_prints_help(mocker, capsys):
42
- mocker.patch.object(cli.sys, "argv", ["stackvox"])
43
- assert cli.main() == 1
44
- captured = capsys.readouterr()
45
- assert "usage: stackvox" in captured.out
46
-
47
-
48
- def test_unknown_subcommand_treated_as_speak_text(mocker):
49
- """`stackvox foo` is treated as `stackvox speak foo`, not an error."""
50
- speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
51
- mocker.patch.object(cli.sys, "argv", ["stackvox", "hello"])
52
- cli.main()
53
- assert speak.call_args.args[0].text == "hello"
54
-
55
-
56
- def test_piped_stdin_with_no_args_routes_to_speak(mocker):
57
- """`echo hi | stackvox` should default to speak."""
58
- speak = mocker.patch.object(cli, "_cmd_speak", return_value=0)
59
- mocker.patch.object(cli.sys, "argv", ["stackvox"])
60
- mocker.patch.object(cli.sys.stdin, "isatty", return_value=False)
61
- mocker.patch.object(cli.sys, "stdin", io.StringIO("hello from stdin\n"))
62
- assert cli.main() == 0
63
- assert speak.call_args.args[0].cmd == "speak"
64
-
65
-
66
- def test_read_text_prefers_file_then_positional_then_stdin(mocker, tmp_path):
67
- """_read_text precedence: --file > positional > piped stdin."""
68
- file_arg = tmp_path / "src.txt"
69
- file_arg.write_text("from-file", encoding="utf-8")
70
-
71
- # Both --file and positional present → file wins.
72
- args = mocker.MagicMock(file=file_arg, text="from-positional")
73
- assert cli._read_text(args) == "from-file"
74
-
75
- # No --file, positional present → positional wins.
76
- args = mocker.MagicMock(file=None, text="from-positional")
77
- assert cli._read_text(args) == "from-positional"
78
-
79
- # No --file, no positional, stdin piped → stdin wins.
80
- mocker.patch.object(cli.sys.stdin, "isatty", return_value=False)
81
- mocker.patch.object(cli.sys, "stdin", io.StringIO("from-stdin"))
82
- args = mocker.MagicMock(file=None, text=None)
83
- assert cli._read_text(args) == "from-stdin"
84
-
85
-
86
- def test_completion_bash_emits_complete_script(mocker, capsys):
87
- mocker.patch.object(cli.sys, "argv", ["stackvox", "completion", "bash"])
88
- assert cli.main() == 0
89
- captured = capsys.readouterr()
90
- assert "_stackvox_completion()" in captured.out
91
- assert "complete -F _stackvox_completion stackvox" in captured.out
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