Skip to content

Commit

Permalink
feat: implement provider events (#278)
Browse files Browse the repository at this point in the history
* feat: implement provider events

Signed-off-by: Federico Bond <federicobond@gmail.com>

* feat: add error_code field to EventDetails and ProviderEventDetails

Signed-off-by: Federico Bond <federicobond@gmail.com>

* fix: replace strings with postponed evaluation of annotations

Signed-off-by: Federico Bond <federicobond@gmail.com>

* feat: run handlers immediately if provider already in associated state

Signed-off-by: Federico Bond <federicobond@gmail.com>

* feat: remove unused _provider from openfeature.api

Signed-off-by: Federico Bond <federicobond@gmail.com>

* test: add some comments to test cases

Signed-off-by: Federico Bond <federicobond@gmail.com>

* test: add provider event late binding test cases

Signed-off-by: Federico Bond <federicobond@gmail.com>

* fix: fix status handlers running immediately if provider already in associated state

Signed-off-by: Federico Bond <federicobond@gmail.com>

* refactor: reuse provider property in OpenFeatureClient

Signed-off-by: Federico Bond <federicobond@gmail.com>

* refactor: move _provider_status_to_event to ProviderEvent.from_provider_status

Signed-off-by: Federico Bond <federicobond@gmail.com>

* refactor: move EventSupport class to an internal module

Signed-off-by: Federico Bond <federicobond@gmail.com>

* refactor: replace EventSupport class with module-level functions

Signed-off-by: Federico Bond <federicobond@gmail.com>

* style: fix code style

---------

Signed-off-by: Federico Bond <federicobond@gmail.com>
  • Loading branch information
federicobond authored Mar 21, 2024
1 parent 04b4009 commit 679409f
Show file tree
Hide file tree
Showing 10 changed files with 412 additions and 13 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.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

Expand Down
89 changes: 89 additions & 0 deletions openfeature/_event_support.py
Original file line number Diff line number Diff line change
@@ -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()
16 changes: 15 additions & 1 deletion openfeature/api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
12 changes: 9 additions & 3 deletions openfeature/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {
Expand Down
60 changes: 60 additions & 0 deletions openfeature/event.py
Original file line number Diff line number Diff line change
@@ -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]
19 changes: 19 additions & 0 deletions openfeature/provider/provider.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
16 changes: 14 additions & 2 deletions openfeature/provider/registry.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()}:
Expand Down Expand Up @@ -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())
5 changes: 2 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 679409f

Please sign in to comment.