Skip to content

Commit dc28b95

Browse files
MikeGoldsmithxrmx
andauthored
feat(config): add additionalProperties support to generated config models (#5131)
* add additional_properties support to generated config models Schema types with additionalProperties (Sampler, SpanExporter, TextMapPropagator, ExperimentalResourceDetector, etc.) now capture unknown keyword arguments in an additional_properties dict field. This enables plugin/custom component names to flow through typed dataclasses without modifying the codegen tool. Implementation: - _additional_properties_support decorator in _common.py wraps the dataclass __init__ to route unknown kwargs into additional_properties - Custom dataclass.jinja2 template for datamodel-codegen conditionally applies the decorator and field when additionalPropertiesType is set - pyproject.toml updated with custom-template-dir and additional-imports - models.py regenerated via tox -e generate-config-from-jsonschema Assisted-by: Claude Opus 4.6 * update CHANGELOG with PR number #5131 * fix CI: plain assignment, pylint suppression for intentional unknown kwargs - Replace object.__setattr__ with plain self.additional_properties = extra (no frozen dataclasses, simpler and more Pythonic) - Add pylint disable=unexpected-keyword-arg on test lines that intentionally pass unknown kwargs to verify the decorator captures them - Add comment explaining setattr usage for __signature__ (pyright workaround) Assisted-by: Claude Opus 4.6 * use ClassVar annotation instead of dataclass field for additional_properties ClassVar tells type checkers the attribute exists without creating a dataclass field. This means additional_properties doesn't appear in __init__ signature, dataclasses.fields(), asdict(), or repr() — only schema-defined fields show up. The decorator sets it as a plain instance attribute at runtime. Assisted-by: Claude Opus 4.6 * rename _additional_properties_support to _additional_properties Shorter name, aligns with the attribute it creates. Assisted-by: Claude Opus 4.6 --------- Co-authored-by: Riccardo Magliocchetti <riccardo.magliocchetti@gmail.com>
1 parent 44d8911 commit dc28b95

8 files changed

Lines changed: 264 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
## Unreleased
1414

15+
- `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
16+
([#5131](https://github.com/open-telemetry/opentelemetry-python/pull/5131))
1517
- Fix incorrect code example in `create_tracer()` docstring
1618
([#5072](https://github.com/open-telemetry/opentelemetry-python/issues/5072))
1719
- `opentelemetry-sdk`: add `load_entry_point` shared utility to declarative file configuration for loading plugins via entry points; refactor propagator loading to use it
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Code Generation Templates
2+
3+
Custom [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator) templates used when generating `models.py` from the OpenTelemetry configuration JSON schema.
4+
5+
## `dataclass.jinja2`
6+
7+
Extends the default dataclass template to support `additionalProperties` from the JSON Schema. Schema types that allow additional properties (e.g. `Sampler`, `SpanExporter`, `TextMapPropagator`) get:
8+
9+
- `@_additional_properties` decorator — captures unknown constructor kwargs
10+
- `additional_properties: ClassVar[dict[str, Any]]` annotation — satisfies type checkers without creating a dataclass field
11+
12+
This enables plugin/custom component names to flow through typed dataclasses without a post-processing step.
13+
14+
## Usage
15+
16+
Templates are applied automatically when regenerating models:
17+
18+
```sh
19+
tox -e generate-config-from-jsonschema
20+
```
21+
22+
The template directory is configured in `pyproject.toml` under `[tool.datamodel-codegen]`.
23+
24+
See `opentelemetry-sdk/src/opentelemetry/sdk/_configuration/README.md` for the full schema update workflow.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
{# Custom dataclass template for OpenTelemetry configuration models.
2+
Extends the default datamodel-codegen dataclass template to support
3+
JSON Schema additionalProperties. When a schema type allows additional
4+
properties (e.g. Sampler, SpanExporter), this template adds:
5+
- @_additional_properties decorator (captures unknown kwargs)
6+
- additional_properties: ClassVar[dict[str, Any]] annotation (for type checkers)
7+
8+
The template checks two context variables set by datamodel-codegen:
9+
- additionalProperties: set when the schema value is a boolean (true/false)
10+
- additionalPropertiesType: set when the schema value is a type object
11+
(e.g. {"type": ["object", "null"]}), which is how the OTel config
12+
schema defines it for plugin-capable types.
13+
14+
See TestGeneratedModelsHaveAdditionalProperties in test_common.py for
15+
regression tests that guard against changes in these context variables.
16+
-#}
17+
{% for decorator in decorators -%}
18+
{{ decorator }}
19+
{% endfor -%}
20+
{%- set args = [] %}
21+
{%- for k, v in (dataclass_arguments or {}).items() %}
22+
{%- if v is not none and v is not false %}
23+
{%- set _ = args.append(k ~ '=' ~ (v|pprint)) %}
24+
{%- endif %}
25+
{%- endfor %}
26+
{%- set has_additional = (additionalProperties is defined and additionalProperties != false) or (additionalPropertiesType is defined) %}
27+
{%- if has_additional %}
28+
@_additional_properties
29+
{%- endif %}
30+
{%- if args %}
31+
@dataclass({{ args | join(', ') }})
32+
{%- else %}
33+
@dataclass
34+
{%- endif %}
35+
{%- if base_class %}
36+
class {{ class_name }}({{ base_class }}):
37+
{%- else %}
38+
class {{ class_name }}:
39+
{%- endif %}
40+
{%- if description %}
41+
"""
42+
{{ description | escape_docstring | indent(4) }}
43+
"""
44+
{%- endif %}
45+
{%- if not fields and not description and not has_additional %}
46+
pass
47+
{%- endif %}
48+
{%- for field in fields -%}
49+
{%- if field.field %}
50+
{{ field.name }}: {{ field.type_hint }} = {{ field.field }}
51+
{%- else %}
52+
{{ field.name }}: {{ field.type_hint }}
53+
{%- if not (field.required or (field.represented_default == 'None' and field.strip_default_none))
54+
%} = {{ field.represented_default }}
55+
{%- endif -%}
56+
{%- endif %}
57+
{%- if field.docstring %}
58+
"""
59+
{{ field.docstring | escape_docstring | indent(4) }}
60+
"""
61+
{%- if field.use_inline_field_description and not loop.last %}
62+
63+
{% endif %}
64+
{%- elif field.inline_field_docstring %}
65+
{{ field.inline_field_docstring }}
66+
{%- if not loop.last %}
67+
68+
{% endif %}
69+
{%- endif %}
70+
{%- endfor -%}
71+
{%- if has_additional %}
72+
additional_properties: ClassVar[dict[str, Any]]
73+
{%- endif -%}

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ This package implements [OpenTelemetry file-based configuration](https://opentel
55
## Files
66

77
- `schema.json` — vendored copy of the [OpenTelemetry configuration JSON schema](https://github.com/open-telemetry/opentelemetry-configuration)
8-
- `models.py` — Python dataclasses generated from `schema.json` by [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator)
8+
- `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)
99

1010
## Updating the schema
1111

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/_common.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
from __future__ import annotations
1616

17+
import dataclasses
18+
import inspect
1719
import logging
1820
from typing import Optional, Type
1921

@@ -23,6 +25,40 @@
2325
_logger = logging.getLogger(__name__)
2426

2527

28+
def _additional_properties(cls):
29+
"""Decorator for dataclasses whose JSON Schema sets additionalProperties.
30+
31+
Wraps the dataclass-generated ``__init__`` so that unknown keyword
32+
arguments are captured into an ``additional_properties`` instance
33+
attribute instead of raising ``TypeError``. This lets plugin/custom
34+
component names flow through the config pipeline without modifying
35+
the codegen output for known fields.
36+
37+
Applied automatically by the custom template in ``opentelemetry-sdk/codegen/``
38+
when ``additionalPropertiesType`` is present in the template context
39+
(set by ``datamodel-codegen`` for schema types with ``additionalProperties``).
40+
"""
41+
original_init = cls.__init__
42+
original_sig = inspect.signature(original_init)
43+
known_fields = frozenset(f.name for f in dataclasses.fields(cls))
44+
45+
def _init(self, **kwargs):
46+
known = {k: v for k, v in kwargs.items() if k in known_fields}
47+
extra = {k: v for k, v in kwargs.items() if k not in known_fields}
48+
original_init(self, **known)
49+
self.additional_properties = extra
50+
51+
# Preserve the original parameter list for IDE autocompletion and
52+
# inspect.signature(), adding **kwargs to signal extras are accepted.
53+
# setattr used because pyright rejects direct __signature__ assignment.
54+
params = list(original_sig.parameters.values())
55+
params.append(inspect.Parameter("kwargs", inspect.Parameter.VAR_KEYWORD))
56+
setattr(_init, "__signature__", original_sig.replace(parameters=params)) # noqa: B010
57+
58+
cls.__init__ = _init
59+
return cls
60+
61+
2662
def load_entry_point(group: str, name: str) -> Type:
2763
"""Load a plugin class from an entry point group by name.
2864

opentelemetry-sdk/src/opentelemetry/sdk/_configuration/models.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# generated by datamodel-codegen:
22
# filename: schema.json
3-
# timestamp: 2026-04-13T15:12:07+00:00
3+
# timestamp: 2026-04-23T13:18:12+00:00
44

55
from __future__ import annotations
66

77
from dataclasses import dataclass
88
from enum import Enum
9-
from typing import Any, TypeAlias
9+
from typing import Any, ClassVar, TypeAlias
10+
11+
from opentelemetry.sdk._configuration._common import _additional_properties
1012

1113
AlwaysOffSampler: TypeAlias = dict[str, Any] | None
1214

@@ -350,12 +352,14 @@ class SeverityNumber(Enum):
350352
fatal4 = "fatal4"
351353

352354

355+
@_additional_properties
353356
@dataclass
354357
class SpanExporter:
355358
otlp_http: OtlpHttpExporter | None = None
356359
otlp_grpc: OtlpGrpcExporter | None = None
357360
otlp_file_development: ExperimentalOtlpFileExporter | None = None
358361
console: ConsoleExporter | None = None
362+
additional_properties: ClassVar[dict[str, Any]]
359363

360364

361365
class SpanKind(Enum):
@@ -500,12 +504,14 @@ class ExperimentalPrometheusMetricExporter:
500504
)
501505

502506

507+
@_additional_properties
503508
@dataclass
504509
class ExperimentalResourceDetector:
505510
container: ExperimentalContainerResourceDetector | None = None
506511
host: ExperimentalHostResourceDetector | None = None
507512
process: ExperimentalProcessResourceDetector | None = None
508513
service: ExperimentalServiceResourceDetector | None = None
514+
additional_properties: ClassVar[dict[str, Any]]
509515

510516

511517
@dataclass
@@ -524,22 +530,28 @@ class ExperimentalTracerConfigurator:
524530
tracers: list[ExperimentalTracerMatcherAndConfig] | None = None
525531

526532

533+
@_additional_properties
527534
@dataclass
528535
class LogRecordExporter:
529536
otlp_http: OtlpHttpExporter | None = None
530537
otlp_grpc: OtlpGrpcExporter | None = None
531538
otlp_file_development: ExperimentalOtlpFileExporter | None = None
532539
console: ConsoleExporter | None = None
540+
additional_properties: ClassVar[dict[str, Any]]
533541

534542

543+
@_additional_properties
535544
@dataclass
536545
class MetricProducer:
537546
opencensus: OpenCensusMetricProducer | None = None
547+
additional_properties: ClassVar[dict[str, Any]]
538548

539549

550+
@_additional_properties
540551
@dataclass
541552
class PullMetricExporter:
542553
prometheus_development: ExperimentalPrometheusMetricExporter | None = None
554+
additional_properties: ClassVar[dict[str, Any]]
543555

544556

545557
@dataclass
@@ -549,12 +561,14 @@ class PullMetricReader:
549561
cardinality_limits: CardinalityLimits | None = None
550562

551563

564+
@_additional_properties
552565
@dataclass
553566
class PushMetricExporter:
554567
otlp_http: OtlpHttpMetricExporter | None = None
555568
otlp_grpc: OtlpGrpcMetricExporter | None = None
556569
otlp_file_development: ExperimentalOtlpFileMetricExporter | None = None
557570
console: ConsoleMetricExporter | None = None
571+
additional_properties: ClassVar[dict[str, Any]]
558572

559573

560574
@dataclass
@@ -567,18 +581,22 @@ class SimpleSpanProcessor:
567581
exporter: SpanExporter
568582

569583

584+
@_additional_properties
570585
@dataclass
571586
class SpanProcessor:
572587
batch: BatchSpanProcessor | None = None
573588
simple: SimpleSpanProcessor | None = None
589+
additional_properties: ClassVar[dict[str, Any]]
574590

575591

592+
@_additional_properties
576593
@dataclass
577594
class TextMapPropagator:
578595
tracecontext: TraceContextPropagator | None = None
579596
baggage: BaggagePropagator | None = None
580597
b3: B3Propagator | None = None
581598
b3multi: B3MultiPropagator | None = None
599+
additional_properties: ClassVar[dict[str, Any]]
582600

583601

584602
@dataclass
@@ -639,10 +657,12 @@ class ExperimentalResourceDetection:
639657
detectors: list[ExperimentalResourceDetector] | None = None
640658

641659

660+
@_additional_properties
642661
@dataclass
643662
class LogRecordProcessor:
644663
batch: BatchLogRecordProcessor | None = None
645664
simple: SimpleLogRecordProcessor | None = None
665+
additional_properties: ClassVar[dict[str, Any]]
646666

647667

648668
@dataclass
@@ -697,6 +717,7 @@ class MeterProvider:
697717
meter_configurator_development: ExperimentalMeterConfigurator | None = None
698718

699719

720+
@_additional_properties
700721
@dataclass
701722
class OpenTelemetryConfiguration:
702723
file_format: str
@@ -710,6 +731,7 @@ class OpenTelemetryConfiguration:
710731
resource: Resource | None = None
711732
instrumentation_development: ExperimentalInstrumentation | None = None
712733
distribution: Distribution | None = None
734+
additional_properties: ClassVar[dict[str, Any]]
713735

714736

715737
@dataclass
@@ -741,6 +763,7 @@ class ExperimentalComposableRuleBasedSamplerRule:
741763
parent: list[ExperimentalSpanParent] | None = None
742764

743765

766+
@_additional_properties
744767
@dataclass
745768
class ExperimentalComposableSampler:
746769
always_off: ExperimentalComposableAlwaysOffSampler | None = None
@@ -750,6 +773,7 @@ class ExperimentalComposableSampler:
750773
)
751774
probability: ExperimentalComposableProbabilitySampler | None = None
752775
rule_based: ExperimentalComposableRuleBasedSampler | None = None
776+
additional_properties: ClassVar[dict[str, Any]]
753777

754778

755779
@dataclass
@@ -768,6 +792,7 @@ class ParentBasedSampler:
768792
local_parent_not_sampled: Sampler | None = None
769793

770794

795+
@_additional_properties
771796
@dataclass
772797
class Sampler:
773798
always_off: AlwaysOffSampler | None = None
@@ -777,6 +802,7 @@ class Sampler:
777802
parent_based: ParentBasedSampler | None = None
778803
probability_development: ExperimentalProbabilitySampler | None = None
779804
trace_id_ratio_based: TraceIdRatioBasedSampler | None = None
805+
additional_properties: ClassVar[dict[str, Any]]
780806

781807

782808
@dataclass

0 commit comments

Comments
 (0)