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.