Files
Saiki/saiki/config.py
2026-05-26 18:09:26 -04:00

149 lines
4.6 KiB
Python

"""Configuration loading for Saiki.
Defaults mirror the original scripts. Users can override them with YAML at
~/.config/saiki/config.yaml or by passing --config to the CLI.
"""
from __future__ import annotations
import copy
import os
from dataclasses import dataclass
from typing import Any
try:
import yaml
except Exception: # pragma: no cover - handled when config files are loaded
yaml = None
DEFAULT_CONFIG: dict[str, Any] = {
"anki_connect_url": "http://localhost:8765",
"media_dir": "~/.var/app/net.ankiweb.Anki/data/Anki2/User 1/collection.media",
"audio_output_root": "~/Languages/Anki/anki-audio",
"word_output_root": "~/Languages/Anki/anki-words",
"sentence_dir": "~/Languages/Anki",
"note_model": "Basic",
"fields": {"front": "Front", "back": "Back"},
"languages": {
"jp": {
"name": "japanese",
"transcript_code": "ja",
"tts_code": "ja",
"tts_tld": "com",
"tts_tempo": 1.35,
"decks": ["日本語"],
"word_model": "ja_core_news_lg",
"field": "Back",
"sentence_file": "sentences_jp.txt",
},
"es": {
"name": "spanish",
"transcript_code": "es",
"tts_code": "es",
"tts_tld": "es",
"tts_tempo": 1.25,
"decks": ["Español"],
"word_model": "es_core_news_sm",
"field": "Back",
"sentence_file": "sentences_es.txt",
},
},
}
@dataclass(frozen=True)
class Config:
data: dict[str, Any]
@property
def anki_connect_url(self) -> str:
return str(self.data["anki_connect_url"])
@property
def media_dir(self) -> str:
return expand_path(str(self.data["media_dir"]))
@property
def audio_output_root(self) -> str:
return expand_path(str(self.data["audio_output_root"]))
@property
def word_output_root(self) -> str:
return expand_path(str(self.data["word_output_root"]))
@property
def sentence_dir(self) -> str:
return expand_path(str(self.data["sentence_dir"]))
@property
def note_model(self) -> str:
return str(self.data.get("note_model", "Basic"))
@property
def fields(self) -> dict[str, str]:
return dict(self.data.get("fields", {}))
@property
def languages(self) -> dict[str, dict[str, Any]]:
return dict(self.data.get("languages", {}))
def language(self, lang: str) -> dict[str, Any]:
try:
return dict(self.languages[lang])
except KeyError as e:
available = ", ".join(sorted(self.languages))
raise ValueError(f"Unsupported language '{lang}'. Available: {available}") from e
def language_name(self, lang: str) -> str:
return str(self.language(lang)["name"])
def transcript_code(self, lang: str) -> str:
return str(self.language(lang)["transcript_code"])
def decks_for(self, lang: str) -> list[str]:
return list(self.language(lang).get("decks", []))
def field_for(self, lang: str) -> str:
return str(self.language(lang).get("field", self.fields.get("back", "Back")))
def sentence_file_for(self, lang: str) -> str:
value = str(self.language(lang).get("sentence_file", f"sentences_{lang}.txt"))
return expand_path(value if os.path.isabs(value) or value.startswith("~") else os.path.join(self.sentence_dir, value))
def expand_path(path: str) -> str:
return os.path.expanduser(os.path.expandvars(path))
def default_config_path() -> str:
return expand_path("~/.config/saiki/config.yaml")
def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
result = copy.deepcopy(base)
for key, value in override.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
def load_config(path: str | None = None) -> Config:
config = copy.deepcopy(DEFAULT_CONFIG)
config_path = expand_path(path) if path else default_config_path()
if os.path.exists(config_path):
if yaml is None:
raise RuntimeError("Loading config files requires PyYAML. Install pyyaml.")
with open(config_path, "r", encoding="utf-8") as fh:
loaded = yaml.safe_load(fh) or {}
if not isinstance(loaded, dict):
raise RuntimeError(f"Config must be a YAML mapping: {config_path}")
config = deep_merge(config, loaded)
return Config(config)
def language_choices(config: Config) -> list[str]:
return sorted(config.languages.keys())