Source code for gyoza.cli.commands.run

"""Run command for the gyoza CLI."""

from __future__ import annotations

import ast
import importlib.util
import json
import os
from pathlib import Path
from types import ModuleType
from typing import Any

import typer

from gyoza.cli.errors import fail

_DEFAULT_INPUT_PATH = "/data/input.json"
_DEFAULT_OUTPUT_PATH = "/data/output.json"
_SEPARATOR = "━" * 50


def _load_module(path: Path) -> ModuleType:
    """Load and return a Python module from a file path."""
    name = f"_gyoza_cli_{path.stem}_{abs(hash(path))}"
    spec = importlib.util.spec_from_file_location(name, path)
    if spec is None or spec.loader is None:
        fail(f"unable to import module at {path}")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


def _find_gyoza_op(module: ModuleType, func_name: str | None) -> Any:
    """
    Return the gyoza op function from *module*.

    Parameters
    ----------
    module : ModuleType
        Loaded Python module.
    func_name : str or None
        Explicit function name to look up. When ``None`` the first callable
        with a ``.run`` attribute defined in the module is used.

    Returns
    -------
    Any
        The decorated function.

    Raises
    ------
    typer.Exit
        On any lookup failure.
    """
    if func_name:
        func = getattr(module, func_name, None)
        if func is None:
            fail(f"function '{func_name}' not found in {module.__file__}")
        return func

    ops = [
        obj
        for obj in module.__dict__.values()
        if callable(obj)
        and hasattr(obj, "run")
        and getattr(obj, "__module__", None) == module.__name__
    ]
    if not ops:
        fail(f"no @gyoza_op function found in {module.__file__}")
    return ops[0]


def _parse_inline_input(raw: str) -> dict[str, Any]:
    """
    Parse an inline string into a dict.

    Parameters
    ----------
    raw : str
        JSON or Python-literal dict string.

    Returns
    -------
    dict[str, Any]
        Parsed input data.

    Raises
    ------
    typer.Exit
        If the string cannot be parsed or is not a dict.
    """
    try:
        parsed = json.loads(raw)
    except json.JSONDecodeError:
        try:
            parsed = ast.literal_eval(raw)
        except (ValueError, SyntaxError) as exc:
            fail(f"invalid --inline-input: {exc}")

    if not isinstance(parsed, dict):
        fail("--inline-input must be a JSON object (dictionary)")
    return parsed


def _read_input_file(path: str) -> dict[str, Any]:
    """
    Read a JSON input file into a dict.

    Parameters
    ----------
    path : str
        Path to the JSON file.

    Returns
    -------
    dict[str, Any]
        Parsed input data.

    Raises
    ------
    typer.Exit
        If the file cannot be read or parsed.
    """
    try:
        with Path(path).open() as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        fail(f"failed to read input file '{path}': {exc}")

    if not isinstance(data, dict):
        fail(f"input file '{path}' must contain a JSON object (dictionary)")
    return data


def _write_output_file(path: str, data: dict[str, Any]) -> None:
    """
    Write the output dict to a JSON file.

    Parameters
    ----------
    path : str
        Destination file path.
    data : dict[str, Any]
        Output data to serialise.

    Raises
    ------
    typer.Exit
        If the file cannot be written.
    """
    try:
        out = Path(path)
        out.parent.mkdir(parents=True, exist_ok=True)
        with out.open("w") as fh:
            json.dump(data, fh, indent=2)
    except OSError as exc:
        fail(f"failed to write output file '{path}': {exc}")


[docs] def run( file: str | None = typer.Option( None, "--file", "-f", help="Python file that defines one @gyoza_op function.", ), function: str | None = typer.Option( None, "--function", "-F", help=( "Name of the @gyoza_op function to execute. " "Defaults to the first one found in the file." ), ), input: str | None = typer.Option( None, "--input", "-i", help=( "JSON input file path. " "Falls back to $GYOZA_INPUT_PATH. " "Ignored when --inline-input is provided." ), ), output: str | None = typer.Option( None, "--output", "-o", help=("JSON output file path. Falls back to $GYOZA_OUTPUT_PATH."), ), inline_input: str | None = typer.Option( None, "--inline-input", help=( 'Inline JSON dict string for inputs, e.g. \'{"a": 1, "b": 2}\'. ' "Mutually exclusive with --input." ), ), ) -> None: """ Execute a @gyoza_op function from a Python file. Three input modes are supported: 1. ``--inline-input`` — parse input directly from the CLI string. 2. ``--input`` — read input from a JSON file at the given path. 3. default — read input from the path in $GYOZA_INPUT_PATH. In all modes the output dict is written as JSON to ``--output`` or $GYOZA_OUTPUT_PATH. """ if not file: fail("--file is required") if inline_input and input: fail("--input and --inline-input are mutually exclusive") code_path = Path(file).resolve() if not code_path.exists(): fail(f"file not found: {file}") resolved_output = output or os.getenv("GYOZA_OUTPUT_PATH", _DEFAULT_OUTPUT_PATH) typer.echo(f"\n{_SEPARATOR}") typer.echo(" ▶️ GYOZA RUN") typer.echo(f"{_SEPARATOR}\n") typer.echo(f" 📄 File: {code_path}") typer.echo(f" 🎯 Function: {function or '(auto-detect)'}") try: module = _load_module(code_path) func = _find_gyoza_op(module, function) except typer.Exit: raise except Exception as exc: # noqa: BLE001 fail(f"failed to load module: {exc}") if inline_input is not None: mode = "inline" input_dict = _parse_inline_input(inline_input) typer.echo(f" 📝 Mode: {mode}") typer.echo(f" 📥 Input: {inline_input}") else: resolved_input = input or os.getenv("GYOZA_INPUT_PATH", _DEFAULT_INPUT_PATH) mode = "path" if input else "env" typer.echo(f" 📝 Mode: {mode}") typer.echo(f" 📥 Input: {resolved_input}") input_dict = _read_input_file(resolved_input) typer.echo(f" 📤 Output: {resolved_output}") typer.echo(f"\n{_SEPARATOR}") typer.echo(" 🚀 Executing...\n") try: output_dict = func.run(input_dict) except Exception as exc: # noqa: BLE001 fail(f"op failed: {exc}") _write_output_file(resolved_output, output_dict) typer.echo(f"\n{_SEPARATOR}") typer.echo(" ✅ Done.") typer.echo(f"{_SEPARATOR}\n")