diff --git a/src/unstract_cli/commands/common.py b/src/unstract_cli/commands/common.py index 0a7181b..d751911 100644 --- a/src/unstract_cli/commands/common.py +++ b/src/unstract_cli/commands/common.py @@ -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.", + ) + @click.option( + "--interval", + type=int, + default=interval_default, + show_default=True, + help="Interval between polling status checks in seconds.", + ) + @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 - return decorate + return decorator def finish( @@ -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", -] +] \ No newline at end of file diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index 96d911e..d0a2ebb 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -76,6 +76,7 @@ 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. @@ -83,9 +84,11 @@ def resolve_format( 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}.", @@ -428,4 +431,4 @@ def diagnostic( "render", "render_table", "resolve_format", -] +] \ No newline at end of file