diff --git a/astropy/coordinates/tests/test_io.py b/astropy/coordinates/tests/test_io.py new file mode 100644 index 000000000000..f61b813cd254 --- /dev/null +++ b/astropy/coordinates/tests/test_io.py @@ -0,0 +1,70 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +# -*- coding: utf-8 -*- + +import ast +import json +import re + +import numpy as np +import pytest + +import astropy.units as u +import astropy.coordinates as coord +from astropy.io.misc.json import JSONExtendedEncoder, JSONExtendedDecoder +from astropy.io.misc.json.tests.test_core import JSONExtendedTestBase + + +class TestJSONExtendedUnits(JSONExtendedTestBase): + """Tests for serializing builtins with extended JSON encoders and decoders.""" + + def test_longitude(self): + """Test round-tripping `astropy.coordinates.Longitude`.""" + obj = coord.Longitude([3, 4], dtype=float, unit=u.deg, wrap_angle=180*u.deg) + + # Raises errors without extended encoder + with pytest.raises(TypeError, match=re.escape("Object of type Longitude is not JSON serializable")): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "astropy.coordinates.angles.Longitude" + assert d["value"] == [3.0, 4.0] + assert d["unit"] == "deg" + assert d["wrap_angle"]["value"] == 180.0 + assert d["wrap_angle"]["unit"] == "deg" + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, coord.Longitude) + assert np.array_equal(out, obj) + + def test_latitude(self): + """Test round-tripping `astropy.coordinates.Latitude`.""" + obj = coord.Latitude([3, 4], dtype=float, unit=u.deg) + + # Raises errors without extended encoder + with pytest.raises(TypeError, match=re.escape("Object of type Latitude is not JSON serializable")): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "astropy.coordinates.angles.Latitude" + assert d["value"] == [3.0, 4.0] + assert d["unit"] == "deg" + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, coord.Latitude) + assert np.array_equal(out, obj) diff --git a/astropy/io/misc/json/tests/__init__.py b/astropy/io/misc/json/tests/__init__.py new file mode 100644 index 000000000000..ccbc6cb99345 --- /dev/null +++ b/astropy/io/misc/json/tests/__init__.py @@ -0,0 +1,2 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +# -*- coding: utf-8 -*- diff --git a/astropy/io/misc/json/tests/test_builtins.py b/astropy/io/misc/json/tests/test_builtins.py new file mode 100644 index 000000000000..9dacee956dc0 --- /dev/null +++ b/astropy/io/misc/json/tests/test_builtins.py @@ -0,0 +1,135 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +# -*- coding: utf-8 -*- + +import ast +import json + +import pytest + +from astropy.io.misc.json import JSONExtendedEncoder, JSONExtendedDecoder + +from .test_core import JSONExtendedTestBase + + +class TestJSONExtendedBuiltins(JSONExtendedTestBase): + """Tests for serializing builtins with extended JSON encoders and decoders.""" + + def test_bytes(self): + """Test round-tripping `bytes`.""" + obj = b"1234" + + # Raises errors without extended encoder + with pytest.raises(TypeError, match="Object of type bytes is not JSON serializable"): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "builtins.bytes" + assert d["value"] == "1234" + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, bytes) + assert out == obj + + def test_complex(self): + """Test round-tripping `complex`.""" + obj = 1 + 2j + + # Raises errors without extended encoder + with pytest.raises(TypeError, match="Object of type complex is not JSON serializable"): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "builtins.complex" + assert d["value"] == [1, 2] + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, complex) + assert out == obj + + def test_set(self): + """Test round-tripping `set`.""" + obj = {1, 2, 3} + + # Raises errors without extended encoder + with pytest.raises(TypeError, match="Object of type set is not JSON serializable"): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "builtins.set" + assert d["value"] == [1, 2, 3] + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, set) + assert out == obj + + def test_NotImplemented(self): + """Test round-tripping `NotImplemented`.""" + obj = NotImplemented + + # Raises errors without extended encoder + with pytest.raises(TypeError, match="Object of type NotImplementedType is not JSON serializable"): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "builtins.NotImplemented" + assert d["value"] == "NotImplemented" + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, type(NotImplemented)) + assert out == obj + + def test_Ellipsis(self): + """Test round-tripping `Ellipsis`.""" + obj = Ellipsis + + # Raises errors without extended encoder + with pytest.raises(TypeError, match="Object of type ellipsis is not JSON serializable"): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "builtins.Ellipsis" + assert d["value"] == "Ellipsis" + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, type(Ellipsis)) + assert out == obj diff --git a/astropy/io/misc/json/tests/test_core.py b/astropy/io/misc/json/tests/test_core.py new file mode 100644 index 000000000000..11bb62782232 --- /dev/null +++ b/astropy/io/misc/json/tests/test_core.py @@ -0,0 +1,6 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +# -*- coding: utf-8 -*- + + +class JSONExtendedTestBase: + """Base for testing JSON extended encoders and decoders""" diff --git a/astropy/io/misc/json/tests/test_numpy.py b/astropy/io/misc/json/tests/test_numpy.py new file mode 100644 index 000000000000..cb1a35c94da3 --- /dev/null +++ b/astropy/io/misc/json/tests/test_numpy.py @@ -0,0 +1,101 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +# -*- coding: utf-8 -*- + +import ast +import json +import re + +import numpy as np +import pytest + +from astropy.io.misc.json import JSONExtendedEncoder, JSONExtendedDecoder + +from .test_core import JSONExtendedTestBase + + +class TestJSONExtendedNumPy(JSONExtendedTestBase): + """Tests for serializing builtins with extended JSON encoders and decoders.""" + + def test_number(self): + """Test round-tripping `numpy.number`.""" + obj = np.int64(10) + + # Raises errors without extended encoder + with pytest.raises(TypeError, match="Object of type int64 is not JSON serializable"): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "numpy.int64" + assert d["value"] == "10" + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, np.int64) + assert out == obj + + def test_dtype_simple(self): + """Test round-tripping `numpy.dtype`.""" + obj = np.dtype("int64") + + # Raises errors without extended encoder + # TODO! "Object of type dtype[int64] is not JSON serializable" when py3.9+ + with pytest.raises(TypeError, match=re.escape("Object of type")): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "numpy.dtype" + assert d["value"] == "int64" + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, np.dtype) + assert out == obj + + @pytest.mark.skip("TODO!") + def test_dtype_structured(self): + """Test round-tripping structured `numpy.dtype`.""" + obj = np.dtype([("f1", np.int64), ("f2", np.float16)]) + + def test_ndarray_simple(self): + """Test round-tripping `numpy.ndarray`.""" + obj = np.array([3, 4], dtype=float) + + # Raises errors without extended encoder + with pytest.raises(TypeError, match=re.escape("Object of type ndarray is not JSON serializable")): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "numpy.ndarray" + assert d["value"] == [3.0, 4.0] + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, np.ndarray) + assert np.array_equal(out, obj) + + @pytest.mark.skip("TODO!") + def test_ndarray_structured(self): + """Test round-tripping structured `numpy.ndarray`.""" + dt = np.dtype([("f1", np.int64), ("f2", np.float16)]) + obj = np.array((1, 3.0), dtype=dt) diff --git a/astropy/units/io.py b/astropy/units/io.py index ecba262b821c..df57acdf22dc 100644 --- a/astropy/units/io.py +++ b/astropy/units/io.py @@ -19,6 +19,11 @@ def json_encode_unit(obj): # FIXME so works with units defined outside units su return code +def json_decode_unit(constructor, value, code): + """Return a |Unit| from an ``json_encode_unit`` dictionary.""" + return constructor(value) + + def json_encode_quantity(obj): """Return a |Quantity| as a JSON-able dictionary.""" from astropy.io.misc.json.core import _json_base_encode @@ -41,7 +46,10 @@ def register_json_extended(): # Unit JSONExtendedEncoder.register_encoding(u.UnitBase)(json_encode_unit) + JSONExtendedDecoder.register_decoding(u.UnitBase)(json_decode_unit) + JSONExtendedEncoder.register_encoding(u.FunctionUnitBase)(json_encode_unit) + JSONExtendedDecoder.register_decoding(u.FunctionUnitBase)(json_decode_unit) # Quantity JSONExtendedEncoder.register_encoding(u.Quantity)(json_encode_quantity) diff --git a/astropy/units/tests/test_io.py b/astropy/units/tests/test_io.py new file mode 100644 index 000000000000..b74b1dd5a439 --- /dev/null +++ b/astropy/units/tests/test_io.py @@ -0,0 +1,77 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +# -*- coding: utf-8 -*- + +import ast +import json +import re + +import numpy as np +import pytest + +import astropy.units as u +from astropy.io.misc.json import JSONExtendedEncoder, JSONExtendedDecoder +from astropy.io.misc.json.tests.test_core import JSONExtendedTestBase + + +class TestJSONExtendedUnits(JSONExtendedTestBase): + """Tests for serializing builtins with extended JSON encoders and decoders.""" + + def test_unit_simple(self): + """Test round-tripping `astropy.units.Unit`.""" + obj = u.Unit("km") + + # Raises errors without extended encoder + with pytest.raises(TypeError, match=re.escape("Object of type PrefixUnit is not JSON serializable")): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "astropy.units.core.PrefixUnit" + assert d["value"] == "km" + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, u.UnitBase) + assert out == obj + + @pytest.mark.skip("TODO!") + def test_unit_structured(self): + """Test round-tripping structured `astropy.units.Unit`.""" + obj = u.Unit(("km", "eV")) + + def test_quantity_simple(self): + """Test round-tripping `astropy.units.Quantity`.""" + obj = u.Quantity([3, 4], dtype=float, unit=u.km) + + # Raises errors without extended encoder + with pytest.raises(TypeError, match=re.escape("Object of type Quantity is not JSON serializable")): + json.dumps(obj) + + # Works with the extended encoder + serialized = json.dumps(obj, cls=JSONExtendedEncoder) + assert isinstance(serialized, str) + d = ast.literal_eval(serialized) + assert d["__class__"] == "astropy.units.quantity.Quantity" + assert d["value"] == [3.0, 4.0] + assert d["unit"] == "km" + + # Comes back partially processed without extended decoder + out = json.loads(serialized) + assert isinstance(out, dict) + + # Roundtrips + out = json.loads(serialized, cls=JSONExtendedDecoder) + assert isinstance(out, u.Quantity) + assert np.array_equal(out, obj) + + @pytest.mark.skip("TODO!") + def test_quantity_structured(self): + """Test round-tripping structured `astropy.units.Quantity`.""" + dt = np.unit([("f1", np.int64), ("f2", np.float16)]) + obj = u.Quantity((1, 3.0), dtype=dt, unit=u.Unit("(u.km, u.eV)"))