From 9e7d6534b6d9af9798eb691ae397245e91b4ba5d Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 22 Jul 2026 01:20:30 -0500 Subject: [PATCH 1/2] Fix declarative config present-but-null components to build with defaults A present-but-null YAML value (e.g. `console:` with no value) previously mapped to `None` during dict-to-dataclass conversion, making it indistinguishable from an absent key. Component factories select on `value is not None`, so a present-null component raised a ConfigurationError instead of building the component with defaults. The configuration spec requires parsing to distinguish a missing key from a present-null one, and a present-null component MUST be created with all defaults. Because `_convert_value` is only invoked for keys that are actually present (absent keys fall through to dataclass field defaults), a `None` value there always means present-null. It is now carried through as: - a defaults-only dataclass instance for dataclass-typed fields, - an empty mapping for mapping-alias fields (e.g. the console exporter), - `None` for primitives (already their use-default value). Absent keys remain `None`, preserving 'not configured' semantics. --- .changelog/0000.fixed | 4 ++ .../configuration/_conversion.py | 42 +++++++++++++++++-- .../tests/test_conversion.py | 42 +++++++++++++++++-- .../tests/test_tracer_provider.py | 22 ++++++++++ 4 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 .changelog/0000.fixed diff --git a/.changelog/0000.fixed b/.changelog/0000.fixed new file mode 100644 index 00000000000..9ff95164dbd --- /dev/null +++ b/.changelog/0000.fixed @@ -0,0 +1,4 @@ +`opentelemetry-configuration`: treat a present-but-null component value (e.g. +`console:` with no value) as "create the component with defaults" instead of +raising a `ConfigurationError`, per the declarative configuration spec's +requirement to distinguish a missing key from a present-null one diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_conversion.py b/opentelemetry-configuration/src/opentelemetry/configuration/_conversion.py index c388b966b7e..13df53cf02f 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_conversion.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_conversion.py @@ -12,7 +12,7 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import fields, is_dataclass +from dataclasses import MISSING, fields, is_dataclass from enum import Enum from types import UnionType from typing import Any, TypeVar, Union, get_args, get_origin, get_type_hints @@ -39,13 +39,47 @@ def _convert_value(value: Any, type_hint: Any) -> Any: 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 + Present-but-null handling: this function is only called for keys that are + actually present in the parsed mapping (absent keys fall through to the + dataclass field defaults in ``_dict_to_dataclass``). A present key whose + value is ``None`` therefore means "present but null", which the + configuration spec requires be treated as "create this component with all + defaults" rather than "not configured". To keep that distinguishable from + an absent key when a component factory later selects on + ``value is not None``: + + - A present-null field typed as a dataclass becomes a defaults-only + instance of that dataclass (e.g. ``otlp_http:`` -> ``OtlpHttpExporter()`` + with every field ``None``, so the exporter reads its own defaults). + - A present-null field typed as a mapping alias (e.g. the ``console`` + exporter, ``dict[str, Any] | None``) becomes an empty mapping ``{}`` so + the selecting factory still fires. + - A present-null primitive (e.g. ``endpoint:``) stays ``None``, which is + already the "use default" value for such fields. + """ unwrapped = _unwrap_optional(type_hint) origin = get_origin(unwrapped) + if value is None: + # Present-but-null: build the component with defaults where the field + # is a component (dataclass or mapping-alias); leave primitives None. + # A dataclass can only be built here if every field is optional (has a + # default or default_factory); otherwise ``unwrapped()`` would raise. + if ( + isinstance(unwrapped, type) + and is_dataclass(unwrapped) + and all( + field.default is not MISSING + or field.default_factory is not MISSING + for field in fields(unwrapped) + ) + ): + return unwrapped() + if origin is dict: + return {} + return None + # list[X] — recurse on each element if origin is list and isinstance(value, list): args = get_args(unwrapped) diff --git a/opentelemetry-configuration/tests/test_conversion.py b/opentelemetry-configuration/tests/test_conversion.py index 6ae15c6e648..c8dc657332b 100644 --- a/opentelemetry-configuration/tests/test_conversion.py +++ b/opentelemetry-configuration/tests/test_conversion.py @@ -10,7 +10,7 @@ from opentelemetry.configuration._common import _additional_properties from opentelemetry.configuration._conversion import _dict_to_dataclass -from opentelemetry.configuration.models import ExemplarFilter +from opentelemetry.configuration.models import ExemplarFilter, SpanExporter @dataclass @@ -72,16 +72,52 @@ def test_converts_list_of_dataclasses(self): self.assertEqual(result.middle.items[0].value, 1) self.assertEqual(result.middle.items[1].value, 2) - def test_none_value_preserved(self): + def test_present_null_dataclass_becomes_defaults_instance(self): + # A present-but-null value for a dataclass-typed field must build that + # dataclass with all defaults, so it is distinguishable from an absent + # key. Primitives present-null stay None (their "use default" value). result = _dict_to_dataclass({"middle": None, "name": "test"}, _Outer) - self.assertIsNone(result.middle) + self.assertIsInstance(result.middle, _Middle) + self.assertIsNone(result.middle.inner) + self.assertIsNone(result.middle.items) self.assertEqual(result.name, "test") + def test_present_null_primitive_stays_none(self): + result = _dict_to_dataclass({"name": None}, _Outer) + self.assertIsNone(result.name) + def test_missing_optional_fields_default_to_none(self): + # Absent keys stay None; this is what "not configured" looks like and + # must remain distinguishable from present-null. result = _dict_to_dataclass({}, _Outer) self.assertIsNone(result.middle) self.assertIsNone(result.name) + def test_present_null_mapping_alias_becomes_empty_dict(self): + # The console exporter field is typed as ``dict[str, Any] | None``. + # A present-null value must become an empty mapping so a component + # factory selecting on ``value is not None`` still fires and builds + # the console exporter with defaults. + result = _dict_to_dataclass({"console": None}, SpanExporter) + self.assertEqual(result.console, {}) + + def test_absent_component_stays_none(self): + # Absent component keys must remain None ("not configured"). + result = _dict_to_dataclass({}, SpanExporter) + self.assertIsNone(result.console) + self.assertIsNone(result.otlp_http) + + def test_populated_component_mapping_still_converts(self): + # A populated component mapping must still convert into a typed + # dataclass instance with its values carried through. + result = _dict_to_dataclass( + {"otlp_http": {"endpoint": "http://localhost:4318"}}, SpanExporter + ) + self.assertIsNone(result.console) + self.assertEqual( + result.otlp_http.endpoint, "http://localhost:4318" + ) + def test_unknown_keys_routed_to_additional_properties(self): result = _dict_to_dataclass( {"known": "yes", "my_plugin": {"opt": True}}, _WithExtras diff --git a/opentelemetry-configuration/tests/test_tracer_provider.py b/opentelemetry-configuration/tests/test_tracer_provider.py index b8fe78f82a7..22634061805 100644 --- a/opentelemetry-configuration/tests/test_tracer_provider.py +++ b/opentelemetry-configuration/tests/test_tracer_provider.py @@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch from opentelemetry import trace as trace_api +from opentelemetry.configuration._conversion import _dict_to_dataclass from opentelemetry.configuration._tracer_provider import ( configure_tracer_provider, create_tracer_provider, @@ -569,6 +570,27 @@ def test_console_exporter_simple(self): self.assertIsInstance(procs[0], SimpleSpanProcessor) self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) + def test_present_null_console_exporter_builds_with_defaults(self): + # A present-but-null console exporter (``console:`` with no value in + # YAML) is carried through conversion as an empty mapping and must + # build a ConsoleSpanExporter with defaults instead of raising. + exporter_config = _dict_to_dataclass( + {"console": None}, SpanExporterConfig + ) + config = self._make_batch_config(exporter_config) + provider = create_tracer_provider(config) + procs = provider._active_span_processor._span_processors + self.assertEqual(len(procs), 1) + self.assertIsInstance(procs[0].span_exporter, ConsoleSpanExporter) + + def test_absent_exporter_type_raises(self): + # An absent exporter (empty mapping, no key present) must still raise + # "no exporter type specified". + exporter_config = _dict_to_dataclass({}, SpanExporterConfig) + config = self._make_batch_config(exporter_config) + with self.assertRaises(ConfigurationError): + create_tracer_provider(config) + def test_otlp_http_missing_package_raises(self): config = self._make_batch_config( SpanExporterConfig(otlp_http=OtlpHttpExporterConfig()) From 61009e88dd8877e9956f0b37b72f75a4f55b5458 Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 22 Jul 2026 08:37:57 -0500 Subject: [PATCH 2/2] Rename changelog fragment to match PR number --- .changelog/{0000.fixed => 34.fixed} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{0000.fixed => 34.fixed} (100%) diff --git a/.changelog/0000.fixed b/.changelog/34.fixed similarity index 100% rename from .changelog/0000.fixed rename to .changelog/34.fixed