diff --git a/ear/test/json.py b/ear/test/json.py new file mode 100644 index 00000000..341f264c --- /dev/null +++ b/ear/test/json.py @@ -0,0 +1,111 @@ +import attrs +import ear.common +import ear.core.metadata_input +import ear.fileio.adm.elements +from fractions import Fraction + + +def value_to_json(value, include_defaults=True): + """turn EAR types into JSON + + This is not complete but will be extended as needed. + + The result can be turned back into objects using json_to_value + + Parameters: + include_defaults (bool): add default values (True), or skip them (False) + Returns: + a json-serialisable object, composed of dict, list, bool, int, float, + str or None + + objects are represented as a dictionary containing the keyword + arguments for the constructor, and a _type key naming the type + """ + + def recurse(v): + return value_to_json(v, include_defaults=include_defaults) + + if value is None: + return None + elif isinstance(value, (bool, int, float, str)): + return value + elif isinstance(value, list): + return [recurse(v) for v in value] + elif isinstance(value, dict): + return {k: recurse(v) for k, v in value.items()} + elif attrs.has(type(value)): + d = dict(_type=type(value).__name__) + + for field in attrs.fields(type(value)): + v = getattr(value, field.name) + + if include_defaults or not _field_is_default(field, v): + d[field.name] = recurse(v) + return d + # types that need special handling; make this generic if we need to add more + elif isinstance(value, Fraction): + return dict( + _type="Fraction", numerator=value.numerator, denominator=value.denominator + ) + else: + assert False, "unknown type" + + +def json_to_value(value): + """turn the results of value_to_json back into objects""" + if isinstance(value, dict): + converted = {k: json_to_value(v) for k, v in value.items()} + if "_type" in value: + t = _known_types[value["_type"]] + del converted["_type"] + return t(**converted) + else: + return converted + if isinstance(value, list): + return [json_to_value(v) for v in value] + else: + return value + + +def _field_is_default(field: attrs.Attribute, value): + """does an attrs field have the default value? + + this depends on values being properly equality-comparable + """ + default = field.default + if default is attrs.NOTHING: + return False + + if isinstance(default, attrs.Factory): + assert not default.takes_self, "not implemented" + + default = default.factory() + + return type(value) is type(default) and value == default + + +def _get_known_types(): + known_types = [ + ear.common.CartesianPosition, + ear.common.CartesianScreen, + ear.common.PolarPosition, + ear.common.PolarScreen, + ear.core.metadata_input.ExtraData, + ear.core.metadata_input.ObjectTypeMetadata, + ear.fileio.adm.elements.AudioBlockFormatObjects, + ear.fileio.adm.elements.CartesianZone, + ear.fileio.adm.elements.ChannelLock, + ear.fileio.adm.elements.Frequency, + ear.fileio.adm.elements.JumpPosition, + ear.fileio.adm.elements.ObjectCartesianPosition, + ear.fileio.adm.elements.ObjectDivergence, + ear.fileio.adm.elements.ObjectPolarPosition, + ear.fileio.adm.elements.PolarZone, + ear.fileio.adm.elements.ScreenEdgeLock, + Fraction, + ] + + return {t.__name__: t for t in known_types} + + +_known_types = _get_known_types() diff --git a/ear/test/test_json.py b/ear/test/test_json.py new file mode 100644 index 00000000..eff764c0 --- /dev/null +++ b/ear/test/test_json.py @@ -0,0 +1,55 @@ +import pytest +from .json import json_to_value, value_to_json +from ear.core.metadata_input import AudioBlockFormatObjects, ObjectTypeMetadata +from fractions import Fraction + + +def _check_json_subset(d1, d2): + """check that dictionaries in d1 are a subset of d2, and that all values + are representable in json""" + + def is_type(t): + return isinstance(d1, t) and isinstance(d2, t) + + if is_type(dict): + for key in d1: + assert key in d2 + _check_json_subset(d1[key], d2[key]) + elif is_type(list): + assert len(d1) == len(d2) + for v1, v2 in zip(d1, d2): + _check_json_subset(v1, v2) + elif is_type(bool) or is_type(int) or is_type(float) or is_type(str): + assert d1 == d2 + elif d1 is None and d2 is None: + pass + else: + assert False, "mismatching or unknown types" + + +@pytest.mark.parametrize("include_defaults", [False, True]) +def test_json_OTM(include_defaults): + otm = ObjectTypeMetadata( + block_format=AudioBlockFormatObjects( + rtime=Fraction(1, 2), position=dict(azimuth=0, elevation=0) + ) + ) + otm_j = value_to_json(otm, include_defaults=include_defaults) + expected = { + "_type": "ObjectTypeMetadata", + "block_format": { + "_type": "AudioBlockFormatObjects", + "position": { + "_type": "ObjectPolarPosition", + "azimuth": 0.0, + "elevation": 0.0, + }, + "rtime": {"_type": "Fraction", "denominator": 2, "numerator": 1}, + }, + } + + _check_json_subset(expected, otm_j) + + otm_j_otm = json_to_value(otm_j) + + assert otm_j_otm == otm