Skip to content
Open
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
62 changes: 62 additions & 0 deletions ldclient/impl/async_flag_tracker.py
Original file line number Diff line number Diff line change
@@ -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))
Comment thread
cursor[bot] marked this conversation as resolved.


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