diff --git a/docs/release-notes.md b/docs/release-notes.md index 91774a1..72916e8 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,5 +1,26 @@ # Release notes +## Version 0.0.8 + +### Added + +- Settings forwards +- `settings_path` parameter has now more allowed value types. +- Assignments to the settings attribute. +- `with_` and `set_` operations returning set object. + +### Changed + +- `settings_path=""` behaves now different (enables settings). The default switched to `None` (disabled settings). + +### Removed + +- Remove deprecated alias for `settings_preloads_name`. + +### Fixed + +- Use the right instance for apply_settings in set_instance. + ## Version 0.0.7 ### Fixed diff --git a/docs/specials.md b/docs/specials.md index 3cecc90..9891e2e 100644 --- a/docs/specials.md +++ b/docs/specials.md @@ -93,4 +93,133 @@ monkay.add_deprecated_lazy_import( }, no_hooks=True ) +``` + +## Setting settings forward + +Sometimes you have some packages which should work independently but +in case of a main package the packages should use the settings of the main package. + +For this monkay settings have a forwarding mode, in which the cache is disabled. +It can be enabled by either setting the settings parameter to a function (most probably less common) +or simply assigning a callable to the monkay settings property. +It is expected that the assigned function returns a suitable settings object. + + +Child + +``` python +import os +from monkay import Monkay + +monkay = Monkay( + globals(), + settings_path=os.environ.get("MONKAY_CHILD_SETTINGS", "foo.test:example") or "" +) + +``` + +Main + +``` python +import os +import child + +monkay = Monkay( + globals(), + settings_path=os.environ.get("MONKAY_MAIN_SETTINGS", "foo.test:example") or "" +) +child.monkay.settings = lambda: monkay.settings + +``` + +## Lazy settings setup + +Like when using a settings forward it is possible to activate the settings later by assigning a string, a class or an settings instance +to the settings attribute. +For this provide an empty string to the settings_path variable. +It ensures the initialization takes place. + +``` python +import os +from monkay import Monkay + +monkay = Monkay( + globals(), + # required for initializing settings + settings_path="" +) + +# somewhere later + +if not os.environg.get("DEBUG"): + monkay.settings = os.environ.get("MONKAY_MAIN_SETTINGS", "foo.test:example") or "" +elif os.environ.get("PERFORMANCE"): + # you can also provide a class + monkay.settings = DebugSettings +else: + monkay.settings = DebugSettings() + +``` + +## Other settings types + +All of the assignment examples are also possible as settings_path parameter. +When assigning a string or a class, the initialization happens on the first access to the settings +attribute and are cached. +Functions get evaluated on every access and should care for caching in case it is required (for forwards the caching +takes place in the main settings). + + +## Temporary disable overwrite + +You can also use the `with_...` functions with None. This disables the overwrite for the scope. +It is used in set_instance when applying extensions. + +## Echoed values + +The `with_` and `set_` methods return the passed variable as contextmanager value. + +e.g. + +``` python + +with monkay.with_settings(Settings()) as new_settings: + # do things with the settings overwrite + + # disable the overwrite + with monkay.with_settings(None) as new_settings2: + # echoed is None + assert new_settings2 is None + # settings are the old settings again + assert monkay.settings is old_settings + +``` + + +## Forwarder + +Sometimes you have an old settings place and want to forward it to the monkay one. +Here does no helper exist but a forwarder is easy to write: + +``` python +from typing import Any, cast, TYPE_CHECKING + +if TYPE_CHECKING: + from .global_settings import EdgySettings + + +class SettingsForward: + def __getattribute__(self, name: str) -> Any: + import edgy + + return getattr(edgy.monkay.settings, name) + +# we want to pretend the forward is the real object +settings = cast("EdgySettings", SettingsForward()) + +__all__ = ["settings"] + + + ``` diff --git a/monkay/__about__.py b/monkay/__about__.py index 7440fa0..c886cbd 100644 --- a/monkay/__about__.py +++ b/monkay/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2024-present alex # # SPDX-License-Identifier: BSD-3-Clauses -__version__ = "0.0.7" +__version__ = "0.0.8" diff --git a/monkay/base.py b/monkay/base.py index 7bbc4f5..e53dfdf 100644 --- a/monkay/base.py +++ b/monkay/base.py @@ -157,6 +157,7 @@ class Monkay(Generic[INSTANCE, SETTINGS]): _extensions_var: None | ContextVar[None | dict[str, ExtensionProtocol[INSTANCE, SETTINGS]]] = None _extensions_applied: None | ContextVar[dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None] = None _settings_var: ContextVar[SETTINGS | None] | None = None + _settings_definition: SETTINGS | type[SETTINGS] | str | Callable[[], SETTINGS] | None = None def __init__( self, @@ -165,9 +166,8 @@ def __init__( with_instance: str | bool = False, with_extensions: str | bool = False, extension_order_key_fn: None | Callable[[ExtensionProtocol[INSTANCE, SETTINGS]], Any] = None, - settings_path: str = "", + settings_path: str | Callable[[], SETTINGS] | SETTINGS | type[SETTINGS] | None = None, preloads: Iterable[str] = (), - settings_preload_name: str = "", settings_preloads_name: str = "", settings_extensions_name: str = "", uncached_imports: Iterable[str] = (), @@ -203,18 +203,9 @@ def __init__( if deprecated_lazy_imports: for name, deprecated_import in deprecated_lazy_imports.items(): self.add_deprecated_lazy_import(name, deprecated_import, no_hooks=True) - self.settings_path = settings_path - if self.settings_path: + if settings_path is not None: self._settings_var = globals_dict[settings_ctx_name] = ContextVar(settings_ctx_name, default=None) - - if settings_preload_name: - warnings.warn( - 'The "settings_preload_name" parameter is deprecated use "settings_preloads_name" instead.', - DeprecationWarning, - stacklevel=2, - ) - if not settings_preloads_name and settings_preload_name: - settings_preloads_name = settings_preload_name + self.settings = settings_path # type: ignore self.settings_preloads_name = settings_preloads_name self.settings_extensions_name = settings_extensions_name @@ -240,7 +231,7 @@ def __init__( def clear_caches(self, settings_cache: bool = True, import_cache: bool = True) -> None: if settings_cache: - self.__dict__.pop("_settings", None) + del self.settings if import_cache: self._cached_imports.clear() @@ -254,18 +245,21 @@ def instance(self) -> INSTANCE | None: def set_instance( self, - instance: INSTANCE, + instance: INSTANCE | None, *, apply_extensions: bool = True, use_extensions_overwrite: bool = True, - ) -> None: + ) -> INSTANCE | None: assert self._instance_var is not None, "Monkay not enabled for instances" # need to address before the instance is swapped if apply_extensions and self._extensions_applied_var.get() is not None: raise RuntimeError("Other apply process in the same context is active.") self._instance = instance - if apply_extensions and self._extensions_var is not None: - self.apply_extensions(use_overwrite=use_extensions_overwrite) + if apply_extensions and instance is not None and self._extensions_var is not None: + # unapply a potential instance overwrite + with self.with_instance(None): + self.apply_extensions(use_overwrite=use_extensions_overwrite) + return instance @contextmanager def with_instance( @@ -274,7 +268,7 @@ def with_instance( *, apply_extensions: bool = False, use_extensions_overwrite: bool = True, - ) -> Generator: + ) -> Generator[INSTANCE | None]: assert self._instance_var is not None, "Monkay not enabled for instances" # need to address before the instance is swapped if apply_extensions and self._extensions_var is not None and self._extensions_applied_var.get() is not None: @@ -283,7 +277,7 @@ def with_instance( try: if apply_extensions and self._extensions_var is not None: self.apply_extensions(use_overwrite=use_extensions_overwrite) - yield + yield instance finally: self._instance_var.reset(token) @@ -366,14 +360,14 @@ def with_extensions( extensions: dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None, *, apply_extensions: bool = False, - ) -> Generator: + ) -> Generator[dict[str, ExtensionProtocol[INSTANCE, SETTINGS]] | None]: # why None, for temporary using the real extensions assert self._extensions_var is not None, "Monkay not enabled for extensions" token = self._extensions_var.set(extensions) try: if apply_extensions and self.instance is not None: self.apply_extensions() - yield + yield extensions finally: self._extensions_var.reset(token) @@ -506,27 +500,59 @@ def find_missing( return missing @cached_property - def _settings(self) -> SETTINGS: - settings: Any = load(self.settings_path, package=self.package) + def _loaded_settings(self) -> SETTINGS | None: + # only class and string pathes + if isclass(self._settings_definition): + return self._settings_definition() + assert isinstance(self._settings_definition, str), f"Not a settings object: {self._settings_definition}" + if not self._settings_definition: + return None + settings: SETTINGS | type[SETTINGS] = load(self._settings_definition, package=self.package) if isclass(settings): settings = settings() - return settings + return cast(SETTINGS, settings) @property def settings(self) -> SETTINGS: assert self._settings_var is not None, "Monkay not enabled for settings" - settings = self._settings_var.get() + settings: SETTINGS | Callable[[], SETTINGS] | None = self._settings_var.get() if settings is None: - settings = self._settings + # when settings_path is callable bypass the cache, for forwards + settings = ( + self._loaded_settings + if isinstance(self._settings_definition, str) or isclass(self._settings_definition) + else self._settings_definition + ) + if callable(settings): + settings = settings() + if settings is None: + raise RuntimeError("Settings are not set yet. Returned settings are None or settings_path is empty.") return settings + @settings.setter + def settings(self, value: str | Callable[[], SETTINGS] | SETTINGS | type[SETTINGS] | None) -> None: + assert self._settings_var is not None, "Monkay not enabled for settings" + if not value: + self._settings_definition = "" + return + if not isinstance(value, str) and not callable(value) and not isclass(value): + self._settings_definition = lambda: value + else: + self._settings_definition = value + del self.settings + + @settings.deleter + def settings(self) -> None: + # clear cache + self.__dict__.pop("_loaded_settings", None) + @contextmanager - def with_settings(self, settings: SETTINGS | None) -> Generator: + def with_settings(self, settings: SETTINGS | None) -> Generator[SETTINGS | None]: assert self._settings_var is not None, "Monkay not enabled for settings" # why None, for temporary using the real settings token = self._settings_var.set(settings) try: - yield + yield settings finally: self._settings_var.reset(token) diff --git a/tests/targets/settings.py b/tests/targets/settings.py index ff24b22..df68867 100644 --- a/tests/targets/settings.py +++ b/tests/targets/settings.py @@ -18,3 +18,6 @@ class Settings(BaseSettings): lambda: load("tests.targets.extension:Extension")(name="settings_extension1"), SettingsExtension, ] + + +hurray = Settings() diff --git a/tests/test_basic.py b/tests/test_basic.py index 33175fc..a1cf889 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -181,11 +181,11 @@ def test_caches(): assert isinstance(mod.settings, BaseSettings) assert "settings" not in mod.monkay._cached_imports # settings cache - assert "_settings" in mod.monkay.__dict__ + assert "_loaded_settings" in mod.monkay.__dict__ mod.monkay.clear_caches() assert not mod.monkay._cached_imports - assert "_settings" not in mod.monkay.__dict__ + assert "_loaded_settings" not in mod.monkay.__dict__ def test_sorted_exports(): diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..c27b397 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,53 @@ +import sys +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True, scope="function") +def cleanup(): + for p in (Path(__file__).parent / "targets").iterdir(): + sys.modules.pop(f"tests.targets.{p.stem}", None) + yield + + +def test_settings_basic(): + import tests.targets.module_full as mod + from tests.targets.settings import Settings, hurray + + new_settings = Settings(preloads=[], extensions=[]) + + old_settings = mod.monkay.settings + settings_path = mod.monkay._settings_definition + assert isinstance(settings_path, str) + assert mod.monkay.settings is old_settings + mod.monkay.settings = new_settings + assert mod.monkay.settings is new_settings + + mod.monkay.settings = lambda: old_settings + assert mod.monkay.settings is old_settings + # auto generated settings + mod.monkay.settings = Settings + mod.monkay.settings = "tests.targets.settings:hurray" + assert mod.monkay.settings is hurray + + +def test_settings_overwrite(): + import tests.targets.module_full as mod + + old_settings = mod.monkay.settings + settings_path = mod.monkay._settings_definition + assert isinstance(settings_path, str) + new_settings = old_settings.model_copy(update={"preloadd": []}) + with mod.monkay.with_settings(new_settings) as yielded: + assert mod.monkay.settings is new_settings + assert mod.monkay.settings is yielded + assert mod.monkay.settings is not old_settings + # overwriting settings doesn't affect temporary scope + mod.monkay.settings = mod.monkay._settings_definition + assert mod.monkay.settings is new_settings + + # now access the non-temporary settings + with mod.monkay.with_settings(None): + assert mod.monkay.settings is not new_settings + assert mod.monkay.settings is not old_settings