diff --git a/ldclient/client.py b/ldclient/client.py index 8b96dffa..9727aa87 100644 --- a/ldclient/client.py +++ b/ldclient/client.py @@ -40,6 +40,9 @@ DataStoreStatusProvider, DataStoreUpdateSink, FeatureStore, FlagTracker) from ldclient.migrations import OpTracker, Stage +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 @@ -223,8 +226,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 +262,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 +281,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) -> EnvironmentMetadata: + 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("Error getting hooks from plugin %s: %s", plugin.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("Error registering plugin %s: %s", plugin.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..728ca8a1 --- /dev/null +++ b/ldclient/plugin.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass +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) + +if TYPE_CHECKING: + from ldclient.client import LDClient + + +@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 + sdk_key: Optional[str] = None #: The SDK key used to initialize the SDK + application: Optional[ApplicationMetadata] = None #: Information about the application + + +@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 + + @property + @abstractmethod + def 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: LDClient, 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 + + @abstractmethod + 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 [] diff --git a/ldclient/testing/test_ldclient_plugin.py b/ldclient/testing/test_ldclient_plugin.py new file mode 100644 index 00000000..fb5a130c --- /dev/null +++ b/ldclient/testing/test_ldclient_plugin.py @@ -0,0 +1,338 @@ +import threading +import unittest +from typing import Any, Callable, Dict, List, Optional +from unittest.mock import patch + +from ldclient.client import LDClient +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: + 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: Dict[str, Any] = {} + 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: Optional[EnvironmentMetadata] = None + self.registration_client: Optional[Any] = None + self.hooks_called = False + 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, + 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 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") + + 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() + # 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.""" + 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 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") + + 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() + # 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.""" + 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..755bb9dd --- /dev/null +++ b/ldclient/testing/test_plugin.py @@ -0,0 +1,145 @@ +""" +Tests for the plugin interface. +""" + +import unittest +from typing import Any, List, Optional +from unittest.mock import Mock + +from ldclient.config import Config +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 + 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: 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()] + + +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, sdk_key="test-key") + + 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()