Skip to content

Commit ed6a42f

Browse files
authored
feat!: add support for domains (#271)
* feat: add support for domains Signed-off-by: Federico Bond <[email protected]> * docs: update README.md Signed-off-by: Federico Bond <[email protected]> * feat: add clear_providers function to api Signed-off-by: Federico Bond <[email protected]> * feat: make _get_provider function private Signed-off-by: Federico Bond <[email protected]> * fix: shutdown all providers on api.shutdown Signed-off-by: Federico Bond <[email protected]> * refactor: move provider dict to a ProviderRegistry class Signed-off-by: Federico Bond <[email protected]> * feat: reset default provider on clear_providers and add tests Signed-off-by: Federico Bond <[email protected]> * docs: update README.md Signed-off-by: Federico Bond <[email protected]> --------- Signed-off-by: Federico Bond <[email protected]>
1 parent 0ec2b69 commit ed6a42f

File tree

7 files changed

+208
-56
lines changed

7 files changed

+208
-56
lines changed

Diff for: README.md

+24-5
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ print("Value: " + str(flag_value))
105105
|| [Targeting](#targeting) | Contextually-aware flag evaluation using [evaluation context](https://openfeature.dev/docs/reference/concepts/evaluation-context). |
106106
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
107107
|| [Logging](#logging) | Integrate with popular logging packages. |
108-
| | [Named clients](#named-clients) | Utilize multiple providers in a single application. |
108+
| | [Domains](#domains) | Logically bind clients with providers. |
109109
|| [Eventing](#eventing) | React to state changes in the provider or flag management system. |
110110
|| [Shutdown](#shutdown) | Gracefully clean up a provider during application shutdown. |
111111
|| [Extending](#extending) | Extend OpenFeature with custom providers and hooks. |
@@ -128,8 +128,8 @@ api.set_provider(NoOpProvider())
128128
open_feature_client = api.get_client()
129129
```
130130

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

134134
### Targeting
135135

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

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

192-
### Named clients
192+
### Domains
193193

194-
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).
194+
Clients can be assigned to a domain.
195+
A domain is a logical identifier which can be used to associate clients with a particular provider.
196+
If a domain has no associated provider, the global provider is used.
197+
198+
```python
199+
from openfeature import api
200+
201+
# Registering the default provider
202+
api.set_provider(MyProvider());
203+
# Registering a provider to a domain
204+
api.set_provider(MyProvider(), "my-domain");
205+
206+
# A client bound to the default provider
207+
default_client = api.get_client();
208+
# A client bound to the MyProvider provider
209+
domain_scoped_client = api.get_client("my-domain");
210+
```
211+
212+
Domains can be defined on a provider during registration.
213+
For more details, please refer to the [providers](#providers) section.
195214

196215
### Eventing
197216

Diff for: openfeature/api.py

+17-20
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,36 @@
66
from openfeature.hook import Hook
77
from openfeature.provider import FeatureProvider
88
from openfeature.provider.metadata import Metadata
9-
from openfeature.provider.no_op_provider import NoOpProvider
10-
11-
_provider: FeatureProvider = NoOpProvider()
9+
from openfeature.provider.registry import ProviderRegistry
1210

1311
_evaluation_context = EvaluationContext()
1412

1513
_hooks: typing.List[Hook] = []
1614

15+
_provider_registry: ProviderRegistry = ProviderRegistry()
16+
1717

1818
def get_client(
19-
name: typing.Optional[str] = None, version: typing.Optional[str] = None
19+
domain: typing.Optional[str] = None, version: typing.Optional[str] = None
2020
) -> OpenFeatureClient:
21-
return OpenFeatureClient(name=name, version=version, provider=_provider)
21+
return OpenFeatureClient(domain=domain, version=version)
2222

2323

24-
def set_provider(provider: FeatureProvider) -> None:
25-
global _provider
26-
if provider is None:
27-
raise GeneralError(error_message="No provider")
28-
if _provider:
29-
_provider.shutdown()
30-
_provider = provider
31-
provider.initialize(_evaluation_context)
24+
def set_provider(
25+
provider: FeatureProvider, domain: typing.Optional[str] = None
26+
) -> None:
27+
if domain is None:
28+
_provider_registry.set_default_provider(provider)
29+
else:
30+
_provider_registry.set_provider(domain, provider)
3231

3332

34-
def get_provider() -> FeatureProvider:
35-
global _provider
36-
return _provider
33+
def clear_providers() -> None:
34+
return _provider_registry.clear_providers()
3735

3836

39-
def get_provider_metadata() -> Metadata:
40-
global _provider
41-
return _provider.get_metadata()
37+
def get_provider_metadata(domain: typing.Optional[str] = None) -> Metadata:
38+
return _provider_registry.get_provider(domain).get_metadata()
4239

4340

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

7067

7168
def shutdown() -> None:
72-
_provider.shutdown()
69+
_provider_registry.shutdown()

Diff for: openfeature/client.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -60,26 +60,29 @@
6060

6161
@dataclass
6262
class ClientMetadata:
63-
name: typing.Optional[str]
63+
name: typing.Optional[str] = None
64+
domain: typing.Optional[str] = None
6465

6566

6667
class OpenFeatureClient:
6768
def __init__(
6869
self,
69-
name: typing.Optional[str],
70+
domain: typing.Optional[str],
7071
version: typing.Optional[str],
71-
provider: FeatureProvider,
7272
context: typing.Optional[EvaluationContext] = None,
7373
hooks: typing.Optional[typing.List[Hook]] = None,
7474
) -> None:
75-
self.name = name
75+
self.domain = domain
7676
self.version = version
7777
self.context = context or EvaluationContext()
7878
self.hooks = hooks or []
79-
self.provider = provider
79+
80+
@property
81+
def provider(self) -> FeatureProvider:
82+
return api._provider_registry.get_provider(self.domain)
8083

8184
def get_metadata(self) -> ClientMetadata:
82-
return ClientMetadata(name=self.name)
85+
return ClientMetadata(domain=self.domain)
8386

8487
def add_hooks(self, hooks: typing.List[Hook]) -> None:
8588
self.hooks = self.hooks + hooks

Diff for: openfeature/provider/registry.py

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import typing
2+
3+
from openfeature.evaluation_context import EvaluationContext
4+
from openfeature.exception import GeneralError
5+
from openfeature.provider import FeatureProvider
6+
from openfeature.provider.no_op_provider import NoOpProvider
7+
8+
9+
class ProviderRegistry:
10+
_default_provider: FeatureProvider
11+
_providers: typing.Dict[str, FeatureProvider]
12+
13+
def __init__(self) -> None:
14+
self._default_provider = NoOpProvider()
15+
self._providers = {}
16+
17+
def set_provider(self, domain: str, provider: FeatureProvider) -> None:
18+
if provider is None:
19+
raise GeneralError(error_message="No provider")
20+
providers = self._providers
21+
if domain in providers:
22+
old_provider = providers[domain]
23+
del providers[domain]
24+
if old_provider not in providers.values():
25+
old_provider.shutdown()
26+
if provider not in providers.values():
27+
provider.initialize(self._get_evaluation_context())
28+
providers[domain] = provider
29+
30+
def get_provider(self, domain: typing.Optional[str]) -> FeatureProvider:
31+
if domain is None:
32+
return self._default_provider
33+
return self._providers.get(domain, self._default_provider)
34+
35+
def set_default_provider(self, provider: FeatureProvider) -> None:
36+
if provider is None:
37+
raise GeneralError(error_message="No provider")
38+
if self._default_provider:
39+
self._default_provider.shutdown()
40+
self._default_provider = provider
41+
provider.initialize(self._get_evaluation_context())
42+
43+
def get_default_provider(self) -> FeatureProvider:
44+
return self._default_provider
45+
46+
def clear_providers(self) -> None:
47+
self.shutdown()
48+
self._providers.clear()
49+
self._default_provider = NoOpProvider()
50+
51+
def shutdown(self) -> None:
52+
for provider in {self._default_provider, *self._providers.values()}:
53+
provider.shutdown()
54+
55+
def _get_evaluation_context(self) -> EvaluationContext:
56+
# imported here to avoid circular imports
57+
from openfeature.api import get_evaluation_context
58+
59+
return get_evaluation_context()

Diff for: tests/features/steps/steps.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@ def step_impl(context, flag_type, key, expected_reason):
2727
@given("a provider is registered with cache disabled")
2828
def step_impl(context):
2929
set_provider(InMemoryProvider(IN_MEMORY_FLAGS))
30-
context.client = get_client(name="Default Provider", version="1.0")
30+
context.client = get_client()
3131

3232

3333
@when(
3434
'a {flag_type} flag with key "{key}" is evaluated with details and default value '
3535
'"{default_value}"'
3636
)
3737
def step_impl(context, flag_type, key, default_value):
38-
context.client = get_client(name="Default Provider", version="1.0")
38+
context.client = get_client()
3939
if flag_type == "boolean":
4040
context.boolean_flag_details = context.client.get_boolean_details(
4141
key, default_value

Diff for: tests/test_api.py

+91-19
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
from openfeature.api import (
66
add_hooks,
77
clear_hooks,
8+
clear_providers,
89
get_client,
910
get_evaluation_context,
1011
get_hooks,
11-
get_provider,
1212
get_provider_metadata,
1313
set_evaluation_context,
1414
set_provider,
@@ -26,11 +26,9 @@ def test_should_not_raise_exception_with_noop_client():
2626
# Given
2727
# No provider has been set
2828
# When
29-
client = get_client(name="Default Provider", version="1.0")
29+
client = get_client()
3030

3131
# Then
32-
assert client.name == "Default Provider"
33-
assert client.version == "1.0"
3432
assert isinstance(client.provider, NoOpProvider)
3533

3634

@@ -39,11 +37,9 @@ def test_should_return_open_feature_client_when_configured_correctly():
3937
set_provider(NoOpProvider())
4038

4139
# When
42-
client = get_client(name="No-op Provider", version="1.0")
40+
client = get_client()
4341

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

4945

@@ -84,18 +80,6 @@ def test_should_invoke_provider_shutdown_function_once_provider_is_no_longer_in_
8480
assert provider_1.shutdown.called
8581

8682

87-
def test_should_return_a_provider_if_setup_correctly():
88-
# Given
89-
set_provider(NoOpProvider())
90-
91-
# When
92-
provider = get_provider()
93-
94-
# Then
95-
assert provider
96-
assert isinstance(provider, NoOpProvider)
97-
98-
9983
def test_should_retrieve_metadata_for_configured_provider():
10084
# Given
10185
set_provider(NoOpProvider())
@@ -156,3 +140,91 @@ def test_should_call_provider_shutdown_on_api_shutdown():
156140

157141
# Then
158142
assert provider.shutdown.called
143+
144+
145+
def test_should_provide_a_function_to_bind_provider_through_domain():
146+
# Given
147+
provider = MagicMock(spec=FeatureProvider)
148+
test_client = get_client("test")
149+
default_client = get_client()
150+
151+
# When
152+
set_provider(provider, domain="test")
153+
154+
# Then
155+
assert default_client.provider != provider
156+
assert default_client.domain is None
157+
158+
assert test_client.provider == provider
159+
assert test_client.domain == "test"
160+
161+
162+
def test_should_not_initialize_provider_already_bound_to_another_domain():
163+
# Given
164+
provider = MagicMock(spec=FeatureProvider)
165+
set_provider(provider, "foo")
166+
167+
# When
168+
set_provider(provider, "bar")
169+
170+
# Then
171+
provider.initialize.assert_called_once()
172+
173+
174+
def test_should_shutdown_unbound_provider():
175+
# Given
176+
provider = MagicMock(spec=FeatureProvider)
177+
set_provider(provider, "foo")
178+
179+
# When
180+
other_provider = MagicMock(spec=FeatureProvider)
181+
set_provider(other_provider, "foo")
182+
183+
provider.shutdown.assert_called_once()
184+
185+
186+
def test_should_not_shutdown_provider_bound_to_another_domain():
187+
# Given
188+
provider = MagicMock(spec=FeatureProvider)
189+
set_provider(provider, "foo")
190+
set_provider(provider, "bar")
191+
192+
# When
193+
other_provider = MagicMock(spec=FeatureProvider)
194+
set_provider(other_provider, "foo")
195+
196+
provider.shutdown.assert_not_called()
197+
198+
199+
def test_shutdown_should_shutdown_every_registered_provider_once():
200+
# Given
201+
provider_1 = MagicMock(spec=FeatureProvider)
202+
provider_2 = MagicMock(spec=FeatureProvider)
203+
set_provider(provider_1)
204+
set_provider(provider_1, "foo")
205+
set_provider(provider_2, "bar")
206+
set_provider(provider_2, "baz")
207+
208+
# When
209+
shutdown()
210+
211+
# Then
212+
provider_1.shutdown.assert_called_once()
213+
provider_2.shutdown.assert_called_once()
214+
215+
216+
def test_clear_providers_shutdowns_every_provider_and_resets_default_provider():
217+
# Given
218+
provider_1 = MagicMock(spec=FeatureProvider)
219+
provider_2 = MagicMock(spec=FeatureProvider)
220+
set_provider(provider_1)
221+
set_provider(provider_2, "foo")
222+
set_provider(provider_2, "bar")
223+
224+
# When
225+
clear_providers()
226+
227+
# Then
228+
provider_1.shutdown.assert_called_once()
229+
provider_2.shutdown.assert_called_once()
230+
assert isinstance(get_client().provider, NoOpProvider)

0 commit comments

Comments
 (0)