diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6a79aaea..7e7e8a3a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ default_stages: [commit] repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.2.1 + rev: v0.3.3 hooks: - id: ruff args: [--fix] @@ -16,7 +16,7 @@ repos: - id: check-merge-conflict - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.8.0 + rev: v1.9.0 hooks: - id: mypy files: openfeature diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 8d5f7155..d12354ef 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"0.5.0"} \ No newline at end of file +{".":"0.6.0"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d0d05e09..ccd2e9a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [0.6.0](https://github.com/open-feature/python-sdk/compare/v0.5.0...v0.6.0) (2024-03-22) + + +### ๐ Bug Fixes + +* run error hooks if provider returns FlagResolutionDetails with non-empty error_code ([#291](https://github.com/open-feature/python-sdk/issues/291)) ([e747544](https://github.com/open-feature/python-sdk/commit/e7475441bd14323431fdf1850e643f5aaaa21abd)) + + +### โจ New Features + +* implement provider events ([#278](https://github.com/open-feature/python-sdk/issues/278)) ([679409f](https://github.com/open-feature/python-sdk/commit/679409fad229d0e675be4a8ee2b3a13860f4e987)) +* implement provider status ([#288](https://github.com/open-feature/python-sdk/issues/288)) ([789e6e0](https://github.com/open-feature/python-sdk/commit/789e6e0f5fcf499604261afd918ed1e8844fa0a0)) + + +### ๐งน Chore + +* add changelog sections ([#282](https://github.com/open-feature/python-sdk/issues/282)) ([141858d](https://github.com/open-feature/python-sdk/commit/141858d2359bf6bf439426b3ea4ba322f4b10421)) +* **deps:** update dependency coverage to v7.4.3 ([#280](https://github.com/open-feature/python-sdk/issues/280)) ([bafa427](https://github.com/open-feature/python-sdk/commit/bafa427a0da40711d327c435ab199286f68fb6b7)) +* **deps:** update dependency coverage to v7.4.4 ([#293](https://github.com/open-feature/python-sdk/issues/293)) ([f5987ef](https://github.com/open-feature/python-sdk/commit/f5987ef8f41892c9cad776d7716592ac0eac4719)) +* **deps:** update dependency pytest to v8.0.2 ([#281](https://github.com/open-feature/python-sdk/issues/281)) ([b2594a5](https://github.com/open-feature/python-sdk/commit/b2594a567c31e48a1ae675b855e84300201e8132)) +* **deps:** update dependency pytest to v8.1.0 ([#287](https://github.com/open-feature/python-sdk/issues/287)) ([7ba7d61](https://github.com/open-feature/python-sdk/commit/7ba7d6146f0f801cadfd7593dc6df4b7d4f488d4)) +* **deps:** update dependency pytest to v8.1.1 ([#289](https://github.com/open-feature/python-sdk/issues/289)) ([3f336b3](https://github.com/open-feature/python-sdk/commit/3f336b3a248dd8e75e162870d26a4b97c61f2ff6)) +* **deps:** update dependency pytest-mock to v3.13.0 ([#298](https://github.com/open-feature/python-sdk/issues/298)) ([04b4009](https://github.com/open-feature/python-sdk/commit/04b4009dbfd112307e17a6f9273e0118ad337fe1)) +* **deps:** update dependency pytest-mock to v3.14.0 ([#300](https://github.com/open-feature/python-sdk/issues/300)) ([a70ae0c](https://github.com/open-feature/python-sdk/commit/a70ae0cb2e5322cc6290dbe5be12f0a665cc0e86)) +* update mypy and ruff ([#296](https://github.com/open-feature/python-sdk/issues/296)) ([6e4eebc](https://github.com/open-feature/python-sdk/commit/6e4eebce2073aa792444ea9f28906b9c925ebd75)) + + +### ๐ Documentation + +* add missing imports in provider dev example in README ([ae26217](https://github.com/open-feature/python-sdk/commit/ae26217328a5ca07722c5e12b01720606259d805)) +* add Missing Imports in Provider Dev Example in README ([#286](https://github.com/open-feature/python-sdk/issues/286)) ([ae26217](https://github.com/open-feature/python-sdk/commit/ae26217328a5ca07722c5e12b01720606259d805)) +* update spec version to 0.8.0 ([#299](https://github.com/open-feature/python-sdk/issues/299)) ([58d27c4](https://github.com/open-feature/python-sdk/commit/58d27c4011b4f7fd96cc7d1ba10f017c7a3db958)) + + +### ๐ Refactoring + +* improve Hook Hints typing ([#285](https://github.com/open-feature/python-sdk/issues/285)) ([5acd6a6](https://github.com/open-feature/python-sdk/commit/5acd6a6598fa45326ddafb0184d184cadea826d0)) + ## [0.5.0](https://github.com/open-feature/python-sdk/compare/v0.4.2...v0.5.0) (2024-02-20) diff --git a/README.md b/README.md index e8874573..d7546966 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,14 @@
-
-
+
+
-
-
+
+
@@ -60,13 +60,13 @@
#### Pip install
```bash
-pip install openfeature-sdk==0.5.0
+pip install openfeature-sdk==0.6.0
```
#### requirements.txt
```bash
-openfeature-sdk==0.5.0
+openfeature-sdk==0.6.0
```
```python
@@ -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
@@ -235,10 +254,12 @@ This can be a new repository or included in [the existing contrib repository](ht
Youโll then need to write the provider by implementing the `AbstractProvider` class exported by the OpenFeature SDK.
```python
-from typing import List, Optional
+from typing import List, Optional, Union
from openfeature.evaluation_context import EvaluationContext
from openfeature.flag_evaluation import FlagResolutionDetails
+from openfeature.hook import Hook
+from openfeature.provider.metadata import Metadata
from openfeature.provider.provider import AbstractProvider
class MyProvider(AbstractProvider):
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 deac93c8..f08749d6 100644
--- a/openfeature/client.py
+++ b/openfeature/client.py
@@ -2,12 +2,15 @@
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,
OpenFeatureError,
+ ProviderFatalError,
+ ProviderNotReadyError,
TypeMismatchError,
)
from openfeature.flag_evaluation import (
@@ -24,7 +27,7 @@
before_hooks,
error_hooks,
)
-from openfeature.provider import FeatureProvider
+from openfeature.provider import FeatureProvider, ProviderStatus
logger = logging.getLogger("openfeature")
@@ -81,6 +84,9 @@ def __init__(
def provider(self) -> FeatureProvider:
return api._provider_registry.get_provider(self.domain)
+ def get_provider_status(self) -> ProviderStatus:
+ return api._provider_registry.get_provider_status(self.provider)
+
def get_metadata(self) -> ClientMetadata:
return ClientMetadata(domain=self.domain)
@@ -232,7 +238,7 @@ def get_object_details(
flag_evaluation_options,
)
- def evaluate_flag_details(
+ def evaluate_flag_details( # noqa: PLR0915
self,
flag_type: FlagType,
flag_key: str,
@@ -282,6 +288,36 @@ def evaluate_flag_details(
reversed_merged_hooks = merged_hooks[:]
reversed_merged_hooks.reverse()
+ status = self.get_provider_status()
+ if status == ProviderStatus.NOT_READY:
+ error_hooks(
+ flag_type,
+ hook_context,
+ ProviderNotReadyError(),
+ reversed_merged_hooks,
+ hook_hints,
+ )
+ return FlagEvaluationDetails(
+ flag_key=flag_key,
+ value=default_value,
+ reason=Reason.ERROR,
+ error_code=ErrorCode.PROVIDER_NOT_READY,
+ )
+ if status == ProviderStatus.FATAL:
+ error_hooks(
+ flag_type,
+ hook_context,
+ ProviderFatalError(),
+ reversed_merged_hooks,
+ hook_hints,
+ )
+ return FlagEvaluationDetails(
+ flag_key=flag_key,
+ value=default_value,
+ reason=Reason.ERROR,
+ error_code=ErrorCode.PROVIDER_FATAL,
+ )
+
try:
# https://github.com/open-feature/spec/blob/main/specification/sections/03-evaluation-context.md
# Any resulting evaluation context from a before hook will overwrite
@@ -389,6 +425,7 @@ def _create_provider_evaluation(
raise GeneralError(error_message="Unknown flag type")
resolution = get_details_callable(*args)
+ resolution.raise_for_error()
# we need to check the get_args to be compatible with union types.
_typecheck_flag_value(resolution.value, flag_type)
@@ -403,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/exception.py b/openfeature/exception.py
index e8a4768d..d17c28fb 100644
--- a/openfeature/exception.py
+++ b/openfeature/exception.py
@@ -1,17 +1,10 @@
+from __future__ import annotations
+
import typing
+from collections.abc import Mapping
from enum import Enum
-class ErrorCode(Enum):
- PROVIDER_NOT_READY = "PROVIDER_NOT_READY"
- FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
- PARSE_ERROR = "PARSE_ERROR"
- TYPE_MISMATCH = "TYPE_MISMATCH"
- TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING"
- INVALID_CONTEXT = "INVALID_CONTEXT"
- GENERAL = "GENERAL"
-
-
class OpenFeatureError(Exception):
"""
A generic open feature exception, this exception should not be raised. Instead
@@ -31,6 +24,36 @@ def __init__(
self.error_code = error_code
+class ProviderNotReadyError(OpenFeatureError):
+ """
+ This exception should be raised when the provider is not ready to be used.
+ """
+
+ def __init__(self, error_message: typing.Optional[str] = None):
+ """
+ Constructor for the ProviderNotReadyError. The error code for this type of
+ exception is ErrorCode.PROVIDER_NOT_READY.
+ @param error_message: a string message representing why the error has been
+ raised
+ """
+ super().__init__(ErrorCode.PROVIDER_NOT_READY, error_message)
+
+
+class ProviderFatalError(OpenFeatureError):
+ """
+ This exception should be raised when the provider encounters a fatal error.
+ """
+
+ def __init__(self, error_message: typing.Optional[str] = None):
+ """
+ Constructor for the ProviderFatalError. The error code for this type of
+ exception is ErrorCode.PROVIDER_FATAL.
+ @param error_message: a string message representing why the error has been
+ raised
+ """
+ super().__init__(ErrorCode.PROVIDER_FATAL, error_message)
+
+
class FlagNotFoundError(OpenFeatureError):
"""
This exception should be raised when the provider cannot find a flag with the
@@ -125,3 +148,32 @@ def __init__(self, error_message: typing.Optional[str]):
raised
"""
super().__init__(ErrorCode.INVALID_CONTEXT, error_message)
+
+
+class ErrorCode(Enum):
+ PROVIDER_NOT_READY = "PROVIDER_NOT_READY"
+ PROVIDER_FATAL = "PROVIDER_FATAL"
+ FLAG_NOT_FOUND = "FLAG_NOT_FOUND"
+ PARSE_ERROR = "PARSE_ERROR"
+ TYPE_MISMATCH = "TYPE_MISMATCH"
+ TARGETING_KEY_MISSING = "TARGETING_KEY_MISSING"
+ INVALID_CONTEXT = "INVALID_CONTEXT"
+ GENERAL = "GENERAL"
+
+ __exceptions__: Mapping[str, typing.Callable[[str], OpenFeatureError]] = {
+ PROVIDER_NOT_READY: ProviderNotReadyError,
+ PROVIDER_FATAL: ProviderFatalError,
+ FLAG_NOT_FOUND: FlagNotFoundError,
+ PARSE_ERROR: ParseError,
+ TYPE_MISMATCH: TypeMismatchError,
+ TARGETING_KEY_MISSING: TargetingKeyMissingError,
+ INVALID_CONTEXT: InvalidContextError,
+ GENERAL: GeneralError,
+ }
+
+ @classmethod
+ def to_exception(
+ cls, error_code: ErrorCode, error_message: str
+ ) -> OpenFeatureError:
+ exc = cls.__exceptions__.get(error_code.value, GeneralError)
+ return exc(error_message)
diff --git a/openfeature/flag_evaluation.py b/openfeature/flag_evaluation.py
index 26b565ad..86233ed2 100644
--- a/openfeature/flag_evaluation.py
+++ b/openfeature/flag_evaluation.py
@@ -6,8 +6,9 @@
from openfeature._backports.strenum import StrEnum
from openfeature.exception import ErrorCode
-if typing.TYPE_CHECKING: # resolves a circular dependency in type annotations
- from openfeature.hook import Hook
+if typing.TYPE_CHECKING: # pragma: no cover
+ # resolves a circular dependency in type annotations
+ from openfeature.hook import Hook, HookHints
class FlagType(StrEnum):
@@ -48,7 +49,7 @@ class FlagEvaluationDetails(typing.Generic[T_co]):
@dataclass
class FlagEvaluationOptions:
hooks: typing.List[Hook] = field(default_factory=list)
- hook_hints: dict = field(default_factory=dict)
+ hook_hints: HookHints = field(default_factory=dict)
U_co = typing.TypeVar("U_co", covariant=True)
@@ -62,3 +63,8 @@ class FlagResolutionDetails(typing.Generic[U_co]):
reason: typing.Optional[typing.Union[str, Reason]] = None
variant: typing.Optional[str] = None
flag_metadata: FlagMetadata = field(default_factory=dict)
+
+ def raise_for_error(self) -> None:
+ if self.error_code:
+ raise ErrorCode.to_exception(self.error_code, self.error_message or "")
+ return None
diff --git a/openfeature/hook/__init__.py b/openfeature/hook/__init__.py
index 13748aac..8cb1c14c 100644
--- a/openfeature/hook/__init__.py
+++ b/openfeature/hook/__init__.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import typing
-from dataclasses import dataclass
+from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
@@ -20,24 +20,53 @@ class HookType(Enum):
ERROR = "error"
-@dataclass
class HookContext:
- flag_key: str
- flag_type: FlagType
- default_value: typing.Any
- evaluation_context: EvaluationContext
- client_metadata: typing.Optional[ClientMetadata] = None
- provider_metadata: typing.Optional[Metadata] = None
+ def __init__( # noqa: PLR0913
+ self,
+ flag_key: str,
+ flag_type: FlagType,
+ default_value: typing.Any,
+ evaluation_context: EvaluationContext,
+ client_metadata: typing.Optional[ClientMetadata] = None,
+ provider_metadata: typing.Optional[Metadata] = None,
+ ):
+ self.flag_key = flag_key
+ self.flag_type = flag_type
+ self.default_value = default_value
+ self.evaluation_context = evaluation_context
+ self.client_metadata = client_metadata
+ self.provider_metadata = provider_metadata
def __setattr__(self, key: str, value: typing.Any) -> None:
- if hasattr(self, key) and key in ("flag_key", "flag_type", "default_value"):
+ if hasattr(self, key) and key in (
+ "flag_key",
+ "flag_type",
+ "default_value",
+ "client_metadata",
+ "provider_metadata",
+ ):
raise AttributeError(f"Attribute {key!r} is immutable")
super().__setattr__(key, value)
+# https://openfeature.dev/specification/sections/hooks/#requirement-421
+HookHints = typing.Mapping[
+ str,
+ typing.Union[
+ bool,
+ int,
+ float,
+ str,
+ datetime,
+ typing.List[typing.Any],
+ typing.Dict[str, typing.Any],
+ ],
+]
+
+
class Hook:
def before(
- self, hook_context: HookContext, hints: dict
+ self, hook_context: HookContext, hints: HookHints
) -> typing.Optional[EvaluationContext]:
"""
Runs before flag is resolved.
@@ -54,7 +83,7 @@ def after(
self,
hook_context: HookContext,
details: FlagEvaluationDetails[typing.Any],
- hints: dict,
+ hints: HookHints,
) -> None:
"""
Runs after a flag is resolved.
@@ -67,7 +96,7 @@ def after(
pass
def error(
- self, hook_context: HookContext, exception: Exception, hints: dict
+ self, hook_context: HookContext, exception: Exception, hints: HookHints
) -> None:
"""
Run when evaluation encounters an error. Errors thrown will be swallowed.
@@ -78,7 +107,7 @@ def error(
"""
pass
- def finally_after(self, hook_context: HookContext, hints: dict) -> None:
+ def finally_after(self, hook_context: HookContext, hints: HookHints) -> None:
"""
Run after flag evaluation, including any error processing.
This will always run. Errors will be swallowed.
diff --git a/openfeature/hook/hook_support.py b/openfeature/hook/hook_support.py
index 9bbfd492..349b25f3 100644
--- a/openfeature/hook/hook_support.py
+++ b/openfeature/hook/hook_support.py
@@ -4,7 +4,7 @@
from openfeature.evaluation_context import EvaluationContext
from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType
-from openfeature.hook import Hook, HookContext, HookType
+from openfeature.hook import Hook, HookContext, HookHints, HookType
logger = logging.getLogger("openfeature")
@@ -14,7 +14,7 @@ def error_hooks(
hook_context: HookContext,
exception: Exception,
hooks: typing.List[Hook],
- hints: typing.Optional[typing.Mapping] = None,
+ hints: typing.Optional[HookHints] = None,
) -> None:
kwargs = {"hook_context": hook_context, "exception": exception, "hints": hints}
_execute_hooks(
@@ -26,7 +26,7 @@ def after_all_hooks(
flag_type: FlagType,
hook_context: HookContext,
hooks: typing.List[Hook],
- hints: typing.Optional[typing.Mapping] = None,
+ hints: typing.Optional[HookHints] = None,
) -> None:
kwargs = {"hook_context": hook_context, "hints": hints}
_execute_hooks(
@@ -39,7 +39,7 @@ def after_hooks(
hook_context: HookContext,
details: FlagEvaluationDetails[typing.Any],
hooks: typing.List[Hook],
- hints: typing.Optional[typing.Mapping] = None,
+ hints: typing.Optional[HookHints] = None,
) -> None:
kwargs = {"hook_context": hook_context, "details": details, "hints": hints}
_execute_hooks_unchecked(
@@ -51,7 +51,7 @@ def before_hooks(
flag_type: FlagType,
hook_context: HookContext,
hooks: typing.List[Hook],
- hints: typing.Optional[typing.Mapping] = None,
+ hints: typing.Optional[HookHints] = None,
) -> EvaluationContext:
kwargs = {"hook_context": hook_context, "hints": hints}
executed_hooks = _execute_hooks_unchecked(
diff --git a/openfeature/provider/__init__.py b/openfeature/provider/__init__.py
index d5ddacff..30ed103f 100644
--- a/openfeature/provider/__init__.py
+++ b/openfeature/provider/__init__.py
@@ -1,4 +1,5 @@
import typing
+from enum import Enum
from openfeature.evaluation_context import EvaluationContext
from openfeature.flag_evaluation import FlagResolutionDetails
@@ -7,55 +8,54 @@
from .metadata import Metadata
+class ProviderStatus(Enum):
+ NOT_READY = "NOT_READY"
+ READY = "READY"
+ ERROR = "ERROR"
+ STALE = "STALE"
+ FATAL = "FATAL"
+
+
class FeatureProvider(typing.Protocol): # pragma: no cover
- def initialize(self, evaluation_context: EvaluationContext) -> None:
- ...
+ def initialize(self, evaluation_context: EvaluationContext) -> None: ...
- def shutdown(self) -> None:
- ...
+ def shutdown(self) -> None: ...
- def get_metadata(self) -> Metadata:
- ...
+ def get_metadata(self) -> Metadata: ...
- def get_provider_hooks(self) -> typing.List[Hook]:
- ...
+ def get_provider_hooks(self) -> typing.List[Hook]: ...
def resolve_boolean_details(
self,
flag_key: str,
default_value: bool,
evaluation_context: typing.Optional[EvaluationContext] = None,
- ) -> FlagResolutionDetails[bool]:
- ...
+ ) -> FlagResolutionDetails[bool]: ...
def resolve_string_details(
self,
flag_key: str,
default_value: str,
evaluation_context: typing.Optional[EvaluationContext] = None,
- ) -> FlagResolutionDetails[str]:
- ...
+ ) -> FlagResolutionDetails[str]: ...
def resolve_integer_details(
self,
flag_key: str,
default_value: int,
evaluation_context: typing.Optional[EvaluationContext] = None,
- ) -> FlagResolutionDetails[int]:
- ...
+ ) -> FlagResolutionDetails[int]: ...
def resolve_float_details(
self,
flag_key: str,
default_value: float,
evaluation_context: typing.Optional[EvaluationContext] = None,
- ) -> FlagResolutionDetails[float]:
- ...
+ ) -> FlagResolutionDetails[float]: ...
def resolve_object_details(
self,
flag_key: str,
default_value: typing.Union[dict, list],
evaluation_context: typing.Optional[EvaluationContext] = None,
- ) -> FlagResolutionDetails[typing.Union[dict, list]]:
- ...
+ ) -> FlagResolutionDetails[typing.Union[dict, list]]: ...
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 55b59931..8d764465 100644
--- a/openfeature/provider/registry.py
+++ b/openfeature/provider/registry.py
@@ -1,18 +1,27 @@
import typing
+from openfeature._event_support import run_handlers_for_provider
from openfeature.evaluation_context import EvaluationContext
-from openfeature.exception import GeneralError
-from openfeature.provider import FeatureProvider
+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
class ProviderRegistry:
_default_provider: FeatureProvider
_providers: typing.Dict[str, FeatureProvider]
+ _provider_status: typing.Dict[FeatureProvider, ProviderStatus]
def __init__(self) -> None:
self._default_provider = NoOpProvider()
self._providers = {}
+ self._provider_status = {
+ self._default_provider: ProviderStatus.READY,
+ }
def set_provider(self, domain: str, provider: FeatureProvider) -> None:
if provider is None:
@@ -22,9 +31,9 @@ def set_provider(self, domain: str, provider: FeatureProvider) -> None:
old_provider = providers[domain]
del providers[domain]
if old_provider not in providers.values():
- old_provider.shutdown()
+ self._shutdown_provider(old_provider)
if provider not in providers.values():
- provider.initialize(self._get_evaluation_context())
+ self._initialize_provider(provider)
providers[domain] = provider
def get_provider(self, domain: typing.Optional[str]) -> FeatureProvider:
@@ -36,9 +45,9 @@ def set_default_provider(self, provider: FeatureProvider) -> None:
if provider is None:
raise GeneralError(error_message="No provider")
if self._default_provider:
- self._default_provider.shutdown()
+ self._shutdown_provider(self._default_provider)
self._default_provider = provider
- provider.initialize(self._get_evaluation_context())
+ self._initialize_provider(provider)
def get_default_provider(self) -> FeatureProvider:
return self._default_provider
@@ -47,13 +56,49 @@ 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()}:
- provider.shutdown()
+ self._shutdown_provider(provider)
def _get_evaluation_context(self) -> EvaluationContext:
# imported here to avoid circular imports
from openfeature.api import get_evaluation_context
return get_evaluation_context()
+
+ def _initialize_provider(self, provider: FeatureProvider) -> None:
+ try:
+ if hasattr(provider, "initialize"):
+ provider.initialize(self._get_evaluation_context())
+ self._set_provider_status(provider, ProviderStatus.READY)
+ except Exception as err:
+ if (
+ isinstance(err, OpenFeatureError)
+ and err.error_code == ErrorCode.PROVIDER_FATAL
+ ):
+ self._set_provider_status(provider, ProviderStatus.FATAL)
+ else:
+ self._set_provider_status(provider, ProviderStatus.ERROR)
+
+ def _shutdown_provider(self, provider: FeatureProvider) -> None:
+ try:
+ if hasattr(provider, "shutdown"):
+ provider.shutdown()
+ self._set_provider_status(provider, ProviderStatus.NOT_READY)
+ except Exception:
+ self._set_provider_status(provider, ProviderStatus.FATAL)
+
+ def get_provider_status(self, provider: FeatureProvider) -> ProviderStatus:
+ return self._provider_status.get(provider, ProviderStatus.NOT_READY)
+
+ 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/pyproject.toml b/pyproject.toml
index b07d7535..5a48847c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openfeature_sdk"
-version = "0.5.0"
+version = "0.6.0"
description = "Standardizing Feature Flagging for Everyone"
readme = "README.md"
authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }]
@@ -29,6 +29,7 @@ Homepage = "https://github.com/open-feature/python-sdk"
files = "openfeature"
namespace_packages = true
explicit_package_bases = true
+local_partial_types = true # will become the new default from version 2
pretty = true
strict = true
disallow_any_generics = false
diff --git a/release-please-config.json b/release-please-config.json
index c5942ddc..45dc90c5 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -12,5 +12,60 @@
"README.md"
]
}
- }
+ },
+ "changelog-sections": [
+ {
+ "type": "fix",
+ "section": "๐ Bug Fixes"
+ },
+ {
+ "type": "feat",
+ "section": "โจ New Features"
+ },
+ {
+ "type": "chore",
+ "section": "๐งน Chore"
+ },
+ {
+ "type": "docs",
+ "section": "๐ Documentation"
+ },
+ {
+ "type": "perf",
+ "section": "๐ Performance"
+ },
+ {
+ "type": "build",
+ "hidden": true,
+ "section": "๐ ๏ธ Build"
+ },
+ {
+ "type": "deps",
+ "section": "๐ฆ Dependencies"
+ },
+ {
+ "type": "ci",
+ "hidden": true,
+ "section": "๐ฆ CI"
+ },
+ {
+ "type": "refactor",
+ "section": "๐ Refactoring"
+ },
+ {
+ "type": "revert",
+ "section": "๐ Reverts"
+ },
+ {
+ "type": "style",
+ "hidden": true,
+ "section": "๐จ Styling"
+ },
+ {
+ "type": "test",
+ "hidden": true,
+ "section": "๐งช Tests"
+ }
+ ],
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
diff --git a/requirements.txt b/requirements.txt
index 92de9921..aacf1897 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
-pytest==8.0.1
-pytest-mock==3.12.0
+pytest==8.1.1
+pytest-mock==3.14.0
pre-commit
-coverage==7.4.2
+coverage==7.4.4
behave==1.2.6
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/hook/test_hook_support.py b/tests/hook/test_hook_support.py
index 69ceb8da..37e06eee 100644
--- a/tests/hook/test_hook_support.py
+++ b/tests/hook/test_hook_support.py
@@ -40,10 +40,14 @@ def test_hook_context_has_immutable_and_mutable_fields():
4.1.3 - The "flag key", "flag type", and "default value" properties MUST be immutable.
4.1.4.1 - The evaluation context MUST be mutable only within the before hook.
+ 4.2.2.2 - The client "metadata" field in the "hook context" MUST be immutable.
+ 4.2.2.3 - The provider "metadata" field in the "hook context" MUST be immutable.
"""
# Given
- hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext())
+ hook_context = HookContext(
+ "flag_key", FlagType.BOOLEAN, True, EvaluationContext(), ClientMetadata("name")
+ )
# When
with pytest.raises(AttributeError):
@@ -52,10 +56,12 @@ def test_hook_context_has_immutable_and_mutable_fields():
hook_context.flag_type = FlagType.STRING
with pytest.raises(AttributeError):
hook_context.default_value = "new_value"
+ with pytest.raises(AttributeError):
+ hook_context.client_metadata = ClientMetadata("new_name")
+ with pytest.raises(AttributeError):
+ hook_context.provider_metadata = Metadata("name")
hook_context.evaluation_context = EvaluationContext("targeting_key")
- hook_context.client_metadata = ClientMetadata("name")
- hook_context.provider_metadata = Metadata("name")
# Then
assert hook_context.flag_key == "flag_key"
@@ -63,7 +69,7 @@ def test_hook_context_has_immutable_and_mutable_fields():
assert hook_context.default_value is True
assert hook_context.evaluation_context.targeting_key == "targeting_key"
assert hook_context.client_metadata.name == "name"
- assert hook_context.provider_metadata.name == "name"
+ assert hook_context.provider_metadata is None
def test_error_hooks_run_error_method(mock_hook):
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 71873405..dc25abee 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -2,11 +2,13 @@
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.flag_evaluation import FlagResolutionDetails, Reason
from openfeature.hook import Hook
+from openfeature.provider import FeatureProvider, ProviderStatus
from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider
from openfeature.provider.no_op_provider import NoOpProvider
@@ -182,3 +184,175 @@ def test_should_call_api_level_hooks(no_op_provider_client):
# Then
api_hook.before.assert_called_once()
api_hook.after.assert_called_once()
+
+
+# Requirement 1.7.5
+def test_should_define_a_provider_status_accessor(no_op_provider_client):
+ # When
+ status = no_op_provider_client.get_provider_status()
+ # Then
+ assert status is not None
+ assert status == ProviderStatus.READY
+
+
+# Requirement 1.7.6
+def test_should_shortcircuit_if_provider_is_not_ready(
+ no_op_provider_client, monkeypatch
+):
+ # Given
+ monkeypatch.setattr(
+ no_op_provider_client, "get_provider_status", lambda: ProviderStatus.NOT_READY
+ )
+ spy_hook = MagicMock(spec=Hook)
+ no_op_provider_client.add_hooks([spy_hook])
+ # When
+ flag_details = no_op_provider_client.get_boolean_details(
+ flag_key="Key", default_value=True
+ )
+ # Then
+ assert flag_details is not None
+ assert flag_details.value
+ assert flag_details.reason == Reason.ERROR
+ assert flag_details.error_code == ErrorCode.PROVIDER_NOT_READY
+ spy_hook.error.assert_called_once()
+
+
+# Requirement 1.7.7
+def test_should_shortcircuit_if_provider_is_in_irrecoverable_error_state(
+ no_op_provider_client, monkeypatch
+):
+ # Given
+ monkeypatch.setattr(
+ no_op_provider_client, "get_provider_status", lambda: ProviderStatus.FATAL
+ )
+ spy_hook = MagicMock(spec=Hook)
+ no_op_provider_client.add_hooks([spy_hook])
+ # When
+ flag_details = no_op_provider_client.get_boolean_details(
+ flag_key="Key", default_value=True
+ )
+ # Then
+ assert flag_details is not None
+ assert flag_details.value
+ assert flag_details.reason == Reason.ERROR
+ assert flag_details.error_code == ErrorCode.PROVIDER_FATAL
+ spy_hook.error.assert_called_once()
+
+
+def test_should_run_error_hooks_if_provider_returns_resolution_with_error_code():
+ # Given
+ spy_hook = MagicMock(spec=Hook)
+ provider = MagicMock(spec=FeatureProvider)
+ provider.get_provider_hooks.return_value = []
+ provider.resolve_boolean_details.return_value = FlagResolutionDetails(
+ value=True,
+ reason=Reason.ERROR,
+ error_code=ErrorCode.PROVIDER_FATAL,
+ error_message="This is an error message",
+ )
+ set_provider(provider)
+ client = get_client()
+ client.add_hooks([spy_hook])
+ # When
+ flag_details = client.get_boolean_details(flag_key="Key", default_value=True)
+ # Then
+ assert flag_details is not None
+ assert flag_details.value
+ 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)