Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 182 additions & 77 deletions src/unstract_cli/commands/common.py
Original file line number Diff line number Diff line change
@@ -1,90 +1,175 @@
"""Pieces every product command shares: the wait flags and result emission."""
"""CLI helpers shared across subcommand groups.

Keeping option decorators, state checks and error mappings here keeps individual
command files short and focused on their own flow.
"""

from __future__ import annotations

import os
from collections.abc import Callable
from typing import Any
from typing import Any, TypeVar

import click

from unstract_cli.app import Context
from unstract_cli.core.output import emit_result

#: Poll interval and the ceiling on the whole wait. Both are flags.
DEFAULT_INTERVAL = 3.0
DEFAULT_TIMEOUT = 300.0

#: Below this, polling is a busy loop against a metered service, not a wait.
MIN_INTERVAL = 0.1

F = Callable[..., Any]


def wait_options(*, default: bool = True) -> Callable[[F], F]:
"""`--wait` and its two knobs.

``--wait`` is a gate, not a duration: how long to wait is ``--timeout`` and
how often to check is ``--interval``, so neither has two spellings.
"""

def decorate(func: F) -> F:
for option in reversed(
[
click.option(
"--wait/--no-wait",
default=default,
help="Poll until the job reaches a terminal state.",
),
click.option(
"--interval",
# Bounded below: an interval of zero polls a metered service
# as fast as the loop can issue calls.
type=click.FloatRange(min=MIN_INTERVAL),
default=DEFAULT_INTERVAL,
show_default=True,
help="Seconds between polls.",
),
click.option(
"--timeout",
"wait_timeout",
# Zero is meaningful -- one poll, then give up -- but a
# negative deadline has already passed.
type=click.FloatRange(min=0),
default=DEFAULT_TIMEOUT,
show_default=True,
help="Seconds to wait before giving up. The job keeps running.",
),
click.option(
"--save",
type=click.Path(dir_okay=False),
default=None,
help="Write the result here before printing it.",
),
]
):
func = option(func)
return func

return decorate


def raw_fields(*fields: str) -> Callable[[click.Command], click.Command]:
"""Declare what `--output raw` prints for this command, best answer first.
from unstract_cli.core.errors import CLIError, ExitCode
from unstract_cli.core.output import (
AgentMode,
OutputFormat,
emit_result,
resolve_format,
)

F = TypeVar("F", bound=Callable[..., Any])

DEFAULT_INTERVAL = 5
DEFAULT_TIMEOUT = 300
MIN_INTERVAL = 1


def common_options(func: F) -> F:
"""Attach the flags every command shares: output format, profile, verbose."""

@click.option(
"-o",
"--output",
"explicit_format",
type=click.Choice([f.value for f in OutputFormat]),
default=None,
help="Format for stdout (json, table, raw). JSON emits the stdout contract.",
)
@click.option(
"--agent",
type=click.Choice([m.value for m in AgentMode]),
default=AgentMode.AUTO.value,
show_default=True,
help="Whether to detect coding-agent callers and default to -o json.",
)
@click.option(
"-p",
"--profile",
default=None,
help="Configuration profile to use.",
)
@click.option(
"-v",
"--verbose",
count=True,
help="Increase diagnostic noise on stderr.",
)
@click.option(
"-q",
"--quiet",
is_flag=True,
help="Suppress all diagnostic noise on stderr.",
)
def wrapper(
explicit_format: str | None,
agent: str,
profile: str | None,
verbose: int,
quiet: bool,
*args: Any,
**kwargs: Any,
) -> Any:
# Build the context from global options
ctx = Context(
output=resolve_format(explicit_format, agent=agent),
profile_name=profile,
verbosity=verbose,
quiet=quiet,
)

# Pass context as first positional parameter
return func(ctx, *args, **kwargs)

return wrapper # type: ignore[return-value]


def text_only_option(func: F) -> F:
"""Flag for commands whose output can be stripped to raw text."""

@click.option(
"--text-only",
is_flag=True,
help="Print only raw output strings without JSON envelopes or formatting.",
)
def wrapper(*args: Any, **kwargs: Any) -> Any:
return func(*args, **kwargs)

Several, because one command has several answers: a queued run replies with
a handle and no result, and a status read replies with a state until there
is a result. Raw prints the first of these the answer actually carries.
return wrapper # type: ignore[return-value]

Recorded on the command so `--discover full` can report the whole list: a
caller asking for raw output has to know what it is going to get, and one
field named there would be wrong for every other shape the command returns.
"""

def decorate(command: click.Command) -> click.Command:
command.raw_fields = fields
return command
def wait_options(
*,
timeout_default: int = DEFAULT_TIMEOUT,
interval_default: int = DEFAULT_INTERVAL,
) -> Callable[[F], F]:
"""Attach flags for long-running task polling."""

def decorator(func: F) -> F:
@click.option(
"--wait/--no-wait",
default=True,
show_default=True,
help="Block until the execution finishes.",
)
@click.option(
"--wait-timeout",
"--timeout",
"wait_timeout",
type=int,
default=timeout_default,
show_default=True,
help="Maximum time to wait in seconds.",
)
Comment thread
miraj2729 marked this conversation as resolved.
@click.option(
"--interval",
type=int,
default=interval_default,
show_default=True,
help="Interval between polling status checks in seconds.",
)
Comment thread
miraj2729 marked this conversation as resolved.
@click.option(
"--save",
type=click.Path(),
default=None,
help="Save the result to a file once complete.",
)
def wrapper(
*args: Any,
wait_timeout: int = DEFAULT_TIMEOUT,
interval: int = DEFAULT_INTERVAL,
**kwargs: Any,
) -> Any:
if interval < MIN_INTERVAL:
raise CLIError(
f"Interval must be at least {MIN_INTERVAL} second(s).",
ExitCode.USAGE,
)
if wait_timeout < 0:
raise CLIError(
"Timeout must be at least 0.",
ExitCode.USAGE,
)
return func(
*args, wait_timeout=wait_timeout, interval=interval, **kwargs
)

return wrapper # type: ignore[return-value]

return decorator


def raw_fields(*fields: str) -> Callable[[F], F]:
"""Decorate a command to declare which payload fields raw format should pick."""

def decorator(func: F) -> F:
func._raw_fields = fields # type: ignore[attr-defined]
return func

Comment thread
miraj2729 marked this conversation as resolved.
return decorate
return decorator


def finish(
Expand All @@ -93,22 +178,42 @@ def finish(
*,
raw_fields: tuple[str, ...] = (),
meta: dict[str, Any] | None = None,
text_only: bool = False,
) -> None:
"""Emit one result envelope, scrubbing any resolved credential from it."""
fmt = OutputFormat.RAW if text_only else ctx.output
emit_result(
data,
ctx.output,
fmt,
meta=meta,
raw_fields=raw_fields,
secrets=ctx.secrets(),
)


def require_file(path: str, description: str = "File") -> str:
"""Ensure a required file exists and is readable."""
if not os.path.exists(path):
raise CLIError(
f"{description} not found at {path!r}.",
ExitCode.USAGE,
)
if not os.path.isfile(path):
raise CLIError(
f"{description} path {path!r} is a directory, not a file.",
ExitCode.USAGE,
)
return path


__all__ = [
"DEFAULT_INTERVAL",
"DEFAULT_TIMEOUT",
"MIN_INTERVAL",
"common_options",
"finish",
"raw_fields",
"require_file",
"text_only_option",
"wait_options",
]
]
7 changes: 5 additions & 2 deletions src/unstract_cli/core/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,19 @@ def resolve_format(
explicit: str | None,
agent: str = AgentMode.AUTO,
env: Mapping[str, str] | None = None,
text_only: bool = False,
) -> OutputFormat:
"""The format to render in.

An explicit ``-o`` wins outright, so detection can only ever pick the
default: two runs of ``-o json`` in different environments render the same
bytes, which is the property a script is relying on.
"""
if text_only:
return OutputFormat.RAW
if explicit:
try:
return OutputFormat(explicit)
return OutputFormat.RAW if explicit == "raw" else OutputFormat(explicit)
except ValueError:
raise CLIError(
f"Unknown output format {explicit!r}.",
Expand Down Expand Up @@ -428,4 +431,4 @@ def diagnostic(
"render",
"render_table",
"resolve_format",
]
]