5 changed files with 290 additions and 0 deletions
@ -0,0 +1,6 @@
|
||||
# Voice Transcriptor |
||||
|
||||
The editable default transcription model is `gpt-4o-transcribe`. New installations use |
||||
Brazilian Portuguese (`pt-BR`) and save output to the user's Documents folder when it is |
||||
available, otherwise to the home folder. API keys are stored only in the operating-system |
||||
keyring and are never written to the settings file. |
||||
@ -0,0 +1,37 @@
|
||||
"""Secure API-key storage backed by the operating system keyring.""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import keyring |
||||
|
||||
|
||||
SERVICE_NAME = "voice-transcriptor" |
||||
ACCOUNT_NAME = "openai-api-key" |
||||
|
||||
|
||||
class CredentialError(Exception): |
||||
"""A credential operation failed without exposing backend details.""" |
||||
|
||||
|
||||
class CredentialService: |
||||
"""Read and write the application's API key through a keyring backend.""" |
||||
|
||||
def __init__(self, backend=keyring) -> None: |
||||
self._backend = backend |
||||
|
||||
def get_api_key(self) -> str | None: |
||||
try: |
||||
return self._backend.get_password(SERVICE_NAME, ACCOUNT_NAME) |
||||
except Exception: |
||||
raise CredentialError("Unable to access the saved API key.") from None |
||||
|
||||
def has_api_key(self) -> bool: |
||||
return bool(self.get_api_key()) |
||||
|
||||
def set_api_key(self, value: str) -> None: |
||||
if not isinstance(value, str) or not value.strip(): |
||||
raise CredentialError("An API key is required.") |
||||
try: |
||||
self._backend.set_password(SERVICE_NAME, ACCOUNT_NAME, value) |
||||
except Exception: |
||||
raise CredentialError("Unable to save the API key.") from None |
||||
@ -0,0 +1,87 @@
|
||||
"""Per-user persistence for non-secret application settings.""" |
||||
|
||||
from __future__ import annotations |
||||
|
||||
import json |
||||
import os |
||||
import tempfile |
||||
from pathlib import Path |
||||
|
||||
from voice_transcriptor.models import AppSettings |
||||
|
||||
|
||||
DEFAULT_MODEL = "gpt-4o-transcribe" |
||||
DEFAULT_LANGUAGE = "pt-BR" |
||||
|
||||
|
||||
class SettingsError(Exception): |
||||
"""Settings could not be written.""" |
||||
|
||||
|
||||
class SettingsRepository: |
||||
"""Load and atomically save settings without storing credentials.""" |
||||
|
||||
def __init__(self, path: Path | None = None) -> None: |
||||
self.path = path if path is not None else self.default_path() |
||||
|
||||
@staticmethod |
||||
def default_path() -> Path: |
||||
app_data = os.environ.get("APPDATA") |
||||
base_directory = Path(app_data) if app_data else Path.home() / "AppData" / "Roaming" |
||||
return base_directory / "VoiceTranscriptor" / "settings.json" |
||||
|
||||
@staticmethod |
||||
def default_settings() -> AppSettings: |
||||
home = Path.home() |
||||
documents = home / "Documents" |
||||
return AppSettings(DEFAULT_MODEL, DEFAULT_LANGUAGE, documents if documents.is_dir() else home) |
||||
|
||||
def load(self) -> tuple[AppSettings, str | None]: |
||||
if not self.path.exists(): |
||||
return self.default_settings(), None |
||||
|
||||
try: |
||||
payload = json.loads(self.path.read_text(encoding="utf-8")) |
||||
return self._settings_from_payload(payload), None |
||||
except (OSError, TypeError, ValueError, json.JSONDecodeError): |
||||
return self.default_settings(), "Could not load settings; defaults are being used." |
||||
|
||||
def save(self, settings: AppSettings) -> None: |
||||
payload = { |
||||
"model": settings.model, |
||||
"language": settings.language, |
||||
"output_directory": str(settings.output_directory), |
||||
} |
||||
temporary_path: Path | None = None |
||||
try: |
||||
self.path.parent.mkdir(parents=True, exist_ok=True) |
||||
descriptor, temporary_name = tempfile.mkstemp( |
||||
dir=self.path.parent, prefix=f".{self.path.name}.", suffix=".tmp" |
||||
) |
||||
temporary_path = Path(temporary_name) |
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as temporary_file: |
||||
json.dump(payload, temporary_file, ensure_ascii=False) |
||||
temporary_path.replace(self.path) |
||||
except OSError as exc: |
||||
raise SettingsError("Unable to save settings.") from exc |
||||
finally: |
||||
if temporary_path is not None and temporary_path.exists(): |
||||
temporary_path.unlink(missing_ok=True) |
||||
|
||||
@staticmethod |
||||
def _settings_from_payload(payload: object) -> AppSettings: |
||||
if not isinstance(payload, dict): |
||||
raise ValueError("Settings payload must be an object.") |
||||
model = payload.get("model") |
||||
language = payload.get("language") |
||||
output_directory = payload.get("output_directory") |
||||
if ( |
||||
not isinstance(model, str) |
||||
or not model.strip() |
||||
or not isinstance(language, str) |
||||
or not language.strip() |
||||
or not isinstance(output_directory, str) |
||||
or not output_directory.strip() |
||||
): |
||||
raise ValueError("Settings payload is invalid.") |
||||
return AppSettings(model, language, Path(output_directory)) |
||||
@ -0,0 +1,72 @@
|
||||
import pytest |
||||
|
||||
from voice_transcriptor.services.credentials import CredentialError, CredentialService |
||||
|
||||
|
||||
class FakeKeyring: |
||||
def __init__(self) -> None: |
||||
self.value: str | None = None |
||||
self.calls: list[tuple[str, str, str | None]] = [] |
||||
|
||||
def get_password(self, service: str, account: str) -> str | None: |
||||
self.calls.append(("get", service, account)) |
||||
return self.value |
||||
|
||||
def set_password(self, service: str, account: str, value: str) -> None: |
||||
self.calls.append(("set", service, account, value)) |
||||
self.value = value |
||||
|
||||
|
||||
def test_get_api_key_uses_stable_keyring_service_and_account() -> None: |
||||
backend = FakeKeyring() |
||||
backend.value = "secret" |
||||
|
||||
assert CredentialService(backend).get_api_key() == "secret" |
||||
assert backend.calls == [("get", "voice-transcriptor", "openai-api-key")] |
||||
|
||||
|
||||
def test_has_api_key_reflects_keyring_value() -> None: |
||||
backend = FakeKeyring() |
||||
service = CredentialService(backend) |
||||
|
||||
assert service.has_api_key() is False |
||||
backend.value = "secret" |
||||
assert service.has_api_key() is True |
||||
|
||||
|
||||
def test_set_api_key_rejects_blank_values() -> None: |
||||
backend = FakeKeyring() |
||||
|
||||
with pytest.raises(CredentialError, match="API key"): |
||||
CredentialService(backend).set_api_key(" \t") |
||||
|
||||
assert backend.calls == [] |
||||
|
||||
|
||||
def test_set_api_key_persists_nonblank_value() -> None: |
||||
backend = FakeKeyring() |
||||
|
||||
CredentialService(backend).set_api_key("secret") |
||||
|
||||
assert backend.calls == [("set", "voice-transcriptor", "openai-api-key", "secret")] |
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["get_password", "set_password"]) |
||||
def test_backend_failures_are_sanitized(method: str) -> None: |
||||
class FailingKeyring: |
||||
def get_password(self, service: str, account: str) -> str | None: |
||||
raise RuntimeError("backend leaked secret") |
||||
|
||||
def set_password(self, service: str, account: str, value: str) -> None: |
||||
raise RuntimeError("backend leaked secret") |
||||
|
||||
service = CredentialService(FailingKeyring()) |
||||
|
||||
with pytest.raises(CredentialError) as caught: |
||||
if method == "get_password": |
||||
service.get_api_key() |
||||
else: |
||||
service.set_api_key("secret") |
||||
|
||||
assert "secret" not in str(caught.value).lower() |
||||
assert caught.value.__cause__ is None |
||||
@ -0,0 +1,88 @@
|
||||
import json |
||||
from pathlib import Path |
||||
|
||||
import pytest |
||||
|
||||
from voice_transcriptor.models import AppSettings |
||||
from voice_transcriptor.services.settings import SettingsRepository |
||||
|
||||
|
||||
def test_load_missing_file_returns_documented_defaults(monkeypatch, tmp_path: Path) -> None: |
||||
documents = tmp_path / "Documents" |
||||
documents.mkdir() |
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) |
||||
|
||||
settings, warning = SettingsRepository(tmp_path / "settings.json").load() |
||||
|
||||
assert settings == AppSettings("gpt-4o-transcribe", "pt-BR", documents) |
||||
assert warning is None |
||||
|
||||
|
||||
def test_load_missing_file_uses_home_when_documents_is_unavailable( |
||||
monkeypatch, tmp_path: Path |
||||
) -> None: |
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) |
||||
|
||||
settings, warning = SettingsRepository(tmp_path / "settings.json").load() |
||||
|
||||
assert settings.output_directory == tmp_path |
||||
assert warning is None |
||||
|
||||
|
||||
def test_save_and_load_round_trip_utf8_settings(tmp_path: Path) -> None: |
||||
path = tmp_path / "settings.json" |
||||
expected = AppSettings("gpt-4o-transcribe", "pt-BR", tmp_path / "Transcrições") |
||||
repository = SettingsRepository(path) |
||||
|
||||
repository.save(expected) |
||||
actual, warning = repository.load() |
||||
|
||||
assert actual == expected |
||||
assert warning is None |
||||
assert json.loads(path.read_text(encoding="utf-8")) == { |
||||
"model": "gpt-4o-transcribe", |
||||
"language": "pt-BR", |
||||
"output_directory": str(tmp_path / "Transcrições"), |
||||
} |
||||
|
||||
|
||||
def test_load_malformed_file_recovers_defaults_with_nonfatal_warning(tmp_path: Path) -> None: |
||||
path = tmp_path / "settings.json" |
||||
path.write_text("not json", encoding="utf-8") |
||||
|
||||
settings, warning = SettingsRepository(path).load() |
||||
|
||||
assert settings.model == "gpt-4o-transcribe" |
||||
assert warning is not None |
||||
assert "settings" in warning.lower() |
||||
|
||||
|
||||
def test_save_replaces_existing_file_atomically(monkeypatch, tmp_path: Path) -> None: |
||||
path = tmp_path / "settings.json" |
||||
path.write_text("old", encoding="utf-8") |
||||
calls: list[tuple[Path, Path]] = [] |
||||
original_replace = Path.replace |
||||
|
||||
def recording_replace(source: Path, destination: Path) -> Path: |
||||
calls.append((source, destination)) |
||||
return original_replace(source, destination) |
||||
|
||||
monkeypatch.setattr(Path, "replace", recording_replace) |
||||
|
||||
SettingsRepository(path).save(AppSettings("model", "pt-BR", tmp_path / "out")) |
||||
|
||||
assert calls |
||||
source, destination = calls[0] |
||||
assert source.parent == path.parent |
||||
assert destination == path |
||||
assert json.loads(path.read_text(encoding="utf-8"))["model"] == "model" |
||||
|
||||
|
||||
def test_saved_settings_never_include_api_key_fields(tmp_path: Path) -> None: |
||||
path = tmp_path / "settings.json" |
||||
|
||||
SettingsRepository(path).save(AppSettings("model", "pt-BR", tmp_path / "out")) |
||||
|
||||
serialized = path.read_text(encoding="utf-8").lower() |
||||
assert "api" not in serialized |
||||
assert "key" not in serialized |
||||
Loading…
Reference in new issue