-
-
Notifications
You must be signed in to change notification settings - Fork 7
feat(configuration): stacked environment configuration overlays #54
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,34 @@ | ||
| import os | ||
|
|
||
| from ..facades import Loader | ||
| from ..utils.str import as_filepath | ||
| from ..utils.structures import data | ||
| from ..exceptions import InvalidConfigurationLocation, InvalidConfigurationSetup | ||
|
|
||
|
|
||
| def deep_merge(base, override): | ||
| """Deep-merge ``override`` on top of ``base`` and return a new value. | ||
|
|
||
| - dicts are merged recursively: ``override`` wins on conflicting keys while keys | ||
| only present in ``base`` are preserved; | ||
| - lists, scalars and mismatched types are replaced by ``override``. | ||
|
|
||
| Neither argument is mutated (the returned value never aliases a mutated input), | ||
| which matters because the loader reads the live stored dict before merging. The | ||
| list branch is the extension point for any future append/prepend strategy. | ||
| """ | ||
| if isinstance(base, dict) and isinstance(override, dict): | ||
| merged = dict(base) | ||
| for key, override_value in override.items(): | ||
| if key in merged: | ||
| merged[key] = deep_merge(merged[key], override_value) | ||
| else: | ||
| merged[key] = override_value | ||
| return merged | ||
| # lists, scalars and type mismatches: the overlay value replaces the base value. | ||
| return override | ||
|
|
||
|
|
||
| class Configuration: | ||
| # Foundation configuration keys | ||
| reserved_keys = [ | ||
|
|
@@ -23,8 +49,21 @@ def __init__(self, application): | |
| self.application = application | ||
| self._config = data() | ||
|
|
||
| def load(self): | ||
| """At boot load configuration from all files and store them in here.""" | ||
| def load(self, overlays: "list[str] | None" = None): | ||
| """At boot load configuration from all files and store them in here. | ||
|
|
||
| Configuration can be "stacked": additional overlay locations are merged on | ||
| top of the base location so an environment only has to declare the values it | ||
| changes. Overlays are partial — they only need to contain the modules and | ||
| keys they want to override. Dict values are deep-merged into the base while | ||
| lists and scalars are replaced. | ||
|
|
||
| The base location is loaded first, then each location in ``overlays`` in | ||
| order, then — if it exists — the overlay for the current environment at | ||
| ``{config.location}/environment/{APP_ENV}``. The environment overlay is | ||
| applied automatically and is a no-op when that directory is absent, so | ||
| existing applications are unaffected. | ||
| """ | ||
| config_root = self.application.make("config.location") | ||
| for module_name, module in Loader.get_modules( | ||
| config_root, raise_exception=True | ||
|
|
@@ -33,12 +72,40 @@ def load(self): | |
| for name, value in params.items(): | ||
| self._config[f"{module_name}.{name.lower()}"] = value | ||
|
|
||
| # stack any explicitly requested overlay locations on top of the base. | ||
| for overlay_location in overlays or []: | ||
| self._apply_overlay(overlay_location) | ||
|
|
||
| # automatically stack the overlay for the current environment when present. | ||
| environment = self.application.environment() | ||
| if environment: | ||
| environment_overlay = f"{config_root}/environment/{environment}" | ||
| if os.path.isdir(as_filepath(environment_overlay)): | ||
| self._apply_overlay(environment_overlay) | ||
|
|
||
|
Comment on lines
+79
to
+85
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See my comment about environment naming problems. |
||
| # check loaded configuration | ||
| if not self._config.get("application"): | ||
| raise InvalidConfigurationLocation( | ||
| f"Config directory {config_root} does not contain required configuration files." | ||
| ) | ||
|
|
||
| def _apply_overlay(self, location): | ||
| """Deep-merge a single overlay location on top of the already loaded config. | ||
|
|
||
| Modules absent from the overlay are left untouched; a module present in the | ||
| overlay is merged into the matching base module (dict keys deep-merge, lists | ||
| and scalars are replaced). A broken overlay module raises, exactly like the | ||
| base load — overlays are trusted first-party configuration, so unlike | ||
| ``merge_with`` they may override reserved keys such as 'application'. | ||
| """ | ||
| for module_name, module in Loader.get_modules( | ||
| location, raise_exception=True | ||
| ).items(): | ||
| params = Loader.get_parameters(module) | ||
| override = {name.lower(): value for name, value in params.items()} | ||
| base = self._config.get(module_name) or {} | ||
| self._config[module_name] = deep_merge(base, override) | ||
|
|
||
| def merge_with(self, path, external_config): | ||
| """Merge external config at key with project config at same key. It's especially | ||
| useful in Masonite packages in order to merge the configuration default package with | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,2 @@ | ||
| from .helpers import config | ||
| from .Configuration import Configuration | ||
| from .Configuration import Configuration, deep_merge |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| KEY = "base-key" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| STORES = { | ||
| "default": "local", | ||
| "local": {"driver": "file", "location": "storage/framework/cache"}, | ||
| "redis": {"driver": "redis", "host": "127.0.0.1", "port": "6379"}, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ITEMS = [1, 2, 3] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| KEY = "env-base-key" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| STORES = { | ||
| "default": "local", | ||
| "local": {"driver": "file"}, | ||
| } |
3 changes: 3 additions & 0 deletions
3
tests/core/configuration/fixtures/base_with_env/environment/testing/cache.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| STORES = { | ||
| "default": "redis", | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| STORES = { | ||
| "redis": {"port": 6380}, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ITEMS = [9] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| from unittest import TestCase | ||
|
|
||
| from src.masonite.configuration import deep_merge | ||
|
|
||
|
|
||
| class TestDeepMerge(TestCase): | ||
| """Unit tests for the pure ``deep_merge`` helper used to stack configuration.""" | ||
|
|
||
| def test_merges_nested_dicts_with_override_winning_and_siblings_preserved(self): | ||
| base = {"stores": {"redis": {"host": "127.0.0.1", "port": "6379"}}} | ||
| override = {"stores": {"redis": {"port": 6380}}} | ||
| self.assertEqual( | ||
| deep_merge(base, override), | ||
| {"stores": {"redis": {"host": "127.0.0.1", "port": 6380}}}, | ||
| ) | ||
|
|
||
| def test_disjoint_keys_are_unioned(self): | ||
| self.assertEqual(deep_merge({"a": 1}, {"b": 2}), {"a": 1, "b": 2}) | ||
|
|
||
| def test_lists_are_replaced_not_appended(self): | ||
| self.assertEqual( | ||
| deep_merge({"items": [1, 2, 3]}, {"items": [9]}), {"items": [9]} | ||
| ) | ||
|
|
||
| def test_scalar_override_wins(self): | ||
| self.assertEqual(deep_merge({"a": 1}, {"a": 2}), {"a": 2}) | ||
|
|
||
| def test_type_mismatch_is_replaced_by_override(self): | ||
| self.assertEqual(deep_merge({"a": {"x": 1}}, {"a": [1, 2]}), {"a": [1, 2]}) | ||
| self.assertEqual(deep_merge({"a": [1]}, {"a": {"x": 1}}), {"a": {"x": 1}}) | ||
|
|
||
| def test_inputs_are_not_mutated(self): | ||
| base = {"a": {"x": 1}} | ||
| override = {"a": {"y": 2}} | ||
| deep_merge(base, override) | ||
| self.assertEqual(base, {"a": {"x": 1}}) | ||
| self.assertEqual(override, {"a": {"y": 2}}) | ||
|
|
||
| def test_empty_override_keeps_base(self): | ||
| self.assertEqual(deep_merge({"a": 1}, {}), {"a": 1}) | ||
|
|
||
| def test_empty_base_takes_override(self): | ||
| self.assertEqual(deep_merge({}, {"a": 1}), {"a": 1}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| from tests import TestCase | ||
|
|
||
| from src.masonite.configuration import Configuration | ||
|
|
||
|
|
||
| class TestOverlayConfiguration(TestCase): | ||
| """Stacked / overlay configuration loading. | ||
|
|
||
| These tests reuse the shared application from ``wsgi`` (via Masonite's | ||
| ``TestCase``) and only swap the bound ``config.location`` to point at isolated | ||
| fixtures, restoring it after each test. A throwaway local ``Configuration`` is | ||
| loaded and asserted on, so the globally bound ``config`` is never mutated. | ||
| """ | ||
|
|
||
| FIXTURES = "tests/core/configuration/fixtures" | ||
|
|
||
| def setUp(self): | ||
| super().setUp() | ||
| self._original_location = self.application.make("config.location") | ||
|
|
||
| def tearDown(self): | ||
| # always restore the real config location, even if an assertion failed | ||
| self.application.bind("config.location", self._original_location) | ||
| super().tearDown() | ||
|
|
||
| def _load(self, base, overlays=None): | ||
| self.application.bind("config.location", f"{self.FIXTURES}/{base}") | ||
| configuration = Configuration(self.application) | ||
| configuration.load(overlays) | ||
| return configuration | ||
|
|
||
| def test_explicit_overlay_overrides_only_targeted_keys(self): | ||
| config = self._load("base", overlays=[f"{self.FIXTURES}/overlay"]) | ||
| # the targeted leaf is overridden... | ||
| self.assertEqual(config.get("cache.stores.redis.port"), 6380) | ||
| # ...while its siblings and untouched keys in the same module survive | ||
| self.assertEqual(config.get("cache.stores.redis.host"), "127.0.0.1") | ||
| self.assertEqual(config.get("cache.stores.default"), "local") | ||
| self.assertEqual(config.get("cache.stores.local.driver"), "file") | ||
|
|
||
| def test_partial_overlay_leaves_unmentioned_modules_intact(self): | ||
| # the overlay never declares application.py, so it must be left as the base | ||
| config = self._load("base", overlays=[f"{self.FIXTURES}/overlay"]) | ||
| self.assertEqual(config.get("application.key"), "base-key") | ||
|
|
||
| def test_overlay_lists_are_replaced_not_appended(self): | ||
| config = self._load("base", overlays=[f"{self.FIXTURES}/overlay"]) | ||
| self.assertEqual(config.get("sample_list.items"), [9]) | ||
|
|
||
| def test_environment_overlay_is_applied_automatically(self): | ||
| # environment() is "testing" under pytest, and base_with_env ships an | ||
| # environment/testing overlay, so load() with no explicit overlays applies it | ||
| config = self._load("base_with_env") | ||
| self.assertEqual(config.get("cache.stores.default"), "redis") | ||
| # values outside the environment overlay are preserved | ||
| self.assertEqual(config.get("cache.stores.local.driver"), "file") | ||
|
|
||
| def test_missing_environment_overlay_is_a_no_op(self): | ||
| # base has no environment/ folder, so a plain load() equals the base config | ||
| config = self._load("base") | ||
| self.assertEqual(config.get("cache.stores.default"), "local") | ||
| self.assertEqual(config.get("sample_list.items"), [1, 2, 3]) | ||
| self.assertEqual(config.get("application.key"), "base-key") |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
see my omment about replacing lists