diff --git a/README.md b/README.md index 47731700..18017e1e 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/_event_support.py b/openfeature/_event_support.py new file mode 100644 index 00000000..5842b0f4 --- /dev/null +++ b/openfeature/_event_support.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, Dict, List + +from openfeature.event import ( + EventDetails, + EventHandler, + ProviderEvent, + ProviderEventDetails, +) +from openfeature.provider import FeatureProvider + +if TYPE_CHECKING: + from openfeature.client import OpenFeatureClient + + +_global_handlers: Dict[ProviderEvent, List[EventHandler]] = defaultdict(list) +_client_handlers: Dict[OpenFeatureClient, Dict[ProviderEvent, List[EventHandler]]] = ( + defaultdict(lambda: defaultdict(list)) +) + + +def run_client_handlers( + client: OpenFeatureClient, event: ProviderEvent, details: EventDetails +) -> None: + for handler in _client_handlers[client][event]: + handler(details) + + +def run_global_handlers(event: ProviderEvent, details: EventDetails) -> None: + for handler in _global_handlers[event]: + handler(details) + + +def add_client_handler( + client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler +) -> None: + handlers = _client_handlers[client][event] + handlers.append(handler) + + _run_immediate_handler(client, event, handler) + + +def remove_client_handler( + client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler +) -> None: + handlers = _client_handlers[client][event] + handlers.remove(handler) + + +def add_global_handler(event: ProviderEvent, handler: EventHandler) -> None: + _global_handlers[event].append(handler) + + from openfeature.api import get_client + + _run_immediate_handler(get_client(), event, handler) + + +def remove_global_handler(event: ProviderEvent, handler: EventHandler) -> None: + _global_handlers[event].remove(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 + run_global_handlers(event, details) + # run the handlers for clients associated to this provider + for client in _client_handlers: + if client.provider == provider: + run_client_handlers(client, event, details) + + +def _run_immediate_handler( + client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler +) -> None: + if event == ProviderEvent.from_provider_status(client.get_provider_status()): + handler(EventDetails(provider_name=client.provider.get_metadata().name)) + + +def clear() -> None: + _global_handlers.clear() + _client_handlers.clear() diff --git a/openfeature/api.py b/openfeature/api.py index f4574545..4460cc70 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -1,7 +1,12 @@ import typing +from openfeature import _event_support from openfeature.client import OpenFeatureClient from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ( + EventHandler, + ProviderEvent, +) from openfeature.exception import GeneralError from openfeature.hook import Hook from openfeature.provider import FeatureProvider @@ -31,7 +36,8 @@ def set_provider( def clear_providers() -> None: - return _provider_registry.clear_providers() + _provider_registry.clear_providers() + _event_support.clear() def get_provider_metadata(domain: typing.Optional[str] = None) -> Metadata: @@ -67,3 +73,11 @@ 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) diff --git a/openfeature/client.py b/openfeature/client.py index b27866ce..f08749d6 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -2,8 +2,9 @@ import typing from dataclasses import dataclass -from openfeature import api +from openfeature import _event_support, api from openfeature.evaluation_context import EvaluationContext +from openfeature.event import EventHandler, ProviderEvent from openfeature.exception import ( ErrorCode, GeneralError, @@ -84,8 +85,7 @@ def provider(self) -> FeatureProvider: return api._provider_registry.get_provider(self.domain) def get_provider_status(self) -> ProviderStatus: - provider = api._provider_registry.get_provider(self.domain) - return api._provider_registry.get_provider_status(provider) + return api._provider_registry.get_provider_status(self.provider) def get_metadata(self) -> ClientMetadata: return ClientMetadata(domain=self.domain) @@ -440,6 +440,12 @@ def _create_provider_evaluation( error_message=resolution.error_message, ) + def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None: + _event_support.add_client_handler(self, event, handler) + + def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None: + _event_support.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..18ef4e1b --- /dev/null +++ b/openfeature/event.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Callable, ClassVar, Dict, List, Optional, Union + +from openfeature.exception import ErrorCode +from openfeature.provider import ProviderStatus + + +class ProviderEvent(Enum): + PROVIDER_READY = "PROVIDER_READY" + PROVIDER_CONFIGURATION_CHANGED = "PROVIDER_CONFIGURATION_CHANGED" + PROVIDER_ERROR = "PROVIDER_ERROR" + PROVIDER_FATAL = "PROVIDER_FATAL" + PROVIDER_STALE = "PROVIDER_STALE" + + __status__: ClassVar[Dict[ProviderStatus, str]] = { + ProviderStatus.READY: PROVIDER_READY, + ProviderStatus.ERROR: PROVIDER_ERROR, + ProviderStatus.FATAL: PROVIDER_FATAL, + ProviderStatus.STALE: PROVIDER_STALE, + } + + @classmethod + def from_provider_status(cls, status: ProviderStatus) -> Optional[ProviderEvent]: + value = ProviderEvent.__status__.get(status) + return ProviderEvent[value] if value else None + + +@dataclass +class ProviderEventDetails: + flags_changed: Optional[List[str]] = None + message: Optional[str] = None + error_code: Optional[ErrorCode] = 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 + error_code: Optional[ErrorCode] = 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, + error_code=details.error_code, + metadata=details.metadata, + ) + + +EventHandler = Callable[[EventDetails], None] diff --git a/openfeature/provider/provider.py b/openfeature/provider/provider.py index ebad417f..2e5da576 100644 --- a/openfeature/provider/provider.py +++ b/openfeature/provider/provider.py @@ -1,7 +1,9 @@ import typing from abc import abstractmethod +from openfeature._event_support import run_handlers_for_provider 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 +68,20 @@ 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: + run_handlers_for_provider(self, event, details) diff --git a/openfeature/provider/registry.py b/openfeature/provider/registry.py index 779ee569..8d764465 100644 --- a/openfeature/provider/registry.py +++ b/openfeature/provider/registry.py @@ -1,6 +1,11 @@ import typing +from openfeature._event_support import run_handlers_for_provider from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ( + ProviderEvent, + ProviderEventDetails, +) from openfeature.exception import ErrorCode, GeneralError, OpenFeatureError from openfeature.provider import FeatureProvider, ProviderStatus from openfeature.provider.no_op_provider import NoOpProvider @@ -14,8 +19,9 @@ class ProviderRegistry: def __init__(self) -> None: self._default_provider = NoOpProvider() self._providers = {} - self._provider_status = {} - self._set_provider_status(self._default_provider, ProviderStatus.NOT_READY) + self._provider_status = { + self._default_provider: ProviderStatus.READY, + } def set_provider(self, domain: str, provider: FeatureProvider) -> None: if provider is None: @@ -50,6 +56,9 @@ def clear_providers(self) -> None: self.shutdown() self._providers.clear() self._default_provider = NoOpProvider() + self._provider_status = { + self._default_provider: ProviderStatus.READY, + } def shutdown(self) -> None: for provider in {self._default_provider, *self._providers.values()}: @@ -90,3 +99,6 @@ def _set_provider_status( self, provider: FeatureProvider, status: ProviderStatus ) -> None: self._provider_status[provider] = status + + if event := ProviderEvent.from_provider_status(status): + run_handlers_for_provider(provider, event, ProviderEventDetails()) diff --git a/tests/conftest.py b/tests/conftest.py index b97e5478..1f0a7982 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,13 +5,12 @@ @pytest.fixture(autouse=True) -def clear_provider(): +def clear_providers(): """ For tests that use set_provider(), we need to clear the provider to avoid issues in other tests. """ - yield - _provider = None + api.clear_providers() @pytest.fixture() diff --git a/tests/test_api.py b/tests/test_api.py index 3756f85c..5bb9c91a 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, @@ -10,16 +11,17 @@ get_evaluation_context, get_hooks, get_provider_metadata, + remove_handler, 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 +230,86 @@ 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(): + # Given + 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 + ) + + # When + provider.emit_provider_configuration_changed(provider_details) + provider.emit_provider_error(provider_details) + provider.emit_provider_stale(provider_details) + + # Then + # NOTE: provider_ready is called immediately after adding the handler + spy.provider_ready.assert_called_once() + 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) + + +def test_add_remove_event_handler(): + # Given + provider = NoOpProvider() + set_provider(provider) + + spy = MagicMock() + + add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed + ) + remove_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed + ) + + provider_details = ProviderEventDetails(message="message") + + # When + provider.emit_provider_configuration_changed(provider_details) + + # Then + spy.provider_configuration_changed.assert_not_called() + + +# Requirement 5.3.3 +def test_handlers_attached_to_provider_already_in_associated_state_should_run_immediately(): + # Given + provider = NoOpProvider() + set_provider(provider) + spy = MagicMock() + + # When + add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) + + # Then + spy.provider_ready.assert_called_once() + + +def test_provider_ready_handlers_run_if_provider_initialize_function_terminates_normally(): + # Given + provider = NoOpProvider() + set_provider(provider) + + spy = MagicMock() + add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) + + # When + provider.initialize(get_evaluation_context()) + + # Then + spy.provider_ready.assert_called_once() diff --git a/tests/test_client.py b/tests/test_client.py index 43223d99..dc25abee 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,6 +4,7 @@ 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 FlagResolutionDetails, Reason from openfeature.hook import Hook @@ -260,3 +261,98 @@ def test_should_run_error_hooks_if_provider_returns_resolution_with_error_code() assert flag_details.reason == Reason.ERROR assert flag_details.error_code == ErrorCode.PROVIDER_FATAL spy_hook.error.assert_called_once() + + +def test_provider_events(): + # Given + 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_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) + + # When + emit_all_events(provider) + emit_all_events(other_provider) + + # Then + # NOTE: provider_ready is called immediately after adding the handler + spy.provider_ready.assert_called_once() + 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) + + +def test_add_remove_event_handler(): + # Given + provider = NoOpProvider() + set_provider(provider) + + spy = MagicMock() + + client = get_client() + client.add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed + ) + client.remove_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed + ) + + provider_details = ProviderEventDetails(message="message") + + # When + provider.emit_provider_configuration_changed(provider_details) + + # Then + spy.provider_configuration_changed.assert_not_called() + + +# Requirement 5.1.2, Requirement 5.1.3 +def test_provider_event_late_binding(): + # Given + provider = NoOpProvider() + set_provider(provider, "my-domain") + other_provider = NoOpProvider() + + spy = MagicMock() + + client = get_client("my-domain") + client.add_handler( + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, spy.provider_configuration_changed + ) + + set_provider(other_provider, "my-domain") + + provider_details = ProviderEventDetails(message="message from provider") + other_provider_details = ProviderEventDetails(message="message from other provider") + + details = EventDetails.from_provider_event_details( + other_provider.get_metadata().name, other_provider_details + ) + + # When + provider.emit_provider_configuration_changed(provider_details) + other_provider.emit_provider_configuration_changed(other_provider_details) + + # Then + spy.provider_configuration_changed.assert_called_once_with(details)