vocalize-cli 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/PKG-INFO +3 -2
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/README.md +2 -1
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_wizard.py +93 -7
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/__init__.py +1 -1
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/wizard.py +114 -46
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/.env.example +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/.github/workflows/ci.yml +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/.gitignore +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/LICENSE +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/hooks/claude_stop_hook.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/hooks/install_hook.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/pyproject.toml +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/conftest.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_audio.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_claude_stop_hook.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_cli.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_config.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_install_hook.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_preprocess.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/tests/test_tts.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/__main__.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/audio.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/cli.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/config.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/exceptions.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/preprocess.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.2.1}/vocalize/tts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vocalize-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A CLI that turns text, markdown, or piped stdin into speech via the ElevenLabs API, with markdown-table-aware preprocessing.
|
|
5
5
|
Project-URL: Homepage, https://github.com/matthager12-collab/vocalize
|
|
6
6
|
Project-URL: Repository, https://github.com/matthager12-collab/vocalize
|
|
@@ -133,7 +133,8 @@ the highlighted one), model, and speed — shows you a summary, and writes the
|
|
|
133
133
|
config file below. Unrecognised top-level keys already in that file are
|
|
134
134
|
carried through; comments and layout are not preserved. A file containing a
|
|
135
135
|
TOML table or array is left alone entirely, with a message saying to edit it
|
|
136
|
-
by hand.
|
|
136
|
+
by hand. The wizard paints on the controlling terminal rather than on stdout,
|
|
137
|
+
so it still works under output-capturing wrappers like `op run`.
|
|
137
138
|
|
|
138
139
|
```bash
|
|
139
140
|
vocalize config
|
|
@@ -100,7 +100,8 @@ the highlighted one), model, and speed — shows you a summary, and writes the
|
|
|
100
100
|
config file below. Unrecognised top-level keys already in that file are
|
|
101
101
|
carried through; comments and layout are not preserved. A file containing a
|
|
102
102
|
TOML table or array is left alone entirely, with a message saying to edit it
|
|
103
|
-
by hand.
|
|
103
|
+
by hand. The wizard paints on the controlling terminal rather than on stdout,
|
|
104
|
+
so it still works under output-capturing wrappers like `op run`.
|
|
104
105
|
|
|
105
106
|
```bash
|
|
106
107
|
vocalize config
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import io
|
|
1
2
|
import sys
|
|
2
3
|
from types import SimpleNamespace
|
|
3
4
|
|
|
@@ -27,6 +28,17 @@ class FakeStdin:
|
|
|
27
28
|
return self._tty
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
class FakeTTY(io.StringIO):
|
|
32
|
+
"""A /dev/tty stand-in that stays readable after the wizard closes it."""
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.closed_by_wizard = False
|
|
37
|
+
|
|
38
|
+
def close(self):
|
|
39
|
+
self.closed_by_wizard = True
|
|
40
|
+
|
|
41
|
+
|
|
30
42
|
class Keyboard:
|
|
31
43
|
"""A scripted keyboard: each getchar() call returns the next key."""
|
|
32
44
|
|
|
@@ -42,8 +54,22 @@ class Keyboard:
|
|
|
42
54
|
return key
|
|
43
55
|
|
|
44
56
|
|
|
45
|
-
def _setup(
|
|
46
|
-
|
|
57
|
+
def _setup(
|
|
58
|
+
monkeypatch,
|
|
59
|
+
tmp_path,
|
|
60
|
+
keys,
|
|
61
|
+
*,
|
|
62
|
+
voices=None,
|
|
63
|
+
prompts=(),
|
|
64
|
+
confirm=True,
|
|
65
|
+
api_key="fake-key",
|
|
66
|
+
patch_ui=True,
|
|
67
|
+
):
|
|
68
|
+
"""Point the wizard at a throwaway config file and a scripted keyboard.
|
|
69
|
+
|
|
70
|
+
patch_ui=True aims the wizard's UI stream at sys.stdout, so capsys sees
|
|
71
|
+
the frames; the tty-seam tests pass False and drive the real factory.
|
|
72
|
+
"""
|
|
47
73
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
|
48
74
|
for var in ("VOCALIZE_VOICE", "VOCALIZE_MODEL", "VOCALIZE_SPEED"):
|
|
49
75
|
monkeypatch.delenv(var, raising=False)
|
|
@@ -51,19 +77,21 @@ def _setup(monkeypatch, tmp_path, keys, *, voices=None, prompts=(), confirm=True
|
|
|
51
77
|
monkeypatch.setenv("LINES", "40")
|
|
52
78
|
|
|
53
79
|
monkeypatch.setattr(sys, "stdin", FakeStdin(tty=True))
|
|
54
|
-
|
|
80
|
+
if patch_ui:
|
|
81
|
+
# Resolved lazily: sys.stdout is whatever capsys has installed.
|
|
82
|
+
monkeypatch.setattr(wizard, "_open_ui_stream", lambda: (sys.stdout, False))
|
|
55
83
|
keyboard = Keyboard(keys)
|
|
56
84
|
monkeypatch.setattr(click, "getchar", keyboard)
|
|
57
85
|
|
|
58
86
|
answers = list(prompts)
|
|
59
87
|
|
|
60
|
-
def
|
|
88
|
+
def fake_ask(ui, label, **kwargs):
|
|
61
89
|
if not answers:
|
|
62
|
-
raise AssertionError(f"unexpected prompt: {
|
|
90
|
+
raise AssertionError(f"unexpected prompt: {label}")
|
|
63
91
|
return answers.pop(0)
|
|
64
92
|
|
|
65
|
-
monkeypatch.setattr(
|
|
66
|
-
monkeypatch.setattr(
|
|
93
|
+
monkeypatch.setattr(wizard, "_ask", fake_ask)
|
|
94
|
+
monkeypatch.setattr(wizard, "_confirm", lambda *args, **kwargs: confirm)
|
|
67
95
|
|
|
68
96
|
if api_key is None:
|
|
69
97
|
|
|
@@ -282,6 +310,64 @@ def test_keep_current_names_the_file_value_not_the_env_var(monkeypatch, tmp_path
|
|
|
282
310
|
assert ctx.path.read_text() == 'voice = "from-the-file"\n'
|
|
283
311
|
|
|
284
312
|
|
|
313
|
+
def _piped(monkeypatch):
|
|
314
|
+
"""stdout relayed down a pipe (`op run`), with /dev/tty still openable."""
|
|
315
|
+
tty = FakeTTY()
|
|
316
|
+
monkeypatch.setattr(sys, "stdout", io.StringIO())
|
|
317
|
+
monkeypatch.setattr(wizard, "_open_tty", lambda: tty)
|
|
318
|
+
assert not sys.stdout.isatty()
|
|
319
|
+
return tty
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_piped_stdout_paints_to_dev_tty(monkeypatch, tmp_path):
|
|
323
|
+
keys = [DOWN, ENTER, DOWN, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER]
|
|
324
|
+
ctx = _setup(monkeypatch, tmp_path, keys, patch_ui=False)
|
|
325
|
+
tty = _piped(monkeypatch)
|
|
326
|
+
|
|
327
|
+
wizard.run_wizard() # not refused: the keyboard and /dev/tty are both there
|
|
328
|
+
|
|
329
|
+
painted = tty.getvalue()
|
|
330
|
+
assert "\x1b[2J\x1b[H" in painted # our own clear; click.clear() would no-op
|
|
331
|
+
assert "Step 1 of 3 — Voice" in painted
|
|
332
|
+
assert "Step 2 of 3 — Model" in painted
|
|
333
|
+
assert "Step 3 of 3 — Speed" in painted
|
|
334
|
+
assert wizard.VOICE_HOTKEYS in painted
|
|
335
|
+
assert "About to write:" in painted
|
|
336
|
+
# None of it went down the pipe, and the stream we opened got closed
|
|
337
|
+
assert "Step 1 of 3 — Voice" not in sys.stdout.getvalue()
|
|
338
|
+
assert tty.closed_by_wizard
|
|
339
|
+
assert ctx.path.read_text() == (
|
|
340
|
+
'voice = "abc123"\n'
|
|
341
|
+
'model = "eleven_flash_v2_5"\n'
|
|
342
|
+
"speed = 0.9\n"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def test_headless_still_refused(monkeypatch, tmp_path):
|
|
347
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
|
348
|
+
monkeypatch.setattr(sys, "stdin", FakeStdin(tty=False))
|
|
349
|
+
opened = []
|
|
350
|
+
monkeypatch.setattr(wizard, "_open_tty", lambda: opened.append(True))
|
|
351
|
+
|
|
352
|
+
with pytest.raises(ConfigError, match="interactive terminal"):
|
|
353
|
+
wizard.run_wizard()
|
|
354
|
+
|
|
355
|
+
assert opened == [] # a dead stdin is decided on its own, before any tty
|
|
356
|
+
assert not (tmp_path / "config" / "vocalize" / "config.toml").exists()
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_confirmation_line_reaches_stdout_too(monkeypatch, tmp_path):
|
|
360
|
+
keys = [DOWN, ENTER, DOWN, ENTER, DOWN, DOWN, DOWN, DOWN, ENTER]
|
|
361
|
+
ctx = _setup(monkeypatch, tmp_path, keys, patch_ui=False)
|
|
362
|
+
tty = _piped(monkeypatch)
|
|
363
|
+
|
|
364
|
+
wizard.run_wizard()
|
|
365
|
+
|
|
366
|
+
assert f"Wrote {ctx.path}" in tty.getvalue()
|
|
367
|
+
# Wrappers and logs only capture stdout, so the outcome has to land there
|
|
368
|
+
assert sys.stdout.getvalue().strip() == f"Wrote {ctx.path}"
|
|
369
|
+
|
|
370
|
+
|
|
285
371
|
def test_the_current_voice_starts_under_the_cursor(monkeypatch, tmp_path, capsys):
|
|
286
372
|
ctx = _setup(monkeypatch, tmp_path, [ENTER, ENTER, ENTER])
|
|
287
373
|
monkeypatch.setenv("VOCALIZE_VOICE", "def456") # after _setup, which clears it
|
|
@@ -4,6 +4,13 @@ Three steps — voice, model, speed — each a keyboard-driven list rendered
|
|
|
4
4
|
with click.echo and driven by click.getchar(). click is already a
|
|
5
5
|
dependency, so this needs no curses, prompt_toolkit, or anything else.
|
|
6
6
|
|
|
7
|
+
Every frame is painted on the controlling terminal rather than on
|
|
8
|
+
stdout. Wrappers that relay a child's output — `op run`, the documented
|
|
9
|
+
way this project injects its API key — leave sys.stdout non-tty while
|
|
10
|
+
the keyboard still works, which makes click.clear() a no-op and lets the
|
|
11
|
+
relay's writes land in the middle of a raw-mode read. Painting at
|
|
12
|
+
/dev/tty sidesteps both.
|
|
13
|
+
|
|
7
14
|
This module owns the *interaction* only. Every default, bound, and
|
|
8
15
|
validation rule still comes from vocalize.config, and the voice list and
|
|
9
16
|
previews go through the same tts/audio functions the rest of the CLI
|
|
@@ -61,15 +68,61 @@ _CHROME_LINES = 8
|
|
|
61
68
|
_KEEP = object()
|
|
62
69
|
_UNSET = object()
|
|
63
70
|
|
|
71
|
+
_NO_TERMINAL = (
|
|
72
|
+
"The config wizard needs an interactive terminal. "
|
|
73
|
+
"Run `vocalize config` in a terminal, not from a pipe or a script."
|
|
74
|
+
)
|
|
75
|
+
|
|
64
76
|
|
|
65
77
|
class _Cancelled(Exception):
|
|
66
78
|
"""Raised inside a step on q, Escape, or EOF."""
|
|
67
79
|
|
|
68
80
|
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
def _open_tty():
|
|
82
|
+
"""The controlling terminal as a writable stream, or None."""
|
|
83
|
+
try:
|
|
84
|
+
return open("/dev/tty", "w", encoding="utf-8")
|
|
85
|
+
except OSError:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _open_ui_stream():
|
|
90
|
+
"""Where to paint the wizard: (stream, did_we_open_it).
|
|
91
|
+
|
|
92
|
+
Returns (None, False) when there's no terminal to paint on at all.
|
|
93
|
+
"""
|
|
94
|
+
stdout = sys.stdout
|
|
95
|
+
if stdout is not None and stdout.isatty():
|
|
96
|
+
return stdout, False
|
|
97
|
+
tty = _open_tty()
|
|
98
|
+
return (tty, True) if tty is not None else (None, False)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _clear(ui) -> None:
|
|
102
|
+
# click.clear() only ever writes to stdout, and no-ops when stdout
|
|
103
|
+
# isn't a tty — which is the whole case this wizard has to survive.
|
|
104
|
+
ui.write("\x1b[2J\x1b[H")
|
|
105
|
+
ui.flush()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ask(ui, label: str) -> str:
|
|
109
|
+
"""Read one typed line. The tty driver echoes the typing itself."""
|
|
110
|
+
click.echo(f"{label}: ", file=ui, nl=False)
|
|
111
|
+
try:
|
|
112
|
+
return input()
|
|
113
|
+
except (EOFError, KeyboardInterrupt):
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _confirm(ui, label: str) -> bool:
|
|
118
|
+
"""Default no: an empty answer, or an EOF, must not write the file."""
|
|
119
|
+
return _ask(ui, f"{label} [y/N]").strip().lower() in ("y", "yes")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _render(ui, title: str, rows: list, cursor: int, legend: str, notes: list) -> None:
|
|
123
|
+
_clear(ui)
|
|
124
|
+
click.echo(title, file=ui)
|
|
125
|
+
click.echo(file=ui)
|
|
73
126
|
|
|
74
127
|
# A full ElevenLabs voice list is longer than an 80x24 terminal, so the
|
|
75
128
|
# list is windowed onto the cursor rather than echoed whole — otherwise
|
|
@@ -79,21 +132,21 @@ def _render(title: str, rows: list, cursor: int, legend: str, notes: list) -> No
|
|
|
79
132
|
end = min(start + height, len(rows))
|
|
80
133
|
|
|
81
134
|
if start > 0:
|
|
82
|
-
click.echo(" …")
|
|
135
|
+
click.echo(" …", file=ui)
|
|
83
136
|
for index in range(start, end):
|
|
84
|
-
click.echo(f"{'>' if index == cursor else ' '} {rows[index][1]}")
|
|
137
|
+
click.echo(f"{'>' if index == cursor else ' '} {rows[index][1]}", file=ui)
|
|
85
138
|
if end < len(rows):
|
|
86
|
-
click.echo(" …")
|
|
139
|
+
click.echo(" …", file=ui)
|
|
87
140
|
|
|
88
|
-
click.echo()
|
|
141
|
+
click.echo(file=ui)
|
|
89
142
|
if notes:
|
|
90
143
|
for note in notes:
|
|
91
|
-
click.echo(note)
|
|
92
|
-
click.echo()
|
|
93
|
-
click.echo(legend)
|
|
144
|
+
click.echo(note, file=ui)
|
|
145
|
+
click.echo(file=ui)
|
|
146
|
+
click.echo(legend, file=ui)
|
|
94
147
|
|
|
95
148
|
|
|
96
|
-
def _select(title, rows, cursor, *, legend=HOTKEYS, notes=(), manual=None, preview=None):
|
|
149
|
+
def _select(ui, title, rows, cursor, *, legend=HOTKEYS, notes=(), manual=None, preview=None):
|
|
97
150
|
"""Run one step of the wizard and return the chosen row value.
|
|
98
151
|
|
|
99
152
|
`manual` and `preview` are callables for the m and p hotkeys; either
|
|
@@ -103,7 +156,7 @@ def _select(title, rows, cursor, *, legend=HOTKEYS, notes=(), manual=None, previ
|
|
|
103
156
|
status = None
|
|
104
157
|
|
|
105
158
|
while True:
|
|
106
|
-
_render(title, rows, cursor, legend, notes + ([status] if status else []))
|
|
159
|
+
_render(ui, title, rows, cursor, legend, notes + ([status] if status else []))
|
|
107
160
|
key = click.getchar()
|
|
108
161
|
|
|
109
162
|
# An empty read means EOF (a closed pty). It matches no key set, so
|
|
@@ -126,22 +179,21 @@ def _select(title, rows, cursor, *, legend=HOTKEYS, notes=(), manual=None, previ
|
|
|
126
179
|
status = preview(rows[cursor][0])
|
|
127
180
|
|
|
128
181
|
|
|
129
|
-
def _manual_text(label: str) -> str | None:
|
|
182
|
+
def _manual_text(ui, label: str) -> str | None:
|
|
130
183
|
"""Type a value by hand. An empty answer means 'never mind'."""
|
|
131
|
-
|
|
132
|
-
return value or None
|
|
184
|
+
return _ask(ui, label).strip() or None
|
|
133
185
|
|
|
134
186
|
|
|
135
|
-
def _manual_speed() -> float | None:
|
|
187
|
+
def _manual_speed(ui) -> float | None:
|
|
136
188
|
"""Type a speed by hand, re-asking until it passes the config check."""
|
|
137
189
|
while True:
|
|
138
|
-
raw =
|
|
190
|
+
raw = _ask(ui, f"Speed ({SPEED_MIN}–{SPEED_MAX})").strip()
|
|
139
191
|
if not raw:
|
|
140
192
|
return None
|
|
141
193
|
try:
|
|
142
194
|
return validate_speed(raw, "manual entry")
|
|
143
195
|
except ConfigError as exc:
|
|
144
|
-
click.echo(str(exc))
|
|
196
|
+
click.echo(str(exc), file=ui)
|
|
145
197
|
|
|
146
198
|
|
|
147
199
|
def _speed_choices() -> list[float]:
|
|
@@ -163,7 +215,7 @@ def _keep_label(existing: dict, key: str, resolved) -> str:
|
|
|
163
215
|
return f"{resolved} — not in the file"
|
|
164
216
|
|
|
165
217
|
|
|
166
|
-
def _voice_step(current: str, keep: str):
|
|
218
|
+
def _voice_step(ui, current: str, keep: str):
|
|
167
219
|
rows = [(_KEEP, f"keep current ({keep})")]
|
|
168
220
|
cursor = 0
|
|
169
221
|
notes = []
|
|
@@ -194,7 +246,7 @@ def _voice_step(current: str, keep: str):
|
|
|
194
246
|
return "Preview needs an API key."
|
|
195
247
|
# Synthesis and playback both block; say so, or the frozen frame
|
|
196
248
|
# reads as a hang.
|
|
197
|
-
click.echo(f"Previewing {voice_id}…")
|
|
249
|
+
click.echo(f"Previewing {voice_id}…", file=ui)
|
|
198
250
|
try:
|
|
199
251
|
play(save(synthesize(client, PREVIEW_TEXT, Settings(voice_id=voice_id)), PREVIEW_PATH))
|
|
200
252
|
except VocalizeError as exc:
|
|
@@ -202,17 +254,18 @@ def _voice_step(current: str, keep: str):
|
|
|
202
254
|
return f"Previewed {voice_id}."
|
|
203
255
|
|
|
204
256
|
return _select(
|
|
257
|
+
ui,
|
|
205
258
|
"Step 1 of 3 — Voice",
|
|
206
259
|
rows,
|
|
207
260
|
cursor,
|
|
208
261
|
legend=VOICE_HOTKEYS,
|
|
209
262
|
notes=notes,
|
|
210
|
-
manual=lambda: _manual_text("Voice ID"),
|
|
263
|
+
manual=lambda: _manual_text(ui, "Voice ID"),
|
|
211
264
|
preview=preview,
|
|
212
265
|
)
|
|
213
266
|
|
|
214
267
|
|
|
215
|
-
def _model_step(current: str, keep: str):
|
|
268
|
+
def _model_step(ui, current: str, keep: str):
|
|
216
269
|
rows = [(_KEEP, f"keep current ({keep})")]
|
|
217
270
|
cursor = 0
|
|
218
271
|
|
|
@@ -224,14 +277,15 @@ def _model_step(current: str, keep: str):
|
|
|
224
277
|
rows.append((model_id, label))
|
|
225
278
|
|
|
226
279
|
return _select(
|
|
280
|
+
ui,
|
|
227
281
|
"Step 2 of 3 — Model",
|
|
228
282
|
rows,
|
|
229
283
|
cursor,
|
|
230
|
-
manual=lambda: _manual_text("Model ID"),
|
|
284
|
+
manual=lambda: _manual_text(ui, "Model ID"),
|
|
231
285
|
)
|
|
232
286
|
|
|
233
287
|
|
|
234
|
-
def _speed_step(current: float | None, keep: str):
|
|
288
|
+
def _speed_step(ui, current: float | None, keep: str):
|
|
235
289
|
rows = [(_KEEP, f"keep current ({keep})"), (_UNSET, "unset (API default)")]
|
|
236
290
|
cursor = 0
|
|
237
291
|
|
|
@@ -242,7 +296,7 @@ def _speed_step(current: float | None, keep: str):
|
|
|
242
296
|
cursor = len(rows)
|
|
243
297
|
rows.append((value, label))
|
|
244
298
|
|
|
245
|
-
return _select("Step 3 of 3 — Speed", rows, cursor, manual=_manual_speed)
|
|
299
|
+
return _select(ui, "Step 3 of 3 — Speed", rows, cursor, manual=lambda: _manual_speed(ui))
|
|
246
300
|
|
|
247
301
|
|
|
248
302
|
def _toml_value(key: str, value) -> str:
|
|
@@ -287,11 +341,21 @@ def run_wizard() -> None:
|
|
|
287
341
|
"""Walk through voice, model and speed, then write the config file."""
|
|
288
342
|
stdin = sys.stdin
|
|
289
343
|
if stdin is None or not stdin.isatty():
|
|
290
|
-
raise ConfigError(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
344
|
+
raise ConfigError(_NO_TERMINAL)
|
|
345
|
+
|
|
346
|
+
# The keyboard is only half of it — there also has to be somewhere to
|
|
347
|
+
# paint. A relayed stdout is fine as long as /dev/tty opens.
|
|
348
|
+
ui, opened = _open_ui_stream()
|
|
349
|
+
if ui is None:
|
|
350
|
+
raise ConfigError(_NO_TERMINAL)
|
|
351
|
+
try:
|
|
352
|
+
_walk(ui)
|
|
353
|
+
finally:
|
|
354
|
+
if opened:
|
|
355
|
+
ui.close()
|
|
356
|
+
|
|
294
357
|
|
|
358
|
+
def _walk(ui) -> None:
|
|
295
359
|
path = config_path()
|
|
296
360
|
existing = load_config_file()
|
|
297
361
|
# Dry-run the serialiser before asking any questions: fail fast rather
|
|
@@ -315,12 +379,12 @@ def run_wizard() -> None:
|
|
|
315
379
|
|
|
316
380
|
try:
|
|
317
381
|
chosen = {
|
|
318
|
-
"voice": _voice_step(current.voice_id, keep["voice"]),
|
|
319
|
-
"model": _model_step(current.model_id, keep["model"]),
|
|
320
|
-
"speed": _speed_step(current.speed, keep["speed"]),
|
|
382
|
+
"voice": _voice_step(ui, current.voice_id, keep["voice"]),
|
|
383
|
+
"model": _model_step(ui, current.model_id, keep["model"]),
|
|
384
|
+
"speed": _speed_step(ui, current.speed, keep["speed"]),
|
|
321
385
|
}
|
|
322
386
|
except _Cancelled:
|
|
323
|
-
click.echo("Cancelled — nothing changed.")
|
|
387
|
+
click.echo("Cancelled — nothing changed.", file=ui)
|
|
324
388
|
return
|
|
325
389
|
|
|
326
390
|
data = dict(existing) # unknown keys ride through untouched
|
|
@@ -332,20 +396,24 @@ def run_wizard() -> None:
|
|
|
332
396
|
else:
|
|
333
397
|
data[key] = value
|
|
334
398
|
|
|
335
|
-
|
|
336
|
-
click.echo("About to write:")
|
|
337
|
-
click.echo()
|
|
399
|
+
_clear(ui)
|
|
400
|
+
click.echo("About to write:", file=ui)
|
|
401
|
+
click.echo(file=ui)
|
|
338
402
|
for key, value in chosen.items():
|
|
339
|
-
click.echo(f" {key:<5} → {_summary_value(value, keep[key])}")
|
|
340
|
-
click.echo()
|
|
341
|
-
click.echo(f"File: {path}")
|
|
342
|
-
click.echo()
|
|
403
|
+
click.echo(f" {key:<5} → {_summary_value(value, keep[key])}", file=ui)
|
|
404
|
+
click.echo(file=ui)
|
|
405
|
+
click.echo(f"File: {path}", file=ui)
|
|
406
|
+
click.echo(file=ui)
|
|
343
407
|
|
|
344
|
-
if not
|
|
345
|
-
click.echo("Cancelled — nothing changed.")
|
|
408
|
+
if not _confirm(ui, "Write these settings?"):
|
|
409
|
+
click.echo("Cancelled — nothing changed.", file=ui)
|
|
346
410
|
return
|
|
347
411
|
|
|
348
412
|
text = _write_config(path, data)
|
|
349
|
-
click.echo(f"Wrote {path}")
|
|
350
|
-
|
|
351
|
-
|
|
413
|
+
click.echo(f"Wrote {path}", file=ui)
|
|
414
|
+
if ui is not sys.stdout:
|
|
415
|
+
# The outcome line is the one thing a wrapper or a log should still
|
|
416
|
+
# see when the UI went to the terminal instead of down the pipe.
|
|
417
|
+
click.echo(f"Wrote {path}", file=sys.stdout)
|
|
418
|
+
click.echo(file=ui)
|
|
419
|
+
click.echo(text.rstrip("\n") or "(empty — every setting is back to its default)", file=ui)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|