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
26 changes: 24 additions & 2 deletions strix/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,16 @@ def apply_config_override(path: Path) -> None:


def persist_current() -> None:
"""Write currently-set env vars to the active config file (0o600)."""
"""Merge currently-set env vars into the active config file (0o600).

Keys already on disk are preserved (including keys unknown to this
version's schema); env vars win on conflicts.
"""
s = load_settings()
target = _override or _DEFAULT_PATH
target.parent.mkdir(parents=True, exist_ok=True)

env_block: dict[str, str] = {}
env_block: dict[str, Any] = _read_persisted_env(target)
for sub_name in s.model_fields:
sub_model = getattr(s, sub_name)
if not isinstance(sub_model, BaseModel):
Expand All @@ -74,6 +78,24 @@ def persist_current() -> None:
write_secret_text(target, json.dumps({"env": env_block}, indent=2))


def _read_persisted_env(path: Path) -> dict[str, Any]:
"""Read the ``{"env": {...}}`` block already stored at ``path``."""
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
env_block = data.get("env", {}) if isinstance(data, dict) else {}
if not isinstance(env_block, dict):
return {}
return {
str(key).upper(): value
for key, value in env_block.items()
if value is not None and value != ""
}


def _aliases_for(finfo: FieldInfo) -> list[str]:
"""Collect every env-var name that should populate ``finfo``."""
aliases: list[str] = []
Expand Down
63 changes: 63 additions & 0 deletions tests/test_config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,69 @@ def test_persist_current_writes_env_block(tmp_path: Path, monkeypatch: pytest.Mo
}


def test_persist_current_preserves_keys_missing_from_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("STRIX_LLM", raising=False)
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "env-key")
target = tmp_path / "cli-config.json"
target.write_text(
json.dumps({"env": {"STRIX_LLM": "file-model", "STRIX_FUTURE_KEY": "keep-me"}}),
encoding="utf-8",
)
loader.apply_config_override(target)

loader.persist_current()

assert json.loads(target.read_text(encoding="utf-8")) == {
"env": {
"STRIX_LLM": "file-model",
"STRIX_FUTURE_KEY": "keep-me",
"OPENAI_API_KEY": "env-key",
}
}


def test_persist_current_env_wins_over_persisted_value(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("STRIX_LLM", "env-model")
target = tmp_path / "cli-config.json"
target.write_text(json.dumps({"env": {"STRIX_LLM": "file-model"}}), encoding="utf-8")
loader.apply_config_override(target)

loader.persist_current()

assert json.loads(target.read_text(encoding="utf-8"))["env"]["STRIX_LLM"] == "env-model"


def test_persist_current_preserves_dict_valued_key(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("STRIX_LLM", raising=False)
monkeypatch.delenv("LLM_EXTRA_HEADERS", raising=False)
target = tmp_path / "cli-config.json"
target.write_text(
json.dumps(
{
"env": {
"STRIX_LLM": "file-model",
"LLM_EXTRA_HEADERS": {"X-Foo": "bar"},
}
}
),
encoding="utf-8",
)
loader.apply_config_override(target)

loader.persist_current()

persisted_env = json.loads(target.read_text(encoding="utf-8"))["env"]
assert persisted_env["STRIX_LLM"] == "file-model"
assert persisted_env["LLM_EXTRA_HEADERS"] == {"X-Foo": "bar"}


def test_persist_current_sets_0600_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("STRIX_LLM", "persisted-model")
target = tmp_path / "cli-config.json"
Expand Down