Skip to content

Commit cc93fc2

Browse files
committed
feat: Add async hook, plugin, and flag tracker
1 parent eb0ecfd commit cc93fc2

5 files changed

Lines changed: 604 additions & 1 deletion

File tree

ldclient/hook.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,65 @@ def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict,
7979
return data
8080

8181

82+
class AsyncHook(ABC):
83+
"""
84+
Abstract class for extending AsyncLDClient functionality via hooks.
85+
86+
.. caution::
87+
This feature is experimental and should NOT be considered ready for production
88+
use. It may change or be removed without notice and is not subject to backwards
89+
compatibility guarantees. Pin to a specific minor version and review the changelog
90+
before upgrading.
91+
92+
All provided async hook implementations **MUST** inherit from this class.
93+
94+
This class includes default implementations for all hook handlers. This
95+
allows LaunchDarkly to expand the list of hook handlers without breaking
96+
customer integrations.
97+
98+
Unlike :class:`Hook`, the before and after methods are coroutines and will
99+
be awaited by the async client.
100+
"""
101+
102+
@property
103+
@abstractmethod
104+
def metadata(self) -> Metadata:
105+
"""
106+
Get metadata about the hook implementation.
107+
"""
108+
return Metadata(name='UNDEFINED')
109+
110+
async def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict:
111+
"""
112+
The before method is called during the execution of a variation method
113+
before the flag value has been determined. The method is a coroutine
114+
and will be awaited.
115+
116+
:param series_context: Contains information about the evaluation being performed. This is not mutable.
117+
:param data: A record associated with each stage of hook invocations.
118+
Each stage is called with the data of the previous stage for a series.
119+
The input record should not be modified.
120+
:return: Data to use when executing the next state of the hook in the evaluation series.
121+
"""
122+
return data
123+
124+
async def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict,
125+
detail: EvaluationDetail) -> dict:
126+
"""
127+
The after method is called during the execution of the variation method
128+
after the flag value has been determined. The method is a coroutine
129+
and will be awaited.
130+
131+
:param series_context: Contains read-only information about the
132+
evaluation being performed.
133+
:param data: A record associated with each stage of hook invocations.
134+
Each stage is called with the data of the previous stage for a series.
135+
:param detail: The result of the evaluation. This value should not be modified.
136+
:return: Data to use when executing the next state of the hook in the evaluation series.
137+
"""
138+
return data
139+
140+
82141
@dataclass
83142
class _EvaluationWithHookResult:
84143
evaluation_detail: EvaluationDetail
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from typing import Any, Callable
2+
3+
from ldclient.context import Context
4+
from ldclient.impl.aio.concurrency import AsyncCallbackScheduler, AsyncLock
5+
from ldclient.impl.listeners import Listeners
6+
from ldclient.interfaces import FlagChange, FlagTracker, FlagValueChange
7+
8+
9+
class AsyncFlagValueChangeListener:
10+
"""Calls the user's listener when a specific flag's evaluated value changes for a specific context."""
11+
12+
def __init__(self, key: str, context: Context, listener: Callable[[FlagValueChange], None], eval_fn: Callable, scheduler: AsyncCallbackScheduler):
13+
self.__key = key
14+
self.__context = context
15+
self.__listener = listener
16+
self.__eval_fn = eval_fn
17+
self.__scheduler = scheduler
18+
19+
self.__lock = AsyncLock()
20+
self.__value: Any = None
21+
22+
@classmethod
23+
async def create(cls, key: str, context: Context, listener: Callable[[FlagValueChange], None], eval_fn: Callable, scheduler: AsyncCallbackScheduler) -> 'AsyncFlagValueChangeListener':
24+
"""Evaluates the flag once to capture the baseline value, then returns the listener."""
25+
instance = cls(key, context, listener, eval_fn, scheduler)
26+
instance.__value = await eval_fn(key, context)
27+
return instance
28+
29+
def __call__(self, flag_change: FlagChange):
30+
self.__scheduler.call(self._on_flag_change, flag_change)
31+
32+
async def _on_flag_change(self, flag_change: FlagChange):
33+
if flag_change.key != self.__key:
34+
return
35+
36+
new_value = await self.__eval_fn(self.__key, self.__context)
37+
38+
async with self.__lock:
39+
old_value, self.__value = self.__value, new_value
40+
41+
if new_value == old_value:
42+
return
43+
44+
self.__listener(FlagValueChange(self.__key, old_value, new_value))
45+
46+
47+
class AsyncFlagTrackerImpl(FlagTracker):
48+
def __init__(self, listeners: Listeners, eval_fn: Callable):
49+
self.__listeners = listeners
50+
self.__eval_fn = eval_fn
51+
self.__scheduler = AsyncCallbackScheduler()
52+
53+
def add_listener(self, listener: Callable[[FlagChange], None]):
54+
self.__listeners.add(listener)
55+
56+
def remove_listener(self, listener: Callable[[FlagChange], None]):
57+
self.__listeners.remove(listener)
58+
59+
async def add_flag_value_change_listener(self, key: str, context: Context, fn: Callable[[FlagValueChange], None]) -> Callable[[FlagChange], None]:
60+
listener = await AsyncFlagValueChangeListener.create(key, context, fn, self.__eval_fn, self.__scheduler)
61+
self.add_listener(listener)
62+
63+
return listener

ldclient/plugin.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from ldclient.context import Context
88
from ldclient.evaluation import EvaluationDetail, FeatureFlagsState
9-
from ldclient.hook import Hook
9+
from ldclient.hook import AsyncHook, Hook
1010
from ldclient.impl import AnyNum
1111
from ldclient.impl.evaluator import error_reason
1212
from ldclient.interfaces import (
@@ -17,6 +17,7 @@
1717
)
1818

1919
if TYPE_CHECKING:
20+
from ldclient.async_client import AsyncLDClient
2021
from ldclient.client import LDClient
2122

2223

@@ -108,3 +109,64 @@ def get_hooks(self, metadata: EnvironmentMetadata) -> List[Hook]:
108109
:return: A list of hooks to be registered with the SDK
109110
"""
110111
return []
112+
113+
114+
class AsyncPlugin(ABC):
115+
"""
116+
Abstract base class for extending AsyncLDClient functionality via plugins.
117+
118+
.. caution::
119+
This feature is experimental and should NOT be considered ready for production
120+
use. It may change or be removed without notice and is not subject to backwards
121+
compatibility guarantees. Pin to a specific minor version and review the changelog
122+
before upgrading.
123+
124+
All provided async plugin implementations **MUST** inherit from this class.
125+
126+
This class includes default implementations for optional methods. This
127+
allows LaunchDarkly to expand the list of plugin methods without breaking
128+
customer integrations.
129+
130+
Unlike :class:`Plugin`, the register() method is a coroutine and will be
131+
awaited by the async client, allowing plugins to perform asynchronous
132+
initialization such as connecting to telemetry backends.
133+
"""
134+
135+
@property
136+
@abstractmethod
137+
def metadata(self) -> PluginMetadata:
138+
"""
139+
Get metadata about the plugin implementation.
140+
141+
:return: Metadata containing information about the plugin
142+
"""
143+
return PluginMetadata(name='UNDEFINED')
144+
145+
async def register(self, client: 'AsyncLDClient', metadata: EnvironmentMetadata) -> None:
146+
"""
147+
Register the plugin with the async SDK client.
148+
149+
This method is called during SDK initialization to allow the plugin
150+
to set up any necessary integrations, register hooks, or perform
151+
other initialization tasks. The method is a coroutine and will be
152+
awaited, allowing asynchronous I/O during registration.
153+
154+
:param client: The AsyncLDClient instance
155+
:param metadata: Metadata about the environment in which the SDK is running
156+
"""
157+
pass
158+
159+
def get_hooks(self, metadata: EnvironmentMetadata) -> List[AsyncHook]:
160+
"""
161+
Get a list of hooks that this plugin provides.
162+
163+
This method is called before register() to collect all hooks from
164+
plugins. The hooks returned will be added to the SDK's hook configuration.
165+
Async plugins provide async :class:`AsyncHook` instances only.
166+
167+
This method is synchronous (returns a list immediately — no I/O).
168+
169+
:param metadata: Metadata about the environment in which the SDK is running
170+
:return: A list of hooks to be registered with the SDK
171+
"""
172+
return []

0 commit comments

Comments
 (0)