From e5d6932f19feeeb23e195dd9ca24ddc298933b17 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 10:55:28 +0100 Subject: [PATCH 01/11] recursively convert parsed dicts to typed dataclasses in loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changelog/XXXX.added | 1 + .../sdk/_configuration/_conversion.py | 101 ++++++++++++++++ .../sdk/_configuration/file/_loader.py | 18 ++- .../tests/_configuration/test_conversion.py | 110 ++++++++++++++++++ 4 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 .changelog/XXXX.added create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py create mode 100644 opentelemetry-sdk/tests/_configuration/test_conversion.py diff --git a/.changelog/XXXX.added b/.changelog/XXXX.added new file mode 100644 index 00000000000..eba843e2afa --- /dev/null +++ b/.changelog/XXXX.added @@ -0,0 +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. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py new file mode 100644 index 00000000000..f33ed57553f --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py @@ -0,0 +1,101 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Recursive dict-to-dataclass conversion for parsed config data. + +The YAML/JSON loader produces nested dicts. Factory functions expect typed +dataclass instances (e.g. ``TracerProvider``, ``SpanProcessor``). This module +walks each field's type annotation and converts nested dicts into their +corresponding dataclass types. +""" + +from __future__ import annotations + +import dataclasses +import enum +import types +import typing +from typing import Any, Union, get_args, get_origin + + +def _unwrap_optional(type_hint: Any) -> Any: + """Strip ``None`` from a ``X | None`` / ``Optional[X]`` annotation. + + Returns the unwrapped type, or the original hint if not a Union with None. + """ + origin = get_origin(type_hint) + if origin is Union or origin is types.UnionType: + non_none = [t for t in get_args(type_hint) if t is not type(None)] + if len(non_none) == 1: + return non_none[0] + return type_hint + + +def _convert_value(value: Any, type_hint: Any) -> Any: + """Convert a value according to its type hint. + + Recursively converts dicts to dataclasses and lists of dicts to lists of + dataclasses. Other values (primitives, enums, ``dict[str, Any]`` aliases) + pass through unchanged. + """ + if value is None: + return None + + unwrapped = _unwrap_optional(type_hint) + origin = get_origin(unwrapped) + + # list[X] — recurse on each element + if origin is list and isinstance(value, list): + args = get_args(unwrapped) + if args: + item_type = args[0] + return [_convert_value(item, item_type) for item in value] + return value + + # Direct dataclass type — recurse + if ( + isinstance(unwrapped, type) + and dataclasses.is_dataclass(unwrapped) + and isinstance(value, dict) + ): + return _dict_to_dataclass(value, unwrapped) + + # Enum type — coerce string/value to the Enum member + if ( + isinstance(unwrapped, type) + and issubclass(unwrapped, enum.Enum) + and not isinstance(value, unwrapped) + ): + return unwrapped(value) + + return value + + +def _dict_to_dataclass(data: dict[str, Any], cls: type) -> Any: + """Recursively convert a dict to a dataclass instance. + + For each key in ``data``: + - If it matches a known dataclass field, the value is converted according + to that field's type annotation (recursing for nested dataclasses). + - Unknown keys are passed through as kwargs; classes decorated with + ``@_additional_properties`` will capture them on the instance's + ``additional_properties`` attribute. + + ``ClassVar`` fields (e.g. the ``additional_properties`` annotation on + decorated dataclasses) are ignored as expected. + """ + if not dataclasses.is_dataclass(cls): + return data + + hints = typing.get_type_hints(cls, include_extras=False) + known_fields = {f.name for f in dataclasses.fields(cls)} + kwargs: dict[str, Any] = {} + + for key, value in data.items(): + if key in known_fields: + kwargs[key] = _convert_value(value, hints.get(key)) + else: + # Unknown key — @_additional_properties decorator will capture it. + kwargs[key] = value + + return cls(**kwargs) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py index d56ce3461a9..c96866ef29c 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/_loader.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any +from opentelemetry.sdk._configuration._conversion import _dict_to_dataclass from opentelemetry.sdk._configuration._exceptions import ConfigurationError from opentelemetry.sdk._configuration.file._env_substitution import ( substitute_env_vars, @@ -172,10 +173,13 @@ def _validate_schema(data: dict) -> None: def _dict_to_model(data: dict[str, Any]) -> OpenTelemetryConfiguration: - """Convert dictionary to OpenTelemetryConfiguration model. + """Convert a parsed config dictionary to the full typed model tree. - Uses the generated dataclass from models.py. This provides basic - validation through dataclass field types. + Walks each field's type annotation, recursively converting nested + dicts to their corresponding dataclass types. The resulting + ``OpenTelemetryConfiguration`` is fully typed end-to-end, so factory + functions can rely on typed attribute access (e.g. ``config.sampler``, + ``config.processors[0].batch.exporter``). Args: data: Parsed configuration dictionary. @@ -187,15 +191,9 @@ def _dict_to_model(data: dict[str, Any]) -> OpenTelemetryConfiguration: TypeError: If data doesn't match expected structure. ValueError: If values are invalid. """ - # Construct the top-level model from the validated dict. Nested fields - # are stored as dicts rather than their dataclass types; factory functions - # in later PRs will handle the full recursive conversion when building - # SDK objects. try: - config = OpenTelemetryConfiguration(**data) - return config + return _dict_to_dataclass(data, OpenTelemetryConfiguration) except TypeError as exc: - # Provide more helpful error message raise TypeError( f"Configuration structure is invalid. " f"Check that all required fields are present and correctly typed: {exc}" diff --git a/opentelemetry-sdk/tests/_configuration/test_conversion.py b/opentelemetry-sdk/tests/_configuration/test_conversion.py new file mode 100644 index 00000000000..328f72e636a --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_conversion.py @@ -0,0 +1,110 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access + +import unittest +from dataclasses import dataclass +from enum import Enum +from typing import Any, ClassVar + +from opentelemetry.sdk._configuration._common import _additional_properties +from opentelemetry.sdk._configuration._conversion import _dict_to_dataclass + + +@dataclass +class _Inner: + value: int | None = None + + +@dataclass +class _Middle: + inner: _Inner | None = None + items: list[_Inner] | None = None + + +@dataclass +class _Outer: + middle: _Middle | None = None + name: str | None = None + + +@_additional_properties +@dataclass +class _WithExtras: + known: str | None = None + additional_properties: ClassVar[dict[str, Any]] + + +class _Level(Enum): + info = "info" + warn = "warn" + + +@dataclass +class _WithEnum: + level: _Level | None = None + + +class TestDictToDataclass(unittest.TestCase): + def test_returns_data_unchanged_for_non_dataclass(self): + self.assertEqual(_dict_to_dataclass({"x": 1}, dict), {"x": 1}) + + def test_converts_flat_dict(self): + result = _dict_to_dataclass({"value": 42}, _Inner) + self.assertIsInstance(result, _Inner) + self.assertEqual(result.value, 42) + + def test_converts_nested_dataclass(self): + result = _dict_to_dataclass( + {"middle": {"inner": {"value": 7}}}, _Outer + ) + self.assertIsInstance(result, _Outer) + self.assertIsInstance(result.middle, _Middle) + self.assertIsInstance(result.middle.inner, _Inner) + self.assertEqual(result.middle.inner.value, 7) + + def test_converts_list_of_dataclasses(self): + result = _dict_to_dataclass( + {"middle": {"items": [{"value": 1}, {"value": 2}]}}, _Outer + ) + self.assertEqual(len(result.middle.items), 2) + self.assertIsInstance(result.middle.items[0], _Inner) + self.assertEqual(result.middle.items[0].value, 1) + self.assertEqual(result.middle.items[1].value, 2) + + def test_none_value_preserved(self): + result = _dict_to_dataclass({"middle": None, "name": "test"}, _Outer) + self.assertIsNone(result.middle) + self.assertEqual(result.name, "test") + + def test_missing_optional_fields_default_to_none(self): + result = _dict_to_dataclass({}, _Outer) + self.assertIsNone(result.middle) + self.assertIsNone(result.name) + + def test_unknown_keys_routed_to_additional_properties(self): + result = _dict_to_dataclass( + {"known": "yes", "my_plugin": {"opt": True}}, _WithExtras + ) + self.assertEqual(result.known, "yes") + self.assertEqual( + result.additional_properties, {"my_plugin": {"opt": True}} + ) + + def test_primitive_values_pass_through(self): + result = _dict_to_dataclass({"name": "hello"}, _Outer) + self.assertEqual(result.name, "hello") + + def test_empty_list_converted(self): + result = _dict_to_dataclass({"middle": {"items": []}}, _Outer) + self.assertEqual(result.middle.items, []) + + def test_enum_value_coerced_from_string(self): + result = _dict_to_dataclass({"level": "info"}, _WithEnum) + self.assertIs(result.level, _Level.info) + + def test_enum_value_already_enum_passes_through(self): + result = _dict_to_dataclass({"level": _Level.warn}, _WithEnum) + self.assertIs(result.level, _Level.warn) From 582c37fd1f296798c5197e5db225c651aa73ba55 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 10:55:58 +0100 Subject: [PATCH 02/11] rename changelog fragment to PR #5269 --- .changelog/{XXXX.added => 5269.added} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{XXXX.added => 5269.added} (100%) diff --git a/.changelog/XXXX.added b/.changelog/5269.added similarity index 100% rename from .changelog/XXXX.added rename to .changelog/5269.added From b302f932aed7e4817bff4e20d525c6d8e27001ba Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 11:16:52 +0100 Subject: [PATCH 03/11] tighten typing on conversion module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../sdk/_configuration/_conversion.py | 14 ++++++++++---- .../tests/_configuration/test_conversion.py | 7 +++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py index f33ed57553f..ad6826e7568 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py @@ -15,7 +15,10 @@ import enum import types import typing -from typing import Any, Union, get_args, get_origin +from collections.abc import Mapping +from typing import Any, TypeVar, Union, get_args, get_origin + +_T = TypeVar("_T") def _unwrap_optional(type_hint: Any) -> Any: @@ -71,8 +74,8 @@ def _convert_value(value: Any, type_hint: Any) -> Any: return value -def _dict_to_dataclass(data: dict[str, Any], cls: type) -> Any: - """Recursively convert a dict to a dataclass instance. +def _dict_to_dataclass(data: Mapping[str, Any], cls: type[_T]) -> _T: + """Recursively convert a mapping to a dataclass instance. For each key in ``data``: - If it matches a known dataclass field, the value is converted according @@ -83,9 +86,12 @@ def _dict_to_dataclass(data: dict[str, Any], cls: type) -> Any: ``ClassVar`` fields (e.g. the ``additional_properties`` annotation on decorated dataclasses) are ignored as expected. + + Raises: + TypeError: If ``cls`` is not a dataclass type. """ if not dataclasses.is_dataclass(cls): - return data + raise TypeError(f"{cls.__name__} is not a dataclass") hints = typing.get_type_hints(cls, include_extras=False) known_fields = {f.name for f in dataclasses.fields(cls)} diff --git a/opentelemetry-sdk/tests/_configuration/test_conversion.py b/opentelemetry-sdk/tests/_configuration/test_conversion.py index 328f72e636a..fe44ca432ac 100644 --- a/opentelemetry-sdk/tests/_configuration/test_conversion.py +++ b/opentelemetry-sdk/tests/_configuration/test_conversion.py @@ -48,8 +48,11 @@ class _WithEnum: class TestDictToDataclass(unittest.TestCase): - def test_returns_data_unchanged_for_non_dataclass(self): - self.assertEqual(_dict_to_dataclass({"x": 1}, dict), {"x": 1}) + def test_raises_on_non_dataclass(self): + # _dict_to_dataclass is internal and assumes cls is a dataclass. + with self.assertRaises(TypeError) as ctx: + _dict_to_dataclass({"x": 1}, dict) + self.assertIn("not a dataclass", str(ctx.exception)) def test_converts_flat_dict(self): result = _dict_to_dataclass({"value": 42}, _Inner) From 3a6fd210d5569df4abe01e01fbba01c28cb7aa59 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 11:49:26 +0100 Subject: [PATCH 04/11] 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 --- .../sdk/_configuration/_conversion.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py index ad6826e7568..5c045680139 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py @@ -74,6 +74,15 @@ def _convert_value(value: Any, type_hint: Any) -> Any: return value +def _resolve_type_hints(cls: type) -> dict[str, Any]: + # Wrapped so callers see ``dict[str, Any]`` rather than the typing internals; + # this keeps astroid from importing Python 3.14's ``annotationlib`` (which + # uses t-strings) during inference under pylint 3.x. See pull/5269 history. + resolved: dict[str, Any] = {} + resolved.update(typing.get_type_hints(cls, include_extras=False)) + return resolved + + def _dict_to_dataclass(data: Mapping[str, Any], cls: type[_T]) -> _T: """Recursively convert a mapping to a dataclass instance. @@ -93,13 +102,14 @@ def _dict_to_dataclass(data: Mapping[str, Any], cls: type[_T]) -> _T: if not dataclasses.is_dataclass(cls): raise TypeError(f"{cls.__name__} is not a dataclass") - hints = typing.get_type_hints(cls, include_extras=False) + hints = _resolve_type_hints(cls) known_fields = {f.name for f in dataclasses.fields(cls)} kwargs: dict[str, Any] = {} for key, value in data.items(): if key in known_fields: - kwargs[key] = _convert_value(value, hints.get(key)) + type_hint = hints.get(key) + kwargs[key] = _convert_value(value, type_hint) else: # Unknown key — @_additional_properties decorator will capture it. kwargs[key] = value From 131378c6e5675244c487a0907eefc0d64995a1a2 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 11:52:51 +0100 Subject: [PATCH 05/11] inline the typing.get_type_hints wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sdk/_configuration/_conversion.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py index 5c045680139..53a67429e0f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_conversion.py @@ -74,15 +74,6 @@ def _convert_value(value: Any, type_hint: Any) -> Any: return value -def _resolve_type_hints(cls: type) -> dict[str, Any]: - # Wrapped so callers see ``dict[str, Any]`` rather than the typing internals; - # this keeps astroid from importing Python 3.14's ``annotationlib`` (which - # uses t-strings) during inference under pylint 3.x. See pull/5269 history. - resolved: dict[str, Any] = {} - resolved.update(typing.get_type_hints(cls, include_extras=False)) - return resolved - - def _dict_to_dataclass(data: Mapping[str, Any], cls: type[_T]) -> _T: """Recursively convert a mapping to a dataclass instance. @@ -102,7 +93,12 @@ def _dict_to_dataclass(data: Mapping[str, Any], cls: type[_T]) -> _T: if not dataclasses.is_dataclass(cls): raise TypeError(f"{cls.__name__} is not a dataclass") - hints = _resolve_type_hints(cls) + # Annotated as ``dict[str, Any]`` so astroid stops tracing into + # ``typing.get_type_hints`` — under pylint 3.x that path leads into + # Python 3.14's ``annotationlib`` (which uses t-strings) and crashes. + hints: dict[str, Any] = dict( + typing.get_type_hints(cls, include_extras=False) + ) known_fields = {f.name for f in dataclasses.fields(cls)} kwargs: dict[str, Any] = {} From 37206210784f55c825c6f5859b29a4e252117015 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 12:17:34 +0100 Subject: [PATCH 06/11] add configure_sdk orchestrator for declarative config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single entry point that takes a parsed OpenTelemetryConfiguration, builds the resource, and applies the tracer/meter/logger providers and propagator globally. Honors the top-level disabled flag — when true, no globals are touched. The orchestrator is a thin composition of the existing per-signal configure_* factories; the deeper unification with the env-var path (see #5126) is left for follow-up. Refs #3631 Refs #5126 Assisted-by: Claude Opus 4.7 --- .changelog/XXXX.added | 1 + .../opentelemetry/sdk/_configuration/_sdk.py | 64 +++++++ .../sdk/_configuration/file/__init__.py | 2 + .../tests/_configuration/test_sdk.py | 170 ++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 .changelog/XXXX.added create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py create mode 100644 opentelemetry-sdk/tests/_configuration/test_sdk.py diff --git a/.changelog/XXXX.added b/.changelog/XXXX.added new file mode 100644 index 00000000000..7f79fad4c20 --- /dev/null +++ b/.changelog/XXXX.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add `configure_sdk(config)` to the declarative configuration API. Single entry point that takes a parsed `OpenTelemetryConfiguration`, builds the resource, and applies the tracer/meter/logger providers and propagator globally. Honors the top-level `disabled` flag. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py new file mode 100644 index 00000000000..dd44480b4df --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_sdk.py @@ -0,0 +1,64 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Top-level orchestrator for declarative SDK configuration. + +Takes a parsed ``OpenTelemetryConfiguration`` and applies it by calling +each per-signal ``configure_*`` factory in order. This is the single +entry point for "apply this config" on the declarative path. +""" + +from __future__ import annotations + +import logging + +from opentelemetry.sdk._configuration._logger_provider import ( + configure_logger_provider, +) +from opentelemetry.sdk._configuration._meter_provider import ( + configure_meter_provider, +) +from opentelemetry.sdk._configuration._propagator import configure_propagator +from opentelemetry.sdk._configuration._resource import create_resource +from opentelemetry.sdk._configuration._tracer_provider import ( + configure_tracer_provider, +) +from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration + +_logger = logging.getLogger(__name__) + + +def configure_sdk(config: OpenTelemetryConfiguration) -> None: + """Configure the global SDK from a parsed declarative configuration. + + Builds a :class:`Resource` from ``config.resource`` and applies it to + each signal provider. Sets the global tracer provider, meter provider, + logger provider, and text map propagator from their respective config + sections. Sections absent from the config (``None``) leave the + corresponding global untouched — matching the spec's "noop default" + behavior. + + Honors the top-level ``disabled`` flag: when true, no globals are set. + + Args: + config: Parsed ``OpenTelemetryConfiguration`` (typically from + ``load_config_file``). + + Example: + >>> from opentelemetry.sdk._configuration.file import ( + ... load_config_file, configure_sdk, + ... ) + >>> config = load_config_file("otel-config.yaml") + >>> configure_sdk(config) + """ + if config.disabled: + _logger.info( + "Declarative configuration has disabled=true; skipping SDK setup." + ) + return + + resource = create_resource(config.resource) + configure_tracer_provider(config.tracer_provider, resource) + configure_meter_provider(config.meter_provider, resource) + configure_logger_provider(config.logger_provider, resource) + configure_propagator(config.propagator) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py index cb1e9ec904f..49b09ba46b0 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/file/__init__.py @@ -27,6 +27,7 @@ create_propagator, ) from opentelemetry.sdk._configuration._resource import create_resource +from opentelemetry.sdk._configuration._sdk import configure_sdk from opentelemetry.sdk._configuration._tracer_provider import ( configure_tracer_provider, create_tracer_provider, @@ -39,6 +40,7 @@ __all__ = [ "load_config_file", + "configure_sdk", "substitute_env_vars", "ConfigurationError", "EnvSubstitutionError", diff --git a/opentelemetry-sdk/tests/_configuration/test_sdk.py b/opentelemetry-sdk/tests/_configuration/test_sdk.py new file mode 100644 index 00000000000..406948036be --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_sdk.py @@ -0,0 +1,170 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# Tests access private members of SDK classes to assert correct configuration. +# pylint: disable=protected-access + +import unittest +from unittest.mock import patch + +from opentelemetry.sdk._configuration._sdk import configure_sdk +from opentelemetry.sdk._configuration.models import ( + OpenTelemetryConfiguration, +) +from opentelemetry.sdk._configuration.models import ( + Propagator as PropagatorConfig, +) +from opentelemetry.sdk._configuration.models import ( + Resource as ResourceConfig, +) +from opentelemetry.sdk._configuration.models import ( + SimpleSpanProcessor as SimpleSpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanExporter as SpanExporterConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanProcessor as SpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + TracerProvider as TracerProviderConfig, +) + + +_MIN_CONFIG_KWARGS = {"file_format": "1.0-rc.1"} + + +def _config(**kwargs) -> OpenTelemetryConfiguration: + return OpenTelemetryConfiguration(**{**_MIN_CONFIG_KWARGS, **kwargs}) + + +class TestConfigureSdk(unittest.TestCase): + @patch("opentelemetry.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._configuration._sdk.create_resource") + def test_calls_each_signal_with_resource( + self, + mock_create_resource, + mock_tracer, + mock_meter, + mock_logger, + mock_propagator, + ): + sentinel_resource = object() + mock_create_resource.return_value = sentinel_resource + + resource_cfg = ResourceConfig() + tracer_cfg = TracerProviderConfig(processors=[]) + propagator_cfg = PropagatorConfig() + config = _config( + resource=resource_cfg, + tracer_provider=tracer_cfg, + propagator=propagator_cfg, + ) + + configure_sdk(config) + + mock_create_resource.assert_called_once_with(resource_cfg) + mock_tracer.assert_called_once_with(tracer_cfg, sentinel_resource) + mock_meter.assert_called_once_with(None, sentinel_resource) + mock_logger.assert_called_once_with(None, sentinel_resource) + mock_propagator.assert_called_once_with(propagator_cfg) + + @patch("opentelemetry.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._configuration._sdk.create_resource") + def test_disabled_skips_everything( + self, + mock_create_resource, + mock_tracer, + mock_meter, + mock_logger, + mock_propagator, + ): + config = _config( + disabled=True, + tracer_provider=TracerProviderConfig(processors=[]), + ) + + configure_sdk(config) + + mock_create_resource.assert_not_called() + mock_tracer.assert_not_called() + mock_meter.assert_not_called() + mock_logger.assert_not_called() + mock_propagator.assert_not_called() + + @patch("opentelemetry.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._configuration._sdk.create_resource") + def test_disabled_false_runs_setup( + self, + mock_create_resource, + mock_tracer, + mock_meter, + mock_logger, + mock_propagator, + ): + config = _config(disabled=False) + configure_sdk(config) + mock_create_resource.assert_called_once() + mock_tracer.assert_called_once() + mock_meter.assert_called_once() + mock_logger.assert_called_once() + mock_propagator.assert_called_once() + + @patch("opentelemetry.sdk._configuration._sdk.configure_propagator") + @patch("opentelemetry.sdk._configuration._sdk.configure_logger_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") + @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.sdk._configuration._sdk.create_resource") + def test_absent_sections_pass_none( + self, + mock_create_resource, # noqa: ARG002 + mock_tracer, + mock_meter, + mock_logger, + mock_propagator, + ): + configure_sdk(_config()) + + # Each configure_* is called exactly once, with config=None. + self.assertEqual(mock_tracer.call_args.args[0], None) + self.assertEqual(mock_meter.call_args.args[0], None) + self.assertEqual(mock_logger.call_args.args[0], None) + self.assertEqual(mock_propagator.call_args.args[0], None) + + +class TestConfigureSdkIntegration(unittest.TestCase): + """End-to-end: build a real OpenTelemetryConfiguration and apply it.""" + + @patch( + "opentelemetry.sdk._configuration._tracer_provider.trace.set_tracer_provider" + ) + def test_applies_tracer_provider_globally(self, mock_set_tracer): + from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider + + config = _config( + tracer_provider=TracerProviderConfig( + processors=[ + SpanProcessorConfig( + simple=SimpleSpanProcessorConfig( + exporter=SpanExporterConfig(console={}) + ) + ) + ] + ) + ) + + configure_sdk(config) + + mock_set_tracer.assert_called_once() + self.assertIsInstance( + mock_set_tracer.call_args[0][0], SdkTracerProvider + ) From fd6c20a29adde9c2fe8fef483155d7e00919b5c5 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 12:41:15 +0100 Subject: [PATCH 07/11] rename changelog fragment to PR #5270 Assisted-by: Claude Opus 4.7 --- .changelog/{XXXX.added => 5270.added} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{XXXX.added => 5270.added} (100%) diff --git a/.changelog/XXXX.added b/.changelog/5270.added similarity index 100% rename from .changelog/XXXX.added rename to .changelog/5270.added From b6e47024ce1c4674ff672fb0b6a498da08fcc989 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 14:03:27 +0100 Subject: [PATCH 08/11] use ExemplarFilter for enum coercion test fixture; allow 'astroid' in codespell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .codespellrc | 2 +- .../tests/_configuration/test_conversion.py | 19 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.codespellrc b/.codespellrc index b82bff46711..788c648bc5b 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,4 +1,4 @@ [codespell] # skipping auto generated folders skip = ./.tox,./.mypy_cache,./docs/_build,./target,*/LICENSE,./venv,.git,./opentelemetry-semantic-conventions,*-requirements*.txt -ignore-words-list = ans,ue,ot,hist,ro +ignore-words-list = ans,ue,ot,hist,ro,astroid diff --git a/opentelemetry-sdk/tests/_configuration/test_conversion.py b/opentelemetry-sdk/tests/_configuration/test_conversion.py index fe44ca432ac..c6cc082e1e3 100644 --- a/opentelemetry-sdk/tests/_configuration/test_conversion.py +++ b/opentelemetry-sdk/tests/_configuration/test_conversion.py @@ -6,11 +6,11 @@ import unittest from dataclasses import dataclass -from enum import Enum from typing import Any, ClassVar from opentelemetry.sdk._configuration._common import _additional_properties from opentelemetry.sdk._configuration._conversion import _dict_to_dataclass +from opentelemetry.sdk._configuration.models import ExemplarFilter @dataclass @@ -37,14 +37,9 @@ class _WithExtras: additional_properties: ClassVar[dict[str, Any]] -class _Level(Enum): - info = "info" - warn = "warn" - - @dataclass class _WithEnum: - level: _Level | None = None + filter: ExemplarFilter | None = None class TestDictToDataclass(unittest.TestCase): @@ -105,9 +100,11 @@ def test_empty_list_converted(self): self.assertEqual(result.middle.items, []) def test_enum_value_coerced_from_string(self): - result = _dict_to_dataclass({"level": "info"}, _WithEnum) - self.assertIs(result.level, _Level.info) + result = _dict_to_dataclass({"filter": "always_on"}, _WithEnum) + self.assertIs(result.filter, ExemplarFilter.always_on) def test_enum_value_already_enum_passes_through(self): - result = _dict_to_dataclass({"level": _Level.warn}, _WithEnum) - self.assertIs(result.level, _Level.warn) + result = _dict_to_dataclass( + {"filter": ExemplarFilter.trace_based}, _WithEnum + ) + self.assertIs(result.filter, ExemplarFilter.trace_based) From 3fc26692ff8e3cf9fa56ac715082fdd24d909d35 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 14:10:26 +0100 Subject: [PATCH 09/11] fix lint on test_sdk.py: hoist import, disable no-self-use Move ``SdkTracerProvider`` import to module top (ruff PLC0415 / pylint C0415) and add explicit ``# pylint: disable=no-self-use`` on the three mock-only tests that intentionally do not touch ``self``. Assisted-by: Claude Opus 4.7 --- opentelemetry-sdk/tests/_configuration/test_sdk.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/tests/_configuration/test_sdk.py b/opentelemetry-sdk/tests/_configuration/test_sdk.py index 406948036be..67f5d3942a2 100644 --- a/opentelemetry-sdk/tests/_configuration/test_sdk.py +++ b/opentelemetry-sdk/tests/_configuration/test_sdk.py @@ -29,6 +29,7 @@ from opentelemetry.sdk._configuration.models import ( TracerProvider as TracerProviderConfig, ) +from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider _MIN_CONFIG_KWARGS = {"file_format": "1.0-rc.1"} @@ -44,6 +45,7 @@ class TestConfigureSdk(unittest.TestCase): @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") @patch("opentelemetry.sdk._configuration._sdk.create_resource") + # pylint: disable=no-self-use def test_calls_each_signal_with_resource( self, mock_create_resource, @@ -77,6 +79,7 @@ def test_calls_each_signal_with_resource( @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") @patch("opentelemetry.sdk._configuration._sdk.create_resource") + # pylint: disable=no-self-use def test_disabled_skips_everything( self, mock_create_resource, @@ -103,6 +106,7 @@ def test_disabled_skips_everything( @patch("opentelemetry.sdk._configuration._sdk.configure_meter_provider") @patch("opentelemetry.sdk._configuration._sdk.configure_tracer_provider") @patch("opentelemetry.sdk._configuration._sdk.create_resource") + # pylint: disable=no-self-use def test_disabled_false_runs_setup( self, mock_create_resource, @@ -148,8 +152,6 @@ class TestConfigureSdkIntegration(unittest.TestCase): "opentelemetry.sdk._configuration._tracer_provider.trace.set_tracer_provider" ) def test_applies_tracer_provider_globally(self, mock_set_tracer): - from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider - config = _config( tracer_provider=TracerProviderConfig( processors=[ From 41667ca0a353fb0b8c282bb3b050096ea40d3bd3 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 14:25:30 +0100 Subject: [PATCH 10/11] remove extra blank line after imports (ruff I001) Assisted-by: Claude Opus 4.7 --- opentelemetry-sdk/tests/_configuration/test_sdk.py | 1 - 1 file changed, 1 deletion(-) diff --git a/opentelemetry-sdk/tests/_configuration/test_sdk.py b/opentelemetry-sdk/tests/_configuration/test_sdk.py index 67f5d3942a2..49f28189302 100644 --- a/opentelemetry-sdk/tests/_configuration/test_sdk.py +++ b/opentelemetry-sdk/tests/_configuration/test_sdk.py @@ -31,7 +31,6 @@ ) from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider - _MIN_CONFIG_KWARGS = {"file_format": "1.0-rc.1"} From 70c93d9ba85e71bda73fa2d9d279f4fc0134f5c6 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 5 Jun 2026 14:38:34 +0100 Subject: [PATCH 11/11] 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 --- .../tests/_configuration/file/test_loader.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/opentelemetry-sdk/tests/_configuration/file/test_loader.py b/opentelemetry-sdk/tests/_configuration/file/test_loader.py index b38f3010ac5..a485c904f0f 100644 --- a/opentelemetry-sdk/tests/_configuration/file/test_loader.py +++ b/opentelemetry-sdk/tests/_configuration/file/test_loader.py @@ -7,11 +7,27 @@ from pathlib import Path from unittest.mock import patch +from opentelemetry.sdk._configuration._tracer_provider import ( + create_tracer_provider, +) from opentelemetry.sdk._configuration.file import ( ConfigurationError, load_config_file, ) +from opentelemetry.sdk._configuration.models import ( + BatchSpanProcessor as BatchSpanProcessorConfig, +) from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration +from opentelemetry.sdk._configuration.models import ( + ParentBasedSampler as ParentBasedSamplerConfig, +) +from opentelemetry.sdk._configuration.models import ( + SpanProcessor as SpanProcessorConfig, +) +from opentelemetry.sdk._configuration.models import ( + TracerProvider as TracerProviderConfig, +) +from opentelemetry.sdk.trace import TracerProvider as SdkTracerProvider class TestConfigLoader(unittest.TestCase): @@ -231,3 +247,61 @@ def test_schema_validation_invalid_enum(self): self.assertIn("schema", str(ctx.exception).lower()) finally: os.unlink(temp_path) + + +class TestConfigLoaderEndToEnd(unittest.TestCase): + """Smoke-test the full YAML -> typed config -> SDK object pipeline. + + Unit tests in test_conversion.py exercise the dict-to-dataclass + conversion in isolation; these tests verify it composes with the + real loader and downstream factory functions on a representative + nested configuration. + """ + + _YAML = """ +file_format: '1.0-rc.1' +tracer_provider: + processors: + - batch: + exporter: + console: {} + sampler: + parent_based: + root: + trace_id_ratio_based: {ratio: 0.5} +""" + + def _load(self) -> OpenTelemetryConfiguration: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False + ) as fh: + fh.write(self._YAML) + path = fh.name + try: + return load_config_file(path) + finally: + os.unlink(path) + + def test_nested_fields_are_typed_dataclasses(self): + config = self._load() + + self.assertIsInstance(config.tracer_provider, TracerProviderConfig) + self.assertIsInstance( + config.tracer_provider.sampler.parent_based, + ParentBasedSamplerConfig, + ) + # Lists of dataclasses are converted element-wise. + self.assertIsInstance( + config.tracer_provider.processors[0], SpanProcessorConfig + ) + self.assertIsInstance( + config.tracer_provider.processors[0].batch, + BatchSpanProcessorConfig, + ) + + def test_typed_config_feeds_factory_function(self): + config = self._load() + + provider = create_tracer_provider(config.tracer_provider) + + self.assertIsInstance(provider, SdkTracerProvider)