From d40f25c99de6f885bbd3b9ddc90bb01705255df7 Mon Sep 17 00:00:00 2001 From: Travis Herbranson Date: Mon, 25 May 2026 17:09:57 -0400 Subject: [PATCH] config: load settings.local.toml as a section-level overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base settings.toml is tracked; the new sibling settings.local.toml is gitignored (entry already at .gitignore:32 — it was aspirational until now, since the loader only read one file). `load_config()` now overlays the local file on top of the base via a new `_overlay_sections` helper that merges one level deep: for each section in the local file, its keys are merged onto the base section's keys, so a local `[database] url = "..."` no longer wipes other `[database]` keys in the base. Sections present in only one file pass through untouched. Overlay is resolved as a sibling of whichever base file was chosen, so SECOND_BRAIN_CONFIG=/etc/sb/settings.toml also picks up the matching /etc/sb/settings.local.toml. This is the dev-side ergonomic fix: Travis's interactive shell can keep the lovebug password in a gitignored, 0600 settings.local.toml instead of needing SECOND_BRAIN_DATABASE_URL exported on every manual `process` run. Existing _merge is untouched (shallow), so the per-section property merges in Config keep current semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/second_brain/config.py | 50 ++++++++++- tests/test_config_overlay.py | 165 +++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 tests/test_config_overlay.py diff --git a/src/second_brain/config.py b/src/second_brain/config.py index 1298515..e0683cb 100644 --- a/src/second_brain/config.py +++ b/src/second_brain/config.py @@ -85,6 +85,28 @@ def _merge(default: dict, override: dict) -> dict: return out +def _overlay_sections(base: dict, overlay: dict) -> dict: + """Section-level overlay: one level deep. + + For each key in `overlay`, if both `base[key]` and `overlay[key]` are + dicts, merge their keys (overlay wins). Otherwise the overlay value + replaces the base value outright. Sections present in only one of + the two pass through untouched. + + Use this — not `_merge` — when combining `settings.toml` with + `settings.local.toml`, so a local `[database] url = "..."` doesn't + silently wipe other `[database]` keys defined in the base file. + """ + out = dict(base) + for key, ov_val in (overlay or {}).items(): + base_val = out.get(key) + if isinstance(base_val, dict) and isinstance(ov_val, dict): + out[key] = {**base_val, **ov_val} + else: + out[key] = ov_val + return out + + class Config: """Holds resolved configuration values.""" @@ -175,7 +197,24 @@ _config_instance: Config | None = None def load_config(path: Path | None = None) -> Config: - """Load (and cache) configuration from a TOML file.""" + """Load (and cache) configuration from a TOML file. + + After loading the base file, look for a sibling `settings.local.toml` + and overlay it (section-level merge — see `_overlay_sections`). The + local file is gitignored and intended for host-specific secrets + (e.g. `[database].url` for a dev shell) without forking the tracked + base. + + Path resolution: + - explicit `path=` arg wins + - else `SECOND_BRAIN_CONFIG` env var + - else walks up from CWD for `config/settings.toml` or `settings.toml` + + The local-overlay sibling is looked up next to whichever base file + was chosen, so `SECOND_BRAIN_CONFIG=/etc/sb/settings.toml` will also + pick up `/etc/sb/settings.local.toml` if present. If no base file is + found at all, no overlay is attempted. + """ global _config_instance if _config_instance is not None: return _config_instance @@ -197,6 +236,15 @@ def load_config(path: Path | None = None) -> Config: with open(path, "rb") as fh: raw = tomllib.load(fh) + # Section-level overlay from a sibling settings.local.toml. + # Filename is the one already covered in .gitignore — host + # secrets live here and never get committed. + local_path = path.parent / "settings.local.toml" + if local_path.exists(): + with open(local_path, "rb") as fh: + local_raw = tomllib.load(fh) + raw = _overlay_sections(raw, local_raw) + _config_instance = Config(raw) return _config_instance diff --git a/tests/test_config_overlay.py b/tests/test_config_overlay.py new file mode 100644 index 0000000..09f4fb4 --- /dev/null +++ b/tests/test_config_overlay.py @@ -0,0 +1,165 @@ +"""Tests for the settings.local.toml overlay loader. + +The overlay is the dev-side path for keeping host-specific secrets +(notably `[database].url`) out of the tracked `settings.toml` without +forcing every interactive shell to export env vars. The footgun this +test suite guards against: a one-key local override (e.g. a single +`[database] url = "..."`) silently wiping every other key in that +section. The merge is section-level, not shallow. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Pure-Python: _overlay_sections +# --------------------------------------------------------------------------- + + +def test_overlay_preserves_sibling_keys_in_overlapping_section(): + """The exact regression target — local `url` must not wipe `pool_size`.""" + from second_brain.config import _overlay_sections + + base = {"database": {"url": "", "pool_size": 5}} + local = {"database": {"url": "postgresql://x"}} + + out = _overlay_sections(base, local) + assert out["database"]["url"] == "postgresql://x" + assert out["database"]["pool_size"] == 5 + + +def test_overlay_section_only_in_base_survives(): + from second_brain.config import _overlay_sections + + base = {"database": {"url": "a"}, "scheduler": {"interval": 30}} + local = {"database": {"url": "b"}} + + out = _overlay_sections(base, local) + assert out["scheduler"] == {"interval": 30} + assert out["database"]["url"] == "b" + + +def test_overlay_section_only_in_local_appears(): + from second_brain.config import _overlay_sections + + base = {"database": {"url": "a"}} + local = {"embeddings": {"ollama_url": "http://x"}} + + out = _overlay_sections(base, local) + assert out["database"] == {"url": "a"} + assert out["embeddings"] == {"ollama_url": "http://x"} + + +def test_overlay_scalar_in_overlay_replaces_dict_in_base(): + """Non-dict overlay values replace outright — no merge attempt.""" + from second_brain.config import _overlay_sections + + base = {"feature": {"a": 1, "b": 2}} + local = {"feature": "off"} # scalar wins outright + + out = _overlay_sections(base, local) + assert out["feature"] == "off" + + +def test_overlay_dict_in_overlay_replaces_scalar_in_base(): + from second_brain.config import _overlay_sections + + base = {"feature": "off"} + local = {"feature": {"a": 1}} + + out = _overlay_sections(base, local) + assert out["feature"] == {"a": 1} + + +def test_overlay_empty_overlay_is_identity(): + from second_brain.config import _overlay_sections + + base = {"database": {"url": "a", "pool_size": 5}} + assert _overlay_sections(base, {}) == base + assert _overlay_sections(base, None) == base + + +def test_overlay_only_merges_one_level(): + """Doc: the overlay is section-level. Nested dicts inside a section + replace, not deep-merge. This pins the documented depth.""" + from second_brain.config import _overlay_sections + + base = {"db": {"url": "a", "pool": {"min": 1, "max": 10}}} + local = {"db": {"pool": {"max": 20}}} + + out = _overlay_sections(base, local) + # pool replaced wholesale — `min` does NOT survive. This is deliberate. + assert out["db"]["pool"] == {"max": 20} + assert out["db"]["url"] == "a" + + +# --------------------------------------------------------------------------- +# load_config: end-to-end with real TOML files on disk +# --------------------------------------------------------------------------- + + +def _write(path: Path, body: str) -> None: + path.write_text(body, encoding="utf-8") + + +@pytest.fixture(autouse=True) +def _reset_config(): + """Drop the cached singleton before/after each test so overlay state + doesn't leak between cases.""" + from second_brain.config import reset_config_singleton + + reset_config_singleton() + yield + reset_config_singleton() + + +def test_load_config_picks_up_sibling_local_overlay(tmp_path): + from second_brain.config import load_config + + base = tmp_path / "settings.toml" + local = tmp_path / "settings.local.toml" + _write( + base, + '[database]\nurl = ""\n\n[embeddings]\nollama_url = "http://ollama:11434"\nmodel = "nomic-embed-text"\n', + ) + _write( + local, + '[database]\nurl = "postgresql+psycopg://lovebug:x@127.0.0.1:5433/petalbrain"\n\n[embeddings]\nollama_url = "http://127.0.0.1:11434"\n', + ) + + cfg = load_config(base) + # url from local wins; default model survives (not in local). + assert cfg.database["url"].startswith("postgresql+psycopg://") + assert cfg.embeddings["ollama_url"] == "http://127.0.0.1:11434" + assert cfg.embeddings["model"] == "nomic-embed-text" + + +def test_load_config_no_local_overlay_is_inert(tmp_path): + from second_brain.config import load_config + + base = tmp_path / "settings.toml" + _write(base, '[database]\nurl = "from-base"\n') + + cfg = load_config(base) + assert cfg.database["url"] == "from-base" + + +def test_load_config_local_overlay_at_explicit_path(tmp_path): + """Doc: when an explicit (or SECOND_BRAIN_CONFIG) path is used, the + overlay sibling is resolved next to THAT path — not next to the + repo's config dir.""" + from second_brain.config import load_config + + subdir = tmp_path / "etc" + subdir.mkdir() + base = subdir / "settings.toml" + local = subdir / "settings.local.toml" + _write(base, '[database]\nurl = "base"\n') + _write(local, '[database]\nurl = "overlay-wins"\n') + + cfg = load_config(base) + assert cfg.database["url"] == "overlay-wins"