Skip to content

Commit

Permalink
feat: implement provider events
Browse files Browse the repository at this point in the history
Signed-off-by: Federico Bond <federicobond@gmail.com>
  • Loading branch information
federicobond committed Feb 21, 2024
1 parent ed6a42f commit 641102d
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 5 deletions.
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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

Expand Down
41 changes: 41 additions & 0 deletions openfeature/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
4 changes: 4 additions & 0 deletions openfeature/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down
82 changes: 82 additions & 0 deletions openfeature/event.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions openfeature/provider/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
31 changes: 29 additions & 2 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)
40 changes: 39 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

0 comments on commit 641102d

Please sign in to comment.