Skip to content

Commit e5d6932

Browse files
committed
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 #5127 Assisted-by: Claude Opus 4.6
1 parent ed622c3 commit e5d6932

4 files changed

Lines changed: 220 additions & 10 deletions

File tree

.changelog/XXXX.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.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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 typing import Any, Union, get_args, get_origin
19+
20+
21+
def _unwrap_optional(type_hint: Any) -> Any:
22+
"""Strip ``None`` from a ``X | None`` / ``Optional[X]`` annotation.
23+
24+
Returns the unwrapped type, or the original hint if not a Union with None.
25+
"""
26+
origin = get_origin(type_hint)
27+
if origin is Union or origin is types.UnionType:
28+
non_none = [t for t in get_args(type_hint) if t is not type(None)]
29+
if len(non_none) == 1:
30+
return non_none[0]
31+
return type_hint
32+
33+
34+
def _convert_value(value: Any, type_hint: Any) -> Any:
35+
"""Convert a value according to its type hint.
36+
37+
Recursively converts dicts to dataclasses and lists of dicts to lists of
38+
dataclasses. Other values (primitives, enums, ``dict[str, Any]`` aliases)
39+
pass through unchanged.
40+
"""
41+
if value is None:
42+
return None
43+
44+
unwrapped = _unwrap_optional(type_hint)
45+
origin = get_origin(unwrapped)
46+
47+
# list[X] — recurse on each element
48+
if origin is list and isinstance(value, list):
49+
args = get_args(unwrapped)
50+
if args:
51+
item_type = args[0]
52+
return [_convert_value(item, item_type) for item in value]
53+
return value
54+
55+
# Direct dataclass type — recurse
56+
if (
57+
isinstance(unwrapped, type)
58+
and dataclasses.is_dataclass(unwrapped)
59+
and isinstance(value, dict)
60+
):
61+
return _dict_to_dataclass(value, unwrapped)
62+
63+
# Enum type — coerce string/value to the Enum member
64+
if (
65+
isinstance(unwrapped, type)
66+
and issubclass(unwrapped, enum.Enum)
67+
and not isinstance(value, unwrapped)
68+
):
69+
return unwrapped(value)
70+
71+
return value
72+
73+
74+
def _dict_to_dataclass(data: dict[str, Any], cls: type) -> Any:
75+
"""Recursively convert a dict to a dataclass instance.
76+
77+
For each key in ``data``:
78+
- If it matches a known dataclass field, the value is converted according
79+
to that field's type annotation (recursing for nested dataclasses).
80+
- Unknown keys are passed through as kwargs; classes decorated with
81+
``@_additional_properties`` will capture them on the instance's
82+
``additional_properties`` attribute.
83+
84+
``ClassVar`` fields (e.g. the ``additional_properties`` annotation on
85+
decorated dataclasses) are ignored as expected.
86+
"""
87+
if not dataclasses.is_dataclass(cls):
88+
return data
89+
90+
hints = typing.get_type_hints(cls, include_extras=False)
91+
known_fields = {f.name for f in dataclasses.fields(cls)}
92+
kwargs: dict[str, Any] = {}
93+
94+
for key, value in data.items():
95+
if key in known_fields:
96+
kwargs[key] = _convert_value(value, hints.get(key))
97+
else:
98+
# Unknown key — @_additional_properties decorator will capture it.
99+
kwargs[key] = value
100+
101+
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}"
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 enum import Enum
10+
from typing import Any, ClassVar
11+
12+
from opentelemetry.sdk._configuration._common import _additional_properties
13+
from opentelemetry.sdk._configuration._conversion import _dict_to_dataclass
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+
class _Level(Enum):
41+
info = "info"
42+
warn = "warn"
43+
44+
45+
@dataclass
46+
class _WithEnum:
47+
level: _Level | None = None
48+
49+
50+
class TestDictToDataclass(unittest.TestCase):
51+
def test_returns_data_unchanged_for_non_dataclass(self):
52+
self.assertEqual(_dict_to_dataclass({"x": 1}, dict), {"x": 1})
53+
54+
def test_converts_flat_dict(self):
55+
result = _dict_to_dataclass({"value": 42}, _Inner)
56+
self.assertIsInstance(result, _Inner)
57+
self.assertEqual(result.value, 42)
58+
59+
def test_converts_nested_dataclass(self):
60+
result = _dict_to_dataclass(
61+
{"middle": {"inner": {"value": 7}}}, _Outer
62+
)
63+
self.assertIsInstance(result, _Outer)
64+
self.assertIsInstance(result.middle, _Middle)
65+
self.assertIsInstance(result.middle.inner, _Inner)
66+
self.assertEqual(result.middle.inner.value, 7)
67+
68+
def test_converts_list_of_dataclasses(self):
69+
result = _dict_to_dataclass(
70+
{"middle": {"items": [{"value": 1}, {"value": 2}]}}, _Outer
71+
)
72+
self.assertEqual(len(result.middle.items), 2)
73+
self.assertIsInstance(result.middle.items[0], _Inner)
74+
self.assertEqual(result.middle.items[0].value, 1)
75+
self.assertEqual(result.middle.items[1].value, 2)
76+
77+
def test_none_value_preserved(self):
78+
result = _dict_to_dataclass({"middle": None, "name": "test"}, _Outer)
79+
self.assertIsNone(result.middle)
80+
self.assertEqual(result.name, "test")
81+
82+
def test_missing_optional_fields_default_to_none(self):
83+
result = _dict_to_dataclass({}, _Outer)
84+
self.assertIsNone(result.middle)
85+
self.assertIsNone(result.name)
86+
87+
def test_unknown_keys_routed_to_additional_properties(self):
88+
result = _dict_to_dataclass(
89+
{"known": "yes", "my_plugin": {"opt": True}}, _WithExtras
90+
)
91+
self.assertEqual(result.known, "yes")
92+
self.assertEqual(
93+
result.additional_properties, {"my_plugin": {"opt": True}}
94+
)
95+
96+
def test_primitive_values_pass_through(self):
97+
result = _dict_to_dataclass({"name": "hello"}, _Outer)
98+
self.assertEqual(result.name, "hello")
99+
100+
def test_empty_list_converted(self):
101+
result = _dict_to_dataclass({"middle": {"items": []}}, _Outer)
102+
self.assertEqual(result.middle.items, [])
103+
104+
def test_enum_value_coerced_from_string(self):
105+
result = _dict_to_dataclass({"level": "info"}, _WithEnum)
106+
self.assertIs(result.level, _Level.info)
107+
108+
def test_enum_value_already_enum_passes_through(self):
109+
result = _dict_to_dataclass({"level": _Level.warn}, _WithEnum)
110+
self.assertIs(result.level, _Level.warn)

0 commit comments

Comments
 (0)