From ca26b1140681b9a0ecf957a3d6046610f39ec3c2 Mon Sep 17 00:00:00 2001 From: balgaly Date: Fri, 17 Apr 2026 22:57:12 +0300 Subject: [PATCH 01/17] test(e2e): add Behave step definitions for context merging Add the missing E2E step definitions so the contextMerging.feature scenarios from the OpenFeature spec run against python-sdk. Fixes #500 Changes: - Bump spec submodule to 130df3eb so contextMerging.feature is copied in during the `poe e2e` task. - Add tests/features/environment.py with a before_scenario hook that resets provider/hook/API-context/transaction-context state, so scenarios cannot leak state between features. - Add tests/features/steps/context_merging_steps.py: - RetrievableContextProvider captures the merged EvaluationContext it receives, so assertions can inspect what the SDK merged. - Step definitions for all scenarios in contextMerging.feature: single-level insert, multi-level insert, and per-key overwrite precedence across API / Transaction / Client / Invocation / Before Hooks. - Client-level context is set via direct attribute assignment on OpenFeatureClient.context (no new setter), since merging already honors client.context (openfeature/client.py:422-429). Runs clean: 4 features / 50 scenarios / 233 steps. Signed-off-by: gruebel --- spec | 2 +- tests/features/environment.py | 15 ++ tests/features/steps/context_merging_steps.py | 215 ++++++++++++++++++ 3 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 tests/features/environment.py create mode 100644 tests/features/steps/context_merging_steps.py diff --git a/spec b/spec index 0cd553d8..130df3eb 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 0cd553d85f03b0b7ab983a988ce1720a3190bc88 +Subproject commit 130df3eb61f1b8d1a121d54f33563ce0ec27c1b6 diff --git a/tests/features/environment.py b/tests/features/environment.py new file mode 100644 index 00000000..4350ddca --- /dev/null +++ b/tests/features/environment.py @@ -0,0 +1,15 @@ +from openfeature import api +from openfeature.evaluation_context import EvaluationContext +from openfeature.transaction_context import ( + ContextVarsTransactionContextPropagator, + set_transaction_context, + set_transaction_context_propagator, +) + + +def before_scenario(context, scenario): + api.clear_providers() + api.clear_hooks() + api.set_evaluation_context(EvaluationContext()) + set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) + set_transaction_context(EvaluationContext()) diff --git a/tests/features/steps/context_merging_steps.py b/tests/features/steps/context_merging_steps.py new file mode 100644 index 00000000..ce083019 --- /dev/null +++ b/tests/features/steps/context_merging_steps.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import typing +from collections.abc import Mapping, Sequence + +from behave import given, then, when + +from openfeature import api +from openfeature.evaluation_context import EvaluationContext +from openfeature.flag_evaluation import FlagResolutionDetails, FlagValueType, Reason +from openfeature.hook import Hook, HookContext, HookHints +from openfeature.provider import AbstractProvider, Metadata +from openfeature.transaction_context import ( + ContextVarsTransactionContextPropagator, + set_transaction_context, + set_transaction_context_propagator, +) + + +class RetrievableContextProvider(AbstractProvider): + """Stores the last merged evaluation context it was asked to resolve.""" + + def __init__(self) -> None: + self.last_context: EvaluationContext | None = None + + def get_metadata(self) -> Metadata: + return Metadata(name="retrievable-context-provider") + + def get_provider_hooks(self) -> list[Hook]: + return [] + + def _capture( + self, default_value: FlagValueType, context: EvaluationContext | None + ) -> FlagResolutionDetails[typing.Any]: + self.last_context = context + return FlagResolutionDetails(value=default_value, reason=Reason.STATIC) + + def resolve_boolean_details( + self, + flag_key: str, + default_value: bool, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[bool]: + return self._capture(default_value, evaluation_context) + + def resolve_string_details( + self, + flag_key: str, + default_value: str, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[str]: + return self._capture(default_value, evaluation_context) + + def resolve_integer_details( + self, + flag_key: str, + default_value: int, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[int]: + return self._capture(default_value, evaluation_context) + + def resolve_float_details( + self, + flag_key: str, + default_value: float, + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[float]: + return self._capture(default_value, evaluation_context) + + def resolve_object_details( + self, + flag_key: str, + default_value: Sequence[FlagValueType] | Mapping[str, FlagValueType], + evaluation_context: EvaluationContext | None = None, + ) -> FlagResolutionDetails[Sequence[FlagValueType] | Mapping[str, FlagValueType]]: + return self._capture(default_value, evaluation_context) + + +class _BeforeHookContextInjector(Hook): + def __init__(self, context: EvaluationContext) -> None: + self._context = context + + def before( + self, hook_context: HookContext, hints: HookHints + ) -> EvaluationContext | None: + return self._context + + +_LEVELS = {"API", "Transaction", "Client", "Invocation", "Before Hooks"} + + +def _ensure_state(context: typing.Any) -> None: + if getattr(context, "_merging_initialized", False): + return + api.clear_providers() + api.clear_hooks() + api.set_evaluation_context(EvaluationContext()) + set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) + set_transaction_context(EvaluationContext()) + + provider = RetrievableContextProvider() + api.set_provider(provider) + context.provider = provider + context.client = api.get_client() + context.invocation_context = EvaluationContext() + context.before_hook_context = EvaluationContext() + context._merging_initialized = True + + +@given("a stable provider with retrievable context is registered") +def step_impl_retrievable_provider(context: typing.Any) -> None: + context._merging_initialized = False + _ensure_state(context) + + +def _add_entry_at_level( + context: typing.Any, level: str, key: str, value: typing.Any +) -> None: + _ensure_state(context) + if level not in _LEVELS: + raise ValueError(f"Unknown level: {level!r}") + if level == "API": + current = api.get_evaluation_context() + api.set_evaluation_context( + EvaluationContext( + targeting_key=current.targeting_key, + attributes={**current.attributes, key: value}, + ) + ) + elif level == "Transaction": + current = api.get_transaction_context() + set_transaction_context( + EvaluationContext( + targeting_key=current.targeting_key, + attributes={**current.attributes, key: value}, + ) + ) + elif level == "Client": + current = context.client.context + context.client.context = EvaluationContext( + targeting_key=current.targeting_key, + attributes={**current.attributes, key: value}, + ) + elif level == "Invocation": + current = context.invocation_context + context.invocation_context = EvaluationContext( + targeting_key=current.targeting_key, + attributes={**current.attributes, key: value}, + ) + elif level == "Before Hooks": + current = context.before_hook_context + context.before_hook_context = EvaluationContext( + targeting_key=current.targeting_key, + attributes={**current.attributes, key: value}, + ) + + +@given( + 'A context entry with key "{key}" and value "{value}" is added to the ' + '"{level}" level' +) +def step_impl_add_entry(context: typing.Any, key: str, value: str, level: str) -> None: + _add_entry_at_level(context, level, key, value) + + +@given("A table with levels of increasing precedence") +def step_impl_levels_table(context: typing.Any) -> None: + _ensure_state(context) + # The feature table is a single-column list of levels. Behave treats the + # first row as the heading, so recombine heading + body rows. + levels = [context.table.headings[0]] + levels.extend(row[0] for row in context.table.rows) + context.precedence_levels = levels + + +@given( + 'Context entries for each level from API level down to the "{level}" level, ' + 'with key "{key}" and value "{value}"' +) +def step_impl_entries_down_to( + context: typing.Any, level: str, key: str, value: str +) -> None: + _ensure_state(context) + levels = context.precedence_levels + if level not in levels: + raise ValueError(f"Level {level!r} not in precedence table {levels!r}") + for current_level in levels: + _add_entry_at_level(context, current_level, key, value) + if current_level == level: + break + + +@when("Some flag was evaluated") +def step_impl_evaluate(context: typing.Any) -> None: + _ensure_state(context) + hook = _BeforeHookContextInjector(context.before_hook_context) + context.client.add_hooks([hook]) + try: + context.client.get_boolean_details( + "some-flag", False, context.invocation_context + ) + finally: + context.client.hooks = [h for h in context.client.hooks if h is not hook] + + +@then('The merged context contains an entry with key "{key}" and value "{value}"') +def step_impl_merged_contains(context: typing.Any, key: str, value: str) -> None: + assert context.provider.last_context is not None, ( + "provider did not receive an evaluation context" + ) + attributes = context.provider.last_context.attributes + assert key in attributes, f"key {key!r} missing from merged context: {attributes!r}" + assert attributes[key] == value, ( + f"expected {key!r}={value!r}, got {attributes[key]!r}" + ) From b939bbbc86abdcea059dedb35a27f8d2ce016aed Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 17 Apr 2026 15:51:03 -0400 Subject: [PATCH 02/17] docs: fix inaccuracies in README code examples (#592) Signed-off-by: Jonathan Norris Signed-off-by: gruebel --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7cef40c2..63badd2b 100644 --- a/README.md +++ b/README.md @@ -142,11 +142,11 @@ If the flag management system you're using supports targeting, you can provide t ```python from openfeature.api import ( get_client, - get_provider, set_provider, get_evaluation_context, set_evaluation_context, ) +from openfeature.evaluation_context import EvaluationContext global_context = EvaluationContext( targeting_key="targeting_key1", attributes={"application": "value1"} @@ -159,7 +159,7 @@ request_context = EvaluationContext( set_evaluation_context(global_context) # merge second context -client = get_client(name="No-op Provider") +client = get_client(domain="No-op Provider") client.get_string_value("email", "fallback", request_context) ``` @@ -172,6 +172,7 @@ If the hook you're looking for hasn't been created yet, see the [develop a hook] Once you've added a hook as a dependency, it can be registered at the global, client, or flag invocation level. ```python +from openfeature import api from openfeature.api import add_hooks from openfeature.flag_evaluation import FlagEvaluationOptions @@ -179,12 +180,12 @@ from openfeature.flag_evaluation import FlagEvaluationOptions add_hooks([MyHook()]) # or configure them in the client -client = OpenFeatureClient() +client = api.get_client() client.add_hooks([MyHook()]) # or at the invocation-level options = FlagEvaluationOptions(hooks=[MyHook()]) -client.get_boolean_flag("my-flag", False, flag_evaluation_options=options) +client.get_boolean_value("my-flag", False, flag_evaluation_options=options) ``` ### Tracking @@ -254,7 +255,7 @@ Please refer to the documentation of the provider you're using to see what event ```python from openfeature import api -from openfeature.provider import ProviderEvent +from openfeature.event import EventDetails, ProviderEvent def on_provider_ready(event_details: EventDetails): print(f"Provider {event_details.provider_name} is ready") @@ -503,10 +504,11 @@ Implement your own hook by creating a hook that inherits from the `Hook` class. Any of the evaluation life-cycle stages (`before`/`after`/`error`/`finally_after`) can be override to add the desired business logic. ```python -from openfeature.hook import Hook +from openfeature.hook import Hook, HookContext, HookHints +from openfeature.flag_evaluation import FlagEvaluationDetails, FlagValueType class MyHook(Hook): - def after(self, hook_context: HookContext, details: FlagEvaluationDetails, hints: dict): + def after(self, hook_context: HookContext, details: FlagEvaluationDetails[FlagValueType], hints: HookHints): print("This runs after the flag has been evaluated") ``` From 9d47ab73f0f308b8ec6a727b83db47f178479699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Gr=C3=BCbel?= Date: Sat, 18 Apr 2026 00:55:24 +0300 Subject: [PATCH 03/17] fix: correctly reset api state on shutdown (#589) correctly reset api state on shutdown Signed-off-by: gruebel --- openfeature/api.py | 11 ++++++- openfeature/evaluation_context/__init__.py | 11 ++++++- openfeature/transaction_context/__init__.py | 5 +++ tests/test_api.py | 36 +++++++++++++++++++++ tests/test_client.py | 3 +- 5 files changed, 63 insertions(+), 3 deletions(-) diff --git a/openfeature/api.py b/openfeature/api.py index 7e06c886..817104ab 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -1,6 +1,7 @@ from openfeature import _event_support from openfeature.client import OpenFeatureClient from openfeature.evaluation_context import ( + clear_evaluation_context, get_evaluation_context, set_evaluation_context, ) @@ -13,6 +14,7 @@ from openfeature.provider._registry import provider_registry from openfeature.provider.metadata import Metadata from openfeature.transaction_context import ( + clear_transaction_context_propagator, get_transaction_context, set_transaction_context, set_transaction_context_propagator, @@ -60,7 +62,14 @@ def get_provider_metadata(domain: str | None = None) -> Metadata: def shutdown() -> None: - provider_registry.shutdown() + # shutdown -> remove providers -> set default provider to NoOp -> remove event handlers + clear_providers() + # remove hooks + clear_hooks() + # set evaluation context to default + clear_evaluation_context() + # set propagator to NoOp + clear_transaction_context_propagator() def add_handler(event: ProviderEvent, handler: EventHandler) -> None: diff --git a/openfeature/evaluation_context/__init__.py b/openfeature/evaluation_context/__init__.py index 4c046418..690c63be 100644 --- a/openfeature/evaluation_context/__init__.py +++ b/openfeature/evaluation_context/__init__.py @@ -7,7 +7,12 @@ from openfeature.exception import GeneralError -__all__ = ["EvaluationContext", "get_evaluation_context", "set_evaluation_context"] +__all__ = [ + "EvaluationContext", + "clear_evaluation_context", + "get_evaluation_context", + "set_evaluation_context", +] # https://openfeature.dev/specification/sections/evaluation-context#requirement-312 EvaluationContextAttribute: typing.TypeAlias = ( @@ -47,5 +52,9 @@ def set_evaluation_context(evaluation_context: EvaluationContext) -> None: _evaluation_context = evaluation_context +def clear_evaluation_context() -> None: + set_evaluation_context(EvaluationContext()) + + # need to be at the bottom, because of the definition order _evaluation_context = EvaluationContext() diff --git a/openfeature/transaction_context/__init__.py b/openfeature/transaction_context/__init__.py index 89f05360..15ac7e01 100644 --- a/openfeature/transaction_context/__init__.py +++ b/openfeature/transaction_context/__init__.py @@ -12,6 +12,7 @@ __all__ = [ "ContextVarsTransactionContextPropagator", "TransactionContextPropagator", + "clear_transaction_context_propagator", "get_transaction_context", "set_transaction_context", "set_transaction_context_propagator", @@ -29,6 +30,10 @@ def set_transaction_context_propagator( _evaluation_transaction_context_propagator = transaction_context_propagator +def clear_transaction_context_propagator() -> None: + set_transaction_context_propagator(NoOpTransactionContextPropagator()) + + def get_transaction_context() -> EvaluationContext: return _evaluation_transaction_context_propagator.get_transaction_context() diff --git a/tests/test_api.py b/tests/test_api.py index 019037db..cacdf694 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -21,7 +21,13 @@ from openfeature.exception import ErrorCode, GeneralError, ProviderFatalError from openfeature.hook import Hook from openfeature.provider import FeatureProvider, Metadata, ProviderStatus +from openfeature.provider._registry import provider_registry from openfeature.provider.no_op_provider import NoOpProvider +from openfeature.transaction_context import ( + ContextVarsTransactionContextPropagator, + get_transaction_context, + set_transaction_context_propagator, +) def test_should_not_raise_exception_with_noop_client(): @@ -198,6 +204,7 @@ def test_should_not_shutdown_provider_bound_to_another_domain(): provider.shutdown.assert_not_called() +# Requirement 1.6.1 def test_shutdown_should_shutdown_every_registered_provider_once(): # Given provider_1 = MagicMock(spec=FeatureProvider) @@ -215,6 +222,35 @@ def test_shutdown_should_shutdown_every_registered_provider_once(): provider_2.shutdown.assert_called_once() +# Requirement 1.6.2 +def test_shutdown_should_reset_api_state(): + # Given + set_provider(MagicMock(spec=FeatureProvider)) + add_hooks([MagicMock(spec=Hook)]) + set_evaluation_context(EvaluationContext("targeting_key", {"attr1": "val1"})) + set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) + + # When + shutdown() + + # Then + provider = provider_registry.get_default_provider() + assert isinstance(provider, NoOpProvider) + + hooks = get_hooks() + assert not hooks + + evaluation_context = get_evaluation_context() + assert evaluation_context.targeting_key is None + assert not evaluation_context.attributes + + transaction_context = ( + get_transaction_context() + ) # NoOpTransactionContextPropagator returns a default context + assert transaction_context.targeting_key is None + assert not transaction_context.attributes + + def test_clear_providers_shutdowns_every_provider_and_resets_default_provider(): # Given provider_1 = MagicMock(spec=FeatureProvider) diff --git a/tests/test_client.py b/tests/test_client.py index 25d2e98c..25819d4d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,6 +23,7 @@ from openfeature.flag_evaluation import FlagResolutionDetails, FlagType, Reason from openfeature.hook import Hook from openfeature.provider import FeatureProvider, ProviderStatus +from openfeature.provider._registry import provider_registry from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider from openfeature.provider.no_op_provider import NoOpProvider from openfeature.transaction_context import ContextVarsTransactionContextPropagator @@ -348,7 +349,7 @@ def _shutdown(self) -> None: monkeypatch.setattr(provider, "shutdown", types.MethodType(_shutdown, provider)) # When - api.shutdown() + provider_registry.shutdown() status = client.get_provider_status() From a70259f252e91b630fcf3e53132521d33a4bcdb7 Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:46:38 -0400 Subject: [PATCH 04/17] chore(main): release 0.9.0 (#555) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: gruebel --- .release-please-manifest.json | 2 +- CHANGELOG.md | 53 +++++++++++++++++++++++++++++++++++ README.md | 8 +++--- openfeature/version.py | 2 +- pyproject.toml | 2 +- 5 files changed, 60 insertions(+), 7 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 489125ea..24ce62fb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"0.8.4"} \ No newline at end of file +{".":"0.9.0"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 26427742..8539bad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [0.9.0](https://github.com/open-feature/python-sdk/compare/v0.8.4...v0.9.0) (2026-04-17) + + +### โš  BREAKING CHANGES + +* remove deprecated openfeature.provider.provider module ([#558](https://github.com/open-feature/python-sdk/issues/558)) +* Drop python 3.9 support ([#554](https://github.com/open-feature/python-sdk/issues/554)) + +### ๐Ÿ› Bug Fixes + +* correctly reset api state on shutdown ([#589](https://github.com/open-feature/python-sdk/issues/589)) ([eaa195b](https://github.com/open-feature/python-sdk/commit/eaa195b1761101a04c767ffbd235d809c9f724ca)) +* Prevent providers from being initialized multiple times ([45f7fd1](https://github.com/open-feature/python-sdk/commit/45f7fd1fd3dd39240282e23bf0ce587435f3ff2f)) +* Prevent providers from being shutdown multiple times ([45f7fd1](https://github.com/open-feature/python-sdk/commit/45f7fd1fd3dd39240282e23bf0ce587435f3ff2f)) +* replace project.scripts with poethepoet ([#563](https://github.com/open-feature/python-sdk/issues/563)) ([05382aa](https://github.com/open-feature/python-sdk/commit/05382aad368800d593209ece7966045b9daf9756)) +* validate domain is present when calling `set_provider` on registry ([#561](https://github.com/open-feature/python-sdk/issues/561)) ([9fc6121](https://github.com/open-feature/python-sdk/commit/9fc612180a3ea8d26820394b329abd24804d2df4)) + + +### โœจ New Features + +* add logging hook ([#577](https://github.com/open-feature/python-sdk/issues/577)) ([ae59756](https://github.com/open-feature/python-sdk/commit/ae59756109f39f4a0d0551cdd80aeaa5aaada571)) +* Drop python 3.9 support ([04106f5](https://github.com/open-feature/python-sdk/commit/04106f532d3a92d9dd70fd2160a07672ca4404f8)) +* Implement tracking ([#564](https://github.com/open-feature/python-sdk/issues/564)) ([b81ee21](https://github.com/open-feature/python-sdk/commit/b81ee21c5bda6408bf41be5f22f4c5f7b280dfee)) + + +### ๐Ÿงน Chore + +* add pyproject-fmt hook ([#578](https://github.com/open-feature/python-sdk/issues/578)) ([fafd902](https://github.com/open-feature/python-sdk/commit/fafd9023c7c1c82656b5af99c998124e88160fdf)) +* **deps:** pin pypa/gh-action-pypi-publish action to ed0c539 ([#582](https://github.com/open-feature/python-sdk/issues/582)) ([c7ada5a](https://github.com/open-feature/python-sdk/commit/c7ada5a13eab9efcff93de7dbf4c3d8291407599)) +* **deps:** update actions/checkout digest to de0fac2 ([#572](https://github.com/open-feature/python-sdk/issues/572)) ([e9dfeac](https://github.com/open-feature/python-sdk/commit/e9dfeac1e696ec038af3b62ed5a6bb6d11453696)) +* **deps:** update astral-sh/setup-uv digest to 37802ad ([#581](https://github.com/open-feature/python-sdk/issues/581)) ([ddb8fd1](https://github.com/open-feature/python-sdk/commit/ddb8fd1296714f4849c821db90d0832337d1f9bb)) +* **deps:** update astral-sh/setup-uv digest to e06108d ([#573](https://github.com/open-feature/python-sdk/issues/573)) ([c35a1e5](https://github.com/open-feature/python-sdk/commit/c35a1e5664613da36e1264ec348b540e1aa0c8da)) +* **deps:** update dependency uv_build to ~=0.10.0 ([#571](https://github.com/open-feature/python-sdk/issues/571)) ([a6366e2](https://github.com/open-feature/python-sdk/commit/a6366e21c41f1c7560ca245840ece09f31c6938e)) +* **deps:** update github/codeql-action digest to 0d579ff ([#574](https://github.com/open-feature/python-sdk/issues/574)) ([0696033](https://github.com/open-feature/python-sdk/commit/0696033b30788bed6d7e0c0360192c2913f130cd)) +* **deps:** update github/codeql-action digest to c10b806 ([#583](https://github.com/open-feature/python-sdk/issues/583)) ([9478ea0](https://github.com/open-feature/python-sdk/commit/9478ea0c0188a997e7396083e9d9b6983604985a)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.15.6 ([#575](https://github.com/open-feature/python-sdk/issues/575)) ([d0d7d8f](https://github.com/open-feature/python-sdk/commit/d0d7d8f6a2c83cf9488cb2185ebb2cb9cc32fbfe)) +* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.19.1 ([#576](https://github.com/open-feature/python-sdk/issues/576)) ([ade3870](https://github.com/open-feature/python-sdk/commit/ade3870dcd734722df357a11771bfc584f128093)) +* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v1.20.0 ([#588](https://github.com/open-feature/python-sdk/issues/588)) ([398d140](https://github.com/open-feature/python-sdk/commit/398d140883b9a16e866fc820d49442af1a5b7e69)) +* Drop python 3.9 support ([#554](https://github.com/open-feature/python-sdk/issues/554)) ([04106f5](https://github.com/open-feature/python-sdk/commit/04106f532d3a92d9dd70fd2160a07672ca4404f8)) +* replace pre-commit with prek ([#580](https://github.com/open-feature/python-sdk/issues/580)) ([d1e0cde](https://github.com/open-feature/python-sdk/commit/d1e0cdee150bebd63ea74cda917df44846c016c3)) +* update pytest to v9 ([#559](https://github.com/open-feature/python-sdk/issues/559)) ([71ebb9f](https://github.com/open-feature/python-sdk/commit/71ebb9fe961acb2ed256f12695123864fcf69cd5)) +* update uv to 0.11 and relax version requirement ([#587](https://github.com/open-feature/python-sdk/issues/587)) ([7d9229f](https://github.com/open-feature/python-sdk/commit/7d9229fd4f039ccd94b2a8a7ebfc0c4f0476294d)) + + +### ๐Ÿ“š Documentation + +* fix inaccuracies in README code examples ([#592](https://github.com/open-feature/python-sdk/issues/592)) ([17d62c4](https://github.com/open-feature/python-sdk/commit/17d62c4946c81985577b0372e6a6344b7b13470d)) + + +### ๐Ÿ”„ Refactoring + +* Delete provider status instead of marking as NOT_READY ([45f7fd1](https://github.com/open-feature/python-sdk/commit/45f7fd1fd3dd39240282e23bf0ce587435f3ff2f)) +* remove deprecated openfeature.provider.provider module ([#558](https://github.com/open-feature/python-sdk/issues/558)) ([7c27c7a](https://github.com/open-feature/python-sdk/commit/7c27c7ae58ab498fdc85ab508a76f7d5261f02f1)) + ## [0.8.4](https://github.com/open-feature/python-sdk/compare/v0.8.3...v0.8.4) (2025-12-08) diff --git a/README.md b/README.md index 63badd2b..5d4cd896 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.4 +pip install openfeature-sdk==0.9.0 ``` #### requirements.txt ```bash -openfeature-sdk==0.8.4 +openfeature-sdk==0.9.0 ``` ```python diff --git a/openfeature/version.py b/openfeature/version.py index fa3ddd8c..3e2f46a3 100644 --- a/openfeature/version.py +++ b/openfeature/version.py @@ -1 +1 @@ -__version__ = "0.8.4" +__version__ = "0.9.0" diff --git a/pyproject.toml b/pyproject.toml index 3f9c3f22..0f690ad3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = [ "uv-build~=0.11.0" ] [project] name = "openfeature-sdk" -version = "0.8.4" +version = "0.9.0" description = "Standardizing Feature Flagging for Everyone" readme = "README.md" keywords = [ From bc9e600fc640b064a79b68d05e5c05fc1eb599c5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 10:27:15 +0200 Subject: [PATCH 05/17] chore(deps): update pre-commit hook tox-dev/pyproject-fmt to v2.21.2 (#601) * chore(deps): update pre-commit hook tox-dev/pyproject-fmt to v2.21.2 * upper bound toml-fmt-common till fixed Signed-off-by: gruebel --------- Signed-off-by: gruebel Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: gruebel Signed-off-by: gruebel --- .pre-commit-config.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26c97a25..0498e2a5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,10 +22,11 @@ repos: priority: 0 - repo: https://github.com/tox-dev/pyproject-fmt - rev: v2.20.0 + rev: v2.21.2 hooks: - id: pyproject-fmt priority: 0 + additional_dependencies: [toml-fmt-common<1.3.3] # removed after fix https://github.com/tox-dev/toml-fmt/issues/355 - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.20.0 From d5e7d203ca353fbcd5452228a692812cd1ea47b8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 10:32:24 +0200 Subject: [PATCH 06/17] chore(deps): update astral-sh/setup-uv action to v8 (#603) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: gruebel --- .github/workflows/build.yml | 6 +++--- .github/workflows/release.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 92a08905..0922a8cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: submodules: recursive - name: Install uv and set the python version - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: ${{ matrix.python-version }} activate-environment: true @@ -63,7 +63,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install uv and set the python version - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: ${{ env.TARGET_PYTHON_VERSION }} ignore-nothing-to-cache: true @@ -81,7 +81,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install uv and set the python version - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: ${{ env.TARGET_PYTHON_VERSION }} ignore-nothing-to-cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dc2a2a17..2c880f3f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Install uv and set the python version - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: ${{ env.TARGET_PYTHON_VERSION }} From de17931d8e24cb3999737ab4185158ac8ffad420 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 10:50:05 +0200 Subject: [PATCH 07/17] chore(deps): update codecov/codecov-action action to v6 (#604) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: gruebel --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0922a8cd..9a691eeb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,7 +49,7 @@ jobs: - if: matrix.python-version == env.TARGET_PYTHON_VERSION name: Upload coverage to Codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 with: flags: unittests # optional name: coverage # optional From 3f868a00940726103f57a745b679a44bf0b92432 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 11:05:32 +0200 Subject: [PATCH 08/17] chore(deps): update googleapis/release-please-action action to v5 (#605) * chore(deps): update googleapis/release-please-action action to v5 * fix config Signed-off-by: gruebel --------- Signed-off-by: gruebel Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: gruebel Signed-off-by: gruebel --- .github/workflows/release.yml | 6 ++---- release-please-config.json | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c880f3f..d5f9df83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: googleapis/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3 + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 id: release with: - command: manifest token: ${{secrets.RELEASE_PLEASE_ACTION_TOKEN}} - default-branch: main - signoff: "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>" + target-branch: main outputs: release_created: ${{ steps.release.outputs.release_created }} release_tag_name: ${{ steps.release.outputs.tag_name }} diff --git a/release-please-config.json b/release-please-config.json index 45dc90c5..2daf148a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,5 +1,6 @@ { "bootstrap-sha": "198336b098f167f858675235214cc907ede10182", + "signoff": "OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com>", "packages": { ".": { "release-type": "python", From 3659fa7aea5194ec17a8b1ae5895b890aa877239 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 11:21:15 +0200 Subject: [PATCH 09/17] chore(deps): update pre-commit hook pre-commit/mirrors-mypy to v2 (#606) * chore(deps): update pre-commit hook pre-commit/mirrors-mypy to v2 * update config Signed-off-by: gruebel --------- Signed-off-by: gruebel Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: gruebel Signed-off-by: gruebel --- .pre-commit-config.yaml | 2 +- pyproject.toml | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0498e2a5..e2a49d14 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: additional_dependencies: [toml-fmt-common<1.3.3] # removed after fix https://github.com/tox-dev/toml-fmt/issues/355 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.20.0 + rev: v2.1.0 hooks: - id: mypy priority: 1 diff --git a/pyproject.toml b/pyproject.toml index 0f690ad3..b345e74d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,9 +108,8 @@ files = "openfeature,tests/typechecking" python_version = "3.10" # should be identical to the minimum supported version namespace_packages = true explicit_package_bases = true -local_partial_types = true # will become the new default from version 2 -allow_redefinition_new = true # will become the new default from version 2 -fixed_format_cache = true # new caching mechanism +native_parser = true # Rust-based parser will become default in the future +num_workers = 2 pretty = true strict = true disallow_any_generics = false From 173b3d7021c02285ea88aeef9ddae8783fed6b0f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 11:35:28 +0200 Subject: [PATCH 10/17] chore(deps): update dependency prek to >=0.4.3,<0.5.0 (#607) * chore(deps): update dependency prek to >=0.4.3,<0.5.0 * adjust CI Signed-off-by: gruebel --------- Signed-off-by: gruebel Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: gruebel Signed-off-by: gruebel --- .github/workflows/build.yml | 2 +- pyproject.toml | 2 +- uv.lock | 40 ++++++++++++++++++------------------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a691eeb..bbd400f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ permissions: contents: read env: - PREK_VERSION: '0.3.x' + PREK_VERSION: '0.4.x' TARGET_PYTHON_VERSION: "3.14" jobs: diff --git a/pyproject.toml b/pyproject.toml index b345e74d..6e073671 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dev = [ "behave>=1.3.0,<2.0.0", "coverage[toml]>=7.10.0,<8.0.0", "poethepoet>=0.40.0,<1.0.0", - "prek>=0.3.0,<0.4.0", + "prek>=0.4.3,<0.5.0", "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", ] diff --git a/uv.lock b/uv.lock index c10abdb9..fd4c1c92 100644 --- a/uv.lock +++ b/uv.lock @@ -197,7 +197,7 @@ wheels = [ [[package]] name = "openfeature-sdk" -version = "0.8.4" +version = "0.9.0" source = { editable = "." } [package.dev-dependencies] @@ -217,7 +217,7 @@ dev = [ { name = "behave", specifier = ">=1.3.0,<2.0.0" }, { name = "coverage", extras = ["toml"], specifier = ">=7.10.0,<8.0.0" }, { name = "poethepoet", specifier = ">=0.40.0,<1.0.0" }, - { name = "prek", specifier = ">=0.3.0,<0.4.0" }, + { name = "prek", specifier = ">=0.4.3,<0.5.0" }, { name = "pytest", specifier = ">=9.0.0,<10.0.0" }, { name = "pytest-asyncio", specifier = ">=1.3.0,<2.0.0" }, ] @@ -287,26 +287,26 @@ wheels = [ [[package]] name = "prek" -version = "0.3.8" +version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/ee/03e8180e3fda9de25b6480bd15cc2bde40d573868d50648b0e527b35562f/prek-0.3.8.tar.gz", hash = "sha256:434a214256516f187a3ab15f869d950243be66b94ad47987ee4281b69643a2d9", size = 400224, upload-time = "2026-03-23T08:23:35.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/3b/a0ae60bbd4c4735f20aeddfbd3c50fb669cd8e99c078a3ed75a6a4a5c6d7/prek-0.4.3.tar.gz", hash = "sha256:e486307ea649e7300b3535fac52fe0ba0b80aebe23143b662659d16e6a7c8b47", size = 461800, upload-time = "2026-05-27T03:18:58.045Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/84/40d2ddf362d12c4cd4a25a8c89a862edf87cdfbf1422aa41aac8e315d409/prek-0.3.8-py3-none-linux_armv6l.whl", hash = "sha256:6fb646ada60658fa6dd7771b2e0fb097f005151be222f869dada3eb26d79ed33", size = 5226646, upload-time = "2026-03-23T08:23:18.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/52/7308a033fa43b7e8e188797bd2b3b017c0f0adda70fa7af575b1f43ea888/prek-0.3.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3d7fdadb15efc19c09953c7a33cf2061a70f367d1e1957358d3ad5cc49d0616", size = 5620104, upload-time = "2026-03-23T08:23:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b1/f106ac000a91511a9cd80169868daf2f5b693480ef5232cec5517a38a512/prek-0.3.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:72728c3295e79ca443f8c1ec037d2a5b914ec73a358f69cf1bc1964511876bf8", size = 5199867, upload-time = "2026-03-23T08:23:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e9/970713f4b019f69de9844e1bab37b8ddb67558e410916f4eb5869a696165/prek-0.3.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:48efc28f2f53b5b8087efca9daaed91572d62df97d5f24a1c7a087fecb5017de", size = 5441801, upload-time = "2026-03-23T08:23:32.617Z" }, - { url = "https://files.pythonhosted.org/packages/12/a4/7ef44032b181753e19452ec3b09abb3a32607cf6b0a0508f0604becaaf2b/prek-0.3.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f6ca9d63bacbc448a5c18e955c78d3ac5176c3a17c3baacdd949b1a623e08a36", size = 5155107, upload-time = "2026-03-23T08:23:31.021Z" }, - { url = "https://files.pythonhosted.org/packages/bd/77/4d9c8985dbba84149760785dfe07093ea1e29d710257dfb7c89615e2234c/prek-0.3.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1000f7029696b4fe712fb1fefd4c55b9c4de72b65509c8e50296370a06f9dc3f", size = 5566541, upload-time = "2026-03-23T08:23:45.694Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1a/81e6769ac1f7f8346d09ce2ab0b47cf06466acd9ff72e87e5d1f0d98cd32/prek-0.3.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ff0bed0e2c1286522987d982168a86cbbd0d069d840506a46c9fda983515517", size = 6552991, upload-time = "2026-03-23T08:23:21.958Z" }, - { url = "https://files.pythonhosted.org/packages/6f/fa/ce2df0dd2dc75a9437a52463239d0782998943d7b04e191fb89b83016c34/prek-0.3.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fb087ac0ffda3ac65bbbae9a38326a7fd27ee007bb4a94323ce1eb539d8bbec", size = 5832972, upload-time = "2026-03-23T08:23:20.258Z" }, - { url = "https://files.pythonhosted.org/packages/18/6b/9d4269df9073216d296244595a21c253b6475dfc9076c0bd2906be7a436c/prek-0.3.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2e1e5e206ff7b31bd079cce525daddc96cd6bc544d20dc128921ad92f7a4c85d", size = 5448371, upload-time = "2026-03-23T08:23:41.835Z" }, - { url = "https://files.pythonhosted.org/packages/60/1d/1e4d8a78abefa5b9d086e5a9f1638a74b5e540eec8a648d9946707701f29/prek-0.3.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dcea3fe23832a4481bccb7c45f55650cb233be7c805602e788bb7dba60f2d861", size = 5270546, upload-time = "2026-03-23T08:23:24.231Z" }, - { url = "https://files.pythonhosted.org/packages/77/07/34f36551a6319ae36e272bea63a42f59d41d2d47ab0d5fb00eb7b4e88e87/prek-0.3.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:4d25e647e9682f6818ab5c31e7a4b842993c14782a6ffcd128d22b784e0d677f", size = 5124032, upload-time = "2026-03-23T08:23:26.368Z" }, - { url = "https://files.pythonhosted.org/packages/e3/01/6d544009bb655e709993411796af77339f439526db4f3b3509c583ad8eb9/prek-0.3.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:de528b82935e33074815acff3c7c86026754d1212136295bc88fe9c43b4231d5", size = 5432245, upload-time = "2026-03-23T08:23:47.877Z" }, - { url = "https://files.pythonhosted.org/packages/54/96/1237ee269e9bfa283ffadbcba1f401f48a47aed2b2563eb1002740d6079d/prek-0.3.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6d660f1c25a126e6d9f682fe61449441226514f412a4469f5d71f8f8cad56db2", size = 5950550, upload-time = "2026-03-23T08:23:43.8Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6b/a574411459049bc691047c9912f375deda10c44a707b6ce98df2b658f0b3/prek-0.3.8-py3-none-win32.whl", hash = "sha256:b0c291c577615d9f8450421dff0b32bfd77a6b0d223ee4115a1f820cb636fdf1", size = 4949501, upload-time = "2026-03-23T08:23:16.338Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b4/46b59fe49f635acd9f6530778ce577f9d8b49452835726a5311ffc902c67/prek-0.3.8-py3-none-win_amd64.whl", hash = "sha256:bc147fdbdd4ec33fc7a987b893ecb69b1413ac100d95c9889a70f3fd58c73d06", size = 5346551, upload-time = "2026-03-23T08:23:34.501Z" }, - { url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/df/be/980a0512f7eec3469dd40574f4e35d9ce7b67b358fea58888d13a0625b0d/prek-0.4.3-py3-none-linux_armv6l.whl", hash = "sha256:c67109de8d9766c2afd6e7e64feb9e1a0d3eceb3b4123280c28344660c1a97cd", size = 5541730, upload-time = "2026-05-27T03:19:09.119Z" }, + { url = "https://files.pythonhosted.org/packages/ef/55/937d707cc01d311e5c856c7019bc7db2c5e1835728396bb1ea32a7ecfdfd/prek-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b43a85f5ddf7827a75491e79ca068a49c5e4efde8dbac844ecb89622a78458e4", size = 5906762, upload-time = "2026-05-27T03:19:21.651Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6a/9a99ac481eb148dba55652df88b029ab6c1f90384bd51996026cdab2dafb/prek-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e99ee90a7b6e84dabef891ff7521eb59dae38953467bdb482f004ea522d3a64c", size = 5461541, upload-time = "2026-05-27T03:18:55.984Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8d/9056b02a100cc18b101fc05ecc82635889f5f8cb1cce5d70b027e517a6d9/prek-0.4.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:f514ec0d95cd4578d74d4601058bd259f5baf91c937f2aaae942d4b070b8077f", size = 5720501, upload-time = "2026-05-27T03:18:52.424Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ea/efbe4523e53022d94272ddfdd3a198ace7de004dd8830a69318085a10393/prek-0.4.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03a4ac3c3023a76faa52ad7775720599b10241930be8902c471085b22572b4b0", size = 5452412, upload-time = "2026-05-27T03:19:17.801Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/8a48b2c6a5117110d688c2d8ca2526264ad9f0d3baed4587038ee85e4c2d/prek-0.4.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40173425ab82bf0a7267d672b3e3aae9dd425eaee3a3641c6a5f040da3ff95e4", size = 5849515, upload-time = "2026-05-27T03:19:05.545Z" }, + { url = "https://files.pythonhosted.org/packages/ec/66/ccce7a1b6c6b610a22b54092d523ea7d35709e42864dace3734c05dd5f98/prek-0.4.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b8d99ee3277f8f3a3453a953120ee5c6c52f7ad89e459a25425cf62135f47b1", size = 6743978, upload-time = "2026-05-27T03:19:07.445Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f8/7a441d780c42e858ad677c82bb54eb3f01b424b710a8db5b9a8782305326/prek-0.4.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08595fe96d24c1fe13486b00d55ce73a7b37040a16e82365942606594c67a6b", size = 6108774, upload-time = "2026-05-27T03:19:03.565Z" }, + { url = "https://files.pythonhosted.org/packages/bf/38/fbb1afe14c7536109c68a1d9ca602f152f1929972d006c517c3b92140192/prek-0.4.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8607d636ef9232675507d97d252e1dcca5628bff79cb069fa945fff09d7bbb43", size = 5723165, upload-time = "2026-05-27T03:18:59.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b8/edafebce2bbd85f9e9de2781c225d690eb2b9897a06b224f5c24658fe398/prek-0.4.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:89484765304a779780f83489eb3aed5de5366f47fce7713fa5a917ebc281baa0", size = 5560557, upload-time = "2026-05-27T03:18:49.59Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2e/a85a40458ac50c452cae2ddd2eed0b70107fd2b4074d7a5003088ac508f1/prek-0.4.3-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:2d2b0c12e3d1c6d90646f9faa2d4c66f9861f3c6e577d7dbd25e733ed095ac56", size = 5417874, upload-time = "2026-05-27T03:19:01.681Z" }, + { url = "https://files.pythonhosted.org/packages/4c/be/106fb026646e1da65da6d2a5f3cfbda817e68a72429645351b7033c0b2b5/prek-0.4.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ca6802eaf191acb6166e9e013dd277ea193ba27c1dca896ab7debf6dca758b6d", size = 5710013, upload-time = "2026-05-27T03:18:54.143Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/edfff5f7d9b6c9e5860dfe05c9488e1b96de990b652db2e379d45af8ad2e/prek-0.4.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a46862d81078d2c8caa286c392f965ed72fb72eb1fed171910ba54fe8d546ed0", size = 6230160, upload-time = "2026-05-27T03:19:15.58Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/70adf26d5da0b7a66d8e284a661feddd5e8c69784b82084f40485fa321e4/prek-0.4.3-py3-none-win32.whl", hash = "sha256:f78e343584cfff106fc3c361109b87949ad8028dc5aa667e0fccd26db8170d7d", size = 5226844, upload-time = "2026-05-27T03:19:13.871Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/9f21aca8ccee6978db831dbf36c2e17461692c75dd291c9b3d170e39a82a/prek-0.4.3-py3-none-win_amd64.whl", hash = "sha256:798d04437d30d6b4e6c1d520fe6ca800c340c9246f0dc8900d8b365df54b71b6", size = 5616068, upload-time = "2026-05-27T03:19:19.653Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ef/cad8f9c66bcc199e22d1ad82a50032067a4c8b4182306d3472ff99f64aa3/prek-0.4.3-py3-none-win_arm64.whl", hash = "sha256:70d9da5fc14ef41565ff7ba9f476fb53166bf719a954339b2e9f42ed494a2f71", size = 5448057, upload-time = "2026-05-27T03:19:11.118Z" }, ] [[package]] From 6add328e509715dffcfeb612b0509d662cdcc055 Mon Sep 17 00:00:00 2001 From: Jonathan Norris Date: Fri, 29 May 2026 15:09:23 -0400 Subject: [PATCH 11/17] feat!: make set_provider non-blocking, add set_provider_and_wait (#595) * feat!: make set_provider non-blocking, add set_provider_and_wait Signed-off-by: Jonathan Norris * fix: ruff format signature collapse in api.py Signed-off-by: Jonathan Norris * fix: use threading.Event in error event test to avoid flaky busy-wait Signed-off-by: Jonathan Norris * fixup: pr feedback and additional checks Signed-off-by: Todd Baert * fix: check active registration in stale-init guard, not _provider_status Signed-off-by: Jonathan Norris * fixup: edge shutdown race Signed-off-by: Todd Baert --------- Signed-off-by: Jonathan Norris Signed-off-by: Todd Baert Co-authored-by: Todd Baert Signed-off-by: gruebel --- README.md | 9 + openfeature/api.py | 8 + openfeature/provider/_registry.py | 190 ++++++++++++++++----- tests/conftest.py | 2 +- tests/features/steps/metadata_steps.py | 4 +- tests/features/steps/steps.py | 6 +- tests/provider/test_registry.py | 220 +++++++++++++++++++++++-- tests/test_api.py | 117 ++++++++++++- 8 files changed, 486 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 5d4cd896..17e38eea 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,15 @@ api.set_provider(NoOpProvider()) open_feature_client = api.get_client() ``` +`set_provider()` is non-blocking: it registers the provider immediately and runs initialization in a background thread. +Flag evaluations during the initialization window return the default value with a `PROVIDER_NOT_READY` error code. +Use `set_provider_and_wait()` if you need to ensure the provider is ready before proceeding: + +```python +# blocks until the provider is initialized (or raises on failure) +api.set_provider_and_wait(NoOpProvider()) +``` + In some situations, it may be beneficial to register multiple providers in the same application. This is possible using [domains](#domains), which is covered in more detail below. diff --git a/openfeature/api.py b/openfeature/api.py index 817104ab..4585e50e 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -33,6 +33,7 @@ "remove_handler", "set_evaluation_context", "set_provider", + "set_provider_and_wait", "set_transaction_context", "set_transaction_context_propagator", "shutdown", @@ -52,6 +53,13 @@ def set_provider(provider: FeatureProvider, domain: str | None = None) -> None: provider_registry.set_provider(domain, provider) +def set_provider_and_wait(provider: FeatureProvider, domain: str | None = None) -> None: + if domain is None: + provider_registry.set_default_provider(provider, wait_for_init=True) + else: + provider_registry.set_provider(domain, provider, wait_for_init=True) + + def clear_providers() -> None: provider_registry.clear_providers() _event_support.clear() diff --git a/openfeature/provider/_registry.py b/openfeature/provider/_registry.py index bf8fa9a8..e46caadd 100644 --- a/openfeature/provider/_registry.py +++ b/openfeature/provider/_registry.py @@ -1,3 +1,5 @@ +import threading + from openfeature._event_support import run_handlers_for_provider from openfeature.evaluation_context import EvaluationContext, get_evaluation_context from openfeature.event import ( @@ -13,77 +15,144 @@ class ProviderRegistry: _default_provider: FeatureProvider _providers: dict[str, FeatureProvider] _provider_status: dict[FeatureProvider, ProviderStatus] + _lock: threading.RLock def __init__(self) -> None: + self._lock = threading.RLock() self._default_provider = NoOpProvider() self._providers = {} self._provider_status = { self._default_provider: ProviderStatus.READY, } - def set_provider(self, domain: str, provider: FeatureProvider) -> None: + def set_provider( + self, domain: str, provider: FeatureProvider, wait_for_init: bool = False + ) -> None: if provider is None: raise GeneralError(error_message="No provider") if domain is None: raise GeneralError(error_message="No domain") - providers = self._providers - if domain in providers: - old_provider = providers[domain] - del providers[domain] - if ( - old_provider != self._default_provider - and old_provider not in providers.values() - ): - self._shutdown_provider(old_provider) - if provider != self._default_provider and provider not in providers.values(): - self._initialize_provider(provider) - providers[domain] = provider + + old_provider: FeatureProvider | None = None + needs_init = False + with self._lock: + old_provider = self._providers.get(domain) + self._providers[domain] = provider + already_bound = provider is self._default_provider or any( + p is provider for d, p in self._providers.items() if d != domain + ) + if not already_bound: + needs_init = True + self._provider_status[provider] = ProviderStatus.NOT_READY + + if needs_init: + self._initialize_provider(provider, wait_for_init=wait_for_init) + + # old-provider shutdown is always async so a hanging shutdown() cannot + # block set_provider. + if old_provider is not None and old_provider is not provider: + self._shutdown_if_unused(old_provider) def get_provider(self, domain: str | None) -> FeatureProvider: if domain is None: return self._default_provider return self._providers.get(domain, self._default_provider) - def set_default_provider(self, provider: FeatureProvider) -> None: + def set_default_provider( + self, provider: FeatureProvider, wait_for_init: bool = False + ) -> None: if provider is None: raise GeneralError(error_message="No provider") - if ( - self._default_provider - and self._default_provider not in self._providers.values() - ): - self._shutdown_provider(self._default_provider) - self._default_provider = provider - if self._default_provider not in self._providers.values(): - self._initialize_provider(provider) + old_provider: FeatureProvider | None = None + needs_init = False + with self._lock: + old_provider = self._default_provider + self._default_provider = provider + if ( + provider is not old_provider + and provider not in self._providers.values() + ): + needs_init = True + self._provider_status[provider] = ProviderStatus.NOT_READY + + if needs_init: + self._initialize_provider(provider, wait_for_init=wait_for_init) + + if old_provider is not None and old_provider is not provider: + self._shutdown_if_unused(old_provider) def get_default_provider(self) -> FeatureProvider: return self._default_provider def clear_providers(self) -> None: self.shutdown() - self._providers.clear() - self._default_provider = NoOpProvider() - self._provider_status = { - self._default_provider: ProviderStatus.READY, - } + with self._lock: + self._providers.clear() + self._default_provider = NoOpProvider() + self._provider_status = { + self._default_provider: ProviderStatus.READY, + } def shutdown(self) -> None: - for provider in {self._default_provider, *self._providers.values()}: + with self._lock: + providers = {self._default_provider, *self._providers.values()} + + for provider in providers: self._shutdown_provider(provider) def _get_evaluation_context(self) -> EvaluationContext: return get_evaluation_context() - def _initialize_provider(self, provider: FeatureProvider) -> None: + def _initialize_provider( + self, provider: FeatureProvider, wait_for_init: bool + ) -> None: provider.attach(self.dispatch_event) + if not hasattr(provider, "initialize"): + # nothing async to do; dispatch READY synchronously. + self.dispatch_event( + provider, ProviderEvent.PROVIDER_READY, ProviderEventDetails() + ) + return + if wait_for_init: + self._run_initialize(provider, raise_on_error=True) + return + + thread = threading.Thread( + target=self._run_initialize, + args=(provider,), + kwargs={"raise_on_error": False}, + daemon=True, + ) + thread.start() + + def _run_initialize( + self, provider: FeatureProvider, raise_on_error: bool = False + ) -> None: try: - if hasattr(provider, "initialize"): - provider.initialize(self._get_evaluation_context()) + provider.initialize(self._get_evaluation_context()) + # stale init: provider was replaced/shut down during initialize(); drop event. + # Check active registration, not _provider_status, since replaced providers + # remain in _provider_status until async shutdown pops them. + with self._lock: + if ( + provider is not self._default_provider + and provider not in self._providers.values() + ): + return self.dispatch_event( provider, ProviderEvent.PROVIDER_READY, ProviderEventDetails() ) except Exception as err: + # stale init: provider was replaced/shut down during initialize(); drop event. + # Check active registration, not _provider_status, since replaced providers + # remain in _provider_status until async shutdown pops them. + with self._lock: + if ( + provider is not self._default_provider + and provider not in self._providers.values() + ): + return error_code = ( err.error_code if isinstance(err, OpenFeatureError) @@ -97,12 +166,42 @@ def _initialize_provider(self, provider: FeatureProvider) -> None: error_code=error_code, ), ) - - def _shutdown_provider(self, provider: FeatureProvider) -> None: + if raise_on_error: + raise + + def _shutdown_if_unused(self, provider: FeatureProvider) -> None: + # only shut down if no longer referenced. shutdown runs on a daemon + # thread so a hanging shutdown() cannot block the caller. + with self._lock: + if provider is self._default_provider: + return + if provider in self._providers.values(): + return + + thread = threading.Thread( + target=self._shutdown_provider, + args=(provider,), + kwargs={"abort_if_re_registered": True}, + daemon=True, + ) + thread.start() + + def _shutdown_provider( + self, provider: FeatureProvider, abort_if_re_registered: bool = False + ) -> None: try: if hasattr(provider, "shutdown"): provider.shutdown() - del self._provider_status[provider] + # if provider is being re-registered, leave its status and event wiring intact + if abort_if_re_registered: + with self._lock: + if ( + provider is self._default_provider + or provider in self._providers.values() + ): + return + with self._lock: + self._provider_status.pop(provider, None) except Exception as err: self.dispatch_event( provider, @@ -132,17 +231,18 @@ def _update_provider_status( event: ProviderEvent, details: ProviderEventDetails, ) -> None: - if event == ProviderEvent.PROVIDER_READY: - self._provider_status[provider] = ProviderStatus.READY - elif event == ProviderEvent.PROVIDER_STALE: - self._provider_status[provider] = ProviderStatus.STALE - elif event == ProviderEvent.PROVIDER_ERROR: - status = ( - ProviderStatus.FATAL - if details.error_code == ErrorCode.PROVIDER_FATAL - else ProviderStatus.ERROR - ) - self._provider_status[provider] = status + with self._lock: + if event == ProviderEvent.PROVIDER_READY: + self._provider_status[provider] = ProviderStatus.READY + elif event == ProviderEvent.PROVIDER_STALE: + self._provider_status[provider] = ProviderStatus.STALE + elif event == ProviderEvent.PROVIDER_ERROR: + status = ( + ProviderStatus.FATAL + if details.error_code == ErrorCode.PROVIDER_FATAL + else ProviderStatus.ERROR + ) + self._provider_status[provider] = status provider_registry = ProviderRegistry() diff --git a/tests/conftest.py b/tests/conftest.py index 1f0a7982..495634c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,5 +15,5 @@ def clear_providers(): @pytest.fixture() def no_op_provider_client(): - api.set_provider(NoOpProvider()) + api.set_provider_and_wait(NoOpProvider()) return api.get_client() diff --git a/tests/features/steps/metadata_steps.py b/tests/features/steps/metadata_steps.py index 0154a9f0..bed87d17 100644 --- a/tests/features/steps/metadata_steps.py +++ b/tests/features/steps/metadata_steps.py @@ -1,13 +1,13 @@ from behave import given, then -from openfeature.api import get_client, set_provider +from openfeature.api import get_client, set_provider_and_wait from openfeature.provider.in_memory_provider import InMemoryProvider from tests.features.data import IN_MEMORY_FLAGS @given("a stable provider") def step_impl_stable_provider(context): - set_provider(InMemoryProvider(IN_MEMORY_FLAGS)) + set_provider_and_wait(InMemoryProvider(IN_MEMORY_FLAGS)) context.client = get_client() diff --git a/tests/features/steps/steps.py b/tests/features/steps/steps.py index 5d9d38fd..9b699331 100644 --- a/tests/features/steps/steps.py +++ b/tests/features/steps/steps.py @@ -4,7 +4,7 @@ from behave import given, then, when -from openfeature.api import get_client, set_provider +from openfeature.api import get_client, set_provider_and_wait from openfeature.client import OpenFeatureClient from openfeature.evaluation_context import EvaluationContext from openfeature.exception import ErrorCode @@ -28,13 +28,13 @@ def step_impl_resolved_should_be(context, flag_type, key, expected_reason): @given("a provider is registered with cache disabled") def step_impl_provider_without_cache(context): - set_provider(InMemoryProvider(IN_MEMORY_FLAGS)) + set_provider_and_wait(InMemoryProvider(IN_MEMORY_FLAGS)) context.client = get_client() @given("a provider is registered") def step_impl_provider(context): - set_provider(InMemoryProvider(IN_MEMORY_FLAGS)) + set_provider_and_wait(InMemoryProvider(IN_MEMORY_FLAGS)) context.client = get_client() diff --git a/tests/provider/test_registry.py b/tests/provider/test_registry.py index b5e10503..f7c55712 100644 --- a/tests/provider/test_registry.py +++ b/tests/provider/test_registry.py @@ -1,8 +1,10 @@ +import threading +import time from unittest.mock import Mock import pytest -from openfeature.exception import GeneralError +from openfeature.exception import GeneralError, ProviderFatalError from openfeature.provider import ProviderStatus from openfeature.provider._registry import ProviderRegistry from openfeature.provider.no_op_provider import NoOpProvider @@ -67,8 +69,8 @@ def test_registering_provider_for_first_time_initializes_it(): registry = ProviderRegistry() provider = Mock() - registry.set_provider("domain1", provider) - registry.set_provider("domain2", provider) + registry.set_provider("domain1", provider, wait_for_init=True) + registry.set_provider("domain2", provider, wait_for_init=True) provider.initialize.assert_called_once() @@ -103,7 +105,7 @@ def test_setting_default_provider_initializes_it(): registry = ProviderRegistry() provider = Mock() - registry.set_default_provider(provider) + registry.set_default_provider(provider, wait_for_init=True) provider.initialize.assert_called_once() @@ -114,8 +116,8 @@ def test_registering_provider_as_default_then_domain_only_initializes_once(): registry = ProviderRegistry() provider = Mock() - registry.set_default_provider(provider) - registry.set_provider("domain", provider) + registry.set_default_provider(provider, wait_for_init=True) + registry.set_provider("domain", provider, wait_for_init=True) provider.initialize.assert_called_once() @@ -126,8 +128,8 @@ def test_registering_provider_as_domain_then_default_only_initializes_once(): registry = ProviderRegistry() provider = Mock() - registry.set_provider("domain", provider) - registry.set_default_provider(provider) + registry.set_provider("domain", provider, wait_for_init=True) + registry.set_default_provider(provider, wait_for_init=True) provider.initialize.assert_called_once() @@ -191,7 +193,7 @@ def test_initializing_provider_sets_status_ready(): assert registry.get_provider_status(provider) == ProviderStatus.NOT_READY - registry.set_provider("domain", provider) + registry.set_provider("domain", provider, wait_for_init=True) provider.initialize.assert_called_once() assert registry.get_provider_status(provider) == ProviderStatus.READY @@ -203,7 +205,7 @@ def test_shutting_down_provider_sets_status_not_ready(): registry = ProviderRegistry() provider = Mock() - registry.set_provider("domain", provider) + registry.set_provider("domain", provider, wait_for_init=True) assert registry.get_provider_status(provider) == ProviderStatus.READY registry.shutdown() @@ -216,8 +218,8 @@ def test_clearing_registry_resets_providers_and_default(): registry = ProviderRegistry() provider = Mock() - registry.set_provider("domain", provider) - registry.set_default_provider(provider) + registry.set_provider("domain", provider, wait_for_init=True) + registry.set_default_provider(provider, wait_for_init=True) registry.clear_providers() @@ -228,3 +230,197 @@ def test_clearing_registry_resets_providers_and_default(): provider.initialize.assert_called_once() provider.shutdown.assert_called_once() + + +def test_set_provider_returns_before_initialization_completes(): + """Test that set_provider (non-blocking) returns before initialize finishes.""" + + registry = ProviderRegistry() + init_started = threading.Event() + init_may_proceed = threading.Event() + provider = Mock() + + def slow_initialize(ctx): + init_started.set() + init_may_proceed.wait() + + provider.initialize.side_effect = slow_initialize + + registry.set_provider("domain", provider) + + assert init_started.wait(timeout=2), "initialize was never called in background" + assert registry.get_provider_status(provider) == ProviderStatus.NOT_READY + + init_may_proceed.set() # unblock the background thread + + +def test_set_provider_and_wait_blocks_until_ready(): + """Test that set_provider with wait_for_init=True blocks until READY.""" + + registry = ProviderRegistry() + initialized = threading.Event() + provider = Mock() + + def tracking_initialize(ctx): + initialized.set() + + provider.initialize.side_effect = tracking_initialize + + registry.set_provider("domain", provider, wait_for_init=True) + + assert initialized.is_set() + assert registry.get_provider_status(provider) == ProviderStatus.READY + + +def test_set_provider_and_wait_reraises_on_error(): + """Test that set_provider with wait_for_init=True re-raises initialization errors.""" + registry = ProviderRegistry() + provider = Mock() + provider.initialize.side_effect = ProviderFatalError() + + with pytest.raises(ProviderFatalError): + registry.set_provider("domain", provider, wait_for_init=True) + + +def test_concurrent_set_provider_for_same_provider_initializes_once(): + """Concurrent set_provider calls for different domains using the same + provider instance must only initialize the provider once.""" + + registry = ProviderRegistry() + init_count = 0 + start_gate = threading.Event() + + def slow_initialize(ctx): + nonlocal init_count + # widen the window in which two threads can both observe "not bound" + start_gate.wait(timeout=2) + init_count += 1 + + provider = Mock() + provider.initialize.side_effect = slow_initialize + + def call(domain): + registry.set_provider(domain, provider, wait_for_init=True) + + t1 = threading.Thread(target=call, args=("d1",)) + t2 = threading.Thread(target=call, args=("d2",)) + t1.start() + t2.start() + start_gate.set() + t1.join(timeout=5) + t2.join(timeout=5) + + assert init_count == 1 + + +def test_provider_replaced_during_async_init_does_not_set_ready_status(): + """If a provider is replaced while its async initialize is still running, + the late PROVIDER_READY event must not resurrect its status.""" + + registry = ProviderRegistry() + init_started = threading.Event() + init_may_proceed = threading.Event() + + slow_provider = Mock() + + def slow_initialize(ctx): + init_started.set() + init_may_proceed.wait(timeout=2) + + slow_provider.initialize.side_effect = slow_initialize + + registry.set_provider("domain", slow_provider) + assert init_started.wait(timeout=2) + + # replace with a different provider before the slow init finishes + replacement = Mock() + registry.set_provider("domain", replacement, wait_for_init=True) + + # now let the slow init complete + init_may_proceed.set() + # give the background thread a moment to attempt its (stale) dispatch + time.sleep(0.1) + + # stale event must not have set READY on the replaced provider + assert registry.get_provider_status(slow_provider) == ProviderStatus.NOT_READY + assert registry.get_provider_status(replacement) == ProviderStatus.READY + + +def test_set_provider_does_not_block_on_hanging_old_shutdown(): + """If the previously-registered provider's shutdown() hangs, a subsequent + set_provider call must not be blocked by it.""" + + registry = ProviderRegistry() + + hanging = Mock() + hang = threading.Event() + hanging.shutdown.side_effect = lambda: hang.wait(timeout=5) + + replacement = Mock() + + registry.set_provider("domain", hanging, wait_for_init=True) + + completed = threading.Event() + + def replace(): + registry.set_provider("domain", replacement, wait_for_init=True) + completed.set() + + threading.Thread(target=replace, daemon=True).start() + + # the swap+init of replacement must complete even though `hanging.shutdown` + # is still blocked. + assert completed.wait(timeout=2), ( + "set_provider was blocked by old provider's hanging shutdown()" + ) + + # release the hung shutdown so the test can clean up + hang.set() + + +def test_stale_shutdown_does_not_clobber_re_registered_provider(): + """If a provider is replaced and then re-registered while its previous + (background) shutdown is still finishing, the stale shutdown must not + pop its status or detach() the freshly-registered instance.""" + + registry = ProviderRegistry() + + shutdown_started = threading.Event() + shutdown_may_proceed = threading.Event() + + provider_a = Mock() + + def slow_shutdown(): + shutdown_started.set() + shutdown_may_proceed.wait(timeout=5) + + provider_a.shutdown.side_effect = slow_shutdown + + provider_b = Mock() + + # step 1: register A + registry.set_provider("domain", provider_a, wait_for_init=True) + assert registry.get_provider_status(provider_a) == ProviderStatus.READY + + # step 2: replace A with B; spawns background shutdown of A which will + # block inside provider_a.shutdown() until we signal it. + registry.set_provider("domain", provider_b, wait_for_init=True) + assert shutdown_started.wait(timeout=2), "A's shutdown thread never started" + + # step 3: re-register A while its previous shutdown is mid-flight + registry.set_provider("domain", provider_a, wait_for_init=True) + assert registry.get_provider_status(provider_a) == ProviderStatus.READY + + # step 4: let the stale shutdown of A complete + shutdown_may_proceed.set() + # give the background thread a moment to run pop/detach + time.sleep(0.2) + + # after the stale shutdown completes, A should still be registered and + # its status should still be READY. If the race triggers, status will + # fall back to NOT_READY (default for missing entry) and detach was called. + assert registry.get_provider("domain") is provider_a + assert registry.get_provider_status(provider_a) == ProviderStatus.READY, ( + "stale shutdown of A clobbered the fresh registration's status" + ) + provider_a.detach.assert_not_called() diff --git a/tests/test_api.py b/tests/test_api.py index cacdf694..b7945cbb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,3 +1,4 @@ +import threading from unittest.mock import MagicMock import pytest @@ -14,6 +15,7 @@ remove_handler, set_evaluation_context, set_provider, + set_provider_and_wait, shutdown, ) from openfeature.evaluation_context import EvaluationContext @@ -69,7 +71,7 @@ def test_should_invoke_provider_initialize_function_on_newly_registered_provider # When set_evaluation_context(evaluation_context) - set_provider(provider) + set_provider_and_wait(provider) # Then provider.initialize.assert_called_with(evaluation_context) @@ -170,10 +172,10 @@ def test_should_provide_a_function_to_bind_provider_through_domain(): def test_should_not_initialize_provider_already_bound_to_another_domain(): # Given provider = MagicMock(spec=FeatureProvider) - set_provider(provider, "foo") + set_provider_and_wait(provider, "foo") # When - set_provider(provider, "bar") + set_provider_and_wait(provider, "bar") # Then provider.initialize.assert_called_once() @@ -326,7 +328,7 @@ def test_add_remove_event_handler(): def test_handlers_attached_to_provider_already_in_associated_state_should_run_immediately(): # Given provider = NoOpProvider() - set_provider(provider) + set_provider_and_wait(provider) spy = MagicMock() # When @@ -345,7 +347,7 @@ def test_provider_ready_handlers_run_if_provider_initialize_function_terminates_ spy.reset_mock() # reset the mock to avoid counting the immediate call on subscribe # When - set_provider(provider) + set_provider_and_wait(provider) # Then spy.provider_ready.assert_called_once() @@ -360,7 +362,8 @@ def test_provider_error_handlers_run_if_provider_initialize_function_terminates_ add_handler(ProviderEvent.PROVIDER_ERROR, spy.provider_error) # When - set_provider(provider) + with pytest.raises(ProviderFatalError): + set_provider_and_wait(provider) # Then spy.provider_error.assert_called_once() @@ -369,7 +372,7 @@ def test_provider_error_handlers_run_if_provider_initialize_function_terminates_ def test_provider_status_is_updated_after_provider_emits_event(): # Given provider = NoOpProvider() - set_provider(provider) + set_provider_and_wait(provider) client = get_client() # When @@ -393,3 +396,103 @@ def test_provider_status_is_updated_after_provider_emits_event(): provider.emit_provider_ready(ProviderEventDetails()) # Then assert client.get_provider_status() == ProviderStatus.READY + + +# Non-blocking set_provider tests + + +def test_set_provider_returns_before_initialization_completes(): + # Given: a provider whose initialize blocks until signalled + init_started = threading.Event() + init_may_proceed = threading.Event() + + provider = MagicMock(spec=FeatureProvider) + + def slow_initialize(ctx): + init_started.set() + init_may_proceed.wait() + + provider.initialize.side_effect = slow_initialize + + # When + set_provider(provider) + + # Then: set_provider returned before initialize completed (we reached this line + # while the background thread is still blocked inside initialize) + assert init_started.wait(timeout=2), "initialize was never called" + init_may_proceed.set() # unblock the background thread + + +def test_provider_status_is_not_ready_during_async_initialization(): + # Given: a provider whose initialize blocks until signalled + init_may_proceed = threading.Event() + provider = MagicMock(spec=FeatureProvider) + + def slow_initialize(ctx): + init_may_proceed.wait() + + provider.initialize.side_effect = slow_initialize + + # When + set_provider(provider) + client = get_client() + + # Then: status is NOT_READY while init is still running + assert client.get_provider_status() == ProviderStatus.NOT_READY + + # Cleanup: let the background thread finish + init_may_proceed.set() + + +def test_set_provider_and_wait_blocks_until_initialization_completes(): + # Given + initialized = threading.Event() + provider = MagicMock(spec=FeatureProvider) + + def slow_initialize(ctx): + initialized.set() + + provider.initialize.side_effect = slow_initialize + + # When + set_provider_and_wait(provider) + + # Then: initialize was called before set_provider_and_wait returned + assert initialized.is_set() + assert get_client().get_provider_status() == ProviderStatus.READY + + +def test_set_provider_and_wait_reraises_on_failure(): + # Given + provider = MagicMock(spec=FeatureProvider) + provider.initialize.side_effect = ProviderFatalError() + + # When / Then + with pytest.raises(ProviderFatalError): + set_provider_and_wait(provider) + + +def test_set_provider_swallows_error_and_emits_provider_error_event(): + # Given + provider = MagicMock(spec=FeatureProvider) + error_fired = threading.Event() + + def failing_initialize(ctx): + raise ProviderFatalError() + + provider.initialize.side_effect = failing_initialize + + spy = MagicMock() + + def on_error(details): + spy.on_error(details) + error_fired.set() + + add_handler(ProviderEvent.PROVIDER_ERROR, on_error) + + # When: non-blocking set_provider โ€” must not raise + set_provider(provider) + + # Then: error event fired, exception was not propagated + assert error_fired.wait(timeout=2), "PROVIDER_ERROR event was never fired" + spy.on_error.assert_called_once() From c61771c644713716500b1194888f2f7287f61d71 Mon Sep 17 00:00:00 2001 From: Nguyen Cat Luong Date: Sat, 30 May 2026 02:40:26 +0700 Subject: [PATCH 12/17] fix: isolate provider event handler dispatch (#599) * Isolate provider event handlers Signed-off-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> * Address event handler review feedback Signed-off-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> * test: cover event dispatch noop path Signed-off-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> * fixup: drain executor at exit and relax non-blocking test timing margin Signed-off-by: Todd Baert --------- Signed-off-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> Signed-off-by: Todd Baert Co-authored-by: Lucas-FManager <265058144+Lucas-FManager@users.noreply.github.com> Co-authored-by: Todd Baert Signed-off-by: gruebel --- openfeature/_event_support.py | 47 ++++++++++++++--- tests/test_api.py | 18 +++++++ tests/test_client.py | 95 ++++++++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 9 deletions(-) diff --git a/openfeature/_event_support.py b/openfeature/_event_support.py index 00557ead..3928be3e 100644 --- a/openfeature/_event_support.py +++ b/openfeature/_event_support.py @@ -1,8 +1,11 @@ from __future__ import annotations +import atexit import threading import typing from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from logging import getLogger from openfeature.event import ( EventDetails, @@ -16,6 +19,10 @@ from openfeature.client import OpenFeatureClient +logger = getLogger("openfeature") +_event_executor = ThreadPoolExecutor(thread_name_prefix="openfeature-event-handler") +atexit.register(_event_executor.shutdown, wait=True) + _global_lock = threading.RLock() _global_handlers: dict[ProviderEvent, list[EventHandler]] = defaultdict(list) @@ -29,14 +36,22 @@ def run_client_handlers( client: OpenFeatureClient, event: ProviderEvent, details: EventDetails ) -> None: with _client_lock: - for handler in _client_handlers[client][event]: - handler(details) + handlers_by_event = _client_handlers.get(client) + if handlers_by_event is None: + return + + handlers = tuple(handlers_by_event.get(event, ())) + + for handler in handlers: + _submit_handler(handler, details) def run_global_handlers(event: ProviderEvent, details: EventDetails) -> None: with _global_lock: - for handler in _global_handlers[event]: - handler(details) + handlers = tuple(_global_handlers.get(event, ())) + + for handler in handlers: + _submit_handler(handler, details) def add_client_handler( @@ -83,9 +98,12 @@ def run_handlers_for_provider( run_global_handlers(event, details) # run the handlers for clients associated to this provider with _client_lock: - for client in _client_handlers: - if client.provider == provider: - run_client_handlers(client, event, details) + clients = tuple( + client for client in _client_handlers if client.provider == provider + ) + + for client in clients: + run_client_handlers(client, event, details) def _run_immediate_handler( @@ -98,7 +116,20 @@ def _run_immediate_handler( ProviderStatus.STALE: ProviderEvent.PROVIDER_STALE, } if event == status_to_event.get(client.get_provider_status()): - handler(EventDetails(provider_name=client.provider.get_metadata().name)) + _submit_handler( + handler, EventDetails(provider_name=client.provider.get_metadata().name) + ) + + +def _submit_handler(handler: EventHandler, details: EventDetails) -> None: + _event_executor.submit(_run_handler, handler, details) + + +def _run_handler(handler: EventHandler, details: EventDetails) -> None: + try: + handler(details) + except Exception: + logger.exception("Unhandled exception in OpenFeature event handler") def clear() -> None: diff --git a/tests/test_api.py b/tests/test_api.py index b7945cbb..cdb077fe 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,5 @@ import threading +import time from unittest.mock import MagicMock import pytest @@ -32,6 +33,15 @@ ) +def wait_for_mock_call(mock: MagicMock, timeout: float = 1.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if mock.call_count: + return + + time.sleep(0.01) + + def test_should_not_raise_exception_with_noop_client(): # Given # No provider has been set @@ -295,6 +305,10 @@ def test_provider_events(): # Then # NOTE: provider_ready is called immediately after adding the handler + wait_for_mock_call(spy.provider_ready) + wait_for_mock_call(spy.provider_configuration_changed) + wait_for_mock_call(spy.provider_error) + wait_for_mock_call(spy.provider_stale) spy.provider_ready.assert_called_once() spy.provider_configuration_changed.assert_called_once_with(details) spy.provider_error.assert_called_once_with(details) @@ -335,6 +349,7 @@ def test_handlers_attached_to_provider_already_in_associated_state_should_run_im add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) # Then + wait_for_mock_call(spy.provider_ready) spy.provider_ready.assert_called_once() @@ -344,12 +359,14 @@ def test_provider_ready_handlers_run_if_provider_initialize_function_terminates_ spy = MagicMock() add_handler(ProviderEvent.PROVIDER_READY, spy.provider_ready) + wait_for_mock_call(spy.provider_ready) spy.reset_mock() # reset the mock to avoid counting the immediate call on subscribe # When set_provider_and_wait(provider) # Then + wait_for_mock_call(spy.provider_ready) spy.provider_ready.assert_called_once() @@ -366,6 +383,7 @@ def test_provider_error_handlers_run_if_provider_initialize_function_terminates_ set_provider_and_wait(provider) # Then + wait_for_mock_call(spy.provider_error) spy.provider_error.assert_called_once() diff --git a/tests/test_client.py b/tests/test_client.py index 25819d4d..63453c99 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,4 +1,5 @@ import inspect +import threading import time import types import uuid @@ -7,7 +8,7 @@ import pytest -from openfeature import api +from openfeature import _event_support, api from openfeature.api import ( add_hooks, clear_hooks, @@ -29,6 +30,15 @@ from openfeature.transaction_context import ContextVarsTransactionContextPropagator +def wait_for_mock_call(mock: MagicMock, timeout: float = 1.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if mock.call_count: + return + + time.sleep(0.01) + + @pytest.mark.parametrize( "flag_type, default_value, get_method", ( @@ -467,6 +477,10 @@ def emit_all_events(provider): # Then # NOTE: provider_ready is called immediately after adding the handler + wait_for_mock_call(spy.provider_ready) + wait_for_mock_call(spy.provider_configuration_changed) + wait_for_mock_call(spy.provider_error) + wait_for_mock_call(spy.provider_stale) spy.provider_ready.assert_called_once() spy.provider_configuration_changed.assert_called_once_with(details) spy.provider_error.assert_called_once_with(details) @@ -525,9 +539,25 @@ def test_provider_event_late_binding(): other_provider.emit_provider_configuration_changed(other_provider_details) # Then + wait_for_mock_call(spy.provider_configuration_changed) spy.provider_configuration_changed.assert_called_once_with(details) +def test_run_client_handlers_without_registered_handlers_is_noop(): + provider = NoOpProvider() + set_provider(provider) + client = get_client("client-without-handlers") + details = EventDetails(provider_name=provider.get_metadata().name) + + assert client not in _event_support._client_handlers + + _event_support.run_client_handlers( + client, ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details + ) + + assert client not in _event_support._client_handlers + + # Requirement 5.1.4, Requirement 5.1.5 def test_provider_event_handler_exception(): # Given @@ -545,6 +575,7 @@ def test_provider_event_handler_exception(): ) # Then + wait_for_mock_call(spy.provider_error) spy.provider_error.assert_called_once_with( EventDetails( flags_changed=None, @@ -556,6 +587,68 @@ def test_provider_event_handler_exception(): ) +def test_provider_event_handler_exception_does_not_stop_subsequent_handlers(): + # Given + provider = NoOpProvider() + set_provider(provider) + + spy = MagicMock() + handler_called = threading.Event() + + raising_handler = MagicMock(side_effect=RuntimeError("handler failed")) + + def recording_handler(details): + spy.provider_error(details) + handler_called.set() + + client = get_client() + client.add_handler(ProviderEvent.PROVIDER_ERROR, raising_handler) + client.add_handler(ProviderEvent.PROVIDER_ERROR, recording_handler) + + details = ProviderEventDetails(error_code=ErrorCode.GENERAL, message="some_error") + expected_details = EventDetails.from_provider_event_details( + provider.get_metadata().name, details + ) + + # When + provider.emit_provider_error(details) + + # Then + assert handler_called.wait(timeout=1) + raising_handler.assert_called_once_with(expected_details) + spy.provider_error.assert_called_once_with(expected_details) + + +def test_provider_event_handlers_do_not_block_emitter(): + # Given + provider = NoOpProvider() + set_provider(provider) + + handler_started = threading.Event() + release_handler = threading.Event() + handler_finished = threading.Event() + + def slow_handler(details): + handler_started.set() + release_handler.wait(timeout=1) + handler_finished.set() + + client = get_client() + client.add_handler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, slow_handler) + + # When + start_time = time.perf_counter() + provider.emit_provider_configuration_changed(ProviderEventDetails()) + elapsed = time.perf_counter() - start_time + + # Then + assert handler_started.wait(timeout=1) + # emit must return well before the handler's blocking wait (1s) would finish + assert elapsed < 0.5 + release_handler.set() + assert handler_finished.wait(timeout=1) + + def test_client_handlers_thread_safety(): provider = NoOpProvider() set_provider(provider) From bb39abb42b19665116f6000d6ecf07a370404370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20Gr=C3=BCbel?= Date: Mon, 1 Jun 2026 13:41:36 +0200 Subject: [PATCH 13/17] test: fix flaky event handler test (#609) fix flaky event handler test Signed-off-by: gruebel --- tests/test_client.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/test_client.py b/tests/test_client.py index 63453c99..44b49e5f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -592,18 +592,25 @@ def test_provider_event_handler_exception_does_not_stop_subsequent_handlers(): provider = NoOpProvider() set_provider(provider) - spy = MagicMock() - handler_called = threading.Event() + raising_handler_called = threading.Event() + recording_handler_called = threading.Event() raising_handler = MagicMock(side_effect=RuntimeError("handler failed")) + recording_handler = MagicMock() + + def raising_handler_wrap(details): + try: + raising_handler(details) + finally: + raising_handler_called.set() - def recording_handler(details): - spy.provider_error(details) - handler_called.set() + def recording_handler_wrap(details): + recording_handler(details) + recording_handler_called.set() client = get_client() - client.add_handler(ProviderEvent.PROVIDER_ERROR, raising_handler) - client.add_handler(ProviderEvent.PROVIDER_ERROR, recording_handler) + client.add_handler(ProviderEvent.PROVIDER_ERROR, raising_handler_wrap) + client.add_handler(ProviderEvent.PROVIDER_ERROR, recording_handler_wrap) details = ProviderEventDetails(error_code=ErrorCode.GENERAL, message="some_error") expected_details = EventDetails.from_provider_event_details( @@ -614,9 +621,10 @@ def recording_handler(details): provider.emit_provider_error(details) # Then - assert handler_called.wait(timeout=1) + assert raising_handler_called.wait(timeout=1) + assert recording_handler_called.wait(timeout=1) raising_handler.assert_called_once_with(expected_details) - spy.provider_error.assert_called_once_with(expected_details) + recording_handler.assert_called_once_with(expected_details) def test_provider_event_handlers_do_not_block_emitter(): From 10c27216127fef16ea5fe35cf7f245c125059cd9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:08:20 +0000 Subject: [PATCH 14/17] chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.15.15 (#608) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Signed-off-by: gruebel --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2a49d14..05c2e2b3 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.15.7 + rev: v0.15.15 hooks: - id: ruff-check priority: 1 From 0e848f56925b92ac3e8ee916c8fb3156e27766db Mon Sep 17 00:00:00 2001 From: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:44:50 -0400 Subject: [PATCH 15/17] chore(main): release 0.10.0 (#602) * chore(main): release 0.10.0 Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> * docs: clarify non-blocking set_provider behavior in changelog Signed-off-by: Todd Baert --------- Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Todd Baert Co-authored-by: Todd Baert Signed-off-by: gruebel --- .release-please-manifest.json | 2 +- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ README.md | 8 ++++---- openfeature/version.py | 2 +- pyproject.toml | 2 +- 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 24ce62fb..336b1d82 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"0.9.0"} \ No newline at end of file +{".":"0.10.0"} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8539bad3..af0d6d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## [0.10.0](https://github.com/open-feature/python-sdk/compare/v0.9.0...v0.10.0) (2026-06-01) + + +### โš  BREAKING CHANGES + +* make set_provider non-blocking, add set_provider_and_wait ([#595](https://github.com/open-feature/python-sdk/issues/595)) + +> [!IMPORTANT] +> `set_provider()` no longer blocks until the provider is ready; provider initialization runs in the background. If your workload needs to wait until the `PROVIDER_READY` event has fired before proceeding (for example, to avoid `PROVIDER_NOT_READY` evaluations during startup), use `set_provider_and_wait()` instead. + +### ๐Ÿ› Bug Fixes + +* isolate provider event handler dispatch ([#599](https://github.com/open-feature/python-sdk/issues/599)) ([0a96426](https://github.com/open-feature/python-sdk/commit/0a96426a3e1df497f376c08a499cfa9227c7e2c2)) + + +### โœจ New Features + +* make set_provider non-blocking, add set_provider_and_wait ([#595](https://github.com/open-feature/python-sdk/issues/595)) ([cbacef0](https://github.com/open-feature/python-sdk/commit/cbacef0093c0ef44bc2ba8ad36de1c5497cf0cff)) + + +### ๐Ÿงน Chore + +* **deps:** update astral-sh/setup-uv action to v8 ([#603](https://github.com/open-feature/python-sdk/issues/603)) ([4422fad](https://github.com/open-feature/python-sdk/commit/4422fad635199d11bfd820425bf8938a68cec1ee)) +* **deps:** update codecov/codecov-action action to v6 ([#604](https://github.com/open-feature/python-sdk/issues/604)) ([e00fab3](https://github.com/open-feature/python-sdk/commit/e00fab3f04d52b3d35258d87c68e3c8d3954af31)) +* **deps:** update dependency prek to >=0.4.3,<0.5.0 ([#607](https://github.com/open-feature/python-sdk/issues/607)) ([760d808](https://github.com/open-feature/python-sdk/commit/760d808009e4eddd2984723d8bb3e3382df180ec)) +* **deps:** update googleapis/release-please-action action to v5 ([#605](https://github.com/open-feature/python-sdk/issues/605)) ([aa1366a](https://github.com/open-feature/python-sdk/commit/aa1366abeccf8d4463e933fbba035891e0531125)) +* **deps:** update pre-commit hook astral-sh/ruff-pre-commit to v0.15.15 ([#608](https://github.com/open-feature/python-sdk/issues/608)) ([5f4e42d](https://github.com/open-feature/python-sdk/commit/5f4e42dee5fad734a39b459b08b94045a7210ec3)) +* **deps:** update pre-commit hook pre-commit/mirrors-mypy to v2 ([#606](https://github.com/open-feature/python-sdk/issues/606)) ([d18beef](https://github.com/open-feature/python-sdk/commit/d18beefeaebb717102c40b02acbedf6b3c0c16a8)) +* **deps:** update pre-commit hook tox-dev/pyproject-fmt to v2.21.2 ([#601](https://github.com/open-feature/python-sdk/issues/601)) ([5cc9534](https://github.com/open-feature/python-sdk/commit/5cc9534661a1901c5d3a0b328d65df6495c46b92)) + ## [0.9.0](https://github.com/open-feature/python-sdk/compare/v0.8.4...v0.9.0) (2026-04-17) diff --git a/README.md b/README.md index 17e38eea..f319dc92 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.9.0 +pip install openfeature-sdk==0.10.0 ``` #### requirements.txt ```bash -openfeature-sdk==0.9.0 +openfeature-sdk==0.10.0 ``` ```python diff --git a/openfeature/version.py b/openfeature/version.py index 3e2f46a3..61fb31ca 100644 --- a/openfeature/version.py +++ b/openfeature/version.py @@ -1 +1 @@ -__version__ = "0.9.0" +__version__ = "0.10.0" diff --git a/pyproject.toml b/pyproject.toml index 6e073671..e8e8e6ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = [ "uv-build~=0.11.0" ] [project] name = "openfeature-sdk" -version = "0.9.0" +version = "0.10.0" description = "Standardizing Feature Flagging for Everyone" readme = "README.md" keywords = [ From 987eb2f201c54ea89a57d2f027eaafca356cca62 Mon Sep 17 00:00:00 2001 From: gruebel Date: Sat, 6 Jun 2026 20:46:09 +0200 Subject: [PATCH 16/17] fix CR comments Signed-off-by: gruebel --- tests/features/steps/context_merging_steps.py | 76 +++++++------------ uv.lock | 2 +- 2 files changed, 27 insertions(+), 51 deletions(-) diff --git a/tests/features/steps/context_merging_steps.py b/tests/features/steps/context_merging_steps.py index ce083019..18dfaee4 100644 --- a/tests/features/steps/context_merging_steps.py +++ b/tests/features/steps/context_merging_steps.py @@ -20,7 +20,9 @@ class RetrievableContextProvider(AbstractProvider): """Stores the last merged evaluation context it was asked to resolve.""" - def __init__(self) -> None: + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: + super().__init__(*args, **kwargs) + self.last_context: EvaluationContext | None = None def get_metadata(self) -> Metadata: @@ -92,11 +94,9 @@ def before( def _ensure_state(context: typing.Any) -> None: if getattr(context, "_merging_initialized", False): return - api.clear_providers() - api.clear_hooks() - api.set_evaluation_context(EvaluationContext()) + + api.shutdown() set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) - set_transaction_context(EvaluationContext()) provider = RetrievableContextProvider() api.set_provider(provider) @@ -116,43 +116,24 @@ def step_impl_retrievable_provider(context: typing.Any) -> None: def _add_entry_at_level( context: typing.Any, level: str, key: str, value: typing.Any ) -> None: - _ensure_state(context) if level not in _LEVELS: raise ValueError(f"Unknown level: {level!r}") + + new_entry = ( + EvaluationContext(targeting_key=value) + if key == "targeting_key" + else EvaluationContext(attributes={key: value}) + ) if level == "API": - current = api.get_evaluation_context() - api.set_evaluation_context( - EvaluationContext( - targeting_key=current.targeting_key, - attributes={**current.attributes, key: value}, - ) - ) + api.set_evaluation_context(api.get_evaluation_context().merge(new_entry)) elif level == "Transaction": - current = api.get_transaction_context() - set_transaction_context( - EvaluationContext( - targeting_key=current.targeting_key, - attributes={**current.attributes, key: value}, - ) - ) + set_transaction_context(api.get_transaction_context().merge(new_entry)) elif level == "Client": - current = context.client.context - context.client.context = EvaluationContext( - targeting_key=current.targeting_key, - attributes={**current.attributes, key: value}, - ) + context.client.context = context.client.context.merge(new_entry) elif level == "Invocation": - current = context.invocation_context - context.invocation_context = EvaluationContext( - targeting_key=current.targeting_key, - attributes={**current.attributes, key: value}, - ) + context.invocation_context = context.invocation_context.merge(new_entry) elif level == "Before Hooks": - current = context.before_hook_context - context.before_hook_context = EvaluationContext( - targeting_key=current.targeting_key, - attributes={**current.attributes, key: value}, - ) + context.before_hook_context = context.before_hook_context.merge(new_entry) @given( @@ -165,7 +146,6 @@ def step_impl_add_entry(context: typing.Any, key: str, value: str, level: str) - @given("A table with levels of increasing precedence") def step_impl_levels_table(context: typing.Any) -> None: - _ensure_state(context) # The feature table is a single-column list of levels. Behave treats the # first row as the heading, so recombine heading + body rows. levels = [context.table.headings[0]] @@ -180,7 +160,6 @@ def step_impl_levels_table(context: typing.Any) -> None: def step_impl_entries_down_to( context: typing.Any, level: str, key: str, value: str ) -> None: - _ensure_state(context) levels = context.precedence_levels if level not in levels: raise ValueError(f"Level {level!r} not in precedence table {levels!r}") @@ -192,15 +171,10 @@ def step_impl_entries_down_to( @when("Some flag was evaluated") def step_impl_evaluate(context: typing.Any) -> None: - _ensure_state(context) - hook = _BeforeHookContextInjector(context.before_hook_context) - context.client.add_hooks([hook]) - try: - context.client.get_boolean_details( - "some-flag", False, context.invocation_context - ) - finally: - context.client.hooks = [h for h in context.client.hooks if h is not hook] + context.client.add_hooks( + [(_BeforeHookContextInjector(context.before_hook_context))] + ) + context.client.get_boolean_details("some-flag", False, context.invocation_context) @then('The merged context contains an entry with key "{key}" and value "{value}"') @@ -208,8 +182,10 @@ def step_impl_merged_contains(context: typing.Any, key: str, value: str) -> None assert context.provider.last_context is not None, ( "provider did not receive an evaluation context" ) - attributes = context.provider.last_context.attributes - assert key in attributes, f"key {key!r} missing from merged context: {attributes!r}" - assert attributes[key] == value, ( - f"expected {key!r}={value!r}, got {attributes[key]!r}" + last_context = context.provider.last_context + actual_value = ( + last_context.targeting_key + if key == "targeting_key" + else last_context.attributes.get(key) ) + assert actual_value == value, f"expected {key!r}={value!r}, got {actual_value!r}" diff --git a/uv.lock b/uv.lock index fd4c1c92..ad2a07af 100644 --- a/uv.lock +++ b/uv.lock @@ -197,7 +197,7 @@ wheels = [ [[package]] name = "openfeature-sdk" -version = "0.9.0" +version = "0.10.0" source = { editable = "." } [package.dev-dependencies] From 1f59d4232dd20dc7367ecb3ce36c7e8ef4d0e657 Mon Sep 17 00:00:00 2001 From: gruebel Date: Sat, 6 Jun 2026 21:00:30 +0200 Subject: [PATCH 17/17] cleanup Signed-off-by: gruebel --- tests/features/steps/context_merging_steps.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/features/steps/context_merging_steps.py b/tests/features/steps/context_merging_steps.py index 18dfaee4..afd75eb4 100644 --- a/tests/features/steps/context_merging_steps.py +++ b/tests/features/steps/context_merging_steps.py @@ -11,9 +11,7 @@ from openfeature.hook import Hook, HookContext, HookHints from openfeature.provider import AbstractProvider, Metadata from openfeature.transaction_context import ( - ContextVarsTransactionContextPropagator, set_transaction_context, - set_transaction_context_propagator, ) @@ -95,9 +93,6 @@ def _ensure_state(context: typing.Any) -> None: if getattr(context, "_merging_initialized", False): return - api.shutdown() - set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) - provider = RetrievableContextProvider() api.set_provider(provider) context.provider = provider