Skip to content

Commit

Permalink
fix(hooking):Don't instantiate a Settings object for every hook call.
Browse files Browse the repository at this point in the history
SCENARIO:

With a `function` as `_registered_hooks` every time a key is accessed on `settings`
the `function` is invoked passing a `settings` object as first argument.

BEFORE:

The `settings` passed to the hook was instantiated for every call.

AFTER:

The `settings` is now a `TempSettingsHolder` that has no effect on passing
and is instantiated only if accessed.

Saved execution time from 0m49 to 0m3 on a Django openapi spec view.
  • Loading branch information
rochacbruno committed Jul 19, 2024
1 parent 8c53ce2 commit 22c4adc
Show file tree
Hide file tree
Showing 9 changed files with 70 additions and 25 deletions.
2 changes: 1 addition & 1 deletion .bumpversion.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "3.2.5"
current_version = "3.2.6-dev0"
parse = """(?x)
(?P<major>0|[1-9]\\d*)\\.
(?P<minor>0|[1-9]\\d*)\\.
Expand Down
2 changes: 1 addition & 1 deletion dynaconf/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.2.5
3.2.6-dev0
2 changes: 1 addition & 1 deletion dynaconf/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ def __init__(self, settings_module=None, **kwargs): # pragma: no cover
self._loaded_py_modules = []
self._loaded_files = []
self._deleted = set()
self._store = DynaBox(box_settings=self)
self._store = kwargs.pop("_store", DynaBox(box_settings=self))
self._env_cache = {}
self._loaded_by_loaders: dict[SourceMetadata | str, Any] = {}
self._loaders = []
Expand Down
59 changes: 48 additions & 11 deletions dynaconf/hooking.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from typing import Any
from typing import Callable

from dynaconf.base import RESERVED_ATTRS
from dynaconf.base import Settings
from dynaconf.loaders.base import SourceMetadata

Expand Down Expand Up @@ -77,17 +76,9 @@ def dispatch(fun, self, *args, **kwargs):
):
return fun(self, *args, **kwargs)

# Create an unhookable (to avoid recursion)
# Create an unhook-able (to avoid recursion)
# temporary settings to pass to the hooked function
temp_settings = Settings(
dynaconf_skip_loaders=True,
dynaconf_skip_validators=True,
)
allowed_keys = self.__dict__.keys() - set(RESERVED_ATTRS)
temp_data = {
k: v for k, v in self.__dict__.items() if k in allowed_keys
}
temp_settings._store.update(temp_data)
temp_settings = TempSettingsHolder(self)

def _hook(action: str, value: HookValue) -> HookValue:
"""executes the hooks for the given action"""
Expand Down Expand Up @@ -304,3 +295,49 @@ class HookableSettings(Settings):
@hookable
def get(self, *args, **kwargs):
return Settings.get(self, *args, **kwargs)


class TempSettingsHolder:
"""Holds settings to be passed down to hooks.
To save runtime resources initialize a copy of it only if accessed.
"""

_settings = None

def __init__(self, settings):
self._original_settings = settings

def _initialize(self):
if self._settings is None:
self._settings = Settings(
dynaconf_skip_loaders=True,
dynaconf_skip_validators=True,
_store=self._original_settings._store._safe_copy(),
)

def __getattr__(self, attr):
self._initialize()
return getattr(self._settings, attr)

def __getitem__(self, item):
self._initialize()
return self._settings[item]

def __setitem__(self, item, value):
self._initialize()
self._settings[item] = value

def __iter__(self):
self._initialize()
return iter(self._settings)

def __contains__(self, item):
self._initialize()
return item in self._settings

def __setattr__(self, attr, value):
if attr in ["_original_settings", "_settings"]:
super().__setattr__(attr, value)
else:
self._initialize()
setattr(self._settings, attr, value)
7 changes: 4 additions & 3 deletions dynaconf/loaders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,13 @@ def execute_module_hooks(
hook_file = os.path.join(
os.path.dirname(loaded_file), "dynaconf_hooks.py"
)
if not os.path.exists(hook_file):
# Return early if file doesn't exist.
# Faster than attempting to import.
continue
hook_module = py_loader.import_from_filename(
obj, hook_file, silent=silent
)
if not hook_module:
# There was no hook on the same path as a python file
continue
_run_hook_module(
hook_type=hook,
hook_module=hook_module,
Expand Down
7 changes: 7 additions & 0 deletions dynaconf/utils/boxing.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ def _safe_get(self, item, *args, **kwargs):
n_item = find_the_correct_casing(item, self) or item
return super().__getitem__(n_item, *args, **kwargs)

def _safe_copy(self):
"""Copy bypassing lazy evaluation"""
return self.__class__(
{k: self._safe_get(k) for k in self.keys()},
box_settings=self._box_config.get("box_settings"),
)

def __copy__(self):
return self.__class__(
super(Box, self).copy(),
Expand Down
12 changes: 6 additions & 6 deletions dynaconf/utils/parse_conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,21 +441,21 @@ def parse_conf_data(data, tomlfy=False, box_settings=None):

if isinstance(data, DynaBox):
# recursively parse inner dict items
_parsed = DynaBox({}, box_settings=box_settings)
# It is important to keep the same object id because
# of mutability
for k, v in data._safe_items():
_parsed[k] = parse_conf_data(
data[k] = parse_conf_data(
v, tomlfy=tomlfy, box_settings=box_settings
)
return _parsed
return data

if isinstance(data, dict):
# recursively parse inner dict items
_parsed = {}
for k, v in data.items():
_parsed[k] = parse_conf_data(
data[k] = parse_conf_data(
v, tomlfy=tomlfy, box_settings=box_settings
)
return _parsed
return data

# return parsed string value
return _parse_conf_data(data, tomlfy=tomlfy, box_settings=box_settings)
Expand Down
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
site_name: Dynaconf - 3.2.5
site_name: Dynaconf - 3.2.6-dev0
site_url: https://dynaconf.com
site_description: Configuration Management for Python
site_author: Bruno Rocha
Expand Down
2 changes: 1 addition & 1 deletion scripts/release-main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
echo "[RELEASE] Checking pre-conditions."
if [[ -n "$(git status -s)" ]]; then echo "You shouldn't have unstaged changes."; exit 1; fi
if [[ ! -d ".git" ]]; then echo "You should run from the repository root dir."; exit 1; fi
if [[ $(git rev-parse --abbrev-ref HEAD) != "master" ]]; then echo "Should be on master branch."; exit 1; fi
# if [[ $(git rev-parse --abbrev-ref HEAD) != "master" ]]; then echo "Should be on master branch."; exit 1; fi

echo "[RELEASE] Starting release process."
set -euo pipefail
Expand Down

0 comments on commit 22c4adc

Please sign in to comment.