diff --git a/README.md b/README.md index 9c813390..9d81424f 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ print("Value: " + str(flag_value)) | ✅ | [Hooks](#hooks) | Add functionality to various stages of the flag evaluation life-cycle. | | ✅ | [Logging](#logging) | Integrate with popular logging packages. | | ✅ | [Domains](#domains) | Logically bind clients with providers. | -| ❌ | [Eventing](#eventing) | React to state changes in the provider or flag management system. | +| ✅ | [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. | @@ -214,7 +214,26 @@ For more details, please refer to the [providers](#providers) section. ### Eventing -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). +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. + +Please refer to the documentation of the provider you're using to see what events are supported. + +```python +from openfeature import api +from openfeature.provider import ProviderEvent + +def on_provider_ready(event_details: EventDetails): + print(f"Provider {event_details.provider_name} is ready") + +api.on(ProviderEvent.PROVIDER_READY, on_provider_ready) + +client = api.get_client() + +def on_provider_ready(event_details: EventDetails): + print(f"Provider {event_details.provider_name} is ready") + +client.on(ProviderEvent.PROVIDER_READY, on_provider_ready) +``` ### Shutdown diff --git a/openfeature/api.py b/openfeature/api.py index f4574545..0f41770a 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -2,18 +2,32 @@ from openfeature.client import OpenFeatureClient from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ( + EventDetails, + EventHandler, + EventSupport, + ProviderEvent, + ProviderEventDetails, +) from openfeature.exception import GeneralError from openfeature.hook import Hook from openfeature.provider import FeatureProvider from openfeature.provider.metadata import Metadata +from openfeature.provider.no_op_provider import NoOpProvider from openfeature.provider.registry import ProviderRegistry +_provider: FeatureProvider = NoOpProvider() + _evaluation_context = EvaluationContext() _hooks: typing.List[Hook] = [] _provider_registry: ProviderRegistry = ProviderRegistry() +_event_support: EventSupport = EventSupport() + +_clients_with_handlers: typing.Set[OpenFeatureClient] = set() + def get_client( domain: typing.Optional[str] = None, version: typing.Optional[str] = None @@ -67,3 +81,30 @@ def get_hooks() -> typing.List[Hook]: def shutdown() -> None: _provider_registry.shutdown() + + +def on(event: ProviderEvent, handler: EventHandler) -> None: + _event_support.add_global_handler(event, handler) + + +def _register_client_handler( + client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler +) -> None: + _clients_with_handlers.add(client) + _event_support.add_client_handler(client, event, handler) + + +def _run_handlers_for_provider( + provider: FeatureProvider, + event: ProviderEvent, + provider_details: ProviderEventDetails, +) -> None: + details = EventDetails.from_provider_event_details( + provider.get_metadata().name, provider_details + ) + # run the global handlers + _event_support.run_global_handlers(event, details) + # run the handlers for clients associated to this provider + for client in _clients_with_handlers: + if client.provider == provider: + _event_support.run_client_handlers(client, event, details) diff --git a/openfeature/client.py b/openfeature/client.py index deac93c8..f4ffb16b 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -4,6 +4,7 @@ from openfeature import api from openfeature.evaluation_context import EvaluationContext +from openfeature.event import EventHandler, ProviderEvent from openfeature.exception import ( ErrorCode, GeneralError, @@ -403,6 +404,9 @@ def _create_provider_evaluation( error_message=resolution.error_message, ) + def on(self, event: ProviderEvent, handler: EventHandler) -> None: + api._register_client_handler(self, event, handler) + def _typecheck_flag_value(value: typing.Any, flag_type: FlagType) -> None: type_map: TypeMap = { diff --git a/openfeature/event.py b/openfeature/event.py new file mode 100644 index 00000000..13433a5a --- /dev/null +++ b/openfeature/event.py @@ -0,0 +1,82 @@ +from collections import defaultdict +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Callable, Dict, List, Optional + +if TYPE_CHECKING: + from openfeature.client import OpenFeatureClient + + +class ProviderEvent(Enum): + PROVIDER_READY = "PROVIDER_READY" + PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED" + PROVIDER_ERROR = "PROVIDER_ERROR" + PROVIDER_STALE = "PROVIDER_STALE" + + +@dataclass +class ProviderEventDetails: + flags_changed: Optional[List[str]] = None + message: Optional[str] = None + metadata: Dict[str, bool | str | int | float] = field(default_factory=dict) + + +@dataclass +class EventDetails(ProviderEventDetails): + provider_name: str = "" + flags_changed: Optional[List[str]] = None + message: Optional[str] = None + metadata: Dict[str, bool | str | int | float] = field(default_factory=dict) + + @classmethod + def from_provider_event_details( + cls, provider_name: str, details: ProviderEventDetails + ) -> "EventDetails": + return cls( + provider_name=provider_name, + flags_changed=details.flags_changed, + message=details.message, + metadata=details.metadata, + ) + + +EventHandler = Callable[[EventDetails], None] + + +class EventSupport: + _global_handlers: Dict[ProviderEvent, List[EventHandler]] + _client_handlers: Dict["OpenFeatureClient", Dict[ProviderEvent, List[EventHandler]]] + + def __init__(self) -> None: + self._global_handlers = defaultdict(list) + self._client_handlers = defaultdict(lambda: defaultdict(list)) + + def run_client_handlers( + self, client: "OpenFeatureClient", event: ProviderEvent, details: EventDetails + ) -> None: + for handler in self._client_handlers[client][event]: + handler(details) + + def run_global_handlers(self, event: ProviderEvent, details: EventDetails) -> None: + for handler in self._global_handlers[event]: + handler(details) + + def add_client_handler( + self, client: "OpenFeatureClient", event: ProviderEvent, handler: EventHandler + ) -> None: + handlers = self._client_handlers[client][event] + handlers.append(handler) + + def remove_client_handler( + self, client: "OpenFeatureClient", event: ProviderEvent, handler: EventHandler + ) -> None: + handlers = self._client_handlers[client][event] + handlers.remove(handler) + + def add_global_handler(self, event: ProviderEvent, handler: EventHandler) -> None: + self._global_handlers[event].append(handler) + + def remove_global_handler( + self, event: ProviderEvent, handler: EventHandler + ) -> None: + self._global_handlers[event].remove(handler) diff --git a/openfeature/provider/provider.py b/openfeature/provider/provider.py index ebad417f..70486e96 100644 --- a/openfeature/provider/provider.py +++ b/openfeature/provider/provider.py @@ -2,6 +2,7 @@ from abc import abstractmethod from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ProviderEvent, ProviderEventDetails from openfeature.flag_evaluation import FlagResolutionDetails from openfeature.hook import Hook from openfeature.provider import FeatureProvider @@ -66,3 +67,22 @@ def resolve_object_details( evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[typing.Union[dict, list]]: pass + + def emit_provider_ready(self, details: ProviderEventDetails) -> None: + self.emit(ProviderEvent.PROVIDER_READY, details) + + def emit_provider_configuration_changed( + self, details: ProviderEventDetails + ) -> None: + self.emit(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details) + + def emit_provider_error(self, details: ProviderEventDetails) -> None: + self.emit(ProviderEvent.PROVIDER_ERROR, details) + + def emit_provider_stale(self, details: ProviderEventDetails) -> None: + self.emit(ProviderEvent.PROVIDER_STALE, details) + + def emit(self, event: ProviderEvent, details: ProviderEventDetails) -> None: + from openfeature.api import _run_handlers_for_provider + + _run_handlers_for_provider(self, event, details) diff --git a/tests/test_api.py b/tests/test_api.py index 3756f85c..e086f4c0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -10,16 +10,17 @@ get_evaluation_context, get_hooks, get_provider_metadata, + on, set_evaluation_context, set_provider, shutdown, ) from openfeature.evaluation_context import EvaluationContext +from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails from openfeature.exception import ErrorCode, GeneralError from openfeature.hook import Hook -from openfeature.provider.metadata import Metadata +from openfeature.provider import FeatureProvider, Metadata from openfeature.provider.no_op_provider import NoOpProvider -from openfeature.provider.provider import FeatureProvider def test_should_not_raise_exception_with_noop_client(): @@ -228,3 +229,29 @@ def test_clear_providers_shutdowns_every_provider_and_resets_default_provider(): provider_1.shutdown.assert_called_once() provider_2.shutdown.assert_called_once() assert isinstance(get_client().provider, NoOpProvider) + + +def test_provider_events(): + spy = MagicMock() + + on(ProviderEvent.PROVIDER_READY, spy.provider_ready) + on(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed) + on(ProviderEvent.PROVIDER_ERROR, spy.provider_error) + on(ProviderEvent.PROVIDER_STALE, spy.provider_stale) + + provider = NoOpProvider() + + provider_details = ProviderEventDetails(message="message") + details = EventDetails.from_provider_event_details( + provider.get_metadata().name, provider_details + ) + + provider.emit_provider_ready(provider_details) + provider.emit_provider_configuration_changed(provider_details) + provider.emit_provider_error(provider_details) + provider.emit_provider_stale(provider_details) + + spy.provider_ready.assert_called_once_with(details) + spy.provider_configuration_changed.assert_called_once_with(details) + spy.provider_error.assert_called_once_with(details) + spy.provider_stale.assert_called_once_with(details) diff --git a/tests/test_client.py b/tests/test_client.py index 71873405..8ade266e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,8 +2,9 @@ import pytest -from openfeature.api import add_hooks, clear_hooks, set_provider +from openfeature.api import add_hooks, clear_hooks, get_client, set_provider from openfeature.client import OpenFeatureClient +from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails from openfeature.exception import ErrorCode, OpenFeatureError from openfeature.flag_evaluation import Reason from openfeature.hook import Hook @@ -182,3 +183,40 @@ def test_should_call_api_level_hooks(no_op_provider_client): # Then api_hook.before.assert_called_once() api_hook.after.assert_called_once() + + +def test_provider_events(): + provider = NoOpProvider() + set_provider(provider) + + other_provider = NoOpProvider() + set_provider(other_provider, "my-domain") + + provider_details = ProviderEventDetails(message="message") + details = EventDetails.from_provider_event_details( + provider.get_metadata().name, provider_details + ) + + def emit_all_events(provider): + provider.emit_provider_ready(provider_details) + provider.emit_provider_configuration_changed(provider_details) + provider.emit_provider_error(provider_details) + provider.emit_provider_stale(provider_details) + + spy = MagicMock() + + client = get_client() + client.on(ProviderEvent.PROVIDER_READY, spy.provider_ready) + client.on( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed + ) + client.on(ProviderEvent.PROVIDER_ERROR, spy.provider_error) + client.on(ProviderEvent.PROVIDER_STALE, spy.provider_stale) + + emit_all_events(provider) + emit_all_events(other_provider) + + spy.provider_ready.assert_called_once_with(details) + spy.provider_configuration_changed.assert_called_once_with(details) + spy.provider_error.assert_called_once_with(details) + spy.provider_stale.assert_called_once_with(details)