dynamic-config-loader 2.0.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dynamic_config_loader-2.0.0/src/dynamic_config_loader.egg-info → dynamic_config_loader-2.2.0}/PKG-INFO +1 -1
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/pyproject.toml +1 -1
- dynamic_config_loader-2.2.0/src/dynamic_config_loader/environment.py +121 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/loader.py +27 -1
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/__init__.py +4 -2
- dynamic_config_loader-2.2.0/src/dynamic_config_loader/strategies/json/__init__.py +34 -0
- dynamic_config_loader-2.2.0/src/dynamic_config_loader/strategies/json/merger.py +63 -0
- dynamic_config_loader-2.2.0/src/dynamic_config_loader/strategies/json/parser.py +40 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0/src/dynamic_config_loader.egg-info}/PKG-INFO +1 -1
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader.egg-info/SOURCES.txt +5 -0
- dynamic_config_loader-2.2.0/tests/test_env_injection.py +99 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/tests/test_factory.py +5 -1
- dynamic_config_loader-2.2.0/tests/test_json_strategy.py +112 -0
- dynamic_config_loader-2.0.0/src/dynamic_config_loader/environment.py +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/AUTHORS +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/LICENSE +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/NOTICE +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/README.md +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/setup.cfg +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/__init__.py +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/base_strategy.py +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/yaml/__init__.py +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/yaml/merger.py +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/yaml/parser.py +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader.egg-info/dependency_links.txt +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader.egg-info/requires.txt +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader.egg-info/top_level.txt +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/tests/test_deep_merge.py +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/tests/test_loader.py +0 -0
- {dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/tests/test_logging_safety.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dynamic-config-loader
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: An enterprise-grade configuration orchestration engine designed to ingest, cascade, and deeply merge multiple configuration file layers.
|
|
5
5
|
Author-email: Avinash Kumar <halfhiddencode@gmail.com>
|
|
6
6
|
License:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dynamic-config-loader"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "An enterprise-grade configuration orchestration engine designed to ingest, cascade, and deeply merge multiple configuration file layers."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
# Copyright (C) 2026 Avinash Kumar
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
Environment Variable Injection Module.
|
|
8
|
+
|
|
9
|
+
Handles scanning, case-normalization, single-underscore parsing, and deep-merging
|
|
10
|
+
of system environment variable parameters into the main configuration matrix.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any, Dict
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_env_value(val: str) -> Any:
|
|
21
|
+
"""
|
|
22
|
+
Parses environment variable string values into standard primitives (bool, int, float, str).
|
|
23
|
+
"""
|
|
24
|
+
val_lower = val.strip().lower()
|
|
25
|
+
if val_lower == "true":
|
|
26
|
+
return True
|
|
27
|
+
if val_lower == "false":
|
|
28
|
+
return False
|
|
29
|
+
try:
|
|
30
|
+
return int(val)
|
|
31
|
+
except ValueError:
|
|
32
|
+
pass
|
|
33
|
+
try:
|
|
34
|
+
return float(val)
|
|
35
|
+
except ValueError:
|
|
36
|
+
pass
|
|
37
|
+
return val.strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def inject_environment_variables(
|
|
41
|
+
config_data: Dict[str, Any], prefix: str, mask_secrets: bool
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Scans the system environment variables and overlays matching prefix keys
|
|
45
|
+
into the config_data dictionary using a single-underscore delimiter structure.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
config_data (Dict[str, Any]): The configuration dictionary to mutate.
|
|
49
|
+
prefix (str): Prefix string to filter variables (e.g. "DCL_").
|
|
50
|
+
mask_secrets (bool): Whether to mask values in diagnostic logs.
|
|
51
|
+
"""
|
|
52
|
+
if os.environ.get("DCL_ENV_LOADER", "").strip().lower() != "true":
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
for env_key, env_val in os.environ.items():
|
|
56
|
+
if env_key.startswith(prefix):
|
|
57
|
+
# Strip prefix
|
|
58
|
+
sliced_key = env_key[len(prefix):]
|
|
59
|
+
if not sliced_key:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
# Case Normalization and tokenizing
|
|
63
|
+
parts = [p.lower() for p in sliced_key.split("_") if p]
|
|
64
|
+
if not parts:
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Reconstruct current_path in dot-notation for logging
|
|
68
|
+
current_path = ".".join(parts)
|
|
69
|
+
|
|
70
|
+
# Parse the env value
|
|
71
|
+
parsed_val = _parse_env_value(env_val)
|
|
72
|
+
|
|
73
|
+
# Traverse config_data to insert
|
|
74
|
+
curr = config_data
|
|
75
|
+
for part in parts[:-1]:
|
|
76
|
+
if part not in curr or not isinstance(curr[part], dict):
|
|
77
|
+
curr[part] = {}
|
|
78
|
+
curr = curr[part]
|
|
79
|
+
|
|
80
|
+
last_key = parts[-1]
|
|
81
|
+
|
|
82
|
+
# Check if key already exists for logging
|
|
83
|
+
if last_key in curr:
|
|
84
|
+
old_val = curr[last_key]
|
|
85
|
+
if old_val != parsed_val:
|
|
86
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
87
|
+
if not mask_secrets:
|
|
88
|
+
logger.debug(
|
|
89
|
+
f"Overriding configuration path '{current_path}' from environment: "
|
|
90
|
+
f"{repr(old_val)} -> {repr(parsed_val)}"
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
logger.debug(
|
|
94
|
+
f"Overriding configuration path '{current_path}' from environment: "
|
|
95
|
+
f"[REDACTED] -> [REDACTED]"
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
logger.info(
|
|
99
|
+
f"Overriding configuration path from environment variable: "
|
|
100
|
+
f"{current_path} [Value Redacted]"
|
|
101
|
+
)
|
|
102
|
+
curr[last_key] = parsed_val
|
|
103
|
+
else:
|
|
104
|
+
# New key insertion
|
|
105
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
106
|
+
if not mask_secrets:
|
|
107
|
+
logger.debug(
|
|
108
|
+
f"Injecting new configuration path '{current_path}' from environment: "
|
|
109
|
+
f"{repr(parsed_val)}"
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
logger.debug(
|
|
113
|
+
f"Injecting new configuration path '{current_path}' from environment: "
|
|
114
|
+
f"[REDACTED]"
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
logger.info(
|
|
118
|
+
f"Overriding configuration path from environment variable: "
|
|
119
|
+
f"{current_path} [Value Redacted]"
|
|
120
|
+
)
|
|
121
|
+
curr[last_key] = parsed_val
|
{dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/loader.py
RENAMED
|
@@ -18,6 +18,7 @@ from typing import Any, Dict, Optional
|
|
|
18
18
|
|
|
19
19
|
# Import your central factory router to replace the local self._handlers dictionary
|
|
20
20
|
from dynamic_config_loader.strategies import factory
|
|
21
|
+
from dynamic_config_loader.environment import inject_environment_variables
|
|
21
22
|
|
|
22
23
|
logger = logging.getLogger(__name__)
|
|
23
24
|
|
|
@@ -30,13 +31,14 @@ class DynamicConfigLoader:
|
|
|
30
31
|
from both static baseline tracks and dynamic environment target vectors.
|
|
31
32
|
"""
|
|
32
33
|
|
|
33
|
-
def __init__(self, default_config_path: Optional[str] = None) -> None:
|
|
34
|
+
def __init__(self, default_config_path: Optional[str] = None, prefix: str = "DCL_") -> None:
|
|
34
35
|
"""
|
|
35
36
|
Initializes the dynamic runtime configuration engine instance.
|
|
36
37
|
|
|
37
38
|
Args:
|
|
38
39
|
default_config_path (str, optional): System target string pointer
|
|
39
40
|
to the baseline configuration data asset. Defaults to None.
|
|
41
|
+
prefix (str): Prefix string for environment variable injection. Defaults to "DCL_".
|
|
40
42
|
"""
|
|
41
43
|
# Central memory state tracking core mapping parameters
|
|
42
44
|
self.config_data: Dict[str, Any] = {}
|
|
@@ -46,6 +48,8 @@ class DynamicConfigLoader:
|
|
|
46
48
|
Path(default_config_path) if default_config_path else None
|
|
47
49
|
)
|
|
48
50
|
|
|
51
|
+
self.prefix = prefix
|
|
52
|
+
|
|
49
53
|
def load(self) -> Dict[str, Any]:
|
|
50
54
|
"""
|
|
51
55
|
Executes structural loops traversing the pipeline layers.
|
|
@@ -68,6 +72,13 @@ class DynamicConfigLoader:
|
|
|
68
72
|
"======================================================================="
|
|
69
73
|
)
|
|
70
74
|
|
|
75
|
+
# Resolve the allowed format strategy class based on the baseline configuration file extension
|
|
76
|
+
allowed_strategy_cls = None
|
|
77
|
+
if self.default_path:
|
|
78
|
+
base_strategy = factory.get_strategy(self.default_path.suffix)
|
|
79
|
+
if base_strategy:
|
|
80
|
+
allowed_strategy_cls = type(base_strategy)
|
|
81
|
+
|
|
71
82
|
# Pipeline Phase 1: Assess and ingest baseline layer properties
|
|
72
83
|
if self.default_path and self.default_path.exists():
|
|
73
84
|
# Dynamically look up the decoupled format strategy class using the extension suffix
|
|
@@ -109,6 +120,12 @@ class DynamicConfigLoader:
|
|
|
109
120
|
if file_path.is_file():
|
|
110
121
|
strategy = factory.get_strategy(file_path.suffix)
|
|
111
122
|
if strategy:
|
|
123
|
+
if allowed_strategy_cls and not isinstance(strategy, allowed_strategy_cls):
|
|
124
|
+
logger.warning(
|
|
125
|
+
f"Skipping override file '{file_path.name}' due to format mismatch "
|
|
126
|
+
f"with baseline strategy '{allowed_strategy_cls.__name__}'."
|
|
127
|
+
)
|
|
128
|
+
continue
|
|
112
129
|
try:
|
|
113
130
|
logger.info(f"Processing override layer discovery file: {file_path.name}")
|
|
114
131
|
extra_data = strategy.parse(file_path)
|
|
@@ -125,6 +142,12 @@ class DynamicConfigLoader:
|
|
|
125
142
|
elif current_path.is_file():
|
|
126
143
|
strategy = factory.get_strategy(current_path.suffix)
|
|
127
144
|
if strategy:
|
|
145
|
+
if allowed_strategy_cls and not isinstance(strategy, allowed_strategy_cls):
|
|
146
|
+
logger.warning(
|
|
147
|
+
f"Skipping direct override file asset '{current_path.name}' due to format mismatch "
|
|
148
|
+
f"with baseline strategy '{allowed_strategy_cls.__name__}'."
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
128
151
|
try:
|
|
129
152
|
logger.info(f"Processing direct override file asset: {current_path.name}")
|
|
130
153
|
extra_data = strategy.parse(current_path)
|
|
@@ -141,4 +164,7 @@ class DynamicConfigLoader:
|
|
|
141
164
|
f"Unsupported file format extension for direct asset override: {current_path.name}"
|
|
142
165
|
)
|
|
143
166
|
|
|
167
|
+
# Pipeline Phase 3: Native Environment Variable Injection Track
|
|
168
|
+
inject_environment_variables(self.config_data, self.prefix, self.mask_secrets)
|
|
169
|
+
|
|
144
170
|
return self.config_data
|
|
@@ -15,6 +15,7 @@ from dynamic_config_loader.strategies.base_strategy import BaseStrategy
|
|
|
15
15
|
|
|
16
16
|
# Import the strategy worker class from the underlying yaml package
|
|
17
17
|
from dynamic_config_loader.strategies.yaml import YamlStrategy
|
|
18
|
+
from dynamic_config_loader.strategies.json import JsonStrategy
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
class StrategyFactory:
|
|
@@ -26,10 +27,11 @@ class StrategyFactory:
|
|
|
26
27
|
"""
|
|
27
28
|
Initializes the factory and registers file extension routes to concrete strategy objects.
|
|
28
29
|
"""
|
|
29
|
-
# Register extension routes straight to your isolated
|
|
30
|
+
# Register extension routes straight to your isolated strategy classes
|
|
30
31
|
self._strategies: Dict[str, BaseStrategy] = {
|
|
31
32
|
".yaml": YamlStrategy(),
|
|
32
|
-
".yml": YamlStrategy()
|
|
33
|
+
".yml": YamlStrategy(),
|
|
34
|
+
".json": JsonStrategy(),
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
def get_strategy(self, extension: str) -> Optional[BaseStrategy]:
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
# Copyright (C) 2026 Avinash Kumar
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
JSON Strategy Package Initializer.
|
|
8
|
+
|
|
9
|
+
Exposes the concrete JsonStrategy engine implementation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
from dynamic_config_loader.strategies.base_strategy import BaseStrategy
|
|
15
|
+
from dynamic_config_loader.strategies.json.parser import parse_json_file
|
|
16
|
+
from dynamic_config_loader.strategies.json.merger import execute_json_deep_merge
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JsonStrategy(BaseStrategy):
|
|
20
|
+
"""
|
|
21
|
+
Concrete Strategy implementation managing the processing life cycle of JSON configurations.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def parse(self, file_path: Path) -> Dict[str, Any]:
|
|
25
|
+
return parse_json_file(file_path)
|
|
26
|
+
|
|
27
|
+
def merge(
|
|
28
|
+
self,
|
|
29
|
+
base_dict: Dict[str, Any],
|
|
30
|
+
update_dict: Dict[str, Any],
|
|
31
|
+
path: str = "",
|
|
32
|
+
mask_secrets: bool = True,
|
|
33
|
+
) -> None:
|
|
34
|
+
execute_json_deep_merge(base_dict, update_dict, path=path, mask_secrets=mask_secrets)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
# Copyright (C) 2026 Avinash Kumar
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
JSON Merging Strategy Module.
|
|
8
|
+
|
|
9
|
+
Provides recursive tree-merging algorithms engineered to blend cascading JSON layers.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def execute_json_deep_merge(
|
|
19
|
+
base_dict: Dict[str, Any],
|
|
20
|
+
update_dict: Dict[str, Any],
|
|
21
|
+
path: str = "",
|
|
22
|
+
mask_secrets: bool = True,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Recursively deep-merges update_dict into base_dict in-place.
|
|
26
|
+
"""
|
|
27
|
+
for key, value in update_dict.items():
|
|
28
|
+
current_path = f"{path}.{key}" if path else key
|
|
29
|
+
|
|
30
|
+
# Case A: Both values are dictionaries -> recurse
|
|
31
|
+
if key in base_dict and isinstance(base_dict[key], dict) and isinstance(value, dict):
|
|
32
|
+
logger.debug(f"Deep merging nested JSON structure at path: {current_path}")
|
|
33
|
+
execute_json_deep_merge(base_dict[key], value, path=current_path, mask_secrets=mask_secrets)
|
|
34
|
+
|
|
35
|
+
# Case B: Structural conflict or key exists
|
|
36
|
+
elif key in base_dict:
|
|
37
|
+
if isinstance(base_dict[key], dict) or isinstance(value, dict):
|
|
38
|
+
logger.warning(
|
|
39
|
+
f"Type mismatch conflict detected at path '{current_path}'. "
|
|
40
|
+
f"Overwriting base JSON structural type ({type(base_dict[key]).__name__}) "
|
|
41
|
+
f"with incoming type ({type(value).__name__})."
|
|
42
|
+
)
|
|
43
|
+
base_dict[key] = value
|
|
44
|
+
else:
|
|
45
|
+
if base_dict[key] != value:
|
|
46
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
47
|
+
if not mask_secrets:
|
|
48
|
+
logger.debug(f"Updating JSON path '{current_path}': {repr(base_dict[key])} -> {repr(value)}")
|
|
49
|
+
else:
|
|
50
|
+
logger.debug(f"Updating JSON path '{current_path}': [REDACTED] -> [REDACTED]")
|
|
51
|
+
else:
|
|
52
|
+
logger.info(f"Updating configuration attribute path: {current_path} [Value Redacted]")
|
|
53
|
+
base_dict[key] = value
|
|
54
|
+
# Case C: New key
|
|
55
|
+
else:
|
|
56
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
57
|
+
if not mask_secrets:
|
|
58
|
+
logger.debug(f"Adding new JSON key at path '{current_path}': {repr(value)}")
|
|
59
|
+
else:
|
|
60
|
+
logger.debug(f"Adding new JSON key at path '{current_path}': [REDACTED]")
|
|
61
|
+
else:
|
|
62
|
+
logger.info(f"Adding new configuration attribute path: {current_path} [Value Redacted]")
|
|
63
|
+
base_dict[key] = value
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
# Copyright (C) 2026 Avinash Kumar
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
JSON Parsing Strategy Module.
|
|
8
|
+
|
|
9
|
+
Provides isolated, low-level file stream ingestion utilities optimized
|
|
10
|
+
for handling standard JSON configuration assets.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_json_file(file_path: Path) -> Dict[str, Any]:
|
|
22
|
+
"""
|
|
23
|
+
Reads a JSON configuration file asset from disk and returns a dictionary mapping.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
27
|
+
data = json.load(f)
|
|
28
|
+
if data is None:
|
|
29
|
+
logger.info(f"Target JSON file context is empty or unassigned: {file_path.name}")
|
|
30
|
+
return {}
|
|
31
|
+
if isinstance(data, dict):
|
|
32
|
+
return data
|
|
33
|
+
logger.warning(
|
|
34
|
+
f"Invalid structural layout detected inside file: {file_path.name}. "
|
|
35
|
+
f"Expected key-value schema tree map, but received root type: {type(data).__name__}."
|
|
36
|
+
)
|
|
37
|
+
return {}
|
|
38
|
+
except (json.JSONDecodeError, IOError, PermissionError) as e:
|
|
39
|
+
logger.exception(f"Failed to read or parse JSON file: {file_path.name}", exc_info=e)
|
|
40
|
+
return {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dynamic-config-loader
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: An enterprise-grade configuration orchestration engine designed to ingest, cascade, and deeply merge multiple configuration file layers.
|
|
5
5
|
Author-email: Avinash Kumar <halfhiddencode@gmail.com>
|
|
6
6
|
License:
|
|
@@ -13,10 +13,15 @@ src/dynamic_config_loader.egg-info/requires.txt
|
|
|
13
13
|
src/dynamic_config_loader.egg-info/top_level.txt
|
|
14
14
|
src/dynamic_config_loader/strategies/__init__.py
|
|
15
15
|
src/dynamic_config_loader/strategies/base_strategy.py
|
|
16
|
+
src/dynamic_config_loader/strategies/json/__init__.py
|
|
17
|
+
src/dynamic_config_loader/strategies/json/merger.py
|
|
18
|
+
src/dynamic_config_loader/strategies/json/parser.py
|
|
16
19
|
src/dynamic_config_loader/strategies/yaml/__init__.py
|
|
17
20
|
src/dynamic_config_loader/strategies/yaml/merger.py
|
|
18
21
|
src/dynamic_config_loader/strategies/yaml/parser.py
|
|
19
22
|
tests/test_deep_merge.py
|
|
23
|
+
tests/test_env_injection.py
|
|
20
24
|
tests/test_factory.py
|
|
25
|
+
tests/test_json_strategy.py
|
|
21
26
|
tests/test_loader.py
|
|
22
27
|
tests/test_logging_safety.py
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from dynamic_config_loader.loader import DynamicConfigLoader
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestEnvInjection(unittest.TestCase):
|
|
11
|
+
|
|
12
|
+
def setUp(self):
|
|
13
|
+
# Clean environment variables
|
|
14
|
+
for key in list(os.environ.keys()):
|
|
15
|
+
if key.startswith("DCL_") or key.startswith("MYAPP_") or key == "ENABLE_UNSAFE_LOGGING":
|
|
16
|
+
del os.environ[key]
|
|
17
|
+
|
|
18
|
+
self.TEST_DIR = Path(__file__).parent
|
|
19
|
+
self.base_file = self.TEST_DIR / "test_configs" / "base_config.yaml"
|
|
20
|
+
|
|
21
|
+
def tearDown(self):
|
|
22
|
+
for key in list(os.environ.keys()):
|
|
23
|
+
if key.startswith("DCL_") or key.startswith("MYAPP_") or key == "ENABLE_UNSAFE_LOGGING":
|
|
24
|
+
del os.environ[key]
|
|
25
|
+
|
|
26
|
+
def test_injection_disabled_by_default(self):
|
|
27
|
+
"""
|
|
28
|
+
Verifies that environment variable injection is skipped if DCL_ENV_LOADER is not true.
|
|
29
|
+
"""
|
|
30
|
+
os.environ["DCL_SERVER_PORT"] = "9000"
|
|
31
|
+
loader = DynamicConfigLoader(default_config_path=str(self.base_file))
|
|
32
|
+
result = loader.load()
|
|
33
|
+
|
|
34
|
+
# The port from base_config.yaml is 5432 and must remain unchanged
|
|
35
|
+
self.assertEqual(result["database"]["port"], 5432)
|
|
36
|
+
|
|
37
|
+
def test_injection_default_prefix(self):
|
|
38
|
+
"""
|
|
39
|
+
Verifies that configuration parameters are injected correctly with the default prefix.
|
|
40
|
+
"""
|
|
41
|
+
os.environ["DCL_ENV_LOADER"] = "true"
|
|
42
|
+
os.environ["DCL_DATABASE_PORT"] = "9000"
|
|
43
|
+
os.environ["DCL_DATABASE_USER"] = "postgres_env"
|
|
44
|
+
os.environ["DCL_NEW_FEATURE_ENABLED"] = "true"
|
|
45
|
+
|
|
46
|
+
loader = DynamicConfigLoader(default_config_path=str(self.base_file))
|
|
47
|
+
result = loader.load()
|
|
48
|
+
|
|
49
|
+
# Check nested structures are created or overridden
|
|
50
|
+
self.assertEqual(result["database"]["port"], 9000) # String "9000" is parsed to int 9000
|
|
51
|
+
self.assertEqual(result["database"]["user"], "postgres_env")
|
|
52
|
+
self.assertTrue(result["new"]["feature"]["enabled"]) # String "true" is parsed to bool True
|
|
53
|
+
|
|
54
|
+
def test_injection_custom_prefix(self):
|
|
55
|
+
"""
|
|
56
|
+
Verifies that configuration parameters are injected correctly using a custom prefix.
|
|
57
|
+
"""
|
|
58
|
+
os.environ["DCL_ENV_LOADER"] = "true"
|
|
59
|
+
os.environ["MYAPP_DB_HOST"] = "remote.db"
|
|
60
|
+
|
|
61
|
+
# Pass custom prefix to constructor
|
|
62
|
+
loader = DynamicConfigLoader(default_config_path=str(self.base_file), prefix="MYAPP_")
|
|
63
|
+
result = loader.load()
|
|
64
|
+
|
|
65
|
+
self.assertEqual(result["db"]["host"], "remote.db")
|
|
66
|
+
# Ensure default DCL_ prefix is ignored when custom prefix is active
|
|
67
|
+
os.environ["DCL_DB_HOST"] = "local.db"
|
|
68
|
+
self.assertNotIn("database", result.get("db", {}))
|
|
69
|
+
|
|
70
|
+
def test_injection_log_masking(self):
|
|
71
|
+
"""
|
|
72
|
+
Verifies that log output respects mask_secrets parameter during injection.
|
|
73
|
+
"""
|
|
74
|
+
os.environ["DCL_ENV_LOADER"] = "true"
|
|
75
|
+
os.environ["DCL_DATABASE_PORT"] = "9999"
|
|
76
|
+
|
|
77
|
+
loader = DynamicConfigLoader(default_config_path=str(self.base_file))
|
|
78
|
+
|
|
79
|
+
# Safe Debug (Default mask_secrets=True)
|
|
80
|
+
with self.assertLogs("dynamic_config_loader", level=logging.DEBUG) as log_capture:
|
|
81
|
+
loader.load()
|
|
82
|
+
|
|
83
|
+
log_messages = "\n".join(log_capture.output)
|
|
84
|
+
self.assertIn("[REDACTED]", log_messages)
|
|
85
|
+
self.assertNotIn("9999", log_messages)
|
|
86
|
+
|
|
87
|
+
# Unsafe Debug (mask_secrets=False via ENABLE_UNSAFE_LOGGING)
|
|
88
|
+
os.environ["ENABLE_UNSAFE_LOGGING"] = "true"
|
|
89
|
+
loader_unsafe = DynamicConfigLoader(default_config_path=str(self.base_file))
|
|
90
|
+
with self.assertLogs("dynamic_config_loader", level=logging.DEBUG) as log_capture_unsafe:
|
|
91
|
+
loader_unsafe.load()
|
|
92
|
+
|
|
93
|
+
log_messages_unsafe = "\n".join(log_capture_unsafe.output)
|
|
94
|
+
self.assertIn("9999", log_messages_unsafe)
|
|
95
|
+
self.assertNotIn("[REDACTED]", log_messages_unsafe)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
unittest.main()
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import unittest
|
|
4
4
|
from dynamic_config_loader.strategies import factory
|
|
5
5
|
from dynamic_config_loader.strategies.yaml import YamlStrategy
|
|
6
|
+
from dynamic_config_loader.strategies.json import JsonStrategy
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class TestStrategyFactory(unittest.TestCase):
|
|
@@ -14,9 +15,11 @@ class TestStrategyFactory(unittest.TestCase):
|
|
|
14
15
|
# Test standard extensions
|
|
15
16
|
strategy_yaml = factory.get_strategy(".yaml")
|
|
16
17
|
strategy_yml = factory.get_strategy(".yml")
|
|
18
|
+
strategy_json = factory.get_strategy(".json")
|
|
17
19
|
|
|
18
20
|
self.assertIsInstance(strategy_yaml, YamlStrategy)
|
|
19
21
|
self.assertIsInstance(strategy_yml, YamlStrategy)
|
|
22
|
+
self.assertIsInstance(strategy_json, JsonStrategy)
|
|
20
23
|
|
|
21
24
|
def test_factory_case_insensitivity(self):
|
|
22
25
|
"""
|
|
@@ -24,15 +27,16 @@ class TestStrategyFactory(unittest.TestCase):
|
|
|
24
27
|
"""
|
|
25
28
|
strategy_mixed = factory.get_strategy(".YaMl")
|
|
26
29
|
strategy_upper = factory.get_strategy(".YML")
|
|
30
|
+
strategy_json_upper = factory.get_strategy(".JSON")
|
|
27
31
|
|
|
28
32
|
self.assertIsInstance(strategy_mixed, YamlStrategy)
|
|
29
33
|
self.assertIsInstance(strategy_upper, YamlStrategy)
|
|
34
|
+
self.assertIsInstance(strategy_json_upper, JsonStrategy)
|
|
30
35
|
|
|
31
36
|
def test_factory_unsupported_extensions(self):
|
|
32
37
|
"""
|
|
33
38
|
Verifies that StrategyFactory returns None for unregistered extensions.
|
|
34
39
|
"""
|
|
35
|
-
self.assertIsNone(factory.get_strategy(".json"))
|
|
36
40
|
self.assertIsNone(factory.get_strategy(".xml"))
|
|
37
41
|
self.assertIsNone(factory.get_strategy(".ini"))
|
|
38
42
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import unittest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from dynamic_config_loader.loader import DynamicConfigLoader
|
|
8
|
+
from dynamic_config_loader.strategies.json.parser import parse_json_file
|
|
9
|
+
from dynamic_config_loader.strategies.json.merger import execute_json_deep_merge
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestJsonStrategy(unittest.TestCase):
|
|
13
|
+
|
|
14
|
+
def setUp(self):
|
|
15
|
+
self.TEST_DIR = Path(__file__).parent
|
|
16
|
+
self.base_file = self.TEST_DIR / "test_configs" / "base_config.json"
|
|
17
|
+
self.overrides_dir = self.TEST_DIR / "test_configs" / "overrides"
|
|
18
|
+
|
|
19
|
+
# Clear env variables
|
|
20
|
+
if "PYTHON_ADDITIONAL_CONFIG" in os.environ:
|
|
21
|
+
del os.environ["PYTHON_ADDITIONAL_CONFIG"]
|
|
22
|
+
if "ENABLE_UNSAFE_LOGGING" in os.environ:
|
|
23
|
+
del os.environ["ENABLE_UNSAFE_LOGGING"]
|
|
24
|
+
|
|
25
|
+
def tearDown(self):
|
|
26
|
+
if "PYTHON_ADDITIONAL_CONFIG" in os.environ:
|
|
27
|
+
del os.environ["PYTHON_ADDITIONAL_CONFIG"]
|
|
28
|
+
if "ENABLE_UNSAFE_LOGGING" in os.environ:
|
|
29
|
+
del os.environ["ENABLE_UNSAFE_LOGGING"]
|
|
30
|
+
|
|
31
|
+
def test_json_parsing(self):
|
|
32
|
+
"""
|
|
33
|
+
Verifies that parse_json_file correctly parses JSON files into dictionaries.
|
|
34
|
+
"""
|
|
35
|
+
data = parse_json_file(self.base_file)
|
|
36
|
+
self.assertEqual(data["app_name"], "Test JSON Gateway")
|
|
37
|
+
self.assertEqual(data["database"]["port"], 5432)
|
|
38
|
+
|
|
39
|
+
def test_json_deep_merging(self):
|
|
40
|
+
"""
|
|
41
|
+
Verifies recursive deep merges and type mismatch resolution for JSON structures.
|
|
42
|
+
"""
|
|
43
|
+
base = {
|
|
44
|
+
"debug": False,
|
|
45
|
+
"database": {
|
|
46
|
+
"host": "localhost",
|
|
47
|
+
"settings": {"pool": 10}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
update = {
|
|
51
|
+
"debug": True,
|
|
52
|
+
"database": {
|
|
53
|
+
"settings": {"pool": 20, "timeout": 30}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
execute_json_deep_merge(base, update)
|
|
58
|
+
|
|
59
|
+
self.assertTrue(base["debug"])
|
|
60
|
+
self.assertEqual(base["database"]["host"], "localhost")
|
|
61
|
+
self.assertEqual(base["database"]["settings"]["pool"], 20)
|
|
62
|
+
self.assertEqual(base["database"]["settings"]["timeout"], 30)
|
|
63
|
+
|
|
64
|
+
def test_json_loader_integration(self):
|
|
65
|
+
"""
|
|
66
|
+
Verifies that DynamicConfigLoader correctly orchestrates baseline JSON and directory overrides JSON.
|
|
67
|
+
"""
|
|
68
|
+
# Set overrides path in env
|
|
69
|
+
os.environ["PYTHON_ADDITIONAL_CONFIG"] = str(self.overrides_dir)
|
|
70
|
+
|
|
71
|
+
loader = DynamicConfigLoader(default_config_path=str(self.base_file))
|
|
72
|
+
result = loader.load()
|
|
73
|
+
|
|
74
|
+
# baseline JSON: Test JSON Gateway, overrides folder has 01_dev_patch.json overriding database host
|
|
75
|
+
self.assertEqual(result["app_name"], "Test JSON Gateway")
|
|
76
|
+
self.assertTrue(result["debug_mode"])
|
|
77
|
+
self.assertEqual(result["database"]["host"], "json-dev-cluster.local")
|
|
78
|
+
self.assertEqual(result["database"]["port"], 5432)
|
|
79
|
+
|
|
80
|
+
def test_json_log_masking(self):
|
|
81
|
+
"""
|
|
82
|
+
Verifies that logging of updates/additions for JSON conforms to mask_secrets checks.
|
|
83
|
+
"""
|
|
84
|
+
base = {"db_pass": "old_secret"}
|
|
85
|
+
update = {"db_pass": "new_secret"}
|
|
86
|
+
|
|
87
|
+
# Safe logs (default mask_secrets=True)
|
|
88
|
+
with self.assertLogs("dynamic_config_loader", level=logging.DEBUG) as log_capture:
|
|
89
|
+
execute_json_deep_merge(base, update, mask_secrets=True)
|
|
90
|
+
|
|
91
|
+
log_messages = "\n".join(log_capture.output)
|
|
92
|
+
self.assertIn("[REDACTED]", log_messages)
|
|
93
|
+
self.assertNotIn("new_secret", log_messages)
|
|
94
|
+
|
|
95
|
+
# Reset base to ensure the second execution performs an update and triggers log outputs
|
|
96
|
+
base = {"db_pass": "old_secret"}
|
|
97
|
+
|
|
98
|
+
# Unsafe logs (mask_secrets=False via ENABLE_UNSAFE_LOGGING setting)
|
|
99
|
+
os.environ["ENABLE_UNSAFE_LOGGING"] = "true"
|
|
100
|
+
loader_unsafe = DynamicConfigLoader(default_config_path=str(self.base_file))
|
|
101
|
+
loader_unsafe.load() # Resolves self.mask_secrets to False
|
|
102
|
+
|
|
103
|
+
with self.assertLogs("dynamic_config_loader", level=logging.DEBUG) as log_capture_unsafe:
|
|
104
|
+
execute_json_deep_merge(base, update, mask_secrets=loader_unsafe.mask_secrets)
|
|
105
|
+
|
|
106
|
+
log_messages_unsafe = "\n".join(log_capture_unsafe.output)
|
|
107
|
+
self.assertIn("new_secret", log_messages_unsafe)
|
|
108
|
+
self.assertNotIn("[REDACTED]", log_messages_unsafe)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{dynamic_config_loader-2.0.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|