Skip to main content

Muotti

Muotti maps an object onto another object. It reads the fields of a source, converts them to the types the destination expects, and builds or updates that destination.

Muotti knows nothing about any particular kind of object, and is taught one shape at a time through object protocols. Dataclasses, TypedDicts, mappings, sequences, plain classes and pydantic models are the ones that come with it. Muotti is used by the Bolinette project to build payloads, responses and configuration sections.

from dataclasses import dataclass
from muotti import DataclassProtocol, Mapper


@dataclass
class UserPayload:
    name: str
    age: int


@dataclass
class User:
    name: str
    age: int
    active: bool = True


mapper = Mapper()
mapper.add_protocol(DataclassProtocol())

assert mapper.map(User, UserPayload("Bob", 42)) == User("Bob", 42, active=True)

Installation

$ pip install muotti             # or use your preferred package manager
$ pip install muotti[pydantic]   # with the pydantic protocol and value converter

Requirements

Muotti requires Python 3.13 (or newer). It depends on peritype to read types at runtime, on mirino for the paths it names fields with, and on escondite for the decorators. pydantic is only needed for the pydantic extra.

Object protocols

An ObjectProtocol teaches Muotti one kind of object: which fields a type has, how to read one from an instance, and how to build the type from the values collected for it. A mapper starts empty, and resolves the first protocol that matches a type, in decreasing priority.

from muotti import DataclassProtocol, Mapper, MappingProtocol

mapper = Mapper()
mapper.add_protocol(DataclassProtocol())
mapper.add_protocol(MappingProtocol())

assert mapper.map(dict[str, object], User, {"name": "Bob", "age": 42}).name == "Bob"

Some protocols know the fields of their type up front, such as the dataclass one. Others are source-driven and take the keys they are given, which is how a dict, a list or a set destination is filled.

Writing a protocol is how a new kind of object is supported: subclass ObjectProtocol, implement matches, fields, read_field and construct, and give it a priority above the bundled ones if it must win over them.

Mapping and merging

map(dest_cls, src) builds a new instance, and takes the source class from the source itself. map(src_cls, dest_cls, src) is the form to use when the source cannot describe itself, a dict being the usual case. merge(src, dest) maps into an instance that already exists, and leaves the fields absent from the source alone.

user = mapper.map(User, UserPayload("Bob", 42))
mapper.merge(UserPayload("Alice", 30), user)

assert user == User("Alice", 30, active=True)

A field missing from the source is an error, unless the destination has a default for it or accepts None. With validate=True, every field is tried and the failures are collected in a single ValidationError instead of raising on the first one.

from muotti.errors import ValidationError

try:
    mapper.map(dict[str, object], User, {"name": "Bob"}, validate=True)
except ValidationError as err:
    assert [str(e.dest) for e in err.errors] == ["User.age"]

Profiles

Field names do not always line up, and a Profile is where that is written down. register opens a sequence between two types, and for_attr points at a destination field with a lambda. That lambda receives an expression recorder, not an instance, so lambda d: d.name records the path to the field instead of reading anything.

from muotti import Mapper, Profile


@dataclass
class Account:
    full_name: str


class AccountProfile(Profile):
    def __init__(self) -> None:
        super().__init__()
        self.register(Account, User).for_attr(
            lambda d: d.name,
            lambda opt: opt.map_from(lambda s: s.full_name),
        ).for_attr(
            lambda d: d.age,
            lambda opt: opt.default(lambda: 0),
        )


mapper.load_profiles([AccountProfile()])

assert mapper.map(User, Account("Bob")) == User("Bob", 0, active=True)

Besides map_from and default, an option can ignore a field, mark it read_only, allow_write on a field the protocol reports as immutable, or force a type with use_type. A sequence can also run a function before_mapping or after_mapping, and include another pair of types to inherit its overrides.

assert_configuration_valid walks every loaded sequence and raises a MappingConfigurationError listing each destination field that is required, has no default, and has nothing in the source to fill it. It is meant to run once at startup, rather than to discover the problem on the first request.

Value conversion

Everything that is not a nested object ends up at a value converter, and that converter is a protocol:

class ValueConverter(Protocol):
    def convert(self, value: Any, spec: FieldSpec, dest: ExpressionNode, src: ExpressionNode) -> Any: ...

A mapper uses BasicValueConverter unless it is given another one. It passes a value through when it already has the target type, and otherwise applies a small set of conversions. Numbers convert between themselves, str and bytes convert into each other, a string becomes a UUID, a Decimal, a PurePath or an Enum member, an ISO 8601 string becomes a datetime, a date or a time, and a number of seconds becomes a timedelta. Lists, sets, tuples and dicts are converted element by element, and the members of a union are tried in order, the ones the value already matches first. With strict=True nothing is converted, and only a value that already has the right type is accepted.

from datetime import date
from muotti import BasicValueConverter, Mapper


@dataclass
class Event:
    day: date
    tags: set[str]


mapper = Mapper(BasicValueConverter())
mapper.add_protocol(DataclassProtocol())
mapper.add_protocol(MappingProtocol())

event = mapper.map(dict[str, object], Event, {"day": "2026-09-18", "tags": ["a", "a", "b"]})
assert event == Event(date(2026, 9, 18), {"a", "b"})

The pydantic extra brings a second converter, which hands the value to a TypeAdapter and follows pydantic's own rules. It is the one to use when those rules are what the rest of an application already expects.

from muotti import Mapper
from muotti.pydantic import PydanticProtocol, PydanticValueConverter

mapper = Mapper(PydanticValueConverter())
mapper.add_protocol(PydanticProtocol())

The two converters do not accept exactly the same values, so a mapper that used to run on pydantic should be given PydanticValueConverter explicitly rather than left on the default.

Discovery through a cache

mapping and mapping_protocol record a profile or a protocol class in an escondite cache when the module is imported, and load_from_cache instantiates everything the cache holds. An application collects its profiles this way instead of importing each one by hand.

from escondite import Cache
from muotti import Mapper, Profile, mapping

cache = Cache()


@mapping(cache=cache)
class AccountProfile(Profile): ...


mapper = Mapper()
mapper.load_from_cache(cache)

Both decorators take the bare and the parametrized form, and fall back to escondite's global cache when none is given. Registering by hand with add_protocol and load_profiles works just as well, the cache is a convenience.

Reference

Bundled protocols

Protocol Destination Priority
PydanticProtocol pydantic.BaseModel, in muotti.pydantic 90
TypedDictProtocol TypedDict, with NotRequired and totality 80
DataclassProtocol @dataclass types 70
MappingProtocol dict and other mappings, source-driven 20
SequenceProtocol / SetProtocol list, tuple / set, frozenset 20
PlainObjectProtocol Any class with annotated attributes 0

Errors

All errors derive from muotti.errors.MuottiError, and a MappingError carries the dest and src paths the failure happened on.

Error Raised when
SourceNotFoundError A required destination field has nothing in the source
DestinationNotNullableError None reached a field that does not accept it
ConversionError A value could not be converted to the type of its field
InstantiationError The destination type rejected the values collected for it
ImmutableFieldError A field cannot be written to
NoProtocolError No ObjectProtocol matched the destination type
ValidationError Raised with validate=True, and holds every error in errors
MappingConfigurationError assert_configuration_valid found fields nothing can fill

License

Muotti is released under the MIT license, see LICENSE.txt.

Metadata

Release files for muotti 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for muotti 0.1.0
File Size Uploaded
muotti-0.1.0.tar.gz 16.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for muotti 0.1.0
File Interpreter ABI Platform
muotti-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 41.5 kB

Release files / muotti-0.1.0.tar.gz

Download URL muotti-0.1.0.tar.gz
Size 16.3 kB
Tags Source
SHA-256 checksum
How to use checksums
3f6c1001f1b07e6a8172b33ffda68dc58b3731653937fab1dcec36eebca834e5
BLAKE2b-256 checksum
How to use checksums
b4fd242fccb32c989878f8cdd46bb197b52d0502c2bca26c229b2817ff5e6481
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.16 {"installer":{"name":"uv","version":"0.12.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / muotti-0.1.0-py3-none-any.whl

Download URL muotti-0.1.0-py3-none-any.whl
Size 25.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9500a3a3a29e67b3e0ec784425a885c1edbfa6762d123bd46dfed9682d3f5054
BLAKE2b-256 checksum
How to use checksums
b50beb8073b6462cd916817d56afc7af0e55f5736cc9c8a41ba8ef45fbd554f0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.16 {"installer":{"name":"uv","version":"0.12.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page