vocalize-cli 0.2.1__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.1 → vocalize_cli-0.3.0}/PKG-INFO +46 -6
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/README.md +44 -5
- {vocalize_cli-0.2.1 → 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.1 → vocalize_cli-0.3.0}/tests/test_config.py +13 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/tests/test_wizard.py +94 -4
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/vocalize/__init__.py +1 -1
- vocalize_cli-0.3.0/vocalize/auth.py +215 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/vocalize/cli.py +54 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/vocalize/config.py +10 -1
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/vocalize/exceptions.py +9 -4
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/vocalize/wizard.py +46 -4
- vocalize_cli-0.2.1/tests/conftest.py +0 -12
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/.env.example +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/.gitignore +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/LICENSE +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/hooks/claude_stop_hook.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/hooks/install_hook.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/tests/test_audio.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/tests/test_claude_stop_hook.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/tests/test_cli.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/tests/test_install_hook.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/tests/test_preprocess.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/tests/test_tts.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/vocalize/__main__.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/vocalize/audio.py +0 -0
- {vocalize_cli-0.2.1 → vocalize_cli-0.3.0}/vocalize/preprocess.py +0 -0
- {vocalize_cli-0.2.1 → 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
|
|
@@ -146,6 +182,7 @@ writing anything.
|
|
|
146
182
|
|
|
147
183
|
| Setting | Flag | Env var | Config file key | Default |
|
|
148
184
|
|---|---|---|---|---|
|
|
185
|
+
| API key | `--api-key` | `ELEVENLABS_API_KEY` | not read from the config file | stored via `vocalize auth` |
|
|
149
186
|
| Voice ID | `--voice` | `VOCALIZE_VOICE` | `voice` | `21m00Tcm4TlvDq8ikWAM` ("Rachel") |
|
|
150
187
|
| Model ID | `--model` | `VOCALIZE_MODEL` | `model` | `eleven_multilingual_v2` |
|
|
151
188
|
| Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
|
|
@@ -167,8 +204,11 @@ stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
|
|
|
167
204
|
`speed` must be a number between 0.7 and 1.2 — anything else is a one-line
|
|
168
205
|
error naming the source it came from.
|
|
169
206
|
|
|
170
|
-
The API key is separate and never read from this file
|
|
171
|
-
`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.
|
|
172
212
|
|
|
173
213
|
## Claude Code integration
|
|
174
214
|
|
|
@@ -293,8 +333,8 @@ All tests run offline: the ElevenLabs client is dependency-injected into
|
|
|
293
333
|
so it's always safe to delete some or all of it — nothing will break,
|
|
294
334
|
you'll just re-pay for a re-synthesized clip.
|
|
295
335
|
- **`--api-key` on the command line is visible to other local processes**
|
|
296
|
-
(anything that can run `ps`). Prefer
|
|
297
|
-
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.
|
|
298
338
|
- `vocalize voices` lists only the first page of results from the
|
|
299
339
|
ElevenLabs API.
|
|
300
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
|
|
@@ -113,6 +148,7 @@ writing anything.
|
|
|
113
148
|
|
|
114
149
|
| Setting | Flag | Env var | Config file key | Default |
|
|
115
150
|
|---|---|---|---|---|
|
|
151
|
+
| API key | `--api-key` | `ELEVENLABS_API_KEY` | not read from the config file | stored via `vocalize auth` |
|
|
116
152
|
| Voice ID | `--voice` | `VOCALIZE_VOICE` | `voice` | `21m00Tcm4TlvDq8ikWAM` ("Rachel") |
|
|
117
153
|
| Model ID | `--model` | `VOCALIZE_MODEL` | `model` | `eleven_multilingual_v2` |
|
|
118
154
|
| Speed | `--speed` | `VOCALIZE_SPEED` | `speed` | unset — the API's own 1.0 |
|
|
@@ -134,8 +170,11 @@ stderr, so a typo doesn't pass unnoticed but doesn't stop the run either.
|
|
|
134
170
|
`speed` must be a number between 0.7 and 1.2 — anything else is a one-line
|
|
135
171
|
error naming the source it came from.
|
|
136
172
|
|
|
137
|
-
The API key is separate and never read from this file
|
|
138
|
-
`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.
|
|
139
178
|
|
|
140
179
|
## Claude Code integration
|
|
141
180
|
|
|
@@ -260,8 +299,8 @@ All tests run offline: the ElevenLabs client is dependency-injected into
|
|
|
260
299
|
so it's always safe to delete some or all of it — nothing will break,
|
|
261
300
|
you'll just re-pay for a re-synthesized clip.
|
|
262
301
|
- **`--api-key` on the command line is visible to other local processes**
|
|
263
|
-
(anything that can run `ps`). Prefer
|
|
264
|
-
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.
|
|
265
304
|
- `vocalize voices` lists only the first page of results from the
|
|
266
305
|
ElevenLabs API.
|
|
267
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
|
|
@@ -33,6 +33,19 @@ def test_raises_clear_error_when_nothing_found(monkeypatch):
|
|
|
33
33
|
resolve_api_key(None)
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def test_falls_back_to_the_keychain(monkeypatch, fake_keychain):
|
|
37
|
+
from vocalize import auth
|
|
38
|
+
|
|
39
|
+
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
|
|
40
|
+
fake_keychain[(auth.SERVICE, auth.USERNAME)] = "keychain-key"
|
|
41
|
+
|
|
42
|
+
assert resolve_api_key(None) == "keychain-key"
|
|
43
|
+
|
|
44
|
+
# The keychain is the last tier: anything more local still wins.
|
|
45
|
+
monkeypatch.setenv("ELEVENLABS_API_KEY", "env-key")
|
|
46
|
+
assert resolve_api_key(None) == "env-key"
|
|
47
|
+
|
|
48
|
+
|
|
36
49
|
def test_dotenv_loader_reads_the_env_file_in_the_cwd(monkeypatch, tmp_path):
|
|
37
50
|
monkeypatch.chdir(tmp_path)
|
|
38
51
|
(tmp_path / ".env").write_text("ELEVENLABS_API_KEY=from-cwd-file\n", encoding="utf-8")
|