Skip to content

Commit e84be01

Browse files
committed
fix(serialization): remove monty dependency
1 parent 1b63c9b commit e84be01

8 files changed

Lines changed: 253 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Always reference these instructions first and fallback to search or bash command
99
- **Bootstrap and install the repository:**
1010

1111
- `cd /home/runner/work/dpdata/dpdata` (or wherever the repo is cloned)
12-
- `uv pip install -e .` -- installs dpdata in development mode with core dependencies (numpy, scipy, h5py, monty, wcmatch)
12+
- `uv pip install -e .` -- installs dpdata in development mode with core dependencies (numpy, scipy, h5py, wcmatch)
1313
- Test installation: `dpdata --version` -- should show version like "dpdata v0.1.dev2+..."
1414

1515
- **Run tests:**
@@ -93,7 +93,7 @@ The following are outputs from frequently run commands. Reference them instead o
9393

9494
### Key dependencies
9595

96-
- Core: numpy>=1.14.3, scipy, h5py, monty, wcmatch
96+
- Core: numpy>=1.14.3, scipy, h5py, wcmatch
9797
- Optional: ase (ASE integration), parmed (AMBER), pymatgen (Materials Project), rdkit (molecular analysis)
9898
- Testing: unittest (built-in), coverage
9999
- Linting: ruff
@@ -132,7 +132,7 @@ The following are outputs from frequently run commands. Reference them instead o
132132

133133
- **Installation timeouts:** Network timeouts during `uv pip install` are common. If this occurs, try:
134134

135-
- Individual package installation: `uv pip install numpy scipy h5py monty wcmatch`
135+
- Individual package installation: `uv pip install numpy scipy h5py wcmatch`
136136
- Use `--timeout` option: `uv pip install --timeout 300 -e .`
137137
- Verify existing installation works: `dpdata --version` should work even if reinstall fails
138138

docs/conf.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,6 @@ def setup(app):
202202
"numpy": ("https://docs.scipy.org/doc/numpy/", None),
203203
"python": ("https://docs.python.org/", None),
204204
"ase": ("https://wiki.fysik.dtu.dk/ase/", None),
205-
"monty": ("https://guide.materialsvirtuallab.org/monty/", None),
206205
"h5py": ("https://docs.h5py.org/en/stable/", None),
207206
}
208207

docs/environment.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ dependencies:
66
- xeus-python
77
- numpy
88
- scipy
9-
- monty
109
- wcmatch
1110
- pip:
1211
- ..

dpdata/serialization.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
from __future__ import annotations
2+
3+
import bz2
4+
import datetime
5+
import gzip
6+
import importlib
7+
import json
8+
from enum import Enum
9+
from pathlib import Path
10+
from typing import Any, BinaryIO, TextIO, cast
11+
from uuid import UUID
12+
13+
import numpy as np
14+
15+
16+
def _detect_format(filename: str | Path, fmt: str | None = None) -> str:
17+
if fmt is not None:
18+
return fmt
19+
basename = Path(filename).name.lower()
20+
if ".mpk" in basename:
21+
return "mpk"
22+
if ".yaml" in basename or ".yml" in basename:
23+
return "yaml"
24+
return "json"
25+
26+
27+
def _open_text(filename: str | Path, mode: str) -> TextIO:
28+
path = str(filename)
29+
lower_path = path.lower()
30+
if lower_path.endswith((".gz", ".z")):
31+
return cast("TextIO", gzip.open(path, mode, encoding="utf-8"))
32+
if lower_path.endswith(".bz2"):
33+
return cast("TextIO", bz2.open(path, mode, encoding="utf-8"))
34+
return cast("TextIO", open(path, mode, encoding="utf-8"))
35+
36+
37+
def _open_binary(filename: str | Path, mode: str) -> BinaryIO:
38+
path = str(filename)
39+
lower_path = path.lower()
40+
if lower_path.endswith((".gz", ".z")):
41+
return cast("BinaryIO", gzip.open(path, mode))
42+
if lower_path.endswith(".bz2"):
43+
return cast("BinaryIO", bz2.open(path, mode))
44+
return cast("BinaryIO", open(path, mode))
45+
46+
47+
def _yaml_dump(obj: Any, fp, *args: Any, **kwargs: Any) -> None:
48+
try:
49+
yaml = importlib.import_module("yaml")
50+
51+
if "sort_keys" not in kwargs:
52+
kwargs["sort_keys"] = False
53+
getattr(yaml, "safe_dump")(obj, fp, *args, **kwargs)
54+
except ModuleNotFoundError:
55+
try:
56+
ruamel_yaml = importlib.import_module("ruamel.yaml")
57+
except ModuleNotFoundError as e:
58+
raise RuntimeError(
59+
"Dumping YAML files requires PyYAML or ruamel.yaml."
60+
) from e
61+
yaml = getattr(ruamel_yaml, "YAML")()
62+
if "indent" in kwargs:
63+
indent = kwargs.pop("indent")
64+
yaml.indent(mapping=indent, sequence=indent, offset=2)
65+
yaml.dump(obj, fp, *args, **kwargs)
66+
67+
68+
def _yaml_load(fp, *args: Any, **kwargs: Any) -> Any:
69+
try:
70+
yaml = importlib.import_module("yaml")
71+
72+
return getattr(yaml, "safe_load")(fp, *args, **kwargs)
73+
except ModuleNotFoundError:
74+
try:
75+
ruamel_yaml = importlib.import_module("ruamel.yaml")
76+
except ModuleNotFoundError as e:
77+
raise RuntimeError(
78+
"Loading YAML files requires PyYAML or ruamel.yaml."
79+
) from e
80+
yaml = getattr(ruamel_yaml, "YAML")(typ="safe")
81+
return yaml.load(fp, *args, **kwargs)
82+
83+
84+
def _encode_ndarray(obj: np.ndarray) -> dict[str, Any]:
85+
if str(obj.dtype).startswith("complex"):
86+
data = [obj.real.tolist(), obj.imag.tolist()]
87+
else:
88+
data = obj.tolist()
89+
return {
90+
"@module": "numpy",
91+
"@class": "array",
92+
"dtype": str(obj.dtype),
93+
"data": data,
94+
}
95+
96+
97+
def to_serializable(obj: Any) -> Any:
98+
"""Convert common dpdata objects to monty-compatible plain data."""
99+
if isinstance(obj, dict):
100+
return {to_serializable(k): to_serializable(v) for k, v in obj.items()}
101+
if isinstance(obj, (list, tuple)):
102+
return [to_serializable(v) for v in obj]
103+
if isinstance(obj, np.ndarray):
104+
return _encode_ndarray(obj)
105+
if isinstance(obj, np.generic):
106+
return obj.item()
107+
if isinstance(obj, datetime.datetime):
108+
return {
109+
"@module": "datetime",
110+
"@class": "datetime",
111+
"string": str(obj),
112+
}
113+
if isinstance(obj, UUID):
114+
return {"@module": "uuid", "@class": "UUID", "string": str(obj)}
115+
if isinstance(obj, Path):
116+
return {"@module": "pathlib", "@class": "Path", "string": str(obj)}
117+
if isinstance(obj, Enum):
118+
return {
119+
"@module": obj.__class__.__module__,
120+
"@class": obj.__class__.__name__,
121+
"value": to_serializable(obj.value),
122+
}
123+
if hasattr(obj, "as_dict"):
124+
data = obj.as_dict()
125+
if "@module" not in data:
126+
data["@module"] = obj.__class__.__module__
127+
if "@class" not in data:
128+
data["@class"] = obj.__class__.__name__
129+
return to_serializable(data)
130+
return obj
131+
132+
133+
def _decode_ndarray(data: dict[str, Any]) -> np.ndarray:
134+
dtype = data["dtype"]
135+
if dtype.startswith("complex"):
136+
real, imag = data["data"]
137+
return np.array(real, dtype=dtype) + np.array(imag, dtype=dtype) * 1j
138+
return np.array(data["data"], dtype=dtype)
139+
140+
141+
def process_decoded(obj: Any) -> Any:
142+
"""Decode monty-style dictionaries used by existing dpdata JSON files."""
143+
if isinstance(obj, dict):
144+
if "@module" in obj and "@class" in obj:
145+
module_name = obj["@module"]
146+
class_name = obj["@class"]
147+
if module_name == "numpy" and class_name == "array":
148+
return _decode_ndarray(obj)
149+
if module_name == "datetime" and class_name == "datetime":
150+
value = obj["string"].split("+")[0]
151+
try:
152+
return datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:%S.%f")
153+
except ValueError:
154+
return datetime.datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
155+
if module_name == "uuid" and class_name == "UUID":
156+
return UUID(obj["string"])
157+
if module_name == "pathlib" and class_name == "Path":
158+
return Path(obj["string"])
159+
try:
160+
module = importlib.import_module(module_name)
161+
cls = getattr(module, class_name)
162+
except (AttributeError, ImportError, ModuleNotFoundError):
163+
cls = None
164+
if cls is not None:
165+
data = {k: v for k, v in obj.items() if not k.startswith("@")}
166+
if hasattr(cls, "from_dict"):
167+
return cls.from_dict(data)
168+
if isinstance(cls, type) and issubclass(cls, Enum):
169+
return cls(process_decoded(data["value"]))
170+
return {process_decoded(k): process_decoded(v) for k, v in obj.items()}
171+
if isinstance(obj, list):
172+
return [process_decoded(v) for v in obj]
173+
return obj
174+
175+
176+
def dumpfn(
177+
obj: Any,
178+
filename: str | Path,
179+
*args: Any,
180+
fmt: str | None = None,
181+
**kwargs: Any,
182+
) -> None:
183+
"""Dump an object to JSON, YAML, or msgpack without requiring monty."""
184+
fmt = _detect_format(filename, fmt)
185+
obj = to_serializable(obj)
186+
if fmt == "json":
187+
with _open_text(filename, "wt") as fp:
188+
json.dump(obj, fp, *args, **kwargs)
189+
return
190+
if fmt == "yaml":
191+
with _open_text(filename, "wt") as fp:
192+
_yaml_dump(obj, fp, *args, **kwargs)
193+
return
194+
if fmt == "mpk":
195+
try:
196+
import msgpack
197+
except ModuleNotFoundError as e:
198+
raise RuntimeError("Dumping msgpack files requires msgpack.") from e
199+
with _open_binary(filename, "wb") as fp:
200+
msgpack.dump(obj, fp, *args, **kwargs)
201+
return
202+
raise TypeError(f"Invalid format: {fmt}")
203+
204+
205+
def loadfn(
206+
filename: str | Path,
207+
*args: Any,
208+
fmt: str | None = None,
209+
**kwargs: Any,
210+
) -> Any:
211+
"""Load JSON, YAML, or msgpack data and decode monty-style objects."""
212+
fmt = _detect_format(filename, fmt)
213+
if fmt == "json":
214+
with _open_text(filename, "rt") as fp:
215+
return process_decoded(json.load(fp, *args, **kwargs))
216+
if fmt == "yaml":
217+
with _open_text(filename, "rt") as fp:
218+
return process_decoded(_yaml_load(fp, *args, **kwargs))
219+
if fmt == "mpk":
220+
try:
221+
import msgpack
222+
except ModuleNotFoundError as e:
223+
raise RuntimeError("Loading msgpack files requires msgpack.") from e
224+
with _open_binary(filename, "rb") as fp:
225+
return process_decoded(msgpack.load(fp, *args, **kwargs))
226+
raise TypeError(f"Invalid format: {fmt}")

dpdata/system.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ def __add__(self, others):
331331

332332
def dump(self, filename: str, indent: int = 4):
333333
"""Dump .json or .yaml file."""
334-
from monty.serialization import dumpfn
334+
from dpdata.serialization import dumpfn
335335

336336
dumpfn(self.as_dict(), filename, indent=indent)
337337

@@ -378,19 +378,17 @@ def map_atom_types(
378378
@staticmethod
379379
def load(filename: str):
380380
"""Rebuild System obj. from .json or .yaml file."""
381-
from monty.serialization import loadfn
381+
from dpdata.serialization import loadfn
382382

383383
return loadfn(filename)
384384

385385
@classmethod
386386
def from_dict(cls, data: dict):
387387
"""Construct a System instance from a data dict."""
388-
from monty.serialization import MontyDecoder # type: ignore
388+
from dpdata.serialization import process_decoded
389389

390390
decoded = {
391-
k: MontyDecoder().process_decoded(v)
392-
for k, v in data.items()
393-
if not k.startswith("@")
391+
k: process_decoded(v) for k, v in data.items() if not k.startswith("@")
394392
}
395393
return cls(**decoded)
396394

pyproject.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ classifiers = [
2020
]
2121
dependencies = [
2222
'numpy>=1.14.3',
23-
'monty',
2423
'scipy',
2524
'h5py',
2625
'wcmatch',
@@ -121,7 +120,6 @@ banned-module-level-imports = [
121120
"deepmd",
122121
"h5py",
123122
"wcmatch",
124-
"monty",
125123
"scipy",
126124
]
127125

tests/test_json.py

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

3+
import os
4+
import tempfile
35
import unittest
46

57
from comp_sys import CompLabeledSys, IsPBC
@@ -26,5 +28,21 @@ def setUp(self):
2628
self.v_places = 4
2729

2830

31+
class TestJsonDumpLoad(unittest.TestCase, CompLabeledSys, IsPBC):
32+
def setUp(self):
33+
self.system_1 = dpdata.LabeledSystem("poscars/OUTCAR.h2o.md", fmt="vasp/outcar")
34+
self.tmpdir = tempfile.TemporaryDirectory()
35+
self.filename = os.path.join(self.tmpdir.name, "h2o.md.json")
36+
self.system_1.dump(self.filename)
37+
self.system_2 = dpdata.LabeledSystem.load(self.filename)
38+
self.places = 6
39+
self.e_places = 6
40+
self.f_places = 6
41+
self.v_places = 4
42+
43+
def tearDown(self):
44+
self.tmpdir.cleanup()
45+
46+
2947
if __name__ == "__main__":
3048
unittest.main()

tests/test_to_pymatgen_entry.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import unittest
55

66
from context import dpdata
7-
from monty.serialization import loadfn # noqa: TID253
7+
8+
from dpdata.serialization import loadfn
89

910
try:
1011
from pymatgen.entries.computed_entries import ComputedStructureEntry # noqa: F401

0 commit comments

Comments
 (0)