From 02076650064d8a20f792c80cda3249a7aa7e336a Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 25 Jun 2025 16:39:16 -0400 Subject: [PATCH 1/5] feat!: Drop support for Python 3.8 (eol 2024-10-07) (#339) --- .github/workflows/ci.yml | 4 ++-- README.md | 2 +- pyproject.toml | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f7a874e..a0c3f668 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] services: redis: @@ -77,7 +77,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index c70e6889..fb0adf89 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## Supported Python versions -This version of the LaunchDarkly SDK is compatible with Python 3.8 through 3.12. It is tested with the most recent patch releases of those versions. Python versions 2.7 to 3.6 are no longer supported. +This version of the LaunchDarkly SDK is compatible with Python 3.9+. ## Getting started diff --git a/pyproject.toml b/pyproject.toml index bedb2ad8..e0d0a487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,11 +13,11 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development", "Topic :: Software Development :: Libraries", ] @@ -27,7 +27,7 @@ exclude = [ ] [tool.poetry.dependencies] -python = ">=3.8" +python = ">=3.9" certifi = ">=2018.4.16" expiringdict = ">=1.1.4" pyRFC3339 = ">=1.0" @@ -86,7 +86,7 @@ urllib3 = ">=1.26.0" jinja2 = "3.1.3" [tool.mypy] -python_version = "3.8" +python_version = "3.9" ignore_missing_imports = true install_types = true non_interactive = true From a8eeb1ecc30a61228ed1d2fbec718348a4058580 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Wed, 25 Jun 2025 16:44:49 -0400 Subject: [PATCH 2/5] chore: Adjust release version (#341) --- release-please-config.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index 90707565..b6ea1ce5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,8 +4,12 @@ "release-type": "python", "versioning": "default", "include-v-in-tag": false, - "extra-files": ["ldclient/version.py", "PROVENANCE.md"], - "include-component-in-tag": false + "extra-files": [ + "ldclient/version.py", + "PROVENANCE.md" + ], + "include-component-in-tag": false, + "release-as": "9.12.0" } } } From a4955620ce0ed1d32f36ab3598b20dcffc5f195a Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Thu, 26 Jun 2025 11:51:06 -0400 Subject: [PATCH 3/5] chore: Add missing make target; update poetry instructions (#338) --- .github/workflows/ci.yml | 4 ++-- CONTRIBUTING.md | 4 ++-- Makefile | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0c3f668..7986892a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: - uses: ./.github/actions/build-docs - name: Run tests - run: make test + run: make test-all - name: Verify typehints run: make lint @@ -124,4 +124,4 @@ jobs: run: poetry install --all-extras - name: Run tests - run: make test + run: make test-all diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9aebeeec..a265a648 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,11 +16,11 @@ We encourage pull requests and other contributions from the community. Before su This project is built using [poetry](https://python-poetry.org/). To learn more about the basics of working with this tool, read [Poetry's basic usage guide](https://python-poetry.org/docs/basic-usage/). -To begin development, active the poetry shell and ensure your dependencies are installed. +To begin development, ensure your dependencies are installed and (optionally) activate the virtualenv. ``` -poetry shell poetry install +eval $(poetry env activate) ``` This library defines several extra dependencies to optionally enhance the SDK's capabilities. Use the following commands to install one or more of the available extras. diff --git a/Makefile b/Makefile index edf84fd9..9ee4463d 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,11 @@ install: .PHONY: test test: #! Run unit tests test: install + @LD_SKIP_DATABASE_TESTS=1 poetry run pytest $(PYTEST_FLAGS) + +.PHONY: test-all +test-all: #! Run unit tests (including database integrations) +test-all: install @poetry run pytest $(PYTEST_FLAGS) .PHONY: lint From 241f6f49b203044f801fdfc976f7d446225ec5e1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 11 Jul 2025 14:11:03 -0700 Subject: [PATCH 4/5] feat: Add support for plugins. (#337) Co-authored-by: Matthew M. Keeler --- ldclient/client.py | 47 +++- ldclient/config.py | 14 + ldclient/plugin.py | 109 ++++++++ ldclient/testing/test_ldclient_plugin.py | 338 +++++++++++++++++++++++ ldclient/testing/test_plugin.py | 145 ++++++++++ 5 files changed, 652 insertions(+), 1 deletion(-) 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/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() From 60ce4d1cc0ceb2fb42ccff6c43117796daf9b92f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 14 Jul 2025 09:03:33 -0700 Subject: [PATCH 5/5] chore(main): release 9.12.0 (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :robot: I have created a release *beep* *boop* --- ## [9.12.0](https://github.com/launchdarkly/python-server-sdk/compare/9.11.1...9.12.0) (2025-07-11) ### ⚠ BREAKING CHANGES * Drop support for Python 3.8 (eol 2024-10-07) ([#339](https://github.com/launchdarkly/python-server-sdk/issues/339)) ### Features * Add support for plugins. ([#337](https://github.com/launchdarkly/python-server-sdk/issues/337)) ([241f6f4](https://github.com/launchdarkly/python-server-sdk/commit/241f6f49b203044f801fdfc976f7d446225ec5e1)) * Drop support for Python 3.8 (eol 2024-10-07) ([#339](https://github.com/launchdarkly/python-server-sdk/issues/339)) ([0207665](https://github.com/launchdarkly/python-server-sdk/commit/02076650064d8a20f792c80cda3249a7aa7e336a)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 12 ++++++++++++ PROVENANCE.md | 2 +- ldclient/version.py | 2 +- pyproject.toml | 2 +- 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2cd73209..048d674e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "9.11.1" + ".": "9.12.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c795243..99d064e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to the LaunchDarkly Python SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [9.12.0](https://github.com/launchdarkly/python-server-sdk/compare/9.11.1...9.12.0) (2025-07-11) + + +### ⚠ BREAKING CHANGES + +* Drop support for Python 3.8 (eol 2024-10-07) ([#339](https://github.com/launchdarkly/python-server-sdk/issues/339)) + +### Features + +* Add support for plugins. ([#337](https://github.com/launchdarkly/python-server-sdk/issues/337)) ([241f6f4](https://github.com/launchdarkly/python-server-sdk/commit/241f6f49b203044f801fdfc976f7d446225ec5e1)) +* Drop support for Python 3.8 (eol 2024-10-07) ([#339](https://github.com/launchdarkly/python-server-sdk/issues/339)) ([0207665](https://github.com/launchdarkly/python-server-sdk/commit/02076650064d8a20f792c80cda3249a7aa7e336a)) + ## [9.11.1](https://github.com/launchdarkly/python-server-sdk/compare/9.11.0...9.11.1) (2025-05-29) diff --git a/PROVENANCE.md b/PROVENANCE.md index 6aa0ad5c..4b23688b 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -9,7 +9,7 @@ To verify SLSA provenance attestations, we recommend using [slsa-verifier](https ``` # Set the version of the SDK to verify -SDK_VERSION=9.11.1 +SDK_VERSION=9.12.0 ``` diff --git a/ldclient/version.py b/ldclient/version.py index ed32cbac..5440358f 100644 --- a/ldclient/version.py +++ b/ldclient/version.py @@ -1 +1 @@ -VERSION = "9.11.1" # x-release-please-version +VERSION = "9.12.0" # x-release-please-version diff --git a/pyproject.toml b/pyproject.toml index e0d0a487..7bb52804 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "launchdarkly-server-sdk" -version = "9.11.1" +version = "9.12.0" description = "LaunchDarkly SDK for Python" authors = ["LaunchDarkly "] license = "Apache-2.0"