From 4feb437b3ca34864501c1216da0ecfe7dda3ce7f Mon Sep 17 00:00:00 2001 From: Seba Date: Wed, 4 May 2022 11:05:31 -0300 Subject: [PATCH 1/2] Add implementation for a selector provider --- pyrovider/services/provider.py | 76 +++++++++- pyrovider/services/tests/mocks.py | 18 +++ .../services/tests/test_selector_provider.py | 138 ++++++++++++++++++ 3 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 pyrovider/services/tests/mocks.py create mode 100644 pyrovider/services/tests/test_selector_provider.py diff --git a/pyrovider/services/provider.py b/pyrovider/services/provider.py index c78d284..96ddcf0 100644 --- a/pyrovider/services/provider.py +++ b/pyrovider/services/provider.py @@ -1,8 +1,9 @@ import os from ast import literal_eval -from typing import List, Dict, Tuple +from typing import List, Dict, Tuple, Callable, Any from collections import defaultdict +from click import MissingParameter from dotenv import find_dotenv, load_dotenv from pyrovider.meta.ioc import Importer @@ -42,6 +43,10 @@ class NotAServiceFactoryError(ServiceProviderError): pass +class NotACallableSelectorError(ServiceProviderError): + pass + + class BadConfPathError(ServiceProviderError): pass @@ -66,6 +71,7 @@ def get_services_and_namespaces(services_names: List[str], provider, parent_name namespace_map[namespace].append(service_name) else: services.append(key) + for namespace, namespace_service_names in namespace_map.items(): namespaces[namespace] = Namespace( namespace, namespace_service_names, provider, parent=parent_namespace @@ -115,6 +121,56 @@ def service_names(self): return self._service_names + +class ServiceSelector: + + """ + The ServiceSelector returns a service name based on the outcome of calling the selector with a given key. The selector callable should return an option str/bool which is expected to be mapped to a service + + The service is configured by specifying + - default: a default service + - key: used by the selector to return an option + - is_bool: Whether it is a bool locator or not. Bool locators dont have multiple options, just on/off (True/False) + - **kwargs: a map of options to services. + + The selector will return a option for a given key. If that option was defined in the kwargs, that service will be used. If there's no match, the default is returned. + + Possible usages: + _- instantiating services based on feature flags (the key is the flag, the callable returns the flag's value depending on context) + - instantiating services based on env vars + - instantiating services based on a condition encapsulated in a callable + """ + + def __init__(self, name: str, selector: Callable, key: str, default: str, is_bool: bool = False, **kwargs: Any): + self.name = name + self.selector = selector + self.key = key + self.default = default + self.options = dict(kwargs) + self.is_bool = is_bool + + if not default: + raise MissingParameter(f"Selector service {self.name} requires a default service") + + if not kwargs: + raise MissingParameter(f"Selector service {self.name} requires a service options to be defined") + + if is_bool: + self.options["off"] = kwargs.get("off") or default + + if not kwargs.get("on"): + raise MissingParameter(f"Selector service {self.name} is a bool selector and requires a value for the 'on' parameter") + + def __call__(self, *args, **kwargs: Any) -> Any: + option = self.selector(self.key) + + if self.is_bool: + option = "on" if option is True else "off" + + service = self.options.get(option) or self.default + return service + + class ServiceProvider: name = None @@ -126,12 +182,14 @@ class ServiceProvider: 'a factory for the service "{}", none was found.' NOT_A_SERVICE_FACTORY_ERRMSG = 'The factory class for the service ' \ '"{}" does not have a "build" method.' + NOT_A_CALLABLE_SELECTOR_ERRMSG = 'The selector provider "{}" requires a callable selector' BAD_CONF_PATH_ERRMSG = 'The path "{}" was not found in the app configuration.' _service_meths = { 'instance': '_get_service_instance', 'class': '_instance_service_with_class', - 'factory': '_instance_service_with_factory' + 'factory': '_instance_service_with_factory', + 'selector': '_instance_service_with_selector', } @@ -144,6 +202,7 @@ def __init__(self, *providers, name: str = None): self.service_instances = {} self.service_classes = {} self.factory_classes = {} + self.selectors = {} self._namespaces = {} self._service_names = [] self._local = Local() @@ -154,6 +213,7 @@ def _init_local(self): self._local.service_instances = {} self._local.service_classes = {} self._local.factory_classes = {} + self._local.selectors = {} def reset(self): release_local(self._local) @@ -285,6 +345,18 @@ def _instance_service_with_factory(self, name: str, **kwargs): return self._local.factory_classes[name](*self._get_args(name), **self._get_kwargs(name, **kwargs)).build() + def _instance_service_with_selector(self, name: str, **kwargs): + if name not in self._local.factory_classes: + selector_callable = self.importer.get_obj(self.service_conf[name]['selector']) + + if not callable(selector_callable): + raise NotACallableSelectorError(self.NOT_A_CALLABLE_SELECTOR_ERRMSG.format(name)) + + self._local.selectors[name] = ServiceSelector(name, selector_callable, **self._get_kwargs(name, **kwargs)) + + service_name = self._local.selectors[name]() + return self.get(service_name) + def _get_args(self, name: str): if 'arguments' in self.service_conf[name]: return [self._get_arg(ref) for ref in self.service_conf[name]['arguments']] diff --git a/pyrovider/services/tests/mocks.py b/pyrovider/services/tests/mocks.py new file mode 100644 index 0000000..fc0ad9b --- /dev/null +++ b/pyrovider/services/tests/mocks.py @@ -0,0 +1,18 @@ +from unittest.mock import MagicMock + + + +def selector(key: str): + return key + + +class ServiceA(MagicMock): + pass + + +class ServiceB(MagicMock): + pass + + +class ServiceC(MagicMock): + pass \ No newline at end of file diff --git a/pyrovider/services/tests/test_selector_provider.py b/pyrovider/services/tests/test_selector_provider.py new file mode 100644 index 0000000..f521f4b --- /dev/null +++ b/pyrovider/services/tests/test_selector_provider.py @@ -0,0 +1,138 @@ +import pytest + +from pyrovider.services.provider import ServiceProvider, MissingParameter, ServiceSelector + + +@pytest.mark.parametrize( + "selector, is_bool, default, key, options, expected_service", + [ + ( + lambda k: "v1", + False, + "default-service", + "xxx", + dict(v1="service-1", v2="service-2"), + "service-1", + ), + ( + lambda k: "zzzz", + False, + "default-service", + "xxx", + dict(v1="service-1", v2="service-2"), + "default-service", + ), + ( + lambda k: True, + True, + "default-service", + "xxx", + dict(on="service-1", off="service-2"), + "service-1", + ), + ( + lambda k: False, + True, + "default-service", + "xxx", + dict(on="service-1", off="service-2"), + "service-2", + ), + ( + lambda k: None, + True, + "default-service", + "xxx", + dict(on="service-1"), + "default-service", + ) + ], + ids=[ + "selection-value-matches-options", + "selection-value-doesnt-match-options-uses-default", + "selection-is-bool-matches-on-flag", + "selection-is-bool-matches-off-flag", + "selection-is-bool-not-match-uses-default", + ], +) +def test_get_service(selector, is_bool, default, key, options, expected_service): + selector_service = ServiceSelector("test", selector=selector, key=key, default=default, is_bool=is_bool, **options) + service_name = selector_service() + assert service_name == expected_service + + +@pytest.mark.parametrize( + "selector, is_bool, default, key, options, expected_error", + [ + ( + lambda k: "v1", + False, + None, + "xxx", + dict(v1="service-1", v2="service-2"), + "requires a default service", + ), + ( + lambda k: "zzzz", + False, + "default-service", + "xxx", + dict(), + "requires a service options to be defined", + ), + ( + lambda k: True, + True, + "default-service", + "xxx", + dict(off="service-2"), + "requires a value for the 'on' parameter", + ), + ], + ids=[ + "missing-default-service", + "missing-options", + "missing-on-service-for-bool-selector", + ], +) +def test_bad_config(selector, is_bool, default, key, options, expected_error): + with pytest.raises(MissingParameter) as e: + selector_service = ServiceSelector("test", selector=selector, key=key, default=default, is_bool=is_bool, **options) + assert expected_error in str(e) + + +@pytest.mark.parametrize( + "conf, expected_service", + [ + ( + { + "test.version-1": { + "class": "pyrovider.services.tests.mocks.ServiceA" + }, + "test.version-2": { + "class": "pyrovider.services.tests.mocks.ServiceB" + }, + "test.version-3": { + "class": "pyrovider.services.tests.mocks.ServiceC" + }, + "test": { + "selector": "pyrovider.services.tests.mocks.selector", + "named_arguments": { + "default": "test.version-1", + "key": "v1", + "v1": "test.version-1", + "v2": "test.version-2", + "v3": "test.version-3", + } + } + }, + "test.version-1", + ), + ], +) +def test_getting_available_namespaces(conf: dict, expected_service): + provider = ServiceProvider() + provider.conf(conf) + + assert provider.get(expected_service).__class__.__name__ == provider.get("test").__class__.__name__ + From 1bacff58b7104a1302e814a3e05bc24ac61c1881 Mon Sep 17 00:00:00 2001 From: Seba Date: Wed, 4 May 2022 15:04:09 -0300 Subject: [PATCH 2/2] Allow selector to be a reference to a module or a service --- pyrovider/services/provider.py | 15 ++++--- .../services/tests/test_selector_provider.py | 41 ++++++++++++++++--- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/pyrovider/services/provider.py b/pyrovider/services/provider.py index 96ddcf0..4d0ff8c 100644 --- a/pyrovider/services/provider.py +++ b/pyrovider/services/provider.py @@ -134,7 +134,6 @@ class ServiceSelector: - **kwargs: a map of options to services. The selector will return a option for a given key. If that option was defined in the kwargs, that service will be used. If there's no match, the default is returned. - Possible usages: _- instantiating services based on feature flags (the key is the flag, the callable returns the flag's value depending on context) - instantiating services based on env vars @@ -160,10 +159,10 @@ def __init__(self, name: str, selector: Callable, key: str, default: str, is_boo if not kwargs.get("on"): raise MissingParameter(f"Selector service {self.name} is a bool selector and requires a value for the 'on' parameter") - + def __call__(self, *args, **kwargs: Any) -> Any: option = self.selector(self.key) - + if self.is_bool: option = "on" if option is True else "off" @@ -347,15 +346,19 @@ def _instance_service_with_factory(self, name: str, **kwargs): def _instance_service_with_selector(self, name: str, **kwargs): if name not in self._local.factory_classes: - selector_callable = self.importer.get_obj(self.service_conf[name]['selector']) + selector_ref = self.service_conf[name]['selector'] + + if selector_ref.startswith("@"): + selector_callable = self.get(selector_ref[1:]) + else: + selector_callable = self.importer.get_obj(selector_ref) if not callable(selector_callable): raise NotACallableSelectorError(self.NOT_A_CALLABLE_SELECTOR_ERRMSG.format(name)) self._local.selectors[name] = ServiceSelector(name, selector_callable, **self._get_kwargs(name, **kwargs)) - service_name = self._local.selectors[name]() - return self.get(service_name) + return self._local.selectors[name]() def _get_args(self, name: str): if 'arguments' in self.service_conf[name]: diff --git a/pyrovider/services/tests/test_selector_provider.py b/pyrovider/services/tests/test_selector_provider.py index f521f4b..3b23dac 100644 --- a/pyrovider/services/tests/test_selector_provider.py +++ b/pyrovider/services/tests/test_selector_provider.py @@ -57,8 +57,8 @@ ) def test_get_service(selector, is_bool, default, key, options, expected_service): selector_service = ServiceSelector("test", selector=selector, key=key, default=default, is_bool=is_bool, **options) - service_name = selector_service() - assert service_name == expected_service + service = selector_service() + assert service == expected_service @pytest.mark.parametrize( @@ -120,17 +120,46 @@ def test_bad_config(selector, is_bool, default, key, options, expected_error): "named_arguments": { "default": "test.version-1", "key": "v1", - "v1": "test.version-1", - "v2": "test.version-2", - "v3": "test.version-3", + "v1": "@test.version-1", + "v2": "@test.version-2", + "v3": "@test.version-3", } } }, "test.version-1", ), + ( + { + "test.version-1": { + "class": "pyrovider.services.tests.mocks.ServiceA" + }, + "test.version-2": { + "class": "pyrovider.services.tests.mocks.ServiceB" + }, + "test.version-3": { + "class": "pyrovider.services.tests.mocks.ServiceC" + }, + "selector": { + "instance": "pyrovider.services.tests.mocks.selector" + }, + "test": { + "selector": "@selector", + "named_arguments": { + "default": "test.version-1", + "key": "v1", + "v1": "@test.version-1", + "v2": "@test.version-2", + "v3": "@test.version-3", + } + } + }, + "test.version-1", + ), + ], + ids=["selector-as-function-reference", "selector-as-service-reference"] ) -def test_getting_available_namespaces(conf: dict, expected_service): +def test_defining_a_selector_service(conf: dict, expected_service): provider = ServiceProvider() provider.conf(conf)