Repository: neo4j/neo4j-python-driver

Issue:
TypeError when serializing DateTime object with pickle
# My Environment
Python Version: Python 3.10.12
Driver Version: 5.15.dev0
Operating System: Pop!_OS 22.04 LTS

# Steps to reproduce
```
import pickle

expected = DateTime(2023, 12, 7, 12, 34, 56, 7890)
dump = pickle.dumps(expected)
actual = pickle.loads(dump)
print(expected == actual)
```

# Expected behavior
```
True
```

# Actual behavior
```
Traceback (most recent call last):
  File "neo4j_driver/test2.py", line 4, in <module>
    dump = pickle.dumps(expected)

TypeError: DateTime.__new__() missing 3 required positional arguments: 'year', 'month', and 'day'
```

Discussion:
Tanguy-Boisset: Hi, I have sent the email and signed the CLA yesterday. I'm unsure whether I have to do something else or not.

Patch:
diff --git a/src/neo4j/time/__init__.py b/src/neo4j/time/__init__.py
index 009f516f..de01deea 100644
--- a/src/neo4j/time/__init__.py
+++ b/src/neo4j/time/__init__.py
@@ -574,12 +574,17 @@ class Duration(t.Tuple[int, int, int, int],  # type: ignore[misc]
         """"""
         return self.iso_format()
 
-    def __copy__(self) -> Duration:
-        return self.__new__(self.__class__, months=self[0], days=self[1],
-                            seconds=self[2], nanoseconds=self[3])
+    def __reduce__(self):
+        return (
+            type(self)._restore, (tuple(self), self.__dict__)
+        )
 
-    def __deepcopy__(self, memo) -> Duration:
-        return self.__copy__()
+    @classmethod
+    def _restore(cls, elements, dict_):
+        instance = tuple.__new__(cls, elements)
+        if dict_:
+            instance.__dict__.update(dict_)
+        return instance
 
     @classmethod
     def from_iso_format(cls, s: str) -> Duration:
@@ -763,6 +768,13 @@ class Date(date_base_class, metaclass=DateType):
     # CONSTRUCTOR #
 
     def __new__(cls, year: int, month: int, day: int) -> Date:
+        # TODO: 6.0 - remove the __new__ magic and ZeroDate being a singleton.
+        #             It's fine to remain as constant. Instead, simply use
+        #             __init__ and simplify pickle/copy (remove __reduce__).
+        #             N.B. this is a breaking change and must be treated as
+        #             such. Also consider introducing __slots__. Potentially
+        #             apply similar treatment to other temporal types as well
+        #             as spatial types.
         if year == month == day == 0:
             return ZeroDate
         year, month, day = _normalize_day(year, month, day)
@@ -1218,11 +1230,17 @@ class Date(date_base_class, metaclass=DateType):
         except TypeError:
             return NotImplemented
 
-    def __copy__(self) -> Date:
-        return self.__new(self.__ordinal, self.__year, self.__month, self.__day)
+    def __reduce__(self):
+        if self is ZeroDate:
+            return "ZeroDate"
+        return type(self)._restore, (self.__dict__,)
 
-    def __deepcopy__(self, *args, **kwargs) -> Date:
-        return self.__copy__()
+    @classmethod
+    def _restore(cls, dict_) -> Date:
+        instance = object.__new__(cls)
+        if dict_:
+            instance.__dict__.update(dict_)
+        return instance
 
     # INSTANCE METHODS #
 
@@ -1396,34 +1414,53 @@ class Time(time_base_class, metaclass=TimeType):
 
     # CONSTRUCTOR #
 
-    def __new__(
-        cls,
+    def __init__(
+        self,
         hour: int = 0,
         minute: int = 0,
         second: int = 0,
         nanosecond: int = 0,
         tzinfo: t.Optional[_tzinfo] = None
-    ) -> Time:
-        hour, minute, second, nanosecond = cls.__normalize_nanosecond(
+    ) -> None:
+        hour, minute, second, nanosecond = self.__normalize_nanosecond(
             hour, minute, second, nanosecond
         )
         ticks = (3600000000000 * hour
                  + 60000000000 * minute
                  + 1000000000 * second
                  + nanosecond)
-        return cls.__new(ticks, hour, minute, second, nanosecond, tzinfo)
+        self.__unchecked_init(ticks, hour, minute, second, nanosecond, tzinfo)
 
     @classmethod
-    def __new(cls, ticks, hour, minute, second, nanosecond, tzinfo):
-        instance = object.__new__(cls)
-        instance.__ticks = int(ticks)
-        instance.__hour = int(hour)
-        instance.__minute = int(minute)
-        instance.__second = int(second)
-        instance.__nanosecond = int(nanosecond)
-        instance.__tzinfo = tzinfo
+    def __unchecked_new(
+        cls,
+        ticks: int,
+        hour: int,
+        minutes: int,
+        second: int,
+        nano: int,
+        tz: t.Optional[_tzinfo]
+    ) -> Time:
+        instance = object.__new__(Time)
+        instance.__unchecked_init(ticks, hour, minutes, second, nano, tz)
         return instance
 
+    def __unchecked_init(
+        self,
+        ticks: int,
+        hour: int,
+        minutes: int,
+        second: int,
+        nano: int,
+        tz: t.Optional[_tzinfo]
+    ) -> None:
+        self.__ticks = ticks
+        self.__hour = hour
+        self.__minute = minutes
+        self.__second = second
+        self.__nanosecond = nano
+        self.__tzinfo = tz
+
     # CLASS METHODS #
 
     @classmethod
@@ -1521,7 +1558,8 @@ class Time(time_base_class, metaclass=TimeType):
             second, nanosecond = divmod(ticks, NANO_SECONDS)
             minute, second = divmod(second, 60)
             hour, minute = divmod(minute, 60)
-            return cls.__new(ticks, hour, minute, second, nanosecond, tz)
+            return cls.__unchecked_new(ticks, hour, minute, second, nanosecond,
+                                       tz)
         raise ValueError("Ticks out of range (0..86400000000000)")
 
     @classmethod
@@ -1619,7 +1657,7 @@ class Time(time_base_class, metaclass=TimeType):
 
     __nanosecond = 0
 
-    __tzinfo = None
+    __tzinfo: t.Optional[_tzinfo] = None
 
     @property
     def ticks(self) -> int:
@@ -1751,13 +1789,6 @@ class Time(time_base_class, metaclass=TimeType):
             return NotImplemented
         return self_ticks > other_ticks
 
-    def __copy__(self) -> Time:
-        return self.__new(self.__ticks, self.__hour, self.__minute,
-                          self.__second, self.__nanosecond, self.__tzinfo)
-
-    def __deepcopy__(self, *args, **kwargs) -> Time:
-        return self.__copy__()
-
     # INSTANCE METHODS #
 
     if t.TYPE_CHECKING:
@@ -2126,6 +2157,10 @@ class DateTime(date_time_base_class, metaclass=DateTimeType):
         """
         assert isinstance(date, Date)
         assert isinstance(time, Time)
+        return cls._combine(date, time)
+
+    @classmethod
+    def _combine(cls, date: Date, time: Time) -> DateTime:
         instance = object.__new__(cls)
         instance.__date = date
         instance.__time = time
@@ -2491,11 +2526,15 @@ class DateTime(date_time_base_class, metaclass=DateTimeType):
             return self.__add__(-other)
         return NotImplemented
 
-    def __copy__(self) -> DateTime:
-        return self.combine(self.__date, self.__time)
+    def __reduce__(self):
+        return type(self)._restore, (self.__dict__,)
 
-    def __deepcopy__(self, memo) -> DateTime:
-        return self.__copy__()
+    @classmethod
+    def _restore(cls, dict_):
+        instance = object.__new__(cls)
+        if dict_:
+            instance.__dict__.update(dict_)
+        return instance
 
     # INSTANCE METHODS #
 


Test patch:
diff --git a/tests/unit/common/spatial/test_cartesian_point.py b/tests/unit/common/spatial/test_cartesian_point.py
index eaf7be6a..71d40a77 100644
--- a/tests/unit/common/spatial/test_cartesian_point.py
+++ b/tests/unit/common/spatial/test_cartesian_point.py
@@ -16,11 +16,23 @@
 
 from __future__ import annotations
 
+import copy
+import pickle
+
 import pytest
 
 from neo4j.spatial import CartesianPoint
 
 
+def make_reduce_points():
+    return (
+        CartesianPoint((1, 2)),
+        CartesianPoint((3.2, 4.0)),
+        CartesianPoint((3, 4, -1)),
+        CartesianPoint((3.2, 4.0, -1.2)),
+    )
+
+
 class TestCartesianPoint:
 
     def test_alias_3d(self) -> None:
@@ -42,3 +54,30 @@ class TestCartesianPoint:
         assert p.y == y
         with pytest.raises(AttributeError):
             _ = p.z
+
+    @pytest.mark.parametrize("p", make_reduce_points())
+    def test_copy(self, p):
+        p.foo = [1, 2]
+        p2 = copy.copy(p)
+        assert p == p2
+        assert p is not p2
+        assert p.foo is p2.foo
+
+    @pytest.mark.parametrize("p", make_reduce_points())
+    def test_deep_copy(self, p):
+        p.foo = [1, [2]]
+        p2 = copy.deepcopy(p)
+        assert p == p2
+        assert p is not p2
+        assert p.foo == p2.foo
+        assert p.foo is not p2.foo
+        assert p.foo[1] is not p2.foo[1]
+
+    @pytest.mark.parametrize("expected", make_reduce_points())
+    def test_pickle(self, expected):
+        expected.foo = [1, [2]]
+        actual = pickle.loads(pickle.dumps(expected))
+        assert expected == actual
+        assert expected is not actual
+        assert expected.foo == actual.foo
+        assert expected.foo is not actual.foo
diff --git a/tests/unit/common/spatial/test_point.py b/tests/unit/common/spatial/test_point.py
index 1cd8dc9f..109e3a22 100644
--- a/tests/unit/common/spatial/test_point.py
+++ b/tests/unit/common/spatial/test_point.py
@@ -16,6 +16,8 @@
 
 from __future__ import annotations
 
+import copy
+import pickle
 import typing as t
 
 import pytest
@@ -26,6 +28,17 @@ from neo4j._spatial import (
 )
 
 
+def make_reduce_points():
+    return (
+        Point((42,)),
+        Point((69.420,)),
+        Point((1, 2)),
+        Point((1.2, 2.3)),
+        Point((1, 3, 3, 7)),
+        Point((1.0, 3.0, 3.0, 7.0)),
+    )
+
+
 class TestPoint:
 
     @pytest.mark.parametrize("argument", (
@@ -59,3 +72,30 @@ class TestPoint:
             p[1] = 2.0  # type: ignore[index]
         with pytest.raises(TypeError):
             p[2] = 2.0  # type: ignore[index]
+
+    @pytest.mark.parametrize("p", make_reduce_points())
+    def test_copy(self, p):
+        p.foo = [1, 2]
+        p2 = copy.copy(p)
+        assert p == p2
+        assert p is not p2
+        assert p.foo is p2.foo
+
+    @pytest.mark.parametrize("p", make_reduce_points())
+    def test_deep_copy(self, p):
+        p.foo = [1, [2]]
+        p2 = copy.deepcopy(p)
+        assert p == p2
+        assert p is not p2
+        assert p.foo == p2.foo
+        assert p.foo is not p2.foo
+        assert p.foo[1] is not p2.foo[1]
+
+    @pytest.mark.parametrize("expected", make_reduce_points())
+    def test_pickle(self, expected):
+        expected.foo = [1, [2]]
+        actual = pickle.loads(pickle.dumps(expected))
+        assert expected == actual
+        assert expected is not actual
+        assert expected.foo == actual.foo
+        assert expected.foo is not actual.foo
diff --git a/tests/unit/common/spatial/test_wgs84_point.py b/tests/unit/common/spatial/test_wgs84_point.py
index 0ad79912..0efde482 100644
--- a/tests/unit/common/spatial/test_wgs84_point.py
+++ b/tests/unit/common/spatial/test_wgs84_point.py
@@ -16,11 +16,23 @@
 
 from __future__ import annotations
 
+import copy
+import pickle
+
 import pytest
 
 from neo4j.spatial import WGS84Point
 
 
+def make_reduce_points():
+    return (
+        WGS84Point((1, 2)),
+        WGS84Point((3.2, 4.0)),
+        WGS84Point((3, 4, -1)),
+        WGS84Point((3.2, 4.0, -1.2)),
+    )
+
+
 class TestWGS84Point:
 
     def test_alias_3d(self) -> None:
@@ -60,3 +72,30 @@ class TestWGS84Point:
             p.height
         with pytest.raises(AttributeError):
             p.z
+
+    @pytest.mark.parametrize("p", make_reduce_points())
+    def test_copy(self, p):
+        p.foo = [1, 2]
+        p2 = copy.copy(p)
+        assert p == p2
+        assert p is not p2
+        assert p.foo is p2.foo
+
+    @pytest.mark.parametrize("p", make_reduce_points())
+    def test_deep_copy(self, p):
+        p.foo = [1, [2]]
+        p2 = copy.deepcopy(p)
+        assert p == p2
+        assert p is not p2
+        assert p.foo == p2.foo
+        assert p.foo is not p2.foo
+        assert p.foo[1] is not p2.foo[1]
+
+    @pytest.mark.parametrize("expected", make_reduce_points())
+    def test_pickle(self, expected):
+        expected.foo = [1, [2]]
+        actual = pickle.loads(pickle.dumps(expected))
+        assert expected == actual
+        assert expected is not actual
+        assert expected.foo == actual.foo
+        assert expected.foo is not actual.foo
diff --git a/tests/unit/common/test_types.py b/tests/unit/common/test_types.py
index c43ac68c..ce8e6b24 100644
--- a/tests/unit/common/test_types.py
+++ b/tests/unit/common/test_types.py
@@ -14,6 +14,10 @@
 # limitations under the License.
 
 
+import copy
+import pickle
+import typing as t
+from dataclasses import dataclass
 from itertools import product
 
 import pytest
@@ -507,53 +511,124 @@ def test_graph_views_v1():
         assert g.relationships[str(id_)] == rel
 
 
-@pytest.mark.parametrize("legacy_id", (True, False))
-def test_graph_views_v2_repr(legacy_id):
-    hydration_scope = HydrationHandler().new_hydration_scope()
-    gh = hydration_scope._graph_hydrator
+@dataclass
+class ExampleGraph:
+    graph: Graph
+    alice: Node
+    bob: Node
+    carol: Node
+    alice_knows_bob: Relationship
+    carol_dislikes_bob: Relationship
+
+
+@pytest.fixture
+def example_graph_builder_v2() -> t.Callable[[bool], ExampleGraph]:
+    def builder(legacy_id: bool) -> ExampleGraph:
+        hydration_scope = HydrationHandler().new_hydration_scope()
+        gh = hydration_scope._graph_hydrator
+
+        alice_element_id = "1" if legacy_id else "alice"
+        bob_element_id = "2" if legacy_id else "bob"
+        carol_element_id = "3" if legacy_id else "carol"
+
+        alice = gh.hydrate_node(
+            1, {"Person"},  {"name": "Alice", "dict": {"list": [1]}},
+            alice_element_id
+        )
+        bob = gh.hydrate_node(
+            2, {"Person"}, {"name": "Bob", "dict": {"list": [1]}},
+            bob_element_id
+        )
+        carol = gh.hydrate_node(
+            3, {"Person"}, {"name": "Carol", "dict": {"list": [1]}},
+            carol_element_id
+        )
+
+        alice_knows_bob_element_id = "1" if legacy_id else "alice_knows_bob"
+        carol_dislikes_bob_element_id = \
+            "2" if legacy_id else "carol_dislikes_bob"
+
+        alice_knows_bob = gh.hydrate_relationship(
+            1, 1, 2, "KNOWS", {"since": 1999}, alice_knows_bob_element_id,
+            alice_element_id, bob_element_id
+        )
+        carol_dislikes_bob = gh.hydrate_relationship(
+            2, 3, 2, "DISLIKES", {}, carol_dislikes_bob_element_id,
+            carol_element_id, bob_element_id
+        )
+
+        return ExampleGraph(
+            graph=hydration_scope.get_graph(),
+            alice=alice,
+            bob=bob,
+            carol=carol,
+            alice_knows_bob=alice_knows_bob,
+            carol_dislikes_bob=carol_dislikes_bob
+        )
+
+    return builder
 
-    alice_element_id = "1" if legacy_id else "alice"
-    bob_element_id = "2" if legacy_id else "bob"
-    carol_element_id = "3" if legacy_id else "carol"
 
-    alice = gh.hydrate_node(1, {"Person"}, {"name": "Alice"}, alice_element_id)
-    bob = gh.hydrate_node(2, {"Person"}, {"name": "Bob"}, bob_element_id)
-    carol = gh.hydrate_node(3, {"Person"}, {"name": "Carol"}, carol_element_id)
-
-    alice_knows_bob_element_id = "1" if legacy_id else "alice_knows_bob"
-    carol_dislikes_bob_element_id = "2" if legacy_id else "carol_dislikes_bob"
-
-    alice_knows_bob = gh.hydrate_relationship(
-        1, 1, 2, "KNOWS", {"since": 1999}, alice_knows_bob_element_id,
-        alice_element_id, bob_element_id
-    )
-    carol_dislikes_bob = gh.hydrate_relationship(
-        2, 3, 2, "DISLIKES", {}, carol_dislikes_bob_element_id,
-        carol_element_id, bob_element_id
-    )
+@pytest.mark.parametrize("legacy_id", (True, False))
+def test_graph_views_v2_repr(example_graph_builder_v2, legacy_id):
+    g = example_graph_builder_v2(legacy_id)
 
-    g = hydration_scope.get_graph()
-    assert len(g.nodes) == 3
+    assert len(g.graph.nodes) == 3
     for id_, element_id, node in (
-        (1, alice_element_id, alice),
-        (2, bob_element_id, bob),
-        (3, carol_element_id, carol)
+        (1, g.alice.element_id, g.alice),
+        (2, g.bob.element_id, g.bob),
+        (3, g.carol.element_id, g.carol),
     ):
         with pytest.warns(DeprecationWarning, match=r"element_id \(str\)"):
-            assert g.nodes[id_] == node
-        assert g.nodes[element_id] == node
+            assert g.graph.nodes[id_] == node
+        assert g.graph.nodes[element_id] == node
         if not legacy_id:
             with pytest.raises(KeyError):
-                g.nodes[str(id_)]
+                g.graph.nodes[str(id_)]
 
-    assert len(g.relationships) == 2
+    assert len(g.graph.relationships) == 2
     for id_, element_id, rel in (
-        (1, alice_knows_bob_element_id, alice_knows_bob),
-        (2, carol_dislikes_bob_element_id, carol_dislikes_bob)
+        (1, g.alice_knows_bob.element_id, g.alice_knows_bob),
+        (2, g.carol_dislikes_bob.element_id, g.carol_dislikes_bob),
     ):
         with pytest.warns(DeprecationWarning, match=r"element_id \(str\)"):
-            assert g.relationships[id_] == rel
-        assert g.relationships[element_id] == rel
+            assert g.graph.relationships[id_] == rel
+        assert g.graph.relationships[element_id] == rel
         if not legacy_id:
             with pytest.raises(KeyError):
-                g.relationships[str(id_)]
+                g.graph.relationships[str(id_)]
+
+
+@pytest.mark.parametrize("legacy_id", (True, False))
+def test_node_copy(example_graph_builder_v2, legacy_id):
+    g = example_graph_builder_v2(legacy_id)
+    alice = g.alice
+    alice2 = copy.copy(alice)
+    assert alice == alice2
+    assert alice2 is not alice
+    a_dict = alice["dict"]
+    a2_dict = alice2["dict"]
+    assert a2_dict is a_dict
+
+
+@pytest.mark.parametrize("legacy_id", (True, False))
+def test_node_deep_copy(example_graph_builder_v2, legacy_id):
+    g = example_graph_builder_v2(legacy_id)
+    alice = g.alice
+    alice2 = copy.deepcopy(alice)
+    # not the same nodes, because they belong to different graphs
+    # (graph got cloned)
+    assert alice != alice2
+    assert alice2 is not alice
+
+    # root both in a fake graph
+    alice2._graph = alice._graph = Graph()
+    assert alice == alice2
+    a_dict = alice["dict"]
+    a2_dict = alice2["dict"]
+    assert a2_dict == a_dict
+    assert a2_dict is not a_dict
+    a_list = a_dict["list"]
+    a2_list = a2_dict["list"]
+    assert a2_list == a_list
+    assert a2_list is not a_list
diff --git a/tests/unit/common/time/test_date.py b/tests/unit/common/time/test_date.py
index 0aa0a134..c69e0b2e 100644
--- a/tests/unit/common/time/test_date.py
+++ b/tests/unit/common/time/test_date.py
@@ -18,6 +18,7 @@ from __future__ import annotations
 
 import copy
 import datetime
+import pickle
 from datetime import date
 from time import struct_time
 
@@ -535,17 +536,43 @@ class TestDate:
         actual = Date.from_iso_format("2018-10-01")
         assert expected == actual
 
-    def test_date_copy(self) -> None:
+    def test_copy(self) -> None:
         d = Date(2010, 10, 1)
+        d.foo = [1, 2]  # type: ignore[attr-defined]
         d2 = copy.copy(d)
-        assert d is not d2
         assert d == d2
+        assert d is not d2
+        assert d.foo is d2.foo  # type: ignore[attr-defined]
 
-    def test_date_deep_copy(self) -> None:
+    def test_zero_date_copy(self) -> None:
+        d2 = copy.copy(ZeroDate)
+        assert ZeroDate is d2
+
+    def test_deep_copy(self) -> None:
         d = Date(2010, 10, 1)
+        d.foo = [1, [2]]  # type: ignore[attr-defined]
         d2 = copy.deepcopy(d)
-        assert d is not d2
         assert d == d2
+        assert d is not d2
+        assert d.foo == d2.foo  # type: ignore[attr-defined]
+        assert d.foo is not d2.foo  # type: ignore[attr-defined]
+        assert d.foo[1] is not d2.foo[1]
+
+    def test_zero_date_deep_copy(self) -> None:
+        d2 = copy.deepcopy(ZeroDate)
+        assert ZeroDate is d2
+
+    def test_pickle(self) -> None:
+        expected = Date(2010, 10, 1)
+        expected.foo = [1, [2]]  # type: ignore[attr-defined]
+        actual = pickle.loads(pickle.dumps(expected))
+        assert expected == actual
+        assert expected.foo == actual.foo  # type: ignore[attr-defined]
+
+    def test_zero_date_pickle(self) -> None:
+        expected = ZeroDate
+        actual = pickle.loads(pickle.dumps(expected))
+        assert expected is actual
 
 
 @pytest.mark.parametrize(("tz", "expected"), (
diff --git a/tests/unit/common/time/test_datetime.py b/tests/unit/common/time/test_datetime.py
index 7e82cf5b..75fc3cbf 100644
--- a/tests/unit/common/time/test_datetime.py
+++ b/tests/unit/common/time/test_datetime.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 import copy
 import itertools
 import operator
+import pickle
 from datetime import (
     datetime,
     timedelta,
@@ -51,6 +52,17 @@ timezone_berlin = timezone("Europe/Berlin")
 timezone_utc = timezone("UTC")
 
 
+def make_reduce_datetimes():
+    return (
+        DateTime(2023, 12, 7, 12, 34, 56, 123456789),
+        DateTime(2018, 10, 1, 12, 34, 56, 123456789, tzinfo=FixedOffset(754)),
+        DateTime(2018, 10, 1, 12, 34, 56, 123456789, tzinfo=FixedOffset(-754)),
+        DateTime(2019, 10, 30, 7, 54, 2, 129790999, tzinfo=timezone_utc),
+        timezone_berlin.localize(DateTime(2022, 3, 27, 1, 30)),
+        timezone_berlin.localize(DateTime(2022, 3, 27, 3, 30)),
+    )
+
+
 def seconds_options(seconds, nanoseconds):
     yield seconds, nanoseconds
     yield seconds + nanoseconds / 1000000000,
@@ -398,17 +410,32 @@ class TestDateTime:
         actual = DateTime.from_iso_format("2018-10-01T12:34:56.123456789-12:34:56.123456")
         assert expected == actual
 
-    def test_datetime_copy(self) -> None:
-        d = DateTime(2010, 10, 1, 10, 0, 10)
-        d2 = copy.copy(d)
-        assert d is not d2
-        assert d == d2
-
-    def test_datetime_deep_copy(self) -> None:
-        d = DateTime(2010, 10, 1, 10, 0, 12)
-        d2 = copy.deepcopy(d)
-        assert d is not d2
-        assert d == d2
+    @pytest.mark.parametrize("dt", make_reduce_datetimes())
+    def test_copy(self, dt):
+        dt.foo = [1, 2]
+        dt2 = copy.copy(dt)
+        assert dt == dt2
+        assert dt is not dt2
+        assert dt.foo is dt2.foo
+
+    @pytest.mark.parametrize("dt", make_reduce_datetimes())
+    def test_deep_copy(self, dt):
+        dt.foo = [1, [2]]
+        dt2 = copy.deepcopy(dt)
+        assert dt == dt2
+        assert dt is not dt2
+        assert dt.foo == dt2.foo
+        assert dt.foo is not dt2.foo
+        assert dt.foo[1] is not dt2.foo[1]
+
+    @pytest.mark.parametrize("expected", make_reduce_datetimes())
+    def test_pickle(self, expected):
+        expected.foo = [1, [2]]
+        actual = pickle.loads(pickle.dumps(expected))
+        assert expected == actual
+        assert expected is not actual
+        assert expected.foo == actual.foo
+        assert expected.foo is not actual.foo
 
 
 def test_iso_format_with_time_zone_case_1() -> None:
diff --git a/tests/unit/common/time/test_duration.py b/tests/unit/common/time/test_duration.py
index 534124c4..23e2b087 100644
--- a/tests/unit/common/time/test_duration.py
+++ b/tests/unit/common/time/test_duration.py
@@ -17,6 +17,7 @@
 from __future__ import annotations
 
 import copy
+import pickle
 from datetime import timedelta
 
 import pytest
@@ -373,16 +374,32 @@ class TestDuration:
     def test_copy(self) -> None:
         d = Duration(years=1, months=2, days=3, hours=4, minutes=5, seconds=6,
                      milliseconds=7, microseconds=8, nanoseconds=9)
+        d.foo = [1, 2]  # type: ignore[attr-defined]
         d2 = copy.copy(d)
-        assert d is not d2
         assert d == d2
+        assert d is not d2
+        assert d.foo is d2.foo  # type: ignore[attr-defined]
 
     def test_deep_copy(self) -> None:
         d = Duration(years=1, months=2, days=3, hours=4, minutes=5, seconds=6,
                      milliseconds=7, microseconds=8, nanoseconds=9)
+        d.foo = [1, [2]]  # type: ignore[attr-defined]
         d2 = copy.deepcopy(d)
-        assert d is not d2
         assert d == d2
+        assert d is not d2
+        assert d.foo == d2.foo  # type: ignore[attr-defined]
+        assert d.foo is not d2.foo  # type: ignore[attr-defined]
+        assert d.foo[1] is not d2.foo[1]  # type: ignore[attr-defined]
+
+    def test_pickle(self) -> None:
+        expected = Duration(
+            years=1, months=2, days=3, hours=4, minutes=5, seconds=6,
+            milliseconds=7, microseconds=8, nanoseconds=9
+        )
+        expected.foo = [1, [2]]  # type: ignore[attr-defined]
+        actual = pickle.loads(pickle.dumps(expected))
+        assert expected == actual
+        assert expected.foo == actual.foo  # type: ignore[attr-defined]
 
     def test_from_iso_format(self) -> None:
         assert Duration() == Duration.from_iso_format("PT0S")
diff --git a/tests/unit/common/time/test_time.py b/tests/unit/common/time/test_time.py
index 72036c6c..a975781e 100644
--- a/tests/unit/common/time/test_time.py
+++ b/tests/unit/common/time/test_time.py
@@ -16,8 +16,10 @@
 
 from __future__ import annotations
 
+import copy
 import itertools
 import operator
+import pickle
 from datetime import (
     time,
     timedelta,
@@ -43,6 +45,18 @@ timezone_us_eastern = timezone("US/Eastern")
 timezone_utc = timezone("UTC")
 
 
+def make_reduce_times():
+    return (
+        Time(12, 34, 56, 789000001, tzinfo=None),
+        Time(12, 34, 56, 789000001, tzinfo=timezone_utc),
+        Time(12, 34, 56, 789000001, tzinfo=datetime_timezone.utc),
+        Time(13, 34, 56, 789000001, tzinfo=FixedOffset(60)),
+        Time(13, 34, 56, 789000001,
+             tzinfo=datetime_timezone(timedelta(hours=1))),
+        Time(7, 34, 56, 789000001, tzinfo=timezone_us_eastern),
+    )
+
+
 class TestTime:
 
     def test_bad_attribute(self) -> None:
@@ -516,6 +530,33 @@ class TestTime:
         assert t2 >= t1
         assert not t1 >= t2
 
+    @pytest.mark.parametrize("t", make_reduce_times())
+    def test_copy(self, t):
+        t.foo = [1, 2]
+        t2 = copy.copy(t)
+        assert t == t2
+        assert t is not t2
+        assert t.foo is t2.foo
+
+    @pytest.mark.parametrize("t", make_reduce_times())
+    def test_deep_copy(self, t):
+        t.foo = [1, [2]]
+        t2 = copy.deepcopy(t)
+        assert t == t2
+        assert t is not t2
+        assert t.foo == t2.foo
+        assert t.foo is not t2.foo
+        assert t.foo[1] is not t2.foo[1]
+
+    @pytest.mark.parametrize("expected", make_reduce_times())
+    def test_pickle(self, expected):
+        expected.foo = [1, [2]]
+        actual = pickle.loads(pickle.dumps(expected))
+        assert expected == actual
+        assert expected is not actual
+        assert expected.foo == actual.foo
+        assert expected.foo is not actual.foo
+
 
 def test_str() -> None:
     t = Time(12, 34, 56, 789123001)