From ca9104381d237887b12c7f90c18b75f4889042fc Mon Sep 17 00:00:00 2001 From: Azis Date: Sun, 15 Feb 2026 13:09:35 +0100 Subject: [PATCH 1/3] refactor: abstract platform-specific audio playback and clipboard commands - Add _default_play_cmd(): detects afplay (macOS), paplay/aplay/ffplay (Linux) - Add _default_clipboard_cmd(): detects pbpaste (macOS), wl-paste/xclip/xsel (Linux) - Inject 'run' dependency into get_text() for clipboard path (was calling subprocess.run directly, bypassing the DI pattern used everywhere else) - Update speak_text() to use _default_play_cmd() instead of hardcoded 'afplay' - Add 14 new tests for platform detection and clipboard run injection - All 44 tests pass --- reed.py | 46 +++++++++++++-- test_reed.py | 157 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 193 insertions(+), 10 deletions(-) diff --git a/reed.py b/reed.py index e76fafb..6d6b007 100755 --- a/reed.py +++ b/reed.py @@ -2,6 +2,8 @@ """reed - A CLI that reads text aloud using piper-tts.""" import argparse +import platform +import shutil import subprocess import sys import tempfile @@ -43,9 +45,42 @@ class ReedError(Exception): } -def get_text(args: argparse.Namespace, stdin: TextIO) -> str: +def _default_play_cmd() -> list[str]: + if platform.system() == "Darwin": + return ["afplay"] + if platform.system() == "Linux": + for cmd, args in [ + ("paplay", []), + ("aplay", []), + ("ffplay", ["-nodisp", "-autoexit"]), + ]: + if shutil.which(cmd): + return [cmd, *args] + raise ReedError("No supported audio player found") + + +def _default_clipboard_cmd() -> list[str]: + if platform.system() == "Darwin": + return ["pbpaste"] + if platform.system() == "Linux": + for cmd, args in [ + ("wl-paste", []), + ("xclip", ["-selection", "clipboard", "-o"]), + ("xsel", ["--clipboard", "--output"]), + ]: + if shutil.which(cmd): + return [cmd, *args] + raise ReedError("No supported clipboard tool found") + + +def get_text( + args: argparse.Namespace, + stdin: TextIO, + run: Callable = subprocess.run, +) -> str: if args.clipboard: - result = subprocess.run(["pbpaste"], capture_output=True, text=True) + clipboard_cmd = _default_clipboard_cmd() + result = run(clipboard_cmd, capture_output=True, text=True) if result.returncode != 0: raise ReedError("Failed to read clipboard") return result.stdout.strip() @@ -175,9 +210,10 @@ def speak_text( f"\n[bold green]✓ Generated in {time.time() - start:.1f}s[/bold green]" ) print_playback_progress(print_fn) - result = run(["afplay", tmp.name]) + play_cmd = _default_play_cmd() + result = run([*play_cmd, tmp.name]) if result.returncode != 0: - raise ReedError("afplay error") + raise ReedError("playback error") print_fn("[bold green]✓ Done[/bold green]") @@ -338,7 +374,7 @@ def main( try: assert stdin is not None - text = get_text(args, stdin) + text = get_text(args, stdin, run=run) if not text: print_error("No text to read.", print_fn) diff --git a/test_reed.py b/test_reed.py index 367528c..8aa32df 100644 --- a/test_reed.py +++ b/test_reed.py @@ -249,8 +249,8 @@ def test_with_output_file(self): class TestSpeakText: - def test_play_path_calls_piper_then_afplay(self): - from reed import speak_text + def test_play_path_calls_piper_then_player(self): + from reed import speak_text, _default_play_cmd calls = [] @@ -264,7 +264,8 @@ def fake_run(cmd, **kwargs): assert len(calls) == 2 assert calls[0][0][1:3] == ["-m", "piper"] assert calls[0][1].get("input") == "hi" - assert calls[1][0][0] == "afplay" + play_cmd = _default_play_cmd() + assert calls[1][0][: len(play_cmd)] == play_cmd def test_output_path_no_afplay(self): from reed import speak_text @@ -293,7 +294,7 @@ def fake_run(cmd, **kwargs): with pytest.raises(ReedError, match="boom"): speak_text("hi", args, run=fake_run) - def test_afplay_error_raises(self): + def test_playback_error_raises(self): from reed import speak_text, ReedError call_count = 0 @@ -306,7 +307,7 @@ def fake_run(cmd, **kwargs): return types.SimpleNamespace(returncode=1, stderr="") args = _make_args() - with pytest.raises(ReedError, match="afplay error"): + with pytest.raises(ReedError, match="playback error"): speak_text("hi", args, run=fake_run) @@ -388,6 +389,152 @@ def test_none_stdin(self): assert _should_enter_interactive(args, None) is False +# ─── _default_play_cmd tests ────────────────────────────────────────── + + +class TestDefaultPlayCmd: + def test_macos_returns_afplay(self, monkeypatch): + from reed import _default_play_cmd + + monkeypatch.setattr("reed.platform.system", lambda: "Darwin") + assert _default_play_cmd() == ["afplay"] + + def test_linux_paplay(self, monkeypatch): + from reed import _default_play_cmd + + monkeypatch.setattr("reed.platform.system", lambda: "Linux") + monkeypatch.setattr( + "reed.shutil.which", + lambda cmd: "/usr/bin/paplay" if cmd == "paplay" else None, + ) + assert _default_play_cmd() == ["paplay"] + + def test_linux_aplay_fallback(self, monkeypatch): + from reed import _default_play_cmd + + monkeypatch.setattr("reed.platform.system", lambda: "Linux") + monkeypatch.setattr( + "reed.shutil.which", + lambda cmd: "/usr/bin/aplay" if cmd == "aplay" else None, + ) + assert _default_play_cmd() == ["aplay"] + + def test_linux_ffplay_fallback(self, monkeypatch): + from reed import _default_play_cmd + + monkeypatch.setattr("reed.platform.system", lambda: "Linux") + monkeypatch.setattr( + "reed.shutil.which", + lambda cmd: "/usr/bin/ffplay" if cmd == "ffplay" else None, + ) + assert _default_play_cmd() == ["ffplay", "-nodisp", "-autoexit"] + + def test_linux_no_player_raises(self, monkeypatch): + from reed import _default_play_cmd, ReedError + + monkeypatch.setattr("reed.platform.system", lambda: "Linux") + monkeypatch.setattr("reed.shutil.which", lambda cmd: None) + with pytest.raises(ReedError, match="No supported audio player found"): + _default_play_cmd() + + def test_unknown_platform_raises(self, monkeypatch): + from reed import _default_play_cmd, ReedError + + monkeypatch.setattr("reed.platform.system", lambda: "Windows") + with pytest.raises(ReedError, match="No supported audio player found"): + _default_play_cmd() + + +# ─── _default_clipboard_cmd tests ──────────────────────────────────── + + +class TestDefaultClipboardCmd: + def test_macos_returns_pbpaste(self, monkeypatch): + from reed import _default_clipboard_cmd + + monkeypatch.setattr("reed.platform.system", lambda: "Darwin") + assert _default_clipboard_cmd() == ["pbpaste"] + + def test_linux_wl_paste(self, monkeypatch): + from reed import _default_clipboard_cmd + + monkeypatch.setattr("reed.platform.system", lambda: "Linux") + monkeypatch.setattr( + "reed.shutil.which", + lambda cmd: "/usr/bin/wl-paste" if cmd == "wl-paste" else None, + ) + assert _default_clipboard_cmd() == ["wl-paste"] + + def test_linux_xclip_fallback(self, monkeypatch): + from reed import _default_clipboard_cmd + + monkeypatch.setattr("reed.platform.system", lambda: "Linux") + monkeypatch.setattr( + "reed.shutil.which", + lambda cmd: "/usr/bin/xclip" if cmd == "xclip" else None, + ) + assert _default_clipboard_cmd() == ["xclip", "-selection", "clipboard", "-o"] + + def test_linux_xsel_fallback(self, monkeypatch): + from reed import _default_clipboard_cmd + + monkeypatch.setattr("reed.platform.system", lambda: "Linux") + monkeypatch.setattr( + "reed.shutil.which", lambda cmd: "/usr/bin/xsel" if cmd == "xsel" else None + ) + assert _default_clipboard_cmd() == ["xsel", "--clipboard", "--output"] + + def test_linux_no_clipboard_raises(self, monkeypatch): + from reed import _default_clipboard_cmd, ReedError + + monkeypatch.setattr("reed.platform.system", lambda: "Linux") + monkeypatch.setattr("reed.shutil.which", lambda cmd: None) + with pytest.raises(ReedError, match="No supported clipboard tool found"): + _default_clipboard_cmd() + + def test_unknown_platform_raises(self, monkeypatch): + from reed import _default_clipboard_cmd, ReedError + + monkeypatch.setattr("reed.platform.system", lambda: "Windows") + with pytest.raises(ReedError, match="No supported clipboard tool found"): + _default_clipboard_cmd() + + +# ─── get_text clipboard with run injection test ────────────────────── + + +class TestGetTextClipboard: + def test_clipboard_uses_injected_run(self): + from reed import get_text + + def fake_run(cmd, **kwargs): + return types.SimpleNamespace( + returncode=0, stdout="clipboard text", stderr="" + ) + + class FakeTty: + def isatty(self): + return True + + args = _make_args(clipboard=True) + result = get_text(args, stdin=FakeTty(), run=fake_run) + assert result == "clipboard text" + + def test_clipboard_error_raises(self): + from reed import get_text, ReedError + + def fake_run(cmd, **kwargs): + return types.SimpleNamespace(returncode=1, stdout="", stderr="fail") + + class FakeTty: + def isatty(self): + return True + + args = _make_args(clipboard=True) + with pytest.raises(ReedError, match="Failed to read clipboard"): + get_text(args, stdin=FakeTty(), run=fake_run) + + # ─── get_text with stdin injection tests ───────────────────────────── From 44c55736e4fe40c17775c8a17136a6209c52765e Mon Sep 17 00:00:00 2001 From: Azis Date: Sun, 15 Feb 2026 18:02:37 +0100 Subject: [PATCH 2/3] docs: update README and CHANGELOG for macOS + Linux platform support --- CHANGELOG.md | 8 +++++++- README.md | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb9ace..e54c5ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Platform abstraction +- **macOS and Linux support**: Audio playback and clipboard commands are now auto-detected at runtime instead of being hardcoded to macOS tools. + - Audio: `afplay` (macOS), `paplay`/`aplay`/`ffplay` (Linux) + - Clipboard: `pbpaste` (macOS), `wl-paste`/`xclip`/`xsel` (Linux) +- **`get_text()` now uses injected `run`**: The clipboard path previously called `subprocess.run` directly, bypassing the dependency injection pattern used everywhere else. Now consistent. + ### Rich terminal UI - **Added Rich library** for styled terminal output (colors, panels, spinners). - **Interactive banner** with styled markup replaces plain-text banner lines. @@ -34,6 +40,6 @@ - **Dependencies declared**: `piper-tts`, `prompt-toolkit`, and `rich` listed in `pyproject.toml`; `mypy` and `pytest` as optional dev dependencies. ### Tests -- **32 tests** (up from 25): added coverage for `/replay` (with and without prior text), `/clear` (verifies `clear_fn` called), afplay failure, missing model error, empty text error, `ReedError` propagation through `main`, `_should_enter_interactive` with `None` stdin, and `get_text` with text args. +- **44 tests** (up from 25): added coverage for platform detection (`_default_play_cmd`, `_default_clipboard_cmd`), clipboard `run` injection, `/replay` (with and without prior text), `/clear` (verifies `clear_fn` called), afplay failure, missing model error, empty text error, `ReedError` propagation through `main`, `_should_enter_interactive` with `None` stdin, and `get_text` with text args. - Banner test now captures `print_fn` output and verifies the banner was actually printed. - `TestMainErrors` uses Rich `Console` capture for end-to-end error message verification. diff --git a/README.md b/README.md index 08a7abe..4345009 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ A CLI that reads text aloud using [piper-tts](https://github.com/rhasspy/piper). ## Requirements - Python 3.14+ -- macOS (uses `afplay` for audio playback and `pbpaste` for clipboard) +- macOS or Linux + - **macOS**: `afplay` (audio), `pbpaste` (clipboard) — included with the OS + - **Linux**: one of `paplay`, `aplay`, or `ffplay` (audio); one of `wl-paste`, `xclip`, or `xsel` (clipboard) - [uv](https://docs.astral.sh/uv/) (for dependency management) - Rich library for beautiful terminal UI From 5a9728e729d5e3defa5d369e8cd5f10b4fa39485 Mon Sep 17 00:00:00 2001 From: Azis Date: Sun, 15 Feb 2026 18:05:01 +0100 Subject: [PATCH 3/3] docs: add no co-authored-by rule to AGENTS.md --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5b59007..d094098 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,3 +49,7 @@ This is `reed`, a Python CLI wrapper around piper-tts for text-to-speech on macO - Commands available in interactive mode: `/quit`, `/exit`, `/help`, `/clear`, `/replay` - Tab autocomplete available for commands - Include `print_fn` parameter for testability with dependency injection + +## Git + +- Do NOT add `Co-authored-by` or `Amp-Thread-ID` trailers to commits