Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 16 additions & 3 deletions src/vouch/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
),
)

Expand Down
12 changes: 11 additions & 1 deletion src/vouch/recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import yaml

Expand All @@ -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:
Expand All @@ -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),
)


Expand Down
13 changes: 13 additions & 0 deletions tests/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions tests/test_recall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down