From b93b245102ae5f078bcc71819b5cc21b7d90aa34 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 11 Aug 2026 21:25:57 +0530 Subject: [PATCH 01/86] feat: CLI scaffold with config, output envelope and poll engine Wheel skeleton for the `unstract` console script: Click app with the whisper / docstudio / config groups, and the three cross-cutting layers every command will sit on. - config: named profiles resolved flag > env > profile > default, with `env:` indirection so the file records where a secret lives rather than the secret, 0600 writes, deployment aliases, and `config doctor` reporting where each setting resolved from without echoing a value. - output: one JSON envelope {ok, data, error, meta} on stdout for success and failure alike, so parsing never depends on TTY detection; table and raw are opt-in renderings, diagnostics go to stderr. - errors: the exit-code table as a stable API, retry policy that never retries a 4xx, redaction, and undeclared statuses reported verbatim rather than guessed. - poll: transport-agnostic --wait loop reading terminal state from the response body rather than the HTTP status, never sleeping past the deadline, echoing the job handle on timeout so work resumes instead of being resubmitted, and persisting a one-shot result before the read is acknowledged. No transport yet: the clients own HTTP. Tests are offline and need no credentials. --- .github/workflows/ci.yml | 20 ++ .gitignore | 7 + README.md | 75 +++++ pyproject.toml | 43 +++ src/unstract_cli/__init__.py | 3 + src/unstract_cli/__main__.py | 65 ++++ src/unstract_cli/app.py | 145 +++++++++ src/unstract_cli/commands/__init__.py | 0 src/unstract_cli/commands/config_cmd.py | 242 +++++++++++++++ src/unstract_cli/config.py | 379 ++++++++++++++++++++++++ src/unstract_cli/core/__init__.py | 0 src/unstract_cli/core/errors.py | 246 +++++++++++++++ src/unstract_cli/core/output.py | 255 ++++++++++++++++ src/unstract_cli/core/poll.py | 168 +++++++++++ tests/__init__.py | 0 tests/conftest.py | 38 +++ tests/test_cli.py | 113 +++++++ tests/test_config.py | 206 +++++++++++++ tests/test_errors.py | 101 +++++++ tests/test_output.py | 95 ++++++ tests/test_poll.py | 217 ++++++++++++++ 21 files changed, 2418 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/unstract_cli/__init__.py create mode 100644 src/unstract_cli/__main__.py create mode 100644 src/unstract_cli/app.py create mode 100644 src/unstract_cli/commands/__init__.py create mode 100644 src/unstract_cli/commands/config_cmd.py create mode 100644 src/unstract_cli/config.py create mode 100644 src/unstract_cli/core/__init__.py create mode 100644 src/unstract_cli/core/errors.py create mode 100644 src/unstract_cli/core/output.py create mode 100644 src/unstract_cli/core/poll.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_output.py create mode 100644 tests/test_poll.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ef419db --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + # Offline by design: no network, no credentials, sub-second. Live round trips + # are a manual pre-release step, not a per-PR gate. + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv venv --python 3.12 + - run: uv pip install -e '.[dev]' + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..130baad --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..419a731 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# unstract-cli + +`unstract` — one CLI for the Unstract suite: extract a document with +LLMWhisperer, run it through a Document Studio API deployment, get structured +JSON back. + +```bash +pipx install git+https://github.com/Zipstack/unstract-cli +unstract config init +unstract config doctor +``` + +## Output contract + +stdout always carries exactly one JSON envelope, on success and on failure +alike: + +```json +{"ok": true, "data": {...}, "error": null, "meta": {}} +``` + +Parsing never needs to check whether a terminal is attached. Diagnostics, +warnings and progress go to stderr. `--output table` and `--output raw` are +opt-in renderings of `data` for humans and pipes. + +Failures exit non-zero with a stable code: + +| Code | Meaning | +|------|---------| +| 0 | success | +| 1 | generic failure | +| 2 | usage error | +| 3 | authentication failed | +| 4 | not found | +| 5 | validation failed | +| 6 | rate limited | +| 7 | timed out (the job handle is in the error payload — resume, do not resubmit) | +| 8 | server error | +| 9 | result already consumed (one-shot read; use `--save` next time) | + +## Configuration + +`~/.unstract/config.toml`, or a project-local `.unstract.toml` found by upward +search, or `$UNSTRACT_CONFIG`, or `--config`. Every setting resolves +**flag > env > profile > built-in default**, and the CLI is fully usable with no +config file at all. + +```toml +default_profile = "cloud-us" + +[profiles.cloud-us.llmwhisperer] +base_url = "https://llmwhisperer-api.us-central.unstract.com/api/v2" +api_key = "env:LLMWHISPERER_API_KEY" + +[profiles.cloud-us.docstudio] +base_url = "https://us-central.unstract.com" +org_id = "org_ABC123" +api_key = "env:UNSTRACT_DEPLOYMENT_KEY" + +[profiles.cloud-us.deployments.invoices] +api_name = "invoice-parser" +``` + +Credentials use `env:VAR_NAME` indirection, so the file records where a secret +lives rather than the secret itself. `unstract config doctor` reports where each +setting resolved from — including whether an `env:` reference is actually set in +the current process — without echoing any value. + +## Development + +```bash +uv venv && uv pip install -e '.[dev]' +pytest # offline; no network, no credentials +ruff check . +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d6eb750 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "unstract-cli" +version = "0.1.0" +description = "Unified, LLM-friendly CLI for the Unstract suite of products" +readme = "README.md" +requires-python = ">=3.12" + +dependencies = [ + # Click is pinned to a major: `--discover` reads the shape of + # `click.Parameter.to_info_dict()`, which a major bump could reshape. + "click>=8.1,<9", + # Zero transitive dependencies. Writing the config file only; reading it + # uses the stdlib `tomllib`. + "tomli-w>=1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "ruff>=0.6", +] + +[project.scripts] +unstract = "unstract_cli.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/unstract_cli"] + +[tool.ruff] +line-length = 90 +target-version = "py312" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/unstract_cli/__init__.py b/src/unstract_cli/__init__.py new file mode 100644 index 0000000..c85c094 --- /dev/null +++ b/src/unstract_cli/__init__.py @@ -0,0 +1,3 @@ +"""Unstract CLI.""" + +__version__ = "0.1.0" diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py new file mode 100644 index 0000000..69e8025 --- /dev/null +++ b/src/unstract_cli/__main__.py @@ -0,0 +1,65 @@ +"""Entry point: turns every failure into an envelope plus a stable exit code. + +Click's own error handling is bypassed on purpose. By default it prints prose to +stderr and exits 1 or 2 with nothing on stdout, which leaves a caller parsing +stdout with an empty stream and no way to tell a usage error from a server +failure. +""" + +from __future__ import annotations + +import sys + +import click + +from unstract_cli.app import cli +from unstract_cli.config import ConfigError +from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.output import OutputFormat, emit_error + + +def _format_from_argv(argv: list[str]) -> OutputFormat: + """Best-effort read of --output before Click has parsed anything. + + A failure during parsing still has to be rendered, and the parsed context + does not exist yet at that point. + """ + for i, arg in enumerate(argv): + value = None + if arg.startswith("--output="): + value = arg.split("=", 1)[1] + elif arg in ("--output", "-o") and i + 1 < len(argv): + value = argv[i + 1] + if value: + try: + return OutputFormat(value) + except ValueError: + break + return OutputFormat.JSON + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + fmt = _format_from_argv(args) + try: + cli.main(args=args, standalone_mode=False) + except CLIError as exc: + return int(emit_error(exc, fmt)) + except ConfigError as exc: + return int(emit_error(CLIError(str(exc), ExitCode.USAGE), fmt)) + except click.UsageError as exc: + return int( + emit_error( + CLIError(exc.format_message(), ExitCode.USAGE, hint="Run with --help."), + fmt, + ) + ) + except click.Abort: + return int(ExitCode.GENERIC) + except click.exceptions.Exit as exc: # --help and --version exit through here + return int(exc.exit_code) + return int(ExitCode.SUCCESS) + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py new file mode 100644 index 0000000..7ac17f7 --- /dev/null +++ b/src/unstract_cli/app.py @@ -0,0 +1,145 @@ +"""The root Click application: global options and the command groups. + +Global options are declared once here and reach every command through the Click +context, so no command re-implements profile selection or output formatting. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import click + +from unstract_cli.commands.config_cmd import config_group +from unstract_cli.config import ConfigError, ResolvedConfig, load_config, set_config_path +from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.output import OutputFormat, diagnostic + + +@dataclass +class Context: + """Everything a command needs from the global options.""" + + output: OutputFormat = OutputFormat.JSON + quiet: bool = False + verbosity: int = 0 + profile: str | None = None + _config: ResolvedConfig | None = field(default=None, repr=False) + + @property + def config(self) -> ResolvedConfig: + """Load the config lazily, so commands that need none never read a file.""" + if self._config is None: + try: + cfg = load_config() + except ConfigError as exc: + raise CLIError(str(exc), ExitCode.USAGE) from exc + for warning in cfg.warnings: + diagnostic(warning, quiet=self.quiet, verbosity=self.verbosity) + self._config = ResolvedConfig(file=cfg, profile_name=self.profile) + return self._config + + def secrets(self) -> list[str]: + """Resolved credentials, for scrubbing anything on its way to a stream.""" + from unstract_cli.config import DOCSTUDIO, LLMWHISPERER + + out: list[str] = [] + for product in (LLMWHISPERER, DOCSTUDIO): + try: + if value := self.config.get(product, "api_key"): + out.append(str(value)) + except ConfigError: + continue + return out + + +pass_context = click.make_pass_decorator(Context, ensure=True) + + +@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.option( + "--config", + "config_file", + default=None, + type=click.Path(dir_okay=False), + help="Config file to use, overriding discovery.", +) +@click.option("--profile", "-p", default=None, help="Configuration profile to use.") +@click.option( + "--output", + "-o", + type=click.Choice([f.value for f in OutputFormat]), + default=OutputFormat.JSON.value, + help="Output format. JSON is the default everywhere, including a terminal.", +) +@click.option( + "--quiet", + "-q", + is_flag=True, + default=False, + help="Suppress diagnostics on stderr. stdout is unaffected.", +) +@click.option("--verbose", "-v", count=True, help="Increase diagnostic detail.") +@click.version_option(package_name="unstract-cli") +@click.pass_context +def cli( + ctx: click.Context, + config_file: str | None, + profile: str | None, + output: str, + quiet: bool, + verbose: int, +) -> None: + """Unstract CLI: extract documents and run API deployments. + + stdout always carries one JSON envelope -- {ok, data, error, meta} -- so + output parses without checking whether a terminal is attached. Diagnostics go + to stderr. + """ + set_config_path(config_file) + ctx.obj = Context( + output=OutputFormat(output), + quiet=quiet, + verbosity=verbose, + profile=profile, + ) + + +@cli.group("whisper") +def whisper_group() -> None: + """Extract text and layout from documents with LLMWhisperer.""" + + +@cli.group("docstudio") +def docstudio_group() -> None: + """Run Document Studio API deployments.""" + + +@docstudio_group.group("deployment") +def deployment_group() -> None: + """Work with a deployed API.""" + + +cli.add_command(config_group) + + +def command_tree() -> dict[str, Any]: + """The registered command tree, read back from Click itself. + + Describing commands anywhere but from the parser lets the description drift + from what the parser accepts, so discovery and help always read this. + """ + + def walk(command: click.Command) -> dict[str, Any]: + entry: dict[str, Any] = {"help": (command.help or "").strip().split("\n")[0]} + if isinstance(command, click.Group): + entry["commands"] = { + name: walk(sub) for name, sub in sorted(command.commands.items()) + } + return entry + + return walk(cli)["commands"] + + +__all__ = ["Context", "cli", "command_tree", "pass_context"] diff --git a/src/unstract_cli/commands/__init__.py b/src/unstract_cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py new file mode 100644 index 0000000..bc737e3 --- /dev/null +++ b/src/unstract_cli/commands/config_cmd.py @@ -0,0 +1,242 @@ +"""The `config` command group -- local only, no network calls. + +These commands map to no API operation: they operate purely on the local config +layer, and they are how a user or an agent bootstraps every other command. + +Nothing here prompts: `init` refuses to clobber an existing file unless +`--force` is passed, rather than asking, so the CLI behaves the same whether or +not a human is watching. +""" + +from __future__ import annotations + +from typing import Any + +import click + +from unstract_cli.config import ( + PRODUCTS, + ConfigError, + ConfigFile, + ResolvedConfig, + config_path, + load_config, + save_config, + starter_profiles, +) +from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.output import OutputFormat, emit_result + +#: Keys whose value is never echoed back, even on explicit request: this output +#: is as likely to land in a log or a transcript as on a screen. +_SECRET_KEY_HINTS = ("key", "token", "secret") + + +def _is_secret(key: str) -> bool: + return any(hint in key.lower() for hint in _SECRET_KEY_HINTS) + + +def _fmt(obj: Any) -> OutputFormat: + """Output format from the root context, defaulting when invoked standalone.""" + return getattr(obj, "output", None) or OutputFormat.JSON + + +def _check_product(product: str) -> str: + if product not in PRODUCTS: + raise CLIError( + f"Unknown config target {product!r}.", + ExitCode.USAGE, + hint="Valid targets: " + ", ".join(PRODUCTS) + ".", + ) + return product + + +@click.group(name="config", help="Manage CLI configuration profiles (local only).") +def config_group() -> None: + """Local configuration management. These commands make no network calls.""" + + +@config_group.command("init", help="Create a starter config file with profile stubs.") +@click.option( + "--force", is_flag=True, default=False, help="Overwrite an existing config file." +) +@click.pass_obj +def config_init(obj: Any, force: bool) -> None: + path = config_path() + if path.exists() and not force: + # Never prompt: state the situation and the exact flag that resolves it. + raise CLIError( + f"Config already exists at {path}.", + ExitCode.USAGE, + hint="Pass --force to overwrite it, or edit the file directly.", + ) + + replaced = path.exists() + new = ConfigFile( + default_profile="cloud-us", profiles=starter_profiles(), path=path, exists=True + ) + written = save_config(new, path) + emit_result( + { + "created": str(written), + "default_profile": "cloud-us", + "profiles": sorted(new.profiles), + "replaced_existing": replaced, + "note": ( + "Credentials use env: indirection, so this file holds no secrets. " + "Set the referenced environment variables to authenticate." + ), + }, + _fmt(obj), + ) + + +@config_group.command("list", help="List profiles defined in the config file.") +@click.pass_obj +def config_list(obj: Any) -> None: + cfg = load_config() + emit_result( + { + "path": str(cfg.path), + "exists": cfg.exists, + "default_profile": cfg.default_profile, + "profiles": { + name: { + block: sorted(settings) if isinstance(settings, dict) else settings + for block, settings in blocks.items() + } + for name, blocks in cfg.profiles.items() + }, + }, + _fmt(obj), + ) + + +@config_group.command("get") +@click.argument("product") +@click.argument("key") +@click.pass_obj +def config_get(obj: Any, product: str, key: str) -> None: + """Show a resolved setting, following flag > env > profile > default. + + PRODUCT and KEY are positional -- not flags. Credentials are reported as + configured or not, never echoed. + + \b + Examples: + unstract config get docstudio org_id + unstract --profile cloud-eu config get llmwhisperer base_url + """ + _check_product(product) + try: + value = _resolved(obj).get(product, key) + except ConfigError as exc: + raise CLIError(str(exc), ExitCode.USAGE) from exc + + emit_result( + { + "product": product, + "key": key, + "value": ("***SET***" if value else None) if _is_secret(key) else value, + "configured": value is not None, + }, + _fmt(obj), + ) + + +@config_group.command("set") +@click.argument("product") +@click.argument("key") +@click.argument("value") +@click.option("--profile", "-p", "profile", default=None, help="Profile to write to.") +@click.pass_obj +def config_set(obj: Any, product: str, key: str, value: str, profile: str | None) -> None: + """Set a value in the config file. + + PRODUCT, KEY and VALUE are positional -- not flags. Writes to the active + profile unless --profile names another. + + \b + Examples: + unstract config set docstudio org_id org_ABC123 + unstract config set llmwhisperer api_key 'env:LLMWHISPERER_API_KEY' + + \b + Prefer `env:VAR_NAME` for credentials: the file then records where the secret + lives rather than the secret itself, and a literal value also lands in your + shell history. + """ + _check_product(product) + cfg = load_config() + name = profile or getattr(obj, "profile", None) or cfg.default_profile or "cloud-us" + + cfg.profiles.setdefault(name, {}).setdefault(product, {})[key] = value + if not cfg.default_profile: + cfg.default_profile = name + written = save_config(cfg) + + warning = None + if _is_secret(key) and not value.startswith("env:"): + warning = ( + "Value stored literally. Prefer `env:VAR_NAME` so the config file holds " + "a reference rather than the secret itself." + ) + + emit_result( + { + "profile": name, + "product": product, + "key": key, + "path": str(written), + "warning": warning, + }, + _fmt(obj), + ) + + +@config_group.command("doctor", help="Diagnose how each setting resolves.") +@click.pass_obj +def config_doctor(obj: Any) -> None: + """Report where each setting resolves from, without echoing any secret. + + Answers the question that costs the most time: the CLI reports a key as "not + configured", but you set it -- where is it looking? For `env:` references it + says whether the variable is present in THIS process, a shell `export` in a + login profile the CLI never inherited being the classic trap. + """ + resolved = _resolved(obj) + products: dict[str, Any] = {} + for product in PRODUCTS: + entry: dict[str, Any] = {} + for key in ("base_url", "api_key", "org_id"): + try: + entry[key] = resolved.resolution_source(product, key) + except ConfigError as exc: + entry[key] = {"resolved": False, "source": "unset", "detail": str(exc)} + products[product] = entry + + try: + aliases = list(resolved.deployment_aliases()) + except ConfigError: + aliases = [] + + emit_result( + { + "active_profile": resolved.active_profile, + "config_path": str(resolved.file.path), + "config_exists": resolved.file.exists, + "products": products, + "deployment_aliases": aliases, + }, + _fmt(obj), + ) + + +def _resolved(obj: Any) -> ResolvedConfig: + """The root context's config, or a freshly loaded one when invoked standalone.""" + if (existing := getattr(obj, "_config", None)) is not None: + return existing + return ResolvedConfig(file=load_config(), profile_name=getattr(obj, "profile", None)) + + +__all__ = ["config_group"] diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py new file mode 100644 index 0000000..efdd3ed --- /dev/null +++ b/src/unstract_cli/config.py @@ -0,0 +1,379 @@ +"""Profile-based configuration. + +Two products with different hosts, different keys, and `org_id` as a URL *path +segment* rather than a flag. Named profiles (kubectl/aws style) hold per-product +host, key and org, plus deployment aliases so a deployment can be named instead +of spelled out. + +The resolution chain -- **flag > env > profile > built-in default** -- is +implemented once here and used by every parameter. It is never re-implemented +per command. + +The CLI is fully usable with **no config file at all**, driven entirely by +environment variables; that is the expected mode in CI and agent sandboxes. +""" + +from __future__ import annotations + +import os +import stat +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import tomli_w + +LLMWHISPERER = "llmwhisperer" +DOCSTUDIO = "docstudio" +PRODUCTS: tuple[str, ...] = (LLMWHISPERER, DOCSTUDIO) + +#: Built-in defaults, lowest precedence. +DEFAULT_BASE_URLS: dict[str, str] = { + LLMWHISPERER: "https://llmwhisperer-api.us-central.unstract.com/api/v2", + DOCSTUDIO: "https://us-central.unstract.com", +} + +#: Environment variables per (product, setting), checked before the config file. +ENV_VARS: dict[tuple[str, str], tuple[str, ...]] = { + (LLMWHISPERER, "api_key"): ("LLMWHISPERER_API_KEY",), + (LLMWHISPERER, "base_url"): ("LLMWHISPERER_BASE_URL",), + (DOCSTUDIO, "api_key"): ("UNSTRACT_DEPLOYMENT_KEY",), + (DOCSTUDIO, "base_url"): ("UNSTRACT_BASE_URL",), + (DOCSTUDIO, "org_id"): ("UNSTRACT_ORG_ID",), +} + +#: Filename a project can commit to point the CLI at its own settings. +PROJECT_CONFIG_NAME = ".unstract.toml" + +#: Where the config lives when nothing else selects one. +HOME_CONFIG = Path("~/.unstract/config.toml") + + +class ConfigError(Exception): + """Configuration could not be loaded or resolved.""" + + +#: Set by the root `--config` flag. Highest precedence, matching the +#: flag > env > file ordering used for every other setting. +_config_override: Path | None = None + + +def set_config_path(path: str | Path | None) -> None: + """Point this process at a specific config file (the `--config` flag).""" + global _config_override + _config_override = Path(path).expanduser() if path else None + + +def find_project_config(start: Path | None = None) -> Path | None: + """Search upward from the working directory for ``.unstract.toml``. + + Mirrors how git and ruff resolve project settings: running the CLI inside a + project picks up that project's config with no flag. The search stops at the + filesystem root, and at ``$HOME`` so a stray file in a parent directory + cannot silently capture every invocation. + """ + current = (start or Path.cwd()).resolve() + home = Path.home().resolve() + for directory in (current, *current.parents): + candidate = directory / PROJECT_CONFIG_NAME + if candidate.is_file(): + return candidate + if directory == home: + break + return None + + +def config_path() -> Path: + """Location of the config file. + + Resolution: ``--config``, then ``$UNSTRACT_CONFIG``, then a project-local + ``.unstract.toml`` found by upward search, then ``~/.unstract/config.toml``. + + Several config files coexisting is expected, not exceptional: a per-project + file checked into a repo, a throwaway one in CI, and a personal default, each + selected per invocation. + """ + if _config_override is not None: + return _config_override + if override := os.environ.get("UNSTRACT_CONFIG"): + return Path(override).expanduser() + if local := find_project_config(): + return local + return HOME_CONFIG.expanduser() + + +def _deref(value: Any) -> Any: + """Resolve ``env:VAR_NAME`` indirection so config files hold no secrets. + + An unset variable resolves to ``None`` rather than the literal string, so a + missing credential surfaces as "not configured" instead of being sent as the + nonsense value ``"env:FOO"``. + """ + if isinstance(value, str) and value.startswith("env:"): + return os.environ.get(value[4:].strip()) or None + return value + + +@dataclass +class ConfigFile: + """Parsed contents of the config file.""" + + default_profile: str | None = None + profiles: dict[str, dict[str, Any]] = field(default_factory=dict) + path: Path | None = None + exists: bool = False + #: Non-fatal diagnostics (e.g. loose file permissions), surfaced on stderr. + warnings: tuple[str, ...] = () + + +def load_config(path: Path | None = None) -> ConfigFile: + """Load the config file. A missing file is normal, not an error.""" + target = path or config_path() + if not target.exists(): + return ConfigFile(path=target, exists=False) + + try: + with target.open("rb") as fh: + raw = tomllib.load(fh) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise ConfigError(f"Could not read config at {target}: {exc}") from exc + + warnings: list[str] = [] + try: + mode = target.stat().st_mode + if mode & (stat.S_IRWXG | stat.S_IRWXO): + warnings.append( + f"Config file {target} is readable by other users " + f"(mode {stat.filemode(mode)}); consider `chmod 600`." + ) + except OSError: # pragma: no cover - stat failure is not worth failing on + pass + + profiles = raw.get("profiles", {}) + if not isinstance(profiles, dict): + raise ConfigError(f"`profiles` in {target} must be a table.") + + return ConfigFile( + default_profile=raw.get("default_profile"), + profiles=profiles, + path=target, + exists=True, + warnings=tuple(warnings), + ) + + +def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: + """Write the config file with owner-only permissions.""" + target = path or cfg.path or config_path() + target.parent.mkdir(parents=True, exist_ok=True) + + doc: dict[str, Any] = {} + if cfg.default_profile: + doc["default_profile"] = cfg.default_profile + doc["profiles"] = cfg.profiles + + # Create with 0600 from the outset rather than widening then narrowing: a + # world-readable window, however brief, is a window. + fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "wb") as fh: + tomli_w.dump(doc, fh) + os.chmod(target, 0o600) + return target + + +@dataclass +class ResolvedConfig: + """Effective settings for one invocation. + + ``overrides`` holds command-line flags, which outrank everything else. + """ + + file: ConfigFile + profile_name: str | None = None + overrides: dict[str, Any] = field(default_factory=dict) + + @property + def active_profile(self) -> str | None: + """Profile selected by flag, ``UNSTRACT_PROFILE``, or the file default.""" + return ( + self.profile_name + or os.environ.get("UNSTRACT_PROFILE") + or self.file.default_profile + ) + + def _profile(self) -> dict[str, Any]: + name = self.active_profile + if not name: + return {} + profile = self.file.profiles.get(name) + if profile is None: + if self.file.exists and self.file.profiles: + known = ", ".join(sorted(self.file.profiles)) or "none" + raise ConfigError( + f"Profile {name!r} not found in {self.file.path}. " + f"Known profiles: {known}" + ) + return {} + return profile if isinstance(profile, dict) else {} + + def _product_block(self, product: str) -> dict[str, Any]: + # Exactly one accepted shape: settings nested under the product name. No + # aliases and no flat fallback -- a config that looks applied but is not + # is worse than one that plainly is not, because the failure surfaces + # later as a missing-credential error with no obvious cause. + block = self._profile().get(product) + return block if isinstance(block, dict) else {} + + def get(self, product: str, key: str, default: Any = None) -> Any: + """Resolve one setting: **flag > env > profile > built-in default**.""" + if (value := self.overrides.get(f"{product}.{key}")) is not None: + return value + if (value := self.overrides.get(key)) is not None: + return value + + for env_var in ENV_VARS.get((product, key), ()): + if value := os.environ.get(env_var): + return value + + if (value := _deref(self._product_block(product).get(key))) is not None: + return value + + if default is not None: + return default + if key == "base_url": + return DEFAULT_BASE_URLS.get(product) + return None + + def require(self, product: str, key: str) -> Any: + """Resolve a setting, or raise a message naming exactly how to supply it.""" + if (value := self.get(product, key)) is not None: + return value + + hints: list[str] = [] + if env_vars := ENV_VARS.get((product, key)): + hints.append(f"set ${env_vars[0]}") + hints.append(f"or add `{key}` to the [profiles..{product}] block") + # Only suggest a flag that actually exists. Credentials have no flag by + # design -- a secret on the command line lands in shell history and + # process listings. + if key != "api_key": + hints.append(f"or pass --{key.replace('_', '-')}") + raise ConfigError( + f"Missing required setting {product}.{key}. To fix: {'; '.join(hints)}." + ) + + def deployment(self, alias: str) -> dict[str, Any]: + """Resolve a deployment alias to its api_name, org and key. + + ``org_id`` and ``api_key`` are optional per alias and fall back to the + profile's Document Studio block, so the common case is one line per + deployment. + """ + aliases = self._profile().get("deployments") + entry = aliases.get(alias) if isinstance(aliases, dict) else None + if not isinstance(entry, dict): + known = ( + ", ".join(sorted(aliases)) + if isinstance(aliases, dict) and aliases + else "none" + ) + raise ConfigError( + f"Deployment alias {alias!r} not found in profile " + f"{self.active_profile!r}. Known aliases: {known}." + ) + if not entry.get("api_name"): + raise ConfigError(f"Deployment alias {alias!r} has no `api_name`.") + return { + "api_name": entry["api_name"], + "org_id": _deref(entry.get("org_id")) or self.get(DOCSTUDIO, "org_id"), + "api_key": _deref(entry.get("api_key")) or self.get(DOCSTUDIO, "api_key"), + } + + def deployment_aliases(self) -> tuple[str, ...]: + """Names of the deployment aliases defined in the active profile.""" + aliases = self._profile().get("deployments") + return tuple(sorted(aliases)) if isinstance(aliases, dict) else () + + def resolution_source(self, product: str, key: str) -> dict[str, Any]: + """Report where a setting resolves from, without echoing a secret. + + `config doctor` uses this to answer the question that costs the most + time: "the CLI says the key is not configured, but I set it -- where is + it looking?" + """ + if ( + self.overrides.get(f"{product}.{key}") is not None + or self.overrides.get(key) is not None + ): + return {"resolved": True, "source": "flag/override"} + + for env_var in ENV_VARS.get((product, key), ()): + if os.environ.get(env_var): + return {"resolved": True, "source": f"env:{env_var}"} + + raw = self._product_block(product).get(key) + if isinstance(raw, str) and raw.startswith("env:"): + var = raw[4:].strip() + present = bool(os.environ.get(var)) + return { + "resolved": present, + "source": f"profile -> env:{var}", + "detail": None + if present + else f"${var} is not set in this process's environment", + } + if raw not in (None, ""): + return {"resolved": True, "source": "profile (literal)"} + + if key == "base_url" and DEFAULT_BASE_URLS.get(product): + return {"resolved": True, "source": "built-in default"} + return {"resolved": False, "source": "unset"} + + +def starter_profiles() -> dict[str, dict[str, Any]]: + """Profile stubs written by `config init`. + + Every credential uses ``env:`` indirection: the generated file is a map of + where secrets live, never a copy of them. + """ + return { + "cloud-us": { + LLMWHISPERER: { + "base_url": DEFAULT_BASE_URLS[LLMWHISPERER], + "api_key": "env:LLMWHISPERER_API_KEY", + }, + DOCSTUDIO: { + "base_url": DEFAULT_BASE_URLS[DOCSTUDIO], + "org_id": "", + "api_key": "env:UNSTRACT_DEPLOYMENT_KEY", + }, + "deployments": {}, + }, + "cloud-eu": { + LLMWHISPERER: { + "base_url": "https://llmwhisperer-api.eu-west.unstract.com/api/v2", + "api_key": "env:LLMWHISPERER_API_KEY", + }, + }, + } + + +__all__ = [ + "DEFAULT_BASE_URLS", + "DOCSTUDIO", + "ENV_VARS", + "HOME_CONFIG", + "LLMWHISPERER", + "PRODUCTS", + "PROJECT_CONFIG_NAME", + "ConfigError", + "ConfigFile", + "ResolvedConfig", + "config_path", + "find_project_config", + "load_config", + "save_config", + "set_config_path", + "starter_profiles", +] diff --git a/src/unstract_cli/core/__init__.py b/src/unstract_cli/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py new file mode 100644 index 0000000..ef68f4c --- /dev/null +++ b/src/unstract_cli/core/errors.py @@ -0,0 +1,246 @@ +"""Exit codes, structured errors, and secret redaction. + +Exit codes are a stable API: a caller branches on them without parsing prose. +Every failure also carries `hint` and `retryable` so the caller can self-correct +rather than retry blindly. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Any + + +class ExitCode(IntEnum): + SUCCESS = 0 + GENERIC = 1 + USAGE = 2 + AUTH = 3 + NOT_FOUND = 4 + VALIDATION = 5 + RATE_LIMITED = 6 + TIMEOUT = 7 + SERVER_ERROR = 8 + ALREADY_CONSUMED = 9 + + +#: HTTP status -> exit code. 422 maps to VALIDATION, which is right for a real +#: validation failure; the deployment API's use of 422 for in-progress states is +#: handled by the poll engine before reaching here, by branching on the response +#: body rather than the status code. +_STATUS_MAP: dict[int, ExitCode] = { + 400: ExitCode.VALIDATION, + 401: ExitCode.AUTH, + 403: ExitCode.AUTH, + 404: ExitCode.NOT_FOUND, + 406: ExitCode.ALREADY_CONSUMED, + 408: ExitCode.TIMEOUT, + 409: ExitCode.VALIDATION, + 422: ExitCode.VALIDATION, + 429: ExitCode.RATE_LIMITED, +} + +_ERROR_CODES: dict[ExitCode, str] = { + ExitCode.GENERIC: "error", + ExitCode.USAGE: "usage_error", + ExitCode.AUTH: "auth_error", + ExitCode.NOT_FOUND: "not_found", + ExitCode.VALIDATION: "validation_error", + ExitCode.RATE_LIMITED: "rate_limited", + ExitCode.TIMEOUT: "timeout", + ExitCode.SERVER_ERROR: "server_error", + ExitCode.ALREADY_CONSUMED: "already_consumed", +} + + +def exit_code_for_status(status: int) -> ExitCode: + """Map an HTTP status onto its exit code.""" + if code := _STATUS_MAP.get(status): + return code + if 500 <= status < 600: + return ExitCode.SERVER_ERROR + if 400 <= status < 500: + return ExitCode.GENERIC + return ExitCode.SUCCESS + + +def is_retryable(status: int) -> bool: + """Retry only on rate limiting and server faults -- never on 4xx. + + Retrying a 4xx re-sends a request the server already rejected on its merits, + and for one-shot reads a blind retry can consume a result the first attempt + already delivered. + """ + return status == 429 or 500 <= status < 600 + + +# --------------------------------------------------------------------------- # +# Redaction +# --------------------------------------------------------------------------- # + +_SECRET_HEADERS = {"unstract-key", "authorization", "apikey"} +_SECRET_HEADER_PREFIXES = ("x-",) +_SECRET_KEY_HINTS = ("key", "token", "secret", "password", "credential", "auth") +REDACTED = "***REDACTED***" + + +def redact_headers(headers: dict[str, Any]) -> dict[str, Any]: + """Redact credential-bearing headers.""" + out: dict[str, Any] = {} + for key, value in headers.items(): + low = key.lower() + secret = low in _SECRET_HEADERS or ( + low.startswith(_SECRET_HEADER_PREFIXES) + and any(hint in low for hint in _SECRET_KEY_HINTS) + ) + out[key] = REDACTED if secret else value + return out + + +def redact_value(value: Any) -> Any: + """Recursively redact secret-looking keys in a payload.""" + if isinstance(value, dict): + return { + k: ( + REDACTED + if any(hint in str(k).lower() for hint in _SECRET_KEY_HINTS) + and isinstance(v, str) + else redact_value(v) + ) + for k, v in value.items() + } + if isinstance(value, list): + return [redact_value(v) for v in value] + return value + + +def scrub(text: str, secrets: list[str]) -> str: + """Remove known secret literals from free text. + + Last line of defence: a credential that reaches a message body via an + upstream error string still must not be printed. Short values are skipped -- + redacting a 3-character "key" would mangle unrelated text. + """ + for secret in secrets: + if secret and len(secret) >= 8: + text = re.sub(re.escape(secret), REDACTED, text) + return text + + +# --------------------------------------------------------------------------- # +# CLIError +# --------------------------------------------------------------------------- # + + +@dataclass +class CLIError(Exception): + """A failure that maps onto an exit code and a structured error payload.""" + + message: str + exit_code: ExitCode = ExitCode.GENERIC + http_status: int | None = None + details: Any = None + endpoint: str | None = None + hint: str | None = None + retryable: bool = False + code: str | None = None + extra: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + super().__init__(self.message) + + def to_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "code": self.code or _ERROR_CODES.get(self.exit_code, "error"), + "message": self.message, + "exit_code": int(self.exit_code), + "retryable": self.retryable, + } + if self.http_status is not None: + payload["http_status"] = self.http_status + if self.details is not None: + payload["details"] = self.details + if self.endpoint: + payload["endpoint"] = self.endpoint + if self.hint: + payload["hint"] = self.hint + payload.update(self.extra) + return payload + + +def error_from_status( + status: int, message: str, *, details: Any = None, endpoint: str | None = None +) -> CLIError: + """Build a CLIError from an HTTP status, with its exit code, hint and retryability.""" + return CLIError( + message, + exit_code_for_status(status), + http_status=status, + details=details, + endpoint=endpoint, + hint=hint_for(status), + retryable=is_retryable(status), + ) + + +def undeclared_status_error( + status: int, body: Any, endpoint: str | None = None +) -> CLIError: + """Report a status the spec does not declare, verbatim. + + A guessed message for an unknown status is worse than none: it sends the + reader after the wrong cause. The body is passed through untouched. + """ + return CLIError( + f"Undeclared status {status} with body {body!r}", + exit_code_for_status(status), + http_status=status, + details=body, + endpoint=endpoint, + retryable=is_retryable(status), + ) + + +def hint_for(status: int) -> str | None: + """A short, actionable next step for a common failure.""" + match status: + case 401 | 403: + return ( + "Check the API key for this product. Keys are per-product: " + "`unstract config doctor` reports which one resolved and from where." + ) + case 404: + return ( + "Verify the resource id, and that the organisation matches the " + "resource's own. For deployments, confirm the API name." + ) + case 406: + return ( + "This result was already retrieved. Results can be read exactly " + "once; re-running the request cannot recover them. Use --save next " + "time to persist on first read." + ) + case 409: + return "The resource is in use, or conflicts with an existing one." + case 429: + return "Rate limited. Back off and retry." + if 500 <= status < 600: + return "Server-side failure. If it persists, check service status." + return None + + +__all__ = [ + "REDACTED", + "CLIError", + "ExitCode", + "error_from_status", + "exit_code_for_status", + "hint_for", + "is_retryable", + "redact_headers", + "redact_value", + "scrub", + "undeclared_status_error", +] diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py new file mode 100644 index 0000000..0b139d0 --- /dev/null +++ b/src/unstract_cli/core/output.py @@ -0,0 +1,255 @@ +"""Output rendering. + +The contract a caller depends on: + +* **stdout carries one JSON envelope and nothing else** -- ``{ok, data, error, + meta}`` -- on success and on failure alike, so parsing never needs TTY + detection and a failed run still yields a valid object rather than an empty + stream. +* Human-facing notes, warnings and progress all go to stderr. +* ``--output table|raw`` are opt-in human/pipe renderings of ``data``. +""" + +from __future__ import annotations + +import json +import shutil +import sys +import textwrap +from enum import StrEnum +from typing import Any + +from unstract_cli.core.errors import CLIError, ExitCode, scrub + + +class OutputFormat(StrEnum): + JSON = "json" + TABLE = "table" + RAW = "raw" + + +def envelope( + *, + data: Any = None, + error: dict[str, Any] | None = None, + meta: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the stdout envelope. ``ok`` is derived, never passed in.""" + return {"ok": error is None, "data": data, "error": error, "meta": meta or {}} + + +def _flatten(value: Any) -> str: + """Render a cell. Nested structures become compact JSON, not Python reprs.""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (dict, list)): + return json.dumps(value, default=str) + return str(value) + + +def _rows_and_columns( + data: Any, columns: tuple[str, ...] = () +) -> tuple[list[str], list[list[str]]]: + """Derive table columns and rows from arbitrary JSON. + + List of objects -> columns from the union of keys in first-seen order; + single object -> a two-column key/value listing; anything else -> one + ``value`` column. ``columns`` overrides the selection where the generic rule + reads poorly. + """ + if isinstance(data, dict): + # Unwrap a single list-valued envelope, e.g. {"results": [...]}. + for key in ("results", "message", "members", "data", "highlights"): + inner = data.get(key) + if isinstance(inner, list) and inner: + data = inner + break + + if isinstance(data, list): + if not data: + return [], [] + if all(isinstance(item, dict) for item in data): + if columns: + headers = list(columns) + else: + headers = [] + for item in data: + headers.extend(k for k in item if k not in headers) + return headers, [[_flatten(item.get(h)) for h in headers] for item in data] + return ["value"], [[_flatten(item)] for item in data] + + if isinstance(data, dict): + keys = list(columns) if columns else list(data) + return ["key", "value"], [[k, _flatten(data.get(k))] for k in keys] + + return ["value"], [[_flatten(data)]] + + +def _terminal_width(default: int = 100) -> int: + try: + return max(shutil.get_terminal_size((default, 24)).columns, 40) + except Exception: # pragma: no cover - detached terminal + return default + + +def render_table( + data: Any, columns: tuple[str, ...] = (), *, max_width: int | None = None +) -> str: + """Render as an aligned plain-text table. + + Plain text rather than box drawing: tables end up in logs and terminals of + varying width, and ASCII survives both. + + Long cells are **wrapped, never truncated**: a table is a view of the data, + not a lossy summary, and a silently dropped tail is the kind of thing you + only notice after acting on it. + """ + headers, rows = _rows_and_columns(data, columns) + if not headers: + return "(no results)" + + gutter = 2 + total_width = max_width or _terminal_width() + + natural = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(natural): + natural[i] = max( + natural[i], max((len(p) for p in cell.split("\n")), default=0) + ) + + # Shrink only the widest columns, and only as far as the terminal requires, + # so a narrow column is never squeezed on behalf of a wide neighbour. + widths = list(natural) + budget = total_width - gutter * (len(headers) - 1) + while sum(widths) > budget and max(widths) > 8: + widest = widths.index(max(widths)) + widths[widest] -= 1 + + def fmt(cells: list[str]) -> list[str]: + """Lay one logical row out over as many physical lines as it needs.""" + wrapped = [ + textwrap.wrap(cell, width=w, break_long_words=True, break_on_hyphens=False) + or [""] + for cell, w in zip(cells, widths, strict=False) + ] + height = max(len(parts) for parts in wrapped) + lines = [] + for line_no in range(height): + pieces = [ + (parts[line_no] if line_no < len(parts) else "").ljust(w) + for parts, w in zip(wrapped, widths, strict=False) + ] + lines.append((" " * gutter).join(pieces).rstrip()) + return lines + + out = fmt(headers) + out.append((" " * gutter).join("-" * w for w in widths).rstrip()) + for row in rows: + out.extend(fmt(row)) + return "\n".join(out) + + +def render( + env: dict[str, Any], + fmt: OutputFormat = OutputFormat.JSON, + *, + columns: tuple[str, ...] = (), + raw_field: str | None = None, +) -> str: + """Render an envelope. ``table`` and ``raw`` show ``data``, or the error.""" + if fmt is OutputFormat.JSON: + return json.dumps(env, indent=2, default=str) + + payload = env["data"] if env["ok"] else env["error"] + if fmt is OutputFormat.TABLE: + return render_table(payload, columns) + + if isinstance(payload, dict) and raw_field and raw_field in payload: + payload = payload[raw_field] + if isinstance(payload, bytes): + return payload.decode("utf-8", errors="replace") + if isinstance(payload, str): + return payload + return json.dumps(payload, indent=2, default=str) + + +def emit( + env: dict[str, Any], + fmt: OutputFormat = OutputFormat.JSON, + *, + columns: tuple[str, ...] = (), + raw_field: str | None = None, + secrets: list[str] | None = None, +) -> None: + """Write one envelope to stdout -- and nothing else to stdout.""" + text = render(env, fmt, columns=columns, raw_field=raw_field) + if secrets: + text = scrub(text, secrets) + print(text) + + +def emit_result( + data: Any, + fmt: OutputFormat = OutputFormat.JSON, + *, + meta: dict[str, Any] | None = None, + columns: tuple[str, ...] = (), + raw_field: str | None = None, + secrets: list[str] | None = None, +) -> None: + """Write a successful result.""" + emit( + envelope(data=data, meta=meta), + fmt, + columns=columns, + raw_field=raw_field, + secrets=secrets, + ) + + +def emit_error( + error: CLIError, + fmt: OutputFormat = OutputFormat.JSON, + *, + meta: dict[str, Any] | None = None, + secrets: list[str] | None = None, +) -> ExitCode: + """Write a failure envelope to stdout and a one-line summary to stderr. + + Returns the exit code so the caller can hand it straight to the shell. + """ + emit(envelope(error=error.to_dict(), meta=meta), fmt, secrets=secrets) + summary = error.message + if secrets: + summary = scrub(summary, secrets) + print(f"error: {summary}", file=sys.stderr) + return error.exit_code + + +def diagnostic( + message: str, *, quiet: bool = False, verbosity: int = 0, level: int = 0 +) -> None: + """Write a human-facing note to **stderr**, keeping stdout parseable. + + ``level`` is the minimum ``-v`` count required: 0 always shows (unless + ``--quiet``), 1 needs ``-v``, 2 needs ``-vv``. + """ + if quiet or verbosity < level: + return + print(message, file=sys.stderr) + + +__all__ = [ + "OutputFormat", + "diagnostic", + "emit", + "emit_error", + "emit_result", + "envelope", + "render", + "render_table", +] diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py new file mode 100644 index 0000000..e34e43a --- /dev/null +++ b/src/unstract_cli/core/poll.py @@ -0,0 +1,168 @@ +"""`--wait` state machine and one-shot result persistence. + +Both products follow execute -> poll -> retrieve, and a caller should not have to +script that loop. + +**The load-bearing rule:** terminal state is decided by the ``status`` field in +the *response body*, never by the HTTP status code. The deployment API returns +HTTP 422 for the in-progress states, so reading the body means this behaves +identically before and after that is fixed server-side. + +The engine takes callables rather than owning any transport: the clients issue +every request, and the clock is injected so the whole thing tests offline. +""" + +from __future__ import annotations + +import json +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from unstract_cli.core.errors import CLIError, ExitCode + + +@dataclass(frozen=True) +class PollSpec: + """How to read progress out of one operation's responses.""" + + #: Where the job handle lives in the initial response (whisper_hash, + #: execution_id, ...). It is echoed back on timeout so a caller can resume + #: rather than reprocess the document. + handle_field: str + terminal_success: tuple[str, ...] + terminal_failure: tuple[str, ...] + #: One name, or candidates tried in order: the run POST and the status GET + #: spell the state differently. + status_field: str | tuple[str, ...] = "status" + + +def _dig(payload: Any, field: str) -> Any: + """Find a field, looking one level into the common envelopes.""" + if not isinstance(payload, dict): + return None + if field in payload: + return payload[field] + for envelope in ("message", "data", "result"): + inner = payload.get(envelope) + if isinstance(inner, dict) and field in inner: + return inner[field] + return None + + +def extract_status(payload: Any, field: str | tuple[str, ...] = "status") -> str | None: + """Read the status from a response body; first candidate that resolves wins.""" + fields = (field,) if isinstance(field, str) else field + for candidate in fields: + value = _dig(payload, candidate) + if value is not None: + return str(value) + return None + + +def extract_handle(payload: Any, field: str) -> str | None: + """Read the job handle out of a response body.""" + value = _dig(payload, field) + return str(value) if value is not None else None + + +def persist(path: str | Path, payload: Any) -> Path: + """Write a result to disk and return where it landed. + + Some results can be read exactly once. Callers must persist **before** the + read is acknowledged to the user, so a crash between the two cannot destroy + a result the server will not serve again. + """ + target = Path(path).expanduser() + target.parent.mkdir(parents=True, exist_ok=True) + text = ( + payload + if isinstance(payload, str) + else json.dumps(payload, indent=2, default=str) + ) + target.write_text(text, encoding="utf-8") + return target + + +def wait_for_completion( + *, + initial: Any, + spec: PollSpec, + poll: Callable[[str], Any], + retrieve: Callable[[str], Any] | None = None, + save: str | Path | None = None, + interval: float = 3.0, + timeout: float = 300.0, + on_status: Callable[[str | None], None] | None = None, + sleep: Callable[[float], None] = time.sleep, + now: Callable[[], float] = time.monotonic, +) -> Any: + """Poll until terminal, then retrieve if the operation has a retrieve step. + + On timeout, raises with the job handle attached, so a caller can resume with + a plain status/retrieve call rather than resubmitting the document. + """ + handle = extract_handle(initial, spec.handle_field) + if not handle: + return initial + + success = {state.lower() for state in spec.terminal_success} + failure = {state.lower() for state in spec.terminal_failure} + deadline = now() + timeout + last_status: str | None = None + payload: Any = initial + + while True: + payload = poll(handle) + status = extract_status(payload, spec.status_field) + + if status != last_status: + if on_status is not None: + on_status(status) + last_status = status + + normalised = (status or "").lower() + if normalised in failure: + raise CLIError( + f"Operation finished with status {status!r}.", + ExitCode.VALIDATION, + details=payload, + hint="Inspect `details` for the per-file error, or check the execution logs.", + extra={spec.handle_field: handle}, + ) + if normalised in success: + break + + remaining = deadline - now() + if remaining <= 0: + raise CLIError( + f"Timed out after {timeout:g}s waiting for completion " + f"(last status: {status!r}).", + ExitCode.TIMEOUT, + hint=( + f"The job is still running. Resume with the {spec.handle_field} " + f"below rather than resubmitting the document." + ), + extra={spec.handle_field: handle, "last_status": status}, + ) + + # Never sleep past the deadline: --wait 30 that returns at 35s has lied, + # and the last poll should land on the deadline, not after it. + sleep(min(interval, remaining)) + + if retrieve is not None: + payload = retrieve(handle) + if save is not None: + persist(save, payload) + return payload + + +__all__ = [ + "PollSpec", + "extract_handle", + "extract_status", + "persist", + "wait_for_completion", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a798c7b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import pytest + +from unstract_cli import config as config_mod + +#: Every variable the loader consults. Cleared per test so a developer's real +#: shell environment cannot change a result. +_ENV_VARS = sorted( + {var for vars_ in config_mod.ENV_VARS.values() for var in vars_} + | {"UNSTRACT_CONFIG", "UNSTRACT_PROFILE"} +) + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch, tmp_path): + for var in _ENV_VARS: + monkeypatch.delenv(var, raising=False) + config_mod.set_config_path(None) + # Both discovery fallbacks are redirected into the tmp dir: an upward search + # from a real cwd could otherwise find a developer's own .unstract.toml. + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(config_mod, "HOME_CONFIG", tmp_path / "home" / "config.toml") + yield + config_mod.set_config_path(None) + + +@pytest.fixture +def write_config(tmp_path, monkeypatch): + """Write a config file and point the CLI at it.""" + + def _write(text: str): + path = tmp_path / "config.toml" + path.write_text(text, encoding="utf-8") + monkeypatch.setenv("UNSTRACT_CONFIG", str(path)) + return path + + return _write diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..786b1ec --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,113 @@ +"""End-to-end through the entry point: exit codes reach the shell, stdout parses.""" + +from __future__ import annotations + +import json + +import pytest + +from unstract_cli.__main__ import main +from unstract_cli.app import cli, command_tree +from unstract_cli.core.errors import ExitCode + + +def run(capsys, *args): + """Invoke the CLI as the console script does, returning (code, stdout json).""" + code = main(list(args)) + captured = capsys.readouterr() + payload = json.loads(captured.out) if captured.out.strip() else None + return code, payload, captured.err + + +def test_v1_groups_are_registered(): + tree = command_tree() + assert set(tree) >= {"config", "whisper", "docstudio"} + assert "deployment" in tree["docstudio"]["commands"] + assert set(tree["config"]["commands"]) == {"doctor", "get", "init", "list", "set"} + + +def test_help_exits_zero(capsys): + assert main(["--help"]) == int(ExitCode.SUCCESS) + + +def test_unknown_command_is_a_usage_error_with_an_envelope(capsys): + code, payload, err = run(capsys, "nope") + assert code == int(ExitCode.USAGE) + assert payload["ok"] is False + assert payload["error"]["exit_code"] == int(ExitCode.USAGE) + assert err.startswith("error:") + + +def test_unknown_config_target_exits_two(capsys): + code, payload, _ = run(capsys, "config", "get", "nosuchproduct", "base_url") + assert code == int(ExitCode.USAGE) + assert "llmwhisperer" in payload["error"]["hint"] + + +def test_set_then_get_round_trip(capsys, tmp_path, monkeypatch): + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) + + code, payload, _ = run(capsys, "config", "set", "docstudio", "org_id", "org_A") + assert code == 0 and payload["ok"] is True + + code, payload, _ = run(capsys, "config", "get", "docstudio", "org_id") + assert code == 0 + assert payload["data"]["value"] == "org_A" + + +def test_set_warns_when_a_credential_is_stored_literally(capsys, tmp_path, monkeypatch): + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) + _, payload, _ = run(capsys, "config", "set", "llmwhisperer", "api_key", "literal-key") + assert "env:VAR_NAME" in payload["data"]["warning"] + + +def test_get_never_echoes_a_credential(capsys, tmp_path, monkeypatch): + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) + run(capsys, "config", "set", "llmwhisperer", "api_key", "super-secret-value") + _, payload, _ = run(capsys, "config", "get", "llmwhisperer", "api_key") + assert payload["data"]["value"] == "***SET***" + assert "super-secret-value" not in json.dumps(payload) + + +def test_init_refuses_to_clobber_without_force(capsys, tmp_path, monkeypatch): + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) + assert run(capsys, "config", "init")[0] == 0 + + code, payload, _ = run(capsys, "config", "init") + assert code == int(ExitCode.USAGE) + assert "--force" in payload["error"]["hint"] + + assert run(capsys, "config", "init", "--force")[0] == 0 + + +def test_doctor_reports_sources_without_leaking_values(capsys, monkeypatch): + monkeypatch.setenv("LLMWHISPERER_API_KEY", "super-secret-value") + code, payload, _ = run(capsys, "config", "doctor") + assert code == 0 + products = payload["data"]["products"] + assert products["llmwhisperer"]["api_key"] == { + "resolved": True, + "source": "env:LLMWHISPERER_API_KEY", + } + assert products["docstudio"]["api_key"]["resolved"] is False + assert "super-secret-value" not in json.dumps(payload) + + +def test_table_output_is_opt_in_and_json_is_the_default(capsys, monkeypatch): + monkeypatch.setattr("sys.stdout.isatty", lambda: True, raising=False) + # JSON even on a TTY: a caller never has to detect the terminal to parse. + assert run(capsys, "config", "doctor")[1]["ok"] is True + + main(["--output", "table", "config", "doctor"]) + out = capsys.readouterr().out + with pytest.raises(json.JSONDecodeError): + json.loads(out) + assert "active_profile" in out + + +def test_click_parameter_info_dict_keeps_the_keys_discovery_reads(): + # Discovery derives flags from Click's own introspection; a Click bump that + # reshaped this dict would silently degrade it. + param = next(p for p in cli.params if p.name == "output") + info = param.to_info_dict() + assert {"name", "opts", "help", "type", "required"} <= set(info) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..2b29736 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,206 @@ +"""Config resolution: flag > env > profile > built-in default.""" + +from __future__ import annotations + +import stat + +import pytest + +from unstract_cli.config import ( + DEFAULT_BASE_URLS, + DOCSTUDIO, + LLMWHISPERER, + ConfigError, + ConfigFile, + ResolvedConfig, + config_path, + find_project_config, + load_config, + save_config, + set_config_path, + starter_profiles, +) + +PROFILE_TOML = """ +default_profile = "p" + +[profiles.p.llmwhisperer] +base_url = "https://profile.example/api/v2" +api_key = "profile-key" + +[profiles.p.docstudio] +org_id = "org_from_profile" +api_key = "env:UNSTRACT_DEPLOYMENT_KEY" + +[profiles.p.deployments.invoices] +api_name = "invoice-parser" + +[profiles.p.deployments.receipts] +api_name = "receipt-parser" +org_id = "org_alias" +api_key = "alias-key" +""" + + +def resolved(overrides=None, profile=None): + return ResolvedConfig( + file=load_config(), profile_name=profile, overrides=overrides or {} + ) + + +def test_default_when_nothing_configured(): + assert resolved().get(LLMWHISPERER, "base_url") == DEFAULT_BASE_URLS[LLMWHISPERER] + assert resolved().get(LLMWHISPERER, "api_key") is None + + +def test_profile_beats_default(write_config): + write_config(PROFILE_TOML) + assert resolved().get(LLMWHISPERER, "base_url") == "https://profile.example/api/v2" + + +def test_env_beats_profile(write_config, monkeypatch): + write_config(PROFILE_TOML) + monkeypatch.setenv("LLMWHISPERER_BASE_URL", "https://env.example/api/v2") + assert resolved().get(LLMWHISPERER, "base_url") == "https://env.example/api/v2" + + +def test_override_beats_env(write_config, monkeypatch): + write_config(PROFILE_TOML) + monkeypatch.setenv("LLMWHISPERER_BASE_URL", "https://env.example/api/v2") + cfg = resolved(overrides={"llmwhisperer.base_url": "https://flag.example"}) + assert cfg.get(LLMWHISPERER, "base_url") == "https://flag.example" + + +def test_env_indirection_resolves_and_missing_var_reads_as_unset( + write_config, monkeypatch +): + write_config(PROFILE_TOML) + assert resolved().get(DOCSTUDIO, "api_key") is None + monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", "secret-value") + assert resolved().get(DOCSTUDIO, "api_key") == "secret-value" + + +def test_require_names_every_way_to_supply_the_setting(): + with pytest.raises(ConfigError) as excinfo: + resolved().require(DOCSTUDIO, "api_key") + message = str(excinfo.value) + assert "UNSTRACT_DEPLOYMENT_KEY" in message + assert "[profiles..docstudio]" in message + # Credentials get no flag, so none may be suggested. + assert "--api-key" not in message + + +def test_unknown_profile_is_an_error_not_a_silent_empty_block(write_config): + write_config(PROFILE_TOML) + with pytest.raises(ConfigError, match="not found"): + resolved(profile="nope").get(DOCSTUDIO, "org_id") + + +def test_profile_selected_by_env_var(write_config, monkeypatch): + write_config(PROFILE_TOML.replace('default_profile = "p"', "")) + monkeypatch.setenv("UNSTRACT_PROFILE", "p") + assert resolved().get(DOCSTUDIO, "org_id") == "org_from_profile" + + +def test_deployment_alias_falls_back_to_the_product_block(write_config, monkeypatch): + write_config(PROFILE_TOML) + monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", "secret-value") + alias = resolved().deployment("invoices") + assert alias == { + "api_name": "invoice-parser", + "org_id": "org_from_profile", + "api_key": "secret-value", + } + + +def test_deployment_alias_overrides_win(write_config): + write_config(PROFILE_TOML) + alias = resolved().deployment("receipts") + assert alias["org_id"] == "org_alias" + assert alias["api_key"] == "alias-key" + + +def test_unknown_deployment_alias_lists_the_known_ones(write_config): + write_config(PROFILE_TOML) + with pytest.raises(ConfigError, match="invoices, receipts"): + resolved().deployment("nope") + + +def test_resolution_source_reports_the_winner(write_config, monkeypatch): + write_config(PROFILE_TOML) + cfg = resolved() + assert ( + cfg.resolution_source(LLMWHISPERER, "base_url")["source"] == "profile (literal)" + ) + assert cfg.resolution_source(DOCSTUDIO, "base_url")["source"] == "built-in default" + assert cfg.resolution_source(DOCSTUDIO, "api_key") == { + "resolved": False, + "source": "profile -> env:UNSTRACT_DEPLOYMENT_KEY", + "detail": "$UNSTRACT_DEPLOYMENT_KEY is not set in this process's environment", + } + monkeypatch.setenv("LLMWHISPERER_API_KEY", "k") + assert resolved().resolution_source(LLMWHISPERER, "api_key") == { + "resolved": True, + "source": "env:LLMWHISPERER_API_KEY", + } + + +# --------------------------------------------------------------------------- # +# File discovery and writing +# --------------------------------------------------------------------------- # + + +def test_discovery_order(tmp_path, monkeypatch): + from unstract_cli import config as config_mod + + home_default = config_mod.HOME_CONFIG + assert config_path() == home_default + + project = tmp_path / "proj" / "nested" + project.mkdir(parents=True) + (tmp_path / "proj" / ".unstract.toml").touch() + monkeypatch.chdir(project) + assert config_path() == tmp_path / "proj" / ".unstract.toml" + + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "env.toml")) + assert config_path() == tmp_path / "env.toml" + + set_config_path(tmp_path / "flag.toml") + assert config_path() == tmp_path / "flag.toml" + + +def test_project_search_stops_at_home(tmp_path, monkeypatch): + home = tmp_path / "home" + work = home / "work" + work.mkdir(parents=True) + monkeypatch.setattr("pathlib.Path.home", lambda: home) + # Above $HOME, so it must not be picked up. + (tmp_path / ".unstract.toml").touch() + assert find_project_config(work) is None + + +def test_missing_file_is_not_an_error(): + cfg = load_config() + assert cfg.exists is False and cfg.profiles == {} + + +def test_saved_config_is_owner_only(tmp_path): + path = tmp_path / "nested" / "config.toml" + written = save_config( + ConfigFile(default_profile="cloud-us", profiles=starter_profiles()), path + ) + assert stat.S_IMODE(written.stat().st_mode) == 0o600 + assert load_config(written).default_profile == "cloud-us" + + +def test_loose_permissions_warn_rather_than_fail(write_config): + path = write_config(PROFILE_TOML) + path.chmod(0o644) + assert any("readable by other users" in w for w in load_config().warnings) + + +def test_starter_profiles_hold_no_literal_secrets(): + for blocks in starter_profiles().values(): + for settings in blocks.values(): + key = settings.get("api_key") + assert key is None or key.startswith("env:") diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..cc39b54 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,101 @@ +"""The exit-code table, retry policy and redaction.""" + +from __future__ import annotations + +import pytest + +from unstract_cli.core.errors import ( + REDACTED, + ExitCode, + error_from_status, + exit_code_for_status, + hint_for, + is_retryable, + redact_headers, + redact_value, + scrub, + undeclared_status_error, +) + + +@pytest.mark.parametrize( + ("status", "expected"), + [ + (200, ExitCode.SUCCESS), + (400, ExitCode.VALIDATION), + (401, ExitCode.AUTH), + (403, ExitCode.AUTH), + (404, ExitCode.NOT_FOUND), + (406, ExitCode.ALREADY_CONSUMED), + (408, ExitCode.TIMEOUT), + (409, ExitCode.VALIDATION), + (418, ExitCode.GENERIC), + (422, ExitCode.VALIDATION), + (429, ExitCode.RATE_LIMITED), + (500, ExitCode.SERVER_ERROR), + (503, ExitCode.SERVER_ERROR), + ], +) +def test_status_to_exit_code(status, expected): + assert exit_code_for_status(status) is expected + + +def test_exit_codes_are_stable_integers(): + # A caller branches on these numbers, so they are an API, not an enum detail. + assert [int(c) for c in ExitCode] == list(range(10)) + assert int(ExitCode.ALREADY_CONSUMED) == 9 + + +@pytest.mark.parametrize("status", [429, 500, 502, 503]) +def test_retryable(status): + assert is_retryable(status) + + +@pytest.mark.parametrize("status", [400, 401, 403, 404, 406, 409, 422]) +def test_not_retryable(status): + # A 4xx retry re-sends what the server already rejected, and for a one-shot + # read it can consume a result the first attempt already delivered. + assert not is_retryable(status) + + +def test_one_shot_status_carries_its_own_hint(): + assert "already retrieved" in hint_for(406) + assert "--save" in hint_for(406) + + +def test_error_from_status_fills_code_hint_and_retryability(): + err = error_from_status(429, "slow down", endpoint="POST /whisper") + assert err.exit_code is ExitCode.RATE_LIMITED + assert err.retryable is True + assert err.to_dict()["endpoint"] == "POST /whisper" + + +def test_undeclared_status_is_reported_verbatim_never_guessed(): + err = undeclared_status_error(418, {"detail": "teapot"}) + assert "Undeclared status 418" in err.message + assert "teapot" in err.message + assert err.to_dict()["details"] == {"detail": "teapot"} + + +def test_redact_headers(): + out = redact_headers( + { + "unstract-key": "abc", + "Authorization": "Bearer x", + "X-Api-Key": "y", + "Content-Type": "application/json", + } + ) + assert out["unstract-key"] == out["Authorization"] == out["X-Api-Key"] == REDACTED + assert out["Content-Type"] == "application/json" + + +def test_redact_value_walks_nested_payloads(): + out = redact_value({"a": {"api_key": "secret", "n": 1}, "b": [{"token": "t"}]}) + assert out == {"a": {"api_key": REDACTED, "n": 1}, "b": [{"token": REDACTED}]} + + +def test_scrub_ignores_short_values(): + # Redacting a 3-character "key" would mangle unrelated text. + assert scrub("the key is abc", ["abc"]) == "the key is abc" + assert scrub("the key is abcdefghij", ["abcdefghij"]) == f"the key is {REDACTED}" diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..1a204c6 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,95 @@ +"""The stdout envelope and its renderings.""" + +from __future__ import annotations + +import json + +from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.output import ( + OutputFormat, + emit_error, + emit_result, + envelope, + render, + render_table, +) + +ENVELOPE_KEYS = {"ok", "data", "error", "meta"} + + +def test_success_envelope_shape(): + env = envelope(data={"a": 1}, meta={"took": 2}) + assert set(env) == ENVELOPE_KEYS + assert env == {"ok": True, "data": {"a": 1}, "error": None, "meta": {"took": 2}} + + +def test_error_envelope_shape(): + err = CLIError("boom", ExitCode.AUTH, http_status=401, hint="check the key") + env = envelope(error=err.to_dict()) + assert set(env) == ENVELOPE_KEYS + assert env["ok"] is False and env["data"] is None + assert env["error"] == { + "code": "auth_error", + "message": "boom", + "exit_code": 3, + "retryable": False, + "http_status": 401, + "hint": "check the key", + } + + +def test_meta_defaults_to_an_object_not_null(): + # A caller reading meta. should not have to null-check the container. + assert envelope(data=1)["meta"] == {} + + +def test_stdout_carries_the_envelope_on_success(capsys): + emit_result({"text": "hello"}, OutputFormat.JSON) + out = capsys.readouterr() + assert json.loads(out.out) == { + "ok": True, + "data": {"text": "hello"}, + "error": None, + "meta": {}, + } + assert out.err == "" + + +def test_stdout_carries_the_envelope_on_failure_and_stderr_gets_a_summary(capsys): + code = emit_error(CLIError("nope", ExitCode.NOT_FOUND)) + out = capsys.readouterr() + parsed = json.loads(out.out) + assert parsed["ok"] is False and parsed["error"]["code"] == "not_found" + assert out.err.strip() == "error: nope" + assert code == ExitCode.NOT_FOUND + + +def test_secrets_are_scrubbed_from_both_streams(capsys): + secret = "sk-supersecret-value" + emit_error(CLIError(f"rejected token {secret}"), secrets=[secret]) + out = capsys.readouterr() + assert secret not in out.out and secret not in out.err + assert "***REDACTED***" in out.out + + +def test_table_and_raw_render_the_payload_not_the_envelope(): + env = envelope(data={"text": "hello"}) + assert "hello" in render(env, OutputFormat.TABLE) + assert "ok" not in render(env, OutputFormat.TABLE) + assert render(env, OutputFormat.RAW, raw_field="text") == "hello" + + +def test_raw_renders_the_error_when_the_run_failed(): + env = envelope(error=CLIError("boom").to_dict()) + assert "boom" in render(env, OutputFormat.RAW) + + +def test_table_wraps_long_cells_rather_than_truncating(): + long = "word " * 40 + rendered = render_table([{"text": long.strip()}], max_width=40) + assert rendered.count("\n") > 2 + assert "".join(rendered.split()).count("word") == 40 + + +def test_table_of_an_empty_list_says_so(): + assert render_table([]) == "(no results)" diff --git a/tests/test_poll.py b/tests/test_poll.py new file mode 100644 index 0000000..12116c8 --- /dev/null +++ b/tests/test_poll.py @@ -0,0 +1,217 @@ +"""The `--wait` engine, driven by a fake clock and fake responses. No network.""" + +from __future__ import annotations + +import json + +import pytest + +from unstract_cli.core.errors import ExitCode +from unstract_cli.core.poll import ( + CLIError, + PollSpec, + extract_handle, + extract_status, + persist, + wait_for_completion, +) + +SPEC = PollSpec( + handle_field="whisper_hash", + terminal_success=("processed",), + terminal_failure=("error",), + status_field=("status", "execution_status"), +) + + +class Clock: + """Monotonic clock that only advances when the engine sleeps.""" + + def __init__(self) -> None: + self.t = 0.0 + self.slept: list[float] = [] + + def now(self) -> float: + return self.t + + def sleep(self, seconds: float) -> None: + self.slept.append(seconds) + self.t += seconds + + +def responses(*payloads): + """A poll callable returning each payload in turn, then repeating the last.""" + queue = list(payloads) + calls: list[str] = [] + + def poll(handle: str): + calls.append(handle) + return queue.pop(0) if len(queue) > 1 else queue[0] + + poll.calls = calls + return poll + + +def test_polls_until_terminal_success(): + clock = Clock() + poll = responses( + {"status": "processing"}, + {"status": "processing"}, + {"status": "processed", "n": 1}, + ) + out = wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=poll, + interval=3, + sleep=clock.sleep, + now=clock.now, + ) + assert out == {"status": "processed", "n": 1} + assert poll.calls == ["h1", "h1", "h1"] + assert clock.slept == [3, 3] + + +def test_terminal_state_comes_from_the_body_not_the_http_status(): + # The deployment API returns HTTP 422 while still executing; only the body's + # status decides, so this reaches COMPLETED without any status-code input. + spec = PollSpec( + handle_field="execution_id", + terminal_success=("COMPLETED",), + terminal_failure=("ERROR",), + status_field=("status", "execution_status"), + ) + clock = Clock() + out = wait_for_completion( + initial={"message": {"execution_id": "e1", "execution_status": "PENDING"}}, + spec=spec, + poll=responses({"status": "EXECUTING"}, {"status": "COMPLETED"}), + sleep=clock.sleep, + now=clock.now, + ) + assert out == {"status": "COMPLETED"} + + +def test_terminal_failure_raises_with_the_handle_attached(): + clock = Clock() + with pytest.raises(CLIError) as excinfo: + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=responses({"status": "error", "detail": "bad page"}), + sleep=clock.sleep, + now=clock.now, + ) + err = excinfo.value + assert err.exit_code is ExitCode.VALIDATION + assert err.to_dict()["whisper_hash"] == "h1" + assert err.to_dict()["details"]["detail"] == "bad page" + + +def test_timeout_carries_the_handle_so_work_is_resumable(): + clock = Clock() + with pytest.raises(CLIError) as excinfo: + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=responses({"status": "processing"}), + interval=5, + timeout=12, + sleep=clock.sleep, + now=clock.now, + ) + err = excinfo.value + assert err.exit_code is ExitCode.TIMEOUT + payload = err.to_dict() + assert payload["whisper_hash"] == "h1" + assert payload["last_status"] == "processing" + assert "Resume" in payload["hint"] + # The last sleep is clipped so the wait lasts exactly as long as asked. + assert clock.slept == [5, 5, 2] + assert clock.now() == 12 + + +def test_missing_handle_returns_the_initial_response_unpolled(): + poll = responses({"status": "processed"}) + out = wait_for_completion( + initial={"no_handle_here": True}, spec=SPEC, poll=poll, sleep=Clock().sleep + ) + assert out == {"no_handle_here": True} + assert poll.calls == [] + + +def test_status_changes_are_reported_once_each(): + clock = Clock() + seen: list[str | None] = [] + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=responses( + {"status": "accepted"}, + {"status": "processing"}, + {"status": "processing"}, + {"status": "processed"}, + ), + on_status=seen.append, + sleep=clock.sleep, + now=clock.now, + ) + assert seen == ["accepted", "processing", "processed"] + + +def test_retrieve_step_runs_after_terminal_success(): + clock = Clock() + out = wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=responses({"status": "processed"}), + retrieve=lambda handle: {"result_for": handle}, + sleep=clock.sleep, + now=clock.now, + ) + assert out == {"result_for": "h1"} + + +def test_save_persists_the_retrieved_result_before_returning(tmp_path): + target = tmp_path / "out" / "result.json" + seen: list[bool] = [] + + def retrieve(handle): + return {"text": "extracted"} + + out = wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=responses({"status": "processed"}), + retrieve=retrieve, + save=target, + sleep=Clock().sleep, + ) + # The file exists by the time the caller is handed the result: a one-shot + # read must survive a crash between retrieval and acknowledgement. + seen.append(target.exists()) + assert seen == [True] + assert json.loads(target.read_text()) == out + + +def test_persist_writes_text_payloads_unwrapped(tmp_path): + target = persist(tmp_path / "a.txt", "plain extracted text") + assert target.read_text() == "plain extracted text" + + +@pytest.mark.parametrize( + "payload", + [ + {"status": "processed"}, + {"message": {"status": "processed"}}, + {"data": {"status": "processed"}}, + {"result": {"status": "processed"}}, + ], +) +def test_status_is_found_one_level_into_the_common_envelopes(payload): + assert extract_status(payload) == "processed" + + +def test_handle_is_found_one_level_in_too(): + assert extract_handle({"message": {"execution_id": "e1"}}, "execution_id") == "e1" + assert extract_handle({"nothing": 1}, "execution_id") is None From ee211ee01ea92ca3736d7ca7ac0ba9dd61df1d7a Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 13:47:30 +0530 Subject: [PATCH 02/86] feat: derive command flags from the committed API specs Flags for an operation come from the spec the published client is generated from, intersected with what that client's signature actually accepts: a spec parameter the frozen client cannot name would raise TypeError at the call rather than reach the API, so it is not offered. Two rules keep the derivation honest. Every option defaults to None, meaning absent, so an unpassed flag is not sent and the client or server default applies rather than a value pinned here. And only None is treated as absent: 0, false and "" are choices a caller made and travel to the request. Help text has three sources in order: the overlay, the spec, and the client method's own docstring, which is the only one that describes the parameters today. The overlay carries what a generated spec cannot express -- allowed values, short flags, wording -- in TOML read with the stdlib. --- pyproject.toml | 10 + src/unstract_cli/core/overlay.py | 38 + src/unstract_cli/core/params.py | 358 +++++++ src/unstract_cli/overlay.toml | 12 + src/unstract_cli/specs/docstudio.json | 442 +++++++++ src/unstract_cli/specs/llmwhisperer.json | 1151 ++++++++++++++++++++++ tests/test_params.py | 229 +++++ 7 files changed, 2240 insertions(+) create mode 100644 src/unstract_cli/core/overlay.py create mode 100644 src/unstract_cli/core/params.py create mode 100644 src/unstract_cli/overlay.toml create mode 100644 src/unstract_cli/specs/docstudio.json create mode 100644 src/unstract_cli/specs/llmwhisperer.json create mode 100644 tests/test_params.py diff --git a/pyproject.toml b/pyproject.toml index d6eb750..cb1d132 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,12 @@ dependencies = [ # Zero transitive dependencies. Writing the config file only; reading it # uses the stdlib `tomllib`. "tomli-w>=1.0", + # Pinned to a commit, not a range: the CLI derives its flags from the specs + # these clients are generated from, and reads their docstrings for help + # text, so a client that moves underneath it changes the CLI's surface. + # Both pins move to released versions before this ships. + "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@c291e36", + "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@bb586c4", ] [project.optional-dependencies] @@ -27,6 +33,10 @@ unstract = "unstract_cli.__main__:main" requires = ["hatchling"] build-backend = "hatchling.build" +# The two clients are pinned to commits until they are released. +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.build.targets.wheel] packages = ["src/unstract_cli"] diff --git a/src/unstract_cli/core/overlay.py b/src/unstract_cli/core/overlay.py new file mode 100644 index 0000000..337f1d6 --- /dev/null +++ b/src/unstract_cli/core/overlay.py @@ -0,0 +1,38 @@ +"""What the specs cannot say about a flag. + +The committed specs are generated from server code, so they carry names, types +and defaults but no allowed-value lists, no short flags and, today, no parameter +descriptions. Those live here rather than in the derivation, so adding one is an +edit to a data file instead of a special case in code. + +TOML, read with the stdlib, for the same reason the config file is TOML: no +parser dependency, and the file stays editable without a code change. + +Anything not overridden falls through to the spec, so an empty overlay is a +valid overlay. +""" + +from __future__ import annotations + +import tomllib +from functools import cache +from importlib import resources +from typing import Any + +OVERLAY_FILE = "overlay.toml" + + +@cache +def load_overlay() -> dict[str, Any]: + """Read the packaged overlay.""" + text = (resources.files("unstract_cli") / OVERLAY_FILE).read_text(encoding="utf-8") + return tomllib.loads(text) + + +def overlay_for(product: str, operation_id: str) -> dict[str, dict[str, Any]]: + """Per-parameter overrides for one operation, keyed by parameter name.""" + entries = load_overlay().get(product, {}).get(operation_id, {}) + return {name: entry for name, entry in entries.items() if isinstance(entry, dict)} + + +__all__ = ["OVERLAY_FILE", "load_overlay", "overlay_for"] diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py new file mode 100644 index 0000000..0f137a2 --- /dev/null +++ b/src/unstract_cli/core/params.py @@ -0,0 +1,358 @@ +"""Command flags, derived from the committed API specs. + +The specs are the same artifacts the published clients are generated from, so a +parameter the API gains reaches the CLI by refreshing a JSON file rather than by +hand-editing a flag list that drifts the moment nobody looks at it. + +Two rules make the derivation safe to hand to a caller: + +* **A flag not passed is not sent.** Every option defaults to ``None``, which + means "absent", and the server's own default applies. Writing the spec's + default into the request instead would pin a value the server would otherwise + choose, and the two diverge the moment the server's default moves. +* **A falsy value is a choice, not an absence.** ``0``, ``false`` and ``""`` all + travel; only ``None`` is filtered. + +What the spec cannot express -- allowed values, short flags, wording -- comes +from the overlay, never from a guess made here. +""" + +from __future__ import annotations + +import inspect +import json +import re +from collections.abc import Callable +from dataclasses import dataclass, replace +from functools import cache +from importlib import resources +from typing import Any + +import click + +from unstract_cli.core.overlay import overlay_for + +#: Spec file per product, vendored so flags derive with no network and no +#: dependency on where the client happens to be installed from. +SPEC_FILES = {"llmwhisperer": "llmwhisperer.json", "docstudio": "docstudio.json"} + +_HTTP_METHODS = frozenset({"get", "post", "put", "patch", "delete"}) + +#: OpenAPI type -> Click type. `array` is handled separately, as repetition. +_TYPES: dict[str, click.ParamType] = { + "string": click.STRING, + "integer": click.INT, + "number": click.FLOAT, +} + + +@cache +def load_spec(product: str) -> dict[str, Any]: + """Read one vendored spec.""" + try: + filename = SPEC_FILES[product] + except KeyError: + raise KeyError(f"No spec vendored for product {product!r}") from None + text = (resources.files("unstract_cli.specs") / filename).read_text(encoding="utf-8") + return json.loads(text) + + +def find_operation(product: str, operation_id: str) -> dict[str, Any]: + """Look one operation up by its operationId.""" + for path, methods in load_spec(product)["paths"].items(): + for method, operation in methods.items(): + if method in _HTTP_METHODS and operation.get("operationId") == operation_id: + return {"path": path, "method": method, **operation} + raise KeyError(f"{product} spec declares no operation {operation_id!r}") + + +@dataclass(frozen=True) +class Param: + """One request parameter, as the spec describes it.""" + + name: str + type: str = "string" + default: Any = None + description: str = "" + array: bool = False + nullable: bool = False + required: bool = False + + @property + def flag(self) -> str: + return "--" + self.name.replace("_", "-") + + +def _from_schema( + name: str, schema: dict[str, Any], description: str, *, required: bool = False +) -> Param: + """Read one parameter out of its JSON schema. + + Nullability has two spellings -- a `null` branch in a type union, and 3.0's + `nullable` keyword, which is what the deployment spec uses. The null branch + carries no information for a flag, so the other branch decides the type. + """ + types = schema.get("type") + if isinstance(types, list): + nullable = "null" in types + remaining = [t for t in types if t != "null"] + type_name = remaining[0] if remaining else "string" + else: + nullable = bool(schema.get("nullable")) + type_name = types or "string" + + array = type_name == "array" + if array: + item = schema.get("items") or {} + type_name = item.get("type", "string") + + return Param( + name=name, + type=type_name, + default=schema.get("default"), + description=(description or schema.get("description") or "").strip(), + array=array, + nullable=nullable, + required=required, + ) + + +def operation_params(product: str, operation_id: str) -> list[Param]: + """Every parameter one operation accepts: query, then request body. + + Path parameters are excluded: they are the route, supplied by the command + from configuration, not by the caller as a flag. + """ + operation = find_operation(product, operation_id) + params = [ + _from_schema( + p["name"], + p.get("schema") or {}, + p.get("description", ""), + required=bool(p.get("required")), + ) + for p in operation.get("parameters", []) + if p.get("in") == "query" + ] + + body = operation.get("requestBody", {}).get("content", {}) + for media_type, content in body.items(): + # A binary body is the document itself, passed as an argument. + if media_type == "application/octet-stream": + continue + schema = content.get("schema") or {} + if ref := schema.get("$ref"): + schema = _resolve_ref(product, ref) + mandatory = set(schema.get("required") or ()) + for name, prop in (schema.get("properties") or {}).items(): + params.append( + _from_schema( + name, + prop, + prop.get("description", ""), + required=name in mandatory, + ) + ) + + return params + + +def client_params(method: Callable[..., Any]) -> dict[str, Any]: + """Parameter name -> default for a client method, ``None`` where there is none. + + The published clients are frozen, so a spec parameter the client's signature + does not name cannot be reached at all: passing it raises ``TypeError`` + rather than sending it. Flags are intersected with this to keep the CLI's + surface equal to what actually works. + """ + return { + name: (None if p.default is inspect.Parameter.empty else p.default) + for name, p in inspect.signature(method).parameters.items() + if name not in ("self", "cls") + } + + +#: `name (type, optional): description` -- the Args entry of a Google-style +#: docstring, which is how both clients document their parameters. +_ARG_LINE = re.compile(r"^\s*(\w+)\s*(\([^)]*\))?\s*:\s*(.*)$") + + +def docstring_params(method: Callable[..., Any]) -> dict[str, str]: + """Parameter descriptions from a client method's own docstring. + + The specs are generated from server code and carry no parameter + descriptions, while the published clients document every parameter. Reading + the docstring keeps one description per parameter, maintained where the + parameter is implemented, instead of a second copy here that goes stale + quietly. + """ + doc = inspect.getdoc(method) or "" + _, _, args = doc.partition("Args:") + if not args: + return {} + + out: dict[str, str] = {} + current: str | None = None + for line in args.splitlines(): + if not line.strip(): + continue + # A new top-level section (Returns:, Raises:) ends the parameter list. + if line[:1] not in " \t" or re.match(r"^\s{0,4}(Returns|Raises|Yields):", line): + break + if (match := _ARG_LINE.match(line)) and (match.group(2) or current is None): + current = match.group(1) + out[current] = match.group(3).strip() + elif current: + out[current] = f"{out[current]} {line.strip()}".strip() + # The default is rendered from the signature, so the docstring's own + # "Defaults to X." sentence would print it a second time, and disagree with + # it whenever the two drift. + return { + name: re.sub(r"\s*Defaults to [^.]*\.\s*$", "", " ".join(text.split())) + for name, text in out.items() + if text + } + + +def _resolve_ref(product: str, ref: str) -> dict[str, Any]: + node: Any = load_spec(product) + for part in ref.lstrip("#/").split("/"): + node = node[part] + return node + + +def _help_text(param: Param, choices: tuple[str, ...]) -> str: + """Help for one flag: what it does, what it accepts, what omitting it means. + + The default is reported but never applied. It answers "what happens if I + leave this out", which is the only question a default can honestly answer + here: the CLI does not resend it, the client or the server does. + """ + parts = [param.description] if param.description else [] + if choices: + parts.append(f"One of: {', '.join(choices)}.") + if param.default is not None and not param.required: + rendered = ( + str(param.default).lower() + if isinstance(param.default, bool) + else str(param.default) + ) + parts.append(f"[default: {rendered}]") + return " ".join(parts) + + +def click_option(param: Param, spec_overlay: dict[str, Any]) -> click.Option: + """Build one Click option from a spec parameter and its overlay entry.""" + entry = spec_overlay.get(param.name, {}) + choices = tuple(entry.get("choices", ())) + help_text = entry.get("help") or _help_text(param, choices) + short = entry.get("short") + + if param.type == "boolean": + # A paired flag, not `is_flag`: a parameter whose default is true cannot + # be turned off by a flag that only knows how to turn things on, and + # `default=None` keeps "not passed" distinct from "passed false". + decls = [f"{param.flag}/--no-{param.name.replace('_', '-')}"] + if short: + decls.insert(0, short) + return click.Option(decls, default=None, required=param.required, help=help_text) + + decls = [param.flag] + if short: + decls.insert(0, short) + return click.Option( + decls, + type=click.Choice(choices) if choices else _TYPES.get(param.type, click.STRING), + default=None, + required=param.required, + multiple=param.array, + help=help_text, + ) + + +def derive_params( + product: str, + operation_id: str, + *, + client_method: Callable[..., Any] | None = None, + exclude: tuple[str, ...] = (), +) -> list[Param]: + """The parameters one command exposes, in spec order. + + With ``client_method``, the spec is intersected with what that method + accepts and the method's own defaults win, because that is the value the + caller gets by omitting the flag. A spec parameter the method does not name + is dropped rather than offered and then rejected at the call. + """ + spec_overlay = overlay_for(product, operation_id) + hidden = {name for name, entry in spec_overlay.items() if entry.get("hidden")} + accepted = client_params(client_method) if client_method is not None else None + described = docstring_params(client_method) if client_method is not None else {} + + out: list[Param] = [] + for param in operation_params(product, operation_id): + if param.name in exclude or param.name in hidden: + continue + if accepted is not None: + if param.name not in accepted: + continue + if (default := accepted[param.name]) is not None: + param = replace(param, default=default) + if not param.description and (text := described.get(param.name)): + param = replace(param, description=text) + out.append(param) + return out + + +def spec_options( + product: str, + operation_id: str, + *, + client_method: Callable[..., Any] | None = None, + exclude: tuple[str, ...] = (), +) -> Callable[[click.Command], click.Command]: + """Decorator: hang one operation's parameters off a command as options. + + ``exclude`` drops parameters the command supplies itself -- the document to + extract is an argument, not a flag, and the CLI owns the polling that + ``use_webhook`` would bypass. + """ + spec_overlay = overlay_for(product, operation_id) + + def decorate(command: click.Command) -> click.Command: + for param in derive_params( + product, operation_id, client_method=client_method, exclude=exclude + ): + command.params.append(click_option(param, spec_overlay)) + return command + + return decorate + + +def requested(values: dict[str, Any], *, drop: tuple[str, ...] = ()) -> dict[str, Any]: + """Keep the parameters the caller actually passed. + + ``None`` is the only absence. An empty tuple from a repeatable option is one + too -- Click spells "not passed" that way for ``multiple=True`` -- but ``0``, + ``False`` and ``""`` are values the caller chose and must survive. + """ + return { + name: value + for name, value in values.items() + if name not in drop and value is not None and value != () + } + + +__all__ = [ + "SPEC_FILES", + "Param", + "click_option", + "client_params", + "derive_params", + "docstring_params", + "find_operation", + "load_spec", + "operation_params", + "requested", + "spec_options", +] diff --git a/src/unstract_cli/overlay.toml b/src/unstract_cli/overlay.toml new file mode 100644 index 0000000..e9f871b --- /dev/null +++ b/src/unstract_cli/overlay.toml @@ -0,0 +1,12 @@ +# Per-flag overrides for spec-derived options: [..]. +# +# Only what the spec cannot express belongs here. Names, types and defaults are +# read from the spec, and help text falls back to the published client's own +# docstring, so an entry is needed only to constrain values, add a short flag, +# hide a parameter the CLI owns, or reword help the client states poorly. + +[llmwhisperer.extract.mode] +choices = ["form", "high_quality", "low_cost", "native_text", "table"] + +[llmwhisperer.extract.output_mode] +choices = ["layout_preserving", "text"] diff --git a/src/unstract_cli/specs/docstudio.json b/src/unstract_cli/specs/docstudio.json new file mode 100644 index 0000000..424b30f --- /dev/null +++ b/src/unstract_cli/specs/docstudio.json @@ -0,0 +1,442 @@ +{ + "components": { + "schemas": { + "ErrorResponse": { + "properties": { + "message": { + "nullable": true + }, + "status": { + "type": "string" + } + }, + "type": "object" + }, + "ExecuteRequest": { + "description": "Subclasses the real serializer so every backend param arrives free.", + "properties": { + "custom_data": { + "nullable": true + }, + "files": { + "items": { + "format": "binary", + "type": "string" + }, + "type": "array" + }, + "hitl_packet_id": { + "nullable": true, + "type": "string" + }, + "hitl_queue_name": { + "nullable": true, + "type": "string" + }, + "include_extracted_text": { + "default": false, + "type": "boolean" + }, + "include_metadata": { + "default": false, + "type": "boolean" + }, + "include_metrics": { + "default": false, + "type": "boolean" + }, + "llm_profile_id": { + "nullable": true, + "type": "string" + }, + "presigned_urls": { + "items": { + "format": "uri", + "type": "string" + }, + "type": "array" + }, + "tags": { + "default": "", + "description": "Comma-separated list of tag names (EX:'tag1,tag2-name,tag3_name')", + "type": "string" + }, + "timeout": { + "default": -1, + "maximum": 300, + "minimum": -1, + "type": "integer" + }, + "use_file_history": { + "default": false, + "type": "boolean" + } + }, + "type": "object" + }, + "ExecuteResponse": { + "properties": { + "message": { + "$ref": "#/components/schemas/ExecutionMessage" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "ExecutionMessage": { + "properties": { + "error": { + "nullable": true, + "type": "string" + }, + "execution_id": { + "type": "string" + }, + "execution_status": { + "type": "string" + }, + "result": { + "items": { + "$ref": "#/components/schemas/FileResult" + }, + "nullable": true, + "type": "array" + }, + "status_api": { + "nullable": true, + "type": "string" + }, + "workflow_id": { + "type": "string" + } + }, + "required": [ + "execution_status" + ], + "type": "object" + }, + "FileResult": { + "properties": { + "error": { + "nullable": true, + "type": "string" + }, + "file": { + "type": "string" + }, + "file_execution_id": { + "type": "string" + }, + "metadata": {}, + "metrics": {}, + "result": {}, + "status": { + "type": "string" + } + }, + "required": [ + "file" + ], + "type": "object" + }, + "StatusResponse": { + "properties": { + "message": { + "items": { + "$ref": "#/components/schemas/FileResult" + }, + "nullable": true, + "type": "array" + }, + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + }, + "securitySchemes": { + "basicAuth": { + "scheme": "basic", + "type": "http" + }, + "cookieAuth": { + "in": "cookie", + "name": "sessionid", + "type": "apiKey" + } + } + }, + "info": { + "title": "Unstract Document Studio", + "version": "v1" + }, + "openapi": "3.0.3", + "paths": { + "/deployment/api/{org_name}/{api_name}/": { + "get": { + "description": "Poll the status of a previously started execution.", + "operationId": "status", + "parameters": [ + { + "description": "API deployment name.", + "in": "path", + "name": "api_name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "execution_id", + "required": true, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "in": "query", + "name": "include_extracted_text", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "include_metadata", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "include_metrics", + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "description": "Organization identifier.", + "in": "path", + "name": "org_name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + }, + "description": "" + }, + "406": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + }, + "description": "" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "basicAuth": [] + } + ], + "tags": [ + "deployment" + ] + }, + "post": { + "description": "Execute an API deployment against one or more files.", + "operationId": "execute", + "parameters": [ + { + "description": "API deployment name.", + "in": "path", + "name": "api_name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Organization identifier.", + "in": "path", + "name": "org_name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/ExecuteRequest" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteResponse" + } + } + }, + "description": "" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExecuteResponse" + } + } + }, + "description": "" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "basicAuth": [] + } + ], + "tags": [ + "deployment" + ] + } + }, + "/deployment/api/{org_name}/{api_name}/mcp/": { + "get": { + "description": "Refuse the SSE stream, but say who is here.\n\nUnder Streamable HTTP a client issues GET to open a server-to-client\nSSE stream, and a server that offers none must answer 405 (spec rev\n2025-06-18). Nothing here pushes messages \u2014 every tool call is\nrequest/response \u2014 so 405 is the honest answer, and returning\n``200 application/json`` instead would leave a conformant client\nparsing an identity document as an event stream.\n\nThe body is kept anyway: uptime checks and humans with curl probe this\npath, and a 405 may carry one. It stays deliberately free of tenant\ndetail \u2014 it reveals only that an MCP server is mounted here.\n\n``JsonResponse``, not DRF's ``Response``, for the same reason ``post``\nuses it: a DRF response runs content negotiation, so a client sending\n``Accept: text/html`` would be handed the browsable-API renderer.\n\nNo ``Allow`` header is set here. RFC 9110 asks for one on a 405, but a\nhandler cannot control it and pretending otherwise misleads a reader:\nDRF's ``finalize_response`` overwrites any handler-set value with\n``self.allowed_methods`` (``GET, POST, HEAD, OPTIONS``, since this view\ndefines both verbs), and ``RemoveAllowHeaderMiddleware`` \u2014 global in\n``MIDDLEWARE`` \u2014 then pops the header from every response before it\nleaves the process. So a client sees no ``Allow`` at all; a test driving\nthe view through ``APIRequestFactory`` bypasses that middleware and sees\nDRF's value.", + "operationId": "mcp_retrieve", + "parameters": [ + { + "in": "path", + "name": "api_name", + "required": true, + "schema": { + "pattern": "^[\\w-]+$", + "type": "string" + } + }, + { + "in": "path", + "name": "org_name", + "required": true, + "schema": { + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "No response body" + } + }, + "tags": [ + "mcp" + ] + }, + "post": { + "description": "Handle a single JSON-RPC request.", + "operationId": "mcp_create", + "parameters": [ + { + "in": "path", + "name": "api_name", + "required": true, + "schema": { + "pattern": "^[\\w-]+$", + "type": "string" + } + }, + { + "in": "path", + "name": "org_name", + "required": true, + "schema": { + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "No response body" + } + }, + "tags": [ + "mcp" + ] + } + } + }, + "tags": [ + { + "description": "Run an API deployment against one or more documents and poll the result.", + "name": "deployment" + } + ] +} diff --git a/src/unstract_cli/specs/llmwhisperer.json b/src/unstract_cli/specs/llmwhisperer.json new file mode 100644 index 0000000..d5ae488 --- /dev/null +++ b/src/unstract_cli/specs/llmwhisperer.json @@ -0,0 +1,1151 @@ +{ + "components": { + "schemas": { + "WebhookConfig": { + "properties": { + "auth_token": { + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + }, + "webhook_name": { + "type": "string" + } + }, + "required": [ + "url", + "auth_token", + "webhook_name" + ], + "type": "object" + }, + "WhisperAccepted": { + "properties": { + "message": { + "type": "string" + }, + "status": { + "type": "string" + }, + "whisper_hash": { + "type": "string" + } + }, + "type": "object" + }, + "WhisperResult": { + "properties": { + "confidence_metadata": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "type": "object" + }, + "result_text": { + "type": "string" + }, + "webhook_metadata": { + "type": "string" + } + }, + "type": "object" + }, + "WhisperStatus": { + "properties": { + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + } + }, + "securitySchemes": { + "unstract_key": { + "in": "header", + "name": "unstract-key", + "type": "apiKey" + } + } + }, + "info": { + "title": "Unstract LLMWhisperer", + "version": "v2" + }, + "openapi": "3.0.3", + "paths": { + "/api/v2/convert-to-pdf": { + "post": { + "operationId": "convert_to_pdf", + "parameters": [ + { + "in": "query", + "name": "url", + "required": false, + "schema": { + "default": "", + "format": "uri", + "type": "string" + } + }, + { + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Convert a document to PDF", + "tags": [ + "convert" + ] + } + }, + "/api/v2/convert-xlsb-to-xlsx": { + "post": { + "operationId": "convert_xlsb_to_xlsx", + "parameters": [ + { + "in": "query", + "name": "url", + "required": false, + "schema": { + "default": "", + "format": "uri", + "type": "string" + } + }, + { + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Convert an XLSB workbook to XLSX", + "tags": [ + "convert" + ] + } + }, + "/api/v2/document-insights": { + "post": { + "operationId": "document_insights", + "parameters": [ + { + "in": "query", + "name": "file_name", + "required": false, + "schema": { + "default": "sample.pdf", + "type": "string" + } + }, + { + "in": "query", + "name": "pages_to_extract", + "required": false, + "schema": { + "default": "", + "type": "string" + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "default": "default", + "type": "string" + } + }, + { + "in": "query", + "name": "url", + "required": false, + "schema": { + "default": "", + "format": "uri", + "type": "string" + } + }, + { + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "use_webhook", + "required": false, + "schema": { + "default": "", + "type": "string" + } + }, + { + "in": "query", + "name": "webhook_metadata", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Run document insights over a file", + "tags": [ + "insights" + ] + } + }, + "/api/v2/document-insights-retrieve": { + "get": { + "operationId": "document_insights_retrieve", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Retrieve document insights result", + "tags": [ + "insights" + ] + } + }, + "/api/v2/get-usage-info": { + "get": { + "operationId": "usage_info", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Subscription usage summary", + "tags": [ + "account" + ] + } + }, + "/api/v2/highlights": { + "get": { + "operationId": "highlights", + "parameters": [ + { + "in": "query", + "name": "extract_all_lines", + "required": false, + "schema": { + "default": "false", + "type": "string" + } + }, + { + "in": "query", + "name": "lines", + "required": false, + "schema": { + "default": "", + "type": "string" + } + }, + { + "in": "query", + "name": "whisper_hash", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Line-level highlight geometry for an extraction", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/pdf-to-images": { + "post": { + "operationId": "pdf_to_images", + "parameters": [ + { + "in": "query", + "name": "file_name", + "required": false, + "schema": { + "default": "sample.pdf", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "default": "png", + "type": "string" + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "default": "default", + "type": "string" + } + }, + { + "in": "query", + "name": "url", + "required": false, + "schema": { + "default": "", + "format": "uri", + "type": "string" + } + }, + { + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "pdf to images", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/pdf-to-images-retrieve": { + "get": { + "operationId": "pdf_to_images_retrieve", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "pdf to images retrieve", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/pdf-to-images-status": { + "get": { + "operationId": "pdf_to_images_status", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "pdf to images status", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/test-connection": { + "get": { + "operationId": "test_connection", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Verify credentials", + "tags": [ + "account" + ] + } + }, + "/api/v2/usage": { + "get": { + "operationId": "usage", + "parameters": [ + { + "in": "query", + "name": "from_date", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "to_date", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Detailed usage statistics", + "tags": [ + "account" + ] + } + }, + "/api/v2/whisper": { + "post": { + "operationId": "extract", + "parameters": [ + { + "in": "query", + "name": "add_line_nos", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "allow_rotated_text", + "required": false, + "schema": { + "default": true, + "type": "boolean" + } + }, + { + "in": "query", + "name": "checkbox_confidence_threshold", + "required": false, + "schema": { + "default": 0.3, + "type": "number" + } + }, + { + "in": "query", + "name": "derotate_threshold", + "required": false, + "schema": { + "default": 10.0, + "type": "number" + } + }, + { + "in": "query", + "name": "file_name", + "required": false, + "schema": { + "default": "sample.pdf", + "type": "string" + } + }, + { + "in": "query", + "name": "gaussian_blur_radius", + "required": false, + "schema": { + "default": 0, + "type": "number" + } + }, + { + "in": "query", + "name": "horizontal_stretch_factor", + "required": false, + "schema": { + "default": 1.0, + "type": "number" + } + }, + { + "in": "query", + "name": "ignore_vertical_text", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "include_line_confidence", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "lang", + "required": false, + "schema": { + "default": "eng", + "type": "string" + } + }, + { + "in": "query", + "name": "line_splitter_strategy", + "required": false, + "schema": { + "default": "left-priority", + "type": "string" + } + }, + { + "in": "query", + "name": "line_splitter_tolerance", + "required": false, + "schema": { + "default": 0.75, + "type": "number" + } + }, + { + "in": "query", + "name": "mark_horizontal_lines", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "mark_vertical_lines", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "median_filter_size", + "required": false, + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "in": "query", + "name": "min_table_width", + "required": false, + "schema": { + "default": 0.0, + "type": "number" + } + }, + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, + { + "in": "query", + "name": "output_mode", + "required": false, + "schema": { + "default": "layout_preserving", + "type": "string" + } + }, + { + "in": "query", + "name": "page_separator", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "pages_to_extract", + "required": false, + "schema": { + "default": "", + "type": "string" + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "default": "default", + "type": "string" + } + }, + { + "in": "query", + "name": "url", + "required": false, + "schema": { + "default": "", + "format": "uri", + "type": "string" + } + }, + { + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "use_webhook", + "required": false, + "schema": { + "default": "", + "type": "string" + } + }, + { + "in": "query", + "name": "watermark_angle_threshold", + "required": false, + "schema": { + "default": 25.0, + "type": "number" + } + }, + { + "in": "query", + "name": "webhook_metadata", + "required": false, + "schema": { + "default": "", + "type": "string" + } + }, + { + "in": "query", + "name": "word_confidence_threshold", + "required": false, + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhisperAccepted" + } + } + }, + "description": "Accepted" + } + }, + "summary": "Submit a document for text extraction", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/whisper-detail": { + "get": { + "operationId": "detail", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Metadata about a whisper job", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/whisper-manage-callback": { + "delete": { + "operationId": "webhook_delete", + "parameters": [ + { + "in": "query", + "name": "webhook_name", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Manage extraction webhooks", + "tags": [ + "webhook" + ] + }, + "get": { + "operationId": "webhook_get", + "parameters": [ + { + "in": "query", + "name": "webhook_name", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Manage extraction webhooks", + "tags": [ + "webhook" + ] + }, + "post": { + "operationId": "webhook_post", + "parameters": [ + { + "in": "query", + "name": "webhook_name", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Manage extraction webhooks", + "tags": [ + "webhook" + ] + }, + "put": { + "operationId": "webhook_put", + "parameters": [ + { + "in": "query", + "name": "webhook_name", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Manage extraction webhooks", + "tags": [ + "webhook" + ] + } + }, + "/api/v2/whisper-retrieve": { + "get": { + "operationId": "retrieve", + "parameters": [ + { + "in": "query", + "name": "text_only", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "whisper_hash", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhisperResult" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "OK" + } + }, + "summary": "Retrieve extraction result (destructive \u2014 one shot)", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/whisper-status": { + "get": { + "operationId": "status", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": false, + "schema": { + "default": "", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhisperStatus" + } + } + }, + "description": "OK" + } + }, + "summary": "Poll extraction status", + "tags": [ + "whisper" + ] + } + } + }, + "security": [ + { + "unstract_key": [] + } + ], + "servers": [ + { + "url": "https://llmwhisperer-api.us-central.unstract.com" + } + ] +} diff --git a/tests/test_params.py b/tests/test_params.py new file mode 100644 index 0000000..cf7aee3 --- /dev/null +++ b/tests/test_params.py @@ -0,0 +1,229 @@ +"""Flag derivation: what the spec says, what the client accepts, what is sent. + +Each test here corresponds to a way derived flags can be wrong while still +looking right: a value silently dropped, a default silently pinned, a flag +offered that the client cannot accept. +""" + +from __future__ import annotations + +import click +import pytest +from unstract.api_deployments.client import APIDeploymentsClient +from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 + +from unstract_cli.core.params import ( + Param, + click_option, + derive_params, + docstring_params, + find_operation, + operation_params, + requested, +) + + +def _by_name(params: list[Param]) -> dict[str, Param]: + return {p.name: p for p in params} + + +# --------------------------------------------------------------------------- # +# Reading the spec +# --------------------------------------------------------------------------- # + + +def test_query_parameters_carry_type_and_default(): + params = _by_name(operation_params("llmwhisperer", "extract")) + assert params["mode"].type == "string" + assert params["add_line_nos"].type == "boolean" + assert params["median_filter_size"].type == "integer" + assert params["horizontal_stretch_factor"].default == 1.0 + + +def test_body_parameters_are_derived_too(): + """The deployment declares its parameters in a multipart body, not a query.""" + params = _by_name(operation_params("docstudio", "execute")) + assert params["tags"].type == "string" + assert params["timeout"].type == "integer" + assert params["presigned_urls"].array is True + # `null | string` in the spec: the null branch carries nothing for a flag. + assert params["llm_profile_id"].type == "string" + assert params["llm_profile_id"].nullable is True + + +def test_a_required_body_parameter_stays_required(): + params = _by_name(operation_params("llmwhisperer", "webhook_post")) + assert {p.name for p in params.values() if p.required} == { + "url", + "auth_token", + "webhook_name", + } + + +def test_the_uploaded_document_is_not_a_flag(): + """The binary body is the document itself, which the command takes as an + argument.""" + assert "body" not in _by_name(operation_params("llmwhisperer", "extract")) + assert find_operation("llmwhisperer", "extract")["method"] == "post" + + +def test_an_unknown_operation_names_itself(): + with pytest.raises(KeyError, match="whisper_sideways"): + find_operation("llmwhisperer", "whisper_sideways") + + +# --------------------------------------------------------------------------- # +# Intersecting the spec with the published client +# --------------------------------------------------------------------------- # + + +def test_only_parameters_the_client_accepts_become_flags(): + """A flag the client cannot accept raises TypeError at the call instead of + reaching the API, so it is not offered at all.""" + spec = set(_by_name(operation_params("llmwhisperer", "extract"))) + derived = set( + _by_name( + derive_params( + "llmwhisperer", "extract", client_method=LLMWhispererClientV2.whisper + ) + ) + ) + assert derived < spec + assert "checkbox_confidence_threshold" in spec - derived + + +def test_the_clients_default_wins_over_the_specs(): + """What a caller gets by omitting a flag is the client's default, since the + client sends its own value regardless of the spec's.""" + spec = _by_name(operation_params("llmwhisperer", "extract")) + derived = _by_name( + derive_params( + "llmwhisperer", "extract", client_method=LLMWhispererClientV2.whisper + ) + ) + assert spec["line_splitter_tolerance"].default == 0.75 + assert derived["line_splitter_tolerance"].default == 0.4 + + +def test_every_deployment_parameter_survives_the_intersection(): + derived = _by_name( + derive_params( + "docstudio", "execute", client_method=APIDeploymentsClient.structure_file + ) + ) + assert "tags" in derived and "hitl_queue_name" in derived + + +def test_excluded_parameters_do_not_become_flags(): + derived = _by_name( + derive_params( + "llmwhisperer", + "extract", + client_method=LLMWhispererClientV2.whisper, + exclude=("use_webhook",), + ) + ) + assert "use_webhook" not in derived + + +# --------------------------------------------------------------------------- # +# Help text +# --------------------------------------------------------------------------- # + + +def test_help_comes_from_the_clients_docstring(): + """The specs carry no parameter descriptions; the clients document every + parameter, so that is where the text comes from.""" + derived = _by_name( + derive_params( + "llmwhisperer", "extract", client_method=LLMWhispererClientV2.whisper + ) + ) + assert "language" in derived["lang"].description.lower() + + +def test_the_docstrings_own_default_sentence_is_dropped(): + """The default is rendered from the signature; printing the docstring's copy + too would show it twice and disagree the moment the two drift.""" + described = docstring_params(LLMWhispererClientV2.whisper) + assert not described["lang"].endswith('Defaults to "eng".') + assert described["tag"] == "The tag for the document." + + +def test_a_multi_line_description_is_joined(): + text = docstring_params(LLMWhispererClientV2.whisper)["word_confidence_threshold"] + assert "\n" not in text and "confidence" in text + + +def test_the_default_is_reported_in_help(): + param = Param("mode", "string", default="form", description="The mode.") + assert click_option(param, {}).help == "The mode. [default: form]" + + +# --------------------------------------------------------------------------- # +# Building Click options +# --------------------------------------------------------------------------- # + + +def test_a_boolean_gets_a_paired_flag_defaulting_to_neither(): + """`is_flag` cannot turn off a parameter that defaults to on, and cannot + distinguish "not passed" from "passed false".""" + option = click_option(Param("allow_rotated_text", "boolean", default=True), {}) + assert option.secondary_opts == ["--no-allow-rotated-text"] + assert option.default is None + + +def test_no_option_carries_a_value_by_default(): + """A default written into the option would be sent on every call, pinning a + value the client or server would otherwise choose.""" + for param in derive_params( + "llmwhisperer", "extract", client_method=LLMWhispererClientV2.whisper + ): + assert click_option(param, {}).default is None + + +def test_choices_come_from_the_overlay(): + """The specs declare no enums, so allowed values can only come from the + overlay -- and a wrong value must fail before the request, not after.""" + option = click_option(Param("mode"), {"mode": {"choices": ["form", "table"]}}) + assert isinstance(option.type, click.Choice) + assert option.type.choices == ("form", "table") + + +def test_an_array_becomes_a_repeatable_option(): + option = click_option(Param("presigned_urls", "string", array=True), {}) + assert option.multiple is True + + +def test_types_map_onto_click_types(): + assert click_option(Param("n", "integer"), {}).type is click.INT + assert click_option(Param("x", "number"), {}).type is click.FLOAT + assert click_option(Param("s", "string"), {}).type is click.STRING + + +def test_a_required_parameter_stays_required(): + assert click_option(Param("url", "string", required=True), {}).required is True + + +# --------------------------------------------------------------------------- # +# Choosing what to send +# --------------------------------------------------------------------------- # + + +def test_falsy_values_are_sent(): + """0, false and "" are choices. A truthiness filter eats them and hands the + decision back to the server without telling anyone.""" + assert requested({"a": 0, "b": False, "c": "", "d": 0.0}) == { + "a": 0, + "b": False, + "c": "", + "d": 0.0, + } + + +def test_unpassed_values_are_not_sent(): + assert requested({"a": None, "b": (), "c": 1}) == {"c": 1} + + +def test_dropped_names_are_not_sent(): + assert requested({"a": 1, "b": 2}, drop=("b",)) == {"a": 1} From bb46d73a990a2714d3d47d7d3236f1ae9bf3c37b Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 13:55:16 +0530 Subject: [PATCH 03/86] feat: the v1 command surface for both products Thirteen commands: whisper extract/status/retrieve/detail/highlights/usage and its four webhook commands, plus deployment run and status. Each one holds only what a spec cannot say -- which parameter is the argument, which the CLI owns, and how a result is polled for. The CLI runs the poll loop for both products rather than using the loop one client ships, so --wait, --interval, --timeout and the handle-returned-on- timeout behaviour are the same everywhere. Deployment runs are queued (timeout=0) so a request does not hold a connection open for the length of the job. Line-highlight scaling is arithmetic on a reply rather than a request, so it is folded into the command that fetches the metadata. Failures converge on one envelope: LLMWhisperer raises with a status code, the deployment client returns one, and both become a CLIError with an exit code and a hint. A result that can be read only once is written to disk before it is printed. --- src/unstract_cli/app.py | 4 + src/unstract_cli/commands/common.py | 83 ++++ src/unstract_cli/commands/docstudio_cmd.py | 124 ++++++ src/unstract_cli/commands/whisper_cmd.py | 277 +++++++++++++ src/unstract_cli/core/clients.py | 165 ++++++++ src/unstract_cli/core/params.py | 78 +++- tests/test_commands.py | 427 +++++++++++++++++++++ 7 files changed, 1143 insertions(+), 15 deletions(-) create mode 100644 src/unstract_cli/commands/common.py create mode 100644 src/unstract_cli/commands/docstudio_cmd.py create mode 100644 src/unstract_cli/commands/whisper_cmd.py create mode 100644 src/unstract_cli/core/clients.py create mode 100644 tests/test_commands.py diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 7ac17f7..c8dc343 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -123,6 +123,10 @@ def deployment_group() -> None: cli.add_command(config_group) +# Imported for their side effect of registering commands, and imported last +# because those modules hang their commands off the groups declared just above. +from unstract_cli.commands import docstudio_cmd, whisper_cmd # noqa: E402,F401 + def command_tree() -> dict[str, Any]: """The registered command tree, read back from Click itself. diff --git a/src/unstract_cli/commands/common.py b/src/unstract_cli/commands/common.py new file mode 100644 index 0000000..e3a2baf --- /dev/null +++ b/src/unstract_cli/commands/common.py @@ -0,0 +1,83 @@ +"""Pieces every product command shares: the wait flags and result emission.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import click + +from unstract_cli.app import Context +from unstract_cli.core.output import emit_result + +#: Seconds between polls, and the ceiling on the whole wait. Both are flags; the +#: defaults are a compromise between a fast small document and not hammering the +#: service while a large one runs. +DEFAULT_INTERVAL = 3.0 +DEFAULT_TIMEOUT = 300.0 + +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", + type=float, + default=DEFAULT_INTERVAL, + show_default=True, + help="Seconds between polls.", + ), + click.option( + "--timeout", + "wait_timeout", + type=float, + 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 finish( + ctx: Context, + data: Any, + *, + raw_field: str | None = None, + meta: dict[str, Any] | None = None, +) -> None: + """Emit one result envelope, scrubbing any resolved credential from it.""" + emit_result( + data, + ctx.output, + meta=meta, + raw_field=raw_field, + secrets=ctx.secrets(), + ) + + +__all__ = ["DEFAULT_INTERVAL", "DEFAULT_TIMEOUT", "finish", "wait_options"] diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py new file mode 100644 index 0000000..79a74e1 --- /dev/null +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -0,0 +1,124 @@ +"""`unstract docstudio deployment ...` -- running a deployed API. + +The deployment client reports failure by returning a status code rather than +raising, and it has no polling loop of its own, so both are handled here. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import click +from unstract.api_deployments.client import APIDeploymentsClient + +from unstract_cli.app import Context, deployment_group, pass_context +from unstract_cli.commands.common import finish, wait_options +from unstract_cli.core.clients import deployment, raise_for_result, translated +from unstract_cli.core.params import requested, spec_options +from unstract_cli.core.poll import PollSpec, wait_for_completion + +PRODUCT = "docstudio" + +#: The run POST and the status GET spell the state under different names, and +#: the API answers HTTP 422 while still executing -- only the body decides. +RUN_POLL = PollSpec( + handle_field="status_check_api_endpoint", + terminal_success=("COMPLETED", "SUCCESS"), + terminal_failure=("ERROR", "ERROR_EXCEPTION", "FAILED", "STOPPED"), + status_field=("execution_status", "status"), +) + +#: `--output raw` prints one field rather than the whole payload. +RAW_FIELD = "extraction_result" + + +@deployment_group.command("run") +@click.argument("target") +@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True)) +@wait_options() +@spec_options( + PRODUCT, + "execute", + client_method=APIDeploymentsClient.structure_file, + # `files` is the FILES argument; `timeout` selects the server's own + # execution mode and would fight the CLI's polling for the same job. + exclude=("files", "timeout"), +) +@pass_context +def run( + ctx: Context, + target: str, + files: tuple[str, ...], + wait: bool, + interval: float, + wait_timeout: float, + save: str | None, + **params: Any, +) -> None: + """Run a deployment against one or more documents. + + TARGET is a deployment alias or an API name. With --wait (the default) this + polls until the execution finishes and returns its result. + """ + client = deployment(ctx.config, target) + with translated(endpoint=client.api_url): + # Queued execution, so the request returns a handle instead of holding + # the connection open for the length of the job. + started = client.structure_file(list(files), timeout=0, **requested(params)) + raise_for_result(started, endpoint=client.api_url) + + if not wait: + finish(ctx, started, raw_field=RAW_FIELD) + return + + result = wait_for_completion( + initial=started, + spec=RUN_POLL, + poll=_status_poller(client), + save=save, + interval=interval, + timeout=wait_timeout, + on_status=lambda status: ( + click.echo(f"status: {status}", err=True) if not ctx.quiet else None + ), + ) + finish(ctx, result, raw_field=RAW_FIELD) + + +def _status_poller(client: APIDeploymentsClient) -> Callable[[str], dict[str, Any]]: + """Poll one execution, failing on a status code the poll loop cannot use.""" + + def poll(endpoint: str) -> dict[str, Any]: + result = client.check_execution_status(endpoint) + # A retryable status is left to the client's own retry policy, which has + # already run; the client reports those as still pending. + if not result.get("pending"): + raise_for_result(result, endpoint=client.api_url) + return result + + return poll + + +@deployment_group.command("status") +@click.argument("target") +@click.argument("execution_id") +@spec_options( + PRODUCT, + "status", + client_method=APIDeploymentsClient.check_execution_status, + exclude=("execution_id",), +) +@pass_context +def status(ctx: Context, target: str, execution_id: str, **params: Any) -> None: + """Report the state of a running or finished execution.""" + client = deployment(ctx.config, target) + endpoint = f"{client.api_url}?execution_id={execution_id}" + with translated(endpoint=client.api_url): + result = client.check_execution_status(endpoint) + if not result.get("pending"): + raise_for_result(result, endpoint=client.api_url) + finish(ctx, result, raw_field=RAW_FIELD) + + +__all__ = ["run", "status"] diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py new file mode 100644 index 0000000..cb756ff --- /dev/null +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -0,0 +1,277 @@ +"""`unstract whisper ...` -- text and layout extraction. + +Every flag below the command name is derived from the committed spec, so this +module holds only what the spec cannot say: which parameter is an argument, +which the CLI owns, and how a result is polled for and retrieved. +""" + +from __future__ import annotations + +from typing import Any + +import click +from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 + +from unstract_cli.app import Context, pass_context, whisper_group +from unstract_cli.commands.common import finish, wait_options +from unstract_cli.core.clients import llmwhisperer, translated +from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.params import requested, spec_options +from unstract_cli.core.poll import PollSpec, persist, wait_for_completion + +PRODUCT = "llmwhisperer" + +#: An extraction is finished when the *body* says so. `unknown` is terminal too: +#: the service reports it for a hash it no longer knows, and polling one forever +#: is worse than reporting it. +EXTRACT_POLL = PollSpec( + handle_field="whisper_hash", + terminal_success=("processed",), + terminal_failure=("error", "unknown"), + status_field="status", +) + +#: `--output raw` prints one field rather than the whole payload. Extraction +#: results carry the text under this name. +RAW_FIELD = "result_text" + + +def _is_url(source: str) -> bool: + return source.startswith(("http://", "https://")) + + +@whisper_group.command("extract") +@click.argument("source") +@wait_options() +@spec_options( + PRODUCT, + "extract", + client_method=LLMWhispererClientV2.whisper, + # `url` is the SOURCE argument when it looks like one. + exclude=("url",), +) +@pass_context +def extract( + ctx: Context, + source: str, + wait: bool, + interval: float, + wait_timeout: float, + save: str | None, + **params: Any, +) -> None: + """Extract text from a document, given a file path or a URL. + + With --wait (the default) this returns the extracted text. With --no-wait it + returns the whisper_hash, and `whisper status` and `whisper retrieve` take + it from there. + """ + client = llmwhisperer(ctx.config) + sent = requested(params) + + if sent.get("use_webhook") and wait: + raise CLIError( + "--wait and --use-webhook are mutually exclusive.", + ExitCode.USAGE, + hint=( + "A webhook delivers the result itself; pass --no-wait to submit " + "and return immediately." + ), + ) + + with translated(endpoint="whisper"): + # The client has its own blocking loop; the CLI's is used instead so + # that --interval, --timeout and the handle-on-timeout behaviour are the + # same for every product. + accepted = client.whisper( + **({"url": source} if _is_url(source) else {"file_path": source}), + **sent, + wait_for_completion=False, + ) + + if not wait: + finish(ctx, accepted) + return + + result = wait_for_completion( + initial=accepted, + spec=EXTRACT_POLL, + poll=client.whisper_status, + retrieve=lambda handle: client.whisper_retrieve(handle).get("extraction"), + save=save, + interval=interval, + timeout=wait_timeout, + on_status=lambda status: ( + click.echo(f"status: {status}", err=True) if not ctx.quiet else None + ), + ) + finish(ctx, result, raw_field=RAW_FIELD) + + +@whisper_group.command("status") +@click.argument("whisper_hash") +@pass_context +def status(ctx: Context, whisper_hash: str) -> None: + """Report the state of a submitted extraction.""" + client = llmwhisperer(ctx.config) + with translated(endpoint="whisper-status"): + finish(ctx, client.whisper_status(whisper_hash)) + + +@whisper_group.command("retrieve") +@click.argument("whisper_hash") +@click.option( + "--save", + type=click.Path(dir_okay=False), + default=None, + help="Write the result here before printing it.", +) +@pass_context +def retrieve(ctx: Context, whisper_hash: str, save: str | None) -> None: + """Fetch a finished extraction. + + A result can be read exactly once, so --save writes it to disk before it is + printed: a broken pipe or a full terminal buffer after the read cannot be + recovered by asking again. + """ + client = llmwhisperer(ctx.config) + with translated(endpoint="whisper-retrieve"): + payload = client.whisper_retrieve(whisper_hash) + result = payload.get("extraction", payload) + if save: + persist(save, result) + finish(ctx, result, raw_field=RAW_FIELD) + + +@whisper_group.command("detail") +@click.argument("whisper_hash") +@pass_context +def detail(ctx: Context, whisper_hash: str) -> None: + """Report processing detail for one extraction.""" + client = llmwhisperer(ctx.config) + with translated(endpoint="whisper-detail"): + finish(ctx, client.whisper_detail(whisper_hash)) + + +@whisper_group.command("highlights") +@click.argument("whisper_hash") +@spec_options( + PRODUCT, + "highlights", + client_method=LLMWhispererClientV2.get_highlight_data, + exclude=("whisper_hash",), +) +@click.option( + "--target-width", + type=int, + default=None, + help="Width of the page as displayed. With --target-height, adds a bounding box per line.", +) +@click.option( + "--target-height", + type=int, + default=None, + help="Height of the page as displayed.", +) +@pass_context +def highlights( + ctx: Context, + whisper_hash: str, + target_width: int | None, + target_height: int | None, + **params: Any, +) -> None: + """Fetch line metadata, optionally scaled to a page you are rendering. + + The scaling is arithmetic on the metadata, not a second request, so it is + folded in here rather than being a command of its own. + """ + client = llmwhisperer(ctx.config) + with translated(endpoint="highlights"): + data = client.get_highlight_data(whisper_hash, **requested(params)) + + if target_width and target_height: + data = { + "lines": data, + "rects": _bounding_boxes(client, data, target_width, target_height), + } + finish(ctx, data) + + +def _bounding_boxes( + client: LLMWhispererClientV2, + data: Any, + target_width: int, + target_height: int, +) -> dict[str, list[int]]: + """(page, x1, y1, x2, y2) per line, for the lines that carry metadata.""" + if not isinstance(data, dict): + return {} + return { + str(line): list(client.get_highlight_rect(metadata, target_width, target_height)) + for line, metadata in data.items() + if isinstance(metadata, list) + and len(metadata) >= 4 + and all(isinstance(v, (int, float)) for v in metadata) + } + + +@whisper_group.command("usage") +@pass_context +def usage(ctx: Context) -> None: + """Report this key's usage and remaining quota.""" + client = llmwhisperer(ctx.config) + with translated(endpoint="get-usage-info"): + finish(ctx, client.get_usage_info()) + + +@whisper_group.group("webhook") +def webhook_group() -> None: + """Manage the webhooks an extraction can deliver its result to.""" + + +@webhook_group.command("create") +@click.argument("name") +@click.option("--url", required=True, help="Where the result is delivered.") +@click.option("--auth-token", required=True, help="Token sent with the delivery.") +@pass_context +def webhook_create(ctx: Context, name: str, url: str, auth_token: str) -> None: + """Register a webhook.""" + client = llmwhisperer(ctx.config) + with translated(endpoint="whisper-manage-callback"): + finish(ctx, client.register_webhook(url, auth_token, name)) + + +@webhook_group.command("update") +@click.argument("name") +@click.option("--url", required=True, help="Where the result is delivered.") +@click.option("--auth-token", required=True, help="Token sent with the delivery.") +@pass_context +def webhook_update(ctx: Context, name: str, url: str, auth_token: str) -> None: + """Replace a webhook's URL and token.""" + client = llmwhisperer(ctx.config) + with translated(endpoint="whisper-manage-callback"): + finish(ctx, client.update_webhook_details(name, url, auth_token)) + + +@webhook_group.command("get") +@click.argument("name") +@pass_context +def webhook_get(ctx: Context, name: str) -> None: + """Show one webhook's configuration.""" + client = llmwhisperer(ctx.config) + with translated(endpoint="whisper-manage-callback"): + finish(ctx, client.get_webhook_details(name)) + + +@webhook_group.command("delete") +@click.argument("name") +@pass_context +def webhook_delete(ctx: Context, name: str) -> None: + """Remove a webhook.""" + client = llmwhisperer(ctx.config) + with translated(endpoint="whisper-manage-callback"): + finish(ctx, client.delete_webhook(name)) + + +__all__ = ["extract", "highlights", "retrieve", "status", "usage", "webhook_group"] diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py new file mode 100644 index 0000000..ce52338 --- /dev/null +++ b/src/unstract_cli/core/clients.py @@ -0,0 +1,165 @@ +"""Building the product clients, and turning their failures into CLI errors. + +The entry point deliberately does not catch bare ``Exception``: an unexpected +crash should look like a crash. Everything a client raises on purpose is +expected, so it is translated here into a ``CLIError`` carrying an exit code, a +hint and the response detail. + +The two clients report failure differently -- LLMWhisperer raises with a status +code attached, the deployment client returns a dict containing one -- so both +shapes converge here rather than in each command. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +from requests.exceptions import ConnectionError, Timeout +from unstract.api_deployments.client import ( + APIDeploymentsClient, + APIDeploymentsClientException, +) +from unstract.llmwhisperer.client_v2 import ( + LLMWhispererClientException, + LLMWhispererClientV2, +) + +from unstract_cli.config import DOCSTUDIO, LLMWHISPERER, ResolvedConfig +from unstract_cli.core.errors import CLIError, ExitCode, error_from_status +from unstract_cli.core.params import find_operation + + +def llmwhisperer(config: ResolvedConfig) -> LLMWhispererClientV2: + """Build the LLMWhisperer client from the resolved configuration.""" + return LLMWhispererClientV2( + base_url=config.require(LLMWHISPERER, "base_url"), + api_key=config.require(LLMWHISPERER, "api_key"), + logging_level="ERROR", + ) + + +def deployment_url(base_url: str, org_id: str, api_name: str) -> str: + """The deployment's full URL, laid out as the spec declares the route. + + The client takes the whole URL and reads the organisation and API name back + out of its last two segments, so the route is built from the spec rather + than from a format string that can disagree with it. + """ + path = find_operation(DOCSTUDIO, "execute")["path"] + path = path.format(org_name=org_id, api_name=api_name) + return base_url.rstrip("/") + path + + +def deployment(config: ResolvedConfig, target: str) -> APIDeploymentsClient: + """Build a deployment client for an alias, or for a bare API name. + + An alias carries its own organisation and key; a bare name falls back to the + profile's, so an unconfigured caller can still name a deployment directly. + """ + if target in config.deployment_aliases(): + entry = config.deployment(target) + api_name, org_id, api_key = ( + entry["api_name"], + entry["org_id"], + entry["api_key"], + ) + else: + api_name = target + org_id = config.get(DOCSTUDIO, "org_id") + api_key = config.get(DOCSTUDIO, "api_key") + + missing = [ + name for name, value in (("org_id", org_id), ("api_key", api_key)) if not value + ] + if missing: + raise CLIError( + f"Deployment {target!r} is missing {' and '.join(missing)}.", + ExitCode.USAGE, + hint=( + "Define the deployment as an alias in the active profile, or set " + "$UNSTRACT_ORG_ID and $UNSTRACT_DEPLOYMENT_KEY." + ), + ) + + return APIDeploymentsClient( + api_url=deployment_url(config.require(DOCSTUDIO, "base_url"), org_id, api_name), + api_key=api_key, + logging_level="ERROR", + ) + + +def _message_and_details(value: Any) -> tuple[str, Any]: + """Split a client's error value into a one-line message and the raw detail. + + LLMWhisperer raises with either a string or the decoded error body, and the + body's own wording is better than anything invented here. + """ + if isinstance(value, dict): + for key in ("message", "error", "detail", "reason"): + if text := value.get(key): + return str(text), value + return str(value), value + return str(value), None + + +@contextmanager +def translated(endpoint: str | None = None) -> Iterator[None]: + """Turn a client failure into a CLIError with an exit code and a hint.""" + try: + yield + except LLMWhispererClientException as exc: + message, details = _message_and_details(exc.value) + status = exc.status_code or ( + details.get("status_code") if isinstance(details, dict) else None + ) + if status: + raise error_from_status( + int(status), message, details=details, endpoint=endpoint + ) from exc + raise CLIError(message, details=details, endpoint=endpoint) from exc + except APIDeploymentsClientException as exc: + raise CLIError(str(exc), ExitCode.USAGE, endpoint=endpoint) from exc + except Timeout as exc: + raise CLIError( + str(exc), + ExitCode.TIMEOUT, + endpoint=endpoint, + retryable=True, + hint="The request timed out in transit; the job may still be running.", + ) from exc + except ConnectionError as exc: + raise CLIError( + str(exc), + ExitCode.SERVER_ERROR, + endpoint=endpoint, + retryable=True, + hint="Could not reach the service. Check the base URL and connectivity.", + ) from exc + + +def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> None: + """Fail on a deployment response that reports an error status. + + The deployment client returns its status code instead of raising, so a + failure would otherwise be reported as a successful run whose payload + happens to contain an error. + """ + status = int(result.get("status_code") or 0) + if status and not 200 <= status < 300: + raise error_from_status( + status, + str(result.get("error") or f"Request failed with status {status}"), + details=result, + endpoint=endpoint, + ) + + +__all__ = [ + "deployment", + "deployment_url", + "llmwhisperer", + "raise_for_result", + "translated", +] diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index 0f137a2..ec11af9 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -157,8 +157,8 @@ def operation_params(product: str, operation_id: str) -> list[Param]: return params -def client_params(method: Callable[..., Any]) -> dict[str, Any]: - """Parameter name -> default for a client method, ``None`` where there is none. +def client_params(method: Callable[..., Any]) -> dict[str, inspect.Parameter]: + """The parameters a client method accepts, by name. The published clients are frozen, so a spec parameter the client's signature does not name cannot be reached at all: passing it raises ``TypeError`` @@ -166,12 +166,49 @@ def client_params(method: Callable[..., Any]) -> dict[str, Any]: surface equal to what actually works. """ return { - name: (None if p.default is inspect.Parameter.empty else p.default) + name: p for name, p in inspect.signature(method).parameters.items() if name not in ("self", "cls") } +#: Python annotation -> OpenAPI type. The clients are generated from the same +#: specs, but a source-derived spec can only report what the endpoint reads off +#: the wire -- `extract_all_lines` is `"false"`, a string, there and a `bool` in +#: the signature. The signature is what the call actually takes. +_ANNOTATIONS: dict[Any, str] = { + bool: "boolean", + int: "integer", + float: "number", + str: "string", +} + + +def _is_unset(value: Any) -> bool: + """Whether a default is a generated client's "absent" sentinel. + + Matched by name rather than by import: each client ships its own ``Unset`` + inside its generated tree, and that path is regenerated wholesale. + """ + return type(value).__name__ == "Unset" + + +def _from_signature(param: Param, signature: inspect.Parameter) -> Param: + """Reconcile a spec parameter with the client signature that will carry it.""" + updates: dict[str, Any] = {} + if (mapped := _ANNOTATIONS.get(signature.annotation)) is not None: + updates["type"] = mapped + if signature.default is inspect.Parameter.empty: + # No default in the signature means the call cannot omit it. + updates["required"] = True + elif not _is_unset(signature.default): + # What omitting the flag gets you: the client sends its own value. An + # `Unset` default sends nothing, so there the spec's default is the + # honest answer, because the server applies it. + updates["default"] = signature.default + return replace(param, **updates) + + #: `name (type, optional): description` -- the Args entry of a Google-style #: docstring, which is how both clients document their parameters. _ARG_LINE = re.compile(r"^\s*(\w+)\s*(\([^)]*\))?\s*:\s*(.*)$") @@ -208,7 +245,7 @@ def docstring_params(method: Callable[..., Any]) -> dict[str, str]: # "Defaults to X." sentence would print it a second time, and disagree with # it whenever the two drift. return { - name: re.sub(r"\s*Defaults to [^.]*\.\s*$", "", " ".join(text.split())) + name: re.sub(r"\s*Defaults to .*\.\s*$", "", " ".join(text.split())) for name, text in out.items() if text } @@ -231,7 +268,7 @@ def _help_text(param: Param, choices: tuple[str, ...]) -> str: parts = [param.description] if param.description else [] if choices: parts.append(f"One of: {', '.join(choices)}.") - if param.default is not None and not param.required: + if param.default not in (None, "") and not param.required: rendered = ( str(param.default).lower() if isinstance(param.default, bool) @@ -296,8 +333,7 @@ def derive_params( if accepted is not None: if param.name not in accepted: continue - if (default := accepted[param.name]) is not None: - param = replace(param, default=default) + param = _from_signature(param, accepted[param.name]) if not param.description and (text := described.get(param.name)): param = replace(param, description=text) out.append(param) @@ -310,21 +346,33 @@ def spec_options( *, client_method: Callable[..., Any] | None = None, exclude: tuple[str, ...] = (), -) -> Callable[[click.Command], click.Command]: +) -> Callable[[Any], Any]: """Decorator: hang one operation's parameters off a command as options. ``exclude`` drops parameters the command supplies itself -- the document to extract is an argument, not a flag, and the CLI owns the polling that ``use_webhook`` would bypass. + + Applies either above or below ``@group.command()``: above it decorates a + built command, below it a bare function that Click has yet to build. """ spec_overlay = overlay_for(product, operation_id) - def decorate(command: click.Command) -> click.Command: - for param in derive_params( - product, operation_id, client_method=client_method, exclude=exclude - ): - command.params.append(click_option(param, spec_overlay)) - return command + def decorate(target: Any) -> Any: + options = [ + click_option(param, spec_overlay) + for param in derive_params( + product, operation_id, client_method=client_method, exclude=exclude + ) + ] + if isinstance(target, click.Command): + target.params.extend(options) + else: + # Click reads this list back in reverse, so the help lists the + # parameters in the order the spec declares them. + pending = getattr(target, "__click_params__", []) + target.__click_params__ = list(reversed(options)) + pending + return target return decorate @@ -337,7 +385,7 @@ def requested(values: dict[str, Any], *, drop: tuple[str, ...] = ()) -> dict[str ``False`` and ``""`` are values the caller chose and must survive. """ return { - name: value + name: list(value) if isinstance(value, tuple) else value for name, value in values.items() if name not in drop and value is not None and value != () } diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..15e6530 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,427 @@ +"""The product commands, with the clients replaced. No network. + +The seam is the client factory, not the transport: what matters here is which +arguments a command hands the client, what it does with the reply, and what a +caller sees on stdout and in the exit code. +""" + +from __future__ import annotations + +import json + +import pytest +from unstract.llmwhisperer.client_v2 import ( + LLMWhispererClientException, + LLMWhispererClientV2, +) + +from unstract_cli.__main__ import main +from unstract_cli.app import command_tree +from unstract_cli.commands import docstudio_cmd, whisper_cmd +from unstract_cli.core.errors import ExitCode + + +def run(capsys, *args): + """Invoke the CLI as the console script does, returning (code, stdout, stderr).""" + code = main(list(args)) + captured = capsys.readouterr() + return code, captured.out, captured.err + + +def envelope(out: str) -> dict: + return json.loads(out) + + +class FakeWhisper: + """Records calls; returns whatever the test queued.""" + + def __init__(self, **replies): + self.replies = replies + self.calls: list[tuple[str, tuple, dict]] = [] + + def _reply(self, name, *args, **kwargs): + self.calls.append((name, args, kwargs)) + reply = self.replies.get(name) + if isinstance(reply, Exception): + raise reply + if isinstance(reply, list): + return reply.pop(0) if len(reply) > 1 else reply[0] + return reply + + #: Pure geometry on a reply, so the real implementation is used rather than + #: a queued answer. + get_highlight_rect = LLMWhispererClientV2.get_highlight_rect + + def __getattr__(self, name): + def call(*args, **kwargs): + return self._reply(name, *args, **kwargs) + + return call + + def kwargs_for(self, name) -> dict: + return next(kw for called, _, kw in self.calls if called == name) + + +@pytest.fixture +def whisper_client(monkeypatch): + """Install a fake LLMWhisperer client and hand it back to the test.""" + + def install(**replies): + client = FakeWhisper(**replies) + monkeypatch.setattr(whisper_cmd, "llmwhisperer", lambda _config: client) + return client + + return install + + +@pytest.fixture +def deployment_client(monkeypatch): + """Install a fake deployment client and hand it back to the test.""" + + def install(**replies): + client = FakeWhisper(**replies) + client.api_url = "https://api.example.com/deployment/api/org/api-name/" + monkeypatch.setattr(docstudio_cmd, "deployment", lambda _config, _t: client) + return client + + return install + + +# --------------------------------------------------------------------------- # +# The command surface +# --------------------------------------------------------------------------- # + + +def test_the_v1_commands_are_registered(): + tree = command_tree() + assert set(tree["whisper"]["commands"]) == { + "detail", + "extract", + "highlights", + "retrieve", + "status", + "usage", + "webhook", + } + assert set(tree["whisper"]["commands"]["webhook"]["commands"]) == { + "create", + "delete", + "get", + "update", + } + assert set(tree["docstudio"]["commands"]["deployment"]["commands"]) == { + "run", + "status", + } + + +# --------------------------------------------------------------------------- # +# whisper extract +# --------------------------------------------------------------------------- # + + +def test_extract_without_wait_returns_the_handle(capsys, whisper_client, tmp_path): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + client = whisper_client(whisper={"whisper_hash": "h1", "status_code": 202}) + + code, out, _ = run(capsys, "whisper", "extract", str(doc), "--no-wait") + + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["data"]["whisper_hash"] == "h1" + assert client.kwargs_for("whisper")["file_path"] == str(doc) + + +def test_only_the_flags_that_were_passed_reach_the_client( + capsys, whisper_client, tmp_path +): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + client = whisper_client(whisper={"whisper_hash": "h1"}) + + run(capsys, "whisper", "extract", str(doc), "--no-wait", "--mode", "table") + + sent = client.kwargs_for("whisper") + assert sent["mode"] == "table" + assert "lang" not in sent and "median_filter_size" not in sent + + +def test_a_falsy_flag_still_reaches_the_client(capsys, whisper_client, tmp_path): + """`--median-filter-size 0` is a choice; a truthiness filter would drop it + and silently leave the client's own default in place.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + client = whisper_client(whisper={"whisper_hash": "h1"}) + + run( + capsys, + "whisper", + "extract", + str(doc), + "--no-wait", + "--median-filter-size", + "0", + "--no-add-line-nos", + ) + + sent = client.kwargs_for("whisper") + assert sent["median_filter_size"] == 0 + assert sent["add_line_nos"] is False + + +def test_a_url_source_is_sent_as_a_url(capsys, whisper_client): + client = whisper_client(whisper={"whisper_hash": "h1"}) + run(capsys, "whisper", "extract", "https://example.com/a.pdf", "--no-wait") + sent = client.kwargs_for("whisper") + assert sent["url"] == "https://example.com/a.pdf" and "file_path" not in sent + + +def test_the_cli_owns_the_wait_loop(capsys, whisper_client, tmp_path): + """The client has a blocking loop of its own; using it would make --interval, + --timeout and the handle-on-timeout behaviour product-specific.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + client = whisper_client( + whisper={"whisper_hash": "h1"}, + whisper_status=[{"status": "processing"}, {"status": "processed"}], + whisper_retrieve={"extraction": {"result_text": "hello"}}, + ) + + code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0") + + assert code == int(ExitCode.SUCCESS) + assert client.kwargs_for("whisper")["wait_for_completion"] is False + assert envelope(out)["data"] == {"result_text": "hello"} + + +def test_raw_output_prints_the_extracted_text(capsys, whisper_client, tmp_path): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client( + whisper={"whisper_hash": "h1"}, + whisper_status={"status": "processed"}, + whisper_retrieve={"extraction": {"result_text": "hello"}}, + ) + + _, out, _ = run( + capsys, "-q", "-o", "raw", "whisper", "extract", str(doc), "--interval", "0" + ) + assert out.strip() == "hello" + + +def test_wait_and_use_webhook_are_mutually_exclusive(capsys, whisper_client, tmp_path): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client(whisper={"whisper_hash": "h1"}) + + code, out, _ = run( + capsys, "whisper", "extract", str(doc), "--use-webhook", "wh1", "--wait" + ) + assert code == int(ExitCode.USAGE) + assert "webhook" in envelope(out)["error"]["hint"] + + +def test_a_failed_extraction_carries_the_handle(capsys, whisper_client, tmp_path): + """A caller can resume from the handle rather than resubmitting.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client( + whisper={"whisper_hash": "h1"}, + whisper_status={"status": "error", "message": "bad scan"}, + ) + + code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0") + assert code == int(ExitCode.VALIDATION) + assert envelope(out)["error"]["whisper_hash"] == "h1" + + +# --------------------------------------------------------------------------- # +# Retrieval is one-shot +# --------------------------------------------------------------------------- # + + +def test_retrieve_saves_before_it_prints(capsys, whisper_client, tmp_path): + """A result can be read once. Persisting after printing loses it to a broken + pipe or a full terminal buffer.""" + target = tmp_path / "out" / "result.json" + whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}}) + + code, out, _ = run(capsys, "whisper", "retrieve", "h1", "--save", str(target)) + + assert code == int(ExitCode.SUCCESS) + assert json.loads(target.read_text())["result_text"] == "hello" + assert envelope(out)["data"]["result_text"] == "hello" + + +def test_an_already_consumed_result_has_its_own_exit_code(capsys, whisper_client): + whisper_client(whisper_retrieve=LLMWhispererClientException("already retrieved", 406)) + code, out, _ = run(capsys, "whisper", "retrieve", "h1") + assert code == int(ExitCode.ALREADY_CONSUMED) + assert "once" in envelope(out)["error"]["hint"] + + +# --------------------------------------------------------------------------- # +# Errors from the client +# --------------------------------------------------------------------------- # + + +def test_an_auth_failure_maps_onto_its_exit_code(capsys, whisper_client): + whisper_client(get_usage_info=LLMWhispererClientException("bad key", 401)) + code, out, _ = run(capsys, "whisper", "usage") + assert code == int(ExitCode.AUTH) + assert envelope(out)["error"]["message"] == "bad key" + + +def test_an_error_body_keeps_its_own_wording(capsys, whisper_client): + whisper_client( + whisper_detail=LLMWhispererClientException( + {"message": "no such hash", "status_code": 404} + ) + ) + code, out, _ = run(capsys, "whisper", "detail", "h1") + assert code == int(ExitCode.NOT_FOUND) + error = envelope(out)["error"] + assert error["message"] == "no such hash" + assert error["details"]["status_code"] == 404 + + +# --------------------------------------------------------------------------- # +# highlights +# --------------------------------------------------------------------------- # + + +def test_highlights_scales_line_metadata_when_a_page_size_is_given( + capsys, whisper_client +): + """Pure arithmetic on the reply, so it is folded into this command rather + than being a command that makes no request.""" + whisper_client(get_highlight_data={"1": [1, 100, 20, 1000]}) + code, out, _ = run( + capsys, + "whisper", + "highlights", + "h1", + "--lines", + "1-5", + "--target-width", + "600", + "--target-height", + "800", + ) + assert code == int(ExitCode.SUCCESS) + data = envelope(out)["data"] + assert data["rects"]["1"] == [1, 0, 64, 600, 80] + + +def test_highlights_returns_the_metadata_alone_without_a_page_size( + capsys, whisper_client +): + whisper_client(get_highlight_data={"1": [1, 100, 20, 1000]}) + _, out, _ = run(capsys, "whisper", "highlights", "h1", "--lines", "1-5") + assert envelope(out)["data"] == {"1": [1, 100, 20, 1000]} + + +# --------------------------------------------------------------------------- # +# Deployments +# --------------------------------------------------------------------------- # + + +def test_run_queues_the_execution_and_polls_it(capsys, deployment_client, tmp_path): + """`timeout=0` queues, so the CLI holds the poll loop instead of the request + holding a connection open for the length of the job.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + client = deployment_client( + structure_file={ + "status_code": 200, + "pending": True, + "execution_status": "PENDING", + "status_check_api_endpoint": "/status?execution_id=e1", + }, + check_execution_status=[ + {"status_code": 200, "pending": True, "execution_status": "EXECUTING"}, + { + "status_code": 200, + "pending": False, + "execution_status": "COMPLETED", + "extraction_result": [{"file": "doc.pdf"}], + }, + ], + ) + + code, out, _ = run( + capsys, + "-q", + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--interval", + "0", + ) + + assert code == int(ExitCode.SUCCESS) + assert client.kwargs_for("structure_file")["timeout"] == 0 + assert envelope(out)["data"]["execution_status"] == "COMPLETED" + + +def test_run_passes_only_the_flags_that_were_given(capsys, deployment_client, tmp_path): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + client = deployment_client( + structure_file={"status_code": 200, "execution_status": "COMPLETED"} + ) + + run( + capsys, + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--no-wait", + "--tags", + "a,b", + "--no-include-metrics", + ) + + sent = client.kwargs_for("structure_file") + assert sent["tags"] == "a,b" + assert sent["include_metrics"] is False + assert "llm_profile_id" not in sent + + +def test_an_error_status_from_a_run_is_a_failure(capsys, deployment_client, tmp_path): + """The client reports the status code instead of raising, so an error would + otherwise be reported as a successful run with an error inside it.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + deployment_client( + structure_file={ + "status_code": 422, + "pending": False, + "execution_status": "ERROR", + "error": "no such API", + } + ) + + code, out, _ = run( + capsys, "docstudio", "deployment", "run", "my-api", str(doc), "--no-wait" + ) + assert code == int(ExitCode.VALIDATION) + assert envelope(out)["error"]["message"] == "no such API" + + +def test_deployment_status_reports_a_running_execution(capsys, deployment_client): + client = deployment_client( + check_execution_status={ + "status_code": 200, + "pending": True, + "execution_status": "EXECUTING", + } + ) + code, out, _ = run(capsys, "docstudio", "deployment", "status", "my-api", "e1") + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["data"]["execution_status"] == "EXECUTING" + assert "execution_id=e1" in client.calls[0][1][0] From af9b8516e6c4f48cf5ce027c0321024688d83ebe Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 13:58:36 +0530 Subject: [PATCH 04/86] feat: --discover and a live probe for config doctor --discover answers what --help answers, as JSON, in three tiers: groups names the products, summary adds their commands, full adds every flag with its type, choices and default plus the exit-code table -- enough to construct a call without a second round trip. A caller starts cheap and drills down. Every tier is read back from Click itself, so a described command cannot drift from the one the parser accepts, and discovery reads no configuration: it is how a caller learns what exists, so it has to work before anything is set up. config doctor --probe adds the second diagnostic question -- does the resolved key work -- to the one it already answered offline, where it resolves from. LLMWhisperer is checked against its usage endpoint. A deployment has no side-effect-free endpoint to call, so its entry reports that the settings resolve and says plainly that nothing was verified. --- src/unstract_cli/app.py | 27 +++- src/unstract_cli/commands/common.py | 22 +++- src/unstract_cli/commands/config_cmd.py | 78 ++++++++++-- src/unstract_cli/commands/docstudio_cmd.py | 4 +- src/unstract_cli/commands/whisper_cmd.py | 4 +- src/unstract_cli/core/discover.py | 104 +++++++++++++++ tests/test_discover.py | 140 +++++++++++++++++++++ 7 files changed, 363 insertions(+), 16 deletions(-) create mode 100644 src/unstract_cli/core/discover.py create mode 100644 tests/test_discover.py diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index c8dc343..4283c68 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -13,8 +13,9 @@ from unstract_cli.commands.config_cmd import config_group from unstract_cli.config import ConfigError, ResolvedConfig, load_config, set_config_path +from unstract_cli.core.discover import TIERS, discover from unstract_cli.core.errors import CLIError, ExitCode -from unstract_cli.core.output import OutputFormat, diagnostic +from unstract_cli.core.output import OutputFormat, diagnostic, emit_result @dataclass @@ -57,7 +58,12 @@ def secrets(self) -> list[str]: pass_context = click.make_pass_decorator(Context, ensure=True) -@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +# `invoke_without_command` so `--discover` is answerable on its own: it is +# how a caller learns which commands exist, so it cannot require one. +@click.group( + invoke_without_command=True, + context_settings={"help_option_names": ["-h", "--help"]}, +) @click.option( "--config", "config_file", @@ -81,6 +87,13 @@ def secrets(self) -> list[str]: help="Suppress diagnostics on stderr. stdout is unaffected.", ) @click.option("--verbose", "-v", count=True, help="Increase diagnostic detail.") +@click.option( + "--discover", + "discover_tier", + type=click.Choice(TIERS), + default=None, + help="Describe this CLI as JSON instead of running a command.", +) @click.version_option(package_name="unstract-cli") @click.pass_context def cli( @@ -90,6 +103,7 @@ def cli( output: str, quiet: bool, verbose: int, + discover_tier: str | None, ) -> None: """Unstract CLI: extract documents and run API deployments. @@ -104,6 +118,15 @@ def cli( verbosity=verbose, profile=profile, ) + if discover_tier: + # Answered without a subcommand and without touching configuration: + # discovery is how a caller finds out what to run, so it must work + # before anything is set up. + emit_result(discover(cli, discover_tier), ctx.obj.output) + ctx.exit(int(ExitCode.SUCCESS)) + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + ctx.exit(int(ExitCode.SUCCESS)) @cli.group("whisper") diff --git a/src/unstract_cli/commands/common.py b/src/unstract_cli/commands/common.py index e3a2baf..a2a88b8 100644 --- a/src/unstract_cli/commands/common.py +++ b/src/unstract_cli/commands/common.py @@ -63,6 +63,20 @@ def decorate(func: F) -> F: return decorate +def raw_field(field: str) -> Callable[[click.Command], click.Command]: + """Declare which field `--output raw` prints for this command. + + Recorded on the command so `--discover full` can report it: a caller asking + for raw output has to know what it is going to get. + """ + + def decorate(command: click.Command) -> click.Command: + command.raw_field = field + return command + + return decorate + + def finish( ctx: Context, data: Any, @@ -80,4 +94,10 @@ def finish( ) -__all__ = ["DEFAULT_INTERVAL", "DEFAULT_TIMEOUT", "finish", "wait_options"] +__all__ = [ + "DEFAULT_INTERVAL", + "DEFAULT_TIMEOUT", + "finish", + "raw_field", + "wait_options", +] diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index bc737e3..b12c4e6 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -15,6 +15,8 @@ import click from unstract_cli.config import ( + DOCSTUDIO, + LLMWHISPERER, PRODUCTS, ConfigError, ConfigFile, @@ -24,6 +26,7 @@ save_config, starter_profiles, ) +from unstract_cli.core.clients import llmwhisperer, translated from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.output import OutputFormat, emit_result @@ -194,15 +197,68 @@ def config_set(obj: Any, product: str, key: str, value: str, profile: str | None ) +def _probe(resolved: ResolvedConfig) -> dict[str, Any]: + """Check each product's credentials against the service, where that is possible. + + LLMWhisperer has a read-only usage endpoint, so its key can be verified for + real. A deployment has no side-effect-free endpoint -- the only thing to call + is an execution -- so its entry reports that the settings resolve and says + plainly that nothing was verified. Claiming otherwise would be worse than + not checking. + """ + out: dict[str, Any] = {} + try: + with translated(endpoint="get-usage-info"): + llmwhisperer(resolved).get_usage_info() + except CLIError as exc: + out[LLMWHISPERER] = { + "checked": True, + "ok": False, + "detail": exc.message, + "exit_code": int(exc.exit_code), + } + except ConfigError as exc: + out[LLMWHISPERER] = {"checked": False, "ok": False, "detail": str(exc)} + else: + out[LLMWHISPERER] = { + "checked": True, + "ok": True, + "detail": "The key was accepted by the usage endpoint.", + } + + resolves = all( + resolved.get(DOCSTUDIO, key) for key in ("org_id", "api_key", "base_url") + ) + out[DOCSTUDIO] = { + "checked": False, + "ok": resolves, + "detail": ( + "Credentials resolve (org and key present); not verified live -- the " + "deployment API has no side-effect-free endpoint to call." + if resolves + else "Organisation or key is missing; nothing was called." + ), + } + return out + + @config_group.command("doctor", help="Diagnose how each setting resolves.") +@click.option( + "--probe/--no-probe", + default=False, + help="Also check the resolved credentials against the service.", +) @click.pass_obj -def config_doctor(obj: Any) -> None: +def config_doctor(obj: Any, probe: bool) -> None: """Report where each setting resolves from, without echoing any secret. Answers the question that costs the most time: the CLI reports a key as "not configured", but you set it -- where is it looking? For `env:` references it says whether the variable is present in THIS process, a shell `export` in a login profile the CLI never inherited being the classic trap. + + Resolution is answered offline. --probe adds the second question -- does the + resolved key work -- which needs the network, so it is opt-in. """ resolved = _resolved(obj) products: dict[str, Any] = {} @@ -220,16 +276,16 @@ def config_doctor(obj: Any) -> None: except ConfigError: aliases = [] - emit_result( - { - "active_profile": resolved.active_profile, - "config_path": str(resolved.file.path), - "config_exists": resolved.file.exists, - "products": products, - "deployment_aliases": aliases, - }, - _fmt(obj), - ) + report: dict[str, Any] = { + "active_profile": resolved.active_profile, + "config_path": str(resolved.file.path), + "config_exists": resolved.file.exists, + "products": products, + "deployment_aliases": aliases, + } + if probe: + report["probe"] = _probe(resolved) + emit_result(report, _fmt(obj)) def _resolved(obj: Any) -> ResolvedConfig: diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index 79a74e1..dff22a1 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -13,7 +13,7 @@ from unstract.api_deployments.client import APIDeploymentsClient from unstract_cli.app import Context, deployment_group, pass_context -from unstract_cli.commands.common import finish, wait_options +from unstract_cli.commands.common import finish, raw_field, wait_options from unstract_cli.core.clients import deployment, raise_for_result, translated from unstract_cli.core.params import requested, spec_options from unstract_cli.core.poll import PollSpec, wait_for_completion @@ -33,6 +33,7 @@ RAW_FIELD = "extraction_result" +@raw_field(RAW_FIELD) @deployment_group.command("run") @click.argument("target") @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True)) @@ -100,6 +101,7 @@ def poll(endpoint: str) -> dict[str, Any]: return poll +@raw_field(RAW_FIELD) @deployment_group.command("status") @click.argument("target") @click.argument("execution_id") diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index cb756ff..784a02e 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -13,7 +13,7 @@ from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 from unstract_cli.app import Context, pass_context, whisper_group -from unstract_cli.commands.common import finish, wait_options +from unstract_cli.commands.common import finish, raw_field, wait_options from unstract_cli.core.clients import llmwhisperer, translated from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.params import requested, spec_options @@ -40,6 +40,7 @@ def _is_url(source: str) -> bool: return source.startswith(("http://", "https://")) +@raw_field(RAW_FIELD) @whisper_group.command("extract") @click.argument("source") @wait_options() @@ -118,6 +119,7 @@ def status(ctx: Context, whisper_hash: str) -> None: finish(ctx, client.whisper_status(whisper_hash)) +@raw_field(RAW_FIELD) @whisper_group.command("retrieve") @click.argument("whisper_hash") @click.option( diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py new file mode 100644 index 0000000..eca1f71 --- /dev/null +++ b/src/unstract_cli/core/discover.py @@ -0,0 +1,104 @@ +"""`--discover`: the CLI describing itself, in three tiers. + +An agent driving this CLI needs to know what exists before it can run anything, +and `--help` is prose scraped from a terminal. Discovery answers the same +question as JSON, at whichever depth the question needs: + +* ``groups`` -- what products are here at all +* ``summary`` -- what commands each group has +* ``full`` -- every flag with its type, default and allowed values, plus the + exit codes, which is enough to construct a call without a second round trip + +Every tier is read back from Click itself. Describing commands from anywhere +else lets the description drift from what the parser accepts. +""" + +from __future__ import annotations + +from typing import Any + +import click + +from unstract_cli.core.errors import _ERROR_CODES, ExitCode + +TIERS = ("groups", "summary", "full") + + +def exit_codes() -> list[dict[str, Any]]: + """The exit-code table, which is part of the contract callers branch on.""" + return [ + { + "code": int(code), + "name": code.name.lower(), + "error_code": _ERROR_CODES.get(code, ""), + } + for code in ExitCode + ] + + +def _param(param: click.Parameter) -> dict[str, Any]: + """One flag or argument, in the terms a caller needs to supply it.""" + entry: dict[str, Any] = { + "name": param.name, + "kind": "argument" if isinstance(param, click.Argument) else "option", + "type": getattr(param.type, "name", "text"), + "required": bool(param.required), + } + if isinstance(param, click.Option): + entry["flags"] = list(param.opts) + list(param.secondary_opts) + entry["help"] = param.help or "" + entry["repeatable"] = bool(param.multiple) + if isinstance(param.type, click.Choice): + entry["choices"] = list(param.type.choices) + if param.default is not None and not isinstance(param, click.Argument): + entry["default"] = param.default + return entry + + +def _describe(command: click.Command, tier: str) -> dict[str, Any]: + entry: dict[str, Any] = {"help": (command.help or "").strip().split("\n")[0]} + if tier == "full" and not isinstance(command, click.Group): + entry["params"] = [ + _param(p) for p in command.params if p.name not in ("help", "discover") + ] + # Which field `--output raw` prints for this command, where it has one. + if raw := getattr(command, "raw_field", None): + entry["raw_field"] = raw + if isinstance(command, click.Group): + entry["commands"] = { + name: _describe(sub, tier) for name, sub in sorted(command.commands.items()) + } + return entry + + +def discover(root: click.Group, tier: str) -> dict[str, Any]: + """Describe the CLI at one tier. + + ``groups`` stops at the top level rather than walking further, so the cheap + question stays cheap: an agent starts here and drills down only where it + needs to. + """ + if tier not in TIERS: + raise ValueError(f"Unknown discovery tier {tier!r}. One of: {', '.join(TIERS)}") + + if tier == "groups": + return { + "tier": tier, + "groups": [ + {"name": name, "help": (sub.help or "").strip().split("\n")[0]} + for name, sub in sorted(root.commands.items()) + ], + } + + payload: dict[str, Any] = { + "tier": tier, + "commands": { + name: _describe(sub, tier) for name, sub in sorted(root.commands.items()) + }, + } + if tier == "full": + payload["exit_codes"] = exit_codes() + return payload + + +__all__ = ["TIERS", "discover", "exit_codes"] diff --git a/tests/test_discover.py b/tests/test_discover.py new file mode 100644 index 0000000..210273a --- /dev/null +++ b/tests/test_discover.py @@ -0,0 +1,140 @@ +"""`--discover`, and the live half of `config doctor`. + +Discovery is what an agent reads before it runs anything, so the tiers have to +stay cheap-then-detailed, and everything reported has to be read back from the +parser rather than described separately. +""" + +from __future__ import annotations + +import json + +import pytest + +from unstract_cli.__main__ import main +from unstract_cli.commands import config_cmd +from unstract_cli.core.errors import CLIError, ExitCode + + +def run(capsys, *args): + code = main(list(args)) + out = capsys.readouterr().out + return code, json.loads(out)["data"] if out.strip() else None + + +def test_groups_names_the_products_and_stops_there(capsys): + """The cheap question stays cheap: no command list, no flags.""" + code, data = run(capsys, "--discover", "groups") + assert code == int(ExitCode.SUCCESS) + assert {g["name"] for g in data["groups"]} == {"config", "docstudio", "whisper"} + assert all(g["help"] for g in data["groups"]) + assert "commands" not in data + + +def test_summary_lists_commands_without_their_flags(capsys): + _, data = run(capsys, "--discover", "summary") + whisper = data["commands"]["whisper"]["commands"] + assert "extract" in whisper + assert whisper["extract"]["help"] + assert "params" not in whisper["extract"] + + +def test_full_carries_enough_to_build_a_call(capsys): + _, data = run(capsys, "--discover", "full") + extract = data["commands"]["whisper"]["commands"]["extract"] + params = {p["name"]: p for p in extract["params"]} + + assert params["source"]["kind"] == "argument" and params["source"]["required"] + assert params["mode"]["choices"] == [ + "form", + "high_quality", + "low_cost", + "native_text", + "table", + ] + assert params["wait"]["flags"] == ["--wait", "--no-wait"] + assert params["interval"]["type"] == "float" + assert extract["raw_field"] == "result_text" + + +def test_full_carries_the_exit_code_table(capsys): + """A caller branches on these; they are part of the contract, not prose.""" + _, data = run(capsys, "--discover", "full") + codes = {entry["name"]: entry["code"] for entry in data["exit_codes"]} + assert codes["already_consumed"] == int(ExitCode.ALREADY_CONSUMED) + assert codes["success"] == 0 + + +def test_discovery_needs_no_configuration(capsys, tmp_path, monkeypatch): + """It is how a caller finds out what to run, so it must work before anything + is set up.""" + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "nonexistent.toml")) + code, data = run(capsys, "--discover", "summary") + assert code == int(ExitCode.SUCCESS) and data["commands"] + + +def test_an_unknown_tier_is_a_usage_error(capsys): + code = main(["--discover", "sideways"]) + capsys.readouterr() + assert code == int(ExitCode.USAGE) + + +# --------------------------------------------------------------------------- # +# config doctor --probe +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def probe_client(monkeypatch): + def install(reply=None): + class Fake: + def get_usage_info(self): + if isinstance(reply, Exception): + raise reply + return reply or {} + + monkeypatch.setattr(config_cmd, "llmwhisperer", lambda _config: Fake()) + + return install + + +def test_doctor_makes_no_call_without_probe(capsys, probe_client): + probe_client(CLIError("must not be called")) + code, data = run(capsys, "config", "doctor") + assert code == int(ExitCode.SUCCESS) + assert "probe" not in data + + +def test_probe_verifies_the_whisperer_key(capsys, probe_client): + probe_client({"quota": 1}) + _, data = run(capsys, "config", "doctor", "--probe") + assert data["probe"]["llmwhisperer"] == { + "checked": True, + "ok": True, + "detail": "The key was accepted by the usage endpoint.", + } + + +def test_a_rejected_key_reports_why(capsys, probe_client): + probe_client(CLIError("bad key", ExitCode.AUTH)) + _, data = run(capsys, "config", "doctor", "--probe") + entry = data["probe"]["llmwhisperer"] + assert entry == { + "checked": True, + "ok": False, + "detail": "bad key", + "exit_code": int(ExitCode.AUTH), + } + + +def test_the_deployment_probe_says_it_verified_nothing(capsys, probe_client, monkeypatch): + """The only deployment endpoint is an execution, so there is nothing + side-effect-free to call. Saying otherwise would be worse than not checking. + """ + probe_client({}) + monkeypatch.setenv("UNSTRACT_ORG_ID", "org_A") + monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", "key") + _, data = run(capsys, "config", "doctor", "--probe") + entry = data["probe"]["docstudio"] + assert entry["checked"] is False and entry["ok"] is True + assert "not verified live" in entry["detail"] From b6ec3195d2ca612d17b1e682e45306730a2c5a83 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 13:59:34 +0530 Subject: [PATCH 05/86] test: pin the parameters no command can reach The vendored specs and the pinned clients move independently, so a refreshed spec can declare a parameter the published client has no argument for. Such a parameter is dropped rather than offered and rejected at the call, and dropping it silently is the failure this pins: the gap is written down per operation, so widening it is a decision rather than an accident. --- src/unstract_cli/specs/README.md | 16 +++++++ tests/test_contract.py | 79 ++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/unstract_cli/specs/README.md create mode 100644 tests/test_contract.py diff --git a/src/unstract_cli/specs/README.md b/src/unstract_cli/specs/README.md new file mode 100644 index 0000000..d655cb3 --- /dev/null +++ b/src/unstract_cli/specs/README.md @@ -0,0 +1,16 @@ +# Vendored API specs + +Copies of the specs the two published clients are generated from, kept here so +flags derive with no network and no assumption about where a client was +installed from. Each one is produced by the service that serves it, never edited +by hand: + +| file | source | +|---|---| +| `llmwhisperer.json` | `specs/llmwhisperer.json` in the LLMWhisperer service repo, generated by `tools/gen_spec.py` | +| `docstudio.json` | `specs/docstudio-oss.json` in the backend, generated by `manage.py generate_docstudio_spec` | + +Refresh one by copying it from the client commit pinned in `pyproject.toml`. +Refreshing it against a different commit is what `tests/test_contract.py` +guards: a spec parameter the pinned client has no argument for cannot become a +flag, and that test names the ones that already cannot. diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..22bf2b9 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,79 @@ +"""What the CLI can reach of what the APIs offer. + +The vendored specs and the pinned clients move independently: a refreshed spec +can declare a parameter the published client has no argument for, and such a +parameter is dropped from the CLI rather than offered and then rejected at the +call. Dropping it silently is the failure mode this file exists to prevent -- +the gap is written down, so widening it is a decision someone makes on purpose. +""" + +from __future__ import annotations + +import inspect + +import pytest +from unstract.api_deployments.client import APIDeploymentsClient +from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 + +from unstract_cli.core.params import derive_params, operation_params + +#: (product, operationId, client method) per command that derives its flags, +#: with the spec parameters that method cannot accept. `url_in_post` is a +#: transport detail the client decides for itself; the rest are API parameters +#: the published client predates. +COMMANDS = [ + ( + "llmwhisperer", + "extract", + LLMWhispererClientV2.whisper, + { + "allow_rotated_text", + "checkbox_confidence_threshold", + "derotate_threshold", + "ignore_vertical_text", + "min_table_width", + "url_in_post", + "watermark_angle_threshold", + }, + ), + ("llmwhisperer", "highlights", LLMWhispererClientV2.get_highlight_data, set()), + ("docstudio", "execute", APIDeploymentsClient.structure_file, {"files"}), + ( + "docstudio", + "status", + APIDeploymentsClient.check_execution_status, + { + "execution_id", + "include_metadata", + "include_metrics", + "include_extracted_text", + }, + ), +] + + +@pytest.mark.parametrize( + ("product", "operation", "method", "unreachable"), + COMMANDS, + ids=[f"{p}:{o}" for p, o, _, _ in COMMANDS], +) +def test_the_parameters_no_command_can_reach_are_the_known_ones( + product, operation, method, unreachable +): + declared = {p.name for p in operation_params(product, operation)} + derived = {p.name for p in derive_params(product, operation, client_method=method)} + assert declared - derived == unreachable + assert derived <= declared + + +@pytest.mark.parametrize( + ("product", "operation", "method"), + [(p, o, m) for p, o, m, _ in COMMANDS], + ids=[f"{p}:{o}" for p, o, _, _ in COMMANDS], +) +def test_every_derived_flag_is_an_argument_the_client_accepts(product, operation, method): + """The check the CLI cannot make at runtime: a flag the client has no + parameter for raises TypeError at the call, after the document is read.""" + accepted = set(inspect.signature(method).parameters) + for param in derive_params(product, operation, client_method=method): + assert param.name in accepted From 2d01eae02fb6e3450d0b6744a2491f7cd17b6e29 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 14:03:55 +0530 Subject: [PATCH 06/86] fix(whisper): read the highlight metadata the service actually returns Two failures a live call found and no offline test could. The metadata arrives as a named object carrying the coordinate list under `raw`, while the client's geometry takes the bare list, so no line was ever scaled. And a line the service has no geometry for is reported as all zeros, whose page height is a divisor in that scaling: it raised ZeroDivisionError out of the client, which the entry point does not catch, so the command printed a traceback with an empty stdout. Such a line now gets no box. --- src/unstract_cli/commands/whisper_cmd.py | 27 +++++++++++++--- tests/test_commands.py | 41 ++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index 784a02e..591df5c 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -200,6 +200,26 @@ def highlights( finish(ctx, data) +def _line_metadata(value: Any) -> list[int] | None: + """The `[page, base_y, height, page_height]` list the geometry needs. + + The service returns it as a named object carrying the list under `raw`, and + the client's geometry takes the bare list, so both shapes are read. + """ + if isinstance(value, dict): + value = value.get("raw") + if ( + isinstance(value, list) + and len(value) >= 4 + and all(isinstance(item, (int, float)) for item in value) + # The page height is a divisor in the scaling, and the service reports a + # line it has no geometry for as all zeros. + and value[3] + ): + return value + return None + + def _bounding_boxes( client: LLMWhispererClientV2, data: Any, @@ -209,12 +229,11 @@ def _bounding_boxes( """(page, x1, y1, x2, y2) per line, for the lines that carry metadata.""" if not isinstance(data, dict): return {} + lines = {line: _line_metadata(value) for line, value in data.items()} return { str(line): list(client.get_highlight_rect(metadata, target_width, target_height)) - for line, metadata in data.items() - if isinstance(metadata, list) - and len(metadata) >= 4 - and all(isinstance(v, (int, float)) for v in metadata) + for line, metadata in lines.items() + if metadata is not None } diff --git a/tests/test_commands.py b/tests/test_commands.py index 15e6530..886401d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -313,6 +313,47 @@ def test_highlights_scales_line_metadata_when_a_page_size_is_given( assert data["rects"]["1"] == [1, 0, 64, 600, 80] +def test_highlights_reads_the_named_metadata_object(capsys, whisper_client): + """The service returns the list inside an object; the client's geometry takes + the bare list.""" + whisper_client(get_highlight_data={"1": {"raw": [1, 100, 20, 1000], "page": 1}}) + _, out, _ = run( + capsys, + "whisper", + "highlights", + "h1", + "--lines", + "1-5", + "--target-width", + "600", + "--target-height", + "800", + ) + assert envelope(out)["data"]["rects"]["1"] == [1, 0, 64, 600, 80] + + +def test_a_line_without_geometry_gets_no_box(capsys, whisper_client): + """The service reports a line it has no geometry for as all zeros, and the + page height is a divisor in the scaling.""" + whisper_client( + get_highlight_data={"1": {"raw": [0, 0, 0, 0]}, "2": {"raw": [1, 100, 20, 1000]}} + ) + code, out, _ = run( + capsys, + "whisper", + "highlights", + "h1", + "--lines", + "1-5", + "--target-width", + "600", + "--target-height", + "800", + ) + assert code == int(ExitCode.SUCCESS) + assert set(envelope(out)["data"]["rects"]) == {"2"} + + def test_highlights_returns_the_metadata_alone_without_a_page_size( capsys, whisper_client ): From 14f787238403b3f40fab307466698b4f902c54b5 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 14:13:27 +0530 Subject: [PATCH 07/86] feat: connection flags, wider clients, and honest one-shot wording Three follow-ups to the command surface. Both client pins move forward, and the six extraction parameters and three status parameters they gained appear as flags with no line written here -- which is what deriving from the specs was for. The contract test's unreachable set shrinks to what the clients own rather than lack: the URL-in-body flag and the execution id read from the endpoint URL. --base-url, --api-key and (for deployments) --org-id sit on the product group and fill the flag tier of flag > env > profile > default, which the loader already supported but nothing populated. A key given on the command line warns: it lands in shell history and in the process list. The 406 hint is scoped to deployments. A whisper result read twice comes back as a 400 whose body says so, and translating on that prose would break the moment the wording changes -- the service's own message already says what happened, and it is passed through verbatim. --- pyproject.toml | 7 ++- src/unstract_cli/app.py | 71 +++++++++++++++++++++++++--- src/unstract_cli/core/errors.py | 9 ++-- tests/test_commands.py | 82 +++++++++++++++++++++++++++++++++ tests/test_contract.py | 24 +++------- tests/test_params.py | 4 +- 6 files changed, 167 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb1d132..e6d3ed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,8 @@ dependencies = [ # these clients are generated from, and reads their docstrings for help # text, so a client that moves underneath it changes the CLI's surface. # Both pins move to released versions before this ships. - "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@c291e36", - "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@bb586c4", + "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@ed89066", + "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@02485e1", ] [project.optional-dependencies] @@ -28,6 +28,9 @@ dev = [ [project.scripts] unstract = "unstract_cli.__main__:main" +# `unstract-client` installs a script named `unstract` too, so whichever package +# is installed last wins. This name always reaches this CLI. +unstract-cli = "unstract_cli.__main__:main" [build-system] requires = ["hatchling"] diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 4283c68..92cfb18 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -6,13 +6,21 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass, field from typing import Any import click from unstract_cli.commands.config_cmd import config_group -from unstract_cli.config import ConfigError, ResolvedConfig, load_config, set_config_path +from unstract_cli.config import ( + DOCSTUDIO, + LLMWHISPERER, + ConfigError, + ResolvedConfig, + load_config, + set_config_path, +) from unstract_cli.core.discover import TIERS, discover from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.output import OutputFormat, diagnostic, emit_result @@ -26,6 +34,9 @@ class Context: quiet: bool = False verbosity: int = 0 profile: str | None = None + #: Command-line overrides, keyed `product.setting` -- the top tier of + #: flag > env > profile > default. + overrides: dict[str, Any] = field(default_factory=dict) _config: ResolvedConfig | None = field(default=None, repr=False) @property @@ -38,13 +49,32 @@ def config(self) -> ResolvedConfig: raise CLIError(str(exc), ExitCode.USAGE) from exc for warning in cfg.warnings: diagnostic(warning, quiet=self.quiet, verbosity=self.verbosity) - self._config = ResolvedConfig(file=cfg, profile_name=self.profile) + self._config = ResolvedConfig( + file=cfg, profile_name=self.profile, overrides=self.overrides + ) return self._config + def override(self, product: str, values: dict[str, Any]) -> None: + """Record the connection flags given for one product. + + Called from the product group, before any command runs, so the flag tier + is populated by the time a command resolves anything. + """ + for key, value in values.items(): + if value is None: + continue + if key == "api_key": + diagnostic( + "warning: a key passed on the command line lands in shell " + "history and in the process list. Prefer the environment " + "variable or `env:` indirection in a profile.", + quiet=self.quiet, + verbosity=self.verbosity, + ) + self.overrides[f"{product}.{key}"] = value + def secrets(self) -> list[str]: """Resolved credentials, for scrubbing anything on its way to a stream.""" - from unstract_cli.config import DOCSTUDIO, LLMWHISPERER - out: list[str] = [] for product in (LLMWHISPERER, DOCSTUDIO): try: @@ -129,14 +159,43 @@ def cli( ctx.exit(int(ExitCode.SUCCESS)) +def _connection_options(*, org_id: bool = False) -> Callable[[Any], Any]: + """The per-product connection settings, as flags. + + They sit on the product group rather than on each command: they say where to + connect, which is the same question for every command underneath. + """ + options = [ + click.option("--base-url", default=None, help="Service URL to use."), + click.option("--api-key", default=None, help="API key to use."), + ] + if org_id: + options.append( + click.option("--org-id", default=None, help="Organisation to run against.") + ) + + def decorate(func: Any) -> Any: + for option in reversed(options): + func = option(func) + return func + + return decorate + + @cli.group("whisper") -def whisper_group() -> None: +@_connection_options() +@pass_context +def whisper_group(ctx: Context, **overrides: str | None) -> None: """Extract text and layout from documents with LLMWhisperer.""" + ctx.override(LLMWHISPERER, overrides) @cli.group("docstudio") -def docstudio_group() -> None: +@_connection_options(org_id=True) +@pass_context +def docstudio_group(ctx: Context, **overrides: str | None) -> None: """Run Document Studio API deployments.""" + ctx.override(DOCSTUDIO, overrides) @docstudio_group.group("deployment") diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index ef68f4c..64e22a5 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -35,6 +35,9 @@ class ExitCode(IntEnum): 401: ExitCode.AUTH, 403: ExitCode.AUTH, 404: ExitCode.NOT_FOUND, + # Only the deployment status endpoint answers 406. A whisper result read + # twice comes back as a 400 whose body says so, and translating on that + # prose would break the moment the wording changes. 406: ExitCode.ALREADY_CONSUMED, 408: ExitCode.TIMEOUT, 409: ExitCode.VALIDATION, @@ -218,9 +221,9 @@ def hint_for(status: int) -> str | None: ) case 406: return ( - "This result was already retrieved. Results can be read exactly " - "once; re-running the request cannot recover them. Use --save next " - "time to persist on first read." + "This execution result was already retrieved. A deployment serves " + "its result exactly once; re-running the status call cannot " + "recover it. Use --save next time to persist on first read." ) case 409: return "The resource is in use, or conflicts with an existing one." diff --git a/tests/test_commands.py b/tests/test_commands.py index 886401d..c3bd1df 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -466,3 +466,85 @@ def test_deployment_status_reports_a_running_execution(capsys, deployment_client assert code == int(ExitCode.SUCCESS) assert envelope(out)["data"]["execution_status"] == "EXECUTING" assert "execution_id=e1" in client.calls[0][1][0] + + +# --------------------------------------------------------------------------- # +# The flag tier of flag > env > profile > default +# --------------------------------------------------------------------------- # + + +def test_a_connection_flag_beats_the_environment(capsys, monkeypatch, tmp_path): + monkeypatch.setenv("LLMWHISPERER_BASE_URL", "https://from-env.test") + monkeypatch.setenv("LLMWHISPERER_API_KEY", "env-key") + seen = {} + monkeypatch.setattr( + whisper_cmd, + "llmwhisperer", + lambda config: ( + seen.update( + base_url=config.get("llmwhisperer", "base_url"), + api_key=config.get("llmwhisperer", "api_key"), + ) + or FakeWhisper(get_usage_info={}) + ), + ) + + code, _, err = run( + capsys, + "whisper", + "--base-url", + "https://from-flag.test", + "--api-key", + "flag-key", + "usage", + ) + + assert code == int(ExitCode.SUCCESS) + assert seen == {"base_url": "https://from-flag.test", "api_key": "flag-key"} + # A key on the command line lands in shell history and the process list. + assert "shell history" in err + + +def test_the_environment_still_wins_over_a_profile(capsys, monkeypatch, write_config): + write_config( + """ + default_profile = "p" + [profiles.p.llmwhisperer] + base_url = "https://from-profile.test" + """ + ) + monkeypatch.setenv("LLMWHISPERER_BASE_URL", "https://from-env.test") + seen = {} + monkeypatch.setattr( + whisper_cmd, + "llmwhisperer", + lambda config: ( + seen.update(base_url=config.get("llmwhisperer", "base_url")) + or FakeWhisper(get_usage_info={}) + ), + ) + + run(capsys, "whisper", "usage") + assert seen == {"base_url": "https://from-env.test"} + + +def test_a_deployment_org_can_come_from_a_flag(capsys, monkeypatch): + monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", "key") + seen = {} + monkeypatch.setattr( + docstudio_cmd, + "deployment", + lambda config, target: ( + seen.update(org=config.get("docstudio", "org_id")) or _deployment_fake() + ), + ) + run(capsys, "docstudio", "--org-id", "org_A", "deployment", "status", "api", "e1") + assert seen == {"org": "org_A"} + + +def _deployment_fake(): + client = FakeWhisper( + check_execution_status={"status_code": 200, "execution_status": "COMPLETED"} + ) + client.api_url = "https://api.example.com/deployment/api/org/api-name/" + return client diff --git a/tests/test_contract.py b/tests/test_contract.py index 22bf2b9..4b90ee7 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -18,23 +18,16 @@ from unstract_cli.core.params import derive_params, operation_params #: (product, operationId, client method) per command that derives its flags, -#: with the spec parameters that method cannot accept. `url_in_post` is a -#: transport detail the client decides for itself; the rest are API parameters -#: the published client predates. +#: with the spec parameters that method cannot accept. Each one is a parameter +#: the client owns rather than one it lacks: `url_in_post` says the URL is in +#: the body, which the client decides; `files` is built from the paths given; +#: `execution_id` is read out of the endpoint URL the server handed back. COMMANDS = [ ( "llmwhisperer", "extract", LLMWhispererClientV2.whisper, - { - "allow_rotated_text", - "checkbox_confidence_threshold", - "derotate_threshold", - "ignore_vertical_text", - "min_table_width", - "url_in_post", - "watermark_angle_threshold", - }, + {"url_in_post"}, ), ("llmwhisperer", "highlights", LLMWhispererClientV2.get_highlight_data, set()), ("docstudio", "execute", APIDeploymentsClient.structure_file, {"files"}), @@ -42,12 +35,7 @@ "docstudio", "status", APIDeploymentsClient.check_execution_status, - { - "execution_id", - "include_metadata", - "include_metrics", - "include_extracted_text", - }, + {"execution_id"}, ), ] diff --git a/tests/test_params.py b/tests/test_params.py index cf7aee3..7b1d376 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -89,7 +89,9 @@ def test_only_parameters_the_client_accepts_become_flags(): ) ) assert derived < spec - assert "checkbox_confidence_threshold" in spec - derived + # In URL mode the URL travels in the body, and saying so is the client's + # decision, not a caller's. + assert spec - derived == {"url_in_post"} def test_the_clients_default_wins_over_the_specs(): From 6dfc2a9a3bd047fdd542784e4d7d90884c9ff477 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 15:57:57 +0530 Subject: [PATCH 08/86] Forward the status parameters, and stop the doctor overstating itself `deployment status` derived --include-metadata, --include-metrics and --include-extracted-text from the spec, collected them into **params, and never passed them to the client. The command succeeded and the payload parsed, so a dropped flag was indistinguishable from a working one. The poll loop behind `deployment run --wait` had the same hole, which made a waited run return less than the identical flags returned without --wait. Both now forward what was asked for, and the parameters the status endpoint does not accept are filtered out rather than sent. Tests cover each flag in both polarities, since a flag silently dropped is exactly what the offline suite missed. Alongside: - `config doctor` no longer reports an `org_id` setting for LLMWhisperer, which has none. It always read as unresolved and there was no way to resolve it. - The deployment probe reports `ok: null`, not `ok: true`. Nothing is called, so there is no verdict; `true` beside `checked: false` reads as a live check that passed. `resolved` carries what is actually known. - The 406 hint pointed at --save, which does not exist on the command that emits the hint. It now names the command that has it. - A 400 carries a hint. The service can answer 400 with an empty error body, in which case the message was a synthesised fallback and there was nothing else to go on. Adds RUNBOOK.md: install, moving the client pins, the live-gate checklist, and the release steps. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- RUNBOOK.md | 151 +++++++++++++++++++++ src/unstract_cli/commands/config_cmd.py | 13 +- src/unstract_cli/commands/docstudio_cmd.py | 20 ++- src/unstract_cli/config.py | 11 ++ src/unstract_cli/core/errors.py | 7 +- tests/test_commands.py | 108 +++++++++++++++ tests/test_discover.py | 7 +- 7 files changed, 305 insertions(+), 12 deletions(-) create mode 100644 RUNBOOK.md diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..2645d93 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,151 @@ +# Runbook + +Maintainer procedures. For what the CLI does and how to configure it, see the +[README](README.md); this file covers the things that are done *to* the CLI — +installing a build, moving the client pins, proving a build against real +services, and cutting a release. + +## Install + +### From a published ref + +```bash +pipx install git+https://github.com/Zipstack/unstract-cli +unstract --version +``` + +Pin the ref when reproducing a report: + +```bash +pipx install "git+https://github.com/Zipstack/unstract-cli@" +``` + +`pipx` puts each install in its own virtualenv, which matters here: the two +clients are pinned to exact commits, and a shared environment would let another +package's resolver move them. + +### Name collision + +`unstract-client` also installs a console script called `unstract`. In an +environment holding both, whichever was installed last owns the name. Two ways +out, in order of preference: + +- `unstract-cli` — a second console script this package always owns. +- `python -m unstract_cli` — works from a source checkout with no install at all. + +Check which one you actually have before filing a bug about a missing command: + +```bash +command -v unstract && unstract --version +``` + +### From a checkout + +```bash +uv venv && uv pip install -e '.[dev]' +pytest # offline: no network, no credentials +ruff check . +``` + +## Moving the client pins + +The CLI derives its flags from the vendored specs intersected with the pinned +clients' signatures, and takes flag help from those clients' docstrings. Moving +a pin therefore changes the CLI's surface without a line of CLI code changing. +That is the intent, so the check is that the change was the intended one: + +1. Update the `unstract-client` and/or `llmwhisperer-client` ref in + `pyproject.toml`. +2. Refresh the vendored spec if the service's spec moved too — see + [`src/unstract_cli/specs/README.md`](src/unstract_cli/specs/README.md). + A spec and a client from different commits is exactly the state + `tests/test_contract.py` exists to catch. +3. `uv pip install -e '.[dev]' && pytest`. +4. Diff the surface before and after: + + ```bash + python -m unstract_cli --discover full > after.json + ``` + + Every added or removed flag should be one you can name a reason for. + `tests/test_contract.py` pins the spec parameters no command can reach; that + set should only ever shrink, and only on purpose. + +Both pins move to released versions before this ships publicly. + +## Live gate + +The offline suite proves the CLI is self-consistent. It cannot prove the +services agree, and the defects worth catching here have all been of that kind: +a payload shaped differently from the spec, a status code meaning something +other than it appears to, geometry that divides by a value the service reports +as zero. Run this against a real tenant before tagging a release. + +### Credentials + +Supply them through the environment, never on the command line and never in a +file inside this repository: + +```bash +export LLMWHISPERER_API_KEY=... +export UNSTRACT_DEPLOYMENT_KEY=... +export UNSTRACT_BASE_URL=https:// +export UNSTRACT_ORG_ID=org_... +``` + +Use a staging tenant. Passing `--api-key` works and warns, because a key on the +command line lands in shell history and in the process list. + +### Checklist + +Run against a document you can re-send; several of these submit real work. + +| # | Command | Pass | +|---|---|---| +| 1 | `config doctor --probe` | every setting reports where it resolved from; the LLMWhisperer probe answers live | +| 2 | `whisper extract ` | polls to completion, returns text | +| 3 | `whisper extract --no-wait` then `whisper status ` then `whisper retrieve ` | the handle survives the round trip | +| 4 | `whisper retrieve ` a second time | refused, exit 9, and the error names the one-shot read | +| 5 | `whisper highlights --target-width 800 --target-height 1000` | bounding boxes for the lines that carry geometry, and no traceback for the lines that do not | +| 6 | `whisper usage` | quota returned | +| 7 | `docstudio deployment run ` | polls to completion, returns structured JSON | +| 8 | `docstudio deployment run --no-wait`, then `docstudio deployment status ` from the run envelope | the handle survives the round trip | +| 9 | any command with `--output raw` | one field, not the envelope | +| 10 | any command with a wrong key | exit 3, JSON envelope on stdout, no traceback | +| 11 | any command with a path that does not exist | exit 2, JSON envelope on stdout | + +Two properties matter more than any single row, because they are what a caller +depends on and what breaks quietly: + +- **stdout is one JSON envelope in every case above, including the failures.** + A traceback on stderr with empty stdout is a bug even when the exit code is + right. +- **A flag passed explicitly reaches the wire, including when its value is + falsy.** `--no-include-metadata` must produce a different payload than passing + nothing at all. A flag that is silently dropped looks identical to a flag that + worked. + +### Interpreting a failure + +A live failure is a finding about the CLI, the client, or the service, in that +order of likelihood — check which layer the response actually came from before +changing anything. Fixes go in the facade or the spec; never in a generated +directory, whose contents are replaced wholesale on the next generation. + +## Release + +1. Live gate green against staging. +2. `pytest` and `ruff check .` clean. +3. Both client pins on released versions, not commits. +4. Tag, then verify the tag installs clean in an environment that has nothing + else in it: + + ```bash + pipx install --force "git+https://github.com/Zipstack/unstract-cli@" + unstract-cli --version + unstract-cli --discover groups + ``` + +5. `--discover groups` on the fresh install should match the checkout's. It is + the cheapest proof that the built wheel carries the specs — they are package + data, and package data is what a build configuration silently drops. diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index b12c4e6..ea4ac41 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -24,6 +24,7 @@ config_path, load_config, save_config, + settings_for, starter_profiles, ) from unstract_cli.core.clients import llmwhisperer, translated @@ -231,10 +232,14 @@ def _probe(resolved: ResolvedConfig) -> dict[str, Any]: ) out[DOCSTUDIO] = { "checked": False, - "ok": resolves, + # Null, not True: nothing was called, so there is no verdict to report. + # A `true` beside `checked: false` reads as a live check that passed. + "ok": None, + "resolved": resolves, "detail": ( - "Credentials resolve (org and key present); not verified live -- the " - "deployment API has no side-effect-free endpoint to call." + "Credentials resolve (org and key present) but were NOT verified -- " + "the deployment API has no side-effect-free endpoint to call, so a " + "wrong key is only discovered by running a deployment." if resolves else "Organisation or key is missing; nothing was called." ), @@ -264,7 +269,7 @@ def config_doctor(obj: Any, probe: bool) -> None: products: dict[str, Any] = {} for product in PRODUCTS: entry: dict[str, Any] = {} - for key in ("base_url", "api_key", "org_id"): + for key in settings_for(product): try: entry[key] = resolved.resolution_source(product, key) except ConfigError as exc: diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index dff22a1..01222f3 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -32,6 +32,11 @@ #: `--output raw` prints one field rather than the whole payload. RAW_FIELD = "extraction_result" +#: Parameters the run POST and the status GET share. What a caller asked to be +#: included in the result has to be asked for again when the result is read, or a +#: waited run returns less than the same flags returned without --wait. +_SHARED_WITH_STATUS = ("include_metadata", "include_metrics", "include_extracted_text") + @raw_field(RAW_FIELD) @deployment_group.command("run") @@ -63,10 +68,11 @@ def run( polls until the execution finishes and returns its result. """ client = deployment(ctx.config, target) + sent = requested(params) with translated(endpoint=client.api_url): # Queued execution, so the request returns a handle instead of holding # the connection open for the length of the job. - started = client.structure_file(list(files), timeout=0, **requested(params)) + started = client.structure_file(list(files), timeout=0, **sent) raise_for_result(started, endpoint=client.api_url) if not wait: @@ -76,7 +82,9 @@ def run( result = wait_for_completion( initial=started, spec=RUN_POLL, - poll=_status_poller(client), + poll=_status_poller( + client, {k: v for k, v in sent.items() if k in _SHARED_WITH_STATUS} + ), save=save, interval=interval, timeout=wait_timeout, @@ -87,11 +95,13 @@ def run( finish(ctx, result, raw_field=RAW_FIELD) -def _status_poller(client: APIDeploymentsClient) -> Callable[[str], dict[str, Any]]: +def _status_poller( + client: APIDeploymentsClient, params: dict[str, Any] +) -> Callable[[str], dict[str, Any]]: """Poll one execution, failing on a status code the poll loop cannot use.""" def poll(endpoint: str) -> dict[str, Any]: - result = client.check_execution_status(endpoint) + result = client.check_execution_status(endpoint, **params) # A retryable status is left to the client's own retry policy, which has # already run; the client reports those as still pending. if not result.get("pending"): @@ -117,7 +127,7 @@ def status(ctx: Context, target: str, execution_id: str, **params: Any) -> None: client = deployment(ctx.config, target) endpoint = f"{client.api_url}?execution_id={execution_id}" with translated(endpoint=client.api_url): - result = client.check_execution_status(endpoint) + result = client.check_execution_status(endpoint, **requested(params)) if not result.get("pending"): raise_for_result(result, endpoint=client.api_url) finish(ctx, result, raw_field=RAW_FIELD) diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index efdd3ed..711523e 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -43,6 +43,16 @@ (DOCSTUDIO, "org_id"): ("UNSTRACT_ORG_ID",), } +def settings_for(product: str) -> tuple[str, ...]: + """The settings a product actually has. + + Products differ: `org_id` is a URL path segment for one and meaningless for + the other, and reporting a setting a user has no way to supply reads as a + misconfiguration they cannot fix. + """ + return tuple(sorted(key for prod, key in ENV_VARS if prod == product)) + + #: Filename a project can commit to point the CLI at its own settings. PROJECT_CONFIG_NAME = ".unstract.toml" @@ -375,5 +385,6 @@ def starter_profiles() -> dict[str, dict[str, Any]]: "load_config", "save_config", "set_config_path", + "settings_for", "starter_profiles", ] diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index 64e22a5..ee7a30a 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -209,6 +209,11 @@ def undeclared_status_error( def hint_for(status: int) -> str | None: """A short, actionable next step for a common failure.""" match status: + case 400: + return ( + "The service rejected the request. Check the ids and parameter " + "values passed; `details` carries the service's own response." + ) case 401 | 403: return ( "Check the API key for this product. Keys are per-product: " @@ -223,7 +228,7 @@ def hint_for(status: int) -> str | None: return ( "This execution result was already retrieved. A deployment serves " "its result exactly once; re-running the status call cannot " - "recover it. Use --save next time to persist on first read." + "recover it. Pass --save to `deployment run` to keep the next one." ) case 409: return "The resource is in use, or conflicts with an existing one." diff --git a/tests/test_commands.py b/tests/test_commands.py index c3bd1df..c19f7fd 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -468,6 +468,114 @@ def test_deployment_status_reports_a_running_execution(capsys, deployment_client assert "execution_id=e1" in client.calls[0][1][0] +@pytest.mark.parametrize( + ("flag", "name", "value"), + [ + ("--include-metadata", "include_metadata", True), + ("--no-include-metadata", "include_metadata", False), + ("--include-metrics", "include_metrics", True), + ("--no-include-metrics", "include_metrics", False), + ("--include-extracted-text", "include_extracted_text", True), + ("--no-include-extracted-text", "include_extracted_text", False), + ], +) +def test_a_status_flag_reaches_the_client(capsys, deployment_client, flag, name, value): + """A derived flag that is collected and never forwarded is indistinguishable + from one that works: the command still succeeds and the payload still parses.""" + client = deployment_client( + check_execution_status={"status_code": 200, "execution_status": "COMPLETED"} + ) + run(capsys, "docstudio", "deployment", "status", flag, "my-api", "e1") + assert client.kwargs_for("check_execution_status")[name] is value + + +def test_status_sends_only_the_flags_that_were_given(capsys, deployment_client): + client = deployment_client( + check_execution_status={"status_code": 200, "execution_status": "COMPLETED"} + ) + run(capsys, "docstudio", "deployment", "status", "my-api", "e1") + assert client.kwargs_for("check_execution_status") == {} + + +def test_a_waited_run_reads_its_result_with_the_flags_it_was_given( + capsys, deployment_client, tmp_path +): + """Otherwise --wait silently returns less than the same flags return without + it: the run is asked for metrics and the read that fetches them is not.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + client = deployment_client( + structure_file={ + "status_code": 200, + "pending": True, + "execution_status": "PENDING", + "status_check_api_endpoint": "/status?execution_id=e1", + }, + check_execution_status={ + "status_code": 200, + "pending": False, + "execution_status": "COMPLETED", + }, + ) + + run( + capsys, + "-q", + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--interval", + "0", + "--include-metrics", + "--no-include-metadata", + ) + + polled = client.kwargs_for("check_execution_status") + assert polled["include_metrics"] is True + assert polled["include_metadata"] is False + # `tags` is a run-time parameter the status endpoint does not accept. + assert "tags" not in polled + + +def test_a_run_only_parameter_is_not_forwarded_to_the_status_read( + capsys, deployment_client, tmp_path +): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + client = deployment_client( + structure_file={ + "status_code": 200, + "pending": True, + "execution_status": "PENDING", + "status_check_api_endpoint": "/status?execution_id=e1", + }, + check_execution_status={ + "status_code": 200, + "pending": False, + "execution_status": "COMPLETED", + }, + ) + + run( + capsys, + "-q", + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--interval", + "0", + "--tags", + "a,b", + ) + + assert client.kwargs_for("structure_file")["tags"] == "a,b" + assert client.kwargs_for("check_execution_status") == {} + + # --------------------------------------------------------------------------- # # The flag tier of flag > env > profile > default # --------------------------------------------------------------------------- # diff --git a/tests/test_discover.py b/tests/test_discover.py index 210273a..4a094fa 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -136,5 +136,8 @@ def test_the_deployment_probe_says_it_verified_nothing(capsys, probe_client, mon monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", "key") _, data = run(capsys, "config", "doctor", "--probe") entry = data["probe"]["docstudio"] - assert entry["checked"] is False and entry["ok"] is True - assert "not verified live" in entry["detail"] + # `ok` is null rather than true: a true beside `checked: false` is read as a + # live check that passed, which is the one thing this probe cannot claim. + assert entry["checked"] is False and entry["ok"] is None + assert entry["resolved"] is True + assert "NOT verified" in entry["detail"] From d0dc35fbc49001e5f9805844d53bf383a00868eb Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 16:01:04 +0530 Subject: [PATCH 09/86] Report which job a waited result belongs to Waiting returns the result and nothing else: the extracted text, or the deployment's structured output. Neither names the job, so a caller who waited had no handle to correlate against the service, quote in a bug report, or use for a follow-up call. Without --wait the handle is the entire payload, so the identity appeared and disappeared depending on a flag. Both waited paths now carry it in `meta` -- the whisper hash and the execution id -- leaving `data` exactly as it was. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/docstudio_cmd.py | 15 +++++++++- src/unstract_cli/commands/whisper_cmd.py | 11 ++++++- tests/test_commands.py | 35 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index 01222f3..df5eb31 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -8,6 +8,7 @@ from collections.abc import Callable from typing import Any +from urllib.parse import parse_qs, urlparse import click from unstract.api_deployments.client import APIDeploymentsClient @@ -92,7 +93,19 @@ def run( click.echo(f"status: {status}", err=True) if not ctx.quiet else None ), ) - finish(ctx, result, raw_field=RAW_FIELD) + # The waited result identifies the execution nowhere at the top level, so a + # caller has nothing to correlate against the service. --no-wait returns the + # handle as data; waiting returns it as meta. + finish(ctx, result, raw_field=RAW_FIELD, meta=_handle_meta(started)) + + +def _handle_meta(started: dict[str, Any]) -> dict[str, Any]: + """The execution's identity, from wherever the run response carries it.""" + if execution_id := started.get("execution_id"): + return {"execution_id": execution_id} + endpoint = str(started.get("status_check_api_endpoint") or "") + found = parse_qs(urlparse(endpoint).query).get("execution_id") + return {"execution_id": found[0]} if found else {} def _status_poller( diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index 591df5c..bf74a79 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -106,7 +106,16 @@ def extract( click.echo(f"status: {status}", err=True) if not ctx.quiet else None ), ) - finish(ctx, result, raw_field=RAW_FIELD) + # Waiting returns the text, which identifies the job nowhere; the hash is + # what a later status, retrieve or highlights call needs. + finish( + ctx, + result, + raw_field=RAW_FIELD, + meta={"whisper_hash": accepted.get("whisper_hash")} + if accepted.get("whisper_hash") + else None, + ) @whisper_group.command("status") diff --git a/tests/test_commands.py b/tests/test_commands.py index c19f7fd..f690760 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -539,6 +539,41 @@ def test_a_waited_run_reads_its_result_with_the_flags_it_was_given( assert "tags" not in polled +def test_a_waited_run_reports_which_execution_it_was( + capsys, deployment_client, tmp_path +): + """The waited payload names the execution nowhere, so without this a caller + has no id to correlate the result against the service.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + deployment_client( + structure_file={ + "status_code": 200, + "pending": True, + "execution_status": "PENDING", + "status_check_api_endpoint": "/status?execution_id=e1", + }, + check_execution_status={ + "status_code": 200, + "pending": False, + "execution_status": "COMPLETED", + }, + ) + + _, out, _ = run( + capsys, + "-q", + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--interval", + "0", + ) + assert envelope(out)["meta"]["execution_id"] == "e1" + + def test_a_run_only_parameter_is_not_forwarded_to_the_status_read( capsys, deployment_client, tmp_path ): From afb864b5cae222c99586605eae10aa4365499aed Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 17:55:13 +0530 Subject: [PATCH 10/86] Stop losing one-shot results, and stop printing keys `--save` exists to protect a read the service serves exactly once, and it was the flag that lost the data: the write ran after the acknowledging read, raised `OSError` through an entry point that does not catch it, and left an empty stdout with the extraction gone. The target is now proven writable before anything destructive runs, the write goes through a temporary file so a full disk cannot truncate the previous copy, and a write that fails anyway raises with the payload attached under its own exit code -- by that point the envelope carries the only copy left. Also on the one-shot path: a waited extract read the result with a bare `.get("extraction")` where the sibling command falls back to the whole payload, so a response shaped any other way printed `ok: true, data: null` for a document that had been processed and billed. Both now read it the same way, and a genuinely empty result is a failure rather than a silent success. Redaction was an opt-in keyword argument that only the success path passed, so every error envelope and every stderr summary went out with the key in it -- four times on stdout in the reproduced case. Credentials are now registered where they resolve and scrubbed by every emitter, and `CLIError.details` is redacted structurally rather than at each call site. Three more places where a failure was reported as a success: the standalone status commands ignored a finished-and-failed execution inside an HTTP 200, the poll loop treated an unreadable body as progress and then blamed the timeout on a job it never confirmed was running, and any status outside 4xx/5xx mapped to exit 0 while printing `ok: false`. Verified by mutation -- moving the save after the print, dropping the registry, dropping the details redaction and dropping the status check each fail the suite now, and none of them did before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 1 + src/unstract_cli/__main__.py | 9 + src/unstract_cli/commands/docstudio_cmd.py | 17 +- src/unstract_cli/commands/whisper_cmd.py | 31 +- src/unstract_cli/config.py | 13 +- src/unstract_cli/core/clients.py | 7 +- src/unstract_cli/core/errors.py | 36 +- src/unstract_cli/core/output.py | 17 +- src/unstract_cli/core/poll.py | 109 +++++- tests/test_commands.py | 176 +++++++++- tests/test_errors.py | 8 +- tests/test_poll.py | 40 ++- uv.lock | 376 +++++++++++++++++++++ 13 files changed, 801 insertions(+), 39 deletions(-) create mode 100644 uv.lock diff --git a/README.md b/README.md index 419a731..8366668 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Failures exit non-zero with a stable code: | 7 | timed out (the job handle is in the error payload — resume, do not resubmit) | | 8 | server error | | 9 | result already consumed (one-shot read; use `--save` next time) | +| 10 | the result was read but could not be saved — it is in `error.details` | ## Configuration diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py index 69e8025..96bcde8 100644 --- a/src/unstract_cli/__main__.py +++ b/src/unstract_cli/__main__.py @@ -54,6 +54,15 @@ def main(argv: list[str] | None = None) -> int: fmt, ) ) + except OSError as exc: + # Not a crash worth a traceback: a full disk or an unwritable path is + # the caller's to fix, and they still need a parseable envelope. + return int( + emit_error( + CLIError(str(exc), ExitCode.GENERIC, hint="Check the path and disk."), + fmt, + ) + ) except click.Abort: return int(ExitCode.GENERIC) except click.exceptions.Exit as exc: # --help and --version exit through here diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index df5eb31..8a638f3 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -16,8 +16,9 @@ from unstract_cli.app import Context, deployment_group, pass_context from unstract_cli.commands.common import finish, raw_field, wait_options from unstract_cli.core.clients import deployment, raise_for_result, translated +from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.params import requested, spec_options -from unstract_cli.core.poll import PollSpec, wait_for_completion +from unstract_cli.core.poll import PollSpec, classify, preflight, wait_for_completion PRODUCT = "docstudio" @@ -70,6 +71,8 @@ def run( """ client = deployment(ctx.config, target) sent = requested(params) + if save: + preflight(save) with translated(endpoint=client.api_url): # Queued execution, so the request returns a handle instead of holding # the connection open for the length of the job. @@ -143,6 +146,18 @@ def status(ctx: Context, target: str, execution_id: str, **params: Any) -> None: result = client.check_execution_status(endpoint, **requested(params)) if not result.get("pending"): raise_for_result(result, endpoint=client.api_url) + # A finished-and-failed execution is reported inside an HTTP 200, so the + # status code alone would call this a success. + if classify(result, RUN_POLL) == "failure": + raise CLIError( + f"Execution {execution_id} finished with status " + f"{result.get('execution_status')!r}.", + ExitCode.VALIDATION, + details=result, + endpoint=client.api_url, + hint="Inspect `details` for the per-file error, or check the execution logs.", + extra={"execution_id": execution_id}, + ) finish(ctx, result, raw_field=RAW_FIELD) diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index bf74a79..d15f784 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -17,7 +17,7 @@ from unstract_cli.core.clients import llmwhisperer, translated from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.params import requested, spec_options -from unstract_cli.core.poll import PollSpec, persist, wait_for_completion +from unstract_cli.core.poll import PollSpec, persist, preflight, wait_for_completion PRODUCT = "llmwhisperer" @@ -69,6 +69,8 @@ def extract( """ client = llmwhisperer(ctx.config) sent = requested(params) + if save: + preflight(save) if sent.get("use_webhook") and wait: raise CLIError( @@ -98,7 +100,7 @@ def extract( initial=accepted, spec=EXTRACT_POLL, poll=client.whisper_status, - retrieve=lambda handle: client.whisper_retrieve(handle).get("extraction"), + retrieve=lambda handle: _extraction(client.whisper_retrieve(handle)), save=save, interval=interval, timeout=wait_timeout, @@ -118,6 +120,27 @@ def extract( ) +def _extraction(payload: Any) -> Any: + """The extracted result out of a retrieve response. + + A retrieve is the acknowledging read, so an empty result here is a document + that was processed, billed and consumed for nothing -- reporting it as a + success would hide that. + """ + result = payload.get("extraction", payload) if isinstance(payload, dict) else payload + if not result: + raise CLIError( + "The service returned no extraction for a completed job.", + ExitCode.SERVER_ERROR, + details=payload, + hint=( + "The read has been acknowledged, so it cannot be repeated. " + "`details` carries the response exactly as it arrived." + ), + ) + return result + + @whisper_group.command("status") @click.argument("whisper_hash") @pass_context @@ -146,9 +169,11 @@ def retrieve(ctx: Context, whisper_hash: str, save: str | None) -> None: recovered by asking again. """ client = llmwhisperer(ctx.config) + if save: + preflight(save) with translated(endpoint="whisper-retrieve"): payload = client.whisper_retrieve(whisper_hash) - result = payload.get("extraction", payload) + result = _extraction(payload) if save: persist(save, result) finish(ctx, result, raw_field=RAW_FIELD) diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 711523e..e2619a6 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -24,6 +24,8 @@ import tomli_w +from unstract_cli.core.errors import remember_secret + LLMWHISPERER = "llmwhisperer" DOCSTUDIO = "docstudio" PRODUCTS: tuple[str, ...] = (LLMWHISPERER, DOCSTUDIO) @@ -43,6 +45,7 @@ (DOCSTUDIO, "org_id"): ("UNSTRACT_ORG_ID",), } + def settings_for(product: str) -> tuple[str, ...]: """The settings a product actually has. @@ -237,6 +240,12 @@ def _product_block(self, product: str) -> dict[str, Any]: def get(self, product: str, key: str, default: Any = None) -> Any: """Resolve one setting: **flag > env > profile > built-in default**.""" + value = self._resolve(product, key, default) + if key == "api_key": + remember_secret(value) + return value + + def _resolve(self, product: str, key: str, default: Any = None) -> Any: if (value := self.overrides.get(f"{product}.{key}")) is not None: return value if (value := self.overrides.get(key)) is not None: @@ -294,10 +303,12 @@ def deployment(self, alias: str) -> dict[str, Any]: ) if not entry.get("api_name"): raise ConfigError(f"Deployment alias {alias!r} has no `api_name`.") + api_key = _deref(entry.get("api_key")) or self.get(DOCSTUDIO, "api_key") + remember_secret(api_key) return { "api_name": entry["api_name"], "org_id": _deref(entry.get("org_id")) or self.get(DOCSTUDIO, "org_id"), - "api_key": _deref(entry.get("api_key")) or self.get(DOCSTUDIO, "api_key"), + "api_key": api_key, } def deployment_aliases(self) -> tuple[str, ...]: diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py index ce52338..15604cb 100644 --- a/src/unstract_cli/core/clients.py +++ b/src/unstract_cli/core/clients.py @@ -147,10 +147,11 @@ def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> Non happens to contain an error. """ status = int(result.get("status_code") or 0) - if status and not 200 <= status < 300: + reported = result.get("error") + if (status and not 200 <= status < 300) or reported: raise error_from_status( - status, - str(result.get("error") or f"Request failed with status {status}"), + status or 500, + str(reported or f"Request failed with status {status}"), details=result, endpoint=endpoint, ) diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index ee7a30a..8d328b9 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -24,6 +24,7 @@ class ExitCode(IntEnum): TIMEOUT = 7 SERVER_ERROR = 8 ALREADY_CONSUMED = 9 + SAVE_FAILED = 10 #: HTTP status -> exit code. 422 maps to VALIDATION, which is right for a real @@ -55,6 +56,7 @@ class ExitCode(IntEnum): ExitCode.TIMEOUT: "timeout", ExitCode.SERVER_ERROR: "server_error", ExitCode.ALREADY_CONSUMED: "already_consumed", + ExitCode.SAVE_FAILED: "save_failed", } @@ -64,9 +66,9 @@ def exit_code_for_status(status: int) -> ExitCode: return code if 500 <= status < 600: return ExitCode.SERVER_ERROR - if 400 <= status < 500: - return ExitCode.GENERIC - return ExitCode.SUCCESS + # Anything else -- a 3xx that was not followed, a status no spec declares -- + # is still a failure. Returning SUCCESS here printed `ok: false` and exited 0. + return ExitCode.GENERIC def is_retryable(status: int) -> bool: @@ -88,6 +90,26 @@ def is_retryable(status: int) -> bool: _SECRET_KEY_HINTS = ("key", "token", "secret", "password", "credential", "auth") REDACTED = "***REDACTED***" +#: Credentials resolved during this run. Scrubbing used to be a keyword +#: argument every emitter had to remember to pass, and the error path never +#: did; registering the value where it is resolved makes forgetting impossible. +_KNOWN_SECRETS: set[str] = set() + + +def remember_secret(value: Any) -> None: + """Record a resolved credential so no stream can print it later.""" + if isinstance(value, str) and len(value) >= 8: + _KNOWN_SECRETS.add(value) + + +def known_secrets() -> list[str]: + """Every credential resolved so far, longest first. + + Longest first so a key that contains another as a prefix is replaced whole + rather than leaving its tail behind. + """ + return sorted(_KNOWN_SECRETS, key=len, reverse=True) + def redact_headers(headers: dict[str, Any]) -> dict[str, Any]: """Redact credential-bearing headers.""" @@ -153,6 +175,8 @@ class CLIError(Exception): def __post_init__(self) -> None: super().__init__(self.message) + if self.exit_code is ExitCode.SUCCESS: + raise ValueError("a CLIError cannot carry the success exit code") def to_dict(self) -> dict[str, Any]: payload: dict[str, Any] = { @@ -164,7 +188,9 @@ def to_dict(self) -> dict[str, Any]: if self.http_status is not None: payload["http_status"] = self.http_status if self.details is not None: - payload["details"] = self.details + # Structural, not opt-in: the details come from a server body that + # can echo the request, headers and key included. + payload["details"] = redact_value(self.details) if self.endpoint: payload["endpoint"] = self.endpoint if self.hint: @@ -243,6 +269,8 @@ def hint_for(status: int) -> str | None: "REDACTED", "CLIError", "ExitCode", + "known_secrets", + "remember_secret", "error_from_status", "exit_code_for_status", "hint_for", diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index 0b139d0..be0a266 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -19,7 +19,7 @@ from enum import StrEnum from typing import Any -from unstract_cli.core.errors import CLIError, ExitCode, scrub +from unstract_cli.core.errors import CLIError, ExitCode, known_secrets, scrub class OutputFormat(StrEnum): @@ -185,10 +185,15 @@ def emit( raw_field: str | None = None, secrets: list[str] | None = None, ) -> None: - """Write one envelope to stdout -- and nothing else to stdout.""" + """Write one envelope to stdout -- and nothing else to stdout. + + Every credential resolved during the run is scrubbed whether or not the + caller passed one: an emitter that has to remember is an emitter that + eventually forgets. + """ text = render(env, fmt, columns=columns, raw_field=raw_field) - if secrets: - text = scrub(text, secrets) + if to_hide := [*(secrets or []), *known_secrets()]: + text = scrub(text, to_hide) print(text) @@ -224,8 +229,8 @@ def emit_error( """ emit(envelope(error=error.to_dict(), meta=meta), fmt, secrets=secrets) summary = error.message - if secrets: - summary = scrub(summary, secrets) + if to_hide := [*(secrets or []), *known_secrets()]: + summary = scrub(summary, to_hide) print(f"error: {summary}", file=sys.stderr) return error.exit_code diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index e34e43a..cf324a3 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -15,8 +15,10 @@ from __future__ import annotations import json +import os import time from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass from pathlib import Path from typing import Any @@ -68,24 +70,90 @@ def extract_handle(payload: Any, field: str) -> str | None: return str(value) if value is not None else None +def preflight(path: str | Path) -> Path: + """Prove the save target is writable, before anything destructive runs. + + `--save` exists to protect a read the server serves exactly once, so + discovering an unwritable path *after* that read is the one failure the + flag must not have. + """ + target = Path(path).expanduser() + try: + target.parent.mkdir(parents=True, exist_ok=True) + existed = target.exists() + with target.open("a", encoding="utf-8"): + pass + if not existed: + target.unlink() + except OSError as exc: + raise CLIError( + f"Cannot write to --save target {path!r}: {exc}.", + ExitCode.USAGE, + hint="Pick a writable path; nothing has been read yet, so nothing is lost.", + ) from exc + return target + + def persist(path: str | Path, payload: Any) -> Path: """Write a result to disk and return where it landed. Some results can be read exactly once. Callers must persist **before** the read is acknowledged to the user, so a crash between the two cannot destroy a result the server will not serve again. + + Written through a temporary file so a full disk leaves the previous copy + intact rather than a truncated one. A failure here raises with the payload + attached: by this point the only surviving copy is in memory, and it has to + reach stdout somehow. """ target = Path(path).expanduser() - target.parent.mkdir(parents=True, exist_ok=True) text = ( payload if isinstance(payload, str) else json.dumps(payload, indent=2, default=str) ) - target.write_text(text, encoding="utf-8") + tmp = target.with_name(target.name + ".tmp") + try: + target.parent.mkdir(parents=True, exist_ok=True) + with tmp.open("w", encoding="utf-8") as handle: + handle.write(text) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, target) + except OSError as exc: + with suppress(OSError): + tmp.unlink(missing_ok=True) + raise CLIError( + f"The result could not be written to {path!r}: {exc}.", + ExitCode.SAVE_FAILED, + details=payload, + hint=( + "`details` carries the result. It has already been read from the " + "service, which will not serve it again -- save it from here." + ), + ) from exc return target +def classify(payload: Any, spec: PollSpec) -> str: + """`success`, `failure`, `pending` or `unknown` for one poll response. + + Shared with the standalone status commands: a finished-and-failed execution + is reported inside an HTTP 200, so a command that only checks the status + code calls it a success. + """ + status = (extract_status(payload, spec.status_field) or "").lower() + if status in {state.lower() for state in spec.terminal_failure}: + return "failure" + if status in {state.lower() for state in spec.terminal_success}: + return "success" + if not status or _dig(payload, "error"): + # An empty status, or a body carrying an error, is not progress. Polling + # on regardless is what turned a server fault into "still running". + return "unknown" + return "pending" + + def wait_for_completion( *, initial: Any, @@ -96,6 +164,9 @@ def wait_for_completion( interval: float = 3.0, timeout: float = 300.0, on_status: Callable[[str | None], None] | None = None, + #: Called with the path once a result is on disk, before the caller sees + #: anything. The ordering it observes is the whole point of --save. + on_saved: Callable[[Path], None] | None = None, sleep: Callable[[float], None] = time.sleep, now: Callable[[], float] = time.monotonic, ) -> Any: @@ -108,14 +179,18 @@ def wait_for_completion( if not handle: return initial - success = {state.lower() for state in spec.terminal_success} - failure = {state.lower() for state in spec.terminal_failure} deadline = now() + timeout last_status: str | None = None payload: Any = initial while True: - payload = poll(handle) + try: + payload = poll(handle) + except CLIError as exc: + # The handle is the difference between resuming and paying to + # process the document a second time. + exc.extra.setdefault(spec.handle_field, handle) + raise status = extract_status(payload, spec.status_field) if status != last_status: @@ -123,8 +198,8 @@ def wait_for_completion( on_status(status) last_status = status - normalised = (status or "").lower() - if normalised in failure: + state = classify(payload, spec) + if state == "failure": raise CLIError( f"Operation finished with status {status!r}.", ExitCode.VALIDATION, @@ -132,7 +207,19 @@ def wait_for_completion( hint="Inspect `details` for the per-file error, or check the execution logs.", extra={spec.handle_field: handle}, ) - if normalised in success: + if state == "unknown": + raise CLIError( + "The service answered with neither a status nor progress.", + ExitCode.SERVER_ERROR, + details=payload, + retryable=True, + hint=( + "The response carries no usable state, so polling on would " + "only repeat it. Retry with the handle below." + ), + extra={spec.handle_field: handle}, + ) + if state == "success": break remaining = deadline - now() @@ -155,14 +242,18 @@ def wait_for_completion( if retrieve is not None: payload = retrieve(handle) if save is not None: - persist(save, payload) + written = persist(save, payload) + if on_saved is not None: + on_saved(written) return payload __all__ = [ "PollSpec", + "classify", "extract_handle", "extract_status", "persist", + "preflight", "wait_for_completion", ] diff --git a/tests/test_commands.py b/tests/test_commands.py index f690760..f8d9476 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -18,7 +18,8 @@ from unstract_cli.__main__ import main from unstract_cli.app import command_tree from unstract_cli.commands import docstudio_cmd, whisper_cmd -from unstract_cli.core.errors import ExitCode +from unstract_cli.config import LLMWHISPERER +from unstract_cli.core.errors import CLIError, ExitCode def run(capsys, *args): @@ -68,7 +69,13 @@ def whisper_client(monkeypatch): def install(**replies): client = FakeWhisper(**replies) - monkeypatch.setattr(whisper_cmd, "llmwhisperer", lambda _config: client) + # Resolving the credential is what registers it for scrubbing, so the + # fake factory has to do it too or the seam hides a production path. + monkeypatch.setattr( + whisper_cmd, + "llmwhisperer", + lambda config: (config.get(LLMWHISPERER, "api_key"), client)[1], + ) return client return install @@ -539,9 +546,7 @@ def test_a_waited_run_reads_its_result_with_the_flags_it_was_given( assert "tags" not in polled -def test_a_waited_run_reports_which_execution_it_was( - capsys, deployment_client, tmp_path -): +def test_a_waited_run_reports_which_execution_it_was(capsys, deployment_client, tmp_path): """The waited payload names the execution nowhere, so without this a caller has no id to correlate the result against the service.""" doc = tmp_path / "doc.pdf" @@ -691,3 +696,164 @@ def _deployment_fake(): ) client.api_url = "https://api.example.com/deployment/api/org/api-name/" return client + + +# --------------------------------------------------------------------------- # +# The one-shot data path +# --------------------------------------------------------------------------- # + + +def test_a_waited_extract_keeps_a_result_that_is_not_wrapped( + capsys, whisper_client, tmp_path +): + """A bare `.get("extraction")` returned None here and printed + `ok: true, data: null` for a document that had been processed and billed.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client( + whisper={"whisper_hash": "h1", "status_code": 202}, + whisper_status={"status": "processed"}, + # No `extraction` key -- the shape the sibling command already tolerated. + whisper_retrieve={"status_code": 200, "result_text": "THE REAL TEXT"}, + ) + + code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0") + + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["data"]["result_text"] == "THE REAL TEXT" + + +def test_a_waited_extract_calls_an_empty_result_a_failure( + capsys, whisper_client, tmp_path +): + """The read is acknowledged either way, so an empty result is a consumed + document with nothing to show for it.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client( + whisper={"whisper_hash": "h1", "status_code": 202}, + whisper_status={"status": "processed"}, + whisper_retrieve={"extraction": {}}, + ) + + code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0") + + assert code == int(ExitCode.SERVER_ERROR) + assert envelope(out)["ok"] is False + + +def test_a_waited_extract_reads_the_result_when_it_is_not_wrapped( + capsys, whisper_client, tmp_path +): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client( + whisper={"whisper_hash": "h1", "status_code": 202}, + whisper_status={"status": "processed"}, + whisper_retrieve={"extraction": {"result_text": "hello"}}, + ) + + code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0") + + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["data"]["result_text"] == "hello" + + +def test_retrieve_writes_the_result_before_it_prints( + capsys, whisper_client, tmp_path, monkeypatch +): + """Ordering, not outcome: asserting after the command returns passes for + either order, which is how this went unnoticed.""" + order: list[str] = [] + target = tmp_path / "out" / "result.json" + whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}}) + + real_persist = whisper_cmd.persist + monkeypatch.setattr( + whisper_cmd, + "persist", + lambda path, payload: (order.append("persist"), real_persist(path, payload))[1], + ) + real_finish = whisper_cmd.finish + monkeypatch.setattr( + whisper_cmd, + "finish", + lambda *a, **kw: (order.append("finish"), real_finish(*a, **kw))[1], + ) + + run(capsys, "whisper", "retrieve", "h1", "--save", str(target)) + + assert order == ["persist", "finish"] + + +def test_retrieve_refuses_an_unwritable_target_before_reading( + capsys, whisper_client, tmp_path +): + """Nothing has been consumed yet at this point, so this failure is cheap -- + the same failure after the read is not recoverable at all.""" + blocker = tmp_path / "not-a-dir" + blocker.write_text("") + client = whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}}) + + code, out, _ = run( + capsys, "whisper", "retrieve", "h1", "--save", str(blocker / "r.json") + ) + + assert code == int(ExitCode.USAGE) + assert client.calls == [] + + +def test_a_save_failure_after_the_read_still_emits_the_result( + capsys, whisper_client, tmp_path, monkeypatch +): + target = tmp_path / "result.json" + whisper_client(whisper_retrieve={"extraction": {"result_text": "IRREPLACEABLE"}}) + + def explode(path, payload): + # What `persist` itself raises when the write fails: the payload rides + # out on the error because there is no other copy left. + raise CLIError( + "The result could not be written.", + ExitCode.SAVE_FAILED, + details=payload, + ) + + monkeypatch.setattr(whisper_cmd, "persist", explode) + + code, out, _ = run(capsys, "whisper", "retrieve", "h1", "--save", str(target)) + + assert code == int(ExitCode.SAVE_FAILED) + assert envelope(out)["error"]["details"]["result_text"] == "IRREPLACEABLE" + + +def test_a_failed_execution_inside_a_200_is_not_a_success(capsys, deployment_client): + deployment_client( + check_execution_status={ + "status_code": 200, + "pending": False, + "execution_status": "ERROR", + "error": "tool crashed", + } + ) + + code, out, _ = run(capsys, "docstudio", "deployment", "status", "api", "e1") + + assert code != int(ExitCode.SUCCESS) + assert envelope(out)["ok"] is False + + +def test_the_key_never_reaches_stdout_or_stderr(capsys, whisper_client, monkeypatch): + """Scrubbing is not a keyword argument a call site can forget.""" + key = "lw-live-ABCDEF0123456789" + monkeypatch.setenv("LLMWHISPERER_API_KEY", key) + whisper_client( + whisper_retrieve=LLMWhispererClientException( + {"message": f"invalid key {key}", "status_code": 401}, 401 + ) + ) + + code, out, err = run(capsys, "whisper", "retrieve", "h1") + + assert code == int(ExitCode.AUTH) + assert key not in out + assert key not in err diff --git a/tests/test_errors.py b/tests/test_errors.py index cc39b54..2066914 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -21,7 +21,10 @@ @pytest.mark.parametrize( ("status", "expected"), [ - (200, ExitCode.SUCCESS), + # Only a failure ever reaches this map: a 2xx or an unfollowed 3xx here + # means something answered outside the contract, which is not success. + (200, ExitCode.GENERIC), + (302, ExitCode.GENERIC), (400, ExitCode.VALIDATION), (401, ExitCode.AUTH), (403, ExitCode.AUTH), @@ -42,8 +45,9 @@ def test_status_to_exit_code(status, expected): def test_exit_codes_are_stable_integers(): # A caller branches on these numbers, so they are an API, not an enum detail. - assert [int(c) for c in ExitCode] == list(range(10)) + assert [int(c) for c in ExitCode] == list(range(11)) assert int(ExitCode.ALREADY_CONSUMED) == 9 + assert int(ExitCode.SAVE_FAILED) == 10 @pytest.mark.parametrize("status", [429, 500, 502, 503]) diff --git a/tests/test_poll.py b/tests/test_poll.py index 12116c8..3c08471 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -13,6 +13,7 @@ extract_handle, extract_status, persist, + preflight, wait_for_completion, ) @@ -174,7 +175,7 @@ def test_retrieve_step_runs_after_terminal_success(): def test_save_persists_the_retrieved_result_before_returning(tmp_path): target = tmp_path / "out" / "result.json" - seen: list[bool] = [] + on_disk: list[bool] = [] def retrieve(handle): return {"text": "extracted"} @@ -185,15 +186,44 @@ def retrieve(handle): poll=responses({"status": "processed"}), retrieve=retrieve, save=target, + # Observed from inside the engine, before the caller is handed anything: + # asserting after the return passes for either ordering. + on_saved=lambda path: on_disk.append(path.exists()), sleep=Clock().sleep, ) - # The file exists by the time the caller is handed the result: a one-shot - # read must survive a crash between retrieval and acknowledgement. - seen.append(target.exists()) - assert seen == [True] + assert on_disk == [True] assert json.loads(target.read_text()) == out +def test_an_unwritable_save_target_is_refused_before_anything_is_read(tmp_path): + blocker = tmp_path / "not-a-dir" + blocker.write_text("") + + with pytest.raises(CLIError) as caught: + preflight(blocker / "result.json") + + assert caught.value.exit_code is ExitCode.USAGE + assert "nothing is lost" in (caught.value.hint or "") + + +def test_a_failed_save_carries_the_result_it_could_not_write(tmp_path): + """By this point the service has served the result and will not again, so + the payload has to leave through the error.""" + blocker = tmp_path / "not-a-dir" + blocker.write_text("") + + with pytest.raises(CLIError) as caught: + persist(blocker / "result.json", {"result_text": "IRREPLACEABLE"}) + + assert caught.value.exit_code is ExitCode.SAVE_FAILED + assert caught.value.details == {"result_text": "IRREPLACEABLE"} + + +def test_a_save_leaves_no_temporary_file_behind(tmp_path): + target = persist(tmp_path / "out.json", {"a": 1}) + assert [p.name for p in tmp_path.iterdir()] == [target.name] + + def test_persist_writes_text_payloads_unwrapped(tmp_path): target = persist(tmp_path / "a.txt", "plain extracted text") assert target.read_text() == "plain extracted text" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f7d3c62 --- /dev/null +++ b/uv.lock @@ -0,0 +1,376 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "llmwhisperer-client" +version = "2.7.0" +source = { git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=02485e1#02485e1e108b854f5379f4b64aa129e071952022" } +dependencies = [ + { name = "attrs" }, + { name = "httpx" }, + { name = "requests" }, + { name = "tenacity" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "unstract-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "llmwhisperer-client" }, + { name = "tomli-w" }, + { name = "unstract-client" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1,<9" }, + { name = "llmwhisperer-client", git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=02485e1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, + { name = "tomli-w", specifier = ">=1.0" }, + { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=ed89066" }, +] +provides-extras = ["dev"] + +[[package]] +name = "unstract-client" +version = "1.5.3" +source = { git = "https://github.com/Zipstack/unstract-python-client?rev=ed89066#ed89066086748f7576887ed5d06dea40e9ac27d7" } +dependencies = [ + { name = "attrs" }, + { name = "click" }, + { name = "httpx" }, + { name = "requests" }, + { name = "rich" }, + { name = "tenacity" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From 52761ad670184e401de12b1fc463091ab022687d Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 18:27:34 +0530 Subject: [PATCH 11/86] Print a table by default, and version the JSON A CLI whose output shape depends on whether a terminal is attached is a CLI whose scripts break when they move from a shell to CI. This drops the isatty question entirely: the default is a table, in a terminal and in a pipe alike, and anything that parses the output asks for `-o json`. An explicit `-o` is the last word. The environment picks the default and nothing more, so the same `-o json` invocation renders the same bytes wherever it runs -- which is the property a caller is actually relying on. Coding agents are the exception worth making: they set a marker in the environment, and there the default becomes json rather than making every call carry a flag. `--agent yes|no` settles it either way. Every envelope now carries `meta.contract_version`, and `--discover full` publishes what a consumer has to do to hold up its end: ignore unknown fields, refuse a version above the one it was written against. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 26 +++++--- RUNBOOK.md | 12 ++-- src/unstract_cli/__main__.py | 36 ++++++----- src/unstract_cli/app.py | 32 +++++++--- src/unstract_cli/commands/config_cmd.py | 4 +- src/unstract_cli/core/discover.py | 31 +++++++++- src/unstract_cli/core/output.py | 75 ++++++++++++++++++++--- tests/conftest.py | 12 ++++ tests/test_cli.py | 81 +++++++++++++++++++++---- tests/test_commands.py | 8 ++- tests/test_discover.py | 15 ++++- tests/test_output.py | 51 +++++++++++++++- 12 files changed, 319 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 8366668..6d443fa 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,30 @@ unstract config init unstract config doctor ``` -## Output contract +## Output -stdout always carries exactly one JSON envelope, on success and on failure -alike: +`unstract` prints a table by default — in a terminal and in a pipe alike, so +what you see while trying something is what a script sees running it. + +**Parsing anything? Pass `-o json`.** stdout then carries exactly one envelope, +on success and on failure alike: ```json -{"ok": true, "data": {...}, "error": null, "meta": {}} +{"ok": true, "data": {...}, "error": null, "meta": {"contract_version": 1}} ``` -Parsing never needs to check whether a terminal is attached. Diagnostics, -warnings and progress go to stderr. `--output table` and `--output raw` are -opt-in renderings of `data` for humans and pipes. +`-o json` output depends on nothing but the command and its arguments — not the +terminal, not the config, not the environment. `-o raw` prints one field +unwrapped, for piping a document's text somewhere else. Diagnostics, warnings +and progress always go to stderr. + +Consuming the JSON: ignore fields you do not recognise, and refuse a +`meta.contract_version` above the one you were written against. `unstract +--discover full` publishes the whole contract alongside every command and flag. + +If a coding agent is driving (detected from the environment it sets), the +*default* becomes json. `--agent yes|no` forces that either way, and an explicit +`-o` always wins over both. Failures exit non-zero with a stable code: diff --git a/RUNBOOK.md b/RUNBOOK.md index 2645d93..37c1e1c 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -64,7 +64,7 @@ That is the intent, so the check is that the change was the intended one: 4. Diff the surface before and after: ```bash - python -m unstract_cli --discover full > after.json + python -m unstract_cli -o json --discover full > after.json ``` Every added or removed flag should be one you can name a reason for. @@ -110,14 +110,16 @@ Run against a document you can re-send; several of these submit real work. | 6 | `whisper usage` | quota returned | | 7 | `docstudio deployment run ` | polls to completion, returns structured JSON | | 8 | `docstudio deployment run --no-wait`, then `docstudio deployment status ` from the run envelope | the handle survives the round trip | -| 9 | any command with `--output raw` | one field, not the envelope | -| 10 | any command with a wrong key | exit 3, JSON envelope on stdout, no traceback | -| 11 | any command with a path that does not exist | exit 2, JSON envelope on stdout | +| 9 | any command with `-o raw` | one field, not the envelope | +| 10 | any command with `-o json` and a wrong key | exit 3, JSON envelope on stdout, no traceback | +| 11 | any command with `-o json` and a path that does not exist | exit 2, JSON envelope on stdout | +| 12 | any command with no `-o` | a table, in a terminal and through a pipe alike | Two properties matter more than any single row, because they are what a caller depends on and what breaks quietly: -- **stdout is one JSON envelope in every case above, including the failures.** +- **With `-o json`, stdout is one envelope in every case above, including the + failures.** A traceback on stderr with empty stdout is a bug even when the exit code is right. - **A flag passed explicitly reaches the wire, including when its value is diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py index 96bcde8..368260c 100644 --- a/src/unstract_cli/__main__.py +++ b/src/unstract_cli/__main__.py @@ -15,27 +15,35 @@ from unstract_cli.app import cli from unstract_cli.config import ConfigError from unstract_cli.core.errors import CLIError, ExitCode -from unstract_cli.core.output import OutputFormat, emit_error +from unstract_cli.core.output import AgentMode, OutputFormat, emit_error, resolve_format -def _format_from_argv(argv: list[str]) -> OutputFormat: - """Best-effort read of --output before Click has parsed anything. +def _option_from_argv(argv: list[str], *spellings: str) -> str | None: + """Best-effort read of one option before Click has parsed anything. A failure during parsing still has to be rendered, and the parsed context does not exist yet at that point. """ for i, arg in enumerate(argv): - value = None - if arg.startswith("--output="): - value = arg.split("=", 1)[1] - elif arg in ("--output", "-o") and i + 1 < len(argv): - value = argv[i + 1] - if value: - try: - return OutputFormat(value) - except ValueError: - break - return OutputFormat.JSON + for spelling in spellings: + if arg.startswith(f"{spelling}="): + return arg.split("=", 1)[1] + if arg == spelling and i + 1 < len(argv): + return argv[i + 1] + return None + + +def _format_from_argv(argv: list[str]) -> OutputFormat: + """Resolve the format the same way the parsed run would.""" + try: + return resolve_format( + _option_from_argv(argv, "--output", "-o"), + _option_from_argv(argv, "--agent") or AgentMode.AUTO, + ) + except ValueError: + # An unusable value here is Click's error to report, not ours to guess + # around; render the failure in the default and let it through. + return resolve_format(None) def main(argv: list[str] | None = None) -> int: diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 92cfb18..c65d6a6 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -23,14 +23,20 @@ ) from unstract_cli.core.discover import TIERS, discover from unstract_cli.core.errors import CLIError, ExitCode -from unstract_cli.core.output import OutputFormat, diagnostic, emit_result +from unstract_cli.core.output import ( + AgentMode, + OutputFormat, + diagnostic, + emit_result, + resolve_format, +) @dataclass class Context: """Everything a command needs from the global options.""" - output: OutputFormat = OutputFormat.JSON + output: OutputFormat = OutputFormat.TABLE quiet: bool = False verbosity: int = 0 profile: str | None = None @@ -105,9 +111,16 @@ def secrets(self) -> list[str]: @click.option( "--output", "-o", + default=None, type=click.Choice([f.value for f in OutputFormat]), - default=OutputFormat.JSON.value, - help="Output format. JSON is the default everywhere, including a terminal.", + help="Output format. Defaults to table; pass json to parse the output.", +) +@click.option( + "--agent", + type=click.Choice([m.value for m in AgentMode]), + default=AgentMode.AUTO.value, + help="Whether a coding agent is driving this: sets the default format to " + "json. Only the default -- an explicit --output always wins.", ) @click.option( "--quiet", @@ -130,20 +143,21 @@ def cli( ctx: click.Context, config_file: str | None, profile: str | None, - output: str, + output: str | None, + agent: str, quiet: bool, verbose: int, discover_tier: str | None, ) -> None: """Unstract CLI: extract documents and run API deployments. - stdout always carries one JSON envelope -- {ok, data, error, meta} -- so - output parses without checking whether a terminal is attached. Diagnostics go - to stderr. + Output is a table by default. With `-o json` stdout carries one envelope -- + {ok, data, error, meta} -- on success and on failure alike, and its content + depends on nothing but the command you ran. Diagnostics go to stderr. """ set_config_path(config_file) ctx.obj = Context( - output=OutputFormat(output), + output=resolve_format(output, agent), quiet=quiet, verbosity=verbose, profile=profile, diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index ea4ac41..0321745 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -29,7 +29,7 @@ ) from unstract_cli.core.clients import llmwhisperer, translated from unstract_cli.core.errors import CLIError, ExitCode -from unstract_cli.core.output import OutputFormat, emit_result +from unstract_cli.core.output import OutputFormat, emit_result, resolve_format #: Keys whose value is never echoed back, even on explicit request: this output #: is as likely to land in a log or a transcript as on a screen. @@ -42,7 +42,7 @@ def _is_secret(key: str) -> bool: def _fmt(obj: Any) -> OutputFormat: """Output format from the root context, defaulting when invoked standalone.""" - return getattr(obj, "output", None) or OutputFormat.JSON + return getattr(obj, "output", None) or resolve_format(None) def _check_product(product: str) -> str: diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py index eca1f71..2d8017e 100644 --- a/src/unstract_cli/core/discover.py +++ b/src/unstract_cli/core/discover.py @@ -7,7 +7,8 @@ * ``groups`` -- what products are here at all * ``summary`` -- what commands each group has * ``full`` -- every flag with its type, default and allowed values, plus the - exit codes, which is enough to construct a call without a second round trip + exit codes and the output contract, which is enough to construct a call and + read its answer without a second round trip Every tier is read back from Click itself. Describing commands from anywhere else lets the description drift from what the parser accepts. @@ -20,10 +21,35 @@ import click from unstract_cli.core.errors import _ERROR_CODES, ExitCode +from unstract_cli.core.output import CONTRACT_VERSION TIERS = ("groups", "summary", "full") +def contract() -> dict[str, Any]: + """How to consume this CLI's output, published rather than assumed. + + Both halves of the compatibility bargain are written down here: what we + promise not to break, and what a consumer has to do for that promise to be + worth anything. + """ + return { + "version": CONTRACT_VERSION, + "envelope": ["ok", "data", "error", "meta"], + "rules": [ + "Pass `-o json`. The default format is for people and is free to " + "change; json is the parseable one and never varies with the " + "terminal, the config or the environment.", + "Ignore fields you do not recognise. New ones are added within a " + "major version.", + "Refuse a `meta.contract_version` whose value is greater than the " + "one you were written against: the shape has changed under you.", + "Branch on the exit code, not on the message text.", + "Read stdout for the envelope only. Diagnostics are on stderr.", + ], + } + + def exit_codes() -> list[dict[str, Any]]: """The exit-code table, which is part of the contract callers branch on.""" return [ @@ -98,7 +124,8 @@ def discover(root: click.Group, tier: str) -> dict[str, Any]: } if tier == "full": payload["exit_codes"] = exit_codes() + payload["contract"] = contract() return payload -__all__ = ["TIERS", "discover", "exit_codes"] +__all__ = ["TIERS", "contract", "discover", "exit_codes"] diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index be0a266..d75b9ec 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -1,26 +1,45 @@ -"""Output rendering. +"""Output rendering, and choosing which rendering to use. The contract a caller depends on: -* **stdout carries one JSON envelope and nothing else** -- ``{ok, data, error, - meta}`` -- on success and on failure alike, so parsing never needs TTY - detection and a failed run still yields a valid object rather than an empty - stream. +* ``-o json`` writes **one envelope to stdout and nothing else** -- ``{ok, data, + error, meta}`` -- on success and on failure alike, so a failed run still + yields a valid object rather than an empty stream. +* What ``-o json`` produces depends on nothing but the command and its + arguments: not on a terminal, not on configuration, not on who is calling. * Human-facing notes, warnings and progress all go to stderr. -* ``--output table|raw`` are opt-in human/pipe renderings of ``data``. +* Without ``-o`` the output is ``table``, which is for people to read and is + free to change. Anything parsing this CLI passes ``-o json`` explicitly. + +The one thing an unflagged run reads from its environment is which *default* to +use: a coding agent gets ``json``, because an agent that has to be told twice is +an agent that parses a table. Detection is never allowed to reach past the +default -- see ``resolve_format``. """ from __future__ import annotations import json +import os import shutil import sys import textwrap +from collections.abc import Mapping from enum import StrEnum +from fnmatch import fnmatch from typing import Any from unstract_cli.core.errors import CLIError, ExitCode, known_secrets, scrub +#: Major version of the stdout envelope, published in every ``meta``. A consumer +#: ignores fields it does not recognise and refuses a version it was not written +#: against. +CONTRACT_VERSION = 1 + +#: Environment markers the coding agents set for the tools they drive. Patterns, +#: so a family of variables can be named once. +AGENT_ENV = ("CLAUDECODE", "CURSOR_AGENT", "CODEX_*", "AI_AGENT") + class OutputFormat(StrEnum): JSON = "json" @@ -28,6 +47,38 @@ class OutputFormat(StrEnum): RAW = "raw" +class AgentMode(StrEnum): + AUTO = "auto" + YES = "yes" + NO = "no" + + +def agent_detected(env: Mapping[str, str] | None = None) -> bool: + """Whether the environment looks like a coding agent's.""" + names = os.environ if env is None else env + return any( + names[name] and fnmatch(name, pattern) for name in names for pattern in AGENT_ENV + ) + + +def resolve_format( + explicit: str | None, + agent: str = AgentMode.AUTO, + env: Mapping[str, str] | None = None, +) -> 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 explicit: + return OutputFormat(explicit) + if agent == AgentMode.YES or (agent == AgentMode.AUTO and agent_detected(env)): + return OutputFormat.JSON + return OutputFormat.TABLE + + def envelope( *, data: Any = None, @@ -35,7 +86,12 @@ def envelope( meta: dict[str, Any] | None = None, ) -> dict[str, Any]: """Build the stdout envelope. ``ok`` is derived, never passed in.""" - return {"ok": error is None, "data": data, "error": error, "meta": meta or {}} + return { + "ok": error is None, + "data": data, + "error": error, + "meta": {**(meta or {}), "contract_version": CONTRACT_VERSION}, + } def _flatten(value: Any) -> str: @@ -249,7 +305,11 @@ def diagnostic( __all__ = [ + "AGENT_ENV", + "CONTRACT_VERSION", + "AgentMode", "OutputFormat", + "agent_detected", "diagnostic", "emit", "emit_error", @@ -257,4 +317,5 @@ def diagnostic( "envelope", "render", "render_table", + "resolve_format", ] diff --git a/tests/conftest.py b/tests/conftest.py index a798c7b..5d33b03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,12 @@ from __future__ import annotations +import os +from fnmatch import fnmatch + import pytest from unstract_cli import config as config_mod +from unstract_cli.core.output import AGENT_ENV #: Every variable the loader consults. Cleared per test so a developer's real #: shell environment cannot change a result. @@ -16,6 +20,14 @@ def clean_env(monkeypatch, tmp_path): for var in _ENV_VARS: monkeypatch.delenv(var, raising=False) + # These decide the default output format, and this suite is as likely to be + # run by an agent as by a person. + for var in [ + name + for name in os.environ + if any(fnmatch(name, pattern) for pattern in AGENT_ENV) + ]: + monkeypatch.delenv(var, raising=False) config_mod.set_config_path(None) # Both discovery fallbacks are redirected into the tmp dir: an upward search # from a real cwd could otherwise find a developer's own .unstract.toml. diff --git a/tests/test_cli.py b/tests/test_cli.py index 786b1ec..d609117 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,17 +3,22 @@ from __future__ import annotations import json +from pathlib import Path -import pytest - +from unstract_cli import app from unstract_cli.__main__ import main from unstract_cli.app import cli, command_tree from unstract_cli.core.errors import ExitCode def run(capsys, *args): - """Invoke the CLI as the console script does, returning (code, stdout json).""" - code = main(list(args)) + """Invoke the CLI as the console script does, returning (code, stdout json). + + `-o json` is passed the way any consumer has to pass it: the default format + is human-facing, and a test that relied on it would be pinning the wrong + thing. + """ + code = main(["-o", "json", *args]) captured = capsys.readouterr() payload = json.loads(captured.out) if captured.out.strip() else None return code, payload, captured.err @@ -93,16 +98,68 @@ def test_doctor_reports_sources_without_leaking_values(capsys, monkeypatch): assert "super-secret-value" not in json.dumps(payload) -def test_table_output_is_opt_in_and_json_is_the_default(capsys, monkeypatch): - monkeypatch.setattr("sys.stdout.isatty", lambda: True, raising=False) - # JSON even on a TTY: a caller never has to detect the terminal to parse. - assert run(capsys, "config", "doctor")[1]["ok"] is True +def doctor(capsys, *args) -> str: + """`config doctor` -- a command with no network -- and its raw stdout.""" + main([*args, "config", "doctor"]) + return capsys.readouterr().out + - main(["--output", "table", "config", "doctor"]) - out = capsys.readouterr().out - with pytest.raises(json.JSONDecodeError): +def is_table(out: str) -> bool: + try: json.loads(out) - assert "active_profile" in out + except json.JSONDecodeError: + return "active_profile" in out + return False + + +class TestOutputFormatEndToEnd: + """One rule: `-o` decides, and where it is absent the environment picks the + default only. Everything here is a way of getting that wrong.""" + + def test_the_default_is_a_table_in_a_terminal_and_in_a_pipe( + self, capsys, monkeypatch + ): + monkeypatch.setattr("sys.stdout.isatty", lambda: True, raising=False) + assert is_table(doctor(capsys)) + monkeypatch.setattr("sys.stdout.isatty", lambda: False, raising=False) + assert is_table(doctor(capsys)) + + def test_no_isatty_call_decides_a_format(self): + """A format that depends on a terminal makes a script's output depend on + how it was launched.""" + source = Path(app.__file__).parent + offenders = [ + path.name + for path in source.rglob("*.py") + if "isatty" in path.read_text(encoding="utf-8") + ] + assert offenders == [] + + def test_an_agent_environment_makes_json_the_default(self, capsys, monkeypatch): + monkeypatch.setenv("CLAUDECODE", "1") + assert json.loads(doctor(capsys))["ok"] is True + + def test_an_explicit_format_wins_over_a_detected_agent(self, capsys, monkeypatch): + monkeypatch.setenv("CLAUDECODE", "1") + assert is_table(doctor(capsys, "-o", "table")) + + def test_agent_no_forces_the_human_default(self, capsys, monkeypatch): + monkeypatch.setenv("CLAUDECODE", "1") + assert is_table(doctor(capsys, "--agent", "no")) + + def test_json_is_byte_identical_however_it_was_asked_for(self, capsys, monkeypatch): + monkeypatch.setattr("sys.stdout.isatty", lambda: True, raising=False) + on_a_tty = doctor(capsys, "-o", "json") + + monkeypatch.setattr("sys.stdout.isatty", lambda: False, raising=False) + monkeypatch.setenv("CLAUDECODE", "1") + piped_under_an_agent = doctor(capsys, "-o", "json") + + assert on_a_tty == piped_under_an_agent + + def test_every_envelope_carries_the_contract_version(self, capsys): + assert run(capsys, "config", "doctor")[1]["meta"]["contract_version"] == 1 + assert run(capsys, "nope")[1]["meta"]["contract_version"] == 1 def test_click_parameter_info_dict_keeps_the_keys_discovery_reads(): diff --git a/tests/test_commands.py b/tests/test_commands.py index f8d9476..24f244c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -23,8 +23,12 @@ def run(capsys, *args): - """Invoke the CLI as the console script does, returning (code, stdout, stderr).""" - code = main(list(args)) + """Invoke the CLI as the console script does, returning (code, stdout, stderr). + + `-o json` explicitly: these assert on the parseable output, which is what a + caller opts into rather than what an unflagged run happens to print. + """ + code = main(["-o", "json", *args]) captured = capsys.readouterr() return code, captured.out, captured.err diff --git a/tests/test_discover.py b/tests/test_discover.py index 4a094fa..29bb286 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -14,10 +14,11 @@ from unstract_cli.__main__ import main from unstract_cli.commands import config_cmd from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.output import CONTRACT_VERSION def run(capsys, *args): - code = main(list(args)) + code = main(["-o", "json", *args]) out = capsys.readouterr().out return code, json.loads(out)["data"] if out.strip() else None @@ -65,6 +66,18 @@ def test_full_carries_the_exit_code_table(capsys): assert codes["success"] == 0 +def test_full_publishes_how_to_consume_the_output(capsys): + """The compatibility bargain is only binding if the consumer can read it.""" + _, data = run(capsys, "--discover", "full") + contract = data["contract"] + assert contract["version"] == CONTRACT_VERSION + assert contract["envelope"] == ["ok", "data", "error", "meta"] + rules = " ".join(contract["rules"]).lower() + assert "-o json" in rules + assert "ignore fields you do not recognise" in rules + assert "contract_version" in rules + + def test_discovery_needs_no_configuration(capsys, tmp_path, monkeypatch): """It is how a caller finds out what to run, so it must work before anything is set up.""" diff --git a/tests/test_output.py b/tests/test_output.py index 1a204c6..9293c11 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -6,12 +6,15 @@ from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.output import ( + CONTRACT_VERSION, + AgentMode, OutputFormat, emit_error, emit_result, envelope, render, render_table, + resolve_format, ) ENVELOPE_KEYS = {"ok", "data", "error", "meta"} @@ -20,7 +23,12 @@ def test_success_envelope_shape(): env = envelope(data={"a": 1}, meta={"took": 2}) assert set(env) == ENVELOPE_KEYS - assert env == {"ok": True, "data": {"a": 1}, "error": None, "meta": {"took": 2}} + assert env == { + "ok": True, + "data": {"a": 1}, + "error": None, + "meta": {"took": 2, "contract_version": CONTRACT_VERSION}, + } def test_error_envelope_shape(): @@ -40,7 +48,13 @@ def test_error_envelope_shape(): def test_meta_defaults_to_an_object_not_null(): # A caller reading meta. should not have to null-check the container. - assert envelope(data=1)["meta"] == {} + assert envelope(data=1)["meta"] == {"contract_version": CONTRACT_VERSION} + + +def test_every_envelope_is_versioned(): + """A consumer cannot refuse a shape it was not written for without this.""" + for env in (envelope(data=1, meta={"job": "x"}), envelope(error={"code": "x"})): + assert env["meta"]["contract_version"] == CONTRACT_VERSION def test_stdout_carries_the_envelope_on_success(capsys): @@ -50,7 +64,7 @@ def test_stdout_carries_the_envelope_on_success(capsys): "ok": True, "data": {"text": "hello"}, "error": None, - "meta": {}, + "meta": {"contract_version": CONTRACT_VERSION}, } assert out.err == "" @@ -93,3 +107,34 @@ def test_table_wraps_long_cells_rather_than_truncating(): def test_table_of_an_empty_list_says_so(): assert render_table([]) == "(no results)" + + +class TestFormatSelection: + """Which rendering a run gets, and what is allowed to influence it.""" + + AGENT = {"CLAUDECODE": "1"} + + def test_the_default_is_a_table(self): + assert resolve_format(None, env={}) is OutputFormat.TABLE + + def test_an_agent_environment_moves_the_default_to_json(self): + for var in ("CLAUDECODE", "CURSOR_AGENT", "CODEX_SANDBOX", "AI_AGENT"): + assert resolve_format(None, env={var: "1"}) is OutputFormat.JSON + + def test_an_unset_marker_is_not_an_agent(self): + """An exported-but-empty variable is how a shell spells 'no'.""" + assert resolve_format(None, env={"CLAUDECODE": ""}) is OutputFormat.TABLE + + def test_an_explicit_format_beats_detection_in_both_directions(self): + assert resolve_format("table", env=self.AGENT) is OutputFormat.TABLE + assert resolve_format("json", env={}) is OutputFormat.JSON + + def test_the_agent_flag_overrides_what_the_environment_says(self): + assert resolve_format(None, AgentMode.NO, self.AGENT) is OutputFormat.TABLE + assert resolve_format(None, AgentMode.YES, {}) is OutputFormat.JSON + + def test_json_renders_the_same_bytes_wherever_it_is_asked_for(self): + env = envelope(data={"text": "hello"}) + one = render(env, resolve_format("json", AgentMode.NO, {})) + two = render(env, resolve_format("json", AgentMode.YES, self.AGENT)) + assert one == two From 14a1966cc1821b5978db397ad3a9947f25944450 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 21:21:34 +0530 Subject: [PATCH 12/86] feat: expose the deployment client's socket timeout Nothing bounded a stalled connection: the deployment client is untimed and its api_timeout is an execution mode the backend reads, not a socket timeout. --transport-timeout sets one. Unset by default, so a run that would have hung still hangs rather than starting to fail in a way no existing script expects. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/app.py | 14 +++++++++- src/unstract_cli/commands/docstudio_cmd.py | 4 +-- src/unstract_cli/core/clients.py | 5 +++- tests/test_commands.py | 32 ++++++++++++++++++++-- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index c65d6a6..3fd1bfd 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -40,6 +40,8 @@ class Context: quiet: bool = False verbosity: int = 0 profile: str | None = None + #: Socket timeout for the deployment client, which has none of its own. + transport_timeout: float | None = None #: Command-line overrides, keyed `product.setting` -- the top tier of #: flag > env > profile > default. overrides: dict[str, Any] = field(default_factory=dict) @@ -206,9 +208,19 @@ def whisper_group(ctx: Context, **overrides: str | None) -> None: @cli.group("docstudio") @_connection_options(org_id=True) +@click.option( + "--transport-timeout", + type=float, + default=None, + help="Seconds before a stalled connection is given up on. Unset means it " + "is not, which is what the client has always done.", +) @pass_context -def docstudio_group(ctx: Context, **overrides: str | None) -> None: +def docstudio_group( + ctx: Context, transport_timeout: float | None, **overrides: str | None +) -> None: """Run Document Studio API deployments.""" + ctx.transport_timeout = transport_timeout ctx.override(DOCSTUDIO, overrides) diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index 8a638f3..4f3afa5 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -69,7 +69,7 @@ def run( TARGET is a deployment alias or an API name. With --wait (the default) this polls until the execution finishes and returns its result. """ - client = deployment(ctx.config, target) + client = deployment(ctx.config, target, ctx.transport_timeout) sent = requested(params) if save: preflight(save) @@ -140,7 +140,7 @@ def poll(endpoint: str) -> dict[str, Any]: @pass_context def status(ctx: Context, target: str, execution_id: str, **params: Any) -> None: """Report the state of a running or finished execution.""" - client = deployment(ctx.config, target) + client = deployment(ctx.config, target, ctx.transport_timeout) endpoint = f"{client.api_url}?execution_id={execution_id}" with translated(endpoint=client.api_url): result = client.check_execution_status(endpoint, **requested(params)) diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py index 15604cb..f1207cc 100644 --- a/src/unstract_cli/core/clients.py +++ b/src/unstract_cli/core/clients.py @@ -52,7 +52,9 @@ def deployment_url(base_url: str, org_id: str, api_name: str) -> str: return base_url.rstrip("/") + path -def deployment(config: ResolvedConfig, target: str) -> APIDeploymentsClient: +def deployment( + config: ResolvedConfig, target: str, transport_timeout: float | None = None +) -> APIDeploymentsClient: """Build a deployment client for an alias, or for a bare API name. An alias carries its own organisation and key; a bare name falls back to the @@ -87,6 +89,7 @@ def deployment(config: ResolvedConfig, target: str) -> APIDeploymentsClient: api_url=deployment_url(config.require(DOCSTUDIO, "base_url"), org_id, api_name), api_key=api_key, logging_level="ERROR", + transport_timeout=transport_timeout, ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 24f244c..8c0c673 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -92,7 +92,13 @@ def deployment_client(monkeypatch): def install(**replies): client = FakeWhisper(**replies) client.api_url = "https://api.example.com/deployment/api/org/api-name/" - monkeypatch.setattr(docstudio_cmd, "deployment", lambda _config, _t: client) + client.built_with = {} + + def build(_config, _target, transport_timeout=None): + client.built_with["transport_timeout"] = transport_timeout + return client + + monkeypatch.setattr(docstudio_cmd, "deployment", build) return client return install @@ -418,6 +424,28 @@ def test_run_queues_the_execution_and_polls_it(capsys, deployment_client, tmp_pa assert envelope(out)["data"]["execution_status"] == "COMPLETED" +@pytest.mark.parametrize( + ("flag", "expected"), [([], None), (["--transport-timeout", "12.5"], 12.5)] +) +def test_the_transport_timeout_flag_reaches_the_client( + capsys, deployment_client, tmp_path, flag, expected +): + """Unset means a stalled connection is never given up on, which is what + the client has always done.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + client = deployment_client( + structure_file={"status_code": 200, "execution_status": "COMPLETED"} + ) + + code, _out, _err = run( + capsys, "-q", "docstudio", *flag, "deployment", "run", "my-api", str(doc) + ) + + assert code == int(ExitCode.SUCCESS) + assert client.built_with["transport_timeout"] == expected + + def test_run_passes_only_the_flags_that_were_given(capsys, deployment_client, tmp_path): doc = tmp_path / "doc.pdf" doc.write_bytes(b"%PDF-") @@ -686,7 +714,7 @@ def test_a_deployment_org_can_come_from_a_flag(capsys, monkeypatch): monkeypatch.setattr( docstudio_cmd, "deployment", - lambda config, target: ( + lambda config, target, transport_timeout=None: ( seen.update(org=config.get("docstudio", "org_id")) or _deployment_fake() ), ) From e0a91eaac9d8fa70ac6b0ea5ae6c656fad4968e6 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 21:23:42 +0530 Subject: [PATCH 13/86] fix: report an interrupt as an interrupt Ctrl-C came back as exit 1 with nothing on stdout, which reads to a supervisor as a failed command worth retrying -- the one thing that must not happen to a run the user deliberately stopped. It now exits 130, the value every shell already reads that way, and prints the same envelope as any other failure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 1 + src/unstract_cli/__main__.py | 11 +++++++++-- src/unstract_cli/core/errors.py | 4 ++++ tests/test_cli.py | 16 ++++++++++++++++ tests/test_errors.py | 5 ++++- 5 files changed, 34 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6d443fa..bf56ab4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Failures exit non-zero with a stable code: | 8 | server error | | 9 | result already consumed (one-shot read; use `--save` next time) | | 10 | the result was read but could not be saved — it is in `error.details` | +| 130 | interrupted (128 + SIGINT) — the user stopped it, not a failure | ## Configuration diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py index 368260c..b866bf5 100644 --- a/src/unstract_cli/__main__.py +++ b/src/unstract_cli/__main__.py @@ -71,8 +71,15 @@ def main(argv: list[str] | None = None) -> int: fmt, ) ) - except click.Abort: - return int(ExitCode.GENERIC) + except (click.Abort, KeyboardInterrupt): + # Click turns an interrupt into Abort, and nothing here prompts, so + # Abort means only that. Reporting it as a generic failure tells a + # supervisor to retry what the user deliberately stopped. + return int( + emit_error( + CLIError("Interrupted.", ExitCode.INTERRUPTED, retryable=True), fmt + ) + ) except click.exceptions.Exit as exc: # --help and --version exit through here return int(exc.exit_code) return int(ExitCode.SUCCESS) diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index 8d328b9..ae40ba8 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -25,6 +25,9 @@ class ExitCode(IntEnum): SERVER_ERROR = 8 ALREADY_CONSUMED = 9 SAVE_FAILED = 10 + #: 128 + SIGINT, the value a shell and every job runner already read as + #: "the user stopped it" rather than as a failure of the command. + INTERRUPTED = 130 #: HTTP status -> exit code. 422 maps to VALIDATION, which is right for a real @@ -57,6 +60,7 @@ class ExitCode(IntEnum): ExitCode.SERVER_ERROR: "server_error", ExitCode.ALREADY_CONSUMED: "already_consumed", ExitCode.SAVE_FAILED: "save_failed", + ExitCode.INTERRUPTED: "interrupted", } diff --git a/tests/test_cli.py b/tests/test_cli.py index d609117..2db5665 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,6 +43,22 @@ def test_unknown_command_is_a_usage_error_with_an_envelope(capsys): assert err.startswith("error:") +def test_an_interrupt_exits_one_thirty_with_an_envelope(capsys, monkeypatch): + """Ctrl-C is not a failure of the command. Reporting it as a generic error + tells a supervisor to retry what the user deliberately stopped.""" + + def interrupted(): + raise KeyboardInterrupt + + monkeypatch.setattr("unstract_cli.commands.config_cmd.load_config", interrupted) + + code, payload, _ = run(capsys, "config", "doctor") + + assert code == int(ExitCode.INTERRUPTED) == 130 + assert payload["ok"] is False + assert payload["error"]["code"] == "interrupted" + + def test_unknown_config_target_exits_two(capsys): code, payload, _ = run(capsys, "config", "get", "nosuchproduct", "base_url") assert code == int(ExitCode.USAGE) diff --git a/tests/test_errors.py b/tests/test_errors.py index 2066914..180aa6b 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -45,9 +45,12 @@ def test_status_to_exit_code(status, expected): def test_exit_codes_are_stable_integers(): # A caller branches on these numbers, so they are an API, not an enum detail. - assert [int(c) for c in ExitCode] == list(range(11)) + assert [int(c) for c in ExitCode] == [*range(11), 130] assert int(ExitCode.ALREADY_CONSUMED) == 9 assert int(ExitCode.SAVE_FAILED) == 10 + # 128 + SIGINT, which every shell and job runner already reads as + # "stopped", rather than the next number in this CLI's own sequence. + assert int(ExitCode.INTERRUPTED) == 130 @pytest.mark.parametrize("status", [429, 500, 502, 503]) From 19d6001d79f9398f5f0213ec59e146f9f76ae192 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 21:53:47 +0530 Subject: [PATCH 14/86] test: pin which of the three help sources wins Overlay, spec and client docstring can each describe a flag. No spec parameter carries a description today, so the order between them is unexercised until one does, which is exactly when an inversion would ship unnoticed. --- tests/test_params.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_params.py b/tests/test_params.py index 7b1d376..437b986 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -12,6 +12,7 @@ from unstract.api_deployments.client import APIDeploymentsClient from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 +from unstract_cli.core import params as params_module from unstract_cli.core.params import ( Param, click_option, @@ -152,6 +153,23 @@ def test_the_docstrings_own_default_sentence_is_dropped(): assert described["tag"] == "The tag for the document." +def test_the_spec_wins_over_the_docstring_and_the_overlay_wins_over_both(monkeypatch): + """Three sources can describe one flag, and only the most specific should + show. Today no spec parameter carries a description, so the precedence is + unexercised until one does -- which is when it would silently invert.""" + described = Param("lang", "string", description="From the spec.") + monkeypatch.setattr(params_module, "operation_params", lambda *_: [described]) + + derived = derive_params( + "llmwhisperer", "extract", client_method=LLMWhispererClientV2.whisper + ) + assert derived[0].description == "From the spec." + assert click_option(derived[0], {}).help.startswith("From the spec.") + assert click_option(derived[0], {"lang": {"help": "From the overlay."}}).help == ( + "From the overlay." + ) + + def test_a_multi_line_description_is_joined(): text = docstring_params(LLMWhispererClientV2.whisper)["word_confidence_threshold"] assert "\n" not in text and "confidence" in text From 4f80f52d869e8ad68f66a645f5c12b7682dc3579 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 22:19:52 +0530 Subject: [PATCH 15/86] fix: take allowed values from the spec, not from a copy of them The vendored LLMWhisperer spec was several revisions behind and now declares enums the CLI was hand-listing. The two had already diverged: --mode rejected three modes the service accepts and --output-mode two, and nothing would have reported it. Read the enum off the spec, keep the overlay for narrowing one on purpose, and drop the descriptions' own value lists for the same reason their default sentences are dropped. `highlights` gains a `mode` query parameter that the published client has no argument for, so it joins the parameters the CLI cannot reach. --- src/unstract_cli/core/params.py | 18 +- src/unstract_cli/overlay.toml | 15 +- src/unstract_cli/specs/llmwhisperer.json | 1004 ++++++++++++++++++++-- tests/test_contract.py | 11 +- tests/test_discover.py | 3 + tests/test_params.py | 12 +- 6 files changed, 951 insertions(+), 112 deletions(-) diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index ec11af9..59f8a61 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -77,6 +77,7 @@ class Param: array: bool = False nullable: bool = False required: bool = False + choices: tuple[str, ...] = () @property def flag(self) -> str: @@ -114,6 +115,7 @@ def _from_schema( array=array, nullable=nullable, required=required, + choices=tuple(schema.get("enum") or ()), ) @@ -213,6 +215,10 @@ def _from_signature(param: Param, signature: inspect.Parameter) -> Param: #: docstring, which is how both clients document their parameters. _ARG_LINE = re.compile(r"^\s*(\w+)\s*(\([^)]*\))?\s*:\s*(.*)$") +#: Sentences a description restates from elsewhere. The value list is matched on +#: its opening quote so prose that merely says "can be" is left alone. +_RESTATED = re.compile(r'(?:\s*(?:Defaults to [^.]*\.|Can be "[^.]*\.))+\s*$') + def docstring_params(method: Callable[..., Any]) -> dict[str, str]: """Parameter descriptions from a client method's own docstring. @@ -241,11 +247,11 @@ def docstring_params(method: Callable[..., Any]) -> dict[str, str]: out[current] = match.group(3).strip() elif current: out[current] = f"{out[current]} {line.strip()}".strip() - # The default is rendered from the signature, so the docstring's own - # "Defaults to X." sentence would print it a second time, and disagree with - # it whenever the two drift. + # The default and the allowed values are both rendered from the spec and the + # signature, so the docstring's own sentences for them would print a second + # copy that disagrees the moment either drifts. return { - name: re.sub(r"\s*Defaults to .*\.\s*$", "", " ".join(text.split())) + name: _RESTATED.sub("", " ".join(text.split())).strip() for name, text in out.items() if text } @@ -281,7 +287,9 @@ def _help_text(param: Param, choices: tuple[str, ...]) -> str: def click_option(param: Param, spec_overlay: dict[str, Any]) -> click.Option: """Build one Click option from a spec parameter and its overlay entry.""" entry = spec_overlay.get(param.name, {}) - choices = tuple(entry.get("choices", ())) + # Falling back to the spec's own enum, so a hand-written list is needed only + # to narrow one on purpose -- a copy of it goes stale as the service grows. + choices = tuple(entry.get("choices", ())) or param.choices help_text = entry.get("help") or _help_text(param, choices) short = entry.get("short") diff --git a/src/unstract_cli/overlay.toml b/src/unstract_cli/overlay.toml index e9f871b..f563cac 100644 --- a/src/unstract_cli/overlay.toml +++ b/src/unstract_cli/overlay.toml @@ -1,12 +1,7 @@ # Per-flag overrides for spec-derived options: [..]. # -# Only what the spec cannot express belongs here. Names, types and defaults are -# read from the spec, and help text falls back to the published client's own -# docstring, so an entry is needed only to constrain values, add a short flag, -# hide a parameter the CLI owns, or reword help the client states poorly. - -[llmwhisperer.extract.mode] -choices = ["form", "high_quality", "low_cost", "native_text", "table"] - -[llmwhisperer.extract.output_mode] -choices = ["layout_preserving", "text"] +# Only what the spec cannot express belongs here. Names, types, defaults and +# allowed values are read from the spec, and help text falls back to the +# published client's own docstring, so an entry is needed only to add a short +# flag, narrow a value list on purpose, hide a parameter the CLI owns, or reword +# help the client states poorly. diff --git a/src/unstract_cli/specs/llmwhisperer.json b/src/unstract_cli/specs/llmwhisperer.json index d5ae488..8cc8dfe 100644 --- a/src/unstract_cli/specs/llmwhisperer.json +++ b/src/unstract_cli/specs/llmwhisperer.json @@ -1,6 +1,14 @@ { "components": { "schemas": { + "Error": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + }, "WebhookConfig": { "properties": { "auth_token": { @@ -44,6 +52,13 @@ }, "type": "array" }, + "line_metadata": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, "metadata": { "additionalProperties": true, "type": "object" @@ -53,12 +68,23 @@ }, "webhook_metadata": { "type": "string" + }, + "whisper_metadata": { + "additionalProperties": true, + "type": "object" } }, "type": "object" }, "WhisperStatus": { "properties": { + "detail": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, "message": { "type": "string" }, @@ -78,6 +104,7 @@ } }, "info": { + "description": "The hosted regions are listed under `servers`; a self-hosted deployment serves the same API from its own URL, which every client takes as a configuration option.", "title": "Unstract LLMWhisperer", "version": "v2" }, @@ -87,13 +114,20 @@ "post": { "operationId": "convert_to_pdf", "parameters": [ + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, { "in": "query", "name": "url", "required": false, "schema": { - "default": "", - "format": "uri", "type": "string" } }, @@ -116,19 +150,59 @@ } } }, - "required": true + "required": false }, "responses": { "200": { "content": { - "application/json": { + "application/pdf": { "schema": { - "additionalProperties": true, - "type": "object" + "format": "binary", + "type": "string" } } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Convert a document to PDF", @@ -141,13 +215,20 @@ "post": { "operationId": "convert_xlsb_to_xlsx", "parameters": [ + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, { "in": "query", "name": "url", "required": false, "schema": { - "default": "", - "format": "uri", "type": "string" } }, @@ -170,19 +251,59 @@ } } }, - "required": true + "required": false }, "responses": { "200": { "content": { - "application/json": { + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { "schema": { - "additionalProperties": true, - "type": "object" + "format": "binary", + "type": "string" } } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Convert an XLSB workbook to XLSX", @@ -204,12 +325,20 @@ "type": "string" } }, + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, { "in": "query", "name": "pages_to_extract", "required": false, "schema": { - "default": "", "type": "string" } }, @@ -227,8 +356,6 @@ "name": "url", "required": false, "schema": { - "default": "", - "format": "uri", "type": "string" } }, @@ -246,7 +373,6 @@ "name": "use_webhook", "required": false, "schema": { - "default": "", "type": "string" } }, @@ -255,7 +381,6 @@ "name": "webhook_metadata", "required": false, "schema": { - "default": "", "type": "string" } } @@ -269,19 +394,58 @@ } } }, - "required": true + "required": false }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/WhisperAccepted" } } }, - "description": "OK" + "description": "Accepted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Run document insights over a file", @@ -297,9 +461,8 @@ { "in": "query", "name": "whisper_hash", - "required": false, + "required": true, "schema": { - "default": "", "type": "string" } } @@ -315,9 +478,49 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, - "summary": "Retrieve document insights result", + "summary": "Retrieve document insights result (destructive \u2014 one shot)", "tags": [ "insights" ] @@ -338,6 +541,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Subscription usage summary", @@ -355,25 +598,33 @@ "name": "extract_all_lines", "required": false, "schema": { - "default": "false", - "type": "string" + "default": false, + "type": "boolean" } }, { + "description": "Line numbers or ranges, e.g. `1-5,9`. Not required when `extract_all_lines=true`.", "in": "query", "name": "lines", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "mode", "required": false, "schema": { - "default": "", + "default": "form", "type": "string" } }, { "in": "query", "name": "whisper_hash", - "required": false, + "required": true, "schema": { - "default": "", "type": "string" } } @@ -389,6 +640,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Line-level highlight geometry for an extraction", @@ -419,6 +710,15 @@ "type": "string" } }, + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, { "in": "query", "name": "tag", @@ -433,8 +733,6 @@ "name": "url", "required": false, "schema": { - "default": "", - "format": "uri", "type": "string" } }, @@ -448,22 +746,72 @@ } } ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": false + }, "responses": { - "200": { + "202": { "content": { "application/json": { "schema": { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/WhisperAccepted" } } }, - "description": "OK" + "description": "Accepted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, - "summary": "pdf to images", + "summary": "Render a PDF's pages as images", "tags": [ - "whisper" + "convert" ] } }, @@ -474,9 +822,8 @@ { "in": "query", "name": "whisper_hash", - "required": false, + "required": true, "schema": { - "default": "", "type": "string" } } @@ -484,19 +831,59 @@ "responses": { "200": { "content": { - "application/json": { + "application/zip": { "schema": { - "additionalProperties": true, - "type": "object" + "format": "binary", + "type": "string" } } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, - "summary": "pdf to images retrieve", + "summary": "Retrieve rendered images as a zip (destructive \u2014 one shot)", "tags": [ - "whisper" + "convert" ] } }, @@ -507,9 +894,8 @@ { "in": "query", "name": "whisper_hash", - "required": false, + "required": true, "schema": { - "default": "", "type": "string" } } @@ -525,11 +911,51 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, - "summary": "pdf to images status", + "summary": "Poll PDF-to-images status", "tags": [ - "whisper" + "convert" ] } }, @@ -548,6 +974,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Verify credentials", @@ -571,7 +1037,7 @@ { "in": "query", "name": "tag", - "required": false, + "required": true, "schema": { "type": "string" } @@ -596,6 +1062,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Detailed usage statistics", @@ -704,6 +1210,11 @@ "required": false, "schema": { "default": "left-priority", + "enum": [ + "left-priority", + "mid-priority", + "right-priority" + ], "type": "string" } }, @@ -758,6 +1269,16 @@ "required": false, "schema": { "default": "form", + "enum": [ + "document_insights", + "excel", + "form", + "high_quality", + "low_cost", + "native_text", + "pdf_to_images", + "table" + ], "type": "string" } }, @@ -767,6 +1288,12 @@ "required": false, "schema": { "default": "layout_preserving", + "enum": [ + "dump-text", + "layout_preserving", + "line-printer", + "text" + ], "type": "string" } }, @@ -775,6 +1302,7 @@ "name": "page_separator", "required": false, "schema": { + "default": "<<<", "type": "string" } }, @@ -783,7 +1311,6 @@ "name": "pages_to_extract", "required": false, "schema": { - "default": "", "type": "string" } }, @@ -797,16 +1324,17 @@ } }, { + "description": "Fetch the document from this URL instead of sending a body.", "in": "query", "name": "url", "required": false, "schema": { - "default": "", "format": "uri", "type": "string" } }, { + "description": "Read the URL to fetch from the request body.", "in": "query", "name": "url_in_post", "required": false, @@ -820,7 +1348,6 @@ "name": "use_webhook", "required": false, "schema": { - "default": "", "type": "string" } }, @@ -838,7 +1365,6 @@ "name": "webhook_metadata", "required": false, "schema": { - "default": "", "type": "string" } }, @@ -860,7 +1386,7 @@ } } }, - "required": true + "required": false }, "responses": { "202": { @@ -872,6 +1398,46 @@ } }, "description": "Accepted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Submit a document for text extraction", @@ -887,9 +1453,8 @@ { "in": "query", "name": "whisper_hash", - "required": false, + "required": true, "schema": { - "default": "", "type": "string" } } @@ -905,6 +1470,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Metadata about a whisper job", @@ -920,9 +1525,8 @@ { "in": "query", "name": "webhook_name", - "required": false, + "required": true, "schema": { - "default": "", "type": "string" } } @@ -938,6 +1542,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Manage extraction webhooks", @@ -951,9 +1595,8 @@ { "in": "query", "name": "webhook_name", - "required": false, + "required": true, "schema": { - "default": "", "type": "string" } } @@ -969,6 +1612,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Manage extraction webhooks", @@ -978,17 +1661,7 @@ }, "post": { "operationId": "webhook_post", - "parameters": [ - { - "in": "query", - "name": "webhook_name", - "required": false, - "schema": { - "default": "", - "type": "string" - } - } - ], + "parameters": [], "requestBody": { "content": { "application/json": { @@ -1000,7 +1673,7 @@ "required": true }, "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { @@ -1009,7 +1682,47 @@ } } }, - "description": "OK" + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Manage extraction webhooks", @@ -1019,17 +1732,7 @@ }, "put": { "operationId": "webhook_put", - "parameters": [ - { - "in": "query", - "name": "webhook_name", - "required": false, - "schema": { - "default": "", - "type": "string" - } - } - ], + "parameters": [], "requestBody": { "content": { "application/json": { @@ -1051,6 +1754,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Manage extraction webhooks", @@ -1075,9 +1818,8 @@ { "in": "query", "name": "whisper_hash", - "required": false, + "required": true, "schema": { - "default": "", "type": "string" } } @@ -1097,6 +1839,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Retrieve extraction result (destructive \u2014 one shot)", @@ -1112,9 +1894,8 @@ { "in": "query", "name": "whisper_hash", - "required": false, + "required": true, "schema": { - "default": "", "type": "string" } } @@ -1129,6 +1910,46 @@ } }, "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." } }, "summary": "Poll extraction status", @@ -1145,7 +1966,12 @@ ], "servers": [ { + "description": "US region (the default of the published clients).", "url": "https://llmwhisperer-api.us-central.unstract.com" + }, + { + "description": "EU region.", + "url": "https://llmwhisperer-api.eu-west.unstract.com" } ] } diff --git a/tests/test_contract.py b/tests/test_contract.py index 4b90ee7..cdc7577 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -18,10 +18,13 @@ from unstract_cli.core.params import derive_params, operation_params #: (product, operationId, client method) per command that derives its flags, -#: with the spec parameters that method cannot accept. Each one is a parameter -#: the client owns rather than one it lacks: `url_in_post` says the URL is in -#: the body, which the client decides; `files` is built from the paths given; +#: with the spec parameters that method cannot accept. Most are a parameter the +#: client owns rather than one it lacks: `url_in_post` says the URL is in the +#: body, which the client decides; `files` is built from the paths given; #: `execution_id` is read out of the endpoint URL the server handed back. +#: `highlights.mode` is the exception -- the endpoint reads it for quota +#: accounting and the published client has no argument for it, so the CLI cannot +#: offer it without the call failing. COMMANDS = [ ( "llmwhisperer", @@ -29,7 +32,7 @@ LLMWhispererClientV2.whisper, {"url_in_post"}, ), - ("llmwhisperer", "highlights", LLMWhispererClientV2.get_highlight_data, set()), + ("llmwhisperer", "highlights", LLMWhispererClientV2.get_highlight_data, {"mode"}), ("docstudio", "execute", APIDeploymentsClient.structure_file, {"files"}), ( "docstudio", diff --git a/tests/test_discover.py b/tests/test_discover.py index 29bb286..3f59845 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -47,10 +47,13 @@ def test_full_carries_enough_to_build_a_call(capsys): assert params["source"]["kind"] == "argument" and params["source"]["required"] assert params["mode"]["choices"] == [ + "document_insights", + "excel", "form", "high_quality", "low_cost", "native_text", + "pdf_to_images", "table", ] assert params["wait"]["flags"] == ["--wait", "--no-wait"] diff --git a/tests/test_params.py b/tests/test_params.py index 437b986..4f68bfc 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -202,10 +202,14 @@ def test_no_option_carries_a_value_by_default(): assert click_option(param, {}).default is None -def test_choices_come_from_the_overlay(): - """The specs declare no enums, so allowed values can only come from the - overlay -- and a wrong value must fail before the request, not after.""" - option = click_option(Param("mode"), {"mode": {"choices": ["form", "table"]}}) +def test_choices_come_from_the_spec_unless_the_overlay_narrows_them(): + """A wrong value must fail before the request, not after -- and the list it + is checked against is the service's own, not a copy that can fall behind.""" + spec_declared = _by_name(operation_params("llmwhisperer", "extract"))["mode"] + assert "excel" in spec_declared.choices + assert click_option(spec_declared, {}).type.choices == spec_declared.choices + + option = click_option(spec_declared, {"mode": {"choices": ["form", "table"]}}) assert isinstance(option.type, click.Choice) assert option.type.choices == ("form", "table") From 5229320a6e0d51b8e50601bd58cefd034718bde5 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 22:22:31 +0530 Subject: [PATCH 16/86] fix: strip a restated default that contains a period A sentence-shaped match ends at the first period, so "Defaults to 0.3." was left in the help beside the default rendered from the signature. Strip each restated sentence with its own end-anchored pass instead. --- src/unstract_cli/core/params.py | 24 ++++++++++++++++-------- tests/test_params.py | 11 ++++++++--- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index 59f8a61..e2d7096 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -215,9 +215,14 @@ def _from_signature(param: Param, signature: inspect.Parameter) -> Param: #: docstring, which is how both clients document their parameters. _ARG_LINE = re.compile(r"^\s*(\w+)\s*(\([^)]*\))?\s*:\s*(.*)$") -#: Sentences a description restates from elsewhere. The value list is matched on -#: its opening quote so prose that merely says "can be" is left alone. -_RESTATED = re.compile(r'(?:\s*(?:Defaults to [^.]*\.|Can be "[^.]*\.))+\s*$') +#: Sentences a description restates from elsewhere, stripped in this order: +#: each is anchored at the end, and the default sentence follows the value list +#: where a description carries both. The list is matched on its opening quote so +#: prose that merely says "can be" is left alone. +_RESTATED = ( + re.compile(r"\s*Defaults to .*\.\s*$"), + re.compile(r'\s*Can be ".*\.\s*$'), +) def docstring_params(method: Callable[..., Any]) -> dict[str, str]: @@ -250,11 +255,14 @@ def docstring_params(method: Callable[..., Any]) -> dict[str, str]: # The default and the allowed values are both rendered from the spec and the # signature, so the docstring's own sentences for them would print a second # copy that disagrees the moment either drifts. - return { - name: _RESTATED.sub("", " ".join(text.split())).strip() - for name, text in out.items() - if text - } + return {name: _strip_restated(text) for name, text in out.items() if text} + + +def _strip_restated(text: str) -> str: + text = " ".join(text.split()) + for pattern in _RESTATED: + text = pattern.sub("", text) + return text.strip() def _resolve_ref(product: str, ref: str) -> dict[str, Any]: diff --git a/tests/test_params.py b/tests/test_params.py index 4f68bfc..47911bc 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -145,11 +145,16 @@ def test_help_comes_from_the_clients_docstring(): assert "language" in derived["lang"].description.lower() -def test_the_docstrings_own_default_sentence_is_dropped(): - """The default is rendered from the signature; printing the docstring's copy - too would show it twice and disagree the moment the two drift.""" +def test_the_docstrings_own_restated_sentences_are_dropped(): + """The default and the allowed values are rendered from the signature and the + spec; printing the docstring's copies too shows each twice and disagrees the + moment either drifts.""" described = docstring_params(LLMWhispererClientV2.whisper) assert not described["lang"].endswith('Defaults to "eng".') + # A default that itself contains a period, which is where a sentence-shaped + # match stops early and leaves half of it behind. + assert not described["checkbox_confidence_threshold"].endswith("Defaults to 0.3.") + assert described["mode"] == "The processing mode." assert described["tag"] == "The tag for the document." From 6ac0646d60e1d360c961f65d6259b8ede7564445 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 22:23:38 +0530 Subject: [PATCH 17/86] build: move the client pins to the heads the specs were taken from The pinned clients predated the fix that stops an omitted optional parameter being sent as the string "None", so a CLI built on them sent it. The derived surface is byte-identical across the move; neither signature changed. --- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e6d3ed6..107254b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,8 @@ dependencies = [ # these clients are generated from, and reads their docstrings for help # text, so a client that moves underneath it changes the CLI's surface. # Both pins move to released versions before this ships. - "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@ed89066", - "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@02485e1", + "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@0882b45", + "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@ef5e5af", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index f7d3c62..e759bab 100644 --- a/uv.lock +++ b/uv.lock @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "llmwhisperer-client" version = "2.7.0" -source = { git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=02485e1#02485e1e108b854f5379f4b64aa129e071952022" } +source = { git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=ef5e5af#ef5e5af854f2e986456d977698ef913f2eb8ca8c" } dependencies = [ { name = "attrs" }, { name = "httpx" }, @@ -345,18 +345,18 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1,<9" }, - { name = "llmwhisperer-client", git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=02485e1" }, + { name = "llmwhisperer-client", git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=ef5e5af" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "tomli-w", specifier = ">=1.0" }, - { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=ed89066" }, + { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=0882b45" }, ] provides-extras = ["dev"] [[package]] name = "unstract-client" version = "1.5.3" -source = { git = "https://github.com/Zipstack/unstract-python-client?rev=ed89066#ed89066086748f7576887ed5d06dea40e9ac27d7" } +source = { git = "https://github.com/Zipstack/unstract-python-client?rev=0882b45#0882b4568be360bbb8ad047a2acb7487a4c77833" } dependencies = [ { name = "attrs" }, { name = "click" }, From bc9b255a0637b09ea0c34893c26df8b8331a4ac7 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 22:29:39 +0530 Subject: [PATCH 18/86] docs: trim comments that narrate rather than explain Each of these restated the line below it, or described a prior state that is no longer there to check against. Keep the reason, drop the narration. --- src/unstract_cli/core/errors.py | 9 ++++----- src/unstract_cli/core/params.py | 21 +++++++++------------ src/unstract_cli/core/poll.py | 4 ++-- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index ae40ba8..772d5db 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -70,8 +70,8 @@ def exit_code_for_status(status: int) -> ExitCode: return code if 500 <= status < 600: return ExitCode.SERVER_ERROR - # Anything else -- a 3xx that was not followed, a status no spec declares -- - # is still a failure. Returning SUCCESS here printed `ok: false` and exited 0. + # A 3xx that was not followed, or a status no spec declares, is still a + # failure: never fall through to SUCCESS. return ExitCode.GENERIC @@ -94,9 +94,8 @@ def is_retryable(status: int) -> bool: _SECRET_KEY_HINTS = ("key", "token", "secret", "password", "credential", "auth") REDACTED = "***REDACTED***" -#: Credentials resolved during this run. Scrubbing used to be a keyword -#: argument every emitter had to remember to pass, and the error path never -#: did; registering the value where it is resolved makes forgetting impossible. +#: Credentials resolved during this run. Registered where they are resolved, so +#: no emitter has to remember to opt into scrubbing. _KNOWN_SECRETS: set[str] = set() diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index e2d7096..7b141a9 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -174,10 +174,9 @@ def client_params(method: Callable[..., Any]) -> dict[str, inspect.Parameter]: } -#: Python annotation -> OpenAPI type. The clients are generated from the same -#: specs, but a source-derived spec can only report what the endpoint reads off -#: the wire -- `extract_all_lines` is `"false"`, a string, there and a `bool` in -#: the signature. The signature is what the call actually takes. +#: Python annotation -> OpenAPI type. A source-derived spec reports what the +#: endpoint reads off the wire, which can differ from what the call takes: +#: `extract_all_lines` is a string there and a `bool` in the signature. _ANNOTATIONS: dict[Any, str] = { bool: "boolean", int: "integer", @@ -215,10 +214,9 @@ def _from_signature(param: Param, signature: inspect.Parameter) -> Param: #: docstring, which is how both clients document their parameters. _ARG_LINE = re.compile(r"^\s*(\w+)\s*(\([^)]*\))?\s*:\s*(.*)$") -#: Sentences a description restates from elsewhere, stripped in this order: -#: each is anchored at the end, and the default sentence follows the value list -#: where a description carries both. The list is matched on its opening quote so -#: prose that merely says "can be" is left alone. +#: Sentences a description restates from elsewhere. Each is anchored at the end, +#: so they are stripped in the order a description carries them. The value list +#: is matched on its opening quote, leaving prose that says "can be" alone. _RESTATED = ( re.compile(r"\s*Defaults to .*\.\s*$"), re.compile(r'\s*Can be ".*\.\s*$'), @@ -244,7 +242,6 @@ def docstring_params(method: Callable[..., Any]) -> dict[str, str]: for line in args.splitlines(): if not line.strip(): continue - # A new top-level section (Returns:, Raises:) ends the parameter list. if line[:1] not in " \t" or re.match(r"^\s{0,4}(Returns|Raises|Yields):", line): break if (match := _ARG_LINE.match(line)) and (match.group(2) or current is None): @@ -252,9 +249,9 @@ def docstring_params(method: Callable[..., Any]) -> dict[str, str]: out[current] = match.group(3).strip() elif current: out[current] = f"{out[current]} {line.strip()}".strip() - # The default and the allowed values are both rendered from the spec and the - # signature, so the docstring's own sentences for them would print a second - # copy that disagrees the moment either drifts. + # The default and the allowed values are rendered from the signature and the + # spec, so the docstring's own sentences for them are a second copy that + # disagrees the moment either drifts. return {name: _strip_restated(text) for name, text in out.items() if text} diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index cf324a3..34042f4 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -148,8 +148,8 @@ def classify(payload: Any, spec: PollSpec) -> str: if status in {state.lower() for state in spec.terminal_success}: return "success" if not status or _dig(payload, "error"): - # An empty status, or a body carrying an error, is not progress. Polling - # on regardless is what turned a server fault into "still running". + # Not progress: polling on regardless reports a server fault as "still + # running" until the deadline. return "unknown" return "pending" From a974329c921675db01410d4f03f0d794b8bce494 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 22:46:40 +0530 Subject: [PATCH 19/86] feat: add the `clone` command Copies one organization's resources into another by calling the client's orchestrator directly. Two endpoints with a key each, which no single profile describes, so both are flags and both keys come from the environment. Also moves the client pin forward to pick up the status path-prefix fix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 6 +- RUNBOOK.md | 12 +- pyproject.toml | 2 +- src/unstract_cli/app.py | 2 +- src/unstract_cli/commands/clone_cmd.py | 199 +++++++++++++++++++++++++ tests/test_commands.py | 57 ++++++- tests/test_discover.py | 7 +- uv.lock | 4 +- 8 files changed, 276 insertions(+), 13 deletions(-) create mode 100644 src/unstract_cli/commands/clone_cmd.py diff --git a/README.md b/README.md index bf56ab4..5f8c26f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `unstract` — one CLI for the Unstract suite: extract a document with LLMWhisperer, run it through a Document Studio API deployment, get structured -JSON back. +JSON back. It also clones one organization's resources into another. ```bash pipx install git+https://github.com/Zipstack/unstract-cli @@ -80,6 +80,10 @@ lives rather than the secret itself. `unstract config doctor` reports where each setting resolved from — including whether an `env:` reference is actually set in the current process — without echoing any value. +`clone` is the exception: it talks to two deployments at once, which no single +profile describes, so it takes both endpoints as flags and both admin Platform +keys from `UNSTRACT_SRC_PLATFORM_KEY` / `UNSTRACT_TGT_PLATFORM_KEY`. + ## Development ```bash diff --git a/RUNBOOK.md b/RUNBOOK.md index 37c1e1c..aadaa4c 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -24,16 +24,15 @@ pipx install "git+https://github.com/Zipstack/unstract-cli@" clients are pinned to exact commits, and a shared environment would let another package's resolver move them. -### Name collision - -`unstract-client` also installs a console script called `unstract`. In an -environment holding both, whichever was installed last owns the name. Two ways -out, in order of preference: +### Other names for the same CLI - `unstract-cli` — a second console script this package always owns. - `python -m unstract_cli` — works from a source checkout with no install at all. -Check which one you actually have before filing a bug about a missing command: +`unstract-client` released before this CLI installed a console script called +`unstract` too. An environment that still holds one of those versions gives the +name to whichever package was installed last, so check what answers before +filing a bug about a missing command: ```bash command -v unstract && unstract --version @@ -114,6 +113,7 @@ Run against a document you can re-send; several of these submit real work. | 10 | any command with `-o json` and a wrong key | exit 3, JSON envelope on stdout, no traceback | | 11 | any command with `-o json` and a path that does not exist | exit 2, JSON envelope on stdout | | 12 | any command with no `-o` | a table, in a terminal and through a pipe alike | +| 13 | `clone --source-url ... --target-url ... --dry-run` | the plan is reported and nothing is written to the target | Two properties matter more than any single row, because they are what a caller depends on and what breaks quietly: diff --git a/pyproject.toml b/pyproject.toml index 107254b..23feb6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ # these clients are generated from, and reads their docstrings for help # text, so a client that moves underneath it changes the CLI's surface. # Both pins move to released versions before this ships. - "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@0882b45", + "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@54f09f4", "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@ef5e5af", ] diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 3fd1bfd..1a723a4 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -233,7 +233,7 @@ def deployment_group() -> None: # Imported for their side effect of registering commands, and imported last # because those modules hang their commands off the groups declared just above. -from unstract_cli.commands import docstudio_cmd, whisper_cmd # noqa: E402,F401 +from unstract_cli.commands import clone_cmd, docstudio_cmd, whisper_cmd # noqa: E402,F401 def command_tree() -> dict[str, Any]: diff --git a/src/unstract_cli/commands/clone_cmd.py b/src/unstract_cli/commands/clone_cmd.py new file mode 100644 index 0000000..12085cc --- /dev/null +++ b/src/unstract_cli/commands/clone_cmd.py @@ -0,0 +1,199 @@ +"""`unstract clone` -- copying one organization's resources into another. + +Two endpoints, each with its own key, so this command takes them as flags rather +than from a profile: a profile describes one connection. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import click + +# The size grammar and the list syntax come from the client rather than a copy +# here, so both spellings of this command accept the same strings. +from unstract.clone.cli import _parse_size, _split_csv +from unstract.clone.context import ( + DEFAULT_CONCURRENCY, + CloneOptions, + OrgEndpoint, +) +from unstract.clone.exceptions import CloneError +from unstract.clone.orchestrator import clone as run_clone +from unstract.clone.report import CloneReport + +from unstract_cli.app import Context, cli, pass_context +from unstract_cli.commands.common import finish +from unstract_cli.core.errors import ( + CLIError, + ExitCode, + known_secrets, + remember_secret, + scrub, +) +from unstract_cli.core.output import OutputFormat + + +@cli.command("clone") +@click.option("--source-url", required=True, help="Base URL of the source deployment.") +@click.option( + "--source-org", required=True, help="Source organization_id (slug in the URL path)." +) +@click.option( + "--source-key", + envvar="UNSTRACT_SRC_PLATFORM_KEY", + required=True, + help="Source admin's Platform API key (or env UNSTRACT_SRC_PLATFORM_KEY).", +) +@click.option("--target-url", required=True, help="Base URL of the target deployment.") +@click.option( + "--target-org", required=True, help="Target organization_id (slug in the URL path)." +) +@click.option( + "--target-key", + envvar="UNSTRACT_TGT_PLATFORM_KEY", + required=True, + help="Target admin's Platform API key (or env UNSTRACT_TGT_PLATFORM_KEY).", +) +@click.option( + "--dry-run", is_flag=True, help="Plan only -- do not write anything to the target." +) +@click.option( + "--include", default=None, help="Comma-separated phases to run (default: all)." +) +@click.option("--exclude", default=None, help="Comma-separated phases to skip.") +@click.option( + "--on-name-conflict", + type=click.Choice(["adopt", "abort"]), + default="adopt", + show_default=True, + help="What to do when a like-named entity exists on the target.", +) +@click.option( + "--api-prefix", + default="api/v1", + show_default=True, + help="Backend URL prefix, matching the deployment's own.", +) +@click.option( + "--file-strategy", + type=click.Choice(["platform_api", "skip"]), + default="platform_api", + show_default=True, + help="How to move Prompt Studio documents. 'skip' copies metadata only.", +) +@click.option("--skip-files", is_flag=True, help="Alias for --file-strategy=skip.") +@click.option( + "--max-file-size", + default="25MB", + show_default=True, + help="Per-file cap for the files phase. Oversize files are reported, not fatal.", +) +@click.option( + "--concurrency", + type=click.IntRange(min=1, max=32), + default=DEFAULT_CONCURRENCY, + show_default=True, + help="Per-phase worker count. 1 is strictly sequential.", +) +@click.option( + "--clone-group-members", + is_flag=True, + help="Also add group members on the target, matched by email.", +) +@pass_context +def clone( + ctx: Context, + source_url: str, + source_org: str, + source_key: str, + target_url: str, + target_org: str, + target_key: str, + **params: Any, +) -> None: + """Copy an organization's resources into another organization. + + Adapters, connectors, workflows, pipelines, API deployments, Prompt Studio + projects and their files, user groups and sharing state. Run --dry-run first: + it reports what would be written without writing it. + """ + for key in (source_key, target_key): + remember_secret(key) + _configure_logging(ctx) + + options = CloneOptions( + dry_run=params["dry_run"], + include=_split_csv(params["include"]), + exclude=_split_csv(params["exclude"]) or (), + on_name_conflict=params["on_name_conflict"], + verbose=ctx.verbosity > 0, + file_strategy="skip" if params["skip_files"] else params["file_strategy"], + max_file_size=_parse_size(params["max_file_size"]), + concurrency=params["concurrency"], + clone_group_members=params["clone_group_members"], + ) + + def endpoint(url: str, org: str, key: str) -> OrgEndpoint: + return OrgEndpoint( + base_url=url, + organization_id=org, + platform_key=key, + api_path_prefix=params["api_prefix"], + ) + + try: + report = run_clone( + endpoint(source_url, source_org, source_key), + endpoint(target_url, target_org, target_key), + options, + ) + except CloneError as exc: + raise CLIError( + str(exc), + ExitCode.USAGE, + hint="The clone could not start. Check the URLs, orgs and keys.", + ) from exc + + _finish(ctx, report) + + +def _configure_logging(ctx: Context) -> None: + """Send the orchestrator's progress to stderr, at the run's own verbosity.""" + logging.basicConfig( + level=logging.WARNING + if ctx.quiet + else (logging.DEBUG if ctx.verbosity else logging.INFO), + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + + +def _finish(ctx: Context, report: CloneReport) -> None: + """Emit the report, then fail if the clone did not fully succeed.""" + failure = None + if report.aborted: + failure = f"Clone aborted: {report.abort_reason}" + elif failed := [phase.name for phase in report.phases if phase.failed]: + failure = f"Clone completed with failures in: {', '.join(sorted(failed))}" + + # A person running this reads the report itself; every other format gets the + # single envelope, which carries the same content as data. + rendered = ctx.output is OutputFormat.TABLE + if rendered: + click.echo(scrub(report.render(), [*ctx.secrets(), *known_secrets()])) + elif not failure: + finish(ctx, report.as_dict()) + + if failure: + raise CLIError( + failure, + ExitCode.GENERIC, + details=None if rendered else report.as_dict(), + hint="The report lists what was copied and what was not. Re-running " + "adopts what already exists on the target rather than duplicating it.", + ) + + +__all__ = ["clone"] diff --git a/tests/test_commands.py b/tests/test_commands.py index 8c0c673..78b7c0f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -10,6 +10,7 @@ import json import pytest +from unstract.clone.report import CloneReport, Endpoint, PhaseResult from unstract.llmwhisperer.client_v2 import ( LLMWhispererClientException, LLMWhispererClientV2, @@ -17,7 +18,7 @@ from unstract_cli.__main__ import main from unstract_cli.app import command_tree -from unstract_cli.commands import docstudio_cmd, whisper_cmd +from unstract_cli.commands import clone_cmd, docstudio_cmd, whisper_cmd from unstract_cli.config import LLMWHISPERER from unstract_cli.core.errors import CLIError, ExitCode @@ -889,3 +890,57 @@ def test_the_key_never_reaches_stdout_or_stderr(capsys, whisper_client, monkeypa assert code == int(ExitCode.AUTH) assert key not in out assert key not in err + + +def test_clone_maps_its_flags_and_reports_a_partial_failure(capsys, monkeypatch): + """Migration flags decide what is copied where, with two admin keys in play.""" + captured: dict = {} + + def fake_clone(source, target, options): + captured.update(source=source, target=target, options=options) + return CloneReport( + source=Endpoint(source.base_url, source.organization_id), + target=Endpoint(target.base_url, target.organization_id), + phases=[PhaseResult(name="adapters", created=1, failed=2)], + ) + + monkeypatch.setattr(clone_cmd, "run_clone", fake_clone) + monkeypatch.setenv("UNSTRACT_SRC_PLATFORM_KEY", "src-key-0123456789") + monkeypatch.setenv("UNSTRACT_TGT_PLATFORM_KEY", "tgt-key-0123456789") + + code, out, err = run( + capsys, + "clone", + "--source-url", + "https://dev.example.com", + "--source-org", + "org_dev", + "--target-url", + "https://qa.example.com", + "--target-org", + "org_qa", + "--dry-run", + "--exclude", + "files, groups", + "--skip-files", + "--max-file-size", + "2MB", + "--api-prefix", + "api/v2", + ) + + assert captured["source"].platform_key == "src-key-0123456789" + assert captured["target"].organization_id == "org_qa" + assert captured["target"].api_path_prefix == "api/v2" + assert captured["options"].dry_run is True + assert captured["options"].exclude == ("files", "groups") + assert captured["options"].file_strategy == "skip" + assert captured["options"].max_file_size == 2 * 1024 * 1024 + + # A phase that failed is not a successful migration, whatever else worked. + assert code == int(ExitCode.GENERIC) + body = envelope(out) + assert body["ok"] is False + assert "adapters" in body["error"]["message"] + for key in ("src-key-0123456789", "tgt-key-0123456789"): + assert key not in out and key not in err diff --git a/tests/test_discover.py b/tests/test_discover.py index 3f59845..09d4e0f 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -27,7 +27,12 @@ def test_groups_names_the_products_and_stops_there(capsys): """The cheap question stays cheap: no command list, no flags.""" code, data = run(capsys, "--discover", "groups") assert code == int(ExitCode.SUCCESS) - assert {g["name"] for g in data["groups"]} == {"config", "docstudio", "whisper"} + assert {g["name"] for g in data["groups"]} == { + "clone", + "config", + "docstudio", + "whisper", + } assert all(g["help"] for g in data["groups"]) assert "commands" not in data diff --git a/uv.lock b/uv.lock index e759bab..d17f125 100644 --- a/uv.lock +++ b/uv.lock @@ -349,14 +349,14 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "tomli-w", specifier = ">=1.0" }, - { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=0882b45" }, + { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=54f09f4" }, ] provides-extras = ["dev"] [[package]] name = "unstract-client" version = "1.5.3" -source = { git = "https://github.com/Zipstack/unstract-python-client?rev=0882b45#0882b4568be360bbb8ad047a2acb7487a4c77833" } +source = { git = "https://github.com/Zipstack/unstract-python-client?rev=54f09f4#54f09f4ed0aa728d297b7d5f003f3dd48e1ce6a3" } dependencies = [ { name = "attrs" }, { name = "click" }, From d515a6f9021d848591eee0c005f265467781f167 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 23:24:54 +0530 Subject: [PATCH 20/86] fix: resync the docstudio spec and pin the flags it derives The vendored copy was several iterations behind the one the pinned client is generated from, so the CLI's help, its parameter set and what --discover publishes all described an older service contract. The flag snapshot is the check that makes a resync safe: every other contract assertion reads the spec on both sides of its comparison, so a spec that loses a parameter loses the flag and the expectation with it. --- src/unstract_cli/specs/README.md | 4 + src/unstract_cli/specs/docstudio.json | 221 +++++++++++++++----------- tests/derived_flags.json | 53 ++++++ tests/test_contract.py | 35 ++++ 4 files changed, 221 insertions(+), 92 deletions(-) create mode 100644 tests/derived_flags.json diff --git a/src/unstract_cli/specs/README.md b/src/unstract_cli/specs/README.md index d655cb3..583b66f 100644 --- a/src/unstract_cli/specs/README.md +++ b/src/unstract_cli/specs/README.md @@ -14,3 +14,7 @@ Refresh one by copying it from the client commit pinned in `pyproject.toml`. Refreshing it against a different commit is what `tests/test_contract.py` guards: a spec parameter the pinned client has no argument for cannot become a flag, and that test names the ones that already cannot. + +A refresh that changes which flags a command offers fails against +`tests/derived_flags.json`. Read the difference before refreshing that file -- +a flag missing from it is a flag the CLI has stopped offering. diff --git a/src/unstract_cli/specs/docstudio.json b/src/unstract_cli/specs/docstudio.json index 424b30f..edf3196 100644 --- a/src/unstract_cli/specs/docstudio.json +++ b/src/unstract_cli/specs/docstudio.json @@ -13,7 +13,7 @@ "type": "object" }, "ExecuteRequest": { - "description": "Subclasses the real serializer so every backend param arrives free.", + "description": "The documents to run, and the options that shape the result.\n\nSupply `files`, `presigned_urls`, or both.", "properties": { "custom_data": { "nullable": true @@ -86,9 +86,9 @@ "type": "object" }, "ExecutionMessage": { + "description": "The execution's identity and, once it has finished, its per-file\nresults.", "properties": { "error": { - "nullable": true, "type": "string" }, "execution_id": { @@ -105,15 +105,14 @@ "type": "array" }, "status_api": { - "nullable": true, - "type": "string" - }, - "workflow_id": { "type": "string" } }, "required": [ - "execution_status" + "error", + "execution_id", + "execution_status", + "status_api" ], "type": "object" }, @@ -161,26 +160,22 @@ } }, "securitySchemes": { - "basicAuth": { - "scheme": "basic", + "deploymentKey": { + "description": "The API deployment's own key.", + "scheme": "bearer", "type": "http" - }, - "cookieAuth": { - "in": "cookie", - "name": "sessionid", - "type": "apiKey" } } }, "info": { - "title": "Unstract Document Studio", + "title": "Unstract API", "version": "v1" }, "openapi": "3.0.3", "paths": { "/deployment/api/{org_name}/{api_name}/": { "get": { - "description": "Poll the status of a previously started execution.", + "description": "Read the result of a previously started execution.\n\nThis read is one-shot: the first call that observes a completed execution acknowledges it and the stored result is discarded, so every later call for that execution answers 406. Poll while the execution is pending, and keep the payload of the call that returns it \u2014 it cannot be fetched again.", "operationId": "status", "parameters": [ { @@ -189,6 +184,7 @@ "name": "api_name", "required": true, "schema": { + "pattern": "^[\\w-]+$", "type": "string" } }, @@ -231,6 +227,7 @@ "name": "org_name", "required": true, "schema": { + "pattern": "^[\\w-]+$", "type": "string" } } @@ -246,6 +243,46 @@ }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The request failed validation." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The API key is not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No API key was supplied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No such active deployment." + }, "406": { "content": { "application/json": { @@ -254,7 +291,7 @@ } } }, - "description": "" + "description": "The result was already consumed by an earlier call." }, "422": { "content": { @@ -266,6 +303,16 @@ }, "description": "" }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too many concurrent executions; retry later." + }, "500": { "content": { "application/json": { @@ -279,10 +326,7 @@ }, "security": [ { - "cookieAuth": [] - }, - { - "basicAuth": [] + "deploymentKey": [] } ], "tags": [ @@ -290,7 +334,7 @@ ] }, "post": { - "description": "Execute an API deployment against one or more files.", + "description": "Execute an API deployment against one or more documents.\n\nSupply the documents either as `files` (multipart upload) or as `presigned_urls` (HTTPS S3 URLs), or both \u2014 a request carrying neither is rejected, and the two together may not exceed 32 documents.\n\nWith the default `timeout` of -1 the call returns as soon as the execution is queued; read the outcome from the status endpoint.", "operationId": "execute", "parameters": [ { @@ -299,6 +343,7 @@ "name": "api_name", "required": true, "schema": { + "pattern": "^[\\w-]+$", "type": "string" } }, @@ -308,6 +353,7 @@ "name": "org_name", "required": true, "schema": { + "pattern": "^[\\w-]+$", "type": "string" } } @@ -332,6 +378,56 @@ }, "description": "" }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The request failed validation." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The API key is not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No API key was supplied." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "No such active deployment." + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "The deployment has no active API key." + }, "422": { "content": { "application/json": { @@ -342,6 +438,16 @@ }, "description": "" }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Too many concurrent executions; retry later." + }, "500": { "content": { "application/json": { @@ -355,82 +461,13 @@ }, "security": [ { - "cookieAuth": [] - }, - { - "basicAuth": [] + "deploymentKey": [] } ], "tags": [ "deployment" ] } - }, - "/deployment/api/{org_name}/{api_name}/mcp/": { - "get": { - "description": "Refuse the SSE stream, but say who is here.\n\nUnder Streamable HTTP a client issues GET to open a server-to-client\nSSE stream, and a server that offers none must answer 405 (spec rev\n2025-06-18). Nothing here pushes messages \u2014 every tool call is\nrequest/response \u2014 so 405 is the honest answer, and returning\n``200 application/json`` instead would leave a conformant client\nparsing an identity document as an event stream.\n\nThe body is kept anyway: uptime checks and humans with curl probe this\npath, and a 405 may carry one. It stays deliberately free of tenant\ndetail \u2014 it reveals only that an MCP server is mounted here.\n\n``JsonResponse``, not DRF's ``Response``, for the same reason ``post``\nuses it: a DRF response runs content negotiation, so a client sending\n``Accept: text/html`` would be handed the browsable-API renderer.\n\nNo ``Allow`` header is set here. RFC 9110 asks for one on a 405, but a\nhandler cannot control it and pretending otherwise misleads a reader:\nDRF's ``finalize_response`` overwrites any handler-set value with\n``self.allowed_methods`` (``GET, POST, HEAD, OPTIONS``, since this view\ndefines both verbs), and ``RemoveAllowHeaderMiddleware`` \u2014 global in\n``MIDDLEWARE`` \u2014 then pops the header from every response before it\nleaves the process. So a client sees no ``Allow`` at all; a test driving\nthe view through ``APIRequestFactory`` bypasses that middleware and sees\nDRF's value.", - "operationId": "mcp_retrieve", - "parameters": [ - { - "in": "path", - "name": "api_name", - "required": true, - "schema": { - "pattern": "^[\\w-]+$", - "type": "string" - } - }, - { - "in": "path", - "name": "org_name", - "required": true, - "schema": { - "pattern": "^[\\w-]+$", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "No response body" - } - }, - "tags": [ - "mcp" - ] - }, - "post": { - "description": "Handle a single JSON-RPC request.", - "operationId": "mcp_create", - "parameters": [ - { - "in": "path", - "name": "api_name", - "required": true, - "schema": { - "pattern": "^[\\w-]+$", - "type": "string" - } - }, - { - "in": "path", - "name": "org_name", - "required": true, - "schema": { - "pattern": "^[\\w-]+$", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "No response body" - } - }, - "tags": [ - "mcp" - ] - } } }, "tags": [ diff --git a/tests/derived_flags.json b/tests/derived_flags.json new file mode 100644 index 0000000..6cc79f4 --- /dev/null +++ b/tests/derived_flags.json @@ -0,0 +1,53 @@ +{ + "llmwhisperer:extract": [ + "--add-line-nos", + "--allow-rotated-text", + "--checkbox-confidence-threshold", + "--derotate-threshold", + "--file-name", + "--gaussian-blur-radius", + "--horizontal-stretch-factor", + "--ignore-vertical-text", + "--include-line-confidence", + "--lang", + "--line-splitter-strategy", + "--line-splitter-tolerance", + "--mark-horizontal-lines", + "--mark-vertical-lines", + "--median-filter-size", + "--min-table-width", + "--mode", + "--output-mode", + "--page-separator", + "--pages-to-extract", + "--tag", + "--url", + "--use-webhook", + "--watermark-angle-threshold", + "--webhook-metadata", + "--word-confidence-threshold" + ], + "llmwhisperer:highlights": [ + "--extract-all-lines", + "--lines", + "--whisper-hash" + ], + "docstudio:execute": [ + "--custom-data", + "--hitl-packet-id", + "--hitl-queue-name", + "--include-extracted-text", + "--include-metadata", + "--include-metrics", + "--llm-profile-id", + "--presigned-urls", + "--tags", + "--timeout", + "--use-file-history" + ], + "docstudio:status": [ + "--include-extracted-text", + "--include-metadata", + "--include-metrics" + ] +} diff --git a/tests/test_contract.py b/tests/test_contract.py index cdc7577..3f565fc 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -10,6 +10,9 @@ from __future__ import annotations import inspect +import json +import os +from pathlib import Path import pytest from unstract.api_deployments.client import APIDeploymentsClient @@ -68,3 +71,35 @@ def test_every_derived_flag_is_an_argument_the_client_accepts(product, operation accepted = set(inspect.signature(method).parameters) for param in derive_params(product, operation, client_method=method): assert param.name in accepted + + +#: The flags the specs derive today. Every other check in this file reads the +#: spec on both sides of its comparison, so a spec that loses a parameter loses +#: the flag and the expectation together; this file is the side that does not +#: move on its own. +SNAPSHOT = Path(__file__).parent / "derived_flags.json" + +#: Refreshing the snapshot is a decision, not a side effect of running the suite. +REFRESH = "UNSTRACT_CLI_REFRESH_FLAG_SNAPSHOT" + + +def _derived_flags() -> dict[str, list[str]]: + return { + f"{product}:{operation}": sorted( + param.flag + for param in derive_params(product, operation, client_method=method) + ) + for product, operation, method, _ in COMMANDS + } + + +def test_the_derived_flags_are_the_ones_last_reviewed(): + current = _derived_flags() + if os.environ.get(REFRESH): + SNAPSHOT.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8") + expected = json.loads(SNAPSHOT.read_text(encoding="utf-8")) + assert current == expected, ( + "The flags derived from the vendored specs have changed. A flag that " + "disappears here disappears from the CLI. Review the difference, then " + f"refresh the snapshot with {REFRESH}=1." + ) From f919eb55d29d8efab6c5a368e8304f6f26e36742 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 23:27:25 +0530 Subject: [PATCH 21/86] fix: keep the job handle on any mid-poll failure, and fail a failed status A transport error was translated into a CLIError outside the poll loop, where the handle no longer exists, so the caller was left to resubmit a document the service had already processed and billed. Translating at the call keeps the loop's own context; the loop attaches the handle itself for anything the caller did not translate. `whisper status` reported a failed extraction as a success, its sibling in the other product having already been fixed: both read the body, not the status code. --- src/unstract_cli/commands/docstudio_cmd.py | 9 ++++-- src/unstract_cli/commands/whisper_cmd.py | 35 ++++++++++++++++++---- src/unstract_cli/core/clients.py | 20 ++++++++++++- src/unstract_cli/core/poll.py | 24 +++++++++++---- tests/test_commands.py | 18 +++++++++++ 5 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index 4f3afa5..79f2303 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -15,7 +15,12 @@ from unstract_cli.app import Context, deployment_group, pass_context from unstract_cli.commands.common import finish, raw_field, wait_options -from unstract_cli.core.clients import deployment, raise_for_result, translated +from unstract_cli.core.clients import ( + deployment, + raise_for_result, + translated, + translating, +) from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.params import requested, spec_options from unstract_cli.core.poll import PollSpec, classify, preflight, wait_for_completion @@ -124,7 +129,7 @@ def poll(endpoint: str) -> dict[str, Any]: raise_for_result(result, endpoint=client.api_url) return result - return poll + return translating(poll, client.api_url) @raw_field(RAW_FIELD) diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index d15f784..c848560 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -14,10 +14,17 @@ from unstract_cli.app import Context, pass_context, whisper_group from unstract_cli.commands.common import finish, raw_field, wait_options -from unstract_cli.core.clients import llmwhisperer, translated +from unstract_cli.core.clients import llmwhisperer, translated, translating from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.params import requested, spec_options -from unstract_cli.core.poll import PollSpec, persist, preflight, wait_for_completion +from unstract_cli.core.poll import ( + PollSpec, + classify, + extract_status, + persist, + preflight, + wait_for_completion, +) PRODUCT = "llmwhisperer" @@ -99,8 +106,11 @@ def extract( result = wait_for_completion( initial=accepted, spec=EXTRACT_POLL, - poll=client.whisper_status, - retrieve=lambda handle: _extraction(client.whisper_retrieve(handle)), + poll=translating(client.whisper_status, "whisper-status"), + retrieve=translating( + lambda handle: _extraction(client.whisper_retrieve(handle)), + "whisper-retrieve", + ), save=save, interval=interval, timeout=wait_timeout, @@ -148,7 +158,22 @@ def status(ctx: Context, whisper_hash: str) -> None: """Report the state of a submitted extraction.""" client = llmwhisperer(ctx.config) with translated(endpoint="whisper-status"): - finish(ctx, client.whisper_status(whisper_hash)) + result = client.whisper_status(whisper_hash) + # A failed extraction is reported inside an HTTP 200, so the status code + # alone would call this a success. + if classify(result, EXTRACT_POLL) == "failure": + raise CLIError( + f"Extraction finished with status {extract_status(result)!r}.", + ExitCode.VALIDATION, + details=result, + endpoint="whisper-status", + hint=( + "`details` carries the service's own message. An `unknown` status " + "means the service no longer holds this hash." + ), + extra={"whisper_hash": whisper_hash}, + ) + finish(ctx, result) @raw_field(RAW_FIELD) diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py index f1207cc..671dd81 100644 --- a/src/unstract_cli/core/clients.py +++ b/src/unstract_cli/core/clients.py @@ -12,7 +12,7 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Callable, Iterator from contextlib import contextmanager from typing import Any @@ -142,6 +142,23 @@ def translated(endpoint: str | None = None) -> Iterator[None]: ) from exc +def translating( + call: Callable[..., Any], endpoint: str | None = None +) -> Callable[..., Any]: + """Wrap one call so its failures are CLIErrors where they happen. + + A ``with translated(...)`` around a loop converts nothing until the loop is + left, by which point what the loop knew -- the job handle above all -- is out + of scope. + """ + + def wrapped(*args: Any, **kwargs: Any) -> Any: + with translated(endpoint=endpoint): + return call(*args, **kwargs) + + return wrapped + + def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> None: """Fail on a deployment response that reports an error status. @@ -166,4 +183,5 @@ def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> Non "llmwhisperer", "raise_for_result", "translated", + "translating", ] diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index 34042f4..681a465 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -183,14 +183,28 @@ def wait_for_completion( last_status: str | None = None payload: Any = initial - while True: + def naming_the_job(call: Callable[[str], Any]) -> Any: + """Run one step of the loop, ensuring any failure names the job. + + The handle is the difference between resuming and paying to process the + document a second time, so it is attached here rather than left to + whatever the caller wrapped the loop in. + """ try: - payload = poll(handle) + return call(handle) except CLIError as exc: - # The handle is the difference between resuming and paying to - # process the document a second time. exc.extra.setdefault(spec.handle_field, handle) raise + except Exception as exc: + raise CLIError( + str(exc) or type(exc).__name__, + ExitCode.SERVER_ERROR, + retryable=True, + extra={spec.handle_field: handle}, + ) from exc + + while True: + payload = naming_the_job(poll) status = extract_status(payload, spec.status_field) if status != last_status: @@ -240,7 +254,7 @@ def wait_for_completion( sleep(min(interval, remaining)) if retrieve is not None: - payload = retrieve(handle) + payload = naming_the_job(retrieve) if save is not None: written = persist(save, payload) if on_saved is not None: diff --git a/tests/test_commands.py b/tests/test_commands.py index 78b7c0f..a98e354 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -10,6 +10,7 @@ import json import pytest +from requests.exceptions import ConnectionError from unstract.clone.report import CloneReport, Endpoint, PhaseResult from unstract.llmwhisperer.client_v2 import ( LLMWhispererClientException, @@ -253,6 +254,23 @@ def test_a_failed_extraction_carries_the_handle(capsys, whisper_client, tmp_path assert envelope(out)["error"]["whisper_hash"] == "h1" +def test_a_transport_failure_mid_poll_carries_the_handle( + capsys, whisper_client, tmp_path +): + """The document is submitted and billed by this point. Without the handle the + only way on is to send it again and pay for it twice.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client( + whisper={"whisper_hash": "h1"}, + whisper_status=ConnectionError("connection dropped"), + ) + + code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0") + assert code == int(ExitCode.SERVER_ERROR) + assert envelope(out)["error"]["whisper_hash"] == "h1" + + # --------------------------------------------------------------------------- # # Retrieval is one-shot # --------------------------------------------------------------------------- # From 50fd6e57e6e950bec168c3350fc4faf0467d61c9 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 23:31:09 +0530 Subject: [PATCH 22/86] fix: report a failure as one, and never authenticate against a guess Four failures the CLI reported as successes or as something vaguer than it knew: - a server-reported error inside a 2xx got the catch-all exit code, which is the least informative one for the most interesting failure this API has; - `config doctor` printed its own findings and exited 0, so a setup script branching on it read a broken configuration as a working one; - a deployment alias pointing at an unset environment variable fell back to the profile's organisation and key, running against a tenant nobody named; - a webhook's auth token was echoed verbatim. The restated-default stripper was also greedy to the end of the string, so a description whose value list came first lost every sentence after it. --- README.md | 3 ++- RUNBOOK.md | 2 +- src/unstract_cli/commands/config_cmd.py | 29 +++++++++++++++++++++++- src/unstract_cli/commands/whisper_cmd.py | 16 ++++++++++--- src/unstract_cli/config.py | 21 +++++++++++++++-- src/unstract_cli/core/clients.py | 18 +++++++++++++-- src/unstract_cli/core/params.py | 10 ++++---- tests/test_discover.py | 8 +++++-- 8 files changed, 91 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5f8c26f..1a1e137 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ api_name = "invoice-parser" Credentials use `env:VAR_NAME` indirection, so the file records where a secret lives rather than the secret itself. `unstract config doctor` reports where each setting resolved from — including whether an `env:` reference is actually set in -the current process — without echoing any value. +the current process — without echoing any value. It exits non-zero when one of +its own checks failed, so a setup script can branch on it. `clone` is the exception: it talks to two deployments at once, which no single profile describes, so it takes both endpoints as flags and both admin Platform diff --git a/RUNBOOK.md b/RUNBOOK.md index aadaa4c..703aec4 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -101,7 +101,7 @@ Run against a document you can re-send; several of these submit real work. | # | Command | Pass | |---|---|---| -| 1 | `config doctor --probe` | every setting reports where it resolved from; the LLMWhisperer probe answers live | +| 1 | `config doctor --probe` | every setting reports where it resolved from; the LLMWhisperer probe answers live; exit 0 when nothing failed, and exit 1 with the same report under `error.details` when something did | | 2 | `whisper extract ` | polls to completion, returns text | | 3 | `whisper extract --no-wait` then `whisper status ` then `whisper retrieve ` | the handle survives the round trip | | 4 | `whisper retrieve ` a second time | refused, exit 9, and the error names the one-shot read | diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 0321745..62385a7 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -264,8 +264,14 @@ def config_doctor(obj: Any, probe: bool) -> None: Resolution is answered offline. --probe adds the second question -- does the resolved key work -- which needs the network, so it is opt-in. + + Exits 0 only when nothing it checked failed. A setting that is simply not + configured is a report, not a failure; a setting that points somewhere and + does not arrive -- an unset `env:` variable, an unknown profile, a probe the + service rejected -- exits non-zero, because a setup script branches on that. """ resolved = _resolved(obj) + problems: list[str] = [] products: dict[str, Any] = {} for product in PRODUCTS: entry: dict[str, Any] = {} @@ -274,12 +280,15 @@ def config_doctor(obj: Any, probe: bool) -> None: entry[key] = resolved.resolution_source(product, key) except ConfigError as exc: entry[key] = {"resolved": False, "source": "unset", "detail": str(exc)} + if detail := entry[key].get("detail"): + problems.append(f"{product}.{key}: {detail}") products[product] = entry try: aliases = list(resolved.deployment_aliases()) - except ConfigError: + except ConfigError as exc: aliases = [] + problems.append(str(exc)) report: dict[str, Any] = { "active_profile": resolved.active_profile, @@ -290,6 +299,24 @@ def config_doctor(obj: Any, probe: bool) -> None: } if probe: report["probe"] = _probe(resolved) + problems += [ + f"probe {name}: {result.get('detail')}" + for name, result in report["probe"].items() + if result["ok"] is False + ] + + if problems: + report["problems"] = problems + more = "" if len(problems) == 1 else f" (+{len(problems) - 1} more)" + raise CLIError( + f"{len(problems)} configuration check(s) failed: {problems[0]}{more}", + ExitCode.GENERIC, + details=report, + hint=( + "`details` carries the whole report, including where each setting " + "resolved from." + ), + ) emit_result(report, _fmt(obj)) diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index c848560..3d11e29 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -15,7 +15,7 @@ from unstract_cli.app import Context, pass_context, whisper_group from unstract_cli.commands.common import finish, raw_field, wait_options from unstract_cli.core.clients import llmwhisperer, translated, translating -from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.errors import CLIError, ExitCode, remember_secret from unstract_cli.core.params import requested, spec_options from unstract_cli.core.poll import ( PollSpec, @@ -317,6 +317,7 @@ def webhook_group() -> None: @pass_context def webhook_create(ctx: Context, name: str, url: str, auth_token: str) -> None: """Register a webhook.""" + remember_secret(auth_token) client = llmwhisperer(ctx.config) with translated(endpoint="whisper-manage-callback"): finish(ctx, client.register_webhook(url, auth_token, name)) @@ -329,6 +330,7 @@ def webhook_create(ctx: Context, name: str, url: str, auth_token: str) -> None: @pass_context def webhook_update(ctx: Context, name: str, url: str, auth_token: str) -> None: """Replace a webhook's URL and token.""" + remember_secret(auth_token) client = llmwhisperer(ctx.config) with translated(endpoint="whisper-manage-callback"): finish(ctx, client.update_webhook_details(name, url, auth_token)) @@ -338,10 +340,18 @@ def webhook_update(ctx: Context, name: str, url: str, auth_token: str) -> None: @click.argument("name") @pass_context def webhook_get(ctx: Context, name: str) -> None: - """Show one webhook's configuration.""" + """Show one webhook's configuration. + + The token is reported as redacted, including for a webhook registered + elsewhere: it authenticates deliveries wherever it was set, and this output + is as likely to land in a log as on a screen. + """ client = llmwhisperer(ctx.config) with translated(endpoint="whisper-manage-callback"): - finish(ctx, client.get_webhook_details(name)) + details = client.get_webhook_details(name) + if isinstance(details, dict): + remember_secret(details.get("auth_token")) + finish(ctx, details) @webhook_group.command("delete") diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index e2619a6..3cd573e 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -303,14 +303,31 @@ def deployment(self, alias: str) -> dict[str, Any]: ) if not entry.get("api_name"): raise ConfigError(f"Deployment alias {alias!r} has no `api_name`.") - api_key = _deref(entry.get("api_key")) or self.get(DOCSTUDIO, "api_key") + api_key = self._alias_setting(alias, entry, "api_key") remember_secret(api_key) return { "api_name": entry["api_name"], - "org_id": _deref(entry.get("org_id")) or self.get(DOCSTUDIO, "org_id"), + "org_id": self._alias_setting(alias, entry, "org_id"), "api_key": api_key, } + def _alias_setting(self, alias: str, entry: dict[str, Any], key: str) -> Any: + """One alias setting, falling back to the profile only where the alias is silent. + + An ``env:`` reference that does not resolve is not silence. Falling back + there runs the deployment against the profile's organisation, with the + profile's key, and reports success. + """ + raw = entry.get(key) + if isinstance(raw, str) and raw.startswith("env:"): + if value := _deref(raw): + return value + raise ConfigError( + f"Deployment alias {alias!r} sets {key} to {raw!r}, and " + f"${raw[4:].strip()} is not set in this process's environment." + ) + return raw or self.get(DOCSTUDIO, key) + def deployment_aliases(self) -> tuple[str, ...]: """Names of the deployment aliases defined in the active profile.""" aliases = self._profile().get("deployments") diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py index 671dd81..aa1687c 100644 --- a/src/unstract_cli/core/clients.py +++ b/src/unstract_cli/core/clients.py @@ -168,13 +168,27 @@ def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> Non """ status = int(result.get("status_code") or 0) reported = result.get("error") - if (status and not 200 <= status < 300) or reported: + if status and not 200 <= status < 300: raise error_from_status( - status or 500, + status, str(reported or f"Request failed with status {status}"), details=result, endpoint=endpoint, ) + if reported: + # Success at the HTTP layer, failure in the body -- the most interesting + # failure this API has, and the one a status-code mapping has nothing to + # say about. Not retryable: re-running starts a second billed execution + # rather than retrying the first. + raise CLIError( + str(reported), + ExitCode.VALIDATION, + http_status=status or None, + details=result, + endpoint=endpoint, + hint="The request was accepted and the work was not done; `details` " + "carries the service's own report.", + ) __all__ = [ diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index 7b141a9..34942f0 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -214,12 +214,14 @@ def _from_signature(param: Param, signature: inspect.Parameter) -> Param: #: docstring, which is how both clients document their parameters. _ARG_LINE = re.compile(r"^\s*(\w+)\s*(\([^)]*\))?\s*:\s*(.*)$") -#: Sentences a description restates from elsewhere. Each is anchored at the end, -#: so they are stripped in the order a description carries them. The value list -#: is matched on its opening quote, leaving prose that says "can be" alone. +#: Sentences a description restates from elsewhere, stripped in the order a +#: description carries them. The value list is matched on its opening quote, +#: leaving prose that says "can be" alone, and ends at the first full stop that +#: closes a quoted value -- a description whose value list is its *first* +#: sentence keeps everything that follows. _RESTATED = ( re.compile(r"\s*Defaults to .*\.\s*$"), - re.compile(r'\s*Can be ".*\.\s*$'), + re.compile(r'\s*Can be ".*?"\s*\.'), ) diff --git a/tests/test_discover.py b/tests/test_discover.py index 09d4e0f..2fd9fa7 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -137,9 +137,13 @@ def test_probe_verifies_the_whisperer_key(capsys, probe_client): def test_a_rejected_key_reports_why(capsys, probe_client): + """A probe that failed exits non-zero: --probe is run from setup scripts, + and a script branches on the exit code, not on the payload.""" probe_client(CLIError("bad key", ExitCode.AUTH)) - _, data = run(capsys, "config", "doctor", "--probe") - entry = data["probe"]["llmwhisperer"] + code = main(["-o", "json", "config", "doctor", "--probe"]) + report = json.loads(capsys.readouterr().out)["error"]["details"] + assert code == int(ExitCode.GENERIC) + entry = report["probe"]["llmwhisperer"] assert entry == { "checked": True, "ok": False, From b9f04accaf3bd15efd035fd95697af9f3ec59691 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 12 Aug 2026 23:32:13 +0530 Subject: [PATCH 23/86] build: move the client pins to the heads carrying the transport fixes --- pyproject.toml | 4 ++-- uv.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 23feb6c..8fb45ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,8 +16,8 @@ dependencies = [ # these clients are generated from, and reads their docstrings for help # text, so a client that moves underneath it changes the CLI's surface. # Both pins move to released versions before this ships. - "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@54f09f4", - "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@ef5e5af", + "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@27dd806", + "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@7f64caf", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index d17f125..89ec794 100644 --- a/uv.lock +++ b/uv.lock @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "llmwhisperer-client" version = "2.7.0" -source = { git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=ef5e5af#ef5e5af854f2e986456d977698ef913f2eb8ca8c" } +source = { git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=7f64caf#7f64caf5893370e0d472c50df3df39ef198fb37b" } dependencies = [ { name = "attrs" }, { name = "httpx" }, @@ -345,18 +345,18 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1,<9" }, - { name = "llmwhisperer-client", git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=ef5e5af" }, + { name = "llmwhisperer-client", git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=7f64caf" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "tomli-w", specifier = ">=1.0" }, - { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=54f09f4" }, + { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=27dd806" }, ] provides-extras = ["dev"] [[package]] name = "unstract-client" version = "1.5.3" -source = { git = "https://github.com/Zipstack/unstract-python-client?rev=54f09f4#54f09f4ed0aa728d297b7d5f003f3dd48e1ce6a3" } +source = { git = "https://github.com/Zipstack/unstract-python-client?rev=27dd806#27dd8067bbac53b8a42c48f4178592fc7f369e7e" } dependencies = [ { name = "attrs" }, { name = "click" }, From dbf730c618fa5b613023b04636e9ee5f0fdad7b8 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 13 Aug 2026 00:02:09 +0530 Subject: [PATCH 24/86] fix: hold the clone's guards, and say what a clone left behind The command that writes into a live organisation had none of its own behaviour pinned. Its table output -- the one a person gets, and the only output path that did not go through the emitter -- scrubbed by hand and was run by no test, while the test that claimed a platform key never reaches stdout passed with the registration deleted. Rendered output now goes out through the same path as every envelope, and a key planted in a report is asserted not to survive it. Also: --on-name-conflict decides what is written into the target and is now asserted to arrive; skipped documents are counted at the top of the payload, because skipping is not fatal and a caller reading the exit code alone would never learn a document did not move; `config doctor` resolves each deployment alias the way a run does, instead of listing names its docstring implies it checked; a failed retrieve is pinned to carry the handle; the restated-default stripper ends at its own sentence rather than at the end of the text; and the groups tier lists leaf commands apart from groups, which a consumer walks differently. --- README.md | 4 +- src/unstract_cli/commands/clone_cmd.py | 33 +++++++---- src/unstract_cli/commands/config_cmd.py | 7 +++ src/unstract_cli/core/discover.py | 16 +++++- src/unstract_cli/core/output.py | 12 +++- src/unstract_cli/core/params.py | 11 ++-- tests/test_commands.py | 76 ++++++++++++++++++++++++- tests/test_discover.py | 13 ++--- 8 files changed, 143 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 1a1e137..5084f80 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,9 @@ its own checks failed, so a setup script can branch on it. `clone` is the exception: it talks to two deployments at once, which no single profile describes, so it takes both endpoints as flags and both admin Platform -keys from `UNSTRACT_SRC_PLATFORM_KEY` / `UNSTRACT_TGT_PLATFORM_KEY`. +keys from `UNSTRACT_SRC_PLATFORM_KEY` / `UNSTRACT_TGT_PLATFORM_KEY`. It exits 0 +when nothing failed, which is not the same as everything having moved: oversize +and unsupported documents are skipped by design, and `data.skipped` counts them. ## Development diff --git a/src/unstract_cli/commands/clone_cmd.py b/src/unstract_cli/commands/clone_cmd.py index 12085cc..a2cfcbc 100644 --- a/src/unstract_cli/commands/clone_cmd.py +++ b/src/unstract_cli/commands/clone_cmd.py @@ -25,14 +25,8 @@ from unstract_cli.app import Context, cli, pass_context from unstract_cli.commands.common import finish -from unstract_cli.core.errors import ( - CLIError, - ExitCode, - known_secrets, - remember_secret, - scrub, -) -from unstract_cli.core.output import OutputFormat +from unstract_cli.core.errors import CLIError, ExitCode, remember_secret +from unstract_cli.core.output import OutputFormat, emit_text @cli.command("clone") @@ -170,6 +164,22 @@ def _configure_logging(ctx: Context) -> None: ) +def _skipped(report: CloneReport) -> dict[str, Any]: + """What the run did not copy, summarised at the top of the payload. + + Skipping an oversize or unsupported file is reported rather than fatal, so + the run still exits 0; a consumer reading only the exit code would otherwise + have to walk the whole report to discover documents that never arrived. + """ + by_phase = {phase.name: phase.skipped for phase in report.phases if phase.skipped} + return { + "total": sum(by_phase.values()), + "by_phase": by_phase, + "oversize_files": len(report.oversize_files), + "unsupported_files": len(report.unsupported_files), + } + + def _finish(ctx: Context, report: CloneReport) -> None: """Emit the report, then fail if the clone did not fully succeed.""" failure = None @@ -178,19 +188,20 @@ def _finish(ctx: Context, report: CloneReport) -> None: elif failed := [phase.name for phase in report.phases if phase.failed]: failure = f"Clone completed with failures in: {', '.join(sorted(failed))}" + payload = {**report.as_dict(), "skipped": _skipped(report)} # A person running this reads the report itself; every other format gets the # single envelope, which carries the same content as data. rendered = ctx.output is OutputFormat.TABLE if rendered: - click.echo(scrub(report.render(), [*ctx.secrets(), *known_secrets()])) + emit_text(report.render(), secrets=ctx.secrets()) elif not failure: - finish(ctx, report.as_dict()) + finish(ctx, payload) if failure: raise CLIError( failure, ExitCode.GENERIC, - details=None if rendered else report.as_dict(), + details=None if rendered else payload, hint="The report lists what was copied and what was not. Re-running " "adopts what already exists on the target rather than duplicating it.", ) diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 62385a7..a627ad1 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -289,6 +289,13 @@ def config_doctor(obj: Any, probe: bool) -> None: except ConfigError as exc: aliases = [] problems.append(str(exc)) + for alias in aliases: + try: + # Resolved the way a run resolves it: that an alias is *listed* says + # nothing about whether the settings behind it arrive. + resolved.deployment(alias) + except ConfigError as exc: + problems.append(f"deployment alias {alias}: {exc}") report: dict[str, Any] = { "active_profile": resolved.active_profile, diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py index 2d8017e..ac79280 100644 --- a/src/unstract_cli/core/discover.py +++ b/src/unstract_cli/core/discover.py @@ -108,11 +108,23 @@ def discover(root: click.Group, tier: str) -> dict[str, Any]: raise ValueError(f"Unknown discovery tier {tier!r}. One of: {', '.join(TIERS)}") if tier == "groups": + top = sorted(root.commands.items()) + + def summary(name: str, command: click.Command) -> dict[str, str]: + return {"name": name, "help": (command.help or "").strip().split("\n")[0]} + return { "tier": tier, "groups": [ - {"name": name, "help": (sub.help or "").strip().split("\n")[0]} - for name, sub in sorted(root.commands.items()) + summary(name, sub) for name, sub in top if isinstance(sub, click.Group) + ], + # A command that has no sub-commands is listed apart from the groups: + # a consumer drilling into each group for its commands finds nothing + # under a leaf, and would drop it. + "commands": [ + summary(name, sub) + for name, sub in top + if not isinstance(sub, click.Group) ], } diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index d75b9ec..6fbd889 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -247,7 +247,16 @@ def emit( caller passed one: an emitter that has to remember is an emitter that eventually forgets. """ - text = render(env, fmt, columns=columns, raw_field=raw_field) + emit_text(render(env, fmt, columns=columns, raw_field=raw_field), secrets=secrets) + + +def emit_text(text: str, *, secrets: list[str] | None = None) -> None: + """Write already-rendered text to stdout, scrubbed the way an envelope is. + + A command that renders its own table is still writing to the stream no + credential may reach, and scrubbing it by hand is the arrangement that + eventually forgets. + """ if to_hide := [*(secrets or []), *known_secrets()]: text = scrub(text, to_hide) print(text) @@ -314,6 +323,7 @@ def diagnostic( "emit", "emit_error", "emit_result", + "emit_text", "envelope", "render", "render_table", diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index 34942f0..c862019 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -215,12 +215,13 @@ def _from_signature(param: Param, signature: inspect.Parameter) -> Param: _ARG_LINE = re.compile(r"^\s*(\w+)\s*(\([^)]*\))?\s*:\s*(.*)$") #: Sentences a description restates from elsewhere, stripped in the order a -#: description carries them. The value list is matched on its opening quote, -#: leaving prose that says "can be" alone, and ends at the first full stop that -#: closes a quoted value -- a description whose value list is its *first* -#: sentence keeps everything that follows. +#: description carries them. Each pattern ends at its own sentence rather than at +#: the end of the text, so a description that carries prose after the restated +#: sentence keeps it: the default ends at the full stop that starts the next +#: sentence, the value list at the full stop closing a quoted value. The value +#: list is matched on its opening quote, leaving prose that says "can be" alone. _RESTATED = ( - re.compile(r"\s*Defaults to .*\.\s*$"), + re.compile(r"\s*Defaults to .*?\.(?=\s+[A-Z]|\s*$)"), re.compile(r'\s*Can be ".*?"\s*\.'), ) diff --git a/tests/test_commands.py b/tests/test_commands.py index a98e354..6d55531 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -271,6 +271,22 @@ def test_a_transport_failure_mid_poll_carries_the_handle( assert envelope(out)["error"]["whisper_hash"] == "h1" +def test_a_failed_retrieve_carries_the_handle(capsys, whisper_client, tmp_path): + """Retrieve is the acknowledging read: a failure here can lose the text and + the handle at once, and the handle is the only way back to either.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client( + whisper={"whisper_hash": "h1"}, + whisper_status={"status": "processed"}, + whisper_retrieve=ConnectionError("connection dropped"), + ) + + code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0") + assert code == int(ExitCode.SERVER_ERROR) + assert envelope(out)["error"]["whisper_hash"] == "h1" + + # --------------------------------------------------------------------------- # # Retrieval is one-shot # --------------------------------------------------------------------------- # @@ -919,7 +935,11 @@ def fake_clone(source, target, options): return CloneReport( source=Endpoint(source.base_url, source.organization_id), target=Endpoint(target.base_url, target.organization_id), - phases=[PhaseResult(name="adapters", created=1, failed=2)], + phases=[ + PhaseResult(name="adapters", created=1, failed=2), + PhaseResult(name="files", created=1, skipped=3), + ], + oversize_files=[{"name": "big.pdf"}, {"name": "bigger.pdf"}], ) monkeypatch.setattr(clone_cmd, "run_clone", fake_clone) @@ -945,6 +965,8 @@ def fake_clone(source, target, options): "2MB", "--api-prefix", "api/v2", + "--on-name-conflict", + "abort", ) assert captured["source"].platform_key == "src-key-0123456789" @@ -954,11 +976,63 @@ def fake_clone(source, target, options): assert captured["options"].exclude == ("files", "groups") assert captured["options"].file_strategy == "skip" assert captured["options"].max_file_size == 2 * 1024 * 1024 + # adopt and abort decide what is written into a live target organisation. + assert captured["options"].on_name_conflict == "abort" # A phase that failed is not a successful migration, whatever else worked. assert code == int(ExitCode.GENERIC) body = envelope(out) assert body["ok"] is False assert "adapters" in body["error"]["message"] + # Documents that never arrived are counted where a consumer reads first. + assert body["error"]["details"]["skipped"] == { + "total": 3, + "by_phase": {"files": 3}, + "oversize_files": 2, + "unsupported_files": 0, + } for key in ("src-key-0123456789", "tgt-key-0123456789"): assert key not in out and key not in err + + +def test_a_key_quoted_in_a_clone_report_does_not_survive_the_table(capsys, monkeypatch): + """The table is the output a person gets, and the report renders itself. + + A platform key quoted back by a failing service lands in a terminal buffer + and in whatever scrapes one, so the rendered report is scrubbed on the same + path as every envelope rather than by hand. + """ + key = "src-key-0123456789" + + def fake_clone(source, target, options): + return CloneReport( + source=Endpoint(source.base_url, source.organization_id), + target=Endpoint(target.base_url, target.organization_id), + phases=[PhaseResult(name="adapters", created=1)], + warnings=[f"target refused the request for {key}"], + ) + + monkeypatch.setattr(clone_cmd, "run_clone", fake_clone) + monkeypatch.setenv("UNSTRACT_SRC_PLATFORM_KEY", key) + monkeypatch.setenv("UNSTRACT_TGT_PLATFORM_KEY", "tgt-key-0123456789") + + code = main( + [ + "-o", + "table", + "clone", + "--source-url", + "https://dev.example.com", + "--source-org", + "org_dev", + "--target-url", + "https://qa.example.com", + "--target-org", + "org_qa", + ] + ) + captured = capsys.readouterr() + + assert code == int(ExitCode.SUCCESS) + assert "adapters" in captured.out + assert key not in captured.out and key not in captured.err diff --git a/tests/test_discover.py b/tests/test_discover.py index 2fd9fa7..5916c9e 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -27,14 +27,11 @@ def test_groups_names_the_products_and_stops_there(capsys): """The cheap question stays cheap: no command list, no flags.""" code, data = run(capsys, "--discover", "groups") assert code == int(ExitCode.SUCCESS) - assert {g["name"] for g in data["groups"]} == { - "clone", - "config", - "docstudio", - "whisper", - } - assert all(g["help"] for g in data["groups"]) - assert "commands" not in data + assert {g["name"] for g in data["groups"]} == {"config", "docstudio", "whisper"} + # A leaf listed among the groups is a group a consumer finds empty. + assert [c["name"] for c in data["commands"]] == ["clone"] + assert all(entry["help"] for entry in [*data["groups"], *data["commands"]]) + assert all("commands" not in entry for entry in data["groups"]) def test_summary_lists_commands_without_their_flags(capsys): From a8b1ac9bafacb12ab0d11ccc438ea9719096742a Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 13 Aug 2026 00:02:16 +0530 Subject: [PATCH 25/86] build: move the deployment client pin to the poll-URL fix The status endpoint's own query parameters are forwarded now, and a deployment URL that carries no derivable prefix is polled where the service said rather than at a rebuilt path. --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8fb45ae..5d7f2f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ # these clients are generated from, and reads their docstrings for help # text, so a client that moves underneath it changes the CLI's surface. # Both pins move to released versions before this ships. - "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@27dd806", + "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@114aef8", "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@7f64caf", ] diff --git a/uv.lock b/uv.lock index 89ec794..95a3a94 100644 --- a/uv.lock +++ b/uv.lock @@ -349,14 +349,14 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "tomli-w", specifier = ">=1.0" }, - { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=27dd806" }, + { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=114aef8" }, ] provides-extras = ["dev"] [[package]] name = "unstract-client" version = "1.5.3" -source = { git = "https://github.com/Zipstack/unstract-python-client?rev=27dd806#27dd8067bbac53b8a42c48f4178592fc7f369e7e" } +source = { git = "https://github.com/Zipstack/unstract-python-client?rev=114aef8#114aef84446fe6c5400258269cb1da0c8e2ab135" } dependencies = [ { name = "attrs" }, { name = "click" }, From 7092cdbf0e42e8b6dbb4c001d6bd1b626369711d Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 13 Aug 2026 10:07:10 +0530 Subject: [PATCH 26/86] docs: draft the release notes, and move the client pin to its tip The notes carry the console-script collision, the behaviours a script would otherwise discover by being surprised, and the service version a custom page separator needs. The pin moves to a documentation-only commit. --- RELEASE_NOTES.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 RELEASE_NOTES.md diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..00cd7ac --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,48 @@ +# Release notes — draft + +Content for the first release. Not published yet. + +## What this is + +One CLI for the Unstract suite: extract a document with LLMWhisperer, run it +through a Document Studio API deployment, clone one organization's resources +into another. Install it with `pipx`, then `unstract config init`. + +## The `unstract` command name + +`unstract-client` released before this CLI installed a console script called +`unstract` too, and that script has been removed there — its clone command is +now `python -m unstract.clone`, and this CLI's `unstract clone` wraps the same +code. An environment holding an older `unstract-client` alongside this package +gives the name to whichever was installed last: + +```bash +command -v unstract && unstract --version +``` + +`pipx` avoids the question by giving this CLI its own environment. A second +console script, `unstract-cli`, always belongs to this package. + +## Behaviour worth knowing before you script against it + +- **A failure the service reports inside a successful HTTP response exits 5 + (validation), not 8 (server error).** Exit 8 invites a retry, and on an API + that bills per execution a blind retry is a second charge for work that was + already done. The service's own report is in `error.details`. +- **`clone` exits 0 when nothing failed, which is not the same as everything + having moved.** Oversize and unsupported documents are skipped by design; + `data.skipped` counts them. +- **`config doctor` exits non-zero when one of its own checks failed**, so a + setup script can branch on it. A setting that is simply not configured is + reported, not failed. +- **A custom `page_separator` needs LLMWhisperer v2.64.2 or later.** An older + service reads only the previous spelling of the parameter, falls back to the + default `<<<` separator, and reports no error. + +## Consuming the output + +Pass `-o json`: stdout is then exactly one `{ok, data, error, meta}` envelope on +success and on failure alike. Ignore fields you do not recognise, refuse a +`meta.contract_version` above the one you were written against, and branch on +the exit code rather than on message text. `unstract --discover full` publishes +the whole contract alongside every command and flag. diff --git a/pyproject.toml b/pyproject.toml index 5d7f2f2..7375ce9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ # these clients are generated from, and reads their docstrings for help # text, so a client that moves underneath it changes the CLI's surface. # Both pins move to released versions before this ships. - "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@114aef8", + "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@a77ef6a", "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@7f64caf", ] diff --git a/uv.lock b/uv.lock index 95a3a94..70ddebb 100644 --- a/uv.lock +++ b/uv.lock @@ -349,14 +349,14 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "tomli-w", specifier = ">=1.0" }, - { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=114aef8" }, + { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=a77ef6a" }, ] provides-extras = ["dev"] [[package]] name = "unstract-client" version = "1.5.3" -source = { git = "https://github.com/Zipstack/unstract-python-client?rev=114aef8#114aef84446fe6c5400258269cb1da0c8e2ab135" } +source = { git = "https://github.com/Zipstack/unstract-python-client?rev=a77ef6a#a77ef6ae65d69a8290b5aa3fb6b13952a2084d45" } dependencies = [ { name = "attrs" }, { name = "click" }, From 6ecd490c8fb61bb6677a1e606ce0697ad231d380 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 13 Aug 2026 10:37:16 +0530 Subject: [PATCH 27/86] docs: shorten the top-level help to what a first run needs The envelope shape is documented in the README and published by --discover; greeting every --help with it buries the two things a reader is there for. --- src/unstract_cli/app.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 1a723a4..9019c5a 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -151,11 +151,11 @@ def cli( verbose: int, discover_tier: str | None, ) -> None: - """Unstract CLI: extract documents and run API deployments. + """The official CLI for Unstract. - Output is a table by default. With `-o json` stdout carries one envelope -- - {ok, data, error, meta} -- on success and on failure alike, and its content - depends on nothing but the command you ran. Diagnostics go to stderr. + Extract documents with LLMWhisperer and run API deployments. `--discover + groups` maps every command as JSON; pass `-o json` when scripting or parsing + the output. """ set_config_path(config_file) ctx.obj = Context( From 85e4e69dfb7e6e926e102da1d32b7c8e9ac07a3e Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 13 Aug 2026 14:34:52 +0530 Subject: [PATCH 28/86] fix: do not let a discovered project config name the host or the key A .unstract.toml found by upward search comes from whatever checkout the user happens to be standing in. It may still select a profile, set org_id and define deployment aliases; api_key and base_url are withheld, with a warning, and reported as withheld by config doctor. Named explicitly with --config or $UNSTRACT_CONFIG, the same file is honoured in full. Also point a first-time user at where keys are minted, from config init, from doctor and from the README, and ship an on-prem profile shape. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 11 ++ src/unstract_cli/commands/config_cmd.py | 12 +- src/unstract_cli/config.py | 141 ++++++++++++++++++++++-- tests/test_config.py | 74 +++++++++++++ 4 files changed, 227 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5084f80..4117af9 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,23 @@ api_key = "env:UNSTRACT_DEPLOYMENT_KEY" api_name = "invoice-parser" ``` +Get an LLMWhisperer key from the LLMWhisperer console; a deployment key is shown +on the API deployment's own page in the Unstract UI. `config init` also writes an +`onprem-example` profile as a shape to copy for a self-hosted install — its host +is a placeholder, and only the *active* profile is ever resolved. + Credentials use `env:VAR_NAME` indirection, so the file records where a secret lives rather than the secret itself. `unstract config doctor` reports where each setting resolved from — including whether an `env:` reference is actually set in the current process — without echoing any value. It exits non-zero when one of its own checks failed, so a setup script can branch on it. +A project-local `.unstract.toml` **found by upward search** may not supply +`api_key` or `base_url`. Those are ignored, with a warning; everything else in it +— profile selection, `org_id`, deployment aliases — applies as usual. A checkout +you did not write is not trusted to name the host your key is sent to. Name the +file explicitly (`--config` or `$UNSTRACT_CONFIG`) and it is honoured in full. + `clone` is the exception: it talks to two deployments at once, which no single profile describes, so it takes both endpoints as flags and both admin Platform keys from `UNSTRACT_SRC_PLATFORM_KEY` / `UNSTRACT_TGT_PLATFORM_KEY`. It exits 0 diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index a627ad1..905120e 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -16,6 +16,7 @@ from unstract_cli.config import ( DOCSTUDIO, + KEY_SOURCES, LLMWHISPERER, PRODUCTS, ConfigError, @@ -88,7 +89,7 @@ def config_init(obj: Any, force: bool) -> None: "replaced_existing": replaced, "note": ( "Credentials use env: indirection, so this file holds no secrets. " - "Set the referenced environment variables to authenticate." + "Set the referenced environment variables to authenticate. " + KEY_SOURCES ), }, _fmt(obj), @@ -304,6 +305,15 @@ def config_doctor(obj: Any, probe: bool) -> None: "products": products, "deployment_aliases": aliases, } + if any( + not entry["api_key"]["resolved"] + for entry in products.values() + if "api_key" in entry + ): + # Not a problem -- an unconfigured setting is reported, not failed -- but + # the next question after "no key" is always where one comes from. The + # field name avoids the word the payload scrubber redacts on. + report["getting_started"] = KEY_SOURCES if probe: report["probe"] = _probe(resolved) problems += [ diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 3cd573e..d3b095c 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -18,6 +18,7 @@ import os import stat import tomllib +from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -46,6 +47,14 @@ } +#: Where the two credentials are minted. Quoted wherever the CLI reports one as +#: missing: knowing a key is unset is no help without knowing where one is made. +KEY_SOURCES = ( + "Get an LLMWhisperer key from the LLMWhisperer console; a deployment key is " + "shown on the API deployment's own page in the Unstract UI." +) + + def settings_for(product: str) -> tuple[str, ...]: """The settings a product actually has. @@ -107,13 +116,23 @@ def config_path() -> Path: file checked into a repo, a throwaway one in CI, and a personal default, each selected per invocation. """ + return _resolve_config_path()[0] + + +def _resolve_config_path() -> tuple[Path, bool]: + """The config path, and whether it was *discovered* rather than named. + + The boolean is the trust signal: a path the user named (``--config`` or + ``$UNSTRACT_CONFIG``) is trusted, one found by walking up from the working + directory is not. See ``UNTRUSTED_PROJECT_KEYS``. + """ if _config_override is not None: - return _config_override + return _config_override, False if override := os.environ.get("UNSTRACT_CONFIG"): - return Path(override).expanduser() + return Path(override).expanduser(), False if local := find_project_config(): - return local - return HOME_CONFIG.expanduser() + return local, True + return HOME_CONFIG.expanduser(), False def _deref(value: Any) -> Any: @@ -128,6 +147,15 @@ def _deref(value: Any) -> Any: return value +#: Settings a *discovered* project-local file may not supply. Such a file is +#: attacker-controlled in any checkout the user did not write, and combined with +#: ``env:`` indirection it would otherwise point the CLI at a host of the +#: author's choosing and hand it the user's real key as a Bearer token. +#: Everything else -- org_id, profile selection, deployment aliases -- is still +#: honoured, so the project-local workflow keeps working. +UNTRUSTED_PROJECT_KEYS = frozenset({"api_key", "base_url"}) + + @dataclass class ConfigFile: """Parsed contents of the config file.""" @@ -138,13 +166,40 @@ class ConfigFile: exists: bool = False #: Non-fatal diagnostics (e.g. loose file permissions), surfaced on stderr. warnings: tuple[str, ...] = () + #: True when `path` was found by walking up from the working directory rather + #: than named. Such a file is not trusted with credentials or hosts. + is_project_local: bool = False + #: Keys withheld from an untrusted file, as ``{(profile, *blocks, key): value}``. + #: They are excluded from *resolution* -- that is the security property -- but + #: kept here so a write-back does not delete them from the user's own file. + withheld: dict[tuple[str, ...], Any] = field(default_factory=dict) + + +def _strip_untrusted(profiles: dict[str, Any]) -> dict[tuple[str, ...], Any]: + """Remove the untrusted keys from a profile tree, in place, reporting what went.""" + withheld: dict[tuple[str, ...], Any] = {} + + def walk(node: Any, trail: tuple[str, ...]) -> None: + if not isinstance(node, dict): + return + for key in list(node): + if key in UNTRUSTED_PROJECT_KEYS: + withheld[(*trail, key)] = node.pop(key) + else: + walk(node[key], (*trail, key)) + + walk(profiles, ()) + return withheld def load_config(path: Path | None = None) -> ConfigFile: """Load the config file. A missing file is normal, not an error.""" - target = path or config_path() + if path is not None: + target, project_local = path, False + else: + target, project_local = _resolve_config_path() if not target.exists(): - return ConfigFile(path=target, exists=False) + return ConfigFile(path=target, exists=False, is_project_local=project_local) try: with target.open("rb") as fh: @@ -167,15 +222,55 @@ def load_config(path: Path | None = None) -> ConfigFile: if not isinstance(profiles, dict): raise ConfigError(f"`profiles` in {target} must be a table.") + # Stripped rather than ignored wholesale, and said out loud: the rest of the + # file is the project's own workflow, and a setting dropped in silence is its + # own kind of surprise. + withheld: dict[tuple[str, ...], Any] = {} + if project_local: + withheld = _strip_untrusted(profiles) + if withheld: + names = ", ".join(sorted(".".join(trail) for trail in withheld)) + warnings.append( + f"Ignoring {names} from project config {target}: a discovered " + f"{PROJECT_CONFIG_NAME} may not supply credentials or base URLs. " + "Pass --config explicitly, or set the environment variable instead." + ) + return ConfigFile( default_profile=raw.get("default_profile"), profiles=profiles, path=target, exists=True, warnings=tuple(warnings), + is_project_local=project_local, + withheld=withheld, ) +def _restored_profiles(cfg: ConfigFile, target: Path) -> dict[str, Any]: + """The profiles to write, with anything withheld put back. + + Withholding a key from resolution is the security property; deleting it from + the user's file is not, and `config set` loads, mutates and saves the whole + document. Restored **only** when writing back to the file they came from -- + into any other path this would copy untrusted values somewhere they are + trusted. + """ + if not cfg.withheld or cfg.path is None or target.resolve() != cfg.path.resolve(): + return cfg.profiles + + profiles = deepcopy(cfg.profiles) + for (*parents, leaf), value in cfg.withheld.items(): + node: dict[str, Any] = profiles + for segment in parents: + child = node.get(segment) + if not isinstance(child, dict): + child = node[segment] = {} + node = child + node.setdefault(leaf, value) + return profiles + + def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: """Write the config file with owner-only permissions.""" target = path or cfg.path or config_path() @@ -184,7 +279,7 @@ def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: doc: dict[str, Any] = {} if cfg.default_profile: doc["default_profile"] = cfg.default_profile - doc["profiles"] = cfg.profiles + doc["profiles"] = _restored_profiles(cfg, target) # Create with 0600 from the outset rather than widening then narrowing: a # world-readable window, however brief, is a window. @@ -364,9 +459,19 @@ def resolution_source(self, product: str, key: str) -> dict[str, Any]: if raw not in (None, ""): return {"resolved": True, "source": "profile (literal)"} - if key == "base_url" and DEFAULT_BASE_URLS.get(product): - return {"resolved": True, "source": "built-in default"} - return {"resolved": False, "source": "unset"} + report: dict[str, Any] = ( + {"resolved": True, "source": "built-in default"} + if key == "base_url" and DEFAULT_BASE_URLS.get(product) + else {"resolved": False, "source": "unset"} + ) + if (self.active_profile, product, key) in self.file.withheld: + # The file does set it; reporting only where the value came from + # would leave the user staring at a setting they can see in the file. + report["detail"] = ( + f"{self.file.path} sets {key}, and a discovered " + f"{PROJECT_CONFIG_NAME} is not trusted with it." + ) + return report def starter_profiles() -> dict[str, dict[str, Any]]: @@ -394,6 +499,20 @@ def starter_profiles() -> dict[str, dict[str, Any]]: "api_key": "env:LLMWHISPERER_API_KEY", }, }, + # A shape to copy for a self-hosted install, not a profile to select: the + # host is a placeholder, and only the *active* profile is ever resolved, + # so leaving it in place costs nothing. + "onprem-example": { + LLMWHISPERER: { + "base_url": "https://llmwhisperer.unstract.internal.example/api/v2", + "api_key": "env:LLMWHISPERER_API_KEY", + }, + DOCSTUDIO: { + "base_url": "https://unstract.internal.example", + "org_id": "", + "api_key": "env:UNSTRACT_DEPLOYMENT_KEY", + }, + }, } @@ -402,9 +521,11 @@ def starter_profiles() -> dict[str, dict[str, Any]]: "DOCSTUDIO", "ENV_VARS", "HOME_CONFIG", + "KEY_SOURCES", "LLMWHISPERER", "PRODUCTS", "PROJECT_CONFIG_NAME", + "UNTRUSTED_PROJECT_KEYS", "ConfigError", "ConfigFile", "ResolvedConfig", diff --git a/tests/test_config.py b/tests/test_config.py index 2b29736..d75a441 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -199,6 +199,80 @@ def test_loose_permissions_warn_rather_than_fail(write_config): assert any("readable by other users" in w for w in load_config().warnings) +#: What a repository could commit: a host of its own choosing, and a key. +PROJECT_TOML = """ +default_profile = "p" + +[profiles.p.llmwhisperer] +base_url = "https://elsewhere.example/api/v2" +api_key = "project-literal-key" + +[profiles.p.docstudio] +org_id = "org_from_project" + +[profiles.p.deployments.invoices] +api_name = "invoice-parser" +api_key = "alias-literal-key" +""" + + +def _plant_project_config(tmp_path, monkeypatch): + work = tmp_path / "checkout" + work.mkdir() + path = work / ".unstract.toml" + path.write_text(PROJECT_TOML, encoding="utf-8") + monkeypatch.chdir(work) + return path + + +def test_a_discovered_project_config_supplies_no_key_and_no_host(tmp_path, monkeypatch): + path = _plant_project_config(tmp_path, monkeypatch) + cfg = resolved() + + assert cfg.get(LLMWHISPERER, "base_url") == DEFAULT_BASE_URLS[LLMWHISPERER] + assert cfg.get(LLMWHISPERER, "api_key") is None + assert cfg.deployment("invoices")["api_key"] is None + # Everything the file is legitimately for still applies. + assert cfg.get(DOCSTUDIO, "org_id") == "org_from_project" + assert cfg.deployment("invoices")["api_name"] == "invoice-parser" + assert any(str(path) in w and "Ignoring" in w for w in cfg.file.warnings) + assert cfg.resolution_source(LLMWHISPERER, "api_key")["detail"] + + +def test_the_same_file_named_explicitly_is_honoured(tmp_path, monkeypatch): + path = _plant_project_config(tmp_path, monkeypatch) + monkeypatch.setenv("UNSTRACT_CONFIG", str(path)) + cfg = resolved() + + assert cfg.get(LLMWHISPERER, "base_url") == "https://elsewhere.example/api/v2" + assert cfg.get(LLMWHISPERER, "api_key") == "project-literal-key" + assert not any("Ignoring" in w for w in cfg.file.warnings) + + +def test_writing_back_a_project_config_keeps_the_keys_it_withheld(tmp_path, monkeypatch): + path = _plant_project_config(tmp_path, monkeypatch) + cfg = load_config() + cfg.profiles["p"]["docstudio"]["org_id"] = "org_edited" + save_config(cfg) + + monkeypatch.setenv("UNSTRACT_CONFIG", str(path)) + reloaded = load_config() + assert reloaded.profiles["p"]["docstudio"]["org_id"] == "org_edited" + assert reloaded.profiles["p"]["llmwhisperer"]["api_key"] == "project-literal-key" + assert reloaded.profiles["p"]["deployments"]["invoices"]["api_key"] == ( + "alias-literal-key" + ) + + +def test_withheld_keys_are_not_carried_into_a_file_the_user_names(tmp_path, monkeypatch): + _plant_project_config(tmp_path, monkeypatch) + elsewhere = tmp_path / "named.toml" + save_config(load_config(), elsewhere) + + monkeypatch.setenv("UNSTRACT_CONFIG", str(elsewhere)) + assert "api_key" not in load_config().profiles["p"]["llmwhisperer"] + + def test_starter_profiles_hold_no_literal_secrets(): for blocks in starter_profiles().values(): for settings in blocks.values(): From 4d7574595e99c8759c8faa619ecd5fdf0117a733 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 13 Aug 2026 15:31:37 +0530 Subject: [PATCH 29/86] fix: never write config through a symlink a checkout chose The discovered config path is written to as well as read from, so a symlinked .unstract.toml let a repository redirect config set and config init --force onto any file it named. The upward search now skips a symlinked candidate, and the write opens with O_NOFOLLOW so a symlink at the target is a clear error rather than a truncation. Also: the config group reports the file's warnings instead of dropping them, doctor answers for a withheld deployment-alias key the way it does for a product one, trust is derived from the path rather than from how the loader was called, and the README says plainly that routing stays repo-controllable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 5 ++ src/unstract_cli/commands/config_cmd.py | 36 +++++++++++++-- src/unstract_cli/config.py | 61 ++++++++++++++++++++----- tests/test_config.py | 30 ++++++++++++ 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4117af9..99750ad 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,11 @@ A project-local `.unstract.toml` **found by upward search** may not supply you did not write is not trusted to name the host your key is sent to. Name the file explicitly (`--config` or `$UNSTRACT_CONFIG`) and it is honoured in full. +What that protects is the key and the host, not the routing: `org_id`, +`api_name` and profile selection stay repo-controllable by design, so a +project file can still decide *which* deployment a command runs against on a +host you trust. Read one before you run inside a checkout you did not write. + `clone` is the exception: it talks to two deployments at once, which no single profile describes, so it takes both endpoints as flags and both admin Platform keys from `UNSTRACT_SRC_PLATFORM_KEY` / `UNSTRACT_TGT_PLATFORM_KEY`. It exits 0 diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 905120e..a6d4828 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -30,7 +30,12 @@ ) from unstract_cli.core.clients import llmwhisperer, translated from unstract_cli.core.errors import CLIError, ExitCode -from unstract_cli.core.output import OutputFormat, emit_result, resolve_format +from unstract_cli.core.output import ( + OutputFormat, + diagnostic, + emit_result, + resolve_format, +) #: Keys whose value is never echoed back, even on explicit request: this output #: is as likely to land in a log or a transcript as on a screen. @@ -99,7 +104,7 @@ def config_init(obj: Any, force: bool) -> None: @config_group.command("list", help="List profiles defined in the config file.") @click.pass_obj def config_list(obj: Any) -> None: - cfg = load_config() + cfg = _loaded(obj) emit_result( { "path": str(cfg.path), @@ -172,7 +177,7 @@ def config_set(obj: Any, product: str, key: str, value: str, profile: str | None shell history. """ _check_product(product) - cfg = load_config() + cfg = _loaded(obj) name = profile or getattr(obj, "profile", None) or cfg.default_profile or "cloud-us" cfg.profiles.setdefault(name, {}).setdefault(product, {})[key] = value @@ -291,6 +296,10 @@ def config_doctor(obj: Any, probe: bool) -> None: aliases = [] problems.append(str(exc)) for alias in aliases: + # An alias carries a key of its own, so it is a second place a project + # file can name one -- and it falls back to the profile's key silently. + if detail := resolved.withheld_detail("deployments", alias, "api_key"): + problems.append(f"deployment alias {alias}: {detail}") try: # Resolved the way a run resolves it: that an alias is *listed* says # nothing about whether the settings behind it arrive. @@ -337,11 +346,30 @@ def config_doctor(obj: Any, probe: bool) -> None: emit_result(report, _fmt(obj)) +def _loaded(obj: Any) -> ConfigFile: + """The config file, with its warnings reported. + + These commands load the file themselves rather than through the root + context, and they are the two a user runs *to understand* their config -- + reading it here without repeating what it warned about would make them the + quietest commands in the CLI about their own subject. + """ + cfg = load_config() + for warning in cfg.warnings: + diagnostic( + warning, + quiet=getattr(obj, "quiet", False), + verbosity=getattr(obj, "verbosity", 0), + ) + return cfg + + def _resolved(obj: Any) -> ResolvedConfig: """The root context's config, or a freshly loaded one when invoked standalone.""" + # Already loaded means the context already reported its warnings. if (existing := getattr(obj, "_config", None)) is not None: return existing - return ResolvedConfig(file=load_config(), profile_name=getattr(obj, "profile", None)) + return ResolvedConfig(file=_loaded(obj), profile_name=getattr(obj, "profile", None)) __all__ = ["config_group"] diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index d3b095c..269425a 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -15,6 +15,7 @@ from __future__ import annotations +import errno import os import stat import tomllib @@ -94,12 +95,16 @@ def find_project_config(start: Path | None = None) -> Path | None: project picks up that project's config with no flag. The search stops at the filesystem root, and at ``$HOME`` so a stray file in a parent directory cannot silently capture every invocation. + + A symlinked candidate is skipped rather than followed: the file it points at + is chosen by whoever wrote the link, and this path is written to as well as + read from -- `config set` and `config init --force` would rewrite the target. """ current = (start or Path.cwd()).resolve() home = Path.home().resolve() for directory in (current, *current.parents): candidate = directory / PROJECT_CONFIG_NAME - if candidate.is_file(): + if candidate.is_file() and not candidate.is_symlink(): return candidate if directory == home: break @@ -192,10 +197,22 @@ def walk(node: Any, trail: tuple[str, ...]) -> None: return withheld +def _is_discovered(path: Path) -> bool: + """Whether this path is the file an upward search would have found. + + Trust follows the file, not the call: naming the project-local file that + discovery would have picked anyway does not make its contents any more the + user's own. ``--config`` and ``$UNSTRACT_CONFIG`` are a deliberate choice and + are resolved before this, so they stay trusted. + """ + candidate = find_project_config() + return candidate is not None and candidate.resolve() == path.resolve() + + def load_config(path: Path | None = None) -> ConfigFile: """Load the config file. A missing file is normal, not an error.""" if path is not None: - target, project_local = path, False + target, project_local = path, _is_discovered(path) else: target, project_local = _resolve_config_path() if not target.exists(): @@ -282,8 +299,20 @@ def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: doc["profiles"] = _restored_profiles(cfg, target) # Create with 0600 from the outset rather than widening then narrowing: a - # world-readable window, however brief, is a window. - fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + # world-readable window, however brief, is a window. O_NOFOLLOW because this + # write truncates: a symlink here means some other file is what actually gets + # overwritten, and the config path is not always one the user chose. + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(target, flags, 0o600) + except OSError as exc: + if exc.errno not in (errno.ELOOP, errno.EMLINK): + raise + raise ConfigError( + f"Refusing to write config through the symlink at {target}: it would " + f"overwrite {os.readlink(target)} instead. Pass --config with the path " + "of the real file." + ) from exc with os.fdopen(fd, "wb") as fh: tomli_w.dump(doc, fh) os.chmod(target, 0o600) @@ -464,15 +493,25 @@ def resolution_source(self, product: str, key: str) -> dict[str, Any]: if key == "base_url" and DEFAULT_BASE_URLS.get(product) else {"resolved": False, "source": "unset"} ) - if (self.active_profile, product, key) in self.file.withheld: - # The file does set it; reporting only where the value came from - # would leave the user staring at a setting they can see in the file. - report["detail"] = ( - f"{self.file.path} sets {key}, and a discovered " - f"{PROJECT_CONFIG_NAME} is not trusted with it." - ) + if detail := self.withheld_detail(product, key): + report["detail"] = detail return report + def withheld_detail(self, *trail: str) -> str | None: + """Why a setting the config file plainly holds did not arrive, if that is why. + + Reporting only where a value came *from* would leave the user staring at + a setting they can see in the file. Takes a trail rather than a + product/key pair so a deployment alias's own key -- nested a level deeper + -- is answerable too. + """ + if (self.active_profile, *trail) not in self.file.withheld: + return None + return ( + f"{self.file.path} sets {trail[-1]}, and a discovered " + f"{PROJECT_CONFIG_NAME} is not trusted with it." + ) + def starter_profiles() -> dict[str, dict[str, Any]]: """Profile stubs written by `config init`. diff --git a/tests/test_config.py b/tests/test_config.py index d75a441..41bca53 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -273,6 +273,36 @@ def test_withheld_keys_are_not_carried_into_a_file_the_user_names(tmp_path, monk assert "api_key" not in load_config().profiles["p"]["llmwhisperer"] +def test_a_symlinked_project_candidate_is_not_discovered(tmp_path, monkeypatch): + work = tmp_path / "checkout" + work.mkdir() + victim = tmp_path / "victim.toml" + victim.write_text("keep = true\n", encoding="utf-8") + (work / ".unstract.toml").symlink_to(victim) + monkeypatch.chdir(work) + + assert find_project_config(work) is None + assert config_path() != work / ".unstract.toml" + + +def test_a_write_through_a_symlink_fails_without_touching_its_target(tmp_path): + victim = tmp_path / "victim.toml" + victim.write_text("keep = true\n", encoding="utf-8") + link = tmp_path / "config.toml" + link.symlink_to(victim) + + with pytest.raises(ConfigError, match="symlink"): + save_config(ConfigFile(profiles=starter_profiles()), link) + assert victim.read_text(encoding="utf-8") == "keep = true\n" + + +def test_a_withheld_alias_key_is_reported_against_the_alias(tmp_path, monkeypatch): + _plant_project_config(tmp_path, monkeypatch) + cfg = resolved() + assert cfg.withheld_detail("deployments", "invoices", "api_key") + assert cfg.withheld_detail("deployments", "invoices", "org_id") is None + + def test_starter_profiles_hold_no_literal_secrets(): for blocks in starter_profiles().values(): for settings in blocks.values(): From 14bdda2aa38a65564ef37802ec4a2141156dc293 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 13 Aug 2026 16:36:08 +0530 Subject: [PATCH 30/86] docs: one key can cover every deployment, and say where it is minted An organization-wide API deployment key authenticates every deployment in the org, so the starter config and the README now show one key on the product block with aliases carrying only api_name; a per-alias key is for an org whose deployments hold separate keys. The missing-credential text names the third place a key comes from, and the 401 hint no longer implies the key is simply wrong: a key that works elsewhere can be rejected here for covering a different deployment or another organization, and the responses are indistinguishable. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 8 +++++++- src/unstract_cli/config.py | 11 +++++++++-- src/unstract_cli/core/errors.py | 9 +++++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 99750ad..273bbf2 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,14 @@ api_key = "env:UNSTRACT_DEPLOYMENT_KEY" api_name = "invoice-parser" ``` +One `api_key` on the `docstudio` block covers every alias under it: a key minted +under **Settings → API Key Manager** authenticates every API deployment in the +organisation, so an alias normally carries only its `api_name`. Give an alias its +own `api_key` when its deployment has a separate key of its own. + Get an LLMWhisperer key from the LLMWhisperer console; a deployment key is shown -on the API deployment's own page in the Unstract UI. `config init` also writes an +on the API deployment's own page in the Unstract UI, and an organisation-wide one +under Settings → API Key Manager. `config init` also writes an `onprem-example` profile as a shape to copy for a self-hosted install — its host is a placeholder, and only the *active* profile is ever resolved. diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 269425a..12152c8 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -52,7 +52,9 @@ #: missing: knowing a key is unset is no help without knowing where one is made. KEY_SOURCES = ( "Get an LLMWhisperer key from the LLMWhisperer console; a deployment key is " - "shown on the API deployment's own page in the Unstract UI." + "shown on the API deployment's own page in the Unstract UI, and a key " + "covering every deployment in the organisation is minted under " + "Settings -> API Key Manager." ) @@ -518,6 +520,11 @@ def starter_profiles() -> dict[str, dict[str, Any]]: Every credential uses ``env:`` indirection: the generated file is a map of where secrets live, never a copy of them. + + One key on the product block, and aliases that carry only ``api_name``: a + key can cover every deployment in the organisation, so a key per alias is + the exception -- for an organisation whose deployments hold separate keys -- + rather than the shape to start from. """ return { "cloud-us": { @@ -530,7 +537,7 @@ def starter_profiles() -> dict[str, dict[str, Any]]: "org_id": "", "api_key": "env:UNSTRACT_DEPLOYMENT_KEY", }, - "deployments": {}, + "deployments": {"example": {"api_name": "your-api-deployment-name"}}, }, "cloud-eu": { LLMWHISPERER: { diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index 772d5db..eae0c44 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -244,9 +244,14 @@ def hint_for(status: int) -> str | None: "values passed; `details` carries the service's own response." ) case 401 | 403: + # A key that is wrong, revoked, from another organisation, or simply + # not permitted on this one deployment all arrive as the same + # response, so the hint must not settle on one of them. return ( - "Check the API key for this product. Keys are per-product: " - "`unstract config doctor` reports which one resolved and from where." + "The key was rejected. Keys are per-product: `unstract config " + "doctor` reports which one resolved and from where. A key that " + "works elsewhere can still be rejected here -- it may not cover " + "this deployment, or may belong to another organisation." ) case 404: return ( From 48ed23fa3a0ed8226a4d8a33e6768c49a45240a0 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 13 Aug 2026 16:52:14 +0530 Subject: [PATCH 31/86] test: pin the trust classification and the config group's own warnings Reverting either left the suite green: the discovered file classified as project-local however its path is spelled, and the config group reporting what the loader withheld. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- tests/test_cli.py | 17 +++++++++++++++++ tests/test_config.py | 27 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2db5665..7184189 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -178,6 +178,23 @@ def test_every_envelope_carries_the_contract_version(self, capsys): assert run(capsys, "nope")[1]["meta"]["contract_version"] == 1 +def test_the_config_group_says_what_it_withheld(capsys, tmp_path, monkeypatch): + """`config list` is one of the commands run *to understand* the config. + + It loads the file itself rather than through the root context, so it has to + report the file's warnings on its own or stay silent about its own subject. + """ + work = tmp_path / "checkout" + work.mkdir() + (work / ".unstract.toml").write_text( + '[profiles.p.llmwhisperer]\napi_key = "planted"\n', encoding="utf-8" + ) + monkeypatch.chdir(work) + + _, _, err = run(capsys, "config", "list") + assert err.count("Ignoring p.llmwhisperer.api_key") == 1 + + def test_click_parameter_info_dict_keeps_the_keys_discovery_reads(): # Discovery derives flags from Click's own introspection; a Click bump that # reshaped this dict would silently degrade it. diff --git a/tests/test_config.py b/tests/test_config.py index 41bca53..60b1331 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,6 +3,7 @@ from __future__ import annotations import stat +from pathlib import Path import pytest @@ -10,6 +11,7 @@ DEFAULT_BASE_URLS, DOCSTUDIO, LLMWHISPERER, + PROJECT_CONFIG_NAME, ConfigError, ConfigFile, ResolvedConfig, @@ -273,6 +275,31 @@ def test_withheld_keys_are_not_carried_into_a_file_the_user_names(tmp_path, monk assert "api_key" not in load_config().profiles["p"]["llmwhisperer"] +def test_naming_the_discovered_file_does_not_make_it_trusted(tmp_path, monkeypatch): + path = _plant_project_config(tmp_path, monkeypatch) + work = path.parent + (work / "sub").mkdir() + (tmp_path / "link").symlink_to(work) + + # The outcome first: the flag is only the mechanism, withholding is the point. + cfg = ResolvedConfig(file=load_config(path)) + assert cfg.get(LLMWHISPERER, "api_key") is None + assert cfg.get(LLMWHISPERER, "base_url") == DEFAULT_BASE_URLS[LLMWHISPERER] + + # However the same file is spelled, it is the same file. + for spelling in ( + Path(PROJECT_CONFIG_NAME), + path, + work / "sub" / ".." / PROJECT_CONFIG_NAME, + tmp_path / "link" / PROJECT_CONFIG_NAME, + ): + assert load_config(spelling).is_project_local is True, spelling + + other = tmp_path / "elsewhere.toml" + other.write_text(PROJECT_TOML, encoding="utf-8") + assert load_config(other).is_project_local is False + + def test_a_symlinked_project_candidate_is_not_discovered(tmp_path, monkeypatch): work = tmp_path / "checkout" work.mkdir() From f6da68e027c8e3a8b93d83ed42efbac84057cd44 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 13 Aug 2026 17:41:31 +0530 Subject: [PATCH 32/86] test: snapshot what each derived flag accepts, not just its name A spec resync that narrows an enum, changes a type or moves a default left the gate green while the CLI began rejecting a value it used to take. The snapshot now carries the whole parameter surface, and the failure names the flags that moved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- tests/derived_flags.json | 507 +++++++++++++++++++++++++++++++++++---- tests/test_contract.py | 39 ++- 2 files changed, 488 insertions(+), 58 deletions(-) diff --git a/tests/derived_flags.json b/tests/derived_flags.json index 6cc79f4..bca07c9 100644 --- a/tests/derived_flags.json +++ b/tests/derived_flags.json @@ -1,53 +1,458 @@ { - "llmwhisperer:extract": [ - "--add-line-nos", - "--allow-rotated-text", - "--checkbox-confidence-threshold", - "--derotate-threshold", - "--file-name", - "--gaussian-blur-radius", - "--horizontal-stretch-factor", - "--ignore-vertical-text", - "--include-line-confidence", - "--lang", - "--line-splitter-strategy", - "--line-splitter-tolerance", - "--mark-horizontal-lines", - "--mark-vertical-lines", - "--median-filter-size", - "--min-table-width", - "--mode", - "--output-mode", - "--page-separator", - "--pages-to-extract", - "--tag", - "--url", - "--use-webhook", - "--watermark-angle-threshold", - "--webhook-metadata", - "--word-confidence-threshold" - ], - "llmwhisperer:highlights": [ - "--extract-all-lines", - "--lines", - "--whisper-hash" - ], - "docstudio:execute": [ - "--custom-data", - "--hitl-packet-id", - "--hitl-queue-name", - "--include-extracted-text", - "--include-metadata", - "--include-metrics", - "--llm-profile-id", - "--presigned-urls", - "--tags", - "--timeout", - "--use-file-history" - ], - "docstudio:status": [ - "--include-extracted-text", - "--include-metadata", - "--include-metrics" - ] + "llmwhisperer:extract": { + "--add-line-nos": { + "name": "add_line_nos", + "type": "boolean", + "default": false, + "description": "Adds line numbers to the extracted text and saves line metadata, which can be queried later using the highlights API.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--allow-rotated-text": { + "name": "allow_rotated_text", + "type": "boolean", + "default": true, + "description": "Whether to keep words whose own orientation is rotated. With this off, a word angled further than watermark_angle_threshold is treated as a watermark and excluded.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--checkbox-confidence-threshold": { + "name": "checkbox_confidence_threshold", + "type": "number", + "default": 0.3, + "description": "The minimum confidence a detected checkbox mark must have to be reported as marked. Accepts a value in the range [0.0, 1.0].", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--derotate-threshold": { + "name": "derotate_threshold", + "type": "number", + "default": 10.0, + "description": "The page rotation in degrees beyond which the page is straightened and re-read.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--file-name": { + "name": "file_name", + "type": "string", + "default": null, + "description": "The name of the file to store in reports.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--gaussian-blur-radius": { + "name": "gaussian_blur_radius", + "type": "integer", + "default": 0, + "description": "The radius of the Gaussian blur.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--horizontal-stretch-factor": { + "name": "horizontal_stretch_factor", + "type": "number", + "default": 1.0, + "description": "The horizontal stretch factor.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--ignore-vertical-text": { + "name": "ignore_vertical_text", + "type": "boolean", + "default": false, + "description": "Whether to drop vertically oriented text instead of extracting it.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--include-line-confidence": { + "name": "include_line_confidence", + "type": "boolean", + "default": false, + "description": "Adds line confidence to the line metadata returned by the highlights API. Requires add_line_nos to be enabled.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--lang": { + "name": "lang", + "type": "string", + "default": "eng", + "description": "The language of the document.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--line-splitter-strategy": { + "name": "line_splitter_strategy", + "type": "string", + "default": null, + "description": "The line splitter strategy.", + "array": false, + "nullable": false, + "required": false, + "choices": [ + "left-priority", + "mid-priority", + "right-priority" + ] + }, + "--line-splitter-tolerance": { + "name": "line_splitter_tolerance", + "type": "number", + "default": 0.4, + "description": "The line splitter tolerance.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--mark-horizontal-lines": { + "name": "mark_horizontal_lines", + "type": "boolean", + "default": false, + "description": "Whether to mark horizontal lines.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--mark-vertical-lines": { + "name": "mark_vertical_lines", + "type": "boolean", + "default": false, + "description": "Whether to mark vertical lines.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--median-filter-size": { + "name": "median_filter_size", + "type": "integer", + "default": 0, + "description": "The size of the median filter.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--min-table-width": { + "name": "min_table_width", + "type": "number", + "default": 0.0, + "description": "The minimum width a table must span, as a fraction of the page width, to be extracted as a table.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--mode": { + "name": "mode", + "type": "string", + "default": "form", + "description": "The processing mode.", + "array": false, + "nullable": false, + "required": false, + "choices": [ + "document_insights", + "excel", + "form", + "high_quality", + "low_cost", + "native_text", + "pdf_to_images", + "table" + ] + }, + "--output-mode": { + "name": "output_mode", + "type": "string", + "default": "layout_preserving", + "description": "The output mode.", + "array": false, + "nullable": false, + "required": false, + "choices": [ + "dump-text", + "layout_preserving", + "line-printer", + "text" + ] + }, + "--page-separator": { + "name": "page_separator", + "type": "string", + "default": null, + "description": "The page separator.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--pages-to-extract": { + "name": "pages_to_extract", + "type": "string", + "default": "", + "description": "The pages to extract.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--tag": { + "name": "tag", + "type": "string", + "default": "default", + "description": "The tag for the document.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--url": { + "name": "url", + "type": "string", + "default": "", + "description": "Fetch the document from this URL instead of sending a body.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--use-webhook": { + "name": "use_webhook", + "type": "string", + "default": "", + "description": "Webhook name to call. If not provided, then no webhook will be called.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--watermark-angle-threshold": { + "name": "watermark_angle_threshold", + "type": "number", + "default": 25.0, + "description": "The angle in degrees beyond which a rotated word counts as a watermark. Only applies when allow_rotated_text is off.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--webhook-metadata": { + "name": "webhook_metadata", + "type": "string", + "default": "", + "description": "The webhook metadata. This data will be passed to the webhook if webhooks are used", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--word-confidence-threshold": { + "name": "word_confidence_threshold", + "type": "number", + "default": 0.3, + "description": "The minimum OCR confidence score a word must have to be included in the extracted text. Accepts a value in the range [0.0, 1.0], where higher values are stricter. Any word whose confidence value falls below the configured threshold is ignored and excluded from the final output. This parameter works only with \"form\", \"high_quality\" and \"table\" modes.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + } + }, + "llmwhisperer:highlights": { + "--extract-all-lines": { + "name": "extract_all_lines", + "type": "boolean", + "default": false, + "description": "", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--lines": { + "name": "lines", + "type": "string", + "default": null, + "description": "Line numbers or ranges, e.g. `1-5,9`. Not required when `extract_all_lines=true`.", + "array": false, + "nullable": false, + "required": true, + "choices": [] + }, + "--whisper-hash": { + "name": "whisper_hash", + "type": "string", + "default": null, + "description": "The hash of the whisper operation.", + "array": false, + "nullable": false, + "required": true, + "choices": [] + } + }, + "docstudio:execute": { + "--custom-data": { + "name": "custom_data", + "type": "string", + "default": null, + "description": "Arbitrary data echoed back with the result.", + "array": false, + "nullable": true, + "required": false, + "choices": [] + }, + "--hitl-packet-id": { + "name": "hitl_packet_id", + "type": "string", + "default": null, + "description": "Human-in-the-loop packet to attach the file to.", + "array": false, + "nullable": true, + "required": false, + "choices": [] + }, + "--hitl-queue-name": { + "name": "hitl_queue_name", + "type": "string", + "default": null, + "description": "Human-in-the-loop queue to route the file to.", + "array": false, + "nullable": true, + "required": false, + "choices": [] + }, + "--include-extracted-text": { + "name": "include_extracted_text", + "type": "boolean", + "default": false, + "description": "Include the extracted text.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--include-metadata": { + "name": "include_metadata", + "type": "boolean", + "default": false, + "description": "Include metadata in the result.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--include-metrics": { + "name": "include_metrics", + "type": "boolean", + "default": false, + "description": "Include metrics in the result.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--llm-profile-id": { + "name": "llm_profile_id", + "type": "string", + "default": null, + "description": "LLM profile to override the deployment's.", + "array": false, + "nullable": true, + "required": false, + "choices": [] + }, + "--presigned-urls": { + "name": "presigned_urls", + "type": "string", + "default": null, + "description": "URLs to fetch the inputs from.", + "array": true, + "nullable": false, + "required": false, + "choices": [] + }, + "--tags": { + "name": "tags", + "type": "string", + "default": "", + "description": "Comma-separated list of tag names (EX:'tag1,tag2-name,tag3_name')", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--timeout": { + "name": "timeout", + "type": "integer", + "default": -1, + "description": "Execution mode \u2014 ``0`` or below runs asynchronously.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--use-file-history": { + "name": "use_file_history", + "type": "boolean", + "default": false, + "description": "Reuse a previous result for the same file.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + } + }, + "docstudio:status": { + "--include-extracted-text": { + "name": "include_extracted_text", + "type": "boolean", + "default": false, + "description": "Include the extracted text.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--include-metadata": { + "name": "include_metadata", + "type": "boolean", + "default": false, + "description": "Include metadata in the result.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + }, + "--include-metrics": { + "name": "include_metrics", + "type": "boolean", + "default": false, + "description": "Include metrics in the result.", + "array": false, + "nullable": false, + "required": false, + "choices": [] + } + } } diff --git a/tests/test_contract.py b/tests/test_contract.py index 3f565fc..788727b 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -12,7 +12,9 @@ import inspect import json import os +from dataclasses import asdict from pathlib import Path +from typing import Any import pytest from unstract.api_deployments.client import APIDeploymentsClient @@ -83,23 +85,46 @@ def test_every_derived_flag_is_an_argument_the_client_accepts(product, operation REFRESH = "UNSTRACT_CLI_REFRESH_FLAG_SNAPSHOT" -def _derived_flags() -> dict[str, list[str]]: +def _derived_flags() -> dict[str, dict[str, Any]]: + """Every flag the specs derive, with the whole of what each one accepts. + + Names alone would let a spec narrow an enum, or change a type or a default, + without moving the snapshot -- and the CLI would start rejecting a value it + used to take, with nothing here to say so. + """ return { - f"{product}:{operation}": sorted( - param.flag - for param in derive_params(product, operation, client_method=method) - ) + f"{product}:{operation}": { + # Choices as a list: JSON has no tuple, and the snapshot is compared + # against what a JSON reader gives back. + param.flag: {**asdict(param), "choices": list(param.choices)} + for param in sorted( + derive_params(product, operation, client_method=method), + key=lambda param: param.flag, + ) + } for product, operation, method, _ in COMMANDS } +def _changed(current: dict[str, Any], expected: dict[str, Any]) -> list[str]: + """The flags that moved, named. Comparing whole payloads reports neither.""" + return sorted( + f"{operation} {flag}" + for operation in current.keys() | expected.keys() + for flag in current.get(operation, {}).keys() | expected.get(operation, {}).keys() + if current.get(operation, {}).get(flag) != expected.get(operation, {}).get(flag) + ) + + def test_the_derived_flags_are_the_ones_last_reviewed(): current = _derived_flags() if os.environ.get(REFRESH): SNAPSHOT.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8") expected = json.loads(SNAPSHOT.read_text(encoding="utf-8")) assert current == expected, ( - "The flags derived from the vendored specs have changed. A flag that " - "disappears here disappears from the CLI. Review the difference, then " + "What the vendored specs derive has changed: " + f"{', '.join(_changed(current, expected))}. A flag that disappears here " + "disappears from the CLI, and a choice or a type that narrows here " + "rejects a value the CLI used to take. Review the difference, then " f"refresh the snapshot with {REFRESH}=1." ) From 473356d047b88b023719ac22cab96d9daddd01f2 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 17 Aug 2026 11:40:18 +0530 Subject: [PATCH 33/86] minor: Edit in discover docstring to clarify intent --- src/unstract_cli/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 9019c5a..efbf450 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -137,7 +137,7 @@ def secrets(self) -> list[str]: "discover_tier", type=click.Choice(TIERS), default=None, - help="Describe this CLI as JSON instead of running a command.", + help="Describe this CLI as JSON instead of running a command, useful for agents.", ) @click.version_option(package_name="unstract-cli") @click.pass_context From 014d28cdc6d91c0fac6d85d5a66c9caa1c24759c Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 18 Aug 2026 17:59:12 +0530 Subject: [PATCH 34/86] fix: treat an empty config value as unset `config init` writes a placeholder for every setting only the user can supply. An empty string satisfied `require`, so a request went out with a hole in it instead of failing with a message naming the setting. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFSunNN6RKRA1xo6kWkztx --- src/unstract_cli/config.py | 18 ++++++++++-------- tests/test_config.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 12152c8..922d0e7 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -148,18 +148,20 @@ def _deref(value: Any) -> Any: An unset variable resolves to ``None`` rather than the literal string, so a missing credential surfaces as "not configured" instead of being sent as the nonsense value ``"env:FOO"``. + + An empty string resolves the same way: the placeholders a generated config + carries must not satisfy `require`. """ - if isinstance(value, str) and value.startswith("env:"): - return os.environ.get(value[4:].strip()) or None + if isinstance(value, str): + if value.startswith("env:"): + return os.environ.get(value[4:].strip()) or None + return value or None return value -#: Settings a *discovered* project-local file may not supply. Such a file is -#: attacker-controlled in any checkout the user did not write, and combined with -#: ``env:`` indirection it would otherwise point the CLI at a host of the -#: author's choosing and hand it the user's real key as a Bearer token. -#: Everything else -- org_id, profile selection, deployment aliases -- is still -#: honoured, so the project-local workflow keeps working. +#: Settings a *discovered* project-local file may not supply: a checkout the +#: user did not write must not choose the host their key is sent to. Everything +#: else -- org_id, profile selection, deployment aliases -- is still honoured. UNTRUSTED_PROJECT_KEYS = frozenset({"api_key", "base_url"}) diff --git a/tests/test_config.py b/tests/test_config.py index 60b1331..739d192 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -92,6 +92,22 @@ def test_require_names_every_way_to_supply_the_setting(): assert "--api-key" not in message +def test_placeholder_is_not_a_value(write_config): + """`config init` writes `org_id = ""`, and that must not satisfy `require`.""" + write_config('default_profile = "p"\n\n[profiles.p.docstudio]\norg_id = ""\n') + assert resolved().get(DOCSTUDIO, "org_id") is None + assert resolved().resolution_source(DOCSTUDIO, "org_id")["resolved"] is False + with pytest.raises(ConfigError): + resolved().require(DOCSTUDIO, "org_id") + + +def test_starter_profile_org_id_does_not_satisfy_require(write_config): + path = write_config("") + save_config(ConfigFile(default_profile="cloud-us", profiles=starter_profiles()), path) + with pytest.raises(ConfigError): + resolved().require(DOCSTUDIO, "org_id") + + def test_unknown_profile_is_an_error_not_a_silent_empty_block(write_config): write_config(PROFILE_TOML) with pytest.raises(ConfigError, match="not found"): From b42691f236585358b7da9c84e6707e0394c94aa4 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 18 Aug 2026 17:59:21 +0530 Subject: [PATCH 35/86] docs: drop the release notes and the runbook Both restated the README for an audience that has neither shipped nor operated this yet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFSunNN6RKRA1xo6kWkztx --- RELEASE_NOTES.md | 48 --------------- RUNBOOK.md | 153 ----------------------------------------------- 2 files changed, 201 deletions(-) delete mode 100644 RELEASE_NOTES.md delete mode 100644 RUNBOOK.md diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md deleted file mode 100644 index 00cd7ac..0000000 --- a/RELEASE_NOTES.md +++ /dev/null @@ -1,48 +0,0 @@ -# Release notes — draft - -Content for the first release. Not published yet. - -## What this is - -One CLI for the Unstract suite: extract a document with LLMWhisperer, run it -through a Document Studio API deployment, clone one organization's resources -into another. Install it with `pipx`, then `unstract config init`. - -## The `unstract` command name - -`unstract-client` released before this CLI installed a console script called -`unstract` too, and that script has been removed there — its clone command is -now `python -m unstract.clone`, and this CLI's `unstract clone` wraps the same -code. An environment holding an older `unstract-client` alongside this package -gives the name to whichever was installed last: - -```bash -command -v unstract && unstract --version -``` - -`pipx` avoids the question by giving this CLI its own environment. A second -console script, `unstract-cli`, always belongs to this package. - -## Behaviour worth knowing before you script against it - -- **A failure the service reports inside a successful HTTP response exits 5 - (validation), not 8 (server error).** Exit 8 invites a retry, and on an API - that bills per execution a blind retry is a second charge for work that was - already done. The service's own report is in `error.details`. -- **`clone` exits 0 when nothing failed, which is not the same as everything - having moved.** Oversize and unsupported documents are skipped by design; - `data.skipped` counts them. -- **`config doctor` exits non-zero when one of its own checks failed**, so a - setup script can branch on it. A setting that is simply not configured is - reported, not failed. -- **A custom `page_separator` needs LLMWhisperer v2.64.2 or later.** An older - service reads only the previous spelling of the parameter, falls back to the - default `<<<` separator, and reports no error. - -## Consuming the output - -Pass `-o json`: stdout is then exactly one `{ok, data, error, meta}` envelope on -success and on failure alike. Ignore fields you do not recognise, refuse a -`meta.contract_version` above the one you were written against, and branch on -the exit code rather than on message text. `unstract --discover full` publishes -the whole contract alongside every command and flag. diff --git a/RUNBOOK.md b/RUNBOOK.md deleted file mode 100644 index 703aec4..0000000 --- a/RUNBOOK.md +++ /dev/null @@ -1,153 +0,0 @@ -# Runbook - -Maintainer procedures. For what the CLI does and how to configure it, see the -[README](README.md); this file covers the things that are done *to* the CLI — -installing a build, moving the client pins, proving a build against real -services, and cutting a release. - -## Install - -### From a published ref - -```bash -pipx install git+https://github.com/Zipstack/unstract-cli -unstract --version -``` - -Pin the ref when reproducing a report: - -```bash -pipx install "git+https://github.com/Zipstack/unstract-cli@" -``` - -`pipx` puts each install in its own virtualenv, which matters here: the two -clients are pinned to exact commits, and a shared environment would let another -package's resolver move them. - -### Other names for the same CLI - -- `unstract-cli` — a second console script this package always owns. -- `python -m unstract_cli` — works from a source checkout with no install at all. - -`unstract-client` released before this CLI installed a console script called -`unstract` too. An environment that still holds one of those versions gives the -name to whichever package was installed last, so check what answers before -filing a bug about a missing command: - -```bash -command -v unstract && unstract --version -``` - -### From a checkout - -```bash -uv venv && uv pip install -e '.[dev]' -pytest # offline: no network, no credentials -ruff check . -``` - -## Moving the client pins - -The CLI derives its flags from the vendored specs intersected with the pinned -clients' signatures, and takes flag help from those clients' docstrings. Moving -a pin therefore changes the CLI's surface without a line of CLI code changing. -That is the intent, so the check is that the change was the intended one: - -1. Update the `unstract-client` and/or `llmwhisperer-client` ref in - `pyproject.toml`. -2. Refresh the vendored spec if the service's spec moved too — see - [`src/unstract_cli/specs/README.md`](src/unstract_cli/specs/README.md). - A spec and a client from different commits is exactly the state - `tests/test_contract.py` exists to catch. -3. `uv pip install -e '.[dev]' && pytest`. -4. Diff the surface before and after: - - ```bash - python -m unstract_cli -o json --discover full > after.json - ``` - - Every added or removed flag should be one you can name a reason for. - `tests/test_contract.py` pins the spec parameters no command can reach; that - set should only ever shrink, and only on purpose. - -Both pins move to released versions before this ships publicly. - -## Live gate - -The offline suite proves the CLI is self-consistent. It cannot prove the -services agree, and the defects worth catching here have all been of that kind: -a payload shaped differently from the spec, a status code meaning something -other than it appears to, geometry that divides by a value the service reports -as zero. Run this against a real tenant before tagging a release. - -### Credentials - -Supply them through the environment, never on the command line and never in a -file inside this repository: - -```bash -export LLMWHISPERER_API_KEY=... -export UNSTRACT_DEPLOYMENT_KEY=... -export UNSTRACT_BASE_URL=https:// -export UNSTRACT_ORG_ID=org_... -``` - -Use a staging tenant. Passing `--api-key` works and warns, because a key on the -command line lands in shell history and in the process list. - -### Checklist - -Run against a document you can re-send; several of these submit real work. - -| # | Command | Pass | -|---|---|---| -| 1 | `config doctor --probe` | every setting reports where it resolved from; the LLMWhisperer probe answers live; exit 0 when nothing failed, and exit 1 with the same report under `error.details` when something did | -| 2 | `whisper extract ` | polls to completion, returns text | -| 3 | `whisper extract --no-wait` then `whisper status ` then `whisper retrieve ` | the handle survives the round trip | -| 4 | `whisper retrieve ` a second time | refused, exit 9, and the error names the one-shot read | -| 5 | `whisper highlights --target-width 800 --target-height 1000` | bounding boxes for the lines that carry geometry, and no traceback for the lines that do not | -| 6 | `whisper usage` | quota returned | -| 7 | `docstudio deployment run ` | polls to completion, returns structured JSON | -| 8 | `docstudio deployment run --no-wait`, then `docstudio deployment status ` from the run envelope | the handle survives the round trip | -| 9 | any command with `-o raw` | one field, not the envelope | -| 10 | any command with `-o json` and a wrong key | exit 3, JSON envelope on stdout, no traceback | -| 11 | any command with `-o json` and a path that does not exist | exit 2, JSON envelope on stdout | -| 12 | any command with no `-o` | a table, in a terminal and through a pipe alike | -| 13 | `clone --source-url ... --target-url ... --dry-run` | the plan is reported and nothing is written to the target | - -Two properties matter more than any single row, because they are what a caller -depends on and what breaks quietly: - -- **With `-o json`, stdout is one envelope in every case above, including the - failures.** - A traceback on stderr with empty stdout is a bug even when the exit code is - right. -- **A flag passed explicitly reaches the wire, including when its value is - falsy.** `--no-include-metadata` must produce a different payload than passing - nothing at all. A flag that is silently dropped looks identical to a flag that - worked. - -### Interpreting a failure - -A live failure is a finding about the CLI, the client, or the service, in that -order of likelihood — check which layer the response actually came from before -changing anything. Fixes go in the facade or the spec; never in a generated -directory, whose contents are replaced wholesale on the next generation. - -## Release - -1. Live gate green against staging. -2. `pytest` and `ruff check .` clean. -3. Both client pins on released versions, not commits. -4. Tag, then verify the tag installs clean in an environment that has nothing - else in it: - - ```bash - pipx install --force "git+https://github.com/Zipstack/unstract-cli@" - unstract-cli --version - unstract-cli --discover groups - ``` - -5. `--discover groups` on the fresh install should match the checkout's. It is - the cheapest proof that the built wheel carries the specs — they are package - data, and package data is what a build configuration silently drops. From 88ee5c8b197eff0d96d2fcc2f9236169b9367382 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 18 Aug 2026 17:59:21 +0530 Subject: [PATCH 36/86] docs: install with uv, and answer what the README left open Install and dev commands go through uv, matching how the project is built and tested. The exit-code table says it is this CLI's own convention and names the enum it copies, and a test now fails when the two disagree. The credential section says a literal key works and why `env:` is the default. `clone` reads as the operator command it is, so an agent does not reach for it unasked, and the connection flags are named as the top tier of the resolution chain. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFSunNN6RKRA1xo6kWkztx --- README.md | 47 ++++++++++++++++++++++++++++++-------------- tests/test_errors.py | 17 ++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 273bbf2..9915d1d 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ LLMWhisperer, run it through a Document Studio API deployment, get structured JSON back. It also clones one organization's resources into another. ```bash -pipx install git+https://github.com/Zipstack/unstract-cli +uv tool install git+https://github.com/Zipstack/unstract-cli unstract config init unstract config doctor ``` +Or run it without installing: `uvx --from git+https://github.com/Zipstack/unstract-cli unstract --discover groups`. + ## Output `unstract` prints a table by default — in a terminal and in a pipe alike, so @@ -35,7 +37,10 @@ If a coding agent is driving (detected from the environment it sets), the *default* becomes json. `--agent yes|no` forces that either way, and an explicit `-o` always wins over both. -Failures exit non-zero with a stable code: +Failures exit non-zero with a stable code. The codes are this CLI's own +convention, not a service's — they are the `ExitCode` enum in +`core/errors.py`, and `--discover full` publishes the table so a caller does not +have to copy it: | Code | Meaning | |------|---------| @@ -57,7 +62,10 @@ Failures exit non-zero with a stable code: `~/.unstract/config.toml`, or a project-local `.unstract.toml` found by upward search, or `$UNSTRACT_CONFIG`, or `--config`. Every setting resolves **flag > env > profile > built-in default**, and the CLI is fully usable with no -config file at all. +config file at all. The flag tier is the connection options on each product +group — `unstract docstudio --base-url … --org-id … deployment run …`, and +`--base-url`/`--api-key` on `whisper` — which override the profile for that one +invocation without writing anything. ```toml default_profile = "cloud-us" @@ -86,11 +94,16 @@ under Settings → API Key Manager. `config init` also writes an `onprem-example` profile as a shape to copy for a self-hosted install — its host is a placeholder, and only the *active* profile is ever resolved. -Credentials use `env:VAR_NAME` indirection, so the file records where a secret -lives rather than the secret itself. `unstract config doctor` reports where each -setting resolved from — including whether an `env:` reference is actually set in -the current process — without echoing any value. It exits non-zero when one of -its own checks failed, so a setup script can branch on it. +A credential can be written into the file literally, but `env:VAR_NAME` +indirection is what `config init` writes and what the examples use: the file +then records where a secret lives rather than the secret itself, and stays safe +to copy or commit. Either way the file is created `0600`, and `config doctor` +warns when its mode is wider than that. + +`unstract config doctor` reports where each setting resolved from — including +whether an `env:` reference is actually set in the current process — without +echoing any value. It exits non-zero when one of its own checks failed, so a +setup script can branch on it. A project-local `.unstract.toml` **found by upward search** may not supply `api_key` or `base_url`. Those are ignored, with a warning; everything else in it @@ -103,16 +116,20 @@ What that protects is the key and the host, not the routing: `org_id`, project file can still decide *which* deployment a command runs against on a host you trust. Read one before you run inside a checkout you did not write. -`clone` is the exception: it talks to two deployments at once, which no single -profile describes, so it takes both endpoints as flags and both admin Platform -keys from `UNSTRACT_SRC_PLATFORM_KEY` / `UNSTRACT_TGT_PLATFORM_KEY`. It exits 0 -when nothing failed, which is not the same as everything having moved: oversize -and unsupported documents are skipped by design, and `data.skipped` counts them. +`clone` is the exception, and it is an operator command: a human moving one +organisation's resources into another, holding two admin Platform keys. It is +not part of the document-processing path the rest of this CLI wraps, so an agent +serving a user request should not reach for it unasked. It talks to two +deployments at once, which no single profile describes, so it takes both +endpoints as flags and both keys from `UNSTRACT_SRC_PLATFORM_KEY` / +`UNSTRACT_TGT_PLATFORM_KEY`. It exits 0 when nothing failed, which is not the +same as everything having moved: oversize and unsupported documents are skipped +by design, and `data.skipped` counts them. ## Development ```bash uv venv && uv pip install -e '.[dev]' -pytest # offline; no network, no credentials -ruff check . +uv run pytest # offline; no network, no credentials +uv run ruff check . ``` diff --git a/tests/test_errors.py b/tests/test_errors.py index 180aa6b..50a8d42 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + import pytest from unstract_cli.core.errors import ( @@ -106,3 +108,18 @@ def test_scrub_ignores_short_values(): # Redacting a 3-character "key" would mangle unrelated text. assert scrub("the key is abc", ["abc"]) == "the key is abc" assert scrub("the key is abcdefghij", ["abcdefghij"]) == f"the key is {REDACTED}" + + +def test_the_readme_table_lists_every_exit_code(): + """The README table is a copy of the enum, and the only one users read.""" + readme = (Path(__file__).resolve().parents[1] / "README.md").read_text() + documented = { + int(row.split("|")[1]) for row in readme.splitlines() if _is_code_row(row) + } + + assert documented == {int(code) for code in ExitCode} + + +def _is_code_row(row: str) -> bool: + cells = row.split("|") + return len(cells) > 2 and cells[1].strip().isdigit() From f2553a43028a9ba417130c3f8b976e098b39301e Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 18 Aug 2026 17:59:47 +0530 Subject: [PATCH 37/86] docs: cut each comment back to the reason it exists Every comment that ran to three or more lines narrated the decision rather than naming it. Each is now one or two lines that hold up without the discussion they came from. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFSunNN6RKRA1xo6kWkztx --- pyproject.toml | 20 +++++++-------- src/unstract_cli/__main__.py | 4 +-- src/unstract_cli/app.py | 5 ++-- src/unstract_cli/commands/common.py | 4 +-- src/unstract_cli/commands/config_cmd.py | 3 +-- src/unstract_cli/commands/docstudio_cmd.py | 10 +++----- src/unstract_cli/commands/whisper_cmd.py | 10 +++----- src/unstract_cli/config.py | 30 ++++++++-------------- src/unstract_cli/core/clients.py | 6 ++--- src/unstract_cli/core/discover.py | 5 ++-- src/unstract_cli/core/errors.py | 16 +++++------- src/unstract_cli/core/output.py | 4 +-- src/unstract_cli/core/params.py | 29 ++++++++------------- src/unstract_cli/core/poll.py | 5 ++-- tests/test_contract.py | 16 ++++-------- 15 files changed, 62 insertions(+), 105 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7375ce9..ef13f4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,16 +6,14 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ - # Click is pinned to a major: `--discover` reads the shape of - # `click.Parameter.to_info_dict()`, which a major bump could reshape. + # Pinned to a major: `--discover` reads the shape of + # `click.Parameter.to_info_dict()`. "click>=8.1,<9", - # Zero transitive dependencies. Writing the config file only; reading it - # uses the stdlib `tomllib`. + # Writing the config file only; reading it uses the stdlib `tomllib`. "tomli-w>=1.0", - # Pinned to a commit, not a range: the CLI derives its flags from the specs - # these clients are generated from, and reads their docstrings for help - # text, so a client that moves underneath it changes the CLI's surface. - # Both pins move to released versions before this ships. + # Pinned to a commit: the CLI derives its flags and help text from these + # clients, so one that moves changes the CLI's surface. Both pins move to + # released versions before this ships. "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@a77ef6a", "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@7f64caf", ] @@ -28,15 +26,15 @@ dev = [ [project.scripts] unstract = "unstract_cli.__main__:main" -# `unstract-client` installs a script named `unstract` too, so whichever package -# is installed last wins. This name always reaches this CLI. +# `unstract-client` installs an `unstract` script too, so whichever package is +# installed last wins that name; this one always reaches this CLI. unstract-cli = "unstract_cli.__main__:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" -# The two clients are pinned to commits until they are released. +# Needed for the git-pinned clients above. [tool.hatch.metadata] allow-direct-references = true diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py index b866bf5..6150bf9 100644 --- a/src/unstract_cli/__main__.py +++ b/src/unstract_cli/__main__.py @@ -72,9 +72,7 @@ def main(argv: list[str] | None = None) -> int: ) ) except (click.Abort, KeyboardInterrupt): - # Click turns an interrupt into Abort, and nothing here prompts, so - # Abort means only that. Reporting it as a generic failure tells a - # supervisor to retry what the user deliberately stopped. + # Nothing here prompts, so Click's Abort can only mean an interrupt. return int( emit_error( CLIError("Interrupted.", ExitCode.INTERRUPTED, retryable=True), fmt diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index efbf450..46edd9a 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -165,9 +165,8 @@ def cli( profile=profile, ) if discover_tier: - # Answered without a subcommand and without touching configuration: - # discovery is how a caller finds out what to run, so it must work - # before anything is set up. + # Discovery is how a caller learns what to run, so it has to answer + # before any configuration exists. emit_result(discover(cli, discover_tier), ctx.obj.output) ctx.exit(int(ExitCode.SUCCESS)) if ctx.invoked_subcommand is None: diff --git a/src/unstract_cli/commands/common.py b/src/unstract_cli/commands/common.py index a2a88b8..600bc7c 100644 --- a/src/unstract_cli/commands/common.py +++ b/src/unstract_cli/commands/common.py @@ -10,9 +10,7 @@ from unstract_cli.app import Context from unstract_cli.core.output import emit_result -#: Seconds between polls, and the ceiling on the whole wait. Both are flags; the -#: defaults are a compromise between a fast small document and not hammering the -#: service while a large one runs. +#: Poll interval and the ceiling on the whole wait. Both are flags. DEFAULT_INTERVAL = 3.0 DEFAULT_TIMEOUT = 300.0 diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index a6d4828..aa02390 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -319,8 +319,7 @@ def config_doctor(obj: Any, probe: bool) -> None: for entry in products.values() if "api_key" in entry ): - # Not a problem -- an unconfigured setting is reported, not failed -- but - # the next question after "no key" is always where one comes from. The + # The next question after "no key" is always where one comes from. The # field name avoids the word the payload scrubber redacts on. report["getting_started"] = KEY_SOURCES if probe: diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index 79f2303..099fc89 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -39,9 +39,8 @@ #: `--output raw` prints one field rather than the whole payload. RAW_FIELD = "extraction_result" -#: Parameters the run POST and the status GET share. What a caller asked to be -#: included in the result has to be asked for again when the result is read, or a -#: waited run returns less than the same flags returned without --wait. +#: Parameters the run POST and the status GET share: what was asked for in the +#: run has to be asked for again when the result is read. _SHARED_WITH_STATUS = ("include_metadata", "include_metrics", "include_extracted_text") @@ -101,9 +100,8 @@ def run( click.echo(f"status: {status}", err=True) if not ctx.quiet else None ), ) - # The waited result identifies the execution nowhere at the top level, so a - # caller has nothing to correlate against the service. --no-wait returns the - # handle as data; waiting returns it as meta. + # A waited result names no execution, so the handle is returned as meta for + # correlation. finish(ctx, result, raw_field=RAW_FIELD, meta=_handle_meta(started)) diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index 3d11e29..9f834a5 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -28,9 +28,8 @@ PRODUCT = "llmwhisperer" -#: An extraction is finished when the *body* says so. `unknown` is terminal too: -#: the service reports it for a hash it no longer knows, and polling one forever -#: is worse than reporting it. +#: Terminal states as the body reports them. `unknown` is one: the service +#: returns it for a hash it no longer knows, which no amount of polling changes. EXTRACT_POLL = PollSpec( handle_field="whisper_hash", terminal_success=("processed",), @@ -90,9 +89,8 @@ def extract( ) with translated(endpoint="whisper"): - # The client has its own blocking loop; the CLI's is used instead so - # that --interval, --timeout and the handle-on-timeout behaviour are the - # same for every product. + # The CLI's own poll loop is used over the client's so that waiting + # behaves the same for every product. accepted = client.whisper( **({"url": source} if _is_url(source) else {"file_path": source}), **sent, diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 922d0e7..042705d 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -179,8 +179,7 @@ class ConfigFile: #: than named. Such a file is not trusted with credentials or hosts. is_project_local: bool = False #: Keys withheld from an untrusted file, as ``{(profile, *blocks, key): value}``. - #: They are excluded from *resolution* -- that is the security property -- but - #: kept here so a write-back does not delete them from the user's own file. + #: Excluded from resolution, but kept so a write-back does not drop them. withheld: dict[tuple[str, ...], Any] = field(default_factory=dict) @@ -243,9 +242,8 @@ def load_config(path: Path | None = None) -> ConfigFile: if not isinstance(profiles, dict): raise ConfigError(f"`profiles` in {target} must be a table.") - # Stripped rather than ignored wholesale, and said out loud: the rest of the - # file is the project's own workflow, and a setting dropped in silence is its - # own kind of surprise. + # Said out loud rather than dropped in silence; the rest of the file still + # applies. withheld: dict[tuple[str, ...], Any] = {} if project_local: withheld = _strip_untrusted(profiles) @@ -302,10 +300,8 @@ def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: doc["default_profile"] = cfg.default_profile doc["profiles"] = _restored_profiles(cfg, target) - # Create with 0600 from the outset rather than widening then narrowing: a - # world-readable window, however brief, is a window. O_NOFOLLOW because this - # write truncates: a symlink here means some other file is what actually gets - # overwritten, and the config path is not always one the user chose. + # 0600 from the outset, never widened even briefly. O_NOFOLLOW because this + # write truncates, and the path is not always one the user chose. flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) try: fd = os.open(target, flags, 0o600) @@ -359,10 +355,8 @@ def _profile(self) -> dict[str, Any]: return profile if isinstance(profile, dict) else {} def _product_block(self, product: str) -> dict[str, Any]: - # Exactly one accepted shape: settings nested under the product name. No - # aliases and no flat fallback -- a config that looks applied but is not - # is worse than one that plainly is not, because the failure surfaces - # later as a missing-credential error with no obvious cause. + # One accepted shape only, settings nested under the product name: a + # config that looks applied but is not fails later with no obvious cause. block = self._profile().get(product) return block if isinstance(block, dict) else {} @@ -401,9 +395,8 @@ def require(self, product: str, key: str) -> Any: if env_vars := ENV_VARS.get((product, key)): hints.append(f"set ${env_vars[0]}") hints.append(f"or add `{key}` to the [profiles..{product}] block") - # Only suggest a flag that actually exists. Credentials have no flag by - # design -- a secret on the command line lands in shell history and - # process listings. + # Credentials have no flag by design: a secret on the command line + # lands in shell history and in the process list. if key != "api_key": hints.append(f"or pass --{key.replace('_', '-')}") raise ConfigError( @@ -547,9 +540,8 @@ def starter_profiles() -> dict[str, dict[str, Any]]: "api_key": "env:LLMWHISPERER_API_KEY", }, }, - # A shape to copy for a self-hosted install, not a profile to select: the - # host is a placeholder, and only the *active* profile is ever resolved, - # so leaving it in place costs nothing. + # A shape to copy for a self-hosted install, not a profile to select: + # its host is a placeholder and only the active profile is resolved. "onprem-example": { LLMWHISPERER: { "base_url": "https://llmwhisperer.unstract.internal.example/api/v2", diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py index aa1687c..d0f3fde 100644 --- a/src/unstract_cli/core/clients.py +++ b/src/unstract_cli/core/clients.py @@ -176,10 +176,8 @@ def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> Non endpoint=endpoint, ) if reported: - # Success at the HTTP layer, failure in the body -- the most interesting - # failure this API has, and the one a status-code mapping has nothing to - # say about. Not retryable: re-running starts a second billed execution - # rather than retrying the first. + # HTTP success carrying a failure in the body. Not retryable: a re-run + # starts a second billed execution rather than retrying the first. raise CLIError( str(reported), ExitCode.VALIDATION, diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py index ac79280..ed175d8 100644 --- a/src/unstract_cli/core/discover.py +++ b/src/unstract_cli/core/discover.py @@ -118,9 +118,8 @@ def summary(name: str, command: click.Command) -> dict[str, str]: "groups": [ summary(name, sub) for name, sub in top if isinstance(sub, click.Group) ], - # A command that has no sub-commands is listed apart from the groups: - # a consumer drilling into each group for its commands finds nothing - # under a leaf, and would drop it. + # Leaf commands are listed apart from the groups, so a consumer + # walking groups for their commands does not drop them. "commands": [ summary(name, sub) for name, sub in top diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index eae0c44..a36402a 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -30,18 +30,15 @@ class ExitCode(IntEnum): INTERRUPTED = 130 -#: HTTP status -> exit code. 422 maps to VALIDATION, which is right for a real -#: validation failure; the deployment API's use of 422 for in-progress states is -#: handled by the poll engine before reaching here, by branching on the response -#: body rather than the status code. +#: HTTP status -> exit code. An in-progress 422 never reaches here: the poll +#: engine branches on the response body first. _STATUS_MAP: dict[int, ExitCode] = { 400: ExitCode.VALIDATION, 401: ExitCode.AUTH, 403: ExitCode.AUTH, 404: ExitCode.NOT_FOUND, - # Only the deployment status endpoint answers 406. A whisper result read - # twice comes back as a 400 whose body says so, and translating on that - # prose would break the moment the wording changes. + # Only the deployment status endpoint answers 406; the whisper equivalent + # is a 400 whose body says so, which is prose we do not translate on. 406: ExitCode.ALREADY_CONSUMED, 408: ExitCode.TIMEOUT, 409: ExitCode.VALIDATION, @@ -244,9 +241,8 @@ def hint_for(status: int) -> str | None: "values passed; `details` carries the service's own response." ) case 401 | 403: - # A key that is wrong, revoked, from another organisation, or simply - # not permitted on this one deployment all arrive as the same - # response, so the hint must not settle on one of them. + # Wrong, revoked, foreign-organisation and not-permitted all arrive + # as the same response, so the hint cannot settle on one of them. return ( "The key was rejected. Keys are per-product: `unstract config " "doctor` reports which one resolved and from where. A key that " diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index 6fbd889..b0c1966 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -31,9 +31,7 @@ from unstract_cli.core.errors import CLIError, ExitCode, known_secrets, scrub -#: Major version of the stdout envelope, published in every ``meta``. A consumer -#: ignores fields it does not recognise and refuses a version it was not written -#: against. +#: Major version of the stdout envelope, published in every ``meta``. CONTRACT_VERSION = 1 #: Environment markers the coding agents set for the tools they drive. Patterns, diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index c862019..817e017 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -174,9 +174,8 @@ def client_params(method: Callable[..., Any]) -> dict[str, inspect.Parameter]: } -#: Python annotation -> OpenAPI type. A source-derived spec reports what the -#: endpoint reads off the wire, which can differ from what the call takes: -#: `extract_all_lines` is a string there and a `bool` in the signature. +#: Python annotation -> OpenAPI type. A source-derived spec describes the wire, +#: which can differ from what the client method takes. _ANNOTATIONS: dict[Any, str] = { bool: "boolean", int: "integer", @@ -203,9 +202,8 @@ def _from_signature(param: Param, signature: inspect.Parameter) -> Param: # No default in the signature means the call cannot omit it. updates["required"] = True elif not _is_unset(signature.default): - # What omitting the flag gets you: the client sends its own value. An - # `Unset` default sends nothing, so there the spec's default is the - # honest answer, because the server applies it. + # What omitting the flag gets you: an `Unset` default sends nothing, so + # the spec's default is the one that applies. updates["default"] = signature.default return replace(param, **updates) @@ -214,12 +212,8 @@ def _from_signature(param: Param, signature: inspect.Parameter) -> Param: #: docstring, which is how both clients document their parameters. _ARG_LINE = re.compile(r"^\s*(\w+)\s*(\([^)]*\))?\s*:\s*(.*)$") -#: Sentences a description restates from elsewhere, stripped in the order a -#: description carries them. Each pattern ends at its own sentence rather than at -#: the end of the text, so a description that carries prose after the restated -#: sentence keeps it: the default ends at the full stop that starts the next -#: sentence, the value list at the full stop closing a quoted value. The value -#: list is matched on its opening quote, leaving prose that says "can be" alone. +#: Sentences a description restates from elsewhere, stripped in the order they +#: appear. Each pattern ends at its own sentence, so prose after it survives. _RESTATED = ( re.compile(r"\s*Defaults to .*?\.(?=\s+[A-Z]|\s*$)"), re.compile(r'\s*Can be ".*?"\s*\.'), @@ -252,9 +246,8 @@ def docstring_params(method: Callable[..., Any]) -> dict[str, str]: out[current] = match.group(3).strip() elif current: out[current] = f"{out[current]} {line.strip()}".strip() - # The default and the allowed values are rendered from the signature and the - # spec, so the docstring's own sentences for them are a second copy that - # disagrees the moment either drifts. + # Default and allowed values are rendered from the signature and the spec; + # the docstring's own copy of them would disagree as soon as either moves. return {name: _strip_restated(text) for name, text in out.items() if text} @@ -302,9 +295,9 @@ def click_option(param: Param, spec_overlay: dict[str, Any]) -> click.Option: short = entry.get("short") if param.type == "boolean": - # A paired flag, not `is_flag`: a parameter whose default is true cannot - # be turned off by a flag that only knows how to turn things on, and - # `default=None` keeps "not passed" distinct from "passed false". + # A paired flag, not `is_flag`: a default-true parameter cannot be + # turned off by an on-only flag, and `None` keeps "not passed" apart + # from "passed false". decls = [f"{param.flag}/--no-{param.name.replace('_', '-')}"] if short: decls.insert(0, short) diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index 681a465..a4e9e2f 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -30,9 +30,8 @@ class PollSpec: """How to read progress out of one operation's responses.""" - #: Where the job handle lives in the initial response (whisper_hash, - #: execution_id, ...). It is echoed back on timeout so a caller can resume - #: rather than reprocess the document. + #: Where the job handle lives in the initial response. Echoed back on + #: timeout so a caller can resume rather than reprocess the document. handle_field: str terminal_success: tuple[str, ...] terminal_failure: tuple[str, ...] diff --git a/tests/test_contract.py b/tests/test_contract.py index 788727b..b0766df 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -23,13 +23,9 @@ from unstract_cli.core.params import derive_params, operation_params #: (product, operationId, client method) per command that derives its flags, -#: with the spec parameters that method cannot accept. Most are a parameter the -#: client owns rather than one it lacks: `url_in_post` says the URL is in the -#: body, which the client decides; `files` is built from the paths given; -#: `execution_id` is read out of the endpoint URL the server handed back. -#: `highlights.mode` is the exception -- the endpoint reads it for quota -#: accounting and the published client has no argument for it, so the CLI cannot -#: offer it without the call failing. +#: with the spec parameters that method cannot accept. Most are parameters the +#: client owns rather than lacks; `highlights.mode` is the exception, and the +#: CLI cannot offer it without the call failing. COMMANDS = [ ( "llmwhisperer", @@ -75,10 +71,8 @@ def test_every_derived_flag_is_an_argument_the_client_accepts(product, operation assert param.name in accepted -#: The flags the specs derive today. Every other check in this file reads the -#: spec on both sides of its comparison, so a spec that loses a parameter loses -#: the flag and the expectation together; this file is the side that does not -#: move on its own. +#: The flags the specs derive today, written down rather than read from the +#: spec, so a parameter lost upstream fails here instead of vanishing quietly. SNAPSHOT = Path(__file__).parent / "derived_flags.json" #: Refreshing the snapshot is a decision, not a side effect of running the suite. From c62a14d2223d5b0cb051f641e137b1770e766897 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 18 Aug 2026 17:59:47 +0530 Subject: [PATCH 38/86] docs: say in the top-level help what the CLI can do The help named the products but not what they are for, so a first reader (or an agent) had to run something to find out what was possible. It now says what each product does and states the json envelope, the exit codes and `--discover` in one paragraph. The command list under it is printed by Click, so the prose does not repeat it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFSunNN6RKRA1xo6kWkztx --- src/unstract_cli/app.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 46edd9a..477e510 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -153,9 +153,14 @@ def cli( ) -> None: """The official CLI for Unstract. - Extract documents with LLMWhisperer and run API deployments. `--discover - groups` maps every command as JSON; pass `-o json` when scripting or parsing - the output. + LLMWhisperer extracts text and layout from documents; Document Studio runs + them through API deployments that return structured JSON. + + Scripting or driving this from an agent: `-o json` prints one + `{ok, data, error, meta}` envelope on stdout and nothing else, failures + exit non-zero with a stable code, and `--discover groups|summary|full` + describes the commands, their flags and the output contract as JSON without + running anything. """ set_config_path(config_file) ctx.obj = Context( From eb4414be9d7330f281728a85135ccf687a855da0 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 19 Aug 2026 10:49:05 +0530 Subject: [PATCH 39/86] fix: close two local write windows on the config and result paths Both writes could be steered or observed by another local user with write access to the containing directory, or read access to an existing config file. `persist` wrote through a predictable `.tmp` sibling opened with plain "w". Anyone able to write the save directory could pre-plant that name as a symlink and have the write truncate the file it pointed at. It now writes through an exclusively created, unpredictably named temporary file in the same directory. `save_config` passed 0600 to `os.open`, but that mode applies only when the call creates the file. Rewriting an existing group- or world-readable config left the old mode in place for the duration of the write, so a freshly written literal credential was readable by anyone who could already read the file until the trailing `chmod`. The descriptor is now narrowed before any content goes through it, which also removes the path-based `chmod` that followed. Both are pinned by a test that fails if either guard is removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/config.py | 9 ++++++--- src/unstract_cli/core/poll.py | 16 ++++++++++++---- tests/test_config.py | 25 +++++++++++++++++++++++++ tests/test_poll.py | 12 ++++++++++++ 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 042705d..4e05350 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -300,8 +300,8 @@ def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: doc["default_profile"] = cfg.default_profile doc["profiles"] = _restored_profiles(cfg, target) - # 0600 from the outset, never widened even briefly. O_NOFOLLOW because this - # write truncates, and the path is not always one the user chose. + # O_NOFOLLOW because this write truncates, and the path is not always one + # the user chose. flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) try: fd = os.open(target, flags, 0o600) @@ -313,9 +313,12 @@ def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: f"overwrite {os.readlink(target)} instead. Pass --config with the path " "of the real file." ) from exc + # The mode above only applies to a file this call creates, so an existing + # wider one is narrowed before any content goes through the descriptor: + # after the write is a window in which the new secret is world-readable. + os.fchmod(fd, 0o600) with os.fdopen(fd, "wb") as fh: tomli_w.dump(doc, fh) - os.chmod(target, 0o600) return target diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index a4e9e2f..1494252 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -16,6 +16,7 @@ import json import os +import tempfile import time from collections.abc import Callable from contextlib import suppress @@ -111,17 +112,24 @@ def persist(path: str | Path, payload: Any) -> Path: if isinstance(payload, str) else json.dumps(payload, indent=2, default=str) ) - tmp = target.with_name(target.name + ".tmp") + tmp: Path | None = None try: target.parent.mkdir(parents=True, exist_ok=True) - with tmp.open("w", encoding="utf-8") as handle: + # A predictable sibling in a directory someone else can write is a + # symlink waiting to be planted, and the write would follow it. `mkstemp` + # names it unpredictably and creates it exclusively; the 0600 it opens + # with is what `os.replace` then gives the result. + handle_fd, name = tempfile.mkstemp(dir=target.parent, suffix=".tmp") + tmp = Path(name) + with os.fdopen(handle_fd, "w", encoding="utf-8") as handle: handle.write(text) handle.flush() os.fsync(handle.fileno()) os.replace(tmp, target) except OSError as exc: - with suppress(OSError): - tmp.unlink(missing_ok=True) + if tmp is not None: + with suppress(OSError): + tmp.unlink(missing_ok=True) raise CLIError( f"The result could not be written to {path!r}: {exc}.", ExitCode.SAVE_FAILED, diff --git a/tests/test_config.py b/tests/test_config.py index 739d192..507be7e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,11 +2,13 @@ from __future__ import annotations +import os import stat from pathlib import Path import pytest +from unstract_cli import config as config_module from unstract_cli.config import ( DEFAULT_BASE_URLS, DOCSTUDIO, @@ -211,6 +213,29 @@ def test_saved_config_is_owner_only(tmp_path): assert load_config(written).default_profile == "cloud-us" +def test_an_existing_file_is_narrowed_before_the_secret_is_written(tmp_path, monkeypatch): + """The mode passed to `os.open` applies only on creation, so rewriting a + world-readable file would otherwise publish the new key while it is written.""" + path = tmp_path / "config.toml" + path.write_text("") + path.chmod(0o644) + + seen = [] + real = config_module.tomli_w.dump + monkeypatch.setattr( + config_module.tomli_w, + "dump", + lambda doc, fh: ( + seen.append(stat.S_IMODE(os.fstat(fh.fileno()).st_mode)), + real(doc, fh), + )[1], + ) + written = save_config(ConfigFile(profiles=starter_profiles()), path) + + assert seen == [0o600] + assert stat.S_IMODE(written.stat().st_mode) == 0o600 + + def test_loose_permissions_warn_rather_than_fail(write_config): path = write_config(PROFILE_TOML) path.chmod(0o644) diff --git a/tests/test_poll.py b/tests/test_poll.py index 3c08471..2009a91 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -224,6 +224,18 @@ def test_a_save_leaves_no_temporary_file_behind(tmp_path): assert [p.name for p in tmp_path.iterdir()] == [target.name] +def test_a_planted_temporary_file_is_not_written_through(tmp_path): + """A save directory another user can write is a directory they can plant a + symlink in, and following it would truncate whatever it points at.""" + victim = tmp_path / "victim" + victim.write_text("do not touch") + (tmp_path / "out.json.tmp").symlink_to(victim) + + persist(tmp_path / "out.json", {"a": 1}) + + assert victim.read_text() == "do not touch" + + def test_persist_writes_text_payloads_unwrapped(tmp_path): target = persist(tmp_path / "a.txt", "plain extracted text") assert target.read_text() == "plain extracted text" From 86ed7cfa78e772787ec7260fd8b2f9f7bd7542d3 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 1 Sep 2026 16:18:32 +0530 Subject: [PATCH 40/86] feat: install with one command, without a Python of the right version uv brings its own interpreter, so the CLI's Python floor stops being the installer's problem. --- README.md | 8 +++++++- install.sh | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100755 install.sh diff --git a/README.md b/README.md index 9915d1d..3c59880 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,17 @@ LLMWhisperer, run it through a Document Studio API deployment, get structured JSON back. It also clones one organization's resources into another. ```bash -uv tool install git+https://github.com/Zipstack/unstract-cli +curl -LsSf https://raw.githubusercontent.com/Zipstack/unstract-cli/main/install.sh | sh unstract config init unstract config doctor ``` +The installer fetches `uv` if it is missing and installs the CLI with it; `uv` +brings its own Python, so nothing on the machine has to match. Already have +`uv`? `uv tool install git+https://github.com/Zipstack/unstract-cli` is the same +thing. Set `UNSTRACT_CLI_SOURCE` to install a branch or a local checkout +instead. + Or run it without installing: `uvx --from git+https://github.com/Zipstack/unstract-cli unstract --discover groups`. ## Output diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..134cf20 --- /dev/null +++ b/install.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Installs the `unstract` CLI. Override the source to install a branch or a +# local checkout: +# UNSTRACT_CLI_SOURCE=/path/to/checkout sh install.sh +set -eu + +# Flips to the bare PyPI name once the CLI is published there. +SOURCE="${UNSTRACT_CLI_SOURCE:-git+https://github.com/Zipstack/unstract-cli@main}" + +if ! command -v uv >/dev/null 2>&1; then + echo "Installing uv..." >&2 + curl -LsSf https://astral.sh/uv/install.sh | sh + # The installer only edits shell rc files, which this shell has already read. + PATH="${XDG_BIN_HOME:-${HOME}/.local/bin}:${HOME}/.cargo/bin:${PATH}" + export PATH +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "uv is installed but not on PATH; open a new shell and re-run." >&2 + exit 1 +fi + +# uv fetches its own interpreter, so the CLI's Python floor is not the user's problem. +uv tool install --force "$SOURCE" + +if command -v unstract >/dev/null 2>&1; then + echo + unstract --version 2>/dev/null || true + echo "Run 'unstract config init' to get started." >&2 + exit 0 +fi + +cat >&2 < Date: Tue, 1 Sep 2026 16:18:45 +0530 Subject: [PATCH 41/86] feat: build on the released clients, against the specs they came from Both clients are on PyPI, so the pins are exact versions rather than commits: the CLI derives its flags and help from their signatures, so a client that moves changes the CLI's surface and each release re-pins on purpose. The vendored specs are refreshed to the commits those releases were generated from, and `provenance.json` now records where each one came from so a copy taken from anywhere else fails a test instead of deriving flags the released client cannot carry. What the refresh moves, and why: * `mode` is gone from five LLMWhisperer operations, so the highlights command no longer has a parameter it cannot reach. * `extract` declares the deprecated `page_seperator` spelling, which the client also accepts. A deprecated parameter is now skipped, or the same value would have had two flags. * `--mode pdf_to_images` is gone: that mode is its own operation. * Several LLMWhisperer defaults are no longer reported, because the client stopped pinning them and lets the server choose. --- pyproject.toml | 17 +- src/unstract_cli/core/params.py | 8 +- src/unstract_cli/specs/README.md | 12 +- src/unstract_cli/specs/docstudio.json | 309 ++++++++- src/unstract_cli/specs/llmwhisperer.json | 844 +++++++++++++++++++++-- src/unstract_cli/specs/provenance.json | 14 + tests/derived_flags.json | 27 +- tests/test_contract.py | 31 +- tests/test_discover.py | 1 - tests/test_specs.py | 29 + uv.lock | 20 +- 11 files changed, 1180 insertions(+), 132 deletions(-) create mode 100644 src/unstract_cli/specs/provenance.json create mode 100644 tests/test_specs.py diff --git a/pyproject.toml b/pyproject.toml index ef13f4d..6e108d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,11 +11,11 @@ dependencies = [ "click>=8.1,<9", # Writing the config file only; reading it uses the stdlib `tomllib`. "tomli-w>=1.0", - # Pinned to a commit: the CLI derives its flags and help text from these - # clients, so one that moves changes the CLI's surface. Both pins move to - # released versions before this ships. - "unstract-client @ git+https://github.com/Zipstack/unstract-python-client@a77ef6a", - "llmwhisperer-client @ git+https://github.com/Zipstack/llm-whisperer-python-client@7f64caf", + # Pinned exactly: the CLI derives its flags and help text from these + # clients, so one that moves changes the CLI's surface. Each release re-pins + # deliberately, against the specs vendored in `src/unstract_cli/specs`. + "unstract-client==1.6.0", + "llmwhisperer-client==2.9.0", ] [project.optional-dependencies] @@ -26,18 +26,13 @@ dev = [ [project.scripts] unstract = "unstract_cli.__main__:main" -# `unstract-client` installs an `unstract` script too, so whichever package is -# installed last wins that name; this one always reaches this CLI. +# An alias for anyone who has the name `unstract` taken by something else. unstract-cli = "unstract_cli.__main__:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" -# Needed for the git-pinned clients above. -[tool.hatch.metadata] -allow-direct-references = true - [tool.hatch.build.targets.wheel] packages = ["src/unstract_cli"] diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index 817e017..d61f476 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -123,7 +123,9 @@ def operation_params(product: str, operation_id: str) -> list[Param]: """Every parameter one operation accepts: query, then request body. Path parameters are excluded: they are the route, supplied by the command - from configuration, not by the caller as a flag. + from configuration, not by the caller as a flag. So are deprecated ones: a + superseded spelling the client still accepts would otherwise become a second + flag for the same value. """ operation = find_operation(product, operation_id) params = [ @@ -134,7 +136,7 @@ def operation_params(product: str, operation_id: str) -> list[Param]: required=bool(p.get("required")), ) for p in operation.get("parameters", []) - if p.get("in") == "query" + if p.get("in") == "query" and not p.get("deprecated") ] body = operation.get("requestBody", {}).get("content", {}) @@ -147,6 +149,8 @@ def operation_params(product: str, operation_id: str) -> list[Param]: schema = _resolve_ref(product, ref) mandatory = set(schema.get("required") or ()) for name, prop in (schema.get("properties") or {}).items(): + if prop.get("deprecated"): + continue params.append( _from_schema( name, diff --git a/src/unstract_cli/specs/README.md b/src/unstract_cli/specs/README.md index 583b66f..6831d27 100644 --- a/src/unstract_cli/specs/README.md +++ b/src/unstract_cli/specs/README.md @@ -10,10 +10,14 @@ by hand: | `llmwhisperer.json` | `specs/llmwhisperer.json` in the LLMWhisperer service repo, generated by `tools/gen_spec.py` | | `docstudio.json` | `specs/docstudio-oss.json` in the backend, generated by `manage.py generate_docstudio_spec` | -Refresh one by copying it from the client commit pinned in `pyproject.toml`. -Refreshing it against a different commit is what `tests/test_contract.py` -guards: a spec parameter the pinned client has no argument for cannot become a -flag, and that test names the ones that already cannot. +`provenance.json` pins the commit each copy was taken from and its sha256, and +`tests/test_specs.py` fails if a vendored file stops matching its pin. + +Refresh one by copying it byte-for-byte from the commit the client pinned in +`pyproject.toml` was generated from, then updating `provenance.json` to match. +Refreshing it against any other commit is what `tests/test_contract.py` guards: +a spec parameter the pinned client has no argument for cannot become a flag, and +that test names the ones that already cannot. A refresh that changes which flags a command offers fails against `tests/derived_flags.json`. Read the difference before refreshing that file -- diff --git a/src/unstract_cli/specs/docstudio.json b/src/unstract_cli/specs/docstudio.json index edf3196..c7f8ebb 100644 --- a/src/unstract_cli/specs/docstudio.json +++ b/src/unstract_cli/specs/docstudio.json @@ -1,17 +1,74 @@ { "components": { "schemas": { - "ErrorResponse": { + "AcknowledgedResponse": { + "description": "The execution's result was handed to an earlier call and discarded.", "properties": { "message": { - "nullable": true + "type": "string" }, "status": { "type": "string" } }, + "required": [ + "message", + "status" + ], + "type": "object" + }, + "ErrorDetail": { + "description": "One problem found with the request.", + "properties": { + "attr": { + "description": "The request field the problem belongs to, when it belongs to one.", + "nullable": true, + "type": "string" + }, + "code": { + "description": "Machine-readable problem identifier.", + "type": "string" + }, + "detail": { + "description": "Human-readable description.", + "type": "string" + } + }, + "required": [ + "attr", + "code", + "detail" + ], "type": "object" }, + "ErrorResponse": { + "description": "The body of a rejected request.\n\nProduced by the project-wide exception handler, so its shape is the same\nfor every failure listed against an operation.", + "properties": { + "errors": { + "items": { + "$ref": "#/components/schemas/ErrorDetail" + }, + "type": "array" + }, + "type": { + "$ref": "#/components/schemas/ErrorType" + } + }, + "required": [ + "errors", + "type" + ], + "type": "object" + }, + "ErrorType": { + "description": "* `validation_error` - validation_error\n* `client_error` - client_error\n* `server_error` - server_error", + "enum": [ + "validation_error", + "client_error", + "server_error" + ], + "type": "string" + }, "ExecuteRequest": { "description": "The documents to run, and the options that shape the result.\n\nSupply `files`, `presigned_urls`, or both.", "properties": { @@ -26,10 +83,12 @@ "type": "array" }, "hitl_packet_id": { + "description": "Groups documents reviewed together into one packet. Requires the enterprise manual-review capability; an installation without it rejects the request with 400.", "nullable": true, "type": "string" }, "hitl_queue_name": { + "description": "Document class name for the manual review queue. Requires the enterprise manual-review capability; an installation without it rejects the request with 400.", "nullable": true, "type": "string" }, @@ -89,6 +148,7 @@ "description": "The execution's identity and, once it has finished, its per-file\nresults.", "properties": { "error": { + "nullable": true, "type": "string" }, "execution_id": { @@ -105,32 +165,41 @@ "type": "array" }, "status_api": { + "nullable": true, "type": "string" } }, "required": [ - "error", "execution_id", - "execution_status", - "status_api" + "execution_status" ], "type": "object" }, "FileResult": { + "description": "One input document's outcome.\n\nEvery key is present on every item; the ones that depend on the request\noptions or on the outcome are sent as `null` when they do not apply.", "properties": { "error": { "nullable": true, "type": "string" }, + "extracted_text": { + "description": "The document's full extracted text. Sent only when the request set `include_extracted_text`.", + "nullable": true, + "type": "string" + }, "file": { "type": "string" }, "file_execution_id": { + "nullable": true, "type": "string" }, - "metadata": {}, - "metrics": {}, - "result": {}, + "metadata": { + "nullable": true + }, + "result": { + "nullable": true + }, "status": { "type": "string" } @@ -175,7 +244,7 @@ "paths": { "/deployment/api/{org_name}/{api_name}/": { "get": { - "description": "Read the result of a previously started execution.\n\nThis read is one-shot: the first call that observes a completed execution acknowledges it and the stored result is discarded, so every later call for that execution answers 406. Poll while the execution is pending, and keep the payload of the call that returns it \u2014 it cannot be fetched again.", + "description": "Read the result of a previously started execution.\n\nThis read is one-shot: the first call that observes a completed execution acknowledges it and the stored result is discarded, so every later call for that execution answers 406. Poll while the execution is pending, and keep the payload of the call that returns it \u2014 it cannot be fetched again.\n\nA still-running execution answers 422 carrying its current `status`, so a polling loop should treat 422 as the normal reply and stop on 200. Clients that raise on any non-2xx need to allow for that.", "operationId": "status", "parameters": [ { @@ -256,38 +325,106 @@ "401": { "content": { "application/json": { + "examples": { + "AuthenticationFailed": { + "value": { + "errors": [ + { + "attr": null, + "code": "authentication_failed", + "detail": "Incorrect authentication credentials." + } + ], + "type": "client_error" + } + }, + "NotAuthenticated": { + "value": { + "errors": [ + { + "attr": null, + "code": "not_authenticated", + "detail": "Authentication credentials were not provided." + } + ], + "type": "client_error" + } + } + }, "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "The API key is not valid." + "description": "No usable API key was supplied for the deployment." }, "403": { "content": { "application/json": { + "examples": { + "PermissionDenied": { + "value": { + "errors": [ + { + "attr": null, + "code": "permission_denied", + "detail": "You do not have permission to perform this action." + } + ], + "type": "client_error" + } + } + }, "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "No API key was supplied." + "description": "The request was refused as unauthorized." }, "404": { "content": { "application/json": { + "examples": { + "NotFound": { + "value": { + "errors": [ + { + "attr": null, + "code": "not_found", + "detail": "Not found." + } + ], + "type": "client_error" + } + } + }, "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "No such active deployment." + "description": "No active deployment, or a referenced document, was found." }, "406": { "content": { "application/json": { + "examples": { + "NotAcceptable": { + "value": { + "errors": [ + { + "attr": null, + "code": "not_acceptable", + "detail": "Could not satisfy the request Accept header." + } + ], + "type": "client_error" + } + } + }, "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/AcknowledgedResponse" } } }, @@ -301,27 +438,31 @@ } } }, - "description": "" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "Too many concurrent executions; retry later." + "description": "The execution is still running, or it finished with an error; read `status` to tell them apart." }, "500": { "content": { "application/json": { + "examples": { + "APIException": { + "value": { + "errors": [ + { + "attr": null, + "code": "error", + "detail": "A server error occurred." + } + ], + "type": "server_error" + } + } + }, "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/StatusResponse" } } }, - "description": "" + "description": "The execution could not be completed; the body carries its last known state." } }, "security": [ @@ -391,34 +532,88 @@ "401": { "content": { "application/json": { + "examples": { + "AuthenticationFailed": { + "value": { + "errors": [ + { + "attr": null, + "code": "authentication_failed", + "detail": "Incorrect authentication credentials." + } + ], + "type": "client_error" + } + }, + "NotAuthenticated": { + "value": { + "errors": [ + { + "attr": null, + "code": "not_authenticated", + "detail": "Authentication credentials were not provided." + } + ], + "type": "client_error" + } + } + }, "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "The API key is not valid." + "description": "No usable API key was supplied for the deployment." }, "403": { "content": { "application/json": { + "examples": { + "PermissionDenied": { + "value": { + "errors": [ + { + "attr": null, + "code": "permission_denied", + "detail": "You do not have permission to perform this action." + } + ], + "type": "client_error" + } + } + }, "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "No API key was supplied." + "description": "The request was refused as unauthorized." }, "404": { "content": { "application/json": { + "examples": { + "NotFound": { + "value": { + "errors": [ + { + "attr": null, + "code": "not_found", + "detail": "Not found." + } + ], + "type": "client_error" + } + } + }, "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }, - "description": "No such active deployment." + "description": "No active deployment, or a referenced document, was found." }, - "409": { + "413": { "content": { "application/json": { "schema": { @@ -426,7 +621,7 @@ } } }, - "description": "The deployment has no active API key." + "description": "A referenced document is larger than the limit." }, "422": { "content": { @@ -436,11 +631,25 @@ } } }, - "description": "" + "description": "The execution finished with an error." }, "429": { "content": { "application/json": { + "examples": { + "Throttled": { + "value": { + "errors": [ + { + "attr": null, + "code": "throttled", + "detail": "Request was throttled." + } + ], + "type": "client_error" + } + } + }, "schema": { "$ref": "#/components/schemas/ErrorResponse" } @@ -449,6 +658,30 @@ "description": "Too many concurrent executions; retry later." }, "500": { + "content": { + "application/json": { + "examples": { + "APIException": { + "value": { + "errors": [ + { + "attr": null, + "code": "error", + "detail": "A server error occurred." + } + ], + "type": "server_error" + } + } + }, + "schema": { + "$ref": "#/components/schemas/ExecuteResponse" + } + } + }, + "description": "The deployment could not be run; the body carries the execution that failed." + }, + "502": { "content": { "application/json": { "schema": { @@ -456,7 +689,17 @@ } } }, - "description": "" + "description": "A referenced document could not be fetched." + }, + "504": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "Fetching a referenced document timed out." } }, "security": [ diff --git a/src/unstract_cli/specs/llmwhisperer.json b/src/unstract_cli/specs/llmwhisperer.json index 8cc8dfe..bef1fdd 100644 --- a/src/unstract_cli/specs/llmwhisperer.json +++ b/src/unstract_cli/specs/llmwhisperer.json @@ -31,6 +31,9 @@ }, "WhisperAccepted": { "properties": { + "format": { + "type": "string" + }, "message": { "type": "string" }, @@ -114,15 +117,6 @@ "post": { "operationId": "convert_to_pdf", "parameters": [ - { - "in": "query", - "name": "mode", - "required": false, - "schema": { - "default": "form", - "type": "string" - } - }, { "in": "query", "name": "url", @@ -184,6 +178,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -203,6 +207,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Convert a document to PDF", @@ -215,15 +249,6 @@ "post": { "operationId": "convert_xlsb_to_xlsx", "parameters": [ - { - "in": "query", - "name": "mode", - "required": false, - "schema": { - "default": "form", - "type": "string" - } - }, { "in": "query", "name": "url", @@ -285,6 +310,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -304,6 +339,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Convert an XLSB workbook to XLSX", @@ -325,15 +390,6 @@ "type": "string" } }, - { - "in": "query", - "name": "mode", - "required": false, - "schema": { - "default": "form", - "type": "string" - } - }, { "in": "query", "name": "pages_to_extract", @@ -427,6 +483,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -446,6 +512,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Run document insights over a file", @@ -499,6 +595,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -518,6 +624,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Retrieve document insights result (destructive \u2014 one shot)", @@ -562,6 +698,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -581,6 +727,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Subscription usage summary", @@ -603,20 +779,11 @@ } }, { - "description": "Line numbers or ranges, e.g. `1-5,9`. Not required when `extract_all_lines=true`.", + "description": "Line numbers or ranges, e.g. `1-5,9`. Required unless `extract_all_lines=true`.", "in": "query", "name": "lines", - "required": true, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "mode", "required": false, "schema": { - "default": "form", "type": "string" } }, @@ -661,6 +828,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -680,6 +857,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Line-level highlight geometry for an extraction", @@ -707,15 +914,10 @@ "required": false, "schema": { "default": "png", - "type": "string" - } - }, - { - "in": "query", - "name": "mode", - "required": false, - "schema": { - "default": "form", + "enum": [ + "png", + "jpeg" + ], "type": "string" } }, @@ -788,6 +990,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -807,13 +1019,43 @@ } }, "description": "No such resource." - } - }, - "summary": "Render a PDF's pages as images", - "tags": [ - "convert" - ] - } + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." + } + }, + "summary": "Render a PDF's pages as images", + "tags": [ + "convert" + ] + } }, "/api/v2/pdf-to-images-retrieve": { "get": { @@ -860,6 +1102,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -879,6 +1131,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Retrieve rendered images as a zip (destructive \u2014 one shot)", @@ -905,8 +1187,7 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, - "type": "object" + "$ref": "#/components/schemas/WhisperStatus" } } }, @@ -932,6 +1213,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -951,6 +1242,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Poll PDF-to-images status", @@ -995,6 +1316,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1014,6 +1345,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Verify credentials", @@ -1083,6 +1444,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1102,6 +1473,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Detailed usage statistics", @@ -1276,7 +1677,6 @@ "high_quality", "low_cost", "native_text", - "pdf_to_images", "table" ], "type": "string" @@ -1306,6 +1706,17 @@ "type": "string" } }, + { + "deprecated": true, + "description": "Deprecated misspelling of `page_separator`, read only when that one is absent. Send both to stay compatible with older deployments.", + "in": "query", + "name": "page_seperator", + "required": false, + "schema": { + "default": "<<<", + "type": "string" + } + }, { "in": "query", "name": "pages_to_extract", @@ -1369,6 +1780,7 @@ } }, { + "description": "Minimum per-word OCR confidence to report. Defaults to 0.05 unless the deployment overrides it.", "in": "query", "name": "word_confidence_threshold", "required": false, @@ -1419,6 +1831,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1438,6 +1860,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Submit a document for text extraction", @@ -1491,6 +1943,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1510,6 +1972,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Metadata about a whisper job", @@ -1563,6 +2055,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1582,6 +2084,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Manage extraction webhooks", @@ -1633,6 +2165,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1652,6 +2194,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Manage extraction webhooks", @@ -1704,6 +2276,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1723,6 +2305,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Manage extraction webhooks", @@ -1775,6 +2387,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1794,6 +2416,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Manage extraction webhooks", @@ -1860,6 +2512,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1879,6 +2541,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Retrieve extraction result (destructive \u2014 one shot)", @@ -1931,6 +2623,16 @@ }, "description": "The API key is missing or not valid." }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Quota exhausted, or the licence does not cover this request." + }, "403": { "content": { "application/json": { @@ -1950,6 +2652,36 @@ } }, "description": "No such resource." + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The uploaded file's type is not supported." + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "Unhandled server error." + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "A dependency this operation needs is not configured or reachable." } }, "summary": "Poll extraction status", diff --git a/src/unstract_cli/specs/provenance.json b/src/unstract_cli/specs/provenance.json new file mode 100644 index 0000000..22719d3 --- /dev/null +++ b/src/unstract_cli/specs/provenance.json @@ -0,0 +1,14 @@ +{ + "docstudio.json": { + "repo": "https://github.com/Zipstack/unstract", + "commit": "0c5f36dabf497220f82917a5f4f92f2cd396b5a5", + "path": "specs/docstudio-oss.json", + "sha256": "e453d4f7444d3757a24a1da73373b11c3d362ceb2d7e13e8658a5b3c068b86f5" + }, + "llmwhisperer.json": { + "repo": "https://github.com/Zipstack/unstract-llm-whisperer", + "commit": "750f941ee229e12cc05d8bd85edaab6a337a8758", + "path": "specs/llmwhisperer.json", + "sha256": "88ecc01e92443ba5ba6079db7f57cf3038f97670cb796f13c326268a3d79f366" + } +} diff --git a/tests/derived_flags.json b/tests/derived_flags.json index bca07c9..2e29f78 100644 --- a/tests/derived_flags.json +++ b/tests/derived_flags.json @@ -13,7 +13,7 @@ "--allow-rotated-text": { "name": "allow_rotated_text", "type": "boolean", - "default": true, + "default": null, "description": "Whether to keep words whose own orientation is rotated. With this off, a word angled further than watermark_angle_threshold is treated as a watermark and excluded.", "array": false, "nullable": false, @@ -23,7 +23,7 @@ "--checkbox-confidence-threshold": { "name": "checkbox_confidence_threshold", "type": "number", - "default": 0.3, + "default": null, "description": "The minimum confidence a detected checkbox mark must have to be reported as marked. Accepts a value in the range [0.0, 1.0].", "array": false, "nullable": false, @@ -33,7 +33,7 @@ "--derotate-threshold": { "name": "derotate_threshold", "type": "number", - "default": 10.0, + "default": null, "description": "The page rotation in degrees beyond which the page is straightened and re-read.", "array": false, "nullable": false, @@ -73,7 +73,7 @@ "--ignore-vertical-text": { "name": "ignore_vertical_text", "type": "boolean", - "default": false, + "default": null, "description": "Whether to drop vertically oriented text instead of extracting it.", "array": false, "nullable": false, @@ -118,7 +118,7 @@ "name": "line_splitter_tolerance", "type": "number", "default": 0.4, - "description": "The line splitter tolerance.", + "description": "The line splitter tolerance. This client pins its own default below the service's, and has always sent it, so the two are expected to differ.", "array": false, "nullable": false, "required": false, @@ -157,7 +157,7 @@ "--min-table-width": { "name": "min_table_width", "type": "number", - "default": 0.0, + "default": null, "description": "The minimum width a table must span, as a fraction of the page width, to be extracted as a table.", "array": false, "nullable": false, @@ -179,7 +179,6 @@ "high_quality", "low_cost", "native_text", - "pdf_to_images", "table" ] }, @@ -251,7 +250,7 @@ "--watermark-angle-threshold": { "name": "watermark_angle_threshold", "type": "number", - "default": 25.0, + "default": null, "description": "The angle in degrees beyond which a rotated word counts as a watermark. Only applies when allow_rotated_text is off.", "array": false, "nullable": false, @@ -272,7 +271,7 @@ "name": "word_confidence_threshold", "type": "number", "default": 0.3, - "description": "The minimum OCR confidence score a word must have to be included in the extracted text. Accepts a value in the range [0.0, 1.0], where higher values are stricter. Any word whose confidence value falls below the configured threshold is ignored and excluded from the final output. This parameter works only with \"form\", \"high_quality\" and \"table\" modes.", + "description": "Minimum per-word OCR confidence to report. Defaults to 0.05 unless the deployment overrides it.", "array": false, "nullable": false, "required": false, @@ -294,7 +293,7 @@ "name": "lines", "type": "string", "default": null, - "description": "Line numbers or ranges, e.g. `1-5,9`. Not required when `extract_all_lines=true`.", + "description": "Line numbers or ranges, e.g. `1-5,9`. Required unless `extract_all_lines=true`.", "array": false, "nullable": false, "required": true, @@ -316,7 +315,7 @@ "name": "custom_data", "type": "string", "default": null, - "description": "Arbitrary data echoed back with the result.", + "description": "Arbitrary JSON. The service returns it under each result item's ``metadata.custom_data``, which is server behaviour: the spec carries the field on the request only, so the round trip is not declared and nothing here pins it. Anything that is not already a string is serialised to JSON before it is sent.", "array": false, "nullable": true, "required": false, @@ -326,7 +325,7 @@ "name": "hitl_packet_id", "type": "string", "default": null, - "description": "Human-in-the-loop packet to attach the file to.", + "description": "Groups documents reviewed together into one packet. Requires the enterprise manual-review capability; an installation without it rejects the request with 400.", "array": false, "nullable": true, "required": false, @@ -336,7 +335,7 @@ "name": "hitl_queue_name", "type": "string", "default": null, - "description": "Human-in-the-loop queue to route the file to.", + "description": "Document class name for the manual review queue. Requires the enterprise manual-review capability; an installation without it rejects the request with 400.", "array": false, "nullable": true, "required": false, @@ -406,7 +405,7 @@ "name": "timeout", "type": "integer", "default": -1, - "description": "Execution mode \u2014 ``0`` or below runs asynchronously.", + "description": "Execution mode \u2014 ``0`` or below queues the execution and returns immediately; above it the call runs synchronously.", "array": false, "nullable": false, "required": false, diff --git a/tests/test_contract.py b/tests/test_contract.py index b0766df..288bba3 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -20,12 +20,11 @@ from unstract.api_deployments.client import APIDeploymentsClient from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 -from unstract_cli.core.params import derive_params, operation_params +from unstract_cli.core.params import derive_params, find_operation, operation_params #: (product, operationId, client method) per command that derives its flags, -#: with the spec parameters that method cannot accept. Most are parameters the -#: client owns rather than lacks; `highlights.mode` is the exception, and the -#: CLI cannot offer it without the call failing. +#: with the spec parameters that method cannot accept -- parameters the client +#: owns rather than lacks. COMMANDS = [ ( "llmwhisperer", @@ -33,7 +32,7 @@ LLMWhispererClientV2.whisper, {"url_in_post"}, ), - ("llmwhisperer", "highlights", LLMWhispererClientV2.get_highlight_data, {"mode"}), + ("llmwhisperer", "highlights", LLMWhispererClientV2.get_highlight_data, set()), ("docstudio", "execute", APIDeploymentsClient.structure_file, {"files"}), ( "docstudio", @@ -71,6 +70,28 @@ def test_every_derived_flag_is_an_argument_the_client_accepts(product, operation assert param.name in accepted +@pytest.mark.parametrize( + ("product", "operation", "method"), + [(p, o, m) for p, o, m, _ in COMMANDS], + ids=[f"{p}:{o}" for p, o, _, _ in COMMANDS], +) +def test_a_deprecated_spelling_does_not_become_a_second_flag(product, operation, method): + """Both spellings of a renamed parameter are declared and both are accepted + by the client, so nothing but the deprecation marks one of them wrong.""" + flags = [ + param.flag for param in derive_params(product, operation, client_method=method) + ] + assert len(flags) == len(set(flags)) + deprecated = { + p["name"] + for p in find_operation(product, operation).get("parameters", []) + if p.get("deprecated") + } + assert deprecated.isdisjoint( + param.name for param in derive_params(product, operation, client_method=method) + ) + + #: The flags the specs derive today, written down rather than read from the #: spec, so a parameter lost upstream fails here instead of vanishing quietly. SNAPSHOT = Path(__file__).parent / "derived_flags.json" diff --git a/tests/test_discover.py b/tests/test_discover.py index 5916c9e..45f5be7 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -55,7 +55,6 @@ def test_full_carries_enough_to_build_a_call(capsys): "high_quality", "low_cost", "native_text", - "pdf_to_images", "table", ] assert params["wait"]["flags"] == ["--wait", "--no-wait"] diff --git a/tests/test_specs.py b/tests/test_specs.py new file mode 100644 index 0000000..1537b42 --- /dev/null +++ b/tests/test_specs.py @@ -0,0 +1,29 @@ +"""The vendored specs are the ones the pinned clients were generated from. + +A spec copied from anywhere else derives flags the released client cannot +carry, and the failure surfaces at the call rather than here. +""" + +from __future__ import annotations + +import hashlib +import json +from importlib import resources + +import pytest + +from unstract_cli.core.params import SPEC_FILES + +PROVENANCE = json.loads( + (resources.files("unstract_cli.specs") / "provenance.json").read_text("utf-8") +) + + +@pytest.mark.parametrize("filename", sorted(SPEC_FILES.values())) +def test_each_vendored_spec_is_the_pinned_one(filename): + blob = (resources.files("unstract_cli.specs") / filename).read_bytes() + assert hashlib.sha256(blob).hexdigest() == PROVENANCE[filename]["sha256"] + + +def test_every_vendored_spec_has_a_provenance_entry(): + assert set(PROVENANCE) == set(SPEC_FILES.values()) diff --git a/uv.lock b/uv.lock index 70ddebb..47d012a 100644 --- a/uv.lock +++ b/uv.lock @@ -172,14 +172,18 @@ wheels = [ [[package]] name = "llmwhisperer-client" -version = "2.7.0" -source = { git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=7f64caf#7f64caf5893370e0d472c50df3df39ef198fb37b" } +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "httpx" }, { name = "requests" }, { name = "tenacity" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/25/c7/d8c822ac12837073d07b9c4c3b4e3967ac6b6f8c91737899ca192e1f24fa/llmwhisperer_client-2.9.0.tar.gz", hash = "sha256:8db10ba2c3a9a8351f22bce809535489154bdc7531e7a54f7c04c7601b0cd784", size = 3317195, upload-time = "2026-09-01T10:15:39.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/7b/b192239d0b31e6979ba4d47336585de0fe437847d6839ff936bb9cb0310d/llmwhisperer_client-2.9.0-py3-none-any.whl", hash = "sha256:a2895053b21819fed2a6c85bc15ba05c74b883071b980360e32be537a285836a", size = 69257, upload-time = "2026-09-01T10:15:37.751Z" }, +] [[package]] name = "markdown-it-py" @@ -345,18 +349,18 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1,<9" }, - { name = "llmwhisperer-client", git = "https://github.com/Zipstack/llm-whisperer-python-client?rev=7f64caf" }, + { name = "llmwhisperer-client", specifier = "==2.9.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "tomli-w", specifier = ">=1.0" }, - { name = "unstract-client", git = "https://github.com/Zipstack/unstract-python-client?rev=a77ef6a" }, + { name = "unstract-client", specifier = "==1.6.0" }, ] provides-extras = ["dev"] [[package]] name = "unstract-client" -version = "1.5.3" -source = { git = "https://github.com/Zipstack/unstract-python-client?rev=a77ef6a#a77ef6ae65d69a8290b5aa3fb6b13952a2084d45" } +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "click" }, @@ -365,6 +369,10 @@ dependencies = [ { name = "rich" }, { name = "tenacity" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/ad/8b/7c4eea378ec143a6c862ad03d96bc1cf0b8fab5f23078d51569b78a704a0/unstract_client-1.6.0.tar.gz", hash = "sha256:9fabdcf7c6752910d986bee5c66fb0e0f32b17f73cb1c27e51402eb372dfe81d", size = 203664, upload-time = "2026-09-01T10:13:54.391Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/18/9492ecbc9b0e83128147f688d815ace4cfee4d332ad1e9543cb8b1550344/unstract_client-1.6.0-py3-none-any.whl", hash = "sha256:8e4642d1fd0d2afd9983117ac6c78436f6476724b53c15f3266c725fde901757", size = 114425, upload-time = "2026-09-01T10:13:53.25Z" }, +] [[package]] name = "urllib3" From 4a2d95ab64707d62a80eef59b9ebc549c1383374 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 1 Sep 2026 16:35:37 +0530 Subject: [PATCH 42/86] ci: publish a release to PyPI from a dispatch Mirrors the two Python clients: a manual dispatch bumps the version, lints, tests, builds, publishes with `uv publish` through a PyPI Trusted Publisher, and only then commits the bump, tags it and cuts the GitHub release -- so a failure anywhere before the publish leaves main untouched. The version now lives only in `__version__`; pyproject reads it through hatch, so the bump edits one file. `version_bump: none` publishes what is already in the repo, which is what a first release needs: the convention here is that the committed version is the last released one, and nothing has been released yet. --- .github/workflows/release.yml | 157 ++++++++++++++++++++++++++++++++++ pyproject.toml | 6 +- uv.lock | 1 - 3 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..74d1972 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,157 @@ +name: Release Tag and Publish Package + +on: + workflow_dispatch: + inputs: + version_bump: + description: "Version bump type. `none` publishes the version already in the repo, which is what the first release needs." + required: true + default: "patch" + type: choice + options: + - patch + - minor + - major + - none + pre_release: + description: "Create as pre-release" + required: false + default: false + type: boolean + release_notes: + description: "Release notes (optional)" + required: false + type: string + +jobs: + release-and-publish: + runs-on: ubuntu-latest + permissions: + contents: write + # Publishing is by PyPI Trusted Publisher, so there is no API token. + id-token: write + steps: + - name: Generate GitHub App Token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.PUSH_TO_MAIN_APP_ID }} + private-key: ${{ secrets.PUSH_TO_MAIN_APP_PRIVATE_KEY }} + owner: Zipstack + repositories: | + unstract-cli + + - uses: actions/checkout@v4 + with: + token: ${{ steps.generate-token.outputs.token }} + fetch-depth: 0 + + - name: Configure Git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@v5 + + # The same install as ci.yml, so what the release run lints and tests is + # what the PR gate lints and tests. + - run: uv venv --python 3.12 + - run: uv pip install -e '.[dev]' + + # Staged locally only: nothing is committed, tagged or released until the + # checks, the build and the publish have all passed, so a failure leaves + # main untouched. + - name: Compute new version + id: version + run: | + VERSION_FILE=src/unstract_cli/__init__.py + CURRENT_VERSION=$(sed -nE 's/^__version__ = "(.*)"/\1/p' "$VERSION_FILE") + echo "Current version: $CURRENT_VERSION" + + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + case "${{ github.event.inputs.version_bump }}" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + echo "New version: $NEW_VERSION" + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + + sed -i "s/^__version__ = \".*\"/__version__ = \"$NEW_VERSION\"/" "$VERSION_FILE" + + if git rev-parse -q --verify "refs/tags/v$NEW_VERSION" >/dev/null; then + echo "Tag v$NEW_VERSION already exists. Exiting..." + exit 1 + fi + + - name: Verify version update + run: | + BUILT_VERSION=$(uv run python -c "import unstract_cli; print(unstract_cli.__version__)") + echo "Package version: $BUILT_VERSION" + echo "Target version: ${{ steps.version.outputs.version }}" + if [ "$BUILT_VERSION" != "${{ steps.version.outputs.version }}" ]; then + echo "Version mismatch! Exiting..." + exit 1 + fi + + - name: Run linting + run: | + uv run ruff check . + uv run ruff format --check . + + - name: Run tests + run: uv run pytest -q + + - name: Build package + run: uv build + + # Publishing is the only step that cannot be undone, so the git metadata + # is written after it: a failure before this point leaves nothing to + # unpublish, and one after it is retried by hand against a live artifact. + - name: Publish to PyPI + run: uv publish + + - name: Commit version bump and create release + run: | + NEW_VERSION="${{ steps.version.outputs.version }}" + + # `none` publishes the version already in the file, so there is + # nothing to commit. + if ! git diff --quiet; then + git add src/unstract_cli/__init__.py + git commit -m "chore: bump version to $NEW_VERSION [skip ci]" + git push origin main + fi + + git tag "v$NEW_VERSION" + git push origin "v$NEW_VERSION" + + RELEASE_NOTES="${{ github.event.inputs.release_notes }}" + if [ -z "$RELEASE_NOTES" ]; then + gh release create "v$NEW_VERSION" \ + --title "Release v$NEW_VERSION" \ + --generate-notes \ + ${{ github.event.inputs.pre_release == 'true' && '--prerelease' || '' }} + else + gh release create "v$NEW_VERSION" \ + --title "Release v$NEW_VERSION" \ + --notes "$RELEASE_NOTES" \ + --generate-notes \ + ${{ github.event.inputs.pre_release == 'true' && '--prerelease' || '' }} + fi + + echo "Created release v$NEW_VERSION" + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + + - name: Success message + run: | + echo "Published ${{ steps.version.outputs.version }} to PyPI with uv publish using Trusted Publishers" + echo "Release: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }}" + echo "PyPI: https://pypi.org/project/unstract-cli/${{ steps.version.outputs.version }}/" diff --git a/pyproject.toml b/pyproject.toml index 6e108d7..20f2ccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,7 @@ [project] name = "unstract-cli" -version = "0.1.0" +# One source of truth, so the release workflow bumps a single file. +dynamic = ["version"] description = "Unified, LLM-friendly CLI for the Unstract suite of products" readme = "README.md" requires-python = ">=3.12" @@ -33,6 +34,9 @@ unstract-cli = "unstract_cli.__main__:main" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.version] +path = "src/unstract_cli/__init__.py" + [tool.hatch.build.targets.wheel] packages = ["src/unstract_cli"] diff --git a/uv.lock b/uv.lock index 47d012a..a1ea6a2 100644 --- a/uv.lock +++ b/uv.lock @@ -331,7 +331,6 @@ wheels = [ [[package]] name = "unstract-cli" -version = "0.1.0" source = { editable = "." } dependencies = [ { name = "click" }, From f14ee3c1cc3bec9388fb944cdffeb8344725cabe Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 1 Sep 2026 19:42:52 +0530 Subject: [PATCH 43/86] ci: cut release candidates before a stable release `pre_release` now publishes `rcN` rather than flagging the release on GitHub alone: the rc number counts up from the tags already published for that target version, so repeat dispatches give rc2, rc3, and the committed `__version__` is left alone because it names the last stable release, not a candidate for the next one. Promoting is the same dispatch with `pre_release` off, which bumps and commits the version, tags it and publishes it stable. --- .github/workflows/release.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74d1972..1dc86fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ on: - major - none pre_release: - description: "Create as pre-release" + description: "Publish a release candidate (`rcN`) instead of the version itself. Dispatch again with this off to promote the same version to stable." required: false default: false type: boolean @@ -78,8 +78,20 @@ jobs: minor) MINOR=$((MINOR + 1)); PATCH=0 ;; patch) PATCH=$((PATCH + 1)) ;; esac + NEXT_VERSION="$MAJOR.$MINOR.$PATCH" + + # A pre-release is a candidate for NEXT_VERSION, not a version of its + # own, so it never moves the committed one: the file keeps naming the + # last stable release, and repeat dispatches count up from the rc tags + # already published for that target. + if [ "${{ github.event.inputs.pre_release }}" = "true" ]; then + HIGHEST_RC=$(git tag -l "v${NEXT_VERSION}rc*" \ + | sed -nE "s/^v${NEXT_VERSION}rc([0-9]+)$/\1/p" | sort -n | tail -1) + NEW_VERSION="${NEXT_VERSION}rc$(( ${HIGHEST_RC:-0} + 1 ))" + else + NEW_VERSION="$NEXT_VERSION" + fi - NEW_VERSION="$MAJOR.$MINOR.$PATCH" echo "New version: $NEW_VERSION" echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" @@ -121,9 +133,12 @@ jobs: run: | NEW_VERSION="${{ steps.version.outputs.version }}" - # `none` publishes the version already in the file, so there is + # A pre-release leaves the committed version alone, and `none` + # publishes the version already in the file, so both reach here with # nothing to commit. - if ! git diff --quiet; then + if [ "${{ github.event.inputs.pre_release }}" = "true" ]; then + git checkout -- src/unstract_cli/__init__.py + elif ! git diff --quiet; then git add src/unstract_cli/__init__.py git commit -m "chore: bump version to $NEW_VERSION [skip ci]" git push origin main From bad14d861e14c5283956d8fcc2f06fd749082583 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 1 Sep 2026 19:49:29 +0530 Subject: [PATCH 44/86] fix: answer honestly where a live run found the CLI lying Every one of these is something a real call reached and a test did not. Discovery published Click's "no default" sentinel as the string `Sentinel.UNSET` -- fourteen flags on the newest Click the pin allows, each reading as a value a caller could send back. The marker stopped being `None` in 8.2 and is not exported, so it is read off a bare option and tracks whichever version is installed. CI now runs the suite a second time against the newest resolvable Click, because `uv run` resolves from the lockfile and an install in the wild does not. A required option was given `default=None`, which from Click 8.2 counts as a value the caller supplied, so `required` was never enforced: `whisper highlights ` with no line range escaped as a raw TypeError from inside the client. Requiredness now follows the spec rather than the client signature -- a signature without a default says only that the call cannot omit the argument, which the command answers by supplying one -- and `--extract-all-lines` stands in for a line range, as the API allows and the client's positional argument does not. The CLI read only `LLMWHISPERER_BASE_URL` while the published client reads `LLMWHISPERER_BASE_URL_V2`, and `UNSTRACT_DEPLOYMENT_KEY` while the deployment client reads `UNSTRACT_API_DEPLOYMENT_KEY`. An environment set up for a client therefore left the CLI on its built-in default, which is production. Both client spellings are now honoured after the CLI's own. `--no-wait` handed the acknowledgement to the result finisher, so `--output raw` printed `null` for a started, billed execution and the handle appeared only on the path that did not need it. An ack now reports the handle in both places. Two `retryable` flags said the opposite of the truth: a poll timeout, the one failure a caller is meant to come back from, said false, while a host name that does not resolve said true. The unresolvable host is now named and reported as final; every other connection failure stays retryable. Also: `--discover` answered in whatever format `-o` asked for, including a wrapped table, though it is the machine-readable description and its own contract tells callers to pass `-o json`; it now always answers as JSON. And it described only leaf commands, omitting the root's `-o` and the connection flags each product group carries, which is a description of a call nobody can make. --- .github/workflows/ci.yml | 5 ++ pyproject.toml | 5 +- src/unstract_cli/app.py | 5 +- src/unstract_cli/commands/docstudio_cmd.py | 9 ++- src/unstract_cli/commands/whisper_cmd.py | 11 ++- src/unstract_cli/config.py | 11 +-- src/unstract_cli/core/clients.py | 20 ++++++ src/unstract_cli/core/discover.py | 27 +++++-- src/unstract_cli/core/params.py | 18 +++-- src/unstract_cli/core/poll.py | 1 + tests/derived_flags.json | 2 +- tests/test_commands.py | 83 ++++++++++++++++++++++ tests/test_config.py | 27 +++++++ tests/test_discover.py | 50 +++++++++++++ tests/test_params.py | 10 +++ tests/test_poll.py | 3 + 16 files changed, 265 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef419db..a68d3a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,8 @@ jobs: - run: uv run ruff check . - run: uv run ruff format --check . - run: uv run pytest -q + # `uv run` resolves from uv.lock, so the suite above never sees the newest + # click the pin allows -- which is what an install in the wild gets, and + # what discovery reads the internals of. + - run: uv pip install -U click + - run: .venv/bin/python -m pytest -q diff --git a/pyproject.toml b/pyproject.toml index 20f2ccc..bd421bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,9 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ - # Pinned to a major: `--discover` reads the shape of - # `click.Parameter.to_info_dict()`. + # Pinned to a major: `--discover` describes the CLI by reading Click's own + # objects -- a parameter's default, flags, type and choices, and a group's + # commands -- so a major that reshapes those reshapes the published contract. "click>=8.1,<9", # Writing the config file only; reading it uses the stdlib `tomllib`. "tomli-w>=1.0", diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 477e510..2eb6306 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -171,8 +171,9 @@ def cli( ) if discover_tier: # Discovery is how a caller learns what to run, so it has to answer - # before any configuration exists. - emit_result(discover(cli, discover_tier), ctx.obj.output) + # before any configuration exists -- and always as JSON, because the + # only consumer of a machine-readable description is a machine. + emit_result(discover(cli, discover_tier), OutputFormat.JSON) ctx.exit(int(ExitCode.SUCCESS)) if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index 099fc89..b75d2e4 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -84,7 +84,14 @@ def run( raise_for_result(started, endpoint=client.api_url) if not wait: - finish(ctx, started, raw_field=RAW_FIELD) + # An ack carries no extraction result, so what `--output raw` prints + # and what the caller has to poll with are both the handle. + finish( + ctx, + started, + raw_field="execution_id", + meta=_handle_meta(started), + ) return result = wait_for_completion( diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index 9f834a5..d9cab4b 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -245,9 +245,18 @@ def highlights( The scaling is arithmetic on the metadata, not a second request, so it is folded in here rather than being a command of its own. """ + sent = requested(params) + if not sent.get("lines") and not sent.get("extract_all_lines"): + raise CLIError( + "Nothing to fetch: pass --lines, or --extract-all-lines for all of them.", + ExitCode.USAGE, + ) + # The client takes `lines` positionally whether or not the request needs it. + sent.setdefault("lines", "") + client = llmwhisperer(ctx.config) with translated(endpoint="highlights"): - data = client.get_highlight_data(whisper_hash, **requested(params)) + data = client.get_highlight_data(whisper_hash, **sent) if target_width and target_height: data = { diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 4e05350..2064808 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -38,11 +38,14 @@ DOCSTUDIO: "https://us-central.unstract.com", } -#: Environment variables per (product, setting), checked before the config file. +#: Environment variables per (product, setting), checked before the config file +#: and in the order given. The trailing names are the ones the published clients +#: themselves read: an environment already set up for a client must not leave +#: the CLI silently on its built-in default, which is production. ENV_VARS: dict[tuple[str, str], tuple[str, ...]] = { (LLMWHISPERER, "api_key"): ("LLMWHISPERER_API_KEY",), - (LLMWHISPERER, "base_url"): ("LLMWHISPERER_BASE_URL",), - (DOCSTUDIO, "api_key"): ("UNSTRACT_DEPLOYMENT_KEY",), + (LLMWHISPERER, "base_url"): ("LLMWHISPERER_BASE_URL", "LLMWHISPERER_BASE_URL_V2"), + (DOCSTUDIO, "api_key"): ("UNSTRACT_DEPLOYMENT_KEY", "UNSTRACT_API_DEPLOYMENT_KEY"), (DOCSTUDIO, "base_url"): ("UNSTRACT_BASE_URL",), (DOCSTUDIO, "org_id"): ("UNSTRACT_ORG_ID",), } @@ -398,7 +401,7 @@ def require(self, product: str, key: str) -> Any: if env_vars := ENV_VARS.get((product, key)): hints.append(f"set ${env_vars[0]}") hints.append(f"or add `{key}` to the [profiles..{product}] block") - # Credentials have no flag by design: a secret on the command line + # `--api-key` exists but is not suggested: a secret on the command line # lands in shell history and in the process list. if key != "api_key": hints.append(f"or pass --{key.replace('_', '-')}") diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py index d0f3fde..439b3d1 100644 --- a/src/unstract_cli/core/clients.py +++ b/src/unstract_cli/core/clients.py @@ -107,6 +107,19 @@ def _message_and_details(value: Any) -> tuple[str, Any]: return str(value), None +def _unresolved_host(exc: BaseException) -> str | None: + """The host a connection failed to resolve, or ``None`` if that is not why. + + A name that does not resolve is the one connection failure retrying cannot + fix. Matched by type name rather than by import: the exception belongs to a + transitive dependency of the clients, not to anything declared here. + """ + reason = getattr(exc.args[0] if exc.args else None, "reason", None) + if type(reason).__name__ != "NameResolutionError": + return None + return getattr(getattr(reason, "conn", None), "host", "") or "" + + @contextmanager def translated(endpoint: str | None = None) -> Iterator[None]: """Turn a client failure into a CLIError with an exit code and a hint.""" @@ -133,6 +146,13 @@ def translated(endpoint: str | None = None) -> Iterator[None]: hint="The request timed out in transit; the job may still be running.", ) from exc except ConnectionError as exc: + if (host := _unresolved_host(exc)) is not None: + raise CLIError( + f"Could not resolve the host {host or endpoint or 'in the base URL'}.", + ExitCode.SERVER_ERROR, + endpoint=endpoint, + hint="Check the base URL for a typo. Retrying will not help.", + ) from exc raise CLIError( str(exc), ExitCode.SERVER_ERROR, diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py index ed175d8..d5f991c 100644 --- a/src/unstract_cli/core/discover.py +++ b/src/unstract_cli/core/discover.py @@ -25,6 +25,11 @@ TIERS = ("groups", "summary", "full") +#: Click's marker for "no default was given". It stopped being `None` in 8.2 and +#: is not exported, so it is read off a bare option and tracks whichever version +#: is installed -- serialised, it would publish a string that reads as a value. +_NO_DEFAULT = click.Option(["--unset"]).default + def contract() -> dict[str, Any]: """How to consume this CLI's output, published rather than assumed. @@ -76,17 +81,28 @@ def _param(param: click.Parameter) -> dict[str, Any]: entry["repeatable"] = bool(param.multiple) if isinstance(param.type, click.Choice): entry["choices"] = list(param.type.choices) - if param.default is not None and not isinstance(param, click.Argument): + if ( + param.default is not None + and param.default is not _NO_DEFAULT + and not isinstance(param, click.Argument) + ): entry["default"] = param.default return entry +def _params(command: click.Command) -> list[dict[str, Any]]: + """The flags a caller can pass to one command, group or the root. + + A group carries the connection settings for everything beneath it, so + describing only the leaves describes a call nobody can make. + """ + return [_param(p) for p in command.params if p.name not in ("help", "discover")] + + def _describe(command: click.Command, tier: str) -> dict[str, Any]: entry: dict[str, Any] = {"help": (command.help or "").strip().split("\n")[0]} - if tier == "full" and not isinstance(command, click.Group): - entry["params"] = [ - _param(p) for p in command.params if p.name not in ("help", "discover") - ] + if tier == "full": + entry["params"] = _params(command) # Which field `--output raw` prints for this command, where it has one. if raw := getattr(command, "raw_field", None): entry["raw_field"] = raw @@ -134,6 +150,7 @@ def summary(name: str, command: click.Command) -> dict[str, str]: }, } if tier == "full": + payload["params"] = _params(root) payload["exit_codes"] = exit_codes() payload["contract"] = contract() return payload diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index d61f476..971876f 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -202,10 +202,12 @@ def _from_signature(param: Param, signature: inspect.Parameter) -> Param: updates: dict[str, Any] = {} if (mapped := _ANNOTATIONS.get(signature.annotation)) is not None: updates["type"] = mapped - if signature.default is inspect.Parameter.empty: - # No default in the signature means the call cannot omit it. - updates["required"] = True - elif not _is_unset(signature.default): + # Whether a flag is mandatory is the spec's answer, not the signature's: a + # signature with no default says only that the *call* cannot omit the + # argument, which the command answers by supplying one. + if signature.default is not inspect.Parameter.empty and not _is_unset( + signature.default + ): # What omitting the flag gets you: an `Unset` default sends nothing, so # the spec's default is the one that applies. updates["default"] = signature.default @@ -298,6 +300,10 @@ def click_option(param: Param, spec_overlay: dict[str, Any]) -> click.Option: help_text = entry.get("help") or _help_text(param, choices) short = entry.get("short") + # A required option is left without one: from Click 8.2 an explicit default + # counts as a value the caller supplied, and `required` stops being enforced. + absent: dict[str, Any] = {} if param.required else {"default": None} + if param.type == "boolean": # A paired flag, not `is_flag`: a default-true parameter cannot be # turned off by an on-only flag, and `None` keeps "not passed" apart @@ -305,7 +311,7 @@ def click_option(param: Param, spec_overlay: dict[str, Any]) -> click.Option: decls = [f"{param.flag}/--no-{param.name.replace('_', '-')}"] if short: decls.insert(0, short) - return click.Option(decls, default=None, required=param.required, help=help_text) + return click.Option(decls, required=param.required, help=help_text, **absent) decls = [param.flag] if short: @@ -313,10 +319,10 @@ def click_option(param: Param, spec_overlay: dict[str, Any]) -> click.Option: return click.Option( decls, type=click.Choice(choices) if choices else _TYPES.get(param.type, click.STRING), - default=None, required=param.required, multiple=param.array, help=help_text, + **absent, ) diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index 1494252..8f473e0 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -249,6 +249,7 @@ def naming_the_job(call: Callable[[str], Any]) -> Any: f"Timed out after {timeout:g}s waiting for completion " f"(last status: {status!r}).", ExitCode.TIMEOUT, + retryable=True, hint=( f"The job is still running. Resume with the {spec.handle_field} " f"below rather than resubmitting the document." diff --git a/tests/derived_flags.json b/tests/derived_flags.json index 2e29f78..2c6277c 100644 --- a/tests/derived_flags.json +++ b/tests/derived_flags.json @@ -296,7 +296,7 @@ "description": "Line numbers or ranges, e.g. `1-5,9`. Required unless `extract_all_lines=true`.", "array": false, "nullable": false, - "required": true, + "required": false, "choices": [] }, "--whisper-hash": { diff --git a/tests/test_commands.py b/tests/test_commands.py index 6d55531..015a354 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import socket import pytest from requests.exceptions import ConnectionError @@ -16,6 +17,8 @@ LLMWhispererClientException, LLMWhispererClientV2, ) +from urllib3.connection import HTTPConnection +from urllib3.exceptions import MaxRetryError, NameResolutionError from unstract_cli.__main__ import main from unstract_cli.app import command_tree @@ -39,6 +42,14 @@ def envelope(out: str) -> dict: return json.loads(out) +def _name_resolution_error(host: str) -> ConnectionError: + """What requests raises when DNS has no answer, built rather than provoked: + resolving a name for real would make this suite depend on the network.""" + conn = HTTPConnection(host) + reason = NameResolutionError(host, conn, socket.gaierror(-2, "no answer")) + return ConnectionError(MaxRetryError(pool=conn, url="/", reason=reason)) + + class FakeWhisper: """Records calls; returns whatever the test queued.""" @@ -414,6 +425,42 @@ def test_highlights_returns_the_metadata_alone_without_a_page_size( assert envelope(out)["data"] == {"1": [1, 100, 20, 1000]} +def test_a_host_that_does_not_resolve_is_not_worth_retrying(capsys, whisper_client): + """Every other connection failure is transient; a name that does not resolve + is a typo, and a caller told to retry retries against it forever.""" + whisper_client(get_usage_info=_name_resolution_error("nope.invalid")) + code, out, _ = run(capsys, "whisper", "usage") + assert code == int(ExitCode.SERVER_ERROR) + error = envelope(out)["error"] + assert error["retryable"] is False + assert "nope.invalid" in error["message"] + + +def test_an_unreachable_service_is_worth_retrying(capsys, whisper_client): + whisper_client(get_usage_info=ConnectionError("connection refused")) + code, out, _ = run(capsys, "whisper", "usage") + assert code == int(ExitCode.SERVER_ERROR) + assert envelope(out)["error"]["retryable"] is True + + +def test_highlights_needs_lines_or_all_of_them(capsys, whisper_client): + """The API takes either; asking for neither is a usage error, not a call.""" + whisper_client(get_highlight_data={}) + code, out, _ = run(capsys, "whisper", "highlights", "h1") + assert code == int(ExitCode.USAGE) + assert "--extract-all-lines" in envelope(out)["error"]["message"] + + +def test_extract_all_lines_stands_in_for_a_line_range(capsys, whisper_client): + """The client takes `lines` positionally even when the request does not need + it, so omitting the flag would raise inside the client rather than answer.""" + client = whisper_client(get_highlight_data={"1": [1, 100, 20, 1000]}) + code, out, _ = run(capsys, "whisper", "highlights", "h1", "--extract-all-lines") + assert code == int(ExitCode.SUCCESS) + sent = client.kwargs_for("get_highlight_data") + assert sent == {"lines": "", "extract_all_lines": True} + + # --------------------------------------------------------------------------- # # Deployments # --------------------------------------------------------------------------- # @@ -507,6 +554,42 @@ def test_run_passes_only_the_flags_that_were_given(capsys, deployment_client, tm assert "llm_profile_id" not in sent +def test_a_queued_run_reports_the_handle_it_started(capsys, deployment_client, tmp_path): + """Without --wait the answer is an acknowledgement, so the only thing worth + printing is what the caller polls with.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + deployment_client( + structure_file={ + "status_code": 200, + "execution_status": "PENDING", + "execution_id": "e-1", + "extraction_result": None, + } + ) + + code, out, _ = run( + capsys, "docstudio", "deployment", "run", "my-api", str(doc), "--no-wait" + ) + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["meta"]["execution_id"] == "e-1" + + code = main( + [ + "-o", + "raw", + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--no-wait", + ] + ) + assert code == int(ExitCode.SUCCESS) + assert capsys.readouterr().out.strip() == "e-1" + + def test_an_error_status_from_a_run_is_a_failure(capsys, deployment_client, tmp_path): """The client reports the status code instead of raising, so an error would otherwise be reported as a successful run with an error inside it.""" diff --git a/tests/test_config.py b/tests/test_config.py index 507be7e..a651167 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -68,6 +68,33 @@ def test_env_beats_profile(write_config, monkeypatch): assert resolved().get(LLMWHISPERER, "base_url") == "https://env.example/api/v2" +@pytest.mark.parametrize( + ("product", "key", "var", "value"), + [ + ( + LLMWHISPERER, + "base_url", + "LLMWHISPERER_BASE_URL_V2", + "https://staging.example/api/v2", + ), + (DOCSTUDIO, "api_key", "UNSTRACT_API_DEPLOYMENT_KEY", "deployment-key"), + ], +) +def test_the_env_names_the_clients_read_are_honoured( + monkeypatch, product, key, var, value +): + """An environment set up for the published client must not leave the CLI on + its built-in default, which points at production.""" + monkeypatch.setenv(var, value) + assert resolved().get(product, key) == value + + +def test_the_cli_s_own_env_name_wins_over_the_client_s(monkeypatch): + monkeypatch.setenv("LLMWHISPERER_BASE_URL", "https://first.example/api/v2") + monkeypatch.setenv("LLMWHISPERER_BASE_URL_V2", "https://second.example/api/v2") + assert resolved().get(LLMWHISPERER, "base_url") == "https://first.example/api/v2" + + def test_override_beats_env(write_config, monkeypatch): write_config(PROFILE_TOML) monkeypatch.setenv("LLMWHISPERER_BASE_URL", "https://env.example/api/v2") diff --git a/tests/test_discover.py b/tests/test_discover.py index 45f5be7..69c3a04 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -9,6 +9,7 @@ import json +import click import pytest from unstract_cli.__main__ import main @@ -62,6 +63,55 @@ def test_full_carries_enough_to_build_a_call(capsys): assert extract["raw_field"] == "result_text" +def test_full_publishes_the_flags_that_are_not_on_the_command(capsys): + """The connection settings live on the group and the format on the root, so + a description of the leaves alone describes a call nobody can make.""" + _, data = run(capsys, "--discover", "full") + + root = {p["name"]: p for p in data["params"]} + assert "-o" in root["output"]["flags"] + assert "--profile" in root["profile"]["flags"] + + whisper = {p["name"]: p for p in data["commands"]["whisper"]["params"]} + assert "--api-key" in whisper["api_key"]["flags"] + assert "--base-url" in whisper["base_url"]["flags"] + assert "org_id" in {p["name"] for p in data["commands"]["docstudio"]["params"]} + + +def test_no_flag_publishes_a_default_it_does_not_have(capsys): + """Click marks "no default given" with a sentinel object, not None, and a + serialised sentinel reads as a value the caller could send back.""" + _, data = run(capsys, "--discover", "full") + + def defaults(node): + for param in node.get("params", []): + if "default" in param: + yield param["name"], param["default"] + for child in node.get("commands", {}).values(): + yield from defaults(child) + + published = list(defaults(data)) + assert published + for name, value in published: + assert not isinstance(value, str) or "Sentinel" not in value, name + assert not repr(value).startswith("<"), name + + +def test_a_bare_option_publishes_no_default(): + """The case the CLI's own flags do not cover: an option declared with no + default at all, which is what a derived required flag is.""" + from unstract_cli.core.discover import _param + + assert "default" not in _param(click.Option(["--bare"])) + + +@pytest.mark.parametrize("fmt", ["table", "raw", "json"]) +def test_discovery_answers_as_json_whatever_the_format_says(capsys, fmt): + """It is the machine-readable description; a wrapped table is not one.""" + assert main(["-o", fmt, "--discover", "summary"]) == int(ExitCode.SUCCESS) + assert json.loads(capsys.readouterr().out)["data"]["commands"] + + def test_full_carries_the_exit_code_table(capsys): """A caller branches on these; they are part of the contract, not prose.""" _, data = run(capsys, "--discover", "full") diff --git a/tests/test_params.py b/tests/test_params.py index 47911bc..e83691a 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -234,6 +234,16 @@ def test_a_required_parameter_stays_required(): assert click_option(Param("url", "string", required=True), {}).required is True +@pytest.mark.parametrize("type_name", ["string", "boolean"]) +def test_a_required_flag_is_enforced_by_the_parser(type_name): + """From Click 8.2 a default counts as a value the caller supplied, so a + required option given one is never actually required.""" + option = click_option(Param("lines", type_name, required=True), {}) + command = click.Command("c", params=[option], callback=lambda **_: None) + with pytest.raises(click.MissingParameter): + command.make_context("c", []) + + # --------------------------------------------------------------------------- # # Choosing what to send # --------------------------------------------------------------------------- # diff --git a/tests/test_poll.py b/tests/test_poll.py index 2009a91..cbb21b1 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -127,6 +127,9 @@ def test_timeout_carries_the_handle_so_work_is_resumable(): assert payload["whisper_hash"] == "h1" assert payload["last_status"] == "processing" assert "Resume" in payload["hint"] + # The job is still running, so this is the failure a caller is meant to + # come back from rather than the one that ends the attempt. + assert payload["retryable"] is True # The last sleep is clipped so the wait lasts exactly as long as asked. assert clock.slept == [5, 5, 2] assert clock.now() == 12 From 14f7fefcc4b0521f3e5d5f1b66646d2da0ceecf7 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 2 Sep 2026 10:21:46 +0530 Subject: [PATCH 45/86] fix: never truncate a working config ahead of a write that may fail `config set` opened the real file with O_TRUNC and tightened its mode afterwards, so anything that failed between the two -- an fchmod the filesystem refuses, a full disk, a value that will not serialise -- left the user with an empty config and no way back to the one they had. It is now written to a temporary file and renamed into place. The rename is atomic, so the previous config survives every failure, and `mkstemp` creates the temporary unpredictably named and 0600, which is the mode the config lands at: a guessable sibling in a shared directory is a symlink waiting to be planted, and a mode widened until after the write is a window in which the new credential is readable. Replacing a symlink would quietly turn a deliberate one into a regular file, so that is still refused rather than followed. --- src/unstract_cli/config.py | 37 +++++++++++++++++++++---------------- tests/test_config.py | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 2064808..3c0f065 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -15,9 +15,9 @@ from __future__ import annotations -import errno import os import stat +import tempfile import tomllib from copy import deepcopy from dataclasses import dataclass, field @@ -303,25 +303,30 @@ def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: doc["default_profile"] = cfg.default_profile doc["profiles"] = _restored_profiles(cfg, target) - # O_NOFOLLOW because this write truncates, and the path is not always one - # the user chose. - flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) - try: - fd = os.open(target, flags, 0o600) - except OSError as exc: - if exc.errno not in (errno.ELOOP, errno.EMLINK): - raise + # The path is not always one the user chose, and replacing a symlink would + # silently turn a deliberate one into a regular file. + if target.is_symlink(): raise ConfigError( f"Refusing to write config through the symlink at {target}: it would " f"overwrite {os.readlink(target)} instead. Pass --config with the path " "of the real file." - ) from exc - # The mode above only applies to a file this call creates, so an existing - # wider one is narrowed before any content goes through the descriptor: - # after the write is a window in which the new secret is world-readable. - os.fchmod(fd, 0o600) - with os.fdopen(fd, "wb") as fh: - tomli_w.dump(doc, fh) + ) + + # Written through a temporary file and renamed into place. Truncating the + # real one first would destroy a working config if anything below it failed, + # and `mkstemp` both names the temporary unpredictably -- a guessable + # sibling in a shared directory is a symlink waiting to be planted -- and + # creates it 0600, which is the mode the rename then gives the config, with + # no window in which the new credential is readable more widely. + handle_fd, name = tempfile.mkstemp(dir=target.parent, suffix=".tmp") + tmp = Path(name) + try: + with os.fdopen(handle_fd, "wb") as fh: + tomli_w.dump(doc, fh) + os.replace(tmp, target) + except BaseException: + tmp.unlink(missing_ok=True) + raise return target diff --git a/tests/test_config.py b/tests/test_config.py index a651167..ccca824 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -263,6 +263,25 @@ def test_an_existing_file_is_narrowed_before_the_secret_is_written(tmp_path, mon assert stat.S_IMODE(written.stat().st_mode) == 0o600 +def test_a_failed_write_leaves_the_previous_config_intact(tmp_path, monkeypatch): + """Truncating the real file first would trade a working config for an empty + one whenever anything after the truncate failed.""" + path = tmp_path / "config.toml" + path.write_text('default_profile = "keep"\n', encoding="utf-8") + + monkeypatch.setattr( + config_module.tomli_w, + "dump", + lambda doc, fh: (_ for _ in ()).throw(OSError("no space left on device")), + ) + with pytest.raises(OSError): + save_config(ConfigFile(profiles=starter_profiles()), path) + + assert path.read_text(encoding="utf-8") == 'default_profile = "keep"\n' + # And nothing half-written left behind next to it. + assert [p.name for p in tmp_path.iterdir()] == ["config.toml"] + + def test_loose_permissions_warn_rather_than_fail(write_config): path = write_config(PROFILE_TOML) path.chmod(0o644) From 4220be44cf03ab125d55c3abdb99d069811df224 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 2 Sep 2026 10:27:45 +0530 Subject: [PATCH 46/86] fix: say why a config in an unwritable directory cannot be saved Replacing the file rather than overwriting it is what keeps a failed write from destroying a working config, and a rename needs the directory even when the file itself is writable. Writing in place when the directory refuses would put the truncate back exactly where recovery is hardest, so the case is reported instead, naming the directory. --- src/unstract_cli/config.py | 12 +++++++++++- tests/test_config.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 3c0f065..8b91878 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -318,7 +318,17 @@ def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: # sibling in a shared directory is a symlink waiting to be planted -- and # creates it 0600, which is the mode the rename then gives the config, with # no window in which the new credential is readable more widely. - handle_fd, name = tempfile.mkstemp(dir=target.parent, suffix=".tmp") + try: + handle_fd, name = tempfile.mkstemp(dir=target.parent, suffix=".tmp") + except OSError as exc: + # Renaming into place is what makes the write atomic, and that needs the + # directory, not just the file. Writing the file in place instead would + # put back the truncate this replaced. + raise ConfigError( + f"Cannot write {target}: its directory {target.parent} is not " + f"writable, and the config is replaced rather than overwritten so a " + f"failed write cannot destroy it ({exc.strerror})." + ) from exc tmp = Path(name) try: with os.fdopen(handle_fd, "wb") as fh: diff --git a/tests/test_config.py b/tests/test_config.py index ccca824..88e9ccf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -282,6 +282,21 @@ def test_a_failed_write_leaves_the_previous_config_intact(tmp_path, monkeypatch) assert [p.name for p in tmp_path.iterdir()] == ["config.toml"] +def test_an_unwritable_directory_is_reported_rather_than_raised(tmp_path): + """Replacing the file needs the directory, which overwriting it did not, so + the case says what is wrong instead of surfacing a bare PermissionError.""" + nested = tmp_path / "locked" + nested.mkdir() + path = nested / "config.toml" + path.write_text("", encoding="utf-8") + nested.chmod(0o500) + try: + with pytest.raises(ConfigError, match="not writable"): + save_config(ConfigFile(profiles=starter_profiles()), path) + finally: + nested.chmod(0o700) + + def test_loose_permissions_warn_rather_than_fail(write_config): path = write_config(PROFILE_TOML) path.chmod(0o644) From dbd2efdb9fd5a200f3168260d575a190ef4a3f9a Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 2 Sep 2026 11:04:32 +0530 Subject: [PATCH 47/86] fix: make raw output and a dead DNS guard answer honestly `--output raw` printed one declared field, which was wrong for most of what these commands return. A queued run's acknowledgement carries no result and names no execution -- the handle is only inside the endpoint it hands back -- so the field was missing and the whole payload was dumped instead. A status read on a running job carries the field as `null`, so raw printed `null` and exited 0, which a caller polling for a result cannot tell apart from a job that finished with nothing. Commands now declare what raw prints best-answer-first, and raw prints the first one the answer actually carries, looking in `meta` too so a handle the CLI had to derive is still reachable. An answer carrying none of them fails and says which were looked for, rather than printing something nobody asked for. `--discover full` publishes the whole list, so what it advertises covers every shape the command returns. The unresolvable-host guard could never fire. It read a urllib3 structure, and both clients are httpx-based and re-raise transport failures carrying only a message, so nothing structural survives at the top. It now reads the cause chain for the resolver's own error, which is there whichever transport asked, and takes the host from the request the error carries. Its test built the urllib3 shape by hand and so could not fail; the stand-in is now produced by putting a transport error through the client's own translation, and a second test, skipped unless asked for, reaches a name no resolver will answer for. Also: * Discovery published a different default per Click version for on/off flags -- `False` on some, nothing on others -- when omitting one sends nothing at all. Both spellings are read the way Click reports them, and CI now diffs the whole discovery payload across the two versions it tests, so the contract cannot vary with the installed Click again. * A target that is not one of the configured aliases is sent as an API name, which is a supported way to name a deployment and also what a misspelt alias looks like. The failure now names the aliases that exist. * The rejected-key hint blamed a foreign organisation, which cannot produce it: the resource is resolved within its organisation first, so that answers 404. * Highlights on an extraction made without line numbers pointed at the call that cannot be fixed. The hint now names the extract flag. * No command mounts a required derived flag, so the fix that keeps Click enforcing one had nothing live to protect. Pinned directly. --- .github/workflows/ci.yml | 5 + src/unstract_cli/commands/common.py | 21 ++- src/unstract_cli/commands/docstudio_cmd.py | 34 ++--- src/unstract_cli/commands/whisper_cmd.py | 27 +++- src/unstract_cli/core/clients.py | 64 +++++++- src/unstract_cli/core/discover.py | 32 ++-- src/unstract_cli/core/errors.py | 10 +- src/unstract_cli/core/output.py | 46 +++++- tests/test_commands.py | 166 ++++++++++++++++++++- tests/test_discover.py | 15 +- tests/test_errors.py | 9 ++ tests/test_output.py | 2 +- tests/test_params.py | 11 ++ 13 files changed, 374 insertions(+), 68 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a68d3a2..48115e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,13 @@ jobs: - run: uv run ruff check . - run: uv run ruff format --check . - run: uv run pytest -q + - run: uv run python -m unstract_cli -o json --discover full > locked.json # `uv run` resolves from uv.lock, so the suite above never sees the newest # click the pin allows -- which is what an install in the wild gets, and # what discovery reads the internals of. - run: uv pip install -U click - run: .venv/bin/python -m pytest -q + - run: .venv/bin/python -m unstract_cli -o json --discover full > latest.json + # Discovery is a published contract read out of Click's own objects, so a + # difference here is the contract changing with the installed version. + - run: diff locked.json latest.json diff --git a/src/unstract_cli/commands/common.py b/src/unstract_cli/commands/common.py index 600bc7c..6347d60 100644 --- a/src/unstract_cli/commands/common.py +++ b/src/unstract_cli/commands/common.py @@ -61,15 +61,20 @@ def decorate(func: F) -> F: return decorate -def raw_field(field: str) -> Callable[[click.Command], click.Command]: - """Declare which field `--output raw` prints for this command. +def raw_fields(*fields: str) -> Callable[[click.Command], click.Command]: + """Declare what `--output raw` prints for this command, best answer first. - Recorded on the command so `--discover full` can report it: a caller asking - for raw output has to know what it is going to get. + 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. + + 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_field = field + command.raw_fields = fields return command return decorate @@ -79,7 +84,7 @@ def finish( ctx: Context, data: Any, *, - raw_field: str | None = None, + raw_fields: tuple[str, ...] = (), meta: dict[str, Any] | None = None, ) -> None: """Emit one result envelope, scrubbing any resolved credential from it.""" @@ -87,7 +92,7 @@ def finish( data, ctx.output, meta=meta, - raw_field=raw_field, + raw_fields=raw_fields, secrets=ctx.secrets(), ) @@ -96,6 +101,6 @@ def finish( "DEFAULT_INTERVAL", "DEFAULT_TIMEOUT", "finish", - "raw_field", + "raw_fields", "wait_options", ] diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index b75d2e4..24656d3 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -14,9 +14,10 @@ from unstract.api_deployments.client import APIDeploymentsClient from unstract_cli.app import Context, deployment_group, pass_context -from unstract_cli.commands.common import finish, raw_field, wait_options +from unstract_cli.commands.common import finish, raw_fields, wait_options from unstract_cli.core.clients import ( deployment, + naming_aliases, raise_for_result, translated, translating, @@ -36,15 +37,18 @@ status_field=("execution_status", "status"), ) -#: `--output raw` prints one field rather than the whole payload. -RAW_FIELD = "extraction_result" +#: What `--output raw` prints, best answer first. A queued run answers with a +#: handle and no result, and a status read answers with a state until there is +#: one, so a single field would be wrong for two of the three shapes. +RUN_RAW = ("extraction_result", "execution_id") +STATUS_RAW = ("extraction_result", "execution_status") #: Parameters the run POST and the status GET share: what was asked for in the #: run has to be asked for again when the result is read. _SHARED_WITH_STATUS = ("include_metadata", "include_metrics", "include_extracted_text") -@raw_field(RAW_FIELD) +@raw_fields(*RUN_RAW) @deployment_group.command("run") @click.argument("target") @click.argument("files", nargs=-1, required=True, type=click.Path(exists=True)) @@ -77,21 +81,17 @@ def run( sent = requested(params) if save: preflight(save) - with translated(endpoint=client.api_url): + with naming_aliases(ctx.config, target), translated(endpoint=client.api_url): # Queued execution, so the request returns a handle instead of holding # the connection open for the length of the job. started = client.structure_file(list(files), timeout=0, **sent) raise_for_result(started, endpoint=client.api_url) if not wait: - # An ack carries no extraction result, so what `--output raw` prints - # and what the caller has to poll with are both the handle. - finish( - ctx, - started, - raw_field="execution_id", - meta=_handle_meta(started), - ) + # The ack names no execution of its own: the handle has to be read + # back out of the endpoint it hands you, and `meta` is where the + # CLI puts what it had to derive. + finish(ctx, started, raw_fields=RUN_RAW, meta=_handle_meta(started)) return result = wait_for_completion( @@ -109,7 +109,7 @@ def run( ) # A waited result names no execution, so the handle is returned as meta for # correlation. - finish(ctx, result, raw_field=RAW_FIELD, meta=_handle_meta(started)) + finish(ctx, result, raw_fields=RUN_RAW, meta=_handle_meta(started)) def _handle_meta(started: dict[str, Any]) -> dict[str, Any]: @@ -137,7 +137,7 @@ def poll(endpoint: str) -> dict[str, Any]: return translating(poll, client.api_url) -@raw_field(RAW_FIELD) +@raw_fields(*STATUS_RAW) @deployment_group.command("status") @click.argument("target") @click.argument("execution_id") @@ -152,7 +152,7 @@ def status(ctx: Context, target: str, execution_id: str, **params: Any) -> None: """Report the state of a running or finished execution.""" client = deployment(ctx.config, target, ctx.transport_timeout) endpoint = f"{client.api_url}?execution_id={execution_id}" - with translated(endpoint=client.api_url): + with naming_aliases(ctx.config, target), translated(endpoint=client.api_url): result = client.check_execution_status(endpoint, **requested(params)) if not result.get("pending"): raise_for_result(result, endpoint=client.api_url) @@ -168,7 +168,7 @@ def status(ctx: Context, target: str, execution_id: str, **params: Any) -> None: hint="Inspect `details` for the per-file error, or check the execution logs.", extra={"execution_id": execution_id}, ) - finish(ctx, result, raw_field=RAW_FIELD) + finish(ctx, result, raw_fields=STATUS_RAW) __all__ = ["run", "status"] diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index d9cab4b..2b3f1ff 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -13,7 +13,7 @@ from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 from unstract_cli.app import Context, pass_context, whisper_group -from unstract_cli.commands.common import finish, raw_field, wait_options +from unstract_cli.commands.common import finish, raw_fields, wait_options from unstract_cli.core.clients import llmwhisperer, translated, translating from unstract_cli.core.errors import CLIError, ExitCode, remember_secret from unstract_cli.core.params import requested, spec_options @@ -39,14 +39,14 @@ #: `--output raw` prints one field rather than the whole payload. Extraction #: results carry the text under this name. -RAW_FIELD = "result_text" +RAW_TEXT = ("result_text",) def _is_url(source: str) -> bool: return source.startswith(("http://", "https://")) -@raw_field(RAW_FIELD) +@raw_fields(*RAW_TEXT) @whisper_group.command("extract") @click.argument("source") @wait_options() @@ -121,7 +121,7 @@ def extract( finish( ctx, result, - raw_field=RAW_FIELD, + raw_fields=RAW_TEXT, meta={"whisper_hash": accepted.get("whisper_hash")} if accepted.get("whisper_hash") else None, @@ -174,7 +174,7 @@ def status(ctx: Context, whisper_hash: str) -> None: finish(ctx, result) -@raw_field(RAW_FIELD) +@raw_fields(*RAW_TEXT) @whisper_group.command("retrieve") @click.argument("whisper_hash") @click.option( @@ -199,7 +199,7 @@ def retrieve(ctx: Context, whisper_hash: str, save: str | None) -> None: result = _extraction(payload) if save: persist(save, result) - finish(ctx, result, raw_field=RAW_FIELD) + finish(ctx, result, raw_fields=RAW_TEXT) @whisper_group.command("detail") @@ -255,8 +255,19 @@ def highlights( sent.setdefault("lines", "") client = llmwhisperer(ctx.config) - with translated(endpoint="highlights"): - data = client.get_highlight_data(whisper_hash, **sent) + try: + with translated(endpoint="highlights"): + data = client.get_highlight_data(whisper_hash, **sent) + except CLIError as exc: + if exc.exit_code is ExitCode.VALIDATION: + # Line metadata is recorded during extraction or not at all, so the + # fix belongs to a call that has already been made and paid for. + exc.hint = ( + "Line metadata exists only for an extraction run with " + "--add-line-nos. It cannot be added to this call: re-run " + "`whisper extract --add-line-nos` for the document." + ) + raise if target_width and target_height: data = { diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py index 439b3d1..66923e6 100644 --- a/src/unstract_cli/core/clients.py +++ b/src/unstract_cli/core/clients.py @@ -12,6 +12,7 @@ from __future__ import annotations +import socket from collections.abc import Callable, Iterator from contextlib import contextmanager from typing import Any @@ -79,7 +80,8 @@ def deployment( raise CLIError( f"Deployment {target!r} is missing {' and '.join(missing)}.", ExitCode.USAGE, - hint=( + hint=_alias_hint(config, target) + or ( "Define the deployment as an alias in the active profile, or set " "$UNSTRACT_ORG_ID and $UNSTRACT_DEPLOYMENT_KEY." ), @@ -93,6 +95,37 @@ def deployment( ) +def _alias_hint(config: ResolvedConfig, target: str) -> str | None: + """What to say when a target is not one of the aliases that are configured. + + A bare API name is a supported way to name a deployment, so a target that is + not an alias cannot be rejected outright. It can still be a misspelt one, + and a caller who has defined aliases is likelier to have meant one of them + than to have typed a raw name, so the ones that exist are worth naming. + """ + if not (aliases := config.deployment_aliases()) or target in aliases: + return None + return ( + f"{target!r} is not one of the deployment aliases in the active profile " + f"({', '.join(aliases)}), so it was sent as an API name." + ) + + +@contextmanager +def naming_aliases(config: ResolvedConfig, target: str) -> Iterator[None]: + """Say which aliases exist when a bare API name is not found. + + Sending a misspelt alias as an API name is indistinguishable from sending a + real one until the service answers, so the correction belongs on the answer. + """ + try: + yield + except CLIError as exc: + if exc.exit_code is ExitCode.NOT_FOUND and (hint := _alias_hint(config, target)): + exc.hint = f"{exc.hint} {hint}" if exc.hint else hint + raise + + def _message_and_details(value: Any) -> tuple[str, Any]: """Split a client's error value into a one-line message and the raw detail. @@ -107,17 +140,35 @@ def _message_and_details(value: Any) -> tuple[str, Any]: return str(value), None +def _causes(exc: BaseException) -> Iterator[BaseException]: + """One failure and everything it was raised from, outermost first.""" + seen: BaseException | None = exc + while seen is not None: + yield seen + seen = seen.__cause__ or seen.__context__ + + def _unresolved_host(exc: BaseException) -> str | None: """The host a connection failed to resolve, or ``None`` if that is not why. A name that does not resolve is the one connection failure retrying cannot - fix. Matched by type name rather than by import: the exception belongs to a - transitive dependency of the clients, not to anything declared here. + fix. Read from the chain rather than from the outermost exception: the + clients re-raise transport failures as their ``requests`` equivalents + carrying only a message, so nothing structural survives at the top -- but + the original is still attached underneath, and `socket.gaierror` is the + resolver's own answer whichever transport asked it. """ - reason = getattr(exc.args[0] if exc.args else None, "reason", None) - if type(reason).__name__ != "NameResolutionError": + if not any(isinstance(cause, socket.gaierror) for cause in _causes(exc)): return None - return getattr(getattr(reason, "conn", None), "host", "") or "" + for cause in _causes(exc): + # httpx keeps the request on the error it raises; urllib3 keeps the + # connection. Either names the host without parsing a message. + url = getattr(getattr(cause, "request", None), "url", None) + if host := getattr(url, "host", "") or getattr( + getattr(cause, "conn", None), "host", "" + ): + return host + return "" @contextmanager @@ -213,6 +264,7 @@ def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> Non "deployment", "deployment_url", "llmwhisperer", + "naming_aliases", "raise_for_result", "translated", "translating", diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py index d5f991c..ebec37e 100644 --- a/src/unstract_cli/core/discover.py +++ b/src/unstract_cli/core/discover.py @@ -30,6 +30,11 @@ #: is installed -- serialised, it would publish a string that reads as a value. _NO_DEFAULT = click.Option(["--unset"]).default +#: The same question for a paired on/off flag, which answers it differently: +#: given no default, some versions report `False` and others their own sentinel. +#: Read the same way, so neither is mistaken for a default the flag really has. +_NO_FLAG_DEFAULT = click.Option(["--unset/--no-unset"], default=None).default + def contract() -> dict[str, Any]: """How to consume this CLI's output, published rather than assumed. @@ -81,12 +86,19 @@ def _param(param: click.Parameter) -> dict[str, Any]: entry["repeatable"] = bool(param.multiple) if isinstance(param.type, click.Choice): entry["choices"] = list(param.type.choices) - if ( - param.default is not None - and param.default is not _NO_DEFAULT - and not isinstance(param, click.Argument) - ): - entry["default"] = param.default + # What omitting the flag actually gets you, which is not what Click reports: + # the same declaration answers differently across the supported range, so + # reading `param.default` straight publishes a contract per version. + default = param.default + if param.secondary_opts and default is _NO_FLAG_DEFAULT: + # An on/off flag the CLI declares with no default means "not passed, so + # not sent". Publishing the `False` some versions report here would + # promise a value the CLI does not send. + default = None + elif default is _NO_DEFAULT: + default = False if getattr(param, "is_flag", False) else None + if default is not None and not isinstance(param, click.Argument): + entry["default"] = default return entry @@ -103,9 +115,11 @@ def _describe(command: click.Command, tier: str) -> dict[str, Any]: entry: dict[str, Any] = {"help": (command.help or "").strip().split("\n")[0]} if tier == "full": entry["params"] = _params(command) - # Which field `--output raw` prints for this command, where it has one. - if raw := getattr(command, "raw_field", None): - entry["raw_field"] = raw + # What `--output raw` prints for this command, best answer first: the + # first of these the answer carries is the one printed, and an answer + # carrying none of them fails rather than printing something else. + if raw := getattr(command, "raw_fields", ()): + entry["raw_fields"] = list(raw) if isinstance(command, click.Group): entry["commands"] = { name: _describe(sub, tier) for name, sub in sorted(command.commands.items()) diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index a36402a..2edd158 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -241,13 +241,15 @@ def hint_for(status: int) -> str | None: "values passed; `details` carries the service's own response." ) case 401 | 403: - # Wrong, revoked, foreign-organisation and not-permitted all arrive - # as the same response, so the hint cannot settle on one of them. + # Wrong, revoked and not-permitted all arrive as the same response, + # so the hint cannot settle on one of them. A key from another + # organisation is not among them: the resource is resolved within + # its organisation first, so that answers 404 instead. return ( "The key was rejected. Keys are per-product: `unstract config " "doctor` reports which one resolved and from where. A key that " - "works elsewhere can still be rejected here -- it may not cover " - "this deployment, or may belong to another organisation." + "works elsewhere can still be rejected here if it does not cover " + "this deployment." ) case 404: return ( diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index b0c1966..edcc4d1 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -207,12 +207,44 @@ def fmt(cells: list[str]) -> list[str]: return "\n".join(out) +def raw_value(env: dict[str, Any], fields: tuple[str, ...]) -> Any: + """The first declared field this answer actually carries. + + Commands declare several because one call has several shapes: a queued run + answers with a handle and no result, and a status read answers with a state + until there is a result to answer with. Each is a value the caller asked + for, so raw prints the first one present rather than the first one declared. + + ``meta`` is searched too, because a handle the CLI had to derive rather than + read off the response lands there and is still what the caller wants. + + Nothing present is a failure, not empty output. Raw is one value on stdout + and nothing else, so it cannot say "not this time" inside itself: printing + the whole payload would answer a question nobody asked, and printing the + field's own ``null`` is worse, because a caller polling for a result cannot + tell it apart from a finished job that produced nothing. + """ + payload = env["data"] if env["ok"] else env["error"] + if not fields or not isinstance(payload, dict): + return payload + for name in fields: + for source in (payload, env.get("meta") or {}): + if isinstance(source, dict) and source.get(name) is not None: + return source[name] + raise CLIError( + f"This answer carries none of {', '.join(fields)}, so there is nothing " + "to print as raw output.", + ExitCode.GENERIC, + hint="Read it with `-o json`, which prints whatever the answer does carry.", + ) + + def render( env: dict[str, Any], fmt: OutputFormat = OutputFormat.JSON, *, columns: tuple[str, ...] = (), - raw_field: str | None = None, + raw_fields: tuple[str, ...] = (), ) -> str: """Render an envelope. ``table`` and ``raw`` show ``data``, or the error.""" if fmt is OutputFormat.JSON: @@ -222,8 +254,7 @@ def render( if fmt is OutputFormat.TABLE: return render_table(payload, columns) - if isinstance(payload, dict) and raw_field and raw_field in payload: - payload = payload[raw_field] + payload = raw_value(env, raw_fields) if isinstance(payload, bytes): return payload.decode("utf-8", errors="replace") if isinstance(payload, str): @@ -236,7 +267,7 @@ def emit( fmt: OutputFormat = OutputFormat.JSON, *, columns: tuple[str, ...] = (), - raw_field: str | None = None, + raw_fields: tuple[str, ...] = (), secrets: list[str] | None = None, ) -> None: """Write one envelope to stdout -- and nothing else to stdout. @@ -245,7 +276,7 @@ def emit( caller passed one: an emitter that has to remember is an emitter that eventually forgets. """ - emit_text(render(env, fmt, columns=columns, raw_field=raw_field), secrets=secrets) + emit_text(render(env, fmt, columns=columns, raw_fields=raw_fields), secrets=secrets) def emit_text(text: str, *, secrets: list[str] | None = None) -> None: @@ -266,7 +297,7 @@ def emit_result( *, meta: dict[str, Any] | None = None, columns: tuple[str, ...] = (), - raw_field: str | None = None, + raw_fields: tuple[str, ...] = (), secrets: list[str] | None = None, ) -> None: """Write a successful result.""" @@ -274,7 +305,7 @@ def emit_result( envelope(data=data, meta=meta), fmt, columns=columns, - raw_field=raw_field, + raw_fields=raw_fields, secrets=secrets, ) @@ -323,6 +354,7 @@ def diagnostic( "emit_result", "emit_text", "envelope", + "raw_value", "render", "render_table", "resolve_format", diff --git a/tests/test_commands.py b/tests/test_commands.py index 015a354..7cca255 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -8,17 +8,18 @@ from __future__ import annotations import json +import os import socket +import httpx import pytest from requests.exceptions import ConnectionError from unstract.clone.report import CloneReport, Endpoint, PhaseResult +from unstract.llmwhisperer import client_v2 from unstract.llmwhisperer.client_v2 import ( LLMWhispererClientException, LLMWhispererClientV2, ) -from urllib3.connection import HTTPConnection -from urllib3.exceptions import MaxRetryError, NameResolutionError from unstract_cli.__main__ import main from unstract_cli.app import command_tree @@ -43,11 +44,25 @@ def envelope(out: str) -> dict: def _name_resolution_error(host: str) -> ConnectionError: - """What requests raises when DNS has no answer, built rather than provoked: - resolving a name for real would make this suite depend on the network.""" - conn = HTTPConnection(host) - reason = NameResolutionError(host, conn, socket.gaierror(-2, "no answer")) - return ConnectionError(MaxRetryError(pool=conn, url="/", reason=reason)) + """The failure the pinned client raises when a host does not resolve. + + Built by putting a transport error through the client's own translation + rather than assembled here: the client re-raises with only a message, so a + hand-made stand-in can keep passing long after the client has stopped + producing anything like it. + """ + + def fail(): + request = httpx.Request("GET", f"https://{host}/api/v2/get-usage-info") + raise httpx.ConnectError( + "[Errno -2] Name or service not known", request=request + ) from socket.gaierror(-2, "Name or service not known") + + try: + client_v2._translate_transport_errors(fail) + except ConnectionError as exc: + return exc + raise AssertionError("the pinned client no longer translates a connect error") class FakeWhisper: @@ -436,6 +451,22 @@ def test_a_host_that_does_not_resolve_is_not_worth_retrying(capsys, whisper_clie assert "nope.invalid" in error["message"] +@pytest.mark.skipif( + not os.environ.get("UNSTRACT_CLI_LIVE"), + reason="asks the resolver about a host; set UNSTRACT_CLI_LIVE=1 to run it", +) +def test_a_real_resolver_failure_reaches_the_same_answer(capsys, monkeypatch): + """The offline stand-in is built by hand, however carefully. This one asks + the pinned client to reach a name no resolver will answer for.""" + monkeypatch.setenv("LLMWHISPERER_API_KEY", "k") + monkeypatch.setenv("LLMWHISPERER_BASE_URL", "https://unresolvable.invalid/api/v2") + code, out, _ = run(capsys, "whisper", "usage") + assert code == int(ExitCode.SERVER_ERROR) + error = envelope(out)["error"] + assert error["retryable"] is False + assert "unresolvable.invalid" in error["message"] + + def test_an_unreachable_service_is_worth_retrying(capsys, whisper_client): whisper_client(get_usage_info=ConnectionError("connection refused")) code, out, _ = run(capsys, "whisper", "usage") @@ -590,6 +621,127 @@ def test_a_queued_run_reports_the_handle_it_started(capsys, deployment_client, t assert capsys.readouterr().out.strip() == "e-1" +def test_a_target_that_is_not_a_configured_alias_names_the_ones_that_are( + capsys, deployment_client, write_config +): + """A misspelt alias is sent as an API name and comes back not-found, which + says nothing about the aliases sitting in the profile.""" + write_config( + 'default_profile = "p"\n' + "[profiles.p.docstudio]\n" + 'org_id = "org"\n' + 'api_key = "k"\n' + "[profiles.p.deployments.invoices]\n" + 'api_name = "invoice-parser"\n' + ) + deployment_client(check_execution_status={"status_code": 404, "error": "not found"}) + code, out, _ = run(capsys, "docstudio", "deployment", "status", "invoces", "e-1") + assert code == int(ExitCode.NOT_FOUND) + hint = envelope(out)["error"]["hint"] + assert "invoces" in hint and "invoices" in hint + + +def test_highlights_on_an_extraction_without_line_numbers_says_where_to_fix_it( + capsys, whisper_client +): + """The call that can be fixed is the extract, which has already been paid + for; a hint about this call sends the caller nowhere.""" + whisper_client( + get_highlight_data=LLMWhispererClientException( + {"message": "no line metadata", "status_code": 400}, 400 + ) + ) + code, out, _ = run(capsys, "whisper", "highlights", "h1", "--lines", "1-5") + assert code == int(ExitCode.VALIDATION) + assert "--add-line-nos" in envelope(out)["error"]["hint"] + + +ACK = { + "status_code": 200, + "execution_status": "PENDING", + "extraction_result": None, + "status_check_api_endpoint": "/deployment/api/status?execution_id=e-1", +} + +PENDING_STATUS = { + "status_code": 422, + "pending": True, + "execution_status": "EXECUTING", + "extraction_result": None, +} + +DONE_STATUS = { + "status_code": 200, + "execution_status": "COMPLETED", + "extraction_result": "the answer", +} + + +def _raw(capsys, *args) -> str: + assert main(["-o", "raw", *args]) == int(ExitCode.SUCCESS) + return capsys.readouterr().out.strip() + + +def test_a_queued_run_renders_the_handle_it_had_to_derive( + capsys, deployment_client, tmp_path +): + """The ack names no execution of its own -- the id is only in the endpoint + it hands back -- so raw would otherwise have nothing true to print.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + deployment_client(structure_file=ACK) + + code, out, _ = run( + capsys, "docstudio", "deployment", "run", "my-api", str(doc), "--no-wait" + ) + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["meta"]["execution_id"] == "e-1" + + deployment_client(structure_file=ACK) + assert ( + _raw(capsys, "docstudio", "deployment", "run", "my-api", str(doc), "--no-wait") + == "e-1" + ) + + +def test_a_still_running_status_never_renders_as_an_empty_result( + capsys, deployment_client +): + """`extraction_result` is present and null while the job runs. Printing that + tells a polling caller the same thing as a finished job with no output.""" + deployment_client(check_execution_status=PENDING_STATUS) + code, out, _ = run(capsys, "docstudio", "deployment", "status", "my-api", "e-1") + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["data"]["execution_status"] == "EXECUTING" + + deployment_client(check_execution_status=PENDING_STATUS) + assert ( + _raw(capsys, "docstudio", "deployment", "status", "my-api", "e-1") == "EXECUTING" + ) + + +def test_a_finished_status_renders_its_result(capsys, deployment_client): + deployment_client(check_execution_status=DONE_STATUS) + code, out, _ = run(capsys, "docstudio", "deployment", "status", "my-api", "e-1") + assert envelope(out)["data"]["extraction_result"] == "the answer" + + deployment_client(check_execution_status=DONE_STATUS) + assert ( + _raw(capsys, "docstudio", "deployment", "status", "my-api", "e-1") == "the answer" + ) + + +def test_raw_fails_rather_than_printing_something_else(capsys, deployment_client): + """An answer carrying none of the declared fields has no raw form. Dumping + the whole payload answers a question the caller did not ask.""" + deployment_client(check_execution_status={"status_code": 200, "unexpected": 1}) + code = main(["-o", "raw", "docstudio", "deployment", "status", "my-api", "e-1"]) + out, err = capsys.readouterr() + assert code == int(ExitCode.GENERIC) + assert "unexpected" not in out + assert "extraction_result" in out or "extraction_result" in err + + def test_an_error_status_from_a_run_is_a_failure(capsys, deployment_client, tmp_path): """The client reports the status code instead of raising, so an error would otherwise be reported as a successful run with an error inside it.""" diff --git a/tests/test_discover.py b/tests/test_discover.py index 69c3a04..67f4bfc 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -60,7 +60,7 @@ def test_full_carries_enough_to_build_a_call(capsys): ] assert params["wait"]["flags"] == ["--wait", "--no-wait"] assert params["interval"]["type"] == "float" - assert extract["raw_field"] == "result_text" + assert extract["raw_fields"] == ["result_text"] def test_full_publishes_the_flags_that_are_not_on_the_command(capsys): @@ -97,6 +97,19 @@ def defaults(node): assert not repr(value).startswith("<"), name +def test_an_on_off_flag_publishes_the_same_default_across_click_versions(): + """The one declaration this CLI uses most answers differently per version: + given no default, some report `False` and some their own sentinel. Neither + is what omitting the flag does, which is to send nothing.""" + from unstract_cli.core.discover import _param + + assert "default" not in _param(click.Option(["--x/--no-x"], default=None)) + # A default that was actually chosen still travels. + assert _param(click.Option(["--y/--no-y"], default=True))["default"] is True + # And a plain on-only flag keeps reporting the False it really defaults to. + assert _param(click.Option(["--z"], is_flag=True))["default"] is False + + def test_a_bare_option_publishes_no_default(): """The case the CLI's own flags do not cover: an option declared with no default at all, which is what a derived required flag is.""" diff --git a/tests/test_errors.py b/tests/test_errors.py index 50a8d42..98706ad 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -123,3 +123,12 @@ def test_the_readme_table_lists_every_exit_code(): def _is_code_row(row: str) -> bool: cells = row.split("|") return len(cells) > 2 and cells[1].strip().isdigit() + + +def test_the_rejected_key_hint_does_not_blame_the_organisation(): + """A key from another organisation cannot produce this: the resource is + resolved within its own organisation first, so that answers 404.""" + hint = hint_for(401) + assert "organisation" not in hint + assert "does not cover" in hint + assert "organisation" in hint_for(404) diff --git a/tests/test_output.py b/tests/test_output.py index 9293c11..02f3c62 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -90,7 +90,7 @@ def test_table_and_raw_render_the_payload_not_the_envelope(): env = envelope(data={"text": "hello"}) assert "hello" in render(env, OutputFormat.TABLE) assert "ok" not in render(env, OutputFormat.TABLE) - assert render(env, OutputFormat.RAW, raw_field="text") == "hello" + assert render(env, OutputFormat.RAW, raw_fields=("text",)) == "hello" def test_raw_renders_the_error_when_the_run_failed(): diff --git a/tests/test_params.py b/tests/test_params.py index e83691a..ab540ca 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -234,6 +234,17 @@ def test_a_required_parameter_stays_required(): assert click_option(Param("url", "string", required=True), {}).required is True +@pytest.mark.parametrize("type_name", ["string", "boolean"]) +def test_a_required_flag_carries_no_default_at_all(type_name): + """No command mounts a required derived flag today, so the parser check + below has nothing live to protect. This pins the property itself: Click + treats any default as a value the caller supplied.""" + option = click_option(Param("lines", type_name, required=True), {}) + bare = click.Option(["--bare"]) + assert option.default is bare.default + assert click_option(Param("lines", type_name), {}).default is None + + @pytest.mark.parametrize("type_name", ["string", "boolean"]) def test_a_required_flag_is_enforced_by_the_parser(type_name): """From Click 8.2 a default counts as a value the caller supplied, so a From 4991834a5ca0ddc670ef66cda5c1e0a83ac79792 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Wed, 2 Sep 2026 11:06:41 +0530 Subject: [PATCH 48/86] ci: compare both ends of the click range, not the lockfile's middle The parity check compared the lockfile's click against the newest one, and those two agree even with the bug it was added to catch: the answer only diverges at the floor the pin allows. It now runs the suite and takes the discovery payload at both ends of that range and diffs those, which is what the pin promises to support. --- .github/workflows/ci.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48115e8..d0518f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,13 +18,14 @@ jobs: - run: uv run ruff check . - run: uv run ruff format --check . - run: uv run pytest -q - - run: uv run python -m unstract_cli -o json --discover full > locked.json - # `uv run` resolves from uv.lock, so the suite above never sees the newest - # click the pin allows -- which is what an install in the wild gets, and - # what discovery reads the internals of. + # `uv run` resolves from uv.lock, whose click sits in the middle of the + # range the pin allows. Both ends are what an install in the wild gets, + # and discovery is a published contract read out of Click's own objects, + # so both ends are run and their answers compared. + - run: uv pip install 'click~=8.1.0' + - run: .venv/bin/python -m pytest -q + - run: .venv/bin/python -m unstract_cli -o json --discover full > floor.json - run: uv pip install -U click - run: .venv/bin/python -m pytest -q - run: .venv/bin/python -m unstract_cli -o json --discover full > latest.json - # Discovery is a published contract read out of Click's own objects, so a - # difference here is the contract changing with the installed version. - - run: diff locked.json latest.json + - run: diff floor.json latest.json From 1c07d117b792e250ae1c0f8c35d5e8470d1eb735 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 3 Sep 2026 12:52:05 +0530 Subject: [PATCH 49/86] fix: put the new config on the disk before the rename that stands for it Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/config.py | 14 ++++++++++++++ tests/test_config.py | 24 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 8b91878..c31ade6 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -15,6 +15,7 @@ from __future__ import annotations +import contextlib import os import stat import tempfile @@ -333,7 +334,20 @@ def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: try: with os.fdopen(handle_fd, "wb") as fh: tomli_w.dump(doc, fh) + # The rename only replaces one whole config with another if the new + # bytes are on the disk before it happens. Without this a crash can + # leave the rename standing over content that never landed. + fh.flush() + os.fsync(fh.fileno()) os.replace(tmp, target) + # And the rename is itself a directory change that has to be persisted; + # syncing the file does not cover the entry that now points at it. + with contextlib.suppress(OSError): # not every platform syncs a directory + dir_fd = os.open(target.parent, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) except BaseException: tmp.unlink(missing_ok=True) raise diff --git a/tests/test_config.py b/tests/test_config.py index 88e9ccf..3190652 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -282,6 +282,30 @@ def test_a_failed_write_leaves_the_previous_config_intact(tmp_path, monkeypatch) assert [p.name for p in tmp_path.iterdir()] == ["config.toml"] +def test_the_replacement_is_synced_before_it_is_renamed(tmp_path, monkeypatch): + """A rename that outruns its own bytes survives a crash while the content + does not, which turns a working config into an empty one.""" + path = tmp_path / "config.toml" + order: list[str] = [] + real_fsync, real_replace = os.fsync, os.replace + monkeypatch.setattr( + config_module.os, + "fsync", + lambda fd: (order.append("fsync"), real_fsync(fd))[1], + ) + monkeypatch.setattr( + config_module.os, + "replace", + lambda src, dst: (order.append("replace"), real_replace(src, dst))[1], + ) + + save_config(ConfigFile(profiles=starter_profiles()), path) + + assert order[: order.index("replace")] == ["fsync"] + # The last one is the directory, so the rename itself is on the disk too. + assert order[-1] == "fsync" + + def test_an_unwritable_directory_is_reported_rather_than_raised(tmp_path): """Replacing the file needs the directory, which overwriting it did not, so the case says what is wrong instead of surfacing a bare PermissionError.""" From 9bb89dfec5d62bb00125a2b613807ca8a29b7b51 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 3 Sep 2026 12:58:47 +0530 Subject: [PATCH 50/86] fix: refuse a symlinked --save target instead of replacing the link Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/core/poll.py | 12 ++++++++++++ tests/test_poll.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index 8f473e0..232c467 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -78,6 +78,18 @@ def preflight(path: str | Path) -> Path: flag must not have. """ target = Path(path).expanduser() + # Saving here would replace the link itself, so it stops being a link and + # whatever it stands for stops being updated. + if target.is_symlink(): + raise CLIError( + f"--save target {path!r} is a symlink to {os.readlink(target)}: the " + "result would replace the link rather than update what it points at.", + ExitCode.USAGE, + hint=( + "Pass the path of the real file; nothing has been read yet, so " + "nothing is lost." + ), + ) try: target.parent.mkdir(parents=True, exist_ok=True) existed = target.exists() diff --git a/tests/test_poll.py b/tests/test_poll.py index cbb21b1..79d8034 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -239,6 +239,23 @@ def test_a_planted_temporary_file_is_not_written_through(tmp_path): assert victim.read_text() == "do not touch" +def test_a_symlinked_save_target_is_refused_before_anything_is_read(tmp_path): + """Saving over the link would turn it into a regular file and leave what it + stood for behind, so it is rejected while the result can still be re-read.""" + real = tmp_path / "results.json" + real.write_text("previous") + link = tmp_path / "latest.json" + link.symlink_to(real) + + with pytest.raises(CLIError) as caught: + preflight(link) + + assert caught.value.exit_code is ExitCode.USAGE + assert "symlink" in str(caught.value) + assert link.is_symlink() + assert real.read_text() == "previous" + + def test_persist_writes_text_payloads_unwrapped(tmp_path): target = persist(tmp_path / "a.txt", "plain extracted text") assert target.read_text() == "plain extracted text" From d21da48080fb01c15fab10b2c8b31735d5e2b1b9 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 3 Sep 2026 13:07:20 +0530 Subject: [PATCH 51/86] fix: check the save target again at the rename, not only ahead of the read Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/core/poll.py | 17 +++++++++++++++++ tests/test_poll.py | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index 232c467..02e380d 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -137,6 +137,23 @@ def persist(path: str | Path, payload: Any) -> Path: handle.write(text) handle.flush() os.fsync(handle.fileno()) + # Re-checked rather than taken on trust from the preflight: the target + # can become a link while the request that produced this result is in + # flight, and the rename would then destroy it. + if target.is_symlink(): + with suppress(OSError): + tmp.unlink(missing_ok=True) + raise CLIError( + f"{path!r} became a symlink to {os.readlink(target)} while the " + "result was being fetched: saving would replace the link rather " + "than update what it points at.", + ExitCode.SAVE_FAILED, + details=payload, + hint=( + "`details` carries the result. Save it to the real path -- it " + "has been read already, and will not be served again." + ), + ) os.replace(tmp, target) except OSError as exc: if tmp is not None: diff --git a/tests/test_poll.py b/tests/test_poll.py index 79d8034..3e0e433 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -256,6 +256,27 @@ def test_a_symlinked_save_target_is_refused_before_anything_is_read(tmp_path): assert real.read_text() == "previous" +def test_a_target_that_became_a_symlink_is_not_replaced(tmp_path): + """The preflight cannot hold the path for the length of the request, so the + rename checks again instead of destroying a link planted in between.""" + real = tmp_path / "results.json" + real.write_text("previous") + link = tmp_path / "latest.json" + link.symlink_to(real) + + with pytest.raises(CLIError) as caught: + persist(link, {"result_text": "IRREPLACEABLE"}) + + assert caught.value.exit_code is ExitCode.SAVE_FAILED + assert caught.value.details == {"result_text": "IRREPLACEABLE"} + assert link.is_symlink() + assert real.read_text() == "previous" + assert sorted(p.name for p in tmp_path.iterdir()) == [ + "latest.json", + "results.json", + ] + + def test_persist_writes_text_payloads_unwrapped(tmp_path): target = persist(tmp_path / "a.txt", "plain extracted text") assert target.read_text() == "plain extracted text" From 08ce3f09b4b65ef900725a8fa52f23dcaef24c30 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 8 Sep 2026 10:44:15 +0530 Subject: [PATCH 52/86] fix: keep one symlink check on the save path, at the point it can still help Checking again at the rename cannot close the window it aims at, and the rename replaces the link rather than following it, so the check that fires before the result is read is the one worth keeping. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/core/poll.py | 17 ----------------- tests/test_poll.py | 21 --------------------- 2 files changed, 38 deletions(-) diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index 02e380d..232c467 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -137,23 +137,6 @@ def persist(path: str | Path, payload: Any) -> Path: handle.write(text) handle.flush() os.fsync(handle.fileno()) - # Re-checked rather than taken on trust from the preflight: the target - # can become a link while the request that produced this result is in - # flight, and the rename would then destroy it. - if target.is_symlink(): - with suppress(OSError): - tmp.unlink(missing_ok=True) - raise CLIError( - f"{path!r} became a symlink to {os.readlink(target)} while the " - "result was being fetched: saving would replace the link rather " - "than update what it points at.", - ExitCode.SAVE_FAILED, - details=payload, - hint=( - "`details` carries the result. Save it to the real path -- it " - "has been read already, and will not be served again." - ), - ) os.replace(tmp, target) except OSError as exc: if tmp is not None: diff --git a/tests/test_poll.py b/tests/test_poll.py index 3e0e433..79d8034 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -256,27 +256,6 @@ def test_a_symlinked_save_target_is_refused_before_anything_is_read(tmp_path): assert real.read_text() == "previous" -def test_a_target_that_became_a_symlink_is_not_replaced(tmp_path): - """The preflight cannot hold the path for the length of the request, so the - rename checks again instead of destroying a link planted in between.""" - real = tmp_path / "results.json" - real.write_text("previous") - link = tmp_path / "latest.json" - link.symlink_to(real) - - with pytest.raises(CLIError) as caught: - persist(link, {"result_text": "IRREPLACEABLE"}) - - assert caught.value.exit_code is ExitCode.SAVE_FAILED - assert caught.value.details == {"result_text": "IRREPLACEABLE"} - assert link.is_symlink() - assert real.read_text() == "previous" - assert sorted(p.name for p in tmp_path.iterdir()) == [ - "latest.json", - "results.json", - ] - - def test_persist_writes_text_payloads_unwrapped(tmp_path): target = persist(tmp_path / "a.txt", "plain extracted text") assert target.read_text() == "plain extracted text" From ba414adac78249e5fea7acaeb4c1ab12539b8aa7 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 8 Sep 2026 10:56:48 +0530 Subject: [PATCH 53/86] ci: install from the lockfile, on the uv the other packages release with Resolving the dev extra fresh ignored uv.lock, so neither gate ran the versions an install resolves to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- .github/workflows/ci.yml | 10 +++++++--- .github/workflows/release.yml | 8 +++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0518f0..cd5ddb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v5 - - run: uv venv --python 3.12 - - run: uv pip install -e '.[dev]' + - uses: astral-sh/setup-uv@v6 + with: + version: "0.6.14" + enable-cache: true + # Synced from uv.lock rather than resolved fresh, so the gate tests the + # dependency set an install actually gets. + - run: uv sync --extra dev --python 3.12 - run: uv run ruff check . - run: uv run ruff format --check . - run: uv run pytest -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1dc86fd..9d485b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,12 +55,14 @@ jobs: with: python-version: "3.12" - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@v6 + with: + version: "0.6.14" + enable-cache: true # The same install as ci.yml, so what the release run lints and tests is # what the PR gate lints and tests. - - run: uv venv --python 3.12 - - run: uv pip install -e '.[dev]' + - run: uv sync --extra dev --python 3.12 # Staged locally only: nothing is committed, tagged or released until the # checks, the build and the publish have all passed, so a failure leaves From c20d09e74807cc73aa3c5e23eb65b554e7be3908 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 8 Sep 2026 13:29:29 +0530 Subject: [PATCH 54/86] docs: add a skill for bumping the client pins and their specs A client bump here is three coupled edits -- the exact pin, the vendored spec re-copied from the commit that client was generated from, and the provenance sha -- and the coupling is only discoverable by tripping test_specs.py or test_contract.py and working backwards. Records the order as a repo-local skill, including the part that is easy to get wrong: derived_flags.json is refreshed deliberately after reading what moved, a flag missing from it being a flag the CLI has stopped offering. Also pins the release shape: publish before tag, `none` for the version already committed, and pre_release for the rc flow. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CSm32dsxD56PkKENgav3Mn --- .claude/skills/bump-client-pins/SKILL.md | 98 ++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .claude/skills/bump-client-pins/SKILL.md diff --git a/.claude/skills/bump-client-pins/SKILL.md b/.claude/skills/bump-client-pins/SKILL.md new file mode 100644 index 0000000..afe45ca --- /dev/null +++ b/.claude/skills/bump-client-pins/SKILL.md @@ -0,0 +1,98 @@ +--- +name: bump-client-pins +description: Bump the exact `unstract-client` / `llmwhisperer-client` pins, re-sync the vendored specs to match, and cut a CLI release. Use whenever a new version of either client is released, when `tests/test_specs.py` or `tests/test_contract.py` fails, when the CLI is missing a flag for an endpoint the API already has, or when someone asks to "bump the client", "update the pins", "refresh the specs", or "release the CLI". Reach for this even when the request sounds like a plain dependency bump — the pins, the vendored specs and `provenance.json` have to move together or the CLI derives flags the pinned client cannot carry. +--- + +# Bumping the client pins + +The CLI derives its flags and help text from two published clients and from +vendored copies of the specs those clients were generated from. That makes a pin +bump three coupled edits, not one: the pin, the spec, and the provenance record. +Move one without the others and the tests say so — which is the point of them. + +## The pieces + +| Thing | Where | +|---|---| +| Exact pins | `pyproject.toml`, `[project].dependencies` | +| Vendored specs | `src/unstract_cli/specs/{docstudio,llmwhisperer}.json` | +| Provenance | `src/unstract_cli/specs/provenance.json` (upstream repo, commit, sha256) | +| Coherence tests | `tests/test_specs.py`, `tests/test_contract.py`, `tests/derived_flags.json` | +| Release | `.github/workflows/release.yml`, `workflow_dispatch` | + +`src/unstract_cli/specs/README.md` explains the vendoring rule in place; read it +if any of the below is unclear. + +## The sequence + +1. **Move the pins** in `pyproject.toml` to the released versions you are + upgrading to. They are exact (`==`) on purpose: the CLI's published surface is + derived from these clients, so a client that moves reshapes the CLI. + +2. **Relock:** `uv lock` then `uv sync --extra dev --python 3.12`. CI installs + from `uv.lock`, not from a fresh resolve, so a lockfile left behind means the + gate tests a dependency set nobody ships. + +3. **Re-sync each vendored spec from the commit the pinned client was generated + from.** The chain is: the client repo's release tag → its `tools/gen_sdk.sh`, + which records the upstream service repo, path and revision the spec was copied + from → the spec file committed in that client at that tag. Copy that file here + byte-for-byte. Copying from anywhere else — upstream `main`, a newer service + commit — is what `tests/test_contract.py` guards: a spec parameter the pinned + client has no argument for cannot become a flag. + +4. **Update `provenance.json`** for each spec you moved: the upstream `commit` + the client recorded, and the `sha256` of the file you just wrote + (`sha256sum src/unstract_cli/specs/`). This is the record that lets the + next person tell a current copy from a stale one. + +5. **Run the tests:** `uv run pytest -q`. + + - `test_specs.py` fails if a vendored file stops matching its pinned sha256, + or if a spec has no provenance entry. It is the cheap check that steps 3 and + 4 actually agree. + - `test_contract.py` fails if a spec parameter the pinned client cannot accept + would have become a flag, and separately if the derived flags stop matching + `tests/derived_flags.json`. + +6. **If `derived_flags.json` fails, read the difference before refreshing it.** + The failure names the flags that moved. A flag missing from the new set is a + flag the CLI has stopped offering; a narrowed choice or changed type is a value + the CLI used to take and now rejects. Once you have decided the change is + intended, refresh it deliberately: + + ```bash + UNSTRACT_CLI_REFRESH_FLAG_SNAPSHOT=1 uv run pytest -q tests/test_contract.py + ``` + + and commit the snapshot in the same PR, so the diff shows what the CLI's + surface gained or lost. + +7. **Lint:** `uv run ruff check . && uv run ruff format --check .` — the release + run repeats exactly this, so a failure here is a failure there. + +## Versioning and release + +Choose the bump by what changed for CLI users: **minor** for new or changed +flags and commands, **patch** for fixes that leave the surface identical. + +Do not bump `__version__` in `src/unstract_cli/__init__.py` in your PR. The +in-repo value names the last released version; `release.yml` reads it, applies +the bump chosen at dispatch and commits the result itself. + +Release by dispatching **Release Tag and Publish Package** on `main`: + +- `version_bump: none` publishes the version already in the repo — what the + first release of a version needs. +- `pre_release: true` publishes `rcN` and deliberately leaves the + committed version alone, counting N up from the rc tags already published for + that target. Dispatch again with it off to promote the same version to stable. +- It publishes to PyPI **before** it tags and releases, because publishing is the + only step that cannot be undone: a failure before it leaves nothing to + unpublish, and one after it is retried by hand against a live artifact. + +## Upstream + +If a client pin is missing an endpoint the service already offers, the fix is in +that client, not here — see the `spec-upgrade` skill in `unstract-python-client` +and `llm-whisperer-python-client`. Bump the pin here once it is released. From 8a5dfdabb1c9129699a6f0bbed867a6f30d1e025 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 8 Sep 2026 15:36:19 +0530 Subject: [PATCH 55/86] docs: check all four provenance fields, and hold version_bump across an rc promotion Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CSm32dsxD56PkKENgav3Mn --- .claude/skills/bump-client-pins/SKILL.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.claude/skills/bump-client-pins/SKILL.md b/.claude/skills/bump-client-pins/SKILL.md index afe45ca..97a32a6 100644 --- a/.claude/skills/bump-client-pins/SKILL.md +++ b/.claude/skills/bump-client-pins/SKILL.md @@ -41,10 +41,13 @@ if any of the below is unclear. commit — is what `tests/test_contract.py` guards: a spec parameter the pinned client has no argument for cannot become a flag. -4. **Update `provenance.json`** for each spec you moved: the upstream `commit` - the client recorded, and the `sha256` of the file you just wrote - (`sha256sum src/unstract_cli/specs/`). This is the record that lets the - next person tell a current copy from a stale one. +4. **Update `provenance.json`** for each spec you moved. Check all four fields + against what that client's `tools/gen_sdk.sh` records — `repo` and `path` as + well as `commit` — because an upstream that moved its spec file leaves those + two stale and the tests cannot see it: they check the `sha256` and the entry + names, nothing about where the file came from. The `sha256` is of the file + you just wrote (`sha256sum src/unstract_cli/specs/`). This record is + what lets the next person tell a current copy from a stale one. 5. **Run the tests:** `uv run pytest -q`. @@ -86,7 +89,10 @@ Release by dispatching **Release Tag and Publish Package** on `main`: first release of a version needs. - `pre_release: true` publishes `rcN` and deliberately leaves the committed version alone, counting N up from the rc tags already published for - that target. Dispatch again with it off to promote the same version to stable. + that target. To promote to stable, dispatch again with it off **and the same + `version_bump`**: the workflow recomputes the target from that input every + time, so a different bump publishes a different version than the one the rc + tested. - It publishes to PyPI **before** it tags and releases, because publishing is the only step that cannot be undone: a failure before it leaves nothing to unpublish, and one after it is retried by hand against a live artifact. From 4e9b1d115236088970a0010745d45ac5f03d5ab9 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Thu, 10 Sep 2026 17:25:13 +0530 Subject: [PATCH 56/86] Remediate review findings across the CLI scaffold Four review iterations plus a simplification pass over the CLI, its config resolution, output envelope and poll engine. The published surface is unchanged: `--discover full`, the exit-code table, the `{ok, data, error, meta}` envelope and every existing flag verify byte-identical against the parent commit. Security - Credential-named fields are matched on whole name segments split at camelCase boundaries, so `accessToken`, `secretAccessKey` and `authorization_header` are recognised. Case was previously folded before the split, collapsing those to one unrecognisable word, and only the trailing segment was tested -- so a server error body echoing them reached stdout intact. - A value under a credential-named key is collapsed whole rather than walked, since nothing beneath such a key is worth the risk of missing one. - `InvalidHeader` is caught ahead of every other transport failure on both the shared client path and the clone path. Its message quotes the offending header value -- the credential -- `repr`-escaped, so the literal scrub cannot match it either. - Diagnostics on stderr are scrubbed like stdout; a note can carry server-authored text, and a credential is no less leaked for arriving on the other stream. - A discovered project-local config may not choose which environment variable is read. `_deref`'s trust boundary has no default, so a caller has to ask for the permissive branch. Correctness - A refused retrieve (408, 429) stays retryable. The blanket un-marking of the one-shot read also flipped a rate limit, so an agent branching on `retryable` would discard a paid extraction still on the server. - A poll spec naming one status as both success and failure is rejected at construction; `classify` tests failure first, so such a status would be reported as an error. - Terminal states are case-folded once at construction rather than on every poll. - `to_dict` derives its reserved names from the payload it builds, so a field added there cannot be forgotten in the guard that stops `extra` from rewriting it. - A flag whose spec type the CLI cannot convert is marked `unsupported` in `--discover`, instead of reading exactly like one it can. Tests - 33 added, each verified by removing the fix and confirming the test fails. Eleven fixes from an earlier round were mutation-green and now carry tests that bite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VcqRYgWLM6s8mFKZbryvGt --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 7 +- .gitignore | 3 + install.sh | 3 +- pyproject.toml | 3 + src/unstract_cli/__main__.py | 15 +- src/unstract_cli/app.py | 3 +- src/unstract_cli/commands/clone_cmd.py | 88 +++++- src/unstract_cli/commands/config_cmd.py | 28 +- src/unstract_cli/commands/docstudio_cmd.py | 52 +++- src/unstract_cli/commands/whisper_cmd.py | 45 ++- src/unstract_cli/config.py | 84 ++++-- src/unstract_cli/core/clients.py | 84 +++++- src/unstract_cli/core/discover.py | 23 +- src/unstract_cli/core/errors.py | 178 +++++++++-- src/unstract_cli/core/output.py | 103 +++++-- src/unstract_cli/core/overlay.py | 22 +- src/unstract_cli/core/params.py | 73 ++++- src/unstract_cli/core/poll.py | 156 ++++++++-- tests/conftest.py | 3 + tests/test_cli.py | 15 +- tests/test_clients.py | 67 +++++ tests/test_commands.py | 328 ++++++++++++++++++++- tests/test_config.py | 79 +++++ tests/test_discover.py | 51 ++++ tests/test_errors.py | 115 ++++++++ tests/test_output.py | 76 ++++- tests/test_params.py | 10 +- tests/test_poll.py | 234 ++++++++++++++- uv.lock | 2 + 30 files changed, 1793 insertions(+), 159 deletions(-) create mode 100644 tests/test_clients.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd5ddb1..8a271fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - run: uv pip install 'click~=8.1.0' - run: .venv/bin/python -m pytest -q - run: .venv/bin/python -m unstract_cli -o json --discover full > floor.json - - run: uv pip install -U click + - run: uv pip install -U 'click>=8.1,<9' - run: .venv/bin/python -m pytest -q - run: .venv/bin/python -m unstract_cli -o json --discover full > latest.json - run: diff floor.json latest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d485b6..2695d65 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,10 @@ on: required: false type: string +concurrency: + group: release + cancel-in-progress: false + jobs: release-and-publish: runs-on: ubuntu-latest @@ -132,6 +136,8 @@ jobs: run: uv publish - name: Commit version bump and create release + env: + RELEASE_NOTES: ${{ github.event.inputs.release_notes }} run: | NEW_VERSION="${{ steps.version.outputs.version }}" @@ -149,7 +155,6 @@ jobs: git tag "v$NEW_VERSION" git push origin "v$NEW_VERSION" - RELEASE_NOTES="${{ github.event.inputs.release_notes }}" if [ -z "$RELEASE_NOTES" ]; then gh release create "v$NEW_VERSION" \ --title "Release v$NEW_VERSION" \ diff --git a/.gitignore b/.gitignore index 130baad..bc4a06a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__/ .ruff_cache/ dist/ build/ +.coverage +.coverage.* +htmlcov/ diff --git a/install.sh b/install.sh index 134cf20..fdd8906 100755 --- a/install.sh +++ b/install.sh @@ -10,7 +10,8 @@ SOURCE="${UNSTRACT_CLI_SOURCE:-git+https://github.com/Zipstack/unstract-cli@main if ! command -v uv >/dev/null 2>&1; then echo "Installing uv..." >&2 curl -LsSf https://astral.sh/uv/install.sh | sh - # The installer only edits shell rc files, which this shell has already read. + # The installer only edits shell rc files, which take effect in a new + # shell; this one needs uv on PATH now. PATH="${XDG_BIN_HOME:-${HOME}/.local/bin}:${HOME}/.cargo/bin:${PATH}" export PATH fi diff --git a/pyproject.toml b/pyproject.toml index bd421bb..fce56de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,9 @@ dependencies = [ # deliberately, against the specs vendored in `src/unstract_cli/specs`. "unstract-client==1.6.0", "llmwhisperer-client==2.9.0", + # Both clients pull this in, but the CLI imports its exception classes + # directly to classify a failure, so it names the dependency itself. + "requests>=2.32.3", ] [project.optional-dependencies] diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py index 6150bf9..1212798 100644 --- a/src/unstract_cli/__main__.py +++ b/src/unstract_cli/__main__.py @@ -8,6 +8,8 @@ from __future__ import annotations +import contextlib +import os import sys import click @@ -40,9 +42,11 @@ def _format_from_argv(argv: list[str]) -> OutputFormat: _option_from_argv(argv, "--output", "-o"), _option_from_argv(argv, "--agent") or AgentMode.AUTO, ) - except ValueError: + except CLIError: # An unusable value here is Click's error to report, not ours to guess - # around; render the failure in the default and let it through. + # around: this runs outside the handler that renders an envelope, so + # raising would lose the stream contract entirely. Fall back to the + # default and let Click's own Choice reject the value downstream. return resolve_format(None) @@ -62,6 +66,13 @@ def main(argv: list[str] | None = None) -> int: fmt, ) ) + except BrokenPipeError: + # The reader is gone, so there is nowhere to render the envelope. Point + # stdout at devnull first: Python flushes it at exit and would otherwise + # raise this again on the way out. + with contextlib.suppress(OSError): + os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno()) + return int(ExitCode.GENERIC) except OSError as exc: # Not a crash worth a traceback: a full disk or an unwritable path is # the caller's to fix, and they still need a parseable envelope. diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 2eb6306..92d5379 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -115,7 +115,8 @@ def secrets(self) -> list[str]: "-o", default=None, type=click.Choice([f.value for f in OutputFormat]), - help="Output format. Defaults to table; pass json to parse the output.", + help="Output format. Defaults to table, or to json when --agent resolves " + "to yes; pass it explicitly to parse the output.", ) @click.option( "--agent", diff --git a/src/unstract_cli/commands/clone_cmd.py b/src/unstract_cli/commands/clone_cmd.py index a2cfcbc..5fa05d7 100644 --- a/src/unstract_cli/commands/clone_cmd.py +++ b/src/unstract_cli/commands/clone_cmd.py @@ -7,27 +7,63 @@ from __future__ import annotations import logging +import re from typing import Any import click - -# The size grammar and the list syntax come from the client rather than a copy -# here, so both spellings of this command accept the same strings. -from unstract.clone.cli import _parse_size, _split_csv +from requests.exceptions import InvalidHeader, RequestException from unstract.clone.context import ( DEFAULT_CONCURRENCY, CloneOptions, OrgEndpoint, ) -from unstract.clone.exceptions import CloneError +from unstract.clone.exceptions import CloneError, PlatformAPIError from unstract.clone.orchestrator import clone as run_clone from unstract.clone.report import CloneReport from unstract_cli.app import Context, cli, pass_context from unstract_cli.commands.common import finish -from unstract_cli.core.errors import CLIError, ExitCode, remember_secret +from unstract_cli.core.clients import UNSENDABLE +from unstract_cli.core.errors import ( + CLIError, + ExitCode, + error_from_status, + remember_secret, +) from unstract_cli.core.output import OutputFormat, emit_text +# Mirrors the table and grammar `unstract.clone.cli` uses, single-letter +# spellings included, so both spellings of this command accept the same strings. +_SIZE_UNITS = { + "B": 1, + "K": 1024, + "KB": 1024, + "M": 1024**2, + "MB": 1024**2, + "G": 1024**3, + "GB": 1024**3, +} +_SIZE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([A-Za-z]*)\s*$") + + +def _parse_size(value: str) -> int: + """Accept ``25``, ``25MB``, ``1.5GB`` etc. Returns bytes.""" + match = _SIZE_RE.match(value) + if not match: + raise click.BadParameter(f"can't parse size {value!r}") + number, unit = match.group(1), match.group(2).upper() or "B" + if unit not in _SIZE_UNITS: + raise click.BadParameter( + f"unknown size unit {unit!r}; use one of {sorted(_SIZE_UNITS)}" + ) + return int(float(number) * _SIZE_UNITS[unit]) + + +def _split_csv(value: str | None) -> tuple[str, ...] | None: + if not value: + return None + return tuple(part.strip() for part in value.split(",") if part.strip()) + @cli.command("clone") @click.option("--source-url", required=True, help="Base URL of the source deployment.") @@ -143,12 +179,52 @@ def endpoint(url: str, org: str, key: str) -> OrgEndpoint: endpoint(target_url, target_org, target_key), options, ) + except PlatformAPIError as exc: + if exc.status_code: + raise error_from_status( + int(exc.status_code), str(exc), details=exc.body + ) from exc + raise CLIError( + str(exc), + ExitCode.SERVER_ERROR, + details=exc.body, + retryable=True, + hint="The Platform API did not answer. Check the URLs and connectivity.", + ) from exc except CloneError as exc: raise CLIError( str(exc), ExitCode.USAGE, hint="The clone could not start. Check the URLs, orgs and keys.", ) from exc + except InvalidHeader as exc: + # The message quotes the offending header value, and that value is the + # platform key. It arrives `repr`-escaped, so the literal scrub cannot + # match it either -- say what happened instead of quoting it. + raise CLIError( + "A request header could not be built.", + ExitCode.USAGE, + hint=( + "A platform key most likely carries a newline or a control " + "character. Check how it is stored." + ), + ) from exc + except UNSENDABLE as exc: + raise CLIError( + str(exc) or type(exc).__name__, + ExitCode.USAGE, + hint=( + "The request could not be built. Check the URLs, and any proxy " + "variables, for a typo." + ), + ) from exc + except RequestException as exc: + raise CLIError( + str(exc) or type(exc).__name__, + ExitCode.SERVER_ERROR, + retryable=True, + hint="The request failed in transit rather than being answered.", + ) from exc _finish(ctx, report) diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index aa02390..3429cca 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -19,6 +19,7 @@ KEY_SOURCES, LLMWHISPERER, PRODUCTS, + UNTRUSTED_PROJECT_KEYS, ConfigError, ConfigFile, ResolvedConfig, @@ -185,12 +186,28 @@ def config_set(obj: Any, product: str, key: str, value: str, profile: str | None cfg.default_profile = name written = save_config(cfg) - warning = None + warnings = [] if _is_secret(key) and not value.startswith("env:"): - warning = ( + warnings.append( "Value stored literally. Prefer `env:VAR_NAME` so the config file holds " "a reference rather than the secret itself." ) + if cfg.is_project_local and key in UNTRUSTED_PROJECT_KEYS: + warnings.append( + f"{written} was found by searching upwards rather than named, so " + f"`{key}` written there is withheld when the config is loaded. Pass " + f"--config {written} to use it, or write it to the home config." + ) + if cfg.is_project_local and value.startswith("env:"): + # Refused for every key, not only the withheld ones, so writing it + # without a word would report success for a setting that never resolves. + warnings.append( + f"{written} was found by searching upwards rather than named, so it " + f"may not choose which environment variable is read and `{value}` is " + f"ignored when the config is loaded. Pass --config {written} to use " + f"it, or write it to the home config." + ) + warning = " ".join(warnings) or None emit_result( { @@ -348,10 +365,9 @@ def config_doctor(obj: Any, probe: bool) -> None: def _loaded(obj: Any) -> ConfigFile: """The config file, with its warnings reported. - These commands load the file themselves rather than through the root - context, and they are the two a user runs *to understand* their config -- - reading it here without repeating what it warned about would make them the - quietest commands in the CLI about their own subject. + A `config` subcommand may run with no root context to have loaded the file, + so it reports here what the file warned about -- reading it silently would + make these the quietest commands in the CLI about their own subject. """ cfg = load_config() for warning in cfg.warnings: diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index 24656d3..cade373 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -8,7 +8,7 @@ from collections.abc import Callable from typing import Any -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, quote, urlparse import click from unstract.api_deployments.client import APIDeploymentsClient @@ -23,8 +23,16 @@ translating, ) from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.output import diagnostic from unstract_cli.core.params import requested, spec_options -from unstract_cli.core.poll import PollSpec, classify, preflight, wait_for_completion +from unstract_cli.core.poll import ( + PollSpec, + PollState, + classify, + persist, + preflight, + wait_for_completion, +) PRODUCT = "docstudio" @@ -79,6 +87,15 @@ def run( """ client = deployment(ctx.config, target, ctx.transport_timeout) sent = requested(params) + if save and not wait: + raise CLIError( + "--save has nothing to write with --no-wait.", + ExitCode.USAGE, + hint=( + "Drop --no-wait, or start now and save later with " + "`deployment status --save`." + ), + ) if save: preflight(save) with naming_aliases(ctx.config, target), translated(endpoint=client.api_url): @@ -103,8 +120,14 @@ def run( save=save, interval=interval, timeout=wait_timeout, - on_status=lambda status: ( - click.echo(f"status: {status}", err=True) if not ctx.quiet else None + on_status=lambda status: diagnostic( + f"status: {status}", quiet=ctx.quiet, verbosity=ctx.verbosity + ), + on_retry=lambda exc: diagnostic( + f"retrying: {exc.message}", quiet=ctx.quiet, verbosity=ctx.verbosity + ), + on_saved=lambda path: diagnostic( + f"saved: {path}", quiet=ctx.quiet, verbosity=ctx.verbosity ), ) # A waited result names no execution, so the handle is returned as meta for @@ -147,18 +170,30 @@ def poll(endpoint: str) -> dict[str, Any]: client_method=APIDeploymentsClient.check_execution_status, exclude=("execution_id",), ) +@click.option( + "--save", + type=click.Path(dir_okay=False), + default=None, + help="Write the result here before printing it.", +) @pass_context -def status(ctx: Context, target: str, execution_id: str, **params: Any) -> None: +def status( + ctx: Context, target: str, execution_id: str, save: str | None, **params: Any +) -> None: """Report the state of a running or finished execution.""" client = deployment(ctx.config, target, ctx.transport_timeout) - endpoint = f"{client.api_url}?execution_id={execution_id}" + if save: + preflight(save) + # Quoted rather than trusted: the id comes from the caller and would + # otherwise be able to carry query syntax of its own. + endpoint = f"{client.api_url}?execution_id={quote(execution_id, safe='')}" with naming_aliases(ctx.config, target), translated(endpoint=client.api_url): result = client.check_execution_status(endpoint, **requested(params)) if not result.get("pending"): raise_for_result(result, endpoint=client.api_url) # A finished-and-failed execution is reported inside an HTTP 200, so the # status code alone would call this a success. - if classify(result, RUN_POLL) == "failure": + if classify(result, RUN_POLL) is PollState.FAILURE: raise CLIError( f"Execution {execution_id} finished with status " f"{result.get('execution_status')!r}.", @@ -168,6 +203,9 @@ def status(ctx: Context, target: str, execution_id: str, **params: Any) -> None: hint="Inspect `details` for the per-file error, or check the execution logs.", extra={"execution_id": execution_id}, ) + if save: + written = persist(save, result) + diagnostic(f"saved: {written}", quiet=ctx.quiet, verbosity=ctx.verbosity) finish(ctx, result, raw_fields=STATUS_RAW) diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index 2b3f1ff..be378c4 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -16,9 +16,11 @@ from unstract_cli.commands.common import finish, raw_fields, wait_options from unstract_cli.core.clients import llmwhisperer, translated, translating from unstract_cli.core.errors import CLIError, ExitCode, remember_secret +from unstract_cli.core.output import diagnostic from unstract_cli.core.params import requested, spec_options from unstract_cli.core.poll import ( PollSpec, + PollState, classify, extract_status, persist, @@ -75,6 +77,15 @@ def extract( """ client = llmwhisperer(ctx.config) sent = requested(params) + if save and not wait: + raise CLIError( + "--save has nothing to write with --no-wait.", + ExitCode.USAGE, + hint=( + "Drop --no-wait, or submit now and save later with " + "`whisper retrieve --save`." + ), + ) if save: preflight(save) @@ -112,8 +123,14 @@ def extract( save=save, interval=interval, timeout=wait_timeout, - on_status=lambda status: ( - click.echo(f"status: {status}", err=True) if not ctx.quiet else None + on_status=lambda status: diagnostic( + f"status: {status}", quiet=ctx.quiet, verbosity=ctx.verbosity + ), + on_retry=lambda exc: diagnostic( + f"retrying: {exc.message}", quiet=ctx.quiet, verbosity=ctx.verbosity + ), + on_saved=lambda path: diagnostic( + f"saved: {path}", quiet=ctx.quiet, verbosity=ctx.verbosity ), ) # Waiting returns the text, which identifies the job nowhere; the hash is @@ -159,7 +176,7 @@ def status(ctx: Context, whisper_hash: str) -> None: result = client.whisper_status(whisper_hash) # A failed extraction is reported inside an HTTP 200, so the status code # alone would call this a success. - if classify(result, EXTRACT_POLL) == "failure": + if classify(result, EXTRACT_POLL) is PollState.FAILURE: raise CLIError( f"Extraction finished with status {extract_status(result)!r}.", ExitCode.VALIDATION, @@ -198,7 +215,8 @@ def retrieve(ctx: Context, whisper_hash: str, save: str | None) -> None: payload = client.whisper_retrieve(whisper_hash) result = _extraction(payload) if save: - persist(save, result) + written = persist(save, result) + diagnostic(f"saved: {written}", quiet=ctx.quiet, verbosity=ctx.verbosity) finish(ctx, result, raw_fields=RAW_TEXT) @@ -331,7 +349,12 @@ def webhook_group() -> None: @webhook_group.command("create") @click.argument("name") @click.option("--url", required=True, help="Where the result is delivered.") -@click.option("--auth-token", required=True, help="Token sent with the delivery.") +@click.option( + "--auth-token", + envvar="UNSTRACT_WEBHOOK_AUTH_TOKEN", + required=True, + help="Token sent with the delivery (or env UNSTRACT_WEBHOOK_AUTH_TOKEN).", +) @pass_context def webhook_create(ctx: Context, name: str, url: str, auth_token: str) -> None: """Register a webhook.""" @@ -344,7 +367,12 @@ def webhook_create(ctx: Context, name: str, url: str, auth_token: str) -> None: @webhook_group.command("update") @click.argument("name") @click.option("--url", required=True, help="Where the result is delivered.") -@click.option("--auth-token", required=True, help="Token sent with the delivery.") +@click.option( + "--auth-token", + envvar="UNSTRACT_WEBHOOK_AUTH_TOKEN", + required=True, + help="Token sent with the delivery (or env UNSTRACT_WEBHOOK_AUTH_TOKEN).", +) @pass_context def webhook_update(ctx: Context, name: str, url: str, auth_token: str) -> None: """Replace a webhook's URL and token.""" @@ -360,9 +388,10 @@ def webhook_update(ctx: Context, name: str, url: str, auth_token: str) -> None: def webhook_get(ctx: Context, name: str) -> None: """Show one webhook's configuration. - The token is reported as redacted, including for a webhook registered + The token is registered for redaction, including for a webhook created elsewhere: it authenticates deliveries wherever it was set, and this output - is as likely to land in a log as on a screen. + is as likely to land in a log as on a screen. A token too short to scrub for + is reported as such on stderr rather than silently printed. """ client = llmwhisperer(ctx.config) with translated(endpoint="whisper-manage-callback"): diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index c31ade6..1319f4c 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -18,6 +18,7 @@ import contextlib import os import stat +import sys import tempfile import tomllib from copy import deepcopy @@ -52,8 +53,9 @@ } -#: Where the two credentials are minted. Quoted wherever the CLI reports one as -#: missing: knowing a key is unset is no help without knowing where one is made. +#: Where the two credentials are minted. Quoted by `config doctor` and by the +#: starter file `config init` writes: knowing a key is unset is no help without +#: knowing where one is made. KEY_SOURCES = ( "Get an LLMWhisperer key from the LLMWhisperer console; a deployment key is " "shown on the API deployment's own page in the Unstract UI, and a key " @@ -146,7 +148,7 @@ def _resolve_config_path() -> tuple[Path, bool]: return HOME_CONFIG.expanduser(), False -def _deref(value: Any) -> Any: +def _deref(value: Any, *, allow_env: bool) -> Any: """Resolve ``env:VAR_NAME`` indirection so config files hold no secrets. An unset variable resolves to ``None`` rather than the literal string, so a @@ -155,17 +157,24 @@ def _deref(value: Any) -> Any: An empty string resolves the same way: the placeholders a generated config carries must not satisfy `require`. + + ``allow_env`` is the trust boundary, and has no default: the permissive + branch is the one that splices an attacker-chosen variable into a request + URL, so a caller has to ask for it. A discovered project-local file may not + name the variable to read, because whatever it names is then spliced into a + request URL and echoed back in any error about it. """ if isinstance(value, str): if value.startswith("env:"): - return os.environ.get(value[4:].strip()) or None + return os.environ.get(value[4:].strip()) or None if allow_env else None return value or None return value -#: Settings a *discovered* project-local file may not supply: a checkout the -#: user did not write must not choose the host their key is sent to. Everything -#: else -- org_id, profile selection, deployment aliases -- is still honoured. +#: Settings a *discovered* project-local file may not supply as literals: a +#: checkout the user did not write must not choose the host their key is sent +#: to. Separately, and for every key, such a file may not name an environment +#: variable to read either -- see `ResolvedConfig._env_refused`. UNTRUSTED_PROJECT_KEYS = frozenset({"api_key", "base_url"}) @@ -364,6 +373,8 @@ class ResolvedConfig: file: ConfigFile profile_name: str | None = None overrides: dict[str, Any] = field(default_factory=dict) + #: `env:` references already refused, so one is reported once per run. + _reported: set[str] = field(default_factory=set, repr=False, init=False) @property def active_profile(self) -> str | None: @@ -405,14 +416,13 @@ def get(self, product: str, key: str, default: Any = None) -> Any: def _resolve(self, product: str, key: str, default: Any = None) -> Any: if (value := self.overrides.get(f"{product}.{key}")) is not None: return value - if (value := self.overrides.get(key)) is not None: - return value for env_var in ENV_VARS.get((product, key), ()): if value := os.environ.get(env_var): return value - if (value := _deref(self._product_block(product).get(key))) is not None: + raw = self._product_block(product).get(key) + if (value := _deref(raw, allow_env=self._env_allowed(raw))) is not None: return value if default is not None: @@ -421,6 +431,39 @@ def _resolve(self, product: str, key: str, default: Any = None) -> Any: return DEFAULT_BASE_URLS.get(product) return None + def _env_refused(self, raw: Any) -> bool: + """Whether this value names an environment variable this file may not read. + + Pure, so a report can ask the same question `_resolve` does without the + side effect of warning about a value it is only describing. + """ + return self.file.is_project_local and ( + isinstance(raw, str) and raw.startswith("env:") + ) + + def _env_allowed(self, raw: Any) -> bool: + """Whether this value may name an environment variable to read.""" + if not self._env_refused(raw): + return True + # Straight to stderr rather than onto `file.warnings`: those are + # reported when the file is loaded, and this is found while resolving. + if raw not in self._reported: + self._reported.add(raw) + print( + f"warning: ignoring {raw!r} in the project-local " + f"{self.file.path}: a config file found by searching upwards " + "may not choose which environment variable is read.", + file=sys.stderr, + ) + return False + + def _env_refusal_detail(self, raw: Any) -> str: + """Why an `env:` reference was not followed.""" + return ( + f"{self.file.path} is a discovered {PROJECT_CONFIG_NAME}, which may " + f"not choose which environment variable is read, so {raw!r} is ignored" + ) + def require(self, product: str, key: str) -> Any: """Resolve a setting, or raise a message naming exactly how to supply it.""" if (value := self.get(product, key)) is not None: @@ -476,11 +519,15 @@ def _alias_setting(self, alias: str, entry: dict[str, Any], key: str) -> Any: """ raw = entry.get(key) if isinstance(raw, str) and raw.startswith("env:"): - if value := _deref(raw): + if value := _deref(raw, allow_env=self._env_allowed(raw)): return value + reason = ( + self._env_refusal_detail(raw) + if self._env_refused(raw) + else f"${raw[4:].strip()} is not set in this process's environment" + ) raise ConfigError( - f"Deployment alias {alias!r} sets {key} to {raw!r}, and " - f"${raw[4:].strip()} is not set in this process's environment." + f"Deployment alias {alias!r} sets {key} to {raw!r}, and {reason}." ) return raw or self.get(DOCSTUDIO, key) @@ -496,10 +543,7 @@ def resolution_source(self, product: str, key: str) -> dict[str, Any]: time: "the CLI says the key is not configured, but I set it -- where is it looking?" """ - if ( - self.overrides.get(f"{product}.{key}") is not None - or self.overrides.get(key) is not None - ): + if self.overrides.get(f"{product}.{key}") is not None: return {"resolved": True, "source": "flag/override"} for env_var in ENV_VARS.get((product, key), ()): @@ -509,6 +553,12 @@ def resolution_source(self, product: str, key: str) -> dict[str, Any]: raw = self._product_block(product).get(key) if isinstance(raw, str) and raw.startswith("env:"): var = raw[4:].strip() + if self._env_refused(raw): + return { + "resolved": False, + "source": f"profile -> env:{var} (refused)", + "detail": self._env_refusal_detail(raw), + } present = bool(os.environ.get(var)) return { "resolved": present, diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py index 66923e6..cd7d1ea 100644 --- a/src/unstract_cli/core/clients.py +++ b/src/unstract_cli/core/clients.py @@ -7,7 +7,8 @@ The two clients report failure differently -- LLMWhisperer raises with a status code attached, the deployment client returns a dict containing one -- so both -shapes converge here rather than in each command. +shapes converge here rather than in each command. The deployment client also +raises for a request it will not send at all, which is always a usage error. """ from __future__ import annotations @@ -17,7 +18,16 @@ from contextlib import contextmanager from typing import Any -from requests.exceptions import ConnectionError, Timeout +from requests.exceptions import ( + ConnectionError, + InvalidHeader, + InvalidSchema, + InvalidURL, + MissingSchema, + RequestException, + Timeout, + URLRequired, +) from unstract.api_deployments.client import ( APIDeploymentsClient, APIDeploymentsClientException, @@ -171,6 +181,18 @@ def _unresolved_host(exc: BaseException) -> str | None: return "" +#: Failures that mean the request was never sendable, so the fault is in the +#: caller's configuration rather than in the service. `InvalidHeader` is not +#: among them: every handler takes it first, to keep the credential it quotes +#: out of the message. `InvalidProxyURL` is an `InvalidURL`. +UNSENDABLE = ( + MissingSchema, + InvalidSchema, + InvalidURL, + URLRequired, +) + + @contextmanager def translated(endpoint: str | None = None) -> Iterator[None]: """Turn a client failure into a CLIError with an exit code and a hint.""" @@ -211,6 +233,40 @@ def translated(endpoint: str | None = None) -> Iterator[None]: retryable=True, hint="Could not reach the service. Check the base URL and connectivity.", ) from exc + except InvalidHeader as exc: + # The message quotes the offending header value, and that value is the + # credential. It arrives `repr`-escaped, so the literal scrub cannot + # match it either -- say what happened instead of quoting it. + raise CLIError( + "A request header could not be built.", + ExitCode.USAGE, + endpoint=endpoint, + hint=( + "A credential most likely carries a newline or a control " + "character. Check how it is stored." + ), + ) from exc + except UNSENDABLE as exc: + # These say the request could never be sent -- a base URL without a + # scheme is the usual one. Retrying is the wrong advice, and the fault + # is in the caller's configuration rather than in the service. + raise CLIError( + str(exc) or type(exc).__name__, + ExitCode.USAGE, + endpoint=endpoint, + hint=( + "The request could not be built. Check the base URL, and any " + "proxy variables, for a typo." + ), + ) from exc + except RequestException as exc: + raise CLIError( + str(exc) or type(exc).__name__, + ExitCode.SERVER_ERROR, + endpoint=endpoint, + retryable=True, + hint="The request failed in transit rather than being answered.", + ) from exc def translating( @@ -237,9 +293,28 @@ def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> Non failure would otherwise be reported as a successful run whose payload happens to contain an error. """ - status = int(result.get("status_code") or 0) + raw_status = result.get("status_code") + try: + status = int(raw_status) if raw_status is not None else 0 + except (TypeError, ValueError): + raise CLIError( + f"The service reported a status code of {raw_status!r}.", + ExitCode.SERVER_ERROR, + details=result, + endpoint=endpoint, + hint="`details` carries the response exactly as it arrived.", + ) from None reported = result.get("error") - if status and not 200 <= status < 300: + if not status: + raise CLIError( + "The service answered without a status code.", + ExitCode.SERVER_ERROR, + details=result, + endpoint=endpoint, + retryable=True, + hint="`details` carries the response exactly as it arrived.", + ) + if not 200 <= status < 300: raise error_from_status( status, str(reported or f"Request failed with status {status}"), @@ -261,6 +336,7 @@ def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> Non __all__ = [ + "UNSENDABLE", "deployment", "deployment_url", "llmwhisperer", diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py index ebec37e..dea4bec 100644 --- a/src/unstract_cli/core/discover.py +++ b/src/unstract_cli/core/discover.py @@ -20,8 +20,9 @@ import click -from unstract_cli.core.errors import _ERROR_CODES, ExitCode +from unstract_cli.core.errors import CLIError, ExitCode, error_code_for from unstract_cli.core.output import CONTRACT_VERSION +from unstract_cli.core.params import Diverged TIERS = ("groups", "summary", "full") @@ -30,9 +31,9 @@ #: is installed -- serialised, it would publish a string that reads as a value. _NO_DEFAULT = click.Option(["--unset"]).default -#: The same question for a paired on/off flag, which answers it differently: -#: given no default, some versions report `False` and others their own sentinel. -#: Read the same way, so neither is mistaken for a default the flag really has. +#: The same question for a paired on/off flag declared with `default=None`, the +#: way this CLI declares one it does not send unless asked: some versions report +#: `False` here and others their own sentinel, and neither is a real default. _NO_FLAG_DEFAULT = click.Option(["--unset/--no-unset"], default=None).default @@ -66,7 +67,7 @@ def exit_codes() -> list[dict[str, Any]]: { "code": int(code), "name": code.name.lower(), - "error_code": _ERROR_CODES.get(code, ""), + "error_code": "" if code is ExitCode.SUCCESS else error_code_for(code), } for code in ExitCode ] @@ -86,6 +87,10 @@ def _param(param: click.Parameter) -> dict[str, Any]: entry["repeatable"] = bool(param.multiple) if isinstance(param.type, click.Choice): entry["choices"] = list(param.type.choices) + if isinstance(param.type, Diverged): + # Otherwise a flag the CLI cannot convert reads exactly like one it can, + # and the caller only finds out by passing it. + entry["unsupported"] = True # What omitting the flag actually gets you, which is not what Click reports: # the same declaration answers differently across the supported range, so # reading `param.default` straight publishes a contract per version. @@ -108,7 +113,7 @@ def _params(command: click.Command) -> list[dict[str, Any]]: A group carries the connection settings for everything beneath it, so describing only the leaves describes a call nobody can make. """ - return [_param(p) for p in command.params if p.name not in ("help", "discover")] + return [_param(p) for p in command.params if p.name != "help"] def _describe(command: click.Command, tier: str) -> dict[str, Any]: @@ -135,7 +140,11 @@ def discover(root: click.Group, tier: str) -> dict[str, Any]: needs to. """ if tier not in TIERS: - raise ValueError(f"Unknown discovery tier {tier!r}. One of: {', '.join(TIERS)}") + raise CLIError( + f"Unknown discovery tier {tier!r}.", + ExitCode.USAGE, + hint=f"One of: {', '.join(TIERS)}.", + ) if tier == "groups": top = sorted(root.commands.items()) diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index 2edd158..b8dc406 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -1,13 +1,14 @@ """Exit codes, structured errors, and secret redaction. Exit codes are a stable API: a caller branches on them without parsing prose. -Every failure also carries `hint` and `retryable` so the caller can self-correct -rather than retry blindly. +Every failure carries `retryable`, and a `hint` wherever one can be given, so +the caller can self-correct rather than retry blindly. """ from __future__ import annotations import re +import sys from dataclasses import dataclass, field from enum import IntEnum from typing import Any @@ -61,9 +62,19 @@ class ExitCode(IntEnum): } +def error_code_for(code: ExitCode) -> str: + """The published error token for an exit code.""" + return _ERROR_CODES.get(code, "error") + + +def error_codes() -> dict[ExitCode, str]: + """Every exit code that names an error, for publication.""" + return dict(_ERROR_CODES) + + def exit_code_for_status(status: int) -> ExitCode: """Map an HTTP status onto its exit code.""" - if code := _STATUS_MAP.get(status): + if (code := _STATUS_MAP.get(status)) is not None: return code if 500 <= status < 600: return ExitCode.SERVER_ERROR @@ -73,13 +84,14 @@ def exit_code_for_status(status: int) -> ExitCode: def is_retryable(status: int) -> bool: - """Retry only on rate limiting and server faults -- never on 4xx. + """Retry on rate limiting, server faults and 408 -- never on another 4xx. Retrying a 4xx re-sends a request the server already rejected on its merits, and for one-shot reads a blind retry can consume a result the first attempt - already delivered. + already delivered. A 408 is the exception: the server abandoned the wait + rather than judging the request, so the same request is still worth sending. """ - return status == 429 or 500 <= status < 600 + return status in (408, 429) or 500 <= status < 600 # --------------------------------------------------------------------------- # @@ -88,18 +100,68 @@ def is_retryable(status: int) -> bool: _SECRET_HEADERS = {"unstract-key", "authorization", "apikey"} _SECRET_HEADER_PREFIXES = ("x-",) -_SECRET_KEY_HINTS = ("key", "token", "secret", "password", "credential", "auth") +#: Words that mark a field or header as carrying a credential. `names_a_secret` +#: matches these as whole name segments; `redact_headers` matches them as +#: substrings, since a header name is a flatter namespace than a payload's. +_SECRET_KEY_HINTS = frozenset( + { + "bearer", + "key", + "keys", + "apikey", + "token", + "tokens", + "secret", + "secrets", + "passwd", + "password", + "credential", + "credentials", + "auth", + "authorization", + } +) REDACTED = "***REDACTED***" +#: Below this, replacing a value would mangle unrelated text more often than it +#: would hide a credential. +_MIN_SECRET_LEN = 8 + #: Credentials resolved during this run. Registered where they are resolved, so #: no emitter has to remember to opt into scrubbing. _KNOWN_SECRETS: set[str] = set() +#: Short credentials already warned about, so one key warns once per run. +_REPORTED_SHORT: set[str] = set() + def remember_secret(value: Any) -> None: """Record a resolved credential so no stream can print it later.""" - if isinstance(value, str) and len(value) >= 8: - _KNOWN_SECRETS.add(value) + if not isinstance(value, str) or not value: + return + if len(value) < _MIN_SECRET_LEN: + # Say so rather than drop it silently: the caller has every reason to + # believe registering a credential is what protects it. Once per value: + # a key resolves several times in one run. + if value not in _REPORTED_SHORT: + _REPORTED_SHORT.add(value) + print( + f"warning: a credential under {_MIN_SECRET_LEN} characters is too " + "short to scrub for and will not be redacted", + file=sys.stderr, + ) + return + _KNOWN_SECRETS.add(value) + + +def forget_secrets() -> None: + """Drop every credential registered so far. + + The registry is process-global, so a test that resolves one would otherwise + leak it into every test that runs after it. + """ + _KNOWN_SECRETS.clear() + _REPORTED_SHORT.clear() def known_secrets() -> list[str]: @@ -124,16 +186,32 @@ def redact_headers(headers: dict[str, Any]) -> dict[str, Any]: return out +#: Splits a field name into words on punctuation and on camelCase boundaries. +#: Case has to be read before it is folded away, or `accessToken` collapses to a +#: single unrecognisable word. +_NAME_SEGMENTS = re.compile(r"[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])") + + +def names_a_secret(key: Any) -> bool: + """Whether a field name marks its value as a credential. + + Matched on whole words rather than as a substring, so `authors` is not read + as `auth`. Any word counts, not just the last: `secretAccessKey` and + `authorization_header` name credentials as surely as `api_key` does. That + also redacts a `key_terms`, which is the side to err on -- an over-redacted + field is an inconvenience, an under-redacted one is a leak. + """ + segments = [p for p in _NAME_SEGMENTS.split(str(key)) if p] + return any(part.lower() in _SECRET_KEY_HINTS for part in segments) + + def redact_value(value: Any) -> Any: """Recursively redact secret-looking keys in a payload.""" if isinstance(value, dict): return { - k: ( - REDACTED - if any(hint in str(k).lower() for hint in _SECRET_KEY_HINTS) - and isinstance(v, str) - else redact_value(v) - ) + # Collapsed whole rather than walked: nothing under a key that + # names a credential is worth more than the risk of missing one. + k: (REDACTED if names_a_secret(k) else redact_value(v)) for k, v in value.items() } if isinstance(value, list): @@ -149,11 +227,35 @@ def scrub(text: str, secrets: list[str]) -> str: redacting a 3-character "key" would mangle unrelated text. """ for secret in secrets: - if secret and len(secret) >= 8: + if secret and len(secret) >= _MIN_SECRET_LEN: text = re.sub(re.escape(secret), REDACTED, text) return text +def scrub_structure(value: Any, secrets: list[str]) -> Any: + """Remove secret literals from every string in a payload. + + Rendering is what defeats a scrub applied afterwards: a table wraps a long + cell across lines and JSON escapes quotes and non-ASCII, so a credential + that was one literal in the payload is no longer one literal in the output. + Replacing before rendering is what closes that; `scrub` stays as a backstop. + """ + if not secrets: + return value + if isinstance(value, str): + return scrub(value, secrets) + if isinstance(value, dict): + return { + scrub_structure(k, secrets): scrub_structure(v, secrets) + for k, v in value.items() + } + if isinstance(value, list): + return [scrub_structure(v, secrets) for v in value] + if isinstance(value, tuple): + return tuple(scrub_structure(v, secrets) for v in value) + return value + + # --------------------------------------------------------------------------- # # CLIError # --------------------------------------------------------------------------- # @@ -170,7 +272,6 @@ class CLIError(Exception): endpoint: str | None = None hint: str | None = None retryable: bool = False - code: str | None = None extra: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: @@ -179,24 +280,26 @@ def __post_init__(self) -> None: raise ValueError("a CLIError cannot carry the success exit code") def to_dict(self) -> dict[str, Any]: + # Written out whole, then thinned: one list of the names this owns, so + # a field added here cannot be forgotten in the guard below. payload: dict[str, Any] = { - "code": self.code or _ERROR_CODES.get(self.exit_code, "error"), + "code": error_code_for(self.exit_code), "message": self.message, "exit_code": int(self.exit_code), "retryable": self.retryable, - } - if self.http_status is not None: - payload["http_status"] = self.http_status - if self.details is not None: + "http_status": self.http_status, # Structural, not opt-in: the details come from a server body that # can echo the request, headers and key included. - payload["details"] = redact_value(self.details) - if self.endpoint: - payload["endpoint"] = self.endpoint - if self.hint: - payload["hint"] = self.hint - payload.update(self.extra) - return payload + "details": redact_value(self.details), + "endpoint": self.endpoint or None, + "hint": self.hint or None, + } + # `extra` carries server-named keys (a poll handle, say), so it may not + # be allowed to rewrite a field a caller branches on -- including one + # omitted from this payload for being unset. + reserved = payload.keys() + extra = {k: v for k, v in self.extra.items() if k not in reserved} + return {k: v for k, v in payload.items() if v is not None} | extra def error_from_status( @@ -238,7 +341,9 @@ def hint_for(status: int) -> str | None: case 400: return ( "The service rejected the request. Check the ids and parameter " - "values passed; `details` carries the service's own response." + "values passed; `details` carries the service's own response. On " + "a retrieve this can also mean the result was already read -- " + "that read cannot be repeated, so pass --save to keep the next one." ) case 401 | 403: # Wrong, revoked and not-permitted all arrive as the same response, @@ -262,8 +367,17 @@ def hint_for(status: int) -> str | None: "its result exactly once; re-running the status call cannot " "recover it. Pass --save to `deployment run` to keep the next one." ) + case 402: + return ( + "Out of quota, or the licence does not cover this request. " + "Check the subscription for this product." + ) case 409: return "The resource is in use, or conflicts with an existing one." + case 413: + return "The upload is larger than the service accepts." + case 415: + return "The file's type is not one this service extracts." case 429: return "Rate limited. Back off and retry." if 500 <= status < 600: @@ -275,6 +389,10 @@ def hint_for(status: int) -> str | None: "REDACTED", "CLIError", "ExitCode", + "error_code_for", + "error_codes", + "scrub_structure", + "forget_secrets", "known_secrets", "remember_secret", "error_from_status", diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index edcc4d1..9017452 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -27,9 +27,15 @@ from collections.abc import Mapping from enum import StrEnum from fnmatch import fnmatch -from typing import Any +from typing import Any, TypedDict -from unstract_cli.core.errors import CLIError, ExitCode, known_secrets, scrub +from unstract_cli.core.errors import ( + CLIError, + ExitCode, + known_secrets, + scrub, + scrub_structure, +) #: Major version of the stdout envelope, published in every ``meta``. CONTRACT_VERSION = 1 @@ -51,11 +57,18 @@ class AgentMode(StrEnum): NO = "no" +#: Values a tool uses to say "set, but not on". Treating these as present would +#: read `CLAUDECODE=0` as the opposite of what it says. +_DISABLED_VALUES = {"", "0", "false", "no", "off"} + + def agent_detected(env: Mapping[str, str] | None = None) -> bool: """Whether the environment looks like a coding agent's.""" names = os.environ if env is None else env return any( - names[name] and fnmatch(name, pattern) for name in names for pattern in AGENT_ENV + fnmatch(name, pattern) and names[name].strip().lower() not in _DISABLED_VALUES + for name in names + for pattern in AGENT_ENV ) @@ -71,18 +84,34 @@ def resolve_format( bytes, which is the property a script is relying on. """ if explicit: - return OutputFormat(explicit) + try: + return OutputFormat(explicit) + except ValueError: + raise CLIError( + f"Unknown output format {explicit!r}.", + ExitCode.USAGE, + hint=f"Pick one of: {', '.join(f.value for f in OutputFormat)}.", + ) from None if agent == AgentMode.YES or (agent == AgentMode.AUTO and agent_detected(env)): return OutputFormat.JSON return OutputFormat.TABLE +class Envelope(TypedDict): + """The one object `-o json` writes to stdout, success or failure alike.""" + + ok: bool + data: Any + error: dict[str, Any] | None + meta: dict[str, Any] + + def envelope( *, data: Any = None, error: dict[str, Any] | None = None, meta: dict[str, Any] | None = None, -) -> dict[str, Any]: +) -> Envelope: """Build the stdout envelope. ``ok`` is derived, never passed in.""" return { "ok": error is None, @@ -144,10 +173,14 @@ def _rows_and_columns( def _terminal_width(default: int = 100) -> int: try: return max(shutil.get_terminal_size((default, 24)).columns, 40) - except Exception: # pragma: no cover - detached terminal + except (OSError, ValueError): # pragma: no cover - detached terminal return default +#: No column shrinks below this: past it a wrapped cell is unreadable anyway. +_MIN_COLUMN = 8 + + def render_table( data: Any, columns: tuple[str, ...] = (), *, max_width: int | None = None ) -> str: @@ -179,9 +212,17 @@ def render_table( # so a narrow column is never squeezed on behalf of a wide neighbour. widths = list(natural) budget = total_width - gutter * (len(headers) - 1) - while sum(widths) > budget and max(widths) > 8: - widest = widths.index(max(widths)) - widths[widest] -= 1 + if sum(widths) > budget: + # Cap the widest columns at a common ceiling -- the same result as + # shaving the widest one character at a time, without the O(width) walk. + floor, ceiling = _MIN_COLUMN, max(widths) + while floor < ceiling: + cap = (floor + ceiling + 1) // 2 + if sum(min(w, cap) for w in widths) <= budget: + floor = cap + else: + ceiling = cap - 1 + widths = [min(w, floor) for w in widths] def fmt(cells: list[str]) -> list[str]: """Lay one logical row out over as many physical lines as it needs.""" @@ -207,7 +248,16 @@ def fmt(cells: list[str]) -> list[str]: return "\n".join(out) -def raw_value(env: dict[str, Any], fields: tuple[str, ...]) -> Any: +def _payload(env: Envelope) -> Any: + """The half of an envelope that carries the answer. + + Read off `error` rather than `ok`: were the two ever to disagree, trusting + `ok` would drop the error and render a null as data. + """ + return env["data"] if env["error"] is None else env["error"] + + +def raw_value(env: Envelope, fields: tuple[str, ...]) -> Any: """The first declared field this answer actually carries. Commands declare several because one call has several shapes: a queued run @@ -224,13 +274,18 @@ def raw_value(env: dict[str, Any], fields: tuple[str, ...]) -> Any: field's own ``null`` is worse, because a caller polling for a result cannot tell it apart from a finished job that produced nothing. """ - payload = env["data"] if env["ok"] else env["error"] + payload = _payload(env) if not fields or not isinstance(payload, dict): return payload for name in fields: for source in (payload, env.get("meta") or {}): - if isinstance(source, dict) and source.get(name) is not None: - return source[name] + if not isinstance(source, dict): + continue + # Only `None` counts as absent: an empty result is a real answer, + # and skipping it would print the next field -- a handle where the + # caller expects text -- rather than nothing. + if (value := source.get(name)) is not None: + return value raise CLIError( f"This answer carries none of {', '.join(fields)}, so there is nothing " "to print as raw output.", @@ -240,7 +295,7 @@ def raw_value(env: dict[str, Any], fields: tuple[str, ...]) -> Any: def render( - env: dict[str, Any], + env: Envelope, fmt: OutputFormat = OutputFormat.JSON, *, columns: tuple[str, ...] = (), @@ -250,7 +305,7 @@ def render( if fmt is OutputFormat.JSON: return json.dumps(env, indent=2, default=str) - payload = env["data"] if env["ok"] else env["error"] + payload = _payload(env) if fmt is OutputFormat.TABLE: return render_table(payload, columns) @@ -262,8 +317,13 @@ def render( return json.dumps(payload, indent=2, default=str) +def _to_hide(secrets: list[str] | None) -> list[str]: + """Every credential to keep off a stream, each named once.""" + return list(dict.fromkeys([*(secrets or []), *known_secrets()])) + + def emit( - env: dict[str, Any], + env: Envelope, fmt: OutputFormat = OutputFormat.JSON, *, columns: tuple[str, ...] = (), @@ -276,6 +336,7 @@ def emit( caller passed one: an emitter that has to remember is an emitter that eventually forgets. """ + env = scrub_structure(env, _to_hide(secrets)) emit_text(render(env, fmt, columns=columns, raw_fields=raw_fields), secrets=secrets) @@ -286,7 +347,7 @@ def emit_text(text: str, *, secrets: list[str] | None = None) -> None: credential may reach, and scrubbing it by hand is the arrangement that eventually forgets. """ - if to_hide := [*(secrets or []), *known_secrets()]: + if to_hide := _to_hide(secrets): text = scrub(text, to_hide) print(text) @@ -323,7 +384,7 @@ def emit_error( """ emit(envelope(error=error.to_dict(), meta=meta), fmt, secrets=secrets) summary = error.message - if to_hide := [*(secrets or []), *known_secrets()]: + if to_hide := _to_hide(secrets): summary = scrub(summary, to_hide) print(f"error: {summary}", file=sys.stderr) return error.exit_code @@ -336,9 +397,14 @@ def diagnostic( ``level`` is the minimum ``-v`` count required: 0 always shows (unless ``--quiet``), 1 needs ``-v``, 2 needs ``-vv``. + + Scrubbed like stdout: a note can carry server-authored text, and a + credential is no less leaked for arriving on the other stream. """ if quiet or verbosity < level: return + if to_hide := known_secrets(): + message = scrub(message, to_hide) print(message, file=sys.stderr) @@ -346,6 +412,7 @@ def diagnostic( "AGENT_ENV", "CONTRACT_VERSION", "AgentMode", + "Envelope", "OutputFormat", "agent_detected", "diagnostic", diff --git a/src/unstract_cli/core/overlay.py b/src/unstract_cli/core/overlay.py index 337f1d6..3a87f3b 100644 --- a/src/unstract_cli/core/overlay.py +++ b/src/unstract_cli/core/overlay.py @@ -1,9 +1,10 @@ """What the specs cannot say about a flag. -The committed specs are generated from server code, so they carry names, types -and defaults but no allowed-value lists, no short flags and, today, no parameter -descriptions. Those live here rather than in the derivation, so adding one is an -edit to a data file instead of a special case in code. +The committed specs are generated from server code, so they describe the API but +not the command line: no short flags, no wording aimed at someone typing, no +way to narrow a value list or hide a parameter a caller should not reach for. +Those four live here rather than in the derivation, so adding one is an edit to +a data file instead of a special case in code. TOML, read with the stdlib, for the same reason the config file is TOML: no parser dependency, and the file stays editable without a code change. @@ -14,6 +15,7 @@ from __future__ import annotations +import sys import tomllib from functools import cache from importlib import resources @@ -32,7 +34,17 @@ def load_overlay() -> dict[str, Any]: def overlay_for(product: str, operation_id: str) -> dict[str, dict[str, Any]]: """Per-parameter overrides for one operation, keyed by parameter name.""" entries = load_overlay().get(product, {}).get(operation_id, {}) - return {name: entry for name, entry in entries.items() if isinstance(entry, dict)} + out = {} + for name, entry in entries.items(): + if isinstance(entry, dict): + out[name] = entry + else: + print( + f"warning: ignoring {OVERLAY_FILE} entry [{product}.{operation_id}." + f"{name}]: expected a table, found {type(entry).__name__}.", + file=sys.stderr, + ) + return out __all__ = ["OVERLAY_FILE", "load_overlay", "overlay_for"] diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index 971876f..a8abada 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -13,8 +13,9 @@ * **A falsy value is a choice, not an absence.** ``0``, ``false`` and ``""`` all travel; only ``None`` is filtered. -What the spec cannot express -- allowed values, short flags, wording -- comes -from the overlay, never from a guess made here. +What the spec does not express for a CLI -- short flags, wording aimed at +someone typing, a narrowed value list, and whether a parameter is hidden -- +comes from the overlay, never from a guess made here. """ from __future__ import annotations @@ -30,6 +31,7 @@ import click +from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.overlay import overlay_for #: Spec file per product, vendored so flags derive with no network and no @@ -180,14 +182,34 @@ def client_params(method: Callable[..., Any]) -> dict[str, inspect.Parameter]: #: Python annotation -> OpenAPI type. A source-derived spec describes the wire, #: which can differ from what the client method takes. -_ANNOTATIONS: dict[Any, str] = { - bool: "boolean", - int: "integer", - float: "number", - str: "string", +_ANNOTATIONS: dict[str, str] = { + "bool": "boolean", + "int": "integer", + "float": "number", + "str": "string", } +def _annotation_type(annotation: Any) -> str | None: + """The OpenAPI type an annotation names, or ``None`` if it names none. + + Read as text rather than by identity: the clients spell an optional + parameter `bool | Unset`, and under PEP 563 every annotation arrives as a + string, so comparing objects would see through neither. + """ + if annotation is inspect.Parameter.empty: + return None + text = ( + annotation + if isinstance(annotation, str) + else getattr(annotation, "__name__", None) or str(annotation) + ) + for part in text.split("|"): + if mapped := _ANNOTATIONS.get(part.strip().rsplit(".", 1)[-1]): + return mapped + return None + + def _is_unset(value: Any) -> bool: """Whether a default is a generated client's "absent" sentinel. @@ -200,7 +222,7 @@ def _is_unset(value: Any) -> bool: def _from_signature(param: Param, signature: inspect.Parameter) -> Param: """Reconcile a spec parameter with the client signature that will carry it.""" updates: dict[str, Any] = {} - if (mapped := _ANNOTATIONS.get(signature.annotation)) is not None: + if (mapped := _annotation_type(signature.annotation)) is not None: updates["type"] = mapped # Whether a flag is mandatory is the spec's answer, not the signature's: a # signature with no default says only that the *call* cannot omit the @@ -318,7 +340,7 @@ def click_option(param: Param, spec_overlay: dict[str, Any]) -> click.Option: decls.insert(0, short) return click.Option( decls, - type=click.Choice(choices) if choices else _TYPES.get(param.type, click.STRING), + type=click.Choice(choices) if choices else _click_type(param), required=param.required, multiple=param.array, help=help_text, @@ -326,6 +348,34 @@ def click_option(param: Param, spec_overlay: dict[str, Any]) -> click.Option: ) +class Diverged(click.ParamType): + """A flag whose spec type this CLI has no mapping for. + + The refusal is deferred to conversion rather than raised while the option is + built: options are built at import time, so raising there would take down + `--help`, `--discover` and every unrelated command with a traceback, on the + one stream that is contracted to always carry an envelope. + """ + + def __init__(self, spec_type: str) -> None: + self.name = spec_type + + def convert(self, value: Any, param: Any, ctx: Any) -> Any: + raise CLIError( + f"The spec declares {getattr(param, 'name', '?')} as type " + f"{self.name!r}, which this CLI has no flag type for.", + ExitCode.GENERIC, + hint="The spec and the CLI have diverged; this needs a code change.", + ) + + +def _click_type(param: Param) -> click.ParamType: + """The click type for a spec type, refusing to guess at an unknown one.""" + if (mapped := _TYPES.get(param.type)) is not None: + return mapped + return Diverged(param.type) + + def derive_params( product: str, operation_id: str, @@ -396,7 +446,7 @@ def decorate(target: Any) -> Any: return decorate -def requested(values: dict[str, Any], *, drop: tuple[str, ...] = ()) -> dict[str, Any]: +def requested(values: dict[str, Any]) -> dict[str, Any]: """Keep the parameters the caller actually passed. ``None`` is the only absence. An empty tuple from a repeatable option is one @@ -406,11 +456,12 @@ def requested(values: dict[str, Any], *, drop: tuple[str, ...] = ()) -> dict[str return { name: list(value) if isinstance(value, tuple) else value for name, value in values.items() - if name not in drop and value is not None and value != () + if value is not None and value != () } __all__ = [ + "Diverged", "SPEC_FILES", "Param", "click_option", diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index 232c467..f57126e 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -20,12 +20,27 @@ import time from collections.abc import Callable from contextlib import suppress -from dataclasses import dataclass +from dataclasses import dataclass, field +from enum import StrEnum from pathlib import Path from typing import Any from unstract_cli.core.errors import CLIError, ExitCode +#: Consecutive transient poll failures tolerated before the wait gives up. +#: Retrying stops at whichever comes first, this count or the deadline -- at the +#: default interval the backoff reaches this count well inside the timeout. +MAX_TRANSIENT_POLLS = 5 + + +class PollState(StrEnum): + """What one poll response says about the job.""" + + SUCCESS = "success" + FAILURE = "failure" + PENDING = "pending" + UNKNOWN = "unknown" + @dataclass(frozen=True) class PollSpec: @@ -40,6 +55,29 @@ class PollSpec: #: spell the state differently. status_field: str | tuple[str, ...] = "status" + #: The terminal states case-folded, since every comparison against them is + #: case-insensitive. Derived, not declared -- see `__post_init__`. + failed: frozenset[str] = field(init=False, repr=False, compare=False) + succeeded: frozenset[str] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + succeeded = frozenset(s.lower() for s in self.terminal_success) + failed = frozenset(s.lower() for s in self.terminal_failure) + object.__setattr__(self, "succeeded", succeeded) + object.__setattr__(self, "failed", failed) + both = succeeded & failed + if both: + # A CLIError rather than a ValueError: specs are module-level, so + # this fires during import, and only a CLIError renders an envelope + # on the stream contracted to always carry one. + raise CLIError( + f"{sorted(both)} is named as both success and failure; " + "classify tests failure first, so a success would be reported " + "as an error.", + ExitCode.GENERIC, + hint="The poll spec is inconsistent; this needs a code change.", + ) + def _dig(payload: Any, field: str) -> Any: """Find a field, looking one level into the common envelopes.""" @@ -119,6 +157,17 @@ def persist(path: str | Path, payload: Any) -> Path: reach stdout somehow. """ target = Path(path).expanduser() + if target.is_symlink(): + raise CLIError( + f"--save target {path!r} is a symlink to {os.readlink(target)}: writing " + "here would replace the link rather than update what it points at.", + ExitCode.SAVE_FAILED, + details=payload, + hint=( + "`details` carries the result. Pass the path of the real file and " + "save it from there." + ), + ) text = ( payload if isinstance(payload, str) @@ -154,23 +203,23 @@ def persist(path: str | Path, payload: Any) -> Path: return target -def classify(payload: Any, spec: PollSpec) -> str: - """`success`, `failure`, `pending` or `unknown` for one poll response. +def classify(payload: Any, spec: PollSpec) -> PollState: + """What one poll response says about the job. Shared with the standalone status commands: a finished-and-failed execution is reported inside an HTTP 200, so a command that only checks the status code calls it a success. """ status = (extract_status(payload, spec.status_field) or "").lower() - if status in {state.lower() for state in spec.terminal_failure}: - return "failure" - if status in {state.lower() for state in spec.terminal_success}: - return "success" + if status in spec.failed: + return PollState.FAILURE + if status in spec.succeeded: + return PollState.SUCCESS if not status or _dig(payload, "error"): # Not progress: polling on regardless reports a server fault as "still # running" until the deadline. - return "unknown" - return "pending" + return PollState.UNKNOWN + return PollState.PENDING def wait_for_completion( @@ -183,6 +232,10 @@ def wait_for_completion( interval: float = 3.0, timeout: float = 300.0, on_status: Callable[[str | None], None] | None = None, + #: Called with the failure being retried. Separate from `on_status` so a + #: server-authored error is never rendered as a job status, and so the + #: caller can scrub it the way it scrubs any other untrusted text. + on_retry: Callable[[CLIError], None] | None = None, #: Called with the path once a result is on disk, before the caller sees #: anything. The ordering it observes is the whole point of --save. on_saved: Callable[[Path], None] | None = None, @@ -192,17 +245,52 @@ def wait_for_completion( """Poll until terminal, then retrieve if the operation has a retrieve step. On timeout, raises with the job handle attached, so a caller can resume with - a plain status/retrieve call rather than resubmitting the document. + a plain status/retrieve call rather than resubmitting the document. A + response carrying no handle is judged on the spot, since there is nothing to + poll: a terminal success is the whole answer and is delivered, anything else + raises. """ + + def deliver(payload: Any) -> Any: + """Save the result before the caller is told it exists.""" + if save is not None: + written = persist(save, payload) + if on_saved is not None: + on_saved(written) + return payload + handle = extract_handle(initial, spec.handle_field) if not handle: - return initial + # No handle means nothing can be polled, so this response is the whole + # answer -- it still has to be judged, and saved if it is a result. + state = classify(initial, spec) + if state is PollState.SUCCESS: + return deliver(initial) + if state is PollState.FAILURE: + raise CLIError( + f"Operation finished with status " + f"{extract_status(initial, spec.status_field)!r}.", + ExitCode.VALIDATION, + details=initial, + hint="Inspect `details` for the per-file error, or check the execution logs.", + ) + raise CLIError( + f"The service accepted the request without a {spec.handle_field}, so " + "there is nothing to poll and no result to return.", + ExitCode.SERVER_ERROR, + details=initial, + retryable=True, + hint=( + "`details` carries the response. Resubmitting is the only way " + f"forward, since no {spec.handle_field} was issued." + ), + ) deadline = now() + timeout last_status: str | None = None payload: Any = initial - def naming_the_job(call: Callable[[str], Any]) -> Any: + def naming_the_job(call: Callable[[str], Any], *, retryable: bool) -> Any: """Run one step of the loop, ensuring any failure names the job. The handle is the difference between resuming and paying to process the @@ -213,17 +301,37 @@ def naming_the_job(call: Callable[[str], Any]) -> Any: return call(handle) except CLIError as exc: exc.extra.setdefault(spec.handle_field, handle) + if not retryable and exc.http_status not in (408, 429): + # A step that must not be repeated has to be un-marked here or + # the envelope invites the retry. Refusals are the exception: + # they mean the request was never served, so the one-shot read + # is still there to collect. + exc.retryable = False raise except Exception as exc: raise CLIError( str(exc) or type(exc).__name__, ExitCode.SERVER_ERROR, - retryable=True, + retryable=retryable, extra={spec.handle_field: handle}, ) from exc + transient = 0 while True: - payload = naming_the_job(poll) + try: + payload = naming_the_job(poll, retryable=True) + except CLIError as exc: + remaining = deadline - now() + if not exc.retryable or remaining <= 0 or transient >= MAX_TRANSIENT_POLLS: + raise + transient += 1 + if on_retry is not None: + on_retry(exc) + # Back off so a rate limit is not answered at the same rate that + # earned it, but never past the deadline the caller set. + sleep(min(interval * 2**transient, remaining)) + continue + transient = 0 status = extract_status(payload, spec.status_field) if status != last_status: @@ -232,7 +340,7 @@ def naming_the_job(call: Callable[[str], Any]) -> Any: last_status = status state = classify(payload, spec) - if state == "failure": + if state is PollState.FAILURE: raise CLIError( f"Operation finished with status {status!r}.", ExitCode.VALIDATION, @@ -240,7 +348,7 @@ def naming_the_job(call: Callable[[str], Any]) -> Any: hint="Inspect `details` for the per-file error, or check the execution logs.", extra={spec.handle_field: handle}, ) - if state == "unknown": + if state is PollState.UNKNOWN: raise CLIError( "The service answered with neither a status nor progress.", ExitCode.SERVER_ERROR, @@ -252,7 +360,7 @@ def naming_the_job(call: Callable[[str], Any]) -> Any: ), extra={spec.handle_field: handle}, ) - if state == "success": + if state is PollState.SUCCESS: break remaining = deadline - now() @@ -263,8 +371,8 @@ def naming_the_job(call: Callable[[str], Any]) -> Any: ExitCode.TIMEOUT, retryable=True, hint=( - f"The job is still running. Resume with the {spec.handle_field} " - f"below rather than resubmitting the document." + f"Resume with the {spec.handle_field} below rather than " + "resubmitting the document." ), extra={spec.handle_field: handle, "last_status": status}, ) @@ -274,16 +382,14 @@ def naming_the_job(call: Callable[[str], Any]) -> Any: sleep(min(interval, remaining)) if retrieve is not None: - payload = naming_the_job(retrieve) - if save is not None: - written = persist(save, payload) - if on_saved is not None: - on_saved(written) - return payload + payload = naming_the_job(retrieve, retryable=False) + return deliver(payload) __all__ = [ + "MAX_TRANSIENT_POLLS", "PollSpec", + "PollState", "classify", "extract_handle", "extract_status", diff --git a/tests/conftest.py b/tests/conftest.py index 5d33b03..e3f4c17 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,7 @@ import pytest from unstract_cli import config as config_mod +from unstract_cli.core.errors import forget_secrets from unstract_cli.core.output import AGENT_ENV #: Every variable the loader consults. Cleared per test so a developer's real @@ -33,8 +34,10 @@ def clean_env(monkeypatch, tmp_path): # from a real cwd could otherwise find a developer's own .unstract.toml. monkeypatch.chdir(tmp_path) monkeypatch.setattr(config_mod, "HOME_CONFIG", tmp_path / "home" / "config.toml") + forget_secrets() yield config_mod.set_config_path(None) + forget_secrets() @pytest.fixture diff --git a/tests/test_cli.py b/tests/test_cli.py index 7184189..572b84f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -31,7 +31,7 @@ def test_v1_groups_are_registered(): assert set(tree["config"]["commands"]) == {"doctor", "get", "init", "list", "set"} -def test_help_exits_zero(capsys): +def test_help_exits_zero(): assert main(["--help"]) == int(ExitCode.SUCCESS) @@ -201,3 +201,16 @@ def test_click_parameter_info_dict_keeps_the_keys_discovery_reads(): param = next(p for p in cli.params if p.name == "output") info = param.to_info_dict() assert {"name", "opts", "help", "type", "required"} <= set(info) + + +def test_the_joined_output_form_is_accepted(capsys): + """`--output=json` is the same request as `--output json`, and the pre-parse + read of it decides what a parse failure is rendered in.""" + assert main(["--output=json", "--discover", "groups"]) == int(ExitCode.SUCCESS) + assert json.loads(capsys.readouterr().out)["data"]["tier"] == "groups" + + +def test_an_unknown_output_format_is_reported_as_an_envelope(capsys): + code, payload, _ = run(capsys, "--output", "yaml", "config", "list") + assert code == int(ExitCode.USAGE) + assert payload["error"]["exit_code"] == int(ExitCode.USAGE) diff --git a/tests/test_clients.py b/tests/test_clients.py new file mode 100644 index 0000000..8d12bd9 --- /dev/null +++ b/tests/test_clients.py @@ -0,0 +1,67 @@ +"""Turning a client's failure into an exit code, a hint and a payload.""" + +from __future__ import annotations + +import pytest +from requests.exceptions import ConnectionError, TooManyRedirects +from unstract.llmwhisperer.client_v2 import LLMWhispererClientException + +from unstract_cli.core.clients import raise_for_result, translated +from unstract_cli.core.errors import CLIError, ExitCode + + +def _translate(exc: Exception) -> CLIError: + with pytest.raises(CLIError) as caught, translated(endpoint="whisper"): + raise exc + return caught.value + + +def test_a_status_carrying_client_error_keeps_its_exit_code(): + err = _translate(LLMWhispererClientException("rate limited", status_code=429)) + assert err.exit_code is ExitCode.RATE_LIMITED + assert err.retryable is True + + +def test_a_transport_failure_is_not_reported_as_a_local_disk_problem(): + """Every `requests` exception is an OSError, so anything left untranslated + reaches the entry point's OSError handler and is blamed on the filesystem.""" + err = _translate(TooManyRedirects("too many redirects")) + assert err.exit_code is ExitCode.SERVER_ERROR + assert err.retryable is True + assert "disk" not in (err.hint or "") + + +def test_an_unreachable_service_is_retryable(): + err = _translate(ConnectionError("connection refused")) + assert err.exit_code is ExitCode.SERVER_ERROR + assert err.retryable is True + + +def test_a_deployment_error_status_becomes_its_exit_code(): + with pytest.raises(CLIError) as caught: + raise_for_result({"status_code": 404, "error": "no such API"}) + assert caught.value.exit_code is ExitCode.NOT_FOUND + + +def test_a_non_numeric_status_is_reported_rather_than_crashing(): + with pytest.raises(CLIError) as caught: + raise_for_result({"status_code": "gateway"}) + assert caught.value.exit_code is ExitCode.SERVER_ERROR + assert caught.value.details == {"status_code": "gateway"} + + +def test_a_missing_status_is_not_read_as_success(): + with pytest.raises(CLIError) as caught: + raise_for_result({"result": "something"}) + assert caught.value.exit_code is ExitCode.SERVER_ERROR + + +def test_a_success_status_carrying_an_error_is_still_a_failure(): + with pytest.raises(CLIError) as caught: + raise_for_result({"status_code": 200, "error": "the work was not done"}) + assert caught.value.exit_code is ExitCode.VALIDATION + assert caught.value.retryable is False + + +def test_a_clean_success_raises_nothing(): + raise_for_result({"status_code": 200, "execution_status": "COMPLETED"}) diff --git a/tests/test_commands.py b/tests/test_commands.py index 7cca255..3c9b3e6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -11,9 +11,10 @@ import os import socket +import click import httpx import pytest -from requests.exceptions import ConnectionError +from requests.exceptions import ConnectionError, InvalidHeader, MissingSchema from unstract.clone.report import CloneReport, Endpoint, PhaseResult from unstract.llmwhisperer import client_v2 from unstract.llmwhisperer.client_v2 import ( @@ -1271,3 +1272,328 @@ def fake_clone(source, target, options): assert code == int(ExitCode.SUCCESS) assert "adapters" in captured.out assert key not in captured.out and key not in captured.err + + +# --------------------------------------------------------------------------- # +# --save: the flag that exists to protect a one-shot read +# --------------------------------------------------------------------------- # + + +def test_save_with_no_wait_is_a_usage_error(capsys, whisper_client, tmp_path): + """--no-wait returns before there is a result, so --save would write + nothing while reporting success.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client(whisper={"whisper_hash": "h1", "status_code": 202}) + + code, out, _ = run( + capsys, + "whisper", + "extract", + str(doc), + "--no-wait", + "--save", + str(tmp_path / "out.json"), + ) + + assert code == int(ExitCode.USAGE) + assert not (tmp_path / "out.json").exists() + assert "retrieve" in envelope(out)["error"]["hint"] + + +def test_deployment_status_can_save_the_result(capsys, deployment_client, tmp_path): + """`deployment status` is the documented way to resume after a timeout, so + it is where a result has to be savable.""" + target = tmp_path / "result.json" + deployment_client( + check_execution_status={ + "status_code": 200, + "execution_status": "COMPLETED", + "extraction_result": {"text": "done"}, + } + ) + + code, out, _ = run( + capsys, + "docstudio", + "deployment", + "status", + "my-api", + "e-1", + "--save", + str(target), + ) + + assert code == int(ExitCode.SUCCESS) + assert json.loads(target.read_text())["execution_status"] == "COMPLETED" + assert envelope(out)["ok"] is True + + +def test_an_execution_id_cannot_carry_query_syntax(capsys, deployment_client): + client = deployment_client( + check_execution_status={"status_code": 200, "execution_status": "COMPLETED"} + ) + run(capsys, "docstudio", "deployment", "status", "my-api", "e-1&admin=1") + endpoint = client.calls[0][1][0] + assert endpoint.endswith("?execution_id=e-1%26admin%3D1") + + +# --------------------------------------------------------------------------- # +# whisper status: a failure inside an HTTP 200 +# --------------------------------------------------------------------------- # + + +def test_whisper_status_fails_on_a_failed_extraction(capsys, whisper_client): + whisper_client(whisper_status={"status": "error", "message": "bad scan"}) + + code, out, _ = run(capsys, "whisper", "status", "h1") + + assert code == int(ExitCode.VALIDATION) + error = envelope(out)["error"] + assert error["details"]["message"] == "bad scan" + assert error["whisper_hash"] == "h1" + + +def test_whisper_status_reports_a_hash_the_service_forgot(capsys, whisper_client): + """`unknown` is terminal: the service no longer holds the hash, and no + amount of polling changes that.""" + whisper_client(whisper_status={"status": "unknown"}) + + code, _, _ = run(capsys, "whisper", "status", "h1") + + assert code == int(ExitCode.VALIDATION) + + +def test_whisper_status_passes_a_running_extraction_through(capsys, whisper_client): + whisper_client(whisper_status={"status": "processing"}) + + code, out, _ = run(capsys, "whisper", "status", "h1") + + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["data"]["status"] == "processing" + + +def test_a_webhook_token_is_not_printed_back(capsys, whisper_client): + token = "wh-secret-abcdefghijklmnop" + whisper_client(get_webhook_details={"name": "n", "auth_token": token, "url": "u"}) + + code, out, _ = run(capsys, "whisper", "webhook", "get", "n") + + assert code == int(ExitCode.SUCCESS) + assert token not in out + + +def test_a_rate_limited_call_exits_six(capsys, whisper_client): + whisper_client( + get_usage_info=LLMWhispererClientException("slow down", status_code=429) + ) + + code, out, _ = run(capsys, "whisper", "usage") + + assert code == int(ExitCode.RATE_LIMITED) == 6 + assert envelope(out)["error"]["retryable"] is True + + +def test_a_wait_that_runs_out_exits_seven_naming_the_handle( + capsys, whisper_client, tmp_path, monkeypatch +): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client( + whisper={"whisper_hash": "h1", "status_code": 202}, + whisper_status={"status": "processing"}, + ) + monkeypatch.setattr("unstract_cli.core.poll.time.sleep", lambda _seconds: None) + + code, out, _ = run( + capsys, "whisper", "extract", str(doc), "--interval", "0", "--timeout", "0" + ) + + assert code == int(ExitCode.TIMEOUT) == 7 + error = envelope(out)["error"] + assert error["whisper_hash"] == "h1" + assert error["retryable"] is True + + +def test_deployment_save_with_no_wait_is_a_usage_error( + capsys, deployment_client, tmp_path +): + """The deployment path needs the same guard as the whisper one: --no-wait + returns before there is a result, so --save would write nothing.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + deployment_client( + structure_file={"status_code": 200, "pending": True, "execution_status": "P"} + ) + + code, out, _ = run( + capsys, + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--no-wait", + "--save", + str(tmp_path / "out.json"), + ) + + assert code == int(ExitCode.USAGE) + assert not (tmp_path / "out.json").exists() + + +def test_a_webhook_token_can_come_from_the_environment( + capsys, whisper_client, monkeypatch +): + """Passing a credential as an argument puts it in the process list, so the + envvar is the supported way to supply it.""" + monkeypatch.setenv("UNSTRACT_WEBHOOK_AUTH_TOKEN", "wh-token-0123456789") + client = whisper_client(register_webhook={"status_code": 201, "message": "ok"}) + + code, _, _ = run( + capsys, + "whisper", + "webhook", + "create", + "hook1", + "--url", + "https://example.com/hook", + ) + + assert code == int(ExitCode.SUCCESS) + assert "wh-token-0123456789" in client.calls[0][1] + + +@pytest.mark.parametrize( + ("value", "expected"), + [("25", 25), ("2K", 2048), ("500M", 500 * 1024**2), ("1.5GB", int(1.5 * 1024**3))], +) +def test_clone_accepts_every_size_spelling_the_client_does(value, expected): + """Both spellings of this command have to accept the same strings.""" + assert clone_cmd._parse_size(value) == expected + + +@pytest.mark.parametrize("value", ["1.2.3", ".", "5X", ""]) +def test_a_malformed_size_is_a_usage_error_not_a_crash(value): + """`float()` raising here would escape as a traceback with no envelope.""" + with pytest.raises(click.BadParameter): + clone_cmd._parse_size(value) + + +def test_splitting_a_csv_drops_blanks_and_trims(): + assert clone_cmd._split_csv(" a , b ,, c ") == ("a", "b", "c") + assert clone_cmd._split_csv("") is None + assert clone_cmd._split_csv(None) is None + + +def test_an_unusable_output_format_is_still_reported_as_an_envelope(capsys): + """The format is resolved before the handler that renders envelopes exists, + so a failure there has to fall back rather than raise past it.""" + code = main(["-o", "bogus", "whisper", "status", "h1"]) + out = capsys.readouterr().out + assert code == int(ExitCode.USAGE) + # Rendered in whatever the fallback resolves to, but on stdout and shaped + # like a report: raising here would leave stdout empty instead. + assert "bogus" in out + + +def test_a_base_url_without_a_scheme_is_a_usage_error(capsys, whisper_client): + """The request was never sendable, so the fault is the caller's config and + retrying it is the wrong advice.""" + whisper_client(get_usage_info=MissingSchema("Invalid URL 'example.com'")) + code, out, _ = run(capsys, "whisper", "usage") + assert code == int(ExitCode.USAGE) + assert envelope(out)["error"]["retryable"] is False + + +def test_a_clone_url_without_a_scheme_is_a_usage_error(capsys, monkeypatch): + def fail(*_args, **_kwargs): + raise MissingSchema("Invalid URL 'dev.example.com'") + + monkeypatch.setattr(clone_cmd, "run_clone", fail) + monkeypatch.setenv("UNSTRACT_SRC_PLATFORM_KEY", "src-key-0123456789") + monkeypatch.setenv("UNSTRACT_TGT_PLATFORM_KEY", "tgt-key-0123456789") + code, out, _ = run( + capsys, + "clone", + "--source-url", + "dev.example.com", + "--source-org", + "a", + "--target-url", + "https://prod.example.com", + "--target-org", + "b", + ) + assert code == int(ExitCode.USAGE) + assert envelope(out)["error"]["retryable"] is False + + +def test_the_clone_size_grammar_matches_the_client_it_mirrors(): + """Both spellings of this command have to accept the same strings, and the + table is copied rather than imported, so nothing else notices a drift.""" + from unstract.clone import cli as upstream + + assert clone_cmd._SIZE_UNITS == upstream._SIZE_UNITS + assert clone_cmd._SIZE_RE.pattern == upstream._SIZE_RE.pattern + + +def test_setting_an_env_reference_in_a_discovered_file_says_it_is_ignored( + capsys, tmp_path, monkeypatch +): + """The refusal applies to every key, not only the withheld ones, so writing + one without a word would report success for a setting that never resolves.""" + work = tmp_path / "work" + work.mkdir() + (work / ".unstract.toml").write_text("", encoding="utf-8") + monkeypatch.chdir(work) + _, out, _ = run(capsys, "config", "set", "docstudio", "org_id", "env:MY_ORG") + assert "ignored when the config is loaded" in envelope(out)["data"]["warning"] + + +@pytest.mark.parametrize( + ("argv", "setup"), + [ + (("whisper", "usage"), "whisper"), + ( + ( + "clone", + "--source-url", + "https://dev.example.com", + "--source-org", + "a", + "--target-url", + "https://prod.example.com", + "--target-org", + "b", + ), + "clone", + ), + ], +) +def test_a_header_that_will_not_build_does_not_quote_the_credential( + capsys, monkeypatch, whisper_client, argv, setup +): + """The only way to reach this is a credential carrying a control character, + and the exception quotes it `repr`-escaped -- past what the scrub matches.""" + # The control character sits inside the key, not after it: `repr` then + # splits the literal, which is precisely what the scrub cannot match. + key = f"sk-live{chr(10)}0123456789" + failure = InvalidHeader( + f"Invalid return character or leading space in header: {key!r}" + ) + escaped = repr(key)[1:-1] + if setup == "whisper": + whisper_client(get_usage_info=failure) + else: + + def fail(*_args, **_kwargs): + raise failure + + monkeypatch.setattr(clone_cmd, "run_clone", fail) + monkeypatch.setenv("UNSTRACT_SRC_PLATFORM_KEY", key) + monkeypatch.setenv("UNSTRACT_TGT_PLATFORM_KEY", "tgt-key-0123456789") + + code, out, err = run(capsys, *argv) + assert code == int(ExitCode.USAGE) + assert escaped not in out and escaped not in err diff --git a/tests/test_config.py b/tests/test_config.py index 3190652..6df53ff 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -461,3 +461,82 @@ def test_starter_profiles_hold_no_literal_secrets(): for settings in blocks.values(): key = settings.get("api_key") assert key is None or key.startswith("env:") + + +def test_a_discovered_file_cannot_choose_which_env_var_is_read( + tmp_path, monkeypatch, capsys +): + """`org_id` is not withheld from a project file, and it is spliced into the + deployment URL and echoed back in any error about it. Letting a checkout + the user did not write name the variable makes that an exfiltration path.""" + work = tmp_path / "work" + work.mkdir() + (work / PROJECT_CONFIG_NAME).write_text( + '[profiles.p.docstudio]\norg_id = "env:CI_DEPLOY_TOKEN"\n', encoding="utf-8" + ) + monkeypatch.chdir(work) + monkeypatch.setenv("CI_DEPLOY_TOKEN", "tkn-should-never-be-read") + + cfg = ResolvedConfig(file=load_config(), profile_name="p") + + assert cfg.get(DOCSTUDIO, "org_id") is None + assert "may not choose which environment variable" in capsys.readouterr().err + + +def test_a_named_file_may_still_use_env_indirection(tmp_path, monkeypatch): + path = tmp_path / "named.toml" + path.write_text('[profiles.p.docstudio]\norg_id = "env:MY_ORG"\n', encoding="utf-8") + monkeypatch.setenv("UNSTRACT_CONFIG", str(path)) + monkeypatch.setenv("MY_ORG", "org_ABC") + + cfg = ResolvedConfig(file=load_config(), profile_name="p") + assert cfg.get(DOCSTUDIO, "org_id") == "org_ABC" + + +def test_doctor_reports_a_refused_env_reference_as_unresolved(tmp_path, monkeypatch): + """Doctor exists to answer "I set it -- where is it looking?", so reporting + a value the resolver refuses as resolved is the one answer it must not give.""" + work = tmp_path / "work" + work.mkdir() + (work / PROJECT_CONFIG_NAME).write_text( + '[profiles.p.docstudio]\norg_id = "env:MY_ORG"\n', encoding="utf-8" + ) + monkeypatch.chdir(work) + monkeypatch.setenv("MY_ORG", "org_ABC") + + cfg = ResolvedConfig(file=load_config(), profile_name="p") + report = cfg.resolution_source(DOCSTUDIO, "org_id") + + assert cfg.get(DOCSTUDIO, "org_id") is None + assert report["resolved"] is False + assert "may not choose which environment variable" in report["detail"] + + +def test_a_refused_alias_reference_names_the_trust_rule_not_a_missing_var( + tmp_path, monkeypatch +): + """Blaming an unset variable sends the user to export one that is set.""" + work = tmp_path / "work" + work.mkdir() + (work / PROJECT_CONFIG_NAME).write_text( + '[profiles.p.docstudio]\napi_key = "k"\n' + '[profiles.p.deployments.inv]\napi_name = "n"\norg_id = "env:MY_ORG"\n', + encoding="utf-8", + ) + monkeypatch.chdir(work) + monkeypatch.setenv("MY_ORG", "org_ABC") + + cfg = ResolvedConfig(file=load_config(), profile_name="p") + with pytest.raises(ConfigError) as caught: + cfg.deployment("inv") + assert "may not choose which environment variable" in str(caught.value) + + +def test_an_override_is_only_read_under_the_key_it_is_written_with(tmp_path): + """`resolution_source` and `get` have to look in the same place, or doctor + reports a value resolved that the CLI never reads.""" + cfg = ResolvedConfig( + file=load_config(), profile_name="p", overrides={"org_id": "bare"} + ) + assert cfg.get(DOCSTUDIO, "org_id") is None + assert cfg.resolution_source(DOCSTUDIO, "org_id")["resolved"] is False diff --git a/tests/test_discover.py b/tests/test_discover.py index 67f4bfc..b232013 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -13,7 +13,9 @@ import pytest from unstract_cli.__main__ import main +from unstract_cli.app import cli from unstract_cli.commands import config_cmd +from unstract_cli.core.discover import discover, exit_codes from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.output import CONTRACT_VERSION @@ -225,3 +227,52 @@ def test_the_deployment_probe_says_it_verified_nothing(capsys, probe_client, mon assert entry["checked"] is False and entry["ok"] is None assert entry["resolved"] is True assert "NOT verified" in entry["detail"] + + +def test_an_unknown_tier_is_a_usage_error_rather_than_a_traceback(): + with pytest.raises(CLIError) as caught: + discover(cli, "everything") + assert caught.value.exit_code is ExitCode.USAGE + + +def test_success_publishes_no_error_code(): + table = {entry["name"]: entry["error_code"] for entry in exit_codes()} + assert table["success"] == "" + assert all(code for name, code in table.items() if name != "success") + + +def test_doctor_reports_an_alias_whose_settings_do_not_arrive( + capsys, write_config, monkeypatch +): + """A listed alias says nothing about whether the settings behind it + resolve, and the failure only shows up when a run is attempted.""" + write_config( + """ + default_profile = "p" + [profiles.p.docstudio] + api_key = "dk-configured-key" + org_id = "org_ABC" + [profiles.p.deployments.invoices] + api_name = "invoice-parser" + [profiles.p.deployments.broken] + api_name = "no-key" + api_key = "env:NOT_SET_ANYWHERE" + """ + ) + monkeypatch.delenv("NOT_SET_ANYWHERE", raising=False) + + code = main(["-o", "json", "config", "doctor"]) + payload = json.loads(capsys.readouterr().out) + + assert code != int(ExitCode.SUCCESS) + report = payload["error"]["details"] + assert set(report["deployment_aliases"]) == {"invoices", "broken"} + assert any("broken" in problem for problem in report["problems"]) + + +def test_a_malformed_config_file_is_a_usage_error(capsys, write_config): + write_config("[profiles.p\nthis is not toml") + + code, _ = run(capsys, "config", "list") + + assert code == int(ExitCode.USAGE) diff --git a/tests/test_errors.py b/tests/test_errors.py index 98706ad..6baccfb 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -2,20 +2,28 @@ from __future__ import annotations +import json from pathlib import Path import pytest +from unstract_cli.core.discover import exit_codes from unstract_cli.core.errors import ( REDACTED, + CLIError, ExitCode, + error_code_for, + error_codes, error_from_status, exit_code_for_status, hint_for, is_retryable, + known_secrets, redact_headers, redact_value, + remember_secret, scrub, + scrub_structure, undeclared_status_error, ) @@ -132,3 +140,110 @@ def test_the_rejected_key_hint_does_not_blame_the_organisation(): assert "organisation" not in hint assert "does not cover" in hint assert "organisation" in hint_for(404) + + +# --------------------------------------------------------------------------- # +# The published table, and what may not silently escape redaction +# --------------------------------------------------------------------------- # + + +def test_every_exit_code_but_success_has_an_error_code(): + """`--discover full` publishes this table, so a code with no name is a hole + in a contract rather than a missing string.""" + named = set(error_codes()) + assert named | {ExitCode.SUCCESS} == set(ExitCode) + + +def test_an_exit_code_with_no_token_falls_back_to_the_generic_one(): + """SUCCESS names no error, so the table published by `--discover` has to + special-case it rather than trust this fallback.""" + assert error_code_for(ExitCode.SUCCESS) == "error" + assert exit_codes()[0] == {"code": 0, "name": "success", "error_code": ""} + + +def test_a_request_timeout_is_retryable(): + """408 exits as TIMEOUT, which the CLI documents as worth retrying; saying + otherwise here contradicts the exit code the same status produces.""" + assert is_retryable(408) is True + assert is_retryable(400) is False + + +def test_extra_cannot_overwrite_a_field_callers_branch_on(): + err = CLIError( + "boom", + ExitCode.VALIDATION, + extra={"exit_code": 0, "code": "ok", "whisper_hash": "h1"}, + ) + payload = err.to_dict() + assert payload["exit_code"] == int(ExitCode.VALIDATION) + assert payload["code"] == "validation_error" + assert payload["whisper_hash"] == "h1" + + +def test_a_secret_named_key_is_redacted_whatever_type_it_holds(): + """A header echo arrives as a list as readily as as a string.""" + out = redact_value({"authorization": ["Bearer sk-live-1234567890"], "n": 1}) + assert out["authorization"] == REDACTED + assert out["n"] == 1 + + +def test_a_credential_too_short_to_scrub_for_says_so(capsys): + remember_secret("short") + assert "short" not in known_secrets() + assert "will not be redacted" in capsys.readouterr().err + + +def test_scrub_structure_replaces_before_anything_renders(): + secret = "sk-live-abcdefghijklmnopqrstuvwxyz012345" + out = scrub_structure({"a": [secret], "b": {secret: secret}}, [secret]) + assert secret not in json.dumps(out) + + +@pytest.mark.parametrize( + "name", + [ + "api_key", + "apiKey", + "x-api-key", + "Authorization", + "authorization_header", + "accessToken", + "secretAccessKey", + "refreshToken", + "bearer_token", + "credentials_json", + "private_key_pem", + "passwd", + "token_value", + ], +) +def test_a_credential_name_is_redacted_however_it_is_spelled(name): + """camelCase is how a JSON body spells these, and the word marking a + credential is not always the last one.""" + assert redact_value({name: "abcdef123456"})[name] == REDACTED + + +@pytest.mark.parametrize("name", ["authors", "rows", "execution_id", "message"]) +def test_a_field_that_only_looks_like_a_credential_is_left_alone(name): + assert redact_value({name: "abcdef123456"})[name] == "abcdef123456" + + +@pytest.mark.parametrize( + "value", + [ + "sk-live-1", + ["live-key-1", None], + {"value": "sk-live-1"}, + 12345678, + ], +) +def test_a_credential_is_collapsed_whatever_shape_it_arrives_in(value): + """A token endpoint answers with any of these, and walking into one leaves + the credential on the leaf it was reached through.""" + assert redact_value({"secret": value})["secret"] == REDACTED + + +def test_a_short_credential_warns_once_per_run(capsys): + remember_secret("short") + remember_secret("short") + assert capsys.readouterr().err.count("too short to scrub") == 1 diff --git a/tests/test_output.py b/tests/test_output.py index 02f3c62..ae2e12b 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -4,11 +4,14 @@ import json -from unstract_cli.core.errors import CLIError, ExitCode +import pytest + +from unstract_cli.core.errors import REDACTED, CLIError, ExitCode, remember_secret from unstract_cli.core.output import ( CONTRACT_VERSION, AgentMode, OutputFormat, + diagnostic, emit_error, emit_result, envelope, @@ -138,3 +141,74 @@ def test_json_renders_the_same_bytes_wherever_it_is_asked_for(self): one = render(env, resolve_format("json", AgentMode.NO, {})) two = render(env, resolve_format("json", AgentMode.YES, self.AGENT)) assert one == two + + +# --------------------------------------------------------------------------- # +# Redaction has to survive the rendering, not follow it +# --------------------------------------------------------------------------- # + +SECRET = "sk-live-éabcdefghijklmnopqrstuvwxyz012345" + + +def test_a_secret_is_scrubbed_before_the_table_can_wrap_it(capsys, monkeypatch): + """The scrub has to run on the structure, not on the rendered text: a + narrow terminal splits a long cell across lines, and a literal broken over + a newline has nothing left for a text scrub to match.""" + monkeypatch.setattr("unstract_cli.core.output._terminal_width", lambda *a, **k: 30) + # A value this long does wrap at this width -- so had the secret reached the + # renderer intact, it would have been split rather than replaced. + emit_result({"answer": "z" * len(SECRET)}, OutputFormat.TABLE) + assert len(capsys.readouterr().out.strip().splitlines()) > 3 + + emit_result({"answer": SECRET}, OutputFormat.TABLE, secrets=[SECRET]) + out = capsys.readouterr().out + assert SECRET[:20] not in "".join(out.split()) + assert REDACTED in out + + +def test_a_secret_escaped_by_json_is_still_redacted(capsys): + """`json.dumps` escapes non-ASCII, so the key in the output is not the key + that was registered.""" + emit_result({"answer": SECRET}, OutputFormat.JSON, secrets=[SECRET]) + out = capsys.readouterr().out + assert SECRET not in out + assert "abcdefghij" not in out + assert json.loads(out)["data"]["answer"] == "***REDACTED***" + + +def test_an_agent_variable_set_to_zero_is_not_an_agent(): + assert resolve_format(None, env={"CLAUDECODE": "0"}) is OutputFormat.TABLE + assert resolve_format(None, env={"CLAUDECODE": "1"}) is OutputFormat.JSON + + +def test_an_unknown_output_format_is_a_usage_error(): + with pytest.raises(CLIError) as caught: + resolve_format("yaml") + assert caught.value.exit_code is ExitCode.USAGE + + +def test_raw_prints_an_empty_answer_rather_than_the_next_field(): + """An empty result is a real answer, and printing the next field instead + would hand back a handle where the caller expects text.""" + env = envelope(data={"extraction_result": "", "execution_id": "e-1"}) + assert ( + render(env, OutputFormat.RAW, raw_fields=("extraction_result", "execution_id")) + == "" + ) + + +def test_a_wide_table_is_shrunk_in_one_pass(): + """Shaving one character per iteration is O(total width). The cap chosen has + to be the widest one that fits, or the table is narrower than it need be.""" + wide = render_table([{"a": "x" * 4000, "b": "y" * 4000}], max_width=60) + assert max(len(line) for line in wide.splitlines()) <= 60 + + +def test_a_diagnostic_note_is_scrubbed_like_stdout(capsys): + """A note can carry server-authored text, and a credential is no less + leaked for arriving on the other stream.""" + remember_secret("sk-live-abcdef123456") + diagnostic("retrying: rejected key sk-live-abcdef123456") + err = capsys.readouterr().err + assert "sk-live-abcdef123456" not in err + assert REDACTED in err diff --git a/tests/test_params.py b/tests/test_params.py index ab540ca..012f027 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -13,6 +13,7 @@ from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 from unstract_cli.core import params as params_module +from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.params import ( Param, click_option, @@ -275,5 +276,10 @@ def test_unpassed_values_are_not_sent(): assert requested({"a": None, "b": (), "c": 1}) == {"c": 1} -def test_dropped_names_are_not_sent(): - assert requested({"a": 1, "b": 2}, drop=("b",)) == {"a": 1} +def test_an_unmapped_spec_type_fails_the_flag_and_not_the_import(): + """Options are built at import time, so refusing there would take down + --help and every unrelated command instead of the one flag.""" + option = click_option(Param(name="shape", type="geojson"), {}) + with pytest.raises(CLIError) as caught: + option.type.convert("x", option, None) + assert caught.value.exit_code is ExitCode.GENERIC diff --git a/tests/test_poll.py b/tests/test_poll.py index 79d8034..b2f6348 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -8,6 +8,7 @@ from unstract_cli.core.errors import ExitCode from unstract_cli.core.poll import ( + MAX_TRANSIENT_POLLS, CLIError, PollSpec, extract_handle, @@ -135,13 +136,35 @@ def test_timeout_carries_the_handle_so_work_is_resumable(): assert clock.now() == 12 -def test_missing_handle_returns_the_initial_response_unpolled(): - poll = responses({"status": "processed"}) +def test_a_finished_response_with_no_handle_is_still_saved(tmp_path): + """A deployment can answer the submit with the finished result. Returning it + unpolled is right; returning it without honouring --save loses it.""" + target = tmp_path / "result.json" + poll = responses() out = wait_for_completion( - initial={"no_handle_here": True}, spec=SPEC, poll=poll, sleep=Clock().sleep + initial={"status": "processed", "result_text": "done"}, + spec=SPEC, + poll=poll, + save=target, + sleep=Clock().sleep, ) - assert out == {"no_handle_here": True} + assert out == {"status": "processed", "result_text": "done"} assert poll.calls == [] + assert json.loads(target.read_text()) == out + + +def test_no_handle_and_no_result_fails_rather_than_reporting_success(): + """Nothing to poll and nothing finished is a broken response, not an answer + the caller should see reported as ok.""" + with pytest.raises(CLIError) as caught: + wait_for_completion( + initial={"no_handle_here": True}, + spec=SPEC, + poll=responses(), + sleep=Clock().sleep, + ) + assert caught.value.exit_code is ExitCode.SERVER_ERROR + assert caught.value.details == {"no_handle_here": True} def test_status_changes_are_reported_once_each(): @@ -277,3 +300,206 @@ def test_status_is_found_one_level_into_the_common_envelopes(payload): def test_handle_is_found_one_level_in_too(): assert extract_handle({"message": {"execution_id": "e1"}}, "execution_id") == "e1" assert extract_handle({"nothing": 1}, "execution_id") is None + + +# --------------------------------------------------------------------------- # +# Transient failures, and what may not be retried +# --------------------------------------------------------------------------- # + + +def test_a_transient_poll_failure_does_not_end_the_wait(): + """A 500 mid-poll says nothing about the job, and giving up on it throws + away a document that has already been paid for.""" + clock = Clock() + calls: list[int] = [] + + def poll(handle): + calls.append(1) + if len(calls) < 3: + raise CLIError("upstream", ExitCode.SERVER_ERROR, retryable=True) + return {"status": "processed"} + + out = wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=poll, + sleep=clock.sleep, + now=clock.now, + ) + assert out == {"status": "processed"} + assert len(calls) == 3 + + +def test_a_non_retryable_poll_failure_ends_the_wait_at_once(): + def poll(handle): + raise CLIError("gone", ExitCode.NOT_FOUND) + + with pytest.raises(CLIError) as caught: + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=poll, + sleep=Clock().sleep, + now=Clock().now, + ) + assert caught.value.exit_code is ExitCode.NOT_FOUND + + +def test_a_failed_retrieve_is_not_reported_as_retryable(): + """The retrieve is the acknowledging read: calling it retryable invites a + second attempt at a result the service will not serve twice.""" + + def retrieve(handle): + raise RuntimeError("connection reset") + + with pytest.raises(CLIError) as caught: + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=responses({"status": "processed"}), + retrieve=retrieve, + sleep=Clock().sleep, + now=Clock().now, + ) + assert caught.value.retryable is False + + +def test_a_refused_retrieve_stays_retryable(): + """A refusal never served the request, so the one-shot read is still there + to collect -- and the envelope must not tell the caller otherwise.""" + + def retrieve(handle): + raise CLIError( + "rate limited", ExitCode.RATE_LIMITED, http_status=429, retryable=True + ) + + with pytest.raises(CLIError) as caught: + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=responses({"status": "processed"}), + retrieve=retrieve, + sleep=Clock().sleep, + now=Clock().now, + ) + assert caught.value.exit_code is ExitCode.RATE_LIMITED + assert caught.value.message == "rate limited" + assert caught.value.retryable is True + + +def test_transient_poll_failures_stop_at_the_cap(): + """Otherwise a hard-down service is retried for the whole timeout.""" + clock = Clock() + calls: list[int] = [] + + def poll(handle): + calls.append(1) + raise CLIError("upstream", ExitCode.SERVER_ERROR, retryable=True) + + with pytest.raises(CLIError) as caught: + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=poll, + sleep=clock.sleep, + now=clock.now, + timeout=10_000, + ) + assert caught.value.message == "upstream" + assert len(calls) == MAX_TRANSIENT_POLLS + 1 + + +def test_the_retry_backoff_never_sleeps_past_the_deadline(): + """`--timeout 30` that returns at 35s has lied, backoff or not.""" + clock = Clock() + + def poll(handle): + raise CLIError("upstream", ExitCode.SERVER_ERROR, retryable=True) + + with pytest.raises(CLIError): + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=poll, + sleep=clock.sleep, + now=clock.now, + interval=3.0, + timeout=10.0, + ) + assert sum(clock.slept) <= 10.0 + + +def test_a_terminal_failure_without_a_handle_is_not_saved(tmp_path): + """A response that is finished and failed is not a result, so --save must + not write it and report success.""" + target = tmp_path / "out.json" + with pytest.raises(CLIError) as caught: + wait_for_completion( + initial={"status": "error", "message": "bad page"}, + spec=SPEC, + poll=responses({"status": "processed"}), + save=target, + sleep=Clock().sleep, + now=Clock().now, + ) + assert caught.value.exit_code is ExitCode.VALIDATION + assert caught.value.details == {"status": "error", "message": "bad page"} + assert not target.exists() + + +def test_persist_refuses_a_symlink_and_keeps_the_result(tmp_path): + """preflight checks this before the read; the link can be planted after it, + and a caller can reach persist without a preflight at all.""" + real = tmp_path / "real.json" + real.write_text("{}", encoding="utf-8") + link = tmp_path / "link.json" + link.symlink_to(real) + + with pytest.raises(CLIError) as caught: + persist(link, {"result_text": "IRREPLACEABLE"}) + + assert caught.value.exit_code is ExitCode.SAVE_FAILED + assert caught.value.details == {"result_text": "IRREPLACEABLE"} + assert real.read_text(encoding="utf-8") == "{}" + + +def test_a_spec_cannot_name_one_status_as_both_outcomes(): + """`classify` tests failure first, so an overlap would report a success as + an error. Case-folded on both sides, the way `classify` reads them.""" + with pytest.raises(CLIError) as caught: + PollSpec( + handle_field="h", + terminal_success=("Done",), + terminal_failure=("done",), + ) + assert caught.value.exit_code is ExitCode.GENERIC + + +def flaky_then(payload, failures=1): + """A poll callable that raises a retryable failure before answering.""" + remaining = [failures] + + def poll(handle: str): + if remaining[0]: + remaining[0] -= 1 + raise CLIError("upstream is busy", ExitCode.SERVER_ERROR, retryable=True) + return payload + + return poll + + +def test_a_retried_failure_is_reported_without_being_dressed_as_a_status(): + seen: list[str] = [] + retries: list[CLIError] = [] + clock = Clock() + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=flaky_then({"status": "processed"}), + sleep=clock.sleep, + now=clock.now, + on_status=seen.append, + on_retry=retries.append, + ) + assert [exc.message for exc in retries] == ["upstream is busy"] + assert not any("upstream is busy" in status for status in seen) diff --git a/uv.lock b/uv.lock index a1ea6a2..e7dfe18 100644 --- a/uv.lock +++ b/uv.lock @@ -335,6 +335,7 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "llmwhisperer-client" }, + { name = "requests" }, { name = "tomli-w" }, { name = "unstract-client" }, ] @@ -350,6 +351,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1,<9" }, { name = "llmwhisperer-client", specifier = "==2.9.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "requests", specifier = ">=2.32.3" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "tomli-w", specifier = ">=1.0" }, { name = "unstract-client", specifier = "==1.6.0" }, From fd35bd5d5fc8ee76e2dd7db20cba00bd3310cc0d Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 11:14:40 +0530 Subject: [PATCH 57/86] fix: make the release workflow parseable, and keep it that way The release step declared `env:` twice. Read strictly that is a duplicate mapping key and the file is invalid; read leniently the second block wins and RELEASE_NOTES is silently never set, so every release takes the generated-notes branch. The blocks are now one. Nothing caught it because a workflow is only parsed when it is dispatched, and this one had not been. The suite now reads every workflow the strict way at PR time, and checks that a step reading $GITHUB_TOKEN is a step something sets it on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- .github/workflows/release.yml | 3 +- pyproject.toml | 3 ++ tests/test_workflows.py | 85 +++++++++++++++++++++++++++++++++++ uv.lock | 48 ++++++++++++++++++++ 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 tests/test_workflows.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2695d65..430ab43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,6 +138,7 @@ jobs: - name: Commit version bump and create release env: RELEASE_NOTES: ${{ github.event.inputs.release_notes }} + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} run: | NEW_VERSION="${{ steps.version.outputs.version }}" @@ -169,8 +170,6 @@ jobs: fi echo "Created release v$NEW_VERSION" - env: - GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - name: Success message run: | diff --git a/pyproject.toml b/pyproject.toml index fce56de..23340a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ dependencies = [ dev = [ "pytest>=8.0", "ruff>=0.6", + # Tests only: the workflow files are parsed in the suite so a file that + # cannot be read fails the gate rather than the first dispatch of it. + "pyyaml>=6.0", ] [project.scripts] diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..d17d64f --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,85 @@ +"""The workflow files parse, and say what they mean. + +A workflow is only parsed when it is dispatched, so a file that cannot be read +sits in the repository looking fine until the day someone needs it to run. That +day is a release. These read the files the way GitHub does, at PR time. +""" + +from pathlib import Path + +import pytest +import yaml + +WORKFLOWS = sorted( + (Path(__file__).resolve().parents[1] / ".github/workflows").glob("*.yml") +) + + +class _StrictLoader(yaml.SafeLoader): + """Refuses what `yaml.safe_load` accepts silently.""" + + +def _no_duplicate_keys(loader: _StrictLoader, node: yaml.MappingNode) -> dict: + """Duplicate keys are an error, not a last-one-wins merge. + + A step that declares `env:` twice keeps only the second, so a variable the + run block reads is quietly never set -- and the file still parses here while + GitHub rejects it outright. Neither outcome may reach main. + """ + seen: set = set() + for key_node, _ in node.value: + key = loader.construct_object(key_node, deep=True) + if key in seen: + raise AssertionError(f"duplicate key {key!r} at {key_node.start_mark}") + seen.add(key) + return yaml.constructor.SafeConstructor.construct_mapping(loader, node, deep=True) + + +_StrictLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicate_keys +) + + +def _load(path: Path) -> dict: + with path.open() as handle: + return yaml.load(handle, Loader=_StrictLoader) + + +def test_there_are_workflows_to_check() -> None: + """Guards the glob: an empty directory would pass every check below.""" + assert WORKFLOWS + + +@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name) +def test_workflow_parses_with_no_duplicate_keys(path: Path) -> None: + assert _load(path) + + +@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name) +def test_workflow_declares_a_trigger_and_a_job(path: Path) -> None: + """`on` is the YAML 1.1 boolean `True` once parsed, which is the key GitHub + means and the one a hand-written check usually misses. + """ + document = _load(path) + + assert document.get(True) or document.get("on"), f"{path.name}: no trigger" + assert document.get("jobs"), f"{path.name}: no jobs" + + +@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name) +def test_every_step_that_names_a_secret_can_read_it(path: Path) -> None: + """A `run` block reading `$GITHUB_TOKEN` needs the step or the job to set it; + nothing fails at parse time when it does not, the command just gets an empty + value and the step fails halfway through a release. + """ + document = _load(path) + + for job_name, job in (document.get("jobs") or {}).items(): + job_env = set(job.get("env") or {}) + for step in job.get("steps") or []: + script = step.get("run") or "" + if "$GITHUB_TOKEN" not in script and "${GITHUB_TOKEN" not in script: + continue + available = job_env | set(step.get("env") or {}) + named = step.get("name", step.get("uses", "?")) + assert "GITHUB_TOKEN" in available, f"{path.name}: {job_name}: {named}" diff --git a/uv.lock b/uv.lock index e7dfe18..aedc3c8 100644 --- a/uv.lock +++ b/uv.lock @@ -249,6 +249,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -343,6 +389,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "pytest" }, + { name = "pyyaml" }, { name = "ruff" }, ] @@ -351,6 +398,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1,<9" }, { name = "llmwhisperer-client", specifier = "==2.9.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "requests", specifier = ">=2.32.3" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "tomli-w", specifier = ">=1.0" }, From e70681cd11d2865d1bd8e3a105d4602abac20618 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 11:14:52 +0530 Subject: [PATCH 58/86] fix: let a run name its documents as presigned URLs FILES was required and checked for existence on disk, so the --presigned-urls flag the spec derives could be advertised by --discover but never used: a URL is not a local file, and omitting FILES failed in Click before the command ran. FILES is now optional and the two sources are checked together, which is where the real requirement lives -- a run naming no documents at all. The check runs before the client is built so a malformed invocation is not reported as a missing credential. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/docstudio_cmd.py | 23 ++++++-- tests/test_commands.py | 63 ++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index cade373..6807e77 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -59,7 +59,10 @@ @raw_fields(*RUN_RAW) @deployment_group.command("run") @click.argument("target") -@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True)) +# Optional because a run can name its documents as `--presigned-urls` +# instead; the two are checked together below, since neither alone is +# required and a run naming no documents at all is the real error. +@click.argument("files", nargs=-1, type=click.Path(exists=True)) @wait_options() @spec_options( PRODUCT, @@ -82,11 +85,23 @@ def run( ) -> None: """Run a deployment against one or more documents. - TARGET is a deployment alias or an API name. With --wait (the default) this - polls until the execution finishes and returns its result. + TARGET is a deployment alias or an API name. Name the documents as local + FILES, as --presigned-urls, or both. With --wait (the default) this polls + until the execution finishes and returns its result. """ - client = deployment(ctx.config, target, ctx.transport_timeout) sent = requested(params) + # Before the client is built: what the caller typed is wrong whatever the + # config resolves to, and a credential error here would name the wrong fault. + if not files and not sent.get("presigned_urls"): + raise CLIError( + "A run needs at least one document.", + ExitCode.USAGE, + hint=( + "Name local files as arguments, or pass --presigned-urls with " + "one or more HTTPS URLs." + ), + ) + client = deployment(ctx.config, target, ctx.transport_timeout) if save and not wait: raise CLIError( "--save has nothing to write with --no-wait.", diff --git a/tests/test_commands.py b/tests/test_commands.py index 3c9b3e6..26827a5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -538,6 +538,55 @@ def test_run_queues_the_execution_and_polls_it(capsys, deployment_client, tmp_pa assert envelope(out)["data"]["execution_status"] == "COMPLETED" +def test_a_run_can_name_its_documents_as_presigned_urls( + capsys, deployment_client, tmp_path +): + """The flag is derived from the spec and advertised by `--discover`, so an + invocation that uses it and nothing else has to reach the client: a local + path was once the only way to name a document, which made the flag + unusable rather than merely unused. + """ + client = deployment_client( + structure_file={ + "status_code": 200, + "pending": False, + "execution_status": "COMPLETED", + "extraction_result": [{"file": "doc.pdf"}], + } + ) + + code, out, _ = run( + capsys, + "-q", + "docstudio", + "deployment", + "run", + "my-api", + "--presigned-urls", + "https://example.com/doc.pdf", + "--interval", + "0", + ) + + assert code == int(ExitCode.SUCCESS) + sent = client.kwargs_for("structure_file") + assert list(sent["presigned_urls"]) == ["https://example.com/doc.pdf"] + assert envelope(out)["data"]["execution_status"] == "COMPLETED" + + +def test_a_run_naming_no_documents_at_all_is_refused(capsys, deployment_client): + """Neither source is required on its own, so nothing in Click's own parsing + catches a run that names no document; without this the request goes out + empty and the server answers for us. + """ + deployment_client(structure_file={"status_code": 200}) + + code, out, _ = run(capsys, "docstudio", "deployment", "run", "my-api") + + assert code == int(ExitCode.USAGE) + assert "at least one document" in envelope(out)["error"]["message"] + + @pytest.mark.parametrize( ("flag", "expected"), [([], None), (["--transport-timeout", "12.5"], 12.5)] ) @@ -705,6 +754,20 @@ def test_a_queued_run_renders_the_handle_it_had_to_derive( ) +def test_an_accepted_extraction_renders_its_handle_not_the_whole_ack( + capsys, whisper_client, tmp_path +): + """An accepted job carries no text, so raw prints the handle -- the one + thing the caller can act on. Declaring no fields here would print the whole + acknowledgement instead, which raw is precisely not for. + """ + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + whisper_client(whisper={"whisper_hash": "h1", "status_code": 202}) + + assert _raw(capsys, "whisper", "extract", str(doc), "--no-wait") == "h1" + + def test_a_still_running_status_never_renders_as_an_empty_result( capsys, deployment_client ): From f9dfd6b89d37843cd2ab110ccb2efad96df78f47 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 11:14:52 +0530 Subject: [PATCH 59/86] fix: give an accepted extraction a raw answer of its own `whisper extract --no-wait` finished without naming its raw fields, which falls back to printing the whole acknowledgement -- the one thing raw output exists not to do. An accepted job carries a handle and no text, so the declared list is now the text then the handle, and raw prints whichever the answer has. That is the same shape a queued deployment run already used. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/whisper_cmd.py | 10 +++++++--- tests/test_discover.py | 4 +++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index be378c4..d42fa37 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -43,12 +43,16 @@ #: results carry the text under this name. RAW_TEXT = ("result_text",) +#: What a submission prints, best answer first: an accepted job answers with a +#: handle and no text, so the handle is the answer until there is one. +EXTRACT_RAW = (*RAW_TEXT, "whisper_hash") + def _is_url(source: str) -> bool: return source.startswith(("http://", "https://")) -@raw_fields(*RAW_TEXT) +@raw_fields(*EXTRACT_RAW) @whisper_group.command("extract") @click.argument("source") @wait_options() @@ -109,7 +113,7 @@ def extract( ) if not wait: - finish(ctx, accepted) + finish(ctx, accepted, raw_fields=EXTRACT_RAW) return result = wait_for_completion( @@ -138,7 +142,7 @@ def extract( finish( ctx, result, - raw_fields=RAW_TEXT, + raw_fields=EXTRACT_RAW, meta={"whisper_hash": accepted.get("whisper_hash")} if accepted.get("whisper_hash") else None, diff --git a/tests/test_discover.py b/tests/test_discover.py index b232013..8914130 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -62,7 +62,9 @@ def test_full_carries_enough_to_build_a_call(capsys): ] assert params["wait"]["flags"] == ["--wait", "--no-wait"] assert params["interval"]["type"] == "float" - assert extract["raw_fields"] == ["result_text"] + # Two, because a submission answers with a handle and no text: raw has to + # name both or it describes only the waited call. + assert extract["raw_fields"] == ["result_text", "whisper_hash"] def test_full_publishes_the_flags_that_are_not_on_the_command(capsys): From f46b650c275007c14ed8608c10a12aa34dad145b Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 11:15:01 +0530 Subject: [PATCH 60/86] fix: write the starter config where it will be honoured Without --config or $UNSTRACT_CONFIG, `config init` took whatever path resolution returned -- including a project .unstract.toml found by walking up from the working directory. Two things went wrong there: the file the caller never named got created or overwritten, and the starter profiles it writes are credential indirections a discovered file is not trusted to supply, so the next command refused the config init had just produced. Discovery stays a way to read a config, not a way to choose where a new one goes. A named path is still the target wherever it points. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/config_cmd.py | 4 +-- src/unstract_cli/config.py | 13 +++++++ tests/test_config.py | 47 +++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 3429cca..e75c0d8 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -23,7 +23,7 @@ ConfigError, ConfigFile, ResolvedConfig, - config_path, + init_path, load_config, save_config, settings_for, @@ -73,7 +73,7 @@ def config_group() -> None: ) @click.pass_obj def config_init(obj: Any, force: bool) -> None: - path = config_path() + path = init_path() if path.exists() and not force: # Never prompt: state the situation and the exact flag that resolves it. raise CLIError( diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 1319f4c..fbc4db0 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -132,6 +132,18 @@ def config_path() -> Path: return _resolve_config_path()[0] +def init_path() -> Path: + """Where `config init` writes when no config file was named. + + Discovery is for reading. A file found by walking up from the working + directory is not trusted with credentials or hosts, so a starter config + written there is one the next command refuses to honour -- and writing to a + checked-in file the user never named is a surprise in its own right. + """ + path, discovered = _resolve_config_path() + return HOME_CONFIG.expanduser() if discovered else path + + def _resolve_config_path() -> tuple[Path, bool]: """The config path, and whether it was *discovered* rather than named. @@ -656,6 +668,7 @@ def starter_profiles() -> dict[str, dict[str, Any]]: "ResolvedConfig", "config_path", "find_project_config", + "init_path", "load_config", "save_config", "set_config_path", diff --git a/tests/test_config.py b/tests/test_config.py index 6df53ff..6bb8ded 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -19,6 +19,7 @@ ResolvedConfig, config_path, find_project_config, + init_path, load_config, save_config, set_config_path, @@ -540,3 +541,49 @@ def test_an_override_is_only_read_under_the_key_it_is_written_with(tmp_path): ) assert cfg.get(DOCSTUDIO, "org_id") is None assert cfg.resolution_source(DOCSTUDIO, "org_id")["resolved"] is False + + +def test_init_writes_the_home_config_even_inside_a_project(tmp_path, monkeypatch): + """A discovered file is read-only as far as `init` is concerned. + + Walking up from the working directory is how a project config is *found*. + Creating one that way writes a file the caller never named -- and one whose + credentials the loader then refuses, because a discovered file is not + trusted to supply them. The starter config has to land where it works. + """ + project = tmp_path / "project" + (project / "sub").mkdir(parents=True) + (project / PROJECT_CONFIG_NAME).write_text(PROFILE_TOML) + monkeypatch.chdir(project / "sub") + + assert find_project_config() == project / PROJECT_CONFIG_NAME + assert init_path() == config_module.HOME_CONFIG.expanduser() + + +@pytest.mark.parametrize("named_by", ["flag", "env"]) +def test_init_writes_the_file_the_caller_named(tmp_path, monkeypatch, named_by): + """Naming a path is the trusted case, and it stays the target wherever it + points -- including at a project file, which the caller has then chosen.""" + chosen = tmp_path / "chosen.toml" + if named_by == "flag": + set_config_path(chosen) + else: + monkeypatch.setenv("UNSTRACT_CONFIG", str(chosen)) + + assert init_path() == chosen + + +def test_the_starter_config_is_one_the_loader_will_honour(tmp_path, monkeypatch): + """The round trip the bug broke: init, then read it back and resolve a + credential from it. Written to a discovered project file this fails, since + `env:` indirection is refused there. + """ + monkeypatch.setenv("LLMWHISPERER_API_KEY", "k-1") + written = save_config( + ConfigFile(default_profile="cloud-us", profiles=starter_profiles()), + init_path(), + ) + + resolved = ResolvedConfig(file=load_config(written), profile_name="cloud-us") + + assert resolved.get(LLMWHISPERER, "api_key") == "k-1" From 4fd39a82c91036764e8a9bb82ee07104a52ba1bb Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:01:24 +0530 Subject: [PATCH 61/86] fix: read past an empty field when picking a raw answer The clients answer with an empty string for a field that has no value yet rather than omitting it, so raw output stopped at the first declared field every time and printed a blank line for a queued run instead of its handle. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/core/output.py | 13 +++++++------ tests/test_commands.py | 6 +++--- tests/test_output.py | 16 ++++++++++++---- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index 9017452..8603499 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -271,8 +271,8 @@ def raw_value(env: Envelope, fields: tuple[str, ...]) -> Any: Nothing present is a failure, not empty output. Raw is one value on stdout and nothing else, so it cannot say "not this time" inside itself: printing the whole payload would answer a question nobody asked, and printing the - field's own ``null`` is worse, because a caller polling for a result cannot - tell it apart from a finished job that produced nothing. + field's own empty value is worse, because a caller polling for a result + cannot tell it apart from a finished job that produced nothing. """ payload = _payload(env) if not fields or not isinstance(payload, dict): @@ -281,10 +281,11 @@ def raw_value(env: Envelope, fields: tuple[str, ...]) -> Any: for source in (payload, env.get("meta") or {}): if not isinstance(source, dict): continue - # Only `None` counts as absent: an empty result is a real answer, - # and skipping it would print the next field -- a handle where the - # caller expects text -- rather than nothing. - if (value := source.get(name)) is not None: + # Empty counts as absent, not as an answer: the clients spell a + # field that has no value yet as `""` rather than leaving it out, + # so stopping at the first present key would print a blank line + # where a later field carries the handle the caller can act on. + if (value := source.get(name)) not in (None, ""): return value raise CLIError( f"This answer carries none of {', '.join(fields)}, so there is nothing " diff --git a/tests/test_commands.py b/tests/test_commands.py index 26827a5..f831e4d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -709,7 +709,7 @@ def test_highlights_on_an_extraction_without_line_numbers_says_where_to_fix_it( ACK = { "status_code": 200, "execution_status": "PENDING", - "extraction_result": None, + "extraction_result": "", "status_check_api_endpoint": "/deployment/api/status?execution_id=e-1", } @@ -717,7 +717,7 @@ def test_highlights_on_an_extraction_without_line_numbers_says_where_to_fix_it( "status_code": 422, "pending": True, "execution_status": "EXECUTING", - "extraction_result": None, + "extraction_result": "", } DONE_STATUS = { @@ -771,7 +771,7 @@ def test_an_accepted_extraction_renders_its_handle_not_the_whole_ack( def test_a_still_running_status_never_renders_as_an_empty_result( capsys, deployment_client ): - """`extraction_result` is present and null while the job runs. Printing that + """`extraction_result` is present and empty while the job runs. Printing that tells a polling caller the same thing as a finished job with no output.""" deployment_client(check_execution_status=PENDING_STATUS) code, out, _ = run(capsys, "docstudio", "deployment", "status", "my-api", "e-1") diff --git a/tests/test_output.py b/tests/test_output.py index ae2e12b..4260c6b 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -187,16 +187,24 @@ def test_an_unknown_output_format_is_a_usage_error(): assert caught.value.exit_code is ExitCode.USAGE -def test_raw_prints_an_empty_answer_rather_than_the_next_field(): - """An empty result is a real answer, and printing the next field instead - would hand back a handle where the caller expects text.""" +def test_raw_reads_past_an_empty_answer_to_the_next_field(): + """The clients spell "no value yet" as an empty string rather than omitting + the key, so stopping there prints a blank line for every queued job.""" env = envelope(data={"extraction_result": "", "execution_id": "e-1"}) assert ( render(env, OutputFormat.RAW, raw_fields=("extraction_result", "execution_id")) - == "" + == "e-1" ) +def test_raw_fails_when_every_declared_field_is_empty(): + """Raw prints one value and nothing else, so an answer carrying none of them + has to fail rather than succeed with a blank line.""" + env = envelope(data={"extraction_result": "", "execution_id": ""}) + with pytest.raises(CLIError): + render(env, OutputFormat.RAW, raw_fields=("extraction_result", "execution_id")) + + def test_a_wide_table_is_shrunk_in_one_pass(): """Shaving one character per iteration is O(total width). The cap chosen has to be the widest one that fits, or the table is narrower than it need be.""" From 72e4e905aa81c492242d475fd03437e9d9060fd0 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:02:43 +0530 Subject: [PATCH 62/86] fix: keep a rescued result whole when a save fails Where `details` is the only surviving copy of a result the service will not serve again, redacting it by field name destroys the part of the answer the caller is being handed it to recover. The literal scrub of every resolved credential still applies on the way out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/whisper_cmd.py | 1 + src/unstract_cli/core/errors.py | 9 ++++++++- src/unstract_cli/core/poll.py | 2 ++ tests/test_poll.py | 20 +++++++++++++++++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py index d42fa37..3ad9728 100644 --- a/src/unstract_cli/commands/whisper_cmd.py +++ b/src/unstract_cli/commands/whisper_cmd.py @@ -162,6 +162,7 @@ def _extraction(payload: Any) -> Any: "The service returned no extraction for a completed job.", ExitCode.SERVER_ERROR, details=payload, + verbatim_details=True, hint=( "The read has been acknowledged, so it cannot be repeated. " "`details` carries the response exactly as it arrived." diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index b8dc406..558730e 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -273,6 +273,11 @@ class CLIError(Exception): hint: str | None = None retryable: bool = False extra: dict[str, Any] = field(default_factory=dict) + #: Set where `details` is the only surviving copy of something the service + #: will not serve again. Redacting by field name would destroy the part of + #: the result the caller most needs; the literal scrub of every resolved + #: credential still applies on the way out. + verbatim_details: bool = False def __post_init__(self) -> None: super().__init__(self.message) @@ -290,7 +295,9 @@ def to_dict(self) -> dict[str, Any]: "http_status": self.http_status, # Structural, not opt-in: the details come from a server body that # can echo the request, headers and key included. - "details": redact_value(self.details), + "details": self.details + if self.verbatim_details + else redact_value(self.details), "endpoint": self.endpoint or None, "hint": self.hint or None, } diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index f57126e..377ea34 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -163,6 +163,7 @@ def persist(path: str | Path, payload: Any) -> Path: "here would replace the link rather than update what it points at.", ExitCode.SAVE_FAILED, details=payload, + verbatim_details=True, hint=( "`details` carries the result. Pass the path of the real file and " "save it from there." @@ -195,6 +196,7 @@ def persist(path: str | Path, payload: Any) -> Path: f"The result could not be written to {path!r}: {exc}.", ExitCode.SAVE_FAILED, details=payload, + verbatim_details=True, hint=( "`details` carries the result. It has already been read from the " "service, which will not serve it again -- save it from here." diff --git a/tests/test_poll.py b/tests/test_poll.py index b2f6348..47c473d 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -6,7 +6,7 @@ import pytest -from unstract_cli.core.errors import ExitCode +from unstract_cli.core.errors import REDACTED, ExitCode from unstract_cli.core.poll import ( MAX_TRANSIENT_POLLS, CLIError, @@ -503,3 +503,21 @@ def test_a_retried_failure_is_reported_without_being_dressed_as_a_status(): ) assert [exc.message for exc in retries] == ["upstream is busy"] assert not any("upstream is busy" in status for status in seen) + + +def test_a_rescued_result_survives_field_name_redaction(tmp_path): + """A failed save leaves `details` as the only copy of a result the service + will not serve again, so collapsing a field for being named like a + credential destroys what the caller is being handed it to recover.""" + result = {"extraction": {"license_key": "AB-123456", "name": "Ada"}} + blocker = tmp_path / "not-a-dir" + blocker.write_text("") + with pytest.raises(CLIError) as caught: + persist(blocker / "out.json", result) + assert caught.value.exit_code is ExitCode.SAVE_FAILED + assert caught.value.to_dict()["details"] == result + + +def test_an_ordinary_failure_still_redacts_by_field_name(): + error = CLIError("nope", ExitCode.VALIDATION, details={"api_key": "AB-123456"}) + assert error.to_dict()["details"] == {"api_key": REDACTED} From 50a1122b4e1d72f4993ad38922205135fdd0a3e4 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:05:22 +0530 Subject: [PATCH 63/86] fix: render a failure in the format the run resolved Reading `-o` out of argv by hand only recognises the spellings it was written for, so a clustered `-ojson` succeeded as JSON and failed as a table. The root callback now fills in a context the entry point holds, leaving the argv scan for failures that happen before any of it has been parsed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/__main__.py | 20 ++++++++++++-------- src/unstract_cli/app.py | 13 +++++++------ tests/test_cli.py | 12 ++++++++++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py index 1212798..d4984ad 100644 --- a/src/unstract_cli/__main__.py +++ b/src/unstract_cli/__main__.py @@ -14,7 +14,7 @@ import click -from unstract_cli.app import cli +from unstract_cli.app import Context, cli from unstract_cli.config import ConfigError from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.output import AgentMode, OutputFormat, emit_error, resolve_format @@ -52,18 +52,22 @@ def _format_from_argv(argv: list[str]) -> OutputFormat: def main(argv: list[str] | None = None) -> int: args = list(sys.argv[1:] if argv is None else argv) - fmt = _format_from_argv(args) + # Seeded with the guess and then filled in by the root callback, so a + # failure after parsing renders in the format the run actually resolved -- + # which argv alone cannot tell, since `-o json` and `-ojson` mean the same + # thing to Click and only one of them looks like an option to read by hand. + ctx = Context(output=_format_from_argv(args)) try: - cli.main(args=args, standalone_mode=False) + cli.main(args=args, standalone_mode=False, obj=ctx) except CLIError as exc: - return int(emit_error(exc, fmt)) + return int(emit_error(exc, ctx.output)) except ConfigError as exc: - return int(emit_error(CLIError(str(exc), ExitCode.USAGE), fmt)) + return int(emit_error(CLIError(str(exc), ExitCode.USAGE), ctx.output)) except click.UsageError as exc: return int( emit_error( CLIError(exc.format_message(), ExitCode.USAGE, hint="Run with --help."), - fmt, + ctx.output, ) ) except BrokenPipeError: @@ -79,14 +83,14 @@ def main(argv: list[str] | None = None) -> int: return int( emit_error( CLIError(str(exc), ExitCode.GENERIC, hint="Check the path and disk."), - fmt, + ctx.output, ) ) except (click.Abort, KeyboardInterrupt): # Nothing here prompts, so Click's Abort can only mean an interrupt. return int( emit_error( - CLIError("Interrupted.", ExitCode.INTERRUPTED, retryable=True), fmt + CLIError("Interrupted.", ExitCode.INTERRUPTED, retryable=True), ctx.output ) ) except click.exceptions.Exit as exc: # --help and --version exit through here diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 92d5379..deeb4ed 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -164,12 +164,13 @@ def cli( running anything. """ set_config_path(config_file) - ctx.obj = Context( - output=resolve_format(output, agent), - quiet=quiet, - verbosity=verbose, - profile=profile, - ) + # Filled in rather than replaced: the entry point holds this object so that + # a failure anywhere below renders in the format resolved here. + obj = ctx.ensure_object(Context) + obj.output = resolve_format(output, agent) + obj.quiet = quiet + obj.verbosity = verbose + obj.profile = profile if discover_tier: # Discovery is how a caller learns what to run, so it has to answer # before any configuration exists -- and always as JSON, because the diff --git a/tests/test_cli.py b/tests/test_cli.py index 572b84f..674b9f8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -214,3 +214,15 @@ def test_an_unknown_output_format_is_reported_as_an_envelope(capsys): code, payload, _ = run(capsys, "--output", "yaml", "config", "list") assert code == int(ExitCode.USAGE) assert payload["error"]["exit_code"] == int(ExitCode.USAGE) + + +def test_a_clustered_short_option_still_selects_the_failure_format(capsys): + """`-ojson` and `-o json` are the same option to Click, so a failure has to + render the same way under both -- reading argv by hand only sees one.""" + assert main(["-ojson", "docstudio", "deployment", "status"]) == int(ExitCode.USAGE) + assert json.loads(capsys.readouterr().out)["error"]["code"] == "usage_error" + + +def test_a_format_named_after_other_short_options_is_still_read(capsys): + assert main(["-qojson", "whisper", "status"]) == int(ExitCode.USAGE) + assert json.loads(capsys.readouterr().out)["ok"] is False From 607146555c74ae276858e3ede5fe4a0e7b1637be Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:06:07 +0530 Subject: [PATCH 64/86] fix: do not lose a finished report to an unreadable config Resolving a credential to scrub for goes through the context's config, which raises a CLI error rather than the config error the handler was written for. A command that takes its endpoints as flags would then exit on a config file it never needed, discarding a report of work already done. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/app.py | 5 ++++- tests/test_commands.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index deeb4ed..4d6a90a 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -88,7 +88,10 @@ def secrets(self) -> list[str]: try: if value := self.config.get(product, "api_key"): out.append(str(value)) - except ConfigError: + except (ConfigError, CLIError): + # A credential that cannot be resolved is one that cannot be + # printed either. Raising here would replace a finished report + # with a config error, after the work it describes is done. continue return out diff --git a/tests/test_commands.py b/tests/test_commands.py index f831e4d..eac442c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1660,3 +1660,40 @@ def fail(*_args, **_kwargs): code, out, err = run(capsys, *argv) assert code == int(ExitCode.USAGE) assert escaped not in out and escaped not in err + + +def test_a_finished_clone_still_reports_when_the_config_is_unreadable( + capsys, monkeypatch, tmp_path +): + """Clone takes both endpoints as flags, so an unreadable config file has no + bearing on it. Scrubbing consults the config for keys to hide, and failing + there would discard a report describing work already done.""" + broken = tmp_path / "broken.toml" + broken.write_text("this is not = = toml", encoding="utf-8") + monkeypatch.setenv("UNSTRACT_CONFIG", str(broken)) + + def fake_clone(source, target, options): + return CloneReport( + source=Endpoint(source.base_url, source.organization_id), + target=Endpoint(target.base_url, target.organization_id), + phases=[PhaseResult(name="adapters", created=1)], + ) + + monkeypatch.setattr(clone_cmd, "run_clone", fake_clone) + monkeypatch.setenv("UNSTRACT_SRC_PLATFORM_KEY", "src-key-0123456789") + monkeypatch.setenv("UNSTRACT_TGT_PLATFORM_KEY", "tgt-key-0123456789") + + code, out, _ = run( + capsys, + "clone", + "--source-url", + "https://dev.example.com", + "--source-org", + "org_dev", + "--target-url", + "https://qa.example.com", + "--target-org", + "org_qa", + ) + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["data"]["skipped"]["total"] == 0 From 22709ce9e8d81c19538861628d0c15766089d1d6 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:07:18 +0530 Subject: [PATCH 65/86] fix: name the execution id when a run times out The handle a run polls on is a status URL, and the status command takes an execution id, so a timed-out run told the caller to resume with something no command accepts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/docstudio_cmd.py | 53 ++++++++++++++-------- tests/test_commands.py | 36 +++++++++++++++ 2 files changed, 70 insertions(+), 19 deletions(-) diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index 6807e77..480698e 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -126,25 +126,40 @@ def run( finish(ctx, started, raw_fields=RUN_RAW, meta=_handle_meta(started)) return - result = wait_for_completion( - initial=started, - spec=RUN_POLL, - poll=_status_poller( - client, {k: v for k, v in sent.items() if k in _SHARED_WITH_STATUS} - ), - save=save, - interval=interval, - timeout=wait_timeout, - on_status=lambda status: diagnostic( - f"status: {status}", quiet=ctx.quiet, verbosity=ctx.verbosity - ), - on_retry=lambda exc: diagnostic( - f"retrying: {exc.message}", quiet=ctx.quiet, verbosity=ctx.verbosity - ), - on_saved=lambda path: diagnostic( - f"saved: {path}", quiet=ctx.quiet, verbosity=ctx.verbosity - ), - ) + try: + result = wait_for_completion( + initial=started, + spec=RUN_POLL, + poll=_status_poller( + client, {k: v for k, v in sent.items() if k in _SHARED_WITH_STATUS} + ), + save=save, + interval=interval, + timeout=wait_timeout, + on_status=lambda status: diagnostic( + f"status: {status}", quiet=ctx.quiet, verbosity=ctx.verbosity + ), + on_retry=lambda exc: diagnostic( + f"retrying: {exc.message}", quiet=ctx.quiet, verbosity=ctx.verbosity + ), + on_saved=lambda path: diagnostic( + f"saved: {path}", quiet=ctx.quiet, verbosity=ctx.verbosity + ), + ) + except CLIError as exc: + # This job polls on a status URL, which is not what the status + # command takes; without the id the caller is told to resume with + # something they cannot pass to it. + handle = _handle_meta(started) + exc.extra = {**exc.extra, **handle} + if exc.exit_code is ExitCode.TIMEOUT and ( + found := handle.get("execution_id") + ): + exc.hint = ( + f"Resume with `unstract docstudio deployment status {target} " + f"{found}` rather than resubmitting the document." + ) + raise # A waited result names no execution, so the handle is returned as meta for # correlation. finish(ctx, result, raw_fields=RUN_RAW, meta=_handle_meta(started)) diff --git a/tests/test_commands.py b/tests/test_commands.py index eac442c..4a29dd8 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1697,3 +1697,39 @@ def fake_clone(source, target, options): ) assert code == int(ExitCode.SUCCESS) assert envelope(out)["data"]["skipped"]["total"] == 0 + + +def test_a_run_that_times_out_names_the_id_its_status_command_takes( + capsys, deployment_client, tmp_path, monkeypatch +): + """The poll handle is a status URL. Told to resume with that, a caller has + nothing to pass to `deployment status`, which takes an execution id.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + deployment_client( + structure_file=ACK, + check_execution_status={ + "status_code": 200, + "execution_status": "EXECUTING", + "extraction_result": "", + }, + ) + monkeypatch.setattr("unstract_cli.core.poll.time.sleep", lambda _seconds: None) + + code, out, _ = run( + capsys, + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--interval", + "0", + "--timeout", + "0", + ) + + assert code == int(ExitCode.TIMEOUT) + error = envelope(out)["error"] + assert error["execution_id"] == "e-1" + assert "deployment status my-api e-1" in error["hint"] From b9481f56c264618e05b6396d671b79907336b340 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:08:14 +0530 Subject: [PATCH 66/86] test: cover building a deployment client The alias branch, the bare-name fallback and the URL the client reads its organisation back out of were reached only through tests that stub the builder out entirely, so nothing exercised the route itself. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- tests/test_clients.py | 68 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/test_clients.py b/tests/test_clients.py index 8d12bd9..412f6dc 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -6,7 +6,13 @@ from requests.exceptions import ConnectionError, TooManyRedirects from unstract.llmwhisperer.client_v2 import LLMWhispererClientException -from unstract_cli.core.clients import raise_for_result, translated +from unstract_cli.config import ResolvedConfig, load_config +from unstract_cli.core.clients import ( + deployment, + deployment_url, + raise_for_result, + translated, +) from unstract_cli.core.errors import CLIError, ExitCode @@ -65,3 +71,63 @@ def test_a_success_status_carrying_an_error_is_still_a_failure(): def test_a_clean_success_raises_nothing(): raise_for_result({"status_code": 200, "execution_status": "COMPLETED"}) + + +CONFIG = """ +default_profile = "p" + +[profiles.p.docstudio] +base_url = "https://h/" +org_id = "org_profile" +api_key = "profile-key" + +[profiles.p.deployments.invoices] +api_name = "invoice-parser" +org_id = "org_alias" +api_key = "alias-key" +""" + + +def _config(tmp_path, text: str = CONFIG): + path = tmp_path / "config.toml" + path.write_text(text, encoding="utf-8") + return ResolvedConfig(file=load_config(path), profile_name="p") + + +def test_a_deployment_url_is_built_from_the_route_the_spec_declares(): + """The client reads the organisation and API name back out of the last two + segments, so a URL that disagrees with the route fails inside the client.""" + url = deployment_url("https://h/", "org_A", "api-B") + assert url.startswith("https://h/") + assert url.endswith("/org_A/api-B/") + assert "//" not in url.removeprefix("https://") + + +def test_an_alias_is_built_from_its_own_organisation_and_key(tmp_path): + client = deployment(_config(tmp_path), "invoices") + assert client.api_url.endswith("/org_alias/invoice-parser/") + assert client.api_key == "alias-key" + + +def test_a_bare_api_name_falls_back_to_the_profile(tmp_path): + client = deployment(_config(tmp_path), "some-api") + assert client.api_url.endswith("/org_profile/some-api/") + assert client.api_key == "profile-key" + + +def test_a_deployment_with_nothing_configured_names_everything_missing(tmp_path): + """Both are required, and reporting one at a time costs a round trip each.""" + with pytest.raises(CLIError) as caught: + deployment(_config(tmp_path, 'default_profile = "p"\n[profiles.p]\n'), "some-api") + assert caught.value.exit_code is ExitCode.USAGE + assert "org_id" in caught.value.message and "api_key" in caught.value.message + + +def test_a_target_that_is_not_an_alias_is_told_which_ones_are(tmp_path): + """A bare API name is legal, so a misspelt alias cannot be rejected -- but + a caller who defined aliases most likely meant one of them.""" + with pytest.raises(CLIError) as caught: + deployment( + _config(tmp_path, CONFIG.replace('org_id = "org_profile"\n', "")), "invoic" + ) + assert "invoices" in (caught.value.hint or "") From e46410bb3d21f6618fd41ba00527e760fbf858f7 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:09:00 +0530 Subject: [PATCH 67/86] ci: publish only after the revertible release steps have run A tag, a branch and a release can all be deleted; a version on PyPI cannot. Publishing first meant any later failure left a released version that no tag in the repository names, which is the one outcome that cannot be cleaned up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- .github/workflows/release.yml | 16 ++++++++-------- tests/test_workflows.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 430ab43..3420982 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,8 +69,7 @@ jobs: - run: uv sync --extra dev --python 3.12 # Staged locally only: nothing is committed, tagged or released until the - # checks, the build and the publish have all passed, so a failure leaves - # main untouched. + # checks and the build have passed, so a failure leaves main untouched. - name: Compute new version id: version run: | @@ -129,12 +128,10 @@ jobs: - name: Build package run: uv build - # Publishing is the only step that cannot be undone, so the git metadata - # is written after it: a failure before this point leaves nothing to - # unpublish, and one after it is retried by hand against a live artifact. - - name: Publish to PyPI - run: uv publish - + # Everything revertible runs first: a tag, a branch and a release can all + # be deleted, and publishing cannot. Failing here leaves nothing on PyPI + # to reconcile; failing the other way around leaves a released version + # that no tag names. - name: Commit version bump and create release env: RELEASE_NOTES: ${{ github.event.inputs.release_notes }} @@ -171,6 +168,9 @@ jobs: echo "Created release v$NEW_VERSION" + - name: Publish to PyPI + run: uv publish + - name: Success message run: | echo "Published ${{ steps.version.outputs.version }} to PyPI with uv publish using Trusted Publishers" diff --git a/tests/test_workflows.py b/tests/test_workflows.py index d17d64f..79e55ea 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -83,3 +83,17 @@ def test_every_step_that_names_a_secret_can_read_it(path: Path) -> None: available = job_env | set(step.get("env") or {}) named = step.get("name", step.get("uses", "?")) assert "GITHUB_TOKEN" in available, f"{path.name}: {job_name}: {named}" + + +def test_the_release_publishes_only_after_everything_revertible_is_done() -> None: + """A tag, a branch and a release can all be deleted; a published version + cannot. Publishing first turns any later failure into a release on PyPI + that no tag in the repository names.""" + steps = _load(Path(__file__).resolve().parents[1] / ".github/workflows/release.yml")[ + "jobs" + ]["release-and-publish"]["steps"] + names = [step.get("name", "") for step in steps] + + assert names.index("Publish to PyPI") > names.index( + "Commit version bump and create release" + ) From 753b0a3e0ef06ffca28d81f12762b07c19bcc3b8 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:09:42 +0530 Subject: [PATCH 68/86] docs: state how a deployment alias and the connection flags interact An alias that names its own org or key keeps them: the flags fill in only what it leaves to the profile, while --base-url is not per-alias and always applies. Pinned by a test so the documented precedence cannot drift silently. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 6 ++++++ tests/test_clients.py | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/README.md b/README.md index 3c59880..c27ad36 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,12 @@ under **Settings → API Key Manager** authenticates every API deployment in the organisation, so an alias normally carries only its `api_name`. Give an alias its own `api_key` when its deployment has a separate key of its own. +An alias sits outside the flag tier for the settings it states itself. Where an +alias names its own `org_id` or `api_key`, those are the ones used and +`--org-id`/`--api-key` do not displace them — the flags fill in only what the +alias leaves to the profile. `--base-url` is not per-alias and always applies, +which is what points a profile's aliases at another host. + Get an LLMWhisperer key from the LLMWhisperer console; a deployment key is shown on the API deployment's own page in the Unstract UI, and an organisation-wide one under Settings → API Key Manager. `config init` also writes an diff --git a/tests/test_clients.py b/tests/test_clients.py index 412f6dc..6d46301 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -131,3 +131,28 @@ def test_a_target_that_is_not_an_alias_is_told_which_ones_are(tmp_path): _config(tmp_path, CONFIG.replace('org_id = "org_profile"\n', "")), "invoic" ) assert "invoices" in (caught.value.hint or "") + + +def test_a_flag_fills_in_what_an_alias_leaves_out_and_no_more(tmp_path): + """The precedence the README states: an alias owns the settings it names, + and the connection flags reach only the ones it leaves to the profile.""" + config = _config( + tmp_path, + CONFIG + '\n[profiles.p.deployments.plain]\napi_name = "plain-api"\n', + ) + config.overrides = { + "docstudio.org_id": "org_flag", + "docstudio.api_key": "flag-key", + "docstudio.base_url": "https://flag-host", + } + + stated = deployment(config, "invoices") + assert stated.api_key == "alias-key" + assert "/org_alias/" in stated.api_url + + silent = deployment(config, "plain") + assert silent.api_key == "flag-key" + assert "/org_flag/" in silent.api_url + + # base_url is not a per-alias setting, so the flag reaches both. + assert stated.api_url.startswith("https://flag-host") From fc2ea7284c6acd23b1113eec8e98ce828b2dcbba Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:12:31 +0530 Subject: [PATCH 69/86] fix: bound the poll interval and floor the backoff Zero seconds between polls is a busy loop against a metered service, and doubling a zero interval never grows it, so the backoff after a rate limit answered at the rate that earned it. The flag now refuses it and the loop floors it for callers that do not come through a flag. Discovery publishes the bounds as their own keys: Click names a bounded number "float range", which is not a type a caller can map onto anything. The sleep seam is resolved on the call rather than captured at import, so replacing it in a test reaches the loop -- which it previously did not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/common.py | 13 ++++++++-- src/unstract_cli/core/discover.py | 9 +++++++ src/unstract_cli/core/poll.py | 18 ++++++++++--- tests/conftest.py | 10 ++++++++ tests/test_commands.py | 39 ++++++++++++++++++----------- tests/test_discover.py | 3 +++ tests/test_poll.py | 27 ++++++++++++++++++++ 7 files changed, 98 insertions(+), 21 deletions(-) diff --git a/src/unstract_cli/commands/common.py b/src/unstract_cli/commands/common.py index 6347d60..ff4de74 100644 --- a/src/unstract_cli/commands/common.py +++ b/src/unstract_cli/commands/common.py @@ -14,6 +14,10 @@ DEFAULT_INTERVAL = 3.0 DEFAULT_TIMEOUT = 300.0 +#: The shortest interval worth allowing: below this the polling is closer to a +#: busy loop against a metered service than to a wait. +MIN_INTERVAL = 0.1 + F = Callable[..., Any] @@ -34,7 +38,9 @@ def decorate(func: F) -> F: ), click.option( "--interval", - type=float, + # 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.", @@ -42,7 +48,9 @@ def decorate(func: F) -> F: click.option( "--timeout", "wait_timeout", - type=float, + # 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.", @@ -100,6 +108,7 @@ def finish( __all__ = [ "DEFAULT_INTERVAL", "DEFAULT_TIMEOUT", + "MIN_INTERVAL", "finish", "raw_fields", "wait_options", diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py index dea4bec..29034f4 100644 --- a/src/unstract_cli/core/discover.py +++ b/src/unstract_cli/core/discover.py @@ -85,6 +85,15 @@ def _param(param: click.Parameter) -> dict[str, Any]: entry["flags"] = list(param.opts) + list(param.secondary_opts) entry["help"] = param.help or "" entry["repeatable"] = bool(param.multiple) + if isinstance(param.type, click.IntRange | click.FloatRange): + # Click names a bounded number "integer range" or "float range", which + # is not a type a caller can map onto anything. Publish the type it + # really is, and the bounds as their own keys. + entry["type"] = "integer" if isinstance(param.type, click.IntRange) else "float" + if param.type.min is not None: + entry["minimum"] = param.type.min + if param.type.max is not None: + entry["maximum"] = param.type.max if isinstance(param.type, click.Choice): entry["choices"] = list(param.type.choices) if isinstance(param.type, Diverged): diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index 377ea34..d58385a 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -32,6 +32,10 @@ #: default interval the backoff reaches this count well inside the timeout. MAX_TRANSIENT_POLLS = 5 +#: Floor on the interval the backoff doubles. A caller reaching this module +#: directly is not bound by what the flags accept, and doubling zero is zero. +MIN_BACKOFF = 0.1 + class PollState(StrEnum): """What one poll response says about the job.""" @@ -241,8 +245,10 @@ def wait_for_completion( #: Called with the path once a result is on disk, before the caller sees #: anything. The ordering it observes is the whole point of --save. on_saved: Callable[[Path], None] | None = None, - sleep: Callable[[float], None] = time.sleep, - now: Callable[[], float] = time.monotonic, + #: Resolved on the call rather than bound at import, so replacing + #: `time.sleep` reaches this loop. + sleep: Callable[[float], None] | None = None, + now: Callable[[], float] | None = None, ) -> Any: """Poll until terminal, then retrieve if the operation has a retrieve step. @@ -252,6 +258,8 @@ def wait_for_completion( poll: a terminal success is the whole answer and is delivered, anything else raises. """ + sleep = sleep or time.sleep + now = now or time.monotonic def deliver(payload: Any) -> Any: """Save the result before the caller is told it exists.""" @@ -330,8 +338,9 @@ def naming_the_job(call: Callable[[str], Any], *, retryable: bool) -> Any: if on_retry is not None: on_retry(exc) # Back off so a rate limit is not answered at the same rate that - # earned it, but never past the deadline the caller set. - sleep(min(interval * 2**transient, remaining)) + # earned it, but never past the deadline the caller set. Floored + # because doubling a zero interval never grows it. + sleep(min(max(interval, MIN_BACKOFF) * 2**transient, remaining)) continue transient = 0 status = extract_status(payload, spec.status_field) @@ -390,6 +399,7 @@ def naming_the_job(call: Callable[[str], Any], *, retryable: bool) -> Any: __all__ = [ "MAX_TRANSIENT_POLLS", + "MIN_BACKOFF", "PollSpec", "PollState", "classify", diff --git a/tests/conftest.py b/tests/conftest.py index e3f4c17..e42f06a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,6 +40,16 @@ def clean_env(monkeypatch, tmp_path): forget_secrets() +@pytest.fixture(autouse=True) +def no_real_waiting(monkeypatch): + """Nothing in this suite is testing that a wait takes wall-clock time. + + The poll engine's own tests drive it with a fake clock they pass in; every + other test reaches it through a command, where a real sleep buys nothing. + """ + monkeypatch.setattr("unstract_cli.core.poll.time.sleep", lambda _seconds: None) + + @pytest.fixture def write_config(tmp_path, monkeypatch): """Write a config file and point the CLI at it.""" diff --git a/tests/test_commands.py b/tests/test_commands.py index 4a29dd8..0f8efb6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -233,7 +233,7 @@ def test_the_cli_owns_the_wait_loop(capsys, whisper_client, tmp_path): whisper_retrieve={"extraction": {"result_text": "hello"}}, ) - code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0") + code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0.1") assert code == int(ExitCode.SUCCESS) assert client.kwargs_for("whisper")["wait_for_completion"] is False @@ -250,7 +250,7 @@ def test_raw_output_prints_the_extracted_text(capsys, whisper_client, tmp_path): ) _, out, _ = run( - capsys, "-q", "-o", "raw", "whisper", "extract", str(doc), "--interval", "0" + capsys, "-q", "-o", "raw", "whisper", "extract", str(doc), "--interval", "0.1" ) assert out.strip() == "hello" @@ -276,7 +276,7 @@ def test_a_failed_extraction_carries_the_handle(capsys, whisper_client, tmp_path whisper_status={"status": "error", "message": "bad scan"}, ) - code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0") + code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0.1") assert code == int(ExitCode.VALIDATION) assert envelope(out)["error"]["whisper_hash"] == "h1" @@ -293,7 +293,7 @@ def test_a_transport_failure_mid_poll_carries_the_handle( whisper_status=ConnectionError("connection dropped"), ) - code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0") + code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0.1") assert code == int(ExitCode.SERVER_ERROR) assert envelope(out)["error"]["whisper_hash"] == "h1" @@ -309,7 +309,7 @@ def test_a_failed_retrieve_carries_the_handle(capsys, whisper_client, tmp_path): whisper_retrieve=ConnectionError("connection dropped"), ) - code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0") + code, out, _ = run(capsys, "-q", "whisper", "extract", str(doc), "--interval", "0.1") assert code == int(ExitCode.SERVER_ERROR) assert envelope(out)["error"]["whisper_hash"] == "h1" @@ -530,7 +530,7 @@ def test_run_queues_the_execution_and_polls_it(capsys, deployment_client, tmp_pa "my-api", str(doc), "--interval", - "0", + "0.1", ) assert code == int(ExitCode.SUCCESS) @@ -565,7 +565,7 @@ def test_a_run_can_name_its_documents_as_presigned_urls( "--presigned-urls", "https://example.com/doc.pdf", "--interval", - "0", + "0.1", ) assert code == int(ExitCode.SUCCESS) @@ -900,7 +900,7 @@ def test_a_waited_run_reads_its_result_with_the_flags_it_was_given( "my-api", str(doc), "--interval", - "0", + "0.1", "--include-metrics", "--no-include-metadata", ) @@ -940,7 +940,7 @@ def test_a_waited_run_reports_which_execution_it_was(capsys, deployment_client, "my-api", str(doc), "--interval", - "0", + "0.1", ) assert envelope(out)["meta"]["execution_id"] == "e1" @@ -973,7 +973,7 @@ def test_a_run_only_parameter_is_not_forwarded_to_the_status_read( "my-api", str(doc), "--interval", - "0", + "0.1", "--tags", "a,b", ) @@ -1083,7 +1083,7 @@ def test_a_waited_extract_keeps_a_result_that_is_not_wrapped( whisper_retrieve={"status_code": 200, "result_text": "THE REAL TEXT"}, ) - code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0") + code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0.1") assert code == int(ExitCode.SUCCESS) assert envelope(out)["data"]["result_text"] == "THE REAL TEXT" @@ -1102,7 +1102,7 @@ def test_a_waited_extract_calls_an_empty_result_a_failure( whisper_retrieve={"extraction": {}}, ) - code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0") + code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0.1") assert code == int(ExitCode.SERVER_ERROR) assert envelope(out)["ok"] is False @@ -1119,7 +1119,7 @@ def test_a_waited_extract_reads_the_result_when_it_is_not_wrapped( whisper_retrieve={"extraction": {"result_text": "hello"}}, ) - code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0") + code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0.1") assert code == int(ExitCode.SUCCESS) assert envelope(out)["data"]["result_text"] == "hello" @@ -1469,7 +1469,7 @@ def test_a_wait_that_runs_out_exits_seven_naming_the_handle( monkeypatch.setattr("unstract_cli.core.poll.time.sleep", lambda _seconds: None) code, out, _ = run( - capsys, "whisper", "extract", str(doc), "--interval", "0", "--timeout", "0" + capsys, "whisper", "extract", str(doc), "--interval", "0.1", "--timeout", "0" ) assert code == int(ExitCode.TIMEOUT) == 7 @@ -1724,7 +1724,7 @@ def test_a_run_that_times_out_names_the_id_its_status_command_takes( "my-api", str(doc), "--interval", - "0", + "0.1", "--timeout", "0", ) @@ -1733,3 +1733,12 @@ def test_a_run_that_times_out_names_the_id_its_status_command_takes( error = envelope(out)["error"] assert error["execution_id"] == "e-1" assert "deployment status my-api e-1" in error["hint"] + + +def test_a_zero_poll_interval_is_refused(capsys, whisper_client, tmp_path): + """Zero seconds between polls is a busy loop against a metered service.""" + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + code, out, _ = run(capsys, "whisper", "extract", str(doc), "--interval", "0") + assert code == int(ExitCode.USAGE) + assert "interval" in envelope(out)["error"]["message"].lower() diff --git a/tests/test_discover.py b/tests/test_discover.py index 8914130..0e9adc4 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -61,7 +61,10 @@ def test_full_carries_enough_to_build_a_call(capsys): "table", ] assert params["wait"]["flags"] == ["--wait", "--no-wait"] + # The type a caller can map, with the bound as its own key: Click's own + # name for a bounded number is "float range", which describes nothing. assert params["interval"]["type"] == "float" + assert params["interval"]["minimum"] == 0.1 # Two, because a submission answers with a handle and no text: raw has to # name both or it describes only the waited call. assert extract["raw_fields"] == ["result_text", "whisper_hash"] diff --git a/tests/test_poll.py b/tests/test_poll.py index 47c473d..fbbeaef 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -521,3 +521,30 @@ def test_a_rescued_result_survives_field_name_redaction(tmp_path): def test_an_ordinary_failure_still_redacts_by_field_name(): error = CLIError("nope", ExitCode.VALIDATION, details={"api_key": "AB-123456"}) assert error.to_dict()["details"] == {"api_key": REDACTED} + + +def test_a_zero_interval_still_backs_off_between_retries(): + """The flags refuse a zero interval, but nothing stops a caller reaching + this loop directly -- and doubling zero never grows it, so a rate limit + would be answered as fast as the loop can issue calls.""" + slept: list[float] = [] + clock = Clock() + + def failing(_handle): + raise CLIError("busy", ExitCode.RATE_LIMITED, http_status=429, retryable=True) + + def record(seconds: float) -> None: + slept.append(seconds) + clock.sleep(seconds) + + with pytest.raises(CLIError): + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=failing, + interval=0, + timeout=600, + sleep=record, + now=clock.now, + ) + assert slept and all(seconds > 0 for seconds in slept) From 2df7c5b490e00fdbd5a16064ada46ba46c44dd81 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:13:04 +0530 Subject: [PATCH 70/86] fix: preflight the write a save actually performs The result is written to a temporary sibling and moved over the target, so the directory is what must be writable. Opening the target itself passed for a writable file in a read-only directory and failed only after the one-shot read the flag exists to protect. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/core/poll.py | 12 +++++++----- tests/test_poll.py | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index d58385a..fb03c2a 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -134,11 +134,13 @@ def preflight(path: str | Path) -> Path: ) try: target.parent.mkdir(parents=True, exist_ok=True) - existed = target.exists() - with target.open("a", encoding="utf-8"): - pass - if not existed: - target.unlink() + # The write `persist` will do, not a stand-in for it: the result is + # written to a temporary sibling and moved over the target, so it is the + # directory that has to be writable. Opening the target itself passes in + # a read-only directory and fails after the read this protects. + probe_fd, probe = tempfile.mkstemp(dir=target.parent, suffix=".tmp") + os.close(probe_fd) + os.unlink(probe) except OSError as exc: raise CLIError( f"Cannot write to --save target {path!r}: {exc}.", diff --git a/tests/test_poll.py b/tests/test_poll.py index fbbeaef..0028d0f 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -548,3 +548,27 @@ def record(seconds: float) -> None: now=clock.now, ) assert slept and all(seconds > 0 for seconds in slept) + + +def test_preflight_refuses_a_writable_file_in_a_directory_it_cannot_write(tmp_path): + """The result is written to a temporary sibling and moved over the target, + so the directory is what has to be writable. Checking the file alone passes + here and fails after the read `--save` exists to protect.""" + locked = tmp_path / "locked" + locked.mkdir() + target = locked / "out.json" + target.write_text("", encoding="utf-8") + locked.chmod(0o500) + try: + with pytest.raises(CLIError) as caught: + preflight(target) + finally: + locked.chmod(0o700) + assert caught.value.exit_code is ExitCode.USAGE + assert "nothing is lost" in (caught.value.hint or "") + + +def test_preflight_accepts_a_path_whose_directory_does_not_exist_yet(tmp_path): + """`persist` creates the parents, so refusing here would refuse a path that + works.""" + assert preflight(tmp_path / "new" / "deeper" / "out.json") From 11c0a86b9cdcd7b1d0163dc87197a31de0814864 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:13:29 +0530 Subject: [PATCH 71/86] fix: stop reporting a local fault as a retryable server failure Everything the service raises on purpose is already a CLI error by the time it reaches the poll loop, so what the bare handler catches is this side's own bug. Labelling it a retryable server error repeated it until the retry budget ran out and then blamed the service. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/core/poll.py | 6 ++++-- tests/test_poll.py | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index fb03c2a..03bc3f4 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -321,10 +321,12 @@ def naming_the_job(call: Callable[[str], Any], *, retryable: bool) -> Any: exc.retryable = False raise except Exception as exc: + # Everything the service can raise on purpose is already a CLIError + # by here, so what reaches this is a fault on this side. Calling it + # a retryable server error spends the retry budget repeating it. raise CLIError( str(exc) or type(exc).__name__, - ExitCode.SERVER_ERROR, - retryable=retryable, + ExitCode.GENERIC, extra={spec.handle_field: handle}, ) from exc diff --git a/tests/test_poll.py b/tests/test_poll.py index 0028d0f..75abdcf 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -572,3 +572,28 @@ def test_preflight_accepts_a_path_whose_directory_does_not_exist_yet(tmp_path): """`persist` creates the parents, so refusing here would refuse a path that works.""" assert preflight(tmp_path / "new" / "deeper" / "out.json") + + +def test_a_fault_on_this_side_is_not_retried_as_a_server_failure(): + """Everything the service raises on purpose is a CLIError by the time it + reaches the loop, so anything else is this CLI's own bug -- repeating it + spends the retry budget and reports someone else's fault.""" + calls: list[str] = [] + + def broken(handle): + calls.append(handle) + raise AttributeError("'NoneType' object has no attribute 'get'") + + clock = Clock() + with pytest.raises(CLIError) as caught: + wait_for_completion( + initial={"whisper_hash": "h1"}, + spec=SPEC, + poll=broken, + timeout=600, + sleep=clock.sleep, + now=clock.now, + ) + assert caught.value.exit_code is ExitCode.GENERIC + assert caught.value.retryable is False + assert len(calls) == 1 From 3eabc6ab98f5bc94e9f9ab938e014effa0882f59 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:15:06 +0530 Subject: [PATCH 72/86] feat: publish what omitting a spec-derived flag gets you The CLI leaves these flags without a Click default so that nothing is resent, which left the value the client or the service applies readable only as a sentence inside the help text. Discovery now carries it as `server_default`. The spec states some of those defaults in prose of its own, so a flag could carry two statements of one default -- and one of them was already wrong. Stripped, leaving the rendered value as the single statement. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/core/discover.py | 4 ++++ src/unstract_cli/core/params.py | 35 ++++++++++++++++++------------- tests/test_discover.py | 26 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py index 29034f4..7d0dde2 100644 --- a/src/unstract_cli/core/discover.py +++ b/src/unstract_cli/core/discover.py @@ -113,6 +113,10 @@ def _param(param: click.Parameter) -> dict[str, Any]: default = False if getattr(param, "is_flag", False) else None if default is not None and not isinstance(param, click.Argument): entry["default"] = default + # For a spec-derived flag: what the client or the service applies when it is + # not passed. The CLI never resends it, so it is not the flag's own default. + if (fallback := getattr(param, "server_default", None)) is not None: + entry["server_default"] = fallback return entry diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index a8abada..39208c8 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -300,7 +300,9 @@ def _help_text(param: Param, choices: tuple[str, ...]) -> str: leave this out", which is the only question a default can honestly answer here: the CLI does not resend it, the client or the server does. """ - parts = [param.description] if param.description else [] + # The spec states some defaults in prose of its own. Left in, the flag + # carries two statements of one default, free to disagree. + parts = [_strip_restated(param.description)] if param.description else [] if choices: parts.append(f"One of: {', '.join(choices)}.") if param.default not in (None, "") and not param.required: @@ -333,19 +335,24 @@ def click_option(param: Param, spec_overlay: dict[str, Any]) -> click.Option: decls = [f"{param.flag}/--no-{param.name.replace('_', '-')}"] if short: decls.insert(0, short) - return click.Option(decls, required=param.required, help=help_text, **absent) - - decls = [param.flag] - if short: - decls.insert(0, short) - return click.Option( - decls, - type=click.Choice(choices) if choices else _click_type(param), - required=param.required, - multiple=param.array, - help=help_text, - **absent, - ) + option = click.Option(decls, required=param.required, help=help_text, **absent) + else: + decls = [param.flag] + if short: + decls.insert(0, short) + option = click.Option( + decls, + type=click.Choice(choices) if choices else _click_type(param), + required=param.required, + multiple=param.array, + help=help_text, + **absent, + ) + # What omitting the flag gets you. It cannot be Click's own default, which + # the CLI leaves unset so that nothing is resent -- and a caller building a + # call needs it as a value, not as a sentence inside the help. + option.server_default = param.default + return option class Diverged(click.ParamType): diff --git a/tests/test_discover.py b/tests/test_discover.py index 0e9adc4..52fd0b3 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -281,3 +281,29 @@ def test_a_malformed_config_file_is_a_usage_error(capsys, write_config): code, _ = run(capsys, "config", "list") assert code == int(ExitCode.USAGE) + + +def test_full_publishes_what_omitting_a_spec_flag_gets_you(capsys): + """The CLI leaves a spec flag's own default unset so that nothing is + resent, which left the value a caller gets by omitting it readable only as + a sentence inside the help text.""" + _, data = run(capsys, "--discover", "full") + extract = data["commands"]["whisper"]["commands"]["extract"] + params = {p["name"]: p for p in extract["params"]} + + assert params["mode"]["server_default"] == "form" + assert params["mode"].get("default") is None + # Not on a flag the CLI declares itself: nothing behind it applies a value. + assert "server_default" not in params["interval"] + + +def test_a_spec_flag_states_its_default_once(capsys): + """The spec describes some defaults in prose of its own, which disagreed + with the value the client actually applies.""" + _, data = run(capsys, "--discover", "full") + extract = data["commands"]["whisper"]["commands"]["extract"] + params = {p["name"]: p for p in extract["params"]} + + threshold = params["word_confidence_threshold"] + assert "Defaults to" not in threshold["help"] + assert f"[default: {threshold['server_default']}]" in threshold["help"] From 3fccf12ca32ec442c0b14e7680f1fe1adde85794 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:15:43 +0530 Subject: [PATCH 73/86] test: check the token where gh actually reads it The check looked for `$GITHUB_TOKEN` spelled out in a run block and so matched nothing: `gh` takes its credential from the environment without naming it. It now matches the steps that shell out to `gh`, and a guard fails if that stops matching anything rather than letting the check pass vacuously. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- tests/test_workflows.py | 44 +++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 79e55ea..3844f00 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -5,6 +5,7 @@ day is a release. These read the files the way GitHub does, at PR time. """ +import re from pathlib import Path import pytest @@ -66,23 +67,40 @@ def test_workflow_declares_a_trigger_and_a_job(path: Path) -> None: assert document.get("jobs"), f"{path.name}: no jobs" -@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name) -def test_every_step_that_names_a_secret_can_read_it(path: Path) -> None: - """A `run` block reading `$GITHUB_TOKEN` needs the step or the job to set it; - nothing fails at parse time when it does not, the command just gets an empty - value and the step fails halfway through a release. - """ - document = _load(path) +#: `gh` as a command rather than as a word: it reads its credential from the +#: environment and says nothing about where it came from. +_INVOKES_GH = re.compile(r"(?:^|[|&;(\n]|\bthen\b|\belse\b)\s*gh\s", re.MULTILINE) + +def _steps_invoking_gh(document: dict) -> list[tuple[str, str, set]]: + """Every step that shells out to `gh`, with the environment it will see.""" + found = [] for job_name, job in (document.get("jobs") or {}).items(): job_env = set(job.get("env") or {}) for step in job.get("steps") or []: - script = step.get("run") or "" - if "$GITHUB_TOKEN" not in script and "${GITHUB_TOKEN" not in script: - continue - available = job_env | set(step.get("env") or {}) - named = step.get("name", step.get("uses", "?")) - assert "GITHUB_TOKEN" in available, f"{path.name}: {job_name}: {named}" + if _INVOKES_GH.search(step.get("run") or ""): + named = step.get("name", step.get("uses", "?")) + found.append((job_name, named, job_env | set(step.get("env") or {}))) + return found + + +def test_the_release_is_the_workflow_that_shells_out_to_gh() -> None: + """Guards the matcher below: a pattern that stops matching would leave every + check that uses it passing vacuously.""" + matched = {path.name for path in WORKFLOWS if _steps_invoking_gh(_load(path))} + assert "release.yml" in matched + + +@pytest.mark.parametrize("path", WORKFLOWS, ids=lambda p: p.name) +def test_every_step_that_shells_out_to_gh_can_authenticate(path: Path) -> None: + """`gh` takes its credential from the environment and exits non-zero without + one. Nothing fails at parse time, so an unset token surfaces halfway through + a release, after the steps before it have already run. + """ + for job_name, named, available in _steps_invoking_gh(_load(path)): + assert available & {"GITHUB_TOKEN", "GH_TOKEN"}, ( + f"{path.name}: {job_name}: {named}" + ) def test_the_release_publishes_only_after_everything_revertible_is_done() -> None: From 79e961c723313c973bd74b48d7a40aa473155025 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:16:21 +0530 Subject: [PATCH 74/86] fix: answer a bare invocation in the format that was asked for Printing help on stdout and exiting 0 tells a parser the run succeeded and then hands it a page of prose in place of the envelope. A group invoked with no command already answers with a usage error; the root now does the same, and a person still gets the help page. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/app.py | 9 +++++++++ tests/test_cli.py | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 4d6a90a..4033157 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -181,6 +181,15 @@ def cli( emit_result(discover(cli, discover_tier), OutputFormat.JSON) ctx.exit(int(ExitCode.SUCCESS)) if ctx.invoked_subcommand is None: + if obj.output is not OutputFormat.TABLE: + # stdout carries one envelope and nothing else, and a run naming no + # command ran nothing -- printing help there and exiting 0 tells a + # parser the work succeeded and hands it a page of prose. + raise CLIError( + "No command given.", + ExitCode.USAGE, + hint="`--discover groups` lists what can be run, as JSON.", + ) click.echo(ctx.get_help()) ctx.exit(int(ExitCode.SUCCESS)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 674b9f8..15b29d7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -226,3 +226,15 @@ def test_a_clustered_short_option_still_selects_the_failure_format(capsys): def test_a_format_named_after_other_short_options_is_still_read(capsys): assert main(["-qojson", "whisper", "status"]) == int(ExitCode.USAGE) assert json.loads(capsys.readouterr().out)["ok"] is False + + +def test_a_bare_invocation_is_a_usage_error_in_a_parseable_format(capsys): + """Help on stdout with exit 0 tells a parser the run succeeded, then hands + it a page of prose where the envelope should be.""" + assert main(["-o", "json"]) == int(ExitCode.USAGE) + assert json.loads(capsys.readouterr().out)["error"]["code"] == "usage_error" + + +def test_a_bare_invocation_still_prints_help_for_a_person(capsys): + assert main(["-o", "table"]) == int(ExitCode.SUCCESS) + assert "Commands:" in capsys.readouterr().out From 4d5171a70e65183574ed7a9120c54d1d789d1e24 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:18:52 +0530 Subject: [PATCH 75/86] fix: put every diagnostic behind --quiet The config, overlay and credential registries are imported by the output layer and so cannot import it back; each wrote straight to stderr, which left three notes that --quiet did not reach. They now go through a sink the run binds. Notes raised while the command tree is built are held until there is a run to ask, and anything still held when the entry point returns is written out rather than dropped for having been early. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/__main__.py | 6 +++- src/unstract_cli/app.py | 7 ++++- src/unstract_cli/config.py | 8 ++---- src/unstract_cli/core/errors.py | 48 ++++++++++++++++++++++++++++++-- src/unstract_cli/core/overlay.py | 8 +++--- tests/conftest.py | 20 ++++++++++++- tests/test_cli.py | 20 +++++++++++++ tests/test_config.py | 4 +-- tests/test_errors.py | 8 +++--- 9 files changed, 108 insertions(+), 21 deletions(-) diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py index d4984ad..69c06ff 100644 --- a/src/unstract_cli/__main__.py +++ b/src/unstract_cli/__main__.py @@ -16,7 +16,7 @@ from unstract_cli.app import Context, cli from unstract_cli.config import ConfigError -from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.errors import CLIError, ExitCode, set_warning_sink from unstract_cli.core.output import AgentMode, OutputFormat, emit_error, resolve_format @@ -95,6 +95,10 @@ def main(argv: list[str] | None = None) -> int: ) except click.exceptions.Exit as exc: # --help and --version exit through here return int(exc.exit_code) + finally: + # A run that failed before the root callback bound a sink still has to + # show what was held, rather than swallowing it for being early. + set_warning_sink(None) return int(ExitCode.SUCCESS) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 4033157..2972c7e 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -22,7 +22,7 @@ set_config_path, ) from unstract_cli.core.discover import TIERS, discover -from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.errors import CLIError, ExitCode, set_warning_sink from unstract_cli.core.output import ( AgentMode, OutputFormat, @@ -174,6 +174,11 @@ def cli( obj.quiet = quiet obj.verbosity = verbose obj.profile = profile + # Modules the output layer imports cannot import it back, so their notes + # reach it through here rather than going straight to stderr unfiltered. + set_warning_sink( + lambda message: diagnostic(message, quiet=obj.quiet, verbosity=obj.verbosity) + ) if discover_tier: # Discovery is how a caller learns what to run, so it has to answer # before any configuration exists -- and always as JSON, because the diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index fbc4db0..25871b1 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -18,7 +18,6 @@ import contextlib import os import stat -import sys import tempfile import tomllib from copy import deepcopy @@ -28,7 +27,7 @@ import tomli_w -from unstract_cli.core.errors import remember_secret +from unstract_cli.core.errors import remember_secret, warn LLMWHISPERER = "llmwhisperer" DOCSTUDIO = "docstudio" @@ -461,11 +460,10 @@ def _env_allowed(self, raw: Any) -> bool: # reported when the file is loaded, and this is found while resolving. if raw not in self._reported: self._reported.add(raw) - print( + warn( f"warning: ignoring {raw!r} in the project-local " f"{self.file.path}: a config file found by searching upwards " - "may not choose which environment variable is read.", - file=sys.stderr, + "may not choose which environment variable is read." ) return False diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index 558730e..ec3c691 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -9,6 +9,7 @@ import re import sys +from collections.abc import Callable from dataclasses import dataclass, field from enum import IntEnum from typing import Any @@ -135,6 +136,48 @@ def is_retryable(status: int) -> bool: _REPORTED_SHORT: set[str] = set() +def _to_stderr(message: str) -> None: + print(message, file=sys.stderr) + + +#: Notes written before a sink was bound. The command tree is built at import, +#: so a warning about the overlay is raised before there is a run to ask whether +#: it was told to be quiet. +_HELD: list[str] = [] + +#: Where a note goes once a run owns the streams. Unbound until then. +_SINK: Callable[[str], None] | None = None + + +def warn(message: str) -> None: + """A note from a module that cannot reach the output layer. + + The config, overlay and credential registries are all imported by it, so + they cannot import it back; this is the seam that keeps their notes subject + to the same `--quiet` as every other diagnostic. + """ + if _SINK is None: + _HELD.append(message) + return + _SINK(message) + + +def set_warning_sink(sink: Callable[[str], None] | None) -> None: + """Route held and future notes. ``None`` sends them to stderr unfiltered.""" + global _SINK + _SINK = sink or _to_stderr + for message in _HELD: + _SINK(message) + _HELD.clear() + + +def forget_warning_sink() -> None: + """Unbind the sink and drop anything held, so one run cannot leak into the next.""" + global _SINK + _SINK = None + _HELD.clear() + + def remember_secret(value: Any) -> None: """Record a resolved credential so no stream can print it later.""" if not isinstance(value, str) or not value: @@ -145,10 +188,9 @@ def remember_secret(value: Any) -> None: # a key resolves several times in one run. if value not in _REPORTED_SHORT: _REPORTED_SHORT.add(value) - print( + warn( f"warning: a credential under {_MIN_SECRET_LEN} characters is too " - "short to scrub for and will not be redacted", - file=sys.stderr, + "short to scrub for and will not be redacted" ) return _KNOWN_SECRETS.add(value) diff --git a/src/unstract_cli/core/overlay.py b/src/unstract_cli/core/overlay.py index 3a87f3b..b051ab5 100644 --- a/src/unstract_cli/core/overlay.py +++ b/src/unstract_cli/core/overlay.py @@ -15,12 +15,13 @@ from __future__ import annotations -import sys import tomllib from functools import cache from importlib import resources from typing import Any +from unstract_cli.core.errors import warn + OVERLAY_FILE = "overlay.toml" @@ -39,10 +40,9 @@ def overlay_for(product: str, operation_id: str) -> dict[str, dict[str, Any]]: if isinstance(entry, dict): out[name] = entry else: - print( + warn( f"warning: ignoring {OVERLAY_FILE} entry [{product}.{operation_id}." - f"{name}]: expected a table, found {type(entry).__name__}.", - file=sys.stderr, + f"{name}]: expected a table, found {type(entry).__name__}." ) return out diff --git a/tests/conftest.py b/tests/conftest.py index e42f06a..b3d663c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,11 @@ import pytest from unstract_cli import config as config_mod -from unstract_cli.core.errors import forget_secrets +from unstract_cli.core.errors import ( + forget_secrets, + forget_warning_sink, + set_warning_sink, +) from unstract_cli.core.output import AGENT_ENV #: Every variable the loader consults. Cleared per test so a developer's real @@ -35,9 +39,23 @@ def clean_env(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) monkeypatch.setattr(config_mod, "HOME_CONFIG", tmp_path / "home" / "config.toml") forget_secrets() + forget_warning_sink() yield config_mod.set_config_path(None) forget_secrets() + forget_warning_sink() + + +@pytest.fixture +def warnings_seen(): + """Notes from the modules that cannot reach the output layer, as a list. + + They are held rather than printed until a run binds a sink, so a test + calling the library directly has to bind one to see them at all. + """ + seen: list[str] = [] + set_warning_sink(seen.append) + return seen @pytest.fixture(autouse=True) diff --git a/tests/test_cli.py b/tests/test_cli.py index 15b29d7..837a55a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -238,3 +238,23 @@ def test_a_bare_invocation_is_a_usage_error_in_a_parseable_format(capsys): def test_a_bare_invocation_still_prints_help_for_a_person(capsys): assert main(["-o", "table"]) == int(ExitCode.SUCCESS) assert "Commands:" in capsys.readouterr().out + + +def test_quiet_silences_a_note_from_below_the_output_layer(capsys, tmp_path, monkeypatch): + """The config and credential registries cannot import the output layer, and + went straight to stderr -- so `--quiet` reached everything except them.""" + work = tmp_path / "work" + work.mkdir() + (work / ".unstract.toml").write_text( + '[profiles.p.docstudio]\norg_id = "env:CI_DEPLOY_TOKEN"\napi_key = "k-0123456789"\n', + encoding="utf-8", + ) + monkeypatch.chdir(work) + monkeypatch.setenv("CI_DEPLOY_TOKEN", "tkn-never-read") + + args = ["-o", "json", "-p", "p", "docstudio", "deployment", "status", "a", "b"] + main(args) + assert "may not choose which environment variable" in capsys.readouterr().err + + main(["-q", *args]) + assert "may not choose which environment variable" not in capsys.readouterr().err diff --git a/tests/test_config.py b/tests/test_config.py index 6bb8ded..94f6128 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -465,7 +465,7 @@ def test_starter_profiles_hold_no_literal_secrets(): def test_a_discovered_file_cannot_choose_which_env_var_is_read( - tmp_path, monkeypatch, capsys + tmp_path, monkeypatch, warnings_seen ): """`org_id` is not withheld from a project file, and it is spliced into the deployment URL and echoed back in any error about it. Letting a checkout @@ -481,7 +481,7 @@ def test_a_discovered_file_cannot_choose_which_env_var_is_read( cfg = ResolvedConfig(file=load_config(), profile_name="p") assert cfg.get(DOCSTUDIO, "org_id") is None - assert "may not choose which environment variable" in capsys.readouterr().err + assert any("may not choose which environment variable" in n for n in warnings_seen) def test_a_named_file_may_still_use_env_indirection(tmp_path, monkeypatch): diff --git a/tests/test_errors.py b/tests/test_errors.py index 6baccfb..f5dcc1b 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -187,10 +187,10 @@ def test_a_secret_named_key_is_redacted_whatever_type_it_holds(): assert out["n"] == 1 -def test_a_credential_too_short_to_scrub_for_says_so(capsys): +def test_a_credential_too_short_to_scrub_for_says_so(warnings_seen): remember_secret("short") assert "short" not in known_secrets() - assert "will not be redacted" in capsys.readouterr().err + assert any("will not be redacted" in note for note in warnings_seen) def test_scrub_structure_replaces_before_anything_renders(): @@ -243,7 +243,7 @@ def test_a_credential_is_collapsed_whatever_shape_it_arrives_in(value): assert redact_value({"secret": value})["secret"] == REDACTED -def test_a_short_credential_warns_once_per_run(capsys): +def test_a_short_credential_warns_once_per_run(warnings_seen): remember_secret("short") remember_secret("short") - assert capsys.readouterr().err.count("too short to scrub") == 1 + assert sum("too short to scrub" in note for note in warnings_seen) == 1 From dcd6f8bf8a1f265b70ba89610b7421f88e26b2d0 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:23:34 +0530 Subject: [PATCH 76/86] keep the parts of a config file this CLI does not own `save_config` rebuilt the document from `default_profile` and `profiles` alone, so writing a profile deleted every other top-level table in the file. The parsed mapping is kept on `ConfigFile` and the write starts from it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/config.py | 10 +++++++++- tests/test_config.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 25871b1..2316596 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -205,6 +205,9 @@ class ConfigFile: #: Keys withheld from an untrusted file, as ``{(profile, *blocks, key): value}``. #: Excluded from resolution, but kept so a write-back does not drop them. withheld: dict[tuple[str, ...], Any] = field(default_factory=dict) + #: The file as it was parsed. A write rebuilds only the tables this CLI owns, + #: so anything else in the file survives being written through. + raw: dict[str, Any] = field(default_factory=dict) def _strip_untrusted(profiles: dict[str, Any]) -> dict[tuple[str, ...], Any]: @@ -287,6 +290,7 @@ def load_config(path: Path | None = None) -> ConfigFile: warnings=tuple(warnings), is_project_local=project_local, withheld=withheld, + raw=raw, ) @@ -319,7 +323,11 @@ def save_config(cfg: ConfigFile, path: Path | None = None) -> Path: target = path or cfg.path or config_path() target.parent.mkdir(parents=True, exist_ok=True) - doc: dict[str, Any] = {} + # Started from the file as it was read: a table this CLI does not know about + # is not a table it may delete, and `config set` would otherwise drop + # whatever else the user or a later version keeps here. + doc: dict[str, Any] = {k: v for k, v in cfg.raw.items() if k != "profiles"} + doc.pop("default_profile", None) if cfg.default_profile: doc["default_profile"] = cfg.default_profile doc["profiles"] = _restored_profiles(cfg, target) diff --git a/tests/test_config.py b/tests/test_config.py index 94f6128..d9a5079 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,7 @@ import os import stat +import tomllib from pathlib import Path import pytest @@ -393,6 +394,18 @@ def test_writing_back_a_project_config_keeps_the_keys_it_withheld(tmp_path, monk ) +def test_a_table_this_cli_does_not_own_survives_a_write(write_config): + path = write_config(PROFILE_TOML + "\n[telemetry]\nenabled = false\n") + cfg = load_config() + cfg.profiles["p"]["docstudio"]["org_id"] = "org_edited" + save_config(cfg, path) + + assert load_config().profiles["p"]["docstudio"]["org_id"] == "org_edited" + assert tomllib.loads(path.read_text(encoding="utf-8"))["telemetry"] == { + "enabled": False + } + + def test_withheld_keys_are_not_carried_into_a_file_the_user_names(tmp_path, monkeypatch): _plant_project_config(tmp_path, monkeypatch) elsewhere = tmp_path / "named.toml" From dcd327f11c7c4a50f84c70851babc726f944703b Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:24:38 +0530 Subject: [PATCH 77/86] say on stderr when a clone left something behind A skipped, oversize or unsupported file does not fail the run, so the exit code says nothing about it and only the table renders the counts. The summary now goes to stderr in every format, under --quiet like any other diagnostic. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/clone_cmd.py | 16 ++++++++++-- tests/test_commands.py | 34 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/unstract_cli/commands/clone_cmd.py b/src/unstract_cli/commands/clone_cmd.py index 5fa05d7..848cb00 100644 --- a/src/unstract_cli/commands/clone_cmd.py +++ b/src/unstract_cli/commands/clone_cmd.py @@ -30,7 +30,7 @@ error_from_status, remember_secret, ) -from unstract_cli.core.output import OutputFormat, emit_text +from unstract_cli.core.output import OutputFormat, diagnostic, emit_text # Mirrors the table and grammar `unstract.clone.cli` uses, single-letter # spellings included, so both spellings of this command accept the same strings. @@ -264,7 +264,19 @@ def _finish(ctx: Context, report: CloneReport) -> None: elif failed := [phase.name for phase in report.phases if phase.failed]: failure = f"Clone completed with failures in: {', '.join(sorted(failed))}" - payload = {**report.as_dict(), "skipped": _skipped(report)} + skipped = _skipped(report) + counts = { + **skipped["by_phase"], + "oversize files": skipped["oversize_files"], + "unsupported files": skipped["unsupported_files"], + } + if named := ", ".join(f"{what} {n}" for what, n in counts.items() if n): + # On stderr in every format: a skip does not fail the run, so a caller + # reading the exit code alone is told nothing about what never arrived, + # and a machine format is not read by eye. + diagnostic(f"Skipped: {named}.", quiet=ctx.quiet, verbosity=ctx.verbosity) + + payload = {**report.as_dict(), "skipped": skipped} # A person running this reads the report itself; every other format gets the # single envelope, which carries the same content as data. rendered = ctx.output is OutputFormat.TABLE diff --git a/tests/test_commands.py b/tests/test_commands.py index 0f8efb6..7cc14b3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1294,6 +1294,40 @@ def fake_clone(source, target, options): assert key not in out and key not in err +def test_a_clone_that_skipped_files_says_so_on_stderr(capsys, monkeypatch): + """A skip is not a failure, so nothing else tells a caller it happened.""" + + def fake_clone(source, target, options): + return CloneReport( + source=Endpoint(source.base_url, source.organization_id), + target=Endpoint(target.base_url, target.organization_id), + phases=[PhaseResult(name="files", created=1, skipped=3)], + oversize_files=[{"name": "big.pdf"}], + ) + + monkeypatch.setattr(clone_cmd, "run_clone", fake_clone) + monkeypatch.setenv("UNSTRACT_SRC_PLATFORM_KEY", "src-key-0123456789") + monkeypatch.setenv("UNSTRACT_TGT_PLATFORM_KEY", "tgt-key-0123456789") + args = ( + "clone", + "--source-url", + "https://dev.example.com", + "--source-org", + "org_dev", + "--target-url", + "https://qa.example.com", + "--target-org", + "org_qa", + ) + + code, out, err = run(capsys, *args) + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["ok"] is True + assert "files 3" in err and "oversize files 1" in err + + assert "Skipped" not in run(capsys, "--quiet", *args)[2] + + def test_a_key_quoted_in_a_clone_report_does_not_survive_the_table(capsys, monkeypatch): """The table is the output a person gets, and the report renders itself. From dfd7102a748acc4741e03e6950ca9126a1296cd3 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:26:13 +0530 Subject: [PATCH 78/86] refuse a config setting the product does not have `config set` wrote any key under any product, so a typo was stored and silently never read; `doctor` now names a key already sitting in a product block that nothing resolves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/commands/config_cmd.py | 15 +++++++++++++++ src/unstract_cli/config.py | 8 ++++++++ tests/test_cli.py | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index e75c0d8..e08ee19 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -62,6 +62,17 @@ def _check_product(product: str) -> str: return product +def _check_key(product: str, key: str) -> str: + """A setting a product does not have would be written and never read again.""" + if key not in (known := settings_for(product)): + raise CLIError( + f"{product} has no setting {key!r}.", + ExitCode.USAGE, + hint=f"Valid keys for {product}: " + ", ".join(known) + ".", + ) + return key + + @click.group(name="config", help="Manage CLI configuration profiles (local only).") def config_group() -> None: """Local configuration management. These commands make no network calls.""" @@ -178,6 +189,7 @@ def config_set(obj: Any, product: str, key: str, value: str, profile: str | None shell history. """ _check_product(product) + _check_key(product, key) cfg = _loaded(obj) name = profile or getattr(obj, "profile", None) or cfg.default_profile or "cloud-us" @@ -305,6 +317,9 @@ def config_doctor(obj: Any, probe: bool) -> None: entry[key] = {"resolved": False, "source": "unset", "detail": str(exc)} if detail := entry[key].get("detail"): problems.append(f"{product}.{key}: {detail}") + for stray in resolved.unknown_settings(product): + # Nothing reads it, so it is a setting the user believes is in force. + problems.append(f"{product}.{stray}: not a setting {product} has.") products[product] = entry try: diff --git a/src/unstract_cli/config.py b/src/unstract_cli/config.py index 2316596..b4238a9 100644 --- a/src/unstract_cli/config.py +++ b/src/unstract_cli/config.py @@ -425,6 +425,14 @@ def _product_block(self, product: str) -> dict[str, Any]: block = self._profile().get(product) return block if isinstance(block, dict) else {} + def unknown_settings(self, product: str) -> tuple[str, ...]: + """Keys written under a product that nothing will ever read back.""" + try: + written = set(self._product_block(product)) + except ConfigError: + return () + return tuple(sorted(written - set(settings_for(product)))) + def get(self, product: str, key: str, default: Any = None) -> Any: """Resolve one setting: **flag > env > profile > built-in default**.""" value = self._resolve(product, key, default) diff --git a/tests/test_cli.py b/tests/test_cli.py index 837a55a..d1a0c90 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -76,6 +76,25 @@ def test_set_then_get_round_trip(capsys, tmp_path, monkeypatch): assert payload["data"]["value"] == "org_A" +def test_set_refuses_a_setting_the_product_does_not_have(capsys, tmp_path, monkeypatch): + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) + code, payload, _ = run(capsys, "config", "set", "llmwhisperer", "org_id", "org_A") + + assert code == int(ExitCode.USAGE) + assert "org_id" in payload["error"]["message"] + assert "base_url" in payload["error"]["hint"] + assert not (tmp_path / "c.toml").exists() + + +def test_doctor_reports_a_setting_nothing_reads(capsys, write_config): + write_config('default_profile = "p"\n\n[profiles.p.llmwhisperer]\norg_id = "org_A"\n') + code, payload, _ = run(capsys, "config", "doctor") + + assert code != 0 + problems = payload["error"]["details"]["problems"] + assert any("llmwhisperer.org_id" in problem for problem in problems) + + def test_set_warns_when_a_credential_is_stored_literally(capsys, tmp_path, monkeypatch): monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) _, payload, _ = run(capsys, "config", "set", "llmwhisperer", "api_key", "literal-key") From c11e36cd7ff6227bf65d566ac0bb4cd54d256ac9 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:28:19 +0530 Subject: [PATCH 79/86] name an overlay entry the specs do not declare An entry for an unknown product, operation or parameter applied nothing and said nothing, so a short flag or a narrowed value list could be written and never take effect. The flag snapshot also records what the overlay resolves, so a narrowing that stops applying moves it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/core/params.py | 42 ++++++++++- tests/derived_flags.json | 129 +++++++++++++++++++++----------- tests/test_contract.py | 14 +++- tests/test_params.py | 28 +++++++ 4 files changed, 166 insertions(+), 47 deletions(-) diff --git a/src/unstract_cli/core/params.py b/src/unstract_cli/core/params.py index 39208c8..b7200fa 100644 --- a/src/unstract_cli/core/params.py +++ b/src/unstract_cli/core/params.py @@ -31,8 +31,8 @@ import click -from unstract_cli.core.errors import CLIError, ExitCode -from unstract_cli.core.overlay import overlay_for +from unstract_cli.core.errors import CLIError, ExitCode, warn +from unstract_cli.core.overlay import OVERLAY_FILE, load_overlay, overlay_for #: Spec file per product, vendored so flags derive with no network and no #: dependency on where the client happens to be installed from. @@ -383,6 +383,42 @@ def _click_type(param: Param) -> click.ParamType: return Diverged(param.type) +@cache +def check_overlay() -> tuple[str, ...]: + """Name every overlay entry the specs do not declare, once per run. + + An entry that matches nothing is inert rather than wrong-looking: the short + flag or the narrowed value list it was written for never reaches the command + line, and the file still parses. + """ + problems: list[str] = [] + for product, operations in load_overlay().items(): + try: + load_spec(product) + except KeyError: + problems.append(f"[{product}]: no spec is vendored for that product.") + continue + for operation_id, entries in operations.items(): + try: + declared = { + param.name for param in operation_params(product, operation_id) + } + except KeyError: + problems.append( + f"[{product}.{operation_id}]: the spec declares no such operation." + ) + continue + problems += [ + f"[{product}.{operation_id}.{name}]: the operation takes no such " + "parameter." + for name in entries + if name not in declared + ] + for problem in problems: + warn(f"warning: ignoring {OVERLAY_FILE} entry {problem}") + return tuple(problems) + + def derive_params( product: str, operation_id: str, @@ -397,6 +433,7 @@ def derive_params( caller gets by omitting the flag. A spec parameter the method does not name is dropped rather than offered and then rejected at the call. """ + check_overlay() spec_overlay = overlay_for(product, operation_id) hidden = {name for name, entry in spec_overlay.items() if entry.get("hidden")} accepted = client_params(client_method) if client_method is not None else None @@ -471,6 +508,7 @@ def requested(values: dict[str, Any]) -> dict[str, Any]: "Diverged", "SPEC_FILES", "Param", + "check_overlay", "click_option", "client_params", "derive_params", diff --git a/tests/derived_flags.json b/tests/derived_flags.json index 2c6277c..1daff8b 100644 --- a/tests/derived_flags.json +++ b/tests/derived_flags.json @@ -8,7 +8,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--allow-rotated-text": { "name": "allow_rotated_text", @@ -18,7 +19,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--checkbox-confidence-threshold": { "name": "checkbox_confidence_threshold", @@ -28,7 +30,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--derotate-threshold": { "name": "derotate_threshold", @@ -38,7 +41,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--file-name": { "name": "file_name", @@ -48,7 +52,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--gaussian-blur-radius": { "name": "gaussian_blur_radius", @@ -58,7 +63,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--horizontal-stretch-factor": { "name": "horizontal_stretch_factor", @@ -68,7 +74,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--ignore-vertical-text": { "name": "ignore_vertical_text", @@ -78,7 +85,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--include-line-confidence": { "name": "include_line_confidence", @@ -88,7 +96,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--lang": { "name": "lang", @@ -98,7 +107,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--line-splitter-strategy": { "name": "line_splitter_strategy", @@ -112,7 +122,8 @@ "left-priority", "mid-priority", "right-priority" - ] + ], + "short": null }, "--line-splitter-tolerance": { "name": "line_splitter_tolerance", @@ -122,7 +133,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--mark-horizontal-lines": { "name": "mark_horizontal_lines", @@ -132,7 +144,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--mark-vertical-lines": { "name": "mark_vertical_lines", @@ -142,7 +155,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--median-filter-size": { "name": "median_filter_size", @@ -152,7 +166,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--min-table-width": { "name": "min_table_width", @@ -162,7 +177,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--mode": { "name": "mode", @@ -180,7 +196,8 @@ "low_cost", "native_text", "table" - ] + ], + "short": null }, "--output-mode": { "name": "output_mode", @@ -195,7 +212,8 @@ "layout_preserving", "line-printer", "text" - ] + ], + "short": null }, "--page-separator": { "name": "page_separator", @@ -205,7 +223,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--pages-to-extract": { "name": "pages_to_extract", @@ -215,7 +234,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--tag": { "name": "tag", @@ -225,7 +245,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--url": { "name": "url", @@ -235,7 +256,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--use-webhook": { "name": "use_webhook", @@ -245,7 +267,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--watermark-angle-threshold": { "name": "watermark_angle_threshold", @@ -255,7 +278,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--webhook-metadata": { "name": "webhook_metadata", @@ -265,7 +289,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--word-confidence-threshold": { "name": "word_confidence_threshold", @@ -275,7 +300,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null } }, "llmwhisperer:highlights": { @@ -287,7 +313,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--lines": { "name": "lines", @@ -297,7 +324,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--whisper-hash": { "name": "whisper_hash", @@ -307,7 +335,8 @@ "array": false, "nullable": false, "required": true, - "choices": [] + "choices": [], + "short": null } }, "docstudio:execute": { @@ -319,7 +348,8 @@ "array": false, "nullable": true, "required": false, - "choices": [] + "choices": [], + "short": null }, "--hitl-packet-id": { "name": "hitl_packet_id", @@ -329,7 +359,8 @@ "array": false, "nullable": true, "required": false, - "choices": [] + "choices": [], + "short": null }, "--hitl-queue-name": { "name": "hitl_queue_name", @@ -339,7 +370,8 @@ "array": false, "nullable": true, "required": false, - "choices": [] + "choices": [], + "short": null }, "--include-extracted-text": { "name": "include_extracted_text", @@ -349,7 +381,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--include-metadata": { "name": "include_metadata", @@ -359,7 +392,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--include-metrics": { "name": "include_metrics", @@ -369,7 +403,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--llm-profile-id": { "name": "llm_profile_id", @@ -379,7 +414,8 @@ "array": false, "nullable": true, "required": false, - "choices": [] + "choices": [], + "short": null }, "--presigned-urls": { "name": "presigned_urls", @@ -389,7 +425,8 @@ "array": true, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--tags": { "name": "tags", @@ -399,7 +436,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--timeout": { "name": "timeout", @@ -409,7 +447,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--use-file-history": { "name": "use_file_history", @@ -419,7 +458,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null } }, "docstudio:status": { @@ -431,7 +471,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--include-metadata": { "name": "include_metadata", @@ -441,7 +482,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null }, "--include-metrics": { "name": "include_metrics", @@ -451,7 +493,8 @@ "array": false, "nullable": false, "required": false, - "choices": [] + "choices": [], + "short": null } } } diff --git a/tests/test_contract.py b/tests/test_contract.py index 288bba3..00e5341 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -20,6 +20,7 @@ from unstract.api_deployments.client import APIDeploymentsClient from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 +from unstract_cli.core.overlay import overlay_for from unstract_cli.core.params import derive_params, find_operation, operation_params #: (product, operationId, client method) per command that derives its flags, @@ -110,8 +111,17 @@ def _derived_flags() -> dict[str, dict[str, Any]]: return { f"{product}:{operation}": { # Choices as a list: JSON has no tuple, and the snapshot is compared - # against what a JSON reader gives back. - param.flag: {**asdict(param), "choices": list(param.choices)} + # against what a JSON reader gives back. Resolved through the overlay + # rather than straight off the spec, so a narrowing or a short flag + # that stops applying moves the snapshot too. + param.flag: { + **asdict(param), + "choices": list( + overlay_for(product, operation).get(param.name, {}).get("choices", ()) + ) + or list(param.choices), + "short": overlay_for(product, operation).get(param.name, {}).get("short"), + } for param in sorted( derive_params(product, operation, client_method=method), key=lambda param: param.flag, diff --git a/tests/test_params.py b/tests/test_params.py index 012f027..20e9e36 100644 --- a/tests/test_params.py +++ b/tests/test_params.py @@ -16,6 +16,7 @@ from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.params import ( Param, + check_overlay, click_option, derive_params, docstring_params, @@ -220,6 +221,33 @@ def test_choices_come_from_the_spec_unless_the_overlay_narrows_them(): assert option.type.choices == ("form", "table") +def test_an_overlay_entry_the_specs_do_not_declare_is_named(monkeypatch, warnings_seen): + """An entry that matches nothing applies nothing, and the file still parses.""" + monkeypatch.setattr( + params_module, + "load_overlay", + lambda: { + "nosuchproduct": {"extract": {"mode": {"short": "-m"}}}, + "llmwhisperer": { + "nosuchoperation": {"mode": {"short": "-m"}}, + "extract": {"nosuchparam": {"short": "-n"}, "mode": {"short": "-m"}}, + }, + }, + ) + check_overlay.cache_clear() + try: + problems = check_overlay() + finally: + check_overlay.cache_clear() + + assert [p.split(":")[0] for p in problems] == [ + "[nosuchproduct]", + "[llmwhisperer.nosuchoperation]", + "[llmwhisperer.extract.nosuchparam]", + ] + assert len(warnings_seen) == 3 + + def test_an_array_becomes_a_repeatable_option(): option = click_option(Param("presigned_urls", "string", array=True), {}) assert option.multiple is True From 16f0799bf9b87d2747d5977f2108a9676949668f Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:30:40 +0530 Subject: [PATCH 80/86] cover the failure paths that had no test Timeout translation and the clause order that decides where a connect timeout lands, the clone command's Platform API, start-up and abort translations, `whisper webhook update`/`delete` with the token they carry, and the entry point's closed-pipe and OSError arms. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- tests/test_cli.py | 26 +++++++++ tests/test_clients.py | 20 ++++++- tests/test_commands.py | 129 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index d1a0c90..d689277 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -59,6 +59,32 @@ def interrupted(): assert payload["error"]["code"] == "interrupted" +def test_a_reader_that_went_away_does_not_raise_on_the_way_out(capsys, monkeypatch): + """`... | head` closes the pipe mid-write; Python flushes stdout again at exit.""" + + def gone(): + raise BrokenPipeError + + monkeypatch.setattr("unstract_cli.commands.config_cmd.load_config", gone) + + assert main(["-o", "json", "config", "doctor"]) == int(ExitCode.GENERIC) + # Whatever stdout now points at, writing to it must not raise. + print("still writable") + + +def test_an_unwritable_stream_is_an_envelope_rather_than_a_traceback(capsys, monkeypatch): + def full_disk(): + raise OSError("No space left on device") + + monkeypatch.setattr("unstract_cli.commands.config_cmd.load_config", full_disk) + + code, payload, _ = run(capsys, "config", "doctor") + + assert code == int(ExitCode.GENERIC) + assert payload["error"]["message"] == "No space left on device" + assert "disk" in payload["error"]["hint"] + + def test_unknown_config_target_exits_two(capsys): code, payload, _ = run(capsys, "config", "get", "nosuchproduct", "base_url") assert code == int(ExitCode.USAGE) diff --git a/tests/test_clients.py b/tests/test_clients.py index 6d46301..26fe790 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -3,7 +3,12 @@ from __future__ import annotations import pytest -from requests.exceptions import ConnectionError, TooManyRedirects +from requests.exceptions import ( + ConnectionError, + ConnectTimeout, + ReadTimeout, + TooManyRedirects, +) from unstract.llmwhisperer.client_v2 import LLMWhispererClientException from unstract_cli.config import ResolvedConfig, load_config @@ -37,6 +42,19 @@ def test_a_transport_failure_is_not_reported_as_a_local_disk_problem(): assert "disk" not in (err.hint or "") +def test_a_request_that_timed_out_in_transit_says_the_job_may_still_run(): + err = _translate(ReadTimeout("read timed out")) + assert err.exit_code is ExitCode.TIMEOUT + assert err.retryable is True + assert "still be running" in (err.hint or "") + + +def test_a_connect_timeout_is_a_timeout_rather_than_a_connection_failure(): + """`ConnectTimeout` is both, so which arm catches it is decided by their order.""" + err = _translate(ConnectTimeout("connect timed out")) + assert err.exit_code is ExitCode.TIMEOUT + + def test_an_unreachable_service_is_retryable(): err = _translate(ConnectionError("connection refused")) assert err.exit_code is ExitCode.SERVER_ERROR diff --git a/tests/test_commands.py b/tests/test_commands.py index 7cc14b3..c8aae4b 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -15,6 +15,7 @@ import httpx import pytest from requests.exceptions import ConnectionError, InvalidHeader, MissingSchema +from unstract.clone.exceptions import CloneError, PlatformAPIError from unstract.clone.report import CloneReport, Endpoint, PhaseResult from unstract.llmwhisperer import client_v2 from unstract.llmwhisperer.client_v2 import ( @@ -1480,6 +1481,61 @@ def test_a_webhook_token_is_not_printed_back(capsys, whisper_client): assert token not in out +def test_a_webhook_can_be_updated_and_removed(capsys, whisper_client): + """The token reaches the client and never the output, on both commands.""" + token = "wh-token-abcdefghijk" + client = whisper_client( + update_webhook_details={"message": "updated"}, + delete_webhook={"message": "deleted"}, + ) + + code, out, err = run( + capsys, + "whisper", + "webhook", + "update", + "hook1", + "--url", + "https://example.com/hook", + "--auth-token", + token, + ) + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["data"]["message"] == "updated" + assert client.calls[0][1] == ("hook1", "https://example.com/hook", token) + assert token not in out and token not in err + + code, out, _ = run(capsys, "whisper", "webhook", "delete", "hook1") + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["data"]["message"] == "deleted" + assert client.calls[-1][1] == ("hook1",) + + +def test_a_token_quoted_back_while_updating_a_webhook_is_scrubbed(capsys, whisper_client): + """`remember_secret` runs before the call, so a failure quoting it is covered.""" + token = "wh-token-abcdefghijk" + whisper_client( + update_webhook_details=LLMWhispererClientException( + f"rejected token {token}", status_code=400 + ) + ) + + code, out, err = run( + capsys, + "whisper", + "webhook", + "update", + "hook1", + "--url", + "https://example.com/hook", + "--auth-token", + token, + ) + + assert code == int(ExitCode.VALIDATION) + assert token not in out and token not in err + + def test_a_rate_limited_call_exits_six(capsys, whisper_client): whisper_client( get_usage_info=LLMWhispererClientException("slow down", status_code=429) @@ -1626,6 +1682,79 @@ def fail(*_args, **_kwargs): assert envelope(out)["error"]["retryable"] is False +CLONE_ARGS = ( + "clone", + "--source-url", + "https://dev.example.com", + "--source-org", + "a", + "--target-url", + "https://prod.example.com", + "--target-org", + "b", +) + + +@pytest.fixture +def clone_raising(monkeypatch): + """Run `clone` against an orchestrator that fails the way the test names.""" + + def install(exc): + def fail(*_args, **_kwargs): + raise exc + + monkeypatch.setattr(clone_cmd, "run_clone", fail) + monkeypatch.setenv("UNSTRACT_SRC_PLATFORM_KEY", "src-key-0123456789") + monkeypatch.setenv("UNSTRACT_TGT_PLATFORM_KEY", "tgt-key-0123456789") + + return install + + +def test_a_platform_api_status_decides_the_clone_exit_code(capsys, clone_raising): + clone_raising(PlatformAPIError("forbidden", status_code=403, body="no access")) + code, out, _ = run(capsys, *CLONE_ARGS) + + assert code == int(ExitCode.AUTH) + assert "no access" in json.dumps(envelope(out)["error"]["details"]) + + +def test_a_platform_api_that_never_answered_is_retryable(capsys, clone_raising): + """No status means no response, which is the case retrying can still fix.""" + clone_raising(PlatformAPIError("connection reset")) + code, out, _ = run(capsys, *CLONE_ARGS) + + assert code == int(ExitCode.SERVER_ERROR) + assert envelope(out)["error"]["retryable"] is True + + +def test_a_clone_that_could_not_start_is_a_usage_error(capsys, clone_raising): + clone_raising(CloneError("source and target are the same organization")) + code, out, _ = run(capsys, *CLONE_ARGS) + + assert code == int(ExitCode.USAGE) + assert "same organization" in envelope(out)["error"]["message"] + + +def test_an_aborted_clone_reports_why_it_stopped(capsys, monkeypatch): + def fake_clone(source, target, options): + return CloneReport( + source=Endpoint(source.base_url, source.organization_id), + target=Endpoint(target.base_url, target.organization_id), + phases=[PhaseResult(name="adapters", created=1)], + aborted=True, + abort_reason="a name already exists on the target", + ) + + monkeypatch.setattr(clone_cmd, "run_clone", fake_clone) + monkeypatch.setenv("UNSTRACT_SRC_PLATFORM_KEY", "src-key-0123456789") + monkeypatch.setenv("UNSTRACT_TGT_PLATFORM_KEY", "tgt-key-0123456789") + + code, out, _ = run(capsys, *CLONE_ARGS) + + assert code == int(ExitCode.GENERIC) + assert "already exists on the target" in envelope(out)["error"]["message"] + + def test_the_clone_size_grammar_matches_the_client_it_mirrors(): """Both spellings of this command have to accept the same strings, and the table is copied rather than imported, so nothing else notices a drift.""" From 3dbdca028c04dea56b8b6f8bd72f39985d94f709 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 13:31:05 +0530 Subject: [PATCH 81/86] drop the header redaction nothing calls No caller builds or prints a header map, so this redacted nothing while reading as if headers were covered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/core/errors.py | 21 ++------------------- tests/test_errors.py | 14 -------------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index ec3c691..4b02b0f 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -99,11 +99,8 @@ def is_retryable(status: int) -> bool: # Redaction # --------------------------------------------------------------------------- # -_SECRET_HEADERS = {"unstract-key", "authorization", "apikey"} -_SECRET_HEADER_PREFIXES = ("x-",) -#: Words that mark a field or header as carrying a credential. `names_a_secret` -#: matches these as whole name segments; `redact_headers` matches them as -#: substrings, since a header name is a flatter namespace than a payload's. +#: Words that mark a field as carrying a credential, matched as whole name +#: segments rather than as substrings. _SECRET_KEY_HINTS = frozenset( { "bearer", @@ -215,19 +212,6 @@ def known_secrets() -> list[str]: return sorted(_KNOWN_SECRETS, key=len, reverse=True) -def redact_headers(headers: dict[str, Any]) -> dict[str, Any]: - """Redact credential-bearing headers.""" - out: dict[str, Any] = {} - for key, value in headers.items(): - low = key.lower() - secret = low in _SECRET_HEADERS or ( - low.startswith(_SECRET_HEADER_PREFIXES) - and any(hint in low for hint in _SECRET_KEY_HINTS) - ) - out[key] = REDACTED if secret else value - return out - - #: Splits a field name into words on punctuation and on camelCase boundaries. #: Case has to be read before it is folded away, or `accessToken` collapses to a #: single unrecognisable word. @@ -448,7 +432,6 @@ def hint_for(status: int) -> str | None: "exit_code_for_status", "hint_for", "is_retryable", - "redact_headers", "redact_value", "scrub", "undeclared_status_error", diff --git a/tests/test_errors.py b/tests/test_errors.py index f5dcc1b..a52d343 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -19,7 +19,6 @@ hint_for, is_retryable, known_secrets, - redact_headers, redact_value, remember_secret, scrub, @@ -94,19 +93,6 @@ def test_undeclared_status_is_reported_verbatim_never_guessed(): assert err.to_dict()["details"] == {"detail": "teapot"} -def test_redact_headers(): - out = redact_headers( - { - "unstract-key": "abc", - "Authorization": "Bearer x", - "X-Api-Key": "y", - "Content-Type": "application/json", - } - ) - assert out["unstract-key"] == out["Authorization"] == out["X-Api-Key"] == REDACTED - assert out["Content-Type"] == "application/json" - - def test_redact_value_walks_nested_payloads(): out = redact_value({"a": {"api_key": "secret", "n": 1}, "b": [{"token": "t"}]}) assert out == {"a": {"api_key": REDACTED, "n": 1}, "b": [{"token": REDACTED}]} From cf38f3d35c80498fe10e70e1ab69edaca08a99ce Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 15:33:17 +0530 Subject: [PATCH 82/86] fix: give the deployment client a socket timeout by default The deployment client sets none of its own, so a stalled connection was waited on forever unless the flag was passed. Default to the 120s the LLMWhisperer client applies; `--transport-timeout 0` keeps the old behaviour for a caller who wants it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/app.py | 15 ++++++++------- src/unstract_cli/core/clients.py | 11 ++++++++++- src/unstract_cli/core/errors.py | 5 +++-- tests/test_clients.py | 7 +++++++ tests/test_commands.py | 11 ++++++++--- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 2972c7e..c4a9d77 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -21,6 +21,7 @@ load_config, set_config_path, ) +from unstract_cli.core.clients import DEFAULT_TRANSPORT_TIMEOUT from unstract_cli.core.discover import TIERS, discover from unstract_cli.core.errors import CLIError, ExitCode, set_warning_sink from unstract_cli.core.output import ( @@ -41,7 +42,7 @@ class Context: verbosity: int = 0 profile: str | None = None #: Socket timeout for the deployment client, which has none of its own. - transport_timeout: float | None = None + transport_timeout: float | None = DEFAULT_TRANSPORT_TIMEOUT #: Command-line overrides, keyed `product.setting` -- the top tier of #: flag > env > profile > default. overrides: dict[str, Any] = field(default_factory=dict) @@ -234,17 +235,17 @@ def whisper_group(ctx: Context, **overrides: str | None) -> None: @_connection_options(org_id=True) @click.option( "--transport-timeout", - type=float, - default=None, - help="Seconds before a stalled connection is given up on. Unset means it " - "is not, which is what the client has always done.", + type=click.FloatRange(min=0), + default=DEFAULT_TRANSPORT_TIMEOUT, + show_default=True, + help="Seconds before a stalled connection is given up on. 0 waits forever.", ) @pass_context def docstudio_group( - ctx: Context, transport_timeout: float | None, **overrides: str | None + ctx: Context, transport_timeout: float, **overrides: str | None ) -> None: """Run Document Studio API deployments.""" - ctx.transport_timeout = transport_timeout + ctx.transport_timeout = transport_timeout or None ctx.override(DOCSTUDIO, overrides) diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py index cd7d1ea..ac2a4c8 100644 --- a/src/unstract_cli/core/clients.py +++ b/src/unstract_cli/core/clients.py @@ -63,8 +63,16 @@ def deployment_url(base_url: str, org_id: str, api_name: str) -> str: return base_url.rstrip("/") + path +#: Socket timeout for the deployment client, which sets none of its own. The +#: same figure the LLMWhisperer client applies, so a stalled connection is given +#: up on the same way on both paths. +DEFAULT_TRANSPORT_TIMEOUT = 120.0 + + def deployment( - config: ResolvedConfig, target: str, transport_timeout: float | None = None + config: ResolvedConfig, + target: str, + transport_timeout: float | None = DEFAULT_TRANSPORT_TIMEOUT, ) -> APIDeploymentsClient: """Build a deployment client for an alias, or for a bare API name. @@ -336,6 +344,7 @@ def raise_for_result(result: dict[str, Any], endpoint: str | None = None) -> Non __all__ = [ + "DEFAULT_TRANSPORT_TIMEOUT", "UNSENDABLE", "deployment", "deployment_url", diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index 4b02b0f..9a572b1 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -319,8 +319,9 @@ def to_dict(self) -> dict[str, Any]: "exit_code": int(self.exit_code), "retryable": self.retryable, "http_status": self.http_status, - # Structural, not opt-in: the details come from a server body that - # can echo the request, headers and key included. + # Redacted by default: the details come from a server body that can + # echo the request, headers and key included. Only a rescued result + # that would be destroyed by it opts out. "details": self.details if self.verbatim_details else redact_value(self.details), diff --git a/tests/test_clients.py b/tests/test_clients.py index 26fe790..f0c8519 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -127,6 +127,13 @@ def test_an_alias_is_built_from_its_own_organisation_and_key(tmp_path): assert client.api_key == "alias-key" +def test_a_deployment_client_is_built_with_a_socket_timeout_by_default(tmp_path): + """The client sets none of its own, so without this a stalled connection + is waited on forever.""" + client = deployment(_config(tmp_path), "some-api") + assert client.transport_timeout == 120.0 + + def test_a_bare_api_name_falls_back_to_the_profile(tmp_path): client = deployment(_config(tmp_path), "some-api") assert client.api_url.endswith("/org_profile/some-api/") diff --git a/tests/test_commands.py b/tests/test_commands.py index c8aae4b..df71c88 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -589,13 +589,18 @@ def test_a_run_naming_no_documents_at_all_is_refused(capsys, deployment_client): @pytest.mark.parametrize( - ("flag", "expected"), [([], None), (["--transport-timeout", "12.5"], 12.5)] + ("flag", "expected"), + [ + ([], 120.0), + (["--transport-timeout", "12.5"], 12.5), + (["--transport-timeout", "0"], None), + ], ) def test_the_transport_timeout_flag_reaches_the_client( capsys, deployment_client, tmp_path, flag, expected ): - """Unset means a stalled connection is never given up on, which is what - the client has always done.""" + """Unset means the default, and only zero means a stalled connection is + never given up on.""" doc = tmp_path / "doc.pdf" doc.write_bytes(b"%PDF-") client = deployment_client( From c7d22a70f16318168f3dfe7c4430f54ba2e57738 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 15:34:19 +0530 Subject: [PATCH 83/86] ci: authenticate the release push the way the other repos do `create-github-app-token@v3` takes the App's client id, and the org's existing variables are named for it, so this repo can share them rather than needing an App of its own. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3420982..615ae7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,9 +37,9 @@ jobs: steps: - name: Generate GitHub App Token id: generate-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: - app-id: ${{ vars.PUSH_TO_MAIN_APP_ID }} + client-id: ${{ vars.PUSH_TO_MAIN_APP_CLIENT_ID }} private-key: ${{ secrets.PUSH_TO_MAIN_APP_PRIVATE_KEY }} owner: Zipstack repositories: | From 5bad0b94e3e6bf48a1e5adacdb4c50ca50093c9b Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 17:46:47 +0530 Subject: [PATCH 84/86] docs: say what the vendored-spec hash does and does not catch The hash and the file move in the same commit, so the check cannot tell a deliberate edit from a refresh. It catches a copy that was corrupted or half-updated, and a provenance entry left behind by its file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- src/unstract_cli/specs/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/unstract_cli/specs/README.md b/src/unstract_cli/specs/README.md index 6831d27..a0b7b75 100644 --- a/src/unstract_cli/specs/README.md +++ b/src/unstract_cli/specs/README.md @@ -10,8 +10,11 @@ by hand: | `llmwhisperer.json` | `specs/llmwhisperer.json` in the LLMWhisperer service repo, generated by `tools/gen_spec.py` | | `docstudio.json` | `specs/docstudio-oss.json` in the backend, generated by `manage.py generate_docstudio_spec` | -`provenance.json` pins the commit each copy was taken from and its sha256, and -`tests/test_specs.py` fails if a vendored file stops matching its pin. +`provenance.json` records the commit each copy was taken from and its sha256, +and `tests/test_specs.py` fails if a vendored file stops matching that hash. The +hash moves in the same commit as the file, so it does not catch a deliberate +edit; it catches a copy that was corrupted or half-updated, and a provenance +entry that was not updated with its file. Refresh one by copying it byte-for-byte from the commit the client pinned in `pyproject.toml` was generated from, then updating `provenance.json` to match. From c8233e4147d2a1512d61057ba897ffa9de70c6b3 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 17:47:26 +0530 Subject: [PATCH 85/86] test: tie each vendored spec to the client pin it was synced for provenance.json now records the exact pin each spec was copied for, and tests/test_specs.py compares it with the pin in pyproject.toml. A client bumped without its spec re-synced fails locally, with no network, instead of deriving flags the released client cannot carry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- .claude/skills/bump-client-pins/SKILL.md | 24 ++++++++++++++---------- src/unstract_cli/specs/README.md | 6 ++++-- src/unstract_cli/specs/provenance.json | 2 ++ tests/test_specs.py | 15 +++++++++++++++ 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/.claude/skills/bump-client-pins/SKILL.md b/.claude/skills/bump-client-pins/SKILL.md index 97a32a6..b0fd9a6 100644 --- a/.claude/skills/bump-client-pins/SKILL.md +++ b/.claude/skills/bump-client-pins/SKILL.md @@ -16,7 +16,7 @@ Move one without the others and the tests say so — which is the point of them. |---|---| | Exact pins | `pyproject.toml`, `[project].dependencies` | | Vendored specs | `src/unstract_cli/specs/{docstudio,llmwhisperer}.json` | -| Provenance | `src/unstract_cli/specs/provenance.json` (upstream repo, commit, sha256) | +| Provenance | `src/unstract_cli/specs/provenance.json` (client pin, upstream repo, commit, sha256) | | Coherence tests | `tests/test_specs.py`, `tests/test_contract.py`, `tests/derived_flags.json` | | Release | `.github/workflows/release.yml`, `workflow_dispatch` | @@ -41,18 +41,22 @@ if any of the below is unclear. commit — is what `tests/test_contract.py` guards: a spec parameter the pinned client has no argument for cannot become a flag. -4. **Update `provenance.json`** for each spec you moved. Check all four fields - against what that client's `tools/gen_sdk.sh` records — `repo` and `path` as - well as `commit` — because an upstream that moved its spec file leaves those - two stale and the tests cannot see it: they check the `sha256` and the entry - names, nothing about where the file came from. The `sha256` is of the file - you just wrote (`sha256sum src/unstract_cli/specs/`). This record is - what lets the next person tell a current copy from a stale one. +4. **Update `provenance.json`** for each spec you moved. Set `client` to the + exact pin you wrote in `pyproject.toml` (`unstract-client==X.Y.Z`); the + tests compare the two, so a pin moved without its spec fails here. Check + `repo`, `path` and `commit` against what that client's `tools/gen_sdk.sh` + records, because an upstream that moved its spec file leaves `repo` and + `path` stale and the tests cannot see it: they check the pin, the `sha256` + and the entry names, nothing about where the file came from. The `sha256` + is of the file you just wrote (`sha256sum src/unstract_cli/specs/`). + This record is what lets the next person tell a current copy from a stale + one. 5. **Run the tests:** `uv run pytest -q`. - - `test_specs.py` fails if a vendored file stops matching its pinned sha256, - or if a spec has no provenance entry. It is the cheap check that steps 3 and + - `test_specs.py` fails if a vendored file stops matching its recorded + sha256, if its `client` no longer equals the pin in `pyproject.toml`, or if + a spec has no provenance entry. It is the cheap check that steps 1, 3 and 4 actually agree. - `test_contract.py` fails if a spec parameter the pinned client cannot accept would have become a flag, and separately if the derived flags stop matching diff --git a/src/unstract_cli/specs/README.md b/src/unstract_cli/specs/README.md index a0b7b75..5e7efc5 100644 --- a/src/unstract_cli/specs/README.md +++ b/src/unstract_cli/specs/README.md @@ -10,8 +10,10 @@ by hand: | `llmwhisperer.json` | `specs/llmwhisperer.json` in the LLMWhisperer service repo, generated by `tools/gen_spec.py` | | `docstudio.json` | `specs/docstudio-oss.json` in the backend, generated by `manage.py generate_docstudio_spec` | -`provenance.json` records the commit each copy was taken from and its sha256, -and `tests/test_specs.py` fails if a vendored file stops matching that hash. The +`provenance.json` records the client pin each copy was synced for, the commit +it was taken from and its sha256. `tests/test_specs.py` fails if a vendored +file stops matching that hash, or if the pin in `pyproject.toml` has moved +without the spec being re-synced. The hash moves in the same commit as the file, so it does not catch a deliberate edit; it catches a copy that was corrupted or half-updated, and a provenance entry that was not updated with its file. diff --git a/src/unstract_cli/specs/provenance.json b/src/unstract_cli/specs/provenance.json index 22719d3..8428ced 100644 --- a/src/unstract_cli/specs/provenance.json +++ b/src/unstract_cli/specs/provenance.json @@ -1,11 +1,13 @@ { "docstudio.json": { + "client": "unstract-client==1.6.0", "repo": "https://github.com/Zipstack/unstract", "commit": "0c5f36dabf497220f82917a5f4f92f2cd396b5a5", "path": "specs/docstudio-oss.json", "sha256": "e453d4f7444d3757a24a1da73373b11c3d362ceb2d7e13e8658a5b3c068b86f5" }, "llmwhisperer.json": { + "client": "llmwhisperer-client==2.9.0", "repo": "https://github.com/Zipstack/unstract-llm-whisperer", "commit": "750f941ee229e12cc05d8bd85edaab6a337a8758", "path": "specs/llmwhisperer.json", diff --git a/tests/test_specs.py b/tests/test_specs.py index 1537b42..7e1699b 100644 --- a/tests/test_specs.py +++ b/tests/test_specs.py @@ -8,7 +8,9 @@ import hashlib import json +import tomllib from importlib import resources +from pathlib import Path import pytest @@ -17,6 +19,7 @@ PROVENANCE = json.loads( (resources.files("unstract_cli.specs") / "provenance.json").read_text("utf-8") ) +PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" @pytest.mark.parametrize("filename", sorted(SPEC_FILES.values())) @@ -27,3 +30,15 @@ def test_each_vendored_spec_is_the_pinned_one(filename): def test_every_vendored_spec_has_a_provenance_entry(): assert set(PROVENANCE) == set(SPEC_FILES.values()) + + +@pytest.mark.parametrize("filename", sorted(SPEC_FILES.values())) +def test_each_vendored_spec_names_the_client_pin_it_was_synced_for(filename): + pins = tomllib.loads(PYPROJECT.read_text("utf-8"))["project"]["dependencies"] + recorded = PROVENANCE[filename]["client"] + name = recorded.split("==")[0] + (pinned,) = (pin for pin in pins if pin.split("==")[0] == name) + assert recorded == pinned, ( + f"{filename} was synced for {recorded} but pyproject.toml pins {pinned}: " + "re-sync the spec and update provenance.json with it" + ) From 1ac518e700710ad1562b7941935cae7c4efef464 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Fri, 11 Sep 2026 18:23:46 +0530 Subject: [PATCH 86/86] fix: a completed run with a failed document exits VALIDATION The service reports a batch as COMPLETED even when a document inside it failed, with the failure carried per file in extraction_result. The CLI read only the execution status, so a caller branching on the exit code was told the batch succeeded with a document's output missing. Both `deployment run` and `deployment status` now walk the per-file results and fail with the failed files named; the full payload is kept verbatim in error.details, since the status read is one-shot and the successful documents survive nowhere else, and --save still writes it before the error is raised. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ --- README.md | 2 +- src/unstract_cli/commands/docstudio_cmd.py | 53 ++++++- tests/test_commands.py | 153 +++++++++++++++++++++ 3 files changed, 206 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c27ad36..0171492 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ have to copy it: | 2 | usage error | | 3 | authentication failed | | 4 | not found | -| 5 | validation failed | +| 5 | validation failed — also a completed run in which a document failed; the full result, successful documents included, is in `error.details` | | 6 | rate limited | | 7 | timed out (the job handle is in the error payload — resume, do not resubmit) | | 8 | server error | diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py index 480698e..1db9196 100644 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ b/src/unstract_cli/commands/docstudio_cmd.py @@ -160,9 +160,57 @@ def run( f"{found}` rather than resubmitting the document." ) raise + handle = _handle_meta(started) + _raise_for_failed_files(result, endpoint=client.api_url, extra=handle) # A waited result names no execution, so the handle is returned as meta for # correlation. - finish(ctx, result, raw_fields=RUN_RAW, meta=_handle_meta(started)) + finish(ctx, result, raw_fields=RUN_RAW, meta=handle) + + +def _failed_files(result: dict[str, Any]) -> list[dict[str, Any]]: + """Per-file entries of a completed execution that produced no result.""" + entries = result.get("extraction_result") + if not isinstance(entries, list): + return [] + failed = [] + for entry in entries: + if not isinstance(entry, dict): + continue + status = entry.get("status") + if status is None: + if entry.get("error"): + failed.append(entry) + elif str(status).casefold() != "success": + failed.append(entry) + return failed + + +def _raise_for_failed_files( + result: dict[str, Any], *, endpoint: str, extra: dict[str, Any] +) -> None: + # A completed execution says the batch ran, not that every document in it + # came out: a failed file is reported inside the success shape. + failed = _failed_files(result) + if not failed: + return + named = "; ".join( + f"{entry.get('file') or '?'}: {entry.get('error') or entry.get('status')}" + for entry in failed + ) + raise CLIError( + f"{len(failed)} of {len(result['extraction_result'])} documents failed: {named}", + ExitCode.VALIDATION, + details=result, + endpoint=endpoint, + # The status read is one-shot, so the successful documents in this + # payload survive nowhere else. + verbatim_details=True, + hint=( + "`error.details` carries the full result, successful documents " + "included. Resubmit only the files named in `failed_files`." + ), + extra={**extra, "failed_files": [entry.get("file") for entry in failed]}, + ) def _handle_meta(started: dict[str, Any]) -> dict[str, Any]: @@ -236,6 +284,9 @@ def status( if save: written = persist(save, result) diagnostic(f"saved: {written}", quiet=ctx.quiet, verbosity=ctx.verbosity) + _raise_for_failed_files( + result, endpoint=client.api_url, extra={"execution_id": execution_id} + ) finish(ctx, result, raw_fields=STATUS_RAW) diff --git a/tests/test_commands.py b/tests/test_commands.py index df71c88..10a5a78 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1404,6 +1404,159 @@ def test_save_with_no_wait_is_a_usage_error(capsys, whisper_client, tmp_path): assert "retrieve" in envelope(out)["error"]["hint"] +#: The flattened shape the pinned client hands back for a finished batch: the +#: execution completed, one document inside it did not. +PARTIAL_FAILURE = { + "status_code": 200, + "pending": False, + "execution_status": "COMPLETED", + "error": "", + "extraction_result": [ + { + "file": "a.pdf", + "file_execution_id": "f1", + "status": "Success", + "result": {"total": 1}, + "error": None, + "metadata": {}, + }, + { + "file": "bad.pdf", + "file_execution_id": "f2", + "status": "Failed", + "result": None, + "error": "Structure tool failed: 415 not supported", + "metadata": {}, + }, + { + "file": "c.pdf", + "file_execution_id": "f3", + "status": "Success", + "result": {"total": 3}, + "error": None, + "metadata": {}, + }, + ], +} + + +def _partial_run(capsys, deployment_client, tmp_path, *extra): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + deployment_client( + structure_file={ + "status_code": 200, + "pending": True, + "execution_status": "PENDING", + "status_check_api_endpoint": "/status?execution_id=e1", + }, + check_execution_status=PARTIAL_FAILURE, + ) + return run( + capsys, + "-q", + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--interval", + "0.1", + *extra, + ) + + +def test_a_completed_run_with_a_failed_document_is_not_a_success( + capsys, deployment_client, tmp_path +): + """The batch status says the job ran, not that every document came out, so + the exit code has to read the per-file results.""" + code, out, _ = _partial_run(capsys, deployment_client, tmp_path) + + assert code == int(ExitCode.VALIDATION) + error = envelope(out)["error"] + assert error["failed_files"] == ["bad.pdf"] + assert error["execution_id"] == "e1" + assert "bad.pdf" in error["message"] + # One-shot read: the successful documents survive only here, unredacted. + assert error["details"] == PARTIAL_FAILURE + + +def test_a_run_whose_documents_all_succeeded_is_still_a_success( + capsys, deployment_client, tmp_path +): + doc = tmp_path / "doc.pdf" + doc.write_bytes(b"%PDF-") + deployment_client( + structure_file={ + "status_code": 200, + "pending": True, + "status_check_api_endpoint": "/status?execution_id=e1", + }, + check_execution_status={ + **PARTIAL_FAILURE, + "extraction_result": [ + {**PARTIAL_FAILURE["extraction_result"][0]}, + {**PARTIAL_FAILURE["extraction_result"][2], "status": "SUCCESS"}, + ], + }, + ) + + code, out, _ = run( + capsys, + "-q", + "docstudio", + "deployment", + "run", + "my-api", + str(doc), + "--interval", + "0.1", + ) + + assert code == int(ExitCode.SUCCESS) + assert envelope(out)["ok"] is True + + +def test_a_failed_document_is_saved_before_the_run_is_failed( + capsys, deployment_client, tmp_path +): + target = tmp_path / "result.json" + code, _, _ = _partial_run(capsys, deployment_client, tmp_path, "--save", str(target)) + + assert code == int(ExitCode.VALIDATION) + saved = json.loads(target.read_text()) + assert [e["file"] for e in saved["extraction_result"]] == [ + "a.pdf", + "bad.pdf", + "c.pdf", + ] + + +def test_a_status_read_with_a_failed_document_is_not_a_success( + capsys, deployment_client, tmp_path +): + target = tmp_path / "result.json" + deployment_client(check_execution_status=PARTIAL_FAILURE) + + code, out, _ = run( + capsys, + "docstudio", + "deployment", + "status", + "my-api", + "e-1", + "--save", + str(target), + ) + + assert code == int(ExitCode.VALIDATION) + error = envelope(out)["error"] + assert error["failed_files"] == ["bad.pdf"] + assert error["execution_id"] == "e-1" + assert target.exists() + + def test_deployment_status_can_save_the_result(capsys, deployment_client, tmp_path): """`deployment status` is the documented way to resume after a timeout, so it is where a result has to be savable."""