Annotation-native toolkit for type inspection, validation, and data modeling
Project description
TypeCraft
Annotation-native toolkit for type inspection, validation, and data modeling
- TypeCraft
Motivation
Type annotations in Python are an expressive, structured description of data, but most of that information is discarded at runtime. TypeCraft is a toolkit for putting it back to work. It treats annotations as first-class data, providing a small, composable set of layers that build on each other:
- A typing layer that wraps an annotation into a rich, introspectable container, with
isinstance-like andissubclass-like checks that honor generics, unions, andLiteral[]. - A conversion layer that uses these annotations to drive validation (loose Python objects → typed Python objects) and serialization (typed Python objects → JSON-compatible primitives), with a registry of user-defined converters and full support for nested generics.
- A modeling layer (
BaseModel) that turns ordinary dataclasses into validated models with field- and type-level converters, aliases, and configurable behavior — without metaclass shenanigans. - A TOML extra that combines the modeling layer with
tomlkitto give a typed, mutable, round-trippable interface for TOML documents.
Each layer is usable on its own. You can use Annotation purely as a typing utility, or validate() and serialize() as standalone functions, without ever touching BaseModel or the TOML extra.
Getting started
Install using pip:
pip install typecraft
To use the TOML extra:
pip install typecraft[toml]
Inspecting annotations
The Annotation class is the core typing primitive. It wraps any annotation (including aliases, generics, unions, Literal[], Annotated[], and callables) and exposes a uniform interface for inspecting and reasoning about it.
The Annotation container
from typing import Literal
from typecraft import Annotation
# basic types
a = Annotation(int)
assert a.raw is int
assert a.concrete_type is int
# generic types
a = Annotation(list[int])
assert a.origin is list
assert a.concrete_type is list
assert a.arg_annotations[0].raw is int
# unions
a = Annotation(int | str)
assert a.is_union
assert [arg.raw for arg in a.arg_annotations] == [int, str]
# literals
a = Annotation(Literal["a", "b", "c"])
assert a.is_literal
assert a.args == ("a", "b", "c")
# type aliases are unwrapped
type IntList = list[int]
a = Annotation(IntList)
assert a.origin is list
assert a.arg_annotations[0].raw is int
Annotation instances are cached by identity, which makes recursive type aliases safe to traverse:
type RecursiveAlias = list[RecursiveAlias] | int
a = Annotation(RecursiveAlias)
list_ann, int_ann = a.arg_annotations
# the inner list[RecursiveAlias] is the same Annotation object as `a`
assert list_ann.arg_annotations[0] is a
Instance and subtype checks
The two most common questions about an annotation are "does this object satisfy it?" (an isinstance-like check) and "is this annotation narrower than that one?" (an issubclass-like check). TypeCraft exposes both, with full awareness of generics, unions, and Literal[]:
from typing import Any, Literal
from typecraft import is_instance, is_narrower
# check if an object satisfies an annotation
assert is_instance(1, int | str)
assert is_instance([1, 2, "3"], list[int | str])
assert not is_instance([1, 2, "3"], list[int])
assert is_instance("a", Literal["a", "b", "c"])
# check if one annotation is narrower (more specific) than another
assert is_narrower(int, int | str)
assert is_narrower(list[int], list[int | str])
assert is_narrower(Literal["a"], Literal["a", "b"])
# Any is both the top type and the bottom type
assert is_narrower(int, Any)
assert is_narrower(Any, int)
These functions accept either a raw annotation or an Annotation instance, so they're equally usable in throwaway checks and in code that already has an Annotation in hand.
Working with Annotated[] metadata
Annotation automatically splits Annotated[] into the underlying type and its extras, exposing both:
from dataclasses import dataclass
from typing import Annotated
from typecraft import Annotation
@dataclass
class Unit:
name: str
a = Annotation(Annotated[float, Unit("meters"), "positive"])
# the wrapped type
assert a.raw is float
assert a.concrete_type is float
# extras preserved as a tuple in declaration order
units = [e for e in a.extras if isinstance(e, Unit)]
assert units[0].name == "meters"
assert "positive" in a.extras
This is the same machinery that the validation and serialization layers use to discover converters declared inline as Annotated[T, ...] extras (see below).
Validation and serialization
The validation and serialization layers are two faces of the same conversion engine. Both walk an annotation, dispatching to type-based converters at each level.
- Validation moves loose data (e.g. JSON, kwargs) towards a typed Python representation.
- Serialization moves a typed Python representation back to JSON-compatible primitives (
str,int,float,bool,None,list,dict).
validate()
In strict mode, validate() only accepts objects that already match the target annotation; it fails otherwise. With strict=False, builtin coercions kick in:
from typing import Annotated
from typecraft import validate
from typecraft.validating import ValidationParams
# strict mode (the default): no conversions
assert validate([1, 2, 3], list[int]) == [1, 2, 3]
# loose mode: builtin coercions
result = validate(["1", "2", 3], list[int], params=ValidationParams(strict=False))
assert result == [1, 2, 3]
# arbitrarily nested generics are walked recursively
result = validate(
[[("1", "2"), ("3", "4")], [("5", "6")]],
list[list[list[int]]],
params=ValidationParams(strict=False),
)
assert result == [[[1, 2], [3, 4]], [[5, 6]]]
# Annotated[] is transparent
result = validate(
["1", "2", "3"],
Annotated[list[int], "positive integers"],
params=ValidationParams(strict=False),
)
assert result == [1, 2, 3]
When validation fails, all errors found in the object tree are aggregated into a single ValidationError with a path-aware message:
from typecraft import validate, ValidationError
try:
validate([1, 2, "3"], list[str | float])
except ValidationError as e:
print(e)
2 validation errors for list[str | float]
[0]=1: int -> str | float: TypeError
Errors during union member conversion:
str: No matching converters
float: No matching converters
[1]=2: int -> str | float: TypeError
Errors during union member conversion:
str: No matching converters
float: No matching converters
serialize()
serialize() walks an object and produces a JSON-compatible value: str, int, float, bool, None, or a list/dict of the same. Builtin types like tuple, set, date, and datetime are converted automatically:
import datetime
from typecraft import serialize
assert serialize((1, 2, 3)) == [1, 2, 3]
assert sorted(serialize({1, 2, 3})) == [1, 2, 3]
assert serialize({"a": [1, 2], "b": [3, 4]}) == {"a": [1, 2], "b": [3, 4]}
# datetimes are serialized to ISO-8601 strings
assert serialize(datetime.date(2026, 1, 1)) == "2026-01-01"
By default, the source type is inferred from the object. Pass source_type to influence dispatch. For example, when a fixed-length tuple[int, str] should be matched by a converter declared on that exact type rather than the generic tuple[Any, ...].
Custom type-based converters
The conversion engine is driven by a registry of converters. A TypeValidator is a function (or callable) that converts an object of one type to another, paired with declarative match rules:
from typecraft import validate
from typecraft.validating import TypeValidator
class Celsius:
degrees: float
def __init__(self, degrees: float):
self.degrees = degrees
# convert from float to Celsius
celsius_validator = TypeValidator(float, Celsius, func=lambda d: Celsius(d))
result = validate(20.0, Celsius, celsius_validator)
assert isinstance(result, Celsius)
assert result.degrees == 20.0
Converters work just as well on parameterized types:
from typecraft import validate
from typecraft.validating import TypeValidator
# only convert lists of positive ints
positive_validator = TypeValidator(
list[int],
list[str],
func=lambda obj: [str(o) for o in obj],
predicate_func=lambda obj: all(o > 0 for o in obj),
)
assert validate([1, 2, 3], list[str], positive_validator) == ["1", "2", "3"]
Validators can be passed individually to validate() or grouped into a TypeValidatorRegistry for reuse. The same applies symmetrically to TypeSerializer and TypeSerializerRegistry.
Converters can also be attached inline using Annotated[]:
from typing import Annotated
from typecraft import serialize, validate
from typecraft.validating import TypeValidator
from typecraft.serializing import TypeSerializer
class MyClass:
val: int
def __init__(self, val: int):
self.val = val
MY_CLASS_VALIDATOR = TypeValidator(int, MyClass, func=lambda obj: MyClass(obj))
MY_CLASS_SERIALIZER = TypeSerializer(MyClass, int, func=lambda obj: obj.val)
type MyClassType = Annotated[MyClass, MY_CLASS_VALIDATOR, MY_CLASS_SERIALIZER]
# validation discovers the inline validator
validated = validate([0, 1, 2], list[MyClassType])
assert all(isinstance(o, MyClass) for o in validated)
# serialization discovers the inline serializer
assert serialize(validated, source_type=list[MyClassType]) == [0, 1, 2]
Plain and predicate validators
Two lighter-weight validator forms run at the annotation level itself, without matching based on type:
PredicateValidatoraccepts the object if a boolean function returnsTrue, and raises otherwise.PlainValidatorruns an arbitrary function; its return value replaces the object, and exceptions become validation errors.
from typing import Annotated
from typecraft import validate
from typecraft.validating import PlainValidator, PredicateValidator
# predicate
positive = PredicateValidator(lambda x: x > 0)
assert validate([1, 2, 3], list[Annotated[int, positive]]) == [1, 2, 3]
# transformer (mode="before" runs prior to type-based validation)
def parse_int(val: object) -> int:
if isinstance(val, str):
return int(val.strip())
if isinstance(val, int):
return val
raise TypeError(f"cannot parse {type(val).__name__}")
stripped = PlainValidator(parse_int, mode="before")
assert validate([" 1 ", " 2", "3 "], list[Annotated[int, stripped]]) == [1, 2, 3]
Validator library
For common validation tasks, typecraft.lib provides ready-made BaseValidator subclasses:
from typing import Annotated
from typecraft import validate
from typecraft.lib import EmailValidator, IntValidator, StrValidator
# numeric bounds
type PortType = Annotated[int, IntValidator(gt=0, lt=65536)]
assert validate(8080, PortType) == 8080
# string length bounds
type ShortStrType = Annotated[str, StrValidator(min_len=1, max_len=64)]
assert validate("hello", ShortStrType) == "hello"
# email pattern
type EmailType = Annotated[str, EmailValidator()]
assert validate("user@example.com", EmailType) == "user@example.com"
Build your own by subclassing BaseValidator[T] and implementing validate().
Symmetric converters
When validation and serialization are symmetric, BaseSymmetricTypeConverter lets you express both in a single class:
from typecraft.converting.converter.symmetric import BaseSymmetricTypeConverter
from typecraft.serializing import SerializationFrame
from typecraft.validating import ValidationFrame
class RangeConverter(BaseSymmetricTypeConverter[list[int], range]):
"""
`range` <-> `[start, stop, step]` list.
"""
@classmethod
def can_validate(cls, obj: list[int]) -> bool:
return 1 <= len(obj) <= 3
@classmethod
def validate(cls, obj: list[int], frame: ValidationFrame) -> range:
return range(*obj)
@classmethod
def serialize(cls, obj: range, frame: SerializationFrame) -> list[int]:
return [obj.start, obj.stop, obj.step]
# extract the validator/serializer
validator = RangeConverter.as_validator()
serializer = RangeConverter.as_serializer()
Type parameters serve as the source/target types for the validator and serializer, so you don't have to repeat them.
Adapter: bidirectional convenience
For ad-hoc validation and serialization of a specific type, Adapter packages both directions and an optional pair of registries into a single object:
from typecraft.adapter import Adapter
from typecraft.serializing import TypeSerializerRegistry
from typecraft.validating import TypeValidatorRegistry
adapter = Adapter(
range,
validator_registry=TypeValidatorRegistry(RangeConverter.as_validator()),
serializer_registry=TypeSerializerRegistry(RangeConverter.as_serializer()),
)
assert adapter.validate([0, 10]) == range(0, 10)
assert adapter.serialize(range(10)) == [0, 10, 1]
Models
BaseModel brings the conversion machinery onto a class. A model is a regular @dataclass(kw_only=True) under the hood — no custom metaclass — with field- and type-level validation, serialization, and aliasing layered on top.
Defining a model
from typecraft import BaseModel, ModelConfig
from typecraft.validating import ValidationParams
class Person(BaseModel):
name: str
age: int = 0
class Team(BaseModel):
# opt into coercion for nested validation
model_config = ModelConfig(default_validation_params=ValidationParams(strict=False))
name: str
members: list[Person]
# nested models can be constructed directly...
team = Team(name="Eng", members=[Person(name="Alice", age=30)])
# ...or from plain dicts, which get validated recursively
team = Team(name="Eng", members=[{"name": "Alice", "age": "30"}])
assert team.members[0].age == 30
Validation errors at every level of nesting are aggregated into a single ValidationError with a path to each problem.
Loading and dumping
model_validate() builds an instance from a mapping, and model_serialize() produces a JSON-compatible dictionary:
data = {"name": "Eng", "members": [{"name": "Alice", "age": 30}]}
team = Team.model_validate(data)
assert team.members[0].name == "Alice"
dump = team.model_serialize()
assert dump == {"name": "Eng", "members": [{"name": "Alice", "age": 30}]}
Field validators and serializers
Use @field_validator and @field_serializer to attach custom logic to specific fields. Both decorators support a mode argument: "before" runs prior to type-based conversion, "after" runs once the value is the right type.
from typecraft import BaseModel, field_serializer, field_validator
class Account(BaseModel):
username: str
tags: set[str]
@field_validator("username", mode="before")
@classmethod
def normalize_username(cls, obj: object) -> object:
return obj.strip().lower() if isinstance(obj, str) else obj
@field_validator("username", mode="after")
@classmethod
def check_length(cls, obj: str) -> str:
if not (3 <= len(obj) <= 32):
raise ValueError("username must be 3-32 chars")
return obj
@field_serializer("tags")
def sort_tags(self, obj: set[str]) -> list[str]:
return sorted(obj)
acct = Account(username=" Alice ", tags={"admin", "active"})
assert acct.username == "alice"
assert acct.model_serialize() == {"username": "alice", "tags": ["active", "admin"]}
Omit field names to apply the validator/serializer to every field. Validators may take an optional ValidationInfo parameter to access the FieldInfo, the validation frame, and any user-defined context:
from typecraft import BaseModel, field_validator, validate
from typecraft.model.methods import ValidationInfo
class Offset(BaseModel):
value: int
@field_validator
def shift(self, obj: object, info: ValidationInfo) -> object:
if isinstance(obj, int) and info.frame.context is not None:
return obj + info.frame.context
return obj
# context is propagated through validate()
offset = validate({"value": 10}, Offset, context=5)
assert offset.value == 15
Type-based validators and serializers
To attach TypeValidators or TypeSerializers scoped to a model (or a subset of its fields), use @type_validators / @type_serializers:
from typing import Any
from typecraft import BaseModel, type_serializers, type_validators
from typecraft.validating import TypeValidator
from typecraft.serializing import TypeSerializer
class MyInt(int):
pass
class Container(BaseModel):
raw: int
custom: MyInt
@type_validators("custom")
@classmethod
def validators(cls) -> tuple[TypeValidator[Any, Any], ...]:
return (TypeValidator(int, MyInt, func=lambda obj: MyInt(obj)),)
@type_serializers
@classmethod
def serializers(cls) -> tuple[TypeSerializer[Any, Any], ...]:
return (TypeSerializer(MyInt, int, func=lambda obj: int(obj)),)
c = Container(raw=1, custom=2)
assert isinstance(c.custom, MyInt)
assert c.model_serialize() == {"raw": 1, "custom": 2}
Pass field names to scope a converter to specific fields, or omit them to apply it to all fields.
Aliases
Field(alias=...) lets a model use a Pythonic field name internally while reading and writing a different key in serialized form. Pass by_alias=True to opt into the alias for either direction:
from typecraft import BaseModel, Field
from typecraft.validating import ValidationParams
from typecraft.serializing import SerializationParams
class Config(BaseModel):
api_key: str = Field(alias="api-key")
# load using the alias
cfg = Config.model_validate({"api-key": "secret"}, params=ValidationParams(by_alias=True))
assert cfg.api_key == "secret"
# dump using the alias
dump = cfg.model_serialize(params=SerializationParams(by_alias=True))
assert dump == {"api-key": "secret"}
Validate on assignment
By default, validation runs only at construction time. Set validate_on_assignment=True to revalidate on every attribute assignment:
from typecraft import BaseModel, ModelConfig, ValidationError
class Strict(BaseModel):
model_config = ModelConfig(validate_on_assignment=True)
count: int = 0
s = Strict()
s.count = 5
try:
s.count = "5" # type: ignore
except ValidationError:
pass
Forbidding extra fields
By default, extra fields passed to a model are silently ignored. Set extra="forbid" to raise on them:
from typecraft import BaseModel, ModelConfig, ValidationError
class Strict(BaseModel):
model_config = ModelConfig(extra="forbid")
name: str
try:
Strict.model_validate({"name": "alice", "rogue": True})
except ValidationError as e:
print(e)
TOML extra
The typecraft.extras.toml module layers TypeCraft's modeling on top of tomlkit to provide a typed, mutable, round-trippable interface for TOML documents. Field assignments are propagated to the underlying tomlkit tree, so item-level details like array multiline-ness, comments, and key ordering are preserved when the document is dumped.
Documents and tables
Subclass BaseDocument for the top-level document and BaseTable / BaseInlineTable for nested tables. Field types may be Python primitives, tomlkit item types, or other wrapper subclasses:
from tomlkit.items import Integer, String
from typecraft import Field
from typecraft.extras.toml import BaseDocument, BaseInlineTable, BaseTable
class ServerTable(BaseTable):
host: String
port: Integer
class CredentialsInline(BaseInlineTable):
user: str
password: str
class Config(BaseDocument):
name: String
server: ServerTable = Field(alias="server")
credentials: CredentialsInline
optional_note: str | None = None
Arrays
Use ArrayWrapper[T] for arrays of primitive or inline-table items, and AoTWrapper[T] for arrays of standalone tables:
from typecraft.extras.toml import AoTWrapper, ArrayWrapper, BaseDocument, BaseTable
from tomlkit.items import String
class Endpoint(BaseTable):
path: String
method: String
class API(BaseDocument):
allowed_ports: ArrayWrapper[int]
grid: ArrayWrapper[ArrayWrapper[int]]
endpoints: AoTWrapper[Endpoint]
The wrappers behave like ordinary MutableSequences — iterate, index, append, slice-assign, and so on. Mutations propagate to the underlying tomlkit array.
Round-tripping
Loading parses with tomlkit and validates the result against your model. Dumping emits the wrapped tomlkit document, so any formatting that came in is preserved on the way out:
from pathlib import Path
config = Config.loads("""\
name = "my-service"
credentials = {user = "admin", password = "hunter2"}
[server]
host = "0.0.0.0"
port = 8080
""")
assert config.server.port == 8080
# mutate freely; the underlying tomlkit document tracks changes
config.server.port = 9090
config.optional_note = "patched"
# dump preserves the original structure plus our edits
print(config.dumps())
# or write straight to a file
config.dump(Path("config.toml"))
Setting an Optional field to None removes the corresponding key from the document, and assigning a wrapper instance plugs it into the same tomlkit tree:
new_server = ServerTable(host="127.0.0.1", port=8000)
config.server = new_server
# the new table is now part of the same document
assert config.tomlkit_obj["server"]["port"] == 8000
config.optional_note = None
assert "optional_note" not in config.tomlkit_obj
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file typecraft-0.2.0.tar.gz.
File metadata
- Download URL: typecraft-0.2.0.tar.gz
- Upload date:
- Size: 49.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","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":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c2a4c2c8224e3809d58d2405f21a8d0e9c3986f4c7912abbd15304ba501d64c9
|
|
| MD5 |
a0be6ff5a3e6a24262d1eb8edeede9b1
|
|
| BLAKE2b-256 |
9d46c27e6407a4df396abadf3d06d4062ceb76fee4961bca77a9e43a8e57f311
|
File details
Details for the file typecraft-0.2.0-py3-none-any.whl.
File metadata
- Download URL: typecraft-0.2.0-py3-none-any.whl
- Upload date:
- Size: 64.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.6 {"installer":{"name":"uv","version":"0.11.6","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":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
89b8e064e40d5d482902ea46a415832d03e9de87aed34d491186c02e247565b6
|
|
| MD5 |
61d1bca2571213531b2bf647932b6e77
|
|
| BLAKE2b-256 |
92f78787efcbc95747813ee9be81ad0aa084bb3337717aaa0e7adcf2aa5c77c9
|