config: load settings.local.toml as a section-level overlay
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) <noreply@anthropic.com>
This commit is contained in:
parent
22f4a0915f
commit
d40f25c99d
@ -85,6 +85,28 @@ def _merge(default: dict, override: dict) -> dict:
|
|||||||
return out
|
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:
|
class Config:
|
||||||
"""Holds resolved configuration values."""
|
"""Holds resolved configuration values."""
|
||||||
|
|
||||||
@ -175,7 +197,24 @@ _config_instance: Config | None = None
|
|||||||
|
|
||||||
|
|
||||||
def load_config(path: Path | None = None) -> Config:
|
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
|
global _config_instance
|
||||||
if _config_instance is not None:
|
if _config_instance is not None:
|
||||||
return _config_instance
|
return _config_instance
|
||||||
@ -197,6 +236,15 @@ def load_config(path: Path | None = None) -> Config:
|
|||||||
with open(path, "rb") as fh:
|
with open(path, "rb") as fh:
|
||||||
raw = tomllib.load(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)
|
_config_instance = Config(raw)
|
||||||
return _config_instance
|
return _config_instance
|
||||||
|
|
||||||
|
|||||||
165
tests/test_config_overlay.py
Normal file
165
tests/test_config_overlay.py
Normal file
@ -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"
|
||||||
Loading…
Reference in New Issue
Block a user