Skip to content

Commit da3ae2b

Browse files
fix: preserve exception notes when copying and pickling (#729)
Co-authored-by: Bernát Gábor <gaborjbernat@gmail.com>
1 parent ae9cb5b commit da3ae2b

3 files changed

Lines changed: 110 additions & 20 deletions

File tree

docs/changelog/729.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Preserve exception notes and custom attributes when copying or pickling ``Timeout`` and ``SoftFileLockProtocolError``.

src/filelock/_error.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ def __init__(self, lock_file: str) -> None:
88
super().__init__()
99
self._lock_file = lock_file
1010

11-
def __reduce__(self) -> tuple[type[Timeout], tuple[str]]:
11+
def __reduce__(self) -> tuple[type[Timeout], tuple[str], dict[str, object]]:
1212
# __init__ needs lock_file, so pickle must restore it as a constructor arg
13-
return self.__class__, (self._lock_file,)
13+
return self.__class__, (self._lock_file,), self.__dict__
1414

1515
def __str__(self) -> str: # pragma: needs hard-link
1616
return f"The file lock '{self._lock_file}' could not be acquired."
@@ -43,8 +43,10 @@ def __init__(self, lock_file: str, claim_name: str | None, reason: str) -> None:
4343

4444
def __reduce__(
4545
self,
46-
) -> tuple[type[SoftFileLockProtocolError], tuple[str, str | None, str]]: # pragma: needs hard-link
47-
return self.__class__, (self._lock_file, self._claim_name, self._reason)
46+
) -> tuple[
47+
type[SoftFileLockProtocolError], tuple[str, str | None, str], dict[str, object]
48+
]: # pragma: needs hard-link
49+
return self.__class__, (self._lock_file, self._claim_name, self._reason), self.__dict__
4850

4951
def __str__(self) -> str:
5052
location = self._lock_file if self._claim_name is None else f"{self._lock_file}: claim {self._claim_name!r}"

tests/test_error.py

Lines changed: 103 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from __future__ import annotations
22

3-
import pickle # ruff:ignore[suspicious-pickle-import] # round-trips Timeout to assert it pickles
4-
from typing import TYPE_CHECKING
3+
import copy
4+
import pickle # ruff:ignore[suspicious-pickle-import] # round-trips exceptions created in these tests
5+
from functools import partial
6+
from typing import TYPE_CHECKING, Final
57

68
import pytest
79

8-
from filelock import Timeout
10+
from filelock import SoftFileLockProtocolError, Timeout
911

1012
if TYPE_CHECKING:
1113
from collections.abc import Callable
@@ -17,23 +19,108 @@
1719
pytest.param(str, "The file lock '/path/to/lock' could not be acquired.", id="str"),
1820
pytest.param(repr, "Timeout('/path/to/lock')", id="repr"),
1921
pytest.param(lambda t: t.lock_file, "/path/to/lock", id="lock_file"),
20-
pytest.param(lambda t: t.__reduce__(), (Timeout, ("/path/to/lock",)), id="reduce"),
2122
],
2223
)
23-
def test_timeout_attribute(timeout: Timeout, extract: Callable[[Timeout], object], expected: object) -> None:
24-
assert extract(timeout) == expected
24+
def test_timeout_attribute(extract: Callable[[Timeout], str], expected: str) -> None:
25+
assert extract(Timeout("/path/to/lock")) == expected
2526

2627

27-
def test_timeout_pickle(timeout: Timeout) -> None:
28-
reloaded = pickle.loads(pickle.dumps(timeout)) # ruff:ignore[suspicious-pickle-usage] # input is the Timeout built in this test
29-
assert (type(reloaded), str(reloaded), repr(reloaded), reloaded.lock_file) == (
30-
type(timeout),
31-
str(timeout),
32-
repr(timeout),
33-
timeout.lock_file,
28+
def test_exception_serialization_preserves_diagnostics(
29+
error: Timeout | SoftFileLockProtocolError,
30+
clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError],
31+
) -> None:
32+
# Python 3.10 supports the notes attribute but has no add_note().
33+
error.__notes__ = ["while acquiring the build cache"]
34+
vars(error)["request_id"] = "request-1"
35+
restored: Final = clone(error)
36+
assert (
37+
type(restored),
38+
str(restored),
39+
repr(restored),
40+
restored.args,
41+
restored.lock_file,
42+
restored.__notes__,
43+
vars(restored)["request_id"],
44+
) == (
45+
type(error),
46+
str(error),
47+
repr(error),
48+
error.args,
49+
error.lock_file,
50+
["while acquiring the build cache"],
51+
"request-1",
3452
)
3553

3654

37-
@pytest.fixture
38-
def timeout() -> Timeout:
39-
return Timeout("/path/to/lock")
55+
def test_exception_serialization_creates_distinct_instance(
56+
error: Timeout | SoftFileLockProtocolError,
57+
clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError],
58+
) -> None:
59+
assert clone(error) is not error
60+
61+
62+
def test_exception_serialization_preserves_note_alias(
63+
error: Timeout | SoftFileLockProtocolError,
64+
clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError],
65+
) -> None:
66+
error.__notes__ = ["original"]
67+
vars(error)["diagnostics"] = error.__notes__
68+
restored: Final = clone(error)
69+
assert vars(restored)["diagnostics"] is restored.__notes__
70+
71+
72+
def test_exception_copy_shares_notes(error: Timeout | SoftFileLockProtocolError) -> None:
73+
error.__notes__ = ["original"]
74+
copy.copy(error).__notes__.append("copy")
75+
assert error.__notes__ == ["original", "copy"]
76+
77+
78+
def test_exception_deepcopy_isolates_notes(error: Timeout | SoftFileLockProtocolError) -> None:
79+
error.__notes__ = ["original"]
80+
copy.deepcopy(error).__notes__.append("copy")
81+
assert error.__notes__ == ["original"]
82+
83+
84+
def test_exception_serialization_preserves_cycle(
85+
error: Timeout | SoftFileLockProtocolError,
86+
clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError],
87+
) -> None:
88+
vars(error)["related"] = error
89+
restored: Final = clone(error)
90+
assert vars(restored)["related"] is (error if clone is copy.copy else restored)
91+
92+
93+
def test_protocol_error_serialization_preserves_claim(
94+
clone: Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError],
95+
) -> None:
96+
restored: Final = clone(SoftFileLockProtocolError("/lock", "claim", "invalid marker"))
97+
assert isinstance(restored, SoftFileLockProtocolError)
98+
assert (restored.claim_name, restored.reason) == ("claim", "invalid marker")
99+
100+
101+
@pytest.fixture(
102+
params=[pytest.param("timeout", id="timeout"), pytest.param("claim", id="claim"), pytest.param(None, id="no-claim")]
103+
)
104+
def error(request: pytest.FixtureRequest) -> Timeout | SoftFileLockProtocolError:
105+
if request.param == "timeout":
106+
return Timeout("/path/to/lock")
107+
return SoftFileLockProtocolError("/path/to/lock", request.param, "invalid marker")
108+
109+
110+
def _pickle_round_trip(
111+
error: Timeout | SoftFileLockProtocolError, *, protocol: int
112+
) -> Timeout | SoftFileLockProtocolError:
113+
return pickle.loads(pickle.dumps(error, protocol=protocol)) # ruff:ignore[suspicious-pickle-usage] # input is created by these tests
114+
115+
116+
@pytest.fixture(
117+
params=[pytest.param(copy.copy, id="copy"), pytest.param(copy.deepcopy, id="deepcopy")]
118+
+ [
119+
pytest.param(partial(_pickle_round_trip, protocol=p), id=f"pickle-{p}")
120+
for p in range(pickle.HIGHEST_PROTOCOL + 1)
121+
],
122+
)
123+
def clone(
124+
request: pytest.FixtureRequest,
125+
) -> Callable[[Timeout | SoftFileLockProtocolError], Timeout | SoftFileLockProtocolError]:
126+
return request.param

0 commit comments

Comments
 (0)