diff options
| author | Yuval Adam <_@yuv.al> | 2026-07-24 13:27:46 +0200 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2026-07-24 13:27:46 +0200 |
| commit | 58b465b5b84cb90424eeffd6f877c8e5ad7bd647 (patch) | |
| tree | 828e16dd5eed5f74b3314a63d5e2c04762a9bc07 | |
| parent | 70781bce8d2c18c8292a89653b6183d2bb210441 (diff) | |
Replace environment configuration with TOML
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | SPEC.md | 2 | ||||
| -rw-r--r-- | docs/CONFIGURATION.md | 46 | ||||
| -rw-r--r-- | parley/cli.py | 2 | ||||
| -rw-r--r-- | parley/config.py | 128 | ||||
| -rw-r--r-- | parley/daemon.py | 2 | ||||
| -rwxr-xr-x | scripts/disable-ibus-test | 19 | ||||
| -rwxr-xr-x | scripts/enable-ibus-test | 34 | ||||
| -rw-r--r-- | tests/test_config.py | 80 |
9 files changed, 212 insertions, 103 deletions
@@ -45,7 +45,7 @@ Parley requires the Parakeet `transcribe-cli` runtime and Unified English model. ~/.local/share/parley/models/ ``` -The model is not stored in this repository. The current runtime build also depends on host CUDA and MKL libraries. +The model is not stored in this repository. The current runtime build also depends on host CUDA and MKL libraries. Optional settings live in [`~/.config/parley/config.toml`](docs/CONFIGURATION.md); Parley does not use application-specific environment variables. ## Status @@ -334,7 +334,7 @@ The extension shortcut replaces or disables the development custom shortcut to p ## 10. Configuration -Configuration should use GSettings once schemas exist. Development may use a documented config file or environment variables until then. +Daemon configuration uses the optional TOML file `~/.config/parley/config.toml` with system-oriented defaults. Parley does not use application-specific environment variables. GNOME Shell-only preferences may use GSettings when schemas are introduced. Planned settings: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..529fab7 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,46 @@ +# Configuration + +Parley works without a configuration file. System executables are discovered on +`PATH`, the model uses the standard Parley data directory, IBus insertion is +enabled, and notifications are disabled. + +Optional settings live in: + +```text +~/.config/parley/config.toml +``` + +Only settings that differ from the defaults need to be present: + +```toml +[audio] +ffmpeg = "ffmpeg" +device = "default" +sample_rate = 16000 + +[transcription] +command = "transcribe-cli" +model = "~/.local/share/parley/models/parakeet-unified-en-0.6b-Q8_0.gguf" +language = "en" + +[storage] +transcript_directory = "~/.local/share/parley/transcripts" + +[insertion] +enabled = true +mode = "ibus" # "ibus" or "clipboard" + +[notifications] +enabled = false +``` + +Executable settings may be command names on `PATH` or absolute paths. The +configured transcription command must currently support the same command-line +and JSONL output contract as `transcribe-cli`. Supporting unrelated runtimes +requires a separate adapter and is not implied by changing `command`. + +Restart the daemon after editing the file: + +```bash +systemctl --user restart parley.service +``` diff --git a/parley/cli.py b/parley/cli.py index 735d532..668527a 100644 --- a/parley/cli.py +++ b/parley/cli.py @@ -58,7 +58,7 @@ def run_once(config: Config) -> tuple[str, Path]: def main() -> int: try: - transcript, path = run_once(Config.from_environment()) + transcript, path = run_once(Config.load()) print(transcript) print(f"Saved to {path}", file=sys.stderr) return 0 diff --git a/parley/config.py b/parley/config.py index 971b6da..33fdc30 100644 --- a/parley/config.py +++ b/parley/config.py @@ -1,25 +1,50 @@ -"""Development configuration for Parley. - -Environment variables are intentionally temporary; GSettings will replace them once -schemas are introduced. -""" +"""TOML configuration and system defaults for Parley.""" from dataclasses import dataclass -import os from pathlib import Path import shutil +import tomllib from .errors import ConfigurationError -def _default_data_home() -> Path: - value = os.environ.get("XDG_DATA_HOME") - return Path(value).expanduser() if value else Path.home() / ".local" / "share" +DEFAULT_CONFIG_PATH = Path.home() / ".config/parley/config.toml" +DEFAULT_MODEL_PATH = ( + Path.home() / ".local/share/parley/models/parakeet-unified-en-0.6b-Q8_0.gguf" +) +DEFAULT_TRANSCRIPT_DIR = Path.home() / ".local/share/parley/transcripts" + + +def _executable(value: str) -> Path: + return Path(shutil.which(value) or value).expanduser() + + +def _table(document: dict, name: str) -> dict: + value = document.get(name, {}) + if not isinstance(value, dict): + raise ConfigurationError(f"configuration section [{name}] must be a table") + return value + +def _string(table: dict, key: str, default: str) -> str: + value = table.get(key, default) + if not isinstance(value, str): + raise ConfigurationError(f"configuration value {key} must be a string") + return value -def _prototype_root() -> Path: - # The source checkout layout documented in README.md. - return Path(__file__).resolve().parents[2] / "transcribe-parakeet" + +def _boolean(table: dict, key: str, default: bool) -> bool: + value = table.get(key, default) + if not isinstance(value, bool): + raise ConfigurationError(f"configuration value {key} must be a boolean") + return value + + +def _integer(table: dict, key: str, default: int) -> int: + value = table.get(key, default) + if not isinstance(value, int) or isinstance(value, bool): + raise ConfigurationError(f"configuration value {key} must be an integer") + return value @dataclass(frozen=True, slots=True) @@ -31,57 +56,68 @@ class Config: device: str = "default" language: str = "en" sample_rate: int = 16_000 - auto_insert: bool = False - insertion_mode: str = "clipboard" + auto_insert: bool = True + insertion_mode: str = "ibus" notifications_enabled: bool = False @classmethod - def from_environment(cls) -> "Config": - prototype = _prototype_root() - data_home = _default_data_home() - ffmpeg = os.environ.get("PARLEY_FFMPEG") or shutil.which("ffmpeg") or "ffmpeg" - installed_model = data_home / "parley/models/parakeet-unified-en-0.6b-Q8_0.gguf" - default_model = ( - installed_model - if installed_model.is_file() - else prototype / "models/parakeet-unified-en-0.6b-Q8_0.gguf" - ) - return cls( - ffmpeg=Path(ffmpeg).expanduser(), - transcribe_cli=Path( - os.environ.get("PARLEY_TRANSCRIBE_CLI") - or shutil.which("transcribe-cli") - or prototype / "bin/transcribe-cli" + def load(cls, path: Path = DEFAULT_CONFIG_PATH) -> "Config": + document: dict = {} + if path.is_file(): + try: + with path.open("rb") as source: + document = tomllib.load(source) + except (OSError, tomllib.TOMLDecodeError) as error: + raise ConfigurationError( + f"could not read configuration {path}: {error}" + ) from error + + audio = _table(document, "audio") + transcription = _table(document, "transcription") + storage = _table(document, "storage") + insertion = _table(document, "insertion") + notifications = _table(document, "notifications") + + config = cls( + ffmpeg=_executable(_string(audio, "ffmpeg", "ffmpeg")), + transcribe_cli=_executable( + _string(transcription, "command", "transcribe-cli") + ), + model=Path( + _string(transcription, "model", str(DEFAULT_MODEL_PATH)) ).expanduser(), - model=Path(os.environ.get("PARLEY_MODEL") or default_model).expanduser(), transcript_dir=Path( - os.environ.get( - "PARLEY_TRANSCRIPT_DIR", data_home / "parley/transcripts" - ) + _string(storage, "transcript_directory", str(DEFAULT_TRANSCRIPT_DIR)) ).expanduser(), - device=os.environ.get("PARLEY_DEVICE", "default"), - language=os.environ.get("PARLEY_LANGUAGE", "en"), - auto_insert=os.environ.get("PARLEY_AUTO_INSERT", "0").lower() - in {"1", "true", "yes", "on"}, - insertion_mode=os.environ.get("PARLEY_INSERTION_MODE", "clipboard"), - notifications_enabled=os.environ.get("PARLEY_NOTIFICATIONS", "0").lower() - in {"1", "true", "yes", "on"}, + device=_string(audio, "device", "default"), + language=_string(transcription, "language", "en"), + sample_rate=_integer(audio, "sample_rate", 16_000), + auto_insert=_boolean(insertion, "enabled", True), + insertion_mode=_string(insertion, "mode", "ibus"), + notifications_enabled=_boolean(notifications, "enabled", False), ) + config._validate_settings() + return config + + def _validate_settings(self) -> None: + if self.sample_rate <= 0: + raise ConfigurationError("audio.sample_rate must be positive") + if self.insertion_mode not in {"clipboard", "ibus"}: + raise ConfigurationError( + f"unsupported insertion mode: {self.insertion_mode}" + ) def validate(self) -> None: + self._validate_settings() if not self.ffmpeg.is_file(): raise ConfigurationError(f"ffmpeg not found: {self.ffmpeg}") if not self.transcribe_cli.is_file(): raise ConfigurationError( f"transcription executable not found: {self.transcribe_cli}" ) - if not os.access(self.transcribe_cli, os.X_OK): + if not self.transcribe_cli.stat().st_mode & 0o111: raise ConfigurationError( f"transcription executable is not executable: {self.transcribe_cli}" ) if not self.model.is_file(): raise ConfigurationError(f"model not found: {self.model}") - if self.insertion_mode not in {"clipboard", "ibus"}: - raise ConfigurationError( - f"unsupported insertion mode: {self.insertion_mode}" - ) diff --git a/parley/daemon.py b/parley/daemon.py index de76bcd..fe7bc29 100644 --- a/parley/daemon.py +++ b/parley/daemon.py @@ -33,7 +33,7 @@ class Daemon: ) self.node_info = Gio.DBusNodeInfo.new_for_xml(xml) self.interface_info = self.node_info.interfaces[0] - resolved_config = config or Config.from_environment() + resolved_config = config or Config.load() self.loop = GLib.MainLoop() self.ibus = IBusIntegration( activate_on_connect=resolved_config.auto_insert diff --git a/scripts/disable-ibus-test b/scripts/disable-ibus-test deleted file mode 100755 index fe8014c..0000000 --- a/scripts/disable-ibus-test +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -state_dir=${XDG_STATE_HOME:-$HOME/.local/state}/parley -dropin=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/parley.service.d/ibus-test.conf -previous_file=$state_dir/previous-ibus-engine - -rm -f "$dropin" -systemctl --user daemon-reload -systemctl --user restart parley.service - -if [[ -s "$previous_file" ]]; then - previous=$(<"$previous_file") - ibus engine "$previous" - rm -f "$previous_file" - echo "Restored IBus engine: $previous" -else - echo "Parley IBus automatic insertion disabled" -fi diff --git a/scripts/enable-ibus-test b/scripts/enable-ibus-test deleted file mode 100755 index 50c3cba..0000000 --- a/scripts/enable-ibus-test +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -state_dir=${XDG_STATE_HOME:-$HOME/.local/state}/parley -dropin_dir=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/parley.service.d -mkdir -p "$state_dir" "$dropin_dir" - -previous=$(ibus engine 2>/dev/null || true) -if [[ -z "$previous" ]]; then - previous=xkb:us::eng -fi -if [[ "$previous" != "parley" ]]; then - printf '%s\n' "$previous" > "$state_dir/previous-ibus-engine" -fi - -cat > "$dropin_dir/ibus-test.conf" <<'EOF' -[Service] -Environment=PARLEY_AUTO_INSERT=1 -Environment=PARLEY_INSERTION_MODE=ibus -EOF -systemctl --user daemon-reload -systemctl --user restart parley.service - -for _ in {1..20}; do - ibus engine parley >/dev/null 2>&1 || true - [[ $(ibus engine 2>/dev/null || true) == parley ]] && break - sleep 0.1 -done -[[ $(ibus engine 2>/dev/null || true) == parley ]] || { - echo "could not select the Parley IBus engine" >&2 - exit 1 -} - -echo "Parley IBus test mode enabled (previous engine: $previous)" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..2c1dac3 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,80 @@ +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from parley.config import Config, DEFAULT_MODEL_PATH, DEFAULT_TRANSCRIPT_DIR +from parley.errors import ConfigurationError + + +class ConfigTests(unittest.TestCase): + @patch("parley.config.shutil.which") + def test_system_defaults(self, which) -> None: + which.side_effect = lambda command: f"/usr/bin/{command}" + + config = Config.load(Path("/nonexistent/parley.toml")) + + self.assertEqual(config.ffmpeg, Path("/usr/bin/ffmpeg")) + self.assertEqual(config.transcribe_cli, Path("/usr/bin/transcribe-cli")) + self.assertEqual(config.model, DEFAULT_MODEL_PATH) + self.assertEqual(config.transcript_dir, DEFAULT_TRANSCRIPT_DIR) + self.assertTrue(config.auto_insert) + self.assertEqual(config.insertion_mode, "ibus") + self.assertFalse(config.notifications_enabled) + + def test_loads_toml_overrides(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "config.toml" + path.write_text( + """ +[audio] +ffmpeg = "/opt/ffmpeg" +device = "studio" +sample_rate = 48000 + +[transcription] +command = "/opt/transcribe" +model = "~/model.gguf" +language = "fr" + +[storage] +transcript_directory = "~/dictation" + +[insertion] +enabled = false +mode = "clipboard" + +[notifications] +enabled = true +""", + encoding="utf-8", + ) + + config = Config.load(path) + + self.assertEqual(config.ffmpeg, Path("/opt/ffmpeg")) + self.assertEqual(config.transcribe_cli, Path("/opt/transcribe")) + self.assertEqual(config.device, "studio") + self.assertEqual(config.sample_rate, 48_000) + self.assertEqual(config.language, "fr") + self.assertFalse(config.auto_insert) + self.assertEqual(config.insertion_mode, "clipboard") + self.assertTrue(config.notifications_enabled) + + def test_rejects_malformed_toml(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "config.toml" + path.write_text("[audio\n", encoding="utf-8") + with self.assertRaisesRegex(ConfigurationError, "could not read"): + Config.load(path) + + def test_rejects_invalid_types(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "config.toml" + path.write_text("[insertion]\nenabled = 'yes'\n", encoding="utf-8") + with self.assertRaisesRegex(ConfigurationError, "must be a boolean"): + Config.load(path) + + +if __name__ == "__main__": + unittest.main() |
