diff --git a/.changelog/34.fixed b/.changelog/34.fixed new file mode 100644 index 0000000000..9ff95164db --- /dev/null +++ b/.changelog/34.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..e7f58f1663 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): @@ -94,10 +95,33 @@ def test_present_null_dataclass_with_required_field_stays_none(self): self.assertIsNone(result.jaeger_remote_development) 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..79a68fc548 100644 --- a/opentelemetry-configuration/tests/test_tracer_provider.py +++ b/opentelemetry-configuration/tests/test_tracer_provider.py @@ -538,6 +538,25 @@ 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(