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 @@

- - Specification + + Specification - - 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 @@ -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)