Skip to content

Commit 1b9cc28

Browse files
committed
add _resolve_component shared utility for plugin loading
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
1 parent 369644c commit 1b9cc28

3 files changed

Lines changed: 112 additions & 0 deletions

File tree

.changelog/5215.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: add `_resolve_component` shared utility for declarative config plugin loading, reducing boilerplate in exporter factory functions

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,45 @@ def load_entry_point(group: str, name: str) -> type:
7272
) from exc
7373

7474

75+
def _resolve_component(
76+
config,
77+
registry: dict,
78+
entry_point_group: str,
79+
component_type: str,
80+
):
81+
"""Resolve a config dataclass to a component instance.
82+
83+
Checks built-in factories in ``registry`` first (by matching typed
84+
field names on ``config``), then falls back to entry point loading
85+
for plugin components found in ``config.additional_properties``.
86+
87+
Args:
88+
config: A dataclass with ``additional_properties`` (from the
89+
``@_additional_properties`` decorator).
90+
registry: Mapping of built-in component names to factory
91+
callables. Each factory receives the field value from config.
92+
entry_point_group: The entry point group name for plugin loading.
93+
component_type: Human-readable name for error messages
94+
(e.g. "span exporter").
95+
96+
Returns:
97+
The resolved component instance.
98+
99+
Raises:
100+
ConfigurationError: If no component type is specified in config.
101+
"""
102+
for name, factory in registry.items():
103+
value = getattr(config, name, None)
104+
if value is not None:
105+
return factory(value)
106+
if config.additional_properties:
107+
name, plugin_config = next(iter(config.additional_properties.items()))
108+
return load_entry_point(entry_point_group, name)(
109+
**(plugin_config or {})
110+
)
111+
raise ConfigurationError(f"No {component_type} type specified in config.")
112+
113+
75114
def _parse_headers(
76115
headers: list | None,
77116
headers_list: str | None,

opentelemetry-sdk/tests/_configuration/test_common.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from opentelemetry.sdk._configuration._common import (
1212
_additional_properties,
1313
_parse_headers,
14+
_resolve_component,
1415
load_entry_point,
1516
)
1617
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
@@ -224,3 +225,74 @@ def test_log_record_exporter(self):
224225

225226
def test_push_metric_exporter(self):
226227
self._assert_supports_additional_properties(PushMetricExporter)
228+
229+
230+
class TestResolveComponent(unittest.TestCase):
231+
def setUp(self):
232+
@_additional_properties
233+
@dataclass
234+
class _Config:
235+
builtin_a: dict | None = None
236+
builtin_b: str | None = None
237+
additional_properties: ClassVar[dict[str, Any]]
238+
239+
self.cls = _Config
240+
self.registry = {
241+
"builtin_a": lambda v: ("resolved_a", v),
242+
"builtin_b": lambda v: ("resolved_b", v),
243+
}
244+
245+
def test_resolves_builtin_from_registry(self):
246+
config = self.cls(builtin_a={"key": "val"})
247+
result = _resolve_component(
248+
config, self.registry, "test_group", "test component"
249+
)
250+
self.assertEqual(result, ("resolved_a", {"key": "val"}))
251+
252+
def test_resolves_plugin_via_entry_point(self):
253+
mock_instance = MagicMock()
254+
mock_class = MagicMock(return_value=mock_instance)
255+
with patch(
256+
"opentelemetry.sdk._configuration._common.entry_points",
257+
return_value=[MagicMock(**{"load.return_value": mock_class})],
258+
):
259+
# pylint: disable=unexpected-keyword-arg
260+
config = self.cls(my_plugin={"opt": "val"})
261+
result = _resolve_component(
262+
config, self.registry, "test_group", "test component"
263+
)
264+
self.assertIs(result, mock_instance)
265+
mock_class.assert_called_once_with(opt="val")
266+
267+
def test_plugin_with_empty_config(self):
268+
mock_instance = MagicMock()
269+
mock_class = MagicMock(return_value=mock_instance)
270+
with patch(
271+
"opentelemetry.sdk._configuration._common.entry_points",
272+
return_value=[MagicMock(**{"load.return_value": mock_class})],
273+
):
274+
# pylint: disable=unexpected-keyword-arg
275+
config = self.cls(my_plugin={})
276+
_resolve_component(
277+
config, self.registry, "test_group", "test component"
278+
)
279+
mock_class.assert_called_once_with()
280+
281+
def test_no_component_raises_configuration_error(self):
282+
config = self.cls()
283+
with self.assertRaises(ConfigurationError):
284+
_resolve_component(
285+
config, self.registry, "test_group", "test component"
286+
)
287+
288+
def test_plugin_not_found_raises_configuration_error(self):
289+
with patch(
290+
"opentelemetry.sdk._configuration._common.entry_points",
291+
return_value=[],
292+
):
293+
# pylint: disable=unexpected-keyword-arg
294+
config = self.cls(missing_plugin={})
295+
with self.assertRaises(ConfigurationError):
296+
_resolve_component(
297+
config, self.registry, "test_group", "test component"
298+
)

0 commit comments

Comments
 (0)