diff --git a/circuitforge_core/preferences/__init__.py b/circuitforge_core/preferences/__init__.py new file mode 100644 index 0000000..5b3e1f7 --- /dev/null +++ b/circuitforge_core/preferences/__init__.py @@ -0,0 +1,3 @@ +from .paths import get_path, set_path + +__all__ = ["get_path", "set_path"] diff --git a/circuitforge_core/preferences/paths.py b/circuitforge_core/preferences/paths.py new file mode 100644 index 0000000..86a1627 --- /dev/null +++ b/circuitforge_core/preferences/paths.py @@ -0,0 +1,64 @@ +"""Dot-path utilities for reading and writing nested preference dicts. + +All operations are immutable: set_path returns a new dict rather than +mutating the input. + +Path format: dot-separated keys, e.g. "affiliate.byok_ids.ebay" +""" +from __future__ import annotations + +from typing import Any + + +def get_path(data: dict, path: str, default: Any = None) -> Any: + """Return the value at *path* inside *data*, or *default* if missing. + + Example:: + + prefs = {"affiliate": {"opt_out": False, "byok_ids": {"ebay": "my-id"}}} + get_path(prefs, "affiliate.byok_ids.ebay") # "my-id" + get_path(prefs, "affiliate.missing", default="x") # "x" + """ + keys = path.split(".") + node: Any = data + for key in keys: + if not isinstance(node, dict): + return default + node = node.get(key, _SENTINEL) + if node is _SENTINEL: + return default + return node + + +def set_path(data: dict, path: str, value: Any) -> dict: + """Return a new dict with *value* written at *path*. + + Intermediate dicts are created as needed; existing values at other paths + are preserved. The original *data* dict is never mutated. + + Example:: + + prefs = {} + updated = set_path(prefs, "affiliate.opt_out", True) + # {"affiliate": {"opt_out": True}} + """ + keys = path.split(".") + return _set_recursive(data, keys, value) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +_SENTINEL = object() + + +def _set_recursive(node: Any, keys: list[str], value: Any) -> dict: + if not isinstance(node, dict): + node = {} + key, rest = keys[0], keys[1:] + if rest: + child = _set_recursive(node.get(key, {}), rest, value) + else: + child = value + return {**node, key: child} diff --git a/tests/test_preferences.py b/tests/test_preferences.py new file mode 100644 index 0000000..6e6c25e --- /dev/null +++ b/tests/test_preferences.py @@ -0,0 +1,75 @@ +"""Tests for circuitforge_core.preferences path utilities.""" +import pytest +from circuitforge_core.preferences import get_path, set_path + + +class TestGetPath: + def test_top_level_key(self): + assert get_path({"a": 1}, "a") == 1 + + def test_nested_key(self): + data = {"affiliate": {"opt_out": False}} + assert get_path(data, "affiliate.opt_out") is False + + def test_deeply_nested(self): + data = {"affiliate": {"byok_ids": {"ebay": "my-tag"}}} + assert get_path(data, "affiliate.byok_ids.ebay") == "my-tag" + + def test_missing_key_returns_default(self): + assert get_path({}, "missing", default="x") == "x" + + def test_missing_nested_returns_default(self): + assert get_path({"a": {}}, "a.b.c", default=42) == 42 + + def test_default_is_none_when_omitted(self): + assert get_path({}, "nope") is None + + def test_non_dict_intermediate_returns_default(self): + assert get_path({"a": "string"}, "a.b", default="d") == "d" + + +class TestSetPath: + def test_top_level_key(self): + result = set_path({}, "opt_out", True) + assert result == {"opt_out": True} + + def test_nested_key_created(self): + result = set_path({}, "affiliate.opt_out", True) + assert result == {"affiliate": {"opt_out": True}} + + def test_deeply_nested(self): + result = set_path({}, "affiliate.byok_ids.ebay", "my-tag") + assert result == {"affiliate": {"byok_ids": {"ebay": "my-tag"}}} + + def test_preserves_sibling_keys(self): + data = {"affiliate": {"opt_out": False, "byok_ids": {}}} + result = set_path(data, "affiliate.opt_out", True) + assert result["affiliate"]["opt_out"] is True + assert result["affiliate"]["byok_ids"] == {} + + def test_preserves_unrelated_top_level_keys(self): + data = {"other": "value", "affiliate": {"opt_out": False}} + result = set_path(data, "affiliate.opt_out", True) + assert result["other"] == "value" + + def test_does_not_mutate_original(self): + data = {"affiliate": {"opt_out": False}} + set_path(data, "affiliate.opt_out", True) + assert data["affiliate"]["opt_out"] is False + + def test_overwrites_existing_value(self): + data = {"affiliate": {"byok_ids": {"ebay": "old-tag"}}} + result = set_path(data, "affiliate.byok_ids.ebay", "new-tag") + assert result["affiliate"]["byok_ids"]["ebay"] == "new-tag" + + def test_non_dict_intermediate_replaced(self): + data = {"affiliate": "not-a-dict"} + result = set_path(data, "affiliate.opt_out", True) + assert result == {"affiliate": {"opt_out": True}} + + def test_roundtrip_get_after_set(self): + prefs = {} + prefs = set_path(prefs, "affiliate.opt_out", True) + prefs = set_path(prefs, "affiliate.byok_ids.ebay", "tag-123") + assert get_path(prefs, "affiliate.opt_out") is True + assert get_path(prefs, "affiliate.byok_ids.ebay") == "tag-123"