Skip to content

Introspection

Framework and tooling authors can inspect finalized public truth without accessing compiler artifacts.

from talea import Contract, Spec
from talea.introspection import inspect_contract, inspect_spec


class User(Spec):
    id: int


spec_info = inspect_spec(User)
contract_info = inspect_contract(Contract(list[User]))

Returned values

Type Important data
FieldInfo annotation, canonical Schema, required/default/factory state, alias, constraints, metadata, presence
DerivationInfo source Spec, retained/omitted fields, include/exclude selection, partial status, input/output mode
SpecInfo fields, generic identity/arguments, recursion, hooks, serializers, metadata, trust, derivation, reachable representations
ContractInfo annotation, canonical Schema, metadata, supported operation names, reachable representations
RepresentationInfo frozen internal/input/output schemas and direction flags, with no callbacks
SerializerInfo serializer name, target field, declared-output flag, and optional output Schema, with no callback

For Contract(UserDataclass), ContractInfo.schema is a frozen DataclassSchema. It exposes exact dataclass type identity, immutable canonical fields, init/keyword-only/default lifecycle truth, frozen transitive trust, generic specialization identity, and finite recursive references without exposing mutable stdlib Field objects.

inspect_spec() accepts a Spec class, including an open generic declaration. Concrete declarations return a cached immutable SpecInfo; open generics expose the declaration truth available before specialization. inspect_contract() accepts a Contract instance and returns a fresh immutable description.

The canonical Schema graph is structural truth and is safe to read. Generated source, compiled callables, locks, lazy publication state, codec choices, and resource counters remain intentionally private. Tooling should not infer semantics from class internals when a public info object or schema node provides the answer.

SpecInfo.representations and ContractInfo.representations contain each reachable represented contract once. Their RepresentationInfo values expose internal, optional input/output, has_loader, and has_dumper. They do not expose callback objects, callback names, globals, generated source, or compiler state; mutation cannot alter runtime truth.

SpecInfo.serializers contains one frozen SerializerInfo per effective field serializer. has_declared_output distinguishes legacy opaque hooks from hooks whose output_schema is canonical truth. Callback objects, globals, code objects, generated projectors, and caches are not exposed. Reading this information does not execute the callback.

For a directional derived Spec, DerivationInfo.mode is "input" or "output"; it is None for ordinary pick/omit/partial derivation. Consumers should inspect this provenance instead of inferring semantics from names such as UserInput.

Framework adapter example

The executable example projects a normal account Spec into a smaller framework-owned descriptor, inspects aliases, constraints, metadata, and Sensitive state, then inspects a derived partial and an arbitrary Contract.

"""Framework-facing immutable introspection without annotation reconstruction."""

from dataclasses import FrozenInstanceError
from typing import Annotated, cast

from talea import Alias, Contract, Description, Ge, Sensitive, Spec, Title, derive_spec
from talea.introspection import FieldInfo, inspect_contract, inspect_spec


class Account(
    Spec,
    metadata=(Title("Account"), Description("Framework-visible account payload.")),
):
    account_id: Annotated[int, Alias("id"), Ge(1)]
    display_name: Annotated[str, Alias("displayName")]
    token: Annotated[str, Sensitive()]


def field_descriptor(field: FieldInfo) -> dict[str, object]:
    """Project public info into one framework's simpler descriptor shape."""

    return {
        "python_name": field.name,
        "external_name": field.alias or field.name,
        "required": field.required,
        "omittable": field.omittable,
        "sensitive": field.sensitive,
        "schema_kind": type(field.schema).__name__ if field.schema is not None else None,
    }


account_info = inspect_spec(Account)
assert account_info.title == "Account"
assert account_info.description == "Framework-visible account payload."
assert account_info.operations == (
    "strict_python",
    "external_python",
    "json_input",
    "python_output",
    "json_output",
)

descriptors = [field_descriptor(field) for field in account_info.fields]
assert descriptors[0]["external_name"] == "id"
assert descriptors[0]["required"] is True
assert descriptors[2]["sensitive"] is True

AccountPatch = derive_spec(Account, exclude=("account_id",), partial=True, name="AccountPatch")
patch_info = inspect_spec(AccountPatch)
assert patch_info.presence_aware is True
assert patch_info.derivation is not None
assert patch_info.derivation.source is Account
assert patch_info.derivation.omitted_fields == ("account_id",)
assert all(field.omittable for field in patch_info.fields)

batch = Contract[list[Account]](list[Account])
contract_info = inspect_contract(batch)
assert contract_info.annotation == list[Account]
assert type(contract_info.schema).__name__ == "SequenceSchema"

try:
    frozen_attribute = "title"
    setattr(account_info, frozen_attribute, "Changed")
except FrozenInstanceError:
    pass
else:
    raise AssertionError("public introspection must be immutable")

try:
    inspect_spec(cast(type[object], dict))
except TypeError as error:
    assert "Spec class" in str(error)
else:
    raise AssertionError("non-Spec classes must not expose synthetic info")

# Frameworks may project this truth into route parameters, documentation, or
# dependency graphs. They should not mutate it or recover semantics by rereading
# __annotations__, compiler globals, generated functions, or private artifacts.

The framework may copy this information into route metadata, dependency descriptions, form fields, or documentation. It should preserve the distinction between canonical Python names and aliases, and between required and omittable. A partial field can be non-required because it is absent; that does not imply its value contract accepts None.

Caching, open generics, and recursion

Concrete inspect_spec() results are weakly cached by class identity and are frozen. A framework can retain them without worrying that another consumer will mutate shared field truth. The cache does not keep an otherwise unreachable dynamic class alive.

An open generic exposes its parameters and annotations, but a field whose type depends on a free parameter has schema=None until specialization. Frameworks must either display that declaration as generic or request a concrete type such as Page[User]; they must not invent Any runtime semantics. Recursive contracts expose finite reference nodes, so traversals should track named identity rather than recursively expanding forever.

Failure and extension rules

inspect_spec() rejects non-Spec classes and inspect_contract() rejects non-Contract objects with TypeError. Introspection does not execute input, serialization, callbacks, or schema projection. It also does not expose generated source or compiled callables as public extension points.

A framework needing JSON/OpenAPI should call the projection API instead of recreating standards semantics from FieldInfo. A runtime validator should use the Spec/Contract operation or compile from public canonical Schema, not reread __annotations__. These boundaries preserve one owner for each truth.