Extending

It is possible to increase the number of types that are supported by the framework (or customize how certain types are marshaled/unmarshaled).

Schema

All (un)marshaling is supported through the marsh.schema.core.Schema class (there are two separate variants of this, one for marshaling and one for unmarshaling).

The common part of the schema is their matching function which always takes a single value and returns a boolean based on if the schema class matched the value or not. If the schema matched the value it will be initialized with the same value which is accessible through self.value.

Registration

A new implementation of a schema must be registered if it is to be available for use by marsh. This is done through the function marsh.schema.register() which acts as a decorator for a registered schema class.

Priority

Registered schemas are ordered by their priority which can be set during registration. The priority order affects which schemas are matched before others.

The base priority is an integer value. Higher values correspond to higher priority. There is also a relative priority where a schema may preceed or succeed one or more other schemas. The relative priority is considered before the base priority.

Marshal

Lets consider the steps for supporting marshaling of complex values. We need to implement and register a new schema. This schema should inherit the base class marsh.schema.MarshalSchema.

import marsh


@marsh.schema.register
class ComplexMarshalSchema(marsh.schema.MarshalSchema):

    @classmethod
    def match(
        cls,
        value
    ) -> bool:
        # we match an instance of `complex`, not its type
        return isinstance(value, complex)

    def marshal(
        self
    ) -> dict:
        return {
            'real': self.value.real,
            'imag': self.value.imag,
        }

Marshaling for the complex type is now supported through the framwork.

Unmarshal

For unmarshaling we perform similar steps as with marshaling but with slight differences. For starters, we need to use a different base class; marsh.schema.UnmarshalSchema. We also need to consider returning a default value when the input is missing. For example, if a field in a dataclass is of type complex and has a default value of complex(1, 2) then our schema class would match that field and contain the type as well as the default value.

import marsh


@marsh.schema.register
class ComplexUnmarshalSchema(marsh.schema.UnmarshalSchema[complex]):

    @classmethod
    def match(
        cls,
        value
    ) -> bool:
        # We match the type of `complex`, not its instance
        return value == complex

    def unmarshal(
        self,
        element: marsh.element.ElementType
    ) -> complex:
        # we first need to check if the input value is missing
        if marsh.utils.is_missing(element):
            # if there is a default value we return it instead
            if self.has_default():
                return self.get_default()
            # otherwise we raise an error since we did not get
            # a value to unmarshal.
            raise marsh.errors.MissingValueError
        if isinstance(element, float):
            return complex(element)
        if marsh.utils.is_mapping(element):
            return complex(**element)
        if marsh.utils.is_sequence(element):
            return complex(*element)
        raise marsh.errors.UnmarshalError(element)

In the above example we allow sequence and mapping inputs without checking their actual values. We could instead take advantage of marsh’s ability to unmarshal typing constructs as a way to validate our input.

from typing import (
    Tuple,
    TypedDict,
    Union,
)
import marsh


class Kwargs(TypedDict, total=False):
    real: float
    imag: float


Args = Union[Tuple[float], Tuple[float, float]]


InputType = Union[float, Args, Kwargs]


@marsh.schema.register
class ComplexUnmarshalSchema(marsh.schema.UnmarshalSchema[complex]):

    @classmethod
    def match(
        cls,
        value
    ) -> bool:
        return value == complex

    def unmarshal(
        self,
        element: marsh.element.ElementType
    ) -> complex:
        if marsh.utils.is_missing(element):
            if self.has_default():
                return self.get_default()
            raise marsh.errors.MissingValueError
        arg = marsh.unmarshal(InputType, element)
        if isinstance(arg, float):
            return complex(arg)
        if marsh.utils.is_mapping(arg):
            return complex(**arg)
        else:
            return complex(*arg)