Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changelog/34.fixed
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
42 changes: 39 additions & 3 deletions opentelemetry-configuration/tests/test_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions opentelemetry-configuration/tests/test_tracer_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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())
Expand Down
Loading