Skip to content

Positional NamedTuple contracts

typing.NamedTuple is useful when a Python application and its wire protocol agree that field order is part of the contract. Talea preserves that choice: the Python value keeps its nominal NamedTuple type, while every external and serialized representation is positional.

from typing import NamedTuple

from talea import Contract


class Coordinate(NamedTuple):
    latitude: float
    longitude: float


coordinates = Contract(Coordinate)
value = coordinates.from_json("[47.37,8.54]")

assert type(value) is Coordinate
assert coordinates.to_python(value) == (47.37, 8.54)

{"latitude": 47.37, "longitude": 8.54} is not an alternative representation. Choose a Spec, stdlib dataclass, or TypedDict when names belong on the external boundary.

Boundary semantics

Operation NamedTuple behavior
validate() requires the exact declared type and complete declared arity, validates each stored slot, preserves identity
from_python() accepts exact list or exact tuple, converts slots, invokes the declared constructor once
from_json() accepts a JSON array after strict JSON decoding
to_python() returns an ordinary tuple containing recursively projected values
to_json() returns a JSON array
json_schema() projects a named Draft 2020-12 array definition
openapi_schema() reuses the same array Schema Object under OpenAPI components

A plain tuple or list fails strict validation even when its contents match. External conversion accepts only the two concrete built-in positional families; strings, bytes, mappings, custom sequences, and tuple/list subclasses are not silently traversed. This keeps the boundary predictable and avoids invoking arbitrary sequence protocol methods.

Slot failures use zero-based integer locations. A bad second slot is located at [1]; a bad value nested inside that slot continues from [1, ...]. Field names remain available for introspection and documentation, but they are not Mapping keys or error-path segments for this positional boundary.

Defaults and arity

NamedTuple defaults follow Python's trailing-default rule:

class Record(NamedTuple):
    required: int
    optional: str = "default"


records = Contract(Record)
assert records.from_python([1]) == Record(1, "default")
assert records.from_python([1, "set"]) == Record(1, "set")

This contract accepts lengths one and two. An empty array reports missing at index 0; a third item reports unexpected at index 2. Nullable types affect the value in an occupied slot and do not make that position omittable.

Defaults are captured as canonical schema truth, validated when the Contract is created, and validated again when an omitted position uses them. This matters for the unusual but legal case of a mutable default whose contents change after compilation. Talea neither copies nor recomputes a static NamedTuple default.

JSON Schema uses ordered prefixItems, items: false, minItems equal to the required slot count, and maxItems equal to the total slot count. A safe non-sensitive, serializable trailing default is attached to its own prefixItems entry. prefixItems alone does not assert tuple length; the explicit minimum and maximum encode the same arity law used by conversion.

Metadata and composition

Field annotations use the normal Talea resolver. Constraints, Sensitive, descriptive schema metadata, and Representation therefore work inside a slot. A Representation can, for example, retain Decimal internally while accepting and emitting exact decimal text in the array.

Alias, including legacy names, is rejected because a positional slot has no external property name. ReadOnly and WriteOnly are also rejected: omitting an interior array position would create sparse directional semantics that Talea does not define. NamedTuple has no Talea serializer or validation hooks; enclosing Specs retain ownership of their own hooks.

NamedTuple fields compose beneath Specs, dataclasses, TypedDicts, sequences, mappings, ordinary unions, callable boundaries, Settings fields, incremental Contract input, and JSONL input. A branch in a tagged union may contain a NamedTuple, but a NamedTuple cannot itself become the object-property discriminator owner.

Nested output selection treats a NamedTuple as one leaf. Selecting the enclosing field is supported; descending by field name or index is not. The selection grammar remains canonical-name object selection and gains no index language.

Generics, recursion, and identity

Concrete generic specializations resolve each slot through the existing type parameter owner:

from typing import Generic, TypeVar

T = TypeVar("T")


class Pair(NamedTuple, Generic[T]):
    first: T
    second: T


assert Contract(Pair[int]).from_python([1, 2]) == Pair(1, 2)

An open Contract(Pair) is rejected rather than treating unresolved parameters as Any. PEP 695 aliases containing concrete NamedTuple types compose normally. Module-resolvable recursive and mutually recursive declarations reuse finite named references. A function-local forward-reference name that has already left scope remains unrecoverable; Talea does not inspect caller frames or add a namespace registry.

Two separately declared NamedTuple classes do not collapse merely because they have equal slot shapes. Declaration identity and generic arguments participate in the same named-schema identity owner used by other recursive structures.

Canonical schema and introspection

Annotation resolution creates one frozen NamedTupleSchema with the exact declared type, ordered NamedTupleField values, slot schemas, defaults, required count, and named identity. Strict validation, input, output, standards projection, recursion, resource accounting, and inspect_contract() all consume that node. Warm execution does not reread __annotations__, _fields, or _field_defaults.

from talea.introspection import inspect_contract
from talea.schema import NamedTupleSchema

info = inspect_contract(Contract(Record))
assert isinstance(info.schema, NamedTupleSchema)
assert tuple(field.name for field in info.schema.fields) == ("required", "optional")
assert info.schema.required_count == 1
assert info.schema.fields[1].default == "default"

The two schema-domain types are public from talea.schema, not the root package. There is no parallel rich NamedTuple introspection hierarchy, and the projection contains no generated functions, callbacks, locks, or caches.

Construction and trust

Supported declarations must retain Python's standard generated NamedTuple constructor shape. Talea validates or converts all positions first, then calls that constructor exactly once. A declaration with an incompatible mutated __new__ is rejected when its schema is resolved. Ordinary user methods are neither inspected nor executed by contract operations.

The outer tuple is immutable, but a slot may contain a mutable list, dictionary, dataclass, or Spec graph. Strict validation and output revalidate such current state instead of granting transitive trust. External traversal shares the existing ResourcePolicy depth, node, and error accounting; runtime cycles follow the normal cycle policy. Sensitive redacts Talea-owned failures, but a successful output operation is still application-authorized disclosure.

Talea never calls _asdict() and introduces no NamedTuple registry. Annotation evaluation remains trusted declaration-time Python work. Use the narrow list/tuple external boundary when positional records are part of the protocol; use an object-shaped owner when field-name evolution, aliases, sparse views, or property discriminators are requirements.

Complete market-data example

The executable example uses nested price levels, exact Decimal wire text, constraints, a trailing venue default, Sensitive data, a generic packet, recursion, a Spec envelope, standards projection, introspection, callable and incremental boundaries, JSONL arrays, and resource limits.

"""Compact market records with positional NamedTuple boundaries."""

from __future__ import annotations

from decimal import Decimal
from typing import Annotated, Generic, NamedTuple, TypeVar, cast

from talea import (
    Contract,
    Ge,
    Representation,
    ResourceLimitError,
    ResourcePolicy,
    Sensitive,
    Spec,
    ValidationError,
    validate_call,
)
from talea.introspection import inspect_contract
from talea.schema import NamedReferenceSchema, NamedTupleSchema, VariadicTupleSchema


def _load_decimal(value: str) -> Decimal:
    return Decimal(value)


def _dump_decimal(value: Decimal) -> str:
    return str(value)


type WireDecimal = Annotated[
    Decimal,
    Representation(input=str, load=_load_decimal, output=str, dump=_dump_decimal),
]


class PriceLevel(NamedTuple):
    """One price and its non-negative displayed quantity."""

    price: WireDecimal
    quantity: Annotated[int, Ge(0)]


class Quote(NamedTuple):
    """Compact positional quote used by the market-data protocol."""

    symbol: str
    bid: PriceLevel
    ask: PriceLevel
    venue: str = "XNAS"


class AuthenticatedQuote(NamedTuple):
    """Quote paired with a credential that Talea-owned failures redact."""

    quote: Quote
    token: Annotated[str, Sensitive()]


T = TypeVar("T")


class Packet(NamedTuple, Generic[T]):
    """Generic sequence-numbered market-data packet."""

    sequence: int
    payload: T


class Chain(NamedTuple):
    """Recursive positional record used for linked venue snapshots."""

    venue: str
    children: tuple[Chain, ...] = ()


class Snapshot(Spec):
    """Object-shaped envelope containing positional quote records."""

    source: str
    quote: Quote


quote_contract = Contract(Quote, policy=ResourcePolicy(max_depth=16, max_nodes=1_000))
quote = Quote(
    "AAPL",
    PriceLevel(Decimal("198.10"), 120),
    PriceLevel(Decimal("198.14"), 90),
)

assert quote_contract.validate(quote) is quote
try:
    quote_contract.validate(tuple(quote))  # type: ignore[arg-type]
except ValidationError as error:
    assert error.errors()[0]["location"] == []
else:
    raise AssertionError("strict validation accepted a plain tuple")

wire_quote: list[object] = ["AAPL", ["198.10", 120], ["198.14", 90]]
converted = quote_contract.from_python(wire_quote)
assert converted == quote
assert quote_contract.from_python(tuple(wire_quote)) == quote
assert quote_contract.from_json('["AAPL",["198.10",120],["198.14",90]]') == quote
assert converted.venue == "XNAS"
try:
    quote_contract.from_python({"symbol": "AAPL"})
except ValidationError as error:
    assert error.errors()[0]["code"] == "type"
else:
    raise AssertionError("a Mapping was accepted as a positional record")

try:
    quote_contract.from_python(["AAPL", ["198.10", -1], ["198.14", 90]])
except ValidationError as error:
    assert error.errors()[0]["location"] == [1, 1]
    assert error.errors()[0]["code"] == "greater_than_or_equal"
else:
    raise AssertionError("a negative displayed quantity was accepted")

invalid_secret = AuthenticatedQuote(quote, 7)  # ty: ignore[invalid-argument-type]
try:
    Contract(AuthenticatedQuote).validate(invalid_secret)
except ValidationError as error:
    assert error.errors()[0]["location"] == [1]
    assert error.errors()[0]["input"] == "<redacted>"
else:
    raise AssertionError("invalid sensitive state was accepted")

python_output = quote_contract.to_python(quote)
assert type(python_output) is tuple
assert python_output == ("AAPL", ("198.10", 120), ("198.14", 90), "XNAS")
assert quote_contract.to_json(quote) == '["AAPL",["198.10",120],["198.14",90],"XNAS"]'

snapshot = Snapshot.from_mapping({"source": "feed-a", "quote": wire_quote})
assert snapshot.quote is not quote
assert snapshot.quote == quote
packet_contract: Contract[Packet[Quote]] = Contract(Packet[Quote])
packet = packet_contract.from_python([42, wire_quote])
assert packet == Packet(42, quote)

chain_contract = Contract(Chain)
chain = chain_contract.from_python(["XNAS", (["BATS"],)])
assert chain == Chain("XNAS", (Chain("BATS"),))

schema = quote_contract.json_schema()
quote_schema = cast(dict[str, object], cast(dict[str, object], schema["$defs"])["Quote"])
assert quote_schema["type"] == "array"
assert quote_schema["minItems"] == 3
assert quote_schema["maxItems"] == 4
assert cast(list[object], quote_schema["prefixItems"])[3] == {
    "type": "string",
    "default": "XNAS",
}
openapi = quote_contract.openapi_schema()
openapi_schemas = cast(dict[str, object], cast(dict[str, object], openapi["components"])["schemas"])
openapi_quote = cast(dict[str, object], openapi_schemas["Quote"])
assert openapi_quote["type"] == "array"
assert openapi_quote["minItems"] == quote_schema["minItems"]
assert openapi_quote["maxItems"] == quote_schema["maxItems"]

info = inspect_contract(quote_contract)
assert isinstance(info.schema, NamedTupleSchema)
assert tuple(field.name for field in info.schema.fields) == ("symbol", "bid", "ask", "venue")
assert info.schema.required_count == 3
assert info.schema.fields[3].default == "XNAS"
try:
    info.schema.required_count = 4  # ty: ignore[invalid-assignment]
except (AttributeError, TypeError):
    pass
else:
    raise AssertionError("introspection schema was mutable")

recursive_schema = chain_contract._artifacts.schema
assert isinstance(recursive_schema, NamedTupleSchema)
children_schema = recursive_schema.fields[1].schema
assert isinstance(children_schema, VariadicTupleSchema)
assert isinstance(children_schema.item, NamedReferenceSchema)


@validate_call
def publish(value: Quote) -> Quote:
    """Publish an already-validated exact quote record."""

    return value


assert publish(quote) is quote
try:
    publish(tuple(quote))  # ty: ignore[invalid-argument-type]
except ValidationError:
    pass
else:
    raise AssertionError("callable validation accepted a plain tuple")

assert list(quote_contract.iter_python((wire_quote, tuple(wire_quote)))) == [quote, quote]
assert list(quote_contract.iter_jsonl(('["AAPL",["198.10",120],["198.14",90]]',))) == [quote]

try:
    quote_contract.from_python(wire_quote, policy=ResourcePolicy(max_nodes=3))
except ResourceLimitError as error:
    assert error.code == "nodes"
else:
    raise AssertionError("the positional graph exceeded max_nodes")