Skip to content

Commit 679409f

Browse files
authored
feat: implement provider events (#278)
* feat: implement provider events Signed-off-by: Federico Bond <[email protected]> * feat: add error_code field to EventDetails and ProviderEventDetails Signed-off-by: Federico Bond <[email protected]> * fix: replace strings with postponed evaluation of annotations Signed-off-by: Federico Bond <[email protected]> * feat: run handlers immediately if provider already in associated state Signed-off-by: Federico Bond <[email protected]> * feat: remove unused _provider from openfeature.api Signed-off-by: Federico Bond <[email protected]> * test: add some comments to test cases Signed-off-by: Federico Bond <[email protected]> * test: add provider event late binding test cases Signed-off-by: Federico Bond <[email protected]> * fix: fix status handlers running immediately if provider already in associated state Signed-off-by: Federico Bond <[email protected]> * refactor: reuse provider property in OpenFeatureClient Signed-off-by: Federico Bond <[email protected]> * refactor: move _provider_status_to_event to ProviderEvent.from_provider_status Signed-off-by: Federico Bond <[email protected]> * refactor: move EventSupport class to an internal module Signed-off-by: Federico Bond <[email protected]> * refactor: replace EventSupport class with module-level functions Signed-off-by: Federico Bond <[email protected]> * style: fix code style --------- Signed-off-by: Federico Bond <[email protected]>
1 parent 04b4009 commit 679409f

File tree

10 files changed

+412
-13
lines changed

10 files changed

+412
-13
lines changed

README.md

Lines changed: 21 additions & 2 deletions
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/_event_support.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
from collections import defaultdict
4+
from typing import TYPE_CHECKING, Dict, List
5+
6+
from openfeature.event import (
7+
EventDetails,
8+
EventHandler,
9+
ProviderEvent,
10+
ProviderEventDetails,
11+
)
12+
from openfeature.provider import FeatureProvider
13+
14+
if TYPE_CHECKING:
15+
from openfeature.client import OpenFeatureClient
16+
17+
18+
_global_handlers: Dict[ProviderEvent, List[EventHandler]] = defaultdict(list)
19+
_client_handlers: Dict[OpenFeatureClient, Dict[ProviderEvent, List[EventHandler]]] = (
20+
defaultdict(lambda: defaultdict(list))
21+
)
22+
23+
24+
def run_client_handlers(
25+
client: OpenFeatureClient, event: ProviderEvent, details: EventDetails
26+
) -> None:
27+
for handler in _client_handlers[client][event]:
28+
handler(details)
29+
30+
31+
def run_global_handlers(event: ProviderEvent, details: EventDetails) -> None:
32+
for handler in _global_handlers[event]:
33+
handler(details)
34+
35+
36+
def add_client_handler(
37+
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
38+
) -> None:
39+
handlers = _client_handlers[client][event]
40+
handlers.append(handler)
41+
42+
_run_immediate_handler(client, event, handler)
43+
44+
45+
def remove_client_handler(
46+
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
47+
) -> None:
48+
handlers = _client_handlers[client][event]
49+
handlers.remove(handler)
50+
51+
52+
def add_global_handler(event: ProviderEvent, handler: EventHandler) -> None:
53+
_global_handlers[event].append(handler)
54+
55+
from openfeature.api import get_client
56+
57+
_run_immediate_handler(get_client(), event, handler)
58+
59+
60+
def remove_global_handler(event: ProviderEvent, handler: EventHandler) -> None:
61+
_global_handlers[event].remove(handler)
62+
63+
64+
def run_handlers_for_provider(
65+
provider: FeatureProvider,
66+
event: ProviderEvent,
67+
provider_details: ProviderEventDetails,
68+
) -> None:
69+
details = EventDetails.from_provider_event_details(
70+
provider.get_metadata().name, provider_details
71+
)
72+
# run the global handlers
73+
run_global_handlers(event, details)
74+
# run the handlers for clients associated to this provider
75+
for client in _client_handlers:
76+
if client.provider == provider:
77+
run_client_handlers(client, event, details)
78+
79+
80+
def _run_immediate_handler(
81+
client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler
82+
) -> None:
83+
if event == ProviderEvent.from_provider_status(client.get_provider_status()):
84+
handler(EventDetails(provider_name=client.provider.get_metadata().name))
85+
86+
87+
def clear() -> None:
88+
_global_handlers.clear()
89+
_client_handlers.clear()

openfeature/api.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import typing
22

3+
from openfeature import _event_support
34
from openfeature.client import OpenFeatureClient
45
from openfeature.evaluation_context import EvaluationContext
6+
from openfeature.event import (
7+
EventHandler,
8+
ProviderEvent,
9+
)
510
from openfeature.exception import GeneralError
611
from openfeature.hook import Hook
712
from openfeature.provider import FeatureProvider
@@ -31,7 +36,8 @@ def set_provider(
3136

3237

3338
def clear_providers() -> None:
34-
return _provider_registry.clear_providers()
39+
_provider_registry.clear_providers()
40+
_event_support.clear()
3541

3642

3743
def get_provider_metadata(domain: typing.Optional[str] = None) -> Metadata:
@@ -67,3 +73,11 @@ def get_hooks() -> typing.List[Hook]:
6773

6874
def shutdown() -> None:
6975
_provider_registry.shutdown()
76+
77+
78+
def add_handler(event: ProviderEvent, handler: EventHandler) -> None:
79+
_event_support.add_global_handler(event, handler)
80+
81+
82+
def remove_handler(event: ProviderEvent, handler: EventHandler) -> None:
83+
_event_support.remove_global_handler(event, handler)

openfeature/client.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
import typing
33
from dataclasses import dataclass
44

5-
from openfeature import api
5+
from openfeature import _event_support, api
66
from openfeature.evaluation_context import EvaluationContext
7+
from openfeature.event import EventHandler, ProviderEvent
78
from openfeature.exception import (
89
ErrorCode,
910
GeneralError,
@@ -84,8 +85,7 @@ def provider(self) -> FeatureProvider:
8485
return api._provider_registry.get_provider(self.domain)
8586

8687
def get_provider_status(self) -> ProviderStatus:
87-
provider = api._provider_registry.get_provider(self.domain)
88-
return api._provider_registry.get_provider_status(provider)
88+
return api._provider_registry.get_provider_status(self.provider)
8989

9090
def get_metadata(self) -> ClientMetadata:
9191
return ClientMetadata(domain=self.domain)
@@ -440,6 +440,12 @@ def _create_provider_evaluation(
440440
error_message=resolution.error_message,
441441
)
442442

443+
def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
444+
_event_support.add_client_handler(self, event, handler)
445+
446+
def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None:
447+
_event_support.remove_client_handler(self, event, handler)
448+
443449

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

openfeature/event.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from enum import Enum
5+
from typing import Callable, ClassVar, Dict, List, Optional, Union
6+
7+
from openfeature.exception import ErrorCode
8+
from openfeature.provider import ProviderStatus
9+
10+
11+
class ProviderEvent(Enum):
12+
PROVIDER_READY = "PROVIDER_READY"
13+
PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED"
14+
PROVIDER_ERROR = "PROVIDER_ERROR"
15+
PROVIDER_FATAL = "PROVIDER_FATAL"
16+
PROVIDER_STALE = "PROVIDER_STALE"
17+
18+
__status__: ClassVar[Dict[ProviderStatus, str]] = {
19+
ProviderStatus.READY: PROVIDER_READY,
20+
ProviderStatus.ERROR: PROVIDER_ERROR,
21+
ProviderStatus.FATAL: PROVIDER_FATAL,
22+
ProviderStatus.STALE: PROVIDER_STALE,
23+
}
24+
25+
@classmethod
26+
def from_provider_status(cls, status: ProviderStatus) -> Optional[ProviderEvent]:
27+
value = ProviderEvent.__status__.get(status)
28+
return ProviderEvent[value] if value else None
29+
30+
31+
@dataclass
32+
class ProviderEventDetails:
33+
flags_changed: Optional[List[str]] = None
34+
message: Optional[str] = None
35+
error_code: Optional[ErrorCode] = None
36+
metadata: Dict[str, Union[bool, str, int, float]] = field(default_factory=dict)
37+
38+
39+
@dataclass
40+
class EventDetails(ProviderEventDetails):
41+
provider_name: str = ""
42+
flags_changed: Optional[List[str]] = None
43+
message: Optional[str] = None
44+
error_code: Optional[ErrorCode] = None
45+
metadata: Dict[str, Union[bool, str, int, float]] = field(default_factory=dict)
46+
47+
@classmethod
48+
def from_provider_event_details(
49+
cls, provider_name: str, details: ProviderEventDetails
50+
) -> EventDetails:
51+
return cls(
52+
provider_name=provider_name,
53+
flags_changed=details.flags_changed,
54+
message=details.message,
55+
error_code=details.error_code,
56+
metadata=details.metadata,
57+
)
58+
59+
60+
EventHandler = Callable[[EventDetails], None]

openfeature/provider/provider.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import typing
22
from abc import abstractmethod
33

4+
from openfeature._event_support import run_handlers_for_provider
45
from openfeature.evaluation_context import EvaluationContext
6+
from openfeature.event import ProviderEvent, ProviderEventDetails
57
from openfeature.flag_evaluation import FlagResolutionDetails
68
from openfeature.hook import Hook
79
from openfeature.provider import FeatureProvider
@@ -66,3 +68,20 @@ def resolve_object_details(
6668
evaluation_context: typing.Optional[EvaluationContext] = None,
6769
) -> FlagResolutionDetails[typing.Union[dict, list]]:
6870
pass
71+
72+
def emit_provider_ready(self, details: ProviderEventDetails) -> None:
73+
self.emit(ProviderEvent.PROVIDER_READY, details)
74+
75+
def emit_provider_configuration_changed(
76+
self, details: ProviderEventDetails
77+
) -> None:
78+
self.emit(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details)
79+
80+
def emit_provider_error(self, details: ProviderEventDetails) -> None:
81+
self.emit(ProviderEvent.PROVIDER_ERROR, details)
82+
83+
def emit_provider_stale(self, details: ProviderEventDetails) -> None:
84+
self.emit(ProviderEvent.PROVIDER_STALE, details)
85+
86+
def emit(self, event: ProviderEvent, details: ProviderEventDetails) -> None:
87+
run_handlers_for_provider(self, event, details)

openfeature/provider/registry.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import typing
22

3+
from openfeature._event_support import run_handlers_for_provider
34
from openfeature.evaluation_context import EvaluationContext
5+
from openfeature.event import (
6+
ProviderEvent,
7+
ProviderEventDetails,
8+
)
49
from openfeature.exception import ErrorCode, GeneralError, OpenFeatureError
510
from openfeature.provider import FeatureProvider, ProviderStatus
611
from openfeature.provider.no_op_provider import NoOpProvider
@@ -14,8 +19,9 @@ class ProviderRegistry:
1419
def __init__(self) -> None:
1520
self._default_provider = NoOpProvider()
1621
self._providers = {}
17-
self._provider_status = {}
18-
self._set_provider_status(self._default_provider, ProviderStatus.NOT_READY)
22+
self._provider_status = {
23+
self._default_provider: ProviderStatus.READY,
24+
}
1925

2026
def set_provider(self, domain: str, provider: FeatureProvider) -> None:
2127
if provider is None:
@@ -50,6 +56,9 @@ def clear_providers(self) -> None:
5056
self.shutdown()
5157
self._providers.clear()
5258
self._default_provider = NoOpProvider()
59+
self._provider_status = {
60+
self._default_provider: ProviderStatus.READY,
61+
}
5362

5463
def shutdown(self) -> None:
5564
for provider in {self._default_provider, *self._providers.values()}:
@@ -90,3 +99,6 @@ def _set_provider_status(
9099
self, provider: FeatureProvider, status: ProviderStatus
91100
) -> None:
92101
self._provider_status[provider] = status
102+
103+
if event := ProviderEvent.from_provider_status(status):
104+
run_handlers_for_provider(provider, event, ProviderEventDetails())

tests/conftest.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@
55

66

77
@pytest.fixture(autouse=True)
8-
def clear_provider():
8+
def clear_providers():
99
"""
1010
For tests that use set_provider(), we need to clear the provider to avoid issues
1111
in other tests.
1212
"""
13-
yield
14-
_provider = None
13+
api.clear_providers()
1514

1615

1716
@pytest.fixture()

0 commit comments

Comments
 (0)