Skip to content

Introspection

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

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


class User(Spec):
    id: int


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


@validate_call
def lookup(user_id: int) -> User:
    return User(id=user_id)


callable_info = inspect_callable(lookup)

Returned values

Type Important data
FieldInfo annotation, canonical Schema, required/default/factory state, current/legacy/accepted external names, 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
ParameterInfo Python name/kind, canonical Schema, required/default state, receiver role, and variadic semantics
CallableInfo original immutable Signature, ordered parameters, return Schema, sync/async state, and function/method kind

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.

For Contract(UserNamedTuple), the same property is a frozen NamedTupleSchema. Its ordered NamedTupleField values expose developer-facing field names, slot schemas, trailing defaults, required positional count, exact declared type, and specialization identity. This is sufficient public projection: there is no parallel NamedTuple info hierarchy and no mutable stdlib metadata or compiler state is exposed.

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. inspect_callable() accepts a validate_call function, bound method, or supported class/static descriptor and projects its retained canonical callable contract without rereading annotations.

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.

For fields, external_name is the current input/output spelling, legacy_names is the immutable ordered historical-input tuple, and accepted_input_names is the current name followed by legacy names. alias remains None or the explicitly declared current alias for compatibility. These tuples are projections of retained declaration truth; mutating a returned info object or collection cannot alter compiled execution.

The same vocabulary is available on canonical DataclassField nodes reached through inspect_contract().schema. A TaggedUnionSchema exposes the one compatible discriminator accepted_input_names tuple projected from its Spec branches. Tooling therefore needs neither annotation rereading nor generated input inspection to discover runtime migration names.

JSON Schema and OpenAPI consume these retained tuples directly. Frameworks should use the standards projection APIs for complete Schema Objects and use introspection for field descriptors; generated schema fragments are outputs, not a second canonical field model.

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.external_name,
        "legacy_names": field.legacy_names,
        "accepted_input_names": field.accepted_input_names,
        "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]["legacy_names"] == ()
assert descriptors[0]["accepted_input_names"] == ("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, inspect_contract() rejects non-Contract objects, and inspect_callable() rejects undecorated functions 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.