Skip to content

Commit b725a20

Browse files
ax3lEZoni
andauthored
Fix NumPy serialization (pals-project#71)
Co-authored-by: Edoardo Zoni <ezoni@lbl.gov>
1 parent 3382121 commit b725a20

3 files changed

Lines changed: 139 additions & 3 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ classifiers = [
4141
]
4242

4343
[project.optional-dependencies]
44-
test = ["pytest"]
44+
test = ["pytest", "numpy"]
4545

4646
[project.urls]
4747
Documentation = "https://pals-project.readthedocs.io"

src/pals/functions.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,25 @@ def load_file_to_dict(filename: str) -> dict:
5454
return pals_data
5555

5656

57+
def _numpy_to_native(obj):
58+
"""Convert a numpy scalar/array to its Python-native equivalent.
59+
60+
Returns ``None`` when the object is not a numpy type or when numpy is not
61+
installed; callers use that to decide whether to fall back to the default
62+
serializer behavior. numpy is an optional dependency.
63+
"""
64+
try:
65+
import numpy as np
66+
except ImportError:
67+
return None
68+
69+
if isinstance(obj, np.ndarray):
70+
return obj.tolist()
71+
if isinstance(obj, np.generic):
72+
return obj.item()
73+
return None
74+
75+
5776
def store_dict_to_file(filename: str, pals_dict: dict):
5877
file_noext, extension, file_noext_noext, extension_inner = inspect_file_extensions(
5978
filename
@@ -63,14 +82,58 @@ def store_dict_to_file(filename: str, pals_dict: dict):
6382
if extension == ".json":
6483
import json
6584

66-
json_data = json.dumps(pals_dict, sort_keys=False, indent=2)
85+
def _json_default(obj):
86+
native = _numpy_to_native(obj)
87+
if native is not None:
88+
return native
89+
raise TypeError(
90+
f"Object of type {type(obj).__name__} is not JSON serializable"
91+
)
92+
93+
json_data = json.dumps(
94+
pals_dict, sort_keys=False, indent=2, default=_json_default
95+
)
6796
with open(filename, "w") as file:
6897
file.write(json_data)
6998

7099
elif extension == ".yaml":
71100
import yaml
72101

73-
yaml_data = yaml.dump(pals_dict, default_flow_style=False, sort_keys=False)
102+
# Subclass the safe dumper so numpy representers are scoped to PALS
103+
# serialization and do not leak into the global pyyaml state used by
104+
# other code in the same process.
105+
class _PALSDumper(yaml.SafeDumper):
106+
pass
107+
108+
try:
109+
import numpy as np
110+
except ImportError:
111+
np = None
112+
113+
if np is not None:
114+
115+
def _represent_numpy_scalar(dumper, value):
116+
native = value.item()
117+
if isinstance(native, bool):
118+
return dumper.represent_bool(native)
119+
if isinstance(native, int):
120+
return dumper.represent_int(native)
121+
if isinstance(native, float):
122+
return dumper.represent_float(native)
123+
return dumper.represent_data(native)
124+
125+
def _represent_numpy_array(dumper, value):
126+
return dumper.represent_list(value.tolist())
127+
128+
_PALSDumper.add_multi_representer(np.generic, _represent_numpy_scalar)
129+
_PALSDumper.add_representer(np.ndarray, _represent_numpy_array)
130+
131+
yaml_data = yaml.dump(
132+
pals_dict,
133+
Dumper=_PALSDumper,
134+
default_flow_style=False,
135+
sort_keys=False,
136+
)
74137
with open(filename, "w") as file:
75138
file.write(yaml_data)
76139

tests/test_serialization.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import pytest
2+
13
import pals
24

35

@@ -322,3 +324,74 @@ def test_comprehensive_lattice(tmp_path):
322324
assert unionele_loaded_json.elements[1].name == "union_drift"
323325
assert unionele_loaded_json.elements[1].kind == "Drift"
324326
assert unionele_loaded_json.elements[1].length == 0.1
327+
328+
329+
def _build_numpy_lattice(np):
330+
"""Build a small lattice using numpy-typed scalar values throughout."""
331+
quad = pals.Quadrupole(
332+
name="q_np",
333+
length=np.float64(0.061),
334+
MagneticMultipoleP=pals.MagneticMultipoleParameters(
335+
Bn1=np.float64(-26.0), Bs1=np.float32(0.5), Kn0=np.int64(-1)
336+
),
337+
)
338+
oct_ = pals.Octupole(
339+
name="o_np",
340+
length=np.float64(0.25),
341+
ElectricMultipoleP=pals.ElectricMultipoleParameters(
342+
En3=np.float64(0.75), Es3=np.float32(0.125)
343+
),
344+
)
345+
return pals.BeamLine(name="line_np", line=[quad, oct_])
346+
347+
348+
def test_yaml_roundtrip_with_numpy(tmp_path):
349+
"""Round-trip numpy-typed values through YAML (regression for issue #67).
350+
351+
Writing YAML with numpy-typed values must not produce !!python/object tags.
352+
Round-tripping must yield Python-native floats with the correct numeric values.
353+
"""
354+
np = pytest.importorskip("numpy")
355+
356+
line = _build_numpy_lattice(np)
357+
yaml_file = tmp_path / "numpy_roundtrip.pals.yaml"
358+
line.to_file(yaml_file)
359+
360+
with open(yaml_file, "r") as f:
361+
text = f.read()
362+
363+
# The bug symptom: YAML contains opaque numpy object tags.
364+
assert "!!python/object" not in text, (
365+
f"YAML output still contains unsafe numpy object tags:\n{text}"
366+
)
367+
assert "numpy" not in text, f"YAML output still references numpy:\n{text}"
368+
369+
loaded = pals.BeamLine.from_file(yaml_file)
370+
loaded_quad = loaded.line[0]
371+
assert loaded_quad.MagneticMultipoleP.Bn1 == -26.0
372+
assert type(loaded_quad.MagneticMultipoleP.Bn1) is float
373+
assert loaded_quad.MagneticMultipoleP.Bs1 == 0.5
374+
assert loaded_quad.MagneticMultipoleP.Kn0 == -1
375+
376+
loaded_oct = loaded.line[1]
377+
assert loaded_oct.ElectricMultipoleP.En3 == 0.75
378+
assert type(loaded_oct.ElectricMultipoleP.En3) is float
379+
380+
381+
def test_json_roundtrip_with_numpy(tmp_path):
382+
"""Round-trip numpy-typed values through JSON (companion for issue #67).
383+
384+
JSON also needs to handle numpy values cleanly (defense-in-depth).
385+
"""
386+
np = pytest.importorskip("numpy")
387+
388+
line = _build_numpy_lattice(np)
389+
json_file = tmp_path / "numpy_roundtrip.pals.json"
390+
line.to_file(json_file)
391+
392+
loaded = pals.BeamLine.from_file(json_file)
393+
loaded_quad = loaded.line[0]
394+
assert loaded_quad.MagneticMultipoleP.Bn1 == -26.0
395+
assert type(loaded_quad.MagneticMultipoleP.Bn1) is float
396+
loaded_oct = loaded.line[1]
397+
assert loaded_oct.ElectricMultipoleP.En3 == 0.75

0 commit comments

Comments
 (0)