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
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from types import ModuleType
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit, urlunsplit

import typer

from gyoza.cli.errors import fail

if TYPE_CHECKING:
    import httpx

_DEFAULT_INPUT_PATH = "/data/input.json"
_DEFAULT_OUTPUT_PATH = "/data/output.json"
_SEPARATOR = "━" * 50
# Seconds to wait before each retry of an upload. The URL names a fixed key,
# so sending the bytes again only overwrites the same object.
_UPLOAD_RETRY_DELAYS = (2, 5, 15, 30)
_RETRYABLE_STATUS = frozenset({408, 429, 500, 502, 503, 504})
# One PUT stops at 5 GiB on S3 and R2; a larger file needs part URLs.
_SINGLE_PUT_MAX = 5 * 1024**3
_READ_CHUNK = 8 * 1024 * 1024


def _load_module(path: Path) -> ModuleType:
    """Load and return a Python module from a file path.

    The module goes into ``sys.modules`` before it executes. Ops declare their
    contracts under ``from __future__ import annotations``, which leaves every
    annotation as a string, and pydantic resolves those strings by looking the
    defining module up in ``sys.modules``. Absent from there, a field like
    ``dict[str, Any] | None`` cannot be resolved — ``Any`` is not a builtin —
    and the op dies at validation time with "Input is not fully defined",
    naming a type the file imports at the top.

    Registering before ``exec_module`` is also what the import system does, so
    a module that refers to itself behaves the same way here. The entry is
    removed again if execution fails, leaving no half-built module behind.
    """
    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)
    sys.modules[name] = module
    try:
        spec.loader.exec_module(module)
    except BaseException:
        sys.modules.pop(name, None)
        raise
    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_json_dict(path: str) -> dict[str, Any]:
    """
    Read a JSON object from a file.

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

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

    Raises
    ------
    OSError
        If the file cannot be read.
    json.JSONDecodeError
        If the file is not valid JSON.
    ValueError
        If the parsed content is not a JSON object.
    """
    with Path(path).open() as fh:
        data = json.load(fh)
    if not isinstance(data, dict):
        msg = "content is not a JSON object (dictionary)"
        raise ValueError(msg)
    return data


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:
        return _read_json_dict(path)
    except (OSError, json.JSONDecodeError) as exc:
        fail(f"failed to read input file '{path}': {exc}")
    except ValueError:
        fail(f"input file '{path}' must contain a JSON object (dictionary)")


def _read_output_template(path: str) -> dict[str, Any]:
    """
    Read the pre-filled outputs template from the output path, if present.

    The worker pre-writes the output file with the caller's partial outputs
    (fields whose value is an upload URL) so this runtime can deliver the
    produced files there.

    Parameters
    ----------
    path : str
        Output file path.

    Returns
    -------
    dict[str, Any]
        The template, or an empty dict when the file is absent or invalid.
    """
    try:
        return _read_json_dict(path)
    except (OSError, json.JSONDecodeError, ValueError):
        return {}


def _is_upload_url(value: Any) -> bool:
    """Return True when *value* is an http(s) URL string."""
    return isinstance(value, str) and value.startswith(("http://", "https://"))


def _canonical_url(url: str) -> str:
    """Strip the query string (signature/expiry) from a presigned URL."""
    parts = urlsplit(url)
    return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))


def _is_parts_target(value: Any) -> bool:
    """Return True when *value* is ``{"url", "parts", "part_size"}``."""
    return (
        isinstance(value, dict)
        and _is_upload_url(value.get("url"))
        and isinstance(value.get("parts"), list)
        and isinstance(value.get("part_size"), int)
    )


def _has_upload_url(template: Any) -> bool:
    """Return True when *template* holds an upload URL at any depth."""
    if _is_upload_url(template) or _is_parts_target(template):
        return True
    if isinstance(template, dict):
        return any(_has_upload_url(v) for v in template.values())
    return False


def _deliver_outputs(
    outputs: dict[str, Any], template: dict[str, Any]
) -> dict[str, Any]:
    """
    Upload output files to the destinations pre-filled in the template.

    For each template field holding an upload URL, the op must have
    produced a local file path in that output field. The file is uploaded
    via HTTP PUT and the field's final value becomes the canonical URL
    (without the presigned query string). Fields without a template URL
    pass through unchanged.

    A template field may also be a dict of upload URLs, for an op whose
    files are keyed by the caller (one per requested item). The op then
    returns a dict under the same field; every key it fills must have an
    upload URL in the template, and each is delivered like a top-level
    field.

    A field the op left as ``None`` is an **optional output that was not
    produced**, not an error: the caller cannot know at submission time
    which optional layers a given run will emit, so it pre-fills the whole
    template. Such a field is skipped and stays ``None`` in the result.
    Producing something that is not a local file is still a failure.

    Parameters
    ----------
    outputs : dict[str, Any]
        Outputs produced by the op function.
    template : dict[str, Any]
        Partial outputs pre-filled by the caller.

    Returns
    -------
    dict[str, Any]
        Final outputs with delivered fields replaced by canonical URLs.

    Raises
    ------
    typer.Exit
        If a field with an upload URL has no local file, a dict field does
        not match its template, or an upload fails.
    """
    if not _has_upload_url(template):
        return outputs

    import httpx  # noqa: PLC0415 — deferred so CLI startup doesn't pay for it

    # No timeout: uploads can be arbitrarily large.
    with httpx.Client(timeout=None) as client:
        return _deliver_level(client, outputs, template, "")


def _deliver_level(
    client: httpx.Client, outputs: dict[str, Any], template: dict[str, Any], prefix: str
) -> dict[str, Any]:
    """Deliver one level of *template*; nested dicts recurse with a dotted *prefix*."""
    import httpx  # noqa: PLC0415

    delivered = dict(outputs)
    for key, target in template.items():
        name = f"{prefix}{key}"
        produced = outputs.get(key)
        if (
            isinstance(target, dict)
            and not _is_parts_target(target)
            and _has_upload_url(target)
        ):
            if produced is None:
                typer.echo(f"  ⏭️  Output '{name}' not produced (optional), skipping.")
                continue
            if not isinstance(produced, dict):
                fail(
                    f"output '{name}' has a dict of upload URLs but the op "
                    f"produced {produced!r}"
                )
            unsigned = [
                k
                for k in produced
                if produced[k] is not None and not _is_upload_url(target.get(k))
            ]
            if unsigned:
                fail(
                    f"output '{name}' produced keys without an upload URL: "
                    + ", ".join(map(str, unsigned))
                )
            delivered[key] = _deliver_level(client, produced, target, f"{name}.")
            continue
        if not (_is_upload_url(target) or _is_parts_target(target)):
            continue
        if produced is None:
            # Optional output the op chose not to produce. The field stays
            # None, which is what the op declared; failing here would kill a
            # run that succeeded, after uploading the earlier keys.
            typer.echo(f"  ⏭️  Output '{name}' not produced (optional), skipping.")
            continue
        if not isinstance(produced, str) or not Path(produced).is_file():
            fail(
                f"output '{name}' has an upload URL but the op did not "
                f"produce a local file (got: {produced!r})"
            )

        typer.echo(f"  📦 Uploading output '{name}' ({produced})...")
        if _is_parts_target(target):
            _upload_in_parts(client, target, Path(produced), name)
            target = target["url"]
        else:
            _upload(client, target, Path(produced), name)

        delivered[key] = _canonical_url(target)
    return delivered


def _upload_in_parts(
    client: httpx.Client, target: dict[str, Any], path: Path, name: str
) -> None:
    """PUT *path* to ``url`` when one PUT takes it, else in ``part_size`` slices."""
    size = path.stat().st_size
    if size <= _SINGLE_PUT_MAX:
        _upload(client, target["url"], path, name)
        return
    part_size, parts = target["part_size"], target["parts"]
    count = -(-size // part_size)
    if count > len(parts):
        fail(
            f"output '{name}' needs {count} parts of {part_size} bytes "
            f"but the run has {len(parts)} part URLs"
        )
    for i in range(count):
        offset = i * part_size
        typer.echo(f"  📦 Part {i + 1}/{count} of '{name}'...")
        _upload(client, parts[i], path, name, offset, min(part_size, size - offset))


def _upload(
    client: httpx.Client,
    target: str,
    path: Path,
    name: str,
    offset: int = 0,
    length: int | None = None,
) -> None:
    """PUT *path*, or *length* bytes of it from *offset*, retrying a storage outage."""
    import httpx  # noqa: PLC0415

    attempts = len(_UPLOAD_RETRY_DELAYS) + 1
    for attempt in range(attempts):
        try:
            with path.open("rb") as fh:
                if length is None:
                    client.put(target, content=fh).raise_for_status()
                else:
                    fh.seek(offset)
                    client.put(
                        target,
                        content=_read_slice(fh, length),
                        headers={"Content-Length": str(length)},
                    ).raise_for_status()
            return
        except httpx.HTTPError as exc:
            if attempt == attempts - 1 or not _retryable(exc):
                fail(f"failed to upload output '{name}': {exc}")
            delay = _UPLOAD_RETRY_DELAYS[attempt]
            typer.echo(
                f"  ↻ Upload of '{name}' failed ({exc}), retrying in {delay}s..."
            )
            time.sleep(delay)


def _read_slice(fh: Any, length: int) -> Iterator[bytes]:
    """Yield the next *length* bytes of *fh* without holding a whole part in memory."""
    while length > 0:
        chunk = fh.read(min(_READ_CHUNK, length))
        if not chunk:
            return
        length -= len(chunk)
        yield chunk


def _retryable(exc: httpx.HTTPError) -> bool:
    import httpx  # noqa: PLC0415

    if isinstance(exc, httpx.HTTPStatusError):
        return exc.response.status_code in _RETRYABLE_STATUS
    return isinstance(exc, httpx.TransportError)


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.
    """
    out = Path(path)
    payload = json.dumps(data, indent=2)
    try:
        out.parent.mkdir(parents=True, exist_ok=True)
        try:
            _replace_output(out, payload)
        except OSError:
            # The rename needs permission on the directory, opening the file
            # needs it on the file. Neither covers every case, so try both.
            out.write_text(payload)
    except OSError as exc:
        fail(f"failed to write output file '{path}': {exc}")


def _replace_output(out: Path, payload: str) -> None:
    """Write *payload* beside *out* and rename it over.

    The rename never truncates what is already there, so a write that dies
    halfway leaves the previous file intact instead of a half-written one.
    """
    tmp = out.with_name(f".{out.name}.{os.getpid()}.tmp")
    try:
        tmp.write_text(payload)
        # The rename swaps the inode, so the mode comes from this process's
        # umask, not from the file being replaced. The worker reads the result
        # back under a different uid, so set it explicitly.
        os.chmod(tmp, 0o644)
        os.replace(tmp, out)
    finally:
        # The worker only clears the run directory once the run is finalised,
        # so debris here outlives the run that left it.
        tmp.unlink(missing_ok=True)


[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") output_template = _read_output_template(resolved_output) try: output_dict = func.run(input_dict) except Exception as exc: # noqa: BLE001 fail(f"op failed: {exc}") output_dict = _deliver_outputs(output_dict, output_template) _write_output_file(resolved_output, output_dict) typer.echo(f"\n{_SEPARATOR}") typer.echo(" ✅ Done.") typer.echo(f"{_SEPARATOR}\n")