Skip to content

Commit 488dc4a

Browse files
refactor(config): add shared _resolve_component utility for plugin loading (#5215)
* 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Aaron Abbott <aaronabbott@google.com>
1 parent f20c680 commit 488dc4a

3 files changed

Lines changed: 145 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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import dataclasses
77
import inspect
88
import logging
9+
from collections.abc import Callable
10+
from typing import Any, Protocol
911

1012
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
1113
from opentelemetry.util._importlib_metadata import entry_points
@@ -72,6 +74,66 @@ def load_entry_point(group: str, name: str) -> type:
7274
) from exc
7375

7476

77+
class _ComponentConfig(Protocol):
78+
"""Protocol for config dataclasses decorated with @_additional_properties.
79+
80+
Values in ``additional_properties`` are nested config dicts (suitable
81+
for ``**kwargs`` splatting to the user-defined component class) or
82+
``None`` (when the YAML uses ``my_plugin:`` or ``my_plugin: null``).
83+
84+
Note: the generated models declare ``additional_properties`` as a
85+
``ClassVar`` even though the decorator assigns it as an instance
86+
attribute at runtime. This is tolerated by pyright in ``standard``
87+
mode but flagged in ``strict`` mode. See #5268.
88+
"""
89+
90+
additional_properties: dict[str, dict[str, Any] | None]
91+
92+
93+
def _resolve_component(
94+
config: _ComponentConfig,
95+
registry: dict[str, Callable[[Any], Any]],
96+
entry_point_group: str,
97+
component_type: str,
98+
) -> Any:
99+
"""Resolve a config dataclass to a component instance.
100+
101+
Checks built-in factories in ``registry`` first (by matching typed
102+
field names on ``config``), then falls back to entry point loading
103+
for plugin components found in ``config.additional_properties``.
104+
105+
The JSON schema enforces exactly one component per config block
106+
(``minProperties: 1, maxProperties: 1``). If multiple typed fields or
107+
``additional_properties`` entries are set (e.g. when schema validation
108+
is bypassed), the first registry match wins.
109+
110+
Args:
111+
config: A dataclass with ``additional_properties`` (from the
112+
``@_additional_properties`` decorator).
113+
registry: Mapping of built-in component names to factory
114+
callables. Each factory receives the field value from config.
115+
entry_point_group: The entry point group name for plugin loading.
116+
component_type: Human-readable name for error messages
117+
(e.g. "span exporter").
118+
119+
Returns:
120+
The resolved component instance.
121+
122+
Raises:
123+
ConfigurationError: If no component type is specified in config.
124+
"""
125+
for name, factory in registry.items():
126+
value = getattr(config, name, None)
127+
if value is not None:
128+
return factory(value)
129+
if config.additional_properties:
130+
name, plugin_config = next(iter(config.additional_properties.items()))
131+
return load_entry_point(entry_point_group, name)(
132+
**(plugin_config or {})
133+
)
134+
raise ConfigurationError(f"No {component_type} type specified in config.")
135+
136+
75137
def _parse_headers(
76138
headers: list | None,
77139
headers_list: str | None,

opentelemetry-sdk/tests/_configuration/test_common.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
_additional_properties,
1313
_map_compression,
1414
_parse_headers,
15+
_resolve_component,
1516
load_entry_point,
1617
)
1718
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
@@ -289,3 +290,84 @@ def test_log_record_exporter(self):
289290

290291
def test_push_metric_exporter(self):
291292
self._assert_supports_additional_properties(PushMetricExporter)
293+
294+
295+
class TestResolveComponent(unittest.TestCase):
296+
def setUp(self):
297+
@_additional_properties
298+
@dataclass
299+
class _Config:
300+
builtin_a: dict | None = None
301+
builtin_b: str | None = None
302+
additional_properties: ClassVar[dict[str, Any]]
303+
304+
self.cls = _Config
305+
self.registry = {
306+
"builtin_a": lambda v: ("resolved_a", v),
307+
"builtin_b": lambda v: ("resolved_b", v),
308+
}
309+
310+
def test_resolves_builtin_from_registry(self):
311+
config = self.cls(builtin_a={"key": "val"})
312+
result = _resolve_component(
313+
config, self.registry, "test_group", "test component"
314+
)
315+
self.assertEqual(result, ("resolved_a", {"key": "val"}))
316+
317+
def test_resolves_plugin_via_entry_point(self):
318+
mock_instance = MagicMock()
319+
mock_class = MagicMock(return_value=mock_instance)
320+
with patch(
321+
"opentelemetry.sdk._configuration._common.entry_points",
322+
return_value=[MagicMock(**{"load.return_value": mock_class})],
323+
):
324+
# pylint: disable=unexpected-keyword-arg
325+
config = self.cls(my_plugin={"opt": "val"})
326+
result = _resolve_component(
327+
config, self.registry, "test_group", "test component"
328+
)
329+
self.assertIs(result, mock_instance)
330+
mock_class.assert_called_once_with(opt="val")
331+
332+
def test_plugin_with_empty_config(self):
333+
mock_instance = MagicMock()
334+
mock_class = MagicMock(return_value=mock_instance)
335+
with patch(
336+
"opentelemetry.sdk._configuration._common.entry_points",
337+
return_value=[MagicMock(**{"load.return_value": mock_class})],
338+
):
339+
# pylint: disable=unexpected-keyword-arg
340+
config = self.cls(my_plugin={})
341+
_resolve_component(
342+
config, self.registry, "test_group", "test component"
343+
)
344+
mock_class.assert_called_once_with()
345+
346+
def test_no_component_raises_configuration_error(self):
347+
config = self.cls()
348+
with self.assertRaises(ConfigurationError):
349+
_resolve_component(
350+
config, self.registry, "test_group", "test component"
351+
)
352+
353+
def test_plugin_not_found_raises_configuration_error(self):
354+
with patch(
355+
"opentelemetry.sdk._configuration._common.entry_points",
356+
return_value=[],
357+
):
358+
# pylint: disable=unexpected-keyword-arg
359+
config = self.cls(missing_plugin={})
360+
with self.assertRaises(ConfigurationError):
361+
_resolve_component(
362+
config, self.registry, "test_group", "test component"
363+
)
364+
365+
def test_first_registry_match_wins_when_multiple_set(self):
366+
"""When multiple built-in fields are set (which the schema should
367+
prevent), the first registry match wins."""
368+
config = self.cls(builtin_a={"a": 1}, builtin_b="b")
369+
result = _resolve_component(
370+
config, self.registry, "test_group", "test component"
371+
)
372+
# builtin_a comes first in the registry dict
373+
self.assertEqual(result, ("resolved_a", {"a": 1}))

0 commit comments

Comments
 (0)