Skip to content

Commit 73649b8

Browse files
✨ Merge environment variables with config file recursively (#62)
* Merge environment variables with config file recursively * Add tests for config merging and fix hex value parsing - Add comprehensive test suite for config functionality - Fix environment variable loading and type conversion - Add special handling for hex color values - Improve code organization and readability * Fix linting issues * 🎨 Format code * 🏷️ Fix typing --------- Co-authored-by: openhands <[email protected]>
1 parent 5696713 commit 73649b8

File tree

2 files changed

+140
-16
lines changed

2 files changed

+140
-16
lines changed

src/config/bot_config.py

+25-16
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import contextlib
55
import os
6+
from collections import defaultdict
67
from typing import Any
78

89
import orjson
@@ -16,19 +17,14 @@
1617
SPLIT: str = "__"
1718

1819

19-
def load_from_env() -> dict[str, dict[str, Any]]:
20-
_config: dict[str, Any] = {}
21-
values = {k: v for k, v in os.environ.items() if k.startswith(f"BOTKIT{SPLIT}")}
22-
values = {k[len(f"BOTKIT{SPLIT}") :]: v for k, v in values.items()}
23-
current: dict[str, Any] = {}
20+
def load_from_env() -> dict[str, Any]: # pyright: ignore [reportExplicitAny]
21+
_config: dict[str, Any] = {} # pyright: ignore [reportExplicitAny]
22+
values = {k: v for k, v in os.environ.items() if k.startswith("BOTKIT__")}
2423
for key, value in values.items():
25-
for i, part in enumerate(key.split(SPLIT)):
26-
part = part.lower() # noqa: PLW2901
27-
if i == 0:
28-
if part not in _config:
29-
_config[part] = {}
30-
current = _config[part]
31-
elif i == len(key.split(SPLIT)) - 1:
24+
parts = key[len("BOTKIT__") :].lower().split("__")
25+
current = _config
26+
for i, part in enumerate(parts):
27+
if i == len(parts) - 1:
3228
current[part] = value
3329
else:
3430
if part not in current:
@@ -49,6 +45,9 @@ def load_json_recursive(data: dict[str, Any]) -> dict[str, Any]:
4945
data[key] = True
5046
elif value.lower() == "false":
5147
data[key] = False
48+
elif value.startswith("0x"):
49+
with contextlib.suppress(ValueError):
50+
data[key] = int(value, 16)
5251
else:
5352
with contextlib.suppress(orjson.JSONDecodeError):
5453
data[key] = orjson.loads(value)
@@ -61,12 +60,22 @@ def load_json_recursive(data: dict[str, Any]) -> dict[str, Any]:
6160
elif os.path.exists("config.yml"):
6261
path = "config.yml"
6362

64-
_config: Any
63+
_config: dict[str, Any] = defaultdict(dict) # pyright: ignore [reportExplicitAny]
6564
config: Config
65+
66+
67+
def merge_dicts(dct: dict[str, Any], merge_dct: dict[str, Any]) -> None: # pyright: ignore [reportExplicitAny]
68+
for k, v in merge_dct.items():
69+
if isinstance(dct.get(k), dict) and isinstance(v, dict):
70+
merge_dicts(dct[k], v)
71+
else:
72+
dct[k] = v
73+
74+
6675
if path:
6776
with open(path, encoding="utf-8") as f:
68-
_config = yaml.safe_load(f)
69-
else:
70-
_config = load_from_env()
77+
_config.update(yaml.safe_load(f))
78+
79+
merge_dicts(_config, load_from_env())
7180

7281
config = Config(**_config) if _config else Config()

tests/config_test.py

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright (c) NiceBots.xyz
2+
# SPDX-License-Identifier: MIT
3+
4+
# ruff: noqa: S101, S105
5+
6+
import os
7+
from typing import Any
8+
9+
import yaml
10+
11+
from src.config.bot_config import load_from_env, merge_dicts
12+
13+
14+
def test_merge_dicts_basic() -> None:
15+
"""Test basic dictionary merging."""
16+
base = {"a": 1, "b": 2}
17+
update = {"b": 3, "c": 4}
18+
merge_dicts(base, update)
19+
assert base == {"a": 1, "b": 3, "c": 4}
20+
21+
22+
def test_merge_dicts_nested() -> None:
23+
"""Test nested dictionary merging."""
24+
base = {"a": {"x": 1, "y": 2}, "b": 3}
25+
update = {"a": {"y": 4, "z": 5}, "c": 6}
26+
merge_dicts(base, update)
27+
assert base == {"a": {"x": 1, "y": 4, "z": 5}, "b": 3, "c": 6}
28+
29+
30+
def test_merge_dicts_deep_nested() -> None:
31+
"""Test deeply nested dictionary merging."""
32+
base = {"a": {"x": {"p": 1, "q": 2}, "y": 3}}
33+
update = {"a": {"x": {"q": 4, "r": 5}}}
34+
merge_dicts(base, update)
35+
assert base == {"a": {"x": {"p": 1, "q": 4, "r": 5}, "y": 3}}
36+
37+
38+
def test_merge_dicts_with_none() -> None:
39+
"""Test merging when values are None."""
40+
base = {"a": {"x": 1}, "b": None}
41+
update = {"a": {"y": 2}, "b": {"z": 3}}
42+
merge_dicts(base, update)
43+
assert base == {"a": {"x": 1, "y": 2}, "b": {"z": 3}}
44+
45+
46+
def test_load_from_env() -> None:
47+
"""Test loading configuration from environment variables."""
48+
# Set up test environment variables
49+
test_env = {
50+
"BOTKIT__TOKEN": "test-token",
51+
"BOTKIT__EXTENSIONS__PING__ENABLED": "true",
52+
"BOTKIT__EXTENSIONS__PING__COLOR": "0xFF0000",
53+
"BOTKIT__EXTENSIONS__TOPGG__TOKEN": "test-topgg-token",
54+
}
55+
56+
for key, value in test_env.items():
57+
os.environ[key] = value
58+
59+
try:
60+
config = load_from_env()
61+
62+
# Check the loaded configuration
63+
assert config["token"] == "test-token"
64+
assert config["extensions"]["ping"]["enabled"] is True
65+
assert config["extensions"]["ping"]["color"] == 0xFF0000
66+
assert config["extensions"]["topgg"]["token"] == "test-topgg-token"
67+
68+
finally:
69+
# Clean up environment variables
70+
for key in test_env:
71+
os.environ.pop(key, None)
72+
73+
74+
def test_config_file_and_env_integration(tmp_path: Any) -> None:
75+
"""Test integration of config file and environment variables."""
76+
# Create a temporary config file
77+
config_file = tmp_path / "config.yaml"
78+
config_data = {
79+
"token": "file-token",
80+
"extensions": {"ping": {"enabled": False, "color": 0x00FF00, "message": "Pong!"}, "topgg": {"enabled": True}},
81+
}
82+
83+
with open(config_file, "w", encoding="utf-8") as f:
84+
yaml.dump(config_data, f)
85+
86+
# Set environment variables that should override some values
87+
test_env = {
88+
"BOTKIT__TOKEN": "env-token",
89+
"BOTKIT__EXTENSIONS__PING__ENABLED": "true",
90+
"BOTKIT__EXTENSIONS__PING__COLOR": "0xFF0000",
91+
}
92+
93+
for key, value in test_env.items():
94+
os.environ[key] = value
95+
96+
try:
97+
# Load config from file
98+
with open(config_file, encoding="utf-8") as f:
99+
config = yaml.safe_load(f)
100+
101+
# Merge with environment variables
102+
env_config = load_from_env()
103+
merge_dicts(config, env_config)
104+
105+
# Verify the merged configuration
106+
assert config["token"] == "env-token" # Overridden by env
107+
assert config["extensions"]["ping"]["enabled"] is True # Overridden by env
108+
assert config["extensions"]["ping"]["color"] == 0xFF0000 # Overridden by env
109+
assert config["extensions"]["ping"]["message"] == "Pong!" # Kept from file
110+
assert config["extensions"]["topgg"]["enabled"] is True # Kept from file
111+
112+
finally:
113+
# Clean up environment variables
114+
for key in test_env:
115+
os.environ.pop(key, None)

0 commit comments

Comments
 (0)