Source code for gyoza.deployment.io_extractor

"""AST-based extractor for input/output specs from Python source files.

Parses a ``.py`` file using only the standard-library ``ast`` module so
that it works without any third-party dependencies installed (e.g. in CI
environments that lack pydantic, torch, etc.).

The extractor looks for:

1. A function decorated with ``@gyoza_op(...)`` to discover the
   ``input_model`` and ``output_model`` class names.
2. Class definitions that inherit from ``BaseModel`` whose names match
   the discovered model names, extracting their annotated fields.

Base classes are followed so that a model composed from a shared config
object publishes the inherited fields too. Bases declared in the same file
are resolved directly; imported ones are located on disk from the file's
import statements and parsed the same way — never imported, so the
no-third-party-dependency guarantee holds.
"""

from __future__ import annotations

import ast
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from gyoza.models.op_definition import (
    InputSpec,
    InputSpecs,
    OutputSpec,
    OutputSpecs,
)

_TYPE_MAP: dict[str, str] = {
    "str": "string",
    "int": "int",
    "float": "float",
    "bool": "boolean",
    "bytes": "bytes",
    "list": "array",
    "dict": "object",
    "tuple": "array",
    "set": "array",
    "frozenset": "array",
    "List": "array",
    "Dict": "object",
    "Tuple": "array",
    "Set": "array",
    "Optional": "optional",
    "Any": "any",
}

# Python literal types, as they appear inside ``Literal[...]``.
_LITERAL_TYPES: tuple[tuple[type, str], ...] = (
    (bool, "boolean"),
    (int, "int"),
    (float, "float"),
    (str, "string"),
    (bytes, "bytes"),
)

# type_str, required, default, description, example
_FieldInfo = tuple[str, bool, Any, str | None, Any]


@dataclass
class _DecoratorInfo:
    input_model_name: str | None = None
    output_model_name: str | None = None


@dataclass
class _FieldMeta:
    """Metadata extracted from a ``Field(...)`` call."""

    default: Any = None
    has_default: bool = False
    has_default_factory: bool = False
    description: str | None = None
    example: Any = None


def _is_none(node: ast.expr) -> bool:
    """Return whether an annotation node is the ``None`` literal."""
    return isinstance(node, ast.Constant) and node.value is None


def _union_members(node: ast.expr) -> list[ast.expr]:
    """Flatten a ``X | Y | Z`` annotation into its operands."""
    if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
        return _union_members(node.left) + _union_members(node.right)
    return [node]


def _single_type(nodes: list[ast.expr]) -> str:
    """Resolve a group of annotation nodes to one type, or ``"any"``.

    ``...`` and ``None`` members are ignored, so ``tuple[str, ...]`` and
    ``str | None`` both narrow to their meaningful type.

    Parameters
    ----------
    nodes : list[ast.expr]
        Annotation nodes making up a union or a subscript parameter list.

    Returns
    -------
    str
        The shared type string, or ``"any"`` when the members disagree.
    """
    resolved = {
        _resolve_type(node)
        for node in nodes
        if not _is_none(node)
        and not (isinstance(node, ast.Constant) and node.value is Ellipsis)
    }
    return resolved.pop() if len(resolved) == 1 else "any"


def _literal_type(node: ast.expr) -> str:
    """Resolve ``Literal[...]`` to the type of the values it allows.

    Parameters
    ----------
    node : ast.expr
        The subscript slice of a ``Literal[...]`` annotation.

    Returns
    -------
    str
        Type string shared by the allowed values, or ``"any"`` when mixed.
    """
    elts = node.elts if isinstance(node, ast.Tuple) else [node]
    names: set[str] = set()
    for elt in elts:
        if not isinstance(elt, ast.Constant):
            return "any"
        for python_type, name in _LITERAL_TYPES:
            if isinstance(elt.value, python_type):
                names.add(name)
                break
    return names.pop() if len(names) == 1 else "any"


def _resolve_type(node: ast.expr) -> str:
    """Convert an AST annotation node to a simplified type string.

    Parameters
    ----------
    node : ast.expr
        The annotation AST node.

    Returns
    -------
    str
        Simplified type string (e.g. ``"string"``, ``"array[string]"``).
    """
    if isinstance(node, ast.Name):
        return _TYPE_MAP.get(node.id, node.id)
    if isinstance(node, ast.Constant) and isinstance(node.value, str):
        return _TYPE_MAP.get(node.value, node.value)
    if isinstance(node, ast.Attribute):
        return _TYPE_MAP.get(node.attr, node.attr)
    if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
        return _single_type(_union_members(node))
    if isinstance(node, ast.Subscript):
        base = _resolve_type(node.value)
        slice_node = node.slice
        if base == "Annotated":
            if isinstance(slice_node, ast.Tuple) and slice_node.elts:
                return _resolve_type(slice_node.elts[0])
            return _resolve_type(slice_node)
        if base == "Literal":
            return _literal_type(slice_node)
        if base == "object":
            return "object"
        inner = _single_type(
            slice_node.elts if isinstance(slice_node, ast.Tuple) else [slice_node]
        )
        return inner if base == "optional" else f"{base}[{inner}]"
    return "any"


_ARITHMETIC: dict[type[ast.operator], Any] = {
    ast.Add: lambda a, b: a + b,
    ast.Sub: lambda a, b: a - b,
    ast.Mult: lambda a, b: a * b,
    ast.Div: lambda a, b: a / b,
    ast.Pow: lambda a, b: a**b,
}


def _arithmetic_value(node: ast.expr) -> tuple[bool, Any]:
    """Fold a constant arithmetic expression such as ``1.0 / 10000.0``.

    Only numeric operands and the four basic operators are folded, so nothing
    from the parsed file is executed.

    Parameters
    ----------
    node : ast.expr
        Expression to evaluate.

    Returns
    -------
    tuple[bool, Any]
        ``(True, value)`` when the expression folds to a number.
    """
    if not isinstance(node, ast.BinOp):
        return False, None
    operation = _ARITHMETIC.get(type(node.op))
    if operation is None:
        return False, None
    left_ok, left = _literal_value(node.left)
    right_ok, right = _literal_value(node.right)
    if not (left_ok and right_ok):
        return False, None
    if not isinstance(left, (int, float)) or not isinstance(right, (int, float)):
        return False, None
    if isinstance(left, bool) or isinstance(right, bool):
        return False, None
    try:
        return True, operation(left, right)
    except (ArithmeticError, TypeError):
        return False, None


def _json_ready(value: Any) -> Any:
    """Convert tuples and sets to lists so a default survives JSON transport."""
    if isinstance(value, (tuple, set, frozenset)):
        return [_json_ready(item) for item in value]
    if isinstance(value, list):
        return [_json_ready(item) for item in value]
    if isinstance(value, dict):
        return {key: _json_ready(item) for key, item in value.items()}
    return value


def _literal_value(node: ast.expr) -> tuple[bool, Any]:
    """Return whether *node* is a supported literal and its value.

    Covers everything ``ast.literal_eval`` accepts — constants, negative
    numbers, lists, tuples, dicts — plus constant arithmetic. Tuples and sets
    are returned as lists so the value stays JSON-serialisable.

    Parameters
    ----------
    node : ast.expr
        AST expression to evaluate as a literal.

    Returns
    -------
    tuple[bool, Any]
        ``(True, value)`` for supported literals; ``(False, None)`` otherwise.
    """
    try:
        return True, _json_ready(ast.literal_eval(node))
    except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError):
        return _arithmetic_value(node)


def _module_constant(
    name: str,
    context: _ModuleContext,
    seen: set[tuple[str, str]],
) -> tuple[bool, Any]:
    """Read a module-level constant, following the import that brought it in.

    Ops name their defaults instead of repeating them — ``MVT_EXTENT`` rather
    than ``65536`` — so the value lives one module away from the field that
    uses it. Reading it here is what lets the published spec carry the same
    number the op would have applied itself.

    Parameters
    ----------
    name : str
        Constant name as written at the use site.
    context : _ModuleContext
        Module to look in first.
    seen : set[tuple[str, str]]
        Already-visited ``(module path, constant name)`` pairs, guarding
        against re-export cycles.

    Returns
    -------
    tuple[bool, Any]
        ``(True, value)`` when the constant resolves to a literal.
    """
    marker = (str(context.path), name)
    if marker in seen:
        return False, None
    seen.add(marker)

    for stmt in context.tree.body:
        if isinstance(stmt, ast.Assign):
            if any(
                isinstance(target, ast.Name) and target.id == name
                for target in stmt.targets
            ):
                return _resolved_value(stmt.value, context, seen)
        elif isinstance(stmt, ast.AnnAssign):
            target = stmt.target
            if (
                isinstance(target, ast.Name)
                and target.id == name
                and stmt.value is not None
            ):
                return _resolved_value(stmt.value, context, seen)

    origin = context.from_imports.get(name)
    if origin is None:
        return False, None
    module, level = origin
    path = _module_file(module, level, context)
    if path is None:
        return False, None
    imported = _load_module(path, context.roots)
    if imported is None:
        return False, None
    return _module_constant(name, imported, seen)


def _module_attribute(
    node: ast.Attribute,
    context: _ModuleContext,
    seen: set[tuple[str, str]],
) -> tuple[bool, Any]:
    """Read ``module.CONSTANT`` by locating *module* on disk.

    Parameters
    ----------
    node : ast.Attribute
        Attribute access whose value is the imported module.
    context : _ModuleContext
        Module the access was written in.
    seen : set[tuple[str, str]]
        Already-visited ``(module path, constant name)`` pairs.

    Returns
    -------
    tuple[bool, Any]
        ``(True, value)`` when the attribute resolves to a literal.
    """
    holder = node.value
    if not isinstance(holder, ast.Name):
        return False, None

    module = context.plain_imports.get(holder.id)
    level = 0
    if module is None:
        origin = context.from_imports.get(holder.id)
        if origin is None:
            return False, None
        parent, level = origin
        module = f"{parent}.{holder.id}" if parent else holder.id

    path = _module_file(module, level, context)
    if path is None:
        return False, None
    imported = _load_module(path, context.roots)
    if imported is None:
        return False, None
    return _module_constant(node.attr, imported, seen)


def _resolved_value(
    node: ast.expr,
    context: _ModuleContext | None = None,
    seen: set[tuple[str, str]] | None = None,
) -> tuple[bool, Any]:
    """Evaluate *node* as a literal, following named constants when possible.

    Parameters
    ----------
    node : ast.expr
        AST expression to evaluate.
    context : _ModuleContext | None
        Module the expression was written in; without one only literals
        resolve.
    seen : set[tuple[str, str]] | None
        Cycle guard shared across a single resolution.

    Returns
    -------
    tuple[bool, Any]
        ``(True, value)`` when the expression resolves to a literal.
    """
    ok, value = _literal_value(node)
    if ok or context is None:
        return ok, value

    tracked = set() if seen is None else seen
    if isinstance(node, ast.Name):
        return _module_constant(node.id, context, tracked)
    if isinstance(node, ast.Attribute):
        return _module_attribute(node, context, tracked)
    return False, None


def _is_field_call(node: ast.expr) -> bool:
    """Return whether *node* is a ``Field(...)`` / ``pydantic.Field(...)`` call."""
    if not isinstance(node, ast.Call):
        return False
    func = node.func
    if isinstance(func, ast.Name):
        return func.id == "Field"
    if isinstance(func, ast.Attribute):
        return func.attr == "Field"
    return False


def _parse_field_call(
    node: ast.Call,
    context: _ModuleContext | None = None,
) -> _FieldMeta:
    """Extract default, description and example from a ``Field(...)`` call.

    A ``default=`` makes the field optional whether or not its value can be
    read here. Requiring what we merely failed to parse would reject every
    caller for an input the op fills in on its own.

    Parameters
    ----------
    node : ast.Call
        The ``Field(...)`` call AST node.
    context : _ModuleContext | None
        Module the call was written in, used to follow named constants.

    Returns
    -------
    _FieldMeta
        Parsed field metadata.
    """
    meta = _FieldMeta()

    if node.args:
        meta.has_default = True
        ok, default = _resolved_value(node.args[0], context)
        if ok:
            meta.default = default

    for kw in node.keywords:
        if kw.arg == "default":
            meta.has_default = True
            ok, default = _resolved_value(kw.value, context)
            if ok:
                meta.default = default
        elif kw.arg == "default_factory":
            # The value is computed at runtime, so it is optional but has no
            # publishable default.
            meta.has_default_factory = True
        elif kw.arg == "description":
            ok, value = _resolved_value(kw.value, context)
            if ok and isinstance(value, str):
                meta.description = value
        elif kw.arg == "example":
            ok, value = _resolved_value(kw.value, context)
            if ok:
                meta.example = value
        elif kw.arg == "examples":
            ok, value = _resolved_value(kw.value, context)
            if ok and isinstance(value, list) and value:
                meta.example = value[0]

    return meta


def _find_field_in_annotated(
    annotation: ast.expr,
    context: _ModuleContext | None = None,
) -> _FieldMeta | None:
    """Find a ``Field(...)`` call inside an ``Annotated[...]`` annotation.

    Parameters
    ----------
    annotation : ast.expr
        Field type annotation AST node.
    context : _ModuleContext | None
        Module the annotation was written in, used to follow named constants.

    Returns
    -------
    _FieldMeta | None
        Metadata from the first nested ``Field`` call, if any.
    """
    if not isinstance(annotation, ast.Subscript):
        return None
    base = annotation.value
    base_name = None
    if isinstance(base, ast.Name):
        base_name = base.id
    elif isinstance(base, ast.Attribute):
        base_name = base.attr
    if base_name != "Annotated":
        return None

    slice_node = annotation.slice
    elts = slice_node.elts if isinstance(slice_node, ast.Tuple) else [slice_node]
    for elt in elts[1:]:
        if _is_field_call(elt) and isinstance(elt, ast.Call):
            return _parse_field_call(elt, context)
    return None


def _is_gyoza_op(node: ast.expr) -> bool:
    if isinstance(node, ast.Call):
        return _is_gyoza_op(node.func)
    if isinstance(node, ast.Name):
        return node.id in {"gyoza_op", "GyozaOp"}
    if isinstance(node, ast.Attribute):
        return node.attr in {"gyoza_op", "GyozaOp"}
    return False


def _find_decorator_info(tree: ast.Module) -> _DecoratorInfo:
    """Walk the AST to find the first ``@gyoza_op(...)`` decorator.

    Parameters
    ----------
    tree : ast.Module
        Parsed module AST.

    Returns
    -------
    _DecoratorInfo
        Extracted ``input_model`` and ``output_model`` class names.

    Raises
    ------
    ValueError
        If no ``@gyoza_op`` decorator is found.
    """
    for node in ast.walk(tree):
        if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            continue
        for dec in node.decorator_list:
            if not (_is_gyoza_op(dec) and isinstance(dec, ast.Call)):
                continue
            info = _DecoratorInfo()
            for kw in dec.keywords:
                if kw.arg == "input_model" and isinstance(kw.value, ast.Name):
                    info.input_model_name = kw.value.id
                elif kw.arg == "output_model" and isinstance(kw.value, ast.Name):
                    info.output_model_name = kw.value.id
            return info
    msg = "No @gyoza_op decorator found in the file"
    raise ValueError(msg)


# Bases that model frameworks provide; following them would pull in
# unrelated class attributes, and they never carry op fields.
_IGNORED_BASES = frozenset(
    {
        "BaseModel",
        "RootModel",
        "GenericModel",
        "object",
        "ABC",
        "ABCMeta",
        "Generic",
        "Protocol",
        "TypedDict",
        "NamedTuple",
        "Enum",
        "StrEnum",
        "IntEnum",
    }
)

# Packages never worth walking into when resolving a base class on disk.
_IGNORED_MODULE_ROOTS = frozenset(
    {"pydantic", "typing", "typing_extensions", "abc", "enum", "dataclasses"}
)


@dataclass
class _ModuleContext:
    """A parsed module plus what it needs to resolve its own base classes.

    Parameters
    ----------
    tree : ast.Module
        Parsed module AST.
    path : Path
        Location of the module on disk.
    roots : tuple[Path, ...]
        Directories searched when resolving an imported module to a file.
    """

    tree: ast.Module
    path: Path
    roots: tuple[Path, ...]
    from_imports: dict[str, tuple[str, int]] = field(default_factory=dict)
    plain_imports: dict[str, str] = field(default_factory=dict)

    def __post_init__(self) -> None:
        for node in ast.walk(self.tree):
            if isinstance(node, ast.ImportFrom):
                for alias in node.names:
                    local = alias.asname or alias.name
                    self.from_imports[local] = (node.module or "", node.level)
            elif isinstance(node, ast.Import):
                for alias in node.names:
                    local = alias.asname or alias.name.split(".")[0]
                    self.plain_imports[local] = alias.name


def _search_roots(path: Path) -> tuple[Path, ...]:
    """Build the directory list used to resolve imported modules to files.

    Covers the module's own package root, the op directory and its ``src``
    layout, then ``sys.path`` so installed packages resolve when present.

    Parameters
    ----------
    path : Path
        Path of the module being parsed.

    Returns
    -------
    tuple[Path, ...]
        Search roots, nearest first, deduplicated.
    """
    roots: list[Path] = []
    package_root = path.parent
    while (package_root / "__init__.py").exists():
        if package_root.parent == package_root:
            break
        package_root = package_root.parent
    roots.append(package_root)

    for candidate in (path.parent, path.parent / "src", package_root / "src"):
        roots.append(candidate)

    roots.extend(Path(entry) for entry in sys.path if entry)

    seen: set[Path] = set()
    unique: list[Path] = []
    for root in roots:
        if root not in seen and root.is_dir():
            seen.add(root)
            unique.append(root)
    return tuple(unique)


def _module_file(
    module: str,
    level: int,
    context: _ModuleContext,
) -> Path | None:
    """Locate the source file of an imported module without importing it.

    Parameters
    ----------
    module : str
        Dotted module name, empty for a bare relative import.
    level : int
        Relative-import level; 0 for an absolute import.
    context : _ModuleContext
        Module the import was written in.

    Returns
    -------
    Path | None
        The module's ``.py`` file, or None when it cannot be found.
    """
    parts = module.split(".") if module else []
    if parts and parts[0] in _IGNORED_MODULE_ROOTS:
        return None

    if level:
        base = context.path.parent
        for _ in range(level - 1):
            base = base.parent
        roots: tuple[Path, ...] = (base,)
    else:
        roots = context.roots

    for root in roots:
        target = root.joinpath(*parts) if parts else root
        for candidate in (target.with_suffix(".py"), target / "__init__.py"):
            if candidate.is_file():
                return candidate
    return None


def _load_module(
    path: Path,
    roots: tuple[Path, ...] | None = None,
) -> _ModuleContext | None:
    """Parse a module from disk, returning None when it cannot be read.

    Parameters
    ----------
    path : Path
        Module file to parse.
    roots : tuple[Path, ...] | None
        Search roots to reuse; derived from *path* when omitted.

    Returns
    -------
    _ModuleContext | None
        Parsed module context, or None on read/syntax failure.
    """
    try:
        tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
    except (OSError, SyntaxError, ValueError):
        return None
    return _ModuleContext(tree=tree, path=path, roots=roots or _search_roots(path))


def _dotted_name(node: ast.expr) -> str | None:
    """Return the dotted source form of a base-class expression."""
    if isinstance(node, ast.Name):
        return node.id
    if isinstance(node, ast.Attribute):
        prefix = _dotted_name(node.value)
        return f"{prefix}.{node.attr}" if prefix else None
    if isinstance(node, ast.Subscript):
        return _dotted_name(node.value)
    return None


def _find_class(tree: ast.Module, class_name: str) -> ast.ClassDef | None:
    """Return the first class definition named *class_name* in *tree*."""
    for node in ast.walk(tree):
        if isinstance(node, ast.ClassDef) and node.name == class_name:
            return node
    return None


def _resolve_base(
    node: ast.expr,
    context: _ModuleContext,
) -> tuple[ast.ClassDef, _ModuleContext] | None:
    """Resolve a base-class expression to its definition and home module.

    Parameters
    ----------
    node : ast.expr
        Base-class expression from a ``ClassDef``.
    context : _ModuleContext
        Module the subclass is declared in.

    Returns
    -------
    tuple[ast.ClassDef, _ModuleContext] | None
        The base class and the module holding it, or None when the base is a
        framework class or cannot be located on disk.
    """
    dotted = _dotted_name(node)
    if not dotted:
        return None

    head, _, tail = dotted.partition(".")
    class_name = tail.rsplit(".", 1)[-1] if tail else head
    if class_name in _IGNORED_BASES:
        return None

    if not tail:
        local = _find_class(context.tree, class_name)
        if local is not None:
            return local, context
        if head not in context.from_imports:
            return None
        module, level = context.from_imports[head]
    else:
        if head not in context.plain_imports:
            return None
        module, level = context.plain_imports[head], 0
        prefix = tail.rsplit(".", 1)[0] if "." in tail else ""
        if prefix:
            module = f"{module}.{prefix}"

    path = _module_file(module, level, context)
    if path is None:
        return None

    # A package may re-export the class; follow the import chain from there.
    imported = _load_module(path, context.roots)
    if imported is None:
        return None
    found = _find_class(imported.tree, class_name)
    if found is not None:
        return found, imported
    if class_name in imported.from_imports:
        return _resolve_base(ast.Name(id=class_name), imported)
    return None


def _extract_class_fields(
    tree: ast.Module,
    class_name: str,
    *,
    path: Path | None = None,
) -> dict[str, _FieldInfo]:
    """Extract annotated fields from a class definition and its base classes.

    Parameters
    ----------
    tree : ast.Module
        Parsed module AST.
    class_name : str
        Name of the class to extract fields from.
    path : Path | None
        Location of the module, used to resolve imported base classes.

    Returns
    -------
    dict[str, _FieldInfo]
        Mapping of field name to
        ``(type_str, required, default, description, example)``.

    Raises
    ------
    ValueError
        If the class is not found.
    """
    node = _find_class(tree, class_name)
    if node is None:
        msg = f"Class '{class_name}' not found in the file"
        raise ValueError(msg)

    source = path or Path.cwd() / "<memory>"
    context = _ModuleContext(tree=tree, path=source, roots=_search_roots(source))
    return _collect_fields(node, context, seen=set())


def _collect_fields(
    node: ast.ClassDef,
    context: _ModuleContext,
    seen: set[tuple[str, str]],
) -> dict[str, _FieldInfo]:
    """Collect a class's own fields on top of the ones it inherits.

    Bases are walked in reverse declaration order before the class body, so a
    subclass redeclaring an inherited field wins — the order Pydantic itself
    resolves fields in.

    Parameters
    ----------
    node : ast.ClassDef
        Class to collect fields from.
    context : _ModuleContext
        Module holding *node*.
    seen : set[tuple[str, str]]
        Already-visited ``(module path, class name)`` pairs, guarding against
        import cycles and diamond bases.

    Returns
    -------
    dict[str, _FieldInfo]
        Field name to extracted metadata, inherited fields first.
    """
    marker = (str(context.path), node.name)
    if marker in seen:
        return {}
    seen.add(marker)

    fields: dict[str, _FieldInfo] = {}
    for base in reversed(node.bases):
        resolved = _resolve_base(base, context)
        if resolved is not None:
            base_node, base_context = resolved
            fields.update(_collect_fields(base_node, base_context, seen))

    fields.update(_own_fields(node, context))
    return fields


def _own_fields(
    node: ast.ClassDef,
    context: _ModuleContext | None = None,
) -> dict[str, _FieldInfo]:
    """Extract the annotated fields declared directly in a class body.

    Parameters
    ----------
    node : ast.ClassDef
        Class whose body is scanned.
    context : _ModuleContext | None
        Module holding *node*, used to follow named constants.

    Returns
    -------
    dict[str, _FieldInfo]
        Field name to ``(type_str, required, default, description, example)``.
    """
    fields: dict[str, _FieldInfo] = {}
    for stmt in node.body:
        if not isinstance(stmt, ast.AnnAssign) or not isinstance(stmt.target, ast.Name):
            continue
        name = stmt.target.id
        type_str = _resolve_type(stmt.annotation)

        description: str | None = None
        example: Any = None
        default: Any = None
        required = True

        annotated_meta = _find_field_in_annotated(stmt.annotation, context)
        if annotated_meta is not None:
            description = annotated_meta.description
            example = annotated_meta.example
            if annotated_meta.has_default:
                default = annotated_meta.default
                required = False
            elif annotated_meta.has_default_factory:
                required = False

        if stmt.value is not None:
            if _is_field_call(stmt.value) and isinstance(stmt.value, ast.Call):
                meta = _parse_field_call(stmt.value, context)
                if meta.description is not None:
                    description = meta.description
                if meta.example is not None:
                    example = meta.example
                if meta.has_default:
                    default = meta.default
                    required = False
                elif meta.has_default_factory:
                    required = False
                else:
                    # Field(...) without default → still required
                    required = True if default is None else required
            else:
                ok, value = _resolved_value(stmt.value, context)
                if ok:
                    default = value
                # A non-literal RHS (e.g. a function call) is still a default,
                # just not one we can publish.
                required = False
        elif annotated_meta is None:
            required = True

        fields[name] = (type_str, required, default, description, example)
    return fields


[docs] def extract_io_specs(io_file_path: Path | str) -> tuple[InputSpecs, OutputSpecs]: """Extract input and output specs from a Python file via AST analysis. Reads the file as text, parses it with ``ast.parse``, then locates the ``@gyoza_op(input_model=..., output_model=...)`` decorator and the corresponding Pydantic model classes to build the specs. This function requires **no third-party imports** at parse time. Parameters ---------- io_file_path : Path | str Path to the ``.py`` file containing the decorated function and model definitions. Returns ------- tuple[InputSpecs, OutputSpecs] Extracted input and output specifications. Raises ------ FileNotFoundError If the file does not exist. ValueError If the file cannot be parsed or required elements are missing. """ path = Path(io_file_path) if not path.exists(): msg = f"IO file not found: {path}" raise FileNotFoundError(msg) tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) info = _find_decorator_info(tree) input_specs = InputSpecs() if info.input_model_name: fields = _extract_class_fields(tree, info.input_model_name, path=path) input_specs = InputSpecs( specs={ name: InputSpec( type=t, required=req, default=default, description=description, example=example, ) for name, (t, req, default, description, example) in fields.items() } ) output_specs = OutputSpecs() if info.output_model_name: fields = _extract_class_fields(tree, info.output_model_name, path=path) output_specs = OutputSpecs( specs={ name: OutputSpec(type=t, description=description, example=example) for name, (t, _, _, description, example) in fields.items() } ) return input_specs, output_specs