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