Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions pyrovider/services/provider.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -42,6 +43,10 @@ class NotAServiceFactoryError(ServiceProviderError):
pass


class NotACallableSelectorError(ServiceProviderError):
pass


class BadConfPathError(ServiceProviderError):

pass
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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',
}


Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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']]
Expand Down
18 changes: 18 additions & 0 deletions pyrovider/services/tests/mocks.py
Original file line number Diff line number Diff line change
@@ -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
167 changes: 167 additions & 0 deletions pyrovider/services/tests/test_selector_provider.py
Original file line number Diff line number Diff line change
@@ -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__