summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuval Adam <_@yuv.al>2026-07-24 13:27:46 +0200
committerYuval Adam <_@yuv.al>2026-07-24 13:27:46 +0200
commit58b465b5b84cb90424eeffd6f877c8e5ad7bd647 (patch)
tree828e16dd5eed5f74b3314a63d5e2c04762a9bc07
parent70781bce8d2c18c8292a89653b6183d2bb210441 (diff)
Replace environment configuration with TOML
-rw-r--r--README.md2
-rw-r--r--SPEC.md2
-rw-r--r--docs/CONFIGURATION.md46
-rw-r--r--parley/cli.py2
-rw-r--r--parley/config.py128
-rw-r--r--parley/daemon.py2
-rwxr-xr-xscripts/disable-ibus-test19
-rwxr-xr-xscripts/enable-ibus-test34
-rw-r--r--tests/test_config.py80
9 files changed, 212 insertions, 103 deletions
diff --git a/README.md b/README.md
index c113776..67a7375 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/SPEC.md b/SPEC.md
index d105cf5..d79ef59 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -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()