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