Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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