Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat: Add support for plugins.
  • Loading branch information
kinyoklion committed Jun 23, 2025
commit 924cec5205bca771633985cb09824ccbbbe411b5
3 changes: 2 additions & 1 deletion ldclient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .client import *
from .context import *
from .migrations import *
from .plugin import *

__version__ = VERSION

Expand Down Expand Up @@ -99,4 +100,4 @@ def _reset_client():
__BASE_TYPES__ = (str, float, int, bool)


__all__ = ['Config', 'Context', 'ContextBuilder', 'ContextMultiBuilder', 'LDClient', 'Result', 'client', 'context', 'evaluation', 'integrations', 'interfaces', 'migrations']
__all__ = ['Config', 'Context', 'ContextBuilder', 'ContextMultiBuilder', 'LDClient', 'Result', 'client', 'context', 'evaluation', 'integrations', 'interfaces', 'migrations', 'Plugin', 'PluginMetadata', 'EnvironmentMetadata', 'SdkMetadata', 'ApplicationMetadata']
48 changes: 46 additions & 2 deletions ldclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@
FeatureStore, FlagTracker)
from ldclient.migrations import OpTracker, Stage
from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind

from ldclient.plugin import SdkMetadata, ApplicationMetadata, EnvironmentMetadata
from ldclient.version import VERSION

from .impl import AnyNum


Expand Down Expand Up @@ -223,8 +225,11 @@ def postfork(self, start_wait: float = 5):
self.__start_up(start_wait)

def __start_up(self, start_wait: float):
environment_metadata = self.__get_environment_metadata()
plugin_hooks = self.__get_plugin_hooks(environment_metadata)

self.__hooks_lock = ReadWriteLock()
self.__hooks = self._config.hooks # type: List[Hook]
self.__hooks = self._config.hooks + plugin_hooks # type: List[Hook]

data_store_listeners = Listeners()
store_sink = DataStoreUpdateSinkImpl(data_store_listeners)
Expand Down Expand Up @@ -256,6 +261,8 @@ def __start_up(self, start_wait: float):

diagnostic_accumulator = self._set_event_processor(self._config)

self.__register_plugins(environment_metadata)

update_processor_ready = threading.Event()
self._update_processor = self._make_update_processor(self._config, self._store, update_processor_ready, diagnostic_accumulator)
self._update_processor.start()
Expand All @@ -273,6 +280,43 @@ def __start_up(self, start_wait: float):
else:
log.warning("Initialization timeout exceeded for LaunchDarkly Client or an error occurred. " "Feature Flags may not yet be available.")

def __get_environment_metadata(self):
sdk_metadata = SdkMetadata(
name="python-server-sdk",
version=VERSION,
wrapper_name=self._config.wrapper_name,
wrapper_version=self._config.wrapper_version
)

application_metadata = None
if self._config.application:
application_metadata = ApplicationMetadata(
id=self._config.application.get('id'),
version=self._config.application.get('version'),
)

return EnvironmentMetadata(
sdk=sdk_metadata,
application=application_metadata,
sdk_key=self._config.sdk_key
)

def __get_plugin_hooks(self, environment_metadata: EnvironmentMetadata) -> List[Hook]:
hooks = []
for plugin in self._config.plugins:
try:
hooks.extend(plugin.get_hooks(environment_metadata))
except Exception as e:
log.error(f"Error getting hooks from plugin {plugin.get_metadata().name}: {e}")
return hooks

def __register_plugins(self, environment_metadata: EnvironmentMetadata):
for plugin in self._config.plugins:
try:
plugin.register(self, environment_metadata)
except Exception as e:
log.error(f"Error registering plugin {plugin.get_metadata().name}: {e}")

def _set_event_processor(self, config):
if config.offline or not config.send_events:
self._event_processor = NullEventProcessor()
Expand Down
14 changes: 14 additions & 0 deletions ldclient/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ldclient.impl.util import log, validate_application_info
from ldclient.interfaces import (BigSegmentStore, DataSourceUpdateSink,
EventProcessor, FeatureStore, UpdateProcessor)
from ldclient.plugin import Plugin

GET_LATEST_FEATURES_PATH = '/sdk/latest-flags'
STREAM_FLAGS_PATH = '/flags'
Expand Down Expand Up @@ -180,6 +181,7 @@ def __init__(
big_segments: Optional[BigSegmentsConfig] = None,
application: Optional[dict] = None,
hooks: Optional[List[Hook]] = None,
plugins: Optional[List[Plugin]] = None,
enable_event_compression: bool = False,
omit_anonymous_contexts: bool = False,
payload_filter_key: Optional[str] = None,
Expand Down Expand Up @@ -249,6 +251,7 @@ def __init__(
:class:`HTTPConfig`.
:param application: Optional properties for setting application metadata. See :py:attr:`~application`
:param hooks: Hooks provide entrypoints which allow for observation of SDK functions.
:param plugins: A list of plugins to be used with the SDK. Plugin support is currently experimental and subject to change.
:param enable_event_compression: Whether or not to enable GZIP compression for outgoing events.
:param omit_anonymous_contexts: Sets whether anonymous contexts should be omitted from index and identify events.
:param payload_filter_key: The payload filter is used to selectively limited the flags and segments delivered in the data source payload.
Expand Down Expand Up @@ -285,6 +288,7 @@ def __init__(
self.__big_segments = BigSegmentsConfig() 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, Hook)] if hooks else []
self.__plugins = [plugin for plugin in plugins if isinstance(plugin, Plugin)] if plugins else []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line reads like a joke. Peak python! lol

self.__enable_event_compression = enable_event_compression
self.__omit_anonymous_contexts = omit_anonymous_contexts
self.__payload_filter_key = payload_filter_key
Expand Down Expand Up @@ -477,6 +481,16 @@ def hooks(self) -> List[Hook]:
"""
return self.__hooks

@property
def plugins(self) -> List[Plugin]:
"""
Initial set of plugins for the client.

LaunchDarkly provides plugin packages, and most applications will
not need to implement their own plugins.
"""
return self.__plugins

@property
def enable_event_compression(self) -> bool:
return self.__enable_event_compression
Expand Down
97 changes: 97 additions & 0 deletions ldclient/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from abc import ABCMeta, abstractmethod
from dataclasses import dataclass
from typing import Any, List, Optional

from ldclient.hook import Hook


@dataclass
class SdkMetadata:
"""
Metadata about the SDK.
"""
name: str #: The id of the SDK (e.g., "python-server-sdk")
version: str #: The version of the SDK
wrapper_name: Optional[str] = None #: The wrapper name if this SDK is a wrapper
wrapper_version: Optional[str] = None #: The wrapper version if this SDK is a wrapper


@dataclass
class ApplicationMetadata:
"""
Metadata about the application using the SDK.
"""
id: Optional[str] = None #: The id of the application
version: Optional[str] = None #: The version of the application


@dataclass
class EnvironmentMetadata:
"""
Metadata about the environment in which the SDK is running.
"""
sdk: SdkMetadata #: Information about the SDK
application: Optional[ApplicationMetadata] = None #: Information about the application
sdk_key: Optional[str] = None #: The SDK key used to initialize the SDK
mobile_key: Optional[str] = None #: The mobile key used to initialize the SDK
client_side_id: Optional[str] = None #: The client-side ID used to initialize the SDK


@dataclass
class PluginMetadata:
"""
Metadata about a plugin implementation.
"""
name: str #: A name representing the plugin instance


class Plugin:
"""
Abstract base class for extending SDK functionality via plugins.

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

Plugins provide an interface which allows for initialization, access to
credentials, and hook registration in a single interface.
"""

__metaclass__ = ABCMeta

@abstractmethod
def get_metadata(self) -> PluginMetadata:
"""
Get metadata about the plugin implementation.

:return: Metadata containing information about the plugin
"""
return PluginMetadata(name='UNDEFINED')

@abstractmethod
def register(self, client: Any, metadata: EnvironmentMetadata) -> None:
"""
Register the plugin with the 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.

:param client: The LDClient instance
:param metadata: Metadata about the environment in which the SDK is running
"""
pass

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.

: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