diff --git a/AGENTS.md b/AGENTS.md index 55b9ab0..be1e863 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ This is `reed`, a convenient CLI for text-to-speech using piper-tts. - Rich (terminal UI library for styled output) - Voice models stored in `~/.local/share/reed/` (Linux/macOS) or `%LOCALAPPDATA%\reed\` (Windows) - `prompt_toolkit` (interactive prompt with history and autocomplete) +- `pathvalidate` (required by piper-tts dependency chain — do NOT remove) ## Structure @@ -67,6 +68,8 @@ Start here: Read `ARCHITECTURE.md` before implementing features that touch playb - Thread safety: Use `threading.Lock` for shared state in `PlaybackController` - Non-blocking playback: Interactive mode uses `PlaybackController`; file/output modes use blocking `subprocess.run()` - Platform checks: Use `os.name == "posix"` for Unix-specific behavior (SIGSTOP/SIGCONT) +- Python 3.14+ syntax is allowed — e.g. `except A, B:` without parentheses (PEP 758), and other 3.14 features. Do not flag these as errors. +- Use `X | None` instead of `Optional[X]` — project uses modern union syntax throughout ## UI Development diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4ba4139..be577df 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -159,7 +159,7 @@ EPUB file → zipfile → META-INF/container.xml → OPF spine → XHTML chapter **Thread Safety:** - All state mutations protected by `threading.Lock` -- `_stop_requested` flag for clean shutdown +- `_stop_event` (threading.Event) for clean shutdown - Daemon threads prevent hanging on exit **Process Management:** diff --git a/findings.md b/findings.md new file mode 100644 index 0000000..e718adb --- /dev/null +++ b/findings.md @@ -0,0 +1,130 @@ +# Reed Code Review Findings + +**Date:** 2026-02-22 +**Scope:** Full repo — `reed.py` (1145 LOC), `test_reed.py` (1955 LOC), `pyproject.toml` +**Status:** 127 tests pass, 0 mypy errors + +--- + +## 🐛 Bugs + +### B1. `print_help()` — Cyan styling silently lost +**File:** `reed.py:678-682` +**Severity:** Low (cosmetic) + +```python +for cmd, desc in COMMANDS.items(): + cmd_text = Text(cmd) + cmd_text.stylize("cyan") + text.append(f"\n{cmd_text} - {desc}") # ← str(cmd_text) strips style +``` + +`f"\n{cmd_text}"` calls `__str__()` on the `Text` object, which returns plain text — the cyan style is discarded. The `/help` output renders all commands unstyled. + +**Fix:** Use `text.append("\n")` then `text.append(cmd_text)` then `text.append(f" - {desc}")`. + +--- + +### B2. Test passes wrong type to `speak_text` +**File:** `test_reed.py:620-622` +**Severity:** Low (test-only, works by accident) + +```python +args = _make_args() # returns argparse.Namespace +with pytest.raises(ReedError, match="playback error"): + speak_text("hi", args, run=fake_run) # ← expects ReedConfig, gets Namespace +``` + +`speak_text` expects `config: ReedConfig` but receives an `argparse.Namespace`. Works only because both objects have the same attribute names. Should use `_make_config()`. + +--- + +## 🧹 Clean Code + +### C1. Unused dependency: `pathvalidate` +**File:** `pyproject.toml:14` +**Severity:** Medium (bloats install) + +`pathvalidate` is listed in `[project.dependencies]` but never imported in `reed.py`. Dead dependency. + +--- + +### C2. Use modern `X | None` syntax instead of `Optional[X]` +**File:** `reed.py` (throughout) +**Severity:** Low (style) + +Project requires Python 3.14+. All `Optional[X]` can be `X | None`, and `list[str]` is already used. The `from typing import Optional` import can be removed. Same for `Sequence`, `Callable`, `Iterator`, `TextIO` which can come from `collections.abc` or be used directly. + +Currently mixed: `Optional[Path]` alongside `list[str]` — inconsistent. + +--- + +### C3. Complex EPUB empty-chapter skip logic in `main()` +**File:** `reed.py:1089-1127` +**Severity:** Medium (readability) + +The 40-line block with `spoken` set, inner for-loop, `for...else`, and `continue`/`break` is the hardest-to-read section. It handles: +1. Normal chapters +2. Empty chapters with a subsequent non-empty fallback +3. Empty chapters with no subsequent text + +This could be extracted to `_iter_epub_chapters_skipping_empty()` to keep `main()` cleaner and make the logic independently testable. + +--- + +### C4. Resource management — `_load_epub_spine` returns unclosed `ZipFile` +**File:** `reed.py:518-573` +**Severity:** Medium (resource leak risk) + +`_load_epub_spine` opens a `ZipFile` and returns it inside tuples. The caller is responsible for closing it. `_iter_epub_chapters` closes it in a `finally` block, but the `main()` EPUB path doesn't — it calls `_load_epub_spine` separately (line 1089) and the returned `zf` is never explicitly closed. + +**Fix:** Make `_load_epub_spine` a context manager, or close `chapters[0][1]` in `main()`. + +--- + +### C5. `_data_dir()` creates directory on every call +**File:** `reed.py:240-248` +**Severity:** Low (performance) + +`mkdir(parents=True, exist_ok=True)` is called every time `_data_dir()` is invoked. It's called multiple times per run (model resolution, voice listing, downloads). A `@functools.lru_cache` would eliminate redundant syscalls. + +Note: caching requires tests to clear the cache via monkeypatch — evaluate tradeoff. + +--- + +### C6. `_default_play_cmd()` re-detects platform and probes `shutil.which` on every call +**File:** `reed.py:320-343` +**Severity:** Low (performance) + +Called once per `speak_text` invocation. In blocking file mode, that's once per page/chapter. Caching would help for long PDFs. + +--- + +### C7. `_playback_worker` swallows piper errors +**File:** `reed.py:122-124` +**Severity:** Low (UX) + +When piper exits non-zero (but not due to stop), the worker prints `✗ Piper error` and returns silently. State transitions to IDLE via the finally block. There's no way for the caller (interactive loop) to know that generation failed — no callback, no state like `PlaybackState.ERROR`. + +Not blocking for current usage (user sees the error message), but worth noting for future. + +--- + +## 📐 Architecture Observations + +### A1. `main()` is 220+ lines with multiple responsibilities +The function handles: arg parsing, model resolution, subcommands (voices, download), config creation, model downloading, interactive routing, PDF reading, EPUB reading (with skip logic), plain text reading. Consider extracting subcommand handlers. + +### A2. Stale ARCHITECTURE.md reference +ARCHITECTURE.md line 162 mentions `_stop_requested` flag but code uses `_stop_event` (a `threading.Event`). Minor doc drift. + +--- + +## ✅ Things Done Well + +- **Dependency injection everywhere** — `run`, `print_fn`, `prompt_fn`, `clear_fn` make everything testable without mocking globals +- **127 tests with 0.12s runtime** — fast, comprehensive test suite +- **Clean separation** — `PlaybackController` is well-isolated with proper thread safety +- **Cross-platform support** — clean fallback chains for audio and clipboard +- **Rich UI** — good use of panels, tables, spinners without over-engineering +- **Monolithic-but-organized** — single file is a valid choice for CLI tools, and the code is well-sectioned diff --git a/reed.py b/reed.py index df6207f..72986f9 100755 --- a/reed.py +++ b/reed.py @@ -17,9 +17,10 @@ import zipfile from enum import Enum, auto from html.parser import HTMLParser -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Callable, Iterator, Optional, Sequence, TextIO +from collections.abc import Callable, Iterator, Sequence +from typing import TYPE_CHECKING, TextIO if TYPE_CHECKING: from prompt_toolkit import PromptSession @@ -61,15 +62,15 @@ class PlaybackController: """ def __init__(self, print_fn: Callable[..., None] = console.print) -> None: - self._current_proc: Optional[subprocess.Popen] = None - self._piper_proc: Optional[subprocess.Popen] = None - self._playback_thread: Optional[threading.Thread] = None + self._current_proc: subprocess.Popen | None = None + self._piper_proc: subprocess.Popen | None = None + self._playback_thread: threading.Thread | None = None self._state = PlaybackState.IDLE self._current_text = "" - self._config: Optional[ReedConfig] = None + self._config: ReedConfig | None = None self._lock = threading.Lock() self._print_fn = print_fn - self._stop_requested = False + self._stop_event = threading.Event() def play(self, text: str, config: ReedConfig) -> None: """Start playback of text in a background thread. @@ -82,7 +83,7 @@ def play(self, text: str, config: ReedConfig) -> None: self._current_text = text self._config = config self._state = PlaybackState.PLAYING - self._stop_requested = False + self._stop_event.clear() self._playback_thread = threading.Thread( target=self._playback_worker, args=(text, config), daemon=True ) @@ -119,7 +120,7 @@ def _playback_worker(self, text: str, config: ReedConfig) -> None: input=text.encode("utf-8") ) - if self._stop_requested or self._piper_proc.returncode != 0: + if self._stop_event.is_set() or self._piper_proc.returncode != 0: self._print_fn("\n[bold red]✗ Piper error[/bold red]") return @@ -129,7 +130,7 @@ def _playback_worker(self, text: str, config: ReedConfig) -> None: # Wait for playback to complete or be interrupted self._current_proc.wait() - if self._stop_requested: + if self._stop_event.is_set(): self._state = PlaybackState.STOPPED self._print_fn("[bold red]⏹ Stopped[/bold red]") else: @@ -199,7 +200,7 @@ def _stop_locked(self) -> bool: if self._state == PlaybackState.IDLE: return False - self._stop_requested = True + self._stop_event.set() if self._current_proc: try: @@ -249,7 +250,10 @@ def _data_dir() -> Path: DEFAULT_MODEL_NAME = "en_US-kristin-medium" -DEFAULT_MODEL = _data_dir() / f"{DEFAULT_MODEL_NAME}.onnx" + + +def _default_model() -> Path: + return _data_dir() / f"{DEFAULT_MODEL_NAME}.onnx" def _model_url(name: str) -> tuple[str, str]: @@ -275,11 +279,11 @@ def _download_file( @dataclass(frozen=True) class ReedConfig: - model: Path = DEFAULT_MODEL + model: Path = field(default_factory=_default_model) speed: float = 1.0 volume: float = 1.0 silence: float = DEFAULT_SILENCE - output: Optional[Path] = None + output: Path | None = None def ensure_model( @@ -431,7 +435,7 @@ def _parse_range_selection( def _iter_pdf_pages( - path: Path, page_selection: Optional[str] + path: Path, page_selection: str | None ) -> Iterator[tuple[int, int, str]]: """Yield ``(page_number, total_pages, text)`` for each selected PDF page.""" if PdfReader is None: @@ -590,7 +594,7 @@ def _split_paragraphs(text: str) -> list[str]: def _iter_epub_chapters( - path: Path, chapter_selection: Optional[str] + path: Path, chapter_selection: str | None ) -> Iterator[tuple[int, int, str]]: """Yield ``(chapter_number, total_chapters, text)`` for each selected EPUB chapter.""" chapters = _load_epub_spine(path) @@ -603,9 +607,13 @@ def _iter_epub_chapters( else: chapter_indices = range(total_chapters) - for index in chapter_indices: - text = _read_epub_chapter(chapters[index]) - yield (index + 1, total_chapters, text) + try: + for index in chapter_indices: + text = _read_epub_chapter(chapters[index]) + yield (index + 1, total_chapters, text) + finally: + if chapters: + chapters[0][1].close() def build_piper_cmd( @@ -613,7 +621,7 @@ def build_piper_cmd( speed: float, volume: float, silence: float, - output: Optional[Path] = None, + output: Path | None = None, ) -> list[str]: cmd = [ sys.executable, @@ -669,9 +677,9 @@ def print_banner(print_fn: Callable[..., None] = console.print) -> None: def print_help(print_fn: Callable[..., None] = console.print) -> None: text = Text.from_markup("\n[bold]Available Commands:[/bold]\n") for cmd, desc in COMMANDS.items(): - cmd_text = Text(cmd) - cmd_text.stylize("cyan") - text.append(f"\n{cmd_text} - {desc}") + text.append("\n") + text.append(cmd, style="cyan") + text.append(f" - {desc}") panel = Panel(text, title="Commands", border_style="cyan") print_fn(panel) @@ -681,8 +689,8 @@ def speak_text( config: ReedConfig, run: Callable[..., CompletedProcess] = subprocess.run, print_fn: Callable[..., None] = console.print, - play_cmd: Optional[list[str]] = None, - controller: Optional[PlaybackController] = None, + play_cmd: list[str] | None = None, + controller: PlaybackController | None = None, ) -> None: """Speak text aloud. @@ -762,9 +770,9 @@ def interactive_loop( prompt: str = "> ", quit_words: tuple[str, ...] = QUIT_WORDS, print_fn: Callable[..., None] = console.print, - prompt_fn: Optional[Callable[[], str]] = None, + prompt_fn: Callable[[], str] | None = None, clear_fn: Callable[..., None] = console.clear, - controller: Optional[PlaybackController] = None, + controller: PlaybackController | None = None, ) -> int: quit_set = {w.lower() for w in quit_words} help_cmd = "/help" @@ -787,9 +795,18 @@ def _path_candidates(path_text: str) -> list[str]: return [normalized] return [normalized, stripped] + def _try_detect_file_path(input_text: str) -> str | None: + """Try to detect if input is a file path. Returns cleaned path or None.""" + for candidate in _path_candidates(input_text): + if not candidate.lower().endswith((".pdf", ".epub")): + continue + if Path(candidate).exists(): + return candidate + return None + def _read_file_path(file_path_str: str) -> None: """Load and read a PDF or EPUB file.""" - file_path: Optional[Path] = None + file_path: Path | None = None for candidate in _path_candidates(file_path_str): candidate_path = Path(candidate) if candidate_path.exists(): @@ -875,16 +892,6 @@ def _read_file_path(file_path_str: str) -> None: _read_file_path(file_path_str) continue - # Check if input is a file path (drag-and-drop or pasted) - def _try_detect_file_path(input_text: str) -> Optional[str]: - """Try to detect if input is a file path. Returns cleaned path or None.""" - for candidate in _path_candidates(input_text): - if not candidate.lower().endswith((".pdf", ".epub")): - continue - if Path(candidate).exists(): - return candidate - return None - detected_path = _try_detect_file_path(text) if detected_path: _read_file_path(detected_path) @@ -900,9 +907,7 @@ def _try_detect_file_path(input_text: str) -> Optional[str]: return 0 -def _should_enter_interactive( - args: argparse.Namespace, stdin: Optional[TextIO] -) -> bool: +def _should_enter_interactive(args: argparse.Namespace, stdin: TextIO | None) -> bool: if args.text or args.file or args.clipboard or args.pages: return False if stdin is not None and hasattr(stdin, "isatty") and stdin.isatty(): @@ -911,10 +916,10 @@ def _should_enter_interactive( def main( - argv: Optional[list[str]] = None, + argv: list[str] | None = None, run: Callable[..., CompletedProcess] = subprocess.run, - interactive_loop_fn: Optional[Callable[..., int]] = None, - stdin: Optional[TextIO] = None, + interactive_loop_fn: Callable[..., int] | None = None, + stdin: TextIO | None = None, print_fn: Callable[..., None] = console.print, ) -> int: if stdin is None: @@ -971,7 +976,7 @@ def main( # Resolve model: None → default, short name → data dir path if args.model is None: - model_path = DEFAULT_MODEL + model_path = _default_model() else: model_path = Path(args.model) if not model_path.exists() and "/" not in args.model and "\\" not in args.model: @@ -1060,8 +1065,6 @@ def main( return code try: - assert stdin is not None - if args.file and Path(args.file).suffix.lower() == ".pdf": for page_num, total, page_text in _iter_pdf_pages( Path(args.file), args.pages @@ -1083,44 +1086,48 @@ def _speak_chapter(ch_text: str) -> None: ) chapters = _load_epub_spine(epub_path) - total = len(chapters) - spoken: set[int] = set() - - for ch_num, total_chapters, text in _iter_epub_chapters( - epub_path, args.pages - ): - if ch_num in spoken: - continue - if text: - spoken.add(ch_num) - print_fn( - f"\n[bold cyan]📖 Chapter {ch_num}/{total_chapters}[/bold cyan]" - ) - _speak_chapter(text) - continue + try: + total = len(chapters) + spoken: set[int] = set() - # Chapter is empty — skip to next chapter with text - for next_index in range(ch_num, total): - next_num = next_index + 1 - if next_num in spoken: + for ch_num, total_chapters, text in _iter_epub_chapters( + epub_path, args.pages + ): + if ch_num in spoken: continue - next_text = _read_epub_chapter(chapters[next_index]) - if next_text: - spoken.add(next_num) + if text: + spoken.add(ch_num) print_fn( - f"\n[yellow]⏭ Chapter {ch_num}/{total_chapters} has no text, " - f"skipping to chapter {next_num}[/yellow]" + f"\n[bold cyan]📖 Chapter {ch_num}/{total_chapters}[/bold cyan]" ) + _speak_chapter(text) + continue + + # Chapter is empty — skip to next chapter with text + for next_index in range(ch_num, total): + next_num = next_index + 1 + if next_num in spoken: + continue + next_text = _read_epub_chapter(chapters[next_index]) + if next_text: + spoken.add(next_num) + print_fn( + f"\n[yellow]⏭ Chapter {ch_num}/{total_chapters} has no text, " + f"skipping to chapter {next_num}[/yellow]" + ) + print_fn( + f"\n[bold cyan]📖 Chapter {next_num}/{total_chapters}[/bold cyan]" + ) + _speak_chapter(next_text) + break + else: print_fn( - f"\n[bold cyan]📖 Chapter {next_num}/{total_chapters}[/bold cyan]" + f"\n[yellow]⏭ Chapter {ch_num}/{total_chapters} has no text " + f"(no subsequent chapter with text found)[/yellow]" ) - _speak_chapter(next_text) - break - else: - print_fn( - f"\n[yellow]⏭ Chapter {ch_num}/{total_chapters} has no text " - f"(no subsequent chapter with text found)[/yellow]" - ) + finally: + if chapters: + chapters[0][1].close() return 0 text = get_text(args, stdin, run=run) diff --git a/test_reed.py b/test_reed.py index 5ed5fb0..5341e5e 100644 --- a/test_reed.py +++ b/test_reed.py @@ -40,6 +40,33 @@ def _make_config(**overrides): return ReedConfig(**defaults) +def _capture_main(**kwargs): + from rich.console import Console as RichConsole + + cap_console = RichConsole(file=io.StringIO(), force_terminal=False) + code = _reed.main(print_fn=cap_console.print, **kwargs) + output = cap_console.file.getvalue() + return code, output + + +def _fake_spine(html_list): + """Create a fake spine: list of (href, FakeZf) from HTML byte strings.""" + + class FakeZf: + def __init__(self, data_map): + self._data = data_map + + def read(self, href): + return self._data[href] + + def close(self): + pass + + data = {f"ch{i}.xhtml": html for i, html in enumerate(html_list)} + zf = FakeZf(data) + return [(href, zf) for href in data] + + def _make_prompt_fn(lines: list[str]): """Create a prompt_fn that yields lines then raises EOFError.""" it = iter(lines) @@ -590,9 +617,9 @@ def fake_run(cmd, **kwargs): return types.SimpleNamespace(returncode=0, stderr="") return types.SimpleNamespace(returncode=1, stderr="") - args = _make_args() + config = _make_config() with pytest.raises(ReedError, match="playback error"): - speak_text("hi", args, run=fake_run) + speak_text("hi", config, run=fake_run) # ─── main integration tests ────────────────────────────────────────── @@ -1081,24 +1108,10 @@ def test_only_whitespace(self): class TestIterEpubChapters: - def _fake_spine(self, html_list): - """Create a fake spine: list of (href, FakeZf) from HTML byte strings.""" - - class FakeZf: - def __init__(self, data_map): - self._data = data_map - - def read(self, href): - return self._data[href] - - data = {f"ch{i}.xhtml": html for i, html in enumerate(html_list)} - zf = FakeZf(data) - return [(href, zf) for href in data] - def test_reads_all_chapters(self, monkeypatch): from reed import _iter_epub_chapters - spine = self._fake_spine([b"
Chapter one
", b"Chapter two
"]) + spine = _fake_spine([b"Chapter one
", b"Chapter two
"]) monkeypatch.setattr("reed._load_epub_spine", lambda p: spine) result = list(_iter_epub_chapters(Path("book.epub"), None)) @@ -1109,7 +1122,7 @@ def test_reads_all_chapters(self, monkeypatch): def test_selected_chapters(self, monkeypatch): from reed import _iter_epub_chapters - spine = self._fake_spine( + spine = _fake_spine( [b"Ch one
", b"Ch two
", b"Ch three
", b"Ch four
"] ) monkeypatch.setattr("reed._load_epub_spine", lambda p: spine) @@ -1120,7 +1133,7 @@ def test_selected_chapters(self, monkeypatch): def test_chapter_out_of_range_raises(self, monkeypatch): from reed import ReedError, _iter_epub_chapters - spine = self._fake_spine([b"Only one
"]) + spine = _fake_spine([b"Only one
"]) monkeypatch.setattr("reed._load_epub_spine", lambda p: spine) with pytest.raises(ReedError, match="Chapter 5 is out of range"): @@ -1129,7 +1142,7 @@ def test_chapter_out_of_range_raises(self, monkeypatch): def test_yields_empty_chapters(self, monkeypatch): from reed import _iter_epub_chapters - spine = self._fake_spine([b"Has text
", b" ", b"Also text
"]) + spine = _fake_spine([b"Has text
", b" ", b"Also text
"]) monkeypatch.setattr("reed._load_epub_spine", lambda p: spine) result = list(_iter_epub_chapters(Path("book.epub"), None)) @@ -1141,7 +1154,7 @@ def test_yields_empty_chapters(self, monkeypatch): def test_empty_text_still_yielded(self, monkeypatch): from reed import _iter_epub_chapters - spine = self._fake_spine([b" "]) + spine = _fake_spine([b" "]) monkeypatch.setattr("reed._load_epub_spine", lambda p: spine) result = list(_iter_epub_chapters(Path("book.epub"), None)) @@ -1152,28 +1165,6 @@ def test_empty_text_still_yielded(self, monkeypatch): class TestMainEpub: - def _capture_main(self, **kwargs): - from rich.console import Console as RichConsole - - cap_console = RichConsole(file=io.StringIO(), force_terminal=False) - code = _reed.main(print_fn=cap_console.print, **kwargs) - output = cap_console.file.getvalue() - return code, output - - def _fake_spine(self, html_list): - """Create a fake spine: list of (href, FakeZf) from HTML byte strings.""" - - class FakeZf: - def __init__(self, data_map): - self._data = data_map - - def read(self, href): - return self._data[href] - - data = {f"ch{i}.xhtml": html for i, html in enumerate(html_list)} - zf = FakeZf(data) - return [(href, zf) for href in data] - def test_epub_file_reads_chapters(self, monkeypatch, tmp_path): epub_file = tmp_path / "book.epub" epub_file.touch() @@ -1186,12 +1177,12 @@ def fake_speak(text, config, *, run, print_fn, play_cmd): monkeypatch.setattr("reed.speak_text", fake_speak) monkeypatch.setattr( "reed._load_epub_spine", - lambda p: self._fake_spine( + lambda p: _fake_spine( [b"Chapter one text
", b"Chapter two text
"] ), ) - code, output = self._capture_main( + code, output = _capture_main( argv=["-f", str(epub_file), "-m", __file__], run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""), stdin=io.StringIO(""), @@ -1213,10 +1204,10 @@ def fake_speak(text, config, *, run, print_fn, play_cmd): monkeypatch.setattr("reed.speak_text", fake_speak) monkeypatch.setattr( "reed._load_epub_spine", - lambda p: self._fake_spine([b" ", b"Real content
", b" "]), + lambda p: _fake_spine([b" ", b"Real content
", b" "]), ) - code, output = self._capture_main( + code, output = _capture_main( argv=["-f", str(epub_file), "--pages", "1", "-m", __file__], run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""), stdin=io.StringIO(""), @@ -1238,10 +1229,10 @@ def fake_speak(text, config, *, run, print_fn, play_cmd): monkeypatch.setattr("reed.speak_text", fake_speak) monkeypatch.setattr( "reed._load_epub_spine", - lambda p: self._fake_spine([b"Content
", b" "]), + lambda p: _fake_spine([b"Content
", b" "]), ) - code, output = self._capture_main( + code, output = _capture_main( argv=["-f", str(epub_file), "--pages", "2", "-m", __file__], run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""), stdin=io.StringIO(""), @@ -1262,10 +1253,10 @@ def fake_speak(text, config, *, run, print_fn, play_cmd): monkeypatch.setattr("reed.speak_text", fake_speak) monkeypatch.setattr( "reed._load_epub_spine", - lambda p: self._fake_spine([b" ", b"Real content
", b" "]), + lambda p: _fake_spine([b" ", b"Real content
", b" "]), ) - code, output = self._capture_main( + code, output = _capture_main( argv=["-f", str(epub_file), "-m", __file__], run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""), stdin=io.StringIO(""), @@ -1286,12 +1277,10 @@ def fake_speak(text, config, *, run, print_fn, play_cmd): monkeypatch.setattr("reed.speak_text", fake_speak) monkeypatch.setattr( "reed._load_epub_spine", - lambda p: self._fake_spine( - [b"First paragraph.
Second paragraph.
"] - ), + lambda p: _fake_spine([b"First paragraph.
Second paragraph.
"]), ) - code, output = self._capture_main( + code, output = _capture_main( argv=["-f", str(epub_file), "-m", __file__], run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""), stdin=io.StringIO(""), @@ -1311,7 +1300,7 @@ def fake_speak(text, config, *, run, print_fn, play_cmd): monkeypatch.setattr("reed.speak_text", fake_speak) monkeypatch.setattr( "reed._load_epub_spine", - lambda p: self._fake_spine( + lambda p: _fake_spine( [ b"First chapter
", b"Second chapter
", @@ -1320,7 +1309,7 @@ def fake_speak(text, config, *, run, print_fn, play_cmd): ), ) - code, output = self._capture_main( + code, output = _capture_main( argv=["-f", str(epub_file), "--pages", "1,3", "-m", __file__], run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""), stdin=io.StringIO(""), @@ -1333,16 +1322,8 @@ def fake_speak(text, config, *, run, print_fn, play_cmd): class TestMainErrors: - def _capture_main(self, **kwargs): - from rich.console import Console as RichConsole - - cap_console = RichConsole(file=io.StringIO(), force_terminal=False) - code = _reed.main(print_fn=cap_console.print, **kwargs) - output = cap_console.file.getvalue() - return code, output - def test_missing_model_returns_1(self): - code, output = self._capture_main( + code, output = _capture_main( argv=["-m", "/nonexistent/model.onnx"], run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""), stdin=io.StringIO("some text"), @@ -1358,7 +1339,7 @@ def no_player() -> list[str]: monkeypatch.setattr("reed._default_play_cmd", no_player) - code, output = self._capture_main( + code, output = _capture_main( argv=[], run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""), stdin=io.StringIO(""), @@ -1370,7 +1351,7 @@ def test_reed_error_returns_1(self): def failing_run(cmd, **kwargs): return types.SimpleNamespace(returncode=1, stderr="piper exploded") - code, output = self._capture_main( + code, output = _capture_main( argv=["-m", __file__], run=failing_run, stdin=io.StringIO("hello"), @@ -1379,7 +1360,7 @@ def failing_run(cmd, **kwargs): assert "piper exploded" in output def test_pages_without_file_returns_1(self): - code, output = self._capture_main( + code, output = _capture_main( argv=["--pages", "1"], run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""), stdin=io.StringIO(""),