Skip to content

Commit f2e77eb

Browse files
committed
feat: add configs merge
1 parent d2b3b28 commit f2e77eb

File tree

2 files changed

+80
-0
lines changed

2 files changed

+80
-0
lines changed

src/py_app_dev/core/config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from typing import Any, TypeVar
2+
3+
from mashumaro import DataClassDictMixin
4+
from mashumaro.config import BaseConfig
5+
6+
7+
class BaseConfigDictMixin(DataClassDictMixin):
8+
class Config(BaseConfig):
9+
# When serializing to dict, omit fields with value None
10+
omit_none = True
11+
12+
13+
TConfig = TypeVar("TConfig", bound="BaseConfigDictMixin")
14+
15+
16+
def deep_merge(base_dict: dict[Any, Any], new_dict: dict[Any, Any]) -> dict[Any, Any]:
17+
"""Recursively merge two dictionaries, where values in new_dict override values in base_dict."""
18+
result: dict[Any, Any] = {}
19+
for key, value in base_dict.items():
20+
result[key] = value
21+
for key, value in new_dict.items():
22+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
23+
result[key] = deep_merge(result[key], value)
24+
else:
25+
result[key] = value
26+
return result
27+
28+
29+
def merge_configs(base: TConfig, override: TConfig) -> TConfig:
30+
merged = deep_merge(base.to_dict(), override.to_dict())
31+
return base.__class__.from_dict(merged)

tests/test_config.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from dataclasses import dataclass
2+
from typing import Any
3+
4+
from py_app_dev.core.config import BaseConfigDictMixin, deep_merge, merge_configs
5+
6+
7+
@dataclass
8+
class SampleConfig(BaseConfigDictMixin):
9+
name: str | None = None
10+
retries: int = 0
11+
nested: dict[str, Any] | None = None
12+
13+
14+
def test_deep_merge():
15+
base = {"a": 1, "b": {"x": 1, "y": 2}, "c": 3}
16+
new = {"b": {"y": 200, "z": 300}, "d": 4}
17+
18+
merged = deep_merge(base, new)
19+
20+
assert merged == {
21+
"a": 1, # preserved
22+
"b": {"x": 1, "y": 200, "z": 300}, # nested merged
23+
"c": 3, # preserved
24+
"d": 4, # added
25+
}
26+
27+
28+
def test_merge_configs_preserves_subclass_and_merges():
29+
base = SampleConfig(name="service", retries=1, nested={"x": 1, "y": {"a": 10}})
30+
override = SampleConfig(retries=5, nested={"y": {"b": 20}, "z": 99})
31+
32+
merged = merge_configs(base, override)
33+
34+
assert isinstance(merged, SampleConfig)
35+
36+
assert merged.name == "service", "Name should be preserved"
37+
assert merged.retries == 5, "Retries should be overridden"
38+
assert merged.nested == {"x": 1, "y": {"a": 10, "b": 20}, "z": 99}
39+
40+
41+
def test_merge_configs_override_none_value():
42+
base = SampleConfig(name=None, retries=2, nested={"k": 1})
43+
override = SampleConfig(name="final", nested=None)
44+
45+
merged = merge_configs(base, override)
46+
47+
assert merged.name == "final", "Name should be taken from override"
48+
assert merged.nested == {"k": 1}, "Nested should not be overridden by None"
49+
assert merged.retries == 0, "Retries should be taken from the default value in override"

0 commit comments

Comments
 (0)