Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 16 additions & 0 deletions opentelemetry-api/src/opentelemetry/attributes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -318,5 +319,20 @@ 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)
dropped = self.dropped
copy_ = BoundedAttributes(
self.maxlen,
attributes,
self._immutable,
self.max_value_len,
self._extended_attributes,
)
copy_.dropped = dropped
memo[id(self)] = copy_
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the memo key be set before calling deepcopy to prevent infinite recursion? (also, consider setting memo in the BoundedList implementation as well)

return copy_

def copy(self): # type: ignore
return self._dict.copy() # type: ignore
28 changes: 28 additions & 0 deletions opentelemetry-api/tests/attributes/test_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

# type: ignore

import copy
import unittest
from typing import MutableSequence

Expand Down Expand Up @@ -320,3 +321,30 @@ def __str__(self):
self.assertEqual(
"<DummyWSGIRequest method=GET path=/example/>", cleaned_value
)

def test_deepcopy(self):
bdict = BoundedAttributes(4, self.base, immutable=False)
bdict.dropped = 10
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"
8 changes: 8 additions & 0 deletions opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import copy
import datetime
import threading
from collections import deque
Expand Down Expand Up @@ -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})"

Expand Down
2 changes: 2 additions & 0 deletions opentelemetry-sdk/src/opentelemetry/sdk/util/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.

from typing import (
Any,
Iterable,
Iterator,
Mapping,
Expand Down Expand Up @@ -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: ...
Expand Down
19 changes: 19 additions & 0 deletions opentelemetry-sdk/tests/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -142,3 +143,21 @@ 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)
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])
66 changes: 65 additions & 1 deletion opentelemetry-sdk/tests/trace/test_trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# pylint: disable=too-many-lines
# pylint: disable=no-member

import copy
import shutil
import subprocess
import unittest
Expand Down Expand Up @@ -58,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,
Expand Down Expand Up @@ -708,6 +709,69 @@ 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 = BoundedList(10)
events.extend(
(
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))
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(
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
Expand Down
Loading