Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 69 additions & 2 deletions src/masonite/configuration/Configuration.py
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``.

Comment on lines +14 to +15

Copy link
Copy Markdown
Collaborator

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

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 = [
Expand All @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
Expand Down
2 changes: 1 addition & 1 deletion src/masonite/configuration/__init__.py
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
1 change: 1 addition & 0 deletions tests/core/configuration/fixtures/base/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
KEY = "base-key"
5 changes: 5 additions & 0 deletions tests/core/configuration/fixtures/base/cache.py
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"},
}
1 change: 1 addition & 0 deletions tests/core/configuration/fixtures/base/sample_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ITEMS = [1, 2, 3]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
KEY = "env-base-key"
4 changes: 4 additions & 0 deletions tests/core/configuration/fixtures/base_with_env/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
STORES = {
"default": "local",
"local": {"driver": "file"},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
STORES = {
"default": "redis",
}
3 changes: 3 additions & 0 deletions tests/core/configuration/fixtures/overlay/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
STORES = {
"redis": {"port": 6380},
}
1 change: 1 addition & 0 deletions tests/core/configuration/fixtures/overlay/sample_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ITEMS = [9]
43 changes: 43 additions & 0 deletions tests/core/configuration/test_deep_merge.py
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})
63 changes: 63 additions & 0 deletions tests/core/configuration/test_overlay_config.py
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")