Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changes/newsfragments/8623.new
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion src/qcodes/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +27,7 @@
__all__ = [
"DelayedKeyboardInterrupt",
"DelegateAttributes",
"NumpyJSONDecoder",
"NumpyJSONEncoder",
"ParameterDiff",
"QCoDeSDeprecationWarning",
Expand Down
74 changes: 74 additions & 0 deletions src/qcodes/utils/json_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
84 changes: 83 additions & 1 deletion tests/helpers/test_json_encoder.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import io
import json
from collections import OrderedDict, UserDict

Expand All @@ -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


Expand Down Expand Up @@ -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