diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7dfd20..990b380 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,10 +80,10 @@ jobs: python-version: "3.13" - name: Initialize CodeQL - uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 + uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3 with: languages: python config-file: ./.github/codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3 + uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1c321a0..5962fb3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ default_stages: [pre-commit] repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.3 + rev: v0.11.4 hooks: - id: ruff args: [--fix] diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..ddabdfe --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @open-feature/sdk-python-maintainers @open-feature/maintainers diff --git a/openfeature/client.py b/openfeature/client.py index 92edab3..55c1930 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -444,12 +444,12 @@ class OpenFeatureClient: def _assert_provider_status( self, - ) -> None: + ) -> typing.Optional[OpenFeatureError]: status = self.get_provider_status() if status == ProviderStatus.NOT_READY: - raise ProviderNotReadyError() + return ProviderNotReadyError() if status == ProviderStatus.FATAL: - raise ProviderFatalError() + return ProviderFatalError() return None def _before_hooks_and_merge_context( @@ -509,7 +509,22 @@ class OpenFeatureClient: ) try: - self._assert_provider_status() + if provider_err := self._assert_provider_status(): + error_hooks( + flag_type, + hook_context, + provider_err, + reversed_merged_hooks, + hook_hints, + ) + flag_evaluation = FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=provider_err.error_code, + error_message=provider_err.error_message, + ) + return flag_evaluation merged_context = self._before_hooks_and_merge_context( flag_type, @@ -526,6 +541,11 @@ class OpenFeatureClient: default_value, merged_context, ) + if err := flag_evaluation.get_exception(): + error_hooks( + flag_type, hook_context, err, reversed_merged_hooks, hook_hints + ) + return flag_evaluation after_hooks( flag_type, @@ -605,7 +625,22 @@ class OpenFeatureClient: ) try: - self._assert_provider_status() + if provider_err := self._assert_provider_status(): + error_hooks( + flag_type, + hook_context, + provider_err, + reversed_merged_hooks, + hook_hints, + ) + flag_evaluation = FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=provider_err.error_code, + error_message=provider_err.error_message, + ) + return flag_evaluation merged_context = self._before_hooks_and_merge_context( flag_type, @@ -622,6 +657,12 @@ class OpenFeatureClient: default_value, merged_context, ) + if err := flag_evaluation.get_exception(): + error_hooks( + flag_type, hook_context, err, reversed_merged_hooks, hook_hints + ) + flag_evaluation.value = default_value + return flag_evaluation after_hooks( flag_type, @@ -691,27 +732,33 @@ class OpenFeatureClient: } get_details_callable = get_details_callables_async.get(flag_type) if not get_details_callable: - raise GeneralError(error_message="Unknown flag type") + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.GENERAL, + error_message="Unknown flag type", + ) resolution = await get_details_callable( flag_key=flag_key, default_value=default_value, evaluation_context=evaluation_context, ) - resolution.raise_for_error() + if resolution.error_code: + return resolution.to_flag_evaluation_details(flag_key) # we need to check the get_args to be compatible with union types. - _typecheck_flag_value(resolution.value, flag_type) + if err := _typecheck_flag_value(value=resolution.value, flag_type=flag_type): + return FlagEvaluationDetails( + flag_key=flag_key, + value=resolution.value, + reason=Reason.ERROR, + error_code=err.error_code, + error_message=err.error_message, + ) - return FlagEvaluationDetails( - flag_key=flag_key, - value=resolution.value, - variant=resolution.variant, - flag_metadata=resolution.flag_metadata or {}, - reason=resolution.reason, - error_code=resolution.error_code, - error_message=resolution.error_message, - ) + return resolution.to_flag_evaluation_details(flag_key) def _create_provider_evaluation( self, @@ -741,27 +788,33 @@ class OpenFeatureClient: get_details_callable = get_details_callables.get(flag_type) if not get_details_callable: - raise GeneralError(error_message="Unknown flag type") + return FlagEvaluationDetails( + flag_key=flag_key, + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.GENERAL, + error_message="Unknown flag type", + ) resolution = get_details_callable( flag_key=flag_key, default_value=default_value, evaluation_context=evaluation_context, ) - resolution.raise_for_error() + if resolution.error_code: + return resolution.to_flag_evaluation_details(flag_key) # we need to check the get_args to be compatible with union types. - _typecheck_flag_value(resolution.value, flag_type) + if err := _typecheck_flag_value(value=resolution.value, flag_type=flag_type): + return FlagEvaluationDetails( + flag_key=flag_key, + value=resolution.value, + reason=Reason.ERROR, + error_code=err.error_code, + error_message=err.error_message, + ) - return FlagEvaluationDetails( - flag_key=flag_key, - value=resolution.value, - variant=resolution.variant, - flag_metadata=resolution.flag_metadata or {}, - reason=resolution.reason, - error_code=resolution.error_code, - error_message=resolution.error_message, - ) + return resolution.to_flag_evaluation_details(flag_key) def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None: _event_support.add_client_handler(self, event, handler) @@ -770,7 +823,9 @@ class OpenFeatureClient: _event_support.remove_client_handler(self, event, handler) -def _typecheck_flag_value(value: typing.Any, flag_type: FlagType) -> None: +def _typecheck_flag_value( + value: typing.Any, flag_type: FlagType +) -> typing.Optional[OpenFeatureError]: type_map: TypeMap = { FlagType.BOOLEAN: bool, FlagType.STRING: str, @@ -780,6 +835,7 @@ def _typecheck_flag_value(value: typing.Any, flag_type: FlagType) -> None: } _type = type_map.get(flag_type) if not _type: - raise GeneralError(error_message="Unknown flag type") + return GeneralError(error_message="Unknown flag type") if not isinstance(value, _type): - raise TypeMismatchError(f"Expected type {_type} but got {type(value)}") + return TypeMismatchError(f"Expected type {_type} but got {type(value)}") + return None diff --git a/openfeature/flag_evaluation.py b/openfeature/flag_evaluation.py index c522eec..c26ea48 100644 --- a/openfeature/flag_evaluation.py +++ b/openfeature/flag_evaluation.py @@ -4,7 +4,7 @@ import typing from dataclasses import dataclass, field from openfeature._backports.strenum import StrEnum -from openfeature.exception import ErrorCode +from openfeature.exception import ErrorCode, OpenFeatureError if typing.TYPE_CHECKING: # pragma: no cover # resolves a circular dependency in type annotations @@ -56,6 +56,11 @@ class FlagEvaluationDetails(typing.Generic[T_co]): error_code: typing.Optional[ErrorCode] = None error_message: typing.Optional[str] = None + def get_exception(self) -> typing.Optional[OpenFeatureError]: + if self.error_code: + return ErrorCode.to_exception(self.error_code, self.error_message or "") + return None + @dataclass class FlagEvaluationOptions: @@ -79,3 +84,14 @@ class FlagResolutionDetails(typing.Generic[U_co]): if self.error_code: raise ErrorCode.to_exception(self.error_code, self.error_message or "") return None + + def to_flag_evaluation_details(self, flag_key: str) -> FlagEvaluationDetails[U_co]: + return FlagEvaluationDetails( + flag_key=flag_key, + value=self.value, + variant=self.variant, + flag_metadata=self.flag_metadata, + reason=self.reason, + error_code=self.error_code, + error_message=self.error_message, + ) diff --git a/openfeature/provider/in_memory_provider.py b/openfeature/provider/in_memory_provider.py index 3bd3fa1..1348186 100644 --- a/openfeature/provider/in_memory_provider.py +++ b/openfeature/provider/in_memory_provider.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from openfeature._backports.strenum import StrEnum from openfeature.evaluation_context import EvaluationContext -from openfeature.exception import FlagNotFoundError +from openfeature.exception import ErrorCode from openfeature.flag_evaluation import FlagMetadata, FlagResolutionDetails, Reason from openfeature.hook import Hook from openfeature.provider import AbstractProvider, Metadata @@ -74,7 +74,7 @@ class InMemoryProvider(AbstractProvider): default_value: bool, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: - return self._resolve(flag_key, evaluation_context) + return self._resolve(flag_key, default_value, evaluation_context) async def resolve_boolean_details_async( self, @@ -82,7 +82,7 @@ class InMemoryProvider(AbstractProvider): default_value: bool, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[bool]: - return await self._resolve_async(flag_key, evaluation_context) + return await self._resolve_async(flag_key, default_value, evaluation_context) def resolve_string_details( self, @@ -90,7 +90,7 @@ class InMemoryProvider(AbstractProvider): default_value: str, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[str]: - return self._resolve(flag_key, evaluation_context) + return self._resolve(flag_key, default_value, evaluation_context) async def resolve_string_details_async( self, @@ -98,7 +98,7 @@ class InMemoryProvider(AbstractProvider): default_value: str, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[str]: - return await self._resolve_async(flag_key, evaluation_context) + return await self._resolve_async(flag_key, default_value, evaluation_context) def resolve_integer_details( self, @@ -106,7 +106,7 @@ class InMemoryProvider(AbstractProvider): default_value: int, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[int]: - return self._resolve(flag_key, evaluation_context) + return self._resolve(flag_key, default_value, evaluation_context) async def resolve_integer_details_async( self, @@ -114,7 +114,7 @@ class InMemoryProvider(AbstractProvider): default_value: int, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[int]: - return await self._resolve_async(flag_key, evaluation_context) + return await self._resolve_async(flag_key, default_value, evaluation_context) def resolve_float_details( self, @@ -122,7 +122,7 @@ class InMemoryProvider(AbstractProvider): default_value: float, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[float]: - return self._resolve(flag_key, evaluation_context) + return self._resolve(flag_key, default_value, evaluation_context) async def resolve_float_details_async( self, @@ -130,7 +130,7 @@ class InMemoryProvider(AbstractProvider): default_value: float, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[float]: - return await self._resolve_async(flag_key, evaluation_context) + return await self._resolve_async(flag_key, default_value, evaluation_context) def resolve_object_details( self, @@ -138,7 +138,7 @@ class InMemoryProvider(AbstractProvider): default_value: typing.Union[dict, list], evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[typing.Union[dict, list]]: - return self._resolve(flag_key, evaluation_context) + return self._resolve(flag_key, default_value, evaluation_context) async def resolve_object_details_async( self, @@ -146,21 +146,28 @@ class InMemoryProvider(AbstractProvider): default_value: typing.Union[dict, list], evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagResolutionDetails[typing.Union[dict, list]]: - return await self._resolve_async(flag_key, evaluation_context) + return await self._resolve_async(flag_key, default_value, evaluation_context) def _resolve( self, flag_key: str, + default_value: V, evaluation_context: typing.Optional[EvaluationContext], ) -> FlagResolutionDetails[V]: flag = self._flags.get(flag_key) if flag is None: - raise FlagNotFoundError(f"Flag '{flag_key}' not found") + return FlagResolutionDetails( + value=default_value, + reason=Reason.ERROR, + error_code=ErrorCode.FLAG_NOT_FOUND, + error_message=f"Flag '{flag_key}' not found", + ) return flag.resolve(evaluation_context) async def _resolve_async( self, flag_key: str, + default_value: V, evaluation_context: typing.Optional[EvaluationContext], ) -> FlagResolutionDetails[V]: - return self._resolve(flag_key, evaluation_context) + return self._resolve(flag_key, default_value, evaluation_context) diff --git a/spec b/spec index 27e4461..0cd553d 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 27e4461b452429ec64bcb89af0301f7664cb702a +Subproject commit 0cd553d85f03b0b7ab983a988ce1720a3190bc88 diff --git a/tests/provider/test_in_memory_provider.py b/tests/provider/test_in_memory_provider.py index cdcea7b..2f17a89 100644 --- a/tests/provider/test_in_memory_provider.py +++ b/tests/provider/test_in_memory_provider.py @@ -2,7 +2,7 @@ from numbers import Number import pytest -from openfeature.exception import FlagNotFoundError +from openfeature.exception import ErrorCode from openfeature.flag_evaluation import FlagResolutionDetails, Reason from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider @@ -22,11 +22,18 @@ async def test_should_handle_unknown_flags_correctly(): # Given provider = InMemoryProvider({}) # When - with pytest.raises(FlagNotFoundError): - provider.resolve_boolean_details(flag_key="Key", default_value=True) - with pytest.raises(FlagNotFoundError): - await provider.resolve_integer_details_async(flag_key="Key", default_value=1) + flag_sync = provider.resolve_boolean_details(flag_key="Key", default_value=True) + flag_async = await provider.resolve_boolean_details_async( + flag_key="Key", default_value=True + ) # Then + assert flag_sync == flag_async + for flag in [flag_sync, flag_async]: + assert flag is not None + assert flag.value is True + assert flag.reason == Reason.ERROR + assert flag.error_code == ErrorCode.FLAG_NOT_FOUND + assert flag.error_message == "Flag 'Key' not found" @pytest.mark.asyncio diff --git a/tests/test_client.py b/tests/test_client.py index 9264018..76b9dde 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,11 +8,11 @@ import pytest from openfeature import api from openfeature.api import add_hooks, clear_hooks, get_client, set_provider -from openfeature.client import GeneralError, OpenFeatureClient, _typecheck_flag_value +from openfeature.client import OpenFeatureClient, _typecheck_flag_value from openfeature.evaluation_context import EvaluationContext from openfeature.event import EventDetails, ProviderEvent, ProviderEventDetails from openfeature.exception import ErrorCode, OpenFeatureError -from openfeature.flag_evaluation import FlagResolutionDetails, Reason +from openfeature.flag_evaluation import FlagResolutionDetails, FlagType, Reason from openfeature.hook import Hook from openfeature.provider import FeatureProvider, ProviderStatus from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider @@ -364,15 +364,27 @@ async def test_client_type_mismatch_exceptions(): @pytest.mark.asyncio -async def test_client_general_exception(): +async def test_typecheck_flag_value_general_error(): # Given flag_value = "A" flag_type = None # When - with pytest.raises(GeneralError) as e: - flag_type = _typecheck_flag_value(flag_value, flag_type) + err = _typecheck_flag_value(value=flag_value, flag_type=flag_type) # Then - assert e.value.error_message == "Unknown flag type" + assert err.error_code == ErrorCode.GENERAL + assert err.error_message == "Unknown flag type" + + +@pytest.mark.asyncio +async def test_typecheck_flag_value_type_mismatch_error(): + # Given + flag_value = "A" + flag_type = FlagType.BOOLEAN + # When + err = _typecheck_flag_value(value=flag_value, flag_type=flag_type) + # Then + assert err.error_code == ErrorCode.TYPE_MISMATCH + assert err.error_message == "Expected type but got " def test_provider_events():