Skip to content

Commit cf0d31b

Browse files
authored
FIX: pickle nested unevaluated expressions (#509)
* DX: add regression tests for pickling nested expressions * DX: ignore Ruff pickle rules in the test suite * MAINT: use DataclassInstance in dataclass field helpers
1 parent 49ad8ea commit cf0d31b

6 files changed

Lines changed: 66 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,8 @@ split-on-trailing-comma = false
545545
"magic-value-comparison",
546546
"no-self-use",
547547
"private-member-access",
548+
"suspicious-pickle-import",
549+
"suspicious-pickle-usage",
548550
"too-many-locals",
549551
"too-many-positional-arguments",
550552
"unnecessary-collection-call",

src/ampform/sympy/_decorator.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
if TYPE_CHECKING:
2424
from collections.abc import Callable, Hashable, Iterable
2525

26+
from _typeshed import DataclassInstance
2627
from sympy.printing.latex import LatexPrinter
2728

2829
if sys.version_info >= (3, 11):
@@ -279,7 +280,7 @@ def new_method(cls, *args, evaluate: bool = False, **kwargs) -> type[ExprClass]:
279280
return expr
280281

281282
cls.__new__ = new_method
282-
cls.__getnewargs__ = dataclasses.astuple
283+
cls.__getnewargs__ = _get_field_values
283284
cls._hashable_content = _hashable_content_method
284285
if non_sympy_fields:
285286
cls._eval_subs = _eval_subs_method
@@ -486,7 +487,7 @@ def class_wrapper(cls: T) -> T:
486487
def _eval_subs_method(self, old, new, **hints):
487488
# https://github.com/sympy/sympy/blob/1.12/sympy/core/basic.py#L1117-L1147
488489
hit = False
489-
old_args = dataclasses.astuple(self)
490+
old_args = _get_field_values(self)
490491
new_args = list(old_args)
491492
for i, old_arg in enumerate(old_args):
492493
if not hasattr(old_arg, "_eval_subs"):
@@ -535,7 +536,7 @@ def _xreplace_method(self, rule) -> tuple[sp.Expr, bool]:
535536
if rule:
536537
new_args = []
537538
hit = False
538-
for arg in dataclasses.astuple(self):
539+
for arg in _get_field_values(self):
539540
if hasattr(arg, "_xreplace") and not isclass(arg):
540541
replace_result, is_replaced = arg._xreplace(rule) # ruff: ignore[private-member-access]
541542
elif isinstance(rule, abc.Mapping):
@@ -551,13 +552,28 @@ def _xreplace_method(self, rule) -> tuple[sp.Expr, bool]:
551552
return self, False
552553

553554

554-
def get_sympy_fields(cls) -> tuple[Field, ...]:
555+
def _get_field_values(self: DataclassInstance) -> tuple[Any, ...]:
556+
"""Get the field values of a dataclass-like class without recursing.
557+
558+
This is a shallow alternative to :func:`dataclasses.astuple`, which recurses into
559+
nested dataclasses and converts them to `tuple` as well. Since decorated classes are
560+
both dataclasses and `~sympy.core.expr.Expr` instances, that recursion would destroy
561+
nested expressions, for instance when pickling.
562+
"""
563+
return tuple(getattr(self, field.name) for field in dataclasses.fields(self))
564+
565+
566+
def get_sympy_fields(
567+
cls: DataclassInstance | type[DataclassInstance],
568+
) -> tuple[Field[Any], ...]:
555569
return tuple(f for f in dataclasses.fields(cls) if _is_sympify(f))
556570

557571

558-
def get_non_sympy_fields(cls) -> tuple[Field, ...]:
572+
def get_non_sympy_fields(
573+
cls: DataclassInstance | type[DataclassInstance],
574+
) -> tuple[Field[Any], ...]:
559575
return tuple(f for f in dataclasses.fields(cls) if not _is_sympify(f))
560576

561577

562-
def _is_sympify(field: Field) -> bool:
578+
def _is_sympify(field: Field[Any]) -> bool:
563579
return bool(field.metadata.get("sympify"))

tests/dynamics/test_deprecated.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import pickle # ruff: ignore[suspicious-pickle-import]
1+
import pickle
22

33
import sympy as sp
44

@@ -15,19 +15,19 @@ def test_pickle():
1515
# Pickle simple SymPy expression
1616
expr = z * angular_momentum
1717
pickled_obj = pickle.dumps(expr)
18-
imported_expr = pickle.loads(pickled_obj) # ruff: ignore[suspicious-pickle-usage]
18+
imported_expr = pickle.loads(pickled_obj)
1919
assert expr == imported_expr
2020

2121
# Pickle UnevaluatedExpression
2222
expr = UnevaluatedExpression()
2323
pickled_obj = pickle.dumps(expr)
24-
imported_expr = pickle.loads(pickled_obj) # ruff: ignore[suspicious-pickle-usage]
24+
imported_expr = pickle.loads(pickled_obj)
2525
assert expr == imported_expr
2626

2727
# Pickle classes derived from UnevaluatedExpression
2828
expr = BlattWeisskopfSquared(z, angular_momentum)
2929
pickled_obj = pickle.dumps(expr)
30-
imported_expr = pickle.loads(pickled_obj) # ruff: ignore[suspicious-pickle-usage]
30+
imported_expr = pickle.loads(pickled_obj)
3131
assert expr == imported_expr
3232

3333
expr = EnergyDependentWidth(
@@ -42,5 +42,5 @@ def test_pickle():
4242
name="Gamma_1",
4343
)
4444
pickled_obj = pickle.dumps(expr)
45-
imported_expr = pickle.loads(pickled_obj) # ruff: ignore[suspicious-pickle-usage]
45+
imported_expr = pickle.loads(pickled_obj)
4646
assert expr == imported_expr

tests/helicity/test_helicity.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4+
import pickle
45
from typing import TYPE_CHECKING
56

67
import pytest
@@ -170,6 +171,13 @@ def test_parameter_defaults_item_types(
170171
assert isinstance(symbol, sp.Symbol)
171172
assert isinstance(value, ParameterValue.__args__)
172173

174+
def test_pickle_roundtrip(self, amplitude_model: tuple[str, HelicityModel]):
175+
"""See https://github.com/ComPWA/ampform/issues/471."""
176+
_, model = amplitude_model
177+
pickled_model: HelicityModel = pickle.loads(pickle.dumps(model))
178+
assert pickled_model.kinematic_variables == model.kinematic_variables
179+
assert pickled_model == model
180+
173181
def test_rename_symbols_no_renames(
174182
self, amplitude_model: tuple[str, HelicityModel]
175183
):

tests/sympy/decorator/test_unevaluated.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import inspect
4+
import pickle
45
from typing import Any, ClassVar
56

67
import pytest
@@ -190,6 +191,32 @@ def evaluate(self) -> sp.Expr:
190191
assert isinstance(q_value.m2, sp.Float)
191192

192193

194+
@unevaluated(implement_doit=False)
195+
class _Outer(sp.Expr):
196+
x: Any
197+
198+
199+
@unevaluated(implement_doit=False)
200+
class _Inner(sp.Expr):
201+
x: Any
202+
typ: type = argument(default=int, sympify=False)
203+
204+
205+
def test_pickle_nested_expressions():
206+
x = sp.Symbol("x")
207+
expr = _Outer(_Inner(x, typ=float))
208+
pickled_expr: _Outer = pickle.loads(pickle.dumps(expr))
209+
assert pickled_expr == expr
210+
assert isinstance(pickled_expr.x, _Inner)
211+
assert pickled_expr.x.x == x
212+
assert pickled_expr.x.typ is float
213+
214+
sympifiable_expr = _Outer(_Outer(x))
215+
pickled_expr = pickle.loads(pickle.dumps(sympifiable_expr))
216+
assert pickled_expr == sympifiable_expr
217+
assert isinstance(pickled_expr.x, _Outer)
218+
219+
193220
def test_subs_with_non_sympy_attributes():
194221
class Protocol: ...
195222

tests/sympy/test_cache_helpers.py

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

33
import logging
4-
import pickle # ruff: ignore[suspicious-pickle-import]
4+
import pickle
55
import sys
66
from concurrent.futures import ThreadPoolExecutor
77
from threading import Event
@@ -64,7 +64,7 @@ def cached_function():
6464
assert not list(tmp_path.rglob("*.tmp"))
6565
cache_files = [path for path in tmp_path.rglob("*") if path.is_file()]
6666
assert len(cache_files) == 1
67-
assert pickle.loads(cache_files[0].read_bytes()) == "result" # ruff: ignore[suspicious-pickle-usage]
67+
assert pickle.loads(cache_files[0].read_bytes()) == "result"
6868

6969

7070
@pytest.mark.parametrize("corrupt_data", [b"", b"not a pickle"])

0 commit comments

Comments
 (0)