Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions ldclient/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions ldclient/impl/async_flag_tracker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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 AsyncFlagTracker, FlagChange, 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, initial_value: Any):
self.__key = key
self.__context = context
self.__listener = listener
self.__eval_fn = eval_fn
self.__scheduler = scheduler

self.__lock = AsyncLock()
self.__value = initial_value

@classmethod
async def create(cls, key: str, context: Context, listener: Callable[[FlagValueChange], None], eval_fn: Callable, scheduler: AsyncCallbackScheduler) -> 'AsyncFlagValueChangeListener':
Comment thread
jsonbailey marked this conversation as resolved.
"""Evaluates the flag once to capture the baseline value, then returns the listener."""
initial_value = await eval_fn(key, context)
return cls(key, context, listener, eval_fn, scheduler, initial_value)

def __call__(self, flag_change: FlagChange):
if flag_change.key != self.__key:
return
Comment thread
cursor[bot] marked this conversation as resolved.
self.__scheduler.call(self._on_flag_change)

async def _on_flag_change(self):
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))
Comment thread
cursor[bot] marked this conversation as resolved.


class AsyncFlagTrackerImpl(AsyncFlagTracker):
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)

Comment thread
jsonbailey marked this conversation as resolved.
return listener
Comment thread
cursor[bot] marked this conversation as resolved.
59 changes: 59 additions & 0 deletions ldclient/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ class AsyncReadOnlyStore(Protocol):
Both async data systems expose their active store through this uniform interface:
:class:`AsyncFeatureStore` satisfies it directly (FDv1), and FDv2 adapts its
synchronous in-memory store to it. This is the async analog of :class:`ReadOnlyStore`.

.. 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.
"""

@abstractmethod
Expand Down Expand Up @@ -691,6 +696,10 @@ def status(self) -> BigSegmentStoreStatus:
"""
Gets the current status of the store.

Before the first poll completes, the synchronous SDK performs a blocking store query and
returns its result, while the async SDK (``AsyncLDClient``) returns the last polled status
without blocking -- ``available=False`` until the first background poll completes.

:return: the status
"""
pass
Expand Down Expand Up @@ -1187,6 +1196,56 @@ def add_flag_value_change_listener(self, key: str, context: Context, listener: C
pass


class AsyncFlagTracker(ABC):
"""
An interface for tracking changes in feature flag configurations, for use with the async client
(:class:`ldclient.async_client.AsyncLDClient`). It mirrors :class:`FlagTracker`, except
:meth:`add_flag_value_change_listener` is a coroutine.

.. 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.

An implementation of this abstract class is returned by
:meth:`ldclient.async_client.AsyncLDClient.flag_tracker`. Application code never needs to
implement this interface.
"""

@abstractmethod
def add_listener(self, listener: Callable[[FlagChange], None]) -> None:
"""
Registers a listener to be notified of feature flag changes in general. Behaves identically
to :meth:`FlagTracker.add_listener`.

:param listener: listener to call when a flag has changed
"""
pass

@abstractmethod
def remove_listener(self, listener: Callable[[FlagChange], None]) -> None:
"""
Unregisters a listener so that it will no longer be notified of feature flag changes.

:param listener: the listener to remove
"""
pass

@abstractmethod
async def add_flag_value_change_listener(self, key: str, context: Context, listener: Callable[[FlagValueChange], None]) -> Callable[[FlagChange], None]:
"""
Registers a listener to be notified when a specific flag's evaluated value changes for a
specific context. Behaves like :meth:`FlagTracker.add_flag_value_change_listener`, but is a
**coroutine** and must be awaited (it evaluates the flag to capture the baseline value).

:param key: the flag key to monitor
:param context: the context to evaluate against the flag
:param listener: the listener to trigger if the value has changed
:return: the subscription; pass it to :meth:`remove_listener` to unsubscribe
"""
pass


class DataStoreStatus:
"""
Information about the data store's status.
Expand Down
64 changes: 63 additions & 1 deletion ldclient/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -17,6 +17,7 @@
)

if TYPE_CHECKING:
from ldclient.async_client import AsyncLDClient
from ldclient.client import LDClient


Expand Down Expand Up @@ -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 []
Loading
Loading