Skip to content
Draft
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
11 changes: 3 additions & 8 deletions contract-tests/async_client_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
]

Expand All @@ -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)

Expand Down
53 changes: 1 addition & 52 deletions contract-tests/hook.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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, {})}
34 changes: 14 additions & 20 deletions ldclient/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -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 {}
Expand Down
8 changes: 4 additions & 4 deletions ldclient/async_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
59 changes: 0 additions & 59 deletions ldclient/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions ldclient/impl/client_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down
7 changes: 2 additions & 5 deletions 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 AsyncHook, Hook
from ldclient.hook import Hook
from ldclient.impl import AnyNum
from ldclient.impl.evaluator import error_reason
from ldclient.interfaces import (
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading