From c1c1f7dd4a94f5c176999a6bf8ee61bc1e2dc1f6 Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 22 Jul 2026 01:33:18 -0500 Subject: [PATCH 1/2] Add pure create() and ConfigProvider/ConfigProperties to declarative config Add a stateless create(model) -> Providers that builds the SDK providers without mutating global state, and have configure_sdk() reuse it (build via create, then install the built providers as the process globals and run instrumentation). Add a ConfigProperties read view over the parsed instrumentation node with typed getters (get_string/get_bool/get_int/get_float/get_config/ get_config_list/get_scalar_list/keys) that return None on missing key or type mismatch, and a global ConfigProvider exposing the instrumentation config. --- .changelog/9999.added | 1 + .../opentelemetry/configuration/__init__.py | 14 +- .../configuration/_config_provider.py | 189 ++++++++++++++++ .../src/opentelemetry/configuration/_sdk.py | 126 +++++++++-- .../tests/test_config_provider.py | 155 ++++++++++++++ opentelemetry-configuration/tests/test_sdk.py | 202 ++++++++++++++---- 6 files changed, 624 insertions(+), 63 deletions(-) create mode 100644 .changelog/9999.added create mode 100644 opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py create mode 100644 opentelemetry-configuration/tests/test_config_provider.py diff --git a/.changelog/9999.added b/.changelog/9999.added new file mode 100644 index 0000000000..6d15614071 --- /dev/null +++ b/.changelog/9999.added @@ -0,0 +1 @@ +Declarative configuration: add a pure `create(model)` that builds providers without mutating global state, and a `ConfigProvider`/`ConfigProperties` read view over the instrumentation config diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py index 1d42963269..57f14e2f21 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/__init__.py @@ -31,14 +31,26 @@ behaviour may change between minor versions. """ +from opentelemetry.configuration._config_provider import ( + ConfigProperties, + ConfigProvider, + get_config_provider, + set_config_provider, +) from opentelemetry.configuration._exceptions import ConfigurationError -from opentelemetry.configuration._sdk import configure_sdk +from opentelemetry.configuration._sdk import Providers, configure_sdk, create from opentelemetry.configuration.file._loader import load_config_file from opentelemetry.configuration.models import OpenTelemetryConfiguration __all__ = [ + "ConfigProperties", + "ConfigProvider", "ConfigurationError", "OpenTelemetryConfiguration", + "Providers", "configure_sdk", + "create", + "get_config_provider", "load_config_file", + "set_config_provider", ] diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py new file mode 100644 index 0000000000..10cf8599c0 --- /dev/null +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py @@ -0,0 +1,189 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Read view over declarative instrumentation configuration. + +Implements the spec's ``ConfigProvider`` / ``ConfigProperties`` API +(``configuration/api.md``): a stateless, typed read view over the parsed +``instrumentation`` node of a declarative configuration, plus a global +``ConfigProvider`` that makes it retrievable by instrumentation code. + +``ConfigProperties`` wraps a mapping (a parsed sub-tree of the config) and +exposes typed getters that return ``None`` when a key is absent or cannot +be coerced to the requested type, matching the spec's "return null" and +Java's ``DeclarativeConfigProperties`` semantics. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import asdict, is_dataclass +from logging import getLogger +from typing import Any + +_logger = getLogger(__name__) + + +def _node_to_mapping(node: Any) -> dict[str, Any]: + """Normalize a config node into a plain ``dict`` for typed access. + + Dataclass nodes (the parsed model tree) are converted recursively via + ``asdict``; mappings are copied as-is. Anything else yields an empty + mapping so the getters uniformly return ``None``. + """ + if node is None: + return {} + if is_dataclass(node) and not isinstance(node, type): + return asdict(node) + if isinstance(node, Mapping): + return dict(node) + return {} + + +class ConfigProperties: + """A typed read view over a parsed configuration sub-tree. + + Wraps a mapping of configuration keys to values. Typed getters coerce + the stored value to the requested type and return ``None`` when the key + is missing or the value has an incompatible type. ``get_config`` returns + a nested :class:`ConfigProperties` for a sub-mapping, enabling traversal + of the full instrumentation tree. + """ + + def __init__(self, properties: Mapping[str, Any] | None = None) -> None: + self._properties: dict[str, Any] = ( + dict(properties) if properties is not None else {} + ) + + def get_string(self, name: str) -> str | None: + """Return the value of ``name`` as a ``str``, or ``None``.""" + value = self._properties.get(name) + return value if isinstance(value, str) else None + + def get_bool(self, name: str) -> bool | None: + """Return the value of ``name`` as a ``bool``, or ``None``.""" + value = self._properties.get(name) + return value if isinstance(value, bool) else None + + def get_int(self, name: str) -> int | None: + """Return the value of ``name`` as an ``int``, or ``None``. + + ``bool`` values are rejected (they are not treated as integers). + """ + value = self._properties.get(name) + if isinstance(value, bool): + return None + return value if isinstance(value, int) else None + + def get_float(self, name: str) -> float | None: + """Return the value of ``name`` as a ``float``, or ``None``. + + Accepts ``int`` values (widened to ``float``); rejects ``bool``. + """ + value = self._properties.get(name) + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + return None + + def get_config(self, name: str) -> ConfigProperties | None: + """Return the sub-mapping at ``name`` as :class:`ConfigProperties`. + + Returns ``None`` when ``name`` is absent or its value is not a + mapping / dataclass node. + """ + value = self._properties.get(name) + if value is None: + return None + if is_dataclass(value) and not isinstance(value, type): + return ConfigProperties(_node_to_mapping(value)) + if isinstance(value, Mapping): + return ConfigProperties(dict(value)) + return None + + def get_config_list(self, name: str) -> list[ConfigProperties] | None: + """Return the list at ``name`` as a list of :class:`ConfigProperties`. + + Each element must be a mapping / dataclass node; returns ``None`` + when ``name`` is absent or is not a list of mappings. + """ + value = self._properties.get(name) + if not isinstance(value, list): + return None + result: list[ConfigProperties] = [] + for item in value: + mapping = _node_to_mapping(item) + if not mapping and item is not None: + return None + result.append(ConfigProperties(mapping)) + return result + + def get_scalar_list(self, name: str, scalar_type: type) -> list | None: + """Return the sequence at ``name`` as a list of ``scalar_type``. + + Elements whose type does not match ``scalar_type`` are dropped + (matching Java's ``getScalarList``). ``bool`` is never treated as an + ``int``. Returns ``None`` when ``name`` is absent or is not a list. + """ + value = self._properties.get(name) + if not isinstance(value, list): + return None + result: list = [] + for item in value: + if scalar_type is int and isinstance(item, bool): + continue + if ( + scalar_type is float + and isinstance(item, int) + and not isinstance(item, bool) + ): + result.append(float(item)) + continue + if isinstance(item, scalar_type): + result.append(item) + return result + + def keys(self) -> list[str]: + """Return the property keys present in this view.""" + return list(self._properties.keys()) + + def __contains__(self, name: str) -> bool: + return name in self._properties + + def __repr__(self) -> str: + return f"ConfigProperties(keys={self.keys()!r})" + + +class ConfigProvider: + """Holds the instrumentation :class:`ConfigProperties` for global access.""" + + def __init__(self, instrumentation_config: ConfigProperties) -> None: + self._instrumentation_config = instrumentation_config + + def get_instrumentation_config(self) -> ConfigProperties: + """Return the read view over the ``instrumentation`` config node.""" + return self._instrumentation_config + + +_CONFIG_PROVIDER: ConfigProvider | None = None + + +def set_config_provider(config_provider: ConfigProvider) -> None: + """Set the global :class:`ConfigProvider`. + + A warning is logged (and the value overwritten) if one is already set, + matching the "set once" behavior of the other declarative globals. + """ + global _CONFIG_PROVIDER # pylint: disable=global-statement + if _CONFIG_PROVIDER is not None: + _logger.warning( + "Overriding of current ConfigProvider is not allowed once set; " + "overwriting the existing instance." + ) + _CONFIG_PROVIDER = config_provider + + +def get_config_provider() -> ConfigProvider | None: + """Return the global :class:`ConfigProvider`, or ``None`` if unset.""" + return _CONFIG_PROVIDER diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py index 698c88cce5..f355b05545 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py @@ -3,25 +3,40 @@ """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. +Provides two entry points on the declarative path: + +* :func:`create` builds the SDK providers from a parsed + ``OpenTelemetryConfiguration`` and returns them **without** touching any + process-global state. It mirrors Java's ``DeclarativeConfiguration.create``. +* :func:`configure_sdk` applies a parsed configuration by delegating to + :func:`create` and then installing the built providers as the process + globals (tracer, meter, logger, propagator, ConfigProvider) and running + instrumentation. This is the "apply this config" entry point. """ from __future__ import annotations +from dataclasses import dataclass from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING, getLogger +from opentelemetry import metrics, trace +from opentelemetry._logs import set_logger_provider +from opentelemetry.configuration._config_provider import ( + ConfigProperties, + ConfigProvider, + _node_to_mapping, + set_config_provider, +) from opentelemetry.configuration._logger_provider import ( - configure_logger_provider, + create_logger_provider, ) from opentelemetry.configuration._meter_provider import ( - configure_meter_provider, + create_meter_provider, ) -from opentelemetry.configuration._propagator import configure_propagator +from opentelemetry.configuration._propagator import create_propagator from opentelemetry.configuration._resource import create_resource from opentelemetry.configuration._tracer_provider import ( - configure_tracer_provider, + create_tracer_provider, ) from opentelemetry.configuration.instrumentation import ( configure_instrumentation, @@ -30,6 +45,11 @@ OpenTelemetryConfiguration, SeverityNumber, ) +from opentelemetry.propagate import set_global_textmap +from opentelemetry.propagators.textmap import TextMapPropagator +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider _logger = getLogger(__name__) @@ -64,15 +84,77 @@ } +@dataclass +class Providers: + """The SDK objects built from a declarative configuration. + + A provider is ``None`` when its config section was absent (matching the + spec's "noop default" behavior). The propagator is always built (an empty + :class:`CompositePropagator` when no propagator section is present). + ``config_provider`` exposes the instrumentation config as a read view. + """ + + tracer_provider: TracerProvider | None + meter_provider: MeterProvider | None + logger_provider: LoggerProvider | None + propagator: TextMapPropagator + config_provider: ConfigProvider + + +def create(config: OpenTelemetryConfiguration) -> Providers: + """Build SDK providers from a parsed declarative configuration. + + This is a pure builder: it constructs and returns the providers without + mutating any process-global state (no ``set_tracer_provider``, etc.) and + without running instrumentation. Use :func:`configure_sdk` to also install + the result as the process globals. + + Sections absent from the config (``None``) yield a ``None`` provider, + matching the spec's "noop default" behavior. The instrumentation config + is always exposed via ``config_provider`` (empty when absent). + + Args: + config: Parsed ``OpenTelemetryConfiguration`` (typically from + ``load_config_file``). + + Returns: + A :class:`Providers` bundle of the built objects. + """ + resource = create_resource(config.resource) + return Providers( + tracer_provider=( + create_tracer_provider(config.tracer_provider, resource) + if config.tracer_provider is not None + else None + ), + meter_provider=( + create_meter_provider(config.meter_provider, resource) + if config.meter_provider is not None + else None + ), + logger_provider=( + create_logger_provider(config.logger_provider, resource) + if config.logger_provider is not None + else None + ), + propagator=create_propagator(config.propagator), + config_provider=ConfigProvider( + ConfigProperties( + _node_to_mapping(config.instrumentation_development) + ) + ), + ) + + 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. + Delegates to :func:`create` to build the providers, then installs them as + the process globals: sets the global tracer provider, meter provider, + logger provider, text map propagator, and :class:`ConfigProvider` from the + built objects. Sections absent from the config leave the corresponding + global untouched — matching the spec's "noop default" behavior. Finally + runs instrumentation from the ``instrumentation`` section. Honors the top-level ``disabled`` flag: when true, the function returns early without setting any globals. The ``log_level`` field, when present @@ -84,7 +166,7 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: ``load_config_file``). Example: - >>> from opentelemetry.configuration.file import ( + >>> from opentelemetry.configuration import ( ... load_config_file, ... configure_sdk, ... ) @@ -99,9 +181,15 @@ def configure_sdk(config: OpenTelemetryConfiguration) -> None: level = _SEVERITY_TO_LOGGING_LEVEL.get(config.log_level, INFO) getLogger("opentelemetry").setLevel(level) - 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) + providers = create(config) + + if providers.tracer_provider is not None: + trace.set_tracer_provider(providers.tracer_provider) + if providers.meter_provider is not None: + metrics.set_meter_provider(providers.meter_provider) + if providers.logger_provider is not None: + set_logger_provider(providers.logger_provider) + set_global_textmap(providers.propagator) + set_config_provider(providers.config_provider) + configure_instrumentation(config.instrumentation_development) diff --git a/opentelemetry-configuration/tests/test_config_provider.py b/opentelemetry-configuration/tests/test_config_provider.py new file mode 100644 index 0000000000..435e8e9b5e --- /dev/null +++ b/opentelemetry-configuration/tests/test_config_provider.py @@ -0,0 +1,155 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import unittest + +import opentelemetry.configuration._config_provider as config_provider_module +from opentelemetry.configuration._config_provider import ( + ConfigProperties, + ConfigProvider, + _node_to_mapping, + get_config_provider, + set_config_provider, +) +from opentelemetry.configuration.models import ( + ExperimentalGeneralInstrumentation, + ExperimentalInstrumentation, +) + + +class TestConfigPropertiesScalars(unittest.TestCase): + def setUp(self): + self.props = ConfigProperties( + { + "name": "service", + "flag": True, + "count": 5, + "ratio": 0.25, + "whole": 3, + } + ) + + def test_get_string(self): + self.assertEqual(self.props.get_string("name"), "service") + + def test_get_string_missing_returns_none(self): + self.assertIsNone(self.props.get_string("nope")) + + def test_get_string_wrong_type_returns_none(self): + self.assertIsNone(self.props.get_string("count")) + + def test_get_bool(self): + self.assertIs(self.props.get_bool("flag"), True) + + def test_get_bool_wrong_type_returns_none(self): + self.assertIsNone(self.props.get_bool("count")) + + def test_get_int(self): + self.assertEqual(self.props.get_int("count"), 5) + + def test_get_int_rejects_bool(self): + self.assertIsNone(self.props.get_int("flag")) + + def test_get_int_wrong_type_returns_none(self): + self.assertIsNone(self.props.get_int("name")) + + def test_get_float(self): + self.assertEqual(self.props.get_float("ratio"), 0.25) + + def test_get_float_widens_int(self): + result = self.props.get_float("whole") + self.assertIsInstance(result, float) + self.assertEqual(result, 3.0) + + def test_get_float_rejects_bool(self): + self.assertIsNone(self.props.get_float("flag")) + + def test_keys(self): + self.assertEqual( + set(self.props.keys()), + {"name", "flag", "count", "ratio", "whole"}, + ) + + def test_contains(self): + self.assertIn("name", self.props) + self.assertNotIn("nope", self.props) + + +class TestConfigPropertiesStructured(unittest.TestCase): + def test_get_config_returns_sub_view(self): + props = ConfigProperties({"peer": {"host": "localhost", "port": 8080}}) + sub = props.get_config("peer") + self.assertIsInstance(sub, ConfigProperties) + self.assertEqual(sub.get_string("host"), "localhost") + self.assertEqual(sub.get_int("port"), 8080) + + def test_get_config_missing_returns_none(self): + self.assertIsNone(ConfigProperties({}).get_config("peer")) + + def test_get_config_non_mapping_returns_none(self): + self.assertIsNone(ConfigProperties({"peer": 5}).get_config("peer")) + + def test_get_config_list(self): + props = ConfigProperties({"servers": [{"host": "a"}, {"host": "b"}]}) + result = props.get_config_list("servers") + self.assertEqual(len(result), 2) + self.assertEqual(result[0].get_string("host"), "a") + self.assertEqual(result[1].get_string("host"), "b") + + def test_get_config_list_missing_returns_none(self): + self.assertIsNone(ConfigProperties({}).get_config_list("servers")) + + def test_get_scalar_list_strings(self): + props = ConfigProperties({"names": ["a", "b", 3]}) + # Non-matching element (3) dropped. + self.assertEqual(props.get_scalar_list("names", str), ["a", "b"]) + + def test_get_scalar_list_ints_drops_bool(self): + props = ConfigProperties({"nums": [1, 2, True]}) + self.assertEqual(props.get_scalar_list("nums", int), [1, 2]) + + def test_get_scalar_list_missing_returns_none(self): + self.assertIsNone(ConfigProperties({}).get_scalar_list("x", str)) + + +class TestNodeToMapping(unittest.TestCase): + def test_dataclass_node_converted_recursively(self): + node = ExperimentalInstrumentation( + general=ExperimentalGeneralInstrumentation( + stability_opt_in_list="http" + ) + ) + mapping = _node_to_mapping(node) + self.assertEqual(mapping["general"]["stability_opt_in_list"], "http") + + def test_none_yields_empty_mapping(self): + self.assertEqual(_node_to_mapping(None), {}) + + def test_config_properties_over_instrumentation_node(self): + node = ExperimentalInstrumentation( + general=ExperimentalGeneralInstrumentation( + stability_opt_in_list="http" + ) + ) + props = ConfigProperties(_node_to_mapping(node)) + general = props.get_config("general") + self.assertIsInstance(general, ConfigProperties) + self.assertEqual(general.get_string("stability_opt_in_list"), "http") + + +class TestGlobalConfigProvider(unittest.TestCase): + def setUp(self): + # Reset the module global before each test. + config_provider_module._CONFIG_PROVIDER = None + + def test_get_returns_none_when_unset(self): + self.assertIsNone(get_config_provider()) + + def test_set_and_get(self): + provider = ConfigProvider(ConfigProperties({"k": "v"})) + set_config_provider(provider) + self.assertIs(get_config_provider(), provider) + self.assertEqual( + get_config_provider().get_instrumentation_config().get_string("k"), + "v", + ) diff --git a/opentelemetry-configuration/tests/test_sdk.py b/opentelemetry-configuration/tests/test_sdk.py index 6a6d2cf1be..cc2f9660f0 100644 --- a/opentelemetry-configuration/tests/test_sdk.py +++ b/opentelemetry-configuration/tests/test_sdk.py @@ -8,8 +8,12 @@ import unittest from unittest.mock import patch -from opentelemetry.configuration._sdk import configure_sdk +import opentelemetry.configuration._config_provider as config_provider_module +from opentelemetry.configuration._config_provider import ConfigProperties +from opentelemetry.configuration._sdk import Providers, configure_sdk, create from opentelemetry.configuration.models import ( + ExperimentalGeneralInstrumentation, + ExperimentalInstrumentation, OpenTelemetryConfiguration, SeverityNumber, ) @@ -41,22 +45,32 @@ def _config(**kwargs) -> OpenTelemetryConfiguration: class TestConfigureSdk(unittest.TestCase): - @patch("opentelemetry.configuration._sdk.configure_propagator") - @patch("opentelemetry.configuration._sdk.configure_logger_provider") - @patch("opentelemetry.configuration._sdk.configure_meter_provider") - @patch("opentelemetry.configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.configuration._sdk.set_config_provider") + @patch("opentelemetry.configuration._sdk.set_global_textmap") + @patch("opentelemetry.configuration._sdk.set_logger_provider") + @patch("opentelemetry.configuration._sdk.metrics.set_meter_provider") + @patch("opentelemetry.configuration._sdk.trace.set_tracer_provider") + @patch("opentelemetry.configuration._sdk.create_logger_provider") + @patch("opentelemetry.configuration._sdk.create_meter_provider") + @patch("opentelemetry.configuration._sdk.create_tracer_provider") @patch("opentelemetry.configuration._sdk.create_resource") - # pylint: disable=no-self-use - def test_calls_each_signal_with_resource( + # pylint: disable=no-self-use,too-many-arguments,too-many-positional-arguments + def test_installs_present_sections_as_globals( self, mock_create_resource, - mock_tracer, - mock_meter, - mock_logger, - mock_propagator, + mock_create_tracer, + mock_create_meter, + mock_create_logger, + mock_set_tracer, + mock_set_meter, + mock_set_logger, + mock_set_textmap, + mock_set_config, ): sentinel_resource = object() mock_create_resource.return_value = sentinel_resource + sentinel_tracer = object() + mock_create_tracer.return_value = sentinel_tracer resource_cfg = ResourceConfig() tracer_cfg = TracerProviderConfig(processors=[]) @@ -70,24 +84,30 @@ def test_calls_each_signal_with_resource( 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) + mock_create_tracer.assert_called_once_with( + tracer_cfg, sentinel_resource + ) + mock_set_tracer.assert_called_once_with(sentinel_tracer) + # Absent meter/logger sections are not built or installed. + mock_create_meter.assert_not_called() + mock_create_logger.assert_not_called() + mock_set_meter.assert_not_called() + mock_set_logger.assert_not_called() + # Propagator and ConfigProvider are always installed. + mock_set_textmap.assert_called_once() + mock_set_config.assert_called_once() - @patch("opentelemetry.configuration._sdk.configure_propagator") - @patch("opentelemetry.configuration._sdk.configure_logger_provider") - @patch("opentelemetry.configuration._sdk.configure_meter_provider") - @patch("opentelemetry.configuration._sdk.configure_tracer_provider") + @patch("opentelemetry.configuration._sdk.set_config_provider") + @patch("opentelemetry.configuration._sdk.set_global_textmap") + @patch("opentelemetry.configuration._sdk.trace.set_tracer_provider") @patch("opentelemetry.configuration._sdk.create_resource") # pylint: disable=no-self-use def test_disabled_skips_everything( self, mock_create_resource, - mock_tracer, - mock_meter, - mock_logger, - mock_propagator, + mock_set_tracer, + mock_set_textmap, + mock_set_config, ): config = _config( disabled=True, @@ -97,31 +117,127 @@ def test_disabled_skips_everything( 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() + mock_set_tracer.assert_not_called() + mock_set_textmap.assert_not_called() + mock_set_config.assert_not_called() - @patch("opentelemetry.configuration._sdk.configure_propagator") - @patch("opentelemetry.configuration._sdk.configure_logger_provider") - @patch("opentelemetry.configuration._sdk.configure_meter_provider") - @patch("opentelemetry.configuration._sdk.configure_tracer_provider") - @patch("opentelemetry.configuration._sdk.create_resource") - def test_absent_sections_pass_none( + @patch("opentelemetry.configuration._sdk.set_config_provider") + @patch("opentelemetry.configuration._sdk.set_global_textmap") + @patch("opentelemetry.configuration._sdk.set_logger_provider") + @patch("opentelemetry.configuration._sdk.metrics.set_meter_provider") + @patch("opentelemetry.configuration._sdk.trace.set_tracer_provider") + def test_absent_sections_do_not_set_provider_globals( self, - mock_create_resource, # noqa: ARG002 - mock_tracer, - mock_meter, - mock_logger, - mock_propagator, + mock_set_tracer, + mock_set_meter, + mock_set_logger, + mock_set_textmap, + mock_set_config, ): 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) + # No provider globals set when their sections are absent. + mock_set_tracer.assert_not_called() + mock_set_meter.assert_not_called() + mock_set_logger.assert_not_called() + # Propagator and ConfigProvider are always installed. + mock_set_textmap.assert_called_once() + mock_set_config.assert_called_once() + + +class TestCreate(unittest.TestCase): + """The pure ``create`` builder returns providers without mutating globals.""" + + def test_returns_providers_without_mutating_globals(self): + config = _config( + tracer_provider=TracerProviderConfig( + processors=[ + SpanProcessorConfig( + simple=SimpleSpanProcessorConfig( + exporter=SpanExporterConfig(console={}) + ) + ) + ] + ), + propagator=PropagatorConfig(), + ) + + with ( + patch( + "opentelemetry.configuration._sdk.trace.set_tracer_provider" + ) as set_tracer, + patch( + "opentelemetry.configuration._sdk.metrics.set_meter_provider" + ) as set_meter, + patch( + "opentelemetry.configuration._sdk.set_logger_provider" + ) as set_logger, + patch( + "opentelemetry.configuration._sdk.set_global_textmap" + ) as set_textmap, + patch( + "opentelemetry.configuration._sdk.set_config_provider" + ) as set_config, + ): + providers = create(config) + + set_tracer.assert_not_called() + set_meter.assert_not_called() + set_logger.assert_not_called() + set_textmap.assert_not_called() + set_config.assert_not_called() + + self.assertIsInstance(providers, Providers) + self.assertIsInstance(providers.tracer_provider, SdkTracerProvider) + # Absent sections yield None providers. + self.assertIsNone(providers.meter_provider) + self.assertIsNone(providers.logger_provider) + # Propagator is always built. + self.assertIsNotNone(providers.propagator) + # ConfigProvider is always present with a read view. + self.assertIsInstance( + providers.config_provider.get_instrumentation_config(), + ConfigProperties, + ) + + def test_exposes_instrumentation_config(self): + config = _config( + instrumentation_development=ExperimentalInstrumentation( + general=ExperimentalGeneralInstrumentation( + stability_opt_in_list="http" + ) + ) + ) + + providers = create(config) + + instrumentation = ( + providers.config_provider.get_instrumentation_config() + ) + general = instrumentation.get_config("general") + self.assertIsNotNone(general) + self.assertEqual(general.get_string("stability_opt_in_list"), "http") + + def test_configure_sdk_sets_config_provider_from_create(self): + config_provider_module._CONFIG_PROVIDER = None + config = _config( + instrumentation_development=ExperimentalInstrumentation( + general=ExperimentalGeneralInstrumentation( + stability_opt_in_list="db" + ) + ) + ) + + configure_sdk(config) + + provider = config_provider_module.get_config_provider() + self.assertIsNotNone(provider) + self.assertEqual( + provider.get_instrumentation_config() + .get_config("general") + .get_string("stability_opt_in_list"), + "db", + ) class TestConfigureSdkLogLevel(unittest.TestCase): From 078f77b5d4f6bc8aa20cd48289ea8c3b1187f8d3 Mon Sep 17 00:00:00 2001 From: Diego Hurtado Date: Wed, 22 Jul 2026 08:38:10 -0500 Subject: [PATCH 2/2] Rename changelog fragment to match PR number --- .changelog/{9999.added => 45.added} | 0 .../configuration/_config_provider.py | 13 ++---- .../src/opentelemetry/configuration/_sdk.py | 18 ++------ .../tests/test_config_provider.py | 12 +---- opentelemetry-configuration/tests/test_sdk.py | 46 +++++-------------- 5 files changed, 20 insertions(+), 69 deletions(-) rename .changelog/{9999.added => 45.added} (100%) diff --git a/.changelog/9999.added b/.changelog/45.added similarity index 100% rename from .changelog/9999.added rename to .changelog/45.added diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py index 10cf8599c0..b7c3f9efb1 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_config_provider.py @@ -51,9 +51,7 @@ class ConfigProperties: """ def __init__(self, properties: Mapping[str, Any] | None = None) -> None: - self._properties: dict[str, Any] = ( - dict(properties) if properties is not None else {} - ) + self._properties: dict[str, Any] = dict(properties) if properties is not None else {} def get_string(self, name: str) -> str | None: """Return the value of ``name`` as a ``str``, or ``None``.""" @@ -133,11 +131,7 @@ def get_scalar_list(self, name: str, scalar_type: type) -> list | None: for item in value: if scalar_type is int and isinstance(item, bool): continue - if ( - scalar_type is float - and isinstance(item, int) - and not isinstance(item, bool) - ): + if scalar_type is float and isinstance(item, int) and not isinstance(item, bool): result.append(float(item)) continue if isinstance(item, scalar_type): @@ -178,8 +172,7 @@ def set_config_provider(config_provider: ConfigProvider) -> None: global _CONFIG_PROVIDER # pylint: disable=global-statement if _CONFIG_PROVIDER is not None: _logger.warning( - "Overriding of current ConfigProvider is not allowed once set; " - "overwriting the existing instance." + "Overriding of current ConfigProvider is not allowed once set; overwriting the existing instance." ) _CONFIG_PROVIDER = config_provider diff --git a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py index f355b05545..ebee28d5ef 100644 --- a/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py +++ b/opentelemetry-configuration/src/opentelemetry/configuration/_sdk.py @@ -123,26 +123,16 @@ def create(config: OpenTelemetryConfiguration) -> Providers: resource = create_resource(config.resource) return Providers( tracer_provider=( - create_tracer_provider(config.tracer_provider, resource) - if config.tracer_provider is not None - else None + create_tracer_provider(config.tracer_provider, resource) if config.tracer_provider is not None else None ), meter_provider=( - create_meter_provider(config.meter_provider, resource) - if config.meter_provider is not None - else None + create_meter_provider(config.meter_provider, resource) if config.meter_provider is not None else None ), logger_provider=( - create_logger_provider(config.logger_provider, resource) - if config.logger_provider is not None - else None + create_logger_provider(config.logger_provider, resource) if config.logger_provider is not None else None ), propagator=create_propagator(config.propagator), - config_provider=ConfigProvider( - ConfigProperties( - _node_to_mapping(config.instrumentation_development) - ) - ), + config_provider=ConfigProvider(ConfigProperties(_node_to_mapping(config.instrumentation_development))), ) diff --git a/opentelemetry-configuration/tests/test_config_provider.py b/opentelemetry-configuration/tests/test_config_provider.py index 435e8e9b5e..5b5a11974a 100644 --- a/opentelemetry-configuration/tests/test_config_provider.py +++ b/opentelemetry-configuration/tests/test_config_provider.py @@ -114,11 +114,7 @@ def test_get_scalar_list_missing_returns_none(self): class TestNodeToMapping(unittest.TestCase): def test_dataclass_node_converted_recursively(self): - node = ExperimentalInstrumentation( - general=ExperimentalGeneralInstrumentation( - stability_opt_in_list="http" - ) - ) + node = ExperimentalInstrumentation(general=ExperimentalGeneralInstrumentation(stability_opt_in_list="http")) mapping = _node_to_mapping(node) self.assertEqual(mapping["general"]["stability_opt_in_list"], "http") @@ -126,11 +122,7 @@ def test_none_yields_empty_mapping(self): self.assertEqual(_node_to_mapping(None), {}) def test_config_properties_over_instrumentation_node(self): - node = ExperimentalInstrumentation( - general=ExperimentalGeneralInstrumentation( - stability_opt_in_list="http" - ) - ) + node = ExperimentalInstrumentation(general=ExperimentalGeneralInstrumentation(stability_opt_in_list="http")) props = ConfigProperties(_node_to_mapping(node)) general = props.get_config("general") self.assertIsInstance(general, ConfigProperties) diff --git a/opentelemetry-configuration/tests/test_sdk.py b/opentelemetry-configuration/tests/test_sdk.py index cc2f9660f0..422eb52350 100644 --- a/opentelemetry-configuration/tests/test_sdk.py +++ b/opentelemetry-configuration/tests/test_sdk.py @@ -84,9 +84,7 @@ def test_installs_present_sections_as_globals( configure_sdk(config) mock_create_resource.assert_called_once_with(resource_cfg) - mock_create_tracer.assert_called_once_with( - tracer_cfg, sentinel_resource - ) + mock_create_tracer.assert_called_once_with(tracer_cfg, sentinel_resource) mock_set_tracer.assert_called_once_with(sentinel_tracer) # Absent meter/logger sections are not built or installed. mock_create_meter.assert_not_called() @@ -152,32 +150,18 @@ def test_returns_providers_without_mutating_globals(self): config = _config( tracer_provider=TracerProviderConfig( processors=[ - SpanProcessorConfig( - simple=SimpleSpanProcessorConfig( - exporter=SpanExporterConfig(console={}) - ) - ) + SpanProcessorConfig(simple=SimpleSpanProcessorConfig(exporter=SpanExporterConfig(console={}))) ] ), propagator=PropagatorConfig(), ) with ( - patch( - "opentelemetry.configuration._sdk.trace.set_tracer_provider" - ) as set_tracer, - patch( - "opentelemetry.configuration._sdk.metrics.set_meter_provider" - ) as set_meter, - patch( - "opentelemetry.configuration._sdk.set_logger_provider" - ) as set_logger, - patch( - "opentelemetry.configuration._sdk.set_global_textmap" - ) as set_textmap, - patch( - "opentelemetry.configuration._sdk.set_config_provider" - ) as set_config, + patch("opentelemetry.configuration._sdk.trace.set_tracer_provider") as set_tracer, + patch("opentelemetry.configuration._sdk.metrics.set_meter_provider") as set_meter, + patch("opentelemetry.configuration._sdk.set_logger_provider") as set_logger, + patch("opentelemetry.configuration._sdk.set_global_textmap") as set_textmap, + patch("opentelemetry.configuration._sdk.set_config_provider") as set_config, ): providers = create(config) @@ -203,17 +187,13 @@ def test_returns_providers_without_mutating_globals(self): def test_exposes_instrumentation_config(self): config = _config( instrumentation_development=ExperimentalInstrumentation( - general=ExperimentalGeneralInstrumentation( - stability_opt_in_list="http" - ) + general=ExperimentalGeneralInstrumentation(stability_opt_in_list="http") ) ) providers = create(config) - instrumentation = ( - providers.config_provider.get_instrumentation_config() - ) + instrumentation = providers.config_provider.get_instrumentation_config() general = instrumentation.get_config("general") self.assertIsNotNone(general) self.assertEqual(general.get_string("stability_opt_in_list"), "http") @@ -222,9 +202,7 @@ def test_configure_sdk_sets_config_provider_from_create(self): config_provider_module._CONFIG_PROVIDER = None config = _config( instrumentation_development=ExperimentalInstrumentation( - general=ExperimentalGeneralInstrumentation( - stability_opt_in_list="db" - ) + general=ExperimentalGeneralInstrumentation(stability_opt_in_list="db") ) ) @@ -233,9 +211,7 @@ def test_configure_sdk_sets_config_provider_from_create(self): provider = config_provider_module.get_config_provider() self.assertIsNotNone(provider) self.assertEqual( - provider.get_instrumentation_config() - .get_config("general") - .get_string("stability_opt_in_list"), + provider.get_instrumentation_config().get_config("general").get_string("stability_opt_in_list"), "db", )