diff --git a/contract-tests/async_client_entity.py b/contract-tests/async_client_entity.py index 2569a156..0698639b 100644 --- a/contract-tests/async_client_entity.py +++ b/contract-tests/async_client_entity.py @@ -7,7 +7,7 @@ import requests from async_big_segment_store_fixture import AsyncBigSegmentStoreFixture from async_flag_change_listener import AsyncListenerRegistry -from hook import AsyncPostingHook +from hook import PostingHook from ldclient import Context from ldclient.async_client import AsyncLDClient @@ -91,10 +91,9 @@ async def start(self): else: opts["send_events"] = False - hooks = [] if config_params.get("hooks") is not None: - hooks = [ - AsyncPostingHook(h["name"], h["callbackUri"], h.get("data", {}), h.get("errors", {})) + opts["hooks"] = [ + PostingHook(h["name"], h["callbackUri"], h.get("data", {}), h.get("errors", {})) for h in config_params["hooks"]["hooks"] ] @@ -115,10 +114,6 @@ async def start(self): sdk_config = AsyncConfig(**opts) self._client = AsyncLDClient(sdk_config) - # The async client accepts AsyncHook instances only; register the - # harness's async posting hooks via add_hook() before start(). - for hook in hooks: - self._client.add_hook(hook) await self._client.start(start_wait / 1000.0) self._listeners = AsyncListenerRegistry(self._client.flag_tracker) diff --git a/contract-tests/hook.py b/contract-tests/hook.py index 25feac37..b05481d2 100644 --- a/contract-tests/hook.py +++ b/contract-tests/hook.py @@ -1,10 +1,9 @@ -import asyncio from typing import Optional import requests from ldclient.evaluation import EvaluationDetail -from ldclient.hook import AsyncHook, EvaluationSeriesContext, Hook, Metadata +from ldclient.hook import EvaluationSeriesContext, Hook, Metadata class PostingHook(Hook): @@ -50,53 +49,3 @@ def __post(self, stage: str, series_context: EvaluationSeriesContext, data: dict requests.post(self.__callback, json=payload) return {**data, **self.__data.get(stage, {})} - - -class AsyncPostingHook(AsyncHook): - """AsyncHook mirror of PostingHook for the async contract-test harness. - - The blocking requests.post is offloaded to a worker thread so it does not - block the event loop. - """ - - def __init__(self, name: str, callback: str, data: dict, errors: dict): - self.__name = name - self.__callback = callback - self.__data = data - self.__errors = errors - - @property - def metadata(self) -> Metadata: - return Metadata(name=self.__name) - - async def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict: - return await self.__post("beforeEvaluation", series_context, data, None) - - async def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, detail: EvaluationDetail) -> dict: - return await self.__post("afterEvaluation", series_context, data, detail) - - async def __post(self, stage: str, series_context: EvaluationSeriesContext, data: dict, detail: Optional[EvaluationDetail]) -> dict: - if stage in self.__errors: - raise Exception(self.__errors[stage]) - - payload = { - 'evaluationSeriesContext': { - 'flagKey': series_context.key, - 'context': series_context.context.to_dict(), - 'defaultValue': series_context.default_value, - 'method': series_context.method, - }, - 'evaluationSeriesData': data, - 'stage': stage, - } - - if detail is not None: - payload['evaluationDetail'] = { - 'value': detail.value, - 'variationIndex': detail.variation_index, - 'reason': detail.reason, - } - - await asyncio.to_thread(requests.post, self.__callback, json=payload) - - return {**data, **self.__data.get(stage, {})} diff --git a/ldclient/async_client.py b/ldclient/async_client.py index 9e740b75..0038d14a 100644 --- a/ldclient/async_client.py +++ b/ldclient/async_client.py @@ -12,8 +12,8 @@ from ldclient.context import Context from ldclient.evaluation import EvaluationDetail, FeatureFlagsState from ldclient.hook import ( - AsyncHook, EvaluationSeriesContext, + Hook, _EvaluationWithHookResult ) from ldclient.impl import AnyNum @@ -86,7 +86,7 @@ def __init__(self, config: AsyncConfig): # drop events. self._event_processor: Any = AsyncNullEventProcessor() - self.__hooks: List = list(config.hooks) + self.__hooks: List[Hook] = list(config.hooks) self._event_factory_default = EventFactory(False) self._event_factory_with_reasons = EventFactory(True) @@ -639,20 +639,17 @@ def secure_mode_hash(self, context: Context) -> str: """ return _secure_mode_hash(self._config, context) - def add_hook(self, hook: AsyncHook): + def add_hook(self, hook: Hook): """ Add a hook to the client. In order to register a hook before the client starts, please use the `hooks` property of `AsyncConfig`. Hooks provide entrypoints which allow for observation of SDK functions. - The async client only accepts :class:`ldclient.hook.AsyncHook` instances; - passing a synchronous :class:`ldclient.hook.Hook` raises ``TypeError``. - :param hook: """ - if not isinstance(hook, AsyncHook): - raise TypeError("AsyncLDClient requires an AsyncHook; synchronous Hook instances are not supported") + if not isinstance(hook, Hook): + return self.__hooks.append(hook) @@ -668,33 +665,30 @@ async def __evaluate_with_hooks(self, key: str, context: Context, default_value: # :return: """ # Snapshot the hook list to ensure hooks added during evaluation don't get called for the current evaluation. - hooks = list(self.__hooks) # type: List[AsyncHook] + hooks = list(self.__hooks) if not hooks: return await block() series_context = EvaluationSeriesContext(key=key, context=context, default_value=default_value, method=method) - hook_data = await self.__execute_before_evaluation(hooks, series_context) + hook_data = self.__execute_before_evaluation(hooks, series_context) evaluation_result = await block() - await self.__execute_after_evaluation(hooks, series_context, hook_data, evaluation_result.evaluation_detail) + self.__execute_after_evaluation(hooks, series_context, hook_data, evaluation_result.evaluation_detail) return evaluation_result - async def __execute_before_evaluation(self, hooks: List[AsyncHook], series_context: EvaluationSeriesContext) -> List[dict]: - return [await self.__try_execute_stage("beforeEvaluation", hook.metadata.name, lambda: hook.before_evaluation(series_context, {})) for hook in hooks] + def __execute_before_evaluation(self, hooks: List[Hook], series_context: EvaluationSeriesContext) -> List[dict]: + return [self.__try_execute_stage("beforeEvaluation", hook.metadata.name, lambda: hook.before_evaluation(series_context, {})) for hook in hooks] - async def __execute_after_evaluation(self, hooks: List[AsyncHook], series_context: EvaluationSeriesContext, hook_data: List[dict], evaluation_detail: EvaluationDetail) -> List[dict]: + def __execute_after_evaluation(self, hooks: List[Hook], series_context: EvaluationSeriesContext, hook_data: List[dict], evaluation_detail: EvaluationDetail) -> List[dict]: return [ - await self.__try_execute_stage("afterEvaluation", hook.metadata.name, lambda: hook.after_evaluation(series_context, data, evaluation_detail)) + self.__try_execute_stage("afterEvaluation", hook.metadata.name, lambda: hook.after_evaluation(series_context, data, evaluation_detail)) for (hook, data) in reversed(list(zip(hooks, hook_data))) ] - async def __try_execute_stage(self, method: str, hook_name: str, block: Callable[[], Any]) -> dict: + def __try_execute_stage(self, method: str, hook_name: str, block: Callable[[], dict]) -> dict: try: - return await block() - except asyncio.CancelledError: - # Do not swallow cancellation; it must propagate for shutdown. - raise + return block() except BaseException as e: log.error(f"An error occurred in {method} of the hook {hook_name}: #{e}") return {} diff --git a/ldclient/async_config.py b/ldclient/async_config.py index 7e28a680..5090d9e3 100644 --- a/ldclient/async_config.py +++ b/ldclient/async_config.py @@ -25,7 +25,7 @@ HTTPConfig, PrivateAttributesConfig ) -from ldclient.hook import AsyncHook +from ldclient.hook import Hook from ldclient.impl.aio.concurrency import AsyncEvent from ldclient.impl.util import ( log, @@ -172,7 +172,7 @@ def __init__( http: HTTPConfig = HTTPConfig(), big_segments: Optional[AsyncBigSegmentsConfig] = None, application: Optional[dict] = None, - hooks: Optional[List[AsyncHook]] = None, + hooks: Optional[List[Hook]] = None, plugins: Optional[List[AsyncPlugin]] = None, enable_event_compression: bool = False, omit_anonymous_contexts: bool = False, @@ -281,7 +281,7 @@ def __init__( self.__http = http self.__big_segments = AsyncBigSegmentsConfig() if not big_segments else big_segments self.__application = validate_application_info(application or {}, log) - self.__hooks = [hook for hook in hooks if isinstance(hook, AsyncHook)] if hooks else [] + self.__hooks = [hook for hook in hooks if isinstance(hook, Hook)] if hooks else [] self.__plugins = [plugin for plugin in plugins if isinstance(plugin, AsyncPlugin)] if plugins else [] self.__enable_event_compression = enable_event_compression self.__omit_anonymous_contexts = omit_anonymous_contexts @@ -429,7 +429,7 @@ def application(self) -> dict: return self.__application @property - def hooks(self) -> List[AsyncHook]: + def hooks(self) -> List[Hook]: """ Initial set of hooks for the client. diff --git a/ldclient/hook.py b/ldclient/hook.py index 49ddba8d..56352637 100644 --- a/ldclient/hook.py +++ b/ldclient/hook.py @@ -80,65 +80,6 @@ def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, return data -class AsyncHook(ABC): - """ - Abstract class for extending AsyncLDClient functionality via hooks. - - .. caution:: - This feature is experimental and should NOT be considered ready for production - use. It may change or be removed without notice and is not subject to backwards - compatibility guarantees. Pin to a specific minor version and review the changelog - before upgrading. - - All provided async hook implementations **MUST** inherit from this class. - - This class includes default implementations for all hook handlers. This - allows LaunchDarkly to expand the list of hook handlers without breaking - customer integrations. - - Unlike :class:`Hook`, the before and after methods are coroutines and will - be awaited by the async client. - """ - - @property - @abstractmethod - def metadata(self) -> Metadata: - """ - Get metadata about the hook implementation. - """ - return Metadata(name='UNDEFINED') - - async def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict: - """ - The before method is called during the execution of a variation method - before the flag value has been determined. The method is a coroutine - and will be awaited. - - :param series_context: Contains information about the evaluation being performed. This is not mutable. - :param data: A record associated with each stage of hook invocations. - Each stage is called with the data of the previous stage for a series. - The input record should not be modified. - :return: Data to use when executing the next state of the hook in the evaluation series. - """ - return data - - async def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, - detail: EvaluationDetail) -> dict: - """ - The after method is called during the execution of the variation method - after the flag value has been determined. The method is a coroutine - and will be awaited. - - :param series_context: Contains read-only information about the - evaluation being performed. - :param data: A record associated with each stage of hook invocations. - Each stage is called with the data of the previous stage for a series. - :param detail: The result of the evaluation. This value should not be modified. - :return: Data to use when executing the next state of the hook in the evaluation series. - """ - return data - - @dataclass class _EvaluationWithHookResult: evaluation_detail: EvaluationDetail diff --git a/ldclient/impl/client_common.py b/ldclient/impl/client_common.py index a99b5eba..6ba0fc07 100644 --- a/ldclient/impl/client_common.py +++ b/ldclient/impl/client_common.py @@ -14,7 +14,7 @@ from ldclient.config import SdkIdentityConfig from ldclient.context import Context -from ldclient.hook import AsyncHook, Hook +from ldclient.hook import Hook from ldclient.impl.util import log from ldclient.plugin import ( ApplicationMetadata, @@ -48,8 +48,8 @@ def get_environment_metadata(config: SdkIdentityConfig, sdk_name: str) -> Enviro ) -def get_plugin_hooks(plugins: Sequence[Union[Plugin, AsyncPlugin]], environment_metadata: EnvironmentMetadata) -> List: - hooks: List = [] +def get_plugin_hooks(plugins: Sequence[Union[Plugin, AsyncPlugin]], environment_metadata: EnvironmentMetadata) -> List[Hook]: + hooks: List[Hook] = [] for plugin in plugins: try: hooks.extend(plugin.get_hooks(environment_metadata)) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 461d361a..f6c059b5 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -6,7 +6,7 @@ from ldclient.context import Context from ldclient.evaluation import EvaluationDetail, FeatureFlagsState -from ldclient.hook import AsyncHook, Hook +from ldclient.hook import Hook from ldclient.impl import AnyNum from ldclient.impl.evaluator import error_reason from ldclient.interfaces import ( @@ -156,15 +156,12 @@ async def register(self, client: 'AsyncLDClient', metadata: EnvironmentMetadata) """ pass - def get_hooks(self, metadata: EnvironmentMetadata) -> List[AsyncHook]: + def get_hooks(self, metadata: EnvironmentMetadata) -> List[Hook]: """ Get a list of hooks that this plugin provides. This method is called before register() to collect all hooks from plugins. The hooks returned will be added to the SDK's hook configuration. - Async plugins provide async :class:`AsyncHook` instances only. - - This method is synchronous (returns a list immediately — no I/O). :param metadata: Metadata about the environment in which the SDK is running :return: A list of hooks to be registered with the SDK diff --git a/ldclient/testing/test_async_client.py b/ldclient/testing/test_async_client.py index 5d3f17aa..876b6b40 100644 --- a/ldclient/testing/test_async_client.py +++ b/ldclient/testing/test_async_client.py @@ -169,9 +169,9 @@ async def test_migration_variation_returns_default_stage(): @pytest.mark.asyncio async def test_hooks_are_invoked_during_variation(): """Hooks added via add_hook() have before/after called during variation().""" - from ldclient.hook import AsyncHook, Metadata + from ldclient.hook import Hook, Metadata - class RecordingHook(AsyncHook): + class RecordingHook(Hook): def __init__(self): self.before_calls = [] self.after_calls = [] @@ -180,11 +180,11 @@ def __init__(self): def metadata(self): return Metadata(name='recording-hook') - async def before_evaluation(self, series_context, data): + def before_evaluation(self, series_context, data): self.before_calls.append(series_context) return data - async def after_evaluation(self, series_context, data, detail): + def after_evaluation(self, series_context, data, detail): self.after_calls.append((series_context, detail)) return data @@ -204,24 +204,12 @@ async def after_evaluation(self, series_context, data, detail): @pytest.mark.asyncio -async def test_add_hook_rejects_sync_hook(): - """add_hook() raises TypeError when given a synchronous Hook.""" - from ldclient.hook import EvaluationSeriesContext, Hook, Metadata - - class SyncHook(Hook): - @property - def metadata(self): - return Metadata(name='sync-hook') - - def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict: - return data - - def after_evaluation(self, series_context, data, detail): - return data - +async def test_add_hook_ignores_non_hook(): + """add_hook() ignores an object that is not a Hook.""" client = AsyncLDClient(_offline_config()) - with pytest.raises(TypeError): - client.add_hook(SyncHook()) + client.add_hook(object()) # type: ignore[arg-type] + async with client: + assert await client.variation('flag', Context.create('user'), 'default') == 'default' @pytest.mark.asyncio @@ -323,11 +311,11 @@ async def test_is_offline_reflects_config(): @pytest.mark.asyncio async def test_hooks_data_isolation(): """Each hook's before_evaluation receives its own isolated {} — not data from a prior hook.""" - from ldclient.hook import AsyncHook, Metadata + from ldclient.hook import Hook, Metadata received_data_by_hook = {} - class IsolationHook(AsyncHook): + class IsolationHook(Hook): def __init__(self, name, inject_key=None, inject_val=None): self._name = name self._inject_key = inject_key @@ -337,14 +325,14 @@ def __init__(self, name, inject_key=None, inject_val=None): def metadata(self): return Metadata(name=self._name) - async def before_evaluation(self, series_context, data): + def before_evaluation(self, series_context, data): # Record a copy of what we received received_data_by_hook[self._name] = dict(data) if self._inject_key: data[self._inject_key] = self._inject_val return data - async def after_evaluation(self, series_context, data, detail): + def after_evaluation(self, series_context, data, detail): return data hook_a = IsolationHook('hook-a', inject_key='hook_a', inject_val=True) diff --git a/ldclient/testing/test_async_hooks.py b/ldclient/testing/test_async_hooks.py index cd007b37..be742e9b 100644 --- a/ldclient/testing/test_async_hooks.py +++ b/ldclient/testing/test_async_hooks.py @@ -1,199 +1,209 @@ """ -Tests for AsyncHook dispatch in the evaluation pipeline. - -The _evaluate_with_hooks helper defined here mirrors the dispatch pattern that -AsyncLDClient uses: async hooks are awaited, running in list order for -before_evaluation and reverse order for after_evaluation. - -Each hook receives its own isolated data dict (matching the sync SDK pattern in -client.py:__execute_before_evaluation). A failing hook logs an error and returns -{} rather than aborting the evaluation (matching __try_execute_stage). +Tests for hook and plugin dispatch in AsyncLDClient. """ -import logging +import asyncio +import contextvars +from typing import List import pytest +from ldclient.async_client import AsyncLDClient +from ldclient.async_config import AsyncConfig from ldclient.context import Context from ldclient.evaluation import EvaluationDetail -from ldclient.hook import AsyncHook, EvaluationSeriesContext, Metadata - -log = logging.getLogger('ldclient') - - -async def _try_execute_stage_async(method, hook_name, coro_or_fn): - """Execute a single hook stage the way AsyncLDClient does, catching and logging any exceptions.""" - try: - return await coro_or_fn() - except BaseException as e: - log.error(f"An error occurred in {method} of the hook {hook_name}: #{e}") - return {} - - -async def _evaluate_with_hooks(hooks, series_context, eval_fn): - """Dispatch before/after hooks around an async evaluation function. - - Each hook gets a fresh empty dict for before_evaluation (matching the sync - SDK's __execute_before_evaluation pattern). The per-hook data returned is - stored and passed back to the corresponding hook in after_evaluation. - A hook that raises an exception is caught and logged; it does not abort - the evaluation or affect other hooks. - """ - # before_evaluation: each hook gets its own isolated {} - hook_data = [] - for hook in hooks: - data = await _try_execute_stage_async( - "beforeEvaluation", hook.metadata.name, - lambda h=hook: h.before_evaluation(series_context, {}) - ) - hook_data.append(data) - - detail = await eval_fn() - - # after_evaluation: reversed order, each hook receives its own before data - for hook, data in reversed(list(zip(hooks, hook_data))): - await _try_execute_stage_async( - "afterEvaluation", hook.metadata.name, - lambda h=hook, d=data: h.after_evaluation(series_context, d, detail) - ) - - return detail - - -class RecordingAsyncHook(AsyncHook): - """Async hook that records calls and threads a counter through data.""" - - def __init__(self, name: str): +from ldclient.hook import EvaluationSeriesContext, Hook, Metadata +from ldclient.plugin import ( + AsyncPlugin, + EnvironmentMetadata, + Plugin, + PluginMetadata +) +from ldclient.testing.mock_async_components import ( + MockAsyncFeatureStore, + MockAsyncUpdateProcessor +) +from ldclient.versioned_data_kind import FEATURES + + +def _offline_config(**kwargs) -> AsyncConfig: + return AsyncConfig("test-sdk-key", offline=True, **kwargs) + + +def _make_flag(key: str, value) -> dict: + return { + 'key': key, + 'version': 1, + 'on': False, + 'offVariation': 0, + 'variations': [value], + 'targets': [], + 'contextTargets': [], + 'rules': [], + 'prerequisites': [], + 'salt': '', + } + + +class RecordingHook(Hook): + def __init__(self, name: str, log: List[str]): self._name = name - self.before_calls: list = [] - self.after_calls: list = [] + self._log = log @property def metadata(self) -> Metadata: return Metadata(name=self._name) - async def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict: - self.before_calls.append(series_context.key) - return {**data, self._name + '_before': True} + def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict: + self._log.append(f"{self._name}:before:{series_context.key}") + return {**data, 'from_before': self._name} - async def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, - detail: EvaluationDetail) -> dict: - self.after_calls.append(series_context.key) - return {**data, self._name + '_after': True} + def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, detail: EvaluationDetail) -> dict: + self._log.append(f"{self._name}:after:{series_context.key}:{data.get('from_before')}:{detail.value}") + return data -@pytest.fixture -def series_context(): - return EvaluationSeriesContext( - key='test-flag', - context=Context.create('user-1'), - default_value=False, - method='variation', - ) - - -@pytest.fixture -def mock_detail(): - return EvaluationDetail(True, 0, {'kind': 'OFF'}) +@pytest.mark.asyncio +async def test_hooks_from_config_run_in_order(): + log: List[str] = [] + config = _offline_config(hooks=[RecordingHook('first', log), RecordingHook('second', log)]) + async with AsyncLDClient(config) as client: + await client.variation('flag', Context.create('user'), 'default') -@pytest.fixture -def eval_fn(mock_detail): - async def _fn(): - return mock_detail - return _fn + assert log == [ + 'first:before:flag', + 'second:before:flag', + 'second:after:flag:second:default', + 'first:after:flag:first:default', + ] @pytest.mark.asyncio -async def test_async_hook_before_and_after_awaited(series_context, eval_fn): - hook = RecordingAsyncHook('async1') - await _evaluate_with_hooks([hook], series_context, eval_fn) +async def test_hook_error_does_not_stop_evaluation(): + log: List[str] = [] - assert hook.before_calls == ['test-flag'] - assert hook.after_calls == ['test-flag'] + class FailingHook(Hook): + @property + def metadata(self) -> Metadata: + return Metadata(name='failing') + def before_evaluation(self, series_context, data): + raise RuntimeError('before failed') -@pytest.mark.asyncio -async def test_hooks_called_in_order(series_context, eval_fn): - """before_evaluation runs in list order; after_evaluation runs in reverse.""" - call_log = [] + def after_evaluation(self, series_context, data, detail): + log.append(f"after:{data}") + return data - class OrderAsyncHook(AsyncHook): - def __init__(self, name): - self._name = name + async with AsyncLDClient(_offline_config(hooks=[FailingHook()])) as client: + result = await client.variation('flag', Context.create('user'), 'default') - @property - def metadata(self): - return Metadata(name=self._name) + assert result == 'default' + assert log == ['after:{}'] - async def before_evaluation(self, sc, data): - call_log.append(self._name + '_before') - return data - async def after_evaluation(self, sc, data, detail): - call_log.append(self._name + '_after') - return data +class _YieldingStore(MockAsyncFeatureStore): + """Yields to the event loop on each read, so concurrent evaluations interleave.""" - hooks = [OrderAsyncHook('first'), OrderAsyncHook('second')] - await _evaluate_with_hooks(hooks, series_context, eval_fn) + def __init__(self, seen: List[str], var: contextvars.ContextVar): + super().__init__() + self._seen = seen + self._var = var - # before: list order (first, second) - # after: reversed (second, first) - assert call_log == ['first_before', 'second_before', 'second_after', 'first_after'] + async def get(self, kind, key): + self._seen.append(self._var.get()) + await asyncio.sleep(0) + return await super().get(kind, key) @pytest.mark.asyncio -async def test_hooks_each_called_with_series_context(series_context, eval_fn): - """Each async hook is invoked with the correct series context.""" - hook_a = RecordingAsyncHook('a') - hook_b = RecordingAsyncHook('b') +async def test_context_variable_set_in_before_stage_is_kept_per_evaluation(): + # A tracing hook makes a span current in the before stage and restores the + # previous context in the after stage. Concurrent evaluations must each see + # only their own value, and the restore must not fail. + current = contextvars.ContextVar('current', default='none') + seen_in_store: List[str] = [] + seen_in_after: List[str] = [] + errors: List[BaseException] = [] + + class ContextVarHook(Hook): + @property + def metadata(self) -> Metadata: + return Metadata(name='context-var') + + def before_evaluation(self, series_context, data): + return {'token': current.set(series_context.context.key)} + + def after_evaluation(self, series_context, data, detail): + seen_in_after.append(f"{series_context.context.key}={current.get()}") + try: + current.reset(data['token']) + except BaseException as e: + errors.append(e) + return data - hooks = [hook_a, hook_b] - await _evaluate_with_hooks(hooks, series_context, eval_fn) + store = _YieldingStore(seen_in_store, current) + await store.force_set(FEATURES, _make_flag('flag', 'on')) + store._initialized = True + config = AsyncConfig( + "test-sdk-key", + feature_store=store, + update_processor_class=MockAsyncUpdateProcessor, + send_events=False, + hooks=[ContextVarHook()], + ) - assert hook_a.before_calls == ['test-flag'] - assert hook_b.before_calls == ['test-flag'] + async with AsyncLDClient(config) as client: + keys = ['a', 'b', 'c'] + results = await asyncio.gather(*(client.variation('flag', Context.create(k), 'off') for k in keys)) + + assert results == ['on', 'on', 'on'] + assert errors == [] + assert sorted(seen_in_store) == keys + assert sorted(seen_in_after) == ['a=a', 'b=b', 'c=c'] + assert current.get() == 'none' @pytest.mark.asyncio -async def test_returns_evaluation_detail(series_context, mock_detail, eval_fn): - hook = RecordingAsyncHook('a') - result = await _evaluate_with_hooks([hook], series_context, eval_fn) - assert result is mock_detail +async def test_async_plugin_hooks_run_and_register_is_awaited(): + log: List[str] = [] + registered: List[AsyncLDClient] = [] + class RecordingPlugin(AsyncPlugin): + @property + def metadata(self) -> PluginMetadata: + return PluginMetadata(name='recording-plugin') -@pytest.mark.asyncio -async def test_no_hooks_returns_detail(series_context, mock_detail, eval_fn): - result = await _evaluate_with_hooks([], series_context, eval_fn) - assert result is mock_detail + async def register(self, client, metadata: EnvironmentMetadata) -> None: + await asyncio.sleep(0) + registered.append(client) + def get_hooks(self, metadata: EnvironmentMetadata) -> List[Hook]: + return [RecordingHook('plugin-hook', log)] -@pytest.mark.asyncio -async def test_data_isolated_per_hook(series_context, eval_fn): - """Each hook receives its own fresh {} for before_evaluation (matching sync SDK pattern). + client = AsyncLDClient(_offline_config(plugins=[RecordingPlugin()])) + async with client: + await client.variation('flag', Context.create('user'), 'default') - Data is not threaded across hooks — each hook's before_evaluation starts - with an empty dict and the result is passed only to that hook's own - after_evaluation. This matches client.py:__execute_before_evaluation. - """ - before_data_received = {} + assert registered == [client] + assert log == ['plugin-hook:before:flag', 'plugin-hook:after:flag:plugin-hook:default'] - class CaptureAsyncHook(AsyncHook): - def __init__(self, name): - self._name = name - @property - def metadata(self): - return Metadata(name=self._name) +def test_config_ignores_non_hook(): + log: List[str] = [] + config = _offline_config(hooks=[object(), RecordingHook('kept', log)]) # type: ignore[list-item] + assert [hook.metadata.name for hook in config.hooks] == ['kept'] - async def before_evaluation(self, sc, data): - before_data_received[self._name] = dict(data) - return {**data, self._name + '_key': 1} - async def after_evaluation(self, sc, data, detail): - return data +def test_config_ignores_sync_plugin(): + class SyncPlugin(Plugin): + @property + def metadata(self) -> PluginMetadata: + return PluginMetadata(name='sync-plugin') + + def register(self, client, metadata): + pass - await _evaluate_with_hooks([CaptureAsyncHook('one'), CaptureAsyncHook('two')], series_context, eval_fn) + def get_hooks(self, metadata): + return [] - # Each hook should have received a fresh empty dict, not the other hook's output - assert before_data_received['one'] == {} - assert before_data_received['two'] == {} + assert _offline_config(plugins=[SyncPlugin()]).plugins == [] # type: ignore[list-item]