diff --git a/CHANGELOG.md b/CHANGELOG.md index 96abef9d..436eada6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,12 @@ All notable changes to vouch are documented here. Format follows - demo image build: `hatch_build.py` (the build hook pyproject.toml declares for console bundling) is copied into the docker build context; the image had been unbuildable since the hook landed (#474). +- `recall.load_config` and `capture.load_config` now degrade to their + defaults on a malformed numeric value (e.g. `max_chars: a lot`), matching + the fallback contract their docstrings already promised and the pattern + `compile.load_config` already used. previously a config typo raised an + uncaught `ValueError` out of the SessionStart recall hook and any + hook-driven `observe()`/`finalize()` call (#488). ## [1.3.0] — 2026-07-14 diff --git a/src/vouch/capture.py b/src/vouch/capture.py index a5bbc127..ee2f9a9c 100644 --- a/src/vouch/capture.py +++ b/src/vouch/capture.py @@ -38,6 +38,15 @@ class CaptureConfig: dedup_window_seconds: float = DEFAULT_DEDUP_WINDOW_SECONDS +def _coerce(value: Any, default: Any, cast: Any) -> Any: + # A config typo (min_observations: a few) must degrade to the default, + # not take down every hook-driven observe()/finalize() call. + try: + return cast(value) + except (TypeError, ValueError): + return default + + def load_config(store: KBStore) -> CaptureConfig: """Read ``capture:`` from config.yaml; fall back to defaults.""" try: @@ -51,9 +60,13 @@ def load_config(store: KBStore) -> CaptureConfig: return CaptureConfig() return CaptureConfig( enabled=bool(raw.get("enabled", DEFAULT_ENABLED)), - min_observations=int(raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS)), - dedup_window_seconds=float( - raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS) + min_observations=_coerce( + raw.get("min_observations", DEFAULT_MIN_OBSERVATIONS), + DEFAULT_MIN_OBSERVATIONS, int, + ), + dedup_window_seconds=_coerce( + raw.get("dedup_window_seconds", DEFAULT_DEDUP_WINDOW_SECONDS), + DEFAULT_DEDUP_WINDOW_SECONDS, float, ), ) diff --git a/src/vouch/recall.py b/src/vouch/recall.py index 911d512c..2d46f274 100644 --- a/src/vouch/recall.py +++ b/src/vouch/recall.py @@ -13,6 +13,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any import yaml @@ -32,6 +33,15 @@ class RecallConfig: max_chars: int = DEFAULT_MAX_CHARS +def _coerce(value: Any, default: Any, cast: Any) -> Any: + # A config typo (max_chars: a lot) must degrade to the default, not + # take down the SessionStart hook that reads this config every run. + try: + return cast(value) + except (TypeError, ValueError): + return default + + def load_config(store: KBStore) -> RecallConfig: """Read ``recall:`` from config.yaml; fall back to defaults.""" try: @@ -45,7 +55,7 @@ def load_config(store: KBStore) -> RecallConfig: return RecallConfig() return RecallConfig( enabled=bool(raw.get("enabled", DEFAULT_ENABLED)), - max_chars=int(raw.get("max_chars", DEFAULT_MAX_CHARS)), + max_chars=_coerce(raw.get("max_chars", DEFAULT_MAX_CHARS), DEFAULT_MAX_CHARS, int), ) diff --git a/tests/test_capture.py b/tests/test_capture.py index e40ca0ed..94e1030d 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -132,6 +132,19 @@ def test_load_config_capture_not_a_mapping(store: KBStore) -> None: assert cap.load_config(store).enabled is True +def test_load_config_bad_numeric_values_fall_back_to_defaults(store: KBStore) -> None: + # a config typo must degrade, not take down every hook-driven + # observe()/finalize() call that reads this config. + store.config_path.write_text( + "capture:\n enabled: true\n" + " min_observations: a few\n dedup_window_seconds: soon\n" + ) + cfg = cap.load_config(store) + assert cfg.enabled is True + assert cfg.min_observations == cap.DEFAULT_MIN_OBSERVATIONS + assert cfg.dedup_window_seconds == cap.DEFAULT_DEDUP_WINDOW_SECONDS + + def test_read_observations_skips_blank_and_bad_lines(store: KBStore) -> None: p = cap.buffer_path(store, "s1") p.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_recall.py b/tests/test_recall.py index a08a02ce..e3523d98 100644 --- a/tests/test_recall.py +++ b/tests/test_recall.py @@ -93,6 +93,17 @@ def test_load_config_recall_not_a_mapping(store: KBStore) -> None: assert recall.load_config(store).enabled is True +def test_load_config_bad_max_chars_falls_back_to_default(store: KBStore) -> None: + # a config typo (max_chars: a lot) must degrade, not take down the + # SessionStart hook that reads this config on every new session. + store.config_path.write_text( + "recall:\n enabled: true\n max_chars: a lot\n", encoding="utf-8" + ) + cfg = recall.load_config(store) + assert cfg.enabled is True + assert cfg.max_chars == recall.DEFAULT_MAX_CHARS + + def test_cli_recall_emits_digest(store: KBStore) -> None: from click.testing import CliRunner