Input boundaries¶
Talea separates already-typed Python construction from external data. The boundary is explicit in the API, so adding parsing does not add a mode branch to ordinary construction:
| Operation | Intended input | Conversion policy | Field errors |
|---|---|---|---|
User(...) |
Already-valid Python values | Strict; only declared transforms | Fail fast |
User.from_mapping(data) |
Untrusted Python Mapping |
Strict Python types plus nested Mapping-to-Spec construction | Aggregate independent fields |
User.from_json(data) |
Serialized JSON | Decode JSON representations according to the declared schema | Aggregate independent fields |
flowchart LR
PythonValues[Python values] --> Constructor[Generated constructor]
MappingInput[Untrusted Mapping] --> MappingBoundary[Compiled Mapping boundary]
JsonInput[JSON text or bytes] --> Codec[Selected JSON decoder]
Codec --> JsonBoundary[Compiled JSON boundary]
Constructor --> Validation[Canonical validation emission]
MappingBoundary --> Validation
JsonBoundary --> Validation
Validation --> Instance[Immutable Spec]
The decoder owns JSON syntax only. Talea's canonical schema and validation emitter continue to own types, constraints, hooks, error locations, nested trust, and immutable slot commitment.
An application may explicitly derive a narrower input contract from normalized field metadata:
from typing import Annotated
from talea import ReadOnly, Spec, derive_spec
class User(Spec):
id: Annotated[int, ReadOnly()]
name: str
UserInput = derive_spec(User, mode="input")
request = UserInput.from_json('{"name":"Ada"}')
The derived class has no effective ReadOnly fields, so canonical names and
aliases for those fields are both unexpected at Mapping and JSON boundaries.
This does not change User.from_mapping(), User.from_json(), or trusted
User(...) construction. Directional derivation is shallow; nested contracts
must be selected explicitly when an endpoint needs nested input views.
Constructing from a Mapping¶
from_mapping is the one Python external-data API:
from types import MappingProxyType
from talea import Spec
class Address(Spec):
city: str
class User(Spec):
identifier: int
name: str
address: Address
user = User.from_mapping(
MappingProxyType(
{
"identifier": 1,
"name": "Ada",
"address": {"city": "Zurich"},
}
)
)
The top level accepts collections.abc.Mapping, including dict, mapping
proxies, and deliberate custom implementations. Keys use the declared Alias
when present and otherwise use the exact Python field name. Canonical and alias
spellings are not both accepted, preserving one unambiguous external contract.
Silent extra-field ignoring is not available. A non-string key is unexpected.
An explicit Representation(input=..., load=...) changes only the annotated
custom-type position: Talea validates the external value against input=,
calls the synchronous loader once, and validates its result against the strict
internal schema. It composes beneath the same objects and containers and shares
the operation's ResourcePolicy traversal state. See Custom domain
representations; ordinary fields retain the rules
on this page.
Python values remain strict. For a field declared as int, the string "20"
fails. A list does not become a tuple, set, or frozenset; UUID, date,
path, IP, Enum, and Decimal strings do not become their Python types. Declare a
transform when a particular Python boundary intentionally accepts another
representation.
A Mapping supplied where a Spec is declared constructs that nested Spec. This also works beneath supported lists, dictionaries, tuples, sets, frozensets, and unions when the containing Python container already has its declared type. Locations compose through every boundary. An existing compatible Spec is retained by identity and receives the established permanent or current-state trust behavior. A nested Spec created successfully during the same operation is not immediately revalidated.
Boundary conversion may rebuild a container when one of its members must become
a nested Spec. Applications should not use from_mapping to establish container
identity; direct Spec(...) construction remains the identity-preserving
already-Python path.
A Mapping supplied for a dataclass Contract follows the same structural
conversion rules and calls the original dataclass constructor exactly once.
Only init=True fields are accepted. Stdlib defaults and factories remain
constructor-owned, __post_init__ runs normally, and Talea then validates all
stored state including init=False fields. Existing exact dataclass instances
preserve identity; arbitrary attribute-bearing objects and subclasses are not
treated as mappings. See Standard-library dataclasses.
Missing, unexpected, and aggregated errors¶
Boundary failures use the same ValidationError and errors() projection as
ordinary construction. Missing required fields use missing; unknown keys use
unexpected. Defaults and factories mean a field is not missing.
Independent field problems are returned together in observable order:
- declared fields in canonical declaration order;
- each missing, structural, constraint, transform, or field-check failure at its declared position;
- unexpected keys in the Mapping's encounter order.
from typing import Annotated
from talea import Ge, Spec, ValidationError
class Registration(Spec):
identifier: int
name: str
age: Annotated[int, Ge(18)]
try:
Registration.from_mapping(
{
"identifier": "bad",
"age": 15,
"extra": True,
}
)
except ValidationError as exc:
assert [(item["code"], item["location"]) for item in exc.errors()] == [
("type", ["identifier"]),
("missing", ["name"]),
("greater_than_or_equal", ["age"]),
("unexpected", ["extra"]),
]
The error list is allocated only after the first failure. A failure inside one container or compact union remains that field's structural failure; Talea does not build an unlimited combinatorial error tree.
Whole-Spec checks run only after every field is valid. Default factories are also delayed until supplied fields and the external key set are valid, so a missing required field, invalid supplied field, or unexpected key cannot cause unnecessary user code to run. Once that phase succeeds, omitted factories run in declaration order exactly once and their outputs follow the normal transform, structure, constraint, and field-check lifecycle.
JSON input¶
from_json accepts str, bytes, and bytearray:
from datetime import datetime
from uuid import UUID
from talea import Spec
class Event(Spec):
identifier: UUID
occurred_at: datetime
coordinates: tuple[float, float]
event = Event.from_json(
b'''{
"identifier": "00000000-0000-0000-0000-000000000000",
"occurred_at": "2026-08-26T12:30:00+00:00",
"coordinates": [47.3769, 8.5417]
}'''
)
The default decoder is json from the standard library with three deliberate
policies:
- duplicate object keys raise
json_duplicaterather than silently keeping a later value; NaN,Infinity, and-Infinityraisejson_invalid;- fractional number tokens are initially preserved as
Decimal, preventing a Decimal field from receiving an already-rounded float.
The compiled input function then converts the decoded representation according to each field's canonical schema. A float field receives a finite Python float; a Decimal field receives the exact token value. A custom decoder that returns a float for a Decimal field is rejected rather than converted through a lossy path.
JSON representation table¶
| Declared contract | Accepted inbound JSON representation | Result |
|---|---|---|
int, str, bool, None, Literal primitives |
Corresponding JSON primitive | Same strict primitive |
float |
Finite JSON number | Python float; integer tokens are accepted |
Decimal |
Finite JSON number or Decimal string | Exact Decimal; no float intermediate on the default path |
bytes |
Strict padded RFC 4648 base64 string | Bytes |
list[T] |
Array | List |
tuple[...], tuple[T, ...] |
Array | Tuple |
set[T], frozenset[T] |
Array | Set or frozenset; duplicate members collapse normally |
dict[str, T] |
Object | Dictionary; object keys remain strings |
nested Spec |
Object | Nested Spec |
| nested stdlib dataclass | Object | Original dataclass instance after its constructor lifecycle |
UUID |
String | UUID |
datetime, date, time |
ISO string accepted by the corresponding fromisoformat |
Temporal value |
| supported path types | String | Declared nominal path family |
| supported IP address/network/interface | String | Exact declared IP family |
| Enum with a JSON-compatible value | That exact value and primitive type | Declared Enum member |
timedelta |
Exact ISO 8601 duration string | Timedelta at microsecond resolution |
Boolean and integer identity remains distinct in Literals and Enum values. JSON object keys are not coerced to integer, UUID, or other dictionary key types. Malformed standard-library strings retain the field's normal structural error and exact location.
For a union, alternatives are considered in declaration order and the first alternative whose boundary conversion and canonical validation succeed wins.
Transforms and checks¶
Both external boundaries use one understandable lifecycle:
- obtain the raw Python value or decoded JSON-native value;
- run declared transforms in order;
- perform boundary-specific nested or JSON representation conversion;
- run canonical structural validation and constraints;
- run field checks;
- after all fields succeed, run whole-Spec checks and commit slots.
A JSON transform therefore sees the decoded representation. For a UUID string,
it sees str; its output then enters Talea's UUID conversion. This matches the
Mapping lifecycle and gives an explicitly declared transform the earliest
application-controlled input point. Every transform and check runs once for a
successfully constructed value.
Selecting another JSON decoder¶
Pass a one-argument decoder explicitly per operation:
Talea does not import or depend on orjson; the example requires the
application to install it. A decoder callable must accept the supplied str,
bytes, or bytearray and return a JSON-native tree of dictionaries, lists,
strings, integers, floats, booleans, and None. Extended returned Python values
are accepted only if the declared boundary semantics validate them.
A custom decoder owns syntax behavior. If it raises ValueError, Talea reports
json_invalid; the decoder exception is retained as the cause for bounded
inputs and omitted for large inputs. Other exceptions propagate as decoder or
application defects. Once a decoder has discarded
duplicate-key evidence, Talea cannot reconstruct it; applications requiring
duplicate rejection must select a decoder configured to provide that guarantee.
Likewise, a decoder used with Decimal fields must preserve fractional values as
Decimal rather than float.
There is no global mutable codec registry or process-wide JSON setting. The
callable is not stored on the Spec declaration or instance. Outbound JSON uses
the symmetric per-call dumps shape without replacing this input abstraction.
See Serialization and JSON output.
Resource policy¶
Every untrusted Mapping and JSON operation uses an immutable
ResourcePolicy. Talea's default is equivalent to:
from talea import ResourcePolicy
policy = ResourcePolicy(
max_input_bytes=8 * 1024 * 1024,
max_depth=64,
max_nodes=100_000,
max_errors=100,
)
Pass policy=policy to Spec.from_mapping() or Spec.from_json(). Each field
also accepts None, which disables only that dimension. There is no setter or
process-global override.
max_input_bytes is the encoded JSON transport size. Byte inputs use exact
length. Text uses UTF-8 byte length without allocating an encoded copy. The
check happens before the default or custom decoder. A scalar root has depth
zero; a root container has depth one; each nested container adds one.
max_nodes counts compiled schema visits, including scalar values and union
branches actually attempted. A tagged union visits only its selected branch.
When depth or work exceeds its limit, ResourceLimitError exposes code,
limit, and observed without retaining the input. Error aggregation instead
returns the first max_errors failures in canonical order and sets
ValidationError.truncated to True; traversal terminates at that point.
These are work limits, not business-schema constraints. Talea deliberately has
no separate string, bytes, integer, Decimal, or per-container size setting.
Use MaxLength or an application transport rule when the data model requires
one. Python 3.14's integer-string conversion limit still applies to JSON integer
tokens. A single node budget covers broad containers without overlapping limit
meanings.
Malformed input and parser limits¶
Default malformed JSON raises ValidationError with json_invalid. When the
standard decoder provides them, context contains line, column, and
position; these describe the serialized source and are separate from Talea's
field location. Invalid UTF-8 reports byte offsets and the decoder reason.
Error projections bound the displayed input. Small standard parser failures are
retained as __cause__; large documents do not retain the decoder exception
that would otherwise keep the complete source alive.
The transport limit bounds what reaches the selected parser. Talea cannot bound
CPU or allocation inside a custom decoder, custom Mapping method, transform,
check, factory, or regular expression; those are trusted application code.
Non-ValueError decoder exceptions and custom Mapping failures propagate rather
than being mislabeled as field failures. Applications still own concurrency,
deadlines, process isolation, and any stricter limit enforced before a complete
payload reaches Talea.
Performance model¶
Each Spec declaration retains a small boundary owner but does not compile its
Mapping or decoded-JSON function until that operation is first used. The two
functions are independent: using from_mapping does not compile JSON support.
First-use installation is synchronized; repeated calls bypass the lock and
perform no annotation reflection or schema traversal. This keeps ordinary class
declaration and the generated Spec(...) constructor free of Mapping
extraction, codec selection, missing/unexpected checks, and aggregation state.
Complete exact dictionaries with required fields use direct constant-key extraction before the general Mapping path. Missing or unexpected shapes fall back before transforms or factories run, preserving aggregation and once-only callback behavior.
JSON timings should be separated into decoder-only, Talea boundary-only, and full JSON-to-Spec work. A faster optional decoder changes the first category; it does not replace or weaken Talea validation. No codec or input metadata is stored per Spec instance.