diff --git a/docs/changes/newsfragments/8623.new b/docs/changes/newsfragments/8623.new new file mode 100644 index 000000000000..19f48f9dc050 --- /dev/null +++ b/docs/changes/newsfragments/8623.new @@ -0,0 +1,3 @@ +Added :class:`qcodes.utils.NumpyJSONDecoder` to deserialize types encoded by +:class:`~qcodes.utils.NumpyJSONEncoder` (such as complex numbers and +:class:`uncertainties.UFloat` objects) back into their native Python types. diff --git a/src/qcodes/utils/__init__.py b/src/qcodes/utils/__init__.py index 62444140d418..ba99f62a9609 100644 --- a/src/qcodes/utils/__init__.py +++ b/src/qcodes/utils/__init__.py @@ -17,7 +17,7 @@ get_all_installed_package_versions, is_qcodes_installed_editably, ) -from .json_utils import NumpyJSONEncoder +from .json_utils import NumpyJSONDecoder, NumpyJSONEncoder from .numpy_utils import list_of_data_to_maybe_ragged_nd_array from .partial_utils import partial_with_docstring from .path_helpers import get_qcodes_path, get_qcodes_user_path @@ -27,6 +27,7 @@ __all__ = [ "DelayedKeyboardInterrupt", "DelegateAttributes", + "NumpyJSONDecoder", "NumpyJSONEncoder", "ParameterDiff", "QCoDeSDeprecationWarning", diff --git a/src/qcodes/utils/json_utils.py b/src/qcodes/utils/json_utils.py index 869918ec4c46..3e024da9bb8e 100644 --- a/src/qcodes/utils/json_utils.py +++ b/src/qcodes/utils/json_utils.py @@ -12,6 +12,8 @@ class NumpyJSONEncoder(json.JSONEncoder): This JSON encoder adds support for serializing types that the built-in ``json`` module does not support out-of-the-box. See the docstring of the ``default`` method for the description of all conversions. + + To deserialize JSON data produced by this encoder, see :class:`NumpyJSONDecoder`. """ def default(self, o: Any) -> Any: @@ -87,3 +89,75 @@ def default(self, o: Any) -> Any: # we cannot convert the object to JSON, just take a string s = str(o) return s + + +class NumpyJSONDecoder(json.JSONDecoder): + """ + This JSON decoder adds support for deserializing types encoded by + :class:`NumpyJSONEncoder`, such as complex numbers and UFloat instances. + + Because Python's standard library ``json`` module does not provide a + pluggable global registry for custom decoders, this decoder should be + passed explicitly to :func:`json.loads` or :func:`json.load` via the + ``cls`` argument, or by passing :meth:`default_object_hook` as the + ``object_hook`` parameter. + + Examples: + Decode a JSON string containing complex numbers and uncertainties:: + + import json + import uncertainties + from qcodes.utils import NumpyJSONDecoder, NumpyJSONEncoder + + data = { + "voltage": 1.5 + 2.0j, + "resistance": uncertainties.ufloat(100.0, 0.5), + } + encoded = json.dumps(data, cls=NumpyJSONEncoder) + decoded = json.loads(encoded, cls=NumpyJSONDecoder) + + assert decoded["voltage"] == 1.5 + 2.0j + assert decoded["resistance"] == uncertainties.ufloat(100.0, 0.5) + + Alternatively, pass :meth:`default_object_hook` directly as ``object_hook``:: + + decoded = json.loads( + encoded, object_hook=NumpyJSONDecoder.default_object_hook + ) + + Read and decode from a JSON file using :func:`json.load`:: + + with open("data.json", "r") as f: + data = json.load(f, cls=NumpyJSONDecoder) + + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + if "object_hook" not in kwargs: + kwargs["object_hook"] = self.default_object_hook + super().__init__(*args, **kwargs) + + @staticmethod + def default_object_hook(dct: dict[str, Any]) -> Any: + """ + Convert dictionary representations of encoded types back to Python objects. + + If ``dct`` contains ``{"__dtype__": "complex", "re": ..., "im": ...}``, + a :class:`complex` number is returned. If ``dct`` contains + ``{"__dtype__": "UFloat", "nominal_value": ..., "std_dev": ...}``, an + :class:`uncertainties.UFloat` instance is returned. Otherwise, ``dct`` + is returned unchanged. + + Args: + dct: Dictionary parsed from JSON. + + Returns: + The decoded object or the original dictionary if unrecognized. + + """ + dtype = dct.get("__dtype__") + if dtype == "complex" and "re" in dct and "im" in dct: + return complex(dct["re"], dct["im"]) + elif dtype == "UFloat" and "nominal_value" in dct and "std_dev" in dct: + return uncertainties.ufloat(dct["nominal_value"], dct["std_dev"]) + return dct diff --git a/tests/helpers/test_json_encoder.py b/tests/helpers/test_json_encoder.py index 01a525e454ff..1a68efb99bd5 100644 --- a/tests/helpers/test_json_encoder.py +++ b/tests/helpers/test_json_encoder.py @@ -1,3 +1,4 @@ +import io import json from collections import OrderedDict, UserDict @@ -7,7 +8,7 @@ import uncertainties # type: ignore[import-untyped] from hypothesis import given -from qcodes.utils import NumpyJSONEncoder +from qcodes.utils import NumpyJSONDecoder, NumpyJSONEncoder from qcodes.utils.types import numpy_complex, numpy_floats, numpy_ints @@ -186,3 +187,84 @@ def test_bytes_array(bytes_to_encode) -> None: # encoding bytes is the same as using the # default encode on the str value of the bytes array assert v == default_encoder.encode(str(value)) + + +def test_numpy_json_decoder_complex() -> None: + encoded = json.dumps( + {"c1": complex(1.0, 2.5), "c2": 3 - 4j}, + cls=NumpyJSONEncoder, + ) + decoded = json.loads(encoded, cls=NumpyJSONDecoder) + + assert decoded["c1"] == complex(1.0, 2.5) + assert isinstance(decoded["c1"], complex) + assert decoded["c2"] == 3 - 4j + assert isinstance(decoded["c2"], complex) + + +def test_numpy_json_decoder_ufloat() -> None: + u = uncertainties.ufloat(2.5, 0.1) + encoded = json.dumps({"measurement": u}, cls=NumpyJSONEncoder) + decoded = json.loads(encoded, cls=NumpyJSONDecoder) + + assert isinstance(decoded["measurement"], uncertainties.UFloat) + assert decoded["measurement"].nominal_value == u.nominal_value + assert decoded["measurement"].std_dev == u.std_dev + + +def test_numpy_json_decoder_roundtrip_examplemetadata() -> None: + encoded = json.dumps( + EXAMPLEMETADATA, + cls=NumpyJSONEncoder, + ) + decoded = json.loads(encoded, cls=NumpyJSONDecoder) + + assert decoded["name"] == "Rapunzel" + assert decoded["age"] == 12 + assert decoded["height"] == 112.234 + assert decoded["scores"] == [0, 21, 42] + assert decoded["RapunzelNumber"] == complex(4.89, 0.11) + assert isinstance(decoded["RapunzelNumber"], complex) + assert decoded["verisimilitude"] == 1j + assert isinstance(decoded["verisimilitude"], complex) + assert decoded["myuserdict"] == {"a": 1} + + +def test_numpy_json_decoder_custom_object_hook() -> None: + def custom_hook(d: dict[str, object]) -> object: + if "custom_tag" in d: + return "hooked" + return d + + data = '{"custom_tag": 1, "c": {"__dtype__": "complex", "re": 1.0, "im": 2.0}}' + decoded = json.loads(data, cls=NumpyJSONDecoder, object_hook=custom_hook) + assert decoded == "hooked" + + +def test_numpy_json_decoder_direct_object_hook() -> None: + u = uncertainties.ufloat(10.0, 0.2) + encoded = json.dumps( + {"c": 1.0 + 2.0j, "u": u}, + cls=NumpyJSONEncoder, + ) + decoded = json.loads(encoded, object_hook=NumpyJSONDecoder.default_object_hook) + + assert decoded["c"] == 1.0 + 2.0j + assert isinstance(decoded["c"], complex) + assert isinstance(decoded["u"], uncertainties.UFloat) + assert decoded["u"].nominal_value == 10.0 + assert decoded["u"].std_dev == 0.2 + + +def test_numpy_json_decoder_load_from_stream() -> None: + encoded = json.dumps({"val": 3.0 + 4.0j}, cls=NumpyJSONEncoder) + stream = io.StringIO(encoded) + decoded = json.load(stream, cls=NumpyJSONDecoder) + + assert decoded["val"] == 3.0 + 4.0j + assert isinstance(decoded["val"], complex) + + +def test_numpy_json_decoder_unrecognized_dict() -> None: + regular_dict = {"a": 1, "b": "hello", "c": [1, 2, 3]} + assert NumpyJSONDecoder.default_object_hook(regular_dict) == regular_dict