Skip to content

Commit

Permalink
feat!: add support for domains (#271)
Browse files Browse the repository at this point in the history
* feat: add support for domains

Signed-off-by: Federico Bond <federicobond@gmail.com>

* docs: update README.md

Signed-off-by: Federico Bond <federicobond@gmail.com>

* feat: add clear_providers function to api

Signed-off-by: Federico Bond <federicobond@gmail.com>

* feat: make _get_provider function private

Signed-off-by: Federico Bond <federicobond@gmail.com>

* fix: shutdown all providers on api.shutdown

Signed-off-by: Federico Bond <federicobond@gmail.com>

* refactor: move provider dict to a ProviderRegistry class

Signed-off-by: Federico Bond <federicobond@gmail.com>

* feat: reset default provider on clear_providers and add tests

Signed-off-by: Federico Bond <federicobond@gmail.com>

* docs: update README.md

Signed-off-by: Federico Bond <federicobond@gmail.com>

---------

Signed-off-by: Federico Bond <federicobond@gmail.com>
  • Loading branch information
federicobond authored Feb 20, 2024
1 parent 0ec2b69 commit ed6a42f
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 56 deletions.
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ print("Value: " + str(flag_value))
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
|| [Logging](#logging) | Integrate with popular logging packages. |
| | [Named clients](#named-clients) | Utilize multiple providers in a single application. |
| | [Domains](#domains) | Logically bind clients with providers. |
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
Expand All @@ -128,8 +128,8 @@ api.set_provider(NoOpProvider())
open_feature_client = api.get_client()
```

<!-- In some situations, it may be beneficial to register multiple providers in the same application.
This is possible using [named clients](#named-clients), which is covered in more detail below. -->
In some situations, it may be beneficial to register multiple providers in the same application.
This is possible using [domains](#domains), which is covered in more detail below.

### Targeting

Expand Down Expand Up @@ -189,9 +189,28 @@ client.get_boolean_flag("my-flag", False, flag_evaluation_options=options)

The OpenFeature SDK logs to the `openfeature` logger using the `logging` package from the Python Standard Library.

### Named clients
### Domains

Named clients are not yet available in the Python SDK. Progress on this feature can be tracked [here](https://github.com/open-feature/python-sdk/issues/125).
Clients can be assigned to a domain.
A domain is a logical identifier which can be used to associate clients with a particular provider.
If a domain has no associated provider, the global provider is used.

```python
from openfeature import api

# Registering the default provider
api.set_provider(MyProvider());
# Registering a provider to a domain
api.set_provider(MyProvider(), "my-domain");

# A client bound to the default provider
default_client = api.get_client();
# A client bound to the MyProvider provider
domain_scoped_client = api.get_client("my-domain");
```

Domains can be defined on a provider during registration.
For more details, please refer to the [providers](#providers) section.

### Eventing

Expand Down
37 changes: 17 additions & 20 deletions openfeature/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,36 @@
from openfeature.hook import Hook
from openfeature.provider import FeatureProvider
from openfeature.provider.metadata import Metadata
from openfeature.provider.no_op_provider import NoOpProvider

_provider: FeatureProvider = NoOpProvider()
from openfeature.provider.registry import ProviderRegistry

_evaluation_context = EvaluationContext()

_hooks: typing.List[Hook] = []

_provider_registry: ProviderRegistry = ProviderRegistry()


def get_client(
name: typing.Optional[str] = None, version: typing.Optional[str] = None
domain: typing.Optional[str] = None, version: typing.Optional[str] = None
) -> OpenFeatureClient:
return OpenFeatureClient(name=name, version=version, provider=_provider)
return OpenFeatureClient(domain=domain, version=version)


def set_provider(provider: FeatureProvider) -> None:
global _provider
if provider is None:
raise GeneralError(error_message="No provider")
if _provider:
_provider.shutdown()
_provider = provider
provider.initialize(_evaluation_context)
def set_provider(
provider: FeatureProvider, domain: typing.Optional[str] = None
) -> None:
if domain is None:
_provider_registry.set_default_provider(provider)
else:
_provider_registry.set_provider(domain, provider)


def get_provider() -> FeatureProvider:
global _provider
return _provider
def clear_providers() -> None:
return _provider_registry.clear_providers()


def get_provider_metadata() -> Metadata:
global _provider
return _provider.get_metadata()
def get_provider_metadata(domain: typing.Optional[str] = None) -> Metadata:
return _provider_registry.get_provider(domain).get_metadata()


def get_evaluation_context() -> EvaluationContext:
Expand Down Expand Up @@ -69,4 +66,4 @@ def get_hooks() -> typing.List[Hook]:


def shutdown() -> None:
_provider.shutdown()
_provider_registry.shutdown()
15 changes: 9 additions & 6 deletions openfeature/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,26 +60,29 @@

@dataclass
class ClientMetadata:
name: typing.Optional[str]
name: typing.Optional[str] = None
domain: typing.Optional[str] = None


class OpenFeatureClient:
def __init__(
self,
name: typing.Optional[str],
domain: typing.Optional[str],
version: typing.Optional[str],
provider: FeatureProvider,
context: typing.Optional[EvaluationContext] = None,
hooks: typing.Optional[typing.List[Hook]] = None,
) -> None:
self.name = name
self.domain = domain
self.version = version
self.context = context or EvaluationContext()
self.hooks = hooks or []
self.provider = provider

@property
def provider(self) -> FeatureProvider:
return api._provider_registry.get_provider(self.domain)

def get_metadata(self) -> ClientMetadata:
return ClientMetadata(name=self.name)
return ClientMetadata(domain=self.domain)

def add_hooks(self, hooks: typing.List[Hook]) -> None:
self.hooks = self.hooks + hooks
Expand Down
59 changes: 59 additions & 0 deletions openfeature/provider/registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import typing

from openfeature.evaluation_context import EvaluationContext
from openfeature.exception import GeneralError
from openfeature.provider import FeatureProvider
from openfeature.provider.no_op_provider import NoOpProvider


class ProviderRegistry:
_default_provider: FeatureProvider
_providers: typing.Dict[str, FeatureProvider]

def __init__(self) -> None:
self._default_provider = NoOpProvider()
self._providers = {}

def set_provider(self, domain: str, provider: FeatureProvider) -> None:
if provider is None:
raise GeneralError(error_message="No provider")
providers = self._providers
if domain in providers:
old_provider = providers[domain]
del providers[domain]
if old_provider not in providers.values():
old_provider.shutdown()
if provider not in providers.values():
provider.initialize(self._get_evaluation_context())
providers[domain] = provider

def get_provider(self, domain: typing.Optional[str]) -> FeatureProvider:
if domain is None:
return self._default_provider
return self._providers.get(domain, self._default_provider)

def set_default_provider(self, provider: FeatureProvider) -> None:
if provider is None:
raise GeneralError(error_message="No provider")
if self._default_provider:
self._default_provider.shutdown()
self._default_provider = provider
provider.initialize(self._get_evaluation_context())

def get_default_provider(self) -> FeatureProvider:
return self._default_provider

def clear_providers(self) -> None:
self.shutdown()
self._providers.clear()
self._default_provider = NoOpProvider()

def shutdown(self) -> None:
for provider in {self._default_provider, *self._providers.values()}:
provider.shutdown()

def _get_evaluation_context(self) -> EvaluationContext:
# imported here to avoid circular imports
from openfeature.api import get_evaluation_context

return get_evaluation_context()
4 changes: 2 additions & 2 deletions tests/features/steps/steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ def step_impl(context, flag_type, key, expected_reason):
@given("a provider is registered with cache disabled")
def step_impl(context):
set_provider(InMemoryProvider(IN_MEMORY_FLAGS))
context.client = get_client(name="Default Provider", version="1.0")
context.client = get_client()


@when(
'a {flag_type} flag with key "{key}" is evaluated with details and default value '
'"{default_value}"'
)
def step_impl(context, flag_type, key, default_value):
context.client = get_client(name="Default Provider", version="1.0")
context.client = get_client()
if flag_type == "boolean":
context.boolean_flag_details = context.client.get_boolean_details(
key, default_value
Expand Down
110 changes: 91 additions & 19 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from openfeature.api import (
add_hooks,
clear_hooks,
clear_providers,
get_client,
get_evaluation_context,
get_hooks,
get_provider,
get_provider_metadata,
set_evaluation_context,
set_provider,
Expand All @@ -26,11 +26,9 @@ def test_should_not_raise_exception_with_noop_client():
# Given
# No provider has been set
# When
client = get_client(name="Default Provider", version="1.0")
client = get_client()

# Then
assert client.name == "Default Provider"
assert client.version == "1.0"
assert isinstance(client.provider, NoOpProvider)


Expand All @@ -39,11 +37,9 @@ def test_should_return_open_feature_client_when_configured_correctly():
set_provider(NoOpProvider())

# When
client = get_client(name="No-op Provider", version="1.0")
client = get_client()

# Then
assert client.name == "No-op Provider"
assert client.version == "1.0"
assert isinstance(client.provider, NoOpProvider)


Expand Down Expand Up @@ -84,18 +80,6 @@ def test_should_invoke_provider_shutdown_function_once_provider_is_no_longer_in_
assert provider_1.shutdown.called


def test_should_return_a_provider_if_setup_correctly():
# Given
set_provider(NoOpProvider())

# When
provider = get_provider()

# Then
assert provider
assert isinstance(provider, NoOpProvider)


def test_should_retrieve_metadata_for_configured_provider():
# Given
set_provider(NoOpProvider())
Expand Down Expand Up @@ -156,3 +140,91 @@ def test_should_call_provider_shutdown_on_api_shutdown():

# Then
assert provider.shutdown.called


def test_should_provide_a_function_to_bind_provider_through_domain():
# Given
provider = MagicMock(spec=FeatureProvider)
test_client = get_client("test")
default_client = get_client()

# When
set_provider(provider, domain="test")

# Then
assert default_client.provider != provider
assert default_client.domain is None

assert test_client.provider == provider
assert test_client.domain == "test"


def test_should_not_initialize_provider_already_bound_to_another_domain():
# Given
provider = MagicMock(spec=FeatureProvider)
set_provider(provider, "foo")

# When
set_provider(provider, "bar")

# Then
provider.initialize.assert_called_once()


def test_should_shutdown_unbound_provider():
# Given
provider = MagicMock(spec=FeatureProvider)
set_provider(provider, "foo")

# When
other_provider = MagicMock(spec=FeatureProvider)
set_provider(other_provider, "foo")

provider.shutdown.assert_called_once()


def test_should_not_shutdown_provider_bound_to_another_domain():
# Given
provider = MagicMock(spec=FeatureProvider)
set_provider(provider, "foo")
set_provider(provider, "bar")

# When
other_provider = MagicMock(spec=FeatureProvider)
set_provider(other_provider, "foo")

provider.shutdown.assert_not_called()


def test_shutdown_should_shutdown_every_registered_provider_once():
# Given
provider_1 = MagicMock(spec=FeatureProvider)
provider_2 = MagicMock(spec=FeatureProvider)
set_provider(provider_1)
set_provider(provider_1, "foo")
set_provider(provider_2, "bar")
set_provider(provider_2, "baz")

# When
shutdown()

# Then
provider_1.shutdown.assert_called_once()
provider_2.shutdown.assert_called_once()


def test_clear_providers_shutdowns_every_provider_and_resets_default_provider():
# Given
provider_1 = MagicMock(spec=FeatureProvider)
provider_2 = MagicMock(spec=FeatureProvider)
set_provider(provider_1)
set_provider(provider_2, "foo")
set_provider(provider_2, "bar")

# When
clear_providers()

# Then
provider_1.shutdown.assert_called_once()
provider_2.shutdown.assert_called_once()
assert isinstance(get_client().provider, NoOpProvider)
Loading

0 comments on commit ed6a42f

Please sign in to comment.