From 5deba25dc6d97f8487d1f67ef42909099c3df822 Mon Sep 17 00:00:00 2001 From: marcozabel Date: Wed, 15 Apr 2026 17:17:37 +0200 Subject: [PATCH 1/5] refactor: to support isolated instances Signed-off-by: marcozabel --- openfeature/_api.py | 153 ++++++++++++++ openfeature/_event_support.py | 211 +++++++++++++------- openfeature/api.py | 35 +--- openfeature/client.py | 53 +++-- openfeature/evaluation_context/__init__.py | 24 +-- openfeature/hook/__init__.py | 16 +- openfeature/provider/_registry.py | 23 ++- openfeature/transaction_context/__init__.py | 21 +- tests/test_client.py | 4 +- 9 files changed, 374 insertions(+), 166 deletions(-) create mode 100644 openfeature/_api.py diff --git a/openfeature/_api.py b/openfeature/_api.py new file mode 100644 index 00000000..a4cf9891 --- /dev/null +++ b/openfeature/_api.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import typing + +from openfeature._event_support import EventSupport +from openfeature.evaluation_context import EvaluationContext +from openfeature.event import EventHandler, ProviderEvent +from openfeature.exception import GeneralError +from openfeature.hook import Hook +from openfeature.provider import FeatureProvider +from openfeature.provider._registry import ProviderRegistry +from openfeature.provider.metadata import Metadata +from openfeature.transaction_context import ( + NoOpTransactionContextPropagator, + TransactionContextPropagator, +) + +if typing.TYPE_CHECKING: + from openfeature.client import OpenFeatureClient + + +class OpenFeatureAPI: + """An independent OpenFeature API instance with its own isolated state. + + Each instance maintains its own providers, evaluation context, hooks, + event handlers, and transaction context propagator — fully separate from + the global singleton and from other instances. + """ + + def __init__(self) -> None: + self._hooks: list[Hook] = [] + self._evaluation_context = EvaluationContext() + self._transaction_context_propagator: TransactionContextPropagator = ( + NoOpTransactionContextPropagator() + ) + self._event_support = EventSupport() + self._provider_registry = ProviderRegistry( + event_support=self._event_support, + evaluation_context_getter=self.get_evaluation_context, + ) + + # --- Client creation --- + + def get_client( + self, domain: str | None = None, version: str | None = None + ) -> OpenFeatureClient: + from openfeature.client import OpenFeatureClient # noqa: PLC0415 + + return OpenFeatureClient(domain=domain, version=version, api=self) + + # --- Provider management --- + + def set_provider( + self, provider: FeatureProvider, domain: str | None = None + ) -> None: + if domain is None: + self._provider_registry.set_default_provider(provider) + else: + self._provider_registry.set_provider(domain, provider) + + def set_provider_and_wait( + self, provider: FeatureProvider, domain: str | None = None + ) -> None: + if domain is None: + self._provider_registry.set_default_provider(provider, wait_for_init=True) + else: + self._provider_registry.set_provider(domain, provider, wait_for_init=True) + + def get_provider_metadata(self, domain: str | None = None) -> Metadata: + return self._provider_registry.get_provider(domain).get_metadata() + + def clear_providers(self) -> None: + self._provider_registry.clear_providers() + self._event_support.clear() + + def shutdown(self) -> None: + # shutdown -> remove providers -> set default provider to NoOp -> remove event handlers + self.clear_providers() + # remove hooks + self.clear_hooks() + # set evaluation context to default + self._evaluation_context = EvaluationContext() + # set propagator to NoOp + self._transaction_context_propagator = NoOpTransactionContextPropagator() + + # --- Hooks --- + + def add_hooks(self, hooks: list[Hook]) -> None: + self._hooks = self._hooks + hooks + + def clear_hooks(self) -> None: + self._hooks = [] + + def get_hooks(self) -> list[Hook]: + return self._hooks + + # --- Evaluation context --- + + def get_evaluation_context(self) -> EvaluationContext: + return self._evaluation_context + + def set_evaluation_context(self, evaluation_context: EvaluationContext) -> None: + if evaluation_context is None: + raise GeneralError(error_message="No api level evaluation context") + self._evaluation_context = evaluation_context + + # --- Transaction context --- + + def set_transaction_context_propagator( + self, transaction_context_propagator: TransactionContextPropagator + ) -> None: + self._transaction_context_propagator = transaction_context_propagator + + def get_transaction_context(self) -> EvaluationContext: + return self._transaction_context_propagator.get_transaction_context() + + def set_transaction_context(self, evaluation_context: EvaluationContext) -> None: + self._transaction_context_propagator.set_transaction_context(evaluation_context) + + # --- Event handlers --- + + def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None: + self._event_support.add_global_handler(event, handler, self.get_client) + + def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None: + self._event_support.remove_global_handler(event, handler) + + +def _create_default_api() -> OpenFeatureAPI: + """Create the default global API instance, wired to legacy module-level singletons. + + The default API reuses the module-level ``_default_event_support`` and + ``provider_registry`` so that backward-compatible module-level functions + continue to work against the same state. + """ + from openfeature._event_support import _default_event_support # noqa: PLC0415 + from openfeature.provider._registry import provider_registry # noqa: PLC0415 + + api = OpenFeatureAPI.__new__(OpenFeatureAPI) + api._hooks = [] + api._evaluation_context = EvaluationContext() + api._transaction_context_propagator = NoOpTransactionContextPropagator() + api._event_support = _default_event_support + api._provider_registry = provider_registry + + # Wire the registry to this API's event support and context getter + provider_registry._event_support = _default_event_support + provider_registry._evaluation_context_getter = api.get_evaluation_context + + return api + + +_default_api = _create_default_api() diff --git a/openfeature/_event_support.py b/openfeature/_event_support.py index 3928be3e..ecb9cf22 100644 --- a/openfeature/_event_support.py +++ b/openfeature/_event_support.py @@ -4,6 +4,7 @@ import threading import typing from collections import defaultdict +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from logging import getLogger @@ -23,67 +24,164 @@ _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) -_client_lock = threading.RLock() -_client_handlers: dict[OpenFeatureClient, dict[ProviderEvent, list[EventHandler]]] = ( - defaultdict(lambda: defaultdict(list)) -) +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") +class EventSupport: + """Per-API-instance event handler storage and dispatch.""" + + def __init__(self) -> None: + self._global_lock = threading.RLock() + self._global_handlers: dict[ProviderEvent, list[EventHandler]] = defaultdict( + list + ) + + self._client_lock = threading.RLock() + self._client_handlers: dict[ + OpenFeatureClient, dict[ProviderEvent, list[EventHandler]] + ] = defaultdict(lambda: defaultdict(list)) + + def run_client_handlers( + self, client: OpenFeatureClient, event: ProviderEvent, details: EventDetails + ) -> None: + with self._client_lock: + handlers_by_event = self._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(self, event: ProviderEvent, details: EventDetails) -> None: + with self._global_lock: + handlers = tuple(self._global_handlers.get(event, ())) + + for handler in handlers: + _submit_handler(handler, details) + + def add_client_handler( + self, client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler + ) -> None: + with self._client_lock: + handlers = self._client_handlers[client][event] + handlers.append(handler) + + self._run_immediate_handler(client, event, handler) + + def remove_client_handler( + self, client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler + ) -> None: + with self._client_lock: + handlers = self._client_handlers[client][event] + handlers.remove(handler) + + def add_global_handler( + self, + event: ProviderEvent, + handler: EventHandler, + get_client: Callable[[], OpenFeatureClient], + ) -> None: + with self._global_lock: + self._global_handlers[event].append(handler) + + self._run_immediate_handler(get_client(), event, handler) + + def remove_global_handler( + self, event: ProviderEvent, handler: EventHandler + ) -> None: + with self._global_lock: + self._global_handlers[event].remove(handler) + + def run_handlers_for_provider( + self, + provider: FeatureProvider, + event: ProviderEvent, + provider_details: ProviderEventDetails, + ) -> None: + details = EventDetails.from_provider_event_details( + provider.get_metadata().name, provider_details + ) + # run the global handlers + self.run_global_handlers(event, details) + # run the handlers for clients associated to this provider + with self._client_lock: + clients = tuple( + client + for client in self._client_handlers + if client.provider == provider + ) + + for client in clients: + self.run_client_handlers(client, event, details) + + def _run_immediate_handler( + self, client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler + ) -> None: + status_to_event = { + ProviderStatus.READY: ProviderEvent.PROVIDER_READY, + ProviderStatus.ERROR: ProviderEvent.PROVIDER_ERROR, + ProviderStatus.FATAL: ProviderEvent.PROVIDER_ERROR, + ProviderStatus.STALE: ProviderEvent.PROVIDER_STALE, + } + if event == status_to_event.get(client.get_provider_status()): + _submit_handler( + handler, + EventDetails(provider_name=client.provider.get_metadata().name), + ) + + def clear(self) -> None: + with self._global_lock: + self._global_handlers.clear() + with self._client_lock: + self._client_handlers.clear() + + +# Default instance used by the global singleton API +_default_event_support = EventSupport() + + +# Backward-compatible module-level functions delegating to the default instance def run_client_handlers( client: OpenFeatureClient, event: ProviderEvent, details: EventDetails ) -> None: - with _client_lock: - 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) + _default_event_support.run_client_handlers(client, event, details) def run_global_handlers(event: ProviderEvent, details: EventDetails) -> None: - with _global_lock: - handlers = tuple(_global_handlers.get(event, ())) - - for handler in handlers: - _submit_handler(handler, details) + _default_event_support.run_global_handlers(event, details) def add_client_handler( client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler ) -> None: - with _client_lock: - handlers = _client_handlers[client][event] - handlers.append(handler) - - _run_immediate_handler(client, event, handler) + _default_event_support.add_client_handler(client, event, handler) def remove_client_handler( client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler ) -> None: - with _client_lock: - handlers = _client_handlers[client][event] - handlers.remove(handler) + _default_event_support.remove_client_handler(client, event, handler) def add_global_handler(event: ProviderEvent, handler: EventHandler) -> None: - with _global_lock: - _global_handlers[event].append(handler) - from openfeature.api import get_client # noqa: PLC0415 - _run_immediate_handler(get_client(), event, handler) + _default_event_support.add_global_handler(event, handler, get_client) def remove_global_handler(event: ProviderEvent, handler: EventHandler) -> None: - with _global_lock: - _global_handlers[event].remove(handler) + _default_event_support.remove_global_handler(event, handler) def run_handlers_for_provider( @@ -91,49 +189,8 @@ def run_handlers_for_provider( event: ProviderEvent, provider_details: ProviderEventDetails, ) -> None: - details = EventDetails.from_provider_event_details( - provider.get_metadata().name, provider_details - ) - # run the global handlers - run_global_handlers(event, details) - # run the handlers for clients associated to this provider - with _client_lock: - 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( - client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler -) -> None: - status_to_event = { - ProviderStatus.READY: ProviderEvent.PROVIDER_READY, - ProviderStatus.ERROR: ProviderEvent.PROVIDER_ERROR, - ProviderStatus.FATAL: ProviderEvent.PROVIDER_ERROR, - ProviderStatus.STALE: ProviderEvent.PROVIDER_STALE, - } - if event == status_to_event.get(client.get_provider_status()): - _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") + _default_event_support.run_handlers_for_provider(provider, event, provider_details) def clear() -> None: - with _global_lock: - _global_handlers.clear() - with _client_lock: - _client_handlers.clear() + _default_event_support.clear() diff --git a/openfeature/api.py b/openfeature/api.py index 4585e50e..271339a1 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -1,7 +1,6 @@ -from openfeature import _event_support +from openfeature._api import _default_api from openfeature.client import OpenFeatureClient from openfeature.evaluation_context import ( - clear_evaluation_context, get_evaluation_context, set_evaluation_context, ) @@ -11,10 +10,8 @@ ) from openfeature.hook import add_hooks, clear_hooks, get_hooks from openfeature.provider import FeatureProvider -from openfeature.provider._registry import provider_registry from openfeature.provider.metadata import Metadata from openfeature.transaction_context import ( - clear_transaction_context_propagator, get_transaction_context, set_transaction_context, set_transaction_context_propagator, @@ -43,46 +40,32 @@ def get_client( domain: str | None = None, version: str | None = None ) -> OpenFeatureClient: - return OpenFeatureClient(domain=domain, version=version) + return _default_api.get_client(domain=domain, version=version) def set_provider(provider: FeatureProvider, domain: str | None = None) -> None: - if domain is None: - provider_registry.set_default_provider(provider) - else: - provider_registry.set_provider(domain, provider) + _default_api.set_provider(provider, domain) 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) + _default_api.set_provider_and_wait(provider, domain) def clear_providers() -> None: - provider_registry.clear_providers() - _event_support.clear() + _default_api.clear_providers() def get_provider_metadata(domain: str | None = None) -> Metadata: - return provider_registry.get_provider(domain).get_metadata() + return _default_api.get_provider_metadata(domain) def shutdown() -> None: - # 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() + _default_api.shutdown() def add_handler(event: ProviderEvent, handler: EventHandler) -> None: - _event_support.add_global_handler(event, handler) + _default_api.add_handler(event, handler) def remove_handler(event: ProviderEvent, handler: EventHandler) -> None: - _event_support.remove_global_handler(event, handler) + _default_api.remove_handler(event, handler) diff --git a/openfeature/client.py b/openfeature/client.py index 95dc5b6d..f0890d76 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -1,11 +1,12 @@ +from __future__ import annotations + import logging import typing from collections.abc import Awaitable, Mapping, Sequence from dataclasses import dataclass from itertools import chain -from openfeature import _event_support -from openfeature.evaluation_context import EvaluationContext, get_evaluation_context +from openfeature.evaluation_context import EvaluationContext from openfeature.event import EventHandler, ProviderEvent from openfeature.exception import ( ErrorCode, @@ -23,7 +24,7 @@ FlagValueType, Reason, ) -from openfeature.hook import Hook, HookContext, HookHints, get_hooks +from openfeature.hook import Hook, HookContext, HookHints from openfeature.hook._hook_support import ( after_all_hooks, after_hooks, @@ -31,9 +32,10 @@ error_hooks, ) from openfeature.provider import FeatureProvider, ProviderStatus -from openfeature.provider._registry import provider_registry from openfeature.track import TrackingEventDetails -from openfeature.transaction_context import get_transaction_context + +if typing.TYPE_CHECKING: + from openfeature._api import OpenFeatureAPI __all__ = [ "ClientMetadata", @@ -81,18 +83,25 @@ def __init__( version: str | None, context: EvaluationContext | None = None, hooks: list[Hook] | None = None, + api: OpenFeatureAPI | None = None, ) -> None: self.domain = domain self.version = version self.context = context or EvaluationContext() self.hooks = hooks or [] + if api is not None: + self._api = api + else: + from openfeature._api import _default_api # noqa: PLC0415 + + self._api = _default_api @property def provider(self) -> FeatureProvider: - return provider_registry.get_provider(self.domain) + return self._api._provider_registry.get_provider(self.domain) def get_provider_status(self) -> ProviderStatus: - return provider_registry.get_provider_status(self.provider) + return self._api._provider_registry.get_provider_status(self.provider) def get_metadata(self) -> ClientMetadata: return ClientMetadata(domain=self.domain) @@ -422,8 +431,8 @@ def _establish_hooks_and_provider( # Merge transaction context into evaluation context before creating hook_context # This ensures hooks have access to the complete context including transaction context merged_eval_context = ( - get_evaluation_context() - .merge(get_transaction_context()) + self._api.get_evaluation_context() + .merge(self._api.get_transaction_context()) .merge(self.context) .merge(evaluation_context) ) @@ -448,7 +457,7 @@ def _establish_hooks_and_provider( ), ) for hook in chain( - get_hooks(), + self._api.get_hooks(), self.hooks, evaluation_hooks, provider.get_provider_hooks(), @@ -540,20 +549,20 @@ async def evaluate_flag_details_async( self, flag_type: FlagType, flag_key: str, - default_value: Sequence["FlagValueType"], + default_value: Sequence[FlagValueType], evaluation_context: EvaluationContext | None = None, flag_evaluation_options: FlagEvaluationOptions | None = None, - ) -> FlagEvaluationDetails[Sequence["FlagValueType"]]: ... + ) -> FlagEvaluationDetails[Sequence[FlagValueType]]: ... @typing.overload async def evaluate_flag_details_async( self, flag_type: FlagType, flag_key: str, - default_value: Mapping[str, "FlagValueType"], + default_value: Mapping[str, FlagValueType], evaluation_context: EvaluationContext | None = None, flag_evaluation_options: FlagEvaluationOptions | None = None, - ) -> FlagEvaluationDetails[Mapping[str, "FlagValueType"]]: ... + ) -> FlagEvaluationDetails[Mapping[str, FlagValueType]]: ... async def evaluate_flag_details_async( self, @@ -716,20 +725,20 @@ def evaluate_flag_details( self, flag_type: FlagType, flag_key: str, - default_value: Sequence["FlagValueType"], + default_value: Sequence[FlagValueType], evaluation_context: EvaluationContext | None = None, flag_evaluation_options: FlagEvaluationOptions | None = None, - ) -> FlagEvaluationDetails[Sequence["FlagValueType"]]: ... + ) -> FlagEvaluationDetails[Sequence[FlagValueType]]: ... @typing.overload def evaluate_flag_details( self, flag_type: FlagType, flag_key: str, - default_value: Mapping[str, "FlagValueType"], + default_value: Mapping[str, FlagValueType], evaluation_context: EvaluationContext | None = None, flag_evaluation_options: FlagEvaluationOptions | None = None, - ) -> FlagEvaluationDetails[Mapping[str, "FlagValueType"]]: ... + ) -> FlagEvaluationDetails[Mapping[str, FlagValueType]]: ... def evaluate_flag_details( self, @@ -951,10 +960,10 @@ def _create_provider_evaluation( return resolution.to_flag_evaluation_details(flag_key) def add_handler(self, event: ProviderEvent, handler: EventHandler) -> None: - _event_support.add_client_handler(self, event, handler) + self._api._event_support.add_client_handler(self, event, handler) def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None: - _event_support.remove_client_handler(self, event, handler) + self._api._event_support.remove_client_handler(self, event, handler) def track( self, @@ -974,8 +983,8 @@ def track( evaluation_context = EvaluationContext() merged_eval_context = ( - get_evaluation_context() - .merge(get_transaction_context()) + self._api.get_evaluation_context() + .merge(self._api.get_transaction_context()) .merge(self.context) .merge(evaluation_context) ) diff --git a/openfeature/evaluation_context/__init__.py b/openfeature/evaluation_context/__init__.py index 690c63be..c828be1a 100644 --- a/openfeature/evaluation_context/__init__.py +++ b/openfeature/evaluation_context/__init__.py @@ -5,14 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime -from openfeature.exception import GeneralError - -__all__ = [ - "EvaluationContext", - "clear_evaluation_context", - "get_evaluation_context", - "set_evaluation_context", -] +__all__ = ["EvaluationContext", "get_evaluation_context", "set_evaluation_context"] # https://openfeature.dev/specification/sections/evaluation-context#requirement-312 EvaluationContextAttribute: typing.TypeAlias = ( @@ -42,19 +35,16 @@ def merge(self, ctx2: EvaluationContext) -> EvaluationContext: def get_evaluation_context() -> EvaluationContext: - return _evaluation_context + from openfeature._api import _default_api # noqa: PLC0415 + return _default_api.get_evaluation_context() -def set_evaluation_context(evaluation_context: EvaluationContext) -> None: - global _evaluation_context - if evaluation_context is None: - raise GeneralError(error_message="No api level evaluation context") - _evaluation_context = evaluation_context +def set_evaluation_context(evaluation_context: EvaluationContext) -> None: + from openfeature._api import _default_api # noqa: PLC0415 -def clear_evaluation_context() -> None: - set_evaluation_context(EvaluationContext()) + _default_api.set_evaluation_context(evaluation_context) -# need to be at the bottom, because of the definition order +# Kept for backward compatibility but no longer used; state lives in _default_api. _evaluation_context = EvaluationContext() diff --git a/openfeature/hook/__init__.py b/openfeature/hook/__init__.py index 247d316b..3bf97403 100644 --- a/openfeature/hook/__init__.py +++ b/openfeature/hook/__init__.py @@ -23,8 +23,6 @@ "get_hooks", ] -_hooks: list[Hook] = [] - # https://openfeature.dev/specification/sections/hooks/#requirement-461 HookData = MutableMapping[str, typing.Any] @@ -152,14 +150,18 @@ def supports_flag_value_type(self, flag_type: FlagType) -> bool: def add_hooks(hooks: list[Hook]) -> None: - global _hooks - _hooks = _hooks + hooks + from openfeature._api import _default_api # noqa: PLC0415 + + _default_api.add_hooks(hooks) def clear_hooks() -> None: - global _hooks - _hooks = [] + from openfeature._api import _default_api # noqa: PLC0415 + + _default_api.clear_hooks() def get_hooks() -> list[Hook]: - return _hooks + from openfeature._api import _default_api # noqa: PLC0415 + + return _default_api.get_hooks() diff --git a/openfeature/provider/_registry.py b/openfeature/provider/_registry.py index e46caadd..3a89a729 100644 --- a/openfeature/provider/_registry.py +++ b/openfeature/provider/_registry.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import threading +import typing +from collections.abc import Callable -from openfeature._event_support import run_handlers_for_provider from openfeature.evaluation_context import EvaluationContext, get_evaluation_context from openfeature.event import ( ProviderEvent, @@ -10,6 +13,9 @@ from openfeature.provider import FeatureProvider, ProviderStatus from openfeature.provider.no_op_provider import NoOpProvider +if typing.TYPE_CHECKING: + from openfeature._event_support import EventSupport + class ProviderRegistry: _default_provider: FeatureProvider @@ -17,13 +23,21 @@ class ProviderRegistry: _provider_status: dict[FeatureProvider, ProviderStatus] _lock: threading.RLock - def __init__(self) -> None: + def __init__( + self, + event_support: EventSupport | None = None, + evaluation_context_getter: Callable[[], EvaluationContext] | None = None, + ) -> None: self._lock = threading.RLock() self._default_provider = NoOpProvider() self._providers = {} self._provider_status = { self._default_provider: ProviderStatus.READY, } + self._event_support = event_support + self._evaluation_context_getter = ( + evaluation_context_getter or get_evaluation_context + ) def set_provider( self, domain: str, provider: FeatureProvider, wait_for_init: bool = False @@ -102,7 +116,7 @@ def shutdown(self) -> None: self._shutdown_provider(provider) def _get_evaluation_context(self) -> EvaluationContext: - return get_evaluation_context() + return self._evaluation_context_getter() def _initialize_provider( self, provider: FeatureProvider, wait_for_init: bool @@ -223,7 +237,8 @@ def dispatch_event( details: ProviderEventDetails, ) -> None: self._update_provider_status(provider, event, details) - run_handlers_for_provider(provider, event, details) + if self._event_support is not None: + self._event_support.run_handlers_for_provider(provider, event, details) def _update_provider_status( self, diff --git a/openfeature/transaction_context/__init__.py b/openfeature/transaction_context/__init__.py index 15ac7e01..ca313e98 100644 --- a/openfeature/transaction_context/__init__.py +++ b/openfeature/transaction_context/__init__.py @@ -11,6 +11,7 @@ __all__ = [ "ContextVarsTransactionContextPropagator", + "NoOpTransactionContextPropagator", "TransactionContextPropagator", "clear_transaction_context_propagator", "get_transaction_context", @@ -18,16 +19,13 @@ "set_transaction_context_propagator", ] -_evaluation_transaction_context_propagator: TransactionContextPropagator = ( - NoOpTransactionContextPropagator() -) - def set_transaction_context_propagator( transaction_context_propagator: TransactionContextPropagator, ) -> None: - global _evaluation_transaction_context_propagator - _evaluation_transaction_context_propagator = transaction_context_propagator + from openfeature._api import _default_api # noqa: PLC0415 + + _default_api.set_transaction_context_propagator(transaction_context_propagator) def clear_transaction_context_propagator() -> None: @@ -35,11 +33,12 @@ def clear_transaction_context_propagator() -> None: def get_transaction_context() -> EvaluationContext: - return _evaluation_transaction_context_propagator.get_transaction_context() + from openfeature._api import _default_api # noqa: PLC0415 + + return _default_api.get_transaction_context() def set_transaction_context(evaluation_context: EvaluationContext) -> None: - global _evaluation_transaction_context_propagator - _evaluation_transaction_context_propagator.set_transaction_context( - evaluation_context - ) + from openfeature._api import _default_api # noqa: PLC0415 + + _default_api.set_transaction_context(evaluation_context) diff --git a/tests/test_client.py b/tests/test_client.py index 44b49e5f..9f59abf3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -549,13 +549,13 @@ def test_run_client_handlers_without_registered_handlers_is_noop(): client = get_client("client-without-handlers") details = EventDetails(provider_name=provider.get_metadata().name) - assert client not in _event_support._client_handlers + assert client not in _event_support._default_event_support._client_handlers _event_support.run_client_handlers( client, ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details ) - assert client not in _event_support._client_handlers + assert client not in _event_support._default_event_support._client_handlers # Requirement 5.1.4, Requirement 5.1.5 From 7ad838b6181f92ffa8cc1b2efd4691cbae0dfdc3 Mon Sep 17 00:00:00 2001 From: marcozabel Date: Wed, 15 Apr 2026 17:17:38 +0200 Subject: [PATCH 2/5] feat(isolated): add factory module for isolated API instances Signed-off-by: marcozabel --- openfeature/_api.py | 8 +- openfeature/client.py | 4 +- openfeature/isolated.py | 37 ++ ...text_var_transaction_context_propagator.py | 7 +- tests/test_isolated_api.py | 363 ++++++++++++++++++ 5 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 openfeature/isolated.py create mode 100644 tests/test_isolated_api.py diff --git a/openfeature/_api.py b/openfeature/_api.py index a4cf9891..2b71b038 100644 --- a/openfeature/_api.py +++ b/openfeature/_api.py @@ -7,7 +7,7 @@ from openfeature.event import EventHandler, ProviderEvent from openfeature.exception import GeneralError from openfeature.hook import Hook -from openfeature.provider import FeatureProvider +from openfeature.provider import FeatureProvider, ProviderStatus from openfeature.provider._registry import ProviderRegistry from openfeature.provider.metadata import Metadata from openfeature.transaction_context import ( @@ -69,6 +69,12 @@ def set_provider_and_wait( def get_provider_metadata(self, domain: str | None = None) -> Metadata: return self._provider_registry.get_provider(domain).get_metadata() + def get_provider(self, domain: str | None = None) -> FeatureProvider: + return self._provider_registry.get_provider(domain) + + def get_provider_status(self, provider: FeatureProvider) -> ProviderStatus: + return self._provider_registry.get_provider_status(provider) + def clear_providers(self) -> None: self._provider_registry.clear_providers() self._event_support.clear() diff --git a/openfeature/client.py b/openfeature/client.py index f0890d76..c1ebff85 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -98,10 +98,10 @@ def __init__( @property def provider(self) -> FeatureProvider: - return self._api._provider_registry.get_provider(self.domain) + return self._api.get_provider(self.domain) def get_provider_status(self) -> ProviderStatus: - return self._api._provider_registry.get_provider_status(self.provider) + return self._api.get_provider_status(self.provider) def get_metadata(self) -> ClientMetadata: return ClientMetadata(domain=self.domain) diff --git a/openfeature/isolated.py b/openfeature/isolated.py new file mode 100644 index 00000000..5ab7ea6d --- /dev/null +++ b/openfeature/isolated.py @@ -0,0 +1,37 @@ +"""Factory for creating isolated OpenFeature API instances. + +Per specification requirement 1.8.3, this module is intentionally separate +from the global singleton ``openfeature.api`` to reduce the risk of +accidentally creating isolated instances when the singleton is appropriate. + +Usage:: + + from openfeature.isolated import create_api + + api = create_api() + api.set_provider(MyProvider()) + client = api.get_client() + +Each instance returned by :func:`create_api` maintains its own providers, +evaluation context, hooks, event handlers, and transaction context propagator +— fully independent from the global singleton and from other instances. + +A single provider instance should not be registered with more than one API +instance simultaneously (spec requirement 1.8.4). +""" + +from openfeature._api import OpenFeatureAPI + +__all__ = ["OpenFeatureAPI", "create_api"] + + +def create_api() -> OpenFeatureAPI: + """Create a new, independent OpenFeature API instance. + + The returned instance is functionally equivalent to the global singleton + (``openfeature.api``), but with completely isolated state. + + Returns: + A new :class:`OpenFeatureAPI` instance. + """ + return OpenFeatureAPI() diff --git a/openfeature/transaction_context/context_var_transaction_context_propagator.py b/openfeature/transaction_context/context_var_transaction_context_propagator.py index 449c67a1..4fb27888 100644 --- a/openfeature/transaction_context/context_var_transaction_context_propagator.py +++ b/openfeature/transaction_context/context_var_transaction_context_propagator.py @@ -7,9 +7,10 @@ class ContextVarsTransactionContextPropagator(TransactionContextPropagator): - _transaction_context_var: ContextVar[EvaluationContext | None] = ContextVar( - "transaction_context", default=None - ) + def __init__(self) -> None: + self._transaction_context_var: ContextVar[EvaluationContext | None] = ( + ContextVar(f"transaction_context_{id(self)}", default=None) + ) def get_transaction_context(self) -> EvaluationContext: context = self._transaction_context_var.get() diff --git a/tests/test_isolated_api.py b/tests/test_isolated_api.py new file mode 100644 index 00000000..f2f85b5f --- /dev/null +++ b/tests/test_isolated_api.py @@ -0,0 +1,363 @@ +"""Tests for isolated OpenFeature API instances (spec section 1.8).""" + +import time +from unittest.mock import MagicMock + +from openfeature import api +from openfeature._event_support import _default_event_support +from openfeature.evaluation_context import EvaluationContext +from openfeature.event import ProviderEvent, ProviderEventDetails +from openfeature.hook import Hook +from openfeature.isolated import OpenFeatureAPI, create_api +from openfeature.provider import FeatureProvider, ProviderStatus +from openfeature.provider.no_op_provider import NoOpProvider +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) + + +# --- Spec 1.8.1: Factory returns independent instances --- + + +def test_create_api_returns_new_instance(): + api1 = create_api() + api2 = create_api() + assert api1 is not api2 + + +def test_isolated_instance_is_openfeature_api(): + api_instance = create_api() + assert isinstance(api_instance, OpenFeatureAPI) + + +# --- Spec 1.8.2: Same API contract --- + + +def test_isolated_api_provides_full_api_contract(): + api_instance = create_api() + + # Provider management + assert hasattr(api_instance, "set_provider") + assert hasattr(api_instance, "get_provider_metadata") + assert hasattr(api_instance, "clear_providers") + assert hasattr(api_instance, "shutdown") + + # Client creation + assert hasattr(api_instance, "get_client") + + # Hooks + assert hasattr(api_instance, "add_hooks") + assert hasattr(api_instance, "clear_hooks") + assert hasattr(api_instance, "get_hooks") + + # Context + assert hasattr(api_instance, "get_evaluation_context") + assert hasattr(api_instance, "set_evaluation_context") + + # Events + assert hasattr(api_instance, "add_handler") + assert hasattr(api_instance, "remove_handler") + + # Transaction context + assert hasattr(api_instance, "get_transaction_context") + assert hasattr(api_instance, "set_transaction_context") + assert hasattr(api_instance, "set_transaction_context_propagator") + + +def test_isolated_api_get_client_returns_working_client(): + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="test-provider") + + api_instance = create_api() + api_instance.set_provider(provider) + + client = api_instance.get_client() + assert client is not None + assert client.provider is provider + + +def test_isolated_api_get_client_with_domain(): + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="domain-provider") + + api_instance = create_api() + api_instance.set_provider(provider, domain="my-domain") + + client = api_instance.get_client(domain="my-domain") + assert client.provider is provider + + +# --- Isolated state: providers --- + + +def test_isolated_providers_are_independent(): + provider_a = MagicMock(spec=FeatureProvider) + provider_a.get_metadata.return_value = MagicMock(name="provider-a") + provider_b = MagicMock(spec=FeatureProvider) + provider_b.get_metadata.return_value = MagicMock(name="provider-b") + + api1 = create_api() + api2 = create_api() + + api1.set_provider(provider_a) + api2.set_provider(provider_b) + + client1 = api1.get_client() + client2 = api2.get_client() + + assert client1.provider is provider_a + assert client2.provider is provider_b + + +def test_isolated_provider_does_not_affect_global(): + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="isolated-provider") + + api_instance = create_api() + api_instance.set_provider(provider) + + # Global singleton should still have NoOpProvider + global_client = api.get_client() + assert isinstance(global_client.provider, NoOpProvider) + + +# --- Isolated state: hooks --- + + +def test_isolated_hooks_are_independent(): + hook_a = MagicMock(spec=Hook) + hook_b = MagicMock(spec=Hook) + + api1 = create_api() + api2 = create_api() + + api1.add_hooks([hook_a]) + api2.add_hooks([hook_b]) + + assert hook_a in api1.get_hooks() + assert hook_b not in api1.get_hooks() + assert hook_b in api2.get_hooks() + assert hook_a not in api2.get_hooks() + + +def test_isolated_hooks_do_not_affect_global(): + hook = MagicMock(spec=Hook) + + api_instance = create_api() + api_instance.add_hooks([hook]) + + assert hook not in api.get_hooks() + + +def test_clear_hooks_on_isolated_api(): + hook = MagicMock(spec=Hook) + + api_instance = create_api() + api_instance.add_hooks([hook]) + assert len(api_instance.get_hooks()) == 1 + + api_instance.clear_hooks() + assert len(api_instance.get_hooks()) == 0 + + +# --- Isolated state: evaluation context --- + + +def test_isolated_evaluation_context_is_independent(): + ctx_a = EvaluationContext(targeting_key="user-a") + ctx_b = EvaluationContext(targeting_key="user-b") + + api1 = create_api() + api2 = create_api() + + api1.set_evaluation_context(ctx_a) + api2.set_evaluation_context(ctx_b) + + assert api1.get_evaluation_context().targeting_key == "user-a" + assert api2.get_evaluation_context().targeting_key == "user-b" + + +def test_isolated_evaluation_context_does_not_affect_global(): + ctx = EvaluationContext(targeting_key="isolated-user") + + api_instance = create_api() + api_instance.set_evaluation_context(ctx) + + assert api.get_evaluation_context().targeting_key != "isolated-user" + + +# --- Isolated state: events --- + + +def test_isolated_event_handlers_are_independent(): + handler_a = MagicMock() + handler_b = MagicMock() + + api1 = create_api() + api2 = create_api() + + provider1 = MagicMock(spec=FeatureProvider) + provider1.get_metadata.return_value = MagicMock(name="p1") + provider2 = MagicMock(spec=FeatureProvider) + provider2.get_metadata.return_value = MagicMock(name="p2") + + api1.set_provider(provider1) + api2.set_provider(provider2) + + # Register handlers for CONFIGURATION_CHANGED to test dispatch isolation + api1.add_handler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, handler_a) + api2.add_handler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, handler_b) + + # Dispatch event on api1's registry — only handler_a should fire + api1._provider_registry.dispatch_event( + provider1, + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + ProviderEventDetails(), + ) + + wait_for_mock_call(handler_a) + assert handler_a.call_count == 1 + assert handler_b.call_count == 0 + + +def test_isolated_event_handlers_do_not_affect_global(): + handler = MagicMock() + + api_instance = create_api() + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="p") + api_instance.set_provider(provider) + api_instance.add_handler(ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, handler) + + # Dispatch on global — isolated handler should NOT fire + global_provider = MagicMock(spec=FeatureProvider) + global_provider.get_metadata.return_value = MagicMock(name="gp") + api.set_provider(global_provider) + + handler.reset_mock() + + _default_event_support.run_handlers_for_provider( + global_provider, + ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, + ProviderEventDetails(), + ) + + assert handler.call_count == 0 + + +# --- Provider lifecycle on isolated instances --- + + +def test_isolated_api_initializes_provider(): + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="init-provider") + + api_instance = create_api() + api_instance.set_provider(provider) + + provider.initialize.assert_called_once() + + +def test_isolated_api_shuts_down_provider(): + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="shutdown-provider") + + api_instance = create_api() + api_instance.set_provider(provider) + api_instance.shutdown() + + provider.shutdown.assert_called_once() + + +def test_isolated_api_clear_providers(): + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="clear-provider") + + api_instance = create_api() + api_instance.set_provider(provider) + api_instance.clear_providers() + + client = api_instance.get_client() + assert isinstance(client.provider, NoOpProvider) + + +# --- Provider status on isolated instances --- + + +def test_isolated_client_provider_status(): + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="status-provider") + + api_instance = create_api() + api_instance.set_provider(provider) + + client = api_instance.get_client() + assert client.get_provider_status() == ProviderStatus.READY + + +# --- Transaction context on isolated instances --- + + +def test_isolated_transaction_context_propagator(): + api1 = create_api() + api2 = create_api() + + api1.set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) + + ctx = EvaluationContext(targeting_key="tx-user") + api1.set_transaction_context(ctx) + + assert api1.get_transaction_context().targeting_key == "tx-user" + # api2 still uses NoOpTransactionContextPropagator → empty context + assert api2.get_transaction_context().targeting_key is None + + +def test_isolated_transaction_context_with_both_using_contextvars(): + """Two APIs with ContextVars propagators must not share state.""" + api1 = create_api() + api2 = create_api() + + api1.set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) + api2.set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) + + api1.set_transaction_context(EvaluationContext(targeting_key="api1-user")) + + assert api1.get_transaction_context().targeting_key == "api1-user" + assert api2.get_transaction_context().targeting_key is None + + +# --- Global singleton backward compatibility --- + + +def test_global_api_still_works(): + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="global-provider") + + api.set_provider(provider) + client = api.get_client() + + assert client.provider is provider + provider.initialize.assert_called_once() + + +def test_global_hooks_still_work(): + hook = MagicMock(spec=Hook) + + api.add_hooks([hook]) + assert hook in api.get_hooks() + + api.clear_hooks() + assert len(api.get_hooks()) == 0 + + +def test_global_evaluation_context_still_works(): + ctx = EvaluationContext(targeting_key="global-user") + api.set_evaluation_context(ctx) + assert api.get_evaluation_context().targeting_key == "global-user" From 556afed90d626f280289abc27b9fc518ca7bc550 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 11 Jun 2026 13:33:48 -0400 Subject: [PATCH 3/5] fixup: remove lazy imports, shims, add check Signed-off-by: Todd Baert --- openfeature/_api.py | 40 +----- openfeature/_event_support.py | 49 ------- openfeature/api.py | 58 ++++++-- openfeature/client.py | 36 ++--- openfeature/evaluation_context/__init__.py | 18 +-- openfeature/hook/__init__.py | 21 --- openfeature/provider/_registry.py | 46 ++++-- openfeature/transaction_context/__init__.py | 33 ----- tests/conftest.py | 7 +- tests/features/environment.py | 7 +- tests/features/steps/context_merging_steps.py | 4 +- tests/hook/test_hook_data.py | 5 +- tests/provider/test_registry.py | 57 ++++---- tests/test_api.py | 16 +-- tests/test_client.py | 21 +-- tests/test_isolated_api.py | 136 +++++++++++++----- tests/test_transaction_context_in_hooks.py | 4 +- uv.lock | 2 +- 18 files changed, 272 insertions(+), 288 deletions(-) diff --git a/openfeature/_api.py b/openfeature/_api.py index 2b71b038..ee1a7b99 100644 --- a/openfeature/_api.py +++ b/openfeature/_api.py @@ -1,8 +1,7 @@ from __future__ import annotations -import typing - from openfeature._event_support import EventSupport +from openfeature.client import OpenFeatureClient from openfeature.evaluation_context import EvaluationContext from openfeature.event import EventHandler, ProviderEvent from openfeature.exception import GeneralError @@ -10,20 +9,17 @@ from openfeature.provider import FeatureProvider, ProviderStatus from openfeature.provider._registry import ProviderRegistry from openfeature.provider.metadata import Metadata -from openfeature.transaction_context import ( +from openfeature.transaction_context import TransactionContextPropagator +from openfeature.transaction_context.no_op_transaction_context_propagator import ( NoOpTransactionContextPropagator, - TransactionContextPropagator, ) -if typing.TYPE_CHECKING: - from openfeature.client import OpenFeatureClient - class OpenFeatureAPI: """An independent OpenFeature API instance with its own isolated state. Each instance maintains its own providers, evaluation context, hooks, - event handlers, and transaction context propagator — fully separate from + event handlers, and transaction context propagator; fully separate from the global singleton and from other instances. """ @@ -44,8 +40,6 @@ def __init__(self) -> None: def get_client( self, domain: str | None = None, version: str | None = None ) -> OpenFeatureClient: - from openfeature.client import OpenFeatureClient # noqa: PLC0415 - return OpenFeatureClient(domain=domain, version=version, api=self) # --- Provider management --- @@ -132,28 +126,4 @@ def remove_handler(self, event: ProviderEvent, handler: EventHandler) -> None: self._event_support.remove_global_handler(event, handler) -def _create_default_api() -> OpenFeatureAPI: - """Create the default global API instance, wired to legacy module-level singletons. - - The default API reuses the module-level ``_default_event_support`` and - ``provider_registry`` so that backward-compatible module-level functions - continue to work against the same state. - """ - from openfeature._event_support import _default_event_support # noqa: PLC0415 - from openfeature.provider._registry import provider_registry # noqa: PLC0415 - - api = OpenFeatureAPI.__new__(OpenFeatureAPI) - api._hooks = [] - api._evaluation_context = EvaluationContext() - api._transaction_context_propagator = NoOpTransactionContextPropagator() - api._event_support = _default_event_support - api._provider_registry = provider_registry - - # Wire the registry to this API's event support and context getter - provider_registry._event_support = _default_event_support - provider_registry._evaluation_context_getter = api.get_evaluation_context - - return api - - -_default_api = _create_default_api() +_default_api = OpenFeatureAPI() diff --git a/openfeature/_event_support.py b/openfeature/_event_support.py index ecb9cf22..6aa772b9 100644 --- a/openfeature/_event_support.py +++ b/openfeature/_event_support.py @@ -145,52 +145,3 @@ def clear(self) -> None: self._global_handlers.clear() with self._client_lock: self._client_handlers.clear() - - -# Default instance used by the global singleton API -_default_event_support = EventSupport() - - -# Backward-compatible module-level functions delegating to the default instance -def run_client_handlers( - client: OpenFeatureClient, event: ProviderEvent, details: EventDetails -) -> None: - _default_event_support.run_client_handlers(client, event, details) - - -def run_global_handlers(event: ProviderEvent, details: EventDetails) -> None: - _default_event_support.run_global_handlers(event, details) - - -def add_client_handler( - client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler -) -> None: - _default_event_support.add_client_handler(client, event, handler) - - -def remove_client_handler( - client: OpenFeatureClient, event: ProviderEvent, handler: EventHandler -) -> None: - _default_event_support.remove_client_handler(client, event, handler) - - -def add_global_handler(event: ProviderEvent, handler: EventHandler) -> None: - from openfeature.api import get_client # noqa: PLC0415 - - _default_event_support.add_global_handler(event, handler, get_client) - - -def remove_global_handler(event: ProviderEvent, handler: EventHandler) -> None: - _default_event_support.remove_global_handler(event, handler) - - -def run_handlers_for_provider( - provider: FeatureProvider, - event: ProviderEvent, - provider_details: ProviderEventDetails, -) -> None: - _default_event_support.run_handlers_for_provider(provider, event, provider_details) - - -def clear() -> None: - _default_event_support.clear() diff --git a/openfeature/api.py b/openfeature/api.py index 271339a1..c1dba194 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -1,27 +1,25 @@ from openfeature._api import _default_api from openfeature.client import OpenFeatureClient -from openfeature.evaluation_context import ( - get_evaluation_context, - set_evaluation_context, -) +from openfeature.evaluation_context import EvaluationContext from openfeature.event import ( EventHandler, ProviderEvent, ) -from openfeature.hook import add_hooks, clear_hooks, get_hooks +from openfeature.hook import Hook from openfeature.provider import FeatureProvider from openfeature.provider.metadata import Metadata -from openfeature.transaction_context import ( - get_transaction_context, - set_transaction_context, - set_transaction_context_propagator, +from openfeature.transaction_context import TransactionContextPropagator +from openfeature.transaction_context.no_op_transaction_context_propagator import ( + NoOpTransactionContextPropagator, ) __all__ = [ "add_handler", "add_hooks", + "clear_evaluation_context", "clear_hooks", "clear_providers", + "clear_transaction_context_propagator", "get_client", "get_evaluation_context", "get_hooks", @@ -69,3 +67,45 @@ def add_handler(event: ProviderEvent, handler: EventHandler) -> None: def remove_handler(event: ProviderEvent, handler: EventHandler) -> None: _default_api.remove_handler(event, handler) + + +def add_hooks(hooks: list[Hook]) -> None: + _default_api.add_hooks(hooks) + + +def clear_hooks() -> None: + _default_api.clear_hooks() + + +def get_hooks() -> list[Hook]: + return _default_api.get_hooks() + + +def get_evaluation_context() -> EvaluationContext: + return _default_api.get_evaluation_context() + + +def set_evaluation_context(evaluation_context: EvaluationContext) -> None: + _default_api.set_evaluation_context(evaluation_context) + + +def clear_evaluation_context() -> None: + set_evaluation_context(EvaluationContext()) + + +def set_transaction_context_propagator( + transaction_context_propagator: TransactionContextPropagator, +) -> None: + _default_api.set_transaction_context_propagator(transaction_context_propagator) + + +def clear_transaction_context_propagator() -> None: + set_transaction_context_propagator(NoOpTransactionContextPropagator()) + + +def get_transaction_context() -> EvaluationContext: + return _default_api.get_transaction_context() + + +def set_transaction_context(evaluation_context: EvaluationContext) -> None: + _default_api.set_transaction_context(evaluation_context) diff --git a/openfeature/client.py b/openfeature/client.py index c1ebff85..f9440580 100644 --- a/openfeature/client.py +++ b/openfeature/client.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging import typing from collections.abc import Awaitable, Mapping, Sequence @@ -39,7 +37,6 @@ __all__ = [ "ClientMetadata", - "OpenFeatureClient", ] logger = logging.getLogger("openfeature") @@ -77,24 +74,27 @@ class ClientMetadata: class OpenFeatureClient: + """Client for evaluating feature flags against a specific OpenFeatureAPI. + + Clients should be obtained via ``OpenFeatureAPI.get_client()`` (or the + module-level ``openfeature.api.get_client()`` for the default API); + direct construction is supported only for advanced use cases and requires + passing the owning ``OpenFeatureAPI`` instance. + """ + def __init__( self, domain: str | None, version: str | None, + api: "OpenFeatureAPI", context: EvaluationContext | None = None, hooks: list[Hook] | None = None, - api: OpenFeatureAPI | None = None, ) -> None: self.domain = domain self.version = version self.context = context or EvaluationContext() self.hooks = hooks or [] - if api is not None: - self._api = api - else: - from openfeature._api import _default_api # noqa: PLC0415 - - self._api = _default_api + self._api = api @property def provider(self) -> FeatureProvider: @@ -549,20 +549,20 @@ async def evaluate_flag_details_async( self, flag_type: FlagType, flag_key: str, - default_value: Sequence[FlagValueType], + default_value: Sequence["FlagValueType"], evaluation_context: EvaluationContext | None = None, flag_evaluation_options: FlagEvaluationOptions | None = None, - ) -> FlagEvaluationDetails[Sequence[FlagValueType]]: ... + ) -> FlagEvaluationDetails[Sequence["FlagValueType"]]: ... @typing.overload async def evaluate_flag_details_async( self, flag_type: FlagType, flag_key: str, - default_value: Mapping[str, FlagValueType], + default_value: Mapping[str, "FlagValueType"], evaluation_context: EvaluationContext | None = None, flag_evaluation_options: FlagEvaluationOptions | None = None, - ) -> FlagEvaluationDetails[Mapping[str, FlagValueType]]: ... + ) -> FlagEvaluationDetails[Mapping[str, "FlagValueType"]]: ... async def evaluate_flag_details_async( self, @@ -725,20 +725,20 @@ def evaluate_flag_details( self, flag_type: FlagType, flag_key: str, - default_value: Sequence[FlagValueType], + default_value: Sequence["FlagValueType"], evaluation_context: EvaluationContext | None = None, flag_evaluation_options: FlagEvaluationOptions | None = None, - ) -> FlagEvaluationDetails[Sequence[FlagValueType]]: ... + ) -> FlagEvaluationDetails[Sequence["FlagValueType"]]: ... @typing.overload def evaluate_flag_details( self, flag_type: FlagType, flag_key: str, - default_value: Mapping[str, FlagValueType], + default_value: Mapping[str, "FlagValueType"], evaluation_context: EvaluationContext | None = None, flag_evaluation_options: FlagEvaluationOptions | None = None, - ) -> FlagEvaluationDetails[Mapping[str, FlagValueType]]: ... + ) -> FlagEvaluationDetails[Mapping[str, "FlagValueType"]]: ... def evaluate_flag_details( self, diff --git a/openfeature/evaluation_context/__init__.py b/openfeature/evaluation_context/__init__.py index c828be1a..d36c577e 100644 --- a/openfeature/evaluation_context/__init__.py +++ b/openfeature/evaluation_context/__init__.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from datetime import datetime -__all__ = ["EvaluationContext", "get_evaluation_context", "set_evaluation_context"] +__all__ = ["EvaluationContext"] # https://openfeature.dev/specification/sections/evaluation-context#requirement-312 EvaluationContextAttribute: typing.TypeAlias = ( @@ -32,19 +32,3 @@ def merge(self, ctx2: EvaluationContext) -> EvaluationContext: targeting_key = ctx2.targeting_key or self.targeting_key return EvaluationContext(targeting_key=targeting_key, attributes=attributes) - - -def get_evaluation_context() -> EvaluationContext: - from openfeature._api import _default_api # noqa: PLC0415 - - return _default_api.get_evaluation_context() - - -def set_evaluation_context(evaluation_context: EvaluationContext) -> None: - from openfeature._api import _default_api # noqa: PLC0415 - - _default_api.set_evaluation_context(evaluation_context) - - -# Kept for backward compatibility but no longer used; state lives in _default_api. -_evaluation_context = EvaluationContext() diff --git a/openfeature/hook/__init__.py b/openfeature/hook/__init__.py index 3bf97403..a9f10976 100644 --- a/openfeature/hook/__init__.py +++ b/openfeature/hook/__init__.py @@ -18,9 +18,6 @@ "HookData", "HookHints", "HookType", - "add_hooks", - "clear_hooks", - "get_hooks", ] @@ -147,21 +144,3 @@ def supports_flag_value_type(self, flag_type: FlagType) -> bool: or not (False) """ return True - - -def add_hooks(hooks: list[Hook]) -> None: - from openfeature._api import _default_api # noqa: PLC0415 - - _default_api.add_hooks(hooks) - - -def clear_hooks() -> None: - from openfeature._api import _default_api # noqa: PLC0415 - - _default_api.clear_hooks() - - -def get_hooks() -> list[Hook]: - from openfeature._api import _default_api # noqa: PLC0415 - - return _default_api.get_hooks() diff --git a/openfeature/provider/_registry.py b/openfeature/provider/_registry.py index 3a89a729..7668b415 100644 --- a/openfeature/provider/_registry.py +++ b/openfeature/provider/_registry.py @@ -2,9 +2,10 @@ import threading import typing +import weakref from collections.abc import Callable -from openfeature.evaluation_context import EvaluationContext, get_evaluation_context +from openfeature.evaluation_context import EvaluationContext from openfeature.event import ( ProviderEvent, ProviderEventDetails, @@ -16,6 +17,30 @@ if typing.TYPE_CHECKING: from openfeature._event_support import EventSupport +# spec 1.8.4: a provider should not be bound to more than one OpenFeature API +# instance simultaneously. We track the owning registry per provider; rebinding +# to a different registry raises. WeakKeyDictionary lets providers be GC'd. +_binding_lock = threading.Lock() +_provider_bindings: weakref.WeakKeyDictionary[FeatureProvider, ProviderRegistry] = ( + weakref.WeakKeyDictionary() +) + + +def _register_binding(provider: FeatureProvider, owner: ProviderRegistry) -> None: + with _binding_lock: + existing = _provider_bindings.get(provider) + if existing is not None and existing is not owner: + raise RuntimeError( + "Provider is already bound to another OpenFeature API instance." + ) + _provider_bindings[provider] = owner + + +def _unregister_binding(provider: FeatureProvider, owner: ProviderRegistry) -> None: + with _binding_lock: + if _provider_bindings.get(provider) is owner: + del _provider_bindings[provider] + class ProviderRegistry: _default_provider: FeatureProvider @@ -25,8 +50,8 @@ class ProviderRegistry: def __init__( self, - event_support: EventSupport | None = None, - evaluation_context_getter: Callable[[], EvaluationContext] | None = None, + event_support: EventSupport, + evaluation_context_getter: Callable[[], EvaluationContext], ) -> None: self._lock = threading.RLock() self._default_provider = NoOpProvider() @@ -35,9 +60,7 @@ def __init__( self._default_provider: ProviderStatus.READY, } self._event_support = event_support - self._evaluation_context_getter = ( - evaluation_context_getter or get_evaluation_context - ) + self._evaluation_context_getter = evaluation_context_getter def set_provider( self, domain: str, provider: FeatureProvider, wait_for_init: bool = False @@ -47,6 +70,8 @@ def set_provider( if domain is None: raise GeneralError(error_message="No domain") + _register_binding(provider, self) + old_provider: FeatureProvider | None = None needs_init = False with self._lock: @@ -78,6 +103,8 @@ def set_default_provider( if provider is None: raise GeneralError(error_message="No provider") + _register_binding(provider, self) + old_provider: FeatureProvider | None = None needs_init = False with self._lock: @@ -226,6 +253,7 @@ def _shutdown_provider( ), ) provider.detach() + _unregister_binding(provider, self) def get_provider_status(self, provider: FeatureProvider) -> ProviderStatus: return self._provider_status.get(provider, ProviderStatus.NOT_READY) @@ -237,8 +265,7 @@ def dispatch_event( details: ProviderEventDetails, ) -> None: self._update_provider_status(provider, event, details) - if self._event_support is not None: - self._event_support.run_handlers_for_provider(provider, event, details) + self._event_support.run_handlers_for_provider(provider, event, details) def _update_provider_status( self, @@ -258,6 +285,3 @@ def _update_provider_status( else ProviderStatus.ERROR ) self._provider_status[provider] = status - - -provider_registry = ProviderRegistry() diff --git a/openfeature/transaction_context/__init__.py b/openfeature/transaction_context/__init__.py index ca313e98..ca711cbf 100644 --- a/openfeature/transaction_context/__init__.py +++ b/openfeature/transaction_context/__init__.py @@ -1,44 +1,11 @@ -from openfeature.evaluation_context import EvaluationContext from openfeature.transaction_context.context_var_transaction_context_propagator import ( ContextVarsTransactionContextPropagator, ) -from openfeature.transaction_context.no_op_transaction_context_propagator import ( - NoOpTransactionContextPropagator, -) from openfeature.transaction_context.transaction_context_propagator import ( TransactionContextPropagator, ) __all__ = [ "ContextVarsTransactionContextPropagator", - "NoOpTransactionContextPropagator", "TransactionContextPropagator", - "clear_transaction_context_propagator", - "get_transaction_context", - "set_transaction_context", - "set_transaction_context_propagator", ] - - -def set_transaction_context_propagator( - transaction_context_propagator: TransactionContextPropagator, -) -> None: - from openfeature._api import _default_api # noqa: PLC0415 - - _default_api.set_transaction_context_propagator(transaction_context_propagator) - - -def clear_transaction_context_propagator() -> None: - set_transaction_context_propagator(NoOpTransactionContextPropagator()) - - -def get_transaction_context() -> EvaluationContext: - from openfeature._api import _default_api # noqa: PLC0415 - - return _default_api.get_transaction_context() - - -def set_transaction_context(evaluation_context: EvaluationContext) -> None: - from openfeature._api import _default_api # noqa: PLC0415 - - _default_api.set_transaction_context(evaluation_context) diff --git a/tests/conftest.py b/tests/conftest.py index 495634c1..1f013650 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,11 +6,8 @@ @pytest.fixture(autouse=True) def clear_providers(): - """ - For tests that use set_provider(), we need to clear the provider to avoid issues - in other tests. - """ - api.clear_providers() + """Fully reset the global default API between tests to avoid cross-test pollution.""" + api.shutdown() @pytest.fixture() diff --git a/tests/features/environment.py b/tests/features/environment.py index 4350ddca..a70bea5e 100644 --- a/tests/features/environment.py +++ b/tests/features/environment.py @@ -1,10 +1,7 @@ from openfeature import api +from openfeature.api import set_transaction_context, set_transaction_context_propagator from openfeature.evaluation_context import EvaluationContext -from openfeature.transaction_context import ( - ContextVarsTransactionContextPropagator, - set_transaction_context, - set_transaction_context_propagator, -) +from openfeature.transaction_context import ContextVarsTransactionContextPropagator def before_scenario(context, scenario): diff --git a/tests/features/steps/context_merging_steps.py b/tests/features/steps/context_merging_steps.py index afd75eb4..c5d488be 100644 --- a/tests/features/steps/context_merging_steps.py +++ b/tests/features/steps/context_merging_steps.py @@ -6,13 +6,11 @@ from behave import given, then, when from openfeature import api +from openfeature.api import set_transaction_context 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 ( - set_transaction_context, -) class RetrievableContextProvider(AbstractProvider): diff --git a/tests/hook/test_hook_data.py b/tests/hook/test_hook_data.py index a7e3e0d3..33327fbb 100644 --- a/tests/hook/test_hook_data.py +++ b/tests/hook/test_hook_data.py @@ -1,7 +1,6 @@ import typing -from openfeature.api import set_provider -from openfeature.client import OpenFeatureClient +from openfeature.api import get_client, set_provider from openfeature.evaluation_context import EvaluationContext from openfeature.flag_evaluation import FlagEvaluationDetails, FlagValueType from openfeature.hook import Hook, HookContext, HookHints @@ -46,7 +45,7 @@ def test_hook_data_is_not_shared_between_hooks(): provider = NoOpProvider() set_provider(provider) - client = OpenFeatureClient(domain=None, version=None) + client = get_client() hook_1 = HookWithData({"key": "value"}) hook_2 = HookWithData({"key": Example()}) diff --git a/tests/provider/test_registry.py b/tests/provider/test_registry.py index f7c55712..2e9e79f6 100644 --- a/tests/provider/test_registry.py +++ b/tests/provider/test_registry.py @@ -4,21 +4,30 @@ import pytest +from openfeature._event_support import EventSupport +from openfeature.evaluation_context import EvaluationContext 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 +def make_registry() -> ProviderRegistry: + return ProviderRegistry( + event_support=EventSupport(), + evaluation_context_getter=lambda: EvaluationContext(), + ) + + def test_registry_serves_noop_as_default(): - registry = ProviderRegistry() + registry = make_registry() assert isinstance(registry.get_default_provider(), NoOpProvider) assert isinstance(registry.get_provider("unknown domain"), NoOpProvider) def test_setting_provider_requires_domain(): - registry = ProviderRegistry() + registry = make_registry() with pytest.raises(GeneralError) as exc_info: registry.set_provider(None, NoOpProvider()) # type: ignore[reportArgumentType] @@ -27,7 +36,7 @@ def test_setting_provider_requires_domain(): def test_setting_provider_requires_provider(): - registry = ProviderRegistry() + registry = make_registry() with pytest.raises(GeneralError) as exc_info: registry.set_provider("domain", None) # type: ignore[reportArgumentType] @@ -36,7 +45,7 @@ def test_setting_provider_requires_provider(): def test_can_register_provider_to_multiple_domains(): - registry = ProviderRegistry() + registry = make_registry() provider = NoOpProvider() registry.set_provider("domain1", provider) @@ -49,7 +58,7 @@ def test_can_register_provider_to_multiple_domains(): def test_registering_provider_replaces_previous_provider(): """Test that registering a provider replaces the previous provider and calls shutdown on the old one.""" - registry = ProviderRegistry() + registry = make_registry() provider1 = Mock() provider2 = Mock() @@ -66,7 +75,7 @@ def test_registering_provider_replaces_previous_provider(): def test_registering_provider_for_first_time_initializes_it(): """Test that registering a provider for the first time calls its initialize method.""" - registry = ProviderRegistry() + registry = make_registry() provider = Mock() registry.set_provider("domain1", provider, wait_for_init=True) @@ -76,7 +85,7 @@ def test_registering_provider_for_first_time_initializes_it(): def test_setting_default_provider_requires_provider(): - registry = ProviderRegistry() + registry = make_registry() with pytest.raises(GeneralError) as exc_info: registry.set_default_provider(None) # type: ignore[reportArgumentType] @@ -87,7 +96,7 @@ def test_setting_default_provider_requires_provider(): def test_replacing_default_provider_shuts_down_old_one(): """Test that replacing the default provider shuts down the old default provider.""" - registry = ProviderRegistry() + registry = make_registry() default_provider1 = Mock() default_provider2 = Mock() @@ -102,7 +111,7 @@ def test_replacing_default_provider_shuts_down_old_one(): def test_setting_default_provider_initializes_it(): - registry = ProviderRegistry() + registry = make_registry() provider = Mock() registry.set_default_provider(provider, wait_for_init=True) @@ -113,7 +122,7 @@ def test_setting_default_provider_initializes_it(): def test_registering_provider_as_default_then_domain_only_initializes_once(): """Test that registering the same provider as default and for a domain only initializes it once.""" - registry = ProviderRegistry() + registry = make_registry() provider = Mock() registry.set_default_provider(provider, wait_for_init=True) @@ -125,7 +134,7 @@ def test_registering_provider_as_default_then_domain_only_initializes_once(): def test_registering_provider_as_domain_then_default_only_initializes_once(): """Test that registering the same provider as default and for a domain only initializes it once.""" - registry = ProviderRegistry() + registry = make_registry() provider = Mock() registry.set_provider("domain", provider, wait_for_init=True) @@ -137,7 +146,7 @@ def test_registering_provider_as_domain_then_default_only_initializes_once(): def test_replacing_provider_used_as_default_does_not_shutdown(): """Test that replacing a provider that is also the default does not shut it down twice.""" - registry = ProviderRegistry() + registry = make_registry() provider1 = Mock() provider2 = Mock() @@ -153,7 +162,7 @@ def test_replacing_provider_used_as_default_does_not_shutdown(): def test_replacing_default_provider_used_as_domain_does_not_shutdown(): """Test that replacing a default provider that is also used for a domain does not shut it down twice.""" - registry = ProviderRegistry() + registry = make_registry() provider1 = Mock() provider2 = Mock() @@ -169,7 +178,7 @@ def test_replacing_default_provider_used_as_domain_does_not_shutdown(): def test_shutting_down_registry_shuts_down_providers_once(): """Test that shutting down the registry shuts down each provider only once.""" - registry = ProviderRegistry() + registry = make_registry() provider1 = Mock() provider2 = Mock() @@ -188,7 +197,7 @@ def test_shutting_down_registry_shuts_down_providers_once(): def test_initializing_provider_sets_status_ready(): """Test that initializing a provider sets its status to READY.""" - registry = ProviderRegistry() + registry = make_registry() provider = Mock() assert registry.get_provider_status(provider) == ProviderStatus.NOT_READY @@ -202,7 +211,7 @@ def test_initializing_provider_sets_status_ready(): def test_shutting_down_provider_sets_status_not_ready(): """Test that shutting down a provider sets its status to NOT_READY.""" - registry = ProviderRegistry() + registry = make_registry() provider = Mock() registry.set_provider("domain", provider, wait_for_init=True) @@ -215,7 +224,7 @@ def test_shutting_down_provider_sets_status_not_ready(): def test_clearing_registry_resets_providers_and_default(): """Test that clearing the registry resets all providers and the default provider.""" - registry = ProviderRegistry() + registry = make_registry() provider = Mock() registry.set_provider("domain", provider, wait_for_init=True) @@ -235,7 +244,7 @@ def test_clearing_registry_resets_providers_and_default(): def test_set_provider_returns_before_initialization_completes(): """Test that set_provider (non-blocking) returns before initialize finishes.""" - registry = ProviderRegistry() + registry = make_registry() init_started = threading.Event() init_may_proceed = threading.Event() provider = Mock() @@ -257,7 +266,7 @@ def slow_initialize(ctx): def test_set_provider_and_wait_blocks_until_ready(): """Test that set_provider with wait_for_init=True blocks until READY.""" - registry = ProviderRegistry() + registry = make_registry() initialized = threading.Event() provider = Mock() @@ -274,7 +283,7 @@ def tracking_initialize(ctx): def test_set_provider_and_wait_reraises_on_error(): """Test that set_provider with wait_for_init=True re-raises initialization errors.""" - registry = ProviderRegistry() + registry = make_registry() provider = Mock() provider.initialize.side_effect = ProviderFatalError() @@ -286,7 +295,7 @@ 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() + registry = make_registry() init_count = 0 start_gate = threading.Event() @@ -317,7 +326,7 @@ 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() + registry = make_registry() init_started = threading.Event() init_may_proceed = threading.Event() @@ -350,7 +359,7 @@ 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() + registry = make_registry() hanging = Mock() hang = threading.Event() @@ -383,7 +392,7 @@ def test_stale_shutdown_does_not_clobber_re_registered_provider(): (background) shutdown is still finishing, the stale shutdown must not pop its status or detach() the freshly-registered instance.""" - registry = ProviderRegistry() + registry = make_registry() shutdown_started = threading.Event() shutdown_may_proceed = threading.Event() diff --git a/tests/test_api.py b/tests/test_api.py index cdb077fe..2f37e731 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,10 +13,12 @@ get_evaluation_context, get_hooks, get_provider_metadata, + get_transaction_context, remove_handler, set_evaluation_context, set_provider, set_provider_and_wait, + set_transaction_context_propagator, shutdown, ) from openfeature.evaluation_context import EvaluationContext @@ -24,13 +26,8 @@ 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, -) +from openfeature.transaction_context import ContextVarsTransactionContextPropagator def wait_for_mock_call(mock: MagicMock, timeout: float = 1.0) -> None: @@ -93,8 +90,9 @@ def test_should_invoke_provider_shutdown_function_once_provider_is_no_longer_in_ provider_2 = MagicMock(spec=FeatureProvider) # When - set_provider(provider_1) - set_provider(provider_2) + set_provider_and_wait(provider_1) + set_provider_and_wait(provider_2) + wait_for_mock_call(provider_1.shutdown) # Then assert provider_1.shutdown.called @@ -246,7 +244,7 @@ def test_shutdown_should_reset_api_state(): shutdown() # Then - provider = provider_registry.get_default_provider() + provider = get_client().provider assert isinstance(provider, NoOpProvider) hooks = get_hooks() diff --git a/tests/test_client.py b/tests/test_client.py index 9f59abf3..6125d4c6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,7 +8,8 @@ import pytest -from openfeature import _event_support, api +from openfeature import api +from openfeature._api import _default_api from openfeature.api import ( add_hooks, clear_hooks, @@ -24,7 +25,6 @@ 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 @@ -188,7 +188,7 @@ def test_should_pass_flag_metadata_from_resolution_to_evaluation_details(): ) set_provider(provider, "my-client") - client = OpenFeatureClient("my-client", None) + client = get_client("my-client") # When details = client.get_boolean_details(flag_key="Key", default_value=False) @@ -239,7 +239,7 @@ def test_should_handle_an_open_feature_exception_thrown_by_a_provider( def test_should_return_client_metadata_with_domain(): # Given - client = OpenFeatureClient("my-client", None, NoOpProvider()) + client = get_client("my-client") # When metadata = client.get_metadata() # Then @@ -359,7 +359,7 @@ def _shutdown(self) -> None: monkeypatch.setattr(provider, "shutdown", types.MethodType(_shutdown, provider)) # When - provider_registry.shutdown() + _default_api._provider_registry.shutdown() status = client.get_provider_status() @@ -549,13 +549,13 @@ def test_run_client_handlers_without_registered_handlers_is_noop(): client = get_client("client-without-handlers") details = EventDetails(provider_name=provider.get_metadata().name) - assert client not in _event_support._default_event_support._client_handlers + assert client not in _default_api._event_support._client_handlers - _event_support.run_client_handlers( + _default_api._event_support.run_client_handlers( client, ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, details ) - assert client not in _event_support._default_event_support._client_handlers + assert client not in _default_api._event_support._client_handlers # Requirement 5.1.4, Requirement 5.1.5 @@ -707,7 +707,9 @@ def test_client_should_merge_contexts(): client_context = EvaluationContext( targeting_key="client", attributes={"client_attr": "client_value"} ) - client = OpenFeatureClient(domain=None, version=None, context=client_context) + client = OpenFeatureClient( + domain=None, version=None, api=_default_api, context=client_context + ) # Invocation-specific context invocation_context = EvaluationContext( @@ -743,6 +745,7 @@ def test_client_should_track_event(): def test_tracking_merges_evaluation_contexts(): + api.set_transaction_context_propagator(ContextVarsTransactionContextPropagator()) spy_provider = MagicMock(spec=NoOpProvider) api.set_provider(spy_provider) client = get_client() diff --git a/tests/test_isolated_api.py b/tests/test_isolated_api.py index f2f85b5f..91fa0097 100644 --- a/tests/test_isolated_api.py +++ b/tests/test_isolated_api.py @@ -1,10 +1,13 @@ """Tests for isolated OpenFeature API instances (spec section 1.8).""" +import inspect import time from unittest.mock import MagicMock +import pytest + from openfeature import api -from openfeature._event_support import _default_event_support +from openfeature._api import _default_api from openfeature.evaluation_context import EvaluationContext from openfeature.event import ProviderEvent, ProviderEventDetails from openfeature.hook import Hook @@ -40,35 +43,42 @@ def test_isolated_instance_is_openfeature_api(): # --- Spec 1.8.2: Same API contract --- -def test_isolated_api_provides_full_api_contract(): - api_instance = create_api() - - # Provider management - assert hasattr(api_instance, "set_provider") - assert hasattr(api_instance, "get_provider_metadata") - assert hasattr(api_instance, "clear_providers") - assert hasattr(api_instance, "shutdown") - - # Client creation - assert hasattr(api_instance, "get_client") +_ISOLATED_API_PUBLIC_METHODS = ( + "add_handler", + "add_hooks", + "clear_hooks", + "clear_providers", + "get_client", + "get_evaluation_context", + "get_hooks", + "get_provider", + "get_provider_metadata", + "get_provider_status", + "get_transaction_context", + "remove_handler", + "set_evaluation_context", + "set_provider", + "set_provider_and_wait", + "set_transaction_context", + "set_transaction_context_propagator", + "shutdown", +) - # Hooks - assert hasattr(api_instance, "add_hooks") - assert hasattr(api_instance, "clear_hooks") - assert hasattr(api_instance, "get_hooks") - # Context - assert hasattr(api_instance, "get_evaluation_context") - assert hasattr(api_instance, "set_evaluation_context") - - # Events - assert hasattr(api_instance, "add_handler") - assert hasattr(api_instance, "remove_handler") +def test_isolated_api_provides_full_api_contract(): + """Spec 1.8.2: factory result MUST expose the same contract as the global API.""" + api_instance = create_api() + reference = OpenFeatureAPI() - # Transaction context - assert hasattr(api_instance, "get_transaction_context") - assert hasattr(api_instance, "set_transaction_context") - assert hasattr(api_instance, "set_transaction_context_propagator") + for name in _ISOLATED_API_PUBLIC_METHODS: + assert hasattr(api_instance, name), f"isolated API missing method: {name}" + attr = getattr(api_instance, name) + assert callable(attr), f"isolated API attribute is not callable: {name}" + actual = inspect.signature(attr) + expected = inspect.signature(getattr(reference, name)) + assert actual == expected, ( + f"signature mismatch for {name}: {actual} != {expected}" + ) def test_isolated_api_get_client_returns_working_client(): @@ -128,6 +138,57 @@ def test_isolated_provider_does_not_affect_global(): assert isinstance(global_client.provider, NoOpProvider) +# --- Spec 1.8.4: Provider should not be bound to multiple APIs --- + + +def test_binding_provider_to_multiple_apis_raises(): + """Spec 1.8.4: provider must not be bound to more than one OpenFeature API. + + Uses a Protocol-only provider (no AbstractProvider subclass) to ensure + detection works regardless of provider implementation strategy. + """ + provider = MagicMock(spec=FeatureProvider) + provider.get_metadata.return_value = MagicMock(name="protocol-provider") + + api1 = create_api() + api2 = create_api() + + api1.set_provider(provider) + + with pytest.raises(RuntimeError, match="already bound"): + api2.set_provider(provider) + + +def test_rebinding_provider_to_same_api_does_not_raise(): + """Re-binding the same provider to the same API (e.g., on a different domain) + must not trigger the spec 1.8.4 error.""" + provider = NoOpProvider() + api_instance = create_api() + + api_instance.set_provider(provider, domain="domain-a") + # second call must not raise + api_instance.set_provider(provider, domain="domain-b") + + assert api_instance.get_provider("domain-a") is provider + assert api_instance.get_provider("domain-b") is provider + + +def test_provider_can_be_rebound_after_being_released(): + """After a provider is released from one API (via clear_providers/shutdown), + binding it to another API must not raise.""" + provider = NoOpProvider() + + api1 = create_api() + api1.set_provider(provider) + api1.shutdown() + + # provider is released; binding to a different API now succeeds + api2 = create_api() + api2.set_provider(provider) + + assert api2.get_provider() is provider + + # --- Isolated state: hooks --- @@ -148,12 +209,16 @@ def test_isolated_hooks_are_independent(): def test_isolated_hooks_do_not_affect_global(): - hook = MagicMock(spec=Hook) + global_hook = MagicMock(spec=Hook) + isolated_hook = MagicMock(spec=Hook) + + api.add_hooks([global_hook]) api_instance = create_api() - api_instance.add_hooks([hook]) + api_instance.add_hooks([isolated_hook]) - assert hook not in api.get_hooks() + assert api.get_hooks() == [global_hook] + assert api_instance.get_hooks() == [isolated_hook] def test_clear_hooks_on_isolated_api(): @@ -185,12 +250,15 @@ def test_isolated_evaluation_context_is_independent(): def test_isolated_evaluation_context_does_not_affect_global(): - ctx = EvaluationContext(targeting_key="isolated-user") + api.set_evaluation_context(EvaluationContext(targeting_key="global-user")) api_instance = create_api() - api_instance.set_evaluation_context(ctx) + api_instance.set_evaluation_context( + EvaluationContext(targeting_key="isolated-user") + ) - assert api.get_evaluation_context().targeting_key != "isolated-user" + assert api.get_evaluation_context().targeting_key == "global-user" + assert api_instance.get_evaluation_context().targeting_key == "isolated-user" # --- Isolated state: events --- @@ -243,7 +311,7 @@ def test_isolated_event_handlers_do_not_affect_global(): handler.reset_mock() - _default_event_support.run_handlers_for_provider( + _default_api._event_support.run_handlers_for_provider( global_provider, ProviderEvent.PROVIDER_CONFIGURATION_CHANGED, ProviderEventDetails(), diff --git a/tests/test_transaction_context_in_hooks.py b/tests/test_transaction_context_in_hooks.py index 61a5b5cf..2e317f46 100644 --- a/tests/test_transaction_context_in_hooks.py +++ b/tests/test_transaction_context_in_hooks.py @@ -1,9 +1,9 @@ from openfeature.api import ( + get_client, set_provider, set_transaction_context, set_transaction_context_propagator, ) -from openfeature.client import OpenFeatureClient from openfeature.evaluation_context import EvaluationContext from openfeature.hook import Hook from openfeature.provider.no_op_provider import NoOpProvider @@ -32,7 +32,7 @@ def test_transaction_context_merged_into_hook_context(): provider = NoOpProvider() set_provider(provider) - client = OpenFeatureClient(domain=None, version=None) + client = get_client() hook = TransactionContextHook() client.add_hooks([hook]) 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 0ba9546fceaa4a29b660e6a1da303b9974cec272 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Thu, 11 Jun 2026 15:25:09 -0400 Subject: [PATCH 4/5] fixup: pr feedback Signed-off-by: Todd Baert --- openfeature/_api.py | 6 ++++++ openfeature/api.py | 7 ++----- openfeature/provider/_registry.py | 8 +++++++ tests/test_isolated_api.py | 35 ++++++++++++++++++++++++++++++- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/openfeature/_api.py b/openfeature/_api.py index ee1a7b99..c9736350 100644 --- a/openfeature/_api.py +++ b/openfeature/_api.py @@ -104,6 +104,9 @@ def set_evaluation_context(self, evaluation_context: EvaluationContext) -> None: raise GeneralError(error_message="No api level evaluation context") self._evaluation_context = evaluation_context + def clear_evaluation_context(self) -> None: + self.set_evaluation_context(EvaluationContext()) + # --- Transaction context --- def set_transaction_context_propagator( @@ -111,6 +114,9 @@ def set_transaction_context_propagator( ) -> None: self._transaction_context_propagator = transaction_context_propagator + def clear_transaction_context_propagator(self) -> None: + self.set_transaction_context_propagator(NoOpTransactionContextPropagator()) + def get_transaction_context(self) -> EvaluationContext: return self._transaction_context_propagator.get_transaction_context() diff --git a/openfeature/api.py b/openfeature/api.py index c1dba194..c4ff833f 100644 --- a/openfeature/api.py +++ b/openfeature/api.py @@ -9,9 +9,6 @@ from openfeature.provider import FeatureProvider from openfeature.provider.metadata import Metadata from openfeature.transaction_context import TransactionContextPropagator -from openfeature.transaction_context.no_op_transaction_context_propagator import ( - NoOpTransactionContextPropagator, -) __all__ = [ "add_handler", @@ -90,7 +87,7 @@ def set_evaluation_context(evaluation_context: EvaluationContext) -> None: def clear_evaluation_context() -> None: - set_evaluation_context(EvaluationContext()) + _default_api.clear_evaluation_context() def set_transaction_context_propagator( @@ -100,7 +97,7 @@ def set_transaction_context_propagator( def clear_transaction_context_propagator() -> None: - set_transaction_context_propagator(NoOpTransactionContextPropagator()) + _default_api.clear_transaction_context_propagator() def get_transaction_context() -> EvaluationContext: diff --git a/openfeature/provider/_registry.py b/openfeature/provider/_registry.py index 7668b415..0cd63652 100644 --- a/openfeature/provider/_registry.py +++ b/openfeature/provider/_registry.py @@ -27,6 +27,14 @@ def _register_binding(provider: FeatureProvider, owner: ProviderRegistry) -> None: + try: + weakref.ref(provider) + except TypeError as exc: + raise TypeError( + f"Provider {type(provider).__name__!r} cannot be tracked because " + "it is not weak-referenceable. If your provider class uses " + "__slots__, add '__weakref__' to the slots list." + ) from exc with _binding_lock: existing = _provider_bindings.get(provider) if existing is not None and existing is not owner: diff --git a/tests/test_isolated_api.py b/tests/test_isolated_api.py index 91fa0097..977cf525 100644 --- a/tests/test_isolated_api.py +++ b/tests/test_isolated_api.py @@ -12,7 +12,7 @@ from openfeature.event import ProviderEvent, ProviderEventDetails from openfeature.hook import Hook from openfeature.isolated import OpenFeatureAPI, create_api -from openfeature.provider import FeatureProvider, ProviderStatus +from openfeature.provider import FeatureProvider, Metadata, ProviderStatus from openfeature.provider.no_op_provider import NoOpProvider from openfeature.transaction_context import ContextVarsTransactionContextPropagator @@ -46,8 +46,10 @@ def test_isolated_instance_is_openfeature_api(): _ISOLATED_API_PUBLIC_METHODS = ( "add_handler", "add_hooks", + "clear_evaluation_context", "clear_hooks", "clear_providers", + "clear_transaction_context_propagator", "get_client", "get_evaluation_context", "get_hooks", @@ -189,6 +191,37 @@ def test_provider_can_be_rebound_after_being_released(): assert api2.get_provider() is provider +def test_set_provider_rejects_non_weak_referenceable_provider(): + """Providers must be weak-referenceable so the SDK can track bindings + without leaking memory; surfacing this requirement up front (rather than + silently skipping the spec 1.8.4 check) avoids hard-to-diagnose bugs.""" + + # A direct ``object`` subclass with ``__slots__`` and no ``__weakref__`` + # entry; instances are not weak-referenceable. Implements the + # ``FeatureProvider`` protocol structurally rather than via inheritance + # (which would inherit ``__weakref__`` from the parent class). + class NotWeakReferenceable: + __slots__ = () + + def attach(self, on_emit): + pass + + def detach(self): + pass + + def get_metadata(self): + return Metadata(name="not-weak-referenceable") + + def get_provider_hooks(self): + return [] + + provider = NotWeakReferenceable() + api_instance = create_api() + + with pytest.raises(TypeError, match="weak-referenceable"): + api_instance.set_provider(provider) # type: ignore[arg-type] + + # --- Isolated state: hooks --- From 5b46606f84f0633beee5bc587b81b6818dc469b7 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Fri, 12 Jun 2026 08:45:29 -0400 Subject: [PATCH 5/5] fixup: pr feedback Signed-off-by: Todd Baert --- openfeature/provider/_registry.py | 32 +++++++++++++++---------------- tests/provider/test_registry.py | 27 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/openfeature/provider/_registry.py b/openfeature/provider/_registry.py index 0cd63652..71adb8d4 100644 --- a/openfeature/provider/_registry.py +++ b/openfeature/provider/_registry.py @@ -17,9 +17,7 @@ if typing.TYPE_CHECKING: from openfeature._event_support import EventSupport -# spec 1.8.4: a provider should not be bound to more than one OpenFeature API -# instance simultaneously. We track the owning registry per provider; rebinding -# to a different registry raises. WeakKeyDictionary lets providers be GC'd. +# spec 1.8.4: provider must not bind to more than one API; we track owning registry per provider, rebinding raises. WeakKeyDictionary lets providers be GC'd _binding_lock = threading.Lock() _provider_bindings: weakref.WeakKeyDictionary[FeatureProvider, ProviderRegistry] = ( weakref.WeakKeyDictionary() @@ -221,11 +219,8 @@ def _run_initialize( 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 + if self._is_active(provider): + return thread = threading.Thread( target=self._shutdown_provider, @@ -235,20 +230,25 @@ def _shutdown_if_unused(self, provider: FeatureProvider) -> None: ) thread.start() + def _is_active(self, provider: FeatureProvider) -> bool: + with self._lock: + return ( + provider is self._default_provider + or provider in self._providers.values() + ) + def _shutdown_provider( self, provider: FeatureProvider, abort_if_re_registered: bool = False ) -> None: try: + # abort if re-registered before shutdown() to avoid tearing down the freshly-registered instance + if abort_if_re_registered and self._is_active(provider): + return if hasattr(provider, "shutdown"): provider.shutdown() - # 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 + # abort if re-registered during shutdown(); leave status and event wiring intact + if abort_if_re_registered and self._is_active(provider): + return with self._lock: self._provider_status.pop(provider, None) except Exception as err: diff --git a/tests/provider/test_registry.py b/tests/provider/test_registry.py index 2e9e79f6..7f0328d4 100644 --- a/tests/provider/test_registry.py +++ b/tests/provider/test_registry.py @@ -433,3 +433,30 @@ def slow_shutdown(): "stale shutdown of A clobbered the fresh registration's status" ) provider_a.detach.assert_not_called() + + +def test_stale_shutdown_skips_shutdown_if_re_registered_first(): + """If a provider is re-registered before its background shutdown gets to + call shutdown() at all, shutdown() must not be invoked on the active + provider.""" + + registry = make_registry() + + provider_a = Mock() + provider_b = Mock() + + # step 1: register A, replace with B, then re-register A. queued background shutdown of A from the A->B swap is racing + registry.set_provider("domain", provider_a, wait_for_init=True) + registry.set_provider("domain", provider_b, wait_for_init=True) + registry.set_provider("domain", provider_a, wait_for_init=True) + # let the natural A->B background shutdown complete before we assert + time.sleep(0.2) + provider_a.shutdown.reset_mock() + provider_a.detach.reset_mock() + + # step 2: simulate the late-arriving stale shutdown; abort check must short-circuit before shutdown() is called + registry._shutdown_provider(provider_a, abort_if_re_registered=True) + + provider_a.shutdown.assert_not_called() + provider_a.detach.assert_not_called() + assert registry.get_provider_status(provider_a) == ProviderStatus.READY