diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7023c114..990b3802 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,7 +29,7 @@ jobs: submodules: recursive - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 with: python-version: ${{ matrix.python-version }} cache: "pip" @@ -46,7 +46,7 @@ jobs: - if: matrix.python-version == '3.13' name: Upload coverage to Codecov - uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # v5.3.1 + uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0 with: flags: unittests # optional name: coverage # optional @@ -59,7 +59,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 + - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 with: python-version: "3.13" cache: "pip" @@ -75,15 +75,15 @@ jobs: security-events: write steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 + - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 with: python-version: "3.13" - name: Initialize CodeQL - uses: github/codeql-action/init@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # 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@9e8d0789d4a0fa9ceb6b1738f7e269594bdd67f0 # v3 + uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d6ac2cb..2ca616b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 + - uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5 with: python-version: '3.13' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 197d17b5..5962fb3a 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.9.6 + rev: v0.11.4 hooks: - id: ruff args: [--fix] diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 02e64b96..88b915dd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"0.8.0"} \ No newline at end of file +{".":"0.8.1"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a43a48ef..4aa027f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # Changelog +## [0.8.1](https://github.com/open-feature/python-sdk/compare/v0.8.0...v0.8.1) (2025-04-09) + + +### ๐Ÿ› Bug Fixes + +* add passthrough init to abstract provider ([#450](https://github.com/open-feature/python-sdk/issues/450)) ([088409e](https://github.com/open-feature/python-sdk/commit/088409ea5cdefef33f28fc4f45026fabac52377a)) +* fix cycle dependency between api and client ([#480](https://github.com/open-feature/python-sdk/issues/480)) ([3636a0d](https://github.com/open-feature/python-sdk/commit/3636a0d75f69712844a768cbc6c2f80fdcf6eb84)) + + +### โœจ New Features + +* add OTel utility function ([#451](https://github.com/open-feature/python-sdk/issues/451)) ([2d1ba85](https://github.com/open-feature/python-sdk/commit/2d1ba85c93cdd954f539d2872783b21683bd8b07)) + + +### ๐Ÿงน Chore + +* add codeowner file to be consistent with the rest of openfeature ([#477](https://github.com/open-feature/python-sdk/issues/477)) ([7a30ef9](https://github.com/open-feature/python-sdk/commit/7a30ef914b3180fc72be9a1d2072a8a288e8b54d)) +* **deps:** update actions/setup-python digest to 8d9ed9a ([#473](https://github.com/open-feature/python-sdk/issues/473)) ([a135911](https://github.com/open-feature/python-sdk/commit/a1359112e9c1d740bcca501cbb5aadd9da3602b6)) +* **deps:** update codecov/codecov-action action to v5.4.0 ([#456](https://github.com/open-feature/python-sdk/issues/456)) ([a666227](https://github.com/open-feature/python-sdk/commit/a666227f55b14d4d2b6e43b6487ac643b6893739)) +* **deps:** update github/codeql-action digest to 1b549b9 ([#470](https://github.com/open-feature/python-sdk/issues/470)) ([4eeab3b](https://github.com/open-feature/python-sdk/commit/4eeab3b6914bd947a63f8d3c5bb89b85b7c2ced1)) +* **deps:** update github/codeql-action digest to 45775bd ([#483](https://github.com/open-feature/python-sdk/issues/483)) ([5a2825b](https://github.com/open-feature/python-sdk/commit/5a2825b00db0653c6d0496ec7f4703f9125cbed7)) +* **deps:** update github/codeql-action digest to 5f8171a ([#467](https://github.com/open-feature/python-sdk/issues/467)) ([d69b759](https://github.com/open-feature/python-sdk/commit/d69b7594a956a49385ef3030c212624d628aec74)) +* **deps:** update github/codeql-action digest to 6bb031a ([#462](https://github.com/open-feature/python-sdk/issues/462)) ([0396592](https://github.com/open-feature/python-sdk/commit/0396592586b6f721754c18a46b6d2fee3c2f80e8)) +* **deps:** update github/codeql-action digest to b56ba49 ([#454](https://github.com/open-feature/python-sdk/issues/454)) ([613388d](https://github.com/open-feature/python-sdk/commit/613388ddde33b6ce5ff3a39760970297dfa83255)) +* **deps:** update github/codeql-action digest to fc7e4a0 ([#481](https://github.com/open-feature/python-sdk/issues/481)) ([1ae9fc2](https://github.com/open-feature/python-sdk/commit/1ae9fc2361f1671cee8c794f02c01eb6ca0b77a6)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.10.0 ([#463](https://github.com/open-feature/python-sdk/issues/463)) ([5fede4d](https://github.com/open-feature/python-sdk/commit/5fede4d4f0cb6e39f84e85c72c9a2dd13434bc78)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.0 ([#465](https://github.com/open-feature/python-sdk/issues/465)) ([d1eb3a0](https://github.com/open-feature/python-sdk/commit/d1eb3a08a8da75022788cc4b9ea7b7d95aec4e69)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.1 ([#468](https://github.com/open-feature/python-sdk/issues/468)) ([c07d3d6](https://github.com/open-feature/python-sdk/commit/c07d3d64677c2ce475b098580b5eba1dd7f95a2e)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.2 ([#469](https://github.com/open-feature/python-sdk/issues/469)) ([95e87c7](https://github.com/open-feature/python-sdk/commit/95e87c71fc835cde7f7528e974509438ab8f2dc3)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.3 ([#475](https://github.com/open-feature/python-sdk/issues/475)) ([2be2c06](https://github.com/open-feature/python-sdk/commit/2be2c06569d89309a70793bb14a82be91d2ccf20)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.11.4 ([#476](https://github.com/open-feature/python-sdk/issues/476)) ([8acc883](https://github.com/open-feature/python-sdk/commit/8acc88328836c70f168ca87b71f4c49a6dba9381)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.10 ([#461](https://github.com/open-feature/python-sdk/issues/461)) ([9057c6b](https://github.com/open-feature/python-sdk/commit/9057c6b3df6ca5dc9e429db231eb4427cce031ea)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.7 ([#453](https://github.com/open-feature/python-sdk/issues/453)) ([a5cb27b](https://github.com/open-feature/python-sdk/commit/a5cb27b67839d60ea631001759478b2e74b75f28)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.8 ([#457](https://github.com/open-feature/python-sdk/issues/457)) ([0c1a388](https://github.com/open-feature/python-sdk/commit/0c1a388ca121e232f5c36b4b7a550d541ae34e5b)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.9.9 ([#458](https://github.com/open-feature/python-sdk/issues/458)) ([9ce51eb](https://github.com/open-feature/python-sdk/commit/9ce51ebff5a896b818e241fd8e3c2dea2fee610c)) +* **deps:** update spec digest to 09aef37 ([#460](https://github.com/open-feature/python-sdk/issues/460)) ([547781f](https://github.com/open-feature/python-sdk/commit/547781fbd82d1e2ee8a17988d11da3875d6a73dd)) +* **deps:** update spec digest to 0cd553d ([#455](https://github.com/open-feature/python-sdk/issues/455)) ([fe99f08](https://github.com/open-feature/python-sdk/commit/fe99f08e9465e8d35dd2b187d8ac01eae98432b7)) +* **deps:** update spec digest to 130df3e ([#471](https://github.com/open-feature/python-sdk/issues/471)) ([9ced6bf](https://github.com/open-feature/python-sdk/commit/9ced6bf2d1c7e3b0f01d062564ee63e49254af00)) +* **deps:** update spec digest to 25c57ee ([#459](https://github.com/open-feature/python-sdk/issues/459)) ([40cbd82](https://github.com/open-feature/python-sdk/commit/40cbd82dda20604a7a7be00e6913710d4a1ab56f)) +* **deps:** update spec digest to 27e4461 ([#472](https://github.com/open-feature/python-sdk/issues/472)) ([490cd06](https://github.com/open-feature/python-sdk/commit/490cd068533bb5ad702adf71915b6e0ac49706d8)) +* **deps:** update spec digest to 54952f3 ([#447](https://github.com/open-feature/python-sdk/issues/447)) ([f907855](https://github.com/open-feature/python-sdk/commit/f907855966cf788a3522e7626c76bd050de59a7e)) +* **deps:** update spec digest to a69f748 ([#452](https://github.com/open-feature/python-sdk/issues/452)) ([95b33b3](https://github.com/open-feature/python-sdk/commit/95b33b39e6ef472264002322162e83665054d71b)) +* **deps:** update spec digest to aad6193 ([#464](https://github.com/open-feature/python-sdk/issues/464)) ([d15388b](https://github.com/open-feature/python-sdk/commit/d15388b542798f7703578927dc5013863a83efa1)) +* improve resolve details callable type hints ([#449](https://github.com/open-feature/python-sdk/issues/449)) ([31afa64](https://github.com/open-feature/python-sdk/commit/31afa6490f7c2fc7a553b69c56840d494a520836)) +* revert spec to commit 0cd553d ([#479](https://github.com/open-feature/python-sdk/issues/479)) ([0ebec53](https://github.com/open-feature/python-sdk/commit/0ebec538db4d1180bad05e89bb62db23ca606a27)) +* use existing submodule version for e2e tests ([#444](https://github.com/open-feature/python-sdk/issues/444)) ([5ae8571](https://github.com/open-feature/python-sdk/commit/5ae8571ccd5f30c0aef87b0bc7f1a08a65254df0)) +* use keyword arguments, validate test ([#446](https://github.com/open-feature/python-sdk/issues/446)) ([f29c450](https://github.com/open-feature/python-sdk/commit/f29c4506a6a13307ba95a9b450a1b19c328975b3)) + + +### ๐Ÿ“š Documentation + +* fix linting issue on the readme ([1198728](https://github.com/open-feature/python-sdk/commit/11987280ba53ba087b1792316acc920a81434630)) + + +### ๐Ÿ”„ Refactoring + +* replace exception raising with error flag resolution ([#474](https://github.com/open-feature/python-sdk/issues/474)) ([e61b69b](https://github.com/open-feature/python-sdk/commit/e61b69bb5079547c62a3ad51499326057db69e7a)) + ## [0.8.0](https://github.com/open-feature/python-sdk/compare/v0.7.5...v0.8.0) (2025-02-11) diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..ddabdfe4 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @open-feature/sdk-python-maintainers @open-feature/maintainers diff --git a/README.md b/README.md index c018e180..af778c12 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.8.0 +pip install openfeature-sdk==0.8.1 ``` #### requirements.txt ```bash -openfeature-sdk==0.8.0 +openfeature-sdk==0.8.1 ``` ```python @@ -411,8 +411,9 @@ class MyProvider(AbstractProvider): Providers can also be extended to support async functionality. To support add asynchronous calls to a provider: -* Implement the `AbstractProvider` as shown above. -* Define asynchronous calls for each data type. + +- Implement the `AbstractProvider` as shown above. +- Define asynchronous calls for each data type. ```python class MyProvider(AbstractProvider): diff --git a/openfeature/api.py b/openfeature/api.py index 36432d0c..d2b22e9d 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -2,19 +2,22 @@ from openfeature import _event_support from openfeature.client import OpenFeatureClient -from openfeature.evaluation_context import EvaluationContext +from openfeature.evaluation_context import ( + get_evaluation_context, + set_evaluation_context, +) from openfeature.event import ( EventHandler, ProviderEvent, ) -from openfeature.exception import GeneralError -from openfeature.hook import Hook +from openfeature.hook import add_hooks, clear_hooks, get_hooks from openfeature.provider import FeatureProvider from openfeature.provider._registry import provider_registry from openfeature.provider.metadata import Metadata -from openfeature.transaction_context import TransactionContextPropagator -from openfeature.transaction_context.no_op_transaction_context_propagator import ( - NoOpTransactionContextPropagator, +from openfeature.transaction_context import ( + get_transaction_context, + set_transaction_context, + set_transaction_context_propagator, ) __all__ = [ @@ -35,13 +38,6 @@ "shutdown", ] -_evaluation_context = EvaluationContext() -_evaluation_transaction_context_propagator: TransactionContextPropagator = ( - NoOpTransactionContextPropagator() -) - -_hooks: list[Hook] = [] - def get_client( domain: typing.Optional[str] = None, version: typing.Optional[str] = None @@ -67,49 +63,6 @@ def get_provider_metadata(domain: typing.Optional[str] = None) -> Metadata: return provider_registry.get_provider(domain).get_metadata() -def get_evaluation_context() -> EvaluationContext: - return _evaluation_context - - -def set_evaluation_context(evaluation_context: EvaluationContext) -> None: - global _evaluation_context - if evaluation_context is None: - raise GeneralError(error_message="No api level evaluation context") - _evaluation_context = evaluation_context - - -def set_transaction_context_propagator( - transaction_context_propagator: TransactionContextPropagator, -) -> None: - global _evaluation_transaction_context_propagator - _evaluation_transaction_context_propagator = transaction_context_propagator - - -def get_transaction_context() -> EvaluationContext: - return _evaluation_transaction_context_propagator.get_transaction_context() - - -def set_transaction_context(evaluation_context: EvaluationContext) -> None: - global _evaluation_transaction_context_propagator - _evaluation_transaction_context_propagator.set_transaction_context( - evaluation_context - ) - - -def add_hooks(hooks: list[Hook]) -> None: - global _hooks - _hooks = _hooks + hooks - - -def clear_hooks() -> None: - global _hooks - _hooks = [] - - -def get_hooks() -> list[Hook]: - return _hooks - - def shutdown() -> None: provider_registry.shutdown() diff --git a/openfeature/client.py b/openfeature/client.py index 7d1f26df..55c19309 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -1,9 +1,10 @@ import logging import typing +from collections.abc import Awaitable from dataclasses import dataclass -from openfeature import _event_support, api -from openfeature.evaluation_context import EvaluationContext +from openfeature import _event_support +from openfeature.evaluation_context import EvaluationContext, get_evaluation_context from openfeature.event import EventHandler, ProviderEvent from openfeature.exception import ( ErrorCode, @@ -20,7 +21,7 @@ FlagType, Reason, ) -from openfeature.hook import Hook, HookContext, HookHints +from openfeature.hook import Hook, HookContext, HookHints, get_hooks from openfeature.hook._hook_support import ( after_all_hooks, after_hooks, @@ -29,6 +30,7 @@ ) from openfeature.provider import FeatureProvider, ProviderStatus from openfeature.provider._registry import provider_registry +from openfeature.transaction_context import get_transaction_context __all__ = [ "ClientMetadata", @@ -37,46 +39,6 @@ logger = logging.getLogger("openfeature") -GetDetailCallable = typing.Union[ - typing.Callable[ - [str, bool, typing.Optional[EvaluationContext]], FlagResolutionDetails[bool] - ], - typing.Callable[ - [str, int, typing.Optional[EvaluationContext]], FlagResolutionDetails[int] - ], - typing.Callable[ - [str, float, typing.Optional[EvaluationContext]], FlagResolutionDetails[float] - ], - typing.Callable[ - [str, str, typing.Optional[EvaluationContext]], FlagResolutionDetails[str] - ], - typing.Callable[ - [str, typing.Union[dict, list], typing.Optional[EvaluationContext]], - FlagResolutionDetails[typing.Union[dict, list]], - ], -] -GetDetailCallableAsync = typing.Union[ - typing.Callable[ - [str, bool, typing.Optional[EvaluationContext]], - typing.Awaitable[FlagResolutionDetails[bool]], - ], - typing.Callable[ - [str, int, typing.Optional[EvaluationContext]], - typing.Awaitable[FlagResolutionDetails[int]], - ], - typing.Callable[ - [str, float, typing.Optional[EvaluationContext]], - typing.Awaitable[FlagResolutionDetails[float]], - ], - typing.Callable[ - [str, str, typing.Optional[EvaluationContext]], - typing.Awaitable[FlagResolutionDetails[str]], - ], - typing.Callable[ - [str, typing.Union[dict, list], typing.Optional[EvaluationContext]], - typing.Awaitable[FlagResolutionDetails[typing.Union[dict, list]]], - ], -] TypeMap = dict[ FlagType, typing.Union[ @@ -88,6 +50,26 @@ ], ] +T = typing.TypeVar("T", bool, int, float, str, typing.Union[dict, list]) + + +class ResolveDetailsCallable(typing.Protocol[T]): + def __call__( + self, + flag_key: str, + default_value: T, + evaluation_context: typing.Optional[EvaluationContext], + ) -> FlagResolutionDetails[T]: ... + + +class ResolveDetailsCallableAsync(typing.Protocol[T]): + def __call__( + self, + flag_key: str, + default_value: T, + evaluation_context: typing.Optional[EvaluationContext], + ) -> Awaitable[FlagResolutionDetails[T]]: ... + @dataclass class ClientMetadata: @@ -452,10 +434,7 @@ def _establish_hooks_and_provider( # in the flag evaluation # before: API, Client, Invocation, Provider merged_hooks = ( - api.get_hooks() - + self.hooks - + evaluation_hooks - + provider.get_provider_hooks() + get_hooks() + self.hooks + evaluation_hooks + provider.get_provider_hooks() ) # after, error, finally: Provider, Invocation, Client, API reversed_merged_hooks = merged_hooks[:] @@ -465,12 +444,12 @@ def _establish_hooks_and_provider( 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( @@ -493,8 +472,8 @@ def _before_hooks_and_merge_context( # Requirement 3.2.2 merge: API.context->transaction.context->client.context->invocation.context merged_context = ( - api.get_evaluation_context() - .merge(api.get_transaction_context()) + get_evaluation_context() + .merge(get_transaction_context()) .merge(self.context) .merge(invocation_context) ) @@ -530,7 +509,22 @@ async def evaluate_flag_details_async( ) 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, @@ -547,6 +541,11 @@ async def evaluate_flag_details_async( 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, @@ -626,7 +625,22 @@ def evaluate_flag_details( ) 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, @@ -643,6 +657,12 @@ def evaluate_flag_details( 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, @@ -701,13 +721,8 @@ async def _create_provider_evaluation_async( default_value: typing.Any, evaluation_context: typing.Optional[EvaluationContext] = None, ) -> FlagEvaluationDetails[typing.Any]: - args = ( - flag_key, - default_value, - evaluation_context, - ) get_details_callables_async: typing.Mapping[ - FlagType, GetDetailCallableAsync + FlagType, ResolveDetailsCallableAsync ] = { FlagType.BOOLEAN: provider.resolve_boolean_details_async, FlagType.INTEGER: provider.resolve_integer_details_async, @@ -717,23 +732,33 @@ async def _create_provider_evaluation_async( } 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(*args) - resolution.raise_for_error() + resolution = await get_details_callable( + flag_key=flag_key, + default_value=default_value, + evaluation_context=evaluation_context, + ) + 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, @@ -753,13 +778,7 @@ def _create_provider_evaluation( :return: a FlagEvaluationDetails object with the fully evaluated flag from a provider """ - args = ( - flag_key, - default_value, - evaluation_context, - ) - - get_details_callables: typing.Mapping[FlagType, GetDetailCallable] = { + get_details_callables: typing.Mapping[FlagType, ResolveDetailsCallable] = { FlagType.BOOLEAN: provider.resolve_boolean_details, FlagType.INTEGER: provider.resolve_integer_details, FlagType.FLOAT: provider.resolve_float_details, @@ -769,23 +788,33 @@ def _create_provider_evaluation( 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(*args) - resolution.raise_for_error() + resolution = get_details_callable( + flag_key=flag_key, + default_value=default_value, + evaluation_context=evaluation_context, + ) + 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) @@ -794,7 +823,9 @@ 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: +def _typecheck_flag_value( + value: typing.Any, flag_type: FlagType +) -> typing.Optional[OpenFeatureError]: type_map: TypeMap = { FlagType.BOOLEAN: bool, FlagType.STRING: str, @@ -804,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/evaluation_context.py b/openfeature/evaluation_context.py deleted file mode 100644 index c3af350c..00000000 --- a/openfeature/evaluation_context.py +++ /dev/null @@ -1,19 +0,0 @@ -import typing -from dataclasses import dataclass, field - -__all__ = ["EvaluationContext"] - - -@dataclass -class EvaluationContext: - targeting_key: typing.Optional[str] = None - attributes: dict = field(default_factory=dict) - - def merge(self, ctx2: "EvaluationContext") -> "EvaluationContext": - if not (self and ctx2): - return self or ctx2 - - attributes = {**self.attributes, **ctx2.attributes} - targeting_key = ctx2.targeting_key or self.targeting_key - - return EvaluationContext(targeting_key=targeting_key, attributes=attributes) diff --git a/openfeature/evaluation_context/__init__.py b/openfeature/evaluation_context/__init__.py new file mode 100644 index 00000000..f1170a12 --- /dev/null +++ b/openfeature/evaluation_context/__init__.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import typing +from dataclasses import dataclass, field + +from openfeature.exception import GeneralError + +__all__ = ["EvaluationContext", "get_evaluation_context", "set_evaluation_context"] + + +@dataclass +class EvaluationContext: + targeting_key: typing.Optional[str] = None + attributes: dict = field(default_factory=dict) + + def merge(self, ctx2: EvaluationContext) -> EvaluationContext: + if not (self and ctx2): + return self or ctx2 + + attributes = {**self.attributes, **ctx2.attributes} + targeting_key = ctx2.targeting_key or self.targeting_key + + return EvaluationContext(targeting_key=targeting_key, attributes=attributes) + + +def get_evaluation_context() -> EvaluationContext: + return _evaluation_context + + +def set_evaluation_context(evaluation_context: EvaluationContext) -> None: + global _evaluation_context + if evaluation_context is None: + raise GeneralError(error_message="No api level evaluation context") + _evaluation_context = evaluation_context + + +# need to be at the bottom, because of the definition order +_evaluation_context = EvaluationContext() diff --git a/openfeature/exception.py b/openfeature/exception.py index 0576ec17..0912ef5f 100644 --- a/openfeature/exception.py +++ b/openfeature/exception.py @@ -2,7 +2,8 @@ import typing from collections.abc import Mapping -from enum import Enum + +from openfeature._backports.strenum import StrEnum __all__ = [ "ErrorCode", @@ -163,7 +164,7 @@ def __init__(self, error_message: typing.Optional[str]): super().__init__(ErrorCode.INVALID_CONTEXT, error_message) -class ErrorCode(Enum): +class ErrorCode(StrEnum): PROVIDER_NOT_READY = "PROVIDER_NOT_READY" PROVIDER_FATAL = "PROVIDER_FATAL" FLAG_NOT_FOUND = "FLAG_NOT_FOUND" diff --git a/openfeature/flag_evaluation.py b/openfeature/flag_evaluation.py index 5cab623c..c26ea485 100644 --- a/openfeature/flag_evaluation.py +++ b/openfeature/flag_evaluation.py @@ -4,7 +4,7 @@ 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 @@ -34,8 +34,9 @@ class Reason(StrEnum): DEFAULT = "DEFAULT" DISABLED = "DISABLED" ERROR = "ERROR" - STATIC = "STATIC" SPLIT = "SPLIT" + STATIC = "STATIC" + STALE = "STALE" TARGETING_MATCH = "TARGETING_MATCH" UNKNOWN = "UNKNOWN" @@ -55,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: @@ -78,3 +84,14 @@ def raise_for_error(self) -> None: 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/hook/__init__.py b/openfeature/hook/__init__.py index 16fa4bdd..e881fdb4 100644 --- a/openfeature/hook/__init__.py +++ b/openfeature/hook/__init__.py @@ -12,7 +12,17 @@ from openfeature.client import ClientMetadata from openfeature.provider.metadata import Metadata -__all__ = ["Hook", "HookContext", "HookHints", "HookType"] +__all__ = [ + "Hook", + "HookContext", + "HookHints", + "HookType", + "add_hooks", + "clear_hooks", + "get_hooks", +] + +_hooks: list[Hook] = [] class HookType(Enum): @@ -133,3 +143,17 @@ def supports_flag_value_type(self, flag_type: FlagType) -> bool: or not (False) """ return True + + +def add_hooks(hooks: list[Hook]) -> None: + global _hooks + _hooks = _hooks + hooks + + +def clear_hooks() -> None: + global _hooks + _hooks = [] + + +def get_hooks() -> list[Hook]: + return _hooks diff --git a/openfeature/provider/__init__.py b/openfeature/provider/__init__.py index 5b9ffd09..17300c0a 100644 --- a/openfeature/provider/__init__.py +++ b/openfeature/provider/__init__.py @@ -112,6 +112,10 @@ async def resolve_object_details_async( class AbstractProvider(FeatureProvider): + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: + # this makes sure to invoke the parent of `FeatureProvider` -> `object` + super(FeatureProvider, self).__init__(*args, **kwargs) + def attach( self, on_emit: typing.Callable[ diff --git a/openfeature/provider/_registry.py b/openfeature/provider/_registry.py index 78412f1e..1b5ff041 100644 --- a/openfeature/provider/_registry.py +++ b/openfeature/provider/_registry.py @@ -1,7 +1,7 @@ import typing from openfeature._event_support import run_handlers_for_provider -from openfeature.evaluation_context import EvaluationContext +from openfeature.evaluation_context import EvaluationContext, get_evaluation_context from openfeature.event import ( ProviderEvent, ProviderEventDetails, @@ -65,9 +65,6 @@ def shutdown(self) -> None: 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: diff --git a/openfeature/provider/in_memory_provider.py b/openfeature/provider/in_memory_provider.py index 3bd3fa1b..13481861 100644 --- a/openfeature/provider/in_memory_provider.py +++ b/openfeature/provider/in_memory_provider.py @@ -3,7 +3,7 @@ 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 @@ def resolve_boolean_details( 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 @@ async def resolve_boolean_details_async( 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 @@ def resolve_string_details( 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 @@ async def resolve_string_details_async( 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 @@ def resolve_integer_details( 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 @@ async def resolve_integer_details_async( 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 @@ def resolve_float_details( 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 @@ async def resolve_float_details_async( 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 @@ def resolve_object_details( 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 @@ async def resolve_object_details_async( 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/openfeature/telemetry/__init__.py b/openfeature/telemetry/__init__.py new file mode 100644 index 00000000..a4b82ab6 --- /dev/null +++ b/openfeature/telemetry/__init__.py @@ -0,0 +1,75 @@ +import typing +from collections.abc import Mapping +from dataclasses import dataclass + +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagEvaluationDetails, Reason +from openfeature.hook import HookContext +from openfeature.telemetry.attributes import TelemetryAttribute +from openfeature.telemetry.body import TelemetryBodyField +from openfeature.telemetry.metadata import TelemetryFlagMetadata + +__all__ = [ + "EvaluationEvent", + "TelemetryAttribute", + "TelemetryBodyField", + "TelemetryFlagMetadata", + "create_evaluation_event", +] + +FLAG_EVALUATION_EVENT_NAME = "feature_flag.evaluation" + +T_co = typing.TypeVar("T_co", covariant=True) + + +@dataclass +class EvaluationEvent(typing.Generic[T_co]): + name: str + attributes: Mapping[TelemetryAttribute, typing.Union[str, T_co]] + body: Mapping[TelemetryBodyField, T_co] + + +def create_evaluation_event( + hook_context: HookContext, details: FlagEvaluationDetails[T_co] +) -> EvaluationEvent[T_co]: + attributes = { + TelemetryAttribute.KEY: details.flag_key, + TelemetryAttribute.EVALUATION_REASON: ( + details.reason or Reason.UNKNOWN + ).lower(), + } + body = {} + + if variant := details.variant: + attributes[TelemetryAttribute.VARIANT] = variant + else: + body[TelemetryBodyField.VALUE] = details.value + + context_id = details.flag_metadata.get( + TelemetryFlagMetadata.CONTEXT_ID, hook_context.evaluation_context.targeting_key + ) + if context_id: + attributes[TelemetryAttribute.CONTEXT_ID] = context_id + + if set_id := details.flag_metadata.get(TelemetryFlagMetadata.FLAG_SET_ID): + attributes[TelemetryAttribute.SET_ID] = set_id + + if version := details.flag_metadata.get(TelemetryFlagMetadata.VERSION): + attributes[TelemetryAttribute.VERSION] = version + + if metadata := hook_context.provider_metadata: + attributes[TelemetryAttribute.PROVIDER_NAME] = metadata.name + + if details.reason == Reason.ERROR: + attributes[TelemetryAttribute.ERROR_TYPE] = ( + details.error_code or ErrorCode.GENERAL + ).lower() + + if err_msg := details.error_message: + attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] = err_msg + + return EvaluationEvent( + name=FLAG_EVALUATION_EVENT_NAME, + attributes=attributes, + body=body, + ) diff --git a/openfeature/telemetry/attributes.py b/openfeature/telemetry/attributes.py new file mode 100644 index 00000000..e232cee6 --- /dev/null +++ b/openfeature/telemetry/attributes.py @@ -0,0 +1,19 @@ +from openfeature._backports.strenum import StrEnum + + +class TelemetryAttribute(StrEnum): + """ + The attributes of an OpenTelemetry compliant event for flag evaluation. + + See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + """ + + CONTEXT_ID = "feature_flag.context.id" + ERROR_TYPE = "error.type" + EVALUATION_ERROR_MESSAGE = "feature_flag.evaluation.error.message" + EVALUATION_REASON = "feature_flag.evaluation.reason" + KEY = "feature_flag.key" + PROVIDER_NAME = "feature_flag.provider_name" + SET_ID = "feature_flag.set.id" + VARIANT = "feature_flag.variant" + VERSION = "feature_flag.version" diff --git a/openfeature/telemetry/body.py b/openfeature/telemetry/body.py new file mode 100644 index 00000000..7b47bbff --- /dev/null +++ b/openfeature/telemetry/body.py @@ -0,0 +1,11 @@ +from openfeature._backports.strenum import StrEnum + + +class TelemetryBodyField(StrEnum): + """ + OpenTelemetry event body fields. + + See: https://opentelemetry.io/docs/specs/semconv/feature-flags/feature-flags-logs/ + """ + + VALUE = "value" diff --git a/openfeature/telemetry/metadata.py b/openfeature/telemetry/metadata.py new file mode 100644 index 00000000..5b7b1085 --- /dev/null +++ b/openfeature/telemetry/metadata.py @@ -0,0 +1,13 @@ +from openfeature._backports.strenum import StrEnum + + +class TelemetryFlagMetadata(StrEnum): + """ + Well-known flag metadata attributes for telemetry events. + + See: https://openfeature.dev/specification/appendix-d/#flag-metadata + """ + + CONTEXT_ID = "contextId" + FLAG_SET_ID = "flagSetId" + VERSION = "version" diff --git a/openfeature/transaction_context/__init__.py b/openfeature/transaction_context/__init__.py index ca711cbf..89f05360 100644 --- a/openfeature/transaction_context/__init__.py +++ b/openfeature/transaction_context/__init__.py @@ -1,6 +1,10 @@ +from openfeature.evaluation_context import EvaluationContext from openfeature.transaction_context.context_var_transaction_context_propagator import ( ContextVarsTransactionContextPropagator, ) +from openfeature.transaction_context.no_op_transaction_context_propagator import ( + NoOpTransactionContextPropagator, +) from openfeature.transaction_context.transaction_context_propagator import ( TransactionContextPropagator, ) @@ -8,4 +12,29 @@ __all__ = [ "ContextVarsTransactionContextPropagator", "TransactionContextPropagator", + "get_transaction_context", + "set_transaction_context", + "set_transaction_context_propagator", ] + +_evaluation_transaction_context_propagator: TransactionContextPropagator = ( + NoOpTransactionContextPropagator() +) + + +def set_transaction_context_propagator( + transaction_context_propagator: TransactionContextPropagator, +) -> None: + global _evaluation_transaction_context_propagator + _evaluation_transaction_context_propagator = transaction_context_propagator + + +def get_transaction_context() -> EvaluationContext: + return _evaluation_transaction_context_propagator.get_transaction_context() + + +def set_transaction_context(evaluation_context: EvaluationContext) -> None: + global _evaluation_transaction_context_propagator + _evaluation_transaction_context_propagator.set_transaction_context( + evaluation_context + ) diff --git a/openfeature/version.py b/openfeature/version.py index 777f190d..8088f751 100644 --- a/openfeature/version.py +++ b/openfeature/version.py @@ -1 +1 @@ -__version__ = "0.8.0" +__version__ = "0.8.1" diff --git a/pyproject.toml b/pyproject.toml index 3cb853e0..7b8dcf43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "openfeature_sdk" -version = "0.8.0" +version = "0.8.1" description = "Standardizing Feature Flagging for Everyone" readme = "README.md" authors = [{ name = "OpenFeature", email = "openfeature-core@groups.io" }] @@ -48,7 +48,7 @@ cov = [ "cov-report", ] e2e = [ - "git submodule add --force https://github.com/open-feature/spec.git spec", + "git submodule update --init --recursive", "cp spec/specification/assets/gherkin/* tests/features/", "behave tests/features/", "rm tests/features/*.feature", diff --git a/spec b/spec new file mode 160000 index 00000000..0cd553d8 --- /dev/null +++ b/spec @@ -0,0 +1 @@ +Subproject commit 0cd553d85f03b0b7ab983a988ce1720a3190bc88 diff --git a/tests/provider/test_in_memory_provider.py b/tests/provider/test_in_memory_provider.py index cdcea7bf..2f17a898 100644 --- a/tests/provider/test_in_memory_provider.py +++ b/tests/provider/test_in_memory_provider.py @@ -2,7 +2,7 @@ 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/telemetry/__init__.py b/tests/telemetry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/telemetry/test_evaluation_event.py b/tests/telemetry/test_evaluation_event.py new file mode 100644 index 00000000..3def8655 --- /dev/null +++ b/tests/telemetry/test_evaluation_event.py @@ -0,0 +1,101 @@ +from openfeature.evaluation_context import EvaluationContext +from openfeature.exception import ErrorCode +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagType, Reason +from openfeature.hook import HookContext +from openfeature.provider import Metadata +from openfeature.telemetry import ( + TelemetryAttribute, + TelemetryBodyField, + TelemetryFlagMetadata, + create_evaluation_event, +) + + +def test_create_evaluation_event(): + # given + hook_context = HookContext( + flag_key="flag_key", + flag_type=FlagType.BOOLEAN, + default_value=True, + evaluation_context=EvaluationContext(), + provider_metadata=Metadata(name="test_provider"), + ) + details = FlagEvaluationDetails( + flag_key=hook_context.flag_key, + value=False, + reason=Reason.CACHED, + ) + + # when + event = create_evaluation_event(hook_context=hook_context, details=details) + + # then + assert event.name == "feature_flag.evaluation" + assert event.attributes[TelemetryAttribute.KEY] == "flag_key" + assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "cached" + assert event.attributes[TelemetryAttribute.PROVIDER_NAME] == "test_provider" + assert event.body[TelemetryBodyField.VALUE] is False + + +def test_create_evaluation_event_with_variant(): + # given + hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) + details = FlagEvaluationDetails( + flag_key=hook_context.flag_key, + value=True, + variant="true", + ) + + # when + event = create_evaluation_event(hook_context=hook_context, details=details) + + # then + assert event.name == "feature_flag.evaluation" + assert event.attributes[TelemetryAttribute.KEY] == "flag_key" + assert event.attributes[TelemetryAttribute.VARIANT] == "true" + assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "unknown" + + +def test_create_evaluation_event_with_metadata(): + # given + hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) + details = FlagEvaluationDetails( + flag_key=hook_context.flag_key, + value=False, + flag_metadata={ + TelemetryFlagMetadata.CONTEXT_ID: "5157782b-2203-4c80-a857-dbbd5e7761db", + TelemetryFlagMetadata.FLAG_SET_ID: "proj-1", + TelemetryFlagMetadata.VERSION: "v1", + }, + ) + + # when + event = create_evaluation_event(hook_context=hook_context, details=details) + + # then + assert ( + event.attributes[TelemetryAttribute.CONTEXT_ID] + == "5157782b-2203-4c80-a857-dbbd5e7761db" + ) + assert event.attributes[TelemetryAttribute.SET_ID] == "proj-1" + assert event.attributes[TelemetryAttribute.VERSION] == "v1" + + +def test_create_evaluation_event_with_error(): + # given + hook_context = HookContext("flag_key", FlagType.BOOLEAN, True, EvaluationContext()) + details = FlagEvaluationDetails( + flag_key=hook_context.flag_key, + value=False, + reason=Reason.ERROR, + error_code=ErrorCode.FLAG_NOT_FOUND, + error_message="flag error", + ) + + # when + event = create_evaluation_event(hook_context=hook_context, details=details) + + # then + assert event.attributes[TelemetryAttribute.EVALUATION_REASON] == "error" + assert event.attributes[TelemetryAttribute.ERROR_TYPE] == "flag_not_found" + assert event.attributes[TelemetryAttribute.EVALUATION_ERROR_MESSAGE] == "flag error" diff --git a/tests/test_client.py b/tests/test_client.py index 5d333993..76b9dde9 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,11 +8,11 @@ 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(): @@ -526,12 +538,20 @@ def test_client_should_merge_contexts(): invocation_context = EvaluationContext( targeting_key="invocation", attributes={"invocation_attr": "invocation_value"} ) - client.get_boolean_details("flag", False, invocation_context) + flag_input = "flag" + flag_default = False + client.get_boolean_details(flag_input, flag_default, invocation_context) # Retrieve the call arguments args, kwargs = provider.resolve_boolean_details.call_args - flag_key, default_value, context = args + flag_key, default_value, context = ( + kwargs["flag_key"], + kwargs["default_value"], + kwargs["evaluation_context"], + ) + assert flag_key == flag_input + assert default_value is flag_default assert context.targeting_key == "invocation" # Last one in the merge chain assert context.attributes["global_attr"] == "global_value" assert context.attributes["transaction_attr"] == "transaction_value"