Skip to content

Commit 641102d

Browse files
committed
feat: implement provider events
Signed-off-by: Federico Bond <[email protected]>
1 parent ed6a42f commit 641102d

File tree

7 files changed

+236
-5
lines changed

7 files changed

+236
-5
lines changed

README.md

+21-2
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ print("Value: " + str(flag_value))
106106
|| [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. |
107107
|| [Logging](#logging) | Integrate with popular logging packages. |
108108
|| [Domains](#domains) | Logically bind clients with providers. |
109-
| | [Eventing](#eventing) | React to state changes in the provider or flag management system. |
109+
| | [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. |
112112

@@ -214,7 +214,26 @@ For more details, please refer to the [providers](#providers) section.
214214

215215
### Eventing
216216

217-
Events 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).
217+
Events allow you to react to state changes in the provider or underlying flag management system, such as flag definition changes, provider readiness, or error conditions. Initialization events (PROVIDER_READY on success, PROVIDER_ERROR on failure) are dispatched for every provider. Some providers support additional events, such as PROVIDER_CONFIGURATION_CHANGED.
218+
219+
Please refer to the documentation of the provider you're using to see what events are supported.
220+
221+
```python
222+
from openfeature import api
223+
from openfeature.provider import ProviderEvent
224+
225+
def on_provider_ready(event_details: EventDetails):
226+
print(f"Provider {event_details.provider_name} is ready")
227+
228+
api.on(ProviderEvent.PROVIDER_READY, on_provider_ready)
229+
230+
client = api.get_client()
231+
232+
def on_provider_ready(event_details: EventDetails):
233+
print(f"Provider {event_details.provider_name} is ready")
234+
235+
client.on(ProviderEvent.PROVIDER_READY, on_provider_ready)
236+
```
218237

219238
### Shutdown
220239

openfeature/api.py

+41
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,32 @@
22

33
from openfeature.client import OpenFeatureClient
44
from openfeature.evaluation_context import EvaluationContext
5+
from openfeature.event import (
6+
EventDetails,
7+
EventHandler,
8+
EventSupport,
9+
ProviderEvent,
10+
ProviderEventDetails,
11+
)
512
from openfeature.exception import GeneralError
613
from openfeature.hook import Hook
714
from openfeature.provider import FeatureProvider
815
from openfeature.provider.metadata import Metadata
16+
from openfeature.provider.no_op_provider import NoOpProvider
917
from openfeature.provider.registry import ProviderRegistry
1018

19+
_provider: FeatureProvider = NoOpProvider()
20+
1121
_evaluation_context = EvaluationContext()
1222

1323
_hooks: typing.List[Hook] = []
1424

1525
_provider_registry: ProviderRegistry = ProviderRegistry()
1626

27+
_event_support: EventSupport = EventSupport()
28+
29+
_clients_with_handlers: typing.Set[OpenFeatureClient] = set()
30+
1731

1832
def get_client(
1933
domain: typing.Optional[str] = None, version: typing.Optional[str] = None
@@ -67,3 +81,30 @@ def get_hooks() -> typing.List[Hook]:
6781

6882
def shutdown() -> None:
6983
_provider_registry.shutdown()
84+
85+
86+
def on(event: ProviderEvent, handler: EventHandler) -> None:
87+
_event_support.add_global_handler(event, handler)
88+
89+
90+
def _register_client_handler(
91+
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
92+
) -> None:
93+
_clients_with_handlers.add(client)
94+
_event_support.add_client_handler(client, event, handler)
95+
96+
97+
def _run_handlers_for_provider(
98+
provider: FeatureProvider,
99+
event: ProviderEvent,
100+
provider_details: ProviderEventDetails,
101+
) -> None:
102+
details = EventDetails.from_provider_event_details(
103+
provider.get_metadata().name, provider_details
104+
)
105+
# run the global handlers
106+
_event_support.run_global_handlers(event, details)
107+
# run the handlers for clients associated to this provider
108+
for client in _clients_with_handlers:
109+
if client.provider == provider:
110+
_event_support.run_client_handlers(client, event, details)

openfeature/client.py

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from openfeature import api
66
from openfeature.evaluation_context import EvaluationContext
7+
from openfeature.event import EventHandler, ProviderEvent
78
from openfeature.exception import (
89
ErrorCode,
910
GeneralError,
@@ -403,6 +404,9 @@ def _create_provider_evaluation(
403404
error_message=resolution.error_message,
404405
)
405406

407+
def on(self, event: ProviderEvent, handler: EventHandler) -> None:
408+
api._register_client_handler(self, event, handler)
409+
406410

407411
def _typecheck_flag_value(value: typing.Any, flag_type: FlagType) -> None:
408412
type_map: TypeMap = {

openfeature/event.py

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from collections import defaultdict
2+
from dataclasses import dataclass, field
3+
from enum import Enum
4+
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
5+
6+
if TYPE_CHECKING:
7+
from openfeature.client import OpenFeatureClient
8+
9+
10+
class ProviderEvent(Enum):
11+
PROVIDER_READY = "PROVIDER_READY"
12+
PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED"
13+
PROVIDER_ERROR = "PROVIDER_ERROR"
14+
PROVIDER_STALE = "PROVIDER_STALE"
15+
16+
17+
@dataclass
18+
class ProviderEventDetails:
19+
flags_changed: Optional[List[str]] = None
20+
message: Optional[str] = None
21+
metadata: Dict[str, bool | str | int | float] = field(default_factory=dict)
22+
23+
24+
@dataclass
25+
class EventDetails(ProviderEventDetails):
26+
provider_name: str = ""
27+
flags_changed: Optional[List[str]] = None
28+
message: Optional[str] = None
29+
metadata: Dict[str, bool | str | int | float] = field(default_factory=dict)
30+
31+
@classmethod
32+
def from_provider_event_details(
33+
cls, provider_name: str, details: ProviderEventDetails
34+
) -> "EventDetails":
35+
return cls(
36+
provider_name=provider_name,
37+
flags_changed=details.flags_changed,
38+
message=details.message,
39+
metadata=details.metadata,
40+
)
41+
42+
43+
EventHandler = Callable[[EventDetails], None]
44+
45+
46+
class EventSupport:
47+
_global_handlers: Dict[ProviderEvent, List[EventHandler]]
48+
_client_handlers: Dict["OpenFeatureClient", Dict[ProviderEvent, List[EventHandler]]]
49+
50+
def __init__(self) -> None:
51+
self._global_handlers = defaultdict(list)
52+
self._client_handlers = defaultdict(lambda: defaultdict(list))
53+
54+
def run_client_handlers(
55+
self, client: "OpenFeatureClient", event: ProviderEvent, details: EventDetails
56+
) -> None:
57+
for handler in self._client_handlers[client][event]:
58+
handler(details)
59+
60+
def run_global_handlers(self, event: ProviderEvent, details: EventDetails) -> None:
61+
for handler in self._global_handlers[event]:
62+
handler(details)
63+
64+
def add_client_handler(
65+
self, client: "OpenFeatureClient", event: ProviderEvent, handler: EventHandler
66+
) -> None:
67+
handlers = self._client_handlers[client][event]
68+
handlers.append(handler)
69+
70+
def remove_client_handler(
71+
self, client: "OpenFeatureClient", event: ProviderEvent, handler: EventHandler
72+
) -> None:
73+
handlers = self._client_handlers[client][event]
74+
handlers.remove(handler)
75+
76+
def add_global_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
77+
self._global_handlers[event].append(handler)
78+
79+
def remove_global_handler(
80+
self, event: ProviderEvent, handler: EventHandler
81+
) -> None:
82+
self._global_handlers[event].remove(handler)

openfeature/provider/provider.py

+20
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from abc import abstractmethod
33

44
from openfeature.evaluation_context import EvaluationContext
5+
from openfeature.event import ProviderEvent, ProviderEventDetails
56
from openfeature.flag_evaluation import FlagResolutionDetails
67
from openfeature.hook import Hook
78
from openfeature.provider import FeatureProvider
@@ -66,3 +67,22 @@ def resolve_object_details(
6667
evaluation_context: typing.Optional[EvaluationContext] = None,
6768
) -> FlagResolutionDetails[typing.Union[dict, list]]:
6869
pass
70+
71+
def emit_provider_ready(self, details: ProviderEventDetails) -> None:
72+
self.emit(ProviderEvent.PROVIDER_READY, details)
73+
74+
def emit_provider_configuration_changed(
75+
self, details: ProviderEventDetails
76+
) -> None:
77+
self.emit(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details)
78+
79+
def emit_provider_error(self, details: ProviderEventDetails) -> None:
80+
self.emit(ProviderEvent.PROVIDER_ERROR, details)
81+
82+
def emit_provider_stale(self, details: ProviderEventDetails) -> None:
83+
self.emit(ProviderEvent.PROVIDER_STALE, details)
84+
85+
def emit(self, event: ProviderEvent, details: ProviderEventDetails) -> None:
86+
from openfeature.api import _run_handlers_for_provider
87+
88+
_run_handlers_for_provider(self, event, details)

tests/test_api.py

+29-2
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@
1010
get_evaluation_context,
1111
get_hooks,
1212
get_provider_metadata,
13+
on,
1314
set_evaluation_context,
1415
set_provider,
1516
shutdown,
1617
)
1718
from openfeature.evaluation_context import EvaluationContext
19+
from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails
1820
from openfeature.exception import ErrorCode, GeneralError
1921
from openfeature.hook import Hook
20-
from openfeature.provider.metadata import Metadata
22+
from openfeature.provider import FeatureProvider, Metadata
2123
from openfeature.provider.no_op_provider import NoOpProvider
22-
from openfeature.provider.provider import FeatureProvider
2324

2425

2526
def test_should_not_raise_exception_with_noop_client():
@@ -228,3 +229,29 @@ def test_clear_providers_shutdowns_every_provider_and_resets_default_provider():
228229
provider_1.shutdown.assert_called_once()
229230
provider_2.shutdown.assert_called_once()
230231
assert isinstance(get_client().provider, NoOpProvider)
232+
233+
234+
def test_provider_events():
235+
spy = MagicMock()
236+
237+
on(ProviderEvent.PROVIDER_READY, spy.provider_ready)
238+
on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed)
239+
on(ProviderEvent.PROVIDER_ERROR, spy.provider_error)
240+
on(ProviderEvent.PROVIDER_STALE, spy.provider_stale)
241+
242+
provider = NoOpProvider()
243+
244+
provider_details = ProviderEventDetails(message="message")
245+
details = EventDetails.from_provider_event_details(
246+
provider.get_metadata().name, provider_details
247+
)
248+
249+
provider.emit_provider_ready(provider_details)
250+
provider.emit_provider_configuration_changed(provider_details)
251+
provider.emit_provider_error(provider_details)
252+
provider.emit_provider_stale(provider_details)
253+
254+
spy.provider_ready.assert_called_once_with(details)
255+
spy.provider_configuration_changed.assert_called_once_with(details)
256+
spy.provider_error.assert_called_once_with(details)
257+
spy.provider_stale.assert_called_once_with(details)

tests/test_client.py

+39-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
import pytest
44

5-
from openfeature.api import add_hooks, clear_hooks, set_provider
5+
from openfeature.api import add_hooks, clear_hooks, get_client, set_provider
66
from openfeature.client import OpenFeatureClient
7+
from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails
78
from openfeature.exception import ErrorCode, OpenFeatureError
89
from openfeature.flag_evaluation import Reason
910
from openfeature.hook import Hook
@@ -182,3 +183,40 @@ def test_should_call_api_level_hooks(no_op_provider_client):
182183
# Then
183184
api_hook.before.assert_called_once()
184185
api_hook.after.assert_called_once()
186+
187+
188+
def test_provider_events():
189+
provider = NoOpProvider()
190+
set_provider(provider)
191+
192+
other_provider = NoOpProvider()
193+
set_provider(other_provider, "my-domain")
194+
195+
provider_details = ProviderEventDetails(message="message")
196+
details = EventDetails.from_provider_event_details(
197+
provider.get_metadata().name, provider_details
198+
)
199+
200+
def emit_all_events(provider):
201+
provider.emit_provider_ready(provider_details)
202+
provider.emit_provider_configuration_changed(provider_details)
203+
provider.emit_provider_error(provider_details)
204+
provider.emit_provider_stale(provider_details)
205+
206+
spy = MagicMock()
207+
208+
client = get_client()
209+
client.on(ProviderEvent.PROVIDER_READY, spy.provider_ready)
210+
client.on(
211+
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
212+
)
213+
client.on(ProviderEvent.PROVIDER_ERROR, spy.provider_error)
214+
client.on(ProviderEvent.PROVIDER_STALE, spy.provider_stale)
215+
216+
emit_all_events(provider)
217+
emit_all_events(other_provider)
218+
219+
spy.provider_ready.assert_called_once_with(details)
220+
spy.provider_configuration_changed.assert_called_once_with(details)
221+
spy.provider_error.assert_called_once_with(details)
222+
spy.provider_stale.assert_called_once_with(details)

0 commit comments

Comments
 (0)