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
Keyinstances are accepted. This unlocks the full power ofmtdt, 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,mtdtacts exactly like a standard Pythondict, completely bypassing allmtdt-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
pickleor JSON: an unpickled key is a new object and will not match the original.use_identity=Falsehelps, but the validator is compared byid(), so it is still fragile. Design around this rather than fighting it. - When you want real runtime type enforcement.
expected_typeis a hint, not a check. Generics, protocols, and nested containers are out of scope. Usepydantic,attrs, orbeartypefor that. - When the shape is known and fixed. A dataclass or
TypedDictis 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.Unionandtyping.Optionalare not supported by validator inference. PEP 604 unions (int | str) are.infer_instancecheck_validatorcorrectly returnsNonefor types that are notisinstance-safe (e.g.,list[int]), meaning you must provide an explicitvalidator=orEmbeddedValidatorfor parameterized generics.- The
reprof a store shows the weak store by address; CPython deliberately suppressesWeakValueDictionarycontents 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)
| File | Size | Uploaded | |
|---|---|---|---|
| mtdt-1.1.0.tar.gz | 24.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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}
|