diff --git a/src/neo4j/time/__init__.py b/src/neo4j/time/__init__.py index 009f516f7..de01deead 100644 --- a/src/neo4j/time/__init__.py +++ b/src/neo4j/time/__init__.py @@ -574,12 +574,17 @@ def __str__(self) -> str: """""" 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 @@ def __sub__(self, other): 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 @@ def from_ticks(cls, ticks: int, tz: t.Optional[_tzinfo] = None) -> Time: 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 @@ def utc_now(cls) -> Time: __nanosecond = 0 - __tzinfo = None + __tzinfo: t.Optional[_tzinfo] = None @property def ticks(self) -> int: @@ -1751,13 +1789,6 @@ def __gt__(self, other: t.Union[Time, time]) -> bool: 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 @@ def combine( # type: ignore[override] """ 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 @@ def __sub__(self, other): 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 # diff --git a/tests/unit/common/spatial/test_cartesian_point.py b/tests/unit/common/spatial/test_cartesian_point.py index eaf7be6a2..71d40a772 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 @@ def test_alias_2d(self) -> None: 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 1cd8dc9f0..109e3a225 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 @@ ) +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 @@ def test_immutable_coordinates(self) -> None: 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 0ad799125..0efde482d 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 @@ def test_alias_2d(self) -> None: 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 c43ac68cc..ce8e6b24a 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 0aa0a1348..c69e0b2e0 100644 --- a/tests/unit/common/time/test_date.py +++ b/tests/unit/common/time/test_date.py @@ -18,6 +18,7 @@ import copy import datetime +import pickle from datetime import date from time import struct_time @@ -535,17 +536,43 @@ def test_from_iso_format(self) -> None: 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 7e82cf5b8..75fc3cbfa 100644 --- a/tests/unit/common/time/test_datetime.py +++ b/tests/unit/common/time/test_datetime.py @@ -19,6 +19,7 @@ import copy import itertools import operator +import pickle from datetime import ( datetime, timedelta, @@ -51,6 +52,17 @@ 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 @@ def test_from_iso_format_with_negative_long_tz(self) -> None: 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 534124c48..23e2b0879 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 @@ def test_iso_format(self) -> None: 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 72036c6c3..a975781ea 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_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 @@ def test_comparison(self, t1, t2) -> None: 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)