Architecture of coola.jsonable¶
This page describes the design of the
coola.jsonable package for contributors: the invariants, the contracts between the parts, and the trade-offs of the main design choices. The rules that users rely on are in the user guide; the API is in the reference.
Overview¶
coola.jsonable is a registry of transformers on the engine of coola.recursive. It adds no
traversal: the cycles, the depth, the order, the per-call cache and the thread safety come from the
engine. A transformer only describes the shape of one type with the expand contract of recursive:
expand(data) returns None for a scalar, or (children, rebuild). Three shapes are enough for every
type:
| Shape | expand(data) |
Used for |
|---|---|---|
| scalar | None: str, int, float, bool, None are kept |
the JSON scalars (DefaultTransformer) |
| container | (children, rebuild): the rebuild makes a list or a dict |
sequences, sets, mappings, dataclasses |
| conversion | ([convert(data)], lambda xs: xs[0]): the result is converted again |
every other type (Path, Enum, arrays, ...) |
The third shape is the whole trick (ConvertTransformer). A conversion returns a value that the
engine converts in turn, until only JSON scalars and containers remain: an Enum gives its value
(which may be a tuple of paths), ndarray.tolist() gives a list that is checked like any list, a
pydantic model gives a dict whose values may be sets or arrays, a SecretStr gives a str. No
converter calls another one, no converter knows the registry, and nothing is returned unchecked.
Constraints¶
- Simple and easy to maintain. One function, one registry, one rule per type: no table of
predicates, no
sys.moduleslook-up and no registry built on every call. - Fast. The benchmark gate (
tests/benchmarks/test_jsonable_benchmark.py) holds on every input, including arrays (see Arrays are checked, except the numeric dtypes). - Consistent with the siblings. Same containers, same dispatch (
TransformerRegistry), same lazy loading (register_lazy), same plugin mechanism (make_default_registry_singletonwith aplugin_group), same keyword-only options. - Optional libraries stay lazy. NumPy, PyTorch, JAX, pandas, polars, pyarrow, xarray and pydantic are imported by their module
only, never by
import coola.jsonable.
Architecture in detail¶
coola/jsonable/
__init__.py package docstring with the Properties; the public names
interface.py to_jsonable, get_default_registry, register_transformers; the default registry
transformers.py ConvertTransformer, SequenceTransformer, SetTransformer, MappingTransformer,
DataclassTransformer
stdlib.py get_default_transformers(): the standard library types
numpy.py get_default_transformers(): ndarray (NDArrayTransformer), generic
torch.py get_default_transformers(): Tensor (TensorTransformer; a sparse or meta tensor is a TypeError)
jax.py get_default_transformers(): Array
pandas.py get_default_transformers(): Series, Index, NaT, NA
polars.py get_default_transformers(): Series
pyarrow.py get_default_transformers(): Array, ChunkedArray
xarray.py get_default_transformers(): DataArray, Dataset, Variable
pydantic.py get_default_transformers(): BaseModel, SecretStr, SecretBytes
to_jsonable(data, *, registry=None, on_unsupported="raise")
| one registry (default: the singleton), one call = one engine run
v
registry.transform(data, leaf) coola.recursive engine
|-- transformer = registry.find_transformer(type(data)) MRO + fallback + lazy libraries
|-- expansion = transformer.expand(data)
| None -> a leaf: leaf(data)
| (children, rebuild) -> the engine converts the children, then rebuild(children)
v
JSON data
interface.py follows summary/interface.py: _register_default_transformers(registry) registers the
native and standard types, register_fallback(dataclasses.is_dataclass, DataclassTransformer()) and
one register_lazy per installed library, and the singleton is built with
make_default_registry_singleton(..., plugin_group="coola.jsonable.transformers").
The leaf function and on_unsupported¶
object and the JSON scalars are registered with DefaultTransformer, so a value of an unregistered
type reaches the leaf function of the engine. The leaf function is the only place where the three
modes differ: _raise keeps the JSON scalars and raises a TypeError for the others, _warn warns
and keeps, _keep is the identity. The transformers are the same in the three modes, so there is one
registry and nothing is built per call. The check is one isinstance against the five scalar types
(about 0.03 µs per leaf).
Keys¶
The keys are children of the mapping ([*keys, *values]), so the engine converts them like any
other value (a Path key gives a str, a tuple key gives a list). _to_dict then checks the result:
a str is kept, bool, int, float and None go through json.dumps (True gives "true"), any
other value raises a TypeError that names the type, and a duplicate raises a ValueError that names
the key. This is the only place where "keys are labels" of recursive is deliberately reversed,
because a JSON object has no other kind of key.
Traps of the dispatch¶
UserStringis aSequenceofUserString: aSequencetransformer that lists its elements never ends. It is registered as astrconversion instdlib.py.numpy.genericcomes beforefloatin the MRO ofnumpy.float64, so a NumPy scalar is converted byitem().np.bytes_is abytessubclass andnp.str_astrsubclass: they followbytesandstr.- A
strenum member or anIntEnummember is astror anintfor the MRO, so it is a scalar and is kept. Register the enum class to normalize it. pd.NaTis adatetimesubclass:type(pd.NaT)is registered, so it givesNoneand not"NaT".- A dataclass class (not an instance) has the type
type, so it is a leaf and is unsupported. ConvertTransformerrejects a function that returns a value of the type of its input (it would never end). It cannot see a cycle of types: the contract test of the default registry and the rule "a conversion must reduce the type to simpler ones" cover it.
Design choices and trade-offs¶
- The output is the JSON data model (tuples become lists), which gives the idempotence property.
- Unsupported types: three modes,
"raise"(default),"warn"and"keep". Adefault=callable likejson.dumpsis not offered because the registry already is the per-type hook;repras a mode isConvertTransformer(repr)registered forobjectin a copy of the registry. - Pydantic:
model_dump()in Python mode, then the registry. The sets are sorted, the arrays and paths are converted, a secret is masked, and the exclusions and plain serializers of the model are honoured. A serializer declared withwhen_used="json"is not applied.mode="json"would make the sets non-deterministic and fail on a NumPy field; the fields as children would ignoreexcludeand every serializer. NaNand the infinities are kept.json.dumps(..., allow_nan=False)enforces RFC 8259.- Bytes are Base64. Lossless but indistinguishable from text when read.
- A
timedeltais a number of seconds. Afloatloses the microseconds above about 285 years. - Frames and tables are not converted by default. A
DataFramehas at least three usual shapes and none is neutral.SeriesandIndexare one-dimensional and are converted. - A package
coola.jsonablewith one function. The name avoidscoola.json, which would hide the standard library. register_transformersandConvertTransformerrather than a function-based API: the registry is the extension point of every package.- The keys go through the registry, so a
Path, aUUIDor anEnumkey works with no code in the mapping transformer. - The subclasses of the JSON scalars are kept. Normalizing them would need an exact-type check on
every leaf and would not change what
jsonwrites. - Arrays are checked, except the numeric dtypes. Checking every element makes complex numbers,
object arrays and
datetime64raise (they are not JSON), but costs 0.09 µs per element: the conversion of 1,000,000 floats took 149 ms, 7.9 timestolistand 47 % ofjson.dumpsof the result, which failed the benchmark gate.NDArrayTransformerandTensorTransformertherefore return no children for the arrays whosetolistalready gives Pythonbool,intandfloat(NumPy kindsb,i,uand the dtypesfloat16,float32,float64; every real tensor): 17 ms, the cost oftolist. The other arrays (complex, object, dates, strings,longdouble) are still checked. - No guard against a converter that never ends, other than its own type. The engine has no depth limit by design.
Public API¶
from coola.jsonable import (
to_jsonable, # (data, *, registry=None, on_unsupported="raise")
get_default_registry, # the singleton TransformerRegistry
register_transformers, # (mapping, *, exist_ok=False)
ConvertTransformer, # (func): the building block of a custom type
DataclassTransformer,
MappingTransformer,
SequenceTransformer,
SetTransformer,
)
Dependents¶
| Package or file | Relation |
|---|---|
coola.recursive |
TransformerRegistry, BaseTransformer, DefaultTransformer and the engine are reused as they are. |
coola.nested |
Does not convert to JSON: use coola.jsonable.to_jsonable. |
coola.io (save_json) |
The docstring says "use coola.jsonable.to_jsonable first". No code change. |
pyproject.toml (import-linter) |
coola.jsonable is in the lists of the contracts: the core packages must not import it. |
| Entry points | Plugin group coola.jsonable.transformers. |
Tests¶
The unit tests mirror the source tree (tests/unit/jsonable/). The property tests
(tests/properties/test_jsonable.py) check that the output is accepted by json and round trips, that
the function is idempotent, that the output does not depend on the order of the input and that the
input is not modified. The integration tests (tests/integration/jsonable/) check the output for five
values of PYTHONHASHSEED (with a pydantic model that has a set), the threads, the depth of 100,000
levels and the cycles. The lazy-import tests check that import coola.jsonable imports no optional
library. The benchmark (tests/benchmarks/test_jsonable_benchmark.py) is the gate for the speed.