From e5d6932f19feeeb23e195dd9ca24ddc298933b17 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 10:55:28 +0100 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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 7066419ace9ffa920f8bcebff1d60cc5d2df7d89 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 13:48:21 +0100 Subject: [PATCH 08/19] honor OTEL_CONFIG_FILE in the SDK configurator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the environment variable is set, route the SDK through the declarative config path — load the file via load_config_file() and apply it via configure_sdk() — in place of the env-var-based _initialize_components(). Other OTEL_* vars are ignored (per spec v1.0.0: when a config file is given, it is the sole source of truth). Kwargs passed to _OTelSDKConfigurator._configure are ignored with a warning when the file path is set, so distros that inject kwargs via super() see a clear signal rather than silent drops. The file-loader imports (pyyaml, jsonschema) stay lazy so installs without the file-configuration extras are not affected. Refs #3631 Assisted-by: Claude Opus 4.7 --- .changelog/XXXX.added | 1 + .../sdk/_configuration/__init__.py | 19 ++++ .../sdk/environment_variables/__init__.py | 12 +++ .../test_configurator_file_routing.py | 99 +++++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 .changelog/XXXX.added create mode 100644 opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py diff --git a/.changelog/XXXX.added b/.changelog/XXXX.added new file mode 100644 index 00000000000..f69fb5c17c7 --- /dev/null +++ b/.changelog/XXXX.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: the SDK configurator now honors the `OTEL_CONFIG_FILE` environment variable. When set, the SDK loads and applies the referenced declarative configuration file (YAML or JSON) in place of the env-var-based init path. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index 3b6e2ba9e97..decfac60718 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -37,6 +37,7 @@ ) from opentelemetry.sdk.environment_variables import ( _OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED, + OTEL_CONFIG_FILE, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL, OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, OTEL_EXPORTER_OTLP_PROTOCOL, @@ -677,4 +678,22 @@ class _OTelSDKConfigurator(_BaseConfigurator): """ def _configure(self, **kwargs): + config_file = environ.get(OTEL_CONFIG_FILE) + if config_file: + # Imported lazily so that the SDK does not require the optional + # file-configuration extras (pyyaml, jsonschema) unless a config + # file is actually requested. + from opentelemetry.sdk._configuration._sdk import configure_sdk + from opentelemetry.sdk._configuration.file._loader import ( + load_config_file, + ) + + if kwargs: + _logger.warning( + "%s is set; ignoring configurator kwargs: %s", + OTEL_CONFIG_FILE, + sorted(kwargs), + ) + configure_sdk(load_config_file(config_file)) + return _initialize_components(**kwargs) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index 94738ca4697..e7d04c4eb02 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -9,6 +9,18 @@ Default: "false" """ +OTEL_CONFIG_FILE = "OTEL_CONFIG_FILE" +""" +.. envvar:: OTEL_CONFIG_FILE + +The :envvar:`OTEL_CONFIG_FILE` environment variable points the SDK at a +declarative configuration file (YAML or JSON). When set, the file is the +sole source of SDK configuration; other ``OTEL_*`` environment variables +are ignored except where referenced via ``${env:VAR}`` substitution inside +the file. See the OpenTelemetry declarative configuration specification +for details. +""" + OTEL_RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES" """ .. envvar:: OTEL_RESOURCE_ATTRIBUTES diff --git a/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py new file mode 100644 index 00000000000..27ec6eec6ca --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py @@ -0,0 +1,99 @@ +# 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 import _OTelSDKConfigurator +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk.environment_variables import OTEL_CONFIG_FILE + + +class TestConfiguratorFileRouting(unittest.TestCase): + def setUp(self): + # _BaseConfigurator caches instances via a singleton; reset so each + # test sees a clean state and doesn't poison MRO lookups for sibling + # tests (e.g. test_configurator.py's CustomConfigurator subclass). + _OTelSDKConfigurator._instance = None + + def tearDown(self): + _OTelSDKConfigurator._instance = None + + @patch.dict("os.environ", {}, clear=True) + @patch("opentelemetry.sdk._configuration._initialize_components") + # pylint: disable=no-self-use + def test_env_var_unset_runs_env_var_path(self, mock_init_components): + _OTelSDKConfigurator()._configure(auto_instrumentation_version="X") + mock_init_components.assert_called_once_with( + auto_instrumentation_version="X" + ) + + @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"}) + @patch("opentelemetry.sdk._configuration._sdk.configure_sdk") + @patch( + "opentelemetry.sdk._configuration.file._loader.load_config_file" + ) + @patch("opentelemetry.sdk._configuration._initialize_components") + # pylint: disable=no-self-use + def test_env_var_set_routes_to_declarative_path( + self, mock_init_components, mock_load, mock_configure_sdk + ): + sentinel_config = object() + mock_load.return_value = sentinel_config + + _OTelSDKConfigurator()._configure() + + mock_load.assert_called_once_with("/tmp/otel.yaml") + mock_configure_sdk.assert_called_once_with(sentinel_config) + mock_init_components.assert_not_called() + + @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/does/not/exist.yaml"}) + @patch("opentelemetry.sdk._configuration._initialize_components") + # pylint: disable=no-self-use + def test_env_var_set_missing_file_propagates(self, mock_init_components): + with self.assertRaises(ConfigurationError): + _OTelSDKConfigurator()._configure() + mock_init_components.assert_not_called() + + @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"}) + @patch("opentelemetry.sdk._configuration._sdk.configure_sdk") + @patch( + "opentelemetry.sdk._configuration.file._loader.load_config_file" + ) + def test_env_var_set_with_kwargs_warns_and_ignores( + self, mock_load, mock_configure_sdk + ): + mock_load.return_value = object() + + with self.assertLogs( + "opentelemetry.sdk._configuration", level="WARNING" + ) as captured: + _OTelSDKConfigurator()._configure( + sampler="X", auto_instrumentation_version="Y" + ) + + self.assertTrue( + any( + "OTEL_CONFIG_FILE" in msg and "sampler" in msg + for msg in captured.output + ), + f"Expected warning about ignored kwargs, got: {captured.output}", + ) + mock_configure_sdk.assert_called_once() + + @patch.dict("os.environ", {}, clear=True) + @patch("opentelemetry.sdk._configuration._initialize_components") + def test_distro_override_pattern_still_works(self, mock_init_components): + class CustomConfigurator(_OTelSDKConfigurator): + def _configure(self, **kwargs): + kwargs["sampler"] = "TEST_SAMPLER" + super()._configure(**kwargs) + + CustomConfigurator()._configure(auto_instrumentation_version="V") + + mock_init_components.assert_called_once_with( + auto_instrumentation_version="V", sampler="TEST_SAMPLER" + ) From da80d63f78bf78815d2f86d58d2bf6feeac5156e Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 13:50:42 +0100 Subject: [PATCH 09/19] rename changelog fragment to PR #5271 Assisted-by: Claude Opus 4.7 --- .changelog/{XXXX.added => 5271.added} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{XXXX.added => 5271.added} (100%) diff --git a/.changelog/XXXX.added b/.changelog/5271.added similarity index 100% rename from .changelog/XXXX.added rename to .changelog/5271.added From b6e47024ce1c4674ff672fb0b6a498da08fcc989 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 14:03:27 +0100 Subject: [PATCH 10/19] 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 11/19] 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 417d451d458af1899695d8b9941620f765c37ce8 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 14:12:06 +0100 Subject: [PATCH 12/19] silence pylint/ruff on intentional lazy imports The configure_sdk / load_config_file imports inside ``_configure`` are deliberately deferred so that the SDK does not pull in the optional file-configuration extras (pyyaml, jsonschema) unless ``OTEL_CONFIG_FILE`` is actually set. Annotate with the corresponding pylint and ruff suppressions; the existing comment already explains why. Assisted-by: Claude Opus 4.7 --- .../src/opentelemetry/sdk/_configuration/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index decfac60718..2d42e8c38f1 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -683,8 +683,11 @@ def _configure(self, **kwargs): # Imported lazily so that the SDK does not require the optional # file-configuration extras (pyyaml, jsonschema) unless a config # file is actually requested. - from opentelemetry.sdk._configuration._sdk import configure_sdk - from opentelemetry.sdk._configuration.file._loader import ( + # pylint: disable=import-outside-toplevel + from opentelemetry.sdk._configuration._sdk import ( # noqa: PLC0415 + configure_sdk, + ) + from opentelemetry.sdk._configuration.file._loader import ( # noqa: PLC0415 load_config_file, ) From 41667ca0a353fb0b8c282bb3b050096ea40d3bd3 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 14:25:30 +0100 Subject: [PATCH 13/19] 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 2b2d47ba11f943b18a57c45eda8c56d95e321b38 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 15:26:57 +0100 Subject: [PATCH 14/19] collapse multi-line @patch decorators (ruff format) Assisted-by: Claude Opus 4.7 --- .../_configuration/test_configurator_file_routing.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py index 27ec6eec6ca..6baee55a145 100644 --- a/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py +++ b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py @@ -33,9 +33,7 @@ def test_env_var_unset_runs_env_var_path(self, mock_init_components): @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"}) @patch("opentelemetry.sdk._configuration._sdk.configure_sdk") - @patch( - "opentelemetry.sdk._configuration.file._loader.load_config_file" - ) + @patch("opentelemetry.sdk._configuration.file._loader.load_config_file") @patch("opentelemetry.sdk._configuration._initialize_components") # pylint: disable=no-self-use def test_env_var_set_routes_to_declarative_path( @@ -60,9 +58,7 @@ def test_env_var_set_missing_file_propagates(self, mock_init_components): @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/tmp/otel.yaml"}) @patch("opentelemetry.sdk._configuration._sdk.configure_sdk") - @patch( - "opentelemetry.sdk._configuration.file._loader.load_config_file" - ) + @patch("opentelemetry.sdk._configuration.file._loader.load_config_file") def test_env_var_set_with_kwargs_warns_and_ignores( self, mock_load, mock_configure_sdk ): From f844bb61ffc1a68475553335066d2c5cc7f71963 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Wed, 3 Jun 2026 17:37:02 +0100 Subject: [PATCH 15/19] add public opentelemetry.sdk.configuration namespace Re-exports the core declarative-config entry points from a non- underscore module: - configure_sdk - load_config_file - OpenTelemetryConfiguration - ConfigurationError ``load_config_file`` is resolved through module-level ``__getattr__`` so the optional file-configuration extras (pyyaml, jsonschema) are only required when a caller actually reaches for it; callers that build an ``OpenTelemetryConfiguration`` programmatically can use ``configure_sdk`` without installing the extras. Refs #3631 Assisted-by: Claude Opus 4.7 --- .changelog/XXXX.added | 1 + .../sdk/configuration/__init__.py | 56 +++++++++++++++++++ .../_configuration/test_public_namespace.py | 47 ++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 .changelog/XXXX.added create mode 100644 opentelemetry-sdk/src/opentelemetry/sdk/configuration/__init__.py create mode 100644 opentelemetry-sdk/tests/_configuration/test_public_namespace.py diff --git a/.changelog/XXXX.added b/.changelog/XXXX.added new file mode 100644 index 00000000000..5be7ec4e636 --- /dev/null +++ b/.changelog/XXXX.added @@ -0,0 +1 @@ +`opentelemetry-sdk`: add public `opentelemetry.sdk.configuration` module that re-exports `configure_sdk`, `load_config_file`, `OpenTelemetryConfiguration`, and `ConfigurationError`. `load_config_file` is resolved lazily so the file-configuration extras (pyyaml, jsonschema) remain optional for callers that build an `OpenTelemetryConfiguration` programmatically. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/configuration/__init__.py new file mode 100644 index 00000000000..59fc63e6594 --- /dev/null +++ b/opentelemetry-sdk/src/opentelemetry/sdk/configuration/__init__.py @@ -0,0 +1,56 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Public API for the OpenTelemetry SDK's declarative configuration. + +Load a parsed configuration from a YAML/JSON file and apply it to the +process-global SDK providers: + +>>> from opentelemetry.sdk.configuration import ( +... load_config_file, configure_sdk, +... ) +>>> config = load_config_file("otel-config.yaml") +>>> configure_sdk(config) + +Construct a configuration programmatically and apply it: + +>>> from opentelemetry.sdk.configuration import ( +... OpenTelemetryConfiguration, configure_sdk, +... ) +>>> configure_sdk(OpenTelemetryConfiguration(file_format="1.0-rc.1")) + +Loading from a file requires the optional ``[file-configuration]`` extras +(``pyyaml`` and ``jsonschema``). ``configure_sdk`` itself has no extra +dependencies — callers that construct an ``OpenTelemetryConfiguration`` +directly can use it without installing the extras. +""" + +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration._sdk import configure_sdk +from opentelemetry.sdk._configuration.models import OpenTelemetryConfiguration + + +def __getattr__(name: str): + # ``load_config_file`` lives behind the optional file-configuration + # extras (pyyaml, jsonschema). Resolve it lazily so importing this + # module does not require those extras for callers that only use + # ``configure_sdk`` with a programmatically built configuration. + if name == "load_config_file": + # pylint: disable=import-outside-toplevel + from opentelemetry.sdk._configuration.file._loader import ( # noqa: PLC0415 + load_config_file, + ) + + return load_config_file + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +# ``load_config_file`` is exposed via ``__getattr__`` rather than a module-level +# binding so the file-configuration extras stay optional. Pylint's static +# analysis doesn't see ``__getattr__`` and flags it as undefined; suppress. +__all__ = [ + "ConfigurationError", + "OpenTelemetryConfiguration", + "configure_sdk", + "load_config_file", # pylint: disable=undefined-all-variable +] diff --git a/opentelemetry-sdk/tests/_configuration/test_public_namespace.py b/opentelemetry-sdk/tests/_configuration/test_public_namespace.py new file mode 100644 index 00000000000..dceb60105ef --- /dev/null +++ b/opentelemetry-sdk/tests/_configuration/test_public_namespace.py @@ -0,0 +1,47 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +# pylint: disable=protected-access + +import unittest + +from opentelemetry.sdk import configuration +from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration._sdk import configure_sdk +from opentelemetry.sdk._configuration.file._loader import load_config_file +from opentelemetry.sdk._configuration.models import ( + OpenTelemetryConfiguration, +) + +_PUBLIC_NAMES = ( + "ConfigurationError", + "OpenTelemetryConfiguration", + "configure_sdk", + "load_config_file", +) + + +class TestPublicNamespace(unittest.TestCase): + def test_public_symbols_resolve(self): + for name in _PUBLIC_NAMES: + self.assertTrue( + hasattr(configuration, name), + f"{name!r} missing from public namespace", + ) + + def test_public_symbols_match_private(self): + # Public namespace re-exports the same objects, not copies. + self.assertIs(configuration.ConfigurationError, ConfigurationError) + self.assertIs( + configuration.OpenTelemetryConfiguration, + OpenTelemetryConfiguration, + ) + self.assertIs(configuration.configure_sdk, configure_sdk) + self.assertIs(configuration.load_config_file, load_config_file) + + def test_unknown_attribute_raises(self): + with self.assertRaises(AttributeError): + _ = configuration.no_such_thing + + def test_dunder_all_is_exhaustive(self): + self.assertEqual(sorted(configuration.__all__), list(_PUBLIC_NAMES)) From 7ca840f45b5d457268ef8c24a785cc5b6b4ee1ad Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 5 Jun 2026 14:05:31 +0100 Subject: [PATCH 16/19] rename changelog fragment to PR #5276 Assisted-by: Claude Opus 4.7 --- .changelog/{XXXX.added => 5276.added} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{XXXX.added => 5276.added} (100%) diff --git a/.changelog/XXXX.added b/.changelog/5276.added similarity index 100% rename from .changelog/XXXX.added rename to .changelog/5276.added From 70c93d9ba85e71bda73fa2d9d279f4fc0134f5c6 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 5 Jun 2026 14:38:34 +0100 Subject: [PATCH 17/19] 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) From 73982d9ebb48cdcae1e725ea7064693db9673d20 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Tue, 16 Jun 2026 11:18:34 +0100 Subject: [PATCH 18/19] address review feedback on OTEL_CONFIG_FILE routing Use a walrus operator in _configure, simplify singleton reset to tearDown only, and hoist no-self-use pylint disable to file scope. --- .../opentelemetry/sdk/_configuration/__init__.py | 3 +-- .../test_configurator_file_routing.py | 14 ++++---------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py index be9d7241331..46d1ca7ddfc 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/__init__.py @@ -721,8 +721,7 @@ class _OTelSDKConfigurator(_BaseConfigurator): """ def _configure(self, **kwargs): - config_file = environ.get(OTEL_CONFIG_FILE) - if config_file: + if config_file := environ.get(OTEL_CONFIG_FILE): # Imported lazily so that the SDK does not require the optional # file-configuration extras (pyyaml, jsonschema) unless a config # file is actually requested. diff --git a/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py index 6baee55a145..89b2098217b 100644 --- a/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py +++ b/opentelemetry-sdk/tests/_configuration/test_configurator_file_routing.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Tests access private members of SDK classes to assert correct configuration. -# pylint: disable=protected-access +# pylint: disable=protected-access,no-self-use import unittest from unittest.mock import patch @@ -13,18 +13,14 @@ class TestConfiguratorFileRouting(unittest.TestCase): - def setUp(self): - # _BaseConfigurator caches instances via a singleton; reset so each - # test sees a clean state and doesn't poison MRO lookups for sibling - # tests (e.g. test_configurator.py's CustomConfigurator subclass). - _OTelSDKConfigurator._instance = None - def tearDown(self): + # _BaseConfigurator caches instances via a singleton; reset so sibling + # tests (e.g. test_configurator.py's CustomConfigurator subclass) are + # not affected by this class's singleton state. _OTelSDKConfigurator._instance = None @patch.dict("os.environ", {}, clear=True) @patch("opentelemetry.sdk._configuration._initialize_components") - # pylint: disable=no-self-use def test_env_var_unset_runs_env_var_path(self, mock_init_components): _OTelSDKConfigurator()._configure(auto_instrumentation_version="X") mock_init_components.assert_called_once_with( @@ -35,7 +31,6 @@ def test_env_var_unset_runs_env_var_path(self, mock_init_components): @patch("opentelemetry.sdk._configuration._sdk.configure_sdk") @patch("opentelemetry.sdk._configuration.file._loader.load_config_file") @patch("opentelemetry.sdk._configuration._initialize_components") - # pylint: disable=no-self-use def test_env_var_set_routes_to_declarative_path( self, mock_init_components, mock_load, mock_configure_sdk ): @@ -50,7 +45,6 @@ def test_env_var_set_routes_to_declarative_path( @patch.dict("os.environ", {OTEL_CONFIG_FILE: "/does/not/exist.yaml"}) @patch("opentelemetry.sdk._configuration._initialize_components") - # pylint: disable=no-self-use def test_env_var_set_missing_file_propagates(self, mock_init_components): with self.assertRaises(ConfigurationError): _OTelSDKConfigurator()._configure() From 4ce08db3b14596c4c264e786236dc6119aa63e42 Mon Sep 17 00:00:00 2001 From: Mike Goldsmith Date: Fri, 19 Jun 2026 09:19:39 +0100 Subject: [PATCH 19/19] tighten OTEL_CONFIG_FILE docstring (review feedback from herin049) The previous wording overstated the env-var contract by implying all ``OTEL_*`` variables are ignored when ``OTEL_CONFIG_FILE`` is set. That's only true for spec-defined variables with schema equivalents: * resource detectors enabled in the config can still read env vars at runtime (e.g. ``OTEL_RESOURCE_ATTRIBUTES``, ``OTEL_SERVICE_NAME``) * ``${env:VAR}`` substitutions inside the file remain in effect Reword to be precise about both. Assisted-by: Claude Opus 4.7 --- .../opentelemetry/sdk/environment_variables/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py index e7d04c4eb02..bb76fe405b9 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/environment_variables/__init__.py @@ -15,10 +15,11 @@ The :envvar:`OTEL_CONFIG_FILE` environment variable points the SDK at a declarative configuration file (YAML or JSON). When set, the file is the -sole source of SDK configuration; other ``OTEL_*`` environment variables -are ignored except where referenced via ``${env:VAR}`` substitution inside -the file. See the OpenTelemetry declarative configuration specification -for details. +sole source for SDK construction. Spec-defined ``OTEL_*`` variables with +schema equivalents are ignored. Env vars may still be read indirectly by +components the file enables (e.g. resource detectors) and via +``${env:VAR}`` substitution inside the file. See the OpenTelemetry +declarative configuration specification for details. """ OTEL_RESOURCE_ATTRIBUTES = "OTEL_RESOURCE_ATTRIBUTES"