# Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See LICENSE in the project root # for license information. """Improved JSON serialization. """ import builtins import json import numbers import operator JsonDecoder = json.JSONDecoder class JsonEncoder(json.JSONEncoder): """Customizable JSON encoder. If the object implements __getstate__, then that method is invoked, and its result is serialized instead of the object itself. """ def default(self, value): try: get_state = value.__getstate__ except AttributeError: pass else: return get_state() return super().default(value) class JsonObject(object): """A wrapped Python object that formats itself as JSON when asked for a string representation via str() or format(). """ json_encoder_factory = JsonEncoder """Used by __format__ when format_spec is not empty.""" json_encoder = json_encoder_factory(indent=4) """The default encoder used by __format__ when format_spec is empty.""" def __init__(self, value): assert not isinstance(value, JsonObject) self.value = value def __getstate__(self): raise NotImplementedError def __repr__(self): return builtins.repr(self.value) def __str__(self): return format(self) def __format__(self, format_spec): """If format_spec is empty, uses self.json_encoder to serialize self.value as a string. Otherwise, format_spec is treated as an argument list to be passed to self.json_encoder_factory - which defaults to JSONEncoder - and then the resulting formatter is used to serialize self.value as a string. Example:: format("{0} {0:indent=4,sort_keys=True}", json.repr(x)) """ if format_spec: # At this point, format_spec is a string that looks something like # "indent=4,sort_keys=True". What we want is to build a function call # from that which looks like: # # json_encoder_factory(indent=4,sort_keys=True) # # which we can then eval() to create our encoder instance. make_encoder = "json_encoder_factory(" + format_spec + ")" encoder = eval( make_encoder, {"json_encoder_factory": self.json_encoder_factory} ) else: encoder = self.json_encoder return encoder.encode(self.value) # JSON property validators, for use with MessageDict. # # A validator is invoked with the actual value of the JSON property passed to it as # the sole argument; or if the property is missing in JSON, then () is passed. Note # that None represents an actual null in JSON, while () is a missing value. # # The validator must either raise TypeError or ValueError describing why the property # value is invalid, or else return the value of the property, possibly after performing # some substitutions - e.g. replacing () with some default value. def _converter(value, classinfo): """Convert value (str) to number, otherwise return None if is not possible""" for one_info in classinfo: if issubclass(one_info, numbers.Number): try: return one_info(value) except ValueError: pass def of_type(*classinfo, **kwargs): """Returns a validator for a JSON property that requires it to have a value of the specified type. If optional=True, () is also allowed. The meaning of classinfo is the same as for isinstance(). """ assert len(classinfo) optional = kwargs.pop("optional", False) assert not len(kwargs) def validate(value): if (optional and value == ()) or isinstance(value, classinfo): return value else: converted_value = _converter(value, classinfo) if converted_value: return converted_value if not optional and value == (): raise ValueError("must be specified") raise TypeError("must be " + " or ".join(t.__name__ for t in classinfo)) return validate def default(default): """Returns a validator for a JSON property with a default value. The validator will only allow property values that have the same type as the specified default value. """ def validate(value): if value == (): return default elif isinstance(value, type(default)): return value else: raise TypeError("must be {0}".format(type(default).__name__)) return validate def enum(*values, **kwargs): """Returns a validator for a JSON enum. The validator will only allow the property to have one of the specified values. If optional=True, and the property is missing, the first value specified is used as the default. """ assert len(values) optional = kwargs.pop("optional", False) assert not len(kwargs) def validate(value): if optional and value == (): return values[0] elif value in values: return value else: raise ValueError("must be one of: {0!r}".format(list(values))) return validate def array(validate_item=False, vectorize=False, size=None): """Returns a validator for a JSON array. If the property is missing, it is treated as if it were []. Otherwise, it must be a list. If validate_item=False, it's treated as if it were (lambda x: x) - i.e. any item is considered valid, and is unchanged. If validate_item is a type or a tuple, it's treated as if it were json.of_type(validate). Every item in the list is replaced with validate_item(item) in-place, propagating any exceptions raised by the latter. If validate_item is a type or a tuple, it is treated as if it were json.of_type(validate_item). If vectorize=True, and the value is neither a list nor a dict, it is treated as if it were a single-element list containing that single value - e.g. "foo" is then the same as ["foo"]; but {} is an error, and not [{}]. If size is not None, it can be an int, a tuple of one int, a tuple of two ints, or a set. If it's an int, the array must have exactly that many elements. If it's a tuple of one int, it's the minimum length. If it's a tuple of two ints, they are the minimum and the maximum lengths. If it's a set, it's the set of sizes that are valid - e.g. for {2, 4}, the array can be either 2 or 4 elements long. """ if not validate_item: validate_item = lambda x: x elif isinstance(validate_item, type) or isinstance(validate_item, tuple): validate_item = of_type(validate_item) if size is None: validate_size = lambda _: True elif isinstance(size, set): size = {operator.index(n) for n in size} validate_size = lambda value: ( len(value) in size or "must have {0} elements".format( " or ".join(str(n) for n in sorted(size)) ) ) elif isinstance(size, tuple): assert 1 <= len(size) <= 2 size = tuple(operator.index(n) for n in size) min_len, max_len = (size + (None,))[0:2] validate_size = lambda value: ( "must have at least {0} elements".format(min_len) if len(value) < min_len else "must have at most {0} elements".format(max_len) if max_len is not None and len(value) < max_len else True ) else: size = operator.index(size) validate_size = lambda value: ( len(value) == size or "must have {0} elements".format(size) ) def validate(value): if value == (): value = [] elif vectorize and not isinstance(value, (list, dict)): value = [value] of_type(list)(value) size_err = validate_size(value) # True if valid, str if error if size_err is not True: raise ValueError(size_err) for i, item in enumerate(value): try: value[i] = validate_item(item) except (TypeError, ValueError) as exc: raise type(exc)(f"[{repr(i)}] {exc}") return value return validate def object(validate_value=False): """Returns a validator for a JSON object. If the property is missing, it is treated as if it were {}. Otherwise, it must be a dict. If validate_value=False, it's treated as if it were (lambda x: x) - i.e. any value is considered valid, and is unchanged. If validate_value is a type or a tuple, it's treated as if it were json.of_type(validate_value). Every value in the dict is replaced with validate_value(value) in-place, propagating any exceptions raised by the latter. If validate_value is a type or a tuple, it is treated as if it were json.of_type(validate_value). Keys are not affected. """ if isinstance(validate_value, type) or isinstance(validate_value, tuple): validate_value = of_type(validate_value) def validate(value): if value == (): return {} of_type(dict)(value) if validate_value: for k, v in value.items(): try: value[k] = validate_value(v) except (TypeError, ValueError) as exc: raise type(exc)(f"[{repr(k)}] {exc}") return value return validate def repr(value): return JsonObject(value) dumps = json.dumps loads = json.loads