dynamic-config-loader 2.1.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.
Files changed (29) hide show
  1. {dynamic_config_loader-2.1.0/src/dynamic_config_loader.egg-info → dynamic_config_loader-2.2.0}/PKG-INFO +1 -1
  2. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/pyproject.toml +1 -1
  3. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/loader.py +19 -0
  4. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/__init__.py +4 -2
  5. dynamic_config_loader-2.2.0/src/dynamic_config_loader/strategies/json/__init__.py +34 -0
  6. dynamic_config_loader-2.2.0/src/dynamic_config_loader/strategies/json/merger.py +63 -0
  7. dynamic_config_loader-2.2.0/src/dynamic_config_loader/strategies/json/parser.py +40 -0
  8. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0/src/dynamic_config_loader.egg-info}/PKG-INFO +1 -1
  9. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader.egg-info/SOURCES.txt +4 -0
  10. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/tests/test_factory.py +5 -1
  11. dynamic_config_loader-2.2.0/tests/test_json_strategy.py +112 -0
  12. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/AUTHORS +0 -0
  13. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/LICENSE +0 -0
  14. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/NOTICE +0 -0
  15. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/README.md +0 -0
  16. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/setup.cfg +0 -0
  17. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/__init__.py +0 -0
  18. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/environment.py +0 -0
  19. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/base_strategy.py +0 -0
  20. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/yaml/__init__.py +0 -0
  21. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/yaml/merger.py +0 -0
  22. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader/strategies/yaml/parser.py +0 -0
  23. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader.egg-info/dependency_links.txt +0 -0
  24. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader.egg-info/requires.txt +0 -0
  25. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/src/dynamic_config_loader.egg-info/top_level.txt +0 -0
  26. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/tests/test_deep_merge.py +0 -0
  27. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/tests/test_env_injection.py +0 -0
  28. {dynamic_config_loader-2.1.0 → dynamic_config_loader-2.2.0}/tests/test_loader.py +0 -0
  29. {dynamic_config_loader-2.1.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.1.0
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.1.0"
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"
@@ -72,6 +72,13 @@ class DynamicConfigLoader:
72
72
  "======================================================================="
73
73
  )
74
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
+
75
82
  # Pipeline Phase 1: Assess and ingest baseline layer properties
76
83
  if self.default_path and self.default_path.exists():
77
84
  # Dynamically look up the decoupled format strategy class using the extension suffix
@@ -113,6 +120,12 @@ class DynamicConfigLoader:
113
120
  if file_path.is_file():
114
121
  strategy = factory.get_strategy(file_path.suffix)
115
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
116
129
  try:
117
130
  logger.info(f"Processing override layer discovery file: {file_path.name}")
118
131
  extra_data = strategy.parse(file_path)
@@ -129,6 +142,12 @@ class DynamicConfigLoader:
129
142
  elif current_path.is_file():
130
143
  strategy = factory.get_strategy(current_path.suffix)
131
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
132
151
  try:
133
152
  logger.info(f"Processing direct override file asset: {current_path.name}")
134
153
  extra_data = strategy.parse(current_path)
@@ -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 yaml folder class
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.1.0
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,11 +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
20
23
  tests/test_env_injection.py
21
24
  tests/test_factory.py
25
+ tests/test_json_strategy.py
22
26
  tests/test_loader.py
23
27
  tests/test_logging_safety.py
@@ -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()