Source code for axio.schema
"""JSON-schema builder for plain async handler functions.
Produces clean schemas with no ``"title"`` keys - no post-processing needed
in transports.
"""
from __future__ import annotations
import inspect
import types
import typing
from typing import Any, Literal, get_args, get_origin, get_type_hints
from .field import MISSING, FieldInfo, get_field_info, is_classvar
PRIMITIVE: dict[type[Any], str] = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
}
VAR_KINDS = frozenset({inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD})
[docs]
def property_schema(annotation: Any) -> dict[str, Any]:
"""Recursively convert a Python type annotation to a JSON schema fragment."""
origin = get_origin(annotation)
args = get_args(annotation)
# Annotated[X, FieldInfo(...)] - unwrap and merge metadata
if origin is typing.Annotated:
inner_type = args[0]
field_info = next((a for a in args[1:] if isinstance(a, FieldInfo)), None)
schema = property_schema(inner_type)
if field_info:
if field_info.description:
schema["description"] = field_info.description
if field_info.ge is not None:
schema["minimum"] = field_info.ge
if field_info.le is not None:
schema["maximum"] = field_info.le
if field_info.default is not MISSING:
schema["default"] = field_info.default
return schema
# X | None or Optional[X] (both UnionType and Union)
if origin is types.UnionType or origin is typing.Union:
non_none = [a for a in args if a is not type(None)]
has_none = len(non_none) < len(args)
if len(non_none) == 1:
base = property_schema(non_none[0])
if has_none:
return {"anyOf": [base, {"type": "null"}]}
return base
parts = [property_schema(a) for a in non_none]
if has_none:
parts.append({"type": "null"})
return {"anyOf": parts}
# Literal["a", "b", ...]
if origin is Literal:
return {"enum": list(args)}
# list[X]
if origin is list:
item_schema = property_schema(args[0]) if args else {}
return {"type": "array", "items": item_schema}
# dict or dict[K, V]
if origin is dict or annotation is dict:
return {"type": "object"}
# Primitive scalars
if annotation in PRIMITIVE:
return {"type": PRIMITIVE[annotation]}
# Unknown - expose as bare object schema
return {}
[docs]
def build_tool_schema(
fn: Any,
hints: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Return a JSON schema object for *fn* (a callable or class).
The schema has the form::
{
"type": "object",
"properties": {"field": {"type": "string"}, ...},
"required": ["field", ...] # only when non-empty
}
No ``"title"`` keys are emitted anywhere in the schema.
Parameters
----------
fn:
A plain async function or a class whose annotations define the fields.
hints:
Pre-computed ``get_type_hints(fn, include_extras=True)`` result.
When supplied, the call to ``get_type_hints`` is skipped.
"""
if hints is None:
hints = get_type_hints(fn, include_extras=True)
try:
sig = inspect.signature(fn)
except (ValueError, TypeError):
sig = None
properties: dict[str, Any] = {}
required: list[str] = []
for name, hint in hints.items():
if name.startswith("_") or is_classvar(hint) or name == "return":
continue
if sig is not None and name in sig.parameters and sig.parameters[name].kind in VAR_KINDS:
continue
prop = property_schema(hint)
fi = get_field_info(hint)
has_fi_default = fi is not None and fi.default is not MISSING
has_sig_default = (
sig is not None and name in sig.parameters and sig.parameters[name].default is not inspect.Parameter.empty
)
has_class_default = isinstance(fn, type) and name in fn.__dict__
# Emit default from signature when not already set by FieldInfo
if has_sig_default and "default" not in prop:
sig_default = sig.parameters[name].default # type: ignore[union-attr]
prop["default"] = sig_default
properties[name] = prop
if not has_fi_default and not has_sig_default and not has_class_default:
required.append(name)
schema: dict[str, Any] = {"type": "object", "properties": properties}
if required:
schema["required"] = required
return schema