From 1b9cc28d4311cf4b7276569fc9b494a1750a66a2 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 15 May 2026 12:12:16 +0100 Subject: [PATCH 1/5] add _resolve_component shared utility for plugin loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the common pattern used by exporter factory functions: check built-in registry → fall back to additional_properties → load via entry point → raise if nothing matched. The pending exporter PR (#5128) will be updated to use this utility, reducing the three near-identical factory functions to one-liners. Assisted-by: Claude Opus 4.6 --- .changelog/5215.added | 1 + .../sdk/_configuration/_common.py | 39 ++++++++++ .../tests/_configuration/test_common.py | 72 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 .changelog/5215.added diff --git a/.changelog/5215.added b/.changelog/5215.added new file mode 100644 index 00000000000..b59b4aaeece --- /dev/null +++ b/.changelog/5215.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add `_resolve_component` shared utility for declarative config plugin loading, reducing boilerplate in exporter factory functions diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index f70358b1510..5f2e18f12c3 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -72,6 +72,45 @@ def load_entry_point(group: str, name: str) -> type: ) from exc +def _resolve_component( + config, + registry: dict, + entry_point_group: str, + component_type: str, +): + """Resolve a config dataclass to a component instance. + + Checks built-in factories in ``registry`` first (by matching typed + field names on ``config``), then falls back to entry point loading + for plugin components found in ``config.additional_properties``. + + Args: + config: A dataclass with ``additional_properties`` (from the + ``@_additional_properties`` decorator). + registry: Mapping of built-in component names to factory + callables. Each factory receives the field value from config. + entry_point_group: The entry point group name for plugin loading. + component_type: Human-readable name for error messages + (e.g. "span exporter"). + + Returns: + The resolved component instance. + + Raises: + ConfigurationError: If no component type is specified in config. + """ + for name, factory in registry.items(): + value = getattr(config, name, None) + if value is not None: + return factory(value) + if config.additional_properties: + name, plugin_config = next(iter(config.additional_properties.items())) + return load_entry_point(entry_point_group, name)( + **(plugin_config or {}) + ) + raise ConfigurationError(f"No {component_type} type specified in config.") + + def _parse_headers( headers: list | None, headers_list: str | None, diff --git a/opentelemetry-sdk/tests/_configuration/test_common.py b/opentelemetry-sdk/tests/_configuration/test_common.py index 77db2ac628a..f66aaa5175e 100644 --- a/opentelemetry-sdk/tests/_configuration/test_common.py +++ b/opentelemetry-sdk/tests/_configuration/test_common.py @@ -11,6 +11,7 @@ from opentelemetry.sdk._configuration._common import ( _additional_properties, _parse_headers, + _resolve_component, load_entry_point, ) from opentelemetry.sdk._configuration._exceptions import ConfigurationError @@ -224,3 +225,74 @@ def test_log_record_exporter(self): def test_push_metric_exporter(self): self._assert_supports_additional_properties(PushMetricExporter) + + +class TestResolveComponent(unittest.TestCase): + def setUp(self): + @_additional_properties + @dataclass + class _Config: + builtin_a: dict | None = None + builtin_b: str | None = None + additional_properties: ClassVar[dict[str, Any]] + + self.cls = _Config + self.registry = { + "builtin_a": lambda v: ("resolved_a", v), + "builtin_b": lambda v: ("resolved_b", v), + } + + def test_resolves_builtin_from_registry(self): + config = self.cls(builtin_a={"key": "val"}) + result = _resolve_component( + config, self.registry, "test_group", "test component" + ) + self.assertEqual(result, ("resolved_a", {"key": "val"})) + + def test_resolves_plugin_via_entry_point(self): + mock_instance = MagicMock() + mock_class = MagicMock(return_value=mock_instance) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[MagicMock(**{"load.return_value": mock_class})], + ): + # pylint: disable=unexpected-keyword-arg + config = self.cls(my_plugin={"opt": "val"}) + result = _resolve_component( + config, self.registry, "test_group", "test component" + ) + self.assertIs(result, mock_instance) + mock_class.assert_called_once_with(opt="val") + + def test_plugin_with_empty_config(self): + mock_instance = MagicMock() + mock_class = MagicMock(return_value=mock_instance) + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[MagicMock(**{"load.return_value": mock_class})], + ): + # pylint: disable=unexpected-keyword-arg + config = self.cls(my_plugin={}) + _resolve_component( + config, self.registry, "test_group", "test component" + ) + mock_class.assert_called_once_with() + + def test_no_component_raises_configuration_error(self): + config = self.cls() + with self.assertRaises(ConfigurationError): + _resolve_component( + config, self.registry, "test_group", "test component" + ) + + def test_plugin_not_found_raises_configuration_error(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + # pylint: disable=unexpected-keyword-arg + config = self.cls(missing_plugin={}) + with self.assertRaises(ConfigurationError): + _resolve_component( + config, self.registry, "test_group", "test component" + ) From d923b4b9ed26d49939a44dc212f826c2c241123d Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 1 Jun 2026 16:26:32 +0100 Subject: [PATCH 2/5] address review: more specific typing on _resolve_component - Add _ComponentConfig Protocol declaring additional_properties contract - Type registry as dict[str, Callable[[Any], Any]] - Add Any return type annotation Assisted-by: Claude Opus 4.6 --- .../opentelemetry/sdk/_configuration/_common.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 5f2e18f12c3..aca4bd4ff1c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -6,6 +6,8 @@ import dataclasses import inspect import logging +from collections.abc import Callable +from typing import Any, Protocol from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.util._importlib_metadata import entry_points @@ -72,12 +74,18 @@ def load_entry_point(group: str, name: str) -> type: ) from exc +class _ComponentConfig(Protocol): + """Protocol for config dataclasses decorated with @_additional_properties.""" + + additional_properties: dict[str, Any] + + def _resolve_component( - config, - registry: dict, + config: _ComponentConfig, + registry: dict[str, Callable[[Any], Any]], entry_point_group: str, component_type: str, -): +) -> Any: """Resolve a config dataclass to a component instance. Checks built-in factories in ``registry`` first (by matching typed From 8d1f3ffbca80161dfdcd26351ce605b455424fda Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 2 Jun 2026 14:45:06 +0100 Subject: [PATCH 3/5] address review: document single-component semantic, test first-match-wins - Add docstring note explaining the JSON schema enforces exactly one component per config block (minProperties: 1, maxProperties: 1), and that "first match wins" is the defensive fallback if validation is bypassed - Add test documenting that when multiple built-in fields are set, the first registry match wins Assisted-by: Claude Opus 4.6 --- .../src/opentelemetry/sdk/_configuration/_common.py | 5 +++++ opentelemetry-sdk/tests/_configuration/test_common.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index f7ef577765c..eca9fcad821 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -92,6 +92,11 @@ def _resolve_component( field names on ``config``), then falls back to entry point loading for plugin components found in ``config.additional_properties``. + The JSON schema enforces exactly one component per config block + (``minProperties: 1, maxProperties: 1``). If multiple typed fields or + ``additional_properties`` entries are set (e.g. when schema validation + is bypassed), the first registry match wins. + Args: config: A dataclass with ``additional_properties`` (from the ``@_additional_properties`` decorator). diff --git a/opentelemetry-sdk/tests/_configuration/test_common.py b/opentelemetry-sdk/tests/_configuration/test_common.py index 4f1e6707fdd..46469f15d2b 100644 --- a/opentelemetry-sdk/tests/_configuration/test_common.py +++ b/opentelemetry-sdk/tests/_configuration/test_common.py @@ -361,3 +361,13 @@ def test_plugin_not_found_raises_configuration_error(self): _resolve_component( config, self.registry, "test_group", "test component" ) + + def test_first_registry_match_wins_when_multiple_set(self): + """When multiple built-in fields are set (which the schema should + prevent), the first registry match wins.""" + config = self.cls(builtin_a={"a": 1}, builtin_b="b") + result = _resolve_component( + config, self.registry, "test_group", "test component" + ) + # builtin_a comes first in the registry dict + self.assertEqual(result, ("resolved_a", {"a": 1})) From 76e940d4d15ffe0620be68b043b3ac50bd14a11b Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 2 Jun 2026 14:55:45 +0100 Subject: [PATCH 4/5] make _ComponentConfig.additional_properties typing more specific Values in additional_properties are either nested config dicts (for **kwargs splatting) or None. Document this with a more concrete type annotation. Note: the Protocol's instance-attribute declaration still diverges from the generated model's ClassVar; tracked separately. Assisted-by: Claude Opus 4.6 --- .../src/opentelemetry/sdk/_configuration/_common.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index eca9fcad821..3026777da80 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -75,9 +75,14 @@ def load_entry_point(group: str, name: str) -> type: class _ComponentConfig(Protocol): - """Protocol for config dataclasses decorated with @_additional_properties.""" + """Protocol for config dataclasses decorated with @_additional_properties. - additional_properties: dict[str, Any] + Values in ``additional_properties`` are nested config dicts (suitable + for ``**kwargs`` splatting to the user-defined component class) or + ``None`` (when the YAML uses ``my_plugin:`` or ``my_plugin: null``). + """ + + additional_properties: dict[str, dict[str, Any] | None] def _resolve_component( From c9468538def4cb563e08a97eccfae621daf2fb06 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 2 Jun 2026 14:56:26 +0100 Subject: [PATCH 5/5] reference follow-up issue #5268 for ClassVar/instance-attribute mismatch Adds a docstring note pointing to #5268 which tracks reviewing the generated additional_properties typing for stricter type checks. Assisted-by: Claude Opus 4.6 --- .../src/opentelemetry/sdk/_configuration/_common.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 3026777da80..40caff98680 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -80,6 +80,11 @@ class _ComponentConfig(Protocol): Values in ``additional_properties`` are nested config dicts (suitable for ``**kwargs`` splatting to the user-defined component class) or ``None`` (when the YAML uses ``my_plugin:`` or ``my_plugin: null``). + + Note: the generated models declare ``additional_properties`` as a + ``ClassVar`` even though the decorator assigns it as an instance + attribute at runtime. This is tolerated by pyright in ``standard`` + mode but flagged in ``strict`` mode. See #5268. """ additional_properties: dict[str, dict[str, Any] | None]