From bafa427a0da40711d327c435ab199286f68fb6b7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 24 Feb 2024 00:25:56 +0100 Subject: [PATCH 01/16] chore(deps): update dependency coverage to v7.4.3 (#280) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 92de9921..37f3ce3e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ pytest==8.0.1 pytest-mock==3.12.0 pre-commit -coverage==7.4.2 +coverage==7.4.3 behave==1.2.6 From b2594a567c31e48a1ae675b855e84300201e8132 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 25 Feb 2024 00:45:32 -0300 Subject: [PATCH 02/16] chore(deps): update dependency pytest to v8.0.2 (#281) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 37f3ce3e..4fb1cfb6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pytest==8.0.1 +pytest==8.0.2 pytest-mock==3.12.0 pre-commit coverage==7.4.3 From 141858d2359bf6bf439426b3ea4ba322f4b10421 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 26 Feb 2024 18:56:00 -0500 Subject: [PATCH 03/16] chore: add changelog sections (#282) Signed-off-by: Todd Baert --- release-please-config.json | 57 +++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) 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" } From 5acd6a6598fa45326ddafb0184d184cadea826d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Gr=C3=BCbel?= Date: Sun, 3 Mar 2024 04:38:14 +0100 Subject: [PATCH 04/16] refactor: improve Hook Hints typing (#285) * improve Hook Hints typing Signed-off-by: gruebel * ignore lint issue for this line Signed-off-by: gruebel * exclude TYPE_CHECKING from coverage report Signed-off-by: gruebel --------- Signed-off-by: gruebel --- openfeature/flag_evaluation.py | 7 ++-- openfeature/hook/__init__.py | 55 ++++++++++++++++++++++++-------- openfeature/hook/hook_support.py | 10 +++--- tests/hook/test_hook_support.py | 14 +++++--- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/openfeature/flag_evaluation.py b/openfeature/flag_evaluation.py index 26b565ad..98adab4b 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) 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/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): From ae26217328a5ca07722c5e12b01720606259d805 Mon Sep 17 00:00:00 2001 From: Zhiwei Date: Sun, 3 Mar 2024 10:04:45 -0500 Subject: [PATCH 05/16] docs: add Missing Imports in Provider Dev Example in README (#286) docs: add missing imports in provider dev example in README Signed-off-by: Zhiwei --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e8874573..47731700 100644 --- a/README.md +++ b/README.md @@ -235,10 +235,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): From 7ba7d6146f0f801cadfd7593dc6df4b7d4f488d4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 3 Mar 2024 23:06:47 +0100 Subject: [PATCH 06/16] chore(deps): update dependency pytest to v8.1.0 (#287) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4fb1cfb6..879a1e43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pytest==8.0.2 +pytest==8.1.0 pytest-mock==3.12.0 pre-commit coverage==7.4.3 From 789e6e0f5fcf499604261afd918ed1e8844fa0a0 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Fri, 8 Mar 2024 09:00:32 +1100 Subject: [PATCH 07/16] feat: implement provider status (#288) * feat: implement provider status Signed-off-by: Federico Bond * feat: set provider status to fatal if initialize raises PROVIDER_FATAL error Signed-off-by: Federico Bond * feat: add a provider status accessor to clients Signed-off-by: Federico Bond * feat: short circuit flag resolution when provider is not ready Signed-off-by: Federico Bond --------- Signed-off-by: Federico Bond --- openfeature/client.py | 40 +++++++++++++++++++++-- openfeature/exception.py | 31 ++++++++++++++++++ openfeature/provider/__init__.py | 9 ++++++ openfeature/provider/registry.py | 47 ++++++++++++++++++++++----- tests/test_client.py | 54 ++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 9 deletions(-) diff --git a/openfeature/client.py b/openfeature/client.py index deac93c8..1ccee33b 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -8,6 +8,8 @@ ErrorCode, GeneralError, OpenFeatureError, + ProviderFatalError, + ProviderNotReadyError, TypeMismatchError, ) from openfeature.flag_evaluation import ( @@ -24,7 +26,7 @@ before_hooks, error_hooks, ) -from openfeature.provider import FeatureProvider +from openfeature.provider import FeatureProvider, ProviderStatus logger = logging.getLogger("openfeature") @@ -81,6 +83,10 @@ def __init__( 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) + 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 diff --git a/openfeature/exception.py b/openfeature/exception.py index e8a4768d..e6ad2456 100644 --- a/openfeature/exception.py +++ b/openfeature/exception.py @@ -4,6 +4,7 @@ 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" @@ -31,6 +32,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 diff --git a/openfeature/provider/__init__.py b/openfeature/provider/__init__.py index d5ddacff..edb94ae1 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,6 +8,14 @@ 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: ... diff --git a/openfeature/provider/registry.py b/openfeature/provider/registry.py index 55b59931..779ee569 100644 --- a/openfeature/provider/registry.py +++ b/openfeature/provider/registry.py @@ -1,18 +1,21 @@ import typing from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import GeneralError -from openfeature.provider import FeatureProvider +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._set_provider_status(self._default_provider, ProviderStatus.NOT_READY) def set_provider(self, domain: str, provider: FeatureProvider) -> None: if provider is None: @@ -22,9 +25,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 +39,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 @@ -50,10 +53,40 @@ def clear_providers(self) -> None: 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 diff --git a/tests/test_client.py b/tests/test_client.py index 71873405..5f710609 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,6 +7,7 @@ from openfeature.exception import ErrorCode, OpenFeatureError from openfeature.flag_evaluation import Reason from openfeature.hook import Hook +from openfeature.provider import ProviderStatus from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider from openfeature.provider.no_op_provider import NoOpProvider @@ -182,3 +183,56 @@ 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() From 3f336b3a248dd8e75e162870d26a4b97c61f2ff6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 9 Mar 2024 16:47:21 +0100 Subject: [PATCH 08/16] chore(deps): update dependency pytest to v8.1.1 (#289) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 879a1e43..bfd380d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -pytest==8.1.0 +pytest==8.1.1 pytest-mock==3.12.0 pre-commit coverage==7.4.3 From e7475441bd14323431fdf1850e643f5aaaa21abd Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Thu, 14 Mar 2024 08:31:46 +1100 Subject: [PATCH 09/16] fix: run error hooks if provider returns FlagResolutionDetails with non-empty error_code (#291) * fix: run error hooks if provider returns FlagResolutionDetails with non-empty error_code Signed-off-by: Federico Bond * refactor: extract error code to exception mapping to class variable Signed-off-by: Federico Bond --------- Signed-off-by: Federico Bond --- openfeature/client.py | 1 + openfeature/exception.py | 43 +++++++++++++++++++++++++--------- openfeature/flag_evaluation.py | 5 ++++ tests/test_client.py | 30 +++++++++++++++++++++--- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/openfeature/client.py b/openfeature/client.py index 1ccee33b..b27866ce 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -425,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) diff --git a/openfeature/exception.py b/openfeature/exception.py index e6ad2456..d17c28fb 100644 --- a/openfeature/exception.py +++ b/openfeature/exception.py @@ -1,18 +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" - 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" - - class OpenFeatureError(Exception): """ A generic open feature exception, this exception should not be raised. Instead @@ -156,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 98adab4b..86233ed2 100644 --- a/openfeature/flag_evaluation.py +++ b/openfeature/flag_evaluation.py @@ -63,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/tests/test_client.py b/tests/test_client.py index 5f710609..43223d99 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,12 +2,12 @@ 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.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 ProviderStatus +from openfeature.provider import FeatureProvider, ProviderStatus from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider from openfeature.provider.no_op_provider import NoOpProvider @@ -236,3 +236,27 @@ def test_should_shortcircuit_if_provider_is_in_irrecoverable_error_state( 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() From f5987ef8f41892c9cad776d7716592ac0eac4719 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 15 Mar 2024 07:41:41 +1100 Subject: [PATCH 10/16] chore(deps): update dependency coverage to v7.4.4 (#293) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index bfd380d1..eed06931 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ pytest==8.1.1 pytest-mock==3.12.0 pre-commit -coverage==7.4.3 +coverage==7.4.4 behave==1.2.6 From 6e4eebce2073aa792444ea9f28906b9c925ebd75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Gr=C3=BCbel?= Date: Mon, 18 Mar 2024 20:16:16 +0100 Subject: [PATCH 11/16] chore: update mypy and ruff (#296) update mypy and ruff Signed-off-by: gruebel --- .pre-commit-config.yaml | 4 ++-- openfeature/provider/__init__.py | 27 +++++++++------------------ pyproject.toml | 1 + 3 files changed, 12 insertions(+), 20 deletions(-) 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/openfeature/provider/__init__.py b/openfeature/provider/__init__.py index edb94ae1..30ed103f 100644 --- a/openfeature/provider/__init__.py +++ b/openfeature/provider/__init__.py @@ -17,54 +17,45 @@ class ProviderStatus(Enum): 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/pyproject.toml b/pyproject.toml index b07d7535..da96ea8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 From 04b4009dbfd112307e17a6f9273e0118ad337fe1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 22 Mar 2024 07:22:24 +1100 Subject: [PATCH 12/16] chore(deps): update dependency pytest-mock to v3.13.0 (#298) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index eed06931..3beb126e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ pytest==8.1.1 -pytest-mock==3.12.0 +pytest-mock==3.13.0 pre-commit coverage==7.4.4 behave==1.2.6 From 679409fad229d0e675be4a8ee2b3a13860f4e987 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Fri, 22 Mar 2024 07:45:00 +1100 Subject: [PATCH 13/16] feat: implement provider events (#278) * feat: implement provider events Signed-off-by: Federico Bond * feat: add error_code field to EventDetails and ProviderEventDetails Signed-off-by: Federico Bond * fix: replace strings with postponed evaluation of annotations Signed-off-by: Federico Bond * feat: run handlers immediately if provider already in associated state Signed-off-by: Federico Bond * feat: remove unused _provider from openfeature.api Signed-off-by: Federico Bond * test: add some comments to test cases Signed-off-by: Federico Bond * test: add provider event late binding test cases Signed-off-by: Federico Bond * fix: fix status handlers running immediately if provider already in associated state Signed-off-by: Federico Bond * refactor: reuse provider property in OpenFeatureClient Signed-off-by: Federico Bond * refactor: move _provider_status_to_event to ProviderEvent.from_provider_status Signed-off-by: Federico Bond * refactor: move EventSupport class to an internal module Signed-off-by: Federico Bond * refactor: replace EventSupport class with module-level functions Signed-off-by: Federico Bond * style: fix code style --------- Signed-off-by: Federico Bond --- README.md | 23 +++++++- openfeature/_event_support.py | 89 +++++++++++++++++++++++++++++ openfeature/api.py | 16 +++++- openfeature/client.py | 12 +++- openfeature/event.py | 60 ++++++++++++++++++++ openfeature/provider/provider.py | 19 +++++++ openfeature/provider/registry.py | 16 +++++- tests/conftest.py | 5 +- tests/test_api.py | 89 ++++++++++++++++++++++++++++- tests/test_client.py | 96 ++++++++++++++++++++++++++++++++ 10 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 openfeature/_event_support.py create mode 100644 openfeature/event.py 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) From a70ae0cb2e5322cc6290dbe5be12f0a665cc0e86 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 22 Mar 2024 13:25:00 +1100 Subject: [PATCH 14/16] chore(deps): update dependency pytest-mock to v3.14.0 (#300) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 3beb126e..aacf1897 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ pytest==8.1.1 -pytest-mock==3.13.0 +pytest-mock==3.14.0 pre-commit coverage==7.4.4 behave==1.2.6 From 58d27c4011b4f7fd96cc7d1ba10f017c7a3db958 Mon Sep 17 00:00:00 2001 From: Federico Bond Date: Fri, 22 Mar 2024 18:06:08 +1100 Subject: [PATCH 15/16] docs: update spec version to 0.8.0 (#299) Release-As: 0.6.0 Signed-off-by: Federico Bond --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 18017e1e..fd75b465 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@

- - Specification + + Specification From 2c23c9e9711645e12f80cc26aa1d6f7665053062 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 22 Mar 2024 15:30:50 +0100 Subject: [PATCH 16/16] chore(main): release 0.6.0 (#283) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 38 +++++++++++++++++++++++++++++++++++ README.md | 8 ++++---- pyproject.toml | 2 +- 4 files changed, 44 insertions(+), 6 deletions(-) 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 fd75b465..d7546966 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ - - Latest version + + Latest version @@ -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 diff --git a/pyproject.toml b/pyproject.toml index da96ea8f..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" }]