From 7b1c2d176381e136d304303538c57c92a0df2bca Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 24 Feb 2026 18:13:04 -0500 Subject: [PATCH 1/4] 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 96bfff7174510bb449c47bbe9b2bcd8bce508b12 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Tue, 24 Feb 2026 18:17:36 -0500 Subject: [PATCH 2/4] update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6003d1a794..7ec0d68ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4958](https://github.com/open-telemetry/opentelemetry-python/pull/4958)) - `opentelemetry-sdk`: fix type annotations on `MetricReader` and related types ([#4938](https://github.com/open-telemetry/opentelemetry-python/pull/4938/)) +- `opentelemetry-api`: Add deepcopy support for `BoundedAttributes` + ([#4934](https://github.com/open-telemetry/opentelemetry-python/pull/4934)) ## Version 1.40.0/0.61b0 (2026-03-04) From 8d5e9a73becb20d1cd415f305e8eb74db0b70797 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 8 Mar 2026 18:01:57 -0400 Subject: [PATCH 3/4] add implementation for BoundedList --- .../src/opentelemetry/attributes/__init__.py | 2 ++ .../tests/attributes/test_attributes.py | 1 + .../src/opentelemetry/sdk/util/__init__.py | 10 ++++++++- .../src/opentelemetry/sdk/util/__init__.pyi | 2 ++ opentelemetry-sdk/tests/test_util.py | 18 +++++++++++++++ opentelemetry-sdk/tests/trace/test_trace.py | 22 ++++++++++++------- 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/opentelemetry-api/src/opentelemetry/attributes/__init__.py b/opentelemetry-api/src/opentelemetry/attributes/__init__.py index 816a4ddb66..80a4721905 100644 --- a/opentelemetry-api/src/opentelemetry/attributes/__init__.py +++ b/opentelemetry-api/src/opentelemetry/attributes/__init__.py @@ -322,6 +322,7 @@ def __len__(self) -> int: def __deepcopy__(self, memo: dict) -> "BoundedAttributes": with self._lock: attributes = copy.deepcopy(self._dict, memo) + dropped = self.dropped copy_ = BoundedAttributes( self.maxlen, attributes, @@ -329,6 +330,7 @@ def __deepcopy__(self, memo: dict) -> "BoundedAttributes": self.max_value_len, self._extended_attributes, ) + copy_.dropped = dropped memo[id(self)] = copy_ return copy_ diff --git a/opentelemetry-api/tests/attributes/test_attributes.py b/opentelemetry-api/tests/attributes/test_attributes.py index 7c247ba317..40d04fbe7d 100644 --- a/opentelemetry-api/tests/attributes/test_attributes.py +++ b/opentelemetry-api/tests/attributes/test_attributes.py @@ -324,6 +324,7 @@ def __str__(self): def test_deepcopy(self): bdict = BoundedAttributes(4, self.base, immutable=False) + bdict.dropped = 10 bdict_copy = copy.deepcopy(bdict) for key in bdict_copy: diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py index 72f92fc25c..030fd29252 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import datetime import threading from collections import deque from collections.abc import MutableMapping, Sequence -from typing import Optional +from typing import Any, Optional from typing_extensions import deprecated @@ -55,6 +56,13 @@ def __init__(self, maxlen: Optional[int]): self._dq = deque(maxlen=maxlen) # type: deque self._lock = threading.Lock() + def __deepcopy__(self, memo): + copy_ = BoundedList(0) + with self._lock: + copy_.dropped = self.dropped + copy_._dq = copy.deepcopy(self._dq, memo) + return copy_ + def __repr__(self): return f"{type(self).__name__}({list(self._dq)}, maxlen={self._dq.maxlen})" diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi index 55042fcf0e..7a4c25a35f 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi @@ -13,6 +13,7 @@ # limitations under the License. from typing import ( + Any, Iterable, Iterator, Mapping, @@ -43,6 +44,7 @@ class BoundedList(Sequence[_T]): dropped: int def __init__(self, maxlen: int): ... + def __deepcopy__(self, memo: dict[int, Any]) -> BoundedList[_T]: ... def insert(self, index: int, value: _T) -> None: ... @overload def __getitem__(self, i: int) -> _T: ... diff --git a/opentelemetry-sdk/tests/test_util.py b/opentelemetry-sdk/tests/test_util.py index db6d3b5787..3400e1adee 100644 --- a/opentelemetry-sdk/tests/test_util.py +++ b/opentelemetry-sdk/tests/test_util.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import copy import unittest from opentelemetry.sdk.util import BoundedList @@ -142,3 +143,20 @@ def test_no_limit(self): for num in range(100): self.assertEqual(blist[num], num) + + def test_deepcopy(self): + blist = BoundedList(maxlen=10) + blist.append(1) + blist.append([2, 3]) + blist.dropped = 5 + + blist_copy = copy.deepcopy(blist) + + self.assertIsNot(blist, blist_copy) + self.assertIsNot(blist._dq, blist_copy._dq) + self.assertIsNot(blist._lock, blist_copy._lock) + self.assertEqual(list(blist), list(blist_copy)) + self.assertEqual(blist.dropped, blist_copy.dropped) + self.assertEqual(blist._dq.maxlen, blist_copy._dq.maxlen) + self.assertIsNot(blist[1], blist_copy[1]) + self.assertEqual(blist[1], blist_copy[1]) diff --git a/opentelemetry-sdk/tests/trace/test_trace.py b/opentelemetry-sdk/tests/trace/test_trace.py index 1a435d8ca5..d264c43c13 100644 --- a/opentelemetry-sdk/tests/trace/test_trace.py +++ b/opentelemetry-sdk/tests/trace/test_trace.py @@ -59,7 +59,7 @@ ParentBased, StaticSampler, ) -from opentelemetry.sdk.util import BoundedDict, ns_to_iso_str +from opentelemetry.sdk.util import BoundedDict, BoundedList, ns_to_iso_str from opentelemetry.sdk.util.instrumentation import InstrumentationInfo from opentelemetry.test.spantestutil import ( get_span_with_dropped_attributes_events_links, @@ -718,10 +718,14 @@ def test_deepcopy(self): attributes = BoundedAttributes( 10, {"key1": "value1", "key2": 42}, immutable=False ) - events = [ - trace.Event("event1", {"ekey": "evalue"}), - trace.Event("event2", {"ekey2": "evalue2"}), - ] + events = BoundedList(10) + events.extend( + ( + trace.Event("event1", {"ekey": "evalue"}), + trace.Event("event2", {"ekey2": "evalue2"}), + ) + ) + links = [ trace_api.Link( context=trace_api.INVALID_SPAN_CONTEXT, @@ -752,9 +756,11 @@ def test_deepcopy(self): ) 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.assertIsNot(span_copy.events, span.events) + self.assertEqual(span_copy.events[0].name, span.events[0].name) + self.assertEqual( + span_copy.events[0].attributes, span.events[0].attributes + ) self.assertEqual(len(span_copy.links), len(span.links)) self.assertEqual( From fe94bb0047d2b16cc7d7d4c41b50e9ac87f0ced8 Mon Sep 17 00:00:00 2001 From: Lukas Hering Date: Sun, 8 Mar 2026 18:07:31 -0400 Subject: [PATCH 4/4] fix lint errors --- opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py | 2 +- opentelemetry-sdk/tests/test_util.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py index 030fd29252..8f884fdc95 100644 --- a/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py +++ b/opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py @@ -17,7 +17,7 @@ import threading from collections import deque from collections.abc import MutableMapping, Sequence -from typing import Any, Optional +from typing import Optional from typing_extensions import deprecated diff --git a/opentelemetry-sdk/tests/test_util.py b/opentelemetry-sdk/tests/test_util.py index 3400e1adee..cd255d5992 100644 --- a/opentelemetry-sdk/tests/test_util.py +++ b/opentelemetry-sdk/tests/test_util.py @@ -144,6 +144,7 @@ def test_no_limit(self): for num in range(100): self.assertEqual(blist[num], num) + # pylint: disable=protected-access def test_deepcopy(self): blist = BoundedList(maxlen=10) blist.append(1)