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.
Files changed (31) hide show
  1. vocalize_cli-0.3.0/CHANGELOG.md +83 -0
  2. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/PKG-INFO +48 -7
  3. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/README.md +46 -6
  4. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/pyproject.toml +1 -0
  5. vocalize_cli-0.3.0/tests/conftest.py +56 -0
  6. vocalize_cli-0.3.0/tests/test_auth.py +333 -0
  7. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_config.py +13 -0
  8. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_wizard.py +186 -10
  9. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/__init__.py +1 -1
  10. vocalize_cli-0.3.0/vocalize/auth.py +215 -0
  11. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/cli.py +54 -0
  12. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/config.py +10 -1
  13. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/exceptions.py +9 -4
  14. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/wizard.py +158 -48
  15. vocalize_cli-0.2.0/tests/conftest.py +0 -12
  16. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/.env.example +0 -0
  17. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/.github/workflows/ci.yml +0 -0
  18. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/.gitignore +0 -0
  19. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/LICENSE +0 -0
  20. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/hooks/claude_stop_hook.py +0 -0
  21. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/hooks/install_hook.py +0 -0
  22. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_audio.py +0 -0
  23. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_claude_stop_hook.py +0 -0
  24. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_cli.py +0 -0
  25. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_install_hook.py +0 -0
  26. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_preprocess.py +0 -0
  27. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/tests/test_tts.py +0 -0
  28. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/__main__.py +0 -0
  29. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/audio.py +0 -0
  30. {vocalize_cli-0.2.0 → vocalize_cli-0.3.0}/vocalize/preprocess.py +0 -0
  31. {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.2.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 either:
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: use `--api-key`,
170
- `ELEVENLABS_API_KEY`, or a `.env` file.
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 the `ELEVENLABS_API_KEY` environment
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 either:
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: use `--api-key`,
137
- `ELEVENLABS_API_KEY`, or a `.env` file.
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 the `ELEVENLABS_API_KEY` environment
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
 
@@ -25,6 +25,7 @@ classifiers = [
25
25
  dependencies = [
26
26
  "elevenlabs>=2.0",
27
27
  "click>=8.1",
28
+ "keyring>=25",
28
29
  "tomli>=2.0; python_version < '3.11'",
29
30
  ]
30
31
 
@@ -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