Skip to main content

mtdt

Type-safe metadata mappings for Python. Two classes, zero dependencies.

v1.1.0 · pip install mtdt · 190/190 tests passing · Python 3.11+ · MIT


The idea

Metadata usually ends up in a plain dict[str, Any]. That works until two libraries both pick "source", until someone stores a list where a str was expected, or until a cached object stays alive because a metadata dict is still holding it.

mtdt moves the policy into the key:

from mtdt import MetadataStore, key
from mtdt.exceptions import InvalidValue

def must_be_str(value):
    if not isinstance(value, str):
        raise InvalidValue(what="author", expected="a str", got=value)

AUTHOR  = key("author", type=str, validator=must_be_str)
VERSION = key("version", type=int, immutable=True)

store = MetadataStore()
store[AUTHOR] = "ada"
store[VERSION] = 1

store[AUTHOR] = 42    # InvalidValue: expected a str for author; got 42.
store[VERSION] = 2    # InvalidUsage: immutable key already set
del store[VERSION]    # InvalidUsage: immutable keys cannot be removed
store["author"]       # InvalidType: keys must be Key instances (strict mode)

Keys are identity-based by default, so two independently created key("author") objects are different keys. Collisions between unrelated packages stop being possible.


Keys

Key is a frozen, slotted, keyword-only dataclass. While you can instantiate it directly, the recommended entry points are the key and key_factory factories.

Field Default Meaning
value None Arbitrary label or payload. Not used for lookup unless use_identity=False.
expected_type Any Static-analysis hint. Does not enforce anything at runtime by itself.
use_identity True Equality/hash by object identity. False switches to structural comparison.
weakref False Route the value into a WeakValueDictionary.
immutable False Write-once. Blocks overwrite and deletion.
validator None Callable[[T], None], raises Invalid on failure.

The key factory is the normal entry point:

from mtdt import key

K = key("label", type=int)                    # positional value, keyword config
K = key("label", type=int, weakref=True)
K = key()                                     # anonymous, identity-only key

expected_type exists for your type checker. store[K] is typed as T when K: Key[T], which is most of the practical value. Runtime enforcement only happens if a validator is attached.

Structural keys

With use_identity=False, keys compare by a structural tuple of their fields. Note that structural equality requires the same validator object, not an equivalent one. Two structurally identical keys built with separately defined validator functions will not match.

Custom factories

key_factory bakes in defaults and can namespace keys:

from mtdt import key_factory

mykey = key_factory(immutable=True, extra_value="myapp")

REGION = mykey("region", type=str)
REGION.value      # ('myapp', 'region')
REGION.immutable  # True

extra_value is mostly for debugging and repr clarity — identity already guarantees uniqueness.


Weak values

If a key is marked weakref=True, its values are held weakly. When the value is garbage collected, the entry is automatically removed from the store.

CACHE = key("cache", weakref=True)

store[CACHE] = expensive_object
del expensive_object
CACHE in store    # False — entry vanished with its last strong reference

Weak keys read and write through a separate WeakValueDictionary. Storing a value that cannot be weakly referenced (int, str, tuple, most builtins) raises InvalidUsage rather than failing silently. len(), iteration, and the keys() / values() / items() views span both backing stores: strong entries first, then weak. The views are live.


Validation

Three ways to attach a validator, in order of directness:

1. Pass it in. Always works, no magic:

K = key("port", type=int, validator=check_port)
K = key("anything", type=int, validator=None)   # explicitly opt out

2. Attach it to your class. set_instancecheck_validator installs a __metadata_key_validator__ attribute that factories pick up automatically:

from mtdt import set_instancecheck_validator

@set_instancecheck_validator
class Config: ...

K = key("config", type=Config)   # isinstance validator found automatically
store[K] = "not a Config"        # InvalidValue

Only works on classes you can set attributes on — not builtins, not most C extension types.

3. Embed it in the annotation. For cases where the type itself is shared:

from typing import Annotated
from mtdt import EmbeddedValidator

Port = Annotated[int, EmbeddedValidator(check_port)]
K = key("port", type=Port)

Validators run on __setitem__, setdefault (only when inserting), and update. They do not run on unchecked_update.


Strict and non-strict stores

MetadataStore operates in two modes, determining what can be used as a key:

  • Strict Mode (Default): Only Key instances are accepted. This unlocks the full power of mtdt, including runtime validation, immutability enforcement, weak reference routing, and namespacing. Use this when you want type-safety and robust metadata management.
  • Non-Strict Mode (strict=False): Any hashable Python object can be used as a key. In this mode, mtdt acts exactly like a standard Python dict, completely bypassing all mtdt-specific features. Use this as an escape hatch for migrating an existing string-keyed dict where you need a drop-in dictionary replacement.
store = MetadataStore()               # strict: Key instances only
loose = MetadataStore(strict=False)   # any hashable key, kwargs allowed

Bulk updates

update() validates everything before writing anything, so a rejected entry leaves the store untouched:

store.update({AUTHOR: "ada", VERSION: 1})
store.update([(AUTHOR, "ada")])

It rejects another MetadataStore as its argument — merging two stores would silently re-run validators against already-validated data and mix the strong and weak partitions. Use unchecked_update() when that is what you actually want:

store.unchecked_update(other_store)   # no type checks, no validators, no immutability

unchecked_update is deliberately unsafe. Reach for it for trusted internal copies and fast paths, nothing else.


Errors

All exceptions derive from Invalid, a dataclass exception carrying what / expected / got / note. The note is attached via add_note(), so it shows up in tracebacks.

Exception Also a Raised when
InvalidType TypeError Key is not a Key in strict mode, or not hashable in non-strict mode. Also for a non-callable validator argument.
InvalidValue RuntimeError A validator rejected the value.
InvalidUsage — Immutable key overwritten or deleted; non-weakref-able value under a weak key; MetadataStore passed to update(); kwargs passed to update() in strict mode.
KeyError — Standard mapping misses on __getitem__, pop, popitem.

Because InvalidType is a TypeError and InvalidValue is a RuntimeError, existing except TypeError handlers keep working.


When this is the right tool

  • Attaching metadata to objects you do not own, where you cannot add attributes.
  • Plugin or extension systems where several independent parties annotate the same object and must not collide.
  • Metadata with mixed lifetimes, where some entries should not keep their values alive.
  • Configuration or registry entries that must be written exactly once.
  • Anywhere a dict[str, Any] has already caused a collision or a type bug.

When it is not

  • As a general-purpose dict. Every operation adds validation overhead and key routing. If you just need a mapping, use a dict.
  • When keys must survive serialization. Identity-based keys do not round-trip through pickle or JSON: an unpickled key is a new object and will not match the original. use_identity=False helps, but the validator is compared by id(), so it is still fragile. Design around this rather than fighting it.
  • When you want real runtime type enforcement. expected_type is a hint, not a check. Generics, protocols, and nested containers are out of scope. Use pydantic, attrs, or beartype for that.
  • When the shape is known and fixed. A dataclass or TypedDict is clearer, faster, and better supported by tooling.
  • When key definitions cannot be shared. Identity keys must be importable from a single module by every consumer. If your producers and consumers only agree on strings, this model does not fit.

Known limitations

  • typing.Union and typing.Optional are not supported by validator inference. PEP 604 unions (int | str) are.
  • infer_instancecheck_validator correctly returns None for types that are not isinstance-safe (e.g., list[int]), meaning you must provide an explicit validator= or EmbeddedValidator for parameterized generics.
  • The repr of a store shows the weak store by address; CPython deliberately suppresses WeakValueDictionary contents during formatting to avoid triggering collection.

API surface

from mtdt import (
    Key, MetadataStore,
    key, key_factory,
    EmbeddedValidator,
    get_embedded_validator,
    set_instancecheck_validator,
    infer_instancecheck_validator,
)
from mtdt.exceptions import Invalid, InvalidType, InvalidUsage, InvalidValue

License

MIT.

Release files for mtdt 1.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 mtdt 1.1.0
File Size Uploaded
mtdt-1.1.0.tar.gz 24.1 kB Details

Built distribution (wheel)

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

Total release size: 39.4 kB

Release files / mtdt-1.1.0.tar.gz

Download URL mtdt-1.1.0.tar.gz
Size 24.1 kB
Tags Source
SHA-256 checksum
How to use checksums
77574d8d1d56e390b948fde0f41408674089e54ad4a5c436eb6d4aa4d7264ff3
BLAKE2b-256 checksum
How to use checksums
8841c3268002aeb1ee23e1ae14218dfd9bd78fe439e9af99fef85f69bc35b022
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / mtdt-1.1.0-py3-none-any.whl

Download URL mtdt-1.1.0-py3-none-any.whl
Size 15.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
367fb08ddb94ebfcab8742d6bd48e0673f1a257d02cabec457f56819b8779b79
BLAKE2b-256 checksum
How to use checksums
6ba070ca6f5f2640e8bb1e7c596e042ea122c6ed114d52ad1d3d069d723c70f8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

1.0.0

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