From e1956926b744f592db1de8ed34a2dadd3c5c67d2 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 14 Apr 2026 12:41:18 +0100 Subject: [PATCH 1/5] add shared load_entry_point util for declarative config plugin loading Extracts a generic `load_entry_point(group, name)` helper into `_common` so that resource detector, exporter, propagator, and sampler plugin loading in declarative file config can all use the same entry point lookup pattern rather than duplicating it. Refactors `_propagator.py` to use the new util, removing its inline entry point lookup. Assisted-by: Claude Sonnet 4.6 --- CHANGELOG.md | 2 + .../sdk/_configuration/_common.py | 30 +++++++++++++- .../sdk/_configuration/_propagator.py | 23 ++--------- .../tests/_configuration/test_common.py | 41 ++++++++++++++++++- .../tests/_configuration/test_propagator.py | 18 ++++---- 5 files changed, 83 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4537ad3f8b..d3bf031ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it + ([#TODO](https://github.com/open-telemetry/opentelemetry-python/pull/TODO)) - `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars ([#4990](https://github.com/open-telemetry/opentelemetry-python/pull/4990)) - `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service` diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 152be1ea01..0498a19e13 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -15,11 +15,39 @@ from __future__ import annotations import logging -from typing import Optional +from typing import Optional, Type + +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.util._importlib_metadata import entry_points _logger = logging.getLogger(__name__) +def load_entry_point(group: str, name: str) -> Type: + """Load a plugin class from an entry point group by name. + + Returns the loaded class — callers are responsible for instantiation + with whatever arguments their config requires. + + Raises: + ConfigurationError: If the entry point is not found or fails to load. + """ + try: + ep = next(iter(entry_points(group=group, name=name)), None) + if ep is None: + raise ConfigurationError( + f"Plugin '{name}' not found in group '{group}'. " + "Make sure the package providing this plugin is installed." + ) + return ep.load() + except ConfigurationError: + raise + except Exception as exc: + raise ConfigurationError( + f"Failed to load plugin '{name}' from group '{group}': {exc}" + ) from exc + + def _parse_headers( headers: Optional[list], headers_list: Optional[str], diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py index 3c6372bb73..315a4e8bed 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -20,7 +20,7 @@ from opentelemetry.propagate import set_global_textmap from opentelemetry.propagators.composite import CompositePropagator from opentelemetry.propagators.textmap import TextMapPropagator -from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration._common import load_entry_point from opentelemetry.sdk._configuration.models import ( Propagator as PropagatorConfig, ) @@ -30,28 +30,11 @@ from opentelemetry.trace.propagation.tracecontext import ( TraceContextTextMapPropagator, ) -from opentelemetry.util._importlib_metadata import entry_points def _load_entry_point_propagator(name: str) -> TextMapPropagator: - """Load a propagator by name from the opentelemetry_propagator entry point group.""" - try: - ep = next( - iter(entry_points(group="opentelemetry_propagator", name=name)), - None, - ) - if not ep: - raise ConfigurationError( - f"Propagator '{name}' not found. " - "It may not be installed or may be misspelled." - ) - return ep.load()() - except ConfigurationError: - raise - except Exception as exc: - raise ConfigurationError( - f"Failed to load propagator '{name}': {exc}" - ) from exc + """Load and instantiate a propagator by name.""" + return load_entry_point("opentelemetry_propagator", name)() def _propagators_from_textmap_config( diff --git a/opentelemetry-sdk/tests/_configuration/test_common.py b/opentelemetry-sdk/tests/_configuration/test_common.py index 5c3fcf112b..0e54c02eef 100644 --- a/opentelemetry-sdk/tests/_configuration/test_common.py +++ b/opentelemetry-sdk/tests/_configuration/test_common.py @@ -14,8 +14,13 @@ import unittest from types import SimpleNamespace +from unittest.mock import MagicMock, patch -from opentelemetry.sdk._configuration._common import _parse_headers +from opentelemetry.sdk._configuration._common import ( + _parse_headers, + load_entry_point, +) +from opentelemetry.sdk._configuration._exceptions import ConfigurationError class TestParseHeaders(unittest.TestCase): @@ -79,3 +84,37 @@ def test_struct_headers_override_headers_list(self): def test_both_empty_struct_and_none_list_returns_empty_dict(self): self.assertEqual(_parse_headers([], None), {}) + + +class TestLoadEntryPoint(unittest.TestCase): + def test_returns_loaded_class(self): + mock_class = MagicMock() + mock_ep = MagicMock() + mock_ep.load.return_value = mock_class + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[mock_ep], + ): + result = load_entry_point("some_group", "some_name") + self.assertIs(result, mock_class) + + def test_raises_when_not_found(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + with self.assertRaises(ConfigurationError) as ctx: + load_entry_point("some_group", "missing") + self.assertIn("missing", str(ctx.exception)) + self.assertIn("some_group", str(ctx.exception)) + + def test_wraps_load_exception_in_configuration_error(self): + mock_ep = MagicMock() + mock_ep.load.side_effect = ImportError("bad import") + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[mock_ep], + ): + with self.assertRaises(ConfigurationError) as ctx: + load_entry_point("some_group", "some_name") + self.assertIn("bad import", str(ctx.exception)) diff --git a/opentelemetry-sdk/tests/_configuration/test_propagator.py b/opentelemetry-sdk/tests/_configuration/test_propagator.py index a8ce467e29..d4aab75e74 100644 --- a/opentelemetry-sdk/tests/_configuration/test_propagator.py +++ b/opentelemetry-sdk/tests/_configuration/test_propagator.py @@ -89,7 +89,7 @@ def test_b3_via_entry_point(self): mock_ep.load.return_value = lambda: mock_propagator with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig( @@ -106,7 +106,7 @@ def test_b3multi_via_entry_point(self): mock_ep.load.return_value = lambda: mock_propagator with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig( @@ -118,7 +118,7 @@ def test_b3multi_via_entry_point(self): def test_b3_not_installed_raises_configuration_error(self): with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): config = PropagatorConfig( @@ -135,7 +135,7 @@ def test_composite_list_tracecontext(self): mock_ep.load.return_value = lambda: mock_tc with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): result = create_propagator(config) @@ -158,7 +158,7 @@ def fake_entry_points(group, name): return [] with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", side_effect=fake_entry_points, ): config = PropagatorConfig(composite_list="tracecontext,baggage") @@ -182,7 +182,7 @@ def test_composite_list_whitespace_around_names(self): mock_ep.load.return_value = lambda: mock_tc with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig(composite_list=" tracecontext ") @@ -195,7 +195,7 @@ def test_entry_point_load_exception_raises_configuration_error(self): mock_ep.load.side_effect = RuntimeError("package broken") with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig(composite_list="broken-prop") @@ -210,7 +210,7 @@ def test_deduplication_across_composite_and_composite_list(self): mock_ep.load.return_value = lambda: mock_tc with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): config = PropagatorConfig( @@ -229,7 +229,7 @@ def test_deduplication_across_composite_and_composite_list(self): def test_unknown_composite_list_propagator_raises(self): with patch( - "opentelemetry.sdk._configuration._propagator.entry_points", + "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): config = PropagatorConfig(composite_list="nonexistent") From a3ad87d837250fe46782df1a8d4391ae5632215a Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 14 Apr 2026 12:44:25 +0100 Subject: [PATCH 2/5] update CHANGELOG with PR number #5093 Assisted-by: Claude Sonnet 4.6 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3bf031ef3..1d532ee6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased - `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it - ([#TODO](https://github.com/open-telemetry/opentelemetry-python/pull/TODO)) + ([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093)) - `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars ([#4990](https://github.com/open-telemetry/opentelemetry-python/pull/4990)) - `opentelemetry-sdk`: Add `service` resource detector support to declarative file configuration via `detection_development.detectors[].service` From 78e880f0e3d917638895bce457064c5f6ca1a577 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 14 Apr 2026 16:28:08 +0100 Subject: [PATCH 3/5] add propagator plugin loading to declarative config via entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextMapPropagator is changed from @dataclass to TypeAlias = dict[str, Any] in models.py, preserving unknown propagator names (plugin names) as dict keys through the config pipeline — same approach as Sampler. _propagators_from_textmap_config() now iterates the dict's key-value pairs directly. Known names (tracecontext, baggage) are bootstrapped from _PROPAGATOR_REGISTRY. All other names — including b3, b3multi, and custom plugin propagators — fall back to load_entry_point("opentelemetry_propagator", name), matching the spec's PluginComponentProvider mechanism. Assisted-by: Claude Sonnet 4.6 --- CHANGELOG.md | 2 + .../sdk/_configuration/_propagator.py | 37 +++++------ .../sdk/_configuration/models.py | 10 ++- .../tests/_configuration/test_propagator.py | 61 +++++++++++-------- 4 files changed, 59 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d532ee6a8..57a512f6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: add propagator plugin loading to declarative file configuration via the `opentelemetry_propagator` entry point group, matching the spec's PluginComponentProvider mechanism + ([#5070](https://github.com/open-telemetry/opentelemetry-python/pull/5070)) - `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it ([#5093](https://github.com/open-telemetry/opentelemetry-python/pull/5093)) - `opentelemetry-sdk`: Add `create_logger_provider`/`configure_logger_provider` to declarative file configuration, enabling LoggerProvider instantiation from config files without reading env vars diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py index 315a4e8bed..2b509a1f99 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -24,32 +24,33 @@ from opentelemetry.sdk._configuration.models import ( Propagator as PropagatorConfig, ) -from opentelemetry.sdk._configuration.models import ( - TextMapPropagator as TextMapPropagatorConfig, -) from opentelemetry.trace.propagation.tracecontext import ( TraceContextTextMapPropagator, ) - -def _load_entry_point_propagator(name: str) -> TextMapPropagator: - """Load and instantiate a propagator by name.""" - return load_entry_point("opentelemetry_propagator", name)() +# Propagators bundled with the SDK — no entry point lookup needed. +_PROPAGATOR_REGISTRY: dict = { + "tracecontext": lambda _: TraceContextTextMapPropagator(), + "baggage": lambda _: W3CBaggagePropagator(), +} def _propagators_from_textmap_config( - config: TextMapPropagatorConfig, + config: dict, ) -> list[TextMapPropagator]: - """Resolve a single TextMapPropagator config entry to a list of propagators.""" + """Resolve a TextMapPropagator config dict to a list of propagators. + + Each key in the dict names a propagator. Known names (tracecontext, baggage) + are bootstrapped directly. All other names — including b3, b3multi, and + custom plugin propagators — are loaded via the ``opentelemetry_propagator`` + entry point group, matching the spec's PluginComponentProvider mechanism. + """ result: list[TextMapPropagator] = [] - if config.tracecontext is not None: - result.append(TraceContextTextMapPropagator()) - if config.baggage is not None: - result.append(W3CBaggagePropagator()) - if config.b3 is not None: - result.append(_load_entry_point_propagator("b3")) - if config.b3multi is not None: - result.append(_load_entry_point_propagator("b3multi")) + for name, prop_config in config.items(): + if name in _PROPAGATOR_REGISTRY: + result.append(_PROPAGATOR_REGISTRY[name](prop_config)) + else: + result.append(load_entry_point("opentelemetry_propagator", name)()) return result @@ -85,7 +86,7 @@ def create_propagator( name = name.strip() if not name or name.lower() == "none": continue - propagator = _load_entry_point_propagator(name) + propagator = load_entry_point("opentelemetry_propagator", name)() propagators.setdefault(type(propagator), propagator) return CompositePropagator(list(propagators.values())) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index 5159137228..59770a8e78 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -573,12 +573,10 @@ class SpanProcessor: simple: SimpleSpanProcessor | None = None -@dataclass -class TextMapPropagator: - tracecontext: TraceContextPropagator | None = None - baggage: BaggagePropagator | None = None - b3: B3Propagator | None = None - b3multi: B3MultiPropagator | None = None +# Diverges from codegen: TextMapPropagator is typed as dict[str, Any] rather +# than a dataclass so that unknown propagator names (plugin/custom propagators) +# are preserved as dict keys through the config pipeline. +TextMapPropagator: TypeAlias = dict[str, Any] @dataclass diff --git a/opentelemetry-sdk/tests/_configuration/test_propagator.py b/opentelemetry-sdk/tests/_configuration/test_propagator.py index d4aab75e74..8f50486668 100644 --- a/opentelemetry-sdk/tests/_configuration/test_propagator.py +++ b/opentelemetry-sdk/tests/_configuration/test_propagator.py @@ -30,9 +30,6 @@ from opentelemetry.sdk._configuration.models import ( Propagator as PropagatorConfig, ) -from opentelemetry.sdk._configuration.models import ( - TextMapPropagator as TextMapPropagatorConfig, -) from opentelemetry.trace.propagation.tracecontext import ( TraceContextTextMapPropagator, ) @@ -50,9 +47,7 @@ def test_empty_config_returns_empty_composite(self): self.assertEqual(result._propagators, []) # type: ignore[attr-defined] def test_tracecontext_only(self): - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(tracecontext={})] - ) + config = PropagatorConfig(composite=[{"tracecontext": {}}]) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] self.assertIsInstance( @@ -61,9 +56,7 @@ def test_tracecontext_only(self): ) def test_baggage_only(self): - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(baggage={})] - ) + config = PropagatorConfig(composite=[{"baggage": {}}]) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] self.assertIsInstance(result._propagators[0], W3CBaggagePropagator) # type: ignore[attr-defined] @@ -71,8 +64,8 @@ def test_baggage_only(self): def test_tracecontext_and_baggage(self): config = PropagatorConfig( composite=[ - TextMapPropagatorConfig(tracecontext={}), - TextMapPropagatorConfig(baggage={}), + {"tracecontext": {}}, + {"baggage": {}}, ] ) result = create_propagator(config) @@ -92,9 +85,7 @@ def test_b3_via_entry_point(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(b3={})] - ) + config = PropagatorConfig(composite=[{"b3": {}}]) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] @@ -109,9 +100,7 @@ def test_b3multi_via_entry_point(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(b3multi={})] - ) + config = PropagatorConfig(composite=[{"b3multi": {}}]) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] @@ -121,9 +110,7 @@ def test_b3_not_installed_raises_configuration_error(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(b3={})] - ) + config = PropagatorConfig(composite=[{"b3": {}}]) with self.assertRaises(ConfigurationError) as ctx: create_propagator(config) self.assertIn("b3", str(ctx.exception)) @@ -214,7 +201,7 @@ def test_deduplication_across_composite_and_composite_list(self): return_value=[mock_ep], ): config = PropagatorConfig( - composite=[TextMapPropagatorConfig(tracecontext={})], + composite=[{"tracecontext": {}}], composite_list="tracecontext", ) result = create_propagator(config) @@ -236,6 +223,30 @@ def test_unknown_composite_list_propagator_raises(self): with self.assertRaises(ConfigurationError): create_propagator(config) + def test_plugin_propagator_via_entry_point(self): + mock_propagator = MagicMock() + mock_ep = MagicMock() + mock_ep.load.return_value = lambda: mock_propagator + + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[mock_ep], + ): + config = PropagatorConfig(composite=[{"my_custom_propagator": {}}]) + result = create_propagator(config) + + self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] + self.assertIs(result._propagators[0], mock_propagator) # type: ignore[attr-defined] + + def test_unknown_composite_propagator_raises(self): + with patch( + "opentelemetry.sdk._configuration._common.entry_points", + return_value=[], + ): + config = PropagatorConfig(composite=[{"nonexistent": {}}]) + with self.assertRaises(ConfigurationError): + create_propagator(config) + class TestConfigurePropagator(unittest.TestCase): def test_configure_propagator_calls_set_global_textmap(self): @@ -248,9 +259,7 @@ def test_configure_propagator_calls_set_global_textmap(self): self.assertIsInstance(arg, CompositePropagator) def test_configure_propagator_with_config(self): - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(tracecontext={})] - ) + config = PropagatorConfig(composite=[{"tracecontext": {}}]) with patch( "opentelemetry.sdk._configuration._propagator.set_global_textmap" ) as mock_set: @@ -263,9 +272,7 @@ def test_configure_propagator_with_config(self): @patch.dict(environ, {OTEL_PROPAGATORS: "baggage"}) def test_otel_propagators_env_var_ignored(self): """OTEL_PROPAGATORS env var must not influence configure_propagator output.""" - config = PropagatorConfig( - composite=[TextMapPropagatorConfig(tracecontext={})] - ) + config = PropagatorConfig(composite=[{"tracecontext": {}}]) with patch( "opentelemetry.sdk._configuration._propagator.set_global_textmap" ) as mock_set: From 2551fe1cff728929d52fee9fb3172020169b1e13 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Mon, 20 Apr 2026 13:58:41 +0100 Subject: [PATCH 4/5] revert models.py changes, use raw dicts without modifying codegen Python dataclasses don't enforce types at runtime, so factory functions can accept raw dicts directly. This removes the need to change TextMapPropagator from a @dataclass to a TypeAlias. Assisted-by: Claude Opus 4.6 (1M context) --- .../src/opentelemetry/sdk/_configuration/models.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index 59770a8e78..5159137228 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -573,10 +573,12 @@ class SpanProcessor: simple: SimpleSpanProcessor | None = None -# Diverges from codegen: TextMapPropagator is typed as dict[str, Any] rather -# than a dataclass so that unknown propagator names (plugin/custom propagators) -# are preserved as dict keys through the config pipeline. -TextMapPropagator: TypeAlias = dict[str, Any] +@dataclass +class TextMapPropagator: + tracecontext: TraceContextPropagator | None = None + baggage: BaggagePropagator | None = None + b3: B3Propagator | None = None + b3multi: B3MultiPropagator | None = None @dataclass From a26c1b9f2cbbf505a383f1fa95f003ad63e3fab5 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 29 Apr 2026 14:56:56 +0100 Subject: [PATCH 5/5] update propagator plugin loading to use additional_properties Use typed TextMapPropagatorConfig with additional_properties from the @_additional_properties decorator instead of raw dict iteration. Known propagators (tracecontext, baggage) are checked via typed fields and _PROPAGATOR_REGISTRY. Known schema fields not in the registry (b3, b3multi) and unknown plugin names from additional_properties are loaded via entry points. Assisted-by: Claude Opus 4.6 --- .../sdk/_configuration/_propagator.py | 31 +++++++---- .../tests/_configuration/test_propagator.py | 51 ++++++++++++++----- 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py index 2b509a1f99..a54672b61f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_propagator.py @@ -24,6 +24,9 @@ from opentelemetry.sdk._configuration.models import ( Propagator as PropagatorConfig, ) +from opentelemetry.sdk._configuration.models import ( + TextMapPropagator as TextMapPropagatorConfig, +) from opentelemetry.trace.propagation.tracecontext import ( TraceContextTextMapPropagator, ) @@ -36,21 +39,29 @@ def _propagators_from_textmap_config( - config: dict, + config: TextMapPropagatorConfig, ) -> list[TextMapPropagator]: - """Resolve a TextMapPropagator config dict to a list of propagators. + """Resolve a TextMapPropagator config to a list of propagators. - Each key in the dict names a propagator. Known names (tracecontext, baggage) - are bootstrapped directly. All other names — including b3, b3multi, and - custom plugin propagators — are loaded via the ``opentelemetry_propagator`` - entry point group, matching the spec's PluginComponentProvider mechanism. + Known names (tracecontext, baggage) are bootstrapped directly via + _PROPAGATOR_REGISTRY. Known schema fields not in the registry (b3, b3multi) + and unknown plugin names from additional_properties are loaded via the + ``opentelemetry_propagator`` entry point group. """ result: list[TextMapPropagator] = [] - for name, prop_config in config.items(): - if name in _PROPAGATOR_REGISTRY: - result.append(_PROPAGATOR_REGISTRY[name](prop_config)) - else: + for name in _PROPAGATOR_REGISTRY: + if getattr(config, name, None) is not None: + result.append(_PROPAGATOR_REGISTRY[name](getattr(config, name))) + + # Known schema fields not in registry (b3, b3multi) — loaded via entry point + for name in ("b3", "b3multi"): + if getattr(config, name, None) is not None: result.append(load_entry_point("opentelemetry_propagator", name)()) + + # Plugin propagators from additional_properties + for name in config.additional_properties: + result.append(load_entry_point("opentelemetry_propagator", name)()) + return result diff --git a/opentelemetry-sdk/tests/_configuration/test_propagator.py b/opentelemetry-sdk/tests/_configuration/test_propagator.py index 8f50486668..bec6216f65 100644 --- a/opentelemetry-sdk/tests/_configuration/test_propagator.py +++ b/opentelemetry-sdk/tests/_configuration/test_propagator.py @@ -30,6 +30,9 @@ from opentelemetry.sdk._configuration.models import ( Propagator as PropagatorConfig, ) +from opentelemetry.sdk._configuration.models import ( + TextMapPropagator as TextMapPropagatorConfig, +) from opentelemetry.trace.propagation.tracecontext import ( TraceContextTextMapPropagator, ) @@ -47,7 +50,9 @@ def test_empty_config_returns_empty_composite(self): self.assertEqual(result._propagators, []) # type: ignore[attr-defined] def test_tracecontext_only(self): - config = PropagatorConfig(composite=[{"tracecontext": {}}]) + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(tracecontext={})] + ) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] self.assertIsInstance( @@ -56,7 +61,9 @@ def test_tracecontext_only(self): ) def test_baggage_only(self): - config = PropagatorConfig(composite=[{"baggage": {}}]) + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(baggage={})] + ) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] self.assertIsInstance(result._propagators[0], W3CBaggagePropagator) # type: ignore[attr-defined] @@ -64,8 +71,8 @@ def test_baggage_only(self): def test_tracecontext_and_baggage(self): config = PropagatorConfig( composite=[ - {"tracecontext": {}}, - {"baggage": {}}, + TextMapPropagatorConfig(tracecontext={}), + TextMapPropagatorConfig(baggage={}), ] ) result = create_propagator(config) @@ -85,7 +92,9 @@ def test_b3_via_entry_point(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): - config = PropagatorConfig(composite=[{"b3": {}}]) + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(b3={})] + ) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] @@ -100,7 +109,9 @@ def test_b3multi_via_entry_point(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): - config = PropagatorConfig(composite=[{"b3multi": {}}]) + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(b3multi={})] + ) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] @@ -110,7 +121,9 @@ def test_b3_not_installed_raises_configuration_error(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): - config = PropagatorConfig(composite=[{"b3": {}}]) + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(b3={})] + ) with self.assertRaises(ConfigurationError) as ctx: create_propagator(config) self.assertIn("b3", str(ctx.exception)) @@ -201,7 +214,7 @@ def test_deduplication_across_composite_and_composite_list(self): return_value=[mock_ep], ): config = PropagatorConfig( - composite=[{"tracecontext": {}}], + composite=[TextMapPropagatorConfig(tracecontext={})], composite_list="tracecontext", ) result = create_propagator(config) @@ -232,7 +245,12 @@ def test_plugin_propagator_via_entry_point(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[mock_ep], ): - config = PropagatorConfig(composite=[{"my_custom_propagator": {}}]) + config = PropagatorConfig( + composite=[ + # pylint: disable=unexpected-keyword-arg + TextMapPropagatorConfig(my_custom_propagator={}) + ] + ) result = create_propagator(config) self.assertEqual(len(result._propagators), 1) # type: ignore[attr-defined] @@ -243,7 +261,12 @@ def test_unknown_composite_propagator_raises(self): "opentelemetry.sdk._configuration._common.entry_points", return_value=[], ): - config = PropagatorConfig(composite=[{"nonexistent": {}}]) + config = PropagatorConfig( + composite=[ + # pylint: disable=unexpected-keyword-arg + TextMapPropagatorConfig(nonexistent={}) + ] + ) with self.assertRaises(ConfigurationError): create_propagator(config) @@ -259,7 +282,9 @@ def test_configure_propagator_calls_set_global_textmap(self): self.assertIsInstance(arg, CompositePropagator) def test_configure_propagator_with_config(self): - config = PropagatorConfig(composite=[{"tracecontext": {}}]) + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(tracecontext={})] + ) with patch( "opentelemetry.sdk._configuration._propagator.set_global_textmap" ) as mock_set: @@ -272,7 +297,9 @@ def test_configure_propagator_with_config(self): @patch.dict(environ, {OTEL_PROPAGATORS: "baggage"}) def test_otel_propagators_env_var_ignored(self): """OTEL_PROPAGATORS env var must not influence configure_propagator output.""" - config = PropagatorConfig(composite=[{"tracecontext": {}}]) + config = PropagatorConfig( + composite=[TextMapPropagatorConfig(tracecontext={})] + ) with patch( "opentelemetry.sdk._configuration._propagator.set_global_textmap" ) as mock_set: