Rename project to Saiki and unify CLI
This commit is contained in:
148
saiki/config.py
Normal file
148
saiki/config.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user