From 924cec5205bca771633985cb09824ccbbbe411b5 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:15:51 -0700 Subject: [PATCH 01/17] feat: Add support for plugins. --- ldclient/__init__.py | 3 +- ldclient/client.py | 48 +++- ldclient/config.py | 14 + ldclient/plugin.py | 97 +++++++ ldclient/testing/test_ldclient_plugin.py | 319 +++++++++++++++++++++++ ldclient/testing/test_plugin.py | 142 ++++++++++ 6 files changed, 620 insertions(+), 3 deletions(-) create mode 100644 ldclient/plugin.py create mode 100644 ldclient/testing/test_ldclient_plugin.py create mode 100644 ldclient/testing/test_plugin.py diff --git a/ldclient/__init__.py b/ldclient/__init__.py index 884c3af8..d2267c4f 100644 --- a/ldclient/__init__.py +++ b/ldclient/__init__.py @@ -9,6 +9,7 @@ from .client import * from .context import * from .migrations import * +from .plugin import * __version__ = VERSION @@ -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'] diff --git a/ldclient/client.py b/ldclient/client.py index 8b96dffa..5718047d 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -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 @@ -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) @@ -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() @@ -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() diff --git a/ldclient/config.py b/ldclient/config.py index 475de271..02455344 100644 --- a/ldclient/config.py +++ b/ldclient/config.py @@ -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' @@ -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, @@ -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. @@ -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 [] self.__enable_event_compression = enable_event_compression self.__omit_anonymous_contexts = omit_anonymous_contexts self.__payload_filter_key = payload_filter_key @@ -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 diff --git a/ldclient/plugin.py b/ldclient/plugin.py new file mode 100644 index 00000000..2ca7e98f --- /dev/null +++ b/ldclient/plugin.py @@ -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 [] \ No newline at end of file diff --git a/ldclient/testing/test_ldclient_plugin.py b/ldclient/testing/test_ldclient_plugin.py new file mode 100644 index 00000000..87f4df6e --- /dev/null +++ b/ldclient/testing/test_ldclient_plugin.py @@ -0,0 +1,319 @@ +""" +Tests for LDClient plugin functionality. + +These tests port the equivalent functionality from the Node.js SDK plugin tests. +""" + +import unittest +from unittest.mock import Mock, patch, MagicMock +import threading +from typing import Any, List, Optional, Callable + +from ldclient.config import Config +from ldclient.client import LDClient +from ldclient.plugin import Plugin, PluginMetadata, EnvironmentMetadata, SdkMetadata, ApplicationMetadata +from ldclient.hook import Hook, Metadata, EvaluationSeriesContext, EvaluationDetail +from ldclient.integrations.test_data import TestData +from ldclient.context import Context + + +class ThreadSafeCounter: + """Thread-safe counter for tracking hook execution order.""" + + def __init__(self): + self._value = 0 + self._lock = threading.Lock() + + def get_and_increment(self) -> int: + """Atomically get the current value and increment it.""" + with self._lock: + current = self._value + self._value += 1 + return current + + +class ConfigurableTestHook(Hook): + """Configurable test hook that can be customized with lambda functions for before/after evaluation.""" + def __init__(self, name: str = "Configurable Test Hook", before_evaluation_behavior=None, after_evaluation_behavior=None): + self._name = name + self.before_called = False + self.after_called = False + self.execution_order = -1 + self._state = {} + self._before_evaluation_behavior = before_evaluation_behavior + self._after_evaluation_behavior = after_evaluation_behavior + + @property + def metadata(self) -> Metadata: + return Metadata(name=self._name) + + def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict: + self.before_called = True + if self._before_evaluation_behavior: + return self._before_evaluation_behavior(self, series_context, data) + return data + + def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, detail: EvaluationDetail) -> dict: + self.after_called = True + if self._after_evaluation_behavior: + return self._after_evaluation_behavior(self, series_context, data, detail) + return data + + def set_state(self, key: str, value: Any) -> None: + self._state[key] = value + + def get_state(self, key: str, default: Any = None) -> Any: + return self._state.get(key, default) + + +class ConfigurableTestPlugin(Plugin): + """Configurable test plugin that can be customized with lambda functions for different test scenarios.""" + + def __init__(self, + name: str = "Configurable Test Plugin", + hooks: Optional[List[Hook]] = None, + register_behavior: Optional[Callable[[Any, EnvironmentMetadata], None]] = None, + get_hooks_behavior: Optional[Callable[[EnvironmentMetadata], List[Hook]]] = None): + self._name = name + self._hooks = hooks if hooks is not None else [] + self._register_behavior = register_behavior + self._get_hooks_behavior = get_hooks_behavior + + # State tracking + self.registered = False + self.registration_metadata = None + self.registration_client = None + self.hooks_called = False + self.hooks_metadata = None + + def get_metadata(self) -> PluginMetadata: + return PluginMetadata(name=self._name) + + def register(self, client: Any, metadata: EnvironmentMetadata) -> None: + self.registration_client = client + self.registration_metadata = metadata + + if self._register_behavior: + self._register_behavior(client, metadata) + + # Only mark as registered if no exception was thrown + self.registered = True + + def get_hooks(self, metadata: EnvironmentMetadata) -> List[Hook]: + self.hooks_called = True + self.hooks_metadata = metadata + + if self._get_hooks_behavior: + return self._get_hooks_behavior(metadata) + + return self._hooks + + +class TestLDClientPlugin(unittest.TestCase): + """Test cases for LDClient plugin functionality.""" + + def test_plugin_environment_metadata(self): + """Test that plugins receive correct environment metadata.""" + plugin = ConfigurableTestPlugin("Test Plugin") + + config = Config( + sdk_key="test-sdk-key", + send_events=False, + offline=True, + wrapper_name="TestWrapper", + wrapper_version="1.0.0", + application={"id": "test-app", "version": "1.0.0"}, + plugins=[plugin] + ) + + with LDClient(config=config) as client: + self.assertTrue(plugin.registered) + self.assertIsNotNone(plugin.registration_metadata) + + # Verify SDK metadata + if plugin.registration_metadata: + self.assertEqual(plugin.registration_metadata.sdk.name, "python-server-sdk") + self.assertEqual(plugin.registration_metadata.sdk.wrapper_name, "TestWrapper") + self.assertEqual(plugin.registration_metadata.sdk.wrapper_version, "1.0.0") + self.assertRegex(plugin.registration_metadata.sdk.version, r"^\d+\.\d+\.\d+$") + + # Verify application metadata + if plugin.registration_metadata.application: + self.assertEqual(plugin.registration_metadata.application.id, "test-app") + self.assertEqual(plugin.registration_metadata.application.version, "1.0.0") + + # Verify SDK key + self.assertEqual(plugin.registration_metadata.sdk_key, "test-sdk-key") + + def test_registers_plugins_and_executes_hooks(self): + """Test that plugins are registered and hooks are executed.""" + hook1 = ConfigurableTestHook("Hook 1") + hook2 = ConfigurableTestHook("Hook 2") + + plugin1 = ConfigurableTestPlugin("Plugin 1", hooks=[hook1]) + plugin2 = ConfigurableTestPlugin("Plugin 2", hooks=[hook2]) + + config = Config( + sdk_key="test-sdk-key", + send_events=False, + offline=True, + plugins=[plugin1, plugin2] + ) + + with LDClient(config=config) as client: + # Verify hooks were collected + self.assertTrue(plugin1.hooks_called) + self.assertTrue(plugin2.hooks_called) + self.assertTrue(plugin1.registered) + self.assertTrue(plugin2.registered) + + # Test that hooks are called during evaluation + client.variation("test-flag", Context.builder("user-key").build(), "default") + + # Verify hooks were called + self.assertTrue(hook1.before_called) + self.assertTrue(hook1.after_called) + self.assertTrue(hook2.before_called) + self.assertTrue(hook2.after_called) + + def test_plugin_error_handling_get_hooks(self): + """Test that errors get_hooks are handled gracefully.""" + error_plugin = ConfigurableTestPlugin( + "Error Plugin", + get_hooks_behavior=lambda metadata: (_ for _ in ()).throw(Exception("Get hooks error in Error Plugin")) + ) + normal_hook = ConfigurableTestHook("Normal Hook") + normal_plugin = ConfigurableTestPlugin("Normal Plugin", hooks=[normal_hook]) + + config = Config( + sdk_key="test-sdk-key", + send_events=False, + offline=True, + plugins=[error_plugin, normal_plugin] + ) + + # The hooks cannot be accessed, but the plugin will still get registered. + with LDClient(config=config) as client: + self.assertTrue(normal_plugin.registered) + self.assertTrue(error_plugin.registered) + + client.variation("test-flag", Context.builder("user-key").build(), "default") + + self.assertTrue(normal_hook.before_called) + self.assertTrue(normal_hook.after_called) + + def test_plugin_error_handling_register(self): + """Test that errors during plugin registration are handled gracefully.""" + error_plugin = ConfigurableTestPlugin( + "Error Plugin", + register_behavior=lambda client, metadata: (_ for _ in ()).throw(Exception("Registration error in Error Plugin")) + ) + normal_hook = ConfigurableTestHook("Normal Hook") + normal_plugin = ConfigurableTestPlugin("Normal Plugin", hooks=[normal_hook]) + + config = Config( + sdk_key="test-sdk-key", + send_events=False, + offline=True, + plugins=[error_plugin, normal_plugin] + ) + + # Should not raise an exception + with LDClient(config=config) as client: + # Normal plugin should still be registered + self.assertTrue(normal_plugin.registered) + + # Error plugin should not be registered + self.assertFalse(error_plugin.registered) + + client.variation("test-flag", Context.builder("user-key").build(), "default") + + self.assertTrue(normal_hook.before_called) + self.assertTrue(normal_hook.after_called) + + def test_plugin_with_existing_hooks(self): + """Test that plugin hooks work alongside existing hooks and config hooks are called before plugin hooks.""" + counter = ThreadSafeCounter() + def make_ordered_before(counter): + return lambda hook, series_context, data: ( + setattr(hook, 'execution_order', counter.get_and_increment()) or data + ) + existing_hook = ConfigurableTestHook("Existing Hook", before_evaluation_behavior=make_ordered_before(counter)) + plugin_hook = ConfigurableTestHook("Plugin Hook", before_evaluation_behavior=make_ordered_before(counter)) + + plugin = ConfigurableTestPlugin("Test Plugin", hooks=[plugin_hook]) + + config = Config( + sdk_key="test-sdk-key", + send_events=False, + offline=True, + hooks=[existing_hook], + plugins=[plugin] + ) + + with LDClient(config=config) as client: + # Test that both hooks are called + client.variation("test-flag", Context.builder("user-key").build(), "default") + + # Verify hooks were called + self.assertTrue(existing_hook.before_called) + self.assertTrue(existing_hook.after_called) + self.assertTrue(plugin_hook.before_called) + self.assertTrue(plugin_hook.after_called) + + # Verify that config hooks are called before plugin hooks + self.assertLess(existing_hook.execution_order, plugin_hook.execution_order, + "Config hooks should be called before plugin hooks") + + def test_plugin_no_hooks(self): + """Test that plugins without hooks work correctly.""" + plugin = ConfigurableTestPlugin("No Hooks Plugin", hooks=[]) + + config = Config( + sdk_key="test-sdk-key", + send_events=False, + offline=True, + plugins=[plugin] + ) + + with LDClient(config=config) as client: + self.assertTrue(plugin.registered) + self.assertTrue(plugin.hooks_called) + + # Should work normally without hooks + result = client.variation("test-flag", Context.builder("user-key").build(), False) + self.assertEqual(result, False) + + def test_plugin_client_access(self): + """Test that plugins can access the client during registration and their hooks are called.""" + hook = ConfigurableTestHook("Client Access Hook") + + def register_behavior(client, metadata): + # Call variation during registration to test that hooks are available + # This should trigger the plugin's hook + result = client.variation("test-flag", Context.builder("user-key").build(), "default") + # Store whether the hook was called during registration + hook.set_state("called_during_registration", hook.before_called) + + plugin = ConfigurableTestPlugin( + "Client Access Plugin", + hooks=[hook], + register_behavior=register_behavior + ) + + config = Config( + sdk_key="test-sdk-key", + send_events=False, + offline=True, + plugins=[plugin] + ) + + with LDClient(config=config) as client: + self.assertTrue(plugin.registered) + self.assertIs(plugin.registration_client, client) + + # Verify that the plugin's hook was called when it called variation during registration + self.assertTrue(hook.get_state("called_during_registration", False), + "Plugin's hook should be called when variation is called during registration") + self.assertTrue(hook.before_called) + self.assertTrue(hook.after_called) diff --git a/ldclient/testing/test_plugin.py b/ldclient/testing/test_plugin.py new file mode 100644 index 00000000..f26231db --- /dev/null +++ b/ldclient/testing/test_plugin.py @@ -0,0 +1,142 @@ +""" +Tests for the plugin interface. +""" + +import unittest +from unittest.mock import Mock, patch + +from ldclient.config import Config +from ldclient.plugin import Plugin, PluginMetadata, EnvironmentMetadata, SdkMetadata, ApplicationMetadata +from ldclient.hook import Hook, Metadata, EvaluationSeriesContext, EvaluationDetail +from typing import Any, List + + +class ExampleHook(Hook): + """Example hook implementation for the example plugin.""" + + @property + def metadata(self) -> Metadata: + return Metadata(name="Example Plugin Hook") + + def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict: + """Called before flag evaluation.""" + # Add some data to track in the evaluation series + data['example_plugin_before'] = True + return data + + def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, detail: EvaluationDetail) -> dict: + """Called after flag evaluation.""" + # Add some data to track in the evaluation series + data['example_plugin_after'] = True + return data + + +class ExamplePlugin(Plugin): + """ + Example plugin implementation. + + This plugin demonstrates how to implement the plugin interface by: + 1. Providing metadata about the plugin + 2. Registering with the client + 3. Providing hooks for SDK observation + """ + + def __init__(self, name: str = "Example Plugin"): + self._name = name + self._client = None + self._environment_metadata = None + + def get_metadata(self) -> PluginMetadata: + """Get metadata about the plugin implementation.""" + return PluginMetadata(name=self._name) + + 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. + """ + self._client = client + self._environment_metadata = metadata + + # Example: Log some information about the environment + print(f"Example Plugin registered with SDK {metadata.sdk.name} version {metadata.sdk.version}") + if metadata.application: + print(f"Application: {metadata.application.name} version {metadata.application.version}") + + 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. + """ + return [ExampleHook()] + + +class TestPlugin(unittest.TestCase): + """Test cases for the plugin interface.""" + + def test_plugin_metadata(self): + """Test that plugin metadata is correctly structured.""" + metadata = PluginMetadata(name="Test Plugin") + self.assertEqual(metadata.name, "Test Plugin") + + def test_environment_metadata(self): + """Test that environment metadata is correctly structured.""" + sdk_metadata = SdkMetadata(name="test-sdk", version="1.0.0") + app_metadata = ApplicationMetadata(id="test-app", version="1.0.0") + + env_metadata = EnvironmentMetadata( + sdk=sdk_metadata, + application=app_metadata, + sdk_key="test-key" + ) + + self.assertEqual(env_metadata.sdk.name, "test-sdk") + self.assertEqual(env_metadata.sdk.version, "1.0.0") + if env_metadata.application: + self.assertEqual(env_metadata.application.id, "test-app") + self.assertEqual(env_metadata.application.version, "1.0.0") + self.assertEqual(env_metadata.sdk_key, "test-key") + + def test_example_plugin(self): + """Test that the example plugin works correctly.""" + plugin = ExamplePlugin("Test Example Plugin") + + # Test metadata + metadata = plugin.get_metadata() + self.assertEqual(metadata.name, "Test Example Plugin") + + # Test hooks + sdk_metadata = SdkMetadata(name="test-sdk", version="1.0.0") + env_metadata = EnvironmentMetadata(sdk=sdk_metadata) + + hooks = plugin.get_hooks(env_metadata) + self.assertEqual(len(hooks), 1) + self.assertIsInstance(hooks[0], Hook) + + # Test registration + mock_client = Mock() + plugin.register(mock_client, env_metadata) + self.assertEqual(plugin._client, mock_client) + self.assertEqual(plugin._environment_metadata, env_metadata) + + def test_config_with_plugins(self): + """Test that Config can be created with plugins.""" + plugin = ExamplePlugin() + config = Config(sdk_key="test-key", plugins=[plugin]) + + self.assertEqual(len(config.plugins), 1) + self.assertEqual(config.plugins[0], plugin) + + def test_config_without_plugins(self): + """Test that Config works without plugins.""" + config = Config(sdk_key="test-key") + self.assertEqual(len(config.plugins), 0) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 531bfd9715d66218fb75cb0cd202c019bb22563c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:20:01 -0700 Subject: [PATCH 02/17] Refactor metadata to be a property. --- ldclient/client.py | 4 ++-- ldclient/plugin.py | 3 ++- ldclient/testing/test_ldclient_plugin.py | 3 ++- ldclient/testing/test_plugin.py | 7 ++++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/ldclient/client.py b/ldclient/client.py index 5718047d..21cb4a48 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -307,7 +307,7 @@ def __get_plugin_hooks(self, environment_metadata: EnvironmentMetadata) -> List[ 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}") + log.error(f"Error getting hooks from plugin {plugin.metadata.name}: {e}") return hooks def __register_plugins(self, environment_metadata: EnvironmentMetadata): @@ -315,7 +315,7 @@ def __register_plugins(self, environment_metadata: EnvironmentMetadata): try: plugin.register(self, environment_metadata) except Exception as e: - log.error(f"Error registering plugin {plugin.get_metadata().name}: {e}") + log.error(f"Error registering plugin {plugin.metadata.name}: {e}") def _set_event_processor(self, config): if config.offline or not config.send_events: diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 2ca7e98f..0cb02155 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -61,8 +61,9 @@ class Plugin: __metaclass__ = ABCMeta + @property @abstractmethod - def get_metadata(self) -> PluginMetadata: + def metadata(self) -> PluginMetadata: """ Get metadata about the plugin implementation. diff --git a/ldclient/testing/test_ldclient_plugin.py b/ldclient/testing/test_ldclient_plugin.py index 87f4df6e..7e885b06 100644 --- a/ldclient/testing/test_ldclient_plugin.py +++ b/ldclient/testing/test_ldclient_plugin.py @@ -86,7 +86,8 @@ def __init__(self, self.hooks_called = False self.hooks_metadata = None - def get_metadata(self) -> PluginMetadata: + @property + def metadata(self) -> PluginMetadata: return PluginMetadata(name=self._name) def register(self, client: Any, metadata: EnvironmentMetadata) -> None: diff --git a/ldclient/testing/test_plugin.py b/ldclient/testing/test_plugin.py index f26231db..749542f8 100644 --- a/ldclient/testing/test_plugin.py +++ b/ldclient/testing/test_plugin.py @@ -46,7 +46,8 @@ def __init__(self, name: str = "Example Plugin"): self._client = None self._environment_metadata = None - def get_metadata(self) -> PluginMetadata: + @property + def metadata(self) -> PluginMetadata: """Get metadata about the plugin implementation.""" return PluginMetadata(name=self._name) @@ -64,7 +65,7 @@ def register(self, client: Any, metadata: EnvironmentMetadata) -> None: # Example: Log some information about the environment print(f"Example Plugin registered with SDK {metadata.sdk.name} version {metadata.sdk.version}") if metadata.application: - print(f"Application: {metadata.application.name} version {metadata.application.version}") + print(f"Application: {metadata.application.id} version {metadata.application.version}") def get_hooks(self, metadata: EnvironmentMetadata) -> List[Hook]: """ @@ -107,7 +108,7 @@ def test_example_plugin(self): plugin = ExamplePlugin("Test Example Plugin") # Test metadata - metadata = plugin.get_metadata() + metadata = plugin.metadata self.assertEqual(metadata.name, "Test Example Plugin") # Test hooks From 2a3a6bf88b2df7a9990ab131c683a4b6c62ed447 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:24:09 -0700 Subject: [PATCH 03/17] Extend testing to ensure correct messages are logged. --- ldclient/testing/test_ldclient_plugin.py | 44 ++++++++++++++++-------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/ldclient/testing/test_ldclient_plugin.py b/ldclient/testing/test_ldclient_plugin.py index 7e885b06..778f8731 100644 --- a/ldclient/testing/test_ldclient_plugin.py +++ b/ldclient/testing/test_ldclient_plugin.py @@ -194,14 +194,21 @@ def test_plugin_error_handling_get_hooks(self): ) # The hooks cannot be accessed, but the plugin will still get registered. - with LDClient(config=config) as client: - self.assertTrue(normal_plugin.registered) - self.assertTrue(error_plugin.registered) + with patch('ldclient.impl.util.log.error') as mock_log_error: + with LDClient(config=config) as client: + self.assertTrue(normal_plugin.registered) + self.assertTrue(error_plugin.registered) - client.variation("test-flag", Context.builder("user-key").build(), "default") + client.variation("test-flag", Context.builder("user-key").build(), "default") - self.assertTrue(normal_hook.before_called) - self.assertTrue(normal_hook.after_called) + self.assertTrue(normal_hook.before_called) + self.assertTrue(normal_hook.after_called) + + # Verify that the error was logged with the correct message + mock_log_error.assert_called_once() + error_call_args = mock_log_error.call_args[0] + self.assertIn("Error getting hooks from plugin Error Plugin", error_call_args[0]) + self.assertIn("Get hooks error in Error Plugin", str(error_call_args)) def test_plugin_error_handling_register(self): """Test that errors during plugin registration are handled gracefully.""" @@ -220,17 +227,24 @@ def test_plugin_error_handling_register(self): ) # Should not raise an exception - with LDClient(config=config) as client: - # Normal plugin should still be registered - self.assertTrue(normal_plugin.registered) - - # Error plugin should not be registered - self.assertFalse(error_plugin.registered) + with patch('ldclient.impl.util.log.error') as mock_log_error: + with LDClient(config=config) as client: + # Normal plugin should still be registered + self.assertTrue(normal_plugin.registered) + + # Error plugin should not be registered + self.assertFalse(error_plugin.registered) - client.variation("test-flag", Context.builder("user-key").build(), "default") + client.variation("test-flag", Context.builder("user-key").build(), "default") - self.assertTrue(normal_hook.before_called) - self.assertTrue(normal_hook.after_called) + self.assertTrue(normal_hook.before_called) + self.assertTrue(normal_hook.after_called) + + # Verify that the error was logged with the correct message + mock_log_error.assert_called_once() + error_call_args = mock_log_error.call_args[0] + self.assertIn("Error registering plugin Error Plugin", error_call_args[0]) + self.assertIn("Registration error in Error Plugin", str(error_call_args)) def test_plugin_with_existing_hooks(self): """Test that plugin hooks work alongside existing hooks and config hooks are called before plugin hooks.""" From 6ddfdd05c983a2a33707173a7875028de428cf68 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:34:33 -0700 Subject: [PATCH 04/17] Linting --- ldclient/client.py | 11 +- ldclient/plugin.py | 2 +- ldclient/testing/test_ldclient_plugin.py | 142 +++++++++++------------ ldclient/testing/test_plugin.py | 62 +++++----- 4 files changed, 108 insertions(+), 109 deletions(-) diff --git a/ldclient/client.py b/ldclient/client.py index 21cb4a48..67b3b64c 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -40,10 +40,11 @@ DataStoreStatusProvider, DataStoreUpdateSink, 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.plugin import (ApplicationMetadata, EnvironmentMetadata, + SdkMetadata) from ldclient.version import VERSION - +from ldclient.versioned_data_kind import FEATURES, SEGMENTS, VersionedDataKind + from .impl import AnyNum @@ -287,14 +288,14 @@ def __get_environment_metadata(self): 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, diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 0cb02155..467fc205 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -95,4 +95,4 @@ def get_hooks(self, metadata: EnvironmentMetadata) -> List[Hook]: :param metadata: Metadata about the environment in which the SDK is running :return: A list of hooks to be registered with the SDK """ - return [] \ No newline at end of file + return [] diff --git a/ldclient/testing/test_ldclient_plugin.py b/ldclient/testing/test_ldclient_plugin.py index 778f8731..c949c00c 100644 --- a/ldclient/testing/test_ldclient_plugin.py +++ b/ldclient/testing/test_ldclient_plugin.py @@ -1,29 +1,23 @@ -""" -Tests for LDClient plugin functionality. - -These tests port the equivalent functionality from the Node.js SDK plugin tests. -""" - -import unittest -from unittest.mock import Mock, patch, MagicMock import threading -from typing import Any, List, Optional, Callable +import unittest +from typing import Any, Callable, Dict, List, Optional +from unittest.mock import patch -from ldclient.config import Config from ldclient.client import LDClient -from ldclient.plugin import Plugin, PluginMetadata, EnvironmentMetadata, SdkMetadata, ApplicationMetadata -from ldclient.hook import Hook, Metadata, EvaluationSeriesContext, EvaluationDetail -from ldclient.integrations.test_data import TestData +from ldclient.config import Config from ldclient.context import Context +from ldclient.hook import (EvaluationDetail, EvaluationSeriesContext, Hook, + Metadata) +from ldclient.plugin import EnvironmentMetadata, Plugin, PluginMetadata class ThreadSafeCounter: """Thread-safe counter for tracking hook execution order.""" - + def __init__(self): self._value = 0 self._lock = threading.Lock() - + def get_and_increment(self) -> int: """Atomically get the current value and increment it.""" with self._lock: @@ -34,12 +28,13 @@ def get_and_increment(self) -> int: class ConfigurableTestHook(Hook): """Configurable test hook that can be customized with lambda functions for before/after evaluation.""" + def __init__(self, name: str = "Configurable Test Hook", before_evaluation_behavior=None, after_evaluation_behavior=None): self._name = name self.before_called = False self.after_called = False self.execution_order = -1 - self._state = {} + self._state: Dict[str, Any] = {} self._before_evaluation_behavior = before_evaluation_behavior self._after_evaluation_behavior = after_evaluation_behavior @@ -68,9 +63,9 @@ def get_state(self, key: str, default: Any = None) -> Any: class ConfigurableTestPlugin(Plugin): """Configurable test plugin that can be customized with lambda functions for different test scenarios.""" - - def __init__(self, - name: str = "Configurable Test Plugin", + + def __init__(self, + name: str = "Configurable Test Plugin", hooks: Optional[List[Hook]] = None, register_behavior: Optional[Callable[[Any, EnvironmentMetadata], None]] = None, get_hooks_behavior: Optional[Callable[[EnvironmentMetadata], List[Hook]]] = None): @@ -78,45 +73,45 @@ def __init__(self, self._hooks = hooks if hooks is not None else [] self._register_behavior = register_behavior self._get_hooks_behavior = get_hooks_behavior - + # State tracking self.registered = False - self.registration_metadata = None - self.registration_client = None + self.registration_metadata: Optional[EnvironmentMetadata] = None + self.registration_client: Optional[Any] = None self.hooks_called = False - self.hooks_metadata = None - + self.hooks_metadata: Optional[EnvironmentMetadata] = None + @property def metadata(self) -> PluginMetadata: return PluginMetadata(name=self._name) - + def register(self, client: Any, metadata: EnvironmentMetadata) -> None: self.registration_client = client self.registration_metadata = metadata - + if self._register_behavior: self._register_behavior(client, metadata) - + # Only mark as registered if no exception was thrown self.registered = True - + def get_hooks(self, metadata: EnvironmentMetadata) -> List[Hook]: self.hooks_called = True self.hooks_metadata = metadata - + if self._get_hooks_behavior: return self._get_hooks_behavior(metadata) - + return self._hooks class TestLDClientPlugin(unittest.TestCase): """Test cases for LDClient plugin functionality.""" - + def test_plugin_environment_metadata(self): """Test that plugins receive correct environment metadata.""" plugin = ConfigurableTestPlugin("Test Plugin") - + config = Config( sdk_key="test-sdk-key", send_events=False, @@ -126,73 +121,73 @@ def test_plugin_environment_metadata(self): application={"id": "test-app", "version": "1.0.0"}, plugins=[plugin] ) - + with LDClient(config=config) as client: self.assertTrue(plugin.registered) self.assertIsNotNone(plugin.registration_metadata) - + # Verify SDK metadata if plugin.registration_metadata: self.assertEqual(plugin.registration_metadata.sdk.name, "python-server-sdk") self.assertEqual(plugin.registration_metadata.sdk.wrapper_name, "TestWrapper") self.assertEqual(plugin.registration_metadata.sdk.wrapper_version, "1.0.0") self.assertRegex(plugin.registration_metadata.sdk.version, r"^\d+\.\d+\.\d+$") - + # Verify application metadata if plugin.registration_metadata.application: self.assertEqual(plugin.registration_metadata.application.id, "test-app") self.assertEqual(plugin.registration_metadata.application.version, "1.0.0") - + # Verify SDK key self.assertEqual(plugin.registration_metadata.sdk_key, "test-sdk-key") - + def test_registers_plugins_and_executes_hooks(self): """Test that plugins are registered and hooks are executed.""" hook1 = ConfigurableTestHook("Hook 1") hook2 = ConfigurableTestHook("Hook 2") - + plugin1 = ConfigurableTestPlugin("Plugin 1", hooks=[hook1]) plugin2 = ConfigurableTestPlugin("Plugin 2", hooks=[hook2]) - + config = Config( sdk_key="test-sdk-key", send_events=False, offline=True, plugins=[plugin1, plugin2] ) - + with LDClient(config=config) as client: # Verify hooks were collected self.assertTrue(plugin1.hooks_called) self.assertTrue(plugin2.hooks_called) self.assertTrue(plugin1.registered) self.assertTrue(plugin2.registered) - + # Test that hooks are called during evaluation client.variation("test-flag", Context.builder("user-key").build(), "default") - + # Verify hooks were called self.assertTrue(hook1.before_called) self.assertTrue(hook1.after_called) self.assertTrue(hook2.before_called) self.assertTrue(hook2.after_called) - + def test_plugin_error_handling_get_hooks(self): """Test that errors get_hooks are handled gracefully.""" error_plugin = ConfigurableTestPlugin( - "Error Plugin", + "Error Plugin", get_hooks_behavior=lambda metadata: (_ for _ in ()).throw(Exception("Get hooks error in Error Plugin")) ) normal_hook = ConfigurableTestHook("Normal Hook") normal_plugin = ConfigurableTestPlugin("Normal Plugin", hooks=[normal_hook]) - + config = Config( sdk_key="test-sdk-key", send_events=False, offline=True, plugins=[error_plugin, normal_plugin] ) - + # The hooks cannot be accessed, but the plugin will still get registered. with patch('ldclient.impl.util.log.error') as mock_log_error: with LDClient(config=config) as client: @@ -203,35 +198,35 @@ def test_plugin_error_handling_get_hooks(self): self.assertTrue(normal_hook.before_called) self.assertTrue(normal_hook.after_called) - + # Verify that the error was logged with the correct message mock_log_error.assert_called_once() error_call_args = mock_log_error.call_args[0] self.assertIn("Error getting hooks from plugin Error Plugin", error_call_args[0]) self.assertIn("Get hooks error in Error Plugin", str(error_call_args)) - + def test_plugin_error_handling_register(self): """Test that errors during plugin registration are handled gracefully.""" error_plugin = ConfigurableTestPlugin( - "Error Plugin", + "Error Plugin", register_behavior=lambda client, metadata: (_ for _ in ()).throw(Exception("Registration error in Error Plugin")) ) normal_hook = ConfigurableTestHook("Normal Hook") normal_plugin = ConfigurableTestPlugin("Normal Plugin", hooks=[normal_hook]) - + config = Config( sdk_key="test-sdk-key", send_events=False, offline=True, plugins=[error_plugin, normal_plugin] ) - + # Should not raise an exception with patch('ldclient.impl.util.log.error') as mock_log_error: with LDClient(config=config) as client: # Normal plugin should still be registered self.assertTrue(normal_plugin.registered) - + # Error plugin should not be registered self.assertFalse(error_plugin.registered) @@ -239,25 +234,26 @@ def test_plugin_error_handling_register(self): self.assertTrue(normal_hook.before_called) self.assertTrue(normal_hook.after_called) - + # Verify that the error was logged with the correct message mock_log_error.assert_called_once() error_call_args = mock_log_error.call_args[0] self.assertIn("Error registering plugin Error Plugin", error_call_args[0]) self.assertIn("Registration error in Error Plugin", str(error_call_args)) - + def test_plugin_with_existing_hooks(self): """Test that plugin hooks work alongside existing hooks and config hooks are called before plugin hooks.""" counter = ThreadSafeCounter() + def make_ordered_before(counter): return lambda hook, series_context, data: ( setattr(hook, 'execution_order', counter.get_and_increment()) or data ) existing_hook = ConfigurableTestHook("Existing Hook", before_evaluation_behavior=make_ordered_before(counter)) plugin_hook = ConfigurableTestHook("Plugin Hook", before_evaluation_behavior=make_ordered_before(counter)) - + plugin = ConfigurableTestPlugin("Test Plugin", hooks=[plugin_hook]) - + config = Config( sdk_key="test-sdk-key", send_events=False, @@ -265,21 +261,21 @@ def make_ordered_before(counter): hooks=[existing_hook], plugins=[plugin] ) - + with LDClient(config=config) as client: # Test that both hooks are called client.variation("test-flag", Context.builder("user-key").build(), "default") - + # Verify hooks were called self.assertTrue(existing_hook.before_called) self.assertTrue(existing_hook.after_called) self.assertTrue(plugin_hook.before_called) self.assertTrue(plugin_hook.after_called) - + # Verify that config hooks are called before plugin hooks - self.assertLess(existing_hook.execution_order, plugin_hook.execution_order, - "Config hooks should be called before plugin hooks") - + self.assertLess(existing_hook.execution_order, plugin_hook.execution_order, + "Config hooks should be called before plugin hooks") + def test_plugin_no_hooks(self): """Test that plugins without hooks work correctly.""" plugin = ConfigurableTestPlugin("No Hooks Plugin", hooks=[]) @@ -290,45 +286,45 @@ def test_plugin_no_hooks(self): offline=True, plugins=[plugin] ) - + with LDClient(config=config) as client: self.assertTrue(plugin.registered) self.assertTrue(plugin.hooks_called) - + # Should work normally without hooks result = client.variation("test-flag", Context.builder("user-key").build(), False) self.assertEqual(result, False) - + def test_plugin_client_access(self): """Test that plugins can access the client during registration and their hooks are called.""" hook = ConfigurableTestHook("Client Access Hook") - + def register_behavior(client, metadata): # Call variation during registration to test that hooks are available # This should trigger the plugin's hook result = client.variation("test-flag", Context.builder("user-key").build(), "default") # Store whether the hook was called during registration hook.set_state("called_during_registration", hook.before_called) - + plugin = ConfigurableTestPlugin( - "Client Access Plugin", + "Client Access Plugin", hooks=[hook], register_behavior=register_behavior ) - + config = Config( sdk_key="test-sdk-key", send_events=False, offline=True, plugins=[plugin] ) - + with LDClient(config=config) as client: self.assertTrue(plugin.registered) self.assertIs(plugin.registration_client, client) - + # Verify that the plugin's hook was called when it called variation during registration - self.assertTrue(hook.get_state("called_during_registration", False), - "Plugin's hook should be called when variation is called during registration") + self.assertTrue(hook.get_state("called_during_registration", False), + "Plugin's hook should be called when variation is called during registration") self.assertTrue(hook.before_called) self.assertTrue(hook.after_called) diff --git a/ldclient/testing/test_plugin.py b/ldclient/testing/test_plugin.py index 749542f8..3d50f342 100644 --- a/ldclient/testing/test_plugin.py +++ b/ldclient/testing/test_plugin.py @@ -3,27 +3,29 @@ """ import unittest -from unittest.mock import Mock, patch +from typing import Any, List, Optional +from unittest.mock import Mock from ldclient.config import Config -from ldclient.plugin import Plugin, PluginMetadata, EnvironmentMetadata, SdkMetadata, ApplicationMetadata -from ldclient.hook import Hook, Metadata, EvaluationSeriesContext, EvaluationDetail -from typing import Any, List +from ldclient.hook import (EvaluationDetail, EvaluationSeriesContext, Hook, + Metadata) +from ldclient.plugin import (ApplicationMetadata, EnvironmentMetadata, Plugin, + PluginMetadata, SdkMetadata) class ExampleHook(Hook): """Example hook implementation for the example plugin.""" - + @property def metadata(self) -> Metadata: return Metadata(name="Example Plugin Hook") - + def before_evaluation(self, series_context: EvaluationSeriesContext, data: dict) -> dict: """Called before flag evaluation.""" # Add some data to track in the evaluation series data['example_plugin_before'] = True return data - + def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, detail: EvaluationDetail) -> dict: """Called after flag evaluation.""" # Add some data to track in the evaluation series @@ -34,105 +36,105 @@ def after_evaluation(self, series_context: EvaluationSeriesContext, data: dict, class ExamplePlugin(Plugin): """ Example plugin implementation. - + This plugin demonstrates how to implement the plugin interface by: 1. Providing metadata about the plugin 2. Registering with the client 3. Providing hooks for SDK observation """ - + def __init__(self, name: str = "Example Plugin"): self._name = name self._client = None - self._environment_metadata = None - + self._environment_metadata: Optional[EnvironmentMetadata] = None + @property def metadata(self) -> PluginMetadata: """Get metadata about the plugin implementation.""" return PluginMetadata(name=self._name) - + 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. """ self._client = client self._environment_metadata = metadata - + # Example: Log some information about the environment print(f"Example Plugin registered with SDK {metadata.sdk.name} version {metadata.sdk.version}") if metadata.application: print(f"Application: {metadata.application.id} version {metadata.application.version}") - + 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. """ - return [ExampleHook()] + return [ExampleHook()] class TestPlugin(unittest.TestCase): """Test cases for the plugin interface.""" - + def test_plugin_metadata(self): """Test that plugin metadata is correctly structured.""" metadata = PluginMetadata(name="Test Plugin") self.assertEqual(metadata.name, "Test Plugin") - + def test_environment_metadata(self): """Test that environment metadata is correctly structured.""" sdk_metadata = SdkMetadata(name="test-sdk", version="1.0.0") app_metadata = ApplicationMetadata(id="test-app", version="1.0.0") - + env_metadata = EnvironmentMetadata( sdk=sdk_metadata, application=app_metadata, sdk_key="test-key" ) - + self.assertEqual(env_metadata.sdk.name, "test-sdk") self.assertEqual(env_metadata.sdk.version, "1.0.0") if env_metadata.application: self.assertEqual(env_metadata.application.id, "test-app") self.assertEqual(env_metadata.application.version, "1.0.0") self.assertEqual(env_metadata.sdk_key, "test-key") - + def test_example_plugin(self): """Test that the example plugin works correctly.""" plugin = ExamplePlugin("Test Example Plugin") - + # Test metadata metadata = plugin.metadata self.assertEqual(metadata.name, "Test Example Plugin") - + # Test hooks sdk_metadata = SdkMetadata(name="test-sdk", version="1.0.0") env_metadata = EnvironmentMetadata(sdk=sdk_metadata) - + hooks = plugin.get_hooks(env_metadata) self.assertEqual(len(hooks), 1) self.assertIsInstance(hooks[0], Hook) - + # Test registration mock_client = Mock() plugin.register(mock_client, env_metadata) self.assertEqual(plugin._client, mock_client) self.assertEqual(plugin._environment_metadata, env_metadata) - + def test_config_with_plugins(self): """Test that Config can be created with plugins.""" plugin = ExamplePlugin() config = Config(sdk_key="test-key", plugins=[plugin]) - + self.assertEqual(len(config.plugins), 1) self.assertEqual(config.plugins[0], plugin) - + def test_config_without_plugins(self): """Test that Config works without plugins.""" config = Config(sdk_key="test-key") @@ -140,4 +142,4 @@ def test_config_without_plugins(self): if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() From a4737b179653fa179411809d798edd2f0c4939fa Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:38:11 -0700 Subject: [PATCH 05/17] Revert changes to __init__ --- ldclient/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ldclient/__init__.py b/ldclient/__init__.py index d2267c4f..884c3af8 100644 --- a/ldclient/__init__.py +++ b/ldclient/__init__.py @@ -9,7 +9,6 @@ from .client import * from .context import * from .migrations import * -from .plugin import * __version__ = VERSION @@ -100,4 +99,4 @@ def _reset_client(): __BASE_TYPES__ = (str, float, int, bool) -__all__ = ['Config', 'Context', 'ContextBuilder', 'ContextMultiBuilder', 'LDClient', 'Result', 'client', 'context', 'evaluation', 'integrations', 'interfaces', 'migrations', 'Plugin', 'PluginMetadata', 'EnvironmentMetadata', 'SdkMetadata', 'ApplicationMetadata'] +__all__ = ['Config', 'Context', 'ContextBuilder', 'ContextMultiBuilder', 'LDClient', 'Result', 'client', 'context', 'evaluation', 'integrations', 'interfaces', 'migrations'] From af2e4a86754ad833e5930a1097093d5d941c205f Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:40:38 -0700 Subject: [PATCH 06/17] Add typing to get_environment_metadata. --- ldclient/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldclient/client.py b/ldclient/client.py index 67b3b64c..c47f01bd 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -281,7 +281,7 @@ 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): + def __get_environment_metadata(self) -> EnvironmentMetadata: sdk_metadata = SdkMetadata( name="python-server-sdk", version=VERSION, From 0aef37e90604591e67f18a18dbfcf58a2627c966 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:42:32 -0700 Subject: [PATCH 07/17] Remove unused credential types. --- ldclient/plugin.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 467fc205..a8ca9bcc 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -33,8 +33,6 @@ class EnvironmentMetadata: 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 From 81a82244283cd4296943bb0b05621340bf5a582d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:50:49 -0700 Subject: [PATCH 08/17] Make get_hooks abstract. --- ldclient/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index a8ca9bcc..32aeb742 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -83,6 +83,7 @@ def register(self, client: Any, metadata: EnvironmentMetadata) -> None: """ pass + @abstractmethod def get_hooks(self, metadata: EnvironmentMetadata) -> List[Hook]: """ Get a list of hooks that this plugin provides. From f15aa862295b7b5d4d5c88140b7a0156a57cb891 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 26 Jun 2025 08:25:47 -0700 Subject: [PATCH 09/17] Update ldclient/client.py Co-authored-by: Matthew M. Keeler --- ldclient/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldclient/client.py b/ldclient/client.py index c47f01bd..8ebf76f7 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -308,7 +308,7 @@ def __get_plugin_hooks(self, environment_metadata: EnvironmentMetadata) -> List[ try: hooks.extend(plugin.get_hooks(environment_metadata)) except Exception as e: - log.error(f"Error getting hooks from plugin {plugin.metadata.name}: {e}") + log.error("Error getting hooks from plugin %s: %s", plugin.metadata.name, e) return hooks def __register_plugins(self, environment_metadata: EnvironmentMetadata): From f4f8ae44852019997fba61383496df4e877f351d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 26 Jun 2025 08:34:43 -0700 Subject: [PATCH 10/17] Update log format. --- ldclient/client.py | 2 +- ldclient/testing/test_ldclient_plugin.py | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/ldclient/client.py b/ldclient/client.py index 8ebf76f7..9727aa87 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -316,7 +316,7 @@ def __register_plugins(self, environment_metadata: EnvironmentMetadata): try: plugin.register(self, environment_metadata) except Exception as e: - log.error(f"Error registering plugin {plugin.metadata.name}: {e}") + log.error("Error registering plugin %s: %s", plugin.metadata.name, e) def _set_event_processor(self, config): if config.offline or not config.send_events: diff --git a/ldclient/testing/test_ldclient_plugin.py b/ldclient/testing/test_ldclient_plugin.py index c949c00c..fb5a130c 100644 --- a/ldclient/testing/test_ldclient_plugin.py +++ b/ldclient/testing/test_ldclient_plugin.py @@ -201,9 +201,13 @@ def test_plugin_error_handling_get_hooks(self): # Verify that the error was logged with the correct message mock_log_error.assert_called_once() - error_call_args = mock_log_error.call_args[0] - self.assertIn("Error getting hooks from plugin Error Plugin", error_call_args[0]) - self.assertIn("Get hooks error in Error Plugin", str(error_call_args)) + # Check the format string and arguments separately + format_string = mock_log_error.call_args[0][0] + format_args = mock_log_error.call_args[0][1:] + self.assertEqual(format_string, "Error getting hooks from plugin %s: %s") + self.assertEqual(len(format_args), 2) + self.assertEqual(format_args[0], "Error Plugin") + self.assertIn("Get hooks error in Error Plugin", str(format_args[1])) def test_plugin_error_handling_register(self): """Test that errors during plugin registration are handled gracefully.""" @@ -237,9 +241,13 @@ def test_plugin_error_handling_register(self): # Verify that the error was logged with the correct message mock_log_error.assert_called_once() - error_call_args = mock_log_error.call_args[0] - self.assertIn("Error registering plugin Error Plugin", error_call_args[0]) - self.assertIn("Registration error in Error Plugin", str(error_call_args)) + # Check the format string and arguments separately + format_string = mock_log_error.call_args[0][0] + format_args = mock_log_error.call_args[0][1:] + self.assertEqual(format_string, "Error registering plugin %s: %s") + self.assertEqual(len(format_args), 2) + self.assertEqual(format_args[0], "Error Plugin") + self.assertIn("Registration error in Error Plugin", str(format_args[1])) def test_plugin_with_existing_hooks(self): """Test that plugin hooks work alongside existing hooks and config hooks are called before plugin hooks.""" From 01801f56642f07bb54995864ccac2c4e4934e36d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:57:58 -0700 Subject: [PATCH 11/17] kw_only --- ldclient/plugin.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 32aeb742..6d9e0827 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -5,7 +5,7 @@ from ldclient.hook import Hook -@dataclass +@dataclass(kw_only=True) class SdkMetadata: """ Metadata about the SDK. @@ -16,7 +16,7 @@ class SdkMetadata: wrapper_version: Optional[str] = None #: The wrapper version if this SDK is a wrapper -@dataclass +@dataclass(kw_only=True) class ApplicationMetadata: """ Metadata about the application using the SDK. @@ -25,17 +25,17 @@ class ApplicationMetadata: version: Optional[str] = None #: The version of the application -@dataclass +@dataclass(kw_only=True) 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 + sdk_key: str = None #: The SDK key used to initialize the SDK -@dataclass +@dataclass(kw_only=True) class PluginMetadata: """ Metadata about a plugin implementation. From 3cb9f99c4e1fc3178d4ce1eefb8e5efa09f83fa9 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:28:09 -0700 Subject: [PATCH 12/17] Update with correct LDClient type. --- ldclient/plugin.py | 12 ++++++++++-- ldclient/testing/test_plugin.py | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 977be1ff..edee2d15 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -1,9 +1,17 @@ +from __future__ import annotations from abc import ABCMeta, abstractmethod from dataclasses import dataclass -from typing import Any, List, Optional +from typing import List, Optional, TYPE_CHECKING +from ldclient.context import Context +from ldclient.evaluation import EvaluationDetail, FeatureFlagsState from ldclient.hook import Hook +from ldclient.impl import AnyNum +from ldclient.impl.evaluator import error_reason +from ldclient.interfaces import BigSegmentStoreStatusProvider, DataSourceStatusProvider, DataStoreStatusProvider, FlagTracker +if TYPE_CHECKING: + from ldclient.client import LDClient @dataclass(kw_only=True) class SdkMetadata: @@ -70,7 +78,7 @@ def metadata(self) -> PluginMetadata: return PluginMetadata(name='UNDEFINED') @abstractmethod - def register(self, client: Any, metadata: EnvironmentMetadata) -> None: + def register(self, client: LDClient, metadata: EnvironmentMetadata) -> None: """ Register the plugin with the SDK client. diff --git a/ldclient/testing/test_plugin.py b/ldclient/testing/test_plugin.py index 3d50f342..755bb9dd 100644 --- a/ldclient/testing/test_plugin.py +++ b/ldclient/testing/test_plugin.py @@ -115,7 +115,7 @@ def test_example_plugin(self): # Test hooks sdk_metadata = SdkMetadata(name="test-sdk", version="1.0.0") - env_metadata = EnvironmentMetadata(sdk=sdk_metadata) + env_metadata = EnvironmentMetadata(sdk=sdk_metadata, sdk_key="test-key") hooks = plugin.get_hooks(env_metadata) self.assertEqual(len(hooks), 1) From 1b131848bd6937a7c4b27dbeda9639a4816dcf75 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:30:07 -0700 Subject: [PATCH 13/17] Remove kw_only --- ldclient/plugin.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index edee2d15..2be64c81 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from ldclient.client import LDClient -@dataclass(kw_only=True) +@dataclass class SdkMetadata: """ Metadata about the SDK. @@ -24,7 +24,7 @@ class SdkMetadata: wrapper_version: Optional[str] = None #: The wrapper version if this SDK is a wrapper -@dataclass(kw_only=True) +@dataclass class ApplicationMetadata: """ Metadata about the application using the SDK. @@ -33,7 +33,7 @@ class ApplicationMetadata: version: Optional[str] = None #: The version of the application -@dataclass(kw_only=True) +@dataclass class EnvironmentMetadata: """ Metadata about the environment in which the SDK is running. @@ -43,7 +43,7 @@ class EnvironmentMetadata: sdk_key: str #: The SDK key used to initialize the SDK -@dataclass(kw_only=True) +@dataclass class PluginMetadata: """ Metadata about a plugin implementation. From aec6d5f9f92a6a8d2e91b36b3e24f12f81e4fbe4 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:33:05 -0700 Subject: [PATCH 14/17] Order of environment data. --- ldclient/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 2be64c81..3e4855fa 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -39,8 +39,8 @@ 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: str #: The SDK key used to initialize the SDK + application: Optional[ApplicationMetadata] = None #: Information about the application @dataclass From 1e4fadb24105e985486c626c540efd2f2199492b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:36:57 -0700 Subject: [PATCH 15/17] SDK Key optional. --- ldclient/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 3e4855fa..4e216d4b 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -39,7 +39,7 @@ class EnvironmentMetadata: Metadata about the environment in which the SDK is running. """ sdk: SdkMetadata #: Information about the SDK - sdk_key: str #: The SDK key used to initialize the SDK + sdk_key: Optional[str] = None #: The SDK key used to initialize the SDK application: Optional[ApplicationMetadata] = None #: Information about the application From ecc14ea0979712b015e159096026faed629efc2b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:47:29 -0700 Subject: [PATCH 16/17] Fix import sorting. --- ldclient/plugin.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 4e216d4b..635a3293 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -1,14 +1,17 @@ from __future__ import annotations + from abc import ABCMeta, abstractmethod from dataclasses import dataclass -from typing import List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional from ldclient.context import Context from ldclient.evaluation import EvaluationDetail, FeatureFlagsState from ldclient.hook import Hook from ldclient.impl import AnyNum from ldclient.impl.evaluator import error_reason -from ldclient.interfaces import BigSegmentStoreStatusProvider, DataSourceStatusProvider, DataStoreStatusProvider, FlagTracker +from ldclient.interfaces import (BigSegmentStoreStatusProvider, + DataSourceStatusProvider, + DataStoreStatusProvider, FlagTracker) if TYPE_CHECKING: from ldclient.client import LDClient From 32433bcea8b2850c97220a6210e1d77279658edc Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Wed, 9 Jul 2025 18:03:15 -0700 Subject: [PATCH 17/17] Format plugin.py --- ldclient/plugin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ldclient/plugin.py b/ldclient/plugin.py index 635a3293..728ca8a1 100644 --- a/ldclient/plugin.py +++ b/ldclient/plugin.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from ldclient.client import LDClient + @dataclass class SdkMetadata: """