From 3135fae1ae8643d317bdd6ec2a916f48c760292f Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 24 Feb 2026 18:13:04 -0500 Subject: [PATCH 1/2] feat: add deepcopy implementation for BoundedAttributes --- .../src/opentelemetry/attributes/__init__.py | 14 +++++ .../tests/attributes/test_attributes.py | 27 +++++++++ opentelemetry-sdk/tests/trace/test_trace.py | 58 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index 5116c2fdd8..816a4ddb66 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import logging import threading from collections import OrderedDict @@ -318,5 +319,18 @@ def __iter__(self): # type: ignore def __len__(self) -> int: return len(self._dict) + def __deepcopy__(self, memo: dict) -> "BoundedAttributes": + with self._lock: + attributes = copy.deepcopy(self._dict, memo) + copy_ = BoundedAttributes( + self.maxlen, + attributes, + self._immutable, + self.max_value_len, + self._extended_attributes, + ) + memo[id(self)] = copy_ + return copy_ + def copy(self): # type: ignore return self._dict.copy() # type: ignore diff --git a/opentelemetry-api/tests/attributes/test_attributes.py b/opentelemetry-api/tests/attributes/test_attributes.py index 8cb6f35fbc..7c247ba317 100644 --- a/opentelemetry-api/tests/attributes/test_attributes.py +++ b/opentelemetry-api/tests/attributes/test_attributes.py @@ -14,6 +14,7 @@ # type: ignore +import copy import unittest from typing import MutableSequence @@ -320,3 +321,29 @@ def __str__(self): self.assertEqual( "", cleaned_value ) + + def test_deepcopy(self): + bdict = BoundedAttributes(4, self.base, immutable=False) + bdict_copy = copy.deepcopy(bdict) + + for key in bdict_copy: + self.assertEqual(bdict_copy[key], bdict[key]) + + self.assertEqual(bdict_copy.dropped, bdict.dropped) + self.assertEqual(bdict_copy.maxlen, bdict.maxlen) + self.assertEqual(bdict_copy.max_value_len, bdict.max_value_len) + + bdict_copy["name"] = "Bob" + self.assertNotEqual(bdict_copy["name"], bdict["name"]) + + bdict["age"] = 99 + self.assertNotEqual(bdict["age"], bdict_copy["age"]) + + def test_deepcopy_preserves_immutability(self): + bdict = BoundedAttributes( + maxlen=4, attributes=self.base, immutable=True + ) + bdict_copy = copy.deepcopy(bdict) + + with self.assertRaises(TypeError): + bdict_copy["invalid"] = "invalid" diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index e9a59c6cde..1a435d8ca5 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -15,6 +15,7 @@ # pylint: disable=too-many-lines # pylint: disable=no-member +import copy import shutil import subprocess import unittest @@ -708,6 +709,63 @@ def test_link_dropped_attributes(self): ) self.assertEqual(link2.dropped_attributes, 0) + def test_deepcopy(self): + context = trace_api.SpanContext( + trace_id=0x000000000000000000000000DEADBEEF, + span_id=0x00000000DEADBEF0, + is_remote=False, + ) + attributes = BoundedAttributes( + 10, {"key1": "value1", "key2": 42}, immutable=False + ) + events = [ + trace.Event("event1", {"ekey": "evalue"}), + trace.Event("event2", {"ekey2": "evalue2"}), + ] + links = [ + trace_api.Link( + context=trace_api.INVALID_SPAN_CONTEXT, + attributes={"lkey": "lvalue"}, + ) + ] + + span = trace.ReadableSpan( + name="test-span", + context=context, + attributes=attributes, + events=events, + links=links, + status=Status(StatusCode.OK), + ) + + span_copy = copy.deepcopy(span) + + self.assertEqual(span_copy.name, span.name) + self.assertEqual(span_copy.status.status_code, span.status.status_code) + self.assertEqual(span_copy.context.trace_id, span.context.trace_id) + self.assertEqual(span_copy.context.span_id, span.context.span_id) + + self.assertEqual(dict(span_copy.attributes), dict(span.attributes)) + attributes["key1"] = "mutated" + self.assertNotEqual( + span_copy.attributes["key1"], span.attributes["key1"] + ) + + self.assertEqual(len(span_copy.events), len(span.events)) + events[0] = trace.Event("mutated-event", {"mutated": "value"}) + self.assertNotEqual(span_copy.events[0].name, events[0].name) + self.assertEqual(span_copy.events[0].name, "event1") + + self.assertEqual(len(span_copy.links), len(span.links)) + self.assertEqual( + span_copy.links[0].attributes, span.links[0].attributes + ) + links[0] = trace_api.Link( + context=trace_api.INVALID_SPAN_CONTEXT, + attributes={"mutated": "link"}, + ) + self.assertNotIn("mutated", span_copy.links[0].attributes) + class DummyError(Exception): pass From 012a9c0bcb65be3ba833b67b285eccccfce9fc84 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 24 Feb 2026 18:17:36 -0500 Subject: [PATCH 2/2] update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b925076a..50e5a74d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4913](https://github.com/open-telemetry/opentelemetry-python/pull/4913)) - bump semantic-conventions to v1.39.0 ([#4914](https://github.com/open-telemetry/opentelemetry-python/pull/4914)) +- `opentelemetry-api`: Add deepcopy support for `BoundedAttributes` + ([#4934](https://github.com/open-telemetry/opentelemetry-python/pull/4934)) ## Version 1.39.0/0.60b0 (2025-12-03)