Skip to content

Commit 12a93a6

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

File tree

7 files changed

+257
-5
lines changed

7 files changed

+257
-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.add_handler(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.add_handler(ProviderEvent.PROVIDER_READY, on_provider_ready)
236+
```
218237

219238
### Shutdown
220239

openfeature/api.py

+39
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,29 @@
22

33
from openfeature.client import OpenFeatureClient
44
from openfeature.evaluation_context import EvaluationContext
5+
from openfeature.event import (
6+
EventHandler,
7+
EventSupport,
8+
ProviderEvent,
9+
ProviderEventDetails,
10+
)
511
from openfeature.exception import GeneralError
612
from openfeature.hook import Hook
713
from openfeature.provider import FeatureProvider
814
from openfeature.provider.metadata import Metadata
15+
from openfeature.provider.no_op_provider import NoOpProvider
916
from openfeature.provider.registry import ProviderRegistry
1017

18+
_provider: FeatureProvider = NoOpProvider()
19+
1120
_evaluation_context = EvaluationContext()
1221

1322
_hooks: typing.List[Hook] = []
1423

1524
_provider_registry: ProviderRegistry = ProviderRegistry()
1625

26+
_event_support: EventSupport = EventSupport()
27+
1728

1829
def get_client(
1930
domain: typing.Optional[str] = None, version: typing.Optional[str] = None
@@ -67,3 +78,31 @@ def get_hooks() -> typing.List[Hook]:
6778

6879
def shutdown() -> None:
6980
_provider_registry.shutdown()
81+
82+
83+
def add_handler(event: ProviderEvent, handler: EventHandler) -> None:
84+
_event_support.add_global_handler(event, handler)
85+
86+
87+
def remove_handler(event: ProviderEvent, handler: EventHandler) -> None:
88+
_event_support.remove_global_handler(event, handler)
89+
90+
91+
def _add_client_handler(
92+
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
93+
) -> None:
94+
_event_support.add_client_handler(client, event, handler)
95+
96+
97+
def _remove_client_handler(
98+
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
99+
) -> None:
100+
_event_support.remove_client_handler(client, event, handler)
101+
102+
103+
def _run_handlers_for_provider(
104+
provider: FeatureProvider,
105+
event: ProviderEvent,
106+
provider_details: ProviderEventDetails,
107+
) -> None:
108+
_event_support.run_handlers_for_provider(provider, event, provider_details)

openfeature/client.py

+7
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,12 @@ def _create_provider_evaluation(
403404
error_message=resolution.error_message,
404405
)
405406

407+
def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
408+
api._add_client_handler(self, event, handler)
409+
410+
def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
411+
api._remove_client_handler(self, event, handler)
412+
406413

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

openfeature/event.py

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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, Union
5+
6+
from openfeature.provider import FeatureProvider
7+
8+
if TYPE_CHECKING:
9+
from openfeature.client import OpenFeatureClient
10+
11+
12+
class ProviderEvent(Enum):
13+
PROVIDER_READY = "PROVIDER_READY"
14+
PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED"
15+
PROVIDER_ERROR = "PROVIDER_ERROR"
16+
PROVIDER_STALE = "PROVIDER_STALE"
17+
18+
19+
@dataclass
20+
class ProviderEventDetails:
21+
flags_changed: Optional[List[str]] = None
22+
message: Optional[str] = None
23+
metadata: Dict[str, Union[bool, str, int, float]] = field(default_factory=dict)
24+
25+
26+
@dataclass
27+
class EventDetails(ProviderEventDetails):
28+
provider_name: str = ""
29+
flags_changed: Optional[List[str]] = None
30+
message: Optional[str] = None
31+
metadata: Dict[str, Union[bool, str, int, float]] = field(default_factory=dict)
32+
33+
@classmethod
34+
def from_provider_event_details(
35+
cls, provider_name: str, details: ProviderEventDetails
36+
) -> "EventDetails":
37+
return cls(
38+
provider_name=provider_name,
39+
flags_changed=details.flags_changed,
40+
message=details.message,
41+
metadata=details.metadata,
42+
)
43+
44+
45+
EventHandler = Callable[[EventDetails], None]
46+
47+
48+
class EventSupport:
49+
_global_handlers: Dict[ProviderEvent, List[EventHandler]]
50+
_client_handlers: Dict["OpenFeatureClient", Dict[ProviderEvent, List[EventHandler]]]
51+
52+
def __init__(self) -> None:
53+
self._global_handlers = defaultdict(list)
54+
self._client_handlers = defaultdict(lambda: defaultdict(list))
55+
56+
def run_client_handlers(
57+
self, client: "OpenFeatureClient", event: ProviderEvent, details: EventDetails
58+
) -> None:
59+
for handler in self._client_handlers[client][event]:
60+
handler(details)
61+
62+
def run_global_handlers(self, event: ProviderEvent, details: EventDetails) -> None:
63+
for handler in self._global_handlers[event]:
64+
handler(details)
65+
66+
def add_client_handler(
67+
self, client: "OpenFeatureClient", event: ProviderEvent, handler: EventHandler
68+
) -> None:
69+
handlers = self._client_handlers[client][event]
70+
handlers.append(handler)
71+
72+
def remove_client_handler(
73+
self, client: "OpenFeatureClient", event: ProviderEvent, handler: EventHandler
74+
) -> None:
75+
handlers = self._client_handlers[client][event]
76+
handlers.remove(handler)
77+
78+
def add_global_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
79+
self._global_handlers[event].append(handler)
80+
81+
def remove_global_handler(
82+
self, event: ProviderEvent, handler: EventHandler
83+
) -> None:
84+
self._global_handlers[event].remove(handler)
85+
86+
def run_handlers_for_provider(
87+
self,
88+
provider: FeatureProvider,
89+
event: ProviderEvent,
90+
provider_details: ProviderEventDetails,
91+
) -> None:
92+
details = EventDetails.from_provider_event_details(
93+
provider.get_metadata().name, provider_details
94+
)
95+
# run the global handlers
96+
self.run_global_handlers(event, details)
97+
# run the handlers for clients associated to this provider
98+
for client in self._client_handlers:
99+
if client.provider == provider:
100+
self.run_client_handlers(client, event, details)

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

+31-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
from openfeature.api import (
6+
add_handler,
67
add_hooks,
78
clear_hooks,
89
clear_providers,
@@ -15,11 +16,11 @@
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,31 @@ 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+
add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready)
238+
add_handler(
239+
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
240+
)
241+
add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error)
242+
add_handler(ProviderEvent.PROVIDER_STALE, spy.provider_stale)
243+
244+
provider = NoOpProvider()
245+
246+
provider_details = ProviderEventDetails(message="message")
247+
details = EventDetails.from_provider_event_details(
248+
provider.get_metadata().name, provider_details
249+
)
250+
251+
provider.emit_provider_ready(provider_details)
252+
provider.emit_provider_configuration_changed(provider_details)
253+
provider.emit_provider_error(provider_details)
254+
provider.emit_provider_stale(provider_details)
255+
256+
spy.provider_ready.assert_called_once_with(details)
257+
spy.provider_configuration_changed.assert_called_once_with(details)
258+
spy.provider_error.assert_called_once_with(details)
259+
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.add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready)
210+
client.add_handler(
211+
ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed
212+
)
213+
client.add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error)
214+
client.add_handler(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)