diff --git a/README.md b/README.md index 9c813390..50ee771f 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.add_handler(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.add_handler(ProviderEvent.PROVIDER_READY, on_provider_ready) +``` ### Shutdown diff --git a/openfeature/api.py b/openfeature/api.py index f4574545..ce49e6f0 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -2,18 +2,29 @@ from openfeature.client import OpenFeatureClient from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ( + 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() + def get_client( domain: typing.Optional[str] = None, version: typing.Optional[str] = None @@ -67,3 +78,31 @@ def get_hooks() -> typing.List[Hook]: def shutdown() -> None: _provider_registry.shutdown() + + +def add_handler(event: ProviderEvent, handler: EventHandler) -> None: + _event_support.add_global_handler(event, handler) + + +def remove_handler(event: ProviderEvent, handler: EventHandler) -> None: + _event_support.remove_global_handler(event, handler) + + +def _add_client_handler( + client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler +) -> None: + _event_support.add_client_handler(client, event, handler) + + +def _remove_client_handler( + client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler +) -> None: + _event_support.remove_client_handler(client, event, handler) + + +def _run_handlers_for_provider( + provider: FeatureProvider, + event: ProviderEvent, + provider_details: ProviderEventDetails, +) -> None: + _event_support.run_handlers_for_provider(provider, event, provider_details) diff --git a/openfeature/client.py b/openfeature/client.py index deac93c8..5c707856 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,12 @@ def _create_provider_evaluation( error_message=resolution.error_message, ) + def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None: + api._add_client_handler(self, event, handler) + + def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None: + api._remove_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..6e02d2c6 --- /dev/null +++ b/openfeature/event.py @@ -0,0 +1,100 @@ +from collections import defaultdict +from dataclasses import dataclass, field +from enum import Enum +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Union + +from openfeature.provider import FeatureProvider + +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, Union[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, Union[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) + + def run_handlers_for_provider( + self, + provider: FeatureProvider, + event: ProviderEvent, + provider_details: ProviderEventDetails, + ) -> None: + details = EventDetails.from_provider_event_details( + provider.get_metadata().name, provider_details + ) + # run the global handlers + self.run_global_handlers(event, details) + # run the handlers for clients associated to this provider + for client in self._client_handlers: + if client.provider == provider: + self.run_client_handlers(client, event, details) 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..50774932 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,6 +3,7 @@ import pytest from openfeature.api import ( + add_handler, add_hooks, clear_hooks, clear_providers, @@ -15,11 +16,11 @@ 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,31 @@ 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() + + add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) + add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed + ) + add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error) + add_handler(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..6b6ccfd5 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.add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) + client.add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed + ) + client.add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error) + client.add_handler(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)