From 2f5df69ef2ed57c871be1d6a020f64672efa2d7b Mon Sep 17 00:00:00 2001 From: Josh Orr Date: Mon, 27 Mar 2023 14:28:55 -0600 Subject: [PATCH] feat: add ability to dynamically alter default retrievers for a class. --- docs/index.md | 8 +-- tests/test_settings.py | 21 +++++++- xsettings/settings.py | 113 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 127 insertions(+), 15 deletions(-) diff --git a/docs/index.md b/docs/index.md index a693a8e..3ef625b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -597,11 +597,11 @@ to follow: - Example: `with MySetting(some_setting='parent-value'):` 3. Retrievers are consulted next. 1. First, retriever set directly on field `xsettings.fields.SettingsField.retriever`. - 1. This can include any field properties `@property`, they are set as the field retriever. + 1. This can include any field properties `@property`, as they are set as the field retriever. 2. Next, instance retriever(s) set directly on the Setting object that is being asked for its field value is checked. - 1. via [`BaseSettings.add_instance_retrievers`](api/xsettings/settings.html#xsettings.settings.Settings.add_instance_retrievers){target=_blank}. + 1. via [`BaseSettings.add_instance_retrievers`](api/xsettings/settings.html#xsettings.settings.Settings.settings__instance_retrievers){target=_blank}. 3. Then any instance-retrievers in the dependency-chain are checked next (see step 2 above for more details). - 4. Default-retrievers assigned to the class(es) are next checked, in `mro` order. + 4. Default-retrievers assigned to the class(es) are next checked, in `mro` order (ie: parent/super-classes are checked last). 4. Finally, any default-value for the field is consulted. - If the default-value is a property, or forward-ref then that is followed. - ie: `BaseSettings.some_attr = OtherSettings.another_attr_to_forward_ref_with` @@ -610,6 +610,8 @@ to follow: Keep in mind that generally, if a value is a `property` (including forward-refs, which are also properties), they are followed via the standard `__get__` mechanism (see earlier in document for forward-ref details). +You can add + ## Resolution Details Values set directly on Setting instances are first checked for and used if one is found. diff --git a/tests/test_settings.py b/tests/test_settings.py index ce6343b..45efb50 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -560,12 +560,16 @@ def test_grab_setting_values_from_parent_dependency_instances(): def r1(*, field: SettingsField, settings: BaseSettings): return 2 if field.name == 'c' else 'str-val' - class MySettings(BaseSettings, default_retrievers=[r1]): + class MySettings(BaseSettings): # Make them fields in our BaseSettings subclass, default value to another settings class. a: str b: str c: int + # we could have added this via `MySettings(..., default_retrievers=[r1])` above, + # but this lets us easily test adding it at run-time. + MySettings.settings__default_retrievers.append(r1) + my_settings = MySettings.proxy() my_settings.a = "override-a" @@ -580,6 +584,7 @@ class MySettings(BaseSettings, default_retrievers=[r1]): assert my_settings.b == 'override-via-child-instance-b' assert my_settings.c == 2 + # ensure values are reverted... assert my_settings.a == 'override-a' assert my_settings.b == 'str-val' assert my_settings.c == 2 @@ -594,3 +599,17 @@ def r2(*, field: SettingsField, settings: BaseSettings): assert my_settings.a == 'override-a' assert my_settings.b == 'str-val-r2' assert my_settings.c == 2 + + # ensure values are reverted... + assert my_settings.a == 'override-a' + assert my_settings.b == 'str-val' + assert my_settings.c == 2 + + # Try adding instance retriever after class was created. + MySettings.grab().settings__instance_retrievers.append(r2) + + # These values come from the `r2` retriever, which should be checked first + # before the default-retriever at the BaseSettings class level (ie: r1 further above). + assert my_settings.a == 'override-a' + assert my_settings.b == 'str-val-r2' + assert my_settings.c == 2 diff --git a/xsettings/settings.py b/xsettings/settings.py index d38d32b..c45dde0 100644 --- a/xsettings/settings.py +++ b/xsettings/settings.py @@ -145,6 +145,59 @@ def __new__( return cls + settings__default_retrievers: 'List[SettingsRetrieverProtocol]' + + # @SettingsClassProperty + @property + def settings__default_retrievers(self) -> 'List[SettingsRetrieverProtocol]': + """ + You can add one or more retrievers to this `subclass` of BaseSettings + (modifies default_retrievers for the entire class + subclasses, only modifies this specific + class). + + You can add or modify the list of default-retrievers via + `BaseSettings.settings__default_retrievers`. It's a list that you can directly modify; + ie: `MySettings.settings__default_retrievers.append(my_retriever)`. + + ## Background + + Below is a quick summary, you can see more detailed information in main docs under the + `"How Setting Field Values Are Resolved"` heading. + + Directly set values (ie: `self.some_settings = 'some-value'`) + are first checked for in self, and next in `xinject.context.XContext.dependency_chain` + (looking at each instance currently in the dependency-chain, see link for details). + + If value can't be found set on self or in dependency chain, + the retrievers are checked next. + + First the field's individual retriever is checked (directly on field object, + this includes any `@property` fields too as the property getter method is stored on + field's individual retriever). + + After the individual field retrievers are consulted, instance retrievers are checked next + before finally checking the default-retrievers for the entire class. + + They are checked in the order added. + + Child dependencies (of the same exactly class/type) in the + `xinject.context.XContext.dependency_chain` will also check these instance-retrievers. + + The dependency chain is checked in the expected order of first consulting self, + then the chain in most recent parent first order. + + For more details on how parent/child dependencies work see + `xinject.context.XContext.dependency_chain`. + + After the dependency-chain is checked, the default-retrievers are checked + in python's `mro` (method-resolution-order), checking its own class first + before checking any super-classes for default-retrievers. + + Returns: + A list of default-retrievers you can examine and/or modify as needed. + """ + return self._default_retrievers + def __getattr__(self, key: str) -> SettingsClassProperty: """ We will return a `ClassProperty` object setup to retrieve the value asked for as @@ -162,8 +215,17 @@ def __getattr__(self, key: str) -> SettingsClassProperty: >>> MySettings.grab().my_url_setting = "my-url" >>> assert SomeClass.some_attr == "my-url" """ - if key.startswith("_"): - return super().__getattr__(key) + + # Anything that starts with `_` or starts with `settings__` + # is handled like a normal pythonattribute. + if key.startswith("_") or key.startswith("settings__"): + raise AttributeError( + f"An attribute lookup that start with `_` or `settings__` just happened ({key}) " + f"and it does not exist. " + f"Attributes name this way can't be fields, but they should also exist so there " + f"must be some sort of bug.... details: " + f"attribute name ({key}) on BaseSettings subclass ({self})." + ) for c in self._setting_subclasses_in_mro: c: _SettingsMeta @@ -346,8 +408,11 @@ def __init__( obj = SomeSettings() obj.some_keyword_arg="hello" - ``` + Args: + retrievers: can be used to populate new instance's retrievers, + see `BaseSettings.settings__instance_retrievers`. + """ self._instance_retrievers = list(xloop(retrievers)) @@ -357,16 +422,41 @@ def __init__( def add_instance_retrievers( self, retrievers: 'Union[List[SettingsRetrieverProtocol], SettingsRetrieverProtocol]' ): + from warnings import warn + warn( + f"BaseSettings.add_instance_retrievers is now deprecated, " + f"was used on subclass ({type(self)}); " + f"use property `settings__instance_retrievers` and call 'append' on result; " + f"ie: `my_settings.settings__instance_retrievers.append(retriever)" + ) + self.settings__instance_retrievers.extend(xloop(retrievers)) + + @property + def settings__instance_retrievers(self) -> 'List[SettingsRetrieverProtocol]': """ You can add one or more retrievers to this `instance` of settings (won't modify default_retrievers for the entire class, only modifies this specific instance). - Directly set values are first checked for in self, and next in - `xinject.context.XContext.dependency_chain` + You can add or modify the list of instance-retrievers via + `BaseSettings.settings__instance_retrievers`. It's a list that you can directly modify; + ie: `my_settings.settings__instance_retrievers.append(my_retriever)`. + + ## Background + + Below is a quick summary, you can see more detailed information in main docs under the + `"How Setting Field Values Are Resolved"` heading. + + Directly set values (ie: `self.some_settings = 'some-value'`) + are first checked for in self, and next in `xinject.context.XContext.dependency_chain` (looking at each instance currently in the dependency-chain, see link for details). - If value can't be found the retrievers are next checked. + If value can't be found set on self or in dependency chain, + the retrievers are checked next. + + First the field's individual retriever is checked (directly on field object, + this includes any `@property` fields too as the property getter method is stored on + field's individual retriever). After the individual field retrievers are consulted, instance retrievers are checked next before finally checking the default-retrievers for the entire class. @@ -386,14 +476,15 @@ def add_instance_retrievers( in python's `mro` (method-resolution-order), checking its own class first before checking any super-classes for default-retrievers. - Args: - retrievers (Union[List[SettingsRetrieverProtocol], SettingsRetrieverProtocol]): The - retriever(s) to add to the instance. + Returns: + A list of instance-retrievers you can examine and/or modify as needed. """ - self._instance_retrievers.extend(xloop(retrievers)) + return self._instance_retrievers def __getattribute__(self, key): - if key.startswith("_"): + # Anything that starts with `_` or starts with `settings__` + # is handled like a normal python attribute. + if key.startswith("_") or key.startswith("settings__"): return object.__getattribute__(self, key) attr_error = None