Browse Source

feat: persist settings and credentials securely

master
Yutsuo 4 days ago
parent
commit
7c78ad465e
  1. 6
      README.md
  2. 37
      src/voice_transcriptor/services/credentials.py
  3. 87
      src/voice_transcriptor/services/settings.py
  4. 72
      tests/test_credentials.py
  5. 88
      tests/test_settings.py

6
README.md

@ -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.

37
src/voice_transcriptor/services/credentials.py

@ -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

87
src/voice_transcriptor/services/settings.py

@ -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))

72
tests/test_credentials.py

@ -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

88
tests/test_settings.py

@ -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…
Cancel
Save