Skip to content

Commit 5fe8092

Browse files
authored
feat: add deepcopy implementation for BoundedAttributes (#4934)
1 parent 28b6852 commit 5fe8092

File tree

7 files changed

+141
-1
lines changed

7 files changed

+141
-1
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
([#4973](https://github.com/open-telemetry/opentelemetry-python/pull/4973))
2727
- `opentelemetry-exporter-prometheus`: Fix metric name prefix
2828
([#4895](https://github.com/open-telemetry/opentelemetry-python/pull/4895))
29+
- `opentelemetry-api`, `opentelemetry-sdk`: Add deepcopy support for `BoundedAttributes` and `BoundedList`
30+
([#4934](https://github.com/open-telemetry/opentelemetry-python/pull/4934))
2931

3032
## Version 1.40.0/0.61b0 (2026-03-04)
3133

opentelemetry-api/src/opentelemetry/attributes/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import copy
1516
import logging
1617
import threading
1718
from collections import OrderedDict
@@ -318,5 +319,20 @@ def __iter__(self): # type: ignore
318319
def __len__(self) -> int:
319320
return len(self._dict)
320321

322+
def __deepcopy__(self, memo: dict) -> "BoundedAttributes":
323+
copy_ = BoundedAttributes(
324+
maxlen=self.maxlen,
325+
immutable=self._immutable,
326+
max_value_len=self.max_value_len,
327+
extended_attributes=self._extended_attributes,
328+
)
329+
memo[id(self)] = copy_
330+
with self._lock:
331+
# Assign _dict directly to avoid re-cleaning already clean values
332+
# and to bypass the immutability guard in __setitem__
333+
copy_._dict = copy.deepcopy(self._dict, memo)
334+
copy_.dropped = self.dropped
335+
return copy_
336+
321337
def copy(self): # type: ignore
322338
return self._dict.copy() # type: ignore

opentelemetry-api/tests/attributes/test_attributes.py

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

1515
# type: ignore
1616

17+
import copy
1718
import unittest
1819
from typing import MutableSequence
1920

@@ -320,3 +321,30 @@ def __str__(self):
320321
self.assertEqual(
321322
"<DummyWSGIRequest method=GET path=/example/>", cleaned_value
322323
)
324+
325+
def test_deepcopy(self):
326+
bdict = BoundedAttributes(4, self.base, immutable=False)
327+
bdict.dropped = 10
328+
bdict_copy = copy.deepcopy(bdict)
329+
330+
for key in bdict_copy:
331+
self.assertEqual(bdict_copy[key], bdict[key])
332+
333+
self.assertEqual(bdict_copy.dropped, bdict.dropped)
334+
self.assertEqual(bdict_copy.maxlen, bdict.maxlen)
335+
self.assertEqual(bdict_copy.max_value_len, bdict.max_value_len)
336+
337+
bdict_copy["name"] = "Bob"
338+
self.assertNotEqual(bdict_copy["name"], bdict["name"])
339+
340+
bdict["age"] = 99
341+
self.assertNotEqual(bdict["age"], bdict_copy["age"])
342+
343+
def test_deepcopy_preserves_immutability(self):
344+
bdict = BoundedAttributes(
345+
maxlen=4, attributes=self.base, immutable=True
346+
)
347+
bdict_copy = copy.deepcopy(bdict)
348+
349+
with self.assertRaises(TypeError):
350+
bdict_copy["invalid"] = "invalid"

opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import copy
1516
import datetime
1617
import threading
1718
from collections import deque
@@ -55,6 +56,14 @@ def __init__(self, maxlen: Optional[int]):
5556
self._dq = deque(maxlen=maxlen) # type: deque
5657
self._lock = threading.Lock()
5758

59+
def __deepcopy__(self, memo):
60+
copy_ = BoundedList(0)
61+
memo[id(self)] = copy_
62+
with self._lock:
63+
copy_.dropped = self.dropped
64+
copy_._dq = copy.deepcopy(self._dq, memo)
65+
return copy_
66+
5867
def __repr__(self):
5968
return f"{type(self).__name__}({list(self._dq)}, maxlen={self._dq.maxlen})"
6069

opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
from typing import (
16+
Any,
1617
Iterable,
1718
Iterator,
1819
Mapping,
@@ -44,6 +45,7 @@ class BoundedList(Sequence[_T]):
4445

4546
dropped: int
4647
def __init__(self, maxlen: Optional[int]): ...
48+
def __deepcopy__(self, memo: dict[int, Any]) -> BoundedList[_T]: ...
4749
def insert(self, index: int, value: _T) -> None: ...
4850
@overload
4951
def __getitem__(self, i: int) -> _T: ...

opentelemetry-sdk/tests/test_util.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import copy
1516
import unittest
1617

1718
from opentelemetry.sdk.util import BoundedList
@@ -142,3 +143,21 @@ def test_no_limit(self):
142143

143144
for num in range(100):
144145
self.assertEqual(blist[num], num)
146+
147+
# pylint: disable=protected-access
148+
def test_deepcopy(self):
149+
blist = BoundedList(maxlen=10)
150+
blist.append(1)
151+
blist.append([2, 3])
152+
blist.dropped = 5
153+
154+
blist_copy = copy.deepcopy(blist)
155+
156+
self.assertIsNot(blist, blist_copy)
157+
self.assertIsNot(blist._dq, blist_copy._dq)
158+
self.assertIsNot(blist._lock, blist_copy._lock)
159+
self.assertEqual(list(blist), list(blist_copy))
160+
self.assertEqual(blist.dropped, blist_copy.dropped)
161+
self.assertEqual(blist._dq.maxlen, blist_copy._dq.maxlen)
162+
self.assertIsNot(blist[1], blist_copy[1])
163+
self.assertEqual(blist[1], blist_copy[1])

opentelemetry-sdk/tests/trace/test_trace.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# pylint: disable=too-many-lines
1616
# pylint: disable=no-member
1717

18+
import copy
1819
import shutil
1920
import subprocess
2021
import unittest
@@ -58,7 +59,7 @@
5859
ParentBased,
5960
StaticSampler,
6061
)
61-
from opentelemetry.sdk.util import BoundedDict, ns_to_iso_str
62+
from opentelemetry.sdk.util import BoundedDict, BoundedList, ns_to_iso_str
6263
from opentelemetry.sdk.util.instrumentation import InstrumentationInfo
6364
from opentelemetry.test.spantestutil import (
6465
get_span_with_dropped_attributes_events_links,
@@ -708,6 +709,69 @@ def test_link_dropped_attributes(self):
708709
)
709710
self.assertEqual(link2.dropped_attributes, 0)
710711

712+
def test_deepcopy(self):
713+
context = trace_api.SpanContext(
714+
trace_id=0x000000000000000000000000DEADBEEF,
715+
span_id=0x00000000DEADBEF0,
716+
is_remote=False,
717+
)
718+
attributes = BoundedAttributes(
719+
10, {"key1": "value1", "key2": 42}, immutable=False
720+
)
721+
events = BoundedList(10)
722+
events.extend(
723+
(
724+
trace.Event("event1", {"ekey": "evalue"}),
725+
trace.Event("event2", {"ekey2": "evalue2"}),
726+
)
727+
)
728+
729+
links = [
730+
trace_api.Link(
731+
context=trace_api.INVALID_SPAN_CONTEXT,
732+
attributes={"lkey": "lvalue"},
733+
)
734+
]
735+
736+
span = trace.ReadableSpan(
737+
name="test-span",
738+
context=context,
739+
attributes=attributes,
740+
events=events,
741+
links=links,
742+
status=Status(StatusCode.OK),
743+
)
744+
745+
span_copy = copy.deepcopy(span)
746+
747+
self.assertEqual(span_copy.name, span.name)
748+
self.assertEqual(span_copy.status.status_code, span.status.status_code)
749+
self.assertEqual(span_copy.context.trace_id, span.context.trace_id)
750+
self.assertEqual(span_copy.context.span_id, span.context.span_id)
751+
752+
self.assertEqual(dict(span_copy.attributes), dict(span.attributes))
753+
attributes["key1"] = "mutated"
754+
self.assertNotEqual(
755+
span_copy.attributes["key1"], span.attributes["key1"]
756+
)
757+
758+
self.assertEqual(len(span_copy.events), len(span.events))
759+
self.assertIsNot(span_copy.events, span.events)
760+
self.assertEqual(span_copy.events[0].name, span.events[0].name)
761+
self.assertEqual(
762+
span_copy.events[0].attributes, span.events[0].attributes
763+
)
764+
765+
self.assertEqual(len(span_copy.links), len(span.links))
766+
self.assertEqual(
767+
span_copy.links[0].attributes, span.links[0].attributes
768+
)
769+
links[0] = trace_api.Link(
770+
context=trace_api.INVALID_SPAN_CONTEXT,
771+
attributes={"mutated": "link"},
772+
)
773+
self.assertNotIn("mutated", span_copy.links[0].attributes)
774+
711775

712776
class DummyError(Exception):
713777
pass

0 commit comments

Comments
 (0)