Jsonable Classes¶
NOTE: This tutorial us outdated and needs to be rewritten
The @jsonable
decorator lets you easily create python classes that are serializable and deserializable in the JSON format. The API somewhat ressembles the API of the dataclasses
buitlin module. A “jsonable” is a class that has been decorated with pheres’ @jsonable
decorator.
There are two main ways to use the decorator. One without type hint, to make jsonable classes, and three use with a type hint, to make jsonable value, array, and object. Those change how an instance serializes and deserializes to JSON. As we expect jsonable classes to be most useful, this usage is presented first.
Jsonable classes¶
Introduction¶
An instance of a Jsonable class can serialize it’s attributes to JSON as a JSONObject, and deserialize if the proper attributes are defined.
This uses the @jsonable
decorator without a type hint (i.e. without square brackets). Attributes to serialize/deserialize are defined at the class level, by providing type annotations.
import pheres as ph
from typing import *
@ph.jsonable
class SimpleJSONable:
"""Simple JSONable class"""
names: List[str]
def __init__(self, names: List[str]=None):
self.names = names or []
def print_all(self):
for name in self.names:
print(name)
When creating a jsonable class, Pheres will look at the annotated attributes of the class (much like @dataclass
does) and register them as Jsonized attributes for that class. This means those attributes will be written to JSON when serializing an instance, and are looked for when deserializing one.
A key difference between @dataclass
and @jsonable
is that @jsonable
will always look at the type hint of the Jsonized attributes to determine the type of the JSONized attribute. The type is used to correctly deserialize from JSON. See JSON-typing for supported type hints.
To allow deserialization, your class must define an __init__()
method that accepts the Jsonized attributes as keywords arguments.
After the class has been processed, Pheres adds the following methods and attributes to it:
to_json()
is a method that serialize an instance to a JSON object, i.e. an object thatjson.dump()
acceptsfrom_json()
is a class methods that can deserialize from JSON files, strings or object to the class. It guesses what its argument is, using the following heuristic:if the passed argument has a
read
attribute, it is a file-like objectif it is a subclass of
str
orbytes
, it is a string-likeelse, it is a python object that represents a JSON
Decoder
is a class attribute (e.g. stored on the class itself) that is aTypedJSONDecoder
parametrized for that classconflict_with()
can be used to check if a JSON could correspond to two jsonable classes
To serialize to a file or string, you can use pheres.JSONableEncoder
as the encoder for json.dump()
and json.dumps()
. The functions pheres.dump()
and pheres.dumps()
use this encoder by default.
Jsonable class conflict¶
Pheres can deserialized arbitrary JSON and automatically find jsonable classes in it. To allow that, pheres must be able to identify the jsonable class to deserialize to, without ambiguity. If a JSON object could correspond to more than one jsonable class, there is a jsonable conflict. jsonable conflicts are not permitted.
Conflicts are found based on the classes’ jsonized attributes names and types. If the types are incompatible, e.g. str
and int
, the value will differentiate the two classes and there is no conflict. For instance, the following two classes have a conflict in JSON, because both representation are a JSON object with a single key "value"
of type str
. We used the @dataclass
decorator to get the __init__
method automatically.
from dataclasses import dataclass
from pheres import jsonable
@dataclass
@jsonable
class A:
value: str
@dataclass
@jsonable
class B:
value: str
To prevent conflicts, when creating a new jsonable, Pheres checks that it does not conflict with any existing jsonable class. It raises a JSONableError
if a conflict is found. This early detection prevent errors later in the code, for instance when deserializing.
In the above example, the error is raised when the class
B
is created, during module import if this file is a module.
Fixing class conflict is relatively easy. The simplest way is to rename attributes so that there is no conflict. But sometimes you wish to keep the same names because this is clearer in python, in which case you can use json-only attributes to solve conflicts (see below). An attribute type
wich is a Literal
of a single value is automatically json-only: this is intended to make solving conflict easy.
Let us fix our conflict using this technique:
from dataclasses import dataclass
from typing import Literal
from pheres import jsonable
@dataclass
@jsonable
class A:
type: Literal["A"]
value: str
@dataclass
@jsonable
class B:
type: Literal["B"]
value: str
This will no longer raise on created class B
. Note that this changes the JSON representation of our class: for instance, the JSON {"value": "yes"}
no longer corresponds to A or B, because it doesn’t contain a "type"
key.
Python-only attributes¶
Pheres can only jsonize attributes that are type-hinted in the class definition. You can remove an attribute from the JSON serialization by not type-hinting it.
However, you might want to type hint some attributes without them being reflected in the JSON – for instance, because you use a type checker, or because you use @dataclass
. For those cases, @jsonable
supports the all_attrs
argument (True
by default). By setting it to False
, Pheres will only jsonize attributes that have been appropriately marked:
@jsonable(all_attrs=False)
class PartialJSONable:
name : ph.JAttr[str] # marked attribute
value: ph.jattr(str) # marked attribute
number: typing.Annotated[int, ph.JSONAttr()] # full syntax for marking attributes
py_only: int # attribute is not marked
To mark attributes, annotate them using the typing.Annotated
type from the builtin typing module. One of the annotation values must be an instance of pheres.JSONAttr
. As a short-hand, you can also:
use the type
pheres.JAttr[]
. It is an type alias that will annotate the provided type hintuse the function
pheres.jattr()
that will build the type hint at runtime. This might be incompatible with type-checkers
json-only attributes¶
Conversely, you might want some attributes to only be present in the JSON serialization of your class. The most common use-case is to resolve jsonable conflicts (see above).
To make an attribute json-only, you need to annotate its type-hint with an instance of pheres.JSONAttr
, passing True
to the instance’s json_only
argument. You can also use the pheres.jattr
short-hand (see below).
@ph.jsonable
class EmptyPython:
"""
JSONable class that doesn't contain any python attributes
"""
type_: Literal["empty_python"] # json-only by default
json_key: ph.jattr(Union[str, int], json_only=True) = 5 # marked as json-only
Note that attributes that are Literal
of a single value are json-only by default. The rationale is that you know the value in python, so you don’t need to load it from JSON. You can still force them to not be json-only by passing False
as the json_only
argument.
@ph.jsonable
class NotEmpty:
type_: ph.jattr(Literal["not_empty"], json_only=False) # forced to be in python
json-only attributes must provide a default value. If the type hint is a Literal
of a single value, the default is not required and the value of the Literal
is the default.
The default is used for serialization only: the value found in JSON is not checked to be equal to the default (only its type is checked). If you need to check for specific value(s), use a Literal
type.
Warning
If you use
@dataclass
on a jsonable class with json-only attribute,@jsonable
must be the inner-most decorator of the two: it removes json-only attributes from the class’ annotation and dictionary so taht@dataclass
doesn’t include them in the__init__
method. You will get aJSONableError
if you get the order wrong.
Default values¶
If a jsonized attribute has a value in the class definition, this is used as the default value for the jsonized attribute. The default value can be a JSON value, or a callable without arguments that builds the default value.
When serializing to JSON, attributes with a value equal to their default value are not included (unless default_values
is True, see below).
When deserializing from JSON, if a jsonized attribute is not found, it is given it’s default value. Callable default value are called without arguments to get the default value. Values are deep-copied (this means you can use a mutable value such as the empty list []
without side effects).
Changing the name of the attribute in JSON¶
The JSONAttr
Annotation can also be used to change the name of the attribute in JSON (compared to python). The python name is always the name of the attribute in the class definition. The JSON key for the attribute can be changed with the key
argument of JSONAttr
. The function jattr()
also supports the argument. JAttr
does not, since it is a type alias.
@ph.jsonable
class Widget:
type_ : ph.jattr(str, key="type") # change the name in JSON
Compatibility with the dataclasses
builtin module¶
The jsonable API is fully compatible with @dataclass
. If the default value of an attribute is a dataclasses.Field
, the default
or default_factory
is retrieved. Attributes that are removed from the class, but were still defined as dataclass fields, are also properly retrieved.
jsonable values¶
Sometimes, you don’t want your class JSON representation to be a JSON object, because it has a single key. @jsonable
can also make a class that is represented in JSON by a single value.
To define such a class, pass the type of the value to @jsonable
as you do for type hints.
@ph.jsonable[str]
class SmartString:
# required by @jsonable
def __init__(self, value):
self.s = value
def to_json(self):
return self.s
...
The syntax @jsonable[]
guesses what you want based on the provided type. There is an explicit syntax:
@ph.jsonable.Value[str]
class SmartString:
...
Jsonable values are a bit different from jsonable object seen before: pheres cannot guess what you do with the value, so you must implement the to_json()
method yourself. Usually this isn’t too hard.
The __init__
method must accept the call self.__init__(value)
, where value
is the decoded JSON value. This is necessary for deserialization.
jsonable values otherwise support everything jsonable classes do (from_json()
,Decoder
etc.)
jsonable array¶
If you want the value to be an array, then the syntax is a bit different: either pass a Tuple
or List
type, or pass all the type hints you would pass to Tuple
to @jsonable
directly, like so:
@jsonable[str, str]
class Person:
def __init__(self, name, surname):
self.name = name
self.surname = surname
def to_json(self):
return (self.name, self.surname)
def hello(self):
return f"Hello, my name is {self.name} {self.surname}"
@jsonable[str, ...]
class AddressBook:
def __init__(self, *addresses):
self.addresses = addresses
def to_json(self):
return self.addresses
def contains(address):
return address in self.addresses
If you want an array of arrays, then the syntax is @jsonable[List[List[T]]]
. You can also use the explicit syntax @jsonable.Array[List[T]]
, where pheres doesn’t unwraps the first List
or Tuple
annotation.
As for jsonable value, the class must define to_json
, pheres can’t do it automatically. Also, the __init__
method must accept the call self.__init__(*array)
, where array is the decoded JSONarray (a python list). For fixed-length jsonable array as Person
above, you can name the arguments explicitely. For variable-length jsonable array such as AddressBook
above, it is not possible.
jsonable arrays otherwise support everything jsonable classes do (from_json()
,Decoder
etc.)
jsonable object¶
Jsonable object are equivalent to jsonable values and arrays, but for JSON object. The difference between jsonable classes and jsonable objects is that object accept any number of arbitrary key, while classes accept only a fixed, finite and predetermined set of key (the jsonized attributes).
The syntax uses the Dict
type, or the explicit syntax @jsonable.Object
which doesn’t unwraps the first Dict
annotation:
@jsonable[Dict[str, str]] # or @jsonable.Object[str]
class Peoples:
def __init__(self, *names):
self.names = names
def to_json(self):
return self.names
def surname(name):
return self.names.get(name)
As for jsonable values and arrays, you must provide to_json
. Jsonable objects otherwise support everything jsonable classes do (from_json()
,Decoder
etc.)