diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e56e7357c..614f240d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- `opentelemetry-sdk`: add `additional_properties` support to generated config models via custom `datamodel-codegen` template, enabling plugin/custom component names to flow through typed dataclasses + ([#5131](https://github.com/open-telemetry/opentelemetry-python/pull/5131)) - Fix incorrect code example in `create_tracer()` docstring ([#5072](https://github.com/open-telemetry/opentelemetry-python/issues/5072)) - `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it diff --git a/opentelemetry-sdk/codegen/README.md b/opentelemetry-sdk/codegen/README.md new file mode 100644 index 0000000000..d28e52b4cf --- /dev/null +++ b/opentelemetry-sdk/codegen/README.md @@ -0,0 +1,24 @@ +# Code Generation Templates + +Custom [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator) templates used when generating `models.py` from the OpenTelemetry configuration JSON schema. + +## `dataclass.jinja2` + +Extends the default dataclass template to support `additionalProperties` from the JSON Schema. Schema types that allow additional properties (e.g. `Sampler`, `SpanExporter`, `TextMapPropagator`) get: + +- `@_additional_properties` decorator — captures unknown constructor kwargs +- `additional_properties: ClassVar[dict[str, Any]]` annotation — satisfies type checkers without creating a dataclass field + +This enables plugin/custom component names to flow through typed dataclasses without a post-processing step. + +## Usage + +Templates are applied automatically when regenerating models: + +```sh +tox -e generate-config-from-jsonschema +``` + +The template directory is configured in `pyproject.toml` under `[tool.datamodel-codegen]`. + +See `opentelemetry-sdk/src/opentelemetry/sdk/_configuration/README.md` for the full schema update workflow. diff --git a/opentelemetry-sdk/codegen/dataclass.jinja2 b/opentelemetry-sdk/codegen/dataclass.jinja2 new file mode 100644 index 0000000000..f584357c8c --- /dev/null +++ b/opentelemetry-sdk/codegen/dataclass.jinja2 @@ -0,0 +1,73 @@ +{# Custom dataclass template for OpenTelemetry configuration models. + Extends the default datamodel-codegen dataclass template to support + JSON Schema additionalProperties. When a schema type allows additional + properties (e.g. Sampler, SpanExporter), this template adds: + - @_additional_properties decorator (captures unknown kwargs) + - additional_properties: ClassVar[dict[str, Any]] annotation (for type checkers) + + The template checks two context variables set by datamodel-codegen: + - additionalProperties: set when the schema value is a boolean (true/false) + - additionalPropertiesType: set when the schema value is a type object + (e.g. {"type": ["object", "null"]}), which is how the OTel config + schema defines it for plugin-capable types. + + See TestGeneratedModelsHaveAdditionalProperties in test_common.py for + regression tests that guard against changes in these context variables. +-#} +{% for decorator in decorators -%} +{{ decorator }} +{% endfor -%} +{%- set args = [] %} +{%- for k, v in (dataclass_arguments or {}).items() %} + {%- if v is not none and v is not false %} + {%- set _ = args.append(k ~ '=' ~ (v|pprint)) %} + {%- endif %} +{%- endfor %} +{%- set has_additional = (additionalProperties is defined and additionalProperties != false) or (additionalPropertiesType is defined) %} +{%- if has_additional %} +@_additional_properties +{%- endif %} +{%- if args %} +@dataclass({{ args | join(', ') }}) +{%- else %} +@dataclass +{%- endif %} +{%- if base_class %} +class {{ class_name }}({{ base_class }}): +{%- else %} +class {{ class_name }}: +{%- endif %} +{%- if description %} + """ + {{ description | escape_docstring | indent(4) }} + """ +{%- endif %} +{%- if not fields and not description and not has_additional %} + pass +{%- endif %} +{%- for field in fields -%} + {%- if field.field %} + {{ field.name }}: {{ field.type_hint }} = {{ field.field }} + {%- else %} + {{ field.name }}: {{ field.type_hint }} + {%- if not (field.required or (field.represented_default == 'None' and field.strip_default_none)) + %} = {{ field.represented_default }} + {%- endif -%} + {%- endif %} + {%- if field.docstring %} + """ + {{ field.docstring | escape_docstring | indent(4) }} + """ +{%- if field.use_inline_field_description and not loop.last %} + +{% endif %} + {%- elif field.inline_field_docstring %} + {{ field.inline_field_docstring }} +{%- if not loop.last %} + +{% endif %} + {%- endif %} +{%- endfor -%} +{%- if has_additional %} + additional_properties: ClassVar[dict[str, Any]] +{%- endif -%} diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/README.md b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/README.md index 5911d5f76a..e7cf918eea 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/README.md +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/README.md @@ -5,7 +5,7 @@ This package implements [OpenTelemetry file-based configuration](https://opentel ## Files - `schema.json` — vendored copy of the [OpenTelemetry configuration JSON schema](https://github.com/open-telemetry/opentelemetry-configuration) -- `models.py` — Python dataclasses generated from `schema.json` by [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator) +- `models.py` — Python dataclasses generated from `schema.json` by [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator), using a custom template from `opentelemetry-sdk/codegen/` (see that directory's README for details) ## Updating the schema diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py index 0498a19e13..5e4d569961 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py @@ -14,6 +14,8 @@ from __future__ import annotations +import dataclasses +import inspect import logging from typing import Optional, Type @@ -23,6 +25,40 @@ _logger = logging.getLogger(__name__) +def _additional_properties(cls): + """Decorator for dataclasses whose JSON Schema sets additionalProperties. + + Wraps the dataclass-generated ``__init__`` so that unknown keyword + arguments are captured into an ``additional_properties`` instance + attribute instead of raising ``TypeError``. This lets plugin/custom + component names flow through the config pipeline without modifying + the codegen output for known fields. + + Applied automatically by the custom template in ``opentelemetry-sdk/codegen/`` + when ``additionalPropertiesType`` is present in the template context + (set by ``datamodel-codegen`` for schema types with ``additionalProperties``). + """ + original_init = cls.__init__ + original_sig = inspect.signature(original_init) + known_fields = frozenset(f.name for f in dataclasses.fields(cls)) + + def _init(self, **kwargs): + known = {k: v for k, v in kwargs.items() if k in known_fields} + extra = {k: v for k, v in kwargs.items() if k not in known_fields} + original_init(self, **known) + self.additional_properties = extra + + # Preserve the original parameter list for IDE autocompletion and + # inspect.signature(), adding **kwargs to signal extras are accepted. + # setattr used because pyright rejects direct __signature__ assignment. + params = list(original_sig.parameters.values()) + params.append(inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD)) + setattr(_init, "__signature__", original_sig.replace(parameters=params)) # noqa: B010 + + cls.__init__ = _init + return cls + + def load_entry_point(group: str, name: str) -> Type: """Load a plugin class from an entry point group by name. diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py index 5159137228..1c97e3536a 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py @@ -1,12 +1,14 @@ # generated by datamodel-codegen: # filename: schema.json -# timestamp: 2026-04-13T15:12:07+00:00 +# timestamp: 2026-04-23T13:18:12+00:00 from __future__ import annotations from dataclasses import dataclass from enum import Enum -from typing import Any, TypeAlias +from typing import Any, ClassVar, TypeAlias + +from opentelemetry.sdk._configuration._common import _additional_properties AlwaysOffSampler: TypeAlias = dict[str, Any] | None @@ -350,12 +352,14 @@ class SeverityNumber(Enum): fatal4 = "fatal4" +@_additional_properties @dataclass class SpanExporter: otlp_http: OtlpHttpExporter | None = None otlp_grpc: OtlpGrpcExporter | None = None otlp_file_development: ExperimentalOtlpFileExporter | None = None console: ConsoleExporter | None = None + additional_properties: ClassVar[dict[str, Any]] class SpanKind(Enum): @@ -500,12 +504,14 @@ class ExperimentalPrometheusMetricExporter: ) +@_additional_properties @dataclass class ExperimentalResourceDetector: container: ExperimentalContainerResourceDetector | None = None host: ExperimentalHostResourceDetector | None = None process: ExperimentalProcessResourceDetector | None = None service: ExperimentalServiceResourceDetector | None = None + additional_properties: ClassVar[dict[str, Any]] @dataclass @@ -524,22 +530,28 @@ class ExperimentalTracerConfigurator: tracers: list[ExperimentalTracerMatcherAndConfig] | None = None +@_additional_properties @dataclass class LogRecordExporter: otlp_http: OtlpHttpExporter | None = None otlp_grpc: OtlpGrpcExporter | None = None otlp_file_development: ExperimentalOtlpFileExporter | None = None console: ConsoleExporter | None = None + additional_properties: ClassVar[dict[str, Any]] +@_additional_properties @dataclass class MetricProducer: opencensus: OpenCensusMetricProducer | None = None + additional_properties: ClassVar[dict[str, Any]] +@_additional_properties @dataclass class PullMetricExporter: prometheus_development: ExperimentalPrometheusMetricExporter | None = None + additional_properties: ClassVar[dict[str, Any]] @dataclass @@ -549,12 +561,14 @@ class PullMetricReader: cardinality_limits: CardinalityLimits | None = None +@_additional_properties @dataclass class PushMetricExporter: otlp_http: OtlpHttpMetricExporter | None = None otlp_grpc: OtlpGrpcMetricExporter | None = None otlp_file_development: ExperimentalOtlpFileMetricExporter | None = None console: ConsoleMetricExporter | None = None + additional_properties: ClassVar[dict[str, Any]] @dataclass @@ -567,18 +581,22 @@ class SimpleSpanProcessor: exporter: SpanExporter +@_additional_properties @dataclass class SpanProcessor: batch: BatchSpanProcessor | None = None simple: SimpleSpanProcessor | None = None + additional_properties: ClassVar[dict[str, Any]] +@_additional_properties @dataclass class TextMapPropagator: tracecontext: TraceContextPropagator | None = None baggage: BaggagePropagator | None = None b3: B3Propagator | None = None b3multi: B3MultiPropagator | None = None + additional_properties: ClassVar[dict[str, Any]] @dataclass @@ -639,10 +657,12 @@ class ExperimentalResourceDetection: detectors: list[ExperimentalResourceDetector] | None = None +@_additional_properties @dataclass class LogRecordProcessor: batch: BatchLogRecordProcessor | None = None simple: SimpleLogRecordProcessor | None = None + additional_properties: ClassVar[dict[str, Any]] @dataclass @@ -697,6 +717,7 @@ class MeterProvider: meter_configurator_development: ExperimentalMeterConfigurator | None = None +@_additional_properties @dataclass class OpenTelemetryConfiguration: file_format: str @@ -710,6 +731,7 @@ class OpenTelemetryConfiguration: resource: Resource | None = None instrumentation_development: ExperimentalInstrumentation | None = None distribution: Distribution | None = None + additional_properties: ClassVar[dict[str, Any]] @dataclass @@ -741,6 +763,7 @@ class ExperimentalComposableRuleBasedSamplerRule: parent: list[ExperimentalSpanParent] | None = None +@_additional_properties @dataclass class ExperimentalComposableSampler: always_off: ExperimentalComposableAlwaysOffSampler | None = None @@ -750,6 +773,7 @@ class ExperimentalComposableSampler: ) probability: ExperimentalComposableProbabilitySampler | None = None rule_based: ExperimentalComposableRuleBasedSampler | None = None + additional_properties: ClassVar[dict[str, Any]] @dataclass @@ -768,6 +792,7 @@ class ParentBasedSampler: local_parent_not_sampled: Sampler | None = None +@_additional_properties @dataclass class Sampler: always_off: AlwaysOffSampler | None = None @@ -777,6 +802,7 @@ class Sampler: parent_based: ParentBasedSampler | None = None probability_development: ExperimentalProbabilitySampler | None = None trace_id_ratio_based: TraceIdRatioBasedSampler | None = None + additional_properties: ClassVar[dict[str, Any]] @dataclass diff --git a/opentelemetry-sdk/tests/_configuration/test_common.py b/opentelemetry-sdk/tests/_configuration/test_common.py index 7bbfc284e9..e7ecb218ae 100644 --- a/opentelemetry-sdk/tests/_configuration/test_common.py +++ b/opentelemetry-sdk/tests/_configuration/test_common.py @@ -12,15 +12,27 @@ # See the License for the specific language governing permissions and # limitations under the License. +import inspect import unittest +from dataclasses import dataclass from types import SimpleNamespace +from typing import Any, ClassVar from unittest.mock import MagicMock, patch from opentelemetry.sdk._configuration._common import ( + _additional_properties, _parse_headers, load_entry_point, ) from opentelemetry.sdk._configuration._exceptions import ConfigurationError +from opentelemetry.sdk._configuration.models import ( + ExperimentalResourceDetector, + LogRecordExporter, + PushMetricExporter, + Sampler, + SpanExporter, + TextMapPropagator, +) class TestParseHeaders(unittest.TestCase): @@ -137,3 +149,89 @@ def test_instantiation_error_not_wrapped(self): # ConfigurationError with self.assertRaises(TypeError, msg="bad init"): cls() + + +class TestAdditionalPropertiesSupport(unittest.TestCase): + def setUp(self): + @_additional_properties + @dataclass + class _SampleConfig: + known_field: dict | None = None + another_field: str | None = None + additional_properties: ClassVar[dict[str, Any]] + + self.cls = _SampleConfig + + def test_known_fields_work_normally(self): + obj = self.cls(known_field={}, another_field="val") + self.assertEqual(obj.known_field, {}) + self.assertEqual(obj.another_field, "val") + self.assertEqual(obj.additional_properties, {}) + + def test_unknown_kwargs_captured_in_additional_properties(self): + # pylint: disable=unexpected-keyword-arg + obj = self.cls(my_plugin={"key": "val"}) + self.assertIsNone(obj.known_field) + self.assertEqual( + obj.additional_properties, {"my_plugin": {"key": "val"}} + ) + + def test_mixed_known_and_unknown_kwargs(self): + # pylint: disable=unexpected-keyword-arg + obj = self.cls(known_field={}, my_plugin={}) + self.assertEqual(obj.known_field, {}) + self.assertEqual(obj.additional_properties, {"my_plugin": {}}) + + def test_no_args_creates_empty_additional_properties(self): + obj = self.cls() + self.assertIsNone(obj.known_field) + self.assertEqual(obj.additional_properties, {}) + + def test_signature_preserves_known_fields_and_adds_kwargs(self): + sig = inspect.signature(self.cls) + param_names = list(sig.parameters.keys()) + self.assertIn("known_field", param_names) + self.assertIn("another_field", param_names) + # **kwargs signals that extras are accepted + kwargs_param = sig.parameters.get("kwargs") + self.assertIsNotNone(kwargs_param) + self.assertEqual(kwargs_param.kind, inspect.Parameter.VAR_KEYWORD) + + +class TestGeneratedModelsHaveAdditionalProperties(unittest.TestCase): + """Guards against regressions in the custom datamodel-codegen template. + + The codegen/dataclass.jinja2 template conditionally applies the + @_additional_properties decorator based on the + additionalPropertiesType template variable. If datamodel-codegen + changes how it passes this variable, these tests will fail. + """ + + def _assert_supports_additional_properties(self, model_cls): + # pylint: disable=unexpected-keyword-arg + obj = model_cls(_test_plugin_key={}) + self.assertTrue( + hasattr(obj, "additional_properties"), + f"{model_cls.__name__} missing additional_properties attribute", + ) + self.assertIn("_test_plugin_key", obj.additional_properties) + + def test_sampler(self): + self._assert_supports_additional_properties(Sampler) + + def test_span_exporter(self): + self._assert_supports_additional_properties(SpanExporter) + + def test_text_map_propagator(self): + self._assert_supports_additional_properties(TextMapPropagator) + + def test_resource_detector(self): + self._assert_supports_additional_properties( + ExperimentalResourceDetector + ) + + def test_log_record_exporter(self): + self._assert_supports_additional_properties(LogRecordExporter) + + def test_push_metric_exporter(self): + self._assert_supports_additional_properties(PushMetricExporter) diff --git a/pyproject.toml b/pyproject.toml index b0d89f84ca..4e4257ceac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,6 +161,8 @@ use-schema-description = true use-title-as-name = true use-union-operator = true target-python-version = "3.10" +custom-template-dir = "opentelemetry-sdk/codegen" +additional-imports = "typing.ClassVar,opentelemetry.sdk._configuration._common._additional_properties" [dependency-groups] dev = [