vocalize-cli 0.2.0__tar.gz → 0.3.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.
- vocalize_cli-0.3.0/CHANGELOG.md +83 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/PKG-INFO +48 -7
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/README.md +46 -6
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/pyproject.toml +1 -0
- vocalize_cli-0.3.0/tests/conftest.py +56 -0
- vocalize_cli-0.3.0/tests/test_auth.py +333 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_config.py +13 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_wizard.py +186 -10
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/__init__.py +1 -1
- vocalize_cli-0.3.0/vocalize/auth.py +215 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/cli.py +54 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/config.py +10 -1
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/exceptions.py +9 -4
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/wizard.py +158 -48
- vocalize_cli-0.2.0/tests/conftest.py +0 -12
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/.env.example +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/.gitignore +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/LICENSE +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/hooks/claude_stop_hook.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/hooks/install_hook.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_audio.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_claude_stop_hook.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_cli.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_install_hook.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_preprocess.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_tts.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/__main__.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/audio.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/preprocess.py +0 -0
- {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/tts.py +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. Format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## 0.3.0
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `vocalize auth` command group for storing your ElevenLabs API key in the OS
|
|
11
|
+
keychain (macOS Keychain, Windows Credential Locker, or Linux Secret
|
|
12
|
+
Service) instead of an environment variable or `.env` file.
|
|
13
|
+
- `vocalize auth login` prompts for the key (hidden input), validates it
|
|
14
|
+
against the ElevenLabs API, and stores it. `--stdin` reads the key from a
|
|
15
|
+
pipe instead, for secret managers — e.g.
|
|
16
|
+
`op read op://vault/elevenlabs/key | vocalize auth login --stdin`.
|
|
17
|
+
- `vocalize auth status` shows where the active key is coming from (flag,
|
|
18
|
+
environment, `.env` file, keychain, or not found), with a masked preview.
|
|
19
|
+
- `vocalize auth logout` removes the stored key.
|
|
20
|
+
- `vocalize config` now offers to set up your API key when none is found,
|
|
21
|
+
before walking through voice, model, and speed — so setup is install, then
|
|
22
|
+
`vocalize config`, done.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- API key resolution order is now: `--api-key` flag, then
|
|
27
|
+
`ELEVENLABS_API_KEY`, then a `.env` file in the current directory, then the
|
|
28
|
+
OS keychain.
|
|
29
|
+
|
|
30
|
+
## 0.2.1
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- The config wizard now paints on the controlling terminal (`/dev/tty`)
|
|
35
|
+
instead of stdout, so it still works under output-capturing wrappers like
|
|
36
|
+
`op run` instead of corrupting their captured output.
|
|
37
|
+
|
|
38
|
+
## 0.2.0
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- A TOML config file (`~/.config/vocalize/config.toml` or
|
|
43
|
+
`$XDG_CONFIG_HOME/vocalize/config.toml`) and matching environment
|
|
44
|
+
variables for voice, model, and speed, resolved as flag, then env var,
|
|
45
|
+
then config file, then default.
|
|
46
|
+
- `--speed` flag and `speed` config/env setting (0.7-1.2).
|
|
47
|
+
- `vocalize config`, an interactive wizard that walks through voice (with a
|
|
48
|
+
live preview of the highlighted choice), model, and speed, then writes the
|
|
49
|
+
config file — no need to hand-write TOML.
|
|
50
|
+
|
|
51
|
+
## 0.1.1
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
|
|
55
|
+
- `--latest` flag on the Claude Code Stop hook, for speaking your most
|
|
56
|
+
recent response on demand instead of installing an automatic hook.
|
|
57
|
+
|
|
58
|
+
### Changed
|
|
59
|
+
|
|
60
|
+
- Fenced code blocks are now spoken as a single short placeholder instead of
|
|
61
|
+
being read out character by character.
|
|
62
|
+
|
|
63
|
+
### Fixed
|
|
64
|
+
|
|
65
|
+
- The CLI's reported version now comes from one place instead of two.
|
|
66
|
+
|
|
67
|
+
## 0.1.0
|
|
68
|
+
|
|
69
|
+
Initial release.
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- `vocalize speak`, `vocalize speak-file`, and `vocalize voices` commands,
|
|
74
|
+
backed by the ElevenLabs TTS API.
|
|
75
|
+
- A markdown-to-speech preprocessing pass: tables, bullet lists, links, and
|
|
76
|
+
code blocks are rewritten into short declarative sentences before
|
|
77
|
+
synthesis.
|
|
78
|
+
- A disk cache keyed by a hash of (text, voice, model, format, speed), so
|
|
79
|
+
repeat runs don't re-spend API quota.
|
|
80
|
+
- A Claude Code Stop hook (`hooks/claude_stop_hook.py`,
|
|
81
|
+
`hooks/install_hook.py`) that speaks Claude's response after every turn.
|
|
82
|
+
- Published to PyPI as `vocalize-cli`; CI running lint and tests with
|
|
83
|
+
coverage on every push.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vocalize-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -20,6 +20,7 @@ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
21
|
Requires-Dist: click>=8.1
|
|
22
22
|
Requires-Dist: elevenlabs>=2.0
|
|
23
|
+
Requires-Dist: keyring>=25
|
|
23
24
|
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
24
25
|
Provides-Extra: dev
|
|
25
26
|
Requires-Dist: build; extra == 'dev'
|
|
@@ -40,6 +41,25 @@ natural-sounding speech using the [ElevenLabs](https://elevenlabs.io) API —
|
|
|
40
41
|
plus a hook that wires it directly into [Claude Code](https://claude.com/claude-code),
|
|
41
42
|
so Claude's responses get read aloud automatically in your terminal or IDE.
|
|
42
43
|
|
|
44
|
+
## Quickstart
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pipx install vocalize-cli
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
vocalize config
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Walks you through your API key, a voice, and a speed, and saves it all.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
vocalize speak "hello"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
To set up just the key, skip the wizard and run `vocalize auth login` — it
|
|
61
|
+
stores the key in your OS keychain.
|
|
62
|
+
|
|
43
63
|
## Why this exists
|
|
44
64
|
|
|
45
65
|
Text-to-speech readers are good at *voices* and bad at *structure*. Point one
|
|
@@ -79,7 +99,23 @@ pip install -e .
|
|
|
79
99
|
Get a free ElevenLabs API key at
|
|
80
100
|
[elevenlabs.io/app/settings/api-keys](https://elevenlabs.io/app/settings/api-keys)
|
|
81
101
|
(free tier: 10,000 characters/month, API access included, no commercial
|
|
82
|
-
license). Then
|
|
102
|
+
license). Then, recommended, store it in your OS keychain:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
vocalize auth login
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
This prompts for the key (input hidden), validates it against the
|
|
109
|
+
ElevenLabs API, and stores it via your OS's own keychain (macOS Keychain,
|
|
110
|
+
Windows Credential Locker, Linux Secret Service) — no plaintext file to
|
|
111
|
+
manage. Piping it in from a secret manager works too:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
op read op://vault/elevenlabs/key | vocalize auth login --stdin
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
An environment variable or `.env` file work as well, and take priority over
|
|
118
|
+
the keychain if both are set:
|
|
83
119
|
|
|
84
120
|
```bash
|
|
85
121
|
export ELEVENLABS_API_KEY=your-key-here
|
|
@@ -133,7 +169,8 @@ the highlighted one), model, and speed — shows you a summary, and writes the
|
|
|
133
169
|
config file below. Unrecognised top-level keys already in that file are
|
|
134
170
|
carried through; comments and layout are not preserved. A file containing a
|
|
135
171
|
TOML table or array is left alone entirely, with a message saying to edit it
|
|
136
|
-
by hand.
|
|
172
|
+
by hand. The wizard paints on the controlling terminal rather than on stdout,
|
|
173
|
+
so it still works under output-capturing wrappers like `op run`.
|
|
137
174
|
|
|
138
175
|
```bash
|
|
139
176
|
vocalize config
|
|
@@ -145,6 +182,7 @@ writing anything.
|
|
|
145
182
|
|
|
146
183
|
| Setting | Flag | Env var | Config file key | Default |
|
|
147
184
|
|---|---|---|---|---|
|
|
185
|
+
| API key | `--api-key` | `ELEVENLABS_API_KEY` | not read from the config file | stored via `vocalize auth` |
|
|
148
186
|
| Voice ID | `--voice` | `VOCALIZE_VOICE` | `voice` | `21m00Tcm4TlvDq8ikWAM` ("Rachel") |
|
|
149
187
|
| Model ID | `--model` | `VOCALIZE_MODEL` | `model` | `eleven_multilingual_v2` |
|
|
150
188
|
| Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
|
|
@@ -166,8 +204,11 @@ stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
|
|
|
166
204
|
`speed` must be a number between 0.7 and 1.2 — anything else is a one-line
|
|
167
205
|
error naming the source it came from.
|
|
168
206
|
|
|
169
|
-
The API key is separate and never read from this file
|
|
170
|
-
`ELEVENLABS_API_KEY`,
|
|
207
|
+
The API key is separate and never read from this file. It resolves in its
|
|
208
|
+
own order: `--api-key` flag, then `ELEVENLABS_API_KEY`, then a `.env` file
|
|
209
|
+
in the current directory, then the OS keychain. `vocalize auth login` sets
|
|
210
|
+
up the keychain entry; `vocalize auth status` shows which of those sources
|
|
211
|
+
is currently supplying the key.
|
|
171
212
|
|
|
172
213
|
## Claude Code integration
|
|
173
214
|
|
|
@@ -292,8 +333,8 @@ All tests run offline: the ElevenLabs client is dependency-injected into
|
|
|
292
333
|
so it's always safe to delete some or all of it — nothing will break,
|
|
293
334
|
you'll just re-pay for a re-synthesized clip.
|
|
294
335
|
- **`--api-key` on the command line is visible to other local processes**
|
|
295
|
-
(anything that can run `ps`). Prefer
|
|
296
|
-
variable or a `.env` file instead.
|
|
336
|
+
(anything that can run `ps`). Prefer `vocalize auth login`, the
|
|
337
|
+
`ELEVENLABS_API_KEY` environment variable, or a `.env` file instead.
|
|
297
338
|
- `vocalize voices` lists only the first page of results from the
|
|
298
339
|
ElevenLabs API.
|
|
299
340
|
|
|
@@ -7,6 +7,25 @@ natural-sounding speech using the [ElevenLabs](https://elevenlabs.io) API —
|
|
|
7
7
|
plus a hook that wires it directly into [Claude Code](https://claude.com/claude-code),
|
|
8
8
|
so Claude's responses get read aloud automatically in your terminal or IDE.
|
|
9
9
|
|
|
10
|
+
## Quickstart
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pipx install vocalize-cli
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
vocalize config
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Walks you through your API key, a voice, and a speed, and saves it all.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
vocalize speak "hello"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
To set up just the key, skip the wizard and run `vocalize auth login` — it
|
|
27
|
+
stores the key in your OS keychain.
|
|
28
|
+
|
|
10
29
|
## Why this exists
|
|
11
30
|
|
|
12
31
|
Text-to-speech readers are good at *voices* and bad at *structure*. Point one
|
|
@@ -46,7 +65,23 @@ pip install -e .
|
|
|
46
65
|
Get a free ElevenLabs API key at
|
|
47
66
|
[elevenlabs.io/app/settings/api-keys](https://elevenlabs.io/app/settings/api-keys)
|
|
48
67
|
(free tier: 10,000 characters/month, API access included, no commercial
|
|
49
|
-
license). Then
|
|
68
|
+
license). Then, recommended, store it in your OS keychain:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
vocalize auth login
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This prompts for the key (input hidden), validates it against the
|
|
75
|
+
ElevenLabs API, and stores it via your OS's own keychain (macOS Keychain,
|
|
76
|
+
Windows Credential Locker, Linux Secret Service) — no plaintext file to
|
|
77
|
+
manage. Piping it in from a secret manager works too:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
op read op://vault/elevenlabs/key | vocalize auth login --stdin
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
An environment variable or `.env` file work as well, and take priority over
|
|
84
|
+
the keychain if both are set:
|
|
50
85
|
|
|
51
86
|
```bash
|
|
52
87
|
export ELEVENLABS_API_KEY=your-key-here
|
|
@@ -100,7 +135,8 @@ the highlighted one), model, and speed — shows you a summary, and writes the
|
|
|
100
135
|
config file below. Unrecognised top-level keys already in that file are
|
|
101
136
|
carried through; comments and layout are not preserved. A file containing a
|
|
102
137
|
TOML table or array is left alone entirely, with a message saying to edit it
|
|
103
|
-
by hand.
|
|
138
|
+
by hand. The wizard paints on the controlling terminal rather than on stdout,
|
|
139
|
+
so it still works under output-capturing wrappers like `op run`.
|
|
104
140
|
|
|
105
141
|
```bash
|
|
106
142
|
vocalize config
|
|
@@ -112,6 +148,7 @@ writing anything.
|
|
|
112
148
|
|
|
113
149
|
| Setting | Flag | Env var | Config file key | Default |
|
|
114
150
|
|---|---|---|---|---|
|
|
151
|
+
| API key | `--api-key` | `ELEVENLABS_API_KEY` | not read from the config file | stored via `vocalize auth` |
|
|
115
152
|
| Voice ID | `--voice` | `VOCALIZE_VOICE` | `voice` | `21m00Tcm4TlvDq8ikWAM` ("Rachel") |
|
|
116
153
|
| Model ID | `--model` | `VOCALIZE_MODEL` | `model` | `eleven_multilingual_v2` |
|
|
117
154
|
| Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
|
|
@@ -133,8 +170,11 @@ stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
|
|
|
133
170
|
`speed` must be a number between 0.7 and 1.2 — anything else is a one-line
|
|
134
171
|
error naming the source it came from.
|
|
135
172
|
|
|
136
|
-
The API key is separate and never read from this file
|
|
137
|
-
`ELEVENLABS_API_KEY`,
|
|
173
|
+
The API key is separate and never read from this file. It resolves in its
|
|
174
|
+
own order: `--api-key` flag, then `ELEVENLABS_API_KEY`, then a `.env` file
|
|
175
|
+
in the current directory, then the OS keychain. `vocalize auth login` sets
|
|
176
|
+
up the keychain entry; `vocalize auth status` shows which of those sources
|
|
177
|
+
is currently supplying the key.
|
|
138
178
|
|
|
139
179
|
## Claude Code integration
|
|
140
180
|
|
|
@@ -259,8 +299,8 @@ All tests run offline: the ElevenLabs client is dependency-injected into
|
|
|
259
299
|
so it's always safe to delete some or all of it — nothing will break,
|
|
260
300
|
you'll just re-pay for a re-synthesized clip.
|
|
261
301
|
- **`--api-key` on the command line is visible to other local processes**
|
|
262
|
-
(anything that can run `ps`). Prefer
|
|
263
|
-
variable or a `.env` file instead.
|
|
302
|
+
(anything that can run `ps`). Prefer `vocalize auth login`, the
|
|
303
|
+
`ELEVENLABS_API_KEY` environment variable, or a `.env` file instead.
|
|
264
304
|
- `vocalize voices` lists only the first page of results from the
|
|
265
305
|
ElevenLabs API.
|
|
266
306
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "hooks"))
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def _no_dotenv(monkeypatch):
|
|
11
|
+
"""Prevent tests from loading the developer's real .env file."""
|
|
12
|
+
monkeypatch.setattr("vocalize.config._load_dotenv_if_present", lambda: None)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _FakeKeychain(dict):
|
|
16
|
+
"""The stored entries, plus the switches a test needs to break them."""
|
|
17
|
+
|
|
18
|
+
deny_delete = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _FakeKeyring:
|
|
22
|
+
"""An in-memory stand-in for the keyring module's three calls."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, store):
|
|
25
|
+
self._store = store
|
|
26
|
+
|
|
27
|
+
def get_password(self, service, username):
|
|
28
|
+
return self._store.get((service, username))
|
|
29
|
+
|
|
30
|
+
def set_password(self, service, username, password):
|
|
31
|
+
self._store[(service, username)] = password
|
|
32
|
+
|
|
33
|
+
def delete_password(self, service, username):
|
|
34
|
+
from keyring.errors import PasswordDeleteError
|
|
35
|
+
|
|
36
|
+
if self._store.deny_delete:
|
|
37
|
+
# The macOS backend's worst habit: a denied or locked keychain
|
|
38
|
+
# raises the very same error a missing entry does, while the
|
|
39
|
+
# entry is still sitting there.
|
|
40
|
+
raise PasswordDeleteError("failed to delete password")
|
|
41
|
+
if self._store.pop((service, username), None) is None:
|
|
42
|
+
raise PasswordDeleteError("no such password")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture(autouse=True)
|
|
46
|
+
def fake_keychain(monkeypatch):
|
|
47
|
+
"""Keep every test off the real OS keychain.
|
|
48
|
+
|
|
49
|
+
Autouse because the damage of missing one is silent: a stored key on
|
|
50
|
+
the developer's machine would otherwise satisfy resolve_api_key and
|
|
51
|
+
quietly turn the "no key found" tests green for the wrong reason.
|
|
52
|
+
Request it by name to seed, inspect, or break the store.
|
|
53
|
+
"""
|
|
54
|
+
store = _FakeKeychain()
|
|
55
|
+
monkeypatch.setattr("vocalize.auth._backend", lambda: _FakeKeyring(store))
|
|
56
|
+
return store
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from click.testing import CliRunner
|
|
3
|
+
|
|
4
|
+
from vocalize import auth
|
|
5
|
+
from vocalize.cli import main
|
|
6
|
+
from vocalize.config import _load_dotenv_if_present
|
|
7
|
+
from vocalize.exceptions import AuthError, TTSRequestError
|
|
8
|
+
|
|
9
|
+
# Bound at import time on purpose: conftest's autouse fixture replaces the
|
|
10
|
+
# module attribute, so this reference is the only way to reach the real one.
|
|
11
|
+
real_load_dotenv = _load_dotenv_if_present
|
|
12
|
+
|
|
13
|
+
ENTRY = (auth.SERVICE, auth.USERNAME)
|
|
14
|
+
SECRET = "sk_supersecret1234567890"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def no_env_key(monkeypatch):
|
|
19
|
+
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fake_tts(monkeypatch, *, failure=None):
|
|
23
|
+
"""Swap the real ElevenLabs client out of the validation path."""
|
|
24
|
+
seen = []
|
|
25
|
+
|
|
26
|
+
def build_client(key):
|
|
27
|
+
seen.append(key)
|
|
28
|
+
return object()
|
|
29
|
+
|
|
30
|
+
def list_voices(client):
|
|
31
|
+
if failure is not None:
|
|
32
|
+
raise TTSRequestError(failure)
|
|
33
|
+
return [{"id": "abc123", "name": "Rachel"}]
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr("vocalize.tts.build_client", build_client)
|
|
36
|
+
monkeypatch.setattr("vocalize.tts.list_voices", list_voices)
|
|
37
|
+
return seen
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _BrokenKeyring:
|
|
41
|
+
"""A backend that fails the way a locked or absent keychain does."""
|
|
42
|
+
|
|
43
|
+
def get_password(self, service, username):
|
|
44
|
+
raise RuntimeError("no recommended backend")
|
|
45
|
+
|
|
46
|
+
def set_password(self, service, username, password):
|
|
47
|
+
raise RuntimeError("no recommended backend")
|
|
48
|
+
|
|
49
|
+
def delete_password(self, service, username):
|
|
50
|
+
raise RuntimeError("no recommended backend")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _FakeReadFailure:
|
|
54
|
+
"""Delete appears to work; the read-back that would confirm it doesn't."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, store):
|
|
57
|
+
self._store = store
|
|
58
|
+
|
|
59
|
+
def get_password(self, service, username):
|
|
60
|
+
raise RuntimeError("keychain locked")
|
|
61
|
+
|
|
62
|
+
def delete_password(self, service, username):
|
|
63
|
+
from keyring.errors import PasswordDeleteError
|
|
64
|
+
|
|
65
|
+
raise PasswordDeleteError("failed to delete password")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _break_backend(monkeypatch):
|
|
69
|
+
monkeypatch.setattr(auth, "_backend", lambda: _BrokenKeyring())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_store_read_delete_round_trip(fake_keychain):
|
|
73
|
+
assert auth.stored_key() is None
|
|
74
|
+
|
|
75
|
+
auth.store_key(SECRET)
|
|
76
|
+
|
|
77
|
+
assert fake_keychain[ENTRY] == SECRET
|
|
78
|
+
assert auth.stored_key() == SECRET
|
|
79
|
+
|
|
80
|
+
auth.delete_key()
|
|
81
|
+
|
|
82
|
+
assert ENTRY not in fake_keychain
|
|
83
|
+
assert auth.stored_key() is None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_stored_key_is_none_when_the_backend_raises(monkeypatch):
|
|
87
|
+
_break_backend(monkeypatch)
|
|
88
|
+
|
|
89
|
+
assert auth.stored_key() is None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_store_key_reports_a_broken_backend(monkeypatch):
|
|
93
|
+
_break_backend(monkeypatch)
|
|
94
|
+
|
|
95
|
+
with pytest.raises(AuthError, match="Could not write"):
|
|
96
|
+
auth.store_key(SECRET)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_delete_on_a_missing_entry_is_silent(fake_keychain):
|
|
100
|
+
auth.delete_key() # must not raise
|
|
101
|
+
|
|
102
|
+
assert fake_keychain == {}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_delete_key_reports_a_broken_backend(monkeypatch):
|
|
106
|
+
_break_backend(monkeypatch)
|
|
107
|
+
|
|
108
|
+
with pytest.raises(AuthError, match="Could not delete"):
|
|
109
|
+
auth.delete_key()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_a_denied_delete_is_not_reported_as_success(fake_keychain):
|
|
113
|
+
# macOS raises PasswordDeleteError for a denial too, entry intact
|
|
114
|
+
fake_keychain[ENTRY] = SECRET
|
|
115
|
+
fake_keychain.deny_delete = True
|
|
116
|
+
|
|
117
|
+
with pytest.raises(AuthError, match="STILL stored"):
|
|
118
|
+
auth.delete_key()
|
|
119
|
+
|
|
120
|
+
assert fake_keychain[ENTRY] == SECRET
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_delete_will_not_claim_success_it_cannot_verify(monkeypatch, fake_keychain):
|
|
124
|
+
fake_keychain[ENTRY] = SECRET
|
|
125
|
+
keyring = _FakeReadFailure(fake_keychain)
|
|
126
|
+
monkeypatch.setattr(auth, "_backend", lambda: keyring)
|
|
127
|
+
|
|
128
|
+
with pytest.raises(AuthError, match="Could not confirm"):
|
|
129
|
+
auth.delete_key()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.mark.parametrize(
|
|
133
|
+
"failure",
|
|
134
|
+
[ModuleNotFoundError("No module named 'keyrings.nope'"), AttributeError("no such backend")],
|
|
135
|
+
ids=["module-not-found", "attribute-error"],
|
|
136
|
+
)
|
|
137
|
+
def test_stored_key_survives_a_backend_selection_failure(monkeypatch, failure):
|
|
138
|
+
# PYTHON_KEYRING_BACKEND naming an uninstalled module fails at import
|
|
139
|
+
# time, so it never surfaces as a KeyringError.
|
|
140
|
+
class _Unselectable:
|
|
141
|
+
def get_password(self, service, username):
|
|
142
|
+
raise failure
|
|
143
|
+
|
|
144
|
+
monkeypatch.setattr(auth, "_backend", lambda: _Unselectable())
|
|
145
|
+
|
|
146
|
+
assert auth.stored_key() is None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_probe_keychain_tells_absent_apart_from_unreadable(monkeypatch, fake_keychain):
|
|
150
|
+
assert auth.probe_keychain() == ("ok", None)
|
|
151
|
+
|
|
152
|
+
fake_keychain[ENTRY] = SECRET
|
|
153
|
+
assert auth.probe_keychain() == ("ok", SECRET)
|
|
154
|
+
|
|
155
|
+
_break_backend(monkeypatch)
|
|
156
|
+
status, reason = auth.probe_keychain()
|
|
157
|
+
assert status == "error"
|
|
158
|
+
assert "no recommended backend" in reason
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_key_source_reports_the_flag(monkeypatch):
|
|
162
|
+
monkeypatch.setenv("ELEVENLABS_API_KEY", "env-key")
|
|
163
|
+
|
|
164
|
+
assert auth.key_source("flag-key") == "flag"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_key_source_reports_the_environment(monkeypatch, fake_keychain):
|
|
168
|
+
monkeypatch.setenv("ELEVENLABS_API_KEY", "env-key")
|
|
169
|
+
fake_keychain[ENTRY] = SECRET
|
|
170
|
+
|
|
171
|
+
assert auth.key_source(None) == "environment"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_key_source_reports_the_dotenv_file(monkeypatch, tmp_path, no_env_key):
|
|
175
|
+
monkeypatch.chdir(tmp_path)
|
|
176
|
+
(tmp_path / ".env").write_text("ELEVENLABS_API_KEY=from-cwd-file\n", encoding="utf-8")
|
|
177
|
+
# setenv before delenv so monkeypatch restores the var whether or not it
|
|
178
|
+
# was set beforehand — the real loader writes straight to os.environ.
|
|
179
|
+
monkeypatch.setenv("ELEVENLABS_API_KEY", "placeholder")
|
|
180
|
+
monkeypatch.delenv("ELEVENLABS_API_KEY")
|
|
181
|
+
monkeypatch.setattr("vocalize.config._load_dotenv_if_present", real_load_dotenv)
|
|
182
|
+
|
|
183
|
+
assert auth.key_source(None) == ".env file"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_key_source_reports_the_keychain(fake_keychain, no_env_key):
|
|
187
|
+
fake_keychain[ENTRY] = SECRET
|
|
188
|
+
|
|
189
|
+
assert auth.key_source(None) == "keychain"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_key_source_reports_not_found(no_env_key):
|
|
193
|
+
assert auth.key_source(None) == "not found"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_masked_never_shows_more_than_four_characters():
|
|
197
|
+
assert auth.masked(SECRET) == "sk_s…"
|
|
198
|
+
assert SECRET not in auth.masked(SECRET)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_login_validates_before_storing(monkeypatch, fake_keychain):
|
|
202
|
+
seen = _fake_tts(monkeypatch)
|
|
203
|
+
|
|
204
|
+
assert auth.login(SECRET) == f"Stored the API key in {auth.WHERE}."
|
|
205
|
+
assert seen == [SECRET]
|
|
206
|
+
assert fake_keychain[ENTRY] == SECRET
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_login_rejects_a_key_with_control_characters(monkeypatch, fake_keychain):
|
|
210
|
+
reached = []
|
|
211
|
+
monkeypatch.setattr("vocalize.auth.validate_key", reached.append)
|
|
212
|
+
utf16_bytes = "s\x00k\x00_\x00a\x00b\x00"
|
|
213
|
+
|
|
214
|
+
with pytest.raises(AuthError, match="encoding"):
|
|
215
|
+
auth.login(utf16_bytes)
|
|
216
|
+
|
|
217
|
+
assert reached == [] # never got near a request
|
|
218
|
+
assert fake_keychain == {}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_cli_login_never_echoes_a_malformed_key(fake_keychain):
|
|
222
|
+
utf16_bytes = "s\x00k\x00_\x00secretpart\x00"
|
|
223
|
+
|
|
224
|
+
result = CliRunner().invoke(main, ["auth", "login", "--stdin"], input=f"{utf16_bytes}\n")
|
|
225
|
+
|
|
226
|
+
assert result.exit_code == 1
|
|
227
|
+
assert "control characters" in result.output
|
|
228
|
+
assert "secretpart" not in result.output
|
|
229
|
+
assert fake_keychain == {}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def test_login_scrubs_the_key_out_of_a_wrapped_api_error(monkeypatch, fake_keychain):
|
|
233
|
+
# What h11 does: the rejected header value is quoted back in full
|
|
234
|
+
_fake_tts(monkeypatch, failure=f"Illegal header value b'xi-api-key: {SECRET}'")
|
|
235
|
+
|
|
236
|
+
result = CliRunner().invoke(main, ["auth", "login"], input=f"{SECRET}\n")
|
|
237
|
+
|
|
238
|
+
assert result.exit_code == 1
|
|
239
|
+
assert SECRET not in result.output
|
|
240
|
+
assert "[key]" in result.output
|
|
241
|
+
assert fake_keychain == {}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_cli_login_stores_a_prompted_key(monkeypatch, fake_keychain):
|
|
245
|
+
_fake_tts(monkeypatch)
|
|
246
|
+
|
|
247
|
+
result = CliRunner().invoke(main, ["auth", "login"], input=f"{SECRET}\n")
|
|
248
|
+
|
|
249
|
+
assert result.exit_code == 0, result.output
|
|
250
|
+
assert fake_keychain[ENTRY] == SECRET
|
|
251
|
+
assert "keychain" in result.output
|
|
252
|
+
assert SECRET not in result.output
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_cli_login_stores_nothing_when_the_key_is_rejected(monkeypatch, fake_keychain):
|
|
256
|
+
_fake_tts(monkeypatch, failure="401 unauthorized")
|
|
257
|
+
|
|
258
|
+
result = CliRunner().invoke(main, ["auth", "login"], input="bad-key\n")
|
|
259
|
+
|
|
260
|
+
assert result.exit_code == 1
|
|
261
|
+
assert "401 unauthorized" in result.output
|
|
262
|
+
assert fake_keychain == {}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_cli_login_reads_a_piped_key(monkeypatch, fake_keychain):
|
|
266
|
+
seen = _fake_tts(monkeypatch)
|
|
267
|
+
|
|
268
|
+
result = CliRunner().invoke(main, ["auth", "login", "--stdin"], input=f"{SECRET}\n")
|
|
269
|
+
|
|
270
|
+
assert result.exit_code == 0, result.output
|
|
271
|
+
assert seen == [SECRET] # the trailing newline is not part of the key
|
|
272
|
+
assert fake_keychain[ENTRY] == SECRET
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_cli_login_refuses_an_empty_key(fake_keychain):
|
|
276
|
+
result = CliRunner().invoke(main, ["auth", "login", "--stdin"], input="\n")
|
|
277
|
+
|
|
278
|
+
assert result.exit_code == 1
|
|
279
|
+
assert "No API key given" in result.output
|
|
280
|
+
assert fake_keychain == {}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_cli_status_masks_the_key(fake_keychain, no_env_key):
|
|
284
|
+
fake_keychain[ENTRY] = SECRET
|
|
285
|
+
|
|
286
|
+
result = CliRunner().invoke(main, ["auth", "status"])
|
|
287
|
+
|
|
288
|
+
assert result.exit_code == 0, result.output
|
|
289
|
+
assert "API key source: keychain" in result.output
|
|
290
|
+
assert "Key: sk_s…" in result.output
|
|
291
|
+
assert SECRET not in result.output
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_cli_status_is_informational_when_there_is_no_key(no_env_key):
|
|
295
|
+
result = CliRunner().invoke(main, ["auth", "status"])
|
|
296
|
+
|
|
297
|
+
assert result.exit_code == 0, result.output
|
|
298
|
+
assert "API key source: not found" in result.output
|
|
299
|
+
assert "vocalize auth login" in result.output
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_cli_status_admits_the_keychain_is_unreadable(monkeypatch, no_env_key):
|
|
303
|
+
_break_backend(monkeypatch)
|
|
304
|
+
|
|
305
|
+
result = CliRunner().invoke(main, ["auth", "status"])
|
|
306
|
+
|
|
307
|
+
assert result.exit_code == 0, result.output
|
|
308
|
+
assert "keychain unavailable (no recommended backend)" in result.output
|
|
309
|
+
assert "Unlock your keychain" in result.output
|
|
310
|
+
assert "not found" not in result.output
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_cli_logout_removes_the_stored_key(fake_keychain):
|
|
314
|
+
fake_keychain[ENTRY] = SECRET
|
|
315
|
+
|
|
316
|
+
result = CliRunner().invoke(main, ["auth", "logout"])
|
|
317
|
+
|
|
318
|
+
assert result.exit_code == 0, result.output
|
|
319
|
+
assert fake_keychain == {}
|
|
320
|
+
assert "Removed" in result.output
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_cli_logout_does_not_claim_a_denied_removal(fake_keychain):
|
|
324
|
+
fake_keychain[ENTRY] = SECRET
|
|
325
|
+
fake_keychain.deny_delete = True
|
|
326
|
+
|
|
327
|
+
result = CliRunner().invoke(main, ["auth", "logout"])
|
|
328
|
+
|
|
329
|
+
assert result.exit_code == 1
|
|
330
|
+
assert "Removed" not in result.output
|
|
331
|
+
assert "STILL stored" in result.output
|
|
332
|
+
assert "rotate the key" in result.output
|
|
333
|
+
assert fake_keychain[ENTRY] == SECRET
|