Skip to content

Commit 15954ef

Browse files
feat(config): recursively convert parsed dicts to typed dataclasses in loader (open-telemetry#5269)
* recursively convert parsed dicts to typed dataclasses in loader Adds `_dict_to_dataclass` in `_conversion.py` which walks each field's type annotation and converts: - nested dicts → typed dataclass instances - lists of dicts → lists of typed dataclasses - string/value → Enum members (e.g. log_level: info) - unknown keys → routed to the @_additional_properties decorator The loader's `_dict_to_model` now produces a fully-typed OpenTelemetryConfiguration tree end-to-end. Factory functions can rely on typed attribute access (config.tracer_provider.processors[0].batch .exporter.otlp_http.endpoint) instead of failing on raw dicts. This closes the gap between load_config_file() and the factory functions — YAML/JSON config → SDK objects now works end-to-end. Closes open-telemetry#5127 Assisted-by: Claude Opus 4.6 * rename changelog fragment to PR open-telemetry#5269 * tighten typing on conversion module - Use TypeVar for _dict_to_dataclass return — callers now get the correct type instead of Any - Use collections.abc.Mapping for input (more permissive than dict) - Add explicit is_dataclass check at entry — raises TypeError with a descriptive message instead of failing later in dataclasses.fields Assisted-by: Claude Opus 4.6 * isolate typing.get_type_hints call to placate astroid 3.x on py3.14 Astroid 3.x (used by pylint 3.x) follows typing.get_type_hints into Python 3.14's annotationlib, which contains t-string literals it can't parse and crashes with AttributeError on 'visit_templatestr'. Wrapping the call in a helper that returns dict[str, Any] stops the inference at the declared return type. Assisted-by: Claude Opus 4.7 * inline the typing.get_type_hints wrap Same effect as the prior helper — declaring the local as ``dict[str, Any]`` stops astroid's inference at the annotation rather than tracing into the typing internals. Assisted-by: Claude Opus 4.7 * use ExemplarFilter for enum coercion test fixture; allow 'astroid' in codespell Replace the bespoke _Level enum (which violated pylint's invalid-name on lowercase members) with the real ExemplarFilter enum from models.py — the generated models use lowercase values verbatim from the JSON schema, so using one of them avoids fighting the linter and exercises the same code path with real data shapes. Add 'astroid' to codespell's ignore-words-list; the prior commit's explanatory comment mentions the library by name and codespell flagged it as a misspelling of 'asteroid'. Assisted-by: Claude Opus 4.7 * add end-to-end loader tests covering YAML -> typed config -> factory The conversion module has unit tests that exercise _dict_to_dataclass in isolation, but nothing verified the full pipeline: load a real YAML file, get back fully-typed nested dataclasses, and feed the result into a downstream factory function. Adds two checks built on a representative nested fixture (tracer provider with a parent-based / trace-id-ratio sampler and a batch processor with console exporter): - nested fields (sampler, processors[*].batch) come back as the expected typed dataclasses, not raw dicts - the typed result is accepted by ``create_tracer_provider`` and produces an SDK ``TracerProvider`` This is the integration coverage requested in PR review feedback; the inline example in the PR description is now an actual regression test. Assisted-by: Claude Opus 4.7 * verify sampler and span processor wiring in factory test Reach into the SDK private fields the same way other tests in this file already do (pylint protected-access disabled at the class level on the similar test_meter_provider.py) so we confirm the YAML actually flowed through into the right Sampler/SpanProcessor/Exporter structure rather than just landing as some TracerProvider instance. Addresses Dylan's nit on open-telemetry#5269. Assisted-by: Claude Opus 4.7 (1M context) --------- Co-authored-by: Lukas Hering <40302054+herin049@users.noreply.github.com>
1 parent 39ff55d commit 15954ef

6 files changed

Lines changed: 322 additions & 11 deletions

File tree

‎.changelog/5269.added‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-sdk`: declarative config loader now recursively converts parsed dicts into typed dataclass instances, including nested dataclasses, lists of dataclasses, and enum values. End-to-end YAML/JSON → SDK configuration now works via the factory functions.

‎.codespellrc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
[codespell]
22
# skipping auto generated folders
33
skip = ./.tox,./.mypy_cache,./docs/_build,./target,*/LICENSE,./venv,.git,./opentelemetry-semantic-conventions,*-requirements*.txt
4-
ignore-words-list = ans,ue,ot,hist,ro
4+
ignore-words-list = ans,ue,ot,hist,ro,astroid
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Recursive dict-to-dataclass conversion for parsed config data.
5+
6+
The YAML/JSON loader produces nested dicts. Factory functions expect typed
7+
dataclass instances (e.g. ``TracerProvider``, ``SpanProcessor``). This module
8+
walks each field's type annotation and converts nested dicts into their
9+
corresponding dataclass types.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import dataclasses
15+
import enum
16+
import types
17+
import typing
18+
from collections.abc import Mapping
19+
from typing import Any, TypeVar, Union, get_args, get_origin
20+
21+
_T = TypeVar("_T")
22+
23+
24+
def _unwrap_optional(type_hint: Any) -> Any:
25+
"""Strip ``None`` from a ``X | None`` / ``Optional[X]`` annotation.
26+
27+
Returns the unwrapped type, or the original hint if not a Union with None.
28+
"""
29+
origin = get_origin(type_hint)
30+
if origin is Union or origin is types.UnionType:
31+
non_none = [t for t in get_args(type_hint) if t is not type(None)]
32+
if len(non_none) == 1:
33+
return non_none[0]
34+
return type_hint
35+
36+
37+
def _convert_value(value: Any, type_hint: Any) -> Any:
38+
"""Convert a value according to its type hint.
39+
40+
Recursively converts dicts to dataclasses and lists of dicts to lists of
41+
dataclasses. Other values (primitives, enums, ``dict[str, Any]`` aliases)
42+
pass through unchanged.
43+
"""
44+
if value is None:
45+
return None
46+
47+
unwrapped = _unwrap_optional(type_hint)
48+
origin = get_origin(unwrapped)
49+
50+
# list[X] — recurse on each element
51+
if origin is list and isinstance(value, list):
52+
args = get_args(unwrapped)
53+
if args:
54+
item_type = args[0]
55+
return [_convert_value(item, item_type) for item in value]
56+
return value
57+
58+
# Direct dataclass type — recurse
59+
if (
60+
isinstance(unwrapped, type)
61+
and dataclasses.is_dataclass(unwrapped)
62+
and isinstance(value, dict)
63+
):
64+
return _dict_to_dataclass(value, unwrapped)
65+
66+
# Enum type — coerce string/value to the Enum member
67+
if (
68+
isinstance(unwrapped, type)
69+
and issubclass(unwrapped, enum.Enum)
70+
and not isinstance(value, unwrapped)
71+
):
72+
return unwrapped(value)
73+
74+
return value
75+
76+
77+
def _dict_to_dataclass(data: Mapping[str, Any], cls: type[_T]) -> _T:
78+
"""Recursively convert a mapping to a dataclass instance.
79+
80+
For each key in ``data``:
81+
- If it matches a known dataclass field, the value is converted according
82+
to that field's type annotation (recursing for nested dataclasses).
83+
- Unknown keys are passed through as kwargs; classes decorated with
84+
``@_additional_properties`` will capture them on the instance's
85+
``additional_properties`` attribute.
86+
87+
``ClassVar`` fields (e.g. the ``additional_properties`` annotation on
88+
decorated dataclasses) are ignored as expected.
89+
90+
Raises:
91+
TypeError: If ``cls`` is not a dataclass type.
92+
"""
93+
if not dataclasses.is_dataclass(cls):
94+
raise TypeError(f"{cls.__name__} is not a dataclass")
95+
96+
# Annotated as ``dict[str, Any]`` so astroid stops tracing into
97+
# ``typing.get_type_hints`` — under pylint 3.x that path leads into
98+
# Python 3.14's ``annotationlib`` (which uses t-strings) and crashes.
99+
hints: dict[str, Any] = dict(
100+
typing.get_type_hints(cls, include_extras=False)
101+
)
102+
known_fields = {f.name for f in dataclasses.fields(cls)}
103+
kwargs: dict[str, Any] = {}
104+
105+
for key, value in data.items():
106+
if key in known_fields:
107+
type_hint = hints.get(key)
108+
kwargs[key] = _convert_value(value, type_hint)
109+
else:
110+
# Unknown key — @_additional_properties decorator will capture it.
111+
kwargs[key] = value
112+
113+
return cls(**kwargs)

‎opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pathlib import Path
1010
from typing import Any
1111

12+
from opentelemetry.sdk._configuration._conversion import _dict_to_dataclass
1213
from opentelemetry.sdk._configuration._exceptions import ConfigurationError
1314
from opentelemetry.sdk._configuration.file._env_substitution import (
1415
substitute_env_vars,
@@ -172,10 +173,13 @@ def _validate_schema(data: dict) -> None:
172173

173174

174175
def _dict_to_model(data: dict[str, Any]) -> OpenTelemetryConfiguration:
175-
"""Convert dictionary to OpenTelemetryConfiguration model.
176+
"""Convert a parsed config dictionary to the full typed model tree.
176177
177-
Uses the generated dataclass from models.py. This provides basic
178-
validation through dataclass field types.
178+
Walks each field's type annotation, recursively converting nested
179+
dicts to their corresponding dataclass types. The resulting
180+
``OpenTelemetryConfiguration`` is fully typed end-to-end, so factory
181+
functions can rely on typed attribute access (e.g. ``config.sampler``,
182+
``config.processors[0].batch.exporter``).
179183
180184
Args:
181185
data: Parsed configuration dictionary.
@@ -187,15 +191,9 @@ def _dict_to_model(data: dict[str, Any]) -> OpenTelemetryConfiguration:
187191
TypeError: If data doesn't match expected structure.
188192
ValueError: If values are invalid.
189193
"""
190-
# Construct the top-level model from the validated dict. Nested fields
191-
# are stored as dicts rather than their dataclass types; factory functions
192-
# in later PRs will handle the full recursive conversion when building
193-
# SDK objects.
194194
try:
195-
config = OpenTelemetryConfiguration(**data)
196-
return config
195+
return _dict_to_dataclass(data, OpenTelemetryConfiguration)
197196
except TypeError as exc:
198-
# Provide more helpful error message
199197
raise TypeError(
200198
f"Configuration structure is invalid. "
201199
f"Check that all required fields are present and correctly typed: {exc}"

‎opentelemetry-sdk/tests/_configuration/file/test_loader.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,32 @@
77
from pathlib import Path
88
from unittest.mock import patch
99

10+
from opentelemetry.sdk._configuration._tracer_provider import (
11+
create_tracer_provider,
12+
)
1013
from opentelemetry.sdk._configuration.file import (
1114
ConfigurationError,
1215
load_config_file,
1316
)
17+
from opentelemetry.sdk._configuration.models import (
18+
BatchSpanProcessor as BatchSpanProcessorConfig,
19+
)
1420
from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration
21+
from opentelemetry.sdk._configuration.models import (
22+
ParentBasedSampler as ParentBasedSamplerConfig,
23+
)
24+
from opentelemetry.sdk._configuration.models import (
25+
SpanProcessor as SpanProcessorConfig,
26+
)
27+
from opentelemetry.sdk._configuration.models import (
28+
TracerProvider as TracerProviderConfig,
29+
)
30+
from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider
31+
from opentelemetry.sdk.trace.export import (
32+
BatchSpanProcessor,
33+
ConsoleSpanExporter,
34+
)
35+
from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased
1536

1637

1738
class TestConfigLoader(unittest.TestCase):
@@ -231,3 +252,71 @@ def test_schema_validation_invalid_enum(self):
231252
self.assertIn("schema", str(ctx.exception).lower())
232253
finally:
233254
os.unlink(temp_path)
255+
256+
257+
class TestConfigLoaderEndToEnd(unittest.TestCase):
258+
"""Smoke-test the full YAML -> typed config -> SDK object pipeline.
259+
260+
Unit tests in test_conversion.py exercise the dict-to-dataclass
261+
conversion in isolation; these tests verify it composes with the
262+
real loader and downstream factory functions on a representative
263+
nested configuration.
264+
"""
265+
266+
_YAML = """
267+
file_format: '1.0-rc.1'
268+
tracer_provider:
269+
processors:
270+
- batch:
271+
exporter:
272+
console: {}
273+
sampler:
274+
parent_based:
275+
root:
276+
trace_id_ratio_based: {ratio: 0.5}
277+
"""
278+
279+
def _load(self) -> OpenTelemetryConfiguration:
280+
with tempfile.NamedTemporaryFile(
281+
mode="w", suffix=".yaml", delete=False
282+
) as fh:
283+
fh.write(self._YAML)
284+
path = fh.name
285+
try:
286+
return load_config_file(path)
287+
finally:
288+
os.unlink(path)
289+
290+
def test_nested_fields_are_typed_dataclasses(self):
291+
config = self._load()
292+
293+
self.assertIsInstance(config.tracer_provider, TracerProviderConfig)
294+
self.assertIsInstance(
295+
config.tracer_provider.sampler.parent_based,
296+
ParentBasedSamplerConfig,
297+
)
298+
# Lists of dataclasses are converted element-wise.
299+
self.assertIsInstance(
300+
config.tracer_provider.processors[0], SpanProcessorConfig
301+
)
302+
self.assertIsInstance(
303+
config.tracer_provider.processors[0].batch,
304+
BatchSpanProcessorConfig,
305+
)
306+
307+
# pylint: disable=protected-access
308+
def test_typed_config_feeds_factory_function(self):
309+
config = self._load()
310+
311+
provider = create_tracer_provider(config.tracer_provider)
312+
313+
self.assertIsInstance(provider, SdkTracerProvider)
314+
# Sampler wiring from the YAML: parent_based(trace_id_ratio_based(0.5)).
315+
self.assertIsInstance(provider.sampler, ParentBased)
316+
self.assertIsInstance(provider.sampler._root, TraceIdRatioBased)
317+
self.assertEqual(provider.sampler._root.rate, 0.5)
318+
# Span processor wiring from the YAML: batch(console).
319+
processors = provider._active_span_processor._span_processors
320+
self.assertEqual(len(processors), 1)
321+
self.assertIsInstance(processors[0], BatchSpanProcessor)
322+
self.assertIsInstance(processors[0].span_exporter, ConsoleSpanExporter)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# Tests access private members of SDK classes to assert correct configuration.
5+
# pylint: disable=protected-access
6+
7+
import unittest
8+
from dataclasses import dataclass
9+
from typing import Any, ClassVar
10+
11+
from opentelemetry.sdk._configuration._common import _additional_properties
12+
from opentelemetry.sdk._configuration._conversion import _dict_to_dataclass
13+
from opentelemetry.sdk._configuration.models import ExemplarFilter
14+
15+
16+
@dataclass
17+
class _Inner:
18+
value: int | None = None
19+
20+
21+
@dataclass
22+
class _Middle:
23+
inner: _Inner | None = None
24+
items: list[_Inner] | None = None
25+
26+
27+
@dataclass
28+
class _Outer:
29+
middle: _Middle | None = None
30+
name: str | None = None
31+
32+
33+
@_additional_properties
34+
@dataclass
35+
class _WithExtras:
36+
known: str | None = None
37+
additional_properties: ClassVar[dict[str, Any]]
38+
39+
40+
@dataclass
41+
class _WithEnum:
42+
filter: ExemplarFilter | None = None
43+
44+
45+
class TestDictToDataclass(unittest.TestCase):
46+
def test_raises_on_non_dataclass(self):
47+
# _dict_to_dataclass is internal and assumes cls is a dataclass.
48+
with self.assertRaises(TypeError) as ctx:
49+
_dict_to_dataclass({"x": 1}, dict)
50+
self.assertIn("not a dataclass", str(ctx.exception))
51+
52+
def test_converts_flat_dict(self):
53+
result = _dict_to_dataclass({"value": 42}, _Inner)
54+
self.assertIsInstance(result, _Inner)
55+
self.assertEqual(result.value, 42)
56+
57+
def test_converts_nested_dataclass(self):
58+
result = _dict_to_dataclass(
59+
{"middle": {"inner": {"value": 7}}}, _Outer
60+
)
61+
self.assertIsInstance(result, _Outer)
62+
self.assertIsInstance(result.middle, _Middle)
63+
self.assertIsInstance(result.middle.inner, _Inner)
64+
self.assertEqual(result.middle.inner.value, 7)
65+
66+
def test_converts_list_of_dataclasses(self):
67+
result = _dict_to_dataclass(
68+
{"middle": {"items": [{"value": 1}, {"value": 2}]}}, _Outer
69+
)
70+
self.assertEqual(len(result.middle.items), 2)
71+
self.assertIsInstance(result.middle.items[0], _Inner)
72+
self.assertEqual(result.middle.items[0].value, 1)
73+
self.assertEqual(result.middle.items[1].value, 2)
74+
75+
def test_none_value_preserved(self):
76+
result = _dict_to_dataclass({"middle": None, "name": "test"}, _Outer)
77+
self.assertIsNone(result.middle)
78+
self.assertEqual(result.name, "test")
79+
80+
def test_missing_optional_fields_default_to_none(self):
81+
result = _dict_to_dataclass({}, _Outer)
82+
self.assertIsNone(result.middle)
83+
self.assertIsNone(result.name)
84+
85+
def test_unknown_keys_routed_to_additional_properties(self):
86+
result = _dict_to_dataclass(
87+
{"known": "yes", "my_plugin": {"opt": True}}, _WithExtras
88+
)
89+
self.assertEqual(result.known, "yes")
90+
self.assertEqual(
91+
result.additional_properties, {"my_plugin": {"opt": True}}
92+
)
93+
94+
def test_primitive_values_pass_through(self):
95+
result = _dict_to_dataclass({"name": "hello"}, _Outer)
96+
self.assertEqual(result.name, "hello")
97+
98+
def test_empty_list_converted(self):
99+
result = _dict_to_dataclass({"middle": {"items": []}}, _Outer)
100+
self.assertEqual(result.middle.items, [])
101+
102+
def test_enum_value_coerced_from_string(self):
103+
result = _dict_to_dataclass({"filter": "always_on"}, _WithEnum)
104+
self.assertIs(result.filter, ExemplarFilter.always_on)
105+
106+
def test_enum_value_already_enum_passes_through(self):
107+
result = _dict_to_dataclass(
108+
{"filter": ExemplarFilter.trace_based}, _WithEnum
109+
)
110+
self.assertIs(result.filter, ExemplarFilter.trace_based)

0 commit comments

Comments
 (0)