From 7c78968f409c3841228dc60095ca8cb7009caebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20M=C3=BCller?= Date: Sat, 21 Feb 2026 12:41:59 +0100 Subject: [PATCH] fix: narrow parse and prefetch exception handling --- src/scribae/feedback.py | 3 ++- src/scribae/io_utils.py | 3 ++- src/scribae/language.py | 4 ++-- src/scribae/meta.py | 2 +- src/scribae/translate_cli.py | 6 +++--- tests/unit/io_utils_test.py | 36 +++++++++++++++++++++++++++++++- tests/unit/translate_cli_test.py | 24 +++++++++++++++++++++ 7 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/scribae/feedback.py b/src/scribae/feedback.py index cba81a4..f7bec43 100644 --- a/src/scribae/feedback.py +++ b/src/scribae/feedback.py @@ -9,6 +9,7 @@ from typing import Any, Literal, cast import frontmatter +import yaml from pydantic import BaseModel, ConfigDict, ValidationError, field_validator from pydantic_ai import Agent, NativeOutput, UnexpectedModelBehavior from pydantic_ai.settings import ModelSettings @@ -603,7 +604,7 @@ def _load_body(body_path: Path, *, max_chars: int) -> BodyDocument: raise FeedbackFileError(f"Draft file not found: {body_path}") from exc except OSError as exc: # pragma: no cover - surfaced by CLI raise FeedbackFileError(f"Unable to read draft: {exc}") from exc - except Exception as exc: # pragma: no cover - parsing errors + except (yaml.YAMLError, TypeError, ValueError) as exc: # pragma: no cover - parsing errors raise FeedbackFileError(f"Unable to parse draft {body_path}: {exc}") from exc metadata = dict(post.metadata or {}) diff --git a/src/scribae/io_utils.py b/src/scribae/io_utils.py index 9b9517b..d18b25e 100644 --- a/src/scribae/io_utils.py +++ b/src/scribae/io_utils.py @@ -6,6 +6,7 @@ from typing import Any import frontmatter +import yaml # Type alias for verbose output callbacks used across modules. Reporter = Callable[[str], None] | None @@ -38,7 +39,7 @@ def load_note(note_path: Path, *, max_chars: int) -> NoteDetails: raise FileNotFoundError(f"Note file not found: {note_path}") from exc except OSError as exc: # pragma: no cover - surfaced to CLI raise OSError(f"Unable to read note {note_path}: {exc}") from exc - except Exception as exc: # pragma: no cover - parsing errors + except (yaml.YAMLError, TypeError, ValueError) as exc: # pragma: no cover - parsing errors raise ValueError(f"Unable to parse note {note_path}: {exc}") from exc metadata = dict(post.metadata or {}) diff --git a/src/scribae/language.py b/src/scribae/language.py index 6615ac1..3d4d0da 100644 --- a/src/scribae/language.py +++ b/src/scribae/language.py @@ -131,7 +131,7 @@ def _detect_language(text: str, language_detector: Callable[[str], str] | None) return normalize_language(detector(sample)) except LanguageResolutionError: raise - except Exception as exc: + except (AttributeError, TypeError, ValueError, RuntimeError) as exc: raise LanguageResolutionError(f"Language detection failed: {exc}") from exc @@ -143,7 +143,7 @@ def _default_language_detector() -> Callable[[str], str]: try: detector = LanguageDetectorBuilder.from_all_languages().build() - except Exception as exc: # pragma: no cover - defensive fallback + except (AttributeError, TypeError, ValueError, RuntimeError) as exc: # pragma: no cover - defensive fallback return _naive_detector(exc) def _detect(sample: str) -> str: diff --git a/src/scribae/meta.py b/src/scribae/meta.py index 27cc116..3997ccc 100644 --- a/src/scribae/meta.py +++ b/src/scribae/meta.py @@ -339,7 +339,7 @@ def _load_body(body_path: Path, *, max_chars: int) -> BodyDocument: raise MetaFileError(f"Body file not found: {body_path}") from exc except OSError as exc: # pragma: no cover - surfaced by CLI raise MetaFileError(f"Unable to read body: {exc}") from exc - except Exception as exc: # pragma: no cover - parsing errors + except (yaml.YAMLError, TypeError, ValueError) as exc: # pragma: no cover - parsing errors raise MetaFileError(f"Unable to parse body {body_path}: {exc}") from exc metadata = dict(post.metadata or {}) diff --git a/src/scribae/translate_cli.py b/src/scribae/translate_cli.py index 3783632..1141a67 100644 --- a/src/scribae/translate_cli.py +++ b/src/scribae/translate_cli.py @@ -24,6 +24,7 @@ TranslationConfig, TranslationPipeline, ) +from scribae.translate.postedit import PostEditAborted translate_app = typer.Typer() @@ -60,7 +61,6 @@ def _configure_library_logging() -> None: pass - def _load_glossary(path: Path | None) -> dict[str, str]: if path is None: return {} @@ -284,7 +284,7 @@ def translate( if reporter: reporter("Fetching post-edit language model...") posteditor.prefetch_language_model() - except Exception as exc: + except (PostEditAborted, RuntimeError) as exc: if not prefetch_only and "nllb" in cfg.mt_backend: secho_info( "Primary MT model prefetch failed; falling back to NLLB.", @@ -295,7 +295,7 @@ def translate( steps = registry.route(resolved_src, tgt, allow_pivot=False, backend=cfg.mt_backend) try: mt.prefetch(steps) - except Exception as fallback_exc: + except RuntimeError as fallback_exc: typer.secho(str(fallback_exc), err=True, fg=typer.colors.RED) raise typer.Exit(4) from fallback_exc else: diff --git a/tests/unit/io_utils_test.py b/tests/unit/io_utils_test.py index 79c21e0..a581f2f 100644 --- a/tests/unit/io_utils_test.py +++ b/tests/unit/io_utils_test.py @@ -1,6 +1,11 @@ from __future__ import annotations -from scribae.io_utils import Reporter, truncate +from pathlib import Path + +import pytest +import yaml + +from scribae.io_utils import Reporter, load_note, truncate class TestTruncate: @@ -76,3 +81,32 @@ def dummy(msg: str) -> None: r2: Reporter = None assert callable(r1) assert r2 is None + + +class TestLoadNoteErrors: + def test_load_note_wraps_value_error_as_parse_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + def _boom(_path: Path) -> None: + raise ValueError("invalid frontmatter") + + monkeypatch.setattr("scribae.io_utils.frontmatter.load", _boom) + + with pytest.raises(ValueError, match="Unable to parse note"): + load_note(Path("note.md"), max_chars=100) + + def test_load_note_wraps_yaml_error_as_parse_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + def _boom(_path: Path) -> None: + raise yaml.YAMLError("bad yaml") + + monkeypatch.setattr("scribae.io_utils.frontmatter.load", _boom) + + with pytest.raises(ValueError, match="Unable to parse note"): + load_note(Path("note.md"), max_chars=100) + + def test_load_note_does_not_mask_unexpected_runtime_errors(self, monkeypatch: pytest.MonkeyPatch) -> None: + def _boom(_path: Path) -> None: + raise RuntimeError("unexpected parser failure") + + monkeypatch.setattr("scribae.io_utils.frontmatter.load", _boom) + + with pytest.raises(RuntimeError, match="unexpected parser failure"): + load_note(Path("note.md"), max_chars=100) diff --git a/tests/unit/translate_cli_test.py b/tests/unit/translate_cli_test.py index 5633fb2..f790851 100644 --- a/tests/unit/translate_cli_test.py +++ b/tests/unit/translate_cli_test.py @@ -453,6 +453,30 @@ def _raise(self: object, steps: list[object]) -> None: assert "prefetch failed" in ansi_stripped +def test_translate_prefetch_does_not_swallow_unexpected_errors( + monkeypatch: pytest.MonkeyPatch, + stub_translation_components: dict[str, Any], +) -> None: + def _raise(self: object, steps: list[object]) -> None: + raise ValueError("unexpected") + + monkeypatch.setattr("scribae.translate_cli.MTTranslator.prefetch", _raise) + + result = runner.invoke( + app, + [ + "translate", + "--src", + "en", + "--tgt", + "de", + "--prefetch-only", + ], + ) + + assert isinstance(result.exception, ValueError) + + def test_translate_warns_on_source_mismatch( monkeypatch: pytest.MonkeyPatch, stub_translation_components: dict[str, Any],