diff --git a/pyrovider/services/provider.py b/pyrovider/services/provider.py index c78d284..4d0ff8c 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,55 @@ 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 +181,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 +201,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 +212,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 +344,22 @@ 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_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)) + + return self._local.selectors[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..3b23dac --- /dev/null +++ b/pyrovider/services/tests/test_selector_provider.py @@ -0,0 +1,167 @@ +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 = selector_service() + assert service == 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", + ), + ( + { + "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_defining_a_selector_service(conf: dict, expected_service): + provider = ServiceProvider() + provider.conf(conf) + + assert provider.get(expected_service).__class__.__name__ == provider.get("test").__class__.__name__ +