Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: add support for domains #271

Merged
merged 8 commits into from
Feb 20, 2024
Merged
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
29 changes: 24 additions & 5 deletions README.md
federicobond marked this conversation as resolved.
Show resolved Hide resolved
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
federicobond marked this conversation as resolved.
Show resolved Hide resolved
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
beeme1mr marked this conversation as resolved.
Show resolved Hide resolved
) -> 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],
federicobond marked this conversation as resolved.
Show resolved Hide resolved
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)
federicobond marked this conversation as resolved.
Show resolved Hide resolved

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")

Check warning on line 19 in openfeature/provider/registry.py

View check run for this annotation

Codecov / codecov/patch

openfeature/provider/registry.py#L19

Added line #L19 was not covered by tests
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

Check warning on line 44 in openfeature/provider/registry.py

View check run for this annotation

Codecov / codecov/patch

openfeature/provider/registry.py#L44

Added line #L44 was not covered by tests

def clear_providers(self) -> None:
federicobond marked this conversation as resolved.
Show resolved Hide resolved
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
Loading