diff --git a/ldclient/hook.py b/ldclient/hook.py index 93c997b6..e9cd63e2 100644 --- a/ldclient/hook.py +++ b/ldclient/hook.py @@ -79,6 +79,65 @@ 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/async_flag_tracker.py b/ldclient/impl/async_flag_tracker.py new file mode 100644 index 00000000..60c47c1f --- /dev/null +++ b/ldclient/impl/async_flag_tracker.py @@ -0,0 +1,62 @@ +from typing import Any, Callable + +from ldclient.context import Context +from ldclient.impl.aio.concurrency import AsyncCallbackScheduler, AsyncLock +from ldclient.impl.listeners import Listeners +from ldclient.interfaces import FlagChange, FlagTracker, FlagValueChange + + +class AsyncFlagValueChangeListener: + """Calls the user's listener when a specific flag's evaluated value changes for a specific context.""" + + def __init__(self, key: str, context: Context, listener: Callable[[FlagValueChange], None], eval_fn: Callable, scheduler: AsyncCallbackScheduler): + self.__key = key + self.__context = context + self.__listener = listener + self.__eval_fn = eval_fn + self.__scheduler = scheduler + + self.__lock = AsyncLock() + self.__value: Any = None + + @classmethod + async def create(cls, key: str, context: Context, listener: Callable[[FlagValueChange], None], eval_fn: Callable, scheduler: AsyncCallbackScheduler) -> 'AsyncFlagValueChangeListener': + """Evaluates the flag once to capture the baseline value, then returns the listener.""" + instance = cls(key, context, listener, eval_fn, scheduler) + instance.__value = await eval_fn(key, context) + return instance + + def __call__(self, flag_change: FlagChange): + self.__scheduler.call(self._on_flag_change, flag_change) + + async def _on_flag_change(self, flag_change: FlagChange): + if flag_change.key != self.__key: + return + + async with self.__lock: + new_value = await self.__eval_fn(self.__key, self.__context) + old_value, self.__value = self.__value, new_value + + if new_value == old_value: + return + + self.__listener(FlagValueChange(self.__key, old_value, new_value)) + + +class AsyncFlagTrackerImpl(FlagTracker): + def __init__(self, listeners: Listeners, eval_fn: Callable): + self.__listeners = listeners + self.__eval_fn = eval_fn + self.__scheduler = AsyncCallbackScheduler() + + def add_listener(self, listener: Callable[[FlagChange], None]): + self.__listeners.add(listener) + + def remove_listener(self, listener: Callable[[FlagChange], None]): + self.__listeners.remove(listener) + + async def add_flag_value_change_listener(self, key: str, context: Context, fn: Callable[[FlagValueChange], None]) -> Callable[[FlagChange], None]: + listener = await AsyncFlagValueChangeListener.create(key, context, fn, self.__eval_fn, self.__scheduler) + self.add_listener(listener) + + return listener diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 485d4905..461d361a 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 Hook +from ldclient.hook import AsyncHook, Hook from ldclient.impl import AnyNum from ldclient.impl.evaluator import error_reason from ldclient.interfaces import ( @@ -17,6 +17,7 @@ ) if TYPE_CHECKING: + from ldclient.async_client import AsyncLDClient from ldclient.client import LDClient @@ -108,3 +109,64 @@ def get_hooks(self, metadata: EnvironmentMetadata) -> List[Hook]: :return: A list of hooks to be registered with the SDK """ return [] + + +class AsyncPlugin(ABC): + """ + Abstract base class for extending AsyncLDClient functionality via plugins. + + .. 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 plugin implementations **MUST** inherit from this class. + + This class includes default implementations for optional methods. This + allows LaunchDarkly to expand the list of plugin methods without breaking + customer integrations. + + Unlike :class:`Plugin`, the register() method is a coroutine and will be + awaited by the async client, allowing plugins to perform asynchronous + initialization such as connecting to telemetry backends. + """ + + @property + @abstractmethod + def metadata(self) -> PluginMetadata: + """ + Get metadata about the plugin implementation. + + :return: Metadata containing information about the plugin + """ + return PluginMetadata(name='UNDEFINED') + + async def register(self, client: 'AsyncLDClient', metadata: EnvironmentMetadata) -> None: + """ + Register the plugin with the async SDK client. + + This method is called during SDK initialization to allow the plugin + to set up any necessary integrations, register hooks, or perform + other initialization tasks. The method is a coroutine and will be + awaited, allowing asynchronous I/O during registration. + + :param client: The AsyncLDClient instance + :param metadata: Metadata about the environment in which the SDK is running + """ + pass + + def get_hooks(self, metadata: EnvironmentMetadata) -> List[AsyncHook]: + """ + 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 + """ + return [] diff --git a/ldclient/testing/test_async_flag_tracker.py b/ldclient/testing/test_async_flag_tracker.py new file mode 100644 index 00000000..68222d7e --- /dev/null +++ b/ldclient/testing/test_async_flag_tracker.py @@ -0,0 +1,204 @@ +""" +Tests for AsyncFlagValueChangeListener and AsyncFlagTrackerImpl. +""" +import asyncio +import threading + +import pytest +import pytest_asyncio + +from ldclient.context import Context +from ldclient.impl.async_flag_tracker import ( + AsyncFlagTrackerImpl, + AsyncFlagValueChangeListener +) +from ldclient.impl.listeners import Listeners +from ldclient.interfaces import FlagChange, FlagValueChange + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def context(): + return Context.create('user-1') + + +@pytest.fixture +def listeners(): + return Listeners() + + +@pytest.fixture +def eval_values(): + """Mutable container so tests can change the value returned by eval_fn.""" + return {'value': 'initial'} + + +@pytest.fixture +def eval_fn(eval_values): + async def _fn(key, ctx): + return eval_values['value'] + return _fn + + +@pytest_asyncio.fixture +async def tracker(listeners, eval_fn): + # The tracker's scheduler captures the running event loop at construction. + return AsyncFlagTrackerImpl(listeners, eval_fn) + + +# --------------------------------------------------------------------------- +# AsyncFlagValueChangeListener tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_create_initializes_value_without_notifying(tracker, context, eval_fn): + changes = [] + await tracker.add_flag_value_change_listener('flag-key', context, changes.append) + + # No notification on creation — only records the baseline value + assert changes == [] + + +@pytest.mark.asyncio +async def test_flag_change_matching_key_triggers_listener(tracker, listeners, eval_values, context): + received = [] + await tracker.add_flag_value_change_listener('flag-key', context, received.append) + + # Change the value the eval_fn returns + eval_values['value'] = 'changed' + + listeners.notify(FlagChange('flag-key')) + await asyncio.sleep(0.1) + + assert len(received) == 1 + change = received[0] + assert change.key == 'flag-key' + assert change.old_value == 'initial' + assert change.new_value == 'changed' + + +@pytest.mark.asyncio +async def test_flag_change_same_value_does_not_trigger_listener(tracker, listeners, context): + received = [] + await tracker.add_flag_value_change_listener('flag-key', context, received.append) + + # Value stays the same + listeners.notify(FlagChange('flag-key')) + await asyncio.sleep(0.1) + + assert received == [] + + +@pytest.mark.asyncio +async def test_flag_change_non_matching_key_ignored(tracker, listeners, eval_values, context): + received = [] + await tracker.add_flag_value_change_listener('flag-key', context, received.append) + + eval_values['value'] = 'changed' + + # Different flag key — should be ignored + listeners.notify(FlagChange('other-flag')) + await asyncio.sleep(0.1) + + assert received == [] + + +@pytest.mark.asyncio +async def test_listener_callback_is_sync(tracker, listeners, eval_values, context): + """The listener callback is invoked synchronously (not awaited).""" + was_called = [] + + def sync_listener(change: FlagValueChange): + was_called.append(change) + + await tracker.add_flag_value_change_listener('flag-key', context, sync_listener) + + eval_values['value'] = 'new' + listeners.notify(FlagChange('flag-key')) + await asyncio.sleep(0.1) + + assert len(was_called) == 1 + + +# --------------------------------------------------------------------------- +# AsyncFlagTrackerImpl tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_add_flag_value_change_listener_returns_listener(tracker, context): + listener = await tracker.add_flag_value_change_listener('flag-key', context, lambda c: None) + assert isinstance(listener, AsyncFlagValueChangeListener) + + +@pytest.mark.asyncio +async def test_add_listener(tracker, listeners): + received = [] + + def listener(change: FlagChange): + received.append(change.key) + + tracker.add_listener(listener) + listeners.notify(FlagChange('my-flag')) + + assert received == ['my-flag'] + + +@pytest.mark.asyncio +async def test_remove_listener(tracker, listeners): + received = [] + + def listener(change: FlagChange): + received.append(change.key) + + tracker.add_listener(listener) + tracker.remove_listener(listener) + + listeners.notify(FlagChange('my-flag')) + + assert received == [] + + +@pytest.mark.asyncio +async def test_remove_flag_value_change_listener(tracker, listeners, eval_values, context): + received = [] + listener = await tracker.add_flag_value_change_listener('flag-key', context, received.append) + + tracker.remove_listener(listener) + + eval_values['value'] = 'updated' + listeners.notify(FlagChange('flag-key')) + await asyncio.sleep(0.1) + + # After removal, no notification should fire + assert received == [] + + +@pytest.mark.asyncio +async def test_notify_from_worker_thread_fires_listener(tracker, listeners, eval_values, context): + """Regression: notify() called from a background thread (no running event loop in + that thread) must still schedule the evaluation correctly and invoke the listener. + + asyncio.create_task raises RuntimeError when called from a non-event-loop thread. + The scheduler uses run_coroutine_threadsafe, which works from any thread. + """ + received = [] + await tracker.add_flag_value_change_listener('flag-key', context, received.append) + + eval_values['value'] = 'changed-from-thread' + + def notify_from_thread(): + # This thread has no running event loop — verifies run_coroutine_threadsafe is used + listeners.notify(FlagChange('flag-key')) + + t = threading.Thread(target=notify_from_thread) + t.start() + t.join() + + # Allow the scheduled coroutine to run on the event loop + await asyncio.sleep(0.1) + + assert len(received) == 1 + assert received[0].new_value == 'changed-from-thread' diff --git a/ldclient/testing/test_async_hooks.py b/ldclient/testing/test_async_hooks.py new file mode 100644 index 00000000..3f0a6c78 --- /dev/null +++ b/ldclient/testing/test_async_hooks.py @@ -0,0 +1,215 @@ +""" +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). +""" +import logging + +import pytest + +from ldclient.context import Context +from ldclient.evaluation import EvaluationDetail +from ldclient.hook import AsyncHook, EvaluationSeriesContext, Metadata + +log = logging.getLogger('ldclient') + + +# --------------------------------------------------------------------------- +# Helpers: inline dispatch function that mirrors AsyncLDClient behaviour +# --------------------------------------------------------------------------- + +async def _try_execute_stage_async(method, hook_name, coro_or_fn): + """Execute a single hook stage, 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 + + +# --------------------------------------------------------------------------- +# Concrete hook implementations for testing +# --------------------------------------------------------------------------- + +class RecordingAsyncHook(AsyncHook): + """Async hook that records calls and threads a counter through data.""" + + def __init__(self, name: str): + self._name = name + self.before_calls: list = [] + self.after_calls: list = [] + + @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} + + 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} + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@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.fixture +def eval_fn(mock_detail): + async def _fn(): + return mock_detail + return _fn + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@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) + + assert hook.before_calls == ['test-flag'] + assert hook.after_calls == ['test-flag'] + + +@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 = [] + + class OrderAsyncHook(AsyncHook): + def __init__(self, name): + self._name = name + + @property + def metadata(self): + return Metadata(name=self._name) + + 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 + + hooks = [OrderAsyncHook('first'), OrderAsyncHook('second')] + await _evaluate_with_hooks(hooks, series_context, eval_fn) + + # before: list order (first, second) + # after: reversed (second, first) + assert call_log == ['first_before', 'second_before', 'second_after', 'first_after'] + + +@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') + + hooks = [hook_a, hook_b] + await _evaluate_with_hooks(hooks, series_context, eval_fn) + + assert hook_a.before_calls == ['test-flag'] + assert hook_b.before_calls == ['test-flag'] + + +@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 + + +@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 + + +@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). + + 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 = {} + + class CaptureAsyncHook(AsyncHook): + def __init__(self, name): + self._name = name + + @property + def metadata(self): + return Metadata(name=self._name) + + 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 + + await _evaluate_with_hooks([CaptureAsyncHook('one'), CaptureAsyncHook('two')], series_context, eval_fn) + + # 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'] == {}