From 3b3ff506a41b70961b0c8faeb40a93bb650bd48a 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 +++ .../tests/test_conversion.py | 36 ++++++++++++++++++- .../tests/test_tracer_provider.py | 21 +++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 .changelog/0000.fixed diff --git a/.changelog/0000.fixed b/.changelog/0000.fixed new file mode 100644 index 0000000000..9ff95164db --- /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/tests/test_conversion.py b/opentelemetry-configuration/tests/test_conversion.py index e12df18168..794c70c97f 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 from opentelemetry.configuration.models import Sampler as SamplerConfig @@ -83,6 +83,7 @@ def test_present_null_dataclass_coerced_to_empty_instance(self): result = _dict_to_dataclass({"middle": None, "name": "test"}, _Outer) self.assertIsInstance(result.middle, _Middle) self.assertIsNone(result.middle.inner) + self.assertIsNone(result.middle.items) self.assertEqual(result.name, "test") def test_present_null_dataclass_with_required_field_stays_none(self): @@ -93,11 +94,44 @@ def test_present_null_dataclass_with_required_field_stays_none(self): result = _dict_to_dataclass({"jaeger_remote_development": None}, SamplerConfig) self.assertIsNone(result.jaeger_remote_development) + def test_present_null_primitive_stays_none(self): + # A present-null primitive (e.g. a scalar field) stays None, which is + # already the "use default" value for such fields. + 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) self.assertEqual(result.known, "yes") diff --git a/opentelemetry-configuration/tests/test_tracer_provider.py b/opentelemetry-configuration/tests/test_tracer_provider.py index 9c950a0e04..bcdd6996f5 100644 --- a/opentelemetry-configuration/tests/test_tracer_provider.py +++ b/opentelemetry-configuration/tests/test_tracer_provider.py @@ -538,6 +538,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())) with patch.dict( From 2c4b7a326d0d068e6bb0a5fc42698afb5bb2a269 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 .../tests/test_conversion.py | 14 ++------------ .../tests/test_tracer_provider.py | 4 +--- 3 files changed, 3 insertions(+), 15 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 diff --git a/opentelemetry-configuration/tests/test_conversion.py b/opentelemetry-configuration/tests/test_conversion.py index 794c70c97f..e7f58f1663 100644 --- a/opentelemetry-configuration/tests/test_conversion.py +++ b/opentelemetry-configuration/tests/test_conversion.py @@ -94,12 +94,6 @@ def test_present_null_dataclass_with_required_field_stays_none(self): result = _dict_to_dataclass({"jaeger_remote_development": None}, SamplerConfig) self.assertIsNone(result.jaeger_remote_development) - def test_present_null_primitive_stays_none(self): - # A present-null primitive (e.g. a scalar field) stays None, which is - # already the "use default" value for such fields. - 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. @@ -124,13 +118,9 @@ def test_absent_component_stays_none(self): 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 - ) + result = _dict_to_dataclass({"otlp_http": {"endpoint": "http://localhost:4318"}}, SpanExporter) self.assertIsNone(result.console) - self.assertEqual( - result.otlp_http.endpoint, "http://localhost:4318" - ) + 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 bcdd6996f5..79a68fc548 100644 --- a/opentelemetry-configuration/tests/test_tracer_provider.py +++ b/opentelemetry-configuration/tests/test_tracer_provider.py @@ -542,9 +542,7 @@ 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 - ) + 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