diff --git a/.claude/skills/bump-client-pins/SKILL.md b/.claude/skills/bump-client-pins/SKILL.md deleted file mode 100644 index b0fd9a6..0000000 --- a/.claude/skills/bump-client-pins/SKILL.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -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` (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` | - -`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. 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 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 - `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. 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. - -## 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. diff --git a/.claude/skills/update-unstract-cli/SKILL.md b/.claude/skills/update-unstract-cli/SKILL.md new file mode 100644 index 0000000..d65ad53 --- /dev/null +++ b/.claude/skills/update-unstract-cli/SKILL.md @@ -0,0 +1,261 @@ +--- +name: update-unstract-cli +description: Update the Unstract CLI's endpoint definitions by cross-referencing the public API documentation. Use when Unstract, LLMWhisperer, or API Hub APIs change, when the CLI is missing a documented endpoint or parameter, or to audit the CLI for drift against the docs. Triggers include "update the CLI", "sync CLI with docs", "check the CLI for API drift", and "add the new endpoint to the CLI". +--- + +# Update the Unstract CLI from the public API docs + +Keep `src/unstract_cli/endpoints/*.py` synchronized with the published API +documentation: detect drift, report it with citations, then apply it. + +This is tractable only because of one architectural property: **every command is +generated from a declarative `Endpoint` record.** The command tree, flags, help +text, validation and `--discover` all derive from those records, so a +correct edit to one record propagates everywhere. You never touch command wiring. + +## Products and API groups + +Unstract is the company and the CLI's name. It builds three products: + +| Product | CLI group | API groups it owns | +| --- | --- | --- | +| **Document Studio** | `docstudio` | `platform`, `deployment`, `hitl` | +| **LLMWhisperer** | `whisper` | `llmwhisperer` | +| **API Hub** | `apihub` | `apihub` | + +There are exactly three products -- never introduce a fourth. Document Studio's +three API groups have distinct hosts and credentials, so records set +`api=ApiGroup.PLATFORM` (etc.) and the product is derived from that, never +stored twice. Document Studio's wire paths use `platform`/`deployment`; the CLI +preserves them verbatim under the `docstudio` group. + +## Sources of truth + +| Product | Documentation source | CLI file | +| --- | --- | --- | +| LLMWhisperer | `llmwhisperer-docs/docs/llm_whisperer/apis/*.md` | `whisper.py` | +| API deployment runtime | `unstract-docs/docs/unstract_platform/api_deployment/*.md` | `deployment.py` | +| Platform v1 | `unstract-docs/docs/unstract_platform/api_documentation/versions/v1-*.mdx` | `platform.py` | +| HITL | `unstract-docs/docs/unstract_platform/human_quality_review/*.md` | `hitl.py` | +| API Hub | **No public docs** — `unstract-verticals/src/api_v1/api.py`, `verticals-portal/portal/postman-collection/*.json` | `apihub.py` | + +Repos are expected as siblings of `unstract-cli`. If one is missing locally, read +it through the GitHub API (`gh api repos/Zipstack//contents/`) rather +than guessing. + +Several records carry unusually detailed help text, a `doc_conflict` note, or a +seemingly redundant parameter *because* of a known server defect — the comment +above each explains which. Read it before "simplifying" one: the verbosity is +load-bearing, and the workaround should be removed only once the backend fix +lands, not because it looks like clutter. + +Each record's `doc_source` field names the file it was authored from — that is +your anchor for diffing. Most point at a `.mdx`/`.md` docs page. A number +legitimately point at **backend source** instead, because the endpoint has no +public docs page, and cite `backend/.../urls.py` exactly as API Hub records cite +`unstract-verticals` source: + +- Prompt Studio Output Manager + task-status — `output list`, `output latest`, + `task-status` (`backend/prompt_studio/...`). +- Workflow assembly — `tool registry {list,settings-schema}`, + `workflow tool {list,get,add,set-metadata,remove}`, + `workflow endpoint {list,set}` (`backend/tool_instance_v2/urls.py`, + `backend/workflow_manager/endpoint_v2/urls.py`). + +These surface in the diff as "in CLI, missing from docs" (info-only); that is +correct and expected — report, never delete. Do **not** reach for `doc_conflict` +to silence them: `doc_conflict` means "a docs page exists but the record +deliberately diverges from it," which is a different thing from "no docs page +exists." + +`doc_conflict` suppresses **both** axes of the diff for its record — the +endpoint's absence from the docs *and* any documented parameter the record does +not expose. That is deliberate: a record which deliberately drops a documented +flag would otherwise be reported as param drift on every run, inviting exactly +the "restoration" the annotation exists to prevent. The diff also recognises a +parameter that is *mirrored* rather than exposed: a body field supplied by +`mirror_as` from a path param is present on the wire, so it is not drift. + +## Procedure + +### 1. Establish scope + +If the user named a product or endpoint, work only on that. Otherwise audit +everything. State the scope before starting. + +### 2. Parse the documentation + +Extract `(method, path, params[])` from each source. Two formats: + +- **Markdown** (LLMWhisperer, API deployment, HITL): parameters live in tables + with `Parameter | Type | Default | Required | Description` columns. The endpoint + and method are in the summary table at the top of the page. +- **MDX** (Platform v1): parameters live in `` component props — + `method`, `path`, `description`, and arrays `pathParams`, `queryParams`, + `requestBody`, `responseBody`. This is JSX, not Markdown: parse the props, not + table cells. `` groups them. + +### 3. Parse the current records + +Read the relevant `endpoints/*.py`. `unstract --discover` gives the same +information as JSON and is often easier to diff against — but remember it reflects +the *installed* package, so re-install after editing if you use it. + +Filter it rather than reading the whole thing: unfiltered `--detail full` is +~50k tokens. + +```bash +unstract --discover --group whisper --detail full +unstract --discover --command 'whisper extract' --detail full +``` + +### 4. Diff on three axes + +1. **In docs, missing from CLI** → new capability to add. +2. **In CLI, missing from docs** → *report only, never delete.* Documentation lags + implementation constantly; an endpoint absent from the docs is far more often + an undocumented feature than a removed one. +3. **Parameter drift** → added, removed, renamed, or changed type / default / + enum / required-ness. + +### 5. Report before editing + +Present a table: each difference, its doc citation (file + heading), and the +proposed record change. Wait for confirmation on anything ambiguous. Never apply +a breaking change without flagging it as such. + +### 6. Apply + +Edit `endpoints/*.py` only. Follow the conventions already in those files: + +- `Param.name` is the **API's spelling**, used verbatim on the wire — including + typos like `page_seperator`. Rename the *flag* with `flag="--page-separator"`, + never the name. +- Choose the right `location` (`QUERY`/`BODY`/`PATH`/`HEADER`/`FORM`) and the + endpoint's `body` kind. +- Every `Param` needs `help`; every `Endpoint` needs `summary` and `doc_source`. +- Reuse the pattern encodings rather than inventing new ones: `MutuallyExclusive`, + `AtLeastOneOf`, `RequiredUnless`, `choices={friendly: wire}`, `multiple`, + `default_from`, `freeform_prefix`, `applies_when`, `replace_semantics`, + `derive_patch`, `with_params`, `mirror_to_body` / `mirror_as`, + `require_response_fields` (treat a 2xx that leaves a field null as a failure). +- **Mirroring a PATH id into the body.** `mirror_to_body=True` copies a path + parameter into the JSON body under the same name (`prompt create`'s `tool_id`, + which the backend otherwise persists as NULL). `mirror_as=""` does + the same but renames it, for a server that wants one identifier twice under two + spellings — `api-deployment key create` takes `api_id` in the URL and the same + value as `api` in the body, so mirroring means the caller passes only + `--api-id`. Prefer this over asking the user to repeat a value. +- **`RequiredUnless`** expresses "required, except in one configuration" — a + field that is mandatory in general but genuinely unread when some other flag + holds a sentinel value. It is exported and tested but **no shipped record uses + it today**: the case it was built for (`profile create --chunk-size 0`) turned + out to be server-enforced, so relaxing it client-side would only have moved a + fast exit-2 to a slow remote 400. Reach for it only when you have confirmed the + *server* accepts the omission. +- **`default_from` accepts a fallback chain**, whitespace-separated, first + resolved value winning: `default_from="deployment.org_id platform.org_id"`. + Use it where two config blocks genuinely hold the same value and one is + typically empty — never to borrow a credential across API groups, which would + be credential confusion rather than convenience. +- **JSON parameters parse themselves.** A `Param(type=ParamType.JSON, …)` value is + parsed to a real object before it is sent, accepting inline JSON *or* + `@path/to/file.json`. Never add manual `json.loads`; just set the type. +- **`--wait` retrieval is declarative.** A `PollSpec` drives execute → poll → + retrieve. When the result store is not keyed by the poll handle (e.g. Prompt + Studio's Output Manager is keyed by `tool_id`, not `task_id`), use + `retrieve_carry` to forward original-request identifiers (a py_name, or a + `(source, dest)` pair to rename one), `retrieve_extra` for a constant flag the + original call did not carry, and `retrieve_omits_handle` when the retrieve + endpoint has no handle parameter. +- **Match `status_field` to the STATUS endpoint's body, not the run response.** + An execute response and its status endpoint often spell the state differently + (deployment: the run POST nests `execution_status` under `message`; the status + GET returns a top-level `status`). The poll reads the *status* endpoint, so + `status_field` must match that — pass a tuple like `("status", "execution_status")` + to accept both. Getting this wrong silently consumes a one-shot result and 406s + on the next poll; verify the real status-GET payload shape, not just the run + response, and write a fixture that mirrors it. +- **One-shot poll = the poll *is* the retrieve.** When the status endpoint is + itself the single-read result store (deployment run), set `one_shot=True` and + **no** `retrieve_endpoint`: the terminal poll's body is the result, and a second + read would 406. Such commands need a client-side `--save` (a `client_side=True` + Param) so the result persists on that one read. +- **Never duplicate a PATCH record**: derive it from its PUT with `derive_patch`, + adding PATCH-only fields via `with_params`. +- Paths are **literal**. Do not normalise trailing slashes or "fix" + `profilemanager` vs `profile-manager` — those inconsistencies are real, and + correcting them produces 404s. A path with no trailing slash must set + `no_trailing_slash=True` so the omission reads as intent; a test enforces this. + +### 7. Verify + +```bash +uv run pytest # includes definition-integrity and flag-coverage tests +uv run ruff check . +uv run mypy src +unstract --discover | python -c "import json,sys; json.load(sys.stdin)" +``` + +Add a respx test for any new endpoint, using the sample payload from the docs. + +### 8. Summarize + +Report what changed, what needs human judgement, and what you deliberately +skipped. + +## Safety rules + +These exist because each one has a specific failure mode behind it. + +1. **Never delete a command just because the docs omit it.** Report it. Docs lag + implementation, and deleting a working command breaks users' scripts. +2. **Never invent a parameter.** Every addition cites a specific file and heading. + If the docs are ambiguous, say so and ask. +3. **Treat renames as breaking.** Add the new flag, keep the old one as a + deprecated alias, and say so in the report. Never rename silently. +4. **Preserve richer hand-written help.** Where a record's help text says more + than the docs, add to it — do not overwrite it with a thinner description. +5. **Honour `doc_conflict`.** A record carrying that field records a *deliberate* + divergence from the docs, verified against another source. Never revert one + without explicit human confirmation. Two live examples: + + - **A wrong docs page.** `whisper detail` uses `/whisper-detail` (singular). + The docs *index* says `/whisper-details` (plural), but the endpoint page and + the official `llm-whisperer-python-client` both use the singular. The index + is wrong. Do not "fix" it. + - **Deliberately dropped parameters.** `api-deployment key create` and + `pipeline key create` each omit two documented body params. The docs list + both `api` and `pipeline`; the record mirrors the one the route already + fixes (`api_id` → `api`) and drops the other, because + `/api/keys/api/{api_id}/` cannot create a pipeline key — that is the + sibling command. Restoring either flag re-introduces the original + complaint, which was having to pass the same id twice. + +6. **`draft: true` means exclude.** Docs pages with that front matter (currently + the multi-doc chat APIs `/md/file/upload`, `/md/file/search`, `/md/chat`) + describe unstable contracts. Do not add them. If the flag is removed upstream, + propose adding them as a new `chat` group and let a human decide. +7. **API Hub changes need human review.** There are no public docs for it; the + contract is inferred from source and Postman collections, which recover + parameter names but not intent. Report proposed changes; do not apply them + silently. Note that `--ext-param KEY=VALUE` already lets users reach new + `ext_*` parameters without a CLI release, so the pressure to guess is low. +8. **Docs are not infallible.** Where an index page and a detail page disagree, + prefer the detail page and the official client libraries. Record the decision + as a `doc_conflict` note so the next run does not re-litigate it. + +## Worked example + +*A new `--ocr-engine` parameter appears in the LLMWhisperer extraction docs.* + +1. Read `llmwhisperer-docs/docs/llm_whisperer/apis/whisper.md`; find the row in + the parameters table: `ocr_engine | string | tesseract | No | OCR engine to use`. +2. Confirm `whisper extract` in `endpoints/whisper.py` has no such `Param`. +3. Report: "`whisper.md` documents `ocr_engine` (string, default `tesseract`, + optional); `whisper extract` lacks it. Proposed: add to `_EXTRACT_PARAMS`." +4. Apply — one `Param` in `_EXTRACT_PARAMS`, with `help` from the description and + `choices` if the docs enumerate values. +5. Verify: `--discover` now lists `--ocr-engine` with its default. Tests and + type checks pass. No other file changed — the flag, help text and index all + came from that single record. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b74c39c..7cdf6d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,74 +1,38 @@ -name: ci +name: CI on: - pull_request: push: branches: [main] + pull_request: 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@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 - # `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 -- - # the floor exactly, not the newest release that satisfies it -- 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>=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 - # The wheel rather than the checkout: packaging and entry points are declared - # in `pyproject.toml` and are not exercised by running the tests in place. - package: - runs-on: ubuntu-latest - env: - VIRTUAL_ENV: /tmp/smoke - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v6 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - version: "0.6.14" - enable-cache: true - - run: uv build --out-dir dist - - run: uv venv --python 3.12 /tmp/smoke - - run: uv pip install dist/*.whl - - name: Both entry points answer - run: | - /tmp/smoke/bin/unstract --version - /tmp/smoke/bin/unstract-cli --version - /tmp/smoke/bin/unstract --help > /dev/null - - name: The published surfaces render from an installed wheel - run: | - /tmp/smoke/bin/unstract -o json --discover full > discover.json - python -c "import json; d = json.load(open('discover.json')); assert d['ok'] and d['data']['commands']" - # A usage error still renders the envelope and still exits 2. - set +e - /tmp/smoke/bin/unstract -o json whisper status > usage.json - code=$? - set -e - test "$code" = "2" - python -c "import json; d = json.load(open('usage.json')); assert d['ok'] is False and d['error']['exit_code'] == 2" - - name: A fresh install can write its own config - env: - UNSTRACT_CONFIG: /tmp/smoke-config.toml + python-version: "3.12" + + - name: Install dependencies + run: uv pip install --system -e ".[dev]" + + - name: Lint + run: ruff check . + + - name: Type check + run: mypy src + + - name: Test + run: pytest -q + + # The machine-readable index is the CLI's discovery contract, so a broken + # tree must fail the build rather than surface at runtime. Check the full + # detail level too: that is where flag introspection actually runs. + - name: Verify command index run: | - /tmp/smoke/bin/unstract config init < /dev/null - test -s /tmp/smoke-config.toml + unstract --discover | python -c "import json,sys; json.load(sys.stdin)" + unstract --discover --detail full | python -c "import json,sys; json.load(sys.stdin)" + unstract --discover --group whisper --detail full | python -c "import json,sys; json.load(sys.stdin)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index fb92574..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,226 +0,0 @@ -name: Release Tag and Publish Package - -on: - workflow_dispatch: - inputs: - version_bump: - description: "Version bump type." - required: true - default: "patch" - type: choice - options: - - patch - - minor - - major - pre_release: - description: "Publish a release candidate (`rcN`) instead of the version itself. Dispatch again with the same bump and this off to promote it to stable." - required: false - default: false - type: boolean - release_notes: - description: "Release notes (optional)" - required: false - type: string - -concurrency: - group: release - cancel-in-progress: false - -jobs: - release-and-publish: - runs-on: ubuntu-latest - # Dispatchable from any branch by anyone who can dispatch it, and a - # candidate reaches PyPI without a commit to review: the published artefact - # must come from the reviewed branch. - if: github.ref == 'refs/heads/main' - permissions: - contents: write - # Publishing is by PyPI Trusted Publisher, so there is no API token. - id-token: write - steps: - # Checked before the checkout, so a missing one fails on the reason - # rather than on a token that renders empty three steps later. - - name: Check the release credentials are visible to this repository - env: - APP_CLIENT_ID: ${{ vars.PUSH_TO_MAIN_APP_CLIENT_ID }} - APP_PRIVATE_KEY: ${{ secrets.PUSH_TO_MAIN_APP_PRIVATE_KEY }} - run: | - missing="" - [ -n "$APP_CLIENT_ID" ] || missing="$missing vars.PUSH_TO_MAIN_APP_CLIENT_ID" - [ -n "$APP_PRIVATE_KEY" ] || missing="$missing secrets.PUSH_TO_MAIN_APP_PRIVATE_KEY" - if [ -n "$missing" ]; then - echo "Missing:$missing" >&2 - echo "An organisation-level variable or secret also has to list this repository." >&2 - exit 1 - fi - - - name: Generate GitHub App Token - id: generate-token - uses: actions/create-github-app-token@v3 - with: - client-id: ${{ vars.PUSH_TO_MAIN_APP_CLIENT_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@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 sync --extra dev --python 3.12 - - # Staged locally only: nothing is committed, tagged or released until the - # checks and the build have 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 - 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 - - 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 one step that cannot be undone, so it runs last, and - # the release is created as a draft so a publish that fails leaves - # nothing public. Until then the bump commit exists only in the tag, so - # a failed publish leaves main untouched. - - name: Commit version bump and create draft release - env: - RELEASE_NOTES: ${{ github.event.inputs.release_notes }} - GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - run: | - NEW_VERSION="${{ steps.version.outputs.version }}" - - # A pre-release leaves the committed version alone. - 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]" - fi - - git tag "v$NEW_VERSION" - git push origin "v$NEW_VERSION" - - if [ -z "$RELEASE_NOTES" ]; then - gh release create "v$NEW_VERSION" \ - --title "Release v$NEW_VERSION" \ - --generate-notes \ - --draft \ - ${{ github.event.inputs.pre_release == 'true' && '--prerelease' || '' }} - else - gh release create "v$NEW_VERSION" \ - --title "Release v$NEW_VERSION" \ - --notes "$RELEASE_NOTES" \ - --generate-notes \ - --draft \ - ${{ github.event.inputs.pre_release == 'true' && '--prerelease' || '' }} - fi - - echo "Created draft release v$NEW_VERSION" - - - name: Publish to PyPI - run: uv publish - - # Everything the run created before the publish, so a rerun after the - # cause is fixed starts clean rather than on the tag guard. Each removal - # tolerates its target being absent: the failure may have come partway - # through creating them. - - name: Remove the tag and draft release a failed publish leaves behind - if: failure() && steps.version.outcome == 'success' - env: - GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - run: | - NEW_VERSION="${{ steps.version.outputs.version }}" - # The wheel and the sdist upload separately, so a failed publish can - # still have put the version on PyPI; then the tag has to stay. - if curl -fsS -o /dev/null "https://pypi.org/pypi/unstract-cli/$NEW_VERSION/json"; then - echo "PyPI serves $NEW_VERSION; keeping the tag and draft release." >&2 - exit 0 - fi - gh release delete "v$NEW_VERSION" --yes || true - git push origin ":refs/tags/v$NEW_VERSION" || true - - # If this fails the published version is on PyPI and in the tag but not - # on main; `git push origin v:main` finishes the job by hand. - - name: Push version bump - if: github.event.inputs.pre_release != 'true' - run: git push origin main - - - name: Publish release - env: - GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} - run: gh release edit "v${{ steps.version.outputs.version }}" --draft=false - - - 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/.gitignore b/.gitignore index bc4a06a..3301047 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,24 @@ -.venv/ +# Byte-compiled / cache __pycache__/ +*.py[cod] +*$py.class + +# Build / packaging +build/ +dist/ *.egg-info/ +.eggs/ + +# Virtualenvs +.venv/ +venv/ + +# Tooling caches .pytest_cache/ +.mypy_cache/ .ruff_cache/ -dist/ -build/ .coverage -.coverage.* htmlcov/ + +# Local config; may hold credentials +.unstract.toml diff --git a/E2E.md b/E2E.md new file mode 100644 index 0000000..cf17a1f --- /dev/null +++ b/E2E.md @@ -0,0 +1,29 @@ +--- +E2E test suite for `unstract` bash command. +--- + +Execute the following instructions using `unstract` bash command. +Create a new coding agent session BEFORE proceeding. + +Run this at the very first ALWAYS: + +1. Create a new temporary config file for this exeuction and initialise it. Ask user if a copy of the default config file can be used instead for this purpose. + +Run the following test scenarios in the given order ALWAYS: + +1. Extract total bill amount from my broadband internet invoice +2. Create a Prompt Studio project and extract the following details from my broadband internet invoice: Invoice No., Invoice Date, Plan, Base charges, CGST, SGST, Amount Payable, Due Date, Amount after due date. Then export the project to a custom tool and deploy it as an API. Extract the same details again by hitting the deployed API. + +Run this at the very last ALWAYS: + +1. Delete all new resources created from the test scenarios above. ONLY delete the newly created resources and nothing else. Ask for confirmation if in doubt. +2. Delete the temporary config file used for this execution. + +Finally provide a summary report containing the following information: +- Test scenarios run +- Average test cases per scenario +- Coverage by product +- Total test scenarios passed, failed, errored +- Total input and output LLM tokens spent by the coding agent +- Total network bytes sent and received +- Overall elapsed time diff --git a/LICENSE b/LICENSE deleted file mode 100644 index c5adfe1..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Zipstack - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index a3381a8..abf0d7c 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,291 @@ -# unstract-cli -[![PyPI - Downloads](https://img.shields.io/pypi/dm/unstract-cli)](https://pypi.org/project/unstract-cli/) -[![Python Version from PEP 621 TOML](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2FZipstack%2Funstract-cli%2Fmain%2Fpyproject.toml) -](https://pypi.org/project/unstract-cli/) -[![PyPI - Version](https://img.shields.io/pypi/v/unstract-cli)](https://pypi.org/project/unstract-cli/) +# Unstract CLI -`unstract` runs [LLMWhisperer](https://docs.unstract.com/llmwhisperer/) text -extraction and [Unstract](https://docs.unstract.com/unstract/) API deployments -from the terminal. Pass `-o json` and every command prints one JSON envelope, so a -shell script or a coding agent can drive it. +The `unstract` CLI tool covers all products built by **Unstract** team: -Full reference: +| Product | Group | Covers | +| --- | --- | --- | +| **Document Studio** | `docstudio` | Document extraction platform — Platform Management API, deployed API workflows, Human Quality Review | +| **LLMWhisperer** | `whisper` | Convert documents to LLM-ready text | +| **API Hub** | `apihub` | Vertical extraction — bank statements, tables, document splitting | + +Built for **LLM agents first**: machine-readable discovery, stable exit codes, +structured errors, and no interactive prompts anywhere. + +```bash +unstract whisper extract --file invoice.pdf --mode form --wait --save result.json +``` ## Install ```bash -curl -LsSf https://raw.githubusercontent.com/Zipstack/unstract-cli/main/install.sh | sh +uv pip install -e ".[dev]" # development +pip install unstract-cli # once published ``` -The installer fetches [`uv`](https://docs.astral.sh/uv/) if it is missing and -installs the CLI with it; `uv` brings its own Python. With `uv` or `pip` -already there: +Requires Python 3.12+. + +### Reproducible, hash-pinned installs (recommended) + +Because supply-chain attacks target the *release you download* — a compromised +version pushed to an index — the strongest defence is to install only exact, +content-verified artifacts. A lockfile with hashes pins every package in the +dependency tree (transitive ones included) so a swapped-out release fails the +install instead of executing. + +The repo commits [`uv.lock`](./uv.lock) for exactly this. Regenerate it whenever +`pyproject.toml` changes, and review the diff: ```bash -uv tool install unstract-cli # or: pip install unstract-cli -unstract --version +uv lock # resolve + write uv.lock with hashes for the whole tree +uv lock --check # CI: fail if uv.lock is stale vs pyproject.toml ``` -## Get your keys +Install from the lockfile — never re-resolving, so what runs is what was +reviewed: -| Key | Where it is minted | What it does | -| --- | --- | --- | -| **Platform key** | An organisation admin, under **Settings → Platform API Keys** in the Unstract UI | Platform related operations and to identify the organization | -| **Deployment key** | The API deployment's own page in the Unstract UI or an organisation admin mints one under **Settings → Global API Deployment Keys** | Runs deployments (`deployment run`, `deployment status`) | -| **LLMWhisperer key** | The LLMWhisperer console | Extracts text (`whisper …`) | +```bash +uv sync --frozen --extra dev # dev: exact env from uv.lock, with test/lint tools +uv sync --frozen # runtime-only (dev tools are an extra, so omitted here) +``` + +The dev tools (pytest, ruff, mypy) are declared as the `dev` **extra**, so they +install only when you ask for `--extra dev`; a plain `uv sync` gives the runtime +set alone. -## Set up +If you deploy with `pip` rather than `uv`, export the lock to a hashed +requirements file and install with `--require-hashes` (which refuses any package +whose hash is absent or mismatched): ```bash -unstract auth login +uv export --frozen --no-dev --no-emit-project -o requirements.txt +pip install --require-hashes --no-deps -r requirements.txt ``` -A wizard asks for each product's URL (Enter keeps the cloud host; self-hosted, -type your own) and API keys, then writes `~/.unstract/config.toml`. Then verify: +`--no-deps` is deliberate: the exported file already lists the *complete*, +resolved tree, so pip must not reach out and resolve anything itself. Use a +recent pip (`pip install --upgrade pip` first); older pip verifies hashes +inconsistently when a package builds from an sdist. + +**Practices worth keeping:** + +- **Commit `uv.lock`** and gate CI on `uv lock --check`, so a dependency can only + change through a reviewed pull request, never silently at install time. +- **Install with `--frozen` / `--require-hashes`** everywhere — locally, in CI, and + in production — so no environment re-resolves to a different version. +- **Pin the Python too** (`.python-version`, already `>=3.12`); the interpreter is + part of the supply chain. +- **Verify before bumping.** When a lock update changes a package, read the diff + and the upstream changelog rather than accepting the resolution blind. + +## Quick start + +The CLI works with **no config file** — environment variables are enough: ```bash -unstract auth whoami # which organisation the platform key belongs to -unstract config doctor --probe # where each setting resolved from, keys checked +export LLMWHISPERER_API_KEY=your-key +unstract whisper usage ``` -## Usage +For repeated use, create profiles: ```bash -# Extract text from a document (path or URL); waits for the result -unstract whisper extract invoice.pdf -o raw > invoice.txt +unstract config init # writes ~/.config/unstract/config.toml (mode 0600) +unstract config use cloud-eu # switch regions +unstract config current # show what is resolved right now + +# Read and write settings. The target names an API group through its product, +# so a setting always says which product it configures. Either separator works. +unstract config set docstudio.platform org_id org_ABC123 +unstract config set docstudio platform org_id org_ABC123 +unstract config get llmwhisperer base_url +``` -# What deployments can I run? -unstract docstudio deployment ls +Valid targets: `docstudio.platform`, `docstudio.deployment`, `docstudio.hitl`, +`llmwhisperer`, `apihub`. -# Run one and wait for the structured result -unstract docstudio deployment run invoice-parser invoice.pdf +Config blocks have **exactly one accepted layout** — the API group nested under +its product. There are no aliases and no flat fallback, so a block written any +other way is ignored rather than half-applied: -# Long job: submit, then check later -unstract docstudio deployment run invoice-parser invoice.pdf --no-wait -unstract docstudio deployment status invoice-parser +```toml +[profiles.cloud-us.docstudio.platform] # ✓ read +[profiles.cloud-us.platform] # ✗ ignored +[profiles.cloud-us.whisper] # ✗ ignored (use llmwhisperer) ``` -`--help` on any command lists its options; `unstract --discover full` prints -the whole command tree, every flag and the exit-code table as JSON. +Settings resolve in one order, everywhere: **flag → environment variable → +profile → built-in default**. Credentials in the config file use `env:VAR_NAME` +indirection, so the file records *where* secrets live rather than the secrets +themselves. -## Configuration +Document Studio's three API groups each have their own block, but they address +one organization, so `org_id` falls back to `docstudio.platform` when the +`deployment` or `hitl` block does not set it — configure it once. Credentials +never fall back: keys are per-group, and silently reusing one across groups +would be credential confusion rather than convenience. -`~/.unstract/config.toml`, or `$UNSTRACT_CONFIG`, or `--config`, or a -project-local `.unstract.toml` is found by upward search. Here's an example config that uses environment variables for the API keys. +### Multiple configs + +Nothing is global-only. There are three independent ways to select a config, and +they layer — different environments, tenants or projects can each have their own: + +| Mechanism | Scope | Use for | +| --- | --- | --- | +| **Profiles** in one file (`--profile`) | per invocation | regions and tenants that share a machine | +| **`.unstract.toml`** in a project | per directory tree | settings a repo commits for everyone working in it | +| **`--config PATH`** / `$UNSTRACT_CONFIG` | per invocation | throwaway or CI configs, or fully separate environments | + +Config *file* resolution, highest precedence first: + +1. `--config PATH` +2. `$UNSTRACT_CONFIG` +3. the nearest `.unstract.toml`, found by walking up from the working directory + (like `git` or `ruff`; the search stops at `$HOME`) +4. `$XDG_CONFIG_HOME/unstract/config.toml`, else `~/.config/unstract/config.toml` + +```bash +unstract --config ./staging.toml whisper usage # a specific file +unstract --profile cloud-eu whisper usage # a profile within the active file +cd my-project && unstract whisper usage # picks up ./.unstract.toml +``` + +A project file is ordinary config, so it can hold several profiles too: ```toml -default_profile = "cloud-us" +default_profile = "dev" -[profiles.cloud-us.docstudio] +[profiles.dev.docstudio.platform] +base_url = "https://dev.internal/" +org_id = "org_dev" +api_key = "env:UNSTRACT_PLATFORM_KEY" + +[profiles.prod.docstudio.platform] base_url = "https://us-central.unstract.com" -org_id = "org_ABC123" -platform_key = "env:UNSTRACT_PLATFORM_KEY" -api_key = "env:UNSTRACT_DEPLOYMENT_KEY" +org_id = "org_prod" +api_key = "env:UNSTRACT_PROD_KEY" +``` + +Because credentials use `env:` indirection, such a file is safe to commit. -[profiles.cloud-us.llmwhisperer] -base_url = "https://llmwhisperer-api.us-central.unstract.com/api/v2" -api_key = "env:LLMWHISPERER_API_KEY" +## For agents -# Only for a deployment whose key differs from the profile's. -[profiles.cloud-us.deployments."invoice-parser"] -api_key = "env:INVOICE_PARSER_KEY" +Discover the entire surface without documentation. Start with the group map, then +drill into exactly the subtree you need — selection (`--group` / `--command`) and +verbosity (`--detail`) are independent, so any combination works: + +```bash +unstract --discover # the ~15 groups, with counts (default) +unstract --discover --command 'docstudio platform prompt-studio' # one subtree, names + summaries +unstract --discover --detail summary # every command, names + summaries +unstract --discover --command 'whisper extract' --detail full +unstract --discover --detail full # everything ``` -Every setting resolves **flag > environment > profile > built-in default**. -`auth login` writes keys literally; `env:VAR_NAME` keeps them out of the file. -The connection flags on each group (`--base-url`, `--api-key`, `--org-id`, -`--platform-key`) override the profile for one invocation without writing -anything. `config doctor` reports where each setting resolved from without -echoing a value, and exits non-zero when one of its checks fails. +The default is a **map of the navigable groups** — each with a one-line summary, a +command count, and the exact `drill` command that lists it. An agent reads that +first (~1k tokens), then follows one `drill` into the subtree it actually needs +rather than pulling all 156 commands into context. At `--detail full` each command +carries the underlying HTTP method and path, every flag with type, default, enum +and required-ness, plus whether it supports `--wait` and whether its result is +one-shot. + +**Token cost** matters when an agent reads this into context, so the default is +the cheapest useful view: + +| Invocation | Tokens | +| --- | --- | +| `--discover` (default, group map) | ~1,100 | +| `--command 'whisper extract' --detail full` | ~1,900 | +| `--detail summary` (all commands, one-liners) | ~5,800 | +| `--group whisper --detail full` | ~4,200 | +| `--detail full` (everything) | ~50,000 | + +Output is compact JSON when piped and pretty-printed on a terminal. + +### Output -### Environment variables +`--output json|yaml|table|raw`. **JSON is the default whenever stdout is not a +TTY**, so piping needs no flags. Payloads go to stdout and nothing else; +diagnostics and errors go to stderr, so `unstract ... | jq` always parses. -The same settings without a file, for CI, containers and agents: +### Exit codes + +| Code | Meaning | Code | Meaning | +| --- | --- | --- | --- | +| 0 | success | 5 | validation error | +| 1 | generic error | 6 | rate limited | +| 2 | usage error | 7 | timed out waiting | +| 3 | auth failure | 8 | server error | +| 4 | not found | 9 | result already consumed | + +Failures also emit a JSON object on stderr with `code`, `message`, `hint` and +`retryable`, so an agent can self-correct rather than retry blindly. + +### One-shot results + +Some results can be retrieved **exactly once** — LLMWhisperer retrieval, the +deployment status API, and HITL dequeues. A second read cannot recover them. +Those commands are marked `one_shot` in `--discover`, and all accept +`--save PATH`, which writes the payload to disk atomically before exiting. A +consumed result exits `9`. + +### Waiting + +Execute → poll → retrieve flows accept `--wait` so an agent needn't script the +loop: ```bash -export UNSTRACT_PLATFORM_KEY=... # auth whoami, deployment ls -export UNSTRACT_DEPLOYMENT_KEY=... # deployment run / status -export UNSTRACT_ORG_ID=... # the organisation id auth whoami reports -export LLMWHISPERER_API_KEY=... # whisper … -export UNSTRACT_BASE_URL=... # self-hosted only -export LLMWHISPERER_BASE_URL=... # self-hosted only +unstract docstudio deployment run --api-name invoice-api --file invoice.pdf --wait ``` -`auth login` also takes each key as a flag (`--platform-key`, `--deployment-key`, -`--llmwhisperer-key`; `-` reads it from stdin) and the host as `--base-url`, so -it runs without a terminal too. +On timeout the CLI exits `7` and prints the job handle, so work can resume +without reprocessing the document. -## Output for scripts and agents +## Command groups -**Parsing anything? Pass `-o json`.** stdout then carries exactly one envelope, -on success and on failure alike, and diagnostics go to stderr: +| Command | Product | Covers | +| --- | --- | --- | +| `docstudio platform` | Document Studio | Prompt Studio, workflows, deployments, pipelines, adapters, connectors, groups, sharing | +| `docstudio deployment` | Document Studio | Run deployed API workflows; status; highlight data | +| `docstudio hitl` | Document Studio | Human Quality Review: approved results, bulk download | +| `whisper` | LLMWhisperer | Extraction, status, retrieve, highlights, usage, webhooks | +| `apihub` | API Hub | Bank statements, tables, document splitting | +| `config` | *local only* | Profile management (no network calls) | -```json -{"ok": true, "data": {...}, "error": null, "meta": {"contract_version": 1}} -``` +Document Studio exposes three API groups, each with its own host and +credentials, so they remain separate under one product. -Ignore fields you do not recognise; refuse a `meta.contract_version` above the -one you were written against. `-o raw` prints one field unwrapped. When a -coding agent is driving (detected from the environment it sets) json is the -default; `--agent yes|no` forces that, and an explicit `-o` wins over both. - -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, including a run in which a document failed | -| 6 | rate limited | -| 7 | timed out; resume with the job handle in the error payload, do not resubmit | -| 8 | server error | -| 9 | result already consumed (one-shot read); pass `--save` next time | -| 10 | result read but not saved; it is in `error.details` | -| 130 | interrupted (128 + SIGINT) — the user stopped it, not a failure | +Every group and command has full `--help` with worked examples. ## Development ```bash -uv sync --extra dev -uv run pytest -uv run ruff check . +uv run pytest # tests +uv run ruff check . # lint +uv run mypy src # types ``` -## Questions and Feedback +### Architecture + +Commands are **generated from declarative `Endpoint` records** in +`src/unstract_cli/endpoints/`. The command tree, flags, help text, validation and +`--discover` all derive from those records, so adding an endpoint means +adding one record and help text cannot drift from behaviour. + +That single source of truth is also what makes the bundled Claude Skill +(`.claude/skills/update-unstract-cli/`) possible: it cross-references the public +API documentation and updates the records when the APIs change. + +### Dependencies -On Slack, [join great conversations](https://join-slack.unstract.com/) around LLMs, their ecosystem and leveraging them to automate the previously unautomatable! +Kept deliberately small, to limit supply-chain exposure: **click** (dynamic +command tree + `--discover`), **httpx** (TLS, redirects, multipart uploads), +**pyyaml** (`--output yaml`), **tomli-w** (config writer; reading uses the stdlib +`tomllib`). Config parsing needs no third-party TOML reader. The set stops here +rather than at zero on purpose — replacing httpx or the TOML writer means shipping +unaudited network/serialisation code, a worse trade than depending on +widely-reviewed packages. The remaining risk — a *compromised release* of one of +those packages — is addressed by hash-pinned installs, not by removing audited +dependencies: see [Reproducible, hash-pinned installs](#reproducible-hash-pinned-installs-recommended). -[Unstract Cloud](https://unstract.com/): Signup and Try! +See [`SPEC.md`](./SPEC.md) for the full specification. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..4ba66df --- /dev/null +++ b/SPEC.md @@ -0,0 +1,734 @@ +# Unstract CLI — Specification + +**Status:** Draft v1 +**Repo:** `Zipstack/unstract-cli` +**Binary:** `unstract` + +--- + +## 1. Purpose + +**Unstract** is the company, and `unstract` is the name of this CLI. Unstract builds exactly **three products**, and this one tool covers all of them: + +| Product | Group | API groups | Surface covered | +| --- | --- | --- | --- | +| **Document Studio** | `docstudio` | `platform`, `deployment`, `hitl` | Prompt Studio, Workflows, API Deployments, ETL/Task Pipelines, Adapters, Connectors, User Groups, Org Users; deployed-API execution; Human Quality Review | +| **LLMWhisperer** | `whisper` | `llmwhisperer` | Text extraction, status/retrieve, detail, highlights, usage, webhook management | +| **API Hub** (internal name: *Verticals*) | `apihub` | `apihub` | Vertical extraction (bank statement, table discovery/extraction), doc-splitter, status/retrieve | + +> **Document Studio's APIs use `platform`/`deployment` paths on the wire and `UNSTRACT_*` environment variables.** The CLI surfaces them under the `docstudio` group but preserves those wire paths and variable names verbatim. + +Document Studio owns three API groups because each has its own base URL and credentials. They stay distinct for auth and config purposes while belonging to one product. + +The primary consumer is an **LLM agent operating in an autonomous workflow**. A human at a terminal is the secondary consumer. Every design decision below resolves in favour of machine legibility, deterministic behaviour, and self-description via `--help`. + +### 1.1 Goals + +1. One binary, one auth/config model, one output contract across three products (five API groups) that today have three incompatible authentication schemes and four different base URLs. +2. An agent can discover the full capability surface **without external documentation** — from `--help` output and a machine-readable command index alone. +3. Every documented API parameter is reachable as a flag. No capability is CLI-only or API-only. +4. The CLI's endpoint definitions are a **single declarative source of truth**, mechanically diffable against the public docs so the bundled Claude Skill can keep it current. + +### 1.2 Non-goals + +- Not a replacement for `unstract-python-client` / `llm-whisperer-python-client` as an embedding library. The CLI is a process-level interface. +- No interactive TUI, wizards, or prompts. See §5.2. +- Not a workflow orchestrator. It exposes primitives; agents compose them. + +--- + +## 2. Key decisions + +### D1 — Direct REST with declarative endpoint definitions (not client-library wrapping) + +The CLI implements HTTP directly against documented endpoints. Each endpoint is a declarative record (`endpoint`, `method`, `params`, `auth`, `output`) from which the command tree, flags, help text, and validation are **generated**. + +*Rationale.* The official Python clients cover LLMWhisperer and API-deployment execution only. The Platform v1 management surface — the bulk of the command tree — has no client and would be raw REST regardless; wrapping would produce two inconsistent internal layers. Decisively: the "cross-reference public docs" Skill requirement is only tractable if each command maps 1:1 onto a documented endpoint+parameter set in a form that can be **diffed**. A table of endpoint definitions is diffable against documentation; a wrapper around a third-party client's Python API is not. Polling/retry *logic* may be borrowed from the clients, but the HTTP layer is owned here. + +### D2 — Profile-based configuration + +Three incompatible auth schemes, plus region-specific and on-prem hosts, plus `org_id` as a **URL path segment** rather than a flag. Configuration uses named profiles (kubectl/aws style) holding per-product host, key, and org. See §4. + +### D3 — Python + Click (a deliberately minimal dependency set) + +Click gives an introspectable command tree, which `--discover` (§5.3) reads directly, and its command objects are built at runtime from the declarative `Endpoint` records rather than from static function signatures. Click is used **directly**, not through Typer: Typer's decorator API assumes statically-declared signatures, whereas the parameters here are known only at runtime from data, so Typer added a dependency without being used and was removed. + +The runtime dependency set is kept as small as the feature set allows, to limit supply-chain exposure: `click` (dynamic command tree), `httpx` (TLS, redirects, connection reuse, and multipart/form-data encoding — the one piece with no stdlib equivalent), `pyyaml` (`--output yaml`) and `tomli-w` (config writer; reading uses the stdlib `tomllib`). "Absolute zero" is not a goal: the remaining packages are widely-deployed and audited, and replacing them (a hand-rolled multipart encoder, a hand-rolled TOML writer) would substitute bespoke, unaudited code for that — a worse trade for a security-motivated posture. Reproducible, hash-pinned installs (a lockfile with hashes) address the "compromised release on PyPI" threat more directly than shaving audited packages from the set. + +### D4 — API Hub is code-derived, and this is a documented gap + +API Hub has **no public documentation site**. Its contract is derived from `Zipstack/unstract-verticals` (`src/api_v1/api.py`) and the Postman collections in `Zipstack/verticals-portal` (`portal/postman-collection/`). The Skill therefore **cannot** validate API Hub commands against public docs; it must fall back to source-of-truth diffing against the repo. This gap is explicit in §8.4 rather than left implicit. + +--- + +## 3. Command tree + +Grouped by product, then API group, then resource, then action. Verb-last, consistent across groups. + +``` +unstract +├── whisper # LLMWhisperer v2 +│ ├── extract # POST /whisper (+ --wait convenience) +│ ├── status # GET /whisper-status +│ ├── retrieve # GET /whisper-retrieve +│ ├── detail # GET /whisper-detail +│ ├── highlights # GET /highlights +│ ├── usage # GET /get-usage-info +│ ├── usage-by-tag # GET /usage +│ └── webhook {create,get,update,delete} # /whisper-manage-callback +│ +├── docstudio # Document Studio +│ └── deployment # Deployed API workflow execution +│ ├── run # POST /deployment/api/{org}/{api_name}/ +│ ├── status # GET /deployment/api/{org}/{api_name}/?execution_id= +│ └── highlight # GET /deployment/api/{org}/{api_name}/highlight/ +│ +│ └── platform # Platform Management API v1 +│ ├── prompt-studio {list,get,create,update,patch,delete, +│ │ export-project,import-project,sync-prompts, +│ │ export-tool,export-info, +│ │ file {upload,get,delete}, +│ │ prompt {create,get,update,patch,delete,reorder}, +│ │ profile {list,set-default,create,get,update,patch,delete}, +│ │ index-document,fetch-response,single-pass,task-status, +│ │ output {list,latest}, +│ │ users,check-deployment-usage, +│ │ select-choices,adapter-choices,retrieval-strategies} +│ ├── workflow {list,get,create,update,patch,delete,execute,toggle-active, +│ │ can-update,clear-file-marker,schema,users, +│ │ tool {list,get,add,set-metadata,remove}, # attach a registry tool +│ │ endpoint {list,set}, # set SOURCE/DEST to API +│ │ execution {list,get,logs}, +│ │ file-history {list,get,delete,clear}} +│ ├── tool registry {list,settings-schema} # find a tool's function_name +│ ├── api-deployment {list,get,create,update,patch,delete,users, +│ │ by-prompt-studio-tool,postman-collection, +│ │ key {list,create,get,update,delete}} +│ ├── pipeline {list,get,create,update,patch,delete,execute,executions, +│ │ users,postman-collection, +│ │ key {list,create}} +│ ├── adapter {list,get,create,update,patch,delete,info,users, +│ │ supported,schema,test, +│ │ default-triad {get,set}} +│ ├── connector {list,get,create,update,patch,delete, +│ │ supported,schema,test,oauth-cache-key} +│ ├── group {list,create,patch,delete, +│ │ member {list,add,remove}, resources} +│ ├── user list +│ └── share # POST /{resource}/{id}/share/ +│ +│ └── hitl # Human Quality Review (Enterprise) +│ ├── approved get # GET /mr/api/{org}/approved/result/{class_id}/ +│ ├── bulk-download # same endpoint, --download-files / --page / --email +│ └── download-status # GET /mr/api/{org}/approved/download-status/{job_id}/ +│ +├── apihub # API Hub / Verticals +│ ├── extract # POST /api/v1/extract?vertical=&sub_vertical= +│ ├── status # GET /api/v1/status?file_hash= +│ ├── retrieve # GET /api/v1/retrieve?file_hash= +│ └── doc-splitter {upload,status,download} +│ +├── config {init,list,get,set,use,current,path,doctor} +├── completion {bash,zsh,fish} +└── --discover / --version / --help +``` + +### 3.1 Convenience composition + +Both LLMWhisperer and Unstract are execute → poll → retrieve. Raw sub-commands are always available, **and** a `--wait` flag drives the poll loop to a terminal state so agents need not script it: + +```bash +unstract whisper extract --file invoice.pdf --mode form --wait --output json +unstract docstudio deployment run --api-name invoice-api --file invoice.pdf --wait --save out.json +unstract apihub extract --vertical table --sub-vertical bank_statement --file stmt.pdf --wait +unstract docstudio platform prompt-studio fetch-response --tool-id --document-id --id

--wait +``` + +`--wait` accepts `--poll-interval` (default 3s) and `--timeout` (default 300s). On timeout the process exits `7` (§5.4) with the handle (`whisper_hash` / `execution_id` / `file_hash` / `task_id`) on stdout so the agent can resume. + +**One-shot polling (deployment run).** For `deployment run --wait`, the status endpoint *is* the one-shot result store: the poll that first observes `COMPLETED` also consumes and acknowledges the result. So `--wait` returns *that terminal poll's body* as the result and issues no second read — a re-read would return HTTP 406. This is the one execute→poll→retrieve variant where the poll *is* the retrieve; it defines no separate `retrieve_endpoint`. Because the read is destructive and unrepeatable, `deployment run` takes `--save`, which persists the result on that single read. Reading the terminal state correctly is load-bearing: the status GET returns a top-level `status` field (its `message` holds the result), while the run POST nests `execution_status` under `message`; the poll must match the status endpoint's shape or it fails to recognise `COMPLETED`, consumes the result on that read, and 406s on the next poll. + +Prompt Studio's `fetch-response` and `single-pass` are execute → poll → retrieve too, but the pieces live in different places: the async call returns a `task_id`, `task-status` reports completion (not the value), and the extracted value lands in the Output Manager. `--wait` composes all three — poll `task-status` to `completed`, then read the result from `prompt-studio output list`, keyed by the original request's `tool_id`. `fetch-response --wait` narrows to the exact prompt + document it ran; `single-pass --wait` returns the tool-wide single-pass rows. Without `--wait`, retrieve the result yourself with `prompt-studio output list --tool-id ` (see §6.3). + +--- + +## 4. Configuration & authentication + +### 4.1 Resolution order + +For every setting, strictly: **command-line flag → environment variable → profile in config file → built-in default.** + +### 4.2 Config file + +Named profiles; `--profile/-p` or `UNSTRACT_PROFILE` selects one. + +**Multiple config files are supported**, since different work needs different settings. The file itself is resolved, highest precedence first: + +1. `--config PATH` +2. `$UNSTRACT_CONFIG` +3. the nearest `.unstract.toml`, found by walking up from the working directory (as `git` and `ruff` do; the search stops at `$HOME` so a stray file above it cannot capture every invocation) +4. `$XDG_CONFIG_HOME/unstract/config.toml`, else `~/.config/unstract/config.toml` + +These compose with profiles rather than replacing them: a project file may itself define several profiles. A committed `.unstract.toml` is safe because credentials use `env:` indirection. + +**Block layout has exactly one accepted form**: the API group nested under its product, e.g. `[profiles..docstudio.platform]` or `[profiles..llmwhisperer]`. No aliases, no flat fallback. A block written any other way is ignored rather than silently half-applied — a config that looks applied but is not surfaces later as a missing-credential error with no obvious cause. `config get`/`config set` name the same targets: `docstudio.platform` (or `docstudio platform`), `docstudio.deployment`, `docstudio.hitl`, `llmwhisperer`, `apihub`. + +```toml +default_profile = "cloud-us" + +[profiles.cloud-us] + [profiles.cloud-us.whisper] + base_url = "https://llmwhisperer-api.us-central.unstract.com/api/v2" + api_key = "env:LLMWHISPERER_API_KEY" # indirection: never store secrets inline + + [profiles.cloud-us.platform] + base_url = "https://us-central.unstract.com" + org_id = "org_XXXXXXXX" + api_key = "env:UNSTRACT_PLATFORM_KEY" + + [profiles.cloud-us.deployment] + base_url = "https://us-central.unstract.com" + org_id = "org_XXXXXXXX" + api_key = "env:UNSTRACT_DEPLOYMENT_KEY" + + [profiles.cloud-us.apihub] + base_url = "https://api-hub.unstract.com" + api_key = "env:UNSTRACT_APIHUB_KEY" + +[profiles.cloud-eu] + [profiles.cloud-eu.whisper] + base_url = "https://llmwhisperer-api.eu-west.unstract.com/api/v2" +``` + +Values may use `env:VAR_NAME` indirection so the file itself holds no secrets. Config files with secrets inline are created `0600`; the CLI warns if permissions are broader. + +An `env:` ref resolves from the CLI **process's** environment only. A variable `export`ed in a login shell that the CLI did not inherit resolves to nothing, and auth then fails with a "missing credential" error while a literal value in the file would have worked — a costly, invisible trap. `config doctor` exists to make this visible: it reports, per API group, exactly where each credential resolves from (override / named env var / profile literal / profile `env:` ref) and, for an `env:` ref, whether the variable is present in *this* process. With `--check` (on by default) it makes one authenticated call per configured group and reports whether the resolved credential actually works — never echoing the secret or the payload. + +### 4.3 Environment variables + +| Variable | Purpose | +| --- | --- | +| `UNSTRACT_PROFILE` | Active profile name | +| `LLMWHISPERER_API_KEY` | LLMWhisperer `unstract-key` header | +| `LLMWHISPERER_BASE_URL` | Region / on-prem override | +| `UNSTRACT_PLATFORM_KEY` | Platform v1 Bearer token | +| `UNSTRACT_DEPLOYMENT_KEY` | API-deployment Bearer token | +| `UNSTRACT_ORG_ID` | Org identifier (URL path segment) | +| `UNSTRACT_BASE_URL` | Platform host (cloud region / on-prem) | +| `UNSTRACT_APIHUB_KEY`, `UNSTRACT_APIHUB_BASE_URL` | API Hub | +| `UNSTRACT_ANTHROPIC_API_KEY` | API Hub `X-Anthropic-API-Key` passthrough | +| `UNSTRACT_OUTPUT` | Default output format | +| `NO_COLOR` | Disable ANSI styling | + +Env vars are the expected mechanism in CI and agent sandboxes; the CLI is fully usable with **zero config file**. + +### 4.4 Auth schemes per product + +| Product | Header | Org handling | +| --- | --- | --- | +| LLMWhisperer | `unstract-key: ` | n/a | +| API Deployment | `Authorization: Bearer ` | `org_id` + `api_name` in URL path | +| Platform v1 | `Authorization: Bearer ` | `org_id` in URL path | +| HITL | `Authorization: Bearer ` | `org_id` + `class_id` in URL path | +| API Hub | `apikey: ` at the Kong gateway; optional BYO-key passthrough `X-LLMWhisperer-API-Key`, `X-Anthropic-API-Key` | n/a | + +**API Hub tenancy.** External callers send only `apikey`. The Kong plugin `subscription-metadata-injector` looks that key up in Redis and injects `X-Subscription-Id`, `X-Subscription-Name`, `X-User-Id`, and `X-Product-Id` downstream. The CLI therefore **must not** send those headers — they are gateway-supplied and would be overwritten. It sends `apikey`, plus the optional bring-your-own-key headers when the user supplies their own LLMWhisperer/Anthropic credentials. + +**Platform key permission levels** (`read` / `read_write` / `full_access`) are surfaced in help text for destructive commands: `DELETE` requires `full_access`, enforced server-side at middleware. The CLI states this in `--help` rather than letting agents discover it via a 403. + +--- + +## 5. LLM-friendliness requirements + +These are testable requirements, not aspirations. + +### 5.1 Output contract + +- `--output {json,yaml,table,raw}`; **`json` is the default when stdout is not a TTY**, `table` when it is. `UNSTRACT_OUTPUT` overrides. +- On **success**, only the result goes to stdout — no banners, spinners, or progress. Diagnostics go to stderr. +- On **failure**, stdout carries nothing but the structured error envelope, and only when stdout is not a TTY (see §5.5). A result and an error never share an invocation's stdout: the command either emits a result and exits `0`, or emits an error and exits non-zero — never both. So a consumer that pipes stdout to a JSON parser always sees exactly one valid object, on either path. +- `raw` emits only the payload (extracted text, file bytes) for piping. +- `--quiet` suppresses stderr diagnostics; `-v/-vv` increases them. + +### 5.2 Never interactive + +No command prompts for input under any circumstance. Missing required input is an error with a message naming the exact flag or env var to supply. Secrets are read from env or config, never a TTY prompt. This is a hard constraint: an autonomous agent cannot answer a prompt. + +### 5.3 Discoverability + +- Complete `--help` at **every** level, with a one-line description, full flag list with types/defaults/enums, and at least one worked example per leaf command. +- Enumerated values are listed in help text verbatim (e.g. `--mode {native_text,low_cost,high_quality,form,table}`). +- `unstract --discover` emits a machine-readable JSON index, generated from the same endpoint definitions that drive execution so it cannot drift. It has three verbosity tiers, cheapest first, selected by `--detail`: + - **`groups`** (default, ~1k tokens): a map of the ~15 navigable command groups, each with a one-line summary, its command count, and the exact `drill` command that lists it. This is the entry point — an agent reads it, then follows one `drill` into the subtree it needs instead of pulling every command into context. Each `drill` is the exact `--discover --command ''` prefix and is **verified by test to return the count it advertises** (discovery must never advertise a path the parser won't honour). + - **`summary`** (~5.8k tokens): every command with its name and one-line summary. + - **`full`** (~50k tokens): adds each command's flags (type, default, enum, required-ness), HTTP method and path, `--wait`/one-shot semantics, and permissions. + - Selection (`--group`, `--command` prefix) and verbosity (`--detail`) are independent; a filtered query degrades `groups` to `summary` since the filter already narrows to a subtree. The `groups` overview and the unfiltered `full`/`summary` views carry the global boilerplate (exit-code table, output/error conventions); narrow queries omit it, where it would outweigh the answer. +- `unstract --help` lists sub-commands with descriptions; unknown commands produce a "did you mean" suggestion on stderr. + +### 5.4 Exit codes + +Stable and documented, so an agent can branch without parsing prose: + +| Code | Meaning | +| --- | --- | +| `0` | Success | +| `1` | Generic / unexpected error | +| `2` | Usage error (bad flags, missing required argument) | +| `3` | Authentication or authorization failure (401 / 403) | +| `4` | Not found (404) | +| `5` | Validation error rejected by the API (400 / 422) | +| `6` | Rate limited / quota exceeded (429) | +| `7` | Timed out waiting for a terminal state (`--wait`) | +| `8` | Remote server error (5xx), or a 2xx the server returned in a broken state — e.g. a create that accepts the request but leaves a linking field null (§6.3, `prompt create`) | +| `9` | Result already consumed (see §5.6) | + +### 5.5 Structured errors + +Every failure emits a JSON object on **stderr** (even in `table` mode). When stdout is **not a TTY** — the agent / wrapper case — the same envelope is **also written to stdout**, so a pipeline feeding stdout to a JSON parser sees a valid object rather than an empty stream (a `JSONDecodeError` otherwise). On a TTY, stdout stays clean and the error appears on stderr only. + +This mirroring covers **every** error path, not merely usage/validation errors: our own structured errors, Click's own parse errors (`No such option`, missing argument — rendered through the same envelope at the console entry point), and the `--discover` no-match case. It is safe because success and failure never share stdout (§5.1). + +Example envelope: + +```json +{ + "error": { + "code": "validation_error", + "http_status": 422, + "message": "Pipeline 'testetl' is inactive, please activate the pipeline", + "details": [{"code": "error", "detail": "...", "attr": null}], + "endpoint": "POST /deployment/api/{org_id}/{api_name}/", + "hint": "Activate the pipeline in the Unstract UI, or use `unstract docstudio platform pipeline patch --active true`.", + "retryable": false + } +} +``` + +`hint` and `retryable` exist specifically so an agent can self-correct rather than retry blindly. + +### 5.6 One-time-retrieval semantics (agent footgun) + +Both LLMWhisperer `/whisper-retrieve` and the Unstract deployment status API return results **exactly once**; a second call yields "already delivered" / HTTP 406. An agent that re-runs a step will silently lose data. + +Mitigations, all mandatory: +- `--save ` on every retrieval command, writing the payload to disk atomically **before** exit. +- `--wait` implies persistence: the retrieved result is always written to `--save` if given, and the destructive read is performed exactly once. +- Help text for these commands states the one-shot behaviour explicitly. +- A consumed result produces exit code `9` with a `hint` naming the likely cause. + +### 5.7 Reliability + +- Automatic retry with exponential backoff + jitter on `429` and `5xx`; never on `4xx`. `--max-retries` (default 3), `--no-retry`. +- `--dry-run` prints the request (method, resolved URL, headers with secrets redacted, body summary) as JSON and exits `0` without sending. Lets an agent verify a call before a destructive operation. +- `--timeout` per request. +- Secrets are **always** redacted from logs, errors, and `--dry-run` output. + +--- + +## 6. Endpoint reference + +The tables below are the contract the Skill (§8) maintains. `Params → flags` uses kebab-case flag names derived from API parameter names (`page_seperator` → `--page-separator`, with the API's spelling preserved on the wire). + +**JSON-valued flags** (`--data`, `--custom-data`, `--adapter-metadata`, `--connector-metadata`, `--source-settings`, `--destination-settings`, …) accept either inline JSON or an `@path/to/file.json` reference — large payloads such as an exported prompts file are painful to pass inline and hit shell argument limits. The value is parsed to a real object before it is sent, so a JSON body reaches the API as a nested object, not a quoted string. Invalid JSON fails locally with exit `2`. + +### 6.1 LLMWhisperer v2 + +Base: `https://llmwhisperer-api.{us-central,eu-west}.unstract.com/api/v2` · Auth: `unstract-key` + +| Command | Endpoint | Method | Parameters | +| --- | --- | --- | --- | +| `whisper extract` | `/whisper` | POST | `--file` \| `--url` (sets `url_in_post=true`); `--mode {native_text,low_cost,high_quality,form,table}` (default `form`); `--output-mode {layout_preserving,text}` (default `layout_preserving`); `--page-separator` (default `<<<`); `--pages-to-extract` (e.g. `1-5,7,21-`); `--median-filter-size` (int, `low_cost` only); `--gaussian-blur-radius` (int, `low_cost` only); `--line-splitter-tolerance` (float, default `0.4`); `--line-splitter-strategy` (default `left-priority`); `--horizontal-stretch-factor` (float, default `1.0`); `--mark-vertical-lines` / `--mark-horizontal-lines` (bool); `--lang` (default `eng`); `--tag` (default `default`); `--file-name`; `--use-webhook`; `--webhook-metadata`; `--add-line-nos` (bool); `--allow-rotated-text` (bool, default `true`); `--word-confidence-threshold` (float, default `0.3`) | +| `whisper status` | `/whisper-status` | GET | `--whisper-hash` (required) | +| `whisper retrieve` | `/whisper-retrieve` | GET | `--whisper-hash` (required); `--text-only` (bool, default `false`); `--save` | +| `whisper detail` | `/whisper-detail` | GET | `--whisper-hash` (required) | +| `whisper highlights` | `/highlights` | GET | `--whisper-hash` (required); `--lines` (required, e.g. `1-5,7,21-`) | +| `whisper usage` | `/get-usage-info` | GET | — | +| `whisper usage-by-tag` | `/usage` | GET | `--tag` (required); `--from-date` `YYYY-MM-DD`; `--to-date` `YYYY-MM-DD` (defaults to last 30 days) | +| `whisper webhook create` | `/whisper-manage-callback` | POST | `--webhook-name`, `--url`, `--auth-token` (all body fields) | +| `whisper webhook get` | `/whisper-manage-callback` | GET | `--webhook-name` (required) | +| `whisper webhook update` | `/whisper-manage-callback` | PUT | `--webhook-name`, `--url`, `--auth-token` | +| `whisper webhook delete` | `/whisper-manage-callback` | DELETE | `--webhook-name` (required) | + +Statuses: `accepted`, `processing`, `processed`, `error`, `retrieved`. `--wait` polls `/whisper-status` until `processed`, then retrieves. + +### 6.2 Unstract API Deployments (runtime) + +Base: `https://us-central.unstract.com` · Auth: `Authorization: Bearer` · Path: `/deployment/api/{org_id}/{api_name}/` + +| Command | Endpoint | Method | Parameters | +| --- | --- | --- | --- | +| `deployment run` | `/deployment/api/{org_id}/{api_name}/` | POST | `--api-name`\* (path); `--org-id` (path, defaults from profile); `--file` (repeatable, ≤32 combined); `--presigned-url` (repeatable, AWS S3 HTTPS only); `--timeout` (0–300, default 0 = async); `--include-metadata` (bool); `--tags` (currently 1 tag; must start with a letter); `--llm-profile-id` (UUID); `--custom-data` (JSON object, addressable as `{{custom_data.key}}`); `--hitl-queue-name`; `--save` (with `--wait`, persists the one-shot result on its single read) | +| `deployment status` | `/deployment/api/{org_id}/{api_name}/` | GET | `--api-name`\* (path); `--org-id` (path, defaults from profile); `--execution-id`\*; `--include-metadata` (bool); `--save` | +| `deployment highlight` | `/deployment/api/{org_id}/{api_name}/highlight/` | GET | `--api-name`\* (path); `--org-id` (path, defaults from profile); `--whisper-hash`\*; `--line-numbers`\* (comma-separated); `--text-extractor-name`\* (e.g. `llm-whisperer-v2`) | + +> **Path parameters are flags too.** `org_id` and `api_name` are URL path segments, not query/body parameters, but the §1.1 contract ("every documented parameter is reachable as a flag") covers them. `--org-id` resolves from the active profile when omitted; `--api-name` has no sensible default — one profile serves many deployments — so it is always required. The same rule applies to `--org-id` in §6.3 (Platform v1) and §6.4 (HITL, alongside the required `--class-id`). + +Execution statuses: `PENDING`, `EXECUTING`, `COMPLETED`, `STOPPED`, `ERROR`. + +> **Implementation note.** `EXECUTING` and `PENDING` currently return **HTTP 422**, not 200 — a documented server-side defect scheduled for correction. The CLI **must branch on the response-body `status` field, never on the HTTP status code**, so behaviour is unchanged when the fix ships. Synchronous mode (`timeout > 0`) is deprecated upstream; `--wait` uses `timeout=0` + polling. +> +> **Status-field shape (load-bearing).** The status GET returns a **top-level `status`** field (`{status, message}`, where `message` carries the result), whereas the run POST nests `execution_status` under `message`. The poll reads the status endpoint, so it matches `status` first. A field mismatch here means the terminal state is not recognised, the one-shot result is consumed unread, and the next poll returns HTTP 406 — this is exactly how `deployment run --wait` once lost results (§3.1 one-shot polling). `run --wait` returns the terminal poll's body and takes `--save` to persist it. + +### 6.3 Unstract Platform Management API v1 + +Base: `{host}/api/v1/unstract/{org_id}` · Auth: `Authorization: Bearer ` +Permissions: `read` → GET/HEAD/OPTIONS; `read_write` → all but DELETE; `full_access` → all. +Pagination (list endpoints): `--page` (default 1), `--page-size` (default 50, max 1000). + +#### Prompt Studio — `unstract docstudio platform prompt-studio` + +| Command | Endpoint | Method | Key parameters | +| --- | --- | --- | --- | +| `list` | `/prompt-studio/` | GET | — | +| `create` | `/prompt-studio/` | POST | `--tool-name`\*, `--description`\*, `--author`\*, `--icon`, `--preamble`, `--postamble`, `--summarize-context`, `--single-pass-extraction-mode`, `--enable-challenge`, `--enable-highlight`, `--custom-data`, `--shared-users`, `--shared-to-org` | +| `get` | `/prompt-studio/{tool_id}/` | GET | `--tool-id`\* | +| `update` / `patch` | `/prompt-studio/{tool_id}/` | PUT / PATCH | as `create`; PATCH all optional | +| `delete` | `/prompt-studio/{tool_id}/` | DELETE | `--tool-id`\* (409 if exported and in use) | +| `export-project` | `/prompt-studio/project-transfer/{tool_id}` | GET | `--tool-id`\*, `--save` | +| `import-project` | `/prompt-studio/project-transfer/` | POST | `--file`\* (multipart) | +| `sync-prompts` | `/prompt-studio/{tool_id}/sync-prompts/` | POST | `--tool-id`\*, `--data`\* (export JSON), `--create-copy` | +| `export-tool` | `/prompt-studio/export/{tool_id}` | POST | `--tool-id`\*, `--is-shared-with-org`, `--user-id` (repeatable), `--force-export` | +| `export-info` | `/prompt-studio/export/{tool_id}` | GET | `--tool-id`\* (204 if never exported) | +| `file upload` | `/prompt-studio/file/{tool_id}` | POST | `--tool-id`\*, `--file`\* (repeatable) | +| `file get` | `/prompt-studio/file/{tool_id}` | GET | `--tool-id`\*, `--document-id`\*, `--view-type {ORIGINAL,EXTRACT,SUMMARIZE}` | +| `file delete` | `/prompt-studio/file/{tool_id}` | DELETE | `--tool-id`\*, `--document-id`\* | +| `prompt create` | `/prompt-studio/prompt-studio-prompt/{tool_id}/` | POST | `--tool-id`\* (**also sent in the body**, see note), `--prompt-key`\*, `--enforce-type {text,number,email,date,boolean,json,line-item,table}` (`date` is locale-ambiguous — see note), `--prompt`, `--sequence-number`, `--prompt-type {PROMPT,NOTES}`, `--active` | +| `prompt get/update/patch/delete` | `/prompt-studio/prompt/{prompt_id}/` | GET/PUT/PATCH/DELETE | `--prompt-id`\* + fields above | +| `prompt reorder` | `/prompt-studio/prompt/reorder/` | POST | `--start-sequence-number`\*, `--end-sequence-number`\*, `--prompt-id`\* | +| `profile list` | `/prompt-studio/prompt-studio-profile/{tool_id}/` | GET | `--tool-id`\* | +| `profile set-default` | `/prompt-studio/prompt-studio-profile/{tool_id}/` | PATCH | `--tool-id`\*, `--default-profile`\* | +| `profile create` | `/prompt-studio/profilemanager/{tool_id}` | POST | `--tool-id`\*, `--profile-name`\*, `--vector-store`\*, `--embedding-model`\*, `--llm`\*, `--x2text`\*, `--chunk-size` (**`0` = whole-document / no-RAG**; see note), `--chunk-overlap`, `--retrieval-strategy {simple,subquestion,fusion,recursive,router,keyword_table,automerging}`, `--similarity-top-k` (max 4 profiles/project) | +| `profile get/update/patch/delete` | `/prompt-studio/profile-manager/{profile_id}/` | GET/PUT/PATCH/DELETE | `--profile-id`\* + fields above | +| `index-document` | `/prompt-studio/index-document/{tool_id}` | POST | `--tool-id`\*, `--document-id`\* | +| `fetch-response` | `/prompt-studio/fetch_response/{tool_id}` | POST | `--tool-id`\*, `--document-id`\*, `--id`\* (prompt), `--run-id`, `--profile-manager` (may be needed even when a default appears set), `--wait` | +| `single-pass` | `/prompt-studio/single-pass-extraction/{tool_id}` | POST | `--tool-id`\*, `--document-id`\*, `--run-id`, `--wait` | +| `task-status` | `/prompt-studio/{tool_id}/task-status/{task_id}` | GET | `--tool-id`\*, `--task-id`\* — status only (`processing`/`completed`/`failed`); the value lands in the Output Manager | +| `output list` | `/prompt-studio/prompt-output/` | GET | `--tool-id`\*, `--prompt-id`, `--document-id`, `--profile-id`, `--is-single-pass-extract` — reads extraction results (backend-sourced, no `.mdx`) | +| `output latest` | `/prompt-studio/prompt-output/latest-by-keys/` | GET | `--tool-id`\* — latest output per prompt key (may return `{}`; prefer `output list`) | +| `users` | `/prompt-studio/users/{tool_id}` | GET | `--tool-id`\* | +| `check-deployment-usage` | `/prompt-studio/{tool_id}/check_deployment_usage/` | GET | `--tool-id`\* | +| `select-choices` | `/prompt-studio/select_choices/` | GET | — | +| `adapter-choices` | `/prompt-studio/adapter-choices/` | GET | — | +| `retrieval-strategies` | `/prompt-studio/{tool_id}/get_retrieval_strategies/` | GET | `--tool-id`\* | + +> **`prompt create` sends `tool_id` in the body as well as the path.** The backend's `create_prompt` persists the request body verbatim and ignores the URL `pk`, so a `tool_id` absent from the body is saved as `NULL` — the prompt exists but links to no project and is unreachable (returns 201 with `tool_id: null`, then a 404 on read). The CLI mirrors the path value into the body to link it correctly, and treats a 201 whose response `tool_id` is null as a failure (exit `8`, §5.4) rather than reporting success. The real fix is a backend change. +> +> **`prompt create` also takes `--profile-manager`.** `fetch_response` resolves a prompt's LLM profile from the prompt's own `profile_manager` field and does **not** fall back to the project's default profile, unlike `index-document` and `single-pass`, which both do. A prompt created without one is therefore unrunnable, and fails with a message naming the *project* default — which is genuinely set, making the error actively misleading. Setting it at creation avoids the trap. The backend fix is to fall back to `ProfileManager.get_default_llm_profile(tool)` when the prompt's field is null, matching the two sibling paths; the flag then becomes convenience rather than a requirement. +> +> **`--chunk-size 0` means whole-document / no-RAG.** It skips embedding and the vector DB entirely — the correct mode for short documents, and the escape hatch when the vector DB is unreachable (indexing otherwise 500s on a dead vector DB even for a one-page file). Set `--chunk-overlap 0` alongside it. `--vector-store` and `--embedding-model` stay **required** even so: both columns are `NOT NULL` server-side, so the values are stored but never queried. Pass any valid adapter id. Making them genuinely optional requires a backend change (nullable columns). +> +> **`--enforce-type date` is locale-ambiguous.** Date normalization reads `DD/MM/YYYY` as `MM/DD/YYYY` (`01/08/2025` → `2025-01-08`). For non-US date sources prefer `--enforce-type text`. +> +> **Retrieving results.** `fetch-response` / `single-pass` return `202 {task_id, run_id, status:accepted}` and never return the value directly — it lands in the Output Manager. Read it with `output list` (or `output latest`), or use `--wait` (§3.1) to poll and return it in one call. + +#### Workflows — `unstract docstudio platform workflow` + +| Command | Endpoint | Method | Key parameters | +| --- | --- | --- | --- | +| `list` | `/workflow/` | GET | `--project`, `--workflow-owner`, `--is-active`, `--order-by {asc,desc}` | +| `create` | `/workflow/` | POST | `--workflow-name`\* (≤128, unique/org), `--description` (≤490), `--deployment-type {DEFAULT,ETL,TASK,API,APP}`, `--source-settings` (JSON), `--destination-settings` (JSON), `--max-file-execution-count` (≥1), `--shared-to-org`, `--shared-users` | +| `get` / `update` / `patch` / `delete` | `/workflow/{id}/` | GET/PUT/PATCH/DELETE | `--id`\* + fields above (update/delete require owner) | +| `execute` | `/workflow/execute/` | POST | `--workflow-id`\*, `--execution-action {START,NEXT,STOP,CONTINUE}`, `--execution-id` (required for NEXT/STOP/CONTINUE), `--log-guid`, `--file` (repeatable) | +| `toggle-active` | `/workflow/active/{id}/` | PUT | `--id`\* | +| `can-update` | `/workflow/{id}/can-update/` | GET | `--id`\* | +| `clear-file-marker` | `/workflow/{id}/clear-file-marker/` | GET | `--id`\* (mutating GET — flagged in help) | +| `schema` | `/workflow/schema/` | GET | `--type {src,dest}` (default `src`), `--entity {file,api,db}` (default `file`) | +| `users` | `/workflow/{id}/users/` | GET | `--id`\* | +| `execution list` | `/workflow/{id}/execution/` | GET | `--id`\* | +| `execution get` | `/workflow/execution/{id}/` | GET | `--id`\* | +| `execution logs` | `/workflow/execution/{id}/logs/` | GET | `--id`\*, `--file-execution-id` (literal `null` for non-file logs), `--log-level {DEBUG,INFO,WARN,ERROR}`, `--ordering`, `--page`, `--page-size` | +| `file-history list` | `/workflow/{workflow_id}/file-histories/` | GET | `--workflow-id`\*, `--status` (CSV), `--execution-count-min/max`, `--file-path` (prefix), `--page`, `--page-size` | +| `file-history get/delete` | `/workflow/{workflow_id}/file-histories/{id}/` | GET/DELETE | `--workflow-id`\*, `--id`\* | +| `file-history clear` | `/workflow/{workflow_id}/file-histories/clear/` | POST | `--workflow-id`\*, ≥1 of: `--ids` (≤100), `--status`, `--execution-count-min/max`, `--file-path` | + +#### Workflow assembly — attach a tool and configure endpoints (deployment prerequisites) + +A workflow is only deployable once a tool is attached and both of its endpoints are set to `API`. These commands expose that assembly, which previously required direct API calls. **These endpoints have no public v1 docs page**; their `doc_source` cites the backend routes (like API Hub, §8.4). + +| Command | Endpoint | Method | Key parameters | +| --- | --- | --- | --- | +| `tool registry list` | `/tool/` | GET | — (each tool's `function_name` is the registry id used below; **not** the Prompt Studio `tool_id`) | +| `tool registry settings-schema` | `/tool_settings_schema/` | GET | `--function-name`\* (reveals required adapter settings, e.g. `challenge_llm`) | +| `workflow tool list` | `/tool_instance/` | GET | `--workflow` (filter) | +| `workflow tool get` | `/tool_instance/{id}/` | GET | `--id`\* (read `metadata` before patching it) | +| `workflow tool add` | `/tool_instance/` | POST | `--workflow`\*, `--tool`\* (registry id) — a workflow holds ≤1 tool; **set the default triad first** (below); attaching activates the workflow | +| `workflow tool set-metadata` | `/tool_instance/{id}/` | PATCH | `--id`\*, `--metadata`\* (JSON; **REPLACES** wholesale — send the complete object, accepts `@file.json`) | +| `workflow tool remove` | `/tool_instance/{id}/` | DELETE | `--id`\* (needs `full_access` — `read_write` cannot delete a tool instance even though it can create one) | +| `workflow endpoint list` | `/workflow/endpoint/` | GET | `--workflow`, `--endpoint-type {SOURCE,DESTINATION}`, `--connection-type` | +| `workflow endpoint set` | `/workflow/endpoint/{id}/` | PATCH | `--id`\*, `--connection-type {API,FILESYSTEM,DATABASE,MANUALREVIEW}`\* | + +> **The deploy-a-tool sequence.** `export-tool` (§Prompt Studio) publishes the project to the registry → `tool registry list` gives its `function_name` → `workflow create` (auto-creates both endpoints, `connection_type` null) → `adapter default-triad set` (**must precede** `tool add`, see below) → `workflow tool add` → `workflow endpoint set` both SOURCE and DESTINATION to `API` → `api-deployment create` → `api-deployment key create` → `deployment run`. For an API deployment, endpoints set to `API` (or `MANUALREVIEW`) need no connector/credentials. +> +> **Ordering trap — set the default triad first (CAPTURE2 GAP 3).** `workflow tool add` seeds the tool instance's adapter settings from the org **default triad**. With no triad set, creation returns HTTP 500 *but still persists a half-configured row*; a retry then 400s with "can't have more than one tool." Run `adapter default-triad set` before `workflow tool add`. +> +> **`challenge_llm` trap (CAPTURE2 BUG 4).** An exported tool's settings schema may list `challenge_llm` as required even when the project has `enable_challenge=false`. A tool instance whose `metadata.challenge_llm` is empty then fails deploy-time validation with an opaque enum error (visible only in `workflow execution logs`). Fix it with a read-modify-write: `workflow tool get` → add a valid LLM adapter id (from `settings-schema`'s enum) to the **existing** `metadata` object → `workflow tool set-metadata --metadata @edited.json`. `set-metadata` **replaces** metadata wholesale (the backend does not merge), so sending only the one key would wipe `prompt_registry_id` and orphan the tool — always send the complete object. +> +> **Not shipped: a one-shot `deploy-api` convenience.** CAPTURE2 floated a single command that runs the whole sequence. Deliberately deferred: the individual commands above make the flow CLI-only, and a mega-command would bury the ordering/preflight decisions (triad, endpoint config) that the user needs to see. Revisit if the assembly proves rote in practice. + +#### API Deployments (management) — `unstract docstudio platform api-deployment` + +| Command | Endpoint | Method | Key parameters | +| --- | --- | --- | --- | +| `list` | `/api/deployment/` | GET | `--workflow`, `--search`, `--page`, `--page-size` | +| `create` | `/api/deployment/` | POST | `--workflow`\*, `--display-name` (≤30), `--description` (≤255), `--api-name` (`^[a-zA-Z0-9_-]+$`, ≤30, unique/org), `--is-active`, `--shared-to-org`, `--shared-users` — returns `api_key`, one active deployment per workflow | +| `get` / `update` / `patch` / `delete` | `/api/deployment/{id}/` | GET/PUT/PATCH/DELETE | `--id`\* (owner only for writes) | +| `users` | `/api/deployment/{id}/users/` | GET | `--id`\* | +| `by-prompt-studio-tool` | `/api/deployment/by-prompt-studio-tool/` | GET | `--tool-id`\* | +| `postman-collection` | `/api/postman_collection/{id}/` | GET | `--id`\*, `--save` (409 if no active key) | +| `key list` / `key create` | `/api/keys/api/{api_id}/` | GET / POST | `--api-id`\*; create: exactly one of `--api` / `--pipeline`, `--description` (≤255), `--is-active` | +| `key get` / `key update` / `key delete` | `/api/keys/{id}/` | GET/PUT/DELETE | `--id`\*, `--is-active`, `--description` | + +#### ETL / Task Pipelines — `unstract docstudio platform pipeline` + +| Command | Endpoint | Method | Key parameters | +| --- | --- | --- | --- | +| `list` | `/pipeline/` | GET | `--type {ETL,TASK,DEFAULT,APP}`, `--workflow`, `--search`, `--ordering {created_at,last_run_time,pipeline_name,run_count}` (`-` prefix = desc), `--page`, `--page-size` | +| `create` | `/pipeline/` | POST | `--pipeline-name`\* (≤32, unique/org), `--workflow`\*, `--pipeline-type`, `--cron-string` (min interval enforced), `--shared-users`, `--shared-to-org` | +| `get` / `update` / `patch` / `delete` | `/pipeline/{id}/` | GET/PUT/PATCH/DELETE | `--id`\*; PATCH also `--active` | +| `execute` | `/pipeline/execute/` | POST | `--pipeline-id`\*, `--execution-id` | +| `executions` | `/pipeline/{id}/executions/` | GET | `--id`\*, `--start-date`, `--end-date` (ISO 8601), `--page`, `--page-size` | +| `users` | `/pipeline/{id}/users/` | GET | `--id`\* | +| `postman-collection` | `/pipeline/api/postman_collection/{id}/` | GET | `--id`\*, `--save` (400 if no active key) | +| `key list` / `key create` | `/api/keys/pipeline/{pipeline_id}/` | GET / POST | `--pipeline-id`\*; create: exactly one of `--pipeline` / `--api`, `--description`, `--is-active` | + +#### Adapters — `unstract docstudio platform adapter` + +| Command | Endpoint | Method | Key parameters | +| --- | --- | --- | --- | +| `supported` | `/supported_adapters/` | GET | `--adapter-type {LLM,EMBEDDING,VECTOR_DB,X2TEXT,OCR}`\* | +| `schema` | `/adapter_schema/` | GET | `--id`\* (SDK identifier, e.g. `openai_llm`) | +| `test` | `/test_adapters/` | POST | `--adapter-id`\*, `--adapter-metadata`\* (JSON), `--adapter-type`\* | +| `list` | `/adapter/` | GET | `--adapter-type` | +| `create` | `/adapter/` | POST | `--adapter-name`\* (≤128), `--adapter-id`\*, `--adapter-type`\*, `--adapter-metadata`\* (JSON, encrypted at rest), `--description`, `--shared-to-org` | +| `get` | `/adapter/{id}/` | GET | `--id`\* (returns decrypted metadata) | +| `update` / `patch` / `delete` | `/adapter/{id}/` | PUT/PATCH/DELETE | `--id`\*; PATCH also `--shared-users` (replaces list); delete 409 if in use, 500 if default | +| `info` | `/adapter/info/{id}/` | GET | `--id`\* (includes `context_window_size`) | +| `users` | `/adapter/users/{id}/` | GET | `--id`\* | +| `default-triad get` | `/adapter/default_triad/` | GET | — | +| `default-triad set` | `/adapter/default_triad/` | POST | `--llm-default`, `--embedding-default`, `--vector-db-default`, `--x2text-default` (note: request keys differ from response keys) | + +#### Connectors — `unstract docstudio platform connector` + +| Command | Endpoint | Method | Key parameters | +| --- | --- | --- | --- | +| `supported` | `/supported_connectors/` | GET | `--type {INPUT,OUTPUT}`, `--connector-mode {FILE_SYSTEM,DATABASE}` | +| `schema` | `/connector_schema/` | GET | `--id`\* | +| `test` | `/test_connectors/` | POST | `--connector-id`\*, `--connector-metadata`\* (JSON) | +| `list` | `/connector/` | GET | `--workflow`, `--created-by`, `--connector-type`, `--connector-mode` | +| `create` | `/connector/` | POST | `--connector-name`\* (≤128), `--connector-id`\*, `--connector-metadata` (JSON), `--connector-version`, `--shared-to-org`, `--shared-users`, `--oauth-key` (query) | +| `get` / `update` / `patch` / `delete` | `/connector/{id}/` | GET/PUT/PATCH/DELETE | `--id`\*; delete 409 if used by a workflow | +| `oauth-cache-key` | `/api/v1/oauth/cache-key/{backend}` | GET | `--backend`\* (e.g. `google-oauth2`); **not org-scoped** | + +#### Groups, users, sharing + +| Command | Endpoint | Method | Key parameters | +| --- | --- | --- | --- | +| `group list` / `group create` | `/groups/` | GET / POST | `--name`\* (unique/org), `--description` | +| `group patch` / `group delete` | `/groups/{id}/` | PATCH / DELETE | `--id`\* (**int**, not UUID); DELETE needs `full_access` | +| `group member list` / `add` | `/groups/{id}/members/` | GET / POST | `--id`\*; add: `--user-ids`\* (ints, idempotent) | +| `group member remove` | `/groups/{id}/members/{user_id}` | DELETE | `--id`\*, `--user-id`\* (no trailing slash; needs `full_access`) | +| `group resources` | `/groups/{id}/resources/` | GET | `--id`\* | +| `user list` | `/users/` | GET | — (`id` returned as **string**; cast to int for `shared_users`) | +| `share` | `/{resource}/{id}/share/` | POST | `--resource`\* (**enum**, see below), `--id`\*, `--shared-users`, `--shared-groups`, `--shared-to-org` (each axis is **replace**, not append) | + +> **`--resource` is an enum, never free text.** The URL path segment is not guessable from the friendly resource name, so an agent passing `api-deployment` would get a 404. The flag accepts friendly names and maps them to exact path segments, with the mapping enumerated in `--help` and `--discover`: +> +> | `--resource` value | Path segment | +> | --- | --- | +> | `adapter` | `adapter` | +> | `connector` | `connector` | +> | `workflow` | `workflow` | +> | `pipeline` | `pipeline` | +> | `api-deployment` | `api/deployment` | +> | `prompt-studio` | `prompt-studio` | + +> **Sharing semantics.** `shared_users` replaces rather than appends. The CLI provides `--add-user` / `--remove-user` convenience flags that read current state first and send the merged list, and documents the replace behaviour of the raw flag in help text. + +### 6.4 Human Quality Review (Enterprise) + +Base: `{host}/mr/api/{org_id}` · Auth: `Authorization: Bearer` + +| Command | Endpoint | Method | Parameters | +| --- | --- | --- | --- | +| `hitl approved get` | `/approved/result/{class_id}/` | GET | `--class-id`\*, `--hitl-queue-name`, `--save` — **dequeue: consumes one item per call** | +| `hitl bulk-download` | `/approved/result/{class_id}/` | GET | `--class-id`\*, `--page` (default 1), `--page-size` (1–500, default 50), `--download-files` (bool), `--email` (async notification) | +| `hitl download-status` | `/approved/download-status/{job_id}/` | GET | `--job-id`\* | + +Pushing to HITL is `unstract docstudio deployment run --hitl-queue-name ` (§6.2), which returns `QUEUED` + `execution_id`. + +### 6.5 API Hub (Verticals) + +Auth: `apikey: ` (Kong gateway). Optional passthrough: `--llmwhisperer-key` → `X-LLMWhisperer-API-Key`, `--anthropic-key` → `X-Anthropic-API-Key` (bring-your-own-key). Subscription/user headers are gateway-injected and never sent by the CLI (§4.4). +**Source of truth: code + Postman collections, not public docs** (see §8.4). + +| Command | Endpoint | Method | Parameters | +| --- | --- | --- | --- | +| `apihub extract` | `/api/v1/extract` | POST | `--vertical`\* (e.g. `table`), `--sub-vertical`\* (`bank_statement`, `discover_tables`, `extract_table`), `--file` \| `--use-cached-file-hash`; **conversion passthrough** (`conv_*`): `--conv-mode` (default `high_quality`), `--conv-output-mode` (default `layout_preserving`), `--conv-lang`, `--conv-tag`, `--conv-filename`, `--conv-page-separator`, `--conv-pages-to-extract`, `--conv-median-filter-size`, `--conv-gaussian-blur-radius`, `--conv-mark-vertical-lines`, `--conv-mark-horizontal-lines`, `--conv-line-splitter-strategy`, `--conv-line-splitter-tolerance`, `--conv-horizontal-stretch-factor`; **extraction** (`ext_*`): `--ext-section-name`, `--ext-compress-double-space`, `--ext-headers`, `--ext-start-page`, `--ext-end-page`, `--ext-page-filter-strategy`, `--ext-use-bank-schema`, `--ext-pattern {generic_table,indent_as_groups}`, `--ext-table-no`, `--ext-cache-result`, `--ext-cache-text` | +| `apihub status` | `/api/v1/status` | GET | `--file-hash`\* | +| `apihub retrieve` | `/api/v1/retrieve` | GET | `--file-hash`\*, `--output-mode {raw,full}` (default `full`), `--sub-vertical`, `--save` | +| `apihub doc-splitter upload` | `/doc-splitter/documents/upload` | POST | `--file`\* | +| `apihub doc-splitter status` | `/doc-splitter/jobs/status` | GET | `--job-id`\* | +| `apihub doc-splitter download` | `/doc-splitter/jobs/download` | GET | `--job-id`\*, `--save` | + +Statuses: `QUEUED_FOR_WHISPER` → `QUEUED_FOR_EXTRACTION` → `COMPLETED`. The `conv_*` prefix maps onto LLMWhisperer parameters; `ext_*` parameters are forwarded verbatim to the vertical worker, so the CLI accepts an escape hatch `--ext-param KEY=VALUE` (repeatable) for parameters newer than the CLI. + +--- + +## 7. Architecture + +``` +unstract_cli/ +├── __main__.py # entry point +├── app.py # Click root group; command tree built from definitions +├── config/ +│ ├── profile.py # profile model, resolution order (flag > env > file > default) +│ └── loader.py # TOML load/save, env: indirection, permission checks +├── core/ +│ ├── http.py # request execution, retry/backoff, redaction +│ ├── errors.py # HTTP status → exit code + structured error mapping +│ ├── output.py # json/yaml/table/raw renderers, TTY detection +│ ├── poll.py # --wait state machines (body-status based, never HTTP code) +│ └── generate.py # endpoint definition → Click command +├── endpoints/ # ← SINGLE SOURCE OF TRUTH (the Skill's edit target) +│ ├── whisper.py +│ ├── deployment.py +│ ├── platform_prompt_studio.py +│ ├── platform_workflow.py +│ ├── platform_deployment.py +│ ├── platform_pipeline.py +│ ├── platform_adapter.py +│ ├── platform_connector.py +│ ├── platform_groups.py +│ ├── hitl.py +│ └── apihub.py +└── skills/ # bundled Claude Skill (§8) +``` + +### 7.1 Endpoint definition shape + +```python +Endpoint( + name="extract", + group="whisper", + method="POST", + path="/whisper", + product="llmwhisperer", + summary="Convert a document to LLM-ready text.", + doc_source="llmwhisperer-docs/docs/llm_whisperer/apis/whisper.md", # skill anchor + params=[ + Param("mode", str, default="form", + choices=["native_text", "low_cost", "high_quality", "form", "table"], + location="query", help="Processing mode."), + Param("word_confidence_threshold", float, default=0.3, location="query", + help="Minimum OCR confidence (0-1); works only with form/high_quality/table."), + ... + ], + body="binary_file", + returns="whisper_hash", + poll=PollSpec(status_endpoint="whisper.status", + terminal_success=("processed",), terminal_failure=("error",), + handle_field="whisper_hash", handle_param="whisper_hash", + retrieve_endpoint="whisper.retrieve", one_shot=True), +) +``` + +`PollSpec.status_field` may be a single field name **or a tuple of candidates tried in order**, because the run POST and the status GET can spell the same state differently — the deployment poll uses `("status", "execution_status")` so the status GET's top-level `status` wins while the run POST's nested `execution_status` still resolves (§6.2). A mismatch here silently consumes a one-shot result; the tuple is what prevents it. + +`PollSpec` also carries the fields that make a two-step retrieve work when the result store is not keyed by the poll handle: `retrieve_carry` forwards identifiers from the *original* request into the retrieve call (each entry a py_name, or a `(source, dest)` pair that renames it), `retrieve_extra` injects a constant flag the original request did not carry, and `retrieve_omits_handle` drops the handle from a retrieve endpoint that has no such parameter. Prompt Studio's `--wait` uses all three: it reads the Output Manager by the original `tool_id`, renames `id → prompt_id`, and omits the `task_id` handle (§3.1, §6.3). When `one_shot=True` and no `retrieve_endpoint` is set, the poll that first reaches a terminal state *is* the retrieve: its body is returned as the result and no second read is issued (deployment run, §6.2). + +Records may also mirror a PATH identifier into the body — `Param.mirror_to_body` under the same name, or `Param.mirror_as=""` where the body spells the identifier differently — and assert non-null response fields on success (`Endpoint.require_response_fields`). `prompt create` uses the first pair to defeat the tool-linking defect (§6.3); `api-deployment key create` uses the rename, sending its `api_id` path value as the body's `api` so the caller supplies one identifier rather than the same value twice. `Param.default_from` accepts a whitespace-separated **fallback chain** resolved left to right (`"deployment.org_id platform.org_id"`), for settings that legitimately live in two blocks where one is typically empty; credentials deliberately do not chain, since borrowing a key across API groups would be credential confusion. `Constraint` also covers conditional requirement (`RequiredUnless`) alongside `MutuallyExclusive` / `AtLeastOneOf`. `ParamType.JSON` values are parsed automatically (inline or `@file.json`, §6). + +The command tree, `--help`, validation, `--discover`, and the docs-diff in §8 all derive from these records. Adding an endpoint means adding one record — no separate command wiring, so help text cannot drift from behaviour. + +> **Note (doc drift, pre-existing):** the file tree above lists per-resource modules (`platform_prompt_studio.py`, `platform_workflow.py`, …); the implementation consolidated the Platform surface into a single `endpoints/platform.py`. This predates the changes described here and is left as-is pending a broader §7 refresh. + +--- + +## 8. Claude Skill: `update-unstract-cli` + +Location: `unstract-cli/.claude/skills/update-unstract-cli/SKILL.md` + +### 8.1 Purpose + +Keep the CLI's endpoint definitions synchronized with the public API documentation by cross-referencing the docs repos, detecting drift, and applying the corresponding edits to `endpoints/`. + +### 8.2 Inputs + +| Source | Repo / path | Covers | +| --- | --- | --- | +| LLMWhisperer docs | `llmwhisperer-docs/docs/llm_whisperer/apis/*.md` | §6.1 | +| Unstract API deployment docs | `unstract-docs/docs/unstract_platform/api_deployment/*.md` | §6.2 | +| Unstract Platform v1 docs | `unstract-docs/docs/unstract_platform/api_documentation/versions/v1-*.mdx` | §6.3 | +| HITL docs | `unstract-docs/docs/unstract_platform/human_quality_review/*.md` | §6.4 | +| API Hub source | `unstract-verticals/src/api_v1/api.py`, `verticals-portal/portal/postman-collection/*.json` | §6.5 (no public docs — see §8.4) | + +Repo paths are configurable; the Skill falls back to the GitHub API when a repo is not checked out locally. + +### 8.3 Procedure + +1. **Parse the documentation.** Extract endpoint tables from Markdown and `` / `` MDX components into a normalized set of `(method, path, params[])` records. Each `Endpoint.doc_source` anchors a definition to its documentation file. +2. **Parse the CLI definitions.** Load `endpoints/*.py` into the same normalized shape. +3. **Diff** on three axes: + - endpoints present in docs but missing from the CLI (**new capability**); + - endpoints in the CLI but absent from docs (**possible removal or deprecation** — report, never auto-delete); + - per-parameter drift: added, removed, renamed, or changed type / default / enum / required-ness. A documented parameter supplied by mirroring (`Param.mirror_as`, §7.1) is **not** drift — it reaches the wire under its documented name without a flag of its own. A record carrying `doc_conflict` is exempt from this axis as well as the previous one, so a deliberately dropped parameter is not re-reported every run. +4. **Report** as a structured table before editing: each difference with its doc citation (file + heading) and the proposed CLI change. +5. **Apply** by editing `endpoints/*.py` only. Because commands are generated (§7.1), no command-wiring, help-text, or `--discover` changes are needed — this is why the diff can be applied mechanically. +6. **Verify:** run `unstract --discover` and confirm it parses and includes the new surface; run the test suite; run `ruff`/`mypy`. +7. **Summarize:** what changed, what needs human judgement (renames, breaking changes), and what was intentionally skipped. + +### 8.4 Known limitation — API Hub + +API Hub has **no public documentation site**. The Skill cannot cross-reference it against public docs and instead diffs against `unstract-verticals/src/api_v1/api.py` (`request.args.get(...)` calls) and the Postman collections in `verticals-portal`. This is lower-fidelity: it recovers parameter names and defaults but not prose descriptions. The Skill must **flag API Hub changes for human review** rather than applying them silently, and the `--ext-param KEY=VALUE` escape hatch (§6.5) exists precisely so agents are not blocked by this lag. + +### 8.5 Safety rules + +- Never delete a command solely because it is absent from the docs — documentation lags implementation. Report and let a human decide. +- Never invent parameters not present in a source; every change cites a specific file and heading. +- Treat renames as breaking: propose an alias with a deprecation note rather than a silent rename. +- Preserve hand-written `help` text where it is richer than the docs; add rather than overwrite. +- **Docs are not infallible.** Where an endpoint index and its detail page disagree, prefer the detail page and the official client libraries, and check §11 "Resolved during drafting" for known documentation defects before proposing a change. A definition may carry a `doc_conflict` note recording a deliberate divergence; the Skill must not revert one without explicit human confirmation. Such a note covers a dropped parameter as well as a divergent path — `api-deployment key create` omits the documented `api`/`pipeline` body flags because the route already fixes the resource and the value is mirrored from the path (§7.1). + +--- + +## 9. Testing + +| Layer | Approach | +| --- | --- | +| Definition integrity | Every `Endpoint` has a unique `(group, name)`, valid `doc_source`, and complete param metadata | +| Command generation | Tree builds; `--help` renders at every level; `--discover` is valid JSON and covers every endpoint | +| HTTP | `respx`/`responses` fixtures per endpoint using the documented sample payloads | +| Exit codes | Table-driven: each HTTP status maps to the §5.4 code | +| Polling | `--wait` reaches terminal state; branches on **body status**, not HTTP code (asserted explicitly against the 422 defect). Prompt Studio `--wait` polls `task-status` then retrieves from the Output Manager keyed on the original `tool_id`, narrowed to the run's prompt + document. Deployment `--wait` recognises the status GET's top-level `status`, returns the terminal poll's body on the *first* terminal poll (asserted `call_count == 1` — no double-consume), and `run --wait --save` persists it | +| One-JSON-document output | A response body of several concatenated JSON objects is recovered into a single array, so `\| json` never breaks; a genuinely malformed body falls back to raw text | +| JSON parameters | A `ParamType.JSON` flag resolves to a nested object in the body (not a quoted string); accepts inline JSON and `@file.json`; invalid JSON exits `2`; a FORM-located JSON field is re-serialized to a string so multipart encoding still works | +| One-shot semantics | Second retrieve yields exit `9`; `--save` persists before exit | +| Error stream | On failure with stdout not a TTY, the structured envelope appears on **both** stdout and stderr; on a TTY, stderr only. Covers our errors, Click's own parse errors (subprocess-tested at the real entry point), and `--discover` no-match | +| Response guards | A 2xx that leaves a `require_response_fields` field null (e.g. `prompt create` → `tool_id`) is treated as a failure (exit `8`), not success | +| Redaction | No secret appears in any output stream, including `--dry-run`, `-vv`, and `config doctor` | +| Non-interactivity | No command reads stdin unless explicitly asked (`--file -`); no TTY prompts | + +--- + +## 10. Delivery phases + +| Phase | Scope | +| --- | --- | +| **1** | Core: config/profiles, HTTP layer, output/errors/exit codes, definition→command generation, `--discover` | +| **2** | LLMWhisperer (§6.1) + `--wait` + one-shot handling — smallest complete product surface | +| **3** | API Deployments runtime (§6.2) + HITL (§6.4) | +| **4** | Platform Management v1 (§6.3) — largest surface | +| **5** | API Hub (§6.5) incl. `--ext-param` escape hatch | +| **6** | Claude Skill (§8), packaging, shell completions | + +--- + +## 11. Open questions + +1. **API Hub public base URL.** Tenancy is resolved (§4.4): callers send `apikey`; Kong injects subscription/user headers from Redis. The externally routable hostname still needs confirmation from the deployment charts — `--base-url` / `UNSTRACT_APIHUB_BASE_URL` is required until then. +2. **Platform API `full_access` permission level.** The v1 overview lists `read`, `read_write`, `full_access`, but the Platform-Keys page exposes only `read` and `read_write` in its UI enum. Since `DELETE` commands require `full_access`, confirm whether it is UI-creatable — if not, every `delete` command is unusable with a UI-issued key and help text must say so. + +### Resolved during drafting + +- **Multi-doc chat APIs are excluded from v1** (confirmed). `/md/file/upload`, `/md/file/search`, and `/md/chat` (base `https://us-central.unstract.com/api/v1`, auth `Authorization: Bearer `) are documented but carry `draft: true`. Draft endpoints may change without notice, and including them would make the update-Skill churn against an unstable contract. When the draft flag is lifted, they belong under a new `unstract chat {upload,search,ask}` group. **The Skill must not auto-add these endpoints** while `draft: true` is present in their front matter — treat the flag as an exclusion marker. +- **`/whisper-detail` vs `/whisper-details`.** The docs index says `/whisper-details`; the endpoint page and the official `llm-whisperer-python-client` (`client_v2.py:349`) both use **`/whisper-detail`** (singular). Pinned to the singular form; the docs index is wrong, and the Skill must not "correct" this back from the index page. diff --git a/install.sh b/install.sh deleted file mode 100755 index d5c24d9..0000000 --- a/install.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/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 - -SOURCE="${UNSTRACT_CLI_SOURCE:-unstract-cli}" - -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 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 - -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. -# A pre-release is taken only while no stable release exists. -uv tool install --force --prerelease if-necessary "$SOURCE" - -if command -v unstract >/dev/null 2>&1; then - echo - unstract --version 2>/dev/null || true - echo "Run 'unstract auth login' to get started." >&2 - exit 0 -fi - -cat >&2 <=8.1,<9", - # Writing the config file only; reading it uses the stdlib `tomllib`. + # httpx owns TLS, redirects, connection reuse and -- the part with no stdlib + # equivalent -- multipart/form-data encoding for file uploads. Kept because + # replacing it means hand-writing unaudited network code (see the note in the + # README/SPEC on why the dependency set stops here rather than at zero). + "httpx>=0.27", + # Zero transitive dependencies each. pyyaml backs `--output yaml`; tomli-w + # writes the config file (reading uses the stdlib `tomllib`). + "pyyaml>=6.0", "tomli-w>=1.0", - # 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.7.1", - "llmwhisperer-client==2.10.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.urls] -Homepage = "https://unstract.com" -Documentation = "https://docs.unstract.com/unstract/unstract_platform/cli/unstract_cli/" -Repository = "https://github.com/Zipstack/unstract-cli" -Issues = "https://github.com/Zipstack/unstract-cli/issues" -Changelog = "https://github.com/Zipstack/unstract-cli/releases" - [project.optional-dependencies] dev = [ "pytest>=8.0", + "pytest-cov>=5.0", + "respx>=0.21", "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", + "mypy>=1.11", + "types-PyYAML", ] [project.scripts] unstract = "unstract_cli.__main__:main" -# 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" -[tool.hatch.version] -path = "src/unstract_cli/__init__.py" - [tool.hatch.build.targets.wheel] packages = ["src/unstract_cli"] @@ -74,7 +54,21 @@ src = ["src", "tests"] [tool.ruff.lint] select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"] -ignore = ["E501"] +ignore = [ + "E501", + # `str, Enum` is deliberate: members must serialise as plain strings in JSON + # output and `--discover`. StrEnum would work on 3.12 but the explicit + # form documents the intent and matches the sibling repos. + "UP042", +] + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +check_untyped_defs = true +ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["src"] diff --git a/src/unstract_cli/__init__.py b/src/unstract_cli/__init__.py index 39bfcdc..add0f52 100644 --- a/src/unstract_cli/__init__.py +++ b/src/unstract_cli/__init__.py @@ -1,3 +1,9 @@ -"""Unstract CLI.""" +"""Unstract CLI -- one LLM-friendly interface to the three products built by +Unstract: Document Studio, LLMWhisperer and API Hub. -__version__ = "0.1.1" +Unstract is the company name, and stays the name of this CLI. +""" + +from unstract_cli.app import __version__ + +__all__ = ["__version__"] diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py index 04cb5aa..30fcd60 100644 --- a/src/unstract_cli/__main__.py +++ b/src/unstract_cli/__main__.py @@ -1,144 +1,51 @@ -"""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. -""" +"""Console entry point.""" from __future__ import annotations -import contextlib -import os -import re import sys import click -from unstract_cli.app import Context, cli -from unstract_cli.config import ConfigError -from unstract_cli.core.errors import CLIError, ExitCode, set_warning_sink -from unstract_cli.core.output import AgentMode, OutputFormat, emit_error, resolve_format - - -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): - 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 - - -#: The root group's options, including `-o` at the end of a `-qv` cluster. -#: `-p` is deliberately absent: leaf commands declare their own `-p` with a -#: different meaning. -_GLOBAL_VALUED = re.compile(r"^(-[qv]*o|--output|--agent|--config)$") -_GLOBAL_FLAG = re.compile( - r"^(-[qv]*o.+|--output=.*|--agent=.*|--config=.*|-[qv]+|--quiet|--verbose)$" -) +def main() -> int: + """Run the CLI. -def _hoist_globals(argv: list[str]) -> list[str]: - """Move the root group's options ahead of the subcommand. + Runs Click with ``standalone_mode=False`` so that Click's own parse errors + (`No such option`, missing argument) reach us instead of being printed and + swallowed internally. We then render them through the same structured error + envelope every other error uses, additionally on stdout when piped, so a + wrapper feeding stdout to a JSON parser sees valid JSON on the error path too + (DOC 9). - Click stops accepting group options once it has read the subcommand name, - but `-o json` is as natural at the end of a line as at the start. + That mode also changes how ``ctx.exit(code)`` behaves: instead of raising + ``SystemExit``, Click *returns* the code from the invocation. Returning 0 + unconditionally here therefore reported every failure as success -- a + validation error printed ``"exit_code": 2`` in its envelope while the process + exited 0, so `set -e` and any agent branching on the status code saw a pass. + The returned value is the command's real exit code, so it is what we return + (SPEC §5.4). """ - hoisted: list[str] = [] - rest: list[str] = [] - i = 0 - while i < len(argv): - arg = argv[i] - if arg == "--": - rest.extend(argv[i:]) - break - if _GLOBAL_VALUED.match(arg): - if i + 1 == len(argv): - raise click.BadOptionUsage(arg, f"Option '{arg}' requires an argument.") - hoisted.extend(argv[i : i + 2]) - i += 1 - elif _GLOBAL_FLAG.match(arg): - hoisted.append(arg) - else: - rest.append(arg) - i += 1 - return hoisted + rest + from unstract_cli.app import build_cli + from unstract_cli.core.errors import CLIError, ExitCode - -def _format_from_argv(argv: list[str]) -> OutputFormat: - """Resolve the format the same way the parsed run would.""" - if "--" in argv: - argv = argv[: argv.index("--")] - # `-qojson` is `-o json` to Click; unfold it so the scan sees it too. - flat: list[str] = [] - for arg in argv: - joined = re.fullmatch(r"-[qv]*o(.+)", arg) - flat.extend(["-o", joined[1]] if joined else [arg]) - try: - return resolve_format( - _option_from_argv(flat, "--output", "-o"), - _option_from_argv(argv, "--agent") or AgentMode.AUTO, - ) - except CLIError: - # Nothing renders an envelope this early, so a bad value is left for - # Click's own Choice to reject once parsing reaches it. - return resolve_format(None) - - -def main(argv: list[str] | None = None) -> int: - args = list(sys.argv[1:] if argv is None else argv) - # Guessed from argv, then corrected by the root callback: a failure after - # parsing has to render in the format the run actually resolved. - ctx = Context(output=_format_from_argv(args)) try: - cli.main(args=_hoist_globals(args), standalone_mode=False, obj=ctx) - except CLIError as exc: - return int(emit_error(exc, ctx.output)) - except ConfigError as exc: - return int(emit_error(CLIError(str(exc), ExitCode.USAGE), ctx.output)) + result = build_cli()(standalone_mode=False) + # Click returns whatever `ctx.exit` was given; a command that simply + # falls off the end returns None, which is success. + return result if isinstance(result, int) else 0 except click.UsageError as exc: - return int( - emit_error( - CLIError(exc.format_message(), ExitCode.USAGE, hint="Run with --help."), - ctx.output, - ) - ) - except BrokenPipeError: - # Nowhere left to render the envelope. Python flushes stdout at exit, - # so point it at devnull or this is raised 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: - # A full disk or an unwritable path is the caller's to fix, and they - # still need a parseable envelope rather than a traceback. - return int( - emit_error( - CLIError(str(exc), ExitCode.GENERIC, hint="Check the path and disk."), - 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), ctx.output - ) - ) - except click.exceptions.Exit as exc: # --help and --version exit through here - return int(exc.exit_code) - finally: - # Notes held before a sink was bound still have to reach the user. - set_warning_sink(None) - return int(ExitCode.SUCCESS) - - -if __name__ == "__main__": # pragma: no cover + # Click's human-facing message (with its usage/help hint) to stderr... + exc.show() + # ...and the machine-readable envelope to stdout when piped. + CLIError(exc.format_message(), ExitCode.USAGE).emit_stdout_only() + return exc.exit_code if exc.exit_code is not None else int(ExitCode.USAGE) + except click.ClickException as exc: + exc.show() + return exc.exit_code + except click.exceptions.Abort: + click.echo("Aborted!", err=True) + return 1 + + +if __name__ == "__main__": sys.exit(main()) diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 1c23ce3..5a362d9 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -1,321 +1,563 @@ -"""The root Click application: global options and the command groups. +"""CLI entry point and the machine-readable command index. -Global options are declared once here and reach every command through the Click -context, so no command re-implements profile selection or output formatting. +`--discover` is the feature that makes this CLI usable by an agent without +external documentation (SPEC.md §5.3). It emits the full command tree as JSON, +combining two sources: + +* the **`Endpoint` records**, which carry what Click cannot know -- HTTP method + and path, the documentation file each record was authored from, the required + key permission, and one-shot semantics; +* the **introspected Click tree**, which carries the flags exactly as the parser + will accept them. + +Emitting both means the index describes the *API*, not merely the CLI, and cannot +drift from the commands actually registered. """ from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass, field +import json +import sys from typing import Any import click from unstract_cli.commands.config_cmd import config_group -from unstract_cli.config import ( - DOCSTUDIO, - LLMWHISPERER, - SECRET_SETTINGS, - ConfigError, - ResolvedConfig, - 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 ( - AgentMode, - OutputFormat, - diagnostic, - emit_result, - resolve_format, +from unstract_cli.config.loader import set_config_path +from unstract_cli.core.generate import build_group_tree +from unstract_cli.core.model import Endpoint +from unstract_cli.endpoints import ALL_ENDPOINTS + +__version__ = "0.1.0" + +#: Flags added to every generated command; excluded from the per-command flag +#: listing in `--discover` so the endpoint's own parameters stand out. +_COMMON_FLAGS = frozenset( + { + "output", "profile", "base_url", "dry_run", "quiet", "verbose", + "max_retries", "no_retry", "timeout", "help", + } ) -@dataclass -class Context: - """Everything a command needs from the global options.""" - - output: OutputFormat = OutputFormat.TABLE - quiet: bool = False - verbosity: int = 0 - profile: str | None = None - #: Transport timeout for the clients that have none of their own. - 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) - _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, 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 in SECRET_SETTINGS: - 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.""" - out: list[str] = [] - for product, key in ( - (LLMWHISPERER, "api_key"), - (DOCSTUDIO, "api_key"), - (DOCSTUDIO, "platform_key"), - ): - try: - if value := self.config.get(product, key): - out.append(str(value)) - except (ConfigError, CLIError): - # Unresolvable means unprintable too, and raising would - # replace a finished report with a config error. - continue +def _param_info(param: click.Parameter) -> dict[str, Any]: + """Describe one parameter using Click's own introspection. + + Uses `to_info_dict()` rather than reading private attributes, so the shape is + a supported contract; `tests/test_cli.py` asserts the keys we rely + on still exist, so a Click upgrade fails loudly rather than silently. + + Crucially this reports whether the parameter is an **option** (``--flag x``) + or a positional **argument** (``x``). Getting that wrong makes the index + actively misleading: an agent would construct a command line the parser + rejects. + """ + info = param.to_info_dict() + type_info = info.get("type", {}) + kind = "argument" if info.get("param_type_name") == "argument" else "option" + entry: dict[str, Any] = { + "name": info.get("name"), + "kind": kind, + "type": type_info.get("param_type", "String").lower(), + "required": info.get("required", False), + "multiple": info.get("multiple", False), + "help": info.get("help") or "", + } + if kind == "option": + entry["flags"] = info.get("opts", []) + entry["is_flag"] = info.get("is_flag", False) + else: + # Positional: how it is written on the command line, not a flag name. + entry["usage"] = getattr(param, "metavar", None) or str( + info.get("name", "") + ).upper() + if (default := info.get("default")) is not None: + entry["default"] = default + if choices := type_info.get("choices"): + entry["choices"] = list(choices) + return entry + + +def _usage_line(path: tuple[str, ...], command: click.Command) -> str: + """Reconstruct the invocation form, so the index shows how to *call* it.""" + parts = ["unstract", *path] + for param in command.params: + info = param.to_info_dict() + if info.get("param_type_name") != "argument": + continue + # A variadic argument's own name says nothing useful ("ARGS..."), so + # prefer a declared metavar, which spells out the real shape. Click + # omits metavar from to_info_dict(), so read the attribute directly. + declared = getattr(param, "metavar", None) + name = declared or str(info.get("name", "")).upper() + if not declared and (info.get("multiple") or info.get("nargs", 1) == -1): + name = f"{name}..." + parts.append(name if info.get("required") else f"[{name}]") + if any( + p.to_info_dict().get("param_type_name") != "argument" + and p.name not in _COMMON_FLAGS + for p in command.params + ): + parts.append("[OPTIONS]") + return " ".join(parts) + + +def _endpoint_info(endpoint: Endpoint, command: click.Command) -> dict[str, Any]: + """Combine an endpoint record with its generated command.""" + entry: dict[str, Any] = { + "command": "unstract " + " ".join(endpoint.command_path), + "path": list(endpoint.command_path), + "kind": "endpoint", + "summary": endpoint.summary, + # Which of Unstract's three products this belongs to, and which API + # surface within it. Document Studio owns three API groups. + "product": endpoint.product.value, + "product_name": endpoint.product_label, + "api_group": endpoint.api.value, + "api": {"method": endpoint.method, "path": endpoint.path}, + "usage": _usage_line(endpoint.command_path, command), + "flags": [ + _param_info(p) + for p in command.params + if p.name not in _COMMON_FLAGS + ], + } + if endpoint.description: + entry["description"] = endpoint.description + if endpoint.permission: + entry["required_permission"] = endpoint.permission.value + if endpoint.constraints: + entry["constraints"] = [c.describe() for c in endpoint.constraints] + if endpoint.poll: + entry["supports_wait"] = True + entry["one_shot"] = endpoint.poll.one_shot + if endpoint.doc_source: + entry["doc_source"] = endpoint.doc_source + if endpoint.doc_conflict: + entry["doc_conflict"] = endpoint.doc_conflict + if endpoint.examples: + entry["examples"] = list(endpoint.examples) + if any(p.client_side and p.name == "save" for p in endpoint.params): + entry["supports_save"] = True + return entry + + +#: Command groups written by hand rather than generated from `Endpoint` records. +#: Introspected like any other command so the index describes what actually runs. +_HAND_AUTHORED_GROUPS: dict[str, click.Group] = {"config": config_group} + +#: Fields kept at `--detail summary`. Enough to choose a command, not to call it. +_SUMMARY_FIELDS = ("command", "kind", "summary") + +#: Detail levels, cheapest first. Full detail for every command is ~50k tokens and +#: the flat summary list is ~4.5k -- both too much for an agent to read blind -- so +#: the default is `groups`: a ~1k-token map of the ~15 navigable groups with their +#: command counts, from which the agent drills into exactly the subtree it needs. +DETAIL_LEVELS = ("groups", "summary", "full") + +#: Below this many (recursive) leaves, a group is a reasonable single drill target +#: and the overview stops descending into it; above it, the overview recurses so no +#: one drill returns an unwieldy slice. Tuned so `docstudio platform` fans out into +#: its resource subgroups while everything else stays one level deep. +_GROUP_OVERVIEW_MAX_LEAVES = 40 + + +def _matches(entry: dict[str, Any], group: str | None, command: str | None) -> bool: + """Filter one entry by group and/or command prefix. + + `command` matches on a path prefix, so "whisper webhook" selects all four + webhook commands while "whisper extract" selects exactly one. + """ + if group and entry["path"][0] != group: + return False + if command: + wanted = command.removeprefix("unstract ").split() + if entry["path"][: len(wanted)] != wanted: + return False + return True + + +def _navigable_groups(node: click.Group, path: tuple[str, ...]) -> list[dict[str, Any]]: + """Walk the Click tree into a coarse map of navigable groups (drift-free). + + Emits one entry per group at the level where it is a sensible single drill + target: a group with more than :data:`_GROUP_OVERVIEW_MAX_LEAVES` reachable + commands (and subgroups to split it) is descended into instead of emitted, so + `docstudio platform` fans out into its resource subgroups while smaller groups + stay one line. Descriptions come from each group's own ``help``, and the + `drill` command is the exact ``--discover --command`` prefix that selects the + subtree -- verified by test to return the advertised count, because this + project's rule is that discovery metadata must never lie about the parser. + """ + leaves = _count_leaves(node) + subgroups = sorted( + (n, c) for n, c in node.commands.items() if isinstance(c, click.Group) + ) + # Descend only when the group is both too big to be one drill target AND has + # subgroups to split it on; otherwise emit it whole. + if leaves > _GROUP_OVERVIEW_MAX_LEAVES and subgroups: + out: list[dict[str, Any]] = [] + # A group with its own direct leaves plus oversized subgroups still needs a + # line for those direct leaves (e.g. `docstudio platform` has one). + if any(not isinstance(c, click.Group) for c in node.commands.values()): + out.append(_group_entry(node, path, direct_only=True)) + for name, child in subgroups: + out.extend(_navigable_groups(child, (*path, name))) return out + return [_group_entry(node, path)] + + +def _count_leaves(node: click.Group) -> int: + """Total leaf (non-group) commands reachable under a group.""" + total = 0 + for child in node.commands.values(): + total += _count_leaves(child) if isinstance(child, click.Group) else 1 + return total + + +def _group_entry( + node: click.Group, path: tuple[str, ...], *, direct_only: bool = False +) -> dict[str, Any]: + """One line in the group overview: what it is, how big, and how to drill in.""" + count = ( + sum(1 for c in node.commands.values() if not isinstance(c, click.Group)) + if direct_only + else _count_leaves(node) + ) + drill = "unstract --discover --command '" + " ".join(path) + "'" + entry = { + "group": " ".join(path), + "commands": count, + "summary": (node.short_help or node.help or "").strip().split("\n")[0], + "drill": drill + " --detail summary", + } + if direct_only: + entry["note"] = "direct commands only; subgroups are listed separately" + return entry + + +def discover( + *, + group: str | None = None, + command: str | None = None, + detail: str = "summary", +) -> dict[str, Any]: + """Build the machine-readable index of the command surface. + + Selection (`group`, `command`) and verbosity (`detail`) are independent, so + any combination works: a summary of one group, full detail for one command, + or full detail for everything. + """ + groups = build_group_tree(list(ALL_ENDPOINTS)) + commands: list[dict[str, Any]] = [] + + for endpoint in ALL_ENDPOINTS: + node: click.Command | None = groups.get(endpoint.group) + for part in endpoint.command_path[1:]: + if isinstance(node, click.Group): + node = node.commands.get(part) + if node is not None: + commands.append(_endpoint_info(endpoint, node)) + + # Hand-authored groups are introspected from the *actual* Click commands, + # never from a parallel description of them. A hand-maintained parameter list + # is exactly the drift the generated half exists to avoid -- it once + # advertised `--product/--key/--value` for `config set`, which really takes + # three positional arguments. + for group_name, click_group in _HAND_AUTHORED_GROUPS.items(): + for name, node in sorted(click_group.commands.items()): + path = (group_name, name) + commands.append( + { + "command": "unstract " + " ".join(path), + "path": list(path), + # Flagged so an agent can tell local operations from API calls. + "kind": "local", + "summary": (node.short_help or node.help or "").strip(), + "usage": _usage_line(path, node), + "flags": [ + _param_info(p) + for p in node.params + if p.name not in _COMMON_FLAGS + ], + } + ) + + commands = [c for c in commands if _matches(c, group, command)] + + # `groups` is the cheap entry point and only makes sense unfiltered: a filter + # already narrows to a subtree, so there it degrades to the flat summary list. + if detail == "groups" and (group or command): + detail = "summary" + + if detail == "summary": + commands = [ + {k: c[k] for k in _SUMMARY_FIELDS if k in c} for c in commands + ] + + envelope: dict[str, Any] = { + "cli": "unstract", + "version": __version__, + "description": ( + "Unstract CLI -- one interface to the three products built by " + "Unstract: Document Studio, LLMWhisperer and API Hub." + ), + "products": { + "docstudio": { + "name": "Document Studio", + "group": "docstudio", + "note": "Owns the platform, deployment and hitl API groups.", + }, + "llmwhisperer": {"name": "LLMWhisperer", "group": "whisper"}, + "apihub": {"name": "API Hub", "group": "apihub"}, + }, + "detail": detail, + } + if group: + envelope["group"] = group + if command: + envelope["command_filter"] = command + + # `groups`: the ~1k-token map of navigable groups, from which an agent drills + # into exactly one subtree. This is the default and the entry point, so it also + # carries the global boilerplate (exit codes, conventions, drill hints). + if detail == "groups": + tree = build_group_tree(list(ALL_ENDPOINTS)) + overview: list[dict[str, Any]] = [] + for name in sorted(tree): + overview.extend(_navigable_groups(tree[name], (name,))) + for name, hand in sorted(_HAND_AUTHORED_GROUPS.items()): + overview.extend(_navigable_groups(hand, (name,))) + return { + **envelope, + "group_count": len(overview), + "command_count": len(commands), + "how_to_drill": ( + "Each group's `drill` command lists its commands (names + summaries). " + "Add --detail full to that command for flags and API paths, or use " + "--detail summary here for the flat list of all commands." + ), + "groups": overview, + **_GLOBAL_FACTS, + } + + envelope["count"] = len(commands) + + if detail == "summary": + # The whole point of the summary level is that an agent reads it first, + # so it has to say how to get the rest. + envelope["drill_down"] = { + "one_group": "unstract --discover --group --detail full", + "one_command": "unstract --discover --command ' ' --detail full", + "everything": "unstract --discover --detail full", + "note": ( + "Full detail for every command is large (~60k tokens). Prefer " + "filtering by group or command." + ), + } + envelope["groups"] = sorted({c["command"].split()[1] for c in commands}) + + # The exit-code table and conventions are global facts, not per-command ones. + # An agent that has already read the unfiltered index knows them, and on a + # narrow query they would outweigh the answer itself -- so they ship only + # with the unfiltered view, which is the entry point. + if group or command: + return {**envelope, "commands": commands} + + return { + **envelope, + **_GLOBAL_FACTS, + "commands": commands, + } + + +#: Global facts an agent branches on: stable across commands, so they ride the +#: unfiltered entry points (the groups overview and the full unfiltered list) and +#: are omitted from narrow queries where they would outweigh the answer. +_GLOBAL_FACTS: dict[str, Any] = { + "exit_codes": { + "0": "success", + "1": "generic error", + "2": "usage error (bad or missing flags)", + "3": "authentication or authorization failure", + "4": "not found", + "5": "validation error rejected by the API", + "6": "rate limited or quota exceeded", + "7": "timed out waiting for a terminal state", + "8": "remote server error", + "9": "result already consumed (one-shot read)", + }, + "conventions": { + "output": ( + "--output json|yaml|table|raw. JSON is the default when stdout is " + "not a TTY. Payloads go to stdout; diagnostics and structured " + "errors go to stderr." + ), + "errors": ( + "Failures emit a JSON object on stderr with code, message, hint " + "and retryable." + ), + "never_interactive": "No command ever prompts; every input is a flag or env var.", + "one_shot": ( + "Commands marked one_shot return their result exactly once. Use " + "--save to persist it; a second read exits 9." + ), + "wait": ( + "Commands with supports_wait accept --wait to poll to completion, " + "plus --poll-interval and --wait-timeout." + ), + }, +} -pass_context = click.make_pass_decorator(Context, ensure=True) +class UnstractCLI(click.Group): + """Root group that suggests a correction for an unknown command.""" + + def resolve_command(self, ctx: click.Context, args: list[str]): + try: + return super().resolve_command(ctx, args) + except click.UsageError: + import difflib + + given = args[0] if args else "" + if matches := difflib.get_close_matches(given, self.list_commands(ctx), n=3): + # Suggestions go to stderr so stdout stays parseable. + click.echo( + f"Unknown command {given!r}. Did you mean: {', '.join(matches)}?", + err=True, + ) + raise -# `invoke_without_command` so `--discover` answers on its own: it is how a -# caller learns which commands exist. @click.group( + cls=UnstractCLI, + # `--discover` and a bare `unstract` must both work without a + # subcommand, so the group has to be invocable on its own. invoke_without_command=True, - context_settings={"help_option_names": ["-h", "--help"]}, + context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100}, ) +@click.version_option(__version__, "-V", "--version", prog_name="unstract") @click.option( - "--config", - "config_file", - default=None, - type=click.Path(dir_okay=False), - help="Config file to use, overriding discovery.", + "--discover", + "discover_flag", + is_flag=True, + default=False, + help="Emit the command tree as JSON, for programmatic discovery.", ) -@click.option("--profile", "-p", default=None, help="Configuration profile to use.") @click.option( - "--output", - "-o", + "--group", default=None, - type=click.Choice([f.value for f in OutputFormat]), - help="Output format. Defaults to table, or to json when --agent resolves " - "to yes; pass it explicitly to parse the output.", + help="Limit --discover to one product group (whisper, platform, ...).", ) @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.", + "--command", + "command_filter", + default=None, + help="Limit --discover to one command or command prefix, e.g. 'whisper extract'.", ) @click.option( - "--quiet", - "-q", - is_flag=True, - default=False, - help="Suppress diagnostics on stderr. stdout is unaffected.", + "--detail", + type=click.Choice(DETAIL_LEVELS), + default="groups", + show_default=True, + help=( + "How much detail: 'groups' is the cheap map of navigable groups (default); " + "'summary' is every command with a one-liner; 'full' adds flags and API paths." + ), ) -@click.option("--verbose", "-v", count=True, help="Increase diagnostic detail.") +@click.option("--profile", "-p", default=None, help="Config profile to use.") @click.option( - "--discover", - "discover_tier", - type=click.Choice(TIERS), + "--config", + "config_file", + type=click.Path(dir_okay=False), default=None, - help="Describe this CLI as JSON instead of running a command, useful for agents.", + help="Config file to use, overriding $UNSTRACT_CONFIG and any .unstract.toml.", ) -@click.version_option(package_name="unstract-cli") @click.pass_context def cli( ctx: click.Context, - config_file: str | None, + discover_flag: bool, + group: str | None, + command_filter: str | None, + detail: str, profile: str | None, - output: str | None, - agent: str, - quiet: bool, - verbose: int, - discover_tier: str | None, + config_file: str | None, ) -> None: - """The official CLI for Unstract. + """Unified, LLM-friendly CLI for the Unstract suite. - LLMWhisperer extracts text and layout from documents; Document Studio runs - them through API deployments that return structured JSON. + Products: LLMWhisperer text extraction (`whisper`), deployed API workflows + (`deployment`), platform management (`platform`), human review (`hitl`) and + API Hub vertical extraction (`apihub`). - 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. + \b + Machine-readable discovery -- start cheap, then drill in: + unstract --discover # all names + summaries + unstract --discover --group whisper # one product + unstract --discover --command 'whisper extract' --detail full + unstract --discover --detail full # everything (large) + + Output defaults to JSON whenever stdout is not a terminal, so piping the CLI + needs no extra flags. """ + # Applied before any subcommand runs, so every load_config() below -- in + # generated commands and in the `config` group alike -- sees the same file. set_config_path(config_file) - # Filled in rather than replaced: the entry point holds this object, so 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 - # Modules the output layer imports cannot import it back, so their notes - # reach it through this sink rather than stderr unfiltered. - set_warning_sink( - lambda message: diagnostic(message, quiet=obj.quiet, verbosity=obj.verbosity) - ) - if discover_tier: - # Discovery has to answer before any configuration exists. - 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: help printed there - # with exit 0 tells a parser that work it never did succeeded. - 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)) - - -#: The connection flags a product group can carry, named after the setting -#: each one overrides. -_CONNECTION_FLAGS: dict[str, str] = { - "base_url": "Service URL to use.", - "api_key": "API key to use.", - "org_id": "Organisation to run against.", - "platform_key": "Platform key to use, for the commands that take one.", -} - -def _connection_options(*settings: str) -> 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( - f"--{name.replace('_', '-')}", default=None, help=_CONNECTION_FLAGS[name] + if discover_flag: + payload = discover( + group=group, command=command_filter, detail=detail ) - for name in ("base_url", *settings) - ] - - def decorate(func: Any) -> Any: - for option in reversed(options): - func = option(func) - return func - - return decorate - - -@cli.group("whisper") -@_connection_options("api_key") -@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") -@_connection_options("api_key", "org_id", "platform_key") -@click.option( - "--transport-timeout", - 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, **overrides: str | None -) -> None: - """Run Document Studio API deployments.""" - ctx.transport_timeout = transport_timeout or None - ctx.override(DOCSTUDIO, overrides) - - -@docstudio_group.group("deployment") -def deployment_group() -> None: - """Work with a deployed API.""" - - -@cli.group("auth") -@_connection_options("platform_key") -@click.option( - "--transport-timeout", - 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 auth_group( - ctx: Context, transport_timeout: float | None, **overrides: str | None -) -> None: - """Sign in, and identify the credential you are using. - - Its flags configure the platform key, which is the credential that knows - which organisation it belongs to. A deployment key does not: it authenticates - against the deployment it was minted for and never reaches this endpoint. - """ - # The same spelling as every other group: the clients are given no bound - # of their own, so an unset flag would wait on a black-holed host forever. - ctx.transport_timeout = transport_timeout or None - ctx.override(DOCSTUDIO, overrides) - - -cli.add_command(config_group) + # A filtered query returns `commands`; the unfiltered groups overview + # returns `groups`. "No match" only applies to a filter that hit nothing. + if (group or command_filter) and not payload.get("commands"): + from unstract_cli.core.errors import CLIError, ExitCode + + CLIError( + "No commands matched" + + (f" --group {group}" if group else "") + + (f" --command {command_filter!r}" if command_filter else "") + + ".", + ExitCode.USAGE, + hint="Run `unstract --discover` to list valid groups.", + ).emit() + ctx.exit(2) + # Compact when piped: pretty-printing costs ~36% more tokens for an agent + # that is only going to parse it anyway. + indent = 2 if sys.stdout.isatty() else None + click.echo(json.dumps(payload, indent=indent)) + ctx.exit(0) + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + ctx.exit(0) -# Imported for the side effect of registering commands, and last because they -# hang those commands off the groups declared above. -from unstract_cli.commands import ( # noqa: E402,F401 - clone_cmd, - docstudio_cmd, - platform_cmd, - whisper_cmd, -) +@click.command("completion", help="Print a shell completion script.") +@click.argument("shell", type=click.Choice(["bash", "zsh", "fish"])) +def completion(shell: str) -> None: + """Emit the completion script for a shell. -def command_tree() -> dict[str, Any]: - """The registered command tree, read back from Click itself. + Click generates these from the same command tree, so completions cover every + generated command without a separate registry. - Describing commands anywhere but from the parser lets the description drift - from what the parser accepts, so discovery and help always read this. + \b + Install with, for example: + unstract completion zsh > ~/.zfunc/_unstract + unstract completion bash >> ~/.bashrc """ + click.echo( + f'eval "$(_UNSTRACT_COMPLETE={shell}_source unstract)"' + if shell != "fish" + else "eval (env _UNSTRACT_COMPLETE=fish_source unstract)" + ) - 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"] +def build_cli() -> click.Group: + """Assemble the full command tree.""" + for group in build_group_tree(list(ALL_ENDPOINTS)).values(): + cli.add_command(group) + cli.add_command(config_group) + cli.add_command(completion) + return cli -__all__ = ["Context", "cli", "command_tree", "pass_context"] +__all__ = ["__version__", "build_cli", "cli", "discover"] diff --git a/src/unstract_cli/commands/clone_cmd.py b/src/unstract_cli/commands/clone_cmd.py deleted file mode 100644 index 067d56f..0000000 --- a/src/unstract_cli/commands/clone_cmd.py +++ /dev/null @@ -1,297 +0,0 @@ -"""`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 -import re -from typing import Any - -import click -from requests.exceptions import InvalidHeader, RequestException -from unstract.clone.context import ( - DEFAULT_CONCURRENCY, - CloneOptions, - OrgEndpoint, -) -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.clients import UNSENDABLE -from unstract_cli.core.errors import ( - CLIError, - ExitCode, - error_from_status, - remember_secret, -) -from unstract_cli.core.output import OutputFormat, diagnostic, emit_text - -# Mirrors the units `unstract.clone.cli` accepts, so both spellings of this -# command take 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.") -@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 PlatformAPIError as exc: - # The exception appends the response body to its own message, and - # `error.message` is published as a one-line summary; the body reaches - # the caller through `details`. - message = str(exc).split("\n body:", 1)[0] - if exc.status_code: - raise error_from_status( - int(exc.status_code), message, details=exc.body - ) from exc - raise CLIError( - message, - 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 -- the key -- and it - # arrives `repr`-escaped, so the scrub cannot match it either. - 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) - - -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 _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 - 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))}" - - 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. - diagnostic(f"Skipped: {named}.", quiet=ctx.quiet, verbosity=ctx.verbosity) - - payload = {**report.as_dict(), "skipped": skipped} - rendered = ctx.output is OutputFormat.TABLE - if rendered: - emit_text(report.render(), secrets=ctx.secrets()) - elif not failure: - finish(ctx, payload) - - if failure: - raise CLIError( - failure, - ExitCode.GENERIC, - 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.", - ) - - -__all__ = ["clone"] diff --git a/src/unstract_cli/commands/common.py b/src/unstract_cli/commands/common.py deleted file mode 100644 index 0a7181b..0000000 --- a/src/unstract_cli/commands/common.py +++ /dev/null @@ -1,114 +0,0 @@ -"""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 - -#: Poll interval and the ceiling on the whole wait. Both are flags. -DEFAULT_INTERVAL = 3.0 -DEFAULT_TIMEOUT = 300.0 - -#: Below this, polling is a busy loop against a metered service, not a wait. -MIN_INTERVAL = 0.1 - -F = Callable[..., Any] - - -def wait_options(*, default: bool = True) -> Callable[[F], F]: - """`--wait` and its two knobs. - - ``--wait`` is a gate, not a duration: how long to wait is ``--timeout`` and - how often to check is ``--interval``, so neither has two spellings. - """ - - def decorate(func: F) -> F: - for option in reversed( - [ - click.option( - "--wait/--no-wait", - default=default, - help="Poll until the job reaches a terminal state.", - ), - click.option( - "--interval", - # Bounded below: an interval of zero polls a metered service - # as fast as the loop can issue calls. - type=click.FloatRange(min=MIN_INTERVAL), - default=DEFAULT_INTERVAL, - show_default=True, - help="Seconds between polls.", - ), - click.option( - "--timeout", - "wait_timeout", - # Zero is meaningful -- one poll, then give up -- but a - # negative deadline has already passed. - type=click.FloatRange(min=0), - default=DEFAULT_TIMEOUT, - show_default=True, - help="Seconds to wait before giving up. The job keeps running.", - ), - click.option( - "--save", - type=click.Path(dir_okay=False), - default=None, - help="Write the result here before printing it.", - ), - ] - ): - func = option(func) - return func - - return decorate - - -def raw_fields(*fields: str) -> Callable[[click.Command], click.Command]: - """Declare what `--output raw` prints for this command, best answer first. - - 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_fields = fields - return command - - return decorate - - -def finish( - ctx: Context, - data: Any, - *, - raw_fields: tuple[str, ...] = (), - 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_fields=raw_fields, - secrets=ctx.secrets(), - ) - - -__all__ = [ - "DEFAULT_INTERVAL", - "DEFAULT_TIMEOUT", - "MIN_INTERVAL", - "finish", - "raw_fields", - "wait_options", -] diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 24ebc84..5e29fdf 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -1,92 +1,73 @@ -"""The `config` command group -- local only, no network calls. +"""The `config` command group -- hand-authored, not generated. -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. +These commands map to no API endpoint: they operate purely on the local config +layer. They are how a user or 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. +`--discover` describes them by introspecting these Click commands directly, so +the index always matches the parser. An earlier parallel description of them +drifted and advertised `--product/--key/--value` for `config set`, which really +takes three positional arguments -- hence: no second description, ever. + +Per SPEC.md §5.2 nothing here prompts: `init` refuses to clobber an existing file +unless `--force` is passed, rather than asking. """ from __future__ import annotations -from pathlib import Path from typing import Any import click -from unstract_cli.config import ( - DOCSTUDIO, - KEY_SOURCES, - LLMWHISPERER, - PRODUCTS, - UNTRUSTED_PROJECT_KEYS, +from unstract_cli.config.loader import ( + GROUP_PATH, + TARGET_NAMES, ConfigError, - ConfigFile, ResolvedConfig, - init_path, + config_path, load_config, + resolve_target, save_config, - settings_for, 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, - diagnostic, - emit_result, - resolve_format, -) -from unstract_cli.core.platform import deployment_rows, organisation, platform_client - -#: The probe entry for docstudio's platform key. Named for the credential, -#: not the product: the deployment key sits beside it under `docstudio`. -PLATFORM_KEY = "platform" - -#: Keys never echoed back: this output lands in logs and transcripts as often -#: as on a screen. -_SECRET_KEY_HINTS = ("key", "token", "secret") - +from unstract_cli.core.output import OutputFormat, default_format, emit -def _is_secret(key: str) -> bool: - return any(hint in key.lower() for hint in _SECRET_KEY_HINTS) +def _resolve_or_fail(target: str) -> str: + """Turn a `TARGET` argument into an API group, or exit 2 with the valid set. -def _fmt(obj: Any) -> OutputFormat: - """Output format from the root context, defaulting when invoked standalone.""" - return getattr(obj, "output", None) or resolve_format(None) - - -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 + A group owned by a product must be named through it (`docstudio.platform`), + so a setting always says which product it configures. Both separators work, + because `docstudio platform` is the natural thing to type after + `unstract docstudio platform ...`. + """ + if (group := resolve_target(target)) is not None: + return group + + ctx = click.get_current_context() + CLIError( + f"Unknown config target {target!r}.", + ExitCode.USAGE, + hint=( + "Valid targets: " + ", ".join(TARGET_NAMES) + ". " + "Groups owned by a product are addressed through it, e.g. " + "`docstudio.platform` (or `docstudio platform`)." + ), + ).emit() + ctx.exit(int(ExitCode.USAGE)) + raise AssertionError # pragma: no cover - ctx.exit raises -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 +def _fmt(output: str | None) -> OutputFormat: + return OutputFormat(output) if output else default_format() -def _saved(cfg: ConfigFile, path: Path | None = None) -> tuple[Path, dict[str, Any]]: - """Write the config, with the envelope's `meta` saying if the write is - not yet known to survive a crash: the warning reaches only stderr.""" - meta: dict[str, Any] = {} - written = save_config( - cfg, path, on_unconfirmed=lambda: meta.update(durability="unconfirmed") - ) - return written, meta +def _output_option(f): + return click.option( + "--output", "-o", "output", + type=click.Choice([f.value for f in OutputFormat]), default=None, + help="Output format. Defaults to json when stdout is not a TTY.", + )(f) @click.group(name="config", help="Manage CLI configuration profiles (local only).") @@ -94,458 +75,306 @@ def config_group() -> None: """Local configuration management. These commands make no network calls.""" -@config_group.command("init") -@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: - """Create a starter config file with profile stubs. +@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.") +@_output_option +def config_init(force: bool, output: str | None) -> None: + ctx = click.get_current_context() + path = config_path() - The stubs reference environment variables and hold no keys. To store your - keys and be done, run `unstract auth login` instead. - """ - path = init_path() if path.exists() and not force: # Never prompt: state the situation and the exact flag that resolves it. - raise CLIError( + CLIError( f"Config already exists at {path}.", ExitCode.USAGE, hint="Pass --force to overwrite it, or edit the file directly.", - ) + ).emit() + ctx.exit(int(ExitCode.USAGE)) + + cfg = load_config(path) if path.exists() else None + from unstract_cli.config.loader import ConfigFile - replaced = path.exists() new = ConfigFile( - default_profile="cloud-us", profiles=starter_profiles(), path=path, exists=True + default_profile="cloud-us", + profiles=starter_profiles(), + path=path, + exists=True, ) - written, meta = _saved(new, path) - variables = sorted( - { - value.removeprefix("env:") - for profile in new.profiles.values() - for block in profile.values() - for value in block.values() - if isinstance(value, str) and value.startswith("env:") - } - ) - diagnostic( - f"Wrote {written}.\n" - f"Next: export {' and '.join(variables)}, or run `unstract auth login` " - "to store keys in the file instead.\n" - "Then `unstract config doctor` shows what resolved.", - quiet=getattr(obj, "quiet", False), - verbosity=getattr(obj, "verbosity", 0), - ) - emit_result( + written = save_config(new, path) + emit( { "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, or run " - "`unstract auth login` to store keys in a profile. " + KEY_SOURCES + "Set the referenced environment variables to authenticate." ), + "replaced_existing": bool(cfg), }, - _fmt(obj), - meta=meta, + _fmt(output), ) @config_group.command("list", help="List profiles defined in the config file.") -@click.pass_obj -def config_list(obj: Any) -> None: - cfg = _loaded(obj) - emit_result( +@_output_option +def config_list(output: str | None) -> None: + cfg = load_config() + profiles = { + name: {product: sorted(block) for product, block in blocks.items()} + for name, blocks in cfg.profiles.items() + } + emit( { "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() - }, + "profiles": profiles, }, - _fmt(obj), + _fmt(output), ) @config_group.command("get") -@click.argument("product") -@click.argument("key") -@click.pass_obj -def config_get(obj: Any, product: str, key: str) -> None: +@click.argument("target", nargs=-1, required=True, metavar="TARGET... KEY") +@click.option("--profile", "-p", default=None, help="Profile to read from.") +@_output_option +def config_get(target: tuple[str, ...], profile: str | None, output: str | None) -> None: """Show a resolved setting, following flag > env > profile > default. - PRODUCT and KEY are positional -- not flags. Credentials are reported as + TARGET 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 + unstract config get docstudio.platform org_id + unstract config get docstudio platform org_id + unstract config get --profile cloud-eu llmwhisperer base_url """ - _check_product(product) + ctx = click.get_current_context() + *target_parts, key = target + if not target_parts: + CLIError( + "Expected a target and a key, e.g. `config get docstudio.platform org_id`.", + ExitCode.USAGE, + hint="Valid targets: " + ", ".join(TARGET_NAMES) + ".", + ).emit() + ctx.exit(int(ExitCode.USAGE)) + product = _resolve_or_fail(" ".join(target_parts)) try: - value = _resolved(obj).get(product, key) + resolved = ResolvedConfig(file=load_config(), profile_name=profile) + value = resolved.get(product, key) except ConfigError as exc: - raise CLIError(str(exc), ExitCode.USAGE) from exc - - emit_result( + CLIError(str(exc), ExitCode.USAGE).emit() + ctx.exit(int(ExitCode.USAGE)) + return + + # Credentials are never echoed, even on explicit request: this output is as + # likely to land in a log or a transcript as on a screen. + is_secret = any(h in key for h in ("key", "token", "secret")) + emit( { "product": product, "key": key, - "value": ("***SET***" if value else None) if _is_secret(key) else value, + "value": ("***SET***" if value else None) if is_secret else value, "configured": value is not None, }, - _fmt(obj), + _fmt(output), ) @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.option( - "--deployment", - default=None, - metavar="API_NAME", - help="Store a docstudio api_key for this one deployment only.", -) -@click.pass_obj -def config_set( - obj: Any, - product: str, - key: str, - value: str, - profile: str | None, - deployment: str | None, -) -> None: +@click.argument("args", nargs=-1, required=True, metavar="TARGET... KEY VALUE") +@click.option("--profile", "-p", default=None, help="Profile to write to.") +@_output_option +def config_set(args: tuple[str, ...], profile: str | None, output: str | None) -> None: """Set a value in the config file. - PRODUCT, KEY and VALUE are positional -- not flags. Writes to the active + TARGET, 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 docstudio.platform org_id org_ABC123 + unstract config set docstudio platform org_id org_ABC123 unstract config set llmwhisperer api_key 'env:LLMWHISPERER_API_KEY' - unstract config set docstudio api_key dk_... --deployment invoice-parser + unstract config set --profile cloud-eu llmwhisperer base_url https://…/api/v2 \b - A credential can be stored either way. `env:VAR_NAME` records where the - secret lives rather than the secret itself, which is what a shared machine - or a CI checkout wants; a literal value is what `auth login` writes, into a - file created `0600`. Passed here a literal also lands in your shell history. + 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) - _check_key(product, key) - if deployment is not None and (product, key) != (DOCSTUDIO, "api_key"): - raise CLIError( - "--deployment only applies to `docstudio api_key`.", + ctx = click.get_current_context() + if len(args) < 3: + CLIError( + "Expected a target, a key and a value, e.g. " + "`config set docstudio.platform org_id org_ABC123`.", ExitCode.USAGE, - hint="A deployment entry holds nothing but the key that runs it.", - ) - cfg = _loaded(obj) - name = profile or getattr(obj, "profile", None) or cfg.default_profile or "cloud-us" - - block = cfg.profiles.setdefault(name, {}) - if deployment is not None: - block = block.setdefault("deployments", {}).setdefault(deployment, {}) - else: - block = block.setdefault(product, {}) - block[key] = value + hint="Valid targets: " + ", ".join(TARGET_NAMES) + ".", + ).emit() + ctx.exit(int(ExitCode.USAGE)) + + *target_parts, key, value = args + product = _resolve_or_fail(" ".join(target_parts)) + cfg = load_config() + name = profile or cfg.default_profile or "cloud-us" + + # Write to the same nested location the reader looks in + # ([profiles.X.docstudio.platform]), or a flat block would be created that + # `config get` then silently ignores. + node: dict[str, Any] = cfg.profiles.setdefault(name, {}) + for segment in GROUP_PATH.get(product, (product,)): + node = node.setdefault(segment, {}) + node[key] = value if not cfg.default_profile: cfg.default_profile = name - written, meta = _saved(cfg) + written = save_config(cfg) - warnings = [] - if _is_secret(key) and not value.startswith("env:"): - warnings.append( - "Value stored literally, in a file created 0600. Write " - "`env:VAR_NAME` instead to hold a reference rather than the secret." - ) - 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 here, so a silent write 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 = None + if any(h in key for h in ("key", "token", "secret")) 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." ) - warning = " ".join(warnings) or None - emit_result( - { - "profile": name, - "product": product, - "key": key, - "deployment": deployment, - "path": str(written), - "warning": warning, - }, - _fmt(obj), - meta=meta, + emit( + {"profile": name, "product": product, "key": key, "path": str(written), + "warning": warning}, + _fmt(output), ) + ctx.exit(int(ExitCode.SUCCESS)) -def _probe(resolved: ResolvedConfig) -> dict[str, Any]: - """Check each credential against the service, where that is possible. - - LLMWhisperer has a read-only usage endpoint, so its key can be verified for - real, and so does the platform API -- `whoami` reads nothing but the key - itself. 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. +@config_group.command("use", help="Set the default profile.") +@click.argument("name") +@_output_option +def config_use(name: str, output: str | None) -> None: + ctx = click.get_current_context() + cfg = load_config() - Keyed by credential rather than by product: docstudio holds two keys that - are checked differently. - """ - 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.", - } + if cfg.profiles and name not in cfg.profiles: + known = ", ".join(sorted(cfg.profiles)) or "none" + CLIError( + f"Profile {name!r} is not defined.", + ExitCode.USAGE, + hint=f"Known profiles: {known}. Create one with `unstract config set`.", + ).emit() + ctx.exit(int(ExitCode.USAGE)) + + cfg.default_profile = name + written = save_config(cfg) + emit({"default_profile": name, "path": str(written)}, _fmt(output)) + + +@config_group.command("current", help="Show the active profile and its resolved settings.") +@click.option("--profile", "-p", default=None, help="Profile to inspect.") +@_output_option +def config_current(profile: str | None, output: str | None) -> None: + resolved = ResolvedConfig(file=load_config(), profile_name=profile) + settings: dict[str, Any] = {} + + # Keyed by the same target names `config get`/`set` accept, so what is + # reported here can be copied straight into a command. + for target, group in ((".".join(p), g) for g, p in GROUP_PATH.items()): + block: dict[str, Any] = {} + for key in ("base_url", "org_id"): + try: + if (value := resolved.get(group, key)) is not None: + block[key] = value + except ConfigError: + continue + try: + block["api_key_configured"] = bool(resolved.get(group, "api_key")) + except ConfigError: + block["api_key_configured"] = False + settings[target] = block - try: - with translated(endpoint="whoami"): - identity = platform_client(resolved).whoami() - except CLIError as exc: - out[PLATFORM_KEY] = { - "checked": True, - "ok": False, - "detail": exc.message, - "exit_code": int(exc.exit_code), - } - except ConfigError as exc: - # An absent platform key is optional; it must not decide the exit code. - out[PLATFORM_KEY] = {"checked": False, "ok": None, "detail": str(exc)} - else: - out[PLATFORM_KEY] = { - "checked": True, - "ok": True, - "organization_id": identity.get("organization_id"), - "detail": "The key was accepted, and resolved to an organisation.", - } - - resolves = all( - resolved.get(DOCSTUDIO, key) for key in ("org_id", "api_key", "base_url") + emit( + { + "active_profile": resolved.active_profile, + "config_path": str(resolved.file.path), + "config_exists": resolved.file.exists, + "settings": settings, + }, + _fmt(output), ) - out[DOCSTUDIO] = { - "checked": False, - # Null, not True: beside `checked: false` a true reads as a passed check. - "ok": None, - "resolved": resolves, - "detail": ( - "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." - ), - } - return out -def _stale_deployments(resolved: ResolvedConfig, names: list[str]) -> list[str]: - """Which deployment entries name a deployment the server no longer has. +@config_group.command("path", help="Show the config file path.") +@_output_option +def config_path_cmd(output: str | None) -> None: + path = config_path() + emit({"path": str(path), "exists": path.exists()}, _fmt(output)) - An API name is editable server-side, so an entry written under one can be - orphaned without anything local changing. Raises when the listing cannot - be asked: a check that was requested and did not run must not read as a - check that passed. - """ - org_id = organisation(resolved) - client = platform_client(resolved, org_id) - with translated(endpoint="api/deployment/"): - return [ - name - for name in names - if not deployment_rows(client.list_deployments(org_id, api_name=name)) - ] - - -@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.", + +#: A cheap authenticated GET per API group, used by `config doctor --check` to +#: prove the resolved credential actually works. Kept read-only and side-effect +#: free. Groups without a safe no-arg probe are reported as source-only. +_DOCTOR_PROBES: dict[str, str] = { + "docstudio.platform": "docstudio.platform.prompt-studio.select-choices", + "llmwhisperer": "whisper.usage", +} + + +@config_group.command( + "doctor", help="Diagnose credential resolution and (optionally) test it live." ) -@click.pass_obj -def config_doctor(obj: Any, probe: bool) -> None: - """Report where each setting resolves from, without echoing any secret. +@click.option("--profile", "-p", default=None, help="Profile to diagnose.") +@click.option("--check/--no-check", default=True, + help="Make one authenticated call per configured group to confirm it works.") +@_output_option +def config_doctor(profile: str | None, check: bool, output: str | None) -> None: + """Report where each credential resolves from, and whether it authenticates. 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. With a - platform key it also checks that every deployment entry still names a - deployment the organisation has. - - 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. + configured", but you set it -- where is it looking? For `env:` refs it says + whether the variable is present in THIS process (a shell `export` in a login + profile the CLI did not inherit is the classic trap), and with --check it + makes one real call so a working credential reads as working. """ - resolved = _resolved(obj) - problems: list[str] = [] - products: dict[str, Any] = {} - for product in PRODUCTS: - entry: dict[str, Any] = {} - for key in settings_for(product): - try: - 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}") - for stray in resolved.unknown_settings(product): - problems.append(f"{product}.{stray}: not a setting {product} has.") - products[product] = entry + from unstract_cli.core import http + from unstract_cli.endpoints import get_endpoint - try: - deployments = list(resolved.deployment_names()) - except ConfigError as exc: - deployments = [] - problems.append(str(exc)) - for api_name in deployments: - # An entry may carry a key of its own, and falls back to the profile's - # key silently. - if detail := resolved.withheld_detail("deployments", api_name, "api_key"): - problems.append(f"deployment {api_name}: {detail}") - try: - # Resolved the way a run would: being listed says nothing about - # whether the key behind it arrives. - resolved.deployment_key(api_name) - except ConfigError as exc: - problems.append(f"deployment {api_name}: {exc}") - - report: dict[str, Any] = { - "active_profile": resolved.active_profile, - "config_path": str(resolved.file.path), - "config_exists": resolved.file.exists, - "products": products, - "deployments": deployments, - } - if any( - not entry["api_key"]["resolved"] - for entry in products.values() - if "api_key" in entry - ): - # The field name avoids the word the payload scrubber redacts on. - report["getting_started"] = KEY_SOURCES - 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 - ] - notes = [] - platform_probe = report["probe"][PLATFORM_KEY] - if deployments and not platform_probe["checked"]: - # The flag was explicit, so the skip is said rather than silent. - notes.append( - "probe: deployment entries were not checked against the " - f"organisation -- no platform key resolves for profile " - f"{resolved.active_profile!r}." - ) - elif deployments and platform_probe["ok"] is not True: - # The listing would send the key that was just rejected, so it can - # only fail the same way and report the same failure twice. - notes.append( - "probe: deployment entries were not checked against the " - "organisation -- the platform key itself did not pass." - ) - elif deployments: - try: - stale = _stale_deployments(resolved, deployments) - except (CLIError, ConfigError) as exc: - problems.append(f"probe deployments: {exc}") - else: - # A warning, not a problem: the entry is harmless until it is - # run, and the server is the only authority on what it is - # called now. - report["stale_deployments"] = stale - notes += [ - f"warning: no deployment is called {api_name!r} any more; " - "run `unstract docstudio deployment ls` for the current names." - for api_name in stale - ] - for note in notes: - diagnostic( - note, - quiet=getattr(obj, "quiet", False), - verbosity=getattr(obj, "verbosity", 0), - ) - - 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)) + resolved = ResolvedConfig(file=load_config(), profile_name=profile) + groups: list[dict[str, Any]] = [] + for target, group in ((".".join(p), g) for g, p in GROUP_PATH.items()): + entry: dict[str, Any] = {"target": target} + for key in ("base_url", "api_key", "org_id"): + try: + entry[key] = resolved.resolution_source(group, key) + except ConfigError: + entry[key] = {"resolved": False, "source": "unset"} -def _loaded(obj: Any) -> ConfigFile: - """The config file, with its warnings reported. + if check and entry["api_key"]["resolved"] and (probe := _DOCTOR_PROBES.get(target)): + entry["live_check"] = _probe(http, get_endpoint(probe), resolved) + groups.append(entry) - 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: - diagnostic( - warning, - quiet=getattr(obj, "quiet", False), - verbosity=getattr(obj, "verbosity", 0), - ) - return cfg + emit( + { + "active_profile": resolved.active_profile, + "config_path": str(resolved.file.path), + "config_exists": resolved.file.exists, + "groups": groups, + }, + _fmt(output), + ) -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=_loaded(obj), profile_name=getattr(obj, "profile", None)) +def _probe(http: Any, endpoint: Any, resolved: ResolvedConfig) -> dict[str, Any]: + """Run one authenticated GET and report only pass/fail, never the payload.""" + try: + plan = http.build_request(endpoint, resolved, {}) + response = http.execute(plan, endpoint=endpoint, max_retries=0) + except Exception as exc: # noqa: BLE001 - doctor must never itself crash + return {"ok": False, "detail": str(exc)} + ok = response.status < 400 + detail = "authenticated" if ok else f"HTTP {response.status}" + if response.status in (401, 403): + detail = f"HTTP {response.status}: credential rejected" + return {"ok": ok, "detail": detail} __all__ = ["config_group"] diff --git a/src/unstract_cli/commands/docstudio_cmd.py b/src/unstract_cli/commands/docstudio_cmd.py deleted file mode 100644 index 197daf2..0000000 --- a/src/unstract_cli/commands/docstudio_cmd.py +++ /dev/null @@ -1,298 +0,0 @@ -"""`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 -from urllib.parse import parse_qs, quote, urlparse - -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, raw_fields, wait_options -from unstract_cli.core.clients import ( - deployment, - deployment_errors, - raise_for_result, - translated, - 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, - PollState, - classify, - persist, - preflight, - 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"), -) - -#: What `--output raw` prints, best answer first: a queued run carries a handle -#: and no result, so a single field would be wrong for some of the shapes. -RUN_RAW = ("extraction_result", "execution_id") -STATUS_RAW = ("extraction_result", "execution_status") - -#: Shared by the run POST and the status GET: what the run asked for has to be -#: asked for again when the result is read. -_SHARED_WITH_STATUS = ("include_metadata", "include_metrics", "include_extracted_text") - - -@raw_fields(*RUN_RAW) -@deployment_group.command("run") -@click.argument("target") -# Optional because a run can name its documents as `--presigned-urls` instead; -# the two are checked together below. -@click.argument("files", nargs=-1, type=click.Path(exists=True)) -@wait_options() -@spec_options( - PRODUCT, - "execute", - client_method=APIDeploymentsClient.structure_file, - # `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 the deployment's API name, as `deployment ls` prints it. 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. - """ - sent = requested(params) - # Checked before the client is built: this is wrong whatever the config - # resolves to, and a credential error 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.", - ExitCode.USAGE, - hint=( - "Drop --no-wait, or start now and save later with " - "`deployment status --save`." - ), - ) - if save: - preflight(save) - key_source = ctx.config.deployment_key_source(target) - with deployment_errors(target, key_source), translated(endpoint=client.api_url): - started = client.structure_file(list(files), timeout=0, **sent) - raise_for_result(started, endpoint=client.api_url) - - if not wait: - # The ack names no execution of its own, so the handle is derived - # from the endpoint it hands back and reported as meta. - finish(ctx, started, raw_fields=RUN_RAW, meta=_handle_meta(started)) - return - - 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: - # Polling runs on a status URL, which is not what the status - # command takes: without the id a resume cannot be spelled. - handle = _handle_meta(started) - exc.extra = {**exc.extra, **handle} - if exc.exit_code is ExitCode.TIMEOUT and ( - found := handle.get("execution_id") - ): - # The status read is one-shot: a resume that drops --save - # spends it with nothing on disk. - saving = f" --save {save}" if save else "" - exc.hint = ( - f"Resume with `unstract docstudio deployment status {target} " - f"{found}{saving}` 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 reported as meta. - 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 [] - # The spec types a file's status as a bare string, and the service spells - # it in mixed case. - 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 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 here - # 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]: - """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( - 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, **params) - # The client's own retry policy has already run and reports what it - # would retry as still pending. - if not result.get("pending"): - raise_for_result(result, endpoint=client.api_url, one_shot=True) - return result - - return translating(poll, client.api_url, one_shot=True) - - -@raw_fields(*STATUS_RAW) -@deployment_group.command("status") -@click.argument("target") -@click.argument("execution_id") -@spec_options( - PRODUCT, - "status", - 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, save: str | None, **params: Any -) -> None: - """Report the state of a running or finished execution.""" - client = deployment(ctx.config, target, ctx.transport_timeout) - if save: - preflight(save) - # The id comes from the caller, so it could otherwise carry query syntax - # of its own. - endpoint = f"{client.api_url}?execution_id={quote(execution_id, safe='')}" - # The status read serves a result exactly once, which is what a 406 from - # it means. - key_source = ctx.config.deployment_key_source(target) - with ( - deployment_errors(target, key_source), - translated(endpoint=client.api_url, one_shot=True), - ): - result = client.check_execution_status(endpoint, **requested(params)) - if not result.get("pending"): - raise_for_result(result, endpoint=client.api_url, one_shot=True) - # A finished-and-failed execution arrives inside an HTTP 200, so the status - # code alone would call it a success. - if classify(result, RUN_POLL) is PollState.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}, - ) - 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) - - -__all__ = ["run", "status"] diff --git a/src/unstract_cli/commands/platform_cmd.py b/src/unstract_cli/commands/platform_cmd.py deleted file mode 100644 index 776e348..0000000 --- a/src/unstract_cli/commands/platform_cmd.py +++ /dev/null @@ -1,692 +0,0 @@ -"""`unstract auth login`, `unstract auth whoami` and `unstract docstudio deployment ls`. - -`whoami` and `ls` authenticate with a platform key rather than a deployment -key. The two credentials are not interchangeable and neither is going away: a -deployment key runs deployments and cannot describe the account, a platform key -describes the account and lists what is in it but cannot run anything. `login` -stores either, and the LLMWhisperer key, into one profile. - -Both platform operations are declared in the vendored docstudio spec, but these -commands take their flags by hand rather than through `spec_options`. -""" - -from __future__ import annotations - -import os -import re -import sys -from typing import Any -from urllib.parse import urlsplit - -import click - -from unstract_cli.app import Context, auth_group, deployment_group, pass_context -from unstract_cli.commands.common import finish -from unstract_cli.config import ( - DEFAULT_BASE_URLS, - DOCSTUDIO, - KEY_SOURCES, - LLMWHISPERER, - PRODUCTS, - ConfigError, - ConfigFile, - ResolvedConfig, - load_config, - save_config, -) -from unstract_cli.core.clients import llmwhisperer, translated -from unstract_cli.core.errors import CLIError, ExitCode, remember_secret -from unstract_cli.core.output import diagnostic -from unstract_cli.core.platform import ( - deployment_rows, - organisation, - platform_client, -) - -#: The fields a deployment listing shows. A row carries many more, run -#: histories included, and `--output table` wraps rather than truncates, so the -#: whole row is unreadable at a terminal. `--full` returns the rows as sent. -LISTING_FIELDS = ("api_name", "display_name", "id", "is_active", "api_endpoint") - - -class SaveDeclinedError(Exception): - """The organisation resolved, and storing it was deliberately skipped. - - Distinct from a write that *failed*: there is nothing to retry and nothing - is wrong. The call succeeded, so it exits 0 and reports the identity, with - `meta.saved` false and `reason` saying which rule declined -- the same shape - `--no-save` already produces. - """ - - def __init__(self, reason: str, hint: str) -> None: - super().__init__(reason) - self.reason = reason - self.hint = hint - - -def _writable_config() -> ConfigFile: - """The config file a command may write to, or a refusal saying why not.""" - cfg = load_config() - if cfg.is_project_local: - # A `.unstract.toml` found by walking up is very likely committed, and - # writing it would rewrite a teammate's settings, drop the file's - # comments and narrow its mode. The config layer already declines to - # trust such a file for credentials. - raise SaveDeclinedError( - f"the config at {cfg.path} is project-local", - hint="Nothing was written. Name the file to write instead: " - "`unstract --config ...`.", - ) - return cfg - - -def _profile_to_write( - ctx: Context, cfg: ConfigFile, name: str | None = None, *, create: bool = False -) -> str: - """The profile a write lands in: the one named, else the one the run is using. - - The fallback is `ResolvedConfig.active_profile`, the same flag > env > - file-default ladder every read uses, rather than a ladder re-derived here: - one that misses a tier writes into a profile no later command reads. - """ - selected = name or ctx.config.active_profile or cfg.default_profile - if selected is None and cfg.exists and cfg.profiles: - # Neither the caller nor the file named one, so the name below is this - # function's own: refusing under it would quote a profile the caller - # never typed. - if len(cfg.profiles) == 1: - selected = next(iter(cfg.profiles)) - else: - known = ", ".join(sorted(cfg.profiles)) - raise ConfigError( - f"no profile is selected and {cfg.path} names no default " - f"(known profiles: {known}); rerun with `-p `" - ) - - selected = selected or "cloud-us" - if not create and cfg.exists and cfg.profiles and selected not in cfg.profiles: - # `setdefault` would materialise the name, disarming the "profile not - # found" check for every later command. Raised as a `ConfigError` so - # the caller's SAVE_FAILED wrapper still carries the identity back. - known = ", ".join(sorted(cfg.profiles)) or "none" - raise ConfigError( - f"profile {selected!r} is not in {cfg.path} " - f"(known profiles: {known}); create it with `config set` first" - ) - return selected - - -def _store_organisation(ctx: Context, org_id: str) -> dict[str, Any]: - """Write the resolved organisation into the profile the run is using. - - It lands on the docstudio block because that is where every consumer reads - it from. - """ - cfg = _writable_config() - name = _profile_to_write(ctx, cfg) - cfg.profiles.setdefault(name, {}).setdefault(DOCSTUDIO, {})["org_id"] = org_id - if not cfg.default_profile: - cfg.default_profile = name - return {"profile": name, "path": str(save_config(cfg))} - - -# --------------------------------------------------------------------------- # -# auth login -# --------------------------------------------------------------------------- # - -#: Each credential `login` takes, in the order it asks for them: its flag, the -#: prompt a terminal sees, and where in a profile it is stored. -_CREDENTIALS = ( - ("platform", "--platform-key", "Platform key", (DOCSTUDIO, "platform_key")), - ("deployment", "--deployment-key", "Deployment key", (DOCSTUDIO, "api_key")), - ("llmwhisperer", "--llmwhisperer-key", "LLMWhisperer key", (LLMWHISPERER, "api_key")), -) - - -def _interactive() -> bool: - return sys.stdin.isatty() - - -def _prompt(text: str, **kwargs: Any) -> Any: - # Prompts go to stderr so `-o json` output on stdout stays parseable. - return click.prompt(text, err=True, **kwargs) - - -def _confirm(text: str, **kwargs: Any) -> bool: - return click.confirm(text, err=True, **kwargs) - - -def _keys_from_flags(given: dict[str, str | None]) -> dict[str, str | None]: - """Flag values as keys, with `-` read from stdin -- at most one of them.""" - from_stdin = [name for name, value in given.items() if value == "-"] - if len(from_stdin) > 1: - flags = ", ".join(flag for name, flag, *_ in _CREDENTIALS if name in from_stdin) - raise CLIError( - f"Only one key can be read from stdin, and {flags} each ask for it.", - ExitCode.USAGE, - hint="Pass the others as values.", - ) - keys = dict(given) - if from_stdin: - keys[from_stdin[0]] = sys.stdin.read().strip() - return keys - - -def _url(value: str) -> str: - parts = urlsplit(value.strip()) - if parts.scheme not in ("http", "https") or not parts.netloc: - raise click.UsageError(f"{value.strip()!r} is not an http(s) URL.") - return value.strip() - - -def _hosts_from_prompts(resolved: ResolvedConfig) -> dict[str, str]: - """One visible prompt per product; only a host typed over the one shown counts.""" - hosts: dict[str, str] = {} - for product in (DOCSTUDIO, LLMWHISPERER): - shown = resolved.get(product, "base_url") - typed = _prompt(f"{product} base URL", default=shown, value_proc=_url) - if _host(typed) != _host(shown): - hosts[f"{product}.base_url"] = typed - return hosts - - -def _keys_from_prompts() -> dict[str, str | None]: - """One hidden, skippable prompt per credential, in the documented order.""" - keys: dict[str, str | None] = {} - for name, _flag, label, _setting in _CREDENTIALS: - keys[name] = ( - _prompt( - f"{label} (Enter to skip)", - hide_input=True, - default="", - show_default=False, - ).strip() - or None - ) - return keys - - -def _validation_config( - ctx: Context, - cfg: ConfigFile, - name: str, - keys: dict[str, str | None], - hosts: dict[str, str] | None = None, -) -> ResolvedConfig: - """The keys being stored, resolved as the profile they will land in. - - Validating through the ordinary config layer means the same base URL the - profile will run against is the one the keys are checked against. A - profile that does not exist yet resolves against nothing but the flags and - the environment, so a stranger's host cannot be the one that answers. - """ - overrides = {**ctx.overrides, **(hosts or {})} - for credential, _flag, _label, (product, key) in _CREDENTIALS: - if keys.get(credential): - overrides[f"{product}.{key}"] = keys[credential] - if name in cfg.profiles: - return ResolvedConfig(file=cfg, profile_name=name, overrides=overrides) - return ResolvedConfig(file=ConfigFile(), profile_name=None, overrides=overrides) - - -def _slug(text: str) -> str: - return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") - - -def _host(url: str) -> tuple[str, str, str]: - """A trailing slash or a capitalised host does not move a key anywhere.""" - parts = urlsplit(url) - return parts.scheme.lower(), parts.netloc.lower(), parts.path.rstrip("/") - - -def _stranded_credentials( - cfg: ConfigFile, name: str, keys: dict[str, str | None], resolved: ResolvedConfig -) -> tuple[list[str], list[tuple[str, ...]]]: - """Keys the profile holds that this login neither supplied nor checked. - - Storing the host a key was checked against moves every other credential in - the profile to a host none of them were checked against. Returns what is - affected, named for a message, and where each one sits. - """ - profile = cfg.profiles.get(name, {}) - supplied = { - (product, key) - for credential, _flag, _label, (product, key) in _CREDENTIALS - if keys.get(credential) - } - labels: list[str] = [] - paths: list[tuple[str, ...]] = [] - for product in PRODUCTS: - stored = profile.get(product, {}).get("base_url") - if isinstance(stored, str) and stored.startswith("env:"): - stored = os.environ.get(stored[4:].strip()) - stored = stored or DEFAULT_BASE_URLS[product] - if _host(stored) == _host(resolved.get(product, "base_url")): - continue - for key in ("api_key", "platform_key"): - if (product, key) not in supplied and profile.get(product, {}).get(key): - labels.append(f"{product} {key}") - paths.append((product, key)) - if product != DOCSTUDIO: - continue - for api_name, entry in (profile.get("deployments") or {}).items(): - if isinstance(entry, dict) and entry.get("api_key"): - labels.append(f"deployment {api_name}") - paths.append(("deployments", api_name, "api_key")) - return labels, paths - - -def _new_profile_name(cfg: ConfigFile, org_id: str, org_name: Any) -> str: - """Ask for the profile to hold this organisation, until the answer is safe. - - The profile named here is replaced, not merged into: its credentials were - never checked against this key's host. A typed name that already exists - therefore needs a confirmation of its own -- all the more when it belongs - to another organisation, or the guard's remedy would be the overwrite it - exists to prevent. - """ - suggested = _slug(str(org_name or "")) or org_id - while True: - name = _prompt("Profile name", default=suggested) - if name not in cfg.profiles: - return name - other = cfg.profiles[name].get(DOCSTUDIO, {}).get("org_id") - owner = f" and belongs to organisation {other}" if other else "" - if _confirm( - f"Profile {name!r} already exists{owner}. Replace it?", default=False - ): - return name - - -@auth_group.command("login") -@click.option("--profile", "-p", "profile", default=None, help="Profile to write.") -@click.option( - "--platform-key", - "platform", - default=None, - metavar="KEY", - help="The platform key to store; `-` reads it from stdin.", -) -@click.option( - "--deployment-key", - "deployment", - default=None, - metavar="KEY", - help="The deployment key to store; `-` reads it from stdin.", -) -@click.option( - "--llmwhisperer-key", - "llmwhisperer", - default=None, - metavar="KEY", - help="The LLMWhisperer key to store; `-` reads it from stdin.", -) -@click.option( - "--force", - is_flag=True, - default=False, - help="Overwrite a profile that belongs to a different organisation, or drop " - "keys a host change leaves unchecked.", -) -@pass_context -def login(ctx: Context, profile: str | None, force: bool, **given: str | None) -> None: - """Store your keys in a profile, checking each one that can be checked. - - At a terminal this first asks for each product's host, Enter keeping the - one shown, then for each key in turn -- platform, deployment, LLMWhisperer - -- and Enter skips one; at least one is needed. Without a terminal, pass - the keys as flags, one of them as `-` to read it from stdin, and the host - as `--base-url` if it is not the default. - - \b - Examples: - unstract auth login - unstract auth login --profile staging - echo "$PLATFORM_KEY" | unstract auth login --platform-key - - unstract auth login --deployment-key "$DEPLOYMENT_KEY" --llmwhisperer-key - - - The platform key is checked with `whoami` and the organisation it resolves - is stored beside it; the LLMWhisperer key is checked against the usage - endpoint. A deployment key has no side-effect-free endpoint, so it is stored - as given and reported as unverified. Nothing is written until every check - has passed. Running it again replaces the keys given and keeps the rest, - unless it stores a different host: keys this run did not check are dropped - rather than left pointing at a server that never accepted them. - - Exits 3 when a key is rejected and 2 when no key was given. - """ - if given["platform"] is None: - given["platform"] = ctx.overrides.get(f"{DOCSTUDIO}.platform_key") - interactive = not any(value is not None for value in given.values()) - if interactive and not _interactive(): - raise CLIError( - "No keys were given and stdin is not a terminal, so there is nothing " - "to ask for them with.", - ExitCode.USAGE, - hint="Pass --platform-key, --deployment-key or --llmwhisperer-key, as a " - "value or as `-` to read one of them from stdin.", - ) - - try: - cfg = _writable_config() - name = _profile_to_write(ctx, cfg, profile, create=True) - except SaveDeclinedError as exc: - raise CLIError( - f"Cannot store keys: {exc.reason}.", ExitCode.USAGE, hint=exc.hint - ) from exc - except ConfigError as exc: - raise CLIError(str(exc), ExitCode.USAGE) from exc - - hosts: dict[str, str] = {} - if interactive: - click.echo( - "Welcome to Unstract -- let's connect this machine to your organisation.", - err=True, - ) - hosts = _hosts_from_prompts(_validation_config(ctx, cfg, name, {})) - keys = _keys_from_prompts() - else: - keys = _keys_from_flags(given) - if not any(keys.values()): - raise CLIError( - "No key was given; at least one is needed.", ExitCode.USAGE, hint=KEY_SOURCES - ) - # A key given here is never read back through the config layer that - # registers one, so nothing else would scrub it out of an error payload. - for value in keys.values(): - remember_secret(value) - - resolved = _validation_config(ctx, cfg, name, keys, hosts) - timeout = getattr(ctx, "transport_timeout", None) - result: dict[str, Any] = {"profile": name, "path": None} - identity: dict[str, Any] = {} - if keys["platform"]: - with translated(endpoint="whoami"): - identity = platform_client(resolved, timeout=timeout).whoami() - result["organization_id"] = identity.get("organization_id") - result["organization_name"] = identity.get("organization_name") - if keys["llmwhisperer"]: - with translated(endpoint="get-usage-info"): - llmwhisperer(resolved).get_usage_info() - for credential, *_ in _CREDENTIALS: - if not keys[credential]: - result[credential] = "skipped" - elif credential == "deployment": - result[credential] = "stored" - else: - result[credential] = "verified" - if keys["deployment"]: - result["note"] = ( - "A deployment key has no side-effect-free endpoint to check it " - "against, so it was stored as given." - ) - - org_id = str(identity["organization_id"]) if identity.get("organization_id") else None - if keys["platform"] and not org_id: - diagnostic( - "warning: the platform API returned no organization_id; the key is " - "stored without one.", - quiet=ctx.quiet, - verbosity=ctx.verbosity, - ) - checked_as = name - existing = cfg.profiles.get(name, {}).get(DOCSTUDIO, {}).get("org_id") - if org_id and existing and existing != org_id: - # Silently overwriting would repoint every deployment entry in the - # profile at an organisation none of them belong to. - found = f"{org_id} ({identity.get('organization_name')})" - if not interactive and not force: - raise CLIError( - f"Profile {name!r} belongs to organisation {existing}, and this " - f"platform key belongs to {found}.", - ExitCode.USAGE, - hint=f"Pass --profile to write another profile, or --force " - f"to overwrite {name!r}.", - ) - if interactive and _confirm( - f"Profile {name!r} belongs to organisation {existing}; this key belongs " - f"to {found}. Create a new profile for it instead of overwriting?", - default=True, - ): - name = _new_profile_name(cfg, org_id, identity.get("organization_name")) - result["profile"] = name - cfg.profiles[name] = {} - - stranded, paths = ( - _stranded_credentials(cfg, name, keys, resolved) - if name == checked_as and name in cfg.profiles - else ([], []) - ) - if stranded: - # Keeping them would leave credentials this run never checked beside the - # host it stores, and the next command would send them there. - listed = ", ".join(stranded) - if not interactive and not force: - raise CLIError( - f"Profile {name!r} holds credentials that were not re-supplied, and " - f"this login stores a different host for them: {listed}.", - ExitCode.USAGE, - hint="Supply them in this login, pass --profile to write " - "another profile, or --force to drop them.", - ) - if ( - interactive - and not force - and not _confirm( - f"This login stores a different host for {listed}, which " - f"{name!r} holds but this run did not check. Drop them?", - default=False, - ) - ): - raise CLIError( - "Nothing was written.", - ExitCode.USAGE, - hint="Supply the keys in this login, or pass --profile to " - "write another profile.", - ) - - block = cfg.profiles.setdefault(name, {}) - for path in paths: - table = block - for segment in path[:-1]: - table = table.get(segment, {}) - table.pop(path[-1], None) - # An entry left with no key is still listed as a deployment the profile holds. - if path[0] == "deployments" and not table: - block["deployments"].pop(path[1], None) - for product in PRODUCTS: - if f"{product}.base_url" in hosts: - block.setdefault(product, {})["base_url"] = hosts[f"{product}.base_url"] - noticed: set[str] = set() - for credential, _flag, _label, (product, key) in _CREDENTIALS: - if not keys[credential]: - continue - product_block = block.setdefault(product, {}) - product_block[key] = keys[credential] - # The key was checked against the host the run resolved -- a flag, the - # environment, or the profile the login started from. A profile that - # records any other host, or none, would send the key somewhere it was - # never checked. - host_source = resolved.resolution_source(product, "base_url") - decided_by = host_source["source"] - if ( - name != checked_as - or "base_url" not in product_block - or decided_by == "flag/override" - or decided_by.startswith("env:") - ): - product_block["base_url"] = resolved.get(product, "base_url") - elif ( - host_source["resolved"] - and decided_by.startswith("profile -> env:") - and product not in noticed - ): - # The profile holds a reference rather than a host, so the host the - # keys were checked against can change without the file changing. - noticed.add(product) - diagnostic( - f"{product} base_url resolves through " - f"${decided_by.removeprefix('profile -> env:')}; the keys were " - f"checked against {resolved.get(product, 'base_url')}. Run " - "`unstract auth login` again if that variable changes.", - quiet=ctx.quiet, - verbosity=ctx.verbosity, - ) - if org_id: - block.setdefault(DOCSTUDIO, {})["org_id"] = org_id - if not cfg.default_profile: - cfg.default_profile = name - try: - result["path"] = str(save_config(cfg)) - except (OSError, ConfigError) as exc: - raise CLIError( - f"The keys were accepted but could not be written: {exc}", - ExitCode.SAVE_FAILED, - ) from exc - - diagnostic( - f"wrote profile {name!r} in {result['path']}", - quiet=ctx.quiet, - verbosity=ctx.verbosity, - ) - finish(ctx, result) - - -@auth_group.command("whoami") -@click.option( - "--save/--no-save", - default=True, - help="Write the resolved organisation into the active profile.", -) -@pass_context -def whoami(ctx: Context, save: bool) -> None: - """Resolve which organisation your platform key belongs to. - - The organisation is otherwise only discoverable by reading it out of a - web-app URL, and every other command needs it. Resolving it here and storing - it means it is supplied once rather than pasted. - - \b - Examples: - export UNSTRACT_PLATFORM_KEY=... - unstract auth whoami - unstract auth whoami --no-save # validate the key, change nothing - - Exits 3 when the key is rejected, so a setup script can branch on it without - reading the message. - """ - client = platform_client(ctx.config, timeout=getattr(ctx, "transport_timeout", None)) - with translated(endpoint="whoami"): - identity = client.whoami() - - if not save: - finish(ctx, identity, meta={"saved": False, "reason": "--no-save"}) - return - - org_id = identity.get("organization_id") - if not org_id: - diagnostic( - "warning: the platform API returned no organization_id; nothing was stored.", - quiet=ctx.quiet, - verbosity=ctx.verbosity, - ) - finish(ctx, identity, meta={"saved": False, "reason": "no organization_id"}) - return - - try: - written = _store_organisation(ctx, str(org_id)) - except SaveDeclinedError as exc: - # The write is a convenience; failing the command would discard the - # identity it was asked for. - diagnostic( - f"note: org_id was not stored -- {exc.reason}. {exc.hint}", - quiet=ctx.quiet, - verbosity=ctx.verbosity, - ) - finish(ctx, identity, meta={"saved": False, "reason": exc.reason}) - return - except (OSError, ConfigError) as exc: - raise CLIError( - f"Resolved the organisation but could not write it: {exc}", - ExitCode.SAVE_FAILED, - details=identity, - hint="`details` carries the identity; set $UNSTRACT_ORG_ID or run " - f"`unstract config set docstudio org_id {org_id}`.", - ) from exc - - # `meta` is not rendered by `-o table` or `-o raw`, so the write is said here. - diagnostic( - f"wrote org_id={org_id} to profile {written['profile']!r} in {written['path']}", - quiet=ctx.quiet, - verbosity=ctx.verbosity, - ) - finish(ctx, identity, meta={"saved": True, **written}) - - -@deployment_group.command("ls") -@click.option( - "--api-name", - default=None, - help="Return only the deployment with this exact API name.", -) -@click.option( - "--full/--no-full", - default=False, - help=f"Return every field the server sends, not just {', '.join(LISTING_FIELDS)}.", -) -@pass_context -def ls(ctx: Context, api_name: str | None, full: bool) -> None: - """List the API deployments in your organisation. - - Answers what a deployment is called, which is the one thing `deployment run` - needs and the UI is the only other place to find. Authenticates with the - platform key, not the deployment key -- but takes the same `--base-url` as - its sibling commands, since one deployment serves both. - - \b - Examples: - unstract docstudio deployment ls - unstract docstudio deployment ls --api-name invoice-parser - unstract docstudio deployment ls --full - """ - if ctx.config.overrides.get(f"{DOCSTUDIO}.api_key") is not None: - # `--api-key` on this group means a deployment key, and this command - # authenticates with a platform key: honouring it would send the wrong - # credential, and ignoring it silently reads as a broken flag. - raise CLIError( - "`--api-key` on `docstudio` is a deployment key; " - "`deployment ls` authenticates with a platform key.", - ExitCode.USAGE, - hint="Pass --platform-key instead, set $UNSTRACT_PLATFORM_KEY, or " - "add `platform_key` to the [profiles..docstudio] block. A " - "deployment key runs a deployment; a platform key describes the " - "account.", - ) - - org_id = organisation(ctx.config) - client = platform_client( - ctx.config, - org_id, - timeout=getattr(ctx, "transport_timeout", None), - ) - with translated(endpoint="api/deployment/"): - page = client.list_deployments(org_id, api_name=api_name) - - rows = deployment_rows(page) - if not full: - rows = [{field: row.get(field) for field in LISTING_FIELDS} for row in rows] - # `count` is the server's total across pages, which is not `len(rows)` once - # there are more deployments than fit one page. This command does not - # paginate, so both are reported and `next` says whether more would come. - finish( - ctx, - {"results": rows}, - meta={ - "shown": len(rows), - "count": page.get("count"), - "more": bool(page.get("next")), - }, - ) - - -__all__ = ["SaveDeclinedError", "login", "ls", "whoami"] diff --git a/src/unstract_cli/commands/whisper_cmd.py b/src/unstract_cli/commands/whisper_cmd.py deleted file mode 100644 index 76b5aa1..0000000 --- a/src/unstract_cli/commands/whisper_cmd.py +++ /dev/null @@ -1,419 +0,0 @@ -"""`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, 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, - preflight, - wait_for_completion, -) - -PRODUCT = "llmwhisperer" - -#: Terminal states as the body reports them. `unknown` is one: it means the -#: service no longer knows the hash, which polling cannot change. -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. -RAW_TEXT = ("result_text",) - -#: What a submission prints, best answer first: an accepted job carries a -#: handle and no text. -EXTRACT_RAW = (*RAW_TEXT, "whisper_hash") - - -def _is_url(source: str) -> bool: - return source.startswith(("http://", "https://")) - - -@raw_fields(*EXTRACT_RAW) -@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 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) - - 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 CLI's own poll loop rather than 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, - wait_for_completion=False, - ) - - if not wait: - finish(ctx, accepted, raw_fields=EXTRACT_RAW) - return - - result = wait_for_completion( - initial=accepted, - spec=EXTRACT_POLL, - poll=translating(client.whisper_status, "whisper-status"), - retrieve=translating( - lambda handle: _extraction(client.whisper_retrieve(handle)), - "whisper-retrieve", - one_shot=True, - ), - 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 - ), - ) - # The text identifies the job nowhere, and the hash is what a later status, - # retrieve or highlights call needs. - finish( - ctx, - result, - raw_fields=EXTRACT_RAW, - meta={"whisper_hash": accepted.get("whisper_hash")} - if accepted.get("whisper_hash") - else None, - ) - - -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, - verbatim_details=True, - 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 -def status(ctx: Context, whisper_hash: str) -> None: - """Report the state of a submitted extraction.""" - client = llmwhisperer(ctx.config) - with translated(endpoint="whisper-status"): - result = client.whisper_status(whisper_hash) - # A failed extraction arrives inside an HTTP 200, so the status code alone - # would call it a success. - if classify(result, EXTRACT_POLL) is PollState.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_fields(*RAW_TEXT) -@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) - if save: - preflight(save) - with translated(endpoint="whisper-retrieve", one_shot=True): - payload = client.whisper_retrieve(whisper_hash) - result = _extraction(payload) - if save: - written = persist(save, result) - diagnostic(f"saved: {written}", quiet=ctx.quiet, verbosity=ctx.verbosity) - finish(ctx, result, raw_fields=RAW_TEXT) - - -@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. - """ - 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) - 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 an earlier, already billed call. - 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 = { - "lines": data, - "rects": _bounding_boxes(client, data, target_width, target_height), - } - 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 divides in the scaling, and a line with no geometry - # is reported as all zeros. - and value[3] - ): - return value - return None - - -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 {} - 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 lines.items() - if metadata is not None - } - - -@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", - 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.""" - remember_secret(auth_token) - 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", - 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.""" - remember_secret(auth_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. - - 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. 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"): - details = client.get_webhook_details(name) - if isinstance(details, dict): - remember_secret(details.get("auth_token")) - finish(ctx, details) - - -@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/config.py b/src/unstract_cli/config.py deleted file mode 100644 index a174239..0000000 --- a/src/unstract_cli/config.py +++ /dev/null @@ -1,747 +0,0 @@ -"""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 a key per deployment for the ones that need their own. - -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 sys -import tempfile -import tomllib -from collections.abc import Callable, Iterator -from copy import deepcopy -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import tomli_w - -from unstract_cli.core.errors import remember_secret, warn - -LLMWHISPERER = "llmwhisperer" -DOCSTUDIO = "docstudio" -PRODUCTS: tuple[str, ...] = (LLMWHISPERER, DOCSTUDIO) - -#: Settings whose values are credentials: registered for scrubbing when -#: resolved, withheld from a discovered project file, never echoed. -SECRET_SETTINGS = frozenset({"api_key", "platform_key"}) - -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 -#: and in the order given. The trailing names are the published clients' own, -#: so an environment set up for a client does not leave the CLI on its default. -#: -#: The platform key sits on the docstudio block beside the deployment key: one -#: deployment serves both the platform API and the deployments it manages, so -#: the two keys share a host and an organisation. -ENV_VARS: dict[tuple[str, str], tuple[str, ...]] = { - (LLMWHISPERER, "api_key"): ("LLMWHISPERER_API_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",), - (DOCSTUDIO, "platform_key"): ("UNSTRACT_PLATFORM_KEY",), -} - - -#: Where the credentials are minted: 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 an " - "organisation admin mints a key covering every deployment in the " - "organisation under Settings -> Global API Deployment Keys. A platform key, " - "which identifies the organisation and lists what is in it but cannot run a " - "deployment, is minted by an organisation admin under " - "Settings -> Platform API Keys." -) - - -def settings_for(product: str) -> tuple[str, ...]: - """The settings a product actually has. - - Products differ: `org_id` and `platform_key` are settings only for - `docstudio` -- llmwhisperer has no organisation -- 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" - -#: 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, and highest precedence like every flag. -_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. - - 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() and not candidate.is_symlink(): - 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. - """ - 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. - - 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, False - if override := os.environ.get("UNSTRACT_CONFIG"): - return Path(override).expanduser(), False - if local := find_project_config(): - return local, True - return HOME_CONFIG.expanduser(), False - - -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 - 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`. - - ``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 if allow_env else None - return value or None - return value - - -#: 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. Such a file may not name an environment variable to read either -- -#: see `ResolvedConfig._env_refused`. -UNTRUSTED_PROJECT_KEYS = SECRET_SETTINGS | {"base_url"} - - -@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, ...] = () - #: 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}``. - #: 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]: - """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 _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, _is_discovered(path) - else: - target, project_local = _resolve_config_path() - if not target.exists(): - return ConfigFile(path=target, exists=False, is_project_local=project_local) - - 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.") - - # Withheld keys are reported rather than dropped in silence; the rest of - # the file still applies. - 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, - raw=raw, - ) - - -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, - *, - on_unconfirmed: Callable[[], None] | None = None, -) -> Path: - """Write the config file with owner-only permissions. - - The file is in place when this returns. ``on_unconfirmed`` is called when - the directory entry could not be synced as well, so the write may not - survive a crash; the warning alone reaches only stderr. - """ - target = path or cfg.path or config_path() - target.parent.mkdir(parents=True, exist_ok=True) - - # Started from the file as it was read: a table this CLI does not know - # about is not a table it may delete. - 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) - - # 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." - ) - - # Written to a temporary file and renamed into place, so a failed write - # cannot destroy a working config. `mkstemp` names the temporary - # unpredictably -- a guessable sibling is a symlink waiting to be planted -- - # and creates it 0600, the mode the rename then gives the config. - try: - handle_fd, name = tempfile.mkstemp(dir=target.parent, suffix=".tmp") - except OSError as exc: - # The atomic rename needs a writable directory, not just a writable - # file. - 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: - tomli_w.dump(doc, fh) - # The rename replaces one whole config with another only if the - # new bytes reached the disk before it. - fh.flush() - os.fsync(fh.fileno()) - os.replace(tmp, target) - # Syncing the file does not cover the directory entry now pointing at - # it. Windows cannot open a directory to sync it; elsewhere the config - # has already landed, so a failure is reported rather than raised. - if sys.platform != "win32": - try: - dir_fd = os.open(target.parent, os.O_RDONLY) - try: - os.fsync(dir_fd) - finally: - os.close(dir_fd) - except OSError as exc: - warn( - f"warning: config written to {target}, but its directory " - f"could not be synced ({exc.strerror}); the write may not " - "survive a crash." - ) - if on_unconfirmed is not None: - on_unconfirmed() - except BaseException: - tmp.unlink(missing_ok=True) - raise - 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) - #: `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: - """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]: - # Settings nested under the product name and nowhere else: 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 {} - - 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) - if key in SECRET_SETTINGS: - remember_secret(value) - return value - - def _tiers(self, product: str, key: str) -> Iterator[Any]: - """What each tier says, in order -- flag, env, profile -- unset as `None`. - - Lazy on purpose: reading the profile block resolves the profile name, - which raises for one that does not exist. A caller answered by an - earlier tier must not be failed by a later one it never consulted. - """ - yield self.overrides.get(f"{product}.{key}") - yield next( - (v for e in ENV_VARS.get((product, key), ()) if (v := os.environ.get(e))), - None, - ) - raw = self._product_block(product).get(key) - yield _deref(raw, allow_env=self._env_allowed(raw)) - - def _explicit(self, product: str, key: str) -> Any: - """The tiers a human supplied: flag, then environment, then profile.""" - return next((v for v in self._tiers(product, key) if v is not None), None) - - def _resolve(self, product: str, key: str, default: Any = None) -> Any: - if (value := self._explicit(product, 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 _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) - 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." - ) - 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: - 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") - # `--api-key` exists but is not suggested: a secret on the command line - # lands in shell history and in the process list. - if key not in SECRET_SETTINGS: - hints.append(f"or pass --{key.replace('_', '-')}") - if not self.file.exists: - hints.append("or run `unstract auth login` to set up interactively") - raise ConfigError( - f"Missing required setting {product}.{key}. To fix: {'; '.join(hints)}." - ) - - def deployment_names(self) -> tuple[str, ...]: - """The deployments the active profile holds a key of its own for.""" - table = self._profile().get("deployments") - return tuple(sorted(table)) if isinstance(table, dict) else () - - def deployment_key(self, api_name: str) -> Any: - """The key to run one deployment with, or ``None`` if nothing names one. - - **flag > env > per-deployment entry > profile key**. The entry is the - most specific value *within* the profile tier, not a tier of its own: a - file value that outranked the environment would let a stale entry hijack - a run the caller set up with ``$UNSTRACT_DEPLOYMENT_KEY``. - """ - value, _source = self._deployment_key(api_name) - remember_secret(value) - return value - - def deployment_key_source(self, api_name: str) -> str | None: - """Which tier the key for this deployment came from, or ``None``. - - One of ``"flag"``, ``"env"``, ``"entry"``, ``"profile"``. A message - about a rejected key names the place it was actually read from, rather - than the place it could have been stored. - """ - return self._deployment_key(api_name)[1] - - def _deployment_key(self, api_name: str) -> tuple[Any, str | None]: - """The key and the tier it came from, resolved once for both callers.""" - tiers = self._tiers(DOCSTUDIO, "api_key") - flag, env = next(tiers), next(tiers) - if flag is not None: - return flag, "flag" - if env is not None: - return env, "env" - if (entry := self._entry_key(api_name)) is not None: - return entry, "entry" - if (profile := next(tiers)) is not None: - return profile, "profile" - return None, None - - def _entry_key(self, api_name: str) -> Any: - """The key the deployment's own entry names, if it names one. - - An ``env:`` reference that does not resolve is not silence. Falling back - there runs the deployment with the profile's key and reports success. - """ - table = self._profile().get("deployments") - entry = table.get(api_name) if isinstance(table, dict) else None - raw = entry.get("api_key") if isinstance(entry, dict) else None - # A discovered project file never gets this far: its keys are withheld - # at load time, so an `env:` reference seen here is always trusted. - if isinstance(raw, str) and raw.startswith("env:"): - if value := _deref(raw, allow_env=True): - return value - raise ConfigError( - f"Deployment {api_name!r} sets api_key to {raw!r}, and " - f"${raw[4:].strip()} is not set in this process's environment." - ) - return raw or None - - def deployment_key_sources(self, api_name: str) -> tuple[str, ...]: - """Every place a key for this deployment could have come from, in order. - - Quoted when none of them did: a caller told only that a key is missing - has to guess which of four places the CLI looked in. - """ - profile = self.active_profile or "" - return ( - "--api-key", - f"${ENV_VARS[(DOCSTUDIO, 'api_key')][0]}", - f'[profiles.{profile}.deployments."{api_name}"] api_key', - f"[profiles.{profile}.docstudio] api_key", - ) - - 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: - 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() - 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, - "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)"} - - 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 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 entry'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`. - - Every credential uses ``env:`` indirection: the generated file is a map of - where secrets live, never a copy of them. - - No ``platform_key`` and no ``deployments`` table on purpose: a platform key - is optional and an ``env:`` reference to an unset variable is a `config - doctor` problem, and one deployment key normally covers the organisation, - so a per-deployment key is the exception rather than the shape to start - from. - """ - 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", - }, - }, - "cloud-eu": { - LLMWHISPERER: { - "base_url": "https://llmwhisperer-api.eu-west.unstract.com/api/v2", - "api_key": "env:LLMWHISPERER_API_KEY", - }, - }, - # 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", - "api_key": "env:LLMWHISPERER_API_KEY", - }, - DOCSTUDIO: { - "base_url": "https://unstract.internal.example", - "org_id": "", - "api_key": "env:UNSTRACT_DEPLOYMENT_KEY", - }, - }, - } - - -__all__ = [ - "DEFAULT_BASE_URLS", - "DOCSTUDIO", - "ENV_VARS", - "HOME_CONFIG", - "KEY_SOURCES", - "LLMWHISPERER", - "PRODUCTS", - "PROJECT_CONFIG_NAME", - "SECRET_SETTINGS", - "UNTRUSTED_PROJECT_KEYS", - "ConfigError", - "ConfigFile", - "ResolvedConfig", - "config_path", - "find_project_config", - "init_path", - "load_config", - "save_config", - "set_config_path", - "settings_for", - "starter_profiles", -] diff --git a/src/unstract_cli/config/__init__.py b/src/unstract_cli/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unstract_cli/config/loader.py b/src/unstract_cli/config/loader.py new file mode 100644 index 0000000..208ad34 --- /dev/null +++ b/src/unstract_cli/config/loader.py @@ -0,0 +1,415 @@ +"""Profile-based configuration (SPEC.md §4). + +Three products, three incompatible auth schemes, region-specific and on-prem +hosts, and `org_id` as a URL *path segment* rather than a flag. Named profiles +(kubectl/aws style) hold per-product host, key and org. + +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 + +from unstract_cli.core.model import ApiGroup, Product + +#: Config blocks are nested by product, then by API group: +#: +#: [profiles.cloud-us.docstudio.platform] +#: [profiles.cloud-us.llmwhisperer] +#: +#: Document Studio owns three API groups with distinct hosts and keys, so they +#: keep separate sub-blocks rather than being flattened into one. +GROUP_PATH: dict[str, tuple[str, ...]] = { + ApiGroup.PLATFORM.value: ("docstudio", "platform"), + ApiGroup.DEPLOYMENT.value: ("docstudio", "deployment"), + ApiGroup.HITL.value: ("docstudio", "hitl"), + ApiGroup.LLMWHISPERER.value: ("llmwhisperer",), + ApiGroup.APIHUB.value: ("apihub",), +} + +#: The names `config get`/`config set` accept, derived from GROUP_PATH so the +#: command line and the file layout can never disagree. A group owned by a +#: product must be addressed through it -- `docstudio.platform`, never a bare +#: `platform` -- because the bare form hides which product a setting belongs to. +TARGET_NAMES: tuple[str, ...] = tuple( + ".".join(path) for path in GROUP_PATH.values() +) + + +def resolve_target(name: str) -> str | None: + """Map a user-supplied target onto its API group. + + Accepts either separator, so `docstudio.platform` and `docstudio platform` + are the same thing -- the latter is what a shell user reaches for after + typing `unstract docstudio platform ...`. + """ + wanted = tuple(name.replace(".", " ").split()) + for group, path in GROUP_PATH.items(): + if wanted == path: + return group + return None + +#: Built-in defaults, lowest precedence. API Hub deliberately has **no** default +#: base URL: its public hostname is unconfirmed (SPEC.md §11.1), and inventing +#: one would send an agent's documents to a host we cannot vouch for. +DEFAULT_BASE_URLS: dict[str, str] = { + ApiGroup.LLMWHISPERER.value: "https://llmwhisperer-api.us-central.unstract.com/api/v2", + ApiGroup.PLATFORM.value: "https://us-central.unstract.com", + ApiGroup.DEPLOYMENT.value: "https://us-central.unstract.com", + ApiGroup.HITL.value: "https://us-central.unstract.com", +} + +#: Environment variables per (product, setting), checked before the config file. +ENV_VARS: dict[tuple[str, str], tuple[str, ...]] = { + (ApiGroup.LLMWHISPERER.value, "api_key"): ("LLMWHISPERER_API_KEY",), + (ApiGroup.LLMWHISPERER.value, "base_url"): ("LLMWHISPERER_BASE_URL",), + (ApiGroup.PLATFORM.value, "api_key"): ("UNSTRACT_PLATFORM_KEY",), + (ApiGroup.PLATFORM.value, "base_url"): ("UNSTRACT_BASE_URL",), + (ApiGroup.PLATFORM.value, "org_id"): ("UNSTRACT_ORG_ID",), + (ApiGroup.DEPLOYMENT.value, "api_key"): ("UNSTRACT_DEPLOYMENT_KEY",), + (ApiGroup.DEPLOYMENT.value, "base_url"): ("UNSTRACT_BASE_URL",), + (ApiGroup.DEPLOYMENT.value, "org_id"): ("UNSTRACT_ORG_ID",), + (ApiGroup.HITL.value, "api_key"): ("UNSTRACT_DEPLOYMENT_KEY", "UNSTRACT_PLATFORM_KEY"), + (ApiGroup.HITL.value, "base_url"): ("UNSTRACT_BASE_URL",), + (ApiGroup.HITL.value, "org_id"): ("UNSTRACT_ORG_ID",), + (ApiGroup.APIHUB.value, "api_key"): ("UNSTRACT_APIHUB_KEY",), + (ApiGroup.APIHUB.value, "base_url"): ("UNSTRACT_APIHUB_BASE_URL",), + (ApiGroup.APIHUB.value, "anthropic_key"): ("UNSTRACT_ANTHROPIC_API_KEY",), + (ApiGroup.APIHUB.value, "llmwhisperer_key"): ("LLMWHISPERER_API_KEY",), +} + + +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 (SPEC.md §4.1). +_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 config_path() -> Path: + """Location of the config file. + + Resolution: ``--config`` flag, then ``$UNSTRACT_CONFIG``, then + ``$XDG_CONFIG_HOME/unstract/config.toml``, then + ``~/.config/unstract/config.toml``. + + Several config files are expected, not exceptional: a per-project file + checked into a repo, a throwaway one in CI, and a personal default can all + coexist, 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 + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg).expanduser() if xdg else Path.home() / ".config" + return base / "unstract" / "config.toml" + + +#: Filename a project can commit to point the CLI at its own settings. +PROJECT_CONFIG_NAME = ".unstract.toml" + + +def find_project_config(start: Path | None = None) -> Path | None: + """Search upward from the working directory for ``.unstract.toml``. + + Mirrors how git, ruff and similar tools resolve project settings: running the + CLI inside a project picks up that project's config without any 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 _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 ``config.toml``.""" + + default_profile: str | None = None + profiles: dict[str, 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_block(self, product: str) -> 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}. Known profiles: {known}" + ) + return {} + # Exactly one accepted shape: the API group nested under its product, + # e.g. [profiles.X.docstudio.platform]. LLMWhisperer and API Hub own a + # single group each, so their block sits directly under the product name. + # + # No aliases and no flat fallback. A block written any other way is not + # silently picked up -- 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. + node: Any = profile + for segment in GROUP_PATH.get(product, (product,)): + node = node.get(segment) if isinstance(node, dict) else None + if node is None: + return {} + return node if isinstance(node, dict) else {} + + def get(self, product: str | ApiGroup | Product, key: str, default: Any = None) -> Any: + """Resolve one setting: **flag > env > profile > built-in default**.""" + product = product.value if isinstance(product, (ApiGroup, Product)) else product + + 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._profile_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 | ApiGroup | Product, 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 + + product = product.value if isinstance(product, (ApiGroup, Product)) else product + hints: list[str] = [] + if env_vars := ENV_VARS.get((product, key)): + hints.append(f"set ${env_vars[0]}") + block = ".".join(GROUP_PATH.get(product, (product,))) + hints.append(f"or add `{key}` to the [profiles..{block}] 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 resolution_source(self, product: str | ApiGroup | Product, 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?" It reports the winning source (override / env var / profile + literal / profile env: ref / unset) so the mismatch is obvious. + """ + name = product.value if isinstance(product, (ApiGroup, Product)) else product + + if self.overrides.get(f"{name}.{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((name, key), ()): + if os.environ.get(env_var): + return {"resolved": True, "source": f"env:{env_var}"} + + raw = self._profile_block(name).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(name): + return {"resolved": True, "source": "built-in default"} + return {"resolved": False, "source": "unset"} + + +def starter_profiles() -> dict[str, 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": { + # Document Studio -- one product, three API groups with distinct + # hosts and credentials. + "docstudio": { + "platform": { + "base_url": DEFAULT_BASE_URLS[ApiGroup.PLATFORM.value], + "org_id": "", + "api_key": "env:UNSTRACT_PLATFORM_KEY", + }, + "deployment": { + "base_url": DEFAULT_BASE_URLS[ApiGroup.DEPLOYMENT.value], + "org_id": "", + "api_key": "env:UNSTRACT_DEPLOYMENT_KEY", + }, + "hitl": { + "base_url": DEFAULT_BASE_URLS[ApiGroup.HITL.value], + "org_id": "", + "api_key": "env:UNSTRACT_DEPLOYMENT_KEY", + }, + }, + "llmwhisperer": { + "base_url": DEFAULT_BASE_URLS[ApiGroup.LLMWHISPERER.value], + "api_key": "env:LLMWHISPERER_API_KEY", + }, + "apihub": {"base_url": "", "api_key": "env:UNSTRACT_APIHUB_KEY"}, + }, + "cloud-eu": { + "llmwhisperer": { + "base_url": "https://llmwhisperer-api.eu-west.unstract.com/api/v2", + "api_key": "env:LLMWHISPERER_API_KEY", + }, + }, + } + + +__all__ = [ + "DEFAULT_BASE_URLS", + "ENV_VARS", + "PROJECT_CONFIG_NAME", + "TARGET_NAMES", + "resolve_target", + "find_project_config", + "set_config_path", + "ConfigError", + "ConfigFile", + "ResolvedConfig", + "config_path", + "load_config", + "save_config", + "starter_profiles", +] diff --git a/src/unstract_cli/core/clients.py b/src/unstract_cli/core/clients.py deleted file mode 100644 index ea03414..0000000 --- a/src/unstract_cli/core/clients.py +++ /dev/null @@ -1,417 +0,0 @@ -"""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 clients report failure differently -- LLMWhisperer raises with a status -code attached, the deployment client returns a dict containing one, the platform -client spells one into its message -- so every shape converges 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 - -import json -import re -import socket -from collections.abc import Callable, Iterator -from contextlib import contextmanager -from typing import Any - -from requests.exceptions import ( - ConnectionError, - InvalidHeader, - InvalidSchema, - InvalidURL, - MissingSchema, - RequestException, - Timeout, - URLRequired, -) -from unstract.api_deployments.client import ( - APIDeploymentsClient, - APIDeploymentsClientException, - PlatformClientError, -) -from unstract.llmwhisperer.client_v2 import ( - LLMWhispererClientException, - LLMWhispererClientV2, -) - -from unstract_cli.config import ( - DOCSTUDIO, - ENV_VARS, - KEY_SOURCES, - 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 - - -#: Transport timeout for the clients that set none of their own. Same figure -#: as the LLMWhisperer client, so every path stalls alike. -DEFAULT_TRANSPORT_TIMEOUT = 120.0 - - -def deployment( - config: ResolvedConfig, - api_name: str, - transport_timeout: float | None = DEFAULT_TRANSPORT_TIMEOUT, -) -> APIDeploymentsClient: - """Build a client for one deployment, named by its API name. - - Fails before any request when no key or organisation resolves: a credential - error from the server would name the wrong fault, and the caller is told - every place the CLI looked rather than only that it found nothing. - """ - org_id = config.get(DOCSTUDIO, "org_id") - if not org_id: - raise CLIError( - f"No organisation is configured to run {api_name!r} in.", - ExitCode.USAGE, - hint="Run `unstract auth login`, set $UNSTRACT_ORG_ID, or pass --org-id.", - ) - api_key = config.deployment_key(api_name) - if not api_key: - looked_in = ", ".join(config.deployment_key_sources(api_name)) - raise CLIError( - f"No key resolves for deployment {api_name!r}. Looked in: {looked_in} " - "-- all unset.", - ExitCode.USAGE, - hint=( - f"Set ${ENV_VARS[(DOCSTUDIO, 'api_key')][0]}, or store a key: " - "`unstract config set docstudio api_key ` for one that covers " - "the organisation, or `unstract config set docstudio api_key " - f" --deployment {api_name}` for this deployment alone. " - f"{KEY_SOURCES}" - ), - ) - - return 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, - ) - - -def _rejected_key_hint(api_name: str, source: str | None) -> str: - """What to do about a key this deployment refused, where it came from. - - Advising a per-deployment key is only useful to a caller who does not have - one: told to the caller whose per-deployment key was just rejected, it is - the step that has already failed. - """ - stored = f"`unstract config set docstudio api_key --deployment {api_name}`" - if source == "entry": - return f"The key stored for {api_name!r} was rejected. Replace it: {stored}." - if source == "flag": - return "The key passed with --api-key was rejected." - if source == "env": - env_var = ENV_VARS[(DOCSTUDIO, "api_key")][0] - return f"The key in ${env_var} was rejected." - return f"This deployment may need a key of its own: {stored}." - - -@contextmanager -def deployment_errors(api_name: str, key_source: str | None = None) -> Iterator[None]: - """Say what a refusal means for *this* deployment, once the server answers. - - A rejected key and an unknown name are both indistinguishable from success - until the service answers, so the correction belongs on the answer: a key - that works elsewhere may not cover this deployment, and a name that was - valid may have been renamed since it was written down. - """ - try: - yield - except CLIError as exc: - if exc.exit_code is ExitCode.AUTH: - exc.message = ( - f"The key supplied for deployment {api_name!r} does not authorize " - f"it: {exc.message}" - ) - exc.hint = _rejected_key_hint(api_name, key_source) - elif exc.exit_code is ExitCode.NOT_FOUND: - more = ( - "Run `unstract docstudio deployment ls` for the current API names; " - "`unstract config doctor --probe` reports profile entries the " - "organisation no longer has." - ) - exc.hint = f"{exc.hint} {more}" if exc.hint else more - 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. - - 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 - - -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. 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. - """ - if not any(isinstance(cause, socket.gaierror) for cause in _causes(exc)): - return None - for cause in _causes(exc): - # httpx keeps the request on its error and urllib3 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 "" - - -#: Failures that mean the request was never sendable, so the fault is in the -#: caller's configuration rather than in the service. `InvalidHeader` is handled -#: before these, to keep the credential it quotes out of the message. -UNSENDABLE = ( - MissingSchema, - InvalidSchema, - InvalidURL, - URLRequired, -) - - -#: The status inside a `PlatformClientError` message: the client spells it into -#: prose rather than carrying it, so this is the only route from a refused -#: platform call to the right exit code. -_PLATFORM_STATUS = re.compile(r"failed with (\d{3})\b") - - -@contextmanager -def translated(endpoint: str | None = None, *, one_shot: bool = False) -> Iterator[None]: - """Turn a client failure into a CLIError with an exit code and a hint. - - ``one_shot`` marks a read the service serves exactly once, which changes - what a 406 from it means. - """ - 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, - one_shot=one_shot, - ) from exc - raise CLIError(message, details=details, endpoint=endpoint) from exc - except PlatformClientError as exc: - # Ordered before `APIDeploymentsClientException`, which it derives - # from: caught there, a rejected key would exit USAGE rather than AUTH, - # which a setup script branches on. The status is read back out of the - # message because the exception does not carry one. - if match := _PLATFORM_STATUS.search(str(exc)): - raise error_from_status( - int(match.group(1)), str(exc), endpoint=endpoint, one_shot=one_shot - ) from exc - # Unparseable: the failure is real and the status is unknown, so report - # it as a server-side failure rather than blaming the caller's usage. - raise CLIError(str(exc), ExitCode.SERVER_ERROR, 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: - 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, - endpoint=endpoint, - 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 -- the credential -- - # and it arrives `repr`-escaped, so the scrub cannot match it either. - 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: - # The request could never be sent, so retrying is the wrong advice and - # the fault is in the 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 json.JSONDecodeError as exc: - # A client that parses the body itself raises this on a 2xx carrying - # HTML from a proxy or SPA host. The base class, not the `requests` - # subclass: only one of the clients raises that one. - raise CLIError( - str(exc), - ExitCode.SERVER_ERROR, - endpoint=endpoint, - hint=( - "The service answered, but not with JSON. Check that " - "`base_url` names the API rather than a proxy or web app." - ), - ) 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( - call: Callable[..., Any], endpoint: str | None = None, *, one_shot: bool = False -) -> 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, one_shot=one_shot): - return call(*args, **kwargs) - - return wrapped - - -def raise_for_result( - result: dict[str, Any], endpoint: str | None = None, *, one_shot: bool = False -) -> 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. - """ - 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 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}"), - details=result, - endpoint=endpoint, - one_shot=one_shot, - ) - if reported: - # HTTP success carrying a failure in the body. Not retryable: a re-run - # starts a second billed execution rather than retrying this one. - 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__ = [ - "DEFAULT_TRANSPORT_TIMEOUT", - "UNSENDABLE", - "deployment", - "deployment_errors", - "deployment_url", - "llmwhisperer", - "raise_for_result", - "translated", - "translating", -] diff --git a/src/unstract_cli/core/discover.py b/src/unstract_cli/core/discover.py deleted file mode 100644 index 76d1f2f..0000000 --- a/src/unstract_cli/core/discover.py +++ /dev/null @@ -1,189 +0,0 @@ -"""`--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 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. -""" - -from __future__ import annotations - -from typing import Any - -import click - -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") - -#: Click's marker for "no default was given": not exported, and not the same -#: value across versions, so it is read off a bare option rather than named. -_NO_DEFAULT = click.Option(["--unset"]).default - -#: The same marker for a paired on/off flag declared with `default=None`, which -#: is how this CLI declares a flag it does not send unless asked. -_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. - - 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 [ - { - "code": int(code), - "name": code.name.lower(), - "error_code": "" if code is ExitCode.SUCCESS else error_code_for(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.IntRange | click.FloatRange): - # Click names a bounded number "integer range", which a caller cannot - # map onto anything: publish the real type and the bounds separately. - 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): - # Otherwise a flag the CLI cannot convert reads like one it can, and - # the caller finds out only by passing it. - entry["unsupported"] = True - # What omitting the flag gets you, which is not what Click reports: the - # same declaration answers differently from version to version. - default = param.default - if param.secondary_opts and default is _NO_FLAG_DEFAULT: - # No default on a paired flag means "not passed, so not sent"; - # publishing the `False` reported here would promise a value. - 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 - # For a spec-derived flag: what 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 - - -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 != "help"] - - -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) - # What `--output raw` prints, best answer first: the first field the - # answer carries wins, and an answer carrying none of them fails. - 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()) - } - 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 CLIError( - f"Unknown discovery tier {tier!r}.", - ExitCode.USAGE, - hint=f"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": [ - summary(name, sub) for name, sub in top if isinstance(sub, click.Group) - ], - # 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 - if not isinstance(sub, click.Group) - ], - } - - payload: dict[str, Any] = { - "tier": tier, - "commands": { - name: _describe(sub, tier) for name, sub in sorted(root.commands.items()) - }, - } - if tier == "full": - payload["params"] = _params(root) - payload["exit_codes"] = exit_codes() - payload["contract"] = contract() - return payload - - -__all__ = ["TIERS", "contract", "discover", "exit_codes"] diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index db94136..3a1cd89 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -1,21 +1,23 @@ -"""Exit codes, structured errors, and secret redaction. +"""Exit codes, structured errors, and secret redaction (SPEC.md §5.4, §5.5, §5.7). -Exit codes are a stable API: a caller branches on them without parsing prose. -Every failure carries `retryable`, and a `hint` wherever one can be given, so -the caller can self-correct rather than retry blindly. +Exit codes are a stable API: an agent branches on them without parsing prose. +Every failure also emits a JSON object on **stderr**, carrying `hint` and +`retryable` so an agent can self-correct rather than retry blindly. """ from __future__ import annotations +import json import re import sys -from collections.abc import Callable from dataclasses import dataclass, field from enum import IntEnum from typing import Any class ExitCode(IntEnum): + """Stable exit codes (SPEC.md §5.4).""" + SUCCESS = 0 GENERIC = 1 USAGE = 2 @@ -26,19 +28,18 @@ class ExitCode(IntEnum): TIMEOUT = 7 SERVER_ERROR = 8 ALREADY_CONSUMED = 9 - SAVE_FAILED = 10 - #: 128 + SIGINT, which a shell already reads as "the user stopped it" - #: rather than as a failure of the command. - INTERRUPTED = 130 -#: HTTP status -> exit code. An in-progress 422 never reaches here: the poll -#: engine branches on the response body first. +#: HTTP status -> exit code. 422 maps to VALIDATION, which is correct for a real +#: validation failure; the deployment API's misuse of 422 for in-progress states +#: is handled by the poller *before* reaching here, by branching on the response +#: body rather than the status code (SPEC.md §6.2). _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, @@ -55,190 +56,63 @@ class ExitCode(IntEnum): ExitCode.TIMEOUT: "timeout", ExitCode.SERVER_ERROR: "server_error", ExitCode.ALREADY_CONSUMED: "already_consumed", - ExitCode.SAVE_FAILED: "save_failed", - ExitCode.INTERRUPTED: "interrupted", } -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, *, one_shot: bool = False) -> ExitCode: - """Map an HTTP status onto its exit code. - - ``one_shot`` marks a read the service serves exactly once, which is the - only place a 406 means the result is gone. Everywhere else it is content - negotiation -- a proxy or a misaimed host -- and reporting that as a - consumed result sends the reader after a command they never ran. - """ - if status == 406 and one_shot: - return ExitCode.ALREADY_CONSUMED - if (code := _STATUS_MAP.get(status)) is not None: +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 - # A 3xx that was not followed, or a status no spec declares, is still a - # failure: never fall through to SUCCESS. - return ExitCode.GENERIC + if 400 <= status < 500: + return ExitCode.GENERIC + return ExitCode.SUCCESS def is_retryable(status: int) -> bool: - """Retry on rate limiting, server faults and 408 -- never on another 4xx. + """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. A 408 is the exception: the server abandoned the wait - rather than judging the request, so the same request is still worth sending. + Retrying a 4xx re-sends a request the server has already rejected on its + merits. Worse, for one-shot reads a blind retry can consume a result that + the first attempt already delivered (SPEC.md §5.6). """ - return status in (408, 429) or 500 <= status < 600 + return status == 429 or 500 <= status < 600 # --------------------------------------------------------------------------- # # Redaction # --------------------------------------------------------------------------- # -#: Words that mark a field as carrying a credential, matched as whole name -#: segments rather than as substrings. -_SECRET_KEY_HINTS = frozenset( - { - "bearer", - "key", - "keys", - "apikey", - "token", - "tokens", - "secret", - "secrets", - "passwd", - "password", - "credential", - "credentials", - "auth", - "authorization", - } -) +_SECRET_HEADERS = {"unstract-key", "authorization", "apikey"} +_SECRET_HEADER_PREFIXES = ("x-", ) +_SECRET_KEY_HINTS = ("key", "token", "secret", "password", "credential", "auth") 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 _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 can be raised before there is a run to ask whether 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. - - This is the seam that keeps such 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: - return - if len(value) < _MIN_SECRET_LEN: - # Once per value: a key resolves several times in one run. - if value not in _REPORTED_SHORT: - _REPORTED_SHORT.add(value) - warn( - f"warning: a credential under {_MIN_SECRET_LEN} characters is too " - "short to scrub for and will not be redacted" - ) - 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]: - """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) - - -#: 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_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(h in low for h 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 { - # Collapsed whole rather than walked: nothing under a key that - # names a credential is worth the risk of missing one. - k: (REDACTED if names_a_secret(k) else redact_value(v)) + k: ( + REDACTED + if any(h in str(k).lower() for h in _SECRET_KEY_HINTS) + and isinstance(v, str) + else redact_value(v) + ) for k, v in value.items() } if isinstance(value, list): @@ -249,40 +123,16 @@ def redact_value(value: Any) -> Any: 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. + The last line of defence: a credential that reaches a message body via an + upstream error string still must not be printed. """ for secret in secrets: - if secret and len(secret) >= _MIN_SECRET_LEN: + if secret and len(secret) >= 8: + text = text.replace(secret, REDACTED) 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 # --------------------------------------------------------------------------- # @@ -290,7 +140,7 @@ def scrub_structure(value: Any, secrets: list[str]) -> Any: @dataclass class CLIError(Exception): - """A failure that maps onto an exit code and a structured error payload.""" + """A failure that maps onto an exit code and a structured stderr payload.""" message: str exit_code: ExitCode = ExitCode.GENERIC @@ -299,127 +149,134 @@ 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) - #: Set where `details` is the only surviving copy of something the service - #: will not serve again, so redacting by field name would destroy the part - #: the caller most needs. The literal scrub still applies on the way out. - verbatim_details: bool = False 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]: - # Written out whole, then thinned: one list of the names this owns, so - # a field added here cannot be missed by the guard below. payload: dict[str, Any] = { - "code": error_code_for(self.exit_code), + "code": self.code or _ERROR_CODES.get(self.exit_code, "error"), "message": self.message, "exit_code": int(self.exit_code), "retryable": self.retryable, - "http_status": self.http_status, - # Redacted by default: a server body can echo the request back, - # headers and key included. - "details": self.details - if self.verbatim_details - else redact_value(self.details), - "endpoint": self.endpoint or None, - "hint": self.hint or None, } - # `extra` carries server-named keys, which may not rewrite a field a - # caller branches on -- including one omitted here 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( - status: int, - message: str, - *, - details: Any = None, - endpoint: str | None = None, - one_shot: bool = False, -) -> CLIError: - """Build a CLIError from an HTTP status, with its exit code, hint and retryability.""" - return CLIError( - message, - exit_code_for_status(status, one_shot=one_shot), - http_status=status, - details=details, - endpoint=endpoint, - hint=hint_for(status, one_shot=one_shot), - 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. + 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 {"error": payload} + + def emit(self, secrets: list[str] | None = None) -> None: + """Write the structured error to stderr, and to stdout when piped. + + Humans always get it on stderr. When stdout is not a TTY -- the agent / + wrapper case -- the same envelope is *also* written to stdout, so a + pipeline that feeds stdout to a JSON parser sees a valid object instead of + an empty stream (DOC 9). A result and an error never share an invocation's + stdout: emit runs only on the error path, which produces no result output. + """ + text = json.dumps(self.to_dict(), indent=2, default=str) + if secrets: + text = scrub(text, secrets) + print(text, file=sys.stderr) + if not sys.stdout.isatty(): + print(text, file=sys.stdout) + + def emit_stdout_only(self, secrets: list[str] | None = None) -> None: + """Write the envelope to stdout when piped, leaving stderr untouched. + + For Click's own usage errors, whose human message Click has already + printed to stderr: this adds only the machine-readable copy on stdout + (DOC 9), without duplicating the human line. + """ + if sys.stdout.isatty(): + return + text = json.dumps(self.to_dict(), indent=2, default=str) + if secrets: + text = scrub(text, secrets) + print(text, file=sys.stdout) + + +def hint_for(status: int, endpoint: str | None = None, message: str | None = None) -> str | None: + """A short, actionable next step for common failures. + + ``message`` lets a hint target a specific server error where the status code + alone is ambiguous -- an adapter-permission 403 and a dead-vector-DB 500 both + have a much more useful next step than the generic status hint. """ - 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, *, one_shot: bool = False) -> str | None: - """A short, actionable next step for a common failure.""" - if status == 406: + low = (message or "").lower() + + # Adapter permission (IMPROVEMENT 4): the API key identity is distinct from the + # human account of the same name, so an adapter can be "available" yet unusable. + if "permission error" in low and "adapter" in low: return ( - "This execution result was already retrieved. A deployment serves " - "its result exactly once; re-running the status call cannot " - "recover it. Pass --save to `deployment run` to keep the next one." - if one_shot - else "The service would not answer in the format asked for. Check " - "that `base_url` names the API rather than a proxy or web app." + "One or more adapters are not shared with your API key. The API user " + "(e.g. *-api-rw-*@platform.internal) is a DIFFERENT identity from your " + "human account. Share the adapters to the org in the web console, or set " + "a default triad, then retry. `adapter list` shows created_by_email." ) + # The message names the *project* default, but the server never reads it: it + # resolves the profile from the prompt's own profile_manager FK. Chasing the + # project default is the wrong fix and cost real time (GOTCHAS #1). + if "default llm profile is not configured" in low: + return ( + "Misleading message: the server resolves the LLM profile from the " + "PROMPT's own profile_manager field and does NOT fall back to the " + "project default -- so `profile set-default` cannot fix this. Set it " + "on the prompt: `prompt patch --prompt-id --profile-manager " + "` (or pass --profile-manager on this call). Create " + "prompts with --profile-manager to avoid it entirely." + ) + # Deploy-time tool validation (GOTCHAS #2). The 422 surfaces only at + # `deployment run`, long after the tool instance was attached. + if "tool validation failed" in low or ("challenge_llm" in low or "challenge llm" in low): + return ( + "The attached tool instance likely has an empty metadata.challenge_llm, " + "which fails validation even when enable_challenge is false. Fix: " + "`workflow tool get --id ` to read the CURRENT metadata, set " + "challenge_llm to a valid LLM adapter id, and send the COMPLETE object " + "back with `workflow tool set-metadata` (it replaces, never merges). " + "To avoid it next time, set the project's --challenge-llm before " + "`export-tool`." + ) + # Dead vector DB (IMPROVEMENT 6): short docs don't need RAG at all. + if "vectordb" in low or "vector db" in low or "qdrant" in low: + return ( + "The vector DB is unreachable. For short documents you can skip RAG " + "entirely: set the profile's chunk_size=0 (and chunk_overlap=0), which " + "bypasses embedding and the vector DB, then re-index." + ) + match status: - case 400: - return ( - "The service rejected the request. Check the ids and parameter " - "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, 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 " - "works elsewhere can still be rejected here if it is the wrong " - "kind for this command, or does not cover this deployment." + "Check the API key for this product. Keys are per-product: see " + "`unstract config list` and SPEC §4.4 for which env var applies." ) case 404: return ( - "Verify the resource id, and that the organisation matches the " - "resource's own. For deployments, confirm the API name." + "Verify the resource id and that --org-id matches the resource's " + "organization. For deployments, confirm --api-name is correct." ) - case 402: + case 406: return ( - "Out of quota, or the licence does not cover this request. " - "Check the subscription for this product." + "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 413: - return "The upload is larger than the service accepts." - case 415: - return "The file's type is not one this service extracts." + return "The resource is in use or conflicts with an existing one." case 429: - return "Rate limited. Back off and retry." + return "Rate limited. The CLI retries automatically; raise --max-retries if needed." if 500 <= status < 600: - return "Server-side failure. If it persists, check service status." + return "Server-side failure. Retried automatically; if it persists, check service status." return None @@ -427,17 +284,10 @@ def hint_for(status: int, *, one_shot: bool = False) -> str | None: "REDACTED", "CLIError", "ExitCode", - "error_code_for", - "error_codes", - "scrub_structure", - "forget_secrets", - "known_secrets", - "remember_secret", - "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/generate.py b/src/unstract_cli/core/generate.py new file mode 100644 index 0000000..ca30a06 --- /dev/null +++ b/src/unstract_cli/core/generate.py @@ -0,0 +1,385 @@ +"""Turn `Endpoint` records into Click commands (SPEC.md D1). + +This module is the load-bearing abstraction: the command tree, every flag, all +help text, validation and `--discover` derive from the same records. Adding +an endpoint means adding one record -- there is no second place to update, so +help text cannot drift from behaviour. + +Click is used directly rather than Typer's decorator API because the parameters +are known only at runtime, from data. Typer's ergonomics are built for +statically-declared function signatures. +""" + +from __future__ import annotations + +import contextlib +import json +from pathlib import Path +from typing import Any + +import click + +from unstract_cli.config.loader import ConfigError, ResolvedConfig, load_config +from unstract_cli.core import http +from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.model import ( + Endpoint, + Param, + ParamType, +) +from unstract_cli.core.output import ( + OutputFormat, + default_format, + diagnostic, + emit, +) +from unstract_cli.core.poll import wait_for_completion + +#: Flags every generated command accepts (SPEC.md §5.1, §5.7). +GLOBAL_FLAGS = ("output", "profile", "quiet", "verbose", "dry_run", "max_retries", "no_retry", "timeout", "base_url") + +_CLICK_TYPES: dict[ParamType, Any] = { + ParamType.STR: click.STRING, + ParamType.INT: click.INT, + ParamType.FLOAT: click.FLOAT, + ParamType.BOOL: click.BOOL, + ParamType.UUID: click.STRING, + ParamType.JSON: click.STRING, + ParamType.FILE: click.STRING, + ParamType.DATE: click.STRING, +} + + +def _help_text(param: Param) -> str: + """Compose help for one flag, folding in everything an agent needs. + + Defaults, enums and conditional applicability all appear here, because + `--help` is the primary discovery surface (SPEC.md §5.3). + """ + parts = [param.help.rstrip(".") if param.help else ""] + if mapping := param.choice_map(): + parts.append(f"One of: {', '.join(mapping)}") + if param.default is not None and not isinstance(param.default, bool): + parts.append(f"Default: {param.default}") + if param.applies_when: + # P9: documented, never enforced -- the server owns the rule. + parts.append(f"Applies only when {param.applies_when}") + if param.multiple: + parts.append("Repeatable") + if param.replace_semantics: + parts.append("REPLACES the existing list rather than appending") + if param.default_from: + parts.append("Defaults from the active profile") + return ". ".join(p for p in parts if p) + "." if any(parts) else "" + + +def _click_option(param: Param) -> click.Option: + """Build a Click option from a `Param` record.""" + mapping = param.choice_map() + if mapping: + ptype: Any = click.Choice(list(mapping)) + else: + ptype = _CLICK_TYPES.get(param.type, click.STRING) + + kwargs: dict[str, Any] = { + "help": _help_text(param), + "required": False, # Enforced later, so we can give a better message. + "multiple": param.multiple, + "type": ptype, + } + # A default is applied server-side too; sending it explicitly is harmless and + # keeps `--discover` honest about what the value will be. + if param.default is not None and not param.multiple: + kwargs["default"] = param.default + kwargs["show_default"] = True + + if param.type is ParamType.BOOL and not mapping: + flag = param.cli_flag.lstrip("-") + return click.Option([f"--{flag}/--no-{flag}"], default=None, help=kwargs["help"]) + + return click.Option([param.cli_flag, param.py_name], **kwargs) + + +def _resolve_config(ctx: click.Context, kwargs: dict[str, Any], endpoint: Endpoint) -> ResolvedConfig: + overrides: dict[str, Any] = {} + if base_url := kwargs.get("base_url"): + overrides[f"{endpoint.product.value}.base_url"] = base_url + if org_id := kwargs.get("org_id"): + overrides[f"{endpoint.product.value}.org_id"] = org_id + + root = ctx.find_root().params if ctx.find_root() else {} + cfg = load_config() + resolved = ResolvedConfig( + file=cfg, + profile_name=kwargs.get("profile") or root.get("profile"), + overrides=overrides, + ) + for warning in cfg.warnings: + diagnostic(f"warning: {warning}", quiet=bool(kwargs.get("quiet"))) + return resolved + + +def _save_payload(payload: Any, destination: str, raw_field: str | None) -> None: + """Persist a result to disk **atomically, before exit** (SPEC.md §5.6). + + One-shot results cannot be re-fetched, so a partial file from an interrupted + write would be indistinguishable from data loss. Write to a temp file in the + same directory, then rename. + """ + path = Path(destination).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + + if isinstance(payload, dict) and raw_field and raw_field in payload: + body = payload[raw_field] + else: + body = payload + + data = ( + body.encode() + if isinstance(body, str) + else body + if isinstance(body, bytes) + else json.dumps(body, indent=2, default=str).encode() + ) + + tmp = path.with_name(f".{path.name}.partial") + tmp.write_bytes(data) + tmp.replace(path) + + +def build_command(endpoint: Endpoint) -> click.Command: + """Generate the Click command for one endpoint.""" + options: list[click.Parameter] = [_click_option(p) for p in endpoint.params] + + if endpoint.poll: + options.append( + click.Option( + ["--wait/--no-wait"], + default=False, + help=( + "Poll until the operation reaches a terminal state, then " + "retrieve the result. Avoids scripting the poll loop." + ), + ) + ) + options.append( + click.Option( + ["--poll-interval"], type=click.FLOAT, default=3.0, show_default=True, + help="Seconds between status checks when --wait is used.", + ) + ) + options.append( + click.Option( + ["--wait-timeout"], type=click.FLOAT, default=300.0, show_default=True, + help="Give up waiting after this many seconds; exits 7 with the handle.", + ) + ) + + options.extend( + [ + click.Option( + ["--output", "-o", "output"], + type=click.Choice([f.value for f in OutputFormat]), + default=None, + help="Output format. Defaults to json when stdout is not a TTY.", + ), + click.Option(["--profile", "-p", "profile"], default=None, help="Config profile to use."), + click.Option(["--base-url"], default=None, help="Override the product base URL."), + click.Option(["--dry-run"], is_flag=True, default=False, + help="Print the resolved request as JSON and exit without sending."), + click.Option(["--quiet", "-q"], is_flag=True, default=False, help="Suppress stderr diagnostics."), + click.Option(["--verbose", "-v"], count=True, help="Increase stderr diagnostics."), + click.Option(["--max-retries"], type=click.INT, default=3, show_default=True, + help="Retries for 429/5xx responses."), + click.Option(["--no-retry"], is_flag=True, default=False, help="Disable retries entirely."), + click.Option(["--timeout"], type=click.FLOAT, default=60.0, show_default=True, + help="Per-request timeout in seconds."), + ] + ) + + def callback(**kwargs: Any) -> None: + ctx = click.get_current_context() + _run_endpoint(ctx, endpoint, kwargs) + + help_text = _command_help(endpoint) + return click.Command( + name=endpoint.name, + params=options, + callback=callback, + help=help_text, + short_help=endpoint.summary, + ) + + +def _command_help(endpoint: Endpoint) -> str: + """Full help body: summary, semantics, endpoint, constraints, examples.""" + lines = [endpoint.summary, ""] + if endpoint.description: + lines += [endpoint.description, ""] + lines.append(f"API: {endpoint.method} {endpoint.path}") + if endpoint.permission: + lines.append(f"Requires platform key permission: {endpoint.permission.value}") + if endpoint.poll and endpoint.poll.one_shot: + lines.append( + "ONE-SHOT: the result can be retrieved only once. Use --save to persist it; " + "a second attempt exits 9." + ) + for constraint in endpoint.constraints: + lines.append(f"Constraint: {constraint.describe()}") + if endpoint.examples: + lines += ["", "Examples:"] + [f" {ex}" for ex in endpoint.examples] + return "\n".join(lines) + + +def _run_endpoint(ctx: click.Context, endpoint: Endpoint, kwargs: dict[str, Any]) -> None: + """Execute one generated command end-to-end.""" + quiet = bool(kwargs.get("quiet")) + verbosity = int(kwargs.get("verbose") or 0) + fmt = OutputFormat(kwargs["output"]) if kwargs.get("output") else default_format() + + # Click always supplies `multiple` options as (), which would otherwise look + # like "explicitly set to empty" further down. + values = {k: v for k, v in kwargs.items() if not (isinstance(v, tuple) and not v)} + + try: + config = _resolve_config(ctx, values, endpoint) + plan = http.build_request(endpoint, config, values) + + if kwargs.get("dry_run"): + emit(plan.describe(), fmt if fmt is not OutputFormat.RAW else OutputFormat.JSON) + ctx.exit(int(ExitCode.SUCCESS)) + + diagnostic(f"{plan.method} {plan.url}", quiet=quiet, verbosity=verbosity, level=1) + + max_retries = 0 if kwargs.get("no_retry") else int(kwargs.get("max_retries") or 0) + response = http.execute( + plan, + endpoint=endpoint, + timeout=float(kwargs.get("timeout") or 60.0), + max_retries=max_retries, + ) + http.raise_for_status(response, endpoint) + payload = response.payload + + # A 2xx does not always mean success: some endpoints return 201 while + # leaving a linking field NULL, silently orphaning the record (BUG 2). + if endpoint.require_response_fields and isinstance(payload, dict): + missing = [ + f for f in endpoint.require_response_fields if payload.get(f) in (None, "") + ] + if missing: + raise CLIError( + f"Server accepted the request (HTTP {response.status}) but left " + f"{', '.join(missing)} empty; the record was not linked correctly.", + ExitCode.SERVER_ERROR, + endpoint=f"{endpoint.method} {endpoint.path}", + details=payload, + hint="This is a known server-side defect. Re-run, or report it upstream.", + ) + + if kwargs.get("wait") and endpoint.poll: + payload = wait_for_completion( + endpoint=endpoint, + initial=payload, + config=config, + values=values, + poll_interval=float(kwargs.get("poll_interval") or 3.0), + timeout=float(kwargs.get("wait_timeout") or 300.0), + max_retries=max_retries, + request_timeout=float(kwargs.get("timeout") or 60.0), + quiet=quiet, + verbosity=verbosity, + ) + + if destination := values.get("save"): + _save_payload(payload, str(destination), endpoint.raw_field) + diagnostic(f"saved to {destination}", quiet=quiet, verbosity=verbosity) + + emit(payload, fmt, columns=endpoint.table_columns, raw_field=endpoint.raw_field) + ctx.exit(int(ExitCode.SUCCESS)) + + except ConfigError as exc: + CLIError(str(exc), ExitCode.USAGE).emit() + ctx.exit(int(ExitCode.USAGE)) + except CLIError as exc: + secrets: list[str] = [] + # Redaction is best-effort here: if credentials cannot be resolved, that + # must not stop the underlying error from being reported. + with contextlib.suppress(Exception): + secrets = http.collect_secrets(_resolve_config(ctx, values, endpoint)) + exc.emit(secrets) + ctx.exit(int(exc.exit_code)) + + +def build_group_tree(endpoints: list[Endpoint]) -> dict[str, click.Group]: + """Assemble endpoints into nested Click groups by command path.""" + groups: dict[str, click.Group] = {} + + for endpoint in endpoints: + group_name = endpoint.group + if group_name not in groups: + groups[group_name] = click.Group( + name=group_name, help=_GROUP_HELP.get(group_name, f"{group_name} commands.") + ) + parent = groups[group_name] + + if endpoint.subgroup: + for part in endpoint.subgroup.split(): + existing = parent.commands.get(part) + if not isinstance(existing, click.Group): + existing = click.Group( + name=part, + help=_SUBGROUP_HELP.get(part, f"{part} commands."), + ) + parent.add_command(existing) + parent = existing + + parent.add_command(build_command(endpoint)) + + return groups + + +#: Top-level group help. One group per product built by Unstract. +_GROUP_HELP = { + "docstudio": ( + "Document Studio — document extraction platform. Covers the Platform " + "Management API, deployed API workflows, and Human Quality Review." + ), + "whisper": "LLMWhisperer — convert documents to LLM-ready text.", + "apihub": "API Hub — vertical extraction (tables, bank statements, doc splitting).", +} + +#: Help for the API groups nested under a product. +_SUBGROUP_HELP = { + "platform": "Platform Management API (v1) — manage Document Studio resources.", + "deployment": "Run deployed API workflows and check their status.", + "hitl": "Human Quality Review — retrieve approved results (Enterprise).", + "output": "Read extraction results from the Prompt Studio Output Manager.", + "tool": "Attach and configure the tool a workflow runs (deployment assembly).", + "endpoint": "Configure a workflow's source/destination endpoints (set to API to deploy).", + "registry": "Browse the tool registry to find a tool's function_name.", + # Resource subgroups under `docstudio platform`, so the discover groups + # overview reads as a map rather than " commands." placeholders. + "prompt-studio": "Build extraction projects: prompts, profiles, files, run extraction.", + "workflow": "Assemble and run workflows: tools, endpoints, executions, file history.", + "api-deployment": "Manage deployed API endpoints and their access keys.", + "pipeline": "Manage ETL/Task pipelines and their executions.", + "adapter": "Manage LLM, embedding, vector-DB and text-extraction adapters.", + "connector": "Manage source/destination connectors for workflows.", + "group": "Manage user groups and their members.", + "user": "List organization users.", + "execution": "Inspect workflow executions and their logs.", + "file-history": "Inspect and clear per-file processing history.", + "profile": "Manage a project's LLM profiles.", + "prompt": "Manage a project's prompts.", + "file": "Upload and fetch a project's documents.", + "key": "Manage API keys for a deployment or pipeline.", + "member": "Manage a group's members.", + "default-triad": "Get or set the org default adapter triad.", + "approved": "Retrieve approved Human Quality Review results.", + "doc-splitter": "Split documents into parts (API Hub).", + "webhook": "Manage LLMWhisperer delivery webhooks.", +} + + +__all__ = ["build_command", "build_group_tree", "GLOBAL_FLAGS"] diff --git a/src/unstract_cli/core/http.py b/src/unstract_cli/core/http.py new file mode 100644 index 0000000..bb0d38c --- /dev/null +++ b/src/unstract_cli/core/http.py @@ -0,0 +1,569 @@ +"""Request construction and execution (SPEC.md §4.4, §5.7). + +Owns auth injection, path substitution, retry/backoff, redaction and `--dry-run`. +Built once here so no individual command can forget any of it. +""" + +from __future__ import annotations + +import json +import random +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx + +from unstract_cli.config.loader import ResolvedConfig +from unstract_cli.core.errors import ( + CLIError, + ExitCode, + exit_code_for_status, + hint_for, + is_retryable, + redact_headers, + redact_value, +) +from unstract_cli.core.model import ( + ApiGroup, + BodyKind, + Endpoint, + Param, + ParamLocation, + ParamType, +) + +#: Headers Kong injects downstream from the `apikey` lookup (SPEC.md §4.4). +#: The CLI must never send these: they are gateway-supplied, and a client-set +#: value would either be overwritten or -- worse -- be trusted as tenancy claims. +GATEWAY_INJECTED_HEADERS = frozenset( + { + "x-subscription-id", + "x-subscription-name", + "x-user-id", + "x-product-id", + } +) + +USER_AGENT = "unstract-cli/0.1.0" + + +@dataclass +class RequestPlan: + """A fully resolved request, ready to send or to print under ``--dry-run``.""" + + method: str + url: str + headers: dict[str, str] = field(default_factory=dict) + params: dict[str, Any] = field(default_factory=dict) + json_body: Any = None + data: Any = None + files: list[tuple[str, tuple[str, bytes, str]]] = field(default_factory=list) + content: bytes | None = None + #: Secret literals to scrub from any output. + secrets: list[str] = field(default_factory=list) + + def describe(self) -> dict[str, Any]: + """Redacted, JSON-safe description for ``--dry-run`` (SPEC.md §5.7).""" + body: Any = None + if self.json_body is not None: + body = redact_value(self.json_body) + elif self.files: + body = { + "multipart": [ + {"field": name, "filename": meta[0], "bytes": len(meta[1])} + for name, meta in self.files + ] + } + elif self.content is not None: + body = {"binary_bytes": len(self.content)} + elif self.data: + body = redact_value(self.data) + return { + "method": self.method, + "url": self.url, + "headers": redact_headers(self.headers), + "query": redact_value(self.params), + "body": body, + } + + +def auth_headers(product: ApiGroup, config: ResolvedConfig) -> dict[str, str]: + """Build auth headers for one API group (SPEC.md §4.4). + + Three products, three schemes -- unifying them is the CLI's core promise. + Document Studio's three API groups share the Bearer scheme but each carries + its own key, so credentials are still resolved per group. + """ + match product: + case ApiGroup.LLMWHISPERER: + return {"unstract-key": config.require(product, "api_key")} + case ApiGroup.PLATFORM | ApiGroup.DEPLOYMENT | ApiGroup.HITL: + return {"Authorization": f"Bearer {config.require(product, 'api_key')}"} + case ApiGroup.APIHUB: + # `apikey` only. Kong resolves it to subscription/user identity. + headers = {"apikey": config.require(product, "api_key")} + if key := config.get(product, "llmwhisperer_key"): + headers["X-LLMWhisperer-API-Key"] = key + if key := config.get(product, "anthropic_key"): + headers["X-Anthropic-API-Key"] = key + return headers + raise CLIError(f"Unknown API group: {product}", ExitCode.GENERIC) # pragma: no cover + + +def collect_secrets(config: ResolvedConfig) -> list[str]: + """Every credential in play, so output can be scrubbed of all of them.""" + secrets: list[str] = [] + for product in ApiGroup: + for key in ("api_key", "llmwhisperer_key", "anthropic_key"): + try: + if (value := config.get(product, key)) and isinstance(value, str): + secrets.append(value) + except Exception: # pragma: no cover - resolution issues aren't fatal here + continue + return secrets + + +def build_url(endpoint: Endpoint, config: ResolvedConfig, values: dict[str, Any]) -> str: + """Resolve the base URL and substitute path parameters. + + ``Endpoint.path`` is used **verbatim** (P11): the upstream API is genuinely + inconsistent about trailing slashes and about `profilemanager` vs + `profile-manager`, and "tidying" a path yields a 404. + """ + base = config.get(endpoint.api, "base_url") + if not base: + raise CLIError( + f"No base URL configured for {endpoint.api.value}.", + ExitCode.USAGE, + hint=( + "Set --base-url or the corresponding environment variable. " + "API Hub has no default base URL (SPEC §11.1)." + ), + ) + + path = endpoint.path + for param in endpoint.path_params(): + value = values.get(param.py_name) + if value is None: + value = _config_default(param, config) + if value is None: + raise CLIError( + f"Missing required path parameter {param.cli_flag}.", + ExitCode.USAGE, + hint=f"Pass {param.cli_flag}, or configure it in your profile.", + ) + placeholder = "{" + param.name + "}" + path = path.replace(placeholder, str(param.to_wire(value))) + + return f"{str(base).rstrip('/')}/{path.lstrip('/')}" + + +def _config_default(param: Param, config: ResolvedConfig) -> Any: + """Resolve a parameter's profile default, trying each source in order. + + ``default_from`` may name several config paths; the first that resolves wins. + That lets one setting stand in for another where they are genuinely the same + value held in two blocks -- `deployment run`'s org_id falls back to the + platform block, which is the same organization and is usually already set + (GOTCHAS #7). An empty string counts as unset, since that is what a + half-filled `config init` stub leaves behind. + """ + for source in param.default_sources: + product, _, key = source.partition(".") + if (value := config.get(product, key)) not in (None, ""): + return value + return None + + +def _guess_content_type(path: Path) -> str: + import mimetypes + + return mimetypes.guess_type(path.name)[0] or "application/octet-stream" + + +def _parse_json_param(param: Param, value: object) -> object: + """Parse a ``ParamType.JSON`` value from a string into a real object (BUG 1). + + Click hands JSON params through as plain strings, so without this the body + field would hold a quoted string (``"data": "{...}"``) and the server would + reject it as ``Expected a dictionary of items but got type "str"``. Accepts + either inline JSON or an ``@path/to/file.json`` reference -- large payloads + such as an exported prompts file are painful to pass inline and hit shell + argument limits. A value that is already parsed (a dict/list, e.g. a test + passing a native object) is returned unchanged. + """ + if param.type is not ParamType.JSON or not isinstance(value, str): + return value + text = value + if text.startswith("@"): + ref = Path(text[1:]).expanduser() + try: + text = ref.read_text() + except OSError as exc: + raise CLIError( + f"{param.cli_flag} could not read {ref}: {exc}", ExitCode.USAGE + ) from exc + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise CLIError( + f"{param.cli_flag} expects valid JSON (or @file.json): {exc}", + ExitCode.USAGE, + ) from exc + + +def build_request( + endpoint: Endpoint, + config: ResolvedConfig, + values: dict[str, Any], + *, + extra_query: dict[str, Any] | None = None, +) -> RequestPlan: + """Turn resolved flag values into a concrete request. + + Constraints are checked first: a malformed invocation should cost exit code 2, + not a network round trip and a remote rejection. + """ + if violations := endpoint.validate(values): + raise CLIError( + "; ".join(violations), + ExitCode.USAGE, + endpoint=f"{endpoint.method} {endpoint.path}", + ) + + headers = {"User-Agent": USER_AGENT, **auth_headers(endpoint.api, config)} + params: dict[str, Any] = {} + body: dict[str, Any] = {} + form: dict[str, Any] = {} + files: list[tuple[str, tuple[str, bytes, str]]] = [] + content: bytes | None = None + + for param in endpoint.params: + if param.client_side: + continue + if param.location is ParamLocation.PATH: + # A PATH param normally travels only in the URL. Mirroring + # additionally copies it into the JSON body, defending against a + # server that links a record by a body field and orphans it when the + # field is absent (BUG 2), or that simply wants the same identifier + # twice under two names (`api_id` in the URL, `api` in the body -- + # GOTCHAS #6). `body_name` is the path name unless `mirror_as` renames it. + if param.mirrors and (raw := values.get(param.py_name)) is not None: + body[param.body_name] = param.to_wire(raw) + continue + + raw = values.get(param.py_name) + + if param.freeform_prefix and raw: + # P5: `--ext-param foo=bar` -> `ext_foo=bar`, so parameters newer than + # the CLI remain reachable without a release. + for item in raw if isinstance(raw, (list, tuple)) else [raw]: + key, sep, val = str(item).partition("=") + if not sep: + raise CLIError( + f"{param.cli_flag} expects KEY=VALUE, got {item!r}", + ExitCode.USAGE, + ) + params[f"{param.freeform_prefix}{key.strip()}"] = val.strip() + continue + + if raw is None: + raw = _config_default(param, config) + if raw is None: + raw = param.default + if raw is None or (isinstance(raw, (list, tuple)) and not raw): + if param.required: + raise CLIError( + f"Missing required parameter {param.cli_flag}.", ExitCode.USAGE + ) + continue + + value = _parse_json_param(param, param.to_wire(raw)) + + match param.location.value: + case "query": + params[param.name] = value + case "body": + body[param.name] = value + case "header": + headers[param.name] = str(value) + case "form": + if param.type.value == "file": + for item in value if isinstance(value, (list, tuple)) else [value]: + p = Path(str(item)) + if not p.exists(): + raise CLIError(f"File not found: {p}", ExitCode.USAGE) + files.append( + (param.name, (p.name, p.read_bytes(), _guess_content_type(p))) + ) + elif param.type is ParamType.JSON and isinstance(value, (dict, list)): + # A multipart form field carries a JSON object as a *string*; + # the server (a DRF form field) `json.loads` it. Left as a + # dict, httpx cannot form-encode it. Re-serialize here so BODY + # gets an object and FORM gets the string it expects -- the + # double-encode guard the JSON parse would otherwise trip. + form[param.name] = json.dumps(value) + else: + form[param.name] = value + + if extra_query: + params.update(extra_query) + + if endpoint.body is BodyKind.BINARY_FILE: + # LLMWhisperer takes the document as a raw octet-stream body. + if file_value := values.get("file"): + p = Path(str(file_value)) + if not p.exists(): + raise CLIError(f"File not found: {p}", ExitCode.USAGE) + content = p.read_bytes() + headers["Content-Type"] = "application/octet-stream" + elif url_value := values.get("url"): + # `url_in_post` sends the source URL as a plain-text body instead. + content = str(url_value).encode() + headers["Content-Type"] = "text/plain" + params["url_in_post"] = "true" + + # Defence in depth: even if a record mistakenly declared one, gateway-injected + # headers must never leave this process (SPEC.md §4.4). + headers = {k: v for k, v in headers.items() if k.lower() not in GATEWAY_INJECTED_HEADERS} + + return RequestPlan( + method=endpoint.method, + url=build_url(endpoint, config, values), + headers=headers, + params={k: _stringify(v) for k, v in params.items() if v is not None}, + json_body=body if body and endpoint.body is BodyKind.JSON else None, + data=form or None, + files=files, + content=content, + secrets=collect_secrets(config), + ) + + +def _stringify(value: Any) -> Any: + """Booleans must travel as `true`/`false`, not Python's `True`/`False`.""" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (list, tuple)): + return [_stringify(v) for v in value] + if isinstance(value, dict): + return json.dumps(value) + return value + + +@dataclass +class Response: + """A completed HTTP exchange.""" + + status: int + payload: Any + headers: dict[str, str] + raw: bytes + + @property + def is_json(self) -> bool: + return isinstance(self.payload, (dict, list)) + + +def _parse(response: httpx.Response) -> Any: + ctype = response.headers.get("content-type", "") + if "json" in ctype: + try: + return response.json() + except ValueError: + # Some endpoints emit several JSON objects concatenated (a streamed + # NDJSON-ish body), which a single parse rejects with "Extra data" and + # which breaks any `| json` consumer (CAPTURE2 DOC 6). Recover them into + # one array so the CLI still emits exactly one valid JSON document; fall + # back to raw text only if they are not clean concatenated JSON. + if (parts := _split_concatenated_json(response.text)) is not None: + return parts + return response.text + if ctype.startswith("text/") or not ctype: + return response.text + return response.content + + +def _split_concatenated_json(text: str) -> list[Any] | None: + """Parse a run of back-to-back JSON values into a list, or None if it isn't one. + + Returns None unless there are at least two values (a single value would have + parsed already), so a genuinely malformed body still falls through to raw text. + """ + decoder = json.JSONDecoder() + items: list[Any] = [] + idx, length = 0, len(text) + while idx < length: + while idx < length and text[idx].isspace(): + idx += 1 + if idx >= length: + break + try: + value, end = decoder.raw_decode(text, idx) + except ValueError: + return None + items.append(value) + idx = end + return items if len(items) > 1 else None + + +def execute( + plan: RequestPlan, + *, + endpoint: Endpoint | None = None, + timeout: float = 60.0, + max_retries: int = 3, + client: httpx.Client | None = None, + sleep=time.sleep, +) -> Response: + """Send a request, retrying only where retrying is safe (SPEC.md §5.7). + + Retries apply to 429 and 5xx. They never apply to 4xx: the server has already + rejected the request on its merits, and for one-shot reads a blind retry can + silently consume a result the first attempt already delivered. + """ + owns_client = client is None + # Note: following redirects means a wrong trailing slash would be silently + # "corrected" by the server rather than failing loudly, which softens the P11 + # literal-path guarantee. Redirects are kept because these APIs legitimately + # use them; the `no_trailing_slash` test is what actually protects paths. + client = client or httpx.Client(timeout=timeout, follow_redirects=True) + last_error: httpx.HTTPError | None = None + + try: + for attempt in range(max_retries + 1): + try: + response = client.request( + plan.method, + plan.url, + headers=plan.headers, + params=plan.params or None, + json=plan.json_body, + data=plan.data, + files=plan.files or None, + content=plan.content, + ) + except httpx.HTTPError as exc: + last_error = exc + if attempt >= max_retries: + break + sleep(_backoff(attempt)) + continue + + if is_retryable(response.status_code) and attempt < max_retries: + sleep(_retry_after(response) or _backoff(attempt)) + continue + + return Response( + status=response.status_code, + payload=_parse(response), + headers=dict(response.headers), + raw=response.content, + ) + finally: + if owns_client: + client.close() + + raise CLIError( + f"Request failed after {max_retries + 1} attempt(s): {last_error}", + ExitCode.SERVER_ERROR, + endpoint=f"{plan.method} {plan.url}", + retryable=True, + hint="Check network connectivity and the configured base URL.", + ) + + +def _backoff(attempt: int) -> float: + """Exponential backoff with jitter, so retries don't synchronise across agents.""" + return min(2.0**attempt, 30.0) * (0.5 + random.random() / 2) + + +def _retry_after(response: httpx.Response) -> float | None: + try: + return float(response.headers.get("retry-after", "")) + except (TypeError, ValueError): + return None + + +#: Body phrases meaning "this result was already delivered". LLMWhisperer signals +#: this with HTTP 200 and a message, not an error status, so a status-only check +#: would report success while the agent silently loses the data (SPEC.md §5.6). +_CONSUMED_PHRASES = ("already delivered", "already acknowledged", "already retrieved") + + +def _looks_consumed(payload: Any) -> bool: + if not isinstance(payload, dict): + return False + message = str(payload.get("message", "")).lower() + return any(phrase in message for phrase in _CONSUMED_PHRASES) + + +def raise_for_status(response: Response, endpoint: Endpoint | None = None) -> None: + """Convert an unsuccessful -- or deceptively successful -- response into an error.""" + # Checked before the status check: this arrives as a 200. + if _looks_consumed(response.payload): + raise CLIError( + _extract_message(response.payload) or "Result already retrieved.", + ExitCode.ALREADY_CONSUMED, + http_status=response.status, + details=response.payload if isinstance(response.payload, dict) else None, + endpoint=f"{endpoint.method} {endpoint.path}" if endpoint else None, + hint=hint_for(406), + retryable=False, + ) + + if response.status < 400: + return + + message = _extract_message(response.payload) or f"HTTP {response.status}" + code = exit_code_for_status(response.status) + + # The deployment API returns 406 for "result already acknowledged"; treat any + # already-delivered signal as the dedicated one-shot exit code (SPEC.md §5.6). + if response.status == 406 or "already" in message.lower() and "deliver" in message.lower(): + code = ExitCode.ALREADY_CONSUMED + + raise CLIError( + message, + code, + http_status=response.status, + details=response.payload if isinstance(response.payload, (dict, list)) else None, + endpoint=f"{endpoint.method} {endpoint.path}" if endpoint else None, + hint=hint_for(response.status, endpoint.path if endpoint else None, message), + retryable=is_retryable(response.status), + ) + + +def _extract_message(payload: Any) -> str | None: + """Pull a human message out of the several error shapes these APIs use.""" + if isinstance(payload, str): + return payload.strip() or None + if not isinstance(payload, dict): + return None + for key in ("message", "detail", "error"): + value = payload.get(key) + if isinstance(value, str) and value: + return value + errors = payload.get("errors") + if isinstance(errors, list) and errors: + parts = [ + e.get("detail") for e in errors if isinstance(e, dict) and e.get("detail") + ] + if parts: + return "; ".join(str(p) for p in parts) + return None + + +__all__ = [ + "GATEWAY_INJECTED_HEADERS", + "RequestPlan", + "Response", + "auth_headers", + "build_request", + "build_url", + "collect_secrets", + "execute", + "raise_for_status", +] diff --git a/src/unstract_cli/core/model.py b/src/unstract_cli/core/model.py new file mode 100644 index 0000000..1820381 --- /dev/null +++ b/src/unstract_cli/core/model.py @@ -0,0 +1,537 @@ +"""Declarative endpoint model — the single source of truth for the CLI. + +Every command, flag, help string, validation rule and `--discover` entry is +derived from the `Endpoint` records in `unstract_cli.endpoints`. Nothing about a +command is written twice, so help text cannot drift from behaviour, and the +bundled Claude Skill has exactly one place to edit (SPEC.md D1). + +The dataclasses here are frozen: records are a contract, not runtime state. + +Parameter patterns P1-P12 map onto these fields as: + +=== ========================== =================================================== +P Pattern Encoding +=== ========================== =================================================== +P1 Mutually exclusive ``Endpoint.constraints=[MutuallyExclusive(...)]`` +P2 At-least-one-of ``Endpoint.constraints=[AtLeastOneOf(...)]`` +P3 Enum -> path segment ``Param.choices={friendly: wire}`` (a mapping) +P4 Repeatable ``Param.multiple=True`` +P5 Freeform key=value ``Param.freeform_prefix="ext_"`` +P6 Path param, profile default ``Param.location=PATH`` + ``Param.default_from=...`` +P7 Location variants ``Param.location`` + ``Endpoint.body`` +P8 PATCH = PUT minus required ``Endpoint.derive_patch_from=`` +P9 Conditional applicability ``Param.applies_when="mode=low_cost"`` (help only) +P10 Int vs UUID identifiers ``Param.type`` (``ParamType.INT`` / ``UUID``) +P11 Trailing-slash sensitivity ``Endpoint.path`` is literal; never normalised +P12 Replace-vs-append ``Param.replace_semantics=True`` (+ helper flags) +=== ========================== =================================================== +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field, replace +from enum import Enum + +# --------------------------------------------------------------------------- # +# Enumerations +# --------------------------------------------------------------------------- # + + +class Product(str, Enum): + """One of the three products built by Unstract. + + Unstract is the company, and the name of this CLI. It builds exactly three + products: **Document Studio**, **LLMWhisperer** and **API Hub**. Document + Studio's API groups use `platform`/`deployment` paths on the wire and + `UNSTRACT_*` environment variables. + """ + + DOCUMENT_STUDIO = "docstudio" + LLMWHISPERER = "llmwhisperer" + APIHUB = "apihub" + + +#: Document Studio exposes three distinct API groups, each with its own base +#: path and credentials, so they stay separate for auth and config purposes even +#: though they belong to one product (SPEC.md §4.4). +class ApiGroup(str, Enum): + """An API surface within a product. Determines base URL and credentials.""" + + #: Document Studio -- Platform Management API v1. + PLATFORM = "platform" + #: Document Studio -- deployed API workflow execution. + DEPLOYMENT = "deployment" + #: Document Studio -- Human Quality Review (Enterprise). + HITL = "hitl" + #: LLMWhisperer -- text extraction. + LLMWHISPERER = "llmwhisperer" + #: API Hub -- vertical extraction. + APIHUB = "apihub" + + +#: Which product each API group belongs to. +GROUP_PRODUCT: dict[ApiGroup, Product] = { + ApiGroup.PLATFORM: Product.DOCUMENT_STUDIO, + ApiGroup.DEPLOYMENT: Product.DOCUMENT_STUDIO, + ApiGroup.HITL: Product.DOCUMENT_STUDIO, + ApiGroup.LLMWHISPERER: Product.LLMWHISPERER, + ApiGroup.APIHUB: Product.APIHUB, +} + +#: Human-readable product names, for help text and `--discover`. +PRODUCT_LABELS: dict[Product, str] = { + Product.DOCUMENT_STUDIO: "Document Studio", + Product.LLMWHISPERER: "LLMWhisperer", + Product.APIHUB: "API Hub", +} + + +class ParamLocation(str, Enum): + """Where a parameter travels in the HTTP request (P7).""" + + QUERY = "query" + BODY = "body" + PATH = "path" + HEADER = "header" + FORM = "form" + + +class BodyKind(str, Enum): + """How the request body is encoded (P7).""" + + NONE = "none" + JSON = "json" + MULTIPART = "multipart" + BINARY_FILE = "binary_file" + TEXT = "text" + + +class ParamType(str, Enum): + """Logical parameter type. Distinguishes INT from UUID identifiers (P10).""" + + STR = "str" + INT = "int" + FLOAT = "float" + BOOL = "bool" + UUID = "uuid" + JSON = "json" + FILE = "file" + DATE = "date" + + +class Permission(str, Enum): + """Platform API key permission level required (SPEC.md §4.4).""" + + READ = "read" + READ_WRITE = "read_write" + FULL_ACCESS = "full_access" + + +# --------------------------------------------------------------------------- # +# Constraints (P1, P2) +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class Constraint: + """Base class for pre-flight validation rules. + + Constraints are checked before any network call, so a malformed invocation + costs an exit code 2 rather than a wasted round trip and a remote 400. + """ + + params: tuple[str, ...] + + def check(self, supplied: Mapping[str, object]) -> str | None: + """Return an error message if violated, else ``None``.""" + raise NotImplementedError + + def describe(self) -> str: + """Human-readable rule, rendered into ``--help`` and ``--discover``.""" + raise NotImplementedError + + +@dataclass(frozen=True) +class MutuallyExclusive(Constraint): + """Exactly one of ``params`` must be supplied (P1). + + Example: ``whisper extract`` takes ``--file`` or ``--url``, never both. + """ + + required: bool = True + + def check(self, supplied: Mapping[str, object]) -> str | None: + present = [p for p in self.params if supplied.get(p) not in (None, (), [])] + flags = ", ".join(f"--{p.replace('_', '-')}" for p in self.params) + if len(present) > 1: + given = ", ".join(f"--{p.replace('_', '-')}" for p in present) + return f"{given} are mutually exclusive; supply exactly one of: {flags}" + if self.required and not present: + return f"one of {flags} is required" + return None + + def describe(self) -> str: + flags = " | ".join(f"--{p.replace('_', '-')}" for p in self.params) + return f"exactly one of: {flags}" if self.required else f"at most one of: {flags}" + + +@dataclass(frozen=True) +class RequiredUnless(Constraint): + """``params`` are required *unless* another flag holds a sentinel value. + + Encodes a rule the plain required/optional split cannot: a field that is + mandatory in general but genuinely unused in one configuration. The live case + is ``profile create --chunk-size 0``, which means "no RAG" -- the vector store + and embedding model are then never consulted, so demanding them makes the + caller invent a value for something that will not be read (GOTCHAS #3). + + Marking such a field ``required=False`` alone would lose the check in the + common case; this keeps it, conditioned on the flag that actually decides. + """ + + #: The flag whose value relaxes the requirement, e.g. ``"chunk_size"``. + unless: str = "" + #: Values of :attr:`unless` that switch the requirement off. + unless_values: tuple[object, ...] = () + + def _relaxed(self, supplied: Mapping[str, object]) -> bool: + value = supplied.get(self.unless) + # Compare as strings so 0 and "0" behave identically: Click hands the + # value through typed, while a test or a config default may not. + return any(str(value) == str(v) for v in self.unless_values) + + def check(self, supplied: Mapping[str, object]) -> str | None: + if self._relaxed(supplied): + return None + missing = [p for p in self.params if supplied.get(p) in (None, (), [])] + if not missing: + return None + flags = ", ".join(f"--{p.replace('_', '-')}" for p in missing) + relaxers = " or ".join(f"--{self.unless.replace('_', '-')} {v}" for v in self.unless_values) + return f"{flags} is required unless {relaxers} is set" + + def describe(self) -> str: + flags = ", ".join(f"--{p.replace('_', '-')}" for p in self.params) + relaxers = " or ".join(f"--{self.unless.replace('_', '-')} {v}" for v in self.unless_values) + return f"{flags} required unless {relaxers}" + + +@dataclass(frozen=True) +class AtLeastOneOf(Constraint): + """At least one of ``params`` must be supplied (P2). + + Example: ``file-history clear`` refuses to run without a filter, which would + otherwise delete every record. + """ + + def check(self, supplied: Mapping[str, object]) -> str | None: + if any(supplied.get(p) not in (None, (), []) for p in self.params): + return None + flags = ", ".join(f"--{p.replace('_', '-')}" for p in self.params) + return f"at least one of {flags} is required" + + def describe(self) -> str: + flags = " | ".join(f"--{p.replace('_', '-')}" for p in self.params) + return f"at least one of: {flags}" + + +# --------------------------------------------------------------------------- # +# Param +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class Param: + """One CLI flag, and how it reaches the wire. + + ``name`` is the API's spelling (used on the wire verbatim, typos included -- + e.g. ``page_seperator``); the CLI flag is the kebab-cased form. + """ + + name: str + type: ParamType = ParamType.STR + location: ParamLocation = ParamLocation.QUERY + required: bool = False + default: object | None = None + help: str = "" + + #: P3 - friendly value -> wire value. A plain sequence means identity mapping. + choices: Mapping[str, str] | Sequence[str] | None = None + #: P4 - repeatable flag, collected into a list. + multiple: bool = False + #: P6 - dotted path into resolved config, e.g. ``"platform.org_id"``. A + #: whitespace-separated list is tried in order, first resolved value winning. + #: `deployment run` uses this to fall back to the platform block's org_id: the + #: deployment block is a separate, initially-empty config section, and an + #: org_id already set for the platform API is the same organization (GOTCHAS #7). + default_from: str | None = None + + @property + def default_sources(self) -> tuple[str, ...]: + """The config paths tried, in order, for this parameter's default.""" + return tuple(self.default_from.split()) if self.default_from else () + #: P5 - collect arbitrary ``--flag KEY=VALUE`` pairs under this prefix. + freeform_prefix: str | None = None + #: P9 - documented applicability. Rendered in help; never enforced locally, + #: because the server owns the rule and enforcing it here would guess wrong. + applies_when: str | None = None + #: P12 - this field replaces rather than appends server-side. + replace_semantics: bool = False + #: Override the derived CLI flag name (rare; e.g. to avoid a collision). + flag: str | None = None + #: Exclude from the request payload (client-side only, e.g. ``--save``). + client_side: bool = False + #: Copy this PATH param into the JSON body as well. A defence against a server + #: that reads an identifier only from the body and orphans the record when it + #: is absent (BUG 2: `prompt create` persists ``tool_id: null`). The URL still + #: carries the value; this just also sends it in the body under :attr:`name`. + mirror_to_body: bool = False + #: Body field name for the mirrored value, when the body spells the identifier + #: differently from the path. `api-deployment key create` is the live case: the + #: URL takes ``api_id`` while the body wants that same value as ``api``, so both + #: had to be passed by hand (GOTCHAS #6). Implies :attr:`mirror_to_body`. + mirror_as: str | None = None + + @property + def mirrors(self) -> bool: + """Whether this PATH param is also copied into the JSON body.""" + return self.mirror_to_body or self.mirror_as is not None + + @property + def body_name(self) -> str: + """The name this parameter takes in the body when mirrored.""" + return self.mirror_as or self.name + + @property + def cli_flag(self) -> str: + """The long-form CLI flag, e.g. ``--word-confidence-threshold``.""" + return self.flag or f"--{self.name.replace('_', '-')}" + + @property + def py_name(self) -> str: + """The Python identifier used for this parameter. + + Derived from :attr:`flag` when one is set, so a renamed flag does not + collide with a global option of the same API name. `deployment run` is + the live case: its API parameter is `timeout`, but the CLI exposes it as + ``--execution-timeout`` to leave ``--timeout`` meaning the HTTP timeout. + Without this, the global flag would silently overwrite the API value. + """ + if self.flag: + return self.flag.lstrip("-").replace("-", "_") + return self.name.replace("-", "_") + + @property + def wire_name(self) -> str: + """The parameter name as the API expects it, typos preserved.""" + return self.name + + def choice_map(self) -> dict[str, str] | None: + """Normalise :attr:`choices` to a ``{friendly: wire}`` mapping (P3).""" + if self.choices is None: + return None + if isinstance(self.choices, Mapping): + return dict(self.choices) + return {c: c for c in self.choices} + + def to_wire(self, value: object) -> object: + """Translate a user-supplied value to its wire representation (P3).""" + mapping = self.choice_map() + if mapping and isinstance(value, str): + return mapping.get(value, value) + return value + + +# --------------------------------------------------------------------------- # +# Polling (SPEC.md §3.1) +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class PollSpec: + """Describes how ``--wait`` drives an async execute -> poll -> retrieve flow. + + Terminal states are matched against a field in the *response body*, never the + HTTP status code: the Unstract deployment API currently returns HTTP 422 for + the in-progress states ``PENDING``/``EXECUTING`` (SPEC.md §6.2), a documented + server defect. Branching on the body keeps behaviour identical before and + after that defect is fixed. + """ + + status_endpoint: str + #: Field name(s) holding the terminal state in the *status endpoint's* body. + #: A tuple is tried in order, because the run POST and the status GET can spell + #: the same state differently: the deployment run response nests + #: ``execution_status`` under ``message``, while the status GET returns a + #: top-level ``status`` (and its ``message`` is the *result*, not a nested + #: object). The poll reads the status endpoint, so ``status`` must win there -- + #: a mismatch means the terminal state goes unrecognised, the one-shot result + #: is consumed on that read, and the next poll returns HTTP 406 (CAPTURE2 BUG 2). + status_field: str | tuple[str, ...] = "status" + terminal_success: tuple[str, ...] = () + terminal_failure: tuple[str, ...] = () + handle_field: str = "" + handle_param: str = "" + retrieve_endpoint: str | None = None + #: Values to forward from the *original* request into the retrieve call. Each + #: entry is either a py_name carried as-is, or a ``(source, dest)`` pair that + #: renames it -- the retrieve endpoint often spells the same identifier + #: differently (fetch-response's ``id``/``document_id`` are the Output + #: Manager's ``prompt_id``/``document_manager``). Without the rename the + #: retrieve would return every row for the tool, not the one prompt+document + #: the caller ran. The retrieve is otherwise keyed by the poll handle, but some + #: result stores are keyed by an original-request identifier instead + #: (prompt-studio reads its Output Manager by ``tool_id``, not by ``task_id``). + retrieve_carry: tuple[str | tuple[str, str], ...] = () + #: Suppress passing the poll handle into the retrieve call. Set when the + #: retrieve endpoint is keyed only by :attr:`retrieve_carry` values and would + #: reject an unexpected handle parameter. + retrieve_omits_handle: bool = False + #: Constant param values injected into the retrieve call, keyed by py_name. + #: Used where the retrieve endpoint needs a fixed flag the original request did + #: not carry (single-pass results are read with ``is_single_pass_extract=true``). + retrieve_extra: tuple[tuple[str, object], ...] = () + #: Results can be read exactly once; a second read loses data (SPEC.md §5.6). + one_shot: bool = False + + +# --------------------------------------------------------------------------- # +# Endpoint +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class Endpoint: + """One CLI command, and the API call behind it.""" + + name: str + group: str + method: str + path: str + #: The API surface this endpoint belongs to, which fixes its base URL and + #: credentials. The owning product is derived from it, never stored twice. + api: ApiGroup + summary: str + params: tuple[Param, ...] = () + body: BodyKind = BodyKind.NONE + #: Sub-group for three-level commands, e.g. ``platform prompt-studio file upload``. + subgroup: str | None = None + constraints: tuple[Constraint, ...] = () + poll: PollSpec | None = None + #: Documentation file this record was authored from; the Skill's diff anchor. + doc_source: str = "" + permission: Permission | None = None + #: Longer help text appended below the summary. + description: str = "" + #: Worked example(s) rendered into ``--help`` (SPEC.md §5.3). + examples: tuple[str, ...] = () + #: A deliberate divergence from the docs; the Skill must not silently revert + #: it (SPEC.md §8.5). Example: `/whisper-detail` is singular despite the docs + #: index saying otherwise. + doc_conflict: str | None = None + #: P11 - some paths legitimately lack a trailing slash. Recorded so a test can + #: assert intent rather than treating every missing slash as a typo. + no_trailing_slash: bool = False + #: Optional column hints for ``--output table``. + table_columns: tuple[str, ...] = () + #: Response key holding the payload for ``--output raw``. + raw_field: str | None = None + #: Response fields that must be non-null on success, else the call is treated + #: as a failure despite a 2xx status. Guards silent-orphan defects where the + #: server returns 201 but leaves a linking field NULL (BUG 2: `prompt create`). + require_response_fields: tuple[str, ...] = () + + @property + def product(self) -> Product: + """The product this endpoint belongs to (derived, never stored).""" + return GROUP_PRODUCT[self.api] + + @property + def product_label(self) -> str: + """Display name, e.g. ``"Document Studio"``.""" + return PRODUCT_LABELS[self.product] + + @property + def command_path(self) -> tuple[str, ...]: + """Full command path, e.g. ``("platform", "prompt-studio", "file", "upload")``.""" + parts = [self.group] + if self.subgroup: + parts.extend(self.subgroup.split()) + parts.append(self.name) + return tuple(parts) + + @property + def dotted_name(self) -> str: + """Stable identifier, e.g. ``whisper.usage``.""" + return ".".join(self.command_path) + + def param(self, name: str) -> Param | None: + """Look up a parameter by API name.""" + return next((p for p in self.params if p.name == name), None) + + def path_params(self) -> tuple[Param, ...]: + return tuple(p for p in self.params if p.location is ParamLocation.PATH) + + def validate(self, supplied: Mapping[str, object]) -> list[str]: + """Run every constraint, returning all violations (P1, P2).""" + return [m for c in self.constraints if (m := c.check(supplied)) is not None] + + +def derive_patch( + source: Endpoint, + *, + name: str = "patch", + summary: str | None = None, + keep_required: Sequence[str] = (), +) -> Endpoint: + """Derive a PATCH endpoint from its PUT counterpart (P8). + + PATCH accepts the same fields as PUT but makes them all optional, except for + identifiers (path params, plus anything named in ``keep_required``). Deriving + rather than copying means a parameter added to the PUT record cannot be + forgotten on the PATCH one -- duplication is precisely how definitions drift. + """ + kept = set(keep_required) + params = tuple( + p + if (p.location is ParamLocation.PATH or p.name in kept) + else replace(p, required=False) + for p in source.params + ) + return replace( + source, + name=name, + method="PATCH", + params=params, + summary=summary or f"Partially update. {source.summary}", + constraints=(), + ) + + +def with_params(source: Endpoint, *extra: Param) -> Endpoint: + """Return a copy of ``source`` with additional parameters appended. + + Used where a derived PATCH accepts a field its PUT counterpart does not -- + for instance `pipeline patch --active`, which has no PUT equivalent. + """ + return replace(source, params=(*source.params, *extra)) + + +__all__ = [ + "AtLeastOneOf", + "BodyKind", + "Constraint", + "Endpoint", + "MutuallyExclusive", + "Param", + "ParamLocation", + "ParamType", + "Permission", + "PollSpec", + "Product", + "RequiredUnless", + "derive_patch", + "field", + "with_params", +] diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index 96d911e..4e6a377 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -1,20 +1,12 @@ -"""Output rendering, and choosing which rendering to use. - -The contract a caller depends on: - -* ``-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. -* 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``. +"""Output rendering (SPEC.md §5.1). + +The contract an agent depends on: + +* ``--output json`` is the default whenever stdout is **not** a TTY, so piping or + capturing the CLI yields machine-readable output with no extra flags. +* **stdout carries the payload and nothing else.** Banners, warnings, progress + and diagnostics all go to stderr, so `unstract ... | jq` always parses. +* ``raw`` emits the payload alone (extracted text, file bytes) for piping. """ from __future__ import annotations @@ -24,105 +16,39 @@ import shutil import sys import textwrap -from collections.abc import Mapping -from enum import StrEnum -from fnmatch import fnmatch -from typing import Any, TypedDict - -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 +from enum import Enum +from typing import Any, TextIO -#: Environment markers 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") +import yaml -class OutputFormat(StrEnum): +class OutputFormat(str, Enum): JSON = "json" + YAML = "yaml" TABLE = "table" RAW = "raw" -class AgentMode(StrEnum): - AUTO = "auto" - YES = "yes" - NO = "no" - - -#: Values that mean "set, but not on": treating them as present would read a -#: marker set to `0` as the opposite of what it says. -_DISABLED_VALUES = {"", "0", "false", "no", "off"} - +def default_format(stream: TextIO | None = None) -> OutputFormat: + """JSON unless a human is watching (SPEC.md §5.1). -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( - fnmatch(name, pattern) and names[name].strip().lower() not in _DISABLED_VALUES - 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. + ``UNSTRACT_OUTPUT`` overrides. TTY detection means an agent gets JSON without + passing a flag, while a human at a terminal gets a table. """ - if explicit: + if env := os.environ.get("UNSTRACT_OUTPUT"): try: - return OutputFormat(explicit) + return OutputFormat(env.lower()) 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)): + pass # An invalid value must not break the command; fall through. + target = stream or sys.stdout + try: + return OutputFormat.TABLE if target.isatty() else OutputFormat.JSON + except (AttributeError, ValueError): # pragma: no cover - detached stream 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, -) -> Envelope: - """Build the stdout envelope. ``ok`` is derived, never passed in.""" - return { - "ok": error is None, - "data": data, - "error": error, - "meta": {**(meta or {}), "contract_version": CONTRACT_VERSION}, - } def _flatten(value: Any) -> str: - """Render a cell. Nested structures become compact JSON, not Python reprs.""" + """Render a cell. Nested structures become compact JSON rather than Python reprs.""" if value is None: return "" if isinstance(value, bool): @@ -137,12 +63,17 @@ def _rows_and_columns( ) -> 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 + The generic rule: + + * list of objects -> columns from the union of keys, in first-seen order + * single object -> two-column key/value listing + * anything else -> a single ``value`` column + + ``columns`` overrides column selection for responses 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: @@ -170,27 +101,26 @@ def _rows_and_columns( def _terminal_width(default: int = 100) -> int: + """Usable width for table output.""" try: return max(shutil.get_terminal_size((default, 24)).columns, 40) - except (OSError, ValueError): # pragma: no cover - detached terminal + except Exception: # 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: """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. + Deliberately plain text rather than Rich box-drawing: tables are the + human-facing format, but they still 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. + not a lossy summary, and silently dropping the tail of a value is the kind of + thing you only notice after acting on it. Wrapped continuation lines are + indented under their column so the table still reads as a grid. """ headers, rows = _rows_and_columns(data, columns) if not headers: @@ -203,25 +133,15 @@ def render_table( 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) - ) + natural[i] = max(natural[i], max((len(p) for p in cell.split("\n")), default=0)) - # Only the widest columns shrink, and only as far as the terminal - # requires, so a narrow column is never squeezed for a wide neighbour. + # 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) - if sum(widths) > budget: - # A common ceiling gives the same result as shaving the widest column - # 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] + 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.""" @@ -247,185 +167,63 @@ def fmt(cells: list[str]) -> list[str]: return "\n".join(out) -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"] - - -#: Fields a client spells as `""` while the job is still running. Only for -#: these does empty mean "not yet"; anywhere else an empty string is the answer. -EMPTY_MEANS_PENDING = frozenset({"extraction_result"}) - - -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 - 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 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): - return payload - for name in fields: - for source in (payload, env.get("meta") or {}): - if not isinstance(source, dict): - continue - value = source.get(name) - if value is None or (value == "" and name in EMPTY_MEANS_PENDING): - continue - return value - 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: Envelope, - fmt: OutputFormat = OutputFormat.JSON, + data: Any, + fmt: OutputFormat, *, columns: tuple[str, ...] = (), - raw_fields: 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 = _payload(env) - if fmt is OutputFormat.TABLE: - return render_table(payload, columns) - - payload = raw_value(env, raw_fields) - 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 _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()])) + """Render a payload in the requested format.""" + match fmt: + case OutputFormat.JSON: + return json.dumps(data, indent=2, default=str) + case OutputFormat.YAML: + return yaml.safe_dump(data, sort_keys=False, default_flow_style=False).rstrip() + case OutputFormat.TABLE: + return render_table(data, columns) + case OutputFormat.RAW: + if isinstance(data, dict) and raw_field and raw_field in data: + value = data[raw_field] + elif isinstance(data, (str, bytes)): + value = data + else: + value = data + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + if isinstance(value, str): + return value + return json.dumps(value, indent=2, default=str) + return json.dumps(data, indent=2, default=str) # pragma: no cover def emit( - env: Envelope, - fmt: OutputFormat = OutputFormat.JSON, - *, - columns: tuple[str, ...] = (), - raw_fields: tuple[str, ...] = (), - secrets: list[str] | None = None, -) -> None: - """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. - """ - env = scrub_structure(env, _to_hide(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: - """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 := _to_hide(secrets): - text = scrub(text, to_hide) - print(text) - - -def emit_result( data: Any, - fmt: OutputFormat = OutputFormat.JSON, + fmt: OutputFormat, *, - meta: dict[str, Any] | None = None, columns: tuple[str, ...] = (), - raw_fields: tuple[str, ...] = (), - secrets: list[str] | None = None, + raw_field: str | None = None, ) -> None: - """Write a successful result.""" - emit( - envelope(data=data, meta=meta), - fmt, - columns=columns, - raw_fields=raw_fields, - 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. + """Write a payload to stdout -- and nothing else to stdout.""" + print(render(data, fmt, columns=columns, raw_field=raw_field)) - 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 to_hide := _to_hide(secrets): - summary = scrub(summary, to_hide) - 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. +def diagnostic(message: str, *, quiet: bool = False, verbosity: int = 0, level: int = 0) -> None: + """Write a human-facing note to **stderr**, keeping stdout pure. ``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) __all__ = [ - "AGENT_ENV", - "CONTRACT_VERSION", - "AgentMode", - "Envelope", "OutputFormat", - "agent_detected", + "default_format", "diagnostic", "emit", - "emit_error", - "emit_result", - "emit_text", - "envelope", - "raw_value", "render", "render_table", - "resolve_format", ] diff --git a/src/unstract_cli/core/overlay.py b/src/unstract_cli/core/overlay.py deleted file mode 100644 index 3e4b7b5..0000000 --- a/src/unstract_cli/core/overlay.py +++ /dev/null @@ -1,50 +0,0 @@ -"""What the specs cannot say about a flag. - -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 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 - -from unstract_cli.core.errors import warn - -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, {}) - out = {} - for name, entry in entries.items(): - if isinstance(entry, dict): - out[name] = entry - else: - warn( - f"warning: ignoring {OVERLAY_FILE} entry [{product}.{operation_id}." - f"{name}]: expected a table, found {type(entry).__name__}." - ) - 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 deleted file mode 100644 index 670f960..0000000 --- a/src/unstract_cli/core/params.py +++ /dev/null @@ -1,523 +0,0 @@ -"""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 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 - -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.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. -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 - choices: tuple[str, ...] = () - - @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, - choices=tuple(schema.get("enum") or ()), - ) - - -def _hidden(node: dict[str, Any]) -> bool: - return bool(node.get("deprecated") or node.get("x-internal")) - - -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. So are deprecated ones: a - superseded spelling the client still accepts would otherwise become a second - flag for the same value. And so are internal ones (`x-internal`): the server - accepts them and the clients keep them, but they are not offered. - """ - 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" and not _hidden(p) - ] - - 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(): - if _hidden(prop): - continue - params.append( - _from_schema( - name, - prop, - prop.get("description", ""), - required=name in mandatory, - ) - ) - - return params - - -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`` - rather than sending it. Flags are intersected with this to keep the CLI's - surface equal to what actually works. - """ - return { - name: p - for name, p in inspect.signature(method).parameters.items() - if name not in ("self", "cls") - } - - -#: Python annotation -> OpenAPI type. A spec describes the wire, which can -#: differ from what the client method takes. -_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. - - 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 := _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 it. - if signature.default is not inspect.Parameter.empty and not _is_unset( - signature.default - ): - # An `Unset` default sends nothing, so the spec's default applies. - 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*(.*)$") - -#: Sentences a description restates from elsewhere. 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*\.'), -) - - -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 - 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() - # Defaults and allowed values are rendered from the signature and the - # spec; the docstring's own copy would disagree as soon as either moves. - 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]: - 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. - """ - # Some specs state a default in prose too, which would leave the flag - # carrying two statements of one default. - 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: - 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, {}) - # Falls back to the spec's own enum: a hand-written list is needed only to - # narrow one on purpose, and 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") - - # A required option is left without a default: an explicit one 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`: an on-only flag cannot turn off a - # default-true parameter, and `None` keeps "not passed" apart from - # "passed false". - decls = [f"{param.flag}/--no-{param.name.replace('_', '-')}"] - if short: - decls.insert(0, short) - 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, as a value rather than as a sentence in - # the help. Click's own default stays unset so that nothing is resent. - option.server_default = param.default - return 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) - - -@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, - *, - 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. - """ - 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 - 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 - 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) - return out - - -def spec_options( - product: str, - operation_id: str, - *, - client_method: Callable[..., Any] | None = None, - exclude: tuple[str, ...] = (), -) -> 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(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 - - -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 - too -- Click spells "not passed" that way for ``multiple=True`` -- but ``0``, - ``False`` and ``""`` are values the caller chose and must survive. - """ - return { - name: list(value) if isinstance(value, tuple) else value - for name, value in values.items() - if value is not None and value != () - } - - -__all__ = [ - "Diverged", - "SPEC_FILES", - "Param", - "check_overlay", - "click_option", - "client_params", - "derive_params", - "docstring_params", - "find_operation", - "load_spec", - "operation_params", - "requested", - "spec_options", -] diff --git a/src/unstract_cli/core/platform.py b/src/unstract_cli/core/platform.py deleted file mode 100644 index c5e7ae1..0000000 --- a/src/unstract_cli/core/platform.py +++ /dev/null @@ -1,102 +0,0 @@ -"""The client that talks to the platform API. - -Both platform-key operations -- `whoami` and the deployment listing -- are -published in the OpenAPI spec and generated into `unstract-client`, so this -module only resolves configuration and builds `PlatformKeyClient`. - -The platform API is served by the same deployment as the API deployments it -manages, so the client reads docstudio's `base_url` and the `platform_key` -beside it: one host, two keys. -""" - -from __future__ import annotations - -from typing import Any - -from unstract.api_deployments.client import PlatformClientError, PlatformKeyClient - -from unstract_cli.config import DOCSTUDIO, ResolvedConfig -from unstract_cli.core.errors import CLIError, ExitCode - - -def platform_client( - config: ResolvedConfig, - org_id: str | None = None, - *, - timeout: float | None = None, -) -> PlatformKeyClient: - """Build a Platform API client from the resolved configuration. - - ``org_id`` is accepted and ignored: the generated operations take it per - call, not per client. It stays in the signature to document which calls - need one -- `whoami` runs before an organisation is known, the listing does - not -- so a caller passes it where it matters. - """ - if timeout is not None and timeout <= 0: - # Rejected at send time by a bare ValueError that renders no envelope, - # so it is refused here, where it is still a usage error about a flag. - raise CLIError( - f"--transport-timeout must be greater than 0, not {timeout:g}.", - ExitCode.USAGE, - hint="Omit the flag to leave the connection unbounded.", - ) - try: - return PlatformKeyClient( - base_url=config.require(DOCSTUDIO, "base_url"), - api_key=config.require(DOCSTUDIO, "platform_key"), - transport_timeout=timeout, - logging_level="ERROR", - ) - except PlatformClientError as exc: - # The client validates the host and the key before sending, and that - # failure renders no envelope where a script is parsing one. It is what - # the caller configured, so it is reported as a usage error. - raise CLIError( - str(exc), - ExitCode.USAGE, - hint="Check `base_url` and the platform key: the host needs a " - "scheme, e.g. https://host, and the key cannot be blank.", - ) from exc - - -def deployment_rows(page: Any, endpoint: str = "api/deployment/") -> list[Any]: - """The rows of a deployment listing, or a protocol error naming the host. - - A listing whose `results` is not a list is not an empty account: it is a - proxy, a login page or a web app answering in the API's place. Read as rows - it would either crash on the first field or report deployments as gone. - """ - rows = page.get("results") if isinstance(page, dict) else None - if rows is None: - rows = [] - if not isinstance(rows, list): - raise CLIError( - "The deployment listing did not come back as a list of deployments.", - ExitCode.SERVER_ERROR, - details=page, - endpoint=endpoint, - hint="Check that `base_url` names the API rather than a proxy or " - "web app; `details` carries what was received.", - ) - return rows - - -def organisation(config: ResolvedConfig) -> str: - """The organisation to act inside, or a usage error naming how to get one. - - It lives on the docstudio block: a platform key resolves it, and everything - that consumes it -- deployment URLs above all -- reads it from there. - """ - if org_id := str(config.get(DOCSTUDIO, "org_id") or "").strip(): - return org_id - raise CLIError( - "No organisation is configured.", - ExitCode.USAGE, - hint=( - "Run `unstract auth whoami` to resolve it from your platform key, " - "or set $UNSTRACT_ORG_ID." - ), - ) - - -__all__ = ["deployment_rows", "organisation", "platform_client"] diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index e621347..c047050 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -1,89 +1,34 @@ -"""`--wait` state machine and one-shot result persistence. +"""`--wait` state machines (SPEC.md §3.1, §6.2). -Both products follow execute -> poll -> retrieve, and a caller should not have to -script that loop. +Both LLMWhisperer and Unstract follow execute -> poll -> retrieve. Agents should +not have to script that loop, so `--wait` drives it to a terminal state. **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. +the *response body*, never by the HTTP status code. The Unstract deployment API +currently returns HTTP 422 for the in-progress states ``PENDING`` and +``EXECUTING`` -- a documented server-side defect that is scheduled to be fixed. +Reading the body means the CLI behaves identically before and after that fix; +reading the status code would break in one direction or the other. """ from __future__ import annotations -import json -import os -import sys -import tempfile import time -from collections.abc import Callable -from contextlib import suppress -from dataclasses import dataclass, field -from enum import StrEnum -from pathlib import Path from typing import Any +from unstract_cli.config.loader import ResolvedConfig +from unstract_cli.core import http 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. -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.""" - - SUCCESS = "success" - FAILURE = "failure" - PENDING = "pending" - UNKNOWN = "unknown" - - -@dataclass(frozen=True) -class PollSpec: - """How to read progress out of one operation's responses.""" - - #: 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, ...] - #: One name, or candidates tried in order, since one operation's responses - #: can spell the state differently. - status_field: str | tuple[str, ...] = "status" - - #: The terminal states case-folded: every comparison against them is - #: case-insensitive. Derived in `__post_init__`, not declared. - 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. - 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.", - ) +from unstract_cli.core.model import Endpoint +from unstract_cli.core.output import diagnostic def _dig(payload: Any, field: str) -> Any: - """Find a field, looking one level into the common envelopes.""" + """Find a field, looking one level into common envelopes. + + Responses variously arrive bare, under ``message``, or under ``data``, and an + agent shouldn't care which. + """ if not isinstance(payload, dict): return None if field in payload: @@ -96,7 +41,13 @@ def _dig(payload: Any, field: str) -> Any: def extract_status(payload: Any, field: str | tuple[str, ...] = "status") -> str | None: - """Read the status from a response body; first candidate that resolves wins.""" + """Read the status from a response body. + + ``field`` may be a single name or a tuple of candidate names tried in order, + because the run POST and the status GET spell the state differently (the run + nests ``execution_status`` under ``message``; the status GET returns a + top-level ``status``). The first candidate that resolves wins. + """ fields = (field,) if isinstance(field, str) else field for candidate in fields: value = _dig(payload, candidate) @@ -106,327 +57,136 @@ def extract_status(payload: Any, field: str | tuple[str, ...] = "status") -> str def extract_handle(payload: Any, field: str) -> str | None: - """Read the job handle out of a response body.""" + """Read the job handle (whisper_hash / execution_id / file_hash).""" value = _dig(payload, field) 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() - # Saving here would replace the link itself, so 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) - # The same write `persist` does: a temporary sibling moved over the - # target, so it is the directory that has to be writable. Opening the - # target instead passes in a read-only directory. - 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}.", - 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() - 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, - verbatim_details=True, - hint=( - "`details` carries the result. Pass the path of the real file and " - "save it from there." - ), - ) - text = ( - payload - if isinstance(payload, str) - else json.dumps(payload, indent=2, default=str) - ) - tmp: Path | None = None - try: - target.parent.mkdir(parents=True, exist_ok=True) - # A predictable sibling in a writable directory is a symlink waiting to - # be planted. `mkstemp` names it unpredictably and creates it 0600, - # which is the mode `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: - 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, - 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." - ), - ) from exc - # Syncing the file does not cover the directory entry the rename created, - # and a result that can be read once has no second copy to recover from. - # Windows cannot open a directory to sync it. - if sys.platform != "win32": - try: - dir_fd = os.open(target.parent, os.O_RDONLY) - try: - os.fsync(dir_fd) - finally: - os.close(dir_fd) - except OSError as exc: - raise CLIError( - f"The result reached {path!r} but the write could not be " - f"confirmed: {exc}.", - ExitCode.SAVE_FAILED, - details=payload, - verbatim_details=True, - hint=( - "`details` carries the result. The file may not survive a " - "crash, and the service will not serve it again -- keep a " - "copy from here." - ), - ) from exc - return target - - -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 spec.failed: - return PollState.FAILURE - if status in spec.succeeded: - return PollState.SUCCESS - if not status or _dig(payload, "error"): - # Not progress: polling on would report a server fault as "still - # running" until the deadline. - return PollState.UNKNOWN - return PollState.PENDING +def _carry_path_values(values: dict[str, Any], target: Endpoint) -> dict[str, Any]: + """Forward path parameters from the original call into a follow-up call.""" + return { + param.py_name: values[param.py_name] + for param in target.path_params() + if values.get(param.py_name) is not None + } def wait_for_completion( *, + endpoint: Endpoint, initial: Any, - spec: PollSpec, - poll: Callable[[str], Any], - retrieve: Callable[[str], Any] | None = None, - save: str | Path | None = None, - interval: float = 3.0, + config: ResolvedConfig, + values: dict[str, Any] | None = None, + poll_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. - on_retry: Callable[[CLIError], None] | None = None, - #: Called once a result is on disk, before the caller sees anything: that - #: ordering is the whole point of --save. - on_saved: Callable[[Path], None] | None = None, - #: 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, + max_retries: int = 3, + request_timeout: float = 60.0, + quiet: bool = False, + verbosity: int = 0, + sleep=time.sleep, + now=time.monotonic, ) -> Any: - """Poll until terminal, then retrieve if the operation has a retrieve step. + """Poll until terminal, then retrieve if the endpoint defines 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 - 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. + On timeout, exits 7 *with the job handle*, so an agent can resume with a + plain `status`/`retrieve` call rather than reprocessing the document. """ - sleep = sleep or time.sleep - now = now or time.monotonic + spec = endpoint.poll + if spec is None: # pragma: no cover - guarded by the caller + return initial - 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 + from unstract_cli.endpoints import get_endpoint handle = extract_handle(initial, spec.handle_field) if not handle: - # Nothing to poll, 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." - ), + diagnostic( + f"--wait: no {spec.handle_field} in response; returning immediately.", + quiet=quiet, + verbosity=verbosity, ) + return initial + status_endpoint = get_endpoint(spec.status_endpoint) deadline = now() + timeout last_status: str | None = None - payload: Any = initial - - 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 - document a second time, so it is attached here rather than left to - whatever the caller wrapped the loop in. - """ - try: - 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 is un-marked, or the - # envelope invites the retry. A refusal is the exception: the - # request was never served, so the read is still there. - exc.retryable = False - raise - except Exception as exc: - # Deliberate service failures are already CLIErrors, so this is a - # fault on this side, and retrying it only spends the budget. - raise CLIError( - str(exc) or type(exc).__name__, - ExitCode.GENERIC, - extra={spec.handle_field: handle}, - ) from exc - transient = 0 + # Path parameters from the original invocation must survive into the status + # and retrieve calls: `deployment status` needs the same --api-name and + # --org-id, and the handle alone cannot supply them. + carried = _carry_path_values(values or {}, status_endpoint) + while True: - 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 rather than answer a rate limit at the rate that earned - # it, but never past the deadline. 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) + poll_values = {**carried, spec.handle_param: handle} + plan = http.build_request(status_endpoint, config, poll_values) + response = http.execute( + plan, + endpoint=status_endpoint, + timeout=request_timeout, + max_retries=max_retries, + ) + + status = extract_status(response.payload, spec.status_field) + + # Only now consider the HTTP status: if the body carried no recognisable + # state, a 4xx/5xx is a real failure rather than the 422 quirk. + if status is None: + http.raise_for_status(response, status_endpoint) + status = extract_status(response.payload, spec.status_field) if status != last_status: - if on_status is not None: - on_status(status) + diagnostic(f"--wait: status={status}", quiet=quiet, verbosity=verbosity, level=1) last_status = status - state = classify(payload, spec) - if state is PollState.FAILURE: + normalised = (status or "").lower() + if normalised in {s.lower() for s in spec.terminal_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.", + endpoint=f"{endpoint.method} {endpoint.path}", + details=response.payload, + hint="Inspect `details` for the per-file error, or check execution logs.", extra={spec.handle_field: handle}, ) - if state is PollState.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 is PollState.SUCCESS: + + if normalised in {s.lower() for s in spec.terminal_success}: break - remaining = deadline - now() - if remaining <= 0: + if now() >= deadline: raise CLIError( f"Timed out after {timeout:g}s waiting for completion " f"(last status: {status!r}).", ExitCode.TIMEOUT, - retryable=True, + endpoint=f"{endpoint.method} {endpoint.path}", hint=( - f"Resume with the {spec.handle_field} below rather than " - "resubmitting the document." + 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: the last poll should land on it, not - # after it. - sleep(min(interval, remaining)) - - if retrieve is not None: - payload = naming_the_job(retrieve, retryable=False) - return deliver(payload) - - -__all__ = [ - "MAX_TRANSIENT_POLLS", - "MIN_BACKOFF", - "PollSpec", - "PollState", - "classify", - "extract_handle", - "extract_status", - "persist", - "preflight", - "wait_for_completion", -] + sleep(poll_interval) + + if not spec.retrieve_endpoint: + return response.payload + + retrieve_endpoint = get_endpoint(spec.retrieve_endpoint) + retrieve_values: dict[str, Any] = _carry_path_values(values or {}, retrieve_endpoint) + # Some result stores are keyed by an identifier from the *original* request, + # not by the poll handle (prompt-studio's Output Manager is read by tool_id). + for entry in spec.retrieve_carry: + source, dest = entry if isinstance(entry, tuple) else (entry, entry) + if (carried_value := (values or {}).get(source)) is not None: + retrieve_values[dest] = carried_value + for name, constant in spec.retrieve_extra: + retrieve_values[name] = constant + if not spec.retrieve_omits_handle: + retrieve_values[spec.handle_param] = handle + plan = http.build_request(retrieve_endpoint, config, retrieve_values) + result = http.execute( + plan, endpoint=retrieve_endpoint, timeout=request_timeout, max_retries=max_retries + ) + http.raise_for_status(result, retrieve_endpoint) + return result.payload + + +__all__ = ["extract_handle", "extract_status", "wait_for_completion"] diff --git a/src/unstract_cli/endpoints/__init__.py b/src/unstract_cli/endpoints/__init__.py new file mode 100644 index 0000000..abb4694 --- /dev/null +++ b/src/unstract_cli/endpoints/__init__.py @@ -0,0 +1,50 @@ +"""Endpoint registry — the single source of truth for the CLI surface. + +Every command in the tree originates here. The bundled Claude Skill edits these +modules and nothing else: because commands, help text and `--discover` are +all generated from these records, a change here propagates everywhere at once. +""" + +from __future__ import annotations + +from unstract_cli.core.model import Endpoint + +from . import apihub, deployment, hitl, platform, whisper + +#: Ordered so `--help` lists groups in a sensible progression: extraction first, +#: then execution, then management. +ALL_ENDPOINTS: tuple[Endpoint, ...] = ( + *whisper.ENDPOINTS, + *deployment.ENDPOINTS, + *platform.ENDPOINTS, + *hitl.ENDPOINTS, + *apihub.ENDPOINTS, +) + +_BY_NAME: dict[str, Endpoint] = {e.dotted_name: e for e in ALL_ENDPOINTS} + + +def get_endpoint(dotted_name: str) -> Endpoint: + """Look up an endpoint by dotted name, e.g. ``whisper.status``. + + Used by the poller to find the status/retrieve endpoints named in a + `PollSpec`, which keeps polling declarative rather than hard-coded. + """ + try: + return _BY_NAME[dotted_name] + except KeyError: + raise KeyError( + f"No endpoint named {dotted_name!r}. Known: {', '.join(sorted(_BY_NAME))}" + ) from None + + +def endpoints_for(api: str) -> tuple[Endpoint, ...]: + """All endpoints on one API surface, e.g. ``"platform"`` or ``"llmwhisperer"``. + + Filters by API group rather than command path, because that is the unit the + documentation is organised around -- one docs source per API. + """ + return tuple(e for e in ALL_ENDPOINTS if e.api.value == api) + + +__all__ = ["ALL_ENDPOINTS", "endpoints_for", "get_endpoint"] diff --git a/src/unstract_cli/endpoints/apihub.py b/src/unstract_cli/endpoints/apihub.py new file mode 100644 index 0000000..ea3a269 --- /dev/null +++ b/src/unstract_cli/endpoints/apihub.py @@ -0,0 +1,214 @@ +"""API Hub (Verticals) endpoints (SPEC.md §6.5). + +**Source of truth is code, not docs.** API Hub has no public documentation site, +so these records were authored from `unstract-verticals/src/api_v1/api.py` and the +Postman collections in `verticals-portal/portal/postman-collection/`. The Skill +must flag changes here for human review rather than applying them silently +(SPEC.md §8.4). + +Auth is the `apikey` header at the Kong gateway. Kong resolves that key in Redis +and injects `X-Subscription-Id`, `X-Subscription-Name`, `X-User-Id` and +`X-Product-Id` downstream -- the CLI must never send those itself. + +Extraction parameters (`ext_*`) are forwarded verbatim to the vertical worker, so +`--ext-param KEY=VALUE` exists as an escape hatch for parameters newer than this +CLI. That matters more here than elsewhere precisely because there are no docs to +track. +""" + +from __future__ import annotations + +from unstract_cli.core.model import ( + ApiGroup, + BodyKind, + Endpoint, + MutuallyExclusive, + Param, + ParamLocation, + ParamType, + PollSpec, +) + +_SRC = "unstract-verticals/src/api_v1/api.py" +_POSTMAN = "verticals-portal/portal/postman-collection" + +#: LLMWhisperer conversion passthrough. These mirror the `whisper extract` flags +#: but reach LLMWhisperer via the vertical worker, hence the `conv_` prefix. +_CONV_PARAMS: tuple[Param, ...] = ( + Param("conv_mode", default="high_quality", + choices=["native_text", "low_cost", "high_quality", "form", "table"], + help="LLMWhisperer processing mode"), + Param("conv_output_mode", default="layout_preserving", + choices=["layout_preserving", "text"], help="LLMWhisperer output mode"), + Param("conv_lang", default="eng", help="Language hint for OCR"), + Param("conv_tag", default="default", help="Audit tag for usage reporting"), + Param("conv_filename", help="Audit file name for usage reporting"), + Param("conv_page_seperator", default="<<<", flag="--conv-page-separator", + help="Page separator string"), + Param("conv_pages_to_extract", help="Pages to extract, e.g. '1-5,7,21-'"), + Param("conv_median_filter_size", type=ParamType.INT, + applies_when="conv_mode=low_cost", help="Median filter size for denoising"), + Param("conv_gaussian_blur_radius", type=ParamType.FLOAT, + applies_when="conv_mode=low_cost", help="Gaussian blur radius for denoising"), + Param("conv_mark_vertical_lines", type=ParamType.BOOL, default=False, + help="Reproduce vertical lines"), + Param("conv_mark_horizontal_lines", type=ParamType.BOOL, default=False, + help="Reproduce horizontal lines"), + Param("conv_line_splitter_strategy", default="left-priority", + help="Line splitter strategy"), + Param("conv_line_splitter_tolerance", type=ParamType.FLOAT, default=0.4, + help="Line splitter tolerance"), + Param("conv_horizontal_stretch_factor", type=ParamType.FLOAT, default=1.0, + help="Horizontal stretch factor"), +) + +#: Vertical-worker extraction parameters. +_EXT_PARAMS: tuple[Param, ...] = ( + Param("ext_section_name", help="Section of the document containing the target table"), + Param("ext_compress_double_space", type=ParamType.BOOL, + help="Collapse double-spaced lines, common in bank/credit-card statements"), + Param("ext_headers", help="Comma-separated headers to force the extraction to use"), + Param("ext_start_page", type=ParamType.INT, help="Start searching from this page"), + Param("ext_end_page", type=ParamType.INT, help="Stop searching at this page"), + Param("ext_page_filter_strategy", help="Page filtering strategy"), + Param("ext_use_bank_schema", type=ParamType.BOOL, + applies_when="sub_vertical=bank_statement", + help="Apply the bank statement schema"), + Param("ext_pattern", choices=["generic_table", "indent_as_groups"], + applies_when="sub_vertical=extract_table", help="Table extraction pattern"), + Param("ext_table_no", type=ParamType.INT, applies_when="sub_vertical=extract_table", + help="Which discovered table to extract, 1-based"), + Param("ext_cache_result", type=ParamType.BOOL, + help="Cache the result for 24 hours, for reuse via --use-cached-file-hash"), + Param("ext_cache_text", type=ParamType.BOOL, + help="Cache extracted text for 24 hours"), + Param("ext_param", multiple=True, freeform_prefix="ext_", + help=( + "Escape hatch for extraction parameters newer than this CLI, as " + "KEY=VALUE (sent as ext_KEY=VALUE); repeatable" + )), +) + + +ENDPOINTS: tuple[Endpoint, ...] = ( + Endpoint( + name="extract", + group="apihub", + method="POST", + path="/api/v1/extract", + api=ApiGroup.APIHUB, + summary="Submit a document for vertical extraction.", + description=( + "Processing runs in stages: QUEUED_FOR_WHISPER -> " + "QUEUED_FOR_EXTRACTION -> COMPLETED. Use --wait to follow it through.\n\n" + "Pass --use-cached-file-hash instead of --file to re-run extraction " + "over a document already processed by table discovery, which skips " + "re-conversion." + ), + params=( + Param("vertical", required=True, default="table", + help="Vertical, e.g. 'table'"), + Param("sub_vertical", required=True, + choices=["bank_statement", "discover_tables", "extract_table"], + help="Sub-vertical determining which worker handles the document"), + Param("file", type=ParamType.FILE, location=ParamLocation.BODY, + client_side=True, help="Path to the document to process"), + Param("use_cached_file_hash", + help="Reuse a previously processed document by its file hash"), + *_CONV_PARAMS, + *_EXT_PARAMS, + ), + body=BodyKind.BINARY_FILE, + constraints=(MutuallyExclusive(("file", "use_cached_file_hash")),), + poll=PollSpec( + status_endpoint="apihub.status", + status_field="status", + terminal_success=("COMPLETED",), + terminal_failure=("ERROR", "FAILED"), + handle_field="file_hash", + handle_param="file_hash", + retrieve_endpoint="apihub.retrieve", + ), + doc_source=f"{_SRC} + {_POSTMAN}", + examples=( + "unstract apihub extract --vertical table --sub-vertical bank_statement " + "--file statement.pdf --wait", + "unstract apihub extract --vertical table --sub-vertical extract_table " + "--use-cached-file-hash --ext-table-no 1", + ), + ), + Endpoint( + name="status", + group="apihub", + method="GET", + path="/api/v1/status", + api=ApiGroup.APIHUB, + summary="Check the processing status of a submitted document.", + description="Statuses: QUEUED_FOR_WHISPER, QUEUED_FOR_EXTRACTION, COMPLETED.", + params=( + Param("file_hash", required=True, help="Hash returned by `apihub extract`"), + ), + doc_source=f"{_SRC} + {_POSTMAN}", + examples=("unstract apihub status --file-hash ",), + ), + Endpoint( + name="retrieve", + group="apihub", + method="GET", + path="/api/v1/retrieve", + api=ApiGroup.APIHUB, + summary="Retrieve extraction results for a processed document.", + params=( + Param("file_hash", required=True, help="Hash returned by `apihub extract`"), + Param("output_mode", default="full", choices=["raw", "full"], + help="raw returns the extraction payload alone"), + Param("sub_vertical", help="Sub-vertical used for the extraction"), + Param("save", client_side=True, help="Write the result to this path"), + ), + doc_source=f"{_SRC} + {_POSTMAN}", + examples=("unstract apihub retrieve --file-hash --save tables.json",), + ), + Endpoint( + name="upload", + group="apihub", + subgroup="doc-splitter", + method="POST", + path="/doc-splitter/documents/upload", + api=ApiGroup.APIHUB, + summary="Upload a document for splitting.", + params=( + Param("file", type=ParamType.FILE, location=ParamLocation.FORM, + required=True, help="Path to the document to split"), + ), + body=BodyKind.MULTIPART, + doc_source=f"{_POSTMAN}/Verticals-DocSplitter.postman_collection.json", + examples=("unstract apihub doc-splitter upload --file bundle.pdf",), + ), + Endpoint( + name="status", + group="apihub", + subgroup="doc-splitter", + method="GET", + path="/doc-splitter/jobs/status", + api=ApiGroup.APIHUB, + summary="Check the status of a document splitting job.", + params=(Param("job_id", required=True, help="Job identifier from `upload`"),), + doc_source=f"{_POSTMAN}/Verticals-DocSplitter.postman_collection.json", + examples=("unstract apihub doc-splitter status --job-id ",), + ), + Endpoint( + name="download", + group="apihub", + subgroup="doc-splitter", + method="GET", + path="/doc-splitter/jobs/download", + api=ApiGroup.APIHUB, + summary="Download the output of a completed splitting job.", + params=( + Param("job_id", required=True, help="Job identifier from `upload`"), + Param("save", client_side=True, help="Write the downloaded output to this path"), + ), + doc_source=f"{_POSTMAN}/Verticals-DocSplitter.postman_collection.json", + examples=("unstract apihub doc-splitter download --job-id --save split.zip",), + ), +) diff --git a/src/unstract_cli/endpoints/deployment.py b/src/unstract_cli/endpoints/deployment.py new file mode 100644 index 0000000..efec84e --- /dev/null +++ b/src/unstract_cli/endpoints/deployment.py @@ -0,0 +1,186 @@ +"""Unstract API Deployment runtime endpoints (SPEC.md §6.2). + +Authored from `unstract-docs/docs/unstract_platform/api_deployment/`. + +Two behaviours worth knowing before reading the records: + +* ``org_id`` and ``api_name`` are URL *path segments*. ``--org-id`` falls back to + the active profile; ``--api-name`` cannot, because one profile serves many + deployments. +* The in-progress states ``PENDING``/``EXECUTING`` are currently returned with + HTTP 422 rather than 200 -- a documented server-side defect. The poller reads + the body's ``status`` field, so behaviour is unchanged when that is fixed. +""" + +from __future__ import annotations + +from unstract_cli.core.model import ( + ApiGroup, + BodyKind, + Endpoint, + Param, + ParamLocation, + ParamType, + PollSpec, +) + +_DOCS = "unstract-docs/docs/unstract_platform/api_deployment" + +#: Path parameters shared by every deployment command. +_PATH_PARAMS: tuple[Param, ...] = ( + Param( + "org_id", + location=ParamLocation.PATH, + # Falls back to the platform block: `docstudio.deployment` is a separate, + # initially-empty config section, so a user who has configured the + # Platform API still hit "missing org_id" here even though it is the same + # organization. The deployment block still wins when set (GOTCHAS #7). + default_from="deployment.org_id platform.org_id", + help="Organization identifier. Falls back to the platform block's org_id", + ), + Param( + "api_name", + location=ParamLocation.PATH, + required=True, + help="Deployed API name, as shown in the API Deployments page", + ), +) + +_EXECUTION_STATUSES = ("PENDING", "EXECUTING", "COMPLETED", "STOPPED", "ERROR") + + +ENDPOINTS: tuple[Endpoint, ...] = ( + Endpoint( + name="run", + group="docstudio", + subgroup="deployment", + method="POST", + path="/deployment/api/{org_id}/{api_name}/", + api=ApiGroup.DEPLOYMENT, + summary="Execute a deployed API workflow on one or more files.", + description=( + "Accepts up to 32 files per call, counting --file and --presigned-url " + "together. Results are keyed by file name, so names must be unique " + "within a call. Use --wait to poll to completion.\n\n" + "ONE-SHOT with --wait: the result store is read exactly once. --wait " + "returns the result from the poll that first observes COMPLETED and " + "does not re-read it. Pass --save to persist it on that single read; " + "without --save the result is only printed and cannot be re-fetched.\n\n" + "Synchronous mode (--timeout > 0) is deprecated upstream; --wait uses " + "asynchronous execution plus polling, which is the supported path.\n\n" + "Auth comes from the `docstudio.deployment` config block, which is " + "separate from the platform block and starts empty. After creating a " + "deployment and a key, wire the key in with:\n" + " unstract config set docstudio.deployment api_key env:UNSTRACT_DEPLOYMENT_KEY\n" + "--org-id now falls back to the platform block's org_id, so that one " + "usually needs no second entry (GOTCHAS #7)." + ), + params=( + *_PATH_PARAMS, + Param("files", type=ParamType.FILE, location=ParamLocation.FORM, + multiple=True, flag="--file", help="File to process; repeatable"), + Param("presigned_urls", location=ParamLocation.FORM, multiple=True, + flag="--presigned-url", + help="HTTPS AWS S3 presigned URL to fetch a file from; repeatable"), + Param("timeout", type=ParamType.INT, location=ParamLocation.FORM, default=0, + flag="--execution-timeout", + help="Seconds to wait server-side (0-300). 0 runs asynchronously"), + Param("include_metadata", type=ParamType.BOOL, location=ParamLocation.FORM, + default=False, + help="Include LLM/embedding usage and cost metadata in the result"), + Param("tags", location=ParamLocation.FORM, + help="Tag for this execution; must start with a letter (limit 1)"), + Param("llm_profile_id", type=ParamType.UUID, location=ParamLocation.FORM, + help="Override the tool's default LLM profile"), + Param("custom_data", type=ParamType.JSON, location=ParamLocation.FORM, + help="JSON object addressable in prompts as {{custom_data.key}}"), + Param("hitl_queue_name", location=ParamLocation.FORM, + help="Route results to this Human Quality Review queue instead of returning them"), + Param("save", client_side=True, + help="With --wait, write the one-shot result to this path before " + "exiting (strongly recommended: the result can be read only once)"), + ), + body=BodyKind.MULTIPART, + # ONE-SHOT poll: the status endpoint *is* the result store. The poll that + # first observes COMPLETED consumes the result, so that terminal poll's + # body is returned as the result and no second read is issued (a second + # read would 406). `status_field` lists `status` first because the status + # GET returns a top-level `status` (its `message` holds the result); the + # nested `execution_status` is the run POST's shape. Reading the wrong one + # made the terminal state go unrecognised (CAPTURE2 BUG 2). + poll=PollSpec( + status_endpoint="docstudio.deployment.status", + status_field=("status", "execution_status"), + terminal_success=("COMPLETED",), + terminal_failure=("ERROR", "STOPPED"), + handle_field="execution_id", + handle_param="execution_id", + one_shot=True, + ), + doc_source=f"{_DOCS}/api_execution.md", + examples=( + "unstract deployment run --api-name invoice-api --file invoice.pdf --wait --save out.json", + "unstract deployment run --api-name invoice-api --file a.pdf --file b.pdf", + "unstract deployment run --api-name invoice-api --file x.pdf --hitl-queue-name review", + ), + ), + Endpoint( + name="status", + group="docstudio", + subgroup="deployment", + method="GET", + path="/deployment/api/{org_id}/{api_name}/", + api=ApiGroup.DEPLOYMENT, + summary="Check the status of an execution and retrieve its result.", + description=( + f"Execution statuses: {', '.join(_EXECUTION_STATUSES)}.\n\n" + "ONE-SHOT: results are removed from the server once retrieved. A " + "second call returns HTTP 406 and exits 9 -- pass --save to persist " + "them on the first read.\n\n" + "Note: PENDING and EXECUTING currently return HTTP 422 rather than " + "200. This CLI reads the status from the response body, so that " + "server-side defect does not affect it." + ), + params=( + *_PATH_PARAMS, + Param("execution_id", type=ParamType.UUID, required=True, + help="Execution identifier returned by `deployment run`"), + Param("include_metadata", type=ParamType.BOOL, default=False, + help="Include LLM/embedding usage and cost metadata"), + Param("save", client_side=True, + help="Write the result to this path before exiting (recommended)"), + ), + doc_source=f"{_DOCS}/api_execution_status.md", + examples=( + "unstract deployment status --api-name invoice-api --execution-id --save out.json", + ), + ), + Endpoint( + name="highlight", + group="docstudio", + subgroup="deployment", + method="GET", + path="/deployment/api/{org_id}/{api_name}/highlight/", + api=ApiGroup.DEPLOYMENT, + summary="Fetch line coordinates for highlighting extracted values.", + description=( + "Requires 'Enable Highlight' on the exported tool. The whisper_hash " + "and line_numbers come from the execution result, at " + "result.metadata.whisper_hash and result.metadata.line_numbers." + ), + params=( + *_PATH_PARAMS, + Param("whisper_hash", required=True, + help="From result.metadata.whisper_hash in the execution result"), + Param("line_numbers", required=True, + help="Comma-separated line numbers, e.g. '2,3,6'"), + Param("text_extractor_name", required=True, + help="Text extractor adapter name, e.g. llm-whisperer-v2"), + ), + doc_source=f"{_DOCS}/api_get_highlight_data.md", + examples=( + "unstract deployment highlight --api-name inv --whisper-hash h " + "--line-numbers 2,3,6 --text-extractor-name llm-whisperer-v2", + ), + ), +) diff --git a/src/unstract_cli/endpoints/hitl.py b/src/unstract_cli/endpoints/hitl.py new file mode 100644 index 0000000..22967fa --- /dev/null +++ b/src/unstract_cli/endpoints/hitl.py @@ -0,0 +1,105 @@ +"""Human Quality Review (HITL) endpoints (SPEC.md §6.4). Enterprise feature. + +Authored from `unstract-docs/docs/unstract_platform/human_quality_review/`. + +Pushing *into* HITL is not a separate command: it is +`unstract deployment run --hitl-queue-name `. +""" + +from __future__ import annotations + +from unstract_cli.core.model import ApiGroup, Endpoint, Param, ParamLocation, ParamType + +_DOCS = "unstract-docs/docs/unstract_platform/human_quality_review" + +_ORG = Param( + "org_id", + location=ParamLocation.PATH, + # Same fallback as `deployment run`: the hitl block is its own, initially + # empty config section, but the organization is the same one (GOTCHAS #7). + default_from="hitl.org_id platform.org_id", + help="Organization identifier. Falls back to the platform block's org_id", +) + +_CLASS_ID = Param( + "class_id", + location=ParamLocation.PATH, + required=True, + help="Class (workflow) identifier, from the Download and Sync Manager", +) + + +ENDPOINTS: tuple[Endpoint, ...] = ( + Endpoint( + name="get", + group="docstudio", + subgroup="hitl approved", + method="GET", + path="/mr/api/{org_id}/approved/result/{class_id}/", + api=ApiGroup.HITL, + summary="Dequeue one approved result from the review queue.", + description=( + "DEQUEUE, not a read: each call removes one item from the approved " + "queue and returns it. The item is gone from the server afterwards, so " + "pass --save to persist it. Use `hitl bulk-download` to page through " + "results without consuming them." + ), + params=( + _ORG, + _CLASS_ID, + Param("hitl_queue_name", help="Queue name suffix used at deployment run time"), + Param("save", client_side=True, + help="Write the dequeued item to this path before exiting (recommended)"), + ), + doc_source=f"{_DOCS}/retrieve_approved_results.md", + examples=( + "unstract hitl approved get --class-id --save approved.json", + ), + ), + Endpoint( + name="bulk-download", + group="docstudio", + subgroup="hitl", + method="GET", + path="/mr/api/{org_id}/approved/result/{class_id}/", + api=ApiGroup.HITL, + summary="Page through approved results, optionally including file content.", + description=( + "Unlike `hitl approved get`, this is a paginated read. With " + "--download-files and a large page, the server may switch to an " + "asynchronous job and return a job id -- poll it with " + "`hitl download-status`." + ), + params=( + _ORG, + _CLASS_ID, + Param("page", type=ParamType.INT, default=1, help="Page number"), + Param("page_size", type=ParamType.INT, default=50, + help="Records per page (1-500)"), + Param("download_files", type=ParamType.BOOL, default=False, + help="Include file content in the response"), + Param("email", help="Email address to notify when an async download completes"), + Param("save", client_side=True, help="Write the response to this path"), + ), + doc_source=f"{_DOCS}/bulk_download.md", + examples=( + "unstract hitl bulk-download --class-id --page 1 --page-size 50", + ), + ), + Endpoint( + name="download-status", + group="docstudio", + subgroup="hitl", + method="GET", + path="/mr/api/{org_id}/approved/download-status/{job_id}/", + api=ApiGroup.HITL, + summary="Check the status of an asynchronous bulk download job.", + params=( + _ORG, + Param("job_id", location=ParamLocation.PATH, required=True, + help="Job identifier returned by `hitl bulk-download`"), + ), + doc_source=f"{_DOCS}/bulk_download.md", + examples=("unstract hitl download-status --job-id job-789",), + ), +) diff --git a/src/unstract_cli/endpoints/platform.py b/src/unstract_cli/endpoints/platform.py new file mode 100644 index 0000000..459b93a --- /dev/null +++ b/src/unstract_cli/endpoints/platform.py @@ -0,0 +1,1340 @@ +"""Unstract Platform Management API v1 endpoints (SPEC.md §6.3). + +Authored from `unstract-docs/docs/unstract_platform/api_documentation/versions/v1-*.mdx`. + +Base path is `{host}/api/v1/unstract/{org_id}`; auth is a platform API key as a +Bearer token. Key permission levels gate methods: `read` allows GET, `read_write` +everything but DELETE, `full_access` everything. + +Two upstream inconsistencies are reproduced here deliberately, because +"correcting" either produces a 404: + +* Trailing slashes are not uniform. Several Prompt Studio paths and the group + member-removal path genuinely have none; those records set + ``no_trailing_slash=True`` so a test can assert intent rather than treating the + omission as a typo. +* Profile creation uses ``profilemanager`` (one word) while profile CRUD uses + ``profile-manager`` (hyphenated). + +PATCH records are *derived* from their PUT counterparts via `derive_patch` +(P8): a parameter added to a PUT record cannot then be forgotten on the PATCH. +""" + +from __future__ import annotations + +from dataclasses import replace + +from unstract_cli.core.model import ( + ApiGroup, + AtLeastOneOf, + BodyKind, + Endpoint, + Param, + ParamLocation, + ParamType, + Permission, + PollSpec, + derive_patch, + with_params, +) + +_DOCS = "unstract-docs/docs/unstract_platform/api_documentation/versions" +_BASE = "/api/v1/unstract/{org_id}" + +_ORG = Param( + "org_id", + location=ParamLocation.PATH, + default_from="platform.org_id", + help="Organization identifier", +) + +#: Shared pagination flags (SPEC.md §6.3). +_PAGE = ( + Param("page", type=ParamType.INT, default=1, help="Page number"), + Param("page_size", type=ParamType.INT, default=50, + help="Results per page (max 1000)"), +) + +_DELETE_NOTE = ( + "Requires a platform API key with full_access permission: DELETE is blocked " + "for read and read_write keys at the middleware." +) + +#: Friendly resource name -> URL path segment (P3). The segment is not guessable +#: from the friendly name -- `api-deployment` lives at `api/deployment` -- so this +#: is an enum rather than free text, and a wrong value fails locally with exit 2 +#: instead of a confusing remote 404. +SHARE_RESOURCES = { + "adapter": "adapter", + "connector": "connector", + "workflow": "workflow", + "pipeline": "pipeline", + "api-deployment": "api/deployment", + "prompt-studio": "prompt-studio", +} + + +def _ep( + name: str, + method: str, + path: str, + summary: str, + params: tuple[Param, ...] = (), + *, + subgroup: str | None = None, + body: BodyKind = BodyKind.NONE, + doc: str = "v1-prompt-studio.mdx", + permission: Permission | None = None, + description: str = "", + examples: tuple[str, ...] = (), + constraints: tuple = (), + no_trailing_slash: bool = False, + table_columns: tuple[str, ...] = (), + require_response_fields: tuple[str, ...] = (), + poll: PollSpec | None = None, + doc_source: str | None = None, + doc_conflict: str | None = None, +) -> Endpoint: + """Construct a Platform endpoint, filling in the org-scoped base path. + + ``doc_source`` overrides the default ``{_DOCS}/{doc}`` provenance for the few + endpoints authored from the backend source rather than the public v1 docs + (the Output Manager and task-status routes have no ``.mdx`` page). Citing + their real source is honest -- exactly how API Hub records cite code -- and + lets the Skill's drift check report them as undocumented (info-only) with a + citation that points where they actually came from. + """ + return Endpoint( + name=name, + group="docstudio", + # Every Platform API command lives under `docstudio platform ...`. + subgroup=f"platform {subgroup}" if subgroup else "platform", + method=method, + path=f"{_BASE}{path}", + api=ApiGroup.PLATFORM, + summary=summary, + params=(_ORG, *params), + body=body, + doc_source=doc_source or f"{_DOCS}/{doc}", + permission=permission, + description=description, + examples=examples, + constraints=constraints, + no_trailing_slash=no_trailing_slash, + table_columns=table_columns, + require_response_fields=require_response_fields, + poll=poll, + doc_conflict=doc_conflict, + ) + + +# --------------------------------------------------------------------------- # +# Prompt Studio +# --------------------------------------------------------------------------- # + +_PS = "prompt-studio" +_TOOL_ID = Param("tool_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Prompt Studio project (tool) identifier") + +#: `prompt create` only: the backend's `create_prompt` persists the request body +#: verbatim and ignores the URL `pk`, so a `tool_id` absent from the body is saved +#: as NULL -- the prompt exists but links to no project and is unreachable (BUG 2). +#: Mirroring the path value into the body links it correctly. Verified this session. +_TOOL_ID_MIRRORED = replace(_TOOL_ID, mirror_to_body=True) + +#: `--wait` for fetch-response / single-pass. These return HTTP 202 with a +#: `task_id`; task-status reports completion (it does NOT return the value), and +#: the extracted output lands in the Output Manager, read by `output list` keyed +#: on the original request's `tool_id` -- not on the poll handle. `retrieve_carry` +#: forwards that tool_id, and `retrieve_omits_handle` keeps the task_id out of the +#: output-list call, which has no such parameter (IMPROVEMENT 3). +_PS_POLL = PollSpec( + status_endpoint="docstudio.platform.prompt-studio.task-status", + status_field="status", + terminal_success=("completed",), + terminal_failure=("failed",), + handle_field="task_id", + handle_param="task_id", + retrieve_endpoint="docstudio.platform.prompt-studio.output.list", + # Narrow the result to the exact prompt + document this call ran, rather than + # returning every row for the tool. fetch-response's `id` is the Output + # Manager's `prompt_id`; both endpoints already share the py_name `document_id` + # (output list exposes `document_manager` under the flag --document-id), so it + # carries across unchanged. + retrieve_carry=( + "tool_id", + ("id", "prompt_id"), + "document_id", + ), + retrieve_omits_handle=True, +) + +#: `--wait` for index-document. Same 202 {task_id, status} shape and the same +#: task-status route as fetch-response, but indexing produces no Output Manager +#: row -- there is nothing to retrieve, so the terminal status IS the result. +#: Without this, index-document was the one async command that forced a manual +#: poll loop while its siblings all had --wait (GOTCHAS #8). +_PS_INDEX_POLL = replace( + _PS_POLL, + retrieve_endpoint=None, + retrieve_carry=(), +) + +#: Single-pass runs all prompts, so its retrieve is intentionally tool-wide; it +#: only needs to ask for the single-pass rows (and the document it ran against). +_PS_SINGLE_PASS_POLL = replace( + _PS_POLL, + retrieve_carry=("tool_id", "document_id"), + retrieve_extra=(("is_single_pass_extract", True),), +) + +_PS_FIELDS: tuple[Param, ...] = ( + Param("tool_name", location=ParamLocation.BODY, required=True, + help="Project name, unique within the organization"), + Param("description", location=ParamLocation.BODY, required=True, + help="Project description"), + Param("author", location=ParamLocation.BODY, required=True, help="Project author"), + Param("icon", location=ParamLocation.BODY, help="Project icon"), + Param("preamble", location=ParamLocation.BODY, help="Text prepended to every prompt"), + Param("postamble", location=ParamLocation.BODY, help="Text appended to every prompt"), + Param("summarize_context", type=ParamType.BOOL, location=ParamLocation.BODY, + default=False, help="Summarize context before extraction"), + Param("single_pass_extraction_mode", type=ParamType.BOOL, location=ParamLocation.BODY, + default=False, help="Run all prompts in a single LLM call"), + Param("enable_challenge", type=ParamType.BOOL, location=ParamLocation.BODY, + default=False, help="Enable LLM challenge for extraction validation"), + # GOTCHAS #2: an exported tool's settings schema lists challenge_llm as + # required even when enable_challenge is false, so a tool instance whose + # metadata.challenge_llm is "" fails deploy-time validation with a 422 that + # only surfaces at `deployment run`. Setting it on the project before + # `export-tool` means the exported metadata carries a real adapter id. + Param("challenge_llm", type=ParamType.UUID, location=ParamLocation.BODY, + help="LLM adapter used to challenge extractions. Set this before " + "`export-tool` even with --no-enable-challenge: the exported tool " + "requires a non-empty challenge_llm at deploy time, and an empty one " + "fails `deployment run` with a 422"), + Param("monitor_llm", type=ParamType.UUID, location=ParamLocation.BODY, + help="LLM adapter used for monitoring. Defaults server-side to the " + "default profile's LLM"), + Param("enable_highlight", type=ParamType.BOOL, location=ParamLocation.BODY, + default=False, help="Record line metadata for source highlighting"), + Param("custom_data", type=ParamType.JSON, location=ParamLocation.BODY, + help="JSON object addressable in prompts as {{custom_data.key}}"), + Param("shared_users", type=ParamType.INT, location=ParamLocation.BODY, multiple=True, + replace_semantics=True, help="User IDs to share with"), + Param("shared_to_org", type=ParamType.BOOL, location=ParamLocation.BODY, default=False, + help="Share with the whole organization"), +) + +_PROMPT_FIELDS: tuple[Param, ...] = ( + Param("prompt_key", location=ParamLocation.BODY, required=True, + help="Output key for this prompt, unique within the project"), + Param("enforce_type", location=ParamLocation.BODY, default="text", + choices=["text", "number", "email", "date", "boolean", "json", "line-item", "table"], + help="Expected output type. Note: 'date' normalization is locale-ambiguous " + "and reads DD/MM/YYYY sources as MM/DD/YYYY (01/08/2025 -> 2025-01-08); " + "prefer 'text' for non-US date formats"), + Param("prompt", location=ParamLocation.BODY, help="Prompt text sent to the LLM"), + Param("sequence_number", type=ParamType.INT, location=ParamLocation.BODY, + help="Position within the project"), + Param("prompt_type", location=ParamLocation.BODY, choices=["PROMPT", "NOTES"], + help="PROMPT extracts a value; NOTES is an annotation"), + Param("active", type=ParamType.BOOL, location=ParamLocation.BODY, default=True, + help="Whether the prompt runs. Boolean flags are --active / --no-active; " + "`--active true` is not valid syntax"), + # The load-bearing field for GOTCHAS #1. `fetch-response` resolves the LLM + # profile from the PROMPT's own profile_manager FK and never falls back to + # the project default, so a prompt created without it is unrunnable -- and + # the resulting error names the *project* default, which is genuinely set. + Param("profile_manager", type=ParamType.UUID, location=ParamLocation.BODY, + help="LLM profile this prompt runs with. Set it at creation: fetch-response " + "reads this field and does NOT fall back to the project default, so a " + "prompt without it fails with 'Default LLM profile is not configured'"), +) + +_PROFILE_FIELDS: tuple[Param, ...] = ( + Param("profile_name", location=ParamLocation.BODY, required=True, + help="Profile name, unique within the project"), + # GOTCHAS #3 asked for these to be optional when --chunk-size 0 ("no RAG"). + # They are NOT, and cannot be made so client-side: ProfileManager declares + # both FKs null=False, and the serializer is `fields = "__all__"`, so DRF + # derives required=True and the server rejects a profile without them + # regardless of chunk_size. Dropping the local check would only trade a fast + # exit-2 for a slower remote 400, so the requirement stays and the help says + # what to pass instead. + Param("vector_store", type=ParamType.UUID, location=ParamLocation.BODY, required=True, + help="Vector DB adapter id. Required even with --chunk-size 0: the server " + "rejects a profile without one. With chunk_size=0 it is stored but " + "never queried, so any valid vector-DB adapter id will do"), + Param("embedding_model", type=ParamType.UUID, location=ParamLocation.BODY, required=True, + help="Embedding adapter id. Required even with --chunk-size 0, for the same " + "reason as --vector-store: stored, but not used when RAG is off"), + Param("llm", type=ParamType.UUID, location=ParamLocation.BODY, required=True, + help="LLM adapter id"), + Param("x2text", type=ParamType.UUID, location=ParamLocation.BODY, required=True, + help="Text extractor adapter id"), + Param("chunk_size", type=ParamType.INT, location=ParamLocation.BODY, + help="Chunk size for RAG. 0 = whole-document / no-RAG: skips embedding " + "and the vector DB entirely. Use 0 for short documents, or when the " + "vector DB is unavailable"), + Param("chunk_overlap", type=ParamType.INT, location=ParamLocation.BODY, + help="Overlap between chunks. Set 0 alongside chunk_size=0"), + Param("retrieval_strategy", location=ParamLocation.BODY, default="simple", + choices=["simple", "subquestion", "fusion", "recursive", "router", + "keyword_table", "automerging"], + help="Retrieval strategy for RAG"), + Param("similarity_top_k", type=ParamType.INT, location=ParamLocation.BODY, + help="Number of chunks to retrieve"), +) + +_ps_update = _ep( + "update", "PUT", "/prompt-studio/{tool_id}/", "Replace a Prompt Studio project.", + (_TOOL_ID, *_PS_FIELDS), subgroup=_PS, body=BodyKind.JSON, + permission=Permission.READ_WRITE, +) + +_prompt_update = _ep( + "update", "PUT", "/prompt-studio/prompt/{prompt_id}/", "Replace a prompt.", + (Param("prompt_id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Prompt identifier"), *_PROMPT_FIELDS), + subgroup=f"{_PS} prompt", body=BodyKind.JSON, permission=Permission.READ_WRITE, +) + +_profile_update = _ep( + "update", "PUT", "/prompt-studio/profile-manager/{profile_id}/", + "Replace an LLM profile.", + (Param("profile_id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Profile identifier"), *_PROFILE_FIELDS), + subgroup=f"{_PS} profile", body=BodyKind.JSON, permission=Permission.READ_WRITE, +) + +_PROMPT_STUDIO: tuple[Endpoint, ...] = ( + _ep("list", "GET", "/prompt-studio/", "List Prompt Studio projects.", + subgroup=_PS, permission=Permission.READ, + table_columns=("tool_id", "tool_name", "author", "created_by_email"), + examples=("unstract platform prompt-studio list",)), + _ep("create", "POST", "/prompt-studio/", "Create a Prompt Studio project.", + _PS_FIELDS, subgroup=_PS, body=BodyKind.JSON, permission=Permission.READ_WRITE, + examples=("unstract platform prompt-studio create --tool-name Invoices " + "--description 'Invoice extraction' --author me",)), + _ep("get", "GET", "/prompt-studio/{tool_id}/", "Show one Prompt Studio project.", + (_TOOL_ID,), subgroup=_PS, permission=Permission.READ), + _ps_update, + derive_patch(_ps_update, summary="Partially update a Prompt Studio project."), + _ep("delete", "DELETE", "/prompt-studio/{tool_id}/", "Delete a Prompt Studio project.", + (_TOOL_ID,), subgroup=_PS, permission=Permission.FULL_ACCESS, + description=f"{_DELETE_NOTE} Returns 409 if the tool is exported and in use.\n\n" + "This is also the ONLY way to remove an exported registry entry: " + "the registry is read-only over the API (it exposes list and " + "settings-schema, with no DELETE route), and deleting the project " + "cascades to the entry it published. Detach the tool from any " + "workflow first (`workflow tool remove`) or this returns 409 " + "(GOTCHAS #9)."), + _ep("export-project", "GET", "/prompt-studio/project-transfer/{tool_id}", + "Export a project as a JSON file.", + (_TOOL_ID, Param("save", client_side=True, help="Write the export to this path")), + subgroup=_PS, permission=Permission.READ, no_trailing_slash=True, + examples=("unstract platform prompt-studio export-project --tool-id --save proj.json",)), + _ep("import-project", "POST", "/prompt-studio/project-transfer/", + "Import a project from an exported JSON file.", + (Param("file", type=ParamType.FILE, location=ParamLocation.FORM, required=True, + help="Previously exported project JSON"),), + subgroup=_PS, body=BodyKind.MULTIPART, permission=Permission.READ_WRITE), + _ep("sync-prompts", "POST", "/prompt-studio/{tool_id}/sync-prompts/", + "Sync prompts from an export into an existing project.", + (_TOOL_ID, + Param("data", type=ParamType.JSON, location=ParamLocation.BODY, required=True, + help="Export JSON containing a `prompts` key"), + Param("create_copy", type=ParamType.BOOL, location=ParamLocation.BODY, + default=False, help="Back the project up before syncing")), + subgroup=_PS, body=BodyKind.JSON, permission=Permission.READ_WRITE), + _ep("export-tool", "POST", "/prompt-studio/export/{tool_id}", + "Export a project to the tool registry for deployment.", + (_TOOL_ID, + Param("is_shared_with_org", type=ParamType.BOOL, location=ParamLocation.BODY, + help="Share the exported tool with the organization"), + Param("user_id", type=ParamType.INT, location=ParamLocation.BODY, multiple=True, + help="User IDs to share the exported tool with"), + Param("force_export", type=ParamType.BOOL, location=ParamLocation.BODY, + default=False, help="Export even if validation warns")), + subgroup=_PS, body=BodyKind.JSON, permission=Permission.READ_WRITE, + no_trailing_slash=True, + description="Publishes the project to the tool registry, where it gets a NEW " + "registry id (`function_name`) that is NOT the Prompt Studio " + "tool_id. This call does not return it -- find it with " + "`tool registry list`, or `api-deployment by-prompt-studio-tool` " + "once deployed (GOTCHAS #5).\n\n" + "Before exporting, set the project's --challenge-llm (see " + "`prompt-studio patch`). The exported tool requires a non-empty " + "challenge_llm at deploy time even when enable_challenge is false; " + "if it is empty the attached tool instance fails validation and " + "`deployment run` ends in ERROR with a 422 -- the failure surfaces " + "only at that last step (GOTCHAS #2).", + examples=("unstract platform prompt-studio export-tool --tool-id ",)), + _ep("export-info", "GET", "/prompt-studio/export/{tool_id}", + "Show export status for a project.", (_TOOL_ID,), subgroup=_PS, + permission=Permission.READ, no_trailing_slash=True, + description="Returns 204 No Content if the project has never been exported."), + _ep("upload", "POST", "/prompt-studio/file/{tool_id}", + "Upload documents to a project.", + (_TOOL_ID, Param("file", type=ParamType.FILE, location=ParamLocation.FORM, + required=True, multiple=True, help="File to upload; repeatable")), + subgroup=f"{_PS} file", body=BodyKind.MULTIPART, permission=Permission.READ_WRITE, + no_trailing_slash=True), + _ep("get", "GET", "/prompt-studio/file/{tool_id}", "Fetch a document's contents.", + (_TOOL_ID, + Param("document_id", type=ParamType.UUID, required=True, help="Document identifier"), + Param("view_type", choices=["ORIGINAL", "EXTRACT", "SUMMARIZE"], + help="Which rendition to fetch")), + subgroup=f"{_PS} file", permission=Permission.READ, no_trailing_slash=True), + _ep("delete", "DELETE", "/prompt-studio/file/{tool_id}", + "Delete a document from a project.", + (_TOOL_ID, Param("document_id", type=ParamType.UUID, location=ParamLocation.BODY, + required=True, help="Document identifier")), + subgroup=f"{_PS} file", body=BodyKind.JSON, permission=Permission.FULL_ACCESS, + description=_DELETE_NOTE, no_trailing_slash=True), + _ep("create", "POST", "/prompt-studio/prompt-studio-prompt/{tool_id}/", + "Create a prompt in a project.", (_TOOL_ID_MIRRORED, *_PROMPT_FIELDS), + subgroup=f"{_PS} prompt", body=BodyKind.JSON, permission=Permission.READ_WRITE, + require_response_fields=("tool_id",), + description="Sends tool_id in the body as well as the path, so the prompt " + "links to the project rather than being orphaned (tool_id: null).\n\n" + "Pass --profile-manager unless you intend to supply it on every " + "run: `fetch-response` resolves the LLM profile from THIS field " + "and does not fall back to the project's default profile. A prompt " + "created without it fails at run time with 'Default LLM profile is " + "not configured' even when `profile set-default` succeeded " + "(GOTCHAS #1).", + examples=("unstract platform prompt-studio prompt create --tool-id " + "--prompt-key invoice_no --prompt 'What is the invoice number?' " + "--profile-manager ",)), + _ep("get", "GET", "/prompt-studio/prompt/{prompt_id}/", "Show one prompt.", + (Param("prompt_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Prompt identifier"),), + subgroup=f"{_PS} prompt", permission=Permission.READ), + _prompt_update, + derive_patch(_prompt_update, summary="Partially update a prompt."), + _ep("delete", "DELETE", "/prompt-studio/prompt/{prompt_id}/", "Delete a prompt.", + (Param("prompt_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Prompt identifier"),), + subgroup=f"{_PS} prompt", permission=Permission.FULL_ACCESS, description=_DELETE_NOTE), + _ep("reorder", "POST", "/prompt-studio/prompt/reorder/", "Reorder a prompt.", + (Param("start_sequence_number", type=ParamType.INT, location=ParamLocation.BODY, + required=True, help="Current position"), + Param("end_sequence_number", type=ParamType.INT, location=ParamLocation.BODY, + required=True, help="Target position"), + Param("prompt_id", type=ParamType.UUID, location=ParamLocation.BODY, + required=True, help="Prompt to move")), + subgroup=f"{_PS} prompt", body=BodyKind.JSON, permission=Permission.READ_WRITE), + _ep("list", "GET", "/prompt-studio/prompt-studio-profile/{tool_id}/", + "List LLM profiles for a project.", (_TOOL_ID,), subgroup=f"{_PS} profile", + permission=Permission.READ), + _ep("set-default", "PATCH", "/prompt-studio/prompt-studio-profile/{tool_id}/", + "Set the project's default LLM profile.", + (_TOOL_ID, Param("default_profile", type=ParamType.UUID, location=ParamLocation.BODY, + required=True, help="Profile to make default")), + subgroup=f"{_PS} profile", body=BodyKind.JSON, permission=Permission.READ_WRITE), + # Note the path: `profilemanager` (one word) on create, `profile-manager` + # (hyphenated) on CRUD. This asymmetry is upstream, and is load-bearing. + _ep("create", "POST", "/prompt-studio/profilemanager/{tool_id}", + "Create an LLM profile (maximum 4 per project).", + (_TOOL_ID, *_PROFILE_FIELDS), subgroup=f"{_PS} profile", body=BodyKind.JSON, + permission=Permission.READ_WRITE, no_trailing_slash=True, + description="With --chunk-size 0 the document is sent to the LLM whole (no RAG) " + "and neither the vector DB nor the embedding model is queried -- but " + "the server still REQUIRES both fields, so pass any valid adapter id " + "for them (GOTCHAS #3). Use chunk_size=0 for short documents, or " + "when the vector DB is unavailable.", + examples=("unstract platform prompt-studio profile create --tool-id " + "--profile-name direct --llm --x2text " + "--vector-store --embedding-model " + "--chunk-size 0 --chunk-overlap 0",)), + _ep("get", "GET", "/prompt-studio/profile-manager/{profile_id}/", "Show one LLM profile.", + (Param("profile_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Profile identifier"),), + subgroup=f"{_PS} profile", permission=Permission.READ), + _profile_update, + derive_patch(_profile_update, summary="Partially update an LLM profile."), + _ep("delete", "DELETE", "/prompt-studio/profile-manager/{profile_id}/", + "Delete an LLM profile.", + (Param("profile_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Profile identifier"),), + subgroup=f"{_PS} profile", permission=Permission.FULL_ACCESS, description=_DELETE_NOTE), + _ep("index-document", "POST", "/prompt-studio/index-document/{tool_id}", + "Index a document for retrieval.", + (_TOOL_ID, Param("document_id", type=ParamType.UUID, location=ParamLocation.BODY, + required=True, help="Document identifier")), + subgroup=_PS, body=BodyKind.JSON, permission=Permission.READ_WRITE, + no_trailing_slash=True, poll=_PS_INDEX_POLL, + description="Returns HTTP 202 {task_id, run_id, status:accepted}. Use --wait to " + "poll to completion instead of calling `task-status` in a loop. " + "Indexing writes no Output Manager row, so --wait returns the final " + "task status rather than an extracted value.", + examples=("unstract platform prompt-studio index-document --tool-id " + "--document-id --wait",)), + _ep("fetch-response", "POST", "/prompt-studio/fetch_response/{tool_id}", + "Run one prompt against a document.", + (_TOOL_ID, + Param("document_id", type=ParamType.UUID, location=ParamLocation.BODY, + required=True, help="Document to run against"), + Param("id", type=ParamType.UUID, location=ParamLocation.BODY, required=True, + help="Prompt identifier"), + Param("run_id", type=ParamType.UUID, location=ParamLocation.BODY, + help="Execution tracking identifier"), + Param("profile_manager", type=ParamType.UUID, location=ParamLocation.BODY, + help="LLM profile to run with. Required unless the prompt itself was " + "created with a profile_manager -- there is no fallback to the " + "project default")), + subgroup=_PS, body=BodyKind.JSON, permission=Permission.READ_WRITE, + no_trailing_slash=True, poll=_PS_POLL, + description="Returns HTTP 202 {task_id, run_id, status:accepted}; the extracted " + "value is written to the Output Manager. Use --wait to poll to " + "completion and return the results, or read them with " + "`prompt-studio output list`.\n\n" + "If this fails with 'Default LLM profile is not configured' while " + "`profile set-default` and `prompt-studio get` both show a default: " + "the message is misleading. The server resolves the profile from the " + "PROMPT's own profile_manager field and never consults the project " + "default, so the real cause is a prompt created without one. Fix it " + "permanently with `prompt patch --prompt-id --profile-manager " + "`, or pass --profile-manager on each run (GOTCHAS #1)."), + _ep("single-pass", "POST", "/prompt-studio/single-pass-extraction/{tool_id}", + "Run all active prompts in a single pass.", + (_TOOL_ID, + Param("document_id", type=ParamType.UUID, location=ParamLocation.BODY, + required=True, help="Document to run against"), + Param("run_id", type=ParamType.UUID, location=ParamLocation.BODY, + help="Execution tracking identifier")), + subgroup=_PS, body=BodyKind.JSON, permission=Permission.READ_WRITE, + no_trailing_slash=True, poll=_PS_SINGLE_PASS_POLL, + description="Requires single_pass_extraction_mode enabled on the project. " + "Returns HTTP 202; use --wait to poll and return results, or read " + "them with `prompt-studio output list --is-single-pass-extract`."), + _ep("task-status", "GET", "/prompt-studio/{tool_id}/task-status/{task_id}", + "Check the status of an async prompt-studio task.", + (_TOOL_ID, + Param("task_id", location=ParamLocation.PATH, required=True, + help="Task identifier returned by fetch-response / single-pass / " + "index-document")), + subgroup=_PS, permission=Permission.READ, no_trailing_slash=True, + description="Status is one of: processing, completed, failed. The extracted " + "value is not returned here -- read it with `prompt-studio output " + "list`.\n\nNeeds BOTH --task-id and --tool-id: the route is " + "/{tool_id}/task-status/{task_id}, and the tool_id is the same one " + "passed to the call that returned the task_id. Prefer --wait on " + "that call, which polls this for you.", + examples=("unstract platform prompt-studio task-status --tool-id " + "--task-id ",), + doc_source="backend/prompt_studio/prompt_studio_core_v2/urls.py"), + _ep("list", "GET", "/prompt-studio/prompt-output/", + "List extraction results for a project.", + (Param("tool_id", type=ParamType.UUID, required=True, + help="Prompt Studio project (tool) identifier"), + Param("prompt_id", type=ParamType.UUID, help="Filter to one prompt"), + Param("document_manager", type=ParamType.UUID, flag="--document-id", + help="Filter to one uploaded document"), + Param("profile_manager", type=ParamType.UUID, flag="--profile-id", + help="Filter to one LLM profile"), + Param("is_single_pass_extract", type=ParamType.BOOL, default=False, + help="Return single-pass results instead of per-prompt")), + subgroup=f"{_PS} output", permission=Permission.READ, + description="Reads the Prompt Studio Output Manager, where fetch-response and " + "single-pass write their results. Each row carries prompt_id, output, " + "context and modified_at; take the latest row per prompt_id.", + table_columns=("prompt_id", "prompt_key", "output", "modified_at"), + examples=("unstract platform prompt-studio output list --tool-id ",), + doc_source="backend/prompt_studio/prompt_studio_output_manager_v2/urls.py"), + _ep("latest", "GET", "/prompt-studio/prompt-output/latest-by-keys/", + "Latest result per prompt key for a project.", + (Param("tool_id", type=ParamType.UUID, required=True, + help="Prompt Studio project (tool) identifier"),), + subgroup=f"{_PS} output", permission=Permission.READ, + description="Returns the latest output keyed by prompt_key. May return {} in some " + "cases where `output list` still has the data -- prefer `output list` " + "if this is empty.", + examples=("unstract platform prompt-studio output latest --tool-id ",), + doc_source="backend/prompt_studio/prompt_studio_output_manager_v2/urls.py"), + _ep("users", "GET", "/prompt-studio/users/{tool_id}", "List users a project is shared with.", + (_TOOL_ID,), subgroup=_PS, permission=Permission.READ, no_trailing_slash=True), + _ep("check-deployment-usage", "GET", "/prompt-studio/{tool_id}/check_deployment_usage/", + "Check whether an exported tool is used by deployments.", (_TOOL_ID,), + subgroup=_PS, permission=Permission.READ), + _ep("select-choices", "GET", "/prompt-studio/select_choices/", + "List dropdown values for Prompt Studio fields.", subgroup=_PS, + permission=Permission.READ), + _ep("adapter-choices", "GET", "/prompt-studio/adapter-choices/", + "List adapters available for LLM profiles.", subgroup=_PS, + permission=Permission.READ, + description="Known to return 500 server_error in some organizations " + "(GOTCHAS #10). If it does, enumerate adapters directly instead: " + "`adapter list --adapter-type LLM` gives the ids that " + "`profile create --llm` expects, filtered by kind.", + examples=("unstract platform adapter list --adapter-type LLM",)), + _ep("retrieval-strategies", "GET", "/prompt-studio/{tool_id}/get_retrieval_strategies/", + "List retrieval strategies available to a project.", (_TOOL_ID,), + subgroup=_PS, permission=Permission.READ), +) + + +# --------------------------------------------------------------------------- # +# Workflows +# --------------------------------------------------------------------------- # + +_WF_ID = Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Workflow identifier") + +_WF_FIELDS: tuple[Param, ...] = ( + Param("workflow_name", location=ParamLocation.BODY, required=True, + help="Workflow name, unique per organization (max 128 chars)"), + Param("description", location=ParamLocation.BODY, help="Description (max 490 chars)"), + Param("deployment_type", location=ParamLocation.BODY, default="DEFAULT", + choices=["DEFAULT", "ETL", "TASK", "API", "APP"], help="Deployment type"), + Param("source_settings", type=ParamType.JSON, location=ParamLocation.BODY, + help="Source connector configuration"), + Param("destination_settings", type=ParamType.JSON, location=ParamLocation.BODY, + help="Destination connector configuration"), + Param("max_file_execution_count", type=ParamType.INT, location=ParamLocation.BODY, + help="Maximum executions per file (minimum 1)"), + Param("shared_to_org", type=ParamType.BOOL, location=ParamLocation.BODY, default=False, + help="Share with the whole organization"), + Param("shared_users", type=ParamType.INT, location=ParamLocation.BODY, multiple=True, + replace_semantics=True, help="User IDs to share with"), +) + +_wf_update = _ep("update", "PUT", "/workflow/{id}/", "Replace a workflow.", + (_WF_ID, *_WF_FIELDS), subgroup="workflow", body=BodyKind.JSON, + doc="v1-workflows.mdx", permission=Permission.READ_WRITE) + +_WORKFLOWS: tuple[Endpoint, ...] = ( + _ep("list", "GET", "/workflow/", "List workflows.", + (Param("project", help="Filter by project id"), + Param("workflow_owner", help="Filter by owner user id"), + Param("is_active", type=ParamType.BOOL, help="Filter by active state"), + Param("order_by", choices=["asc", "desc"], help="Sort by modified_at"), + *_PAGE), + subgroup="workflow", doc="v1-workflows.mdx", permission=Permission.READ, + table_columns=("id", "workflow_name", "deployment_type", "is_active"), + examples=("unstract platform workflow list",)), + _ep("create", "POST", "/workflow/", "Create a workflow.", _WF_FIELDS, + subgroup="workflow", body=BodyKind.JSON, doc="v1-workflows.mdx", + permission=Permission.READ_WRITE), + _ep("get", "GET", "/workflow/{id}/", "Show one workflow.", (_WF_ID,), + subgroup="workflow", doc="v1-workflows.mdx", permission=Permission.READ), + _wf_update, + derive_patch(_wf_update, summary="Partially update a workflow."), + _ep("delete", "DELETE", "/workflow/{id}/", "Delete a workflow.", (_WF_ID,), + subgroup="workflow", doc="v1-workflows.mdx", permission=Permission.FULL_ACCESS, + description=f"{_DELETE_NOTE} Only the workflow owner may delete."), + _ep("execute", "POST", "/workflow/execute/", "Execute a workflow.", + (Param("workflow_id", type=ParamType.UUID, location=ParamLocation.BODY, + required=True, help="Workflow to execute"), + Param("execution_action", location=ParamLocation.BODY, + choices=["START", "NEXT", "STOP", "CONTINUE"], help="Execution action"), + Param("execution_id", type=ParamType.UUID, location=ParamLocation.BODY, + help="Required for NEXT, STOP and CONTINUE"), + Param("log_guid", type=ParamType.UUID, location=ParamLocation.BODY, + help="Correlates log entries"), + # The API field is `files`; the flag reads better singular since it is + # repeated once per file. + Param("files", type=ParamType.FILE, location=ParamLocation.FORM, + multiple=True, flag="--file", + help="File to process, for API-mode workflows; repeatable")), + subgroup="workflow", body=BodyKind.JSON, doc="v1-workflows.mdx", + permission=Permission.READ_WRITE), + _ep("toggle-active", "PUT", "/workflow/active/{id}/", "Toggle a workflow's active state.", + (_WF_ID,), subgroup="workflow", doc="v1-workflows.mdx", + permission=Permission.READ_WRITE), + _ep("can-update", "GET", "/workflow/{id}/can-update/", + "Check whether the current user may update a workflow.", (_WF_ID,), + subgroup="workflow", doc="v1-workflows.mdx", permission=Permission.READ), + _ep("clear-file-marker", "GET", "/workflow/{id}/clear-file-marker/", + "Clear file processing markers so files can be reprocessed.", (_WF_ID,), + subgroup="workflow", doc="v1-workflows.mdx", permission=Permission.READ_WRITE, + description=( + "NOTE: this is a mutating GET request -- that is how the API defines it. " + "It changes server state despite the method." + )), + _ep("schema", "GET", "/workflow/schema/", "Show the connector configuration schema.", + (Param("type", default="src", choices=["src", "dest"], help="Endpoint side"), + Param("entity", default="file", choices=["file", "api", "db"], help="Entity type")), + subgroup="workflow", doc="v1-workflows.mdx", permission=Permission.READ), + _ep("users", "GET", "/workflow/{id}/users/", "List users a workflow is shared with.", + (_WF_ID,), subgroup="workflow", doc="v1-workflows.mdx", permission=Permission.READ), + _ep("list", "GET", "/workflow/{id}/execution/", "List executions of a workflow.", + (_WF_ID, *_PAGE), subgroup="workflow execution", doc="v1-workflows.mdx", + permission=Permission.READ, + table_columns=("id", "status", "total_files", "execution_time")), + _ep("get", "GET", "/workflow/execution/{id}/", "Show one execution.", + (Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Execution identifier"),), + subgroup="workflow execution", doc="v1-workflows.mdx", permission=Permission.READ), + _ep("logs", "GET", "/workflow/execution/{id}/logs/", "Show logs for an execution.", + (Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Execution identifier"), + Param("file_execution_id", help="Filter by file execution; pass 'null' for non-file logs"), + Param("log_level", default="INFO", choices=["DEBUG", "INFO", "WARN", "ERROR"], + help="Minimum log level"), + Param("ordering", help="Ordering field, e.g. event_time"), + *_PAGE), + subgroup="workflow execution", doc="v1-workflows.mdx", permission=Permission.READ), + _ep("list", "GET", "/workflow/{workflow_id}/file-histories/", "List file histories.", + (Param("workflow_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Workflow identifier"), + Param("status", help="Comma-separated statuses to filter by"), + Param("execution_count_min", type=ParamType.INT, help="Minimum execution count"), + Param("execution_count_max", type=ParamType.INT, help="Maximum execution count"), + Param("file_path", help="File path prefix"), + *_PAGE), + subgroup="workflow file-history", doc="v1-workflows.mdx", permission=Permission.READ), + _ep("get", "GET", "/workflow/{workflow_id}/file-histories/{id}/", + "Show one file history entry.", + (Param("workflow_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Workflow identifier"), + Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="File history identifier")), + subgroup="workflow file-history", doc="v1-workflows.mdx", permission=Permission.READ), + _ep("delete", "DELETE", "/workflow/{workflow_id}/file-histories/{id}/", + "Delete one file history entry.", + (Param("workflow_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Workflow identifier"), + Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="File history identifier")), + subgroup="workflow file-history", doc="v1-workflows.mdx", + permission=Permission.FULL_ACCESS, description=_DELETE_NOTE), + _ep("clear", "POST", "/workflow/{workflow_id}/file-histories/clear/", + "Bulk delete file histories matching filters.", + (Param("workflow_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Workflow identifier"), + Param("ids", type=ParamType.UUID, location=ParamLocation.BODY, multiple=True, + help="Specific entries to delete (max 100)"), + Param("status", location=ParamLocation.BODY, multiple=True, + help="Statuses to delete"), + Param("execution_count_min", type=ParamType.INT, location=ParamLocation.BODY, + help="Minimum execution count"), + Param("execution_count_max", type=ParamType.INT, location=ParamLocation.BODY, + help="Maximum execution count"), + Param("file_path", location=ParamLocation.BODY, help="File path prefix")), + subgroup="workflow file-history", body=BodyKind.JSON, doc="v1-workflows.mdx", + permission=Permission.FULL_ACCESS, + constraints=(AtLeastOneOf(("ids", "status", "execution_count_min", + "execution_count_max", "file_path")),), + description=( + "At least one filter is required: an unfiltered clear would delete every " + "file history for the workflow." + )), +) + + +# --------------------------------------------------------------------------- # +# Workflow assembly: tool instances + endpoint configuration +# +# These are the two steps that turn a bare workflow into a deployable one, and +# they have no public v1 docs page -- `doc_source` cites the backend routes, the +# way API Hub records cite source. `workflow create` auto-creates SOURCE and +# DESTINATION endpoints but leaves their connection_type null; an API deployment +# then rejects the workflow until both are set to "API". Attaching the exported +# tool is `POST /tool_instance/` keyed by the tool's *registry* id (the +# `function_name` from `tool registry list`), NOT the Prompt Studio tool_id. +# (CAPTURE2 GAP 1.) +# --------------------------------------------------------------------------- # + +_WORKFLOW_ASSEMBLY: tuple[Endpoint, ...] = ( + _ep("list", "GET", "/tool/", "List tools in the registry (for attaching to a workflow).", + subgroup="tool registry", permission=Permission.READ, + doc_source="backend/tool_instance_v2/urls.py", + description="Each tool's `function_name` is its registry id -- the value " + "`workflow tool add --tool` expects. This is NOT the Prompt Studio " + "tool_id; `prompt-studio export-tool` publishes a project here first.\n\n" + "The registry carries no back-reference to the Prompt Studio " + "tool_id and the endpoint accepts no filters, so after exporting " + "you must match the entry by its `name`, which is the project's " + "tool_name (GOTCHAS #5). Give projects distinct names, or the " + "match is ambiguous.", + table_columns=("function_name", "name", "description"), + examples=("unstract platform tool registry list",)), + _ep("settings-schema", "GET", "/tool_settings_schema/", + "Show the settings JSON schema for a registry tool.", + (Param("function_name", required=True, + help="Registry id from `tool registry list`"),), + subgroup="tool registry", permission=Permission.READ, + doc_source="backend/tool_instance_v2/urls.py", + description="Reveals which adapter settings the tool requires at deploy time. " + "Note: an exported tool may list `challenge_llm` as required even " + "when the project has enable_challenge=false; a tool instance whose " + "metadata.challenge_llm is empty then fails deployment validation " + "(CAPTURE2 BUG 4). Set it with `workflow tool set-metadata`."), + _ep("list", "GET", "/tool_instance/", "List tools attached to workflows.", + (Param("workflow", type=ParamType.UUID, help="Filter to one workflow"),), + subgroup="workflow tool", permission=Permission.READ, + doc_source="backend/tool_instance_v2/urls.py", + table_columns=("id", "tool_id", "step", "workflow")), + _ep("add", "POST", "/tool_instance/", "Attach a registry tool to a workflow.", + (Param("workflow_id", type=ParamType.UUID, location=ParamLocation.BODY, + required=True, flag="--workflow", help="Workflow to attach the tool to"), + Param("tool_id", location=ParamLocation.BODY, required=True, flag="--tool", + help="Registry id (function_name) from `tool registry list`")), + subgroup="workflow tool", body=BodyKind.JSON, permission=Permission.READ_WRITE, + doc_source="backend/tool_instance_v2/urls.py", + description="A workflow holds at most one tool. Seeds adapter settings from the " + "org DEFAULT TRIAD -- set that first with `adapter default-triad set`, " + "or creation 500s while still persisting a half-configured row " + "(CAPTURE2 GAP 3). Attaching activates the workflow.", + examples=("unstract platform workflow tool add --workflow --tool ",)), + _ep("get", "GET", "/tool_instance/{id}/", "Show one attached tool, including its metadata.", + (Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Tool instance id from `workflow tool list`"),), + subgroup="workflow tool", permission=Permission.READ, + doc_source="backend/tool_instance_v2/urls.py", + description="Read this to see the tool instance's current `metadata` before " + "patching it. Required for the read-modify-write below."), + _ep("set-metadata", "PATCH", "/tool_instance/{id}/", + "Replace an attached tool's metadata (e.g. to set challenge_llm).", + (Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Tool instance id from `workflow tool list`"), + Param("metadata", type=ParamType.JSON, location=ParamLocation.BODY, required=True, + help="COMPLETE metadata object (accepts @file.json). This REPLACES the " + "stored metadata wholesale -- it is not merged")), + subgroup="workflow tool", body=BodyKind.JSON, permission=Permission.READ_WRITE, + doc_source="backend/tool_instance_v2/urls.py", + description="REPLACES metadata wholesale (the backend does not merge), so you " + "MUST send the full object. Sending only one key wipes " + "prompt_registry_id and orphans the tool. Use this to fix the " + "deploy-time challenge_llm requirement (CAPTURE2 BUG 4): read the " + "instance's current metadata, add a valid LLM adapter id (from " + "`settings-schema`'s enum), and pass the whole object back:\n\n" + " # 1. read the current metadata object (the `metadata` field)\n" + " unstract ... workflow tool get --id \n" + " # 2. save that object to m.json, set metadata.challenge_llm,\n" + " # then send the COMPLETE object back:\n" + " unstract ... workflow tool set-metadata --id --metadata @m.json", + examples=("unstract platform workflow tool set-metadata --id --metadata @metadata.json",)), + _ep("remove", "DELETE", "/tool_instance/{id}/", "Detach a tool from a workflow.", + (Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Tool instance id from `workflow tool list`"),), + subgroup="workflow tool", permission=Permission.FULL_ACCESS, + doc_source="backend/tool_instance_v2/urls.py", + description=f"{_DELETE_NOTE} A read_write key cannot DELETE a tool instance " + "even though it can create one (CAPTURE2 GAP 5)."), + _ep("list", "GET", "/workflow/endpoint/", "List a workflow's source/destination endpoints.", + (Param("workflow", type=ParamType.UUID, help="Filter to one workflow"), + Param("endpoint_type", choices=["SOURCE", "DESTINATION"], help="Filter by side"), + Param("connection_type", + choices=["FILESYSTEM", "DATABASE", "API", "MANUALREVIEW"], + help="Filter by connection type")), + subgroup="workflow endpoint", permission=Permission.READ, + doc_source="backend/workflow_manager/endpoint_v2/urls.py", + table_columns=("id", "endpoint_type", "connection_type"), + description="Both endpoints are auto-created by `workflow create` with a null " + "connection_type; set them to API before creating a deployment."), + _ep("set", "PATCH", "/workflow/endpoint/{id}/", "Configure a workflow endpoint.", + (Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Endpoint id from `workflow endpoint list`"), + Param("connection_type", location=ParamLocation.BODY, required=True, + choices=["API", "FILESYSTEM", "DATABASE", "MANUALREVIEW"], + help="Connection type for this endpoint")), + subgroup="workflow endpoint", body=BodyKind.JSON, permission=Permission.READ_WRITE, + doc_source="backend/workflow_manager/endpoint_v2/urls.py", + description="For an API deployment, set BOTH endpoints to connection_type=API. " + "API and MANUALREVIEW need no connector/credentials.", + examples=("unstract platform workflow endpoint set --id --connection-type API",)), +) + + +# --------------------------------------------------------------------------- # +# API Deployments (management) +# --------------------------------------------------------------------------- # + +_DEP_ID = Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Deployment identifier") + +_DEP_FIELDS: tuple[Param, ...] = ( + Param("display_name", location=ParamLocation.BODY, help="Display name (max 30 chars)"), + Param("description", location=ParamLocation.BODY, help="Description (max 255 chars)"), + Param("workflow", type=ParamType.UUID, location=ParamLocation.BODY, required=True, + help="Workflow to deploy; must have source and destination configured"), + Param("api_name", location=ParamLocation.BODY, + help="URL name, matching ^[a-zA-Z0-9_-]+$ (max 30 chars, unique per org)"), + Param("is_active", type=ParamType.BOOL, location=ParamLocation.BODY, default=True, + help="Whether the deployment accepts requests"), + Param("shared_to_org", type=ParamType.BOOL, location=ParamLocation.BODY, default=False, + help="Share with the whole organization"), + Param("shared_users", type=ParamType.INT, location=ParamLocation.BODY, multiple=True, + replace_semantics=True, help="User IDs to share with"), +) + +_dep_update = _ep("update", "PUT", "/api/deployment/{id}/", "Replace an API deployment.", + (_DEP_ID, *_DEP_FIELDS), subgroup="api-deployment", body=BodyKind.JSON, + doc="v1-api-deployments.mdx", permission=Permission.READ_WRITE) + +_API_DEPLOYMENTS: tuple[Endpoint, ...] = ( + _ep("list", "GET", "/api/deployment/", "List API deployments.", + (Param("workflow", type=ParamType.UUID, help="Filter by workflow"), + Param("search", help="Case-insensitive search on display name"), *_PAGE), + subgroup="api-deployment", doc="v1-api-deployments.mdx", permission=Permission.READ, + table_columns=("id", "api_name", "display_name", "is_active", "run_count")), + _ep("create", "POST", "/api/deployment/", "Create an API deployment.", _DEP_FIELDS, + subgroup="api-deployment", body=BodyKind.JSON, doc="v1-api-deployments.mdx", + permission=Permission.READ_WRITE, + description=( + "Returns an api_key in the response -- store it, it is how the deployment " + "is called. Only one active deployment is allowed per workflow." + )), + _ep("get", "GET", "/api/deployment/{id}/", "Show one API deployment.", (_DEP_ID,), + subgroup="api-deployment", doc="v1-api-deployments.mdx", permission=Permission.READ), + _dep_update, + derive_patch(_dep_update, summary="Partially update an API deployment."), + _ep("delete", "DELETE", "/api/deployment/{id}/", "Delete an API deployment.", (_DEP_ID,), + subgroup="api-deployment", doc="v1-api-deployments.mdx", + permission=Permission.FULL_ACCESS, description=f"{_DELETE_NOTE} Owner only."), + _ep("users", "GET", "/api/deployment/{id}/users/", + "List users a deployment is shared with.", (_DEP_ID,), subgroup="api-deployment", + doc="v1-api-deployments.mdx", permission=Permission.READ), + _ep("by-prompt-studio-tool", "GET", "/api/deployment/by-prompt-studio-tool/", + "Find deployments backed by a Prompt Studio tool.", + (Param("tool_id", type=ParamType.UUID, required=True, help="Prompt Studio tool id"),), + subgroup="api-deployment", doc="v1-api-deployments.mdx", permission=Permission.READ), + _ep("postman-collection", "GET", "/api/postman_collection/{id}/", + "Download a Postman collection for a deployment.", + (_DEP_ID, Param("save", client_side=True, help="Write the collection to this path")), + subgroup="api-deployment", doc="v1-api-deployments.mdx", permission=Permission.READ, + description="Returns 409 if the deployment has no active API key."), + _ep("list", "GET", "/api/keys/api/{api_id}/", "List API keys for a deployment.", + (Param("api_id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Deployment identifier"),), + subgroup="api-deployment key", doc="v1-api-deployments.mdx", permission=Permission.READ), + _ep("create", "POST", "/api/keys/api/{api_id}/", "Create an API key for a deployment.", + # `api_id` is the URL path param and `api` the body param, and the server + # needs BOTH -- the same value, spelled twice (GOTCHAS #6). `mirror_as` + # copies the path value into the body as `api`, so `--api-id` alone is + # enough; there is nothing for the caller to repeat. + (Param("api_id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + mirror_as="api", help="Deployment identifier. Also sent as the body's " + "`api` field, so it need not be repeated"), + Param("description", location=ParamLocation.BODY, help="Description (max 255 chars)"), + Param("is_active", type=ParamType.BOOL, location=ParamLocation.BODY, default=True, + help="Whether the key is usable")), + subgroup="api-deployment key", body=BodyKind.JSON, doc="v1-api-deployments.mdx", + permission=Permission.READ_WRITE, + description="Creating a key for a *pipeline* is a different route -- use " + "`pipeline key create`, which takes --pipeline-id.", + doc_conflict="The docs list `api` and `pipeline` as body params. `api` is " + "mirrored from the --api-id path param (it is always the same " + "value, and the docs' own example repeats it), and `pipeline` is " + "dropped: this route is /api/keys/api/{api_id}/, so a pipeline key " + "belongs on `pipeline key create`. Do not restore either flag.", + examples=("unstract platform api-deployment key create --api-id ",)), + _ep("get", "GET", "/api/keys/{id}/", "Show one API key.", + (Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Key identifier"),), + subgroup="api-deployment key", doc="v1-api-deployments.mdx", permission=Permission.READ), + _ep("update", "PUT", "/api/keys/{id}/", "Update an API key.", + (Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Key identifier"), + Param("is_active", type=ParamType.BOOL, location=ParamLocation.BODY, + help="Whether the key is usable"), + Param("description", location=ParamLocation.BODY, help="Description (max 255 chars)")), + subgroup="api-deployment key", body=BodyKind.JSON, doc="v1-api-deployments.mdx", + permission=Permission.READ_WRITE), + _ep("delete", "DELETE", "/api/keys/{id}/", "Delete an API key.", + (Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Key identifier"),), + subgroup="api-deployment key", doc="v1-api-deployments.mdx", + permission=Permission.FULL_ACCESS, description=_DELETE_NOTE), +) + + +# --------------------------------------------------------------------------- # +# Pipelines +# --------------------------------------------------------------------------- # + +_PIPE_ID = Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Pipeline identifier") + +_PIPE_FIELDS: tuple[Param, ...] = ( + Param("pipeline_name", location=ParamLocation.BODY, required=True, + help="Pipeline name, unique per organization (max 32 chars)"), + Param("workflow", type=ParamType.UUID, location=ParamLocation.BODY, required=True, + help="Workflow to run"), + Param("pipeline_type", location=ParamLocation.BODY, default="DEFAULT", + choices=["ETL", "TASK", "DEFAULT", "APP"], help="Pipeline type"), + Param("cron_string", location=ParamLocation.BODY, + help="UNIX cron schedule; the platform enforces a minimum interval"), + Param("shared_users", type=ParamType.INT, location=ParamLocation.BODY, multiple=True, + replace_semantics=True, help="User IDs to share with"), + Param("shared_to_org", type=ParamType.BOOL, location=ParamLocation.BODY, default=False, + help="Share with the whole organization"), +) + +_pipe_update = _ep("update", "PUT", "/pipeline/{id}/", "Replace a pipeline.", + (_PIPE_ID, *_PIPE_FIELDS), subgroup="pipeline", body=BodyKind.JSON, + doc="v1-etl-pipelines.mdx", permission=Permission.READ_WRITE) + +#: PATCH additionally accepts `active`, which PUT does not. +_pipe_patch = with_params( + derive_patch(_pipe_update, summary="Partially update a pipeline."), + Param("active", type=ParamType.BOOL, location=ParamLocation.BODY, + help="Activate or deactivate the pipeline"), +) + +_PIPELINES: tuple[Endpoint, ...] = ( + _ep("list", "GET", "/pipeline/", "List pipelines.", + (Param("type", choices=["ETL", "TASK", "DEFAULT", "APP"], help="Filter by type"), + Param("workflow", type=ParamType.UUID, help="Filter by workflow"), + Param("search", help="Search on pipeline name"), + Param("ordering", + choices=["created_at", "last_run_time", "pipeline_name", "run_count"], + help="Sort field; prefix with '-' for descending"), + *_PAGE), + subgroup="pipeline", doc="v1-etl-pipelines.mdx", permission=Permission.READ, + table_columns=("id", "pipeline_name", "pipeline_type", "active", "last_run_status")), + _ep("create", "POST", "/pipeline/", "Create a pipeline.", _PIPE_FIELDS, + subgroup="pipeline", body=BodyKind.JSON, doc="v1-etl-pipelines.mdx", + permission=Permission.READ_WRITE), + _ep("get", "GET", "/pipeline/{id}/", "Show one pipeline.", (_PIPE_ID,), + subgroup="pipeline", doc="v1-etl-pipelines.mdx", permission=Permission.READ), + _pipe_update, + _pipe_patch, + _ep("delete", "DELETE", "/pipeline/{id}/", "Delete a pipeline and its scheduler job.", + (_PIPE_ID,), subgroup="pipeline", doc="v1-etl-pipelines.mdx", + permission=Permission.FULL_ACCESS, description=_DELETE_NOTE), + _ep("execute", "POST", "/pipeline/execute/", "Execute a pipeline.", + (Param("pipeline_id", type=ParamType.UUID, location=ParamLocation.BODY, + required=True, help="Pipeline to execute"), + Param("execution_id", type=ParamType.UUID, location=ParamLocation.BODY, + help="Execution identifier; generated if omitted")), + subgroup="pipeline", body=BodyKind.JSON, doc="v1-etl-pipelines.mdx", + permission=Permission.READ_WRITE), + _ep("executions", "GET", "/pipeline/{id}/executions/", "List executions of a pipeline.", + (_PIPE_ID, + Param("start_date", help="ISO 8601 start of range"), + Param("end_date", help="ISO 8601 end of range"), *_PAGE), + subgroup="pipeline", doc="v1-etl-pipelines.mdx", permission=Permission.READ), + _ep("users", "GET", "/pipeline/{id}/users/", "List users a pipeline is shared with.", + (_PIPE_ID,), subgroup="pipeline", doc="v1-etl-pipelines.mdx", + permission=Permission.READ), + # Distinct from the deployment collection path; both exist, and they differ. + _ep("postman-collection", "GET", "/pipeline/api/postman_collection/{id}/", + "Download a Postman collection for a pipeline.", + (_PIPE_ID, Param("save", client_side=True, help="Write the collection to this path")), + subgroup="pipeline", doc="v1-etl-pipelines.mdx", permission=Permission.READ, + description="Returns 400 if the pipeline has no active API key."), + _ep("list", "GET", "/api/keys/pipeline/{pipeline_id}/", "List API keys for a pipeline.", + (Param("pipeline_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Pipeline identifier"),), + subgroup="pipeline key", doc="v1-etl-pipelines.mdx", permission=Permission.READ), + _ep("create", "POST", "/api/keys/pipeline/{pipeline_id}/", + "Create an API key for a pipeline.", + # Same path/body duplication as `api-deployment key create` (GOTCHAS #6): + # the URL takes `pipeline_id`, the body wants the same value as `pipeline`. + (Param("pipeline_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, mirror_as="pipeline", + help="Pipeline identifier. Also sent as the body's `pipeline` field, " + "so it need not be repeated"), + Param("description", location=ParamLocation.BODY, help="Description (max 255 chars)"), + Param("is_active", type=ParamType.BOOL, location=ParamLocation.BODY, default=True, + help="Whether the key is usable")), + subgroup="pipeline key", body=BodyKind.JSON, doc="v1-etl-pipelines.mdx", + permission=Permission.READ_WRITE, + description="Creating a key for an *API deployment* is a different route -- use " + "`api-deployment key create`, which takes --api-id.", + doc_conflict="Mirror of the `api-deployment key create` divergence: `pipeline` " + "is mirrored from --pipeline-id, and the `api` body param is " + "dropped because this route is /api/keys/pipeline/{pipeline_id}/. " + "Do not restore either flag.", + examples=("unstract platform pipeline key create --pipeline-id ",)), +) + + +# --------------------------------------------------------------------------- # +# Adapters +# --------------------------------------------------------------------------- # + +_ADAPTER_TYPES = ["LLM", "EMBEDDING", "VECTOR_DB", "X2TEXT", "OCR"] +_AD_ID = Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Adapter instance identifier") + +_AD_FIELDS: tuple[Param, ...] = ( + Param("adapter_name", location=ParamLocation.BODY, required=True, + help="Instance name, unique per name+type+org (max 128 chars)"), + Param("adapter_id", location=ParamLocation.BODY, required=True, + help="SDK adapter identifier, e.g. openai_llm"), + Param("adapter_type", location=ParamLocation.BODY, required=True, + choices=_ADAPTER_TYPES, help="Adapter category"), + Param("adapter_metadata", type=ParamType.JSON, location=ParamLocation.BODY, + required=True, help="Provider configuration; encrypted at rest"), + Param("description", location=ParamLocation.BODY, help="Description"), + Param("shared_to_org", type=ParamType.BOOL, location=ParamLocation.BODY, default=False, + help="Share with the whole organization"), +) + +_ad_update = _ep("update", "PUT", "/adapter/{id}/", "Replace an adapter instance.", + (_AD_ID, *_AD_FIELDS), subgroup="adapter", body=BodyKind.JSON, + doc="v1-adapters.mdx", permission=Permission.READ_WRITE) + +#: Only PATCH documents `shared_users` for adapters; POST/PUT do not. +_ad_patch = with_params( + derive_patch(_ad_update, summary="Partially update an adapter instance."), + Param("shared_users", type=ParamType.INT, location=ParamLocation.BODY, + multiple=True, replace_semantics=True, help="User IDs to share with"), +) + +_ADAPTERS: tuple[Endpoint, ...] = ( + _ep("supported", "GET", "/supported_adapters/", "List adapters supported by the platform.", + (Param("adapter_type", required=True, choices=_ADAPTER_TYPES, + help="Adapter category to list"),), + subgroup="adapter", doc="v1-adapters.mdx", permission=Permission.READ, + examples=("unstract platform adapter supported --adapter-type LLM",)), + _ep("schema", "GET", "/adapter_schema/", "Show the configuration schema for an adapter.", + (Param("id", required=True, help="SDK adapter identifier, e.g. openai_llm"),), + subgroup="adapter", doc="v1-adapters.mdx", permission=Permission.READ), + _ep("test", "POST", "/test_adapters/", "Test adapter credentials.", + (Param("adapter_id", location=ParamLocation.BODY, required=True, + help="SDK adapter identifier"), + Param("adapter_metadata", type=ParamType.JSON, location=ParamLocation.BODY, + required=True, help="Provider configuration to test"), + Param("adapter_type", location=ParamLocation.BODY, required=True, + choices=_ADAPTER_TYPES, help="Adapter category")), + subgroup="adapter", body=BodyKind.JSON, doc="v1-adapters.mdx", + permission=Permission.READ_WRITE), + _ep("list", "GET", "/adapter/", "List configured adapter instances.", + (Param("adapter_type", choices=_ADAPTER_TYPES, help="Filter by category"),), + subgroup="adapter", doc="v1-adapters.mdx", permission=Permission.READ, + description="`is_available` reflects the adapter CLASS being installed, NOT " + "whether your API key may use it. An adapter owned by another user " + "and not shared will still 403 at extraction. Check created_by_email " + "and share adapters to the org (or set a default triad) before use.", + table_columns=("id", "adapter_name", "adapter_type", "model", + "is_available", "created_by_email")), + _ep("create", "POST", "/adapter/", "Create an adapter instance.", _AD_FIELDS, + subgroup="adapter", body=BodyKind.JSON, doc="v1-adapters.mdx", + permission=Permission.READ_WRITE), + _ep("get", "GET", "/adapter/{id}/", "Show one adapter instance.", (_AD_ID,), + subgroup="adapter", doc="v1-adapters.mdx", permission=Permission.READ, + description="Returns decrypted adapter_metadata, including credentials."), + _ad_update, + _ad_patch, + _ep("delete", "DELETE", "/adapter/{id}/", "Delete an adapter instance.", (_AD_ID,), + subgroup="adapter", doc="v1-adapters.mdx", permission=Permission.FULL_ACCESS, + description=( + f"{_DELETE_NOTE} Returns 409 if the adapter is used by a workflow or " + "Prompt Studio project, and 500 if it is configured as a default." + )), + _ep("info", "GET", "/adapter/info/{id}/", "Show adapter summary including context window.", + (_AD_ID,), subgroup="adapter", doc="v1-adapters.mdx", permission=Permission.READ), + _ep("users", "GET", "/adapter/users/{id}/", "List users an adapter is shared with.", + (_AD_ID,), subgroup="adapter", doc="v1-adapters.mdx", permission=Permission.READ), + _ep("get", "GET", "/adapter/default_triad/", "Show the organization's default adapters.", + subgroup="adapter default-triad", doc="v1-adapters.mdx", permission=Permission.READ, + description="Returns an empty object `{}` when no default triad has been set " + "for the organization -- that is 'unset', not an error (GOTCHAS " + "#10). Set one with `adapter default-triad set`; `workflow tool " + "add` seeds a tool instance's adapters from it, so configuring it " + "first avoids a half-configured tool instance.", + examples=("unstract platform adapter default-triad get",)), + # The request keys here differ from the response keys of the GET above + # (llm_default vs default_llm_adapter). That asymmetry is upstream. + _ep("set", "POST", "/adapter/default_triad/", "Set the organization's default adapters.", + (Param("llm_default", type=ParamType.UUID, location=ParamLocation.BODY, + help="Default LLM adapter"), + Param("embedding_default", type=ParamType.UUID, location=ParamLocation.BODY, + help="Default embedding adapter"), + Param("vector_db_default", type=ParamType.UUID, location=ParamLocation.BODY, + help="Default vector DB adapter"), + Param("x2text_default", type=ParamType.UUID, location=ParamLocation.BODY, + help="Default text extractor adapter")), + subgroup="adapter default-triad", body=BodyKind.JSON, doc="v1-adapters.mdx", + permission=Permission.READ_WRITE), +) + + +# --------------------------------------------------------------------------- # +# Connectors +# --------------------------------------------------------------------------- # + +_CN_ID = Param("id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Connector instance identifier") + +_CN_FIELDS: tuple[Param, ...] = ( + Param("connector_name", location=ParamLocation.BODY, required=True, + help="Instance name, unique per organization (max 128 chars)"), + Param("connector_id", location=ParamLocation.BODY, required=True, + help="Connector type identifier"), + Param("connector_metadata", type=ParamType.JSON, location=ParamLocation.BODY, + help="Connection configuration; not needed when using the OAuth flow"), + Param("connector_version", location=ParamLocation.BODY, help="Connector version"), + Param("shared_to_org", type=ParamType.BOOL, location=ParamLocation.BODY, default=False, + help="Share with the whole organization"), + Param("shared_users", type=ParamType.INT, location=ParamLocation.BODY, multiple=True, + replace_semantics=True, help="User IDs to share with"), +) + +_cn_update = _ep("update", "PUT", "/connector/{id}/", "Replace a connector instance.", + (_CN_ID, Param("oauth-key", help="Cache key from the OAuth flow"), + *_CN_FIELDS), + subgroup="connector", body=BodyKind.JSON, doc="v1-connectors.mdx", + permission=Permission.READ_WRITE) + +_CONNECTORS: tuple[Endpoint, ...] = ( + _ep("supported", "GET", "/supported_connectors/", + "List connector types supported by the platform.", + (Param("type", choices=["INPUT", "OUTPUT"], help="Filter by direction"), + Param("connector_mode", choices=["FILE_SYSTEM", "DATABASE"], help="Filter by mode")), + subgroup="connector", doc="v1-connectors.mdx", permission=Permission.READ), + _ep("schema", "GET", "/connector_schema/", + "Show the configuration schema for a connector type.", + (Param("id", required=True, help="Connector type identifier"),), + subgroup="connector", doc="v1-connectors.mdx", permission=Permission.READ), + _ep("test", "POST", "/test_connectors/", "Test connector credentials.", + (Param("connector_id", location=ParamLocation.BODY, required=True, + help="Connector type identifier"), + Param("connector_metadata", type=ParamType.JSON, location=ParamLocation.BODY, + required=True, help="Connection configuration to test")), + subgroup="connector", body=BodyKind.JSON, doc="v1-connectors.mdx", + permission=Permission.READ_WRITE), + _ep("list", "GET", "/connector/", "List connector instances.", + (Param("workflow", type=ParamType.UUID, help="Filter by workflow"), + Param("created_by", help="Filter by creator user id"), + Param("connector_type", choices=["INPUT", "OUTPUT"], help="Filter by direction"), + Param("connector_mode", choices=["FILE_SYSTEM", "DATABASE"], help="Filter by mode")), + subgroup="connector", doc="v1-connectors.mdx", permission=Permission.READ, + table_columns=("id", "connector_name", "connector_id", "connector_mode")), + _ep("create", "POST", "/connector/", "Create a connector instance.", + (Param("oauth-key", help="Cache key from the OAuth flow, for OAuth connectors"), + *_CN_FIELDS), + subgroup="connector", body=BodyKind.JSON, doc="v1-connectors.mdx", + permission=Permission.READ_WRITE), + _ep("get", "GET", "/connector/{id}/", "Show one connector instance.", (_CN_ID,), + subgroup="connector", doc="v1-connectors.mdx", permission=Permission.READ), + _cn_update, + derive_patch(_cn_update, summary="Partially update a connector instance."), + _ep("delete", "DELETE", "/connector/{id}/", "Delete a connector instance.", (_CN_ID,), + subgroup="connector", doc="v1-connectors.mdx", permission=Permission.FULL_ACCESS, + description=f"{_DELETE_NOTE} Returns 409 if used by a workflow."), +) + +#: Not org-scoped, unlike everything else in this module. +_OAUTH_CACHE_KEY = Endpoint( + name="oauth-cache-key", + group="docstudio", + subgroup="platform connector", + method="GET", + path="/api/v1/oauth/cache-key/{backend}", + api=ApiGroup.PLATFORM, + summary="Generate a cache key to associate an OAuth flow with a connector.", + params=( + Param("backend", location=ParamLocation.PATH, required=True, + help="OAuth backend identifier, e.g. google-oauth2"), + ), + doc_source=f"{_DOCS}/v1-connectors.mdx", + permission=Permission.READ, + description="This endpoint is not organization-scoped.", + no_trailing_slash=True, +) + + +# --------------------------------------------------------------------------- # +# Groups, users, sharing +# --------------------------------------------------------------------------- # + +#: Group identifiers are plain integers, unlike the UUIDs used elsewhere (P10). +_GROUP_ID = Param("id", type=ParamType.INT, location=ParamLocation.PATH, required=True, + help="Group identifier (an integer, not a UUID)") + +_GROUPS: tuple[Endpoint, ...] = ( + _ep("list", "GET", "/groups/", "List user groups.", subgroup="group", + doc="v1-user-groups.mdx", permission=Permission.READ, + table_columns=("id", "name", "description", "member_count")), + _ep("create", "POST", "/groups/", "Create a user group.", + (Param("name", location=ParamLocation.BODY, required=True, + help="Group name, unique per organization"), + Param("description", location=ParamLocation.BODY, help="Group description")), + subgroup="group", body=BodyKind.JSON, doc="v1-user-groups.mdx", + permission=Permission.READ_WRITE, + description=( + "The response echoes only name and description. Re-list groups to " + "obtain the new id." + )), + _ep("patch", "PATCH", "/groups/{id}/", "Update a group's name or description.", + (_GROUP_ID, + Param("name", location=ParamLocation.BODY, help="New name, unique per organization"), + Param("description", location=ParamLocation.BODY, help="New description")), + subgroup="group", body=BodyKind.JSON, doc="v1-user-groups.mdx", + permission=Permission.READ_WRITE), + _ep("delete", "DELETE", "/groups/{id}/", "Delete a group.", (_GROUP_ID,), + subgroup="group", doc="v1-user-groups.mdx", permission=Permission.FULL_ACCESS, + description=( + f"{_DELETE_NOTE} Removes every resource share referencing the group; " + "member accounts themselves are not deleted." + )), + _ep("list", "GET", "/groups/{id}/members/", "List members of a group.", (_GROUP_ID,), + subgroup="group member", doc="v1-user-groups.mdx", permission=Permission.READ), + _ep("add", "POST", "/groups/{id}/members/", "Add members to a group.", + (_GROUP_ID, + Param("user_ids", type=ParamType.INT, location=ParamLocation.BODY, multiple=True, + required=True, help="User IDs to add; existing members are ignored")), + subgroup="group member", body=BodyKind.JSON, doc="v1-user-groups.mdx", + permission=Permission.READ_WRITE), + # No trailing slash on this path, unlike its siblings. + _ep("remove", "DELETE", "/groups/{id}/members/{user_id}", "Remove a member from a group.", + (_GROUP_ID, Param("user_id", type=ParamType.INT, location=ParamLocation.PATH, + required=True, help="User to remove")), + subgroup="group member", doc="v1-user-groups.mdx", permission=Permission.FULL_ACCESS, + description=_DELETE_NOTE, no_trailing_slash=True), + _ep("resources", "GET", "/groups/{id}/resources/", + "List resources shared with a group.", (_GROUP_ID,), subgroup="group", + doc="v1-user-groups.mdx", permission=Permission.READ), + _ep("list", "GET", "/users/", "List organization members.", subgroup="user", + doc="v1-organization-users.mdx", permission=Permission.READ, + table_columns=("id", "email", "role", "is_admin"), + description=( + "Member ids are returned as strings but must be sent as integers in " + "shared_users; the CLI converts them for you." + )), + Endpoint( + name="share", + group="docstudio", + subgroup="platform", + method="POST", + path=f"{_BASE}/{{resource}}/{{id}}/share/", + api=ApiGroup.PLATFORM, + summary="Share a resource with users, groups, or the whole organization.", + description=( + "Each axis REPLACES its existing list rather than appending. To add " + "one user without dropping the others, read the current shares first " + "and send the combined list." + ), + params=( + _ORG, + Param("resource", location=ParamLocation.PATH, required=True, + choices=SHARE_RESOURCES, + help="Resource type; mapped to the correct URL segment"), + Param("id", location=ParamLocation.PATH, required=True, + help="Resource identifier"), + Param("shared_users", type=ParamType.INT, location=ParamLocation.BODY, + multiple=True, replace_semantics=True, help="User IDs to share with"), + Param("shared_groups", type=ParamType.INT, location=ParamLocation.BODY, + multiple=True, replace_semantics=True, help="Group IDs to share with"), + Param("shared_to_org", type=ParamType.BOOL, location=ParamLocation.BODY, + help="Share with the whole organization"), + ), + body=BodyKind.JSON, + doc_source=f"{_DOCS}/v1-user-groups.mdx", + permission=Permission.READ_WRITE, + examples=( + "unstract platform share --resource api-deployment --id --shared-users 2 --shared-users 5", + ), + ), +) + + +ENDPOINTS: tuple[Endpoint, ...] = ( + *_PROMPT_STUDIO, + *_WORKFLOWS, + *_WORKFLOW_ASSEMBLY, + *_API_DEPLOYMENTS, + *_PIPELINES, + *_ADAPTERS, + *_CONNECTORS, + _OAUTH_CACHE_KEY, + *_GROUPS, +) diff --git a/src/unstract_cli/endpoints/whisper.py b/src/unstract_cli/endpoints/whisper.py new file mode 100644 index 0000000..c030d32 --- /dev/null +++ b/src/unstract_cli/endpoints/whisper.py @@ -0,0 +1,304 @@ +"""LLMWhisperer v2 endpoint records (SPEC.md §6.1). + +Authored from `llmwhisperer-docs/docs/llm_whisperer/apis/`. Base URL is +region-specific (US / EU) and configurable for on-prem; auth is the +`unstract-key` header. +""" + +from __future__ import annotations + +from unstract_cli.core.model import ( + ApiGroup, + BodyKind, + Endpoint, + MutuallyExclusive, + Param, + ParamLocation, + ParamType, + PollSpec, +) + +_DOCS = "llmwhisperer-docs/docs/llm_whisperer/apis" + +#: Parameters shared by the extraction endpoint. Kept as a module constant so the +#: `--wait` flow and the raw command describe exactly the same surface. +_EXTRACT_PARAMS: tuple[Param, ...] = ( + Param( + "file", + type=ParamType.FILE, + location=ParamLocation.BODY, + client_side=True, + help="Path to the document to convert", + ), + Param( + "url", + location=ParamLocation.BODY, + client_side=True, + help="Publicly accessible URL to fetch the document from; sets url_in_post", + ), + # Set automatically when --url is used, but exposed so every documented + # parameter is reachable as a flag (SPEC.md §1.1.3). + Param( + "url_in_post", + type=ParamType.BOOL, + help="Send the source URL in the request body; implied by --url", + ), + Param( + "mode", + default="form", + choices=["native_text", "low_cost", "high_quality", "form", "table"], + help=( + "Processing mode. native_text for digital PDFs, low_cost for clean " + "scans, high_quality for handwriting/noisy scans, form for forms and " + "checkboxes, table for dense tabular documents" + ), + ), + Param( + "output_mode", + default="layout_preserving", + choices=["layout_preserving", "text"], + help="layout_preserving keeps document structure (best for LLMs); text is plain", + ), + # The API's own spelling is `page_seperator`; preserved verbatim on the wire. + Param("page_seperator", default="<<<", flag="--page-separator", + help="String used to separate pages"), + Param("pages_to_extract", help="Pages to extract, e.g. '1-5,7,21-'"), + Param("median_filter_size", type=ParamType.INT, applies_when="mode=low_cost", + help="Median filter size for denoising"), + Param("gaussian_blur_radius", type=ParamType.FLOAT, applies_when="mode=low_cost", + help="Gaussian blur radius for denoising"), + Param("line_splitter_tolerance", type=ParamType.FLOAT, default=0.4, + help="Fraction of average character height before text moves to the next line"), + Param("line_splitter_strategy", default="left-priority", + help="Advanced line-splitting strategy"), + Param("horizontal_stretch_factor", type=ParamType.FLOAT, default=1.0, + help="Horizontal stretch; raise slightly when multi-column layouts merge"), + Param("mark_vertical_lines", type=ParamType.BOOL, default=False, + applies_when="mode is not native_text", + help="Reproduce vertical lines in the output"), + Param("mark_horizontal_lines", type=ParamType.BOOL, default=False, + applies_when="mode is not native_text and mark_vertical_lines=true", + help="Reproduce horizontal lines in the output"), + Param("lang", default="eng", help="Language hint for OCR (currently auto-detected)"), + Param("tag", default="default", help="Audit tag, cross-referenced in usage reports"), + Param("file_name", help="Audit file name, cross-referenced in usage reports"), + Param("use_webhook", help="Name of a registered webhook to call on completion"), + Param("webhook_metadata", help="Metadata forwarded verbatim to the webhook"), + Param("add_line_nos", type=ParamType.BOOL, default=False, + help="Add line numbers and store line metadata for the highlights API"), + Param("allow_rotated_text", type=ParamType.BOOL, default=True, + applies_when="mode in form/high_quality/table", + help="Keep rotated text such as watermarks; false filters it out"), + Param("word_confidence_threshold", type=ParamType.FLOAT, default=0.3, + applies_when="mode in form/high_quality/table", + help="Drop words whose OCR confidence is below this value (0-1)"), +) + + +ENDPOINTS: tuple[Endpoint, ...] = ( + Endpoint( + name="usage", + group="whisper", + method="GET", + path="/get-usage-info", + api=ApiGroup.LLMWHISPERER, + summary="Show usage metrics for your LLMWhisperer account.", + doc_source=f"{_DOCS}/usage_api.md", + examples=("unstract whisper usage",), + ), + Endpoint( + name="extract", + group="whisper", + method="POST", + path="/whisper", + api=ApiGroup.LLMWHISPERER, + summary="Convert a document to LLM-ready text.", + description=( + "Accepts PDFs, scanned documents, images, Office documents and " + "spreadsheets. Returns a whisper_hash immediately; use --wait to poll " + "and retrieve the text in one step." + ), + params=_EXTRACT_PARAMS, + body=BodyKind.BINARY_FILE, + constraints=(MutuallyExclusive(("file", "url")),), + poll=PollSpec( + status_endpoint="whisper.status", + status_field="status", + terminal_success=("processed",), + terminal_failure=("error",), + handle_field="whisper_hash", + handle_param="whisper_hash", + retrieve_endpoint="whisper.retrieve", + one_shot=True, + ), + doc_source=f"{_DOCS}/whisper.md", + raw_field="result_text", + examples=( + "unstract whisper extract --file invoice.pdf --mode form --wait", + "unstract whisper extract --url https://example.com/doc.pdf --mode high_quality", + ), + ), + Endpoint( + name="status", + group="whisper", + method="GET", + path="/whisper-status", + api=ApiGroup.LLMWHISPERER, + summary="Check the status of a conversion.", + description="Statuses: accepted, processing, processed, error, retrieved.", + params=( + Param("whisper_hash", required=True, help="Hash returned by `whisper extract`"), + ), + doc_source=f"{_DOCS}/whisper_status.md", + examples=("unstract whisper status --whisper-hash abc123",), + ), + Endpoint( + name="retrieve", + group="whisper", + method="GET", + path="/whisper-retrieve", + api=ApiGroup.LLMWHISPERER, + summary="Retrieve the extracted text of a completed conversion.", + description=( + "ONE-SHOT: text can be retrieved only once, for privacy reasons. A " + "second call fails and the text cannot be recovered -- pass --save to " + "persist it on the first read." + ), + params=( + Param("whisper_hash", required=True, help="Hash returned by `whisper extract`"), + Param("text_only", type=ParamType.BOOL, default=False, + help="Return only the text, omitting metadata"), + Param("save", client_side=True, + help="Write the result to this path before exiting (recommended)"), + ), + doc_source=f"{_DOCS}/whisper_retrieve.md", + raw_field="result_text", + examples=( + "unstract whisper retrieve --whisper-hash abc123 --save result.json", + "unstract whisper retrieve --whisper-hash abc123 --text-only --output raw", + ), + ), + Endpoint( + name="detail", + group="whisper", + method="GET", + path="/whisper-detail", + api=ApiGroup.LLMWHISPERER, + summary="Show processing details and metadata for a conversion.", + params=( + Param("whisper_hash", required=True, help="Hash returned by `whisper extract`"), + ), + doc_source=f"{_DOCS}/whisper_detail.md", + # The docs *index* lists `/whisper-details` (plural), but the endpoint page + # and the official client both use the singular. Verified against + # llm-whisperer-python-client client_v2.py:349. + doc_conflict=( + "Docs index says /whisper-details (plural); endpoint page and official " + "Python client use /whisper-detail (singular). Singular is correct -- " + "do not 'fix' this from the index page." + ), + examples=("unstract whisper detail --whisper-hash abc123",), + ), + Endpoint( + name="highlights", + group="whisper", + method="GET", + path="/highlights", + api=ApiGroup.LLMWHISPERER, + summary="Retrieve line metadata (bounding boxes) for highlighting.", + description="Requires the conversion to have been run with --add-line-nos.", + params=( + Param("whisper_hash", required=True, help="Hash returned by `whisper extract`"), + Param("lines", required=True, help="Lines to fetch, e.g. '1-5,7,21-'"), + ), + doc_source=f"{_DOCS}/highlighting_api.md", + examples=("unstract whisper highlights --whisper-hash abc123 --lines 1-20",), + ), + Endpoint( + name="usage-by-tag", + group="whisper", + method="GET", + path="/usage", + api=ApiGroup.LLMWHISPERER, + summary="Show usage metrics filtered by audit tag.", + description="Without dates, usage covers the last 30 days.", + params=( + Param("tag", required=True, help="Audit tag to report on"), + Param("from_date", type=ParamType.DATE, help="Start date, YYYY-MM-DD"), + Param("to_date", type=ParamType.DATE, help="End date, YYYY-MM-DD"), + ), + doc_source=f"{_DOCS}/usage_stat.md", + examples=("unstract whisper usage-by-tag --tag invoices --from-date 2026-01-01",), + ), + Endpoint( + name="create", + group="whisper", + subgroup="webhook", + method="POST", + path="/whisper-manage-callback", + api=ApiGroup.LLMWHISPERER, + summary="Register a webhook to be called when a conversion completes.", + description=( + "The URL is verified at registration: LLMWhisperer posts a test payload " + "and refuses to register if it does not return 200." + ), + params=( + Param("webhook_name", required=True, location=ParamLocation.BODY, + help="Name used to reference this webhook in `whisper extract --use-webhook`"), + Param("url", required=True, location=ParamLocation.BODY, + help="URL to call when conversion completes"), + Param("auth_token", location=ParamLocation.BODY, default="", + help="Bearer token for the webhook; empty if unauthenticated"), + ), + body=BodyKind.JSON, + doc_source=f"{_DOCS}/webhook_manage.md", + examples=( + "unstract whisper webhook create --webhook-name prod --url https://example.com/hook", + ), + ), + Endpoint( + name="get", + group="whisper", + subgroup="webhook", + method="GET", + path="/whisper-manage-callback", + api=ApiGroup.LLMWHISPERER, + summary="Show a registered webhook's configuration.", + params=(Param("webhook_name", required=True, help="Name of the webhook"),), + doc_source=f"{_DOCS}/webhook_manage.md", + examples=("unstract whisper webhook get --webhook-name prod",), + ), + Endpoint( + name="update", + group="whisper", + subgroup="webhook", + method="PUT", + path="/whisper-manage-callback", + api=ApiGroup.LLMWHISPERER, + summary="Update a registered webhook.", + params=( + Param("webhook_name", required=True, location=ParamLocation.BODY, + help="Name of the existing webhook"), + Param("url", required=True, location=ParamLocation.BODY, help="New URL"), + Param("auth_token", location=ParamLocation.BODY, default="", + help="New bearer token; empty if unauthenticated"), + ), + body=BodyKind.JSON, + doc_source=f"{_DOCS}/webhook_manage.md", + examples=( + "unstract whisper webhook update --webhook-name prod --url https://example.com/v2", + ), + ), + Endpoint( + name="delete", + group="whisper", + subgroup="webhook", + method="DELETE", + path="/whisper-manage-callback", + api=ApiGroup.LLMWHISPERER, + summary="Delete a registered webhook.", + params=(Param("webhook_name", required=True, help="Name of the webhook"),), + doc_source=f"{_DOCS}/webhook_manage.md", + examples=("unstract whisper webhook delete --webhook-name prod",), + ), +) diff --git a/src/unstract_cli/overlay.toml b/src/unstract_cli/overlay.toml deleted file mode 100644 index f563cac..0000000 --- a/src/unstract_cli/overlay.toml +++ /dev/null @@ -1,7 +0,0 @@ -# Per-flag overrides for spec-derived options: [..]. -# -# 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/skill/__init__.py b/src/unstract_cli/skill/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unstract_cli/skill/docdiff.py b/src/unstract_cli/skill/docdiff.py new file mode 100644 index 0000000..4c197f1 --- /dev/null +++ b/src/unstract_cli/skill/docdiff.py @@ -0,0 +1,417 @@ +"""Cross-reference endpoint records against the public API documentation. + +Supports the `update-unstract-cli` Claude Skill (SPEC.md §8): parse the docs, +parse the records, and report drift with citations. + +Two documentation formats: + +* **Markdown** (LLMWhisperer, API deployment, HITL) -- parameters in tables with + ``Parameter | Type | Default | Required | Description`` columns. +* **MDX** (Platform v1) -- parameters as ```` component props. This + is JSX rather than Markdown, so props are parsed, not table cells. + +Findings are *reported*, never auto-applied. Documentation lags implementation, +so an endpoint missing from the docs is usually undocumented rather than removed +(SPEC.md §8.5). +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from unstract_cli.core.model import Endpoint +from unstract_cli.endpoints import ALL_ENDPOINTS + +#: Front matter marking an unstable contract; such pages are excluded entirely. +DRAFT_MARKER = re.compile(r"^draft:\s*true\s*$", re.MULTILINE) + + +@dataclass +class DocParam: + """A parameter as the documentation describes it.""" + + name: str + type: str = "" + default: str = "" + required: bool = False + description: str = "" + + +@dataclass +class DocEndpoint: + """An endpoint as the documentation describes it.""" + + method: str + path: str + params: list[DocParam] = field(default_factory=list) + source: str = "" + heading: str = "" + + @property + def key(self) -> tuple[str, str]: + return (self.method.upper(), self.path) + + +@dataclass +class Finding: + """One drift observation, always carrying a citation.""" + + kind: str # missing_in_cli | missing_in_docs | param_drift + severity: str # info | warning | action + message: str + citation: str = "" + command: str = "" + suggestion: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "kind": self.kind, + "severity": self.severity, + "message": self.message, + "citation": self.citation, + "command": self.command, + "suggestion": self.suggestion, + } + + +# --------------------------------------------------------------------------- # +# Parsing +# --------------------------------------------------------------------------- # + +_TRUTHY = {"yes", "true", "required"} + + +def _clean(cell: str) -> str: + """Strip Markdown emphasis, links and code ticks from a table cell.""" + text = cell.strip() + text = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text) + text = text.replace("`", "").replace("*", "").replace("
", " ") + return text.strip() + + +#: Headings that begin a *request parameter* table. Everything after a heading +#: not in this set is response/status documentation, which must not be mistaken +#: for input parameters -- otherwise every response field looks like a missing +#: flag. +_REQUEST_HEADINGS = re.compile( + r"^#{1,4}\s+(request\s+)?(parameters|request\s+body|query\s+parameters|" + r"path\s+parameters|body|arguments|request)\b", + re.IGNORECASE, +) +_HEADING = re.compile(r"^#{1,4}\s+\S") + + +def parse_markdown_params(text: str) -> list[DocParam]: + """Extract request parameters from a Markdown parameter table. + + Only tables under a request-parameter heading are considered. API reference + pages document responses, metadata and status codes in the same table format, + and treating those as inputs produces a flood of false drift. + """ + params: list[DocParam] = [] + seen: set[str] = set() + in_request_section = False + + for line in text.splitlines(): + if _HEADING.match(line): + in_request_section = bool(_REQUEST_HEADINGS.match(line)) + continue + if not in_request_section: + continue + if not line.strip().startswith("|"): + continue + cells = [_clean(c) for c in line.strip().strip("|").split("|")] + if len(cells) < 3: + continue + + name = cells[0] + # Skip header and separator rows, and prose rows that aren't parameters. + if not name or name.lower() in {"parameter", "name", "field"}: + continue + if set(name) <= {"-", ":", " "}: + continue + if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", name): + continue + if name in seen: + continue + + seen.add(name) + required = len(cells) > 3 and any(t in cells[3].lower() for t in _TRUTHY) + params.append( + DocParam( + name=name, + type=cells[1] if len(cells) > 1 else "", + default=cells[2] if len(cells) > 2 else "", + required=required, + description=cells[4] if len(cells) > 4 else "", + ) + ) + return params + + +def parse_markdown_doc(path: Path) -> list[DocEndpoint]: + """Parse a Markdown API reference page.""" + text = path.read_text(encoding="utf-8") + if DRAFT_MARKER.search(text): + return [] # Unstable contract; excluded by policy (SPEC.md §11). + + endpoint = "" + for pattern in ( + r"\|\s*Endpoint\s*\|\s*`([^`]+)`", + r"\|\s*URL\s*\|\s*`https?://[^/]+(/[^`]+)`", + ): + if match := re.search(pattern, text): + endpoint = match.group(1).strip() + break + if not endpoint: + return [] + + methods = re.findall(r"\|\s*Method\s*\|\s*(.+?)\s*\|", text) + found = re.findall(r"\b(GET|POST|PUT|PATCH|DELETE)\b", methods[0]) if methods else ["GET"] + + params = parse_markdown_params(text) + if not endpoint.startswith("/"): + endpoint = "/" + endpoint + + return [ + DocEndpoint(method=m, path=endpoint, params=params, source=str(path)) + for m in (found or ["GET"]) + ] + + +def _prop_entries(block: str, prop: str) -> list[tuple[str, str]]: + """Return ``(name, raw_object)`` pairs from one `` prop array. + + Extracts ``prop={[ {...}, {...} ]}`` by scanning for the matching bracket, so + a nested brace inside a description cannot terminate the array early. + """ + match = re.search(rf"\b{prop}=\{{\[", block) + if not match: + return [] + + depth = 0 + start = match.end() - 1 + end = start + for index in range(start, len(block)): + char = block[index] + if char == "[": + depth += 1 + elif char == "]": + depth -= 1 + if depth == 0: + end = index + break + else: + return [] + + return [ + (m.group(1), m.group(0)) + for m in re.finditer(r'\{\s*name:\s*"([^"]+)"[^}]*\}', block[start : end + 1]) + ] + + +def parse_mdx_doc(path: Path) -> list[DocEndpoint]: + """Parse `` components from a Platform v1 MDX page.""" + text = path.read_text(encoding="utf-8") + if DRAFT_MARKER.search(text): + return [] + + endpoints: list[DocEndpoint] = [] + for block in re.findall(r"", text, re.DOTALL): + method = re.search(r'method="([^"]+)"', block) + path_match = re.search(r'path="([^"]+)"', block) + if not method or not path_match: + continue + + params: list[DocParam] = [] + seen: set[str] = set() + # Only request-side props. `responseBody` describes output: treating it + # as input makes every response field look like a missing flag, which is + # the MDX equivalent of the response-table problem in Markdown. + for prop in ("pathParams", "queryParams", "requestBody"): + for entry in _prop_entries(block, prop): + name = entry[0] + # `members[].id`-style rows document nested response shapes, and + # prose placeholders like "(any writable field)" are descriptions + # of a whole class of fields rather than a named parameter. + if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_-]*", name): + continue + if name in seen: + continue + seen.add(name) + params.append( + DocParam( + name=name, + required="required: true" in entry[1] + or "required={true}" in entry[1], + type=( + t.group(1) + if (t := re.search(r'type:\s*"([^"]+)"', entry[1])) + else "" + ), + ) + ) + + endpoints.append( + DocEndpoint( + method=method.group(1).upper(), + path=path_match.group(1), + params=params, + source=str(path), + ) + ) + return endpoints + + +def parse_docs(root: Path) -> list[DocEndpoint]: + """Parse every documentation page under a docs repository root.""" + results: list[DocEndpoint] = [] + for path in sorted(root.rglob("*.md")): + results.extend(parse_markdown_doc(path)) + for path in sorted(root.rglob("*.mdx")): + results.extend(parse_mdx_doc(path)) + return results + + +# --------------------------------------------------------------------------- # +# Diffing +# --------------------------------------------------------------------------- # + + +def _normalise(path: str) -> str: + """Compare paths by shape, ignoring placeholder spelling and prefixes. + + The same endpoint is written several ways across sources: records use + `{org_id}` while the Markdown docs use ``, and Platform v1 + paths carry an org-scoped prefix that its MDX omits. Only the shape + distinguishes one endpoint from another, so both placeholder syntaxes + collapse to `{}`. + + Note that trailing slashes are normalised **here only**, for matching. + `Endpoint.path` itself remains literal, because the server distinguishes + them (P11). + """ + path = re.sub(r"^/api/v1/unstract/\{org_id\}", "", path) + path = re.sub(r"\{[^}]+\}", "{}", path) + path = re.sub(r"<[^>]+>", "{}", path) + return path.rstrip("/") or "/" + + +def diff( + doc_endpoints: list[DocEndpoint], + cli_endpoints: tuple[Endpoint, ...] = ALL_ENDPOINTS, +) -> list[Finding]: + """Compare documentation against records, three ways (SPEC.md §8.3).""" + findings: list[Finding] = [] + + by_cli: dict[tuple[str, str], Endpoint] = { + (e.method.upper(), _normalise(e.path)): e for e in cli_endpoints + } + by_doc: dict[tuple[str, str], DocEndpoint] = { + (d.method.upper(), _normalise(d.path)): d for d in doc_endpoints + } + + for key, doc in by_doc.items(): + cli = by_cli.get(key) + if cli is None: + findings.append( + Finding( + kind="missing_in_cli", + severity="action", + message=f"{doc.method} {doc.path} is documented but has no CLI command.", + citation=doc.source, + suggestion="Add an Endpoint record to the matching endpoints/*.py module.", + ) + ) + continue + + # A record carrying `doc_conflict` has already been reconciled against the + # docs by hand, and the divergence is the point -- `api-deployment key + # create` deliberately drops the documented `pipeline` body param because + # the path fixes the resource. Reporting it would invite the Skill to + # "restore" the very thing that was removed, so the exemption covers + # parameters as well as the endpoint's existence. + if cli.doc_conflict: + continue + + cli_names = {p.name for p in cli.params} + for param in doc.params: + if param.name in cli_names: + continue + # A parameter mirrored into the body from a path param is present on + # the wire under its documented name, just not as a separate flag. + if any(p.mirrors and p.body_name == param.name for p in cli.params): + continue + # Path parameters often appear under different placeholder names. + if any(param.name in cli.path for _ in (0,)): + continue + findings.append( + Finding( + kind="param_drift", + severity="action", + message=( + f"{cli.dotted_name}: documented parameter " + f"{param.name!r} is not exposed as a flag." + ), + citation=doc.source, + command="unstract " + " ".join(cli.command_path), + suggestion=( + f"Add Param({param.name!r}" + + (", required=True" if param.required else "") + + ") to this record." + ), + ) + ) + + for key, cli in by_cli.items(): + if key in by_doc: + continue + if cli.doc_conflict: + continue # A verified deliberate divergence; never revert it. + findings.append( + Finding( + kind="missing_in_docs", + severity="info", + message=( + f"{cli.dotted_name} ({cli.method} {cli.path}) was not found in " + "the parsed docs. Docs lag implementation -- report only." + ), + citation=cli.doc_source, + command="unstract " + " ".join(cli.command_path), + suggestion="Verify manually. NEVER delete a command for this reason alone.", + ) + ) + + return findings + + +def report(findings: list[Finding]) -> str: + """Render findings as JSON for the Skill to act on.""" + by_kind: dict[str, list[dict[str, Any]]] = {} + for finding in findings: + by_kind.setdefault(finding.kind, []).append(finding.to_dict()) + return json.dumps( + { + "summary": {kind: len(items) for kind, items in by_kind.items()}, + "total": len(findings), + "findings": by_kind, + }, + indent=2, + ) + + +__all__ = [ + "DocEndpoint", + "DocParam", + "Finding", + "diff", + "parse_docs", + "parse_markdown_doc", + "parse_markdown_params", + "parse_mdx_doc", + "report", +] diff --git a/src/unstract_cli/specs/README.md b/src/unstract_cli/specs/README.md deleted file mode 100644 index 9dfae0d..0000000 --- a/src/unstract_cli/specs/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# 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` | - -`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 recorded pin and the one in -`pyproject.toml` disagree. 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. -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 -- -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 deleted file mode 100644 index ea8039b..0000000 --- a/src/unstract_cli/specs/docstudio.json +++ /dev/null @@ -1,1082 +0,0 @@ -{ - "components": { - "schemas": { - "APIDeploymentSummary": { - "description": "One API deployment, as it appears in an organisation's listing.\n\n`api_name` and `api_endpoint` are what an execution call needs;\n`display_name` and `description` say what the deployment is for.", - "properties": { - "api_endpoint": { - "readOnly": true, - "type": "string" - }, - "api_name": { - "maxLength": 30, - "readOnly": true, - "type": "string" - }, - "co_owners_count": { - "readOnly": true, - "type": "integer" - }, - "created_by": { - "nullable": true, - "readOnly": true, - "type": "integer" - }, - "created_by_email": { - "description": "Get the email of the creator.", - "nullable": true, - "readOnly": true, - "type": "string" - }, - "description": { - "maxLength": 255, - "readOnly": true, - "type": "string" - }, - "display_name": { - "maxLength": 30, - "readOnly": true, - "type": "string" - }, - "id": { - "format": "uuid", - "readOnly": true, - "type": "string" - }, - "is_active": { - "readOnly": true, - "type": "boolean" - }, - "is_owner": { - "readOnly": true, - "type": "boolean" - }, - "last_5_run_statuses": { - "description": "Fetch the last 5 execution statuses with timestamps for this API deployment.", - "items": { - "additionalProperties": {}, - "type": "object" - }, - "readOnly": true, - "type": "array" - }, - "last_run_time": { - "nullable": true, - "readOnly": true, - "type": "string" - }, - "owner_emails": { - "description": "Email of each owner, earliest first. Empty if none is a person.", - "items": { - "type": "string" - }, - "readOnly": true, - "type": "array" - }, - "run_count": { - "readOnly": true, - "type": "integer" - }, - "workflow": { - "format": "uuid", - "type": "string" - }, - "workflow_name": { - "readOnly": true, - "type": "string" - } - }, - "required": [ - "api_endpoint", - "api_name", - "co_owners_count", - "created_by", - "created_by_email", - "description", - "display_name", - "id", - "is_active", - "is_owner", - "last_5_run_statuses", - "last_run_time", - "owner_emails", - "run_count", - "workflow", - "workflow_name" - ], - "type": "object" - }, - "AcknowledgedResponse": { - "description": "The execution's result was handed to an earlier call and discarded.", - "properties": { - "message": { - "type": "string" - }, - "status": { - "type": "string" - } - }, - "required": [ - "message", - "status" - ], - "type": "object" - }, - "ApiKeyPermission": { - "description": "* `read` - Read\n* `read_write` - Read/Write\n* `full_access` - Full Access", - "enum": [ - "read", - "read_write", - "full_access" - ], - "type": "string" - }, - "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": { - "custom_data": { - "nullable": true - }, - "files": { - "items": { - "format": "binary", - "type": "string" - }, - "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" - }, - "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" - } - }, - "type": "object" - }, - "ExecuteResponse": { - "properties": { - "message": { - "$ref": "#/components/schemas/ExecutionMessage" - } - }, - "required": [ - "message" - ], - "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": { - "type": "string" - }, - "execution_status": { - "type": "string" - }, - "result": { - "items": { - "$ref": "#/components/schemas/FileResult" - }, - "nullable": true, - "type": "array" - }, - "status_api": { - "nullable": true, - "type": "string" - } - }, - "required": [ - "execution_id", - "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": { - "nullable": true - }, - "result": { - "nullable": true - }, - "status": { - "type": "string" - } - }, - "required": [ - "file" - ], - "type": "object" - }, - "PaginatedAPIDeploymentSummaryList": { - "properties": { - "count": { - "example": 123, - "type": "integer" - }, - "next": { - "example": "http://api.example.org/accounts/?page=4", - "format": "uri", - "nullable": true, - "type": "string" - }, - "previous": { - "example": "http://api.example.org/accounts/?page=2", - "format": "uri", - "nullable": true, - "type": "string" - }, - "results": { - "items": { - "$ref": "#/components/schemas/APIDeploymentSummary" - }, - "type": "array" - } - }, - "required": [ - "count", - "results" - ], - "type": "object" - }, - "PlatformKeyError": { - "description": "Why a platform-key request was refused.\n\nProduced by the authentication middleware rather than by the project's\nexception handler, so it carries a single human-readable message and none\nof the per-field structure the organisation-scoped endpoints return. It is\nthe shape of this operation's credential failures specifically, not of\nevery failure it can return.", - "properties": { - "message": { - "description": "Human-readable reason for the refusal.", - "type": "string" - } - }, - "required": [ - "message" - ], - "type": "object" - }, - "StatusResponse": { - "properties": { - "message": { - "items": { - "$ref": "#/components/schemas/FileResult" - }, - "nullable": true, - "type": "array" - }, - "status": { - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - "WhoAmIResponse": { - "description": "The organisation a platform API key belongs to, and what it may do.", - "properties": { - "key_name": { - "description": "The key's name, as it was minted.", - "type": "string" - }, - "organization_id": { - "description": "The organisation's identifier, as it appears in web-app URLs and in every organisation-scoped API path.", - "type": "string" - }, - "organization_name": { - "description": "The organisation's display name.", - "type": "string" - }, - "permission": { - "allOf": [ - { - "$ref": "#/components/schemas/ApiKeyPermission" - } - ], - "description": "The key's permission tier, which decides the HTTP methods it may issue.\n\n* `read` - Read\n* `read_write` - Read/Write\n* `full_access` - Full Access" - } - }, - "required": [ - "key_name", - "organization_id", - "organization_name", - "permission" - ], - "type": "object" - } - }, - "securitySchemes": { - "deploymentKey": { - "description": "A key that runs an API deployment: the deployment's own key, or a global API deployment key with access to it.", - "scheme": "bearer", - "type": "http" - }, - "platformKey": { - "description": "An organisation-wide platform API key, minted under Settings. It carries the organisation it belongs to, but cannot execute an API deployment.", - "scheme": "bearer", - "type": "http" - } - } - }, - "info": { - "title": "Unstract API", - "version": "v1" - }, - "openapi": "3.0.3", - "paths": { - "/api/v1/unstract/whoami/": { - "get": { - "description": "Takes a platform API key and nothing else: the organisation is read from the key itself, so this route carries no organisation segment. Call it once and store `organization_id`; every other endpoint takes it as a path segment.\n\nAn API deployment key is rejected as unauthenticated.", - "operationId": "whoami", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WhoAmIResponse" - } - } - }, - "description": "" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlatformKeyError" - } - } - }, - "description": "No usable platform API key was supplied \u2014 absent, malformed, unknown, or revoked." - }, - "500": { - "description": "The request could not be served. The body is not guaranteed to be JSON." - } - }, - "security": [ - { - "platformKey": [] - } - ], - "summary": "Resolve the organisation a platform key belongs to", - "tags": [ - "identity" - ] - } - }, - "/api/v1/unstract/{org_id}/api/deployment/": { - "get": { - "description": "Lists what an organisation has deployed, authenticated by a platform API key that belongs to it.\n\nA deployment key is not part of this listing; executing a deployment still needs one.\n\nOrdered by most recent run first, then by identifier, so paging is stable.", - "operationId": "list_deployments", - "parameters": [ - { - "description": "Return only the deployment with exactly this `api_name`.", - "in": "query", - "name": "api_name", - "schema": { - "type": "string" - } - }, - { - "description": "Which field to use when ordering the results.", - "in": "query", - "name": "ordering", - "required": false, - "schema": { - "type": "string" - } - }, - { - "description": "A page number within the paginated result set.", - "in": "query", - "name": "page", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Number of results to return per page.", - "in": "query", - "name": "page_size", - "required": false, - "schema": { - "type": "integer" - } - }, - { - "description": "Return only deployments whose display name contains this text, case-insensitively.", - "in": "query", - "name": "search", - "schema": { - "type": "string" - } - }, - { - "description": "Return only the deployments of this workflow.", - "in": "query", - "name": "workflow", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "description": "The organisation the request is scoped to, as `whoami` reports it in `organization_id`.", - "in": "path", - "name": "org_id", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PaginatedAPIDeploymentSummaryList" - } - } - }, - "description": "One page of the organisation's API deployments." - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "A query parameter was malformed." - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlatformKeyError" - } - } - }, - "description": "No usable platform API key was supplied \u2014 absent, malformed, unknown, or revoked." - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PlatformKeyError" - } - } - }, - "description": "The key was recognised but refused: it does not belong to the organisation named in the path, or its permission tier is not one this deployment knows." - }, - "500": { - "description": "The request could not be served. The body is not guaranteed to be JSON." - } - }, - "security": [ - { - "platformKey": [] - } - ], - "summary": "List an organisation's API deployments", - "tags": [ - "deployment" - ] - } - }, - "/deployment/api/{org_name}/{api_name}/": { - "get": { - "description": "Reads a previously started execution, taking the same deployment key that ran it. The 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. 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.", - "operationId": "status", - "parameters": [ - { - "description": "API deployment name.", - "in": "path", - "name": "api_name", - "required": true, - "schema": { - "pattern": "^[\\w-]+$", - "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": { - "pattern": "^[\\w-]+$", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatusResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "The request failed validation." - }, - "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": "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": "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 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/AcknowledgedResponse" - } - } - }, - "description": "The result was already consumed by an earlier call." - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatusResponse" - } - } - }, - "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/StatusResponse" - } - } - }, - "description": "The execution could not be completed; the body carries its last known state." - } - }, - "security": [ - { - "deploymentKey": [] - } - ], - "summary": "Read the result of an execution", - "tags": [ - "deployment" - ] - }, - "post": { - "description": "Runs an API deployment. Takes a deployment key \u2014 either the deployment's own key, or a global API deployment key that has access to it.\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": [ - { - "description": "API deployment name.", - "in": "path", - "name": "api_name", - "required": true, - "schema": { - "pattern": "^[\\w-]+$", - "type": "string" - } - }, - { - "description": "Organization identifier.", - "in": "path", - "name": "org_name", - "required": true, - "schema": { - "pattern": "^[\\w-]+$", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/ExecuteRequest" - } - } - } - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExecuteResponse" - } - } - }, - "description": "" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "The request failed validation." - }, - "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": "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": "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 active deployment, or a referenced document, was found." - }, - "413": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "description": "A referenced document is larger than the limit." - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExecuteResponse" - } - } - }, - "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" - } - } - }, - "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": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - }, - "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": [ - { - "deploymentKey": [] - } - ], - "summary": "Execute an API deployment against documents", - "tags": [ - "deployment" - ] - } - } - }, - "tags": [ - { - "description": "Discover an organisation's API deployments, run one against one or more documents, and poll the result.", - "name": "deployment" - }, - { - "description": "Resolve what a platform API key is scoped to, so a client can discover its organisation rather than being told it.", - "name": "identity" - } - ] -} diff --git a/src/unstract_cli/specs/llmwhisperer.json b/src/unstract_cli/specs/llmwhisperer.json deleted file mode 100644 index 1664cd9..0000000 --- a/src/unstract_cli/specs/llmwhisperer.json +++ /dev/null @@ -1,2717 +0,0 @@ -{ - "components": { - "schemas": { - "Error": { - "properties": { - "message": { - "type": "string" - } - }, - "type": "object" - }, - "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": { - "format": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "type": "string" - }, - "whisper_hash": { - "type": "string" - } - }, - "type": "object" - }, - "WhisperResult": { - "properties": { - "confidence_metadata": { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" - }, - "line_metadata": { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array" - }, - "metadata": { - "additionalProperties": true, - "type": "object" - }, - "result_text": { - "type": "string" - }, - "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" - }, - "status": { - "type": "string" - } - }, - "type": "object" - } - }, - "securitySchemes": { - "unstract_key": { - "in": "header", - "name": "unstract-key", - "type": "apiKey" - } - } - }, - "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" - }, - "openapi": "3.0.3", - "paths": { - "/api/v2/convert-to-pdf": { - "post": { - "operationId": "convert_to_pdf", - "parameters": [ - { - "in": "query", - "name": "url", - "required": false, - "schema": { - "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": false - }, - "responses": { - "200": { - "content": { - "application/pdf": { - "schema": { - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "convert" - ] - } - }, - "/api/v2/convert-xlsb-to-xlsx": { - "post": { - "operationId": "convert_xlsb_to_xlsx", - "parameters": [ - { - "in": "query", - "name": "url", - "required": false, - "schema": { - "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": false - }, - "responses": { - "200": { - "content": { - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { - "schema": { - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "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": { - "type": "string" - } - }, - { - "in": "query", - "name": "tag", - "required": false, - "schema": { - "default": "default", - "type": "string" - } - }, - { - "in": "query", - "name": "url", - "required": false, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "url_in_post", - "required": false, - "schema": { - "default": false, - "type": "boolean" - } - }, - { - "in": "query", - "name": "use_webhook", - "required": false, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "webhook_metadata", - "required": false, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "format": "binary", - "type": "string" - } - } - }, - "required": false - }, - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WhisperAccepted" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "insights" - ] - } - }, - "/api/v2/document-insights-retrieve": { - "get": { - "operationId": "document_insights_retrieve", - "parameters": [ - { - "in": "query", - "name": "whisper_hash", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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)", - "tags": [ - "insights" - ] - } - }, - "/api/v2/get-usage-info": { - "get": { - "operationId": "usage_info", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "account" - ] - } - }, - "/api/v2/highlights": { - "get": { - "operationId": "highlights", - "parameters": [ - { - "in": "query", - "name": "extract_all_lines", - "required": false, - "schema": { - "default": false, - "type": "boolean" - } - }, - { - "description": "Line numbers or ranges, e.g. `1-5,9`. Required unless `extract_all_lines=true`.", - "in": "query", - "name": "lines", - "required": false, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "whisper_hash", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "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", - "enum": [ - "png", - "jpeg" - ], - "type": "string" - } - }, - { - "in": "query", - "name": "tag", - "required": false, - "schema": { - "default": "default", - "type": "string" - } - }, - { - "in": "query", - "name": "url", - "required": false, - "schema": { - "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": false - }, - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WhisperAccepted" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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": { - "operationId": "pdf_to_images_retrieve", - "parameters": [ - { - "in": "query", - "name": "whisper_hash", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/zip": { - "schema": { - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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)", - "tags": [ - "convert" - ] - } - }, - "/api/v2/pdf-to-images-status": { - "get": { - "operationId": "pdf_to_images_status", - "parameters": [ - { - "in": "query", - "name": "whisper_hash", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WhisperStatus" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "convert" - ] - } - }, - "/api/v2/test-connection": { - "get": { - "operationId": "test_connection", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "account" - ] - } - }, - "/api/v2/usage": { - "get": { - "operationId": "usage", - "parameters": [ - { - "in": "query", - "name": "from_date", - "required": false, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "tag", - "required": true, - "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" - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "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" - }, - "x-internal": true - }, - { - "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" - }, - "x-internal": true - }, - { - "in": "query", - "name": "include_cell_borders", - "required": false, - "schema": { - "default": true, - "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", - "enum": [ - "left-priority", - "mid-priority", - "right-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" - }, - "x-internal": true - }, - { - "in": "query", - "name": "mode", - "required": false, - "schema": { - "default": "form", - "enum": [ - "form", - "high_quality", - "low_cost", - "native_text", - "table" - ], - "type": "string" - } - }, - { - "in": "query", - "name": "output_mode", - "required": false, - "schema": { - "default": "layout_preserving", - "enum": [ - "layout_preserving", - "text" - ], - "type": "string" - } - }, - { - "in": "query", - "name": "page_separator", - "required": false, - "schema": { - "default": "<<<", - "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", - "required": false, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "tag", - "required": false, - "schema": { - "default": "default", - "type": "string" - } - }, - { - "description": "Fetch the document from this URL instead of sending a body.", - "in": "query", - "name": "url", - "required": false, - "schema": { - "format": "uri", - "type": "string" - } - }, - { - "description": "Read the URL to fetch from the request body.", - "in": "query", - "name": "url_in_post", - "required": false, - "schema": { - "default": false, - "type": "boolean" - } - }, - { - "in": "query", - "name": "use_webhook", - "required": false, - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "watermark_angle_threshold", - "required": false, - "schema": { - "default": 25.0, - "type": "number" - } - }, - { - "in": "query", - "name": "webhook_metadata", - "required": false, - "schema": { - "type": "string" - } - }, - { - "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, - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "format": "binary", - "type": "string" - } - } - }, - "required": false - }, - "responses": { - "202": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WhisperAccepted" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "whisper" - ] - } - }, - "/api/v2/whisper-detail": { - "get": { - "operationId": "detail", - "parameters": [ - { - "in": "query", - "name": "whisper_hash", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "whisper" - ] - } - }, - "/api/v2/whisper-manage-callback": { - "delete": { - "operationId": "webhook_delete", - "parameters": [ - { - "in": "query", - "name": "webhook_name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "webhook" - ] - }, - "get": { - "operationId": "webhook_get", - "parameters": [ - { - "in": "query", - "name": "webhook_name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "webhook" - ] - }, - "post": { - "operationId": "webhook_post", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookConfig" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "webhook" - ] - }, - "put": { - "operationId": "webhook_put", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookConfig" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "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": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WhisperResult" - } - }, - "text/plain": { - "schema": { - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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)", - "tags": [ - "whisper" - ] - } - }, - "/api/v2/whisper-status": { - "get": { - "operationId": "status", - "parameters": [ - { - "in": "query", - "name": "whisper_hash", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WhisperStatus" - } - } - }, - "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." - }, - "402": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "description": "Quota exhausted, or the licence does not cover this request." - }, - "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." - }, - "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", - "tags": [ - "whisper" - ] - } - } - }, - "security": [ - { - "unstract_key": [] - } - ], - "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/src/unstract_cli/specs/provenance.json b/src/unstract_cli/specs/provenance.json deleted file mode 100644 index 761c8a4..0000000 --- a/src/unstract_cli/specs/provenance.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "docstudio.json": { - "client": "unstract-client==1.7.1", - "repo": "https://github.com/Zipstack/unstract", - "commit": "5d46c66ccff8ae1ac3c31a3c3d5d64206e329002", - "path": "specs/docstudio-oss.json", - "sha256": "c0de3a14254600f436483561ebb737feac4023fdef2fd46d6abf15dcd2182ad9" - }, - "llmwhisperer.json": { - "client": "llmwhisperer-client==2.10.0", - "repo": "https://github.com/Zipstack/unstract-llm-whisperer", - "commit": "9fe7085b44da6ca37e635fba3ab082c725ef8aef", - "path": "specs/llmwhisperer.json", - "sha256": "4d758a3e3a8dbe972c7031bf5db813450c8855ca112fbc5bc1d42ac296a44d34" - } -} diff --git a/tests/conftest.py b/tests/conftest.py index b3d663c..502ed2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,81 +1,70 @@ -from __future__ import annotations +"""Shared fixtures. + +Every test runs against an isolated config path and a controlled environment, so +a developer's real `~/.config/unstract/config.toml` can never influence results +(or be written to). +""" -import os -from fnmatch import fnmatch +from __future__ import annotations import pytest +from click.testing import CliRunner -from unstract_cli import config as config_mod -from unstract_cli.core.errors import ( - forget_secrets, - forget_warning_sink, - set_warning_sink, -) -from unstract_cli.core.output import AGENT_ENV +from unstract_cli.app import build_cli -#: 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"} -) +#: Recognisable so redaction tests can assert this exact string never appears. +FAKE_KEY = "sk-test-SECRETVALUE-0123456789" + +WHISPER_BASE = "https://llmwhisperer-api.us-central.unstract.com/api/v2" +PLATFORM_BASE = "https://us-central.unstract.com" @pytest.fixture(autouse=True) -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) - ]: +def isolated_env(tmp_path, monkeypatch): + """Point config at a temp path and clear inherited credentials.""" + for var in ( + "UNSTRACT_PROFILE", "LLMWHISPERER_API_KEY", "LLMWHISPERER_BASE_URL", + "UNSTRACT_PLATFORM_KEY", "UNSTRACT_DEPLOYMENT_KEY", "UNSTRACT_ORG_ID", + "UNSTRACT_BASE_URL", "UNSTRACT_APIHUB_KEY", "UNSTRACT_APIHUB_BASE_URL", + "UNSTRACT_ANTHROPIC_API_KEY", "UNSTRACT_OUTPUT", "NO_COLOR", + ): 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") - forget_secrets() - forget_warning_sink() - yield - config_mod.set_config_path(None) - forget_secrets() - forget_warning_sink() + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "config.toml")) + return tmp_path @pytest.fixture -def warnings_seen(): - """Notes from the modules that cannot reach the output layer, as a list. +def whisper_env(monkeypatch): + """Credentials for LLMWhisperer, supplied only through the environment. - 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. + Deliberately env-only: this is also the assertion that the CLI is fully + usable with no config file, which is how it runs in CI and agent sandboxes. """ - seen: list[str] = [] - set_warning_sink(seen.append) - return seen + monkeypatch.setenv("LLMWHISPERER_API_KEY", FAKE_KEY) + return FAKE_KEY -@pytest.fixture(autouse=True) -def no_real_waiting(monkeypatch): - """Nothing in this suite is testing that a wait takes wall-clock time. +@pytest.fixture +def platform_env(monkeypatch): + monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", FAKE_KEY) + monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", FAKE_KEY) + monkeypatch.setenv("UNSTRACT_ORG_ID", "org_test123") + return FAKE_KEY - 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 runner(): + """Click runner that keeps stderr separate, so stdout purity is testable.""" + return CliRunner() @pytest.fixture -def write_config(tmp_path, monkeypatch): - """Write a config file and point the CLI at it.""" +def cli(): + return build_cli() - 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 +@pytest.fixture +def sample_file(tmp_path): + path = tmp_path / "sample.pdf" + path.write_bytes(b"%PDF-1.4 fake document for testing") + return path diff --git a/tests/derived_flags.json b/tests/derived_flags.json deleted file mode 100644 index ce2456f..0000000 --- a/tests/derived_flags.json +++ /dev/null @@ -1,463 +0,0 @@ -{ - "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": [], - "short": null - }, - "--allow-rotated-text": { - "name": "allow_rotated_text", - "type": "boolean", - "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, - "required": false, - "choices": [], - "short": null - }, - "--checkbox-confidence-threshold": { - "name": "checkbox_confidence_threshold", - "type": "number", - "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, - "required": false, - "choices": [], - "short": null - }, - "--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": [], - "short": null - }, - "--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": [], - "short": null - }, - "--horizontal-stretch-factor": { - "name": "horizontal_stretch_factor", - "type": "number", - "default": 1.0, - "description": "The horizontal stretch factor.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--include-cell-borders": { - "name": "include_cell_borders", - "type": "boolean", - "default": null, - "description": "Whether an Excel workbook's own cell borders are drawn into the extracted text as box-drawing characters. Turning it off drops every border, leaving columns aligned and no cell value lost, which costs fewer tokens downstream. This keys off the uploaded file type, not the mode: it applies to any XLS or XLSX upload whatever mode was requested, the default \"form\" mode included, and is ignored for every other kind of document.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--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": [], - "short": null - }, - "--lang": { - "name": "lang", - "type": "string", - "default": "eng", - "description": "The language of the document.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--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" - ], - "short": null - }, - "--line-splitter-tolerance": { - "name": "line_splitter_tolerance", - "type": "number", - "default": 0.4, - "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, - "choices": [], - "short": null - }, - "--mark-horizontal-lines": { - "name": "mark_horizontal_lines", - "type": "boolean", - "default": false, - "description": "Whether to mark horizontal lines.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--mark-vertical-lines": { - "name": "mark_vertical_lines", - "type": "boolean", - "default": false, - "description": "Whether to mark vertical lines.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--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": [], - "short": null - }, - "--mode": { - "name": "mode", - "type": "string", - "default": "form", - "description": "The processing mode.", - "array": false, - "nullable": false, - "required": false, - "choices": [ - "form", - "high_quality", - "low_cost", - "native_text", - "table" - ], - "short": null - }, - "--output-mode": { - "name": "output_mode", - "type": "string", - "default": "layout_preserving", - "description": "The output mode.", - "array": false, - "nullable": false, - "required": false, - "choices": [ - "layout_preserving", - "text" - ], - "short": null - }, - "--page-separator": { - "name": "page_separator", - "type": "string", - "default": null, - "description": "The page separator.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--pages-to-extract": { - "name": "pages_to_extract", - "type": "string", - "default": "", - "description": "The pages to extract.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--tag": { - "name": "tag", - "type": "string", - "default": "default", - "description": "The tag for the document.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--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": [], - "short": null - }, - "--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": [], - "short": null - }, - "--watermark-angle-threshold": { - "name": "watermark_angle_threshold", - "type": "number", - "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, - "required": false, - "choices": [], - "short": null - }, - "--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": [], - "short": null - }, - "--word-confidence-threshold": { - "name": "word_confidence_threshold", - "type": "number", - "default": 0.3, - "description": "Minimum per-word OCR confidence to report. Defaults to 0.05 unless the deployment overrides it.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - } - }, - "llmwhisperer:highlights": { - "--extract-all-lines": { - "name": "extract_all_lines", - "type": "boolean", - "default": false, - "description": "", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--lines": { - "name": "lines", - "type": "string", - "default": null, - "description": "Line numbers or ranges, e.g. `1-5,9`. Required unless `extract_all_lines=true`.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--whisper-hash": { - "name": "whisper_hash", - "type": "string", - "default": null, - "description": "The hash of the whisper operation.", - "array": false, - "nullable": false, - "required": true, - "choices": [], - "short": null - } - }, - "docstudio:execute": { - "--custom-data": { - "name": "custom_data", - "type": "string", - "default": null, - "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, - "choices": [], - "short": null - }, - "--hitl-packet-id": { - "name": "hitl_packet_id", - "type": "string", - "default": null, - "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, - "choices": [], - "short": null - }, - "--hitl-queue-name": { - "name": "hitl_queue_name", - "type": "string", - "default": null, - "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, - "choices": [], - "short": null - }, - "--include-extracted-text": { - "name": "include_extracted_text", - "type": "boolean", - "default": false, - "description": "Include the extracted text.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--include-metadata": { - "name": "include_metadata", - "type": "boolean", - "default": false, - "description": "Include metadata in the result.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--include-metrics": { - "name": "include_metrics", - "type": "boolean", - "default": false, - "description": "Include metrics in the result.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--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": [], - "short": null - }, - "--presigned-urls": { - "name": "presigned_urls", - "type": "string", - "default": null, - "description": "URLs to fetch the inputs from.", - "array": true, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--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": [], - "short": null - }, - "--timeout": { - "name": "timeout", - "type": "integer", - "default": -1, - "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, - "choices": [], - "short": null - } - }, - "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": [], - "short": null - }, - "--include-metadata": { - "name": "include_metadata", - "type": "boolean", - "default": false, - "description": "Include metadata in the result.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - }, - "--include-metrics": { - "name": "include_metrics", - "type": "boolean", - "default": false, - "description": "Include metrics in the result.", - "array": false, - "nullable": false, - "required": false, - "choices": [], - "short": null - } - } -} diff --git a/tests/test_cli.py b/tests/test_cli.py index a137f83..cc8a2fd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,412 +1,732 @@ -"""End-to-end through the entry point: exit codes reach the shell, stdout parses.""" +"""End-to-end CLI behaviour: the promises an agent actually depends on.""" from __future__ import annotations -import errno import json import os import stat +import subprocess import sys from pathlib import Path +import httpx import pytest +import respx -from unstract_cli import app -from unstract_cli import config as config_module -from unstract_cli.__main__ import main -from unstract_cli.app import cli, command_tree -from unstract_cli.core.errors import ExitCode +from unstract_cli.app import discover +from unstract_cli.endpoints import ALL_ENDPOINTS +from .conftest import FAKE_KEY, WHISPER_BASE -def run(capsys, *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 +class TestWalkingSkeleton: + """M1.3 - one endpoint, end to end, proving the whole spine.""" + @respx.mock + def test_whisper_usage_succeeds(self, runner, cli, whisper_env): + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response( + 200, json={"subscription_plan": "free", "daily_quota": 100} + ) + ) + result = runner.invoke(cli, ["whisper", "usage", "--output", "json"]) + assert result.exit_code == 0 + assert json.loads(result.stdout)["subscription_plan"] == "free" -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"} + @respx.mock + def test_works_with_env_vars_only(self, runner, cli, whisper_env, isolated_env): + """Zero-config operation: no config file exists at all here.""" + assert not (isolated_env / "config.toml").exists() + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response(200, json={"subscription_plan": "free"}) + ) + assert runner.invoke(cli, ["whisper", "usage"]).exit_code == 0 -def test_help_exits_zero(): - assert main(["--help"]) == int(ExitCode.SUCCESS) +class TestExitCodes: + """SPEC §5.4 end-to-end, not just at the mapping layer.""" - -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_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_a_reader_that_went_away_does_not_raise_on_the_way_out(capfd, monkeypatch): - """`... | head` closes the pipe mid-write; Python flushes stdout again at - exit, so stdout has to be pointed somewhere that write can go. - - `capfd` rather than `capsys`: the redirect asks stdout for its descriptor, - which a `capsys` stream refuses, and the refusal is swallowed by the same - guard that swallows a failed redirect. - """ - redirected = [] - - def gone(): - raise BrokenPipeError - - monkeypatch.setattr("unstract_cli.commands.config_cmd.load_config", gone) - monkeypatch.setattr(os, "dup2", lambda src, dst: redirected.append(dst)) - - assert main(["-o", "json", "config", "doctor"]) == int(ExitCode.GENERIC) - assert redirected == [sys.stdout.fileno()] - - -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) - 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 _failing_directory_sync(monkeypatch): - """The file syncs, the directory does not: the shape of a write that is in - place but not yet known to survive a crash.""" - real_fsync = os.fsync - - def fsync(fd): - if os.fstat(fd).st_mode & stat.S_IFDIR: - raise OSError(errno.EINVAL, "Invalid argument") - real_fsync(fd) - - monkeypatch.setattr(config_module.os, "fsync", fsync) - - -@pytest.mark.skipif(sys.platform == "win32", reason="no directory handles") -@pytest.mark.parametrize("command", [("set", "docstudio", "org_id", "org_A"), ("init",)]) -def test_a_write_that_may_not_survive_a_crash_says_so_in_meta( - capsys, tmp_path, monkeypatch, command -): - """The warning goes to stderr, which a JSON consumer does not read: the - envelope has to carry it too, or `ok: true` promises what the disk has - not confirmed.""" - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) - _failing_directory_sync(monkeypatch) - - code, payload, err = run(capsys, "config", *command) - - assert code == 0 and payload["ok"] is True - assert payload["meta"]["durability"] == "unconfirmed" - assert "could not be synced" in err - - -def test_a_confirmed_write_claims_nothing_about_durability(capsys, tmp_path, monkeypatch): - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) - - _, payload, _ = run(capsys, "config", "set", "docstudio", "org_id", "org_A") - - assert "durability" not in payload["meta"] - - -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_set_can_store_a_key_for_one_deployment(capsys, tmp_path, monkeypatch): - """The entry is where a run looks after the environment and before the - profile's key, so the write lands under the API name, not the product.""" - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) - code, payload, _ = run( - capsys, "config", "set", "docstudio", "api_key", "dk-1", "--deployment", "inv" - ) - assert code == 0 and payload["data"]["deployment"] == "inv" - text = (tmp_path / "c.toml").read_text(encoding="utf-8") - assert "[profiles.cloud-us.deployments.inv]" in text - assert "[profiles.cloud-us.docstudio]" not in text - - -def test_set_refuses_to_store_anything_but_a_key_per_deployment( - capsys, tmp_path, monkeypatch -): - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) - code, payload, _ = run( - capsys, "config", "set", "docstudio", "org_id", "org_A", "--deployment", "inv" + @respx.mock + @pytest.mark.parametrize( + "status,expected", + [(401, 3), (403, 3), (404, 4), (400, 5), (422, 5), (429, 6), (500, 8)], ) - assert code == int(ExitCode.USAGE) - assert "--deployment" in payload["error"]["message"] - 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") - 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_init_says_what_to_do_next_on_stderr(capsys, tmp_path, monkeypatch): - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) - - code, payload, err = run(capsys, "config", "init") - - assert code == 0 and payload["ok"] is True - assert "export LLMWHISPERER_API_KEY and UNSTRACT_DEPLOYMENT_KEY" in err - assert "`unstract auth login`" in err - assert "config doctor" in err - - -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_doctor_reports_the_platform_key_beside_the_deployment_key(capsys, monkeypatch): - """Two keys on one block, each with its own row: a platform key is - optional, so its absence is a report and not a problem.""" - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-super-secret-value") - code, payload, _ = run(capsys, "config", "doctor") - assert code == 0 - docstudio = payload["data"]["products"]["docstudio"] - assert docstudio["platform_key"] == { - "resolved": True, - "source": "env:UNSTRACT_PLATFORM_KEY", - } - assert "platform" not in payload["data"]["products"] - assert "pk-super-secret-value" not in json.dumps(payload) - - -def doctor(capsys, *args) -> str: - """`config doctor` -- a command with no network -- and its raw stdout.""" - main([*args, "config", "doctor"]) - return capsys.readouterr().out - - -def is_table(out: str) -> bool: - try: - json.loads(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 + def test_http_status_maps_to_exit_code(self, runner, cli, whisper_env, status, expected): + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response(status, json={"message": "failure"}) + ) + result = runner.invoke(cli, ["whisper", "usage", "--no-retry"]) + assert result.exit_code == expected + + def test_missing_credentials_is_usage_error(self, runner, cli): + result = runner.invoke(cli, ["whisper", "usage"]) + assert result.exit_code == 2 + assert "LLMWHISPERER_API_KEY" in result.stderr + + def test_constraint_violation_is_usage_error(self, runner, cli, whisper_env, sample_file): + result = runner.invoke( + cli, ["whisper", "extract", "--file", str(sample_file), "--url", "http://x"] + ) + assert result.exit_code == 2 + + +class TestStdoutPurity: + """SPEC §5.1 - `unstract ... | jq` must always parse.""" + + @respx.mock + def test_errors_are_parseable_on_both_streams_when_piped(self, runner, cli, whisper_env): + """DOC 9 - when stdout is not a TTY (the agent/wrapper case), the error + envelope is mirrored to stdout so a stdout->JSON pipeline sees a valid + object instead of an empty stream. The human copy still goes to stderr.""" + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response(403, json={"message": "Unauthorized"}) + ) + result = runner.invoke(cli, ["whisper", "usage", "--no-retry"]) + # CliRunner's stdout is not a TTY -- exactly the piped case. + assert json.loads(result.stdout)["error"]["code"] == "auth_error" + assert json.loads(result.stderr)["error"]["code"] == "auth_error" + + @respx.mock + def test_diagnostics_go_to_stderr(self, runner, cli, whisper_env): + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response(200, json={"ok": True}) + ) + result = runner.invoke(cli, ["whisper", "usage", "-vv"]) + assert json.loads(result.stdout) == {"ok": True} + + @respx.mock + def test_json_is_default_when_not_a_tty(self, runner, cli, whisper_env): + """CliRunner's stdout is not a TTY, which is the agent's situation.""" + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response(200, json={"plan": "free"}) + ) + result = runner.invoke(cli, ["whisper", "usage"]) + assert json.loads(result.stdout) == {"plan": "free"} + + def test_click_usage_error_is_parseable_on_stdout(self): + """DOC 9, real entry point - Click's own parse errors (No such option) + must also emit the envelope on stdout when piped. CliRunner bypasses the + __main__ handler, so drive the actual console entry point in a subprocess + with a captured (non-TTY) stdout.""" + proc = subprocess.run( + [sys.executable, "-m", "unstract_cli", "whisper", "usage", "--nope"], + capture_output=True, + text=True, + ) + assert proc.returncode == 2 + assert json.loads(proc.stdout)["error"]["exit_code"] == 2 + assert "No such option" in proc.stderr # human message survives on stderr + + +class TestOneShotSemantics: + """SPEC §5.6 - the footgun that silently loses an agent's data.""" + + @respx.mock + def test_save_writes_before_exit(self, runner, cli, whisper_env, tmp_path): + respx.get(f"{WHISPER_BASE}/whisper-retrieve").mock( + return_value=httpx.Response(200, json={"result_text": "IMPORTANT DATA"}) + ) + out = tmp_path / "result.txt" + result = runner.invoke( + cli, ["whisper", "retrieve", "--whisper-hash", "h1", "--save", str(out)] + ) + assert result.exit_code == 0 + # raw_field means the saved artefact is the text itself, not the envelope. + assert out.read_text() == "IMPORTANT DATA" + + @respx.mock + def test_second_retrieve_exits_9(self, runner, cli, whisper_env): + respx.get(f"{WHISPER_BASE}/whisper-retrieve").mock( + return_value=httpx.Response(200, json={"message": "Whisper already delivered"}) + ) + result = runner.invoke(cli, ["whisper", "retrieve", "--whisper-hash", "h1"]) + assert result.exit_code == 9 + assert "once" in json.loads(result.stderr)["error"]["hint"].lower() + + @respx.mock + def test_no_retry_on_consumed_result(self, runner, cli, whisper_env): + """A retry here could consume a result the first call already delivered.""" + route = respx.get(f"{WHISPER_BASE}/whisper-retrieve").mock( + return_value=httpx.Response(406, json={"message": "already delivered"}) + ) + runner.invoke(cli, ["whisper", "retrieve", "--whisper-hash", "h1"]) + assert route.call_count == 1 + + @respx.mock + def test_deployment_run_wait_save_persists_one_shot_result( + self, runner, cli, monkeypatch, sample_file, tmp_path ): - 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. `auth login` asks the question once, to decide - whether it may prompt for keys -- never what it prints in.""" - 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 == ["platform_cmd.py"] - text = (source / "commands" / "platform_cmd.py").read_text(encoding="utf-8") - assert text.count("isatty") == 1 and "sys.stdin.isatty()" in text - - 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_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" + """CAPTURE2 BUG 2 - `run --wait --save` must persist the one-shot result on + its single read: no 406, no data loss.""" + monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", FAKE_KEY) + monkeypatch.setenv("UNSTRACT_ORG_ID", "org_test") + base = "https://us-central.unstract.com" + respx.post(f"{base}/deployment/api/org_test/inv/").mock( + return_value=httpx.Response( + 200, json={"message": {"execution_id": "e-9", "execution_status": "PENDING"}} + ) + ) + respx.get(f"{base}/deployment/api/org_test/inv/").mock( + return_value=httpx.Response( + 200, json={"status": "COMPLETED", + "message": [{"file": "bill.pdf", "result": {"invoice_no": "X-1"}}]} + ) + ) + out = tmp_path / "result.json" + result = runner.invoke( + cli, + ["docstudio", "deployment", "run", "--api-name", "inv", + "--file", str(sample_file), "--wait", "--save", str(out), + "--base-url", base], + ) + assert result.exit_code == 0, result.stderr + assert json.loads(out.read_text())["message"][0]["result"]["invoice_no"] == "X-1" + + +class TestDryRun: + def test_prints_request_and_sends_nothing(self, runner, cli, whisper_env, sample_file): + with respx.mock: + route = respx.post(f"{WHISPER_BASE}/whisper") + result = runner.invoke( + cli, ["whisper", "extract", "--file", str(sample_file), "--dry-run"] + ) + assert result.exit_code == 0 + assert route.call_count == 0, "--dry-run must not send the request" + payload = json.loads(result.stdout) + assert payload["method"] == "POST" + assert payload["query"]["mode"] == "form" + + def test_redacts_secrets(self, runner, cli, whisper_env, sample_file): + result = runner.invoke( + cli, ["whisper", "extract", "--file", str(sample_file), "--dry-run"] + ) + assert FAKE_KEY not in result.stdout + assert "***REDACTED***" in result.stdout + + +class TestNoSecretLeaks: + """SPEC §5.7 - a credential must not reach any stream, ever.""" + + @respx.mock + def test_verbose_error_path_stays_clean(self, runner, cli, whisper_env): + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response(401, json={"message": f"bad key {FAKE_KEY}"}) + ) + result = runner.invoke(cli, ["whisper", "usage", "-vv", "--no-retry"]) + assert FAKE_KEY not in result.stdout + assert FAKE_KEY not in result.stderr, "the key leaked through the API's own message" + + +class TestDiscover: + """SPEC §5.3 - the machine-readable index agents discover the CLI through.""" + + def test_valid_json_covering_every_endpoint(self, runner, cli): + # The command list lives at --detail summary (or full); the default is now + # the cheaper groups overview. + result = runner.invoke(cli, ["--discover", "--detail", "summary"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + endpoints = [c for c in data["commands"] if c["kind"] == "endpoint"] + assert len(endpoints) == len(ALL_ENDPOINTS) + + def test_default_is_groups_overview(self, runner, cli): + """The default --discover is the cheap navigable-groups map, not the flat + command list -- ~1k tokens instead of ~4.5k.""" + result = runner.invoke(cli, ["--discover"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["detail"] == "groups" + assert "commands" not in data, "the groups overview must not carry the flat list" + assert data["groups"], "must list navigable groups" + # It stays the entry point, so it keeps the global boilerplate. + assert data["exit_codes"]["9"].startswith("result already consumed") + assert "conventions" in data + # And it is genuinely cheaper than the flat summary. + summary = json.loads( + runner.invoke(cli, ["--discover", "--detail", "summary"]).stdout + ) + assert len(result.stdout) < len(json.dumps(summary)) / 2 + + def test_group_overview_drill_hints_resolve(self, runner, cli): + """The scar this project carries: discovery must never advertise a path the + parser won't honour. Every group's `drill` command must return the count it + advertised.""" + overview = json.loads(runner.invoke(cli, ["--discover"]).stdout) + for g in overview["groups"]: + if g.get("note"): + continue # direct-only lines advertise a partial count by design + filt = g["drill"].removeprefix("unstract --discover --command ") + filt = filt.removesuffix(" --detail summary").strip().strip("'") + got = discover(command=filt) + assert got["count"] == g["commands"], ( + f"{g['group']!r} advertised {g['commands']} but drill returned {got['count']}" + ) + + def test_group_overview_is_drift_free(self, runner, cli): + """Every command reachable in the flat list falls under some overview group, + and the advertised counts sum to the whole surface.""" + overview = json.loads(runner.invoke(cli, ["--discover"]).stdout) + # Counts of non-direct-only group lines partition the command surface. + total = sum(g["commands"] for g in overview["groups"] if not g.get("note")) + # The one direct-only line (docstudio platform's own `share`) adds its leaves. + total += sum(g["commands"] for g in overview["groups"] if g.get("note")) + assert total == overview["command_count"] + + def test_click_to_info_dict_contract(self): + """R3's retirement rests on this exact shape. + + If a Click upgrade reshapes `to_info_dict()`, discovery would silently + degrade -- so the dependency bump must fail here instead. + """ + import click + + info = click.Option( + ["--mode"], type=click.Choice(["a", "b"]), default="a", help="h" + ).to_info_dict() + for key in ("name", "opts", "type", "required", "multiple", "default", "help"): + assert key in info, f"Click no longer reports {key!r}" + assert info["type"]["choices"] == ("a", "b") + + def test_flags_carry_full_metadata(self): + extract = next( + c for c in discover(detail="full")["commands"] + if c["command"] == "unstract whisper extract" + ) + mode = next(f for f in extract["flags"] if f["name"] == "mode") + assert mode["choices"] == ["native_text", "low_cost", "high_quality", "form", "table"] + assert mode["default"] == "form" + assert extract["api"] == {"method": "POST", "path": "/whisper"} + assert extract["supports_wait"] and extract["one_shot"] + + def test_local_commands_flagged(self): + """An agent must be able to tell local operations from API calls.""" + data = discover(detail="full") + local = [c for c in data["commands"] if c["kind"] == "local"] + assert {c["command"] for c in local} >= {"unstract config init", "unstract config use"} + assert all("api" not in c for c in local) + + def test_documents_exit_codes_and_conventions(self): + data = discover() + assert data["exit_codes"]["9"].startswith("result already consumed") + assert "never_interactive" in data["conventions"] + + def test_flag_is_discover_only(self, runner, cli): + """`--dump-commands` was the original name and is fully removed. + + Pinned by a test so it cannot creep back in via a copied example. + """ + assert "--discover" in runner.invoke(cli, ["--help"]).stdout + result = runner.invoke(cli, ["--dump-commands"]) + assert result.exit_code != 0 + + def test_advertised_params_match_the_real_parser(self, cli): + """The index must describe what the parser actually accepts. + + This is the guarantee `--discover` exists to provide. It once failed for + the hand-authored `config` group, which was described by a parallel data + structure rather than introspected: it advertised `--product/--key/--value` + for `config set`, whose parameters are positional. An agent following + that index would build a command line the parser rejects. + """ + mismatches = [] + for entry in discover(detail="full")["commands"]: + node = cli + for part in entry["path"]: + node = node.commands.get(part) if hasattr(node, "commands") else None + if node is None: + break + assert node is not None, f"{entry['command']} is advertised but not registered" + + real = { + p.to_info_dict()["name"]: ( + "argument" + if p.to_info_dict()["param_type_name"] == "argument" + else "option" + ) + for p in node.params + } + for flag in entry["flags"]: + if real.get(flag["name"]) != flag["kind"]: + mismatches.append( + f"{entry['command']}: {flag['name']} advertised as " + f"{flag['kind']}, parser says {real.get(flag['name'])}" + ) + assert not mismatches, "\n".join(mismatches) + + def test_positional_args_are_not_advertised_as_flags(self): + """A positional must never carry a `--flag` spelling.""" + entry = next( + c for c in discover(command="config set", detail="full")["commands"] + ) + arg = next(f for f in entry["flags"] if f["kind"] == "argument") + assert "flags" not in arg, "a positional is not a flag" + # The metavar spells out the real shape; the bare name ("ARGS") would not. + assert entry["usage"] == "unstract config set TARGET... KEY VALUE" + + def test_usage_line_present_for_every_command(self): + """`usage` shows the invocation form, which flags alone cannot convey.""" + for entry in discover(detail="full")["commands"]: + assert entry["usage"].startswith("unstract "), entry["command"] + + def test_choices_reflect_the_real_enum(self): + """Advertised choices must come from the parser, not a copy of it.""" + entry = next( + c for c in discover(command="whisper extract", detail="full")["commands"] + ) + mode = next(f for f in entry["flags"] if f["name"] == "mode") + assert "form" in mode["choices"] and "high_quality" in mode["choices"] + + def test_summary_entries_are_minimal(self): + """At --detail summary each command carries only what's needed to choose + it -- names and one-liners, no flags.""" + data = discover(detail="summary") + assert data["detail"] == "summary" + entry = data["commands"][0] + assert set(entry) <= {"command", "kind", "summary"} + assert "flags" not in entry + + def test_summary_explains_how_to_drill_down(self): + data = discover(detail="summary") + assert "--group" in data["drill_down"]["one_group"] + assert set(data["groups"]) >= {"whisper", "docstudio", "apihub", "config"} + + def test_filter_by_group(self): + data = discover(group="whisper") + assert data["count"] == 11 + assert all(c["command"].startswith("unstract whisper") for c in data["commands"]) + + def test_filter_by_exact_command(self): + data = discover(command="whisper extract", detail="full") + assert data["count"] == 1 + assert data["commands"][0]["api"] == {"method": "POST", "path": "/whisper"} + + def test_filter_by_command_prefix(self): + """A prefix selects a subtree, so `whisper webhook` gets all four.""" + assert discover(command="whisper webhook")["count"] == 4 + + def test_filter_tolerates_unstract_prefix(self): + """`command` values are copied from output, which includes 'unstract '.""" + assert discover(command="unstract whisper extract")["count"] == 1 + + def test_group_and_command_combine(self): + assert discover(group="docstudio", command="docstudio platform adapter")["count"] == 13 + assert discover(group="whisper", command="docstudio platform adapter")["count"] == 0 + + def test_detail_is_independent_of_selection(self): + """Both axes are orthogonal: any combination must work.""" + for group in (None, "whisper"): + for detail in ("summary", "full"): + data = discover(group=group, detail=detail) + assert data["count"] > 0 + assert data["detail"] == detail + + def test_narrow_queries_omit_global_boilerplate(self): + """On a filtered view the exit-code table would outweigh the answer.""" + narrow = discover(group="whisper") + assert "exit_codes" not in narrow + assert "exit_codes" in discover() + + def test_no_match_exits_2_with_parseable_error(self, runner, cli): + """DOC 9 - the error envelope is mirrored to stdout when piped, so a + consumer parsing --discover output sees a valid object, not empty input.""" + result = runner.invoke(cli, ["--discover", "--group", "nope"]) + assert result.exit_code == 2 + assert "hint" in json.loads(result.stdout)["error"] + assert "hint" in json.loads(result.stderr)["error"] + + def test_compact_when_piped(self, runner, cli): + """Pretty-printing costs ~36% more tokens for a consumer that parses it.""" + out = runner.invoke(cli, ["--discover", "--group", "whisper"]).stdout + assert json.loads(out)["count"] == 11 + assert "\n " not in out, "output should be compact when stdout is not a TTY" + + def test_doc_conflict_recorded(self): + """The whisper-detail divergence must be visible, so it is not 'fixed'.""" + detail = next( + c for c in discover(detail="full")["commands"] + if c["command"] == "unstract whisper detail" + ) + assert "singular" in detail["doc_conflict"] + + +class TestHelp: + def test_every_group_renders_help(self, runner, cli): + for group in ("whisper", "docstudio", "apihub", "config"): + result = runner.invoke(cli, [group, "--help"]) + assert result.exit_code == 0, f"{group} --help failed" + + def test_leaf_help_shows_api_and_examples(self, runner, cli): + result = runner.invoke(cli, ["whisper", "extract", "--help"]) + assert "POST /whisper" in result.stdout + assert "Examples:" in result.stdout + + def test_destructive_command_states_permission(self, runner, cli): + result = runner.invoke(cli, ["docstudio", "platform", "workflow", "delete", "--help"]) + assert "full_access" in result.stdout + + def test_one_shot_help_warns(self, runner, cli): + result = runner.invoke(cli, ["whisper", "retrieve", "--help"]) + assert "ONE-SHOT" in result.stdout + + def test_unknown_command_suggests(self, runner, cli): + result = runner.invoke(cli, ["whisperr"]) + assert result.exit_code != 0 + assert "whisper" in result.stderr + + +class TestFlagCoverage: + """SPEC §1.1.3 - every documented parameter must be reachable as a flag.""" + + def test_all_params_surface_as_flags(self, cli): + data = discover(detail="full") + by_command = {c["command"]: c for c in data["commands"]} + for endpoint in ALL_ENDPOINTS: + entry = by_command["unstract " + " ".join(endpoint.command_path)] + exposed = {f["name"] for f in entry["flags"]} + for param in endpoint.params: + assert param.py_name in exposed, ( + f"{endpoint.dotted_name}: {param.name} is not reachable as a flag" + ) + + +class TestConfigCommands: + """M1.5 - hand-authored commands, and never a prompt.""" + + def test_init_writes_0600(self, runner, cli, isolated_env): + result = runner.invoke(cli, ["config", "init"]) + assert result.exit_code == 0 + path = isolated_env / "config.toml" + assert path.exists() + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + + def test_init_refuses_to_clobber_without_force(self, runner, cli): + runner.invoke(cli, ["config", "init"]) + result = runner.invoke(cli, ["config", "init"]) + assert result.exit_code == 2 + assert "--force" in json.loads(result.stderr)["error"]["hint"] + + def test_init_force_overwrites(self, runner, cli): + runner.invoke(cli, ["config", "init"]) + assert runner.invoke(cli, ["config", "init", "--force"]).exit_code == 0 + + def test_use_switches_default_profile(self, runner, cli): + runner.invoke(cli, ["config", "init"]) + assert runner.invoke(cli, ["config", "use", "cloud-eu"]).exit_code == 0 + current = json.loads(runner.invoke(cli, ["config", "current"]).stdout) + assert current["active_profile"] == "cloud-eu" + + def test_use_rejects_unknown_profile(self, runner, cli): + runner.invoke(cli, ["config", "init"]) + result = runner.invoke(cli, ["config", "use", "nope"]) + assert result.exit_code == 2 + + def test_starter_config_contains_no_secrets(self, runner, cli, isolated_env): + runner.invoke(cli, ["config", "init"]) + content = (isolated_env / "config.toml").read_text() + assert "env:" in content + assert "sk-" not in content + + def test_get_never_echoes_a_secret(self, runner, cli, whisper_env): + result = runner.invoke(cli, ["config", "get", "llmwhisperer", "api_key"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["configured"] is True + assert FAKE_KEY not in result.stdout + + @pytest.mark.parametrize( + "target", [["docstudio.platform"], ["docstudio", "platform"]] ) - 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. - 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) - - -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 - - -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 - - -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", + def test_both_separators_address_the_same_group(self, runner, cli, target): + """`docstudio platform` is what a shell user types; the dot is canonical.""" + runner.invoke(cli, ["config", "init"]) + assert runner.invoke( + cli, ["config", "set", *target, "org_id", "org_X"] + ).exit_code == 0 + shown = json.loads( + runner.invoke(cli, ["config", "get", *target, "org_id"]).stdout + ) + assert shown["value"] == "org_X" + + @pytest.mark.parametrize("bare", ["platform", "deployment", "hitl", "whisper"]) + def test_unqualified_target_is_rejected(self, runner, cli, bare): + """A group owned by a product must be named through it. + + A bare `platform` hides which product the setting belongs to, so it is + an error rather than a silent guess. + """ + runner.invoke(cli, ["config", "init"]) + result = runner.invoke(cli, ["config", "set", bare, "org_id", "x"]) + assert result.exit_code == 2 + error = json.loads(result.stderr)["error"] + assert "docstudio.platform" in error["hint"], "the hint must list valid targets" + + @pytest.mark.parametrize( + "block", ["[profiles.p.whisper]", "[profiles.p.platform]", "[profiles.p.hitl]"] ) - 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 + def test_non_canonical_blocks_are_ignored(self, runner, cli, isolated_env, block): + """There is exactly one accepted layout -- no aliases, no flat fallback. + + A block written any other way must NOT be silently picked up: a config + that looks applied but is not surfaces later as a missing-credential + error with no obvious cause. + """ + (isolated_env / "config.toml").write_text( + f'default_profile = "p"\n\n{block}\nbase_url = "https://wrong.example/"\n' + ) + current = json.loads(runner.invoke(cli, ["config", "current"]).stdout) + for settings in current["settings"].values(): + assert settings.get("base_url") != "https://wrong.example/" + + def test_canonical_block_is_read(self, runner, cli, isolated_env): + (isolated_env / "config.toml").write_text( + 'default_profile = "p"\n\n' + "[profiles.p.docstudio.platform]\n" + 'org_id = "org_CANON"\n' + ) + shown = json.loads( + runner.invoke(cli, ["config", "get", "docstudio.platform", "org_id"]).stdout + ) + assert shown["value"] == "org_CANON" + + def test_set_writes_where_get_reads(self, runner, cli, isolated_env): + """A write must land in the block the reader consults. + + Config blocks nest by product ([profiles.X.docstudio.platform]); writing + a flat block instead would be silently ignored on read. + """ + runner.invoke(cli, ["config", "init"]) + assert runner.invoke( + cli, ["config", "set", "docstudio.platform", "org_id", "org_ROUNDTRIP"] + ).exit_code == 0 + + shown = json.loads(runner.invoke(cli, ["config", "get", "docstudio.platform", "org_id"]).stdout) + assert shown["value"] == "org_ROUNDTRIP" + + text = (isolated_env / "config.toml").read_text() + assert "[profiles.cloud-us.docstudio.platform]" in text + assert "[profiles.cloud-us.platform]" not in text, "stray flat block written" + + def test_docstudio_owns_three_api_groups(self, runner, cli, isolated_env): + """Document Studio's groups keep separate credentials and hosts.""" + runner.invoke(cli, ["config", "init"]) + text = (isolated_env / "config.toml").read_text() + for api in ("platform", "deployment", "hitl"): + assert f"[profiles.cloud-us.docstudio.{api}]" in text + + def test_config_flag_selects_a_file(self, runner, cli, tmp_path): + """Several config files can coexist; --config picks one per invocation.""" + alt = tmp_path / "alt.toml" + assert runner.invoke(cli, ["--config", str(alt), "config", "init"]).exit_code == 0 + assert alt.exists() + + shown = json.loads( + runner.invoke(cli, ["--config", str(alt), "config", "path"]).stdout + ) + assert shown["path"] == str(alt) + + def test_config_flag_outranks_env(self, runner, cli, monkeypatch, tmp_path): + env_file, flag_file = tmp_path / "env.toml", tmp_path / "flag.toml" + runner.invoke(cli, ["--config", str(flag_file), "config", "init"]) + monkeypatch.setenv("UNSTRACT_CONFIG", str(env_file)) + + shown = json.loads( + runner.invoke(cli, ["--config", str(flag_file), "config", "path"]).stdout + ) + assert shown["path"] == str(flag_file), "flag must outrank $UNSTRACT_CONFIG" + + def test_project_config_found_from_subdirectory(self, runner, cli, tmp_path, monkeypatch): + """A project's `.unstract.toml` applies to anyone working inside it. + + Found by walking upward, the way git and ruff locate their settings, so a + repo can commit its own hosts and org without every caller passing flags. + """ + monkeypatch.delenv("UNSTRACT_CONFIG", raising=False) + project = tmp_path / "proj" + (project / "deep" / "nested").mkdir(parents=True) + (project / ".unstract.toml").write_text( + 'default_profile = "proj"\n\n' + '[profiles.proj.llmwhisperer]\n' + 'base_url = "https://project.example/api/v2"\n' + ) + monkeypatch.chdir(project / "deep" / "nested") + + shown = json.loads(runner.invoke(cli, ["config", "path"]).stdout) + assert shown["path"] == str(project / ".unstract.toml") + + current = json.loads(runner.invoke(cli, ["config", "current"]).stdout) + assert current["active_profile"] == "proj" + assert current["settings"]["llmwhisperer"]["base_url"].startswith( + "https://project.example" + ) + + def test_project_config_search_stops_at_home(self, monkeypatch, tmp_path): + """A stray file above $HOME must not capture every invocation.""" + from unstract_cli.config.loader import find_project_config + + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + (tmp_path.parent / ".unstract.toml").write_text("") + work = tmp_path / "work" + work.mkdir() + try: + assert find_project_config(work) is None + finally: + (tmp_path.parent / ".unstract.toml").unlink(missing_ok=True) + + def test_profile_selects_region(self, runner, cli, whisper_env): + runner.invoke(cli, ["config", "init"]) + with respx.mock: + route = respx.get( + "https://llmwhisperer-api.eu-west.unstract.com/api/v2/get-usage-info" + ).mock(return_value=httpx.Response(200, json={"plan": "eu"})) + result = runner.invoke(cli, ["whisper", "usage", "--profile", "cloud-eu"]) + assert result.exit_code == 0, result.stderr + assert route.call_count == 1 + + def test_doctor_names_the_unset_env_var(self, runner, cli, isolated_env, monkeypatch): + """DOC 8 - an `env:` ref pointing at a variable absent from THIS process + is the classic silent-auth-failure trap; doctor must name it.""" + monkeypatch.delenv("LLMWHISPERER_API_KEY", raising=False) + (isolated_env / "config.toml").write_text( + 'default_profile = "p"\n\n' + "[profiles.p.llmwhisperer]\n" + 'api_key = "env:LLMWHISPERER_API_KEY"\n' + ) + report = json.loads(runner.invoke(cli, ["config", "doctor", "--no-check"]).stdout) + whisper = next(g for g in report["groups"] if g["target"] == "llmwhisperer") + assert whisper["api_key"]["resolved"] is False + assert "LLMWHISPERER_API_KEY" in whisper["api_key"]["detail"] + + def test_doctor_reports_env_source_when_present(self, runner, cli, isolated_env, whisper_env): + (isolated_env / "config.toml").write_text( + 'default_profile = "p"\n\n' + "[profiles.p.llmwhisperer]\n" + 'api_key = "env:LLMWHISPERER_API_KEY"\n' + ) + report = json.loads(runner.invoke(cli, ["config", "doctor", "--no-check"]).stdout) + whisper = next(g for g in report["groups"] if g["target"] == "llmwhisperer") + assert whisper["api_key"]["resolved"] is True + assert "LLMWHISPERER_API_KEY" in whisper["api_key"]["source"] + + def test_doctor_live_check_reports_auth(self, runner, cli, isolated_env, whisper_env): + (isolated_env / "config.toml").write_text( + 'default_profile = "p"\n\n' + "[profiles.p.llmwhisperer]\n" + 'api_key = "env:LLMWHISPERER_API_KEY"\n' + ) + with respx.mock: + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response(200, json={"plan": "free"}) + ) + report = json.loads(runner.invoke(cli, ["config", "doctor"]).stdout) + whisper = next(g for g in report["groups"] if g["target"] == "llmwhisperer") + assert whisper["live_check"]["ok"] is True + + def test_doctor_never_echoes_the_secret(self, runner, cli, isolated_env): + (isolated_env / "config.toml").write_text( + 'default_profile = "p"\n\n' + "[profiles.p.llmwhisperer]\n" + 'api_key = "sk-literal-secret-value"\n' + ) + out = runner.invoke(cli, ["config", "doctor", "--no-check"]).stdout + assert "sk-literal-secret-value" not in out + assert "literal" in out diff --git a/tests/test_clients.py b/tests/test_clients.py deleted file mode 100644 index f9a8843..0000000 --- a/tests/test_clients.py +++ /dev/null @@ -1,227 +0,0 @@ -"""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, - ConnectTimeout, - ReadTimeout, - TooManyRedirects, -) -from unstract.llmwhisperer.client_v2 import LLMWhispererClientException - -from unstract_cli.config import ResolvedConfig, load_config -from unstract_cli.core.clients import ( - deployment, - deployment_errors, - deployment_url, - raise_for_result, - translated, -) -from unstract_cli.core.errors import CLIError, ExitCode, error_from_status - - -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_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 - 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"}) - - -CONFIG = """ -default_profile = "p" - -[profiles.p.docstudio] -base_url = "https://h/" -org_id = "org_profile" -api_key = "profile-key" - -[profiles.p.deployments."invoice-parser"] -api_key = "entry-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_a_deployment_with_an_entry_runs_with_its_own_key(tmp_path): - client = deployment(_config(tmp_path), "invoice-parser") - assert client.api_url.endswith("/org_profile/invoice-parser/") - assert client.api_key == "entry-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_deployment_without_an_entry_runs_with_the_profile_key(tmp_path): - client = deployment(_config(tmp_path), "some-api") - assert client.api_url.endswith("/org_profile/some-api/") - assert client.api_key == "profile-key" - - -@pytest.mark.parametrize( - "ablate, winner", - [ - ((), "flag-key"), - (("flag",), "env-key"), - (("flag", "env"), "entry-key"), - (("flag", "env", "entry"), "profile-key"), - ], -) -def test_the_key_for_a_run_resolves_flag_env_entry_profile( - tmp_path, monkeypatch, ablate, winner -): - """The per-deployment entry is the most specific value *within* the profile - tier, not a tier above the environment: a file value that beat - `$UNSTRACT_DEPLOYMENT_KEY` would let a stale entry hijack a run the caller - set up for CI. Every source set, then removed one at a time from the top.""" - text = CONFIG if "entry" not in ablate else CONFIG.split("[profiles.p.deployments")[0] - config = _config(tmp_path, text) - if "env" not in ablate: - monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", "env-key") - if "flag" not in ablate: - config.overrides = {"docstudio.api_key": "flag-key"} - - assert deployment(config, "invoice-parser").api_key == winner - - -def test_a_deployment_with_no_key_anywhere_says_where_it_looked(tmp_path): - """The chain walked, and the exact command for each remedy: a caller told - only that a key is missing has to guess which of four places was read.""" - with pytest.raises(CLIError) as caught: - deployment( - _config( - tmp_path, - 'default_profile = "p"\n[profiles.p.docstudio]\norg_id = "org_X"\n', - ), - "invoice-parser", - ) - error = caught.value - assert error.exit_code is ExitCode.USAGE - assert "invoice-parser" in error.message - for source in ( - "--api-key", - "$UNSTRACT_DEPLOYMENT_KEY", - '[profiles.p.deployments."invoice-parser"] api_key', - "[profiles.p.docstudio] api_key", - ): - assert source in error.message, source - assert "config set docstudio api_key --deployment invoice-parser" in error.hint - assert ( - "organisation admin mints a key covering every deployment in the " - "organisation under Settings -> Global API Deployment Keys" in error.hint - ) - - -def test_a_deployment_with_no_organisation_fails_before_the_key_is_read(tmp_path): - 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 "organisation" in caught.value.message - assert "auth login" in caught.value.hint - - -def test_a_rejected_key_is_reported_against_the_deployment(): - """The server's refusal is where "this deployment may need its own key" - becomes true, so that is where it is said -- with the command that stores - one.""" - with pytest.raises(CLIError) as caught, deployment_errors("invoice-parser"): - raise error_from_status(401, "Unauthorized") - error = caught.value - assert error.exit_code is ExitCode.AUTH - assert "invoice-parser" in error.message and "does not authorize" in error.message - assert "config set docstudio api_key --deployment invoice-parser" in error.hint - - -def test_an_unknown_deployment_is_pointed_at_the_listing(): - """An API name is editable server-side, so a name that was right can stop - being right without anything local changing.""" - with pytest.raises(CLIError) as caught, deployment_errors("invoice-parser"): - raise error_from_status(404, "Not found") - assert "deployment ls" in caught.value.hint - - -def test_other_failures_pass_through_the_deployment_context_untouched(): - with pytest.raises(CLIError) as caught, deployment_errors("invoice-parser"): - raise error_from_status(500, "boom") - assert caught.value.message == "boom" diff --git a/tests/test_commands.py b/tests/test_commands.py deleted file mode 100644 index 5614cd5..0000000 --- a/tests/test_commands.py +++ /dev/null @@ -1,3979 +0,0 @@ -"""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 os -import socket -import stat -import sys - -import click -import httpx -import pytest -from requests.exceptions import ConnectionError, InvalidHeader, MissingSchema -from unstract.api_deployments.client import PlatformClientError -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 ( - LLMWhispererClientException, - LLMWhispererClientV2, -) - -from unstract_cli.__main__ import main -from unstract_cli.app import command_tree -from unstract_cli.commands import clone_cmd, docstudio_cmd, platform_cmd, whisper_cmd -from unstract_cli.config import DOCSTUDIO, LLMWHISPERER -from unstract_cli.core.errors import CLIError, ExitCode - - -def run(capsys, *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 - - -def envelope(out: str) -> dict: - return json.loads(out) - - -def _name_resolution_error(host: str) -> ConnectionError: - """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: - """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) - # 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 - - -@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/" - 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 - - -@pytest.fixture -def platform_client(monkeypatch): - """Install a fake Platform API client and hand it back to the test.""" - - def install(**replies): - client = FakeWhisper(**replies) - client.built_with = {} - - def build(config, org_id=None, *, timeout=None): - # Resolving the key is what registers it for scrubbing, so the fake - # factory has to do it too or the seam hides a production path. - # The signature tracks the real `platform_client` deliberately: a - # fixture that drifts from it passes while testing nothing. - client.built_with["api_key"] = config.get(DOCSTUDIO, "platform_key") - client.built_with["org_id"] = org_id - client.built_with["timeout"] = timeout - return client - - monkeypatch.setattr(platform_cmd, "platform_client", build) - 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"]) == { - "ls", - "run", - "status", - } - assert set(tree["auth"]["commands"]) == {"login", "whoami"} - - -@pytest.mark.parametrize( - ("command", "flag"), - [ - (("whisper", "extract"), "--derotate-threshold"), - (("whisper", "extract"), "--min-table-width"), - (("whisper", "extract"), "--ignore-vertical-text"), - (("docstudio", "deployment", "run"), "--use-file-history"), - ], -) -def test_a_withdrawn_option_is_unknown_to_the_shipped_command(capsys, command, flag): - """The services stopped publishing these; a caller still passing one must - learn that before the request, not from a silently ignored setting.""" - code, out, _ = run(capsys, *command, "x.pdf", flag) - assert code == int(ExitCode.USAGE) - message = envelope(out)["error"]["message"] - assert "No such option" in message and flag in message - - -@pytest.mark.parametrize( - ("flag", "value"), - [("--output-mode", "line-printer"), ("--mode", "excel")], -) -def test_a_withdrawn_choice_is_refused_by_the_shipped_command(capsys, flag, value): - code, out, _ = run(capsys, "whisper", "extract", "x.pdf", flag, value) - assert code == int(ExitCode.USAGE) - assert f"'{value}' is not one of" in envelope(out)["error"]["message"] - - -# --------------------------------------------------------------------------- # -# 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.1") - - 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.1" - ) - assert out.strip() == "hello" - - -def test_raw_retrieve_of_an_empty_extraction_prints_nothing_and_succeeds( - capsys, whisper_client -): - whisper_client(whisper_retrieve={"extraction": {"result_text": ""}}) - - code, out, _ = run(capsys, "-q", "-o", "raw", "whisper", "retrieve", "h1") - - assert code == int(ExitCode.SUCCESS) - assert out == "\n" - - -def test_raw_extract_of_an_empty_document_prints_empty_text_not_the_hash( - capsys, whisper_client, tmp_path -): - doc = tmp_path / "blank.pdf" - doc.write_bytes(b"%PDF-") - whisper_client( - whisper={"whisper_hash": "h1"}, - whisper_status={"status": "processed"}, - whisper_retrieve={"extraction": {"result_text": ""}}, - ) - - _, out, _ = run( - capsys, "-q", "-o", "raw", "whisper", "extract", str(doc), "--interval", "0.1" - ) - - assert out == "\n" - - -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.1") - assert code == int(ExitCode.VALIDATION) - 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.1") - assert code == int(ExitCode.SERVER_ERROR) - 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.1") - assert code == int(ExitCode.SERVER_ERROR) - 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_a_body_that_is_not_json_is_a_server_failure_not_a_crash(capsys, whisper_client): - """A proxy or web-app host answers 200 with HTML, which the client parses - itself; untranslated it reaches the entry point as a crash.""" - whisper_client(get_usage_info=json.JSONDecodeError("Expecting value", "", 0)) - - code, out, _ = run(capsys, "whisper", "usage") - - assert code == int(ExitCode.SERVER_ERROR) - assert "base_url" in envelope(out)["error"]["hint"] - - -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_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 -): - 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]} - - -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"] - - -@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") - 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 -# --------------------------------------------------------------------------- # - - -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.1", - ) - - assert code == int(ExitCode.SUCCESS) - assert client.kwargs_for("structure_file")["timeout"] == 0 - 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.1", - ) - - 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"), - [ - ([], 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 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( - 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-") - 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_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" - - -@pytest.mark.parametrize( - "argv", - [ - ("docstudio", "deployment", "status", "invoice-parser", "e-1"), - ("docstudio", "deployment", "run", "invoice-parser", "DOC", "--no-wait"), - ], - ids=["status", "run"], -) -def test_a_rejected_key_names_the_deployment_and_how_to_give_it_its_own( - capsys, deployment_client, tmp_path, argv -): - """A 401 is the first moment "this deployment may need its own key" is - known to be true, so the hint that says so has to be reachable from both - commands that send one.""" - doc = tmp_path / "doc.pdf" - doc.write_bytes(b"%PDF") - deployment_client( - check_execution_status={"status_code": 401, "error": "Unauthorized"}, - structure_file={"status_code": 401, "error": "Unauthorized"}, - ) - code, out, _ = run(capsys, *(str(doc) if a == "DOC" else a for a in argv)) - assert code == int(ExitCode.AUTH) - error = envelope(out)["error"] - assert "invoice-parser" in error["message"] - assert "does not authorize" in error["message"] - assert ( - "config set docstudio api_key --deployment invoice-parser" - in (error["hint"]) - ) - - -REJECTED_KEY_CONFIG = """ -default_profile = "p" -[profiles.p.docstudio] -org_id = "org_X" -api_key = "dk-profile-000001" -[profiles.p.deployments."invoice-parser"] -api_key = "dk-entry-0000001" -""" - - -@pytest.mark.parametrize( - ("argv", "expected"), - [ - pytest.param( - ("docstudio", "deployment", "status", "invoice-parser", "e-1"), - "The key stored for 'invoice-parser' was rejected", - id="the deployment's own entry", - ), - pytest.param( - ("docstudio", "deployment", "status", "receipt-parser", "e-1"), - "may need a key of its own", - id="the profile key", - ), - pytest.param( - ( - "docstudio", - "--api-key", - "dk-flag-00000001", - "deployment", - "status", - "invoice-parser", - "e-1", - ), - "passed with --api-key", - id="the flag", - ), - ], -) -def test_a_rejected_key_is_reported_where_it_came_from( - capsys, deployment_client, monkeypatch, tmp_path, argv, expected -): - """Telling the caller whose per-deployment key was just rejected to store a - per-deployment key names the step that has already failed.""" - _config_with(tmp_path, monkeypatch, REJECTED_KEY_CONFIG) - deployment_client(check_execution_status={"status_code": 401, "error": "no"}) - - code, out, _ = run(capsys, *argv) - - assert code == int(ExitCode.AUTH) - assert expected in envelope(out)["error"]["hint"] - - -def test_a_rejected_key_from_the_environment_names_the_variable( - capsys, deployment_client, monkeypatch, tmp_path -): - """The variable outranks both stored keys, so editing either changes - nothing until it is unset.""" - _config_with(tmp_path, monkeypatch, REJECTED_KEY_CONFIG) - monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", "dk-env-000000001") - deployment_client(check_execution_status={"status_code": 401, "error": "no"}) - - code, out, _ = run( - capsys, "docstudio", "deployment", "status", "invoice-parser", "e-1" - ) - - assert code == int(ExitCode.AUTH) - assert "$UNSTRACT_DEPLOYMENT_KEY was rejected" in envelope(out)["error"]["hint"] - - -def test_an_unknown_api_name_is_pointed_at_the_listing(capsys, deployment_client): - """A misspelt or renamed API name comes back not-found, and the server is - the only authority on what the current names are.""" - 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) - assert "deployment ls" in envelope(out)["error"]["hint"] - - -def test_a_status_read_of_a_consumed_result_has_its_own_exit_code( - capsys, deployment_client -): - """A deployment hands its result over once, so a 406 from the status read - means it is gone rather than that the request was malformed.""" - deployment_client( - check_execution_status={"status_code": 406, "error": "already retrieved"} - ) - code, out, _ = run(capsys, "docstudio", "deployment", "status", "my-api", "e-1") - assert code == int(ExitCode.ALREADY_CONSUMED) - assert "--save" in envelope(out)["error"]["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": "", - "status_check_api_endpoint": "/deployment/api/status?execution_id=e-1", -} - -PENDING_STATUS = { - "status_code": 422, - "pending": True, - "execution_status": "EXECUTING", - "extraction_result": "", -} - -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_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 -): - """`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") - 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.""" - 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] - - -@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.1", - "--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_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.1", - ) - 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 -): - 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.1", - "--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 -# --------------------------------------------------------------------------- # - - -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, transport_timeout=None: ( - 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 - - -# --------------------------------------------------------------------------- # -# 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.1") - - 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.1") - - 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.1") - - 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 - - -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), - PhaseResult(name="files", created=1, skipped=3), - ], - oversize_files=[{"name": "big.pdf"}, {"name": "bigger.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") - - 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", - "--on-name-conflict", - "abort", - ) - - 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 - # 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_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. - - 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 - - -# --------------------------------------------------------------------------- # -# auth whoami -# --------------------------------------------------------------------------- # - -IDENTITY = { - "organization_id": "org_ABC123", - "organization_name": "Acme", - "permission": "read", - "key_name": "ci", -} - - -def _platform_env(monkeypatch, tmp_path): - """A resolvable platform key, and a config file of our own to write into.""" - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "config.toml")) - - -def test_whoami_reports_the_identity_the_service_returned( - capsys, platform_client, monkeypatch, tmp_path -): - _platform_env(monkeypatch, tmp_path) - platform_client(whoami=IDENTITY) - - code, out, _ = run(capsys, "auth", "whoami") - - assert code == int(ExitCode.SUCCESS) - assert envelope(out)["data"] == IDENTITY - - -def test_a_fresh_install_is_pointed_at_login_rather_than_at_a_file(capsys): - code, _, err = run(capsys, "auth", "whoami") - - assert code == int(ExitCode.USAGE) - assert "run `unstract auth login`" in err - - -def test_whoami_is_called_with_no_organisation( - capsys, platform_client, monkeypatch, tmp_path -): - """Resolving the organisation is the point, so requiring one would be - circular.""" - _platform_env(monkeypatch, tmp_path) - client = platform_client(whoami=IDENTITY) - - run(capsys, "auth", "whoami") - - assert client.built_with["org_id"] is None - assert client.built_with["api_key"] == "pk-123" - - -def test_whoami_stores_the_organisation_where_everything_else_reads_it( - capsys, platform_client, monkeypatch, tmp_path -): - _platform_env(monkeypatch, tmp_path) - platform_client(whoami=IDENTITY) - - _, out, _ = run(capsys, "auth", "whoami") - - assert envelope(out)["meta"]["saved"] is True - # Read back through the CLI rather than out of the file: what matters is - # that the next command resolves it, not where the bytes landed. - _, out, _ = run(capsys, "config", "get", "docstudio", "org_id") - assert envelope(out)["data"]["value"] == "org_ABC123" - - -def test_whoami_can_validate_without_writing_anything( - capsys, platform_client, monkeypatch, tmp_path -): - _platform_env(monkeypatch, tmp_path) - platform_client(whoami=IDENTITY) - - _, out, _ = run(capsys, "auth", "whoami", "--no-save") - - assert envelope(out)["meta"]["saved"] is False - assert not (tmp_path / "config.toml").exists() - - -def test_a_rejected_platform_key_exits_on_the_auth_code( - capsys, platform_client, monkeypatch, tmp_path -): - """A traceback here would mean the Platform API's own exception type never - reached the translator.""" - _platform_env(monkeypatch, tmp_path) - platform_client(whoami=PlatformClientError("whoami failed with 401: nope")) - - code, out, _ = run(capsys, "auth", "whoami") - - assert code == int(ExitCode.AUTH) - assert envelope(out)["ok"] is False - - -def test_whoami_stores_nothing_when_no_organisation_comes_back( - capsys, platform_client, monkeypatch, tmp_path -): - """The key was accepted but resolved nothing to store, which the next - command fails on -- so it is said rather than reported as a save.""" - _platform_env(monkeypatch, tmp_path) - platform_client(whoami={"organization_name": "Acme"}) - - code, out, err = run(capsys, "auth", "whoami") - meta = envelope(out)["meta"] - - assert code == int(ExitCode.SUCCESS) - assert (meta["saved"], meta["reason"]) == (False, "no organization_id") - assert "no organization_id" in err - assert not (tmp_path / "config.toml").exists() - - -def test_whoami_without_a_key_is_a_usage_error(capsys, monkeypatch, tmp_path): - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "config.toml")) - code, out, _ = run(capsys, "auth", "whoami") - - assert code == int(ExitCode.USAGE) - assert "UNSTRACT_PLATFORM_KEY" in json.dumps(envelope(out)["error"]) - - -# --------------------------------------------------------------------------- # -# docstudio deployment ls -# --------------------------------------------------------------------------- # - - -def _page(*rows, count=None, next_url=None) -> dict: - """The paginated envelope `list_deployments` returns. - - The old `list_api_deployments` returned a flat list; the generated operation - returns `{count, next, previous, results}`, and `ls` reports the server's - total separately from what it shows. - """ - return { - "count": count if count is not None else len(rows), - "next": next_url, - "previous": None, - "results": list(rows), - } - - -DEPLOYMENT_ROW = { - "api_name": "invoice-parser", - "display_name": "Invoices", - "id": "dep-1", - "is_active": True, - "api_endpoint": "https://example.com/deployment/api/org/invoice-parser/", - "created_by_email": "someone@example.com", - "last_5_run_statuses": [], -} - - -def _returns(value): - """Queue one reply whose value is itself a list. - - `FakeWhisper` reads a list reply as a queue of replies, so a bare list would - hand back its first row rather than the listing. - """ - return [value] - - -def _listing_env(monkeypatch, tmp_path): - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - monkeypatch.setenv("UNSTRACT_ORG_ID", "org_ABC123") - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "config.toml")) - - -def test_ls_narrows_the_row_to_what_a_caller_can_read( - capsys, platform_client, monkeypatch, tmp_path -): - _listing_env(monkeypatch, tmp_path) - platform_client(list_deployments=_returns(_page(DEPLOYMENT_ROW))) - - _, out, _ = run(capsys, "docstudio", "deployment", "ls") - - (row,) = envelope(out)["data"]["results"] - assert set(row) == set(platform_cmd.LISTING_FIELDS) - assert row["api_name"] == "invoice-parser" - - -def test_ls_can_return_every_field_the_server_sent( - capsys, platform_client, monkeypatch, tmp_path -): - _listing_env(monkeypatch, tmp_path) - platform_client(list_deployments=_returns(_page(DEPLOYMENT_ROW))) - - _, out, _ = run(capsys, "docstudio", "deployment", "ls", "--full") - - (row,) = envelope(out)["data"]["results"] - assert row == DEPLOYMENT_ROW - - -def test_ls_reports_the_servers_total_apart_from_what_it_shows( - capsys, platform_client, monkeypatch, tmp_path -): - """The listing is paginated and this command does not follow the pages, so - `count` (the server's total) and `shown` (this page) are different numbers. - Reporting one as the other would tell a caller with more deployments than a - page that they had seen everything. - """ - _config_with( - tmp_path, - monkeypatch, - 'default_profile = "cloud-us"\n[profiles.cloud-us.docstudio]\norg_id = "org_X"\n', - ) - platform_client( - list_deployments=_returns( - _page(DEPLOYMENT_ROW, count=37, next_url="iframe.php?url=https%3A%2F%2Fh%2Fnext%3Fpage%3D2") - ) - ) - - code, out, _ = run(capsys, "docstudio", "deployment", "ls") - meta = envelope(out)["meta"] - - assert code == 0 - assert meta["shown"] == 1 - assert meta["count"] == 37 - assert meta["more"] is True - - -def test_ls_says_there_is_no_more_when_the_page_is_the_whole_set( - capsys, platform_client, monkeypatch, tmp_path -): - _config_with( - tmp_path, - monkeypatch, - 'default_profile = "cloud-us"\n[profiles.cloud-us.docstudio]\norg_id = "org_X"\n', - ) - platform_client(list_deployments=_returns(_page(DEPLOYMENT_ROW))) - - code, out, _ = run(capsys, "docstudio", "deployment", "ls") - meta = envelope(out)["meta"] - - assert code == 0 - assert (meta["shown"], meta["count"], meta["more"]) == (1, 1, False) - - -def test_ls_survives_a_page_with_no_results_key( - capsys, platform_client, monkeypatch, tmp_path -): - """`results` is declared required, but the facade returns whatever JSON the - server sent. Absent, the projection used to raise `TypeError` on `None`. - """ - _config_with( - tmp_path, - monkeypatch, - 'default_profile = "cloud-us"\n[profiles.cloud-us.docstudio]\norg_id = "org_X"\n', - ) - platform_client(list_deployments=_returns({"count": 0, "next": None})) - - code, out, _ = run(capsys, "docstudio", "deployment", "ls") - - assert code == 0 - assert envelope(out)["data"]["results"] == [] - - -def test_a_listing_that_is_not_a_list_is_a_protocol_failure( - capsys, platform_client, monkeypatch, tmp_path -): - """A login page or proxy answering in the API's place is not an account - with no deployments, and projecting its body would crash on the first row. - """ - _listing_env(monkeypatch, tmp_path) - platform_client(list_deployments=_returns({"results": "Sign in"})) - - code, out, _ = run(capsys, "docstudio", "deployment", "ls") - error = envelope(out)["error"] - - assert code == int(ExitCode.SERVER_ERROR) - assert "base_url" in error["hint"] - assert error["details"]["results"] == "Sign in" - - -def test_ls_passes_the_name_filter_to_the_server( - capsys, platform_client, monkeypatch, tmp_path -): - """Filtering here rather than locally: the server has the exact-match - filter, and a local one would still page the whole organisation.""" - _listing_env(monkeypatch, tmp_path) - client = platform_client(list_deployments=_returns(_page(DEPLOYMENT_ROW))) - - run(capsys, "docstudio", "deployment", "ls", "--api-name", "invoice-parser") - - assert client.kwargs_for("list_deployments") == {"api_name": "invoice-parser"} - - -def test_ls_runs_inside_the_configured_organisation( - capsys, platform_client, monkeypatch, tmp_path -): - _listing_env(monkeypatch, tmp_path) - client = platform_client(list_deployments=_returns(_page())) - - run(capsys, "docstudio", "deployment", "ls") - - _, args, _ = next(call for call in client.calls if call[0] == "list_deployments") - # The factory takes an organisation and ignores it: the listing call is - # where the wrong one would actually reach the server. - assert args[0] == "org_ABC123" - - -@pytest.mark.parametrize( - "argv", - [ - ["auth", "--base-url", "localhost:8000", "whoami"], - ["docstudio", "--base-url", "localhost:8000", "deployment", "ls"], - ], - ids=["whoami", "ls"], -) -def test_a_client_that_cannot_be_built_is_an_envelope_not_a_traceback( - capsys, monkeypatch, tmp_path, argv -): - """The real factory, not the fixture: the client validates the host before - it sends anything, and every test that replaces the factory replaces that - check with it.""" - _listing_env(monkeypatch, tmp_path) - - code, out, _ = run(capsys, *argv) - - assert code == int(ExitCode.USAGE) - assert envelope(out)["ok"] is False - - -def test_ls_without_an_organisation_says_how_to_get_one( - capsys, platform_client, monkeypatch, tmp_path -): - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "config.toml")) - platform_client(list_deployments=_returns(_page())) - - code, out, _ = run(capsys, "docstudio", "deployment", "ls") - - assert code == int(ExitCode.USAGE) - assert "whoami" in json.dumps(envelope(out)["error"]) - - -# --------------------------------------------------------------------------- # -# auth whoami — where it writes, and what happens when it cannot -# --------------------------------------------------------------------------- # - - -def _config_with(tmp_path, monkeypatch, text): - path = tmp_path / "config.toml" - path.write_text(text, encoding="utf-8") - monkeypatch.setenv("UNSTRACT_CONFIG", str(path)) - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - return path - - -def test_whoami_writes_to_the_profile_the_run_is_actually_using( - capsys, platform_client, monkeypatch, tmp_path -): - """Reads resolve through `active_profile` (flag > env > file default). - Re-deriving that chain here dropped the env tier, so the organisation was - written into a profile no later command reads -- and `deployment ls` then - failed immediately after a `whoami` reporting `saved: true`. - """ - path = _config_with( - tmp_path, - monkeypatch, - 'default_profile = "cloud-us"\n' - '[profiles.cloud-us.docstudio]\norg_id = ""\n' - '[profiles.cloud-eu.docstudio]\norg_id = ""\n', - ) - monkeypatch.setenv("UNSTRACT_PROFILE", "cloud-eu") - platform_client(whoami=IDENTITY) - - _, out, _ = run(capsys, "auth", "whoami") - - assert envelope(out)["meta"]["profile"] == "cloud-eu" - assert 'org_id = "org_ABC123"' in path.read_text().split("[profiles.cloud-eu")[1] - - -def test_whoami_refuses_to_invent_a_profile_that_does_not_exist( - capsys, platform_client, monkeypatch, tmp_path -): - """`setdefault` created it. That silently disarmed the "Profile not found" - guard for every later command, which then resolved the built-in production - defaults instead -- from a single typo, permanently. - """ - _config_with( - tmp_path, - monkeypatch, - 'default_profile = "cloud-us"\n[profiles.cloud-us.docstudio]\norg_id = ""\n', - ) - platform_client(whoami=IDENTITY) - - code, out, _ = run(capsys, "-p", "cloud-uss", "auth", "whoami") - - # SAVE_FAILED, not USAGE: the key resolved and only the note-taking failed, - # so the identity comes back in `details` rather than being discarded. - error = envelope(out)["error"] - assert code == int(ExitCode.SAVE_FAILED) - assert "cloud-uss" in json.dumps(error) - assert error["details"]["organization_id"] == IDENTITY["organization_id"] - - -def test_whoami_writes_to_the_only_profile_when_no_default_is_named( - capsys, platform_client, monkeypatch, tmp_path -): - """The unknown-profile guard fired on the literal "cloud-us" fallback -- a - name the caller never typed -- for any file with profiles and no - `default_profile`, and advised creating a third that would shadow theirs. - """ - path = _config_with( - tmp_path, - monkeypatch, - '[profiles.work.docstudio]\norg_id = ""\n', - ) - platform_client(whoami=IDENTITY) - - code, out, _ = run(capsys, "auth", "whoami") - - assert code == 0 - assert envelope(out)["meta"]["profile"] == "work" - written = path.read_text(encoding="utf-8") - assert f'org_id = "{IDENTITY["organization_id"]}"' in written - assert 'default_profile = "work"' in written - - -def test_whoami_will_not_guess_between_several_unselected_profiles( - capsys, platform_client, monkeypatch, tmp_path -): - """Two profiles and no default: writing into either would be a guess. It - says so and hands the identity back, rather than naming `cloud-us`. - """ - _config_with( - tmp_path, - monkeypatch, - '[profiles.work.docstudio]\norg_id = ""\n[profiles.home.docstudio]\norg_id = ""\n', - ) - platform_client(whoami=IDENTITY) - - code, out, _ = run(capsys, "auth", "whoami") - error = envelope(out)["error"] - - assert code == int(ExitCode.SAVE_FAILED) - assert "cloud-us" not in json.dumps(error) - assert "-p " in json.dumps(error) - assert error["details"]["organization_id"] == IDENTITY["organization_id"] - - -def test_whoami_keeps_the_identity_when_the_write_fails( - capsys, platform_client, monkeypatch, tmp_path -): - """The read succeeded and only the convenience write failed. Losing the - identity to a full disk would report a working key as a total failure, on - an exit code that means "you invoked it wrong". - """ - _config_with( - tmp_path, - monkeypatch, - 'default_profile = "cloud-us"\n[profiles.cloud-us.docstudio]\norg_id = ""\n', - ) - platform_client(whoami=IDENTITY) - monkeypatch.setattr( - platform_cmd, - "save_config", - lambda *a, **k: (_ for _ in ()).throw(OSError(13, "nope")), - ) - - code, out, _ = run(capsys, "auth", "whoami") - error = envelope(out)["error"] - - assert code == int(ExitCode.SAVE_FAILED) - # Not full equality: `redact_value` masks any field whose name looks - # secret, and `key_name` matches. The organisation is the part the caller - # needs in order to carry on without the write. - assert error["details"]["organization_id"] == IDENTITY["organization_id"] - assert error["details"]["key_name"] == "***REDACTED***" - - -def test_whoami_does_not_rewrite_a_discovered_project_config( - capsys, platform_client, monkeypatch, tmp_path -): - """A `.unstract.toml` found by walking up is very likely committed. Writing - it replaced a teammate's org_id, dropped every comment and narrowed the mode - -- from a command named `whoami`, with no flag asked for. - """ - project = tmp_path / "repo" - project.mkdir() - (project / ".unstract.toml").write_text( - '# hand written\ndefault_profile = "team"\n[profiles.team.docstudio]\norg_id = "org_TEAM"\n', - encoding="utf-8", - ) - monkeypatch.chdir(project) - monkeypatch.delenv("UNSTRACT_CONFIG", raising=False) - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - platform_client(whoami=IDENTITY) - - code, out, envelope_err = run(capsys, "auth", "whoami") - body = envelope(out) - - # Declining the write is not failing the call: a committed - # `.unstract.toml` is supported, and this is the first command a new user - # runs, so failing it would discard the identity with the write. - assert code == 0 - assert body["data"]["organization_id"] == IDENTITY["organization_id"] - assert body["meta"]["saved"] is False - assert "project-local" in body["meta"]["reason"] - assert "# hand written" in (project / ".unstract.toml").read_text() - assert "org_TEAM" in (project / ".unstract.toml").read_text() - - -@pytest.mark.parametrize( - ("argv", "expected"), - [ - (["auth", "whoami"], 120.0), - (["auth", "--transport-timeout", "12.5", "whoami"], 12.5), - (["auth", "--transport-timeout", "0", "whoami"], None), - (["docstudio", "deployment", "ls"], 120.0), - (["docstudio", "--transport-timeout", "12.5", "deployment", "ls"], 12.5), - (["docstudio", "--transport-timeout", "0", "deployment", "ls"], None), - ], -) -def test_the_transport_timeout_flag_reaches_the_platform_client( - capsys, platform_client, monkeypatch, tmp_path, argv, expected -): - """The flag was accepted on both groups and threaded through the factory, - but nothing asserted the commands passed it: deleting either call site left - the suite green. The fixture recorded the value and no test read it. - """ - _config_with( - tmp_path, - monkeypatch, - 'default_profile = "cloud-us"\n[profiles.cloud-us.docstudio]\norg_id = "org_X"\n', - ) - client = platform_client(whoami=IDENTITY, list_deployments=_returns(_page())) - - code, _, _ = run(capsys, *argv) - - assert code == 0 - assert client.built_with["timeout"] == expected - - -@pytest.mark.parametrize("value", ["-1", "-0.5"]) -def test_a_negative_transport_timeout_is_a_usage_error_not_a_traceback( - capsys, monkeypatch, tmp_path, value -): - """A bound below zero is refused at the flag, where it is still a usage - error about something the caller typed.""" - _config_with(tmp_path, monkeypatch, "") - - code, out, _ = run(capsys, "auth", "--transport-timeout", value, "whoami") - - assert code == int(ExitCode.USAGE) - assert "transport-timeout" in envelope(out)["error"]["message"] - - -def test_a_deployment_key_flag_is_refused_rather_than_ignored_by_ls( - capsys, platform_client, monkeypatch, tmp_path -): - """`--api-key` on the docstudio group is a *deployment* key and `ls` - authenticates with a platform key. It was accepted, dropped, and the - platform key then reported missing -- which reads as a broken flag rather - than the wrong credential. - """ - _config_with( - tmp_path, - monkeypatch, - 'default_profile = "cloud-us"\n[profiles.cloud-us.docstudio]\norg_id = "org_X"\n', - ) - platform_client(list_deployments=_returns(_page())) - - code, out, _ = run( - capsys, "docstudio", "--api-key", "dk-FROM-FLAG", "deployment", "ls" - ) - error = envelope(out)["error"] - - assert code == int(ExitCode.USAGE) - assert "platform key" in error["message"] - assert "UNSTRACT_PLATFORM_KEY" in error["hint"] - assert "dk-FROM-FLAG" not in json.dumps(envelope(out)) - - -@pytest.mark.parametrize( - "args", - [ - ("auth", "--platform-key", "pk-FROM-FLAG-0123", "whoami", "--no-save"), - ("docstudio", "--platform-key", "pk-FROM-FLAG-0123", "deployment", "ls"), - ], -) -def test_a_platform_key_flag_reaches_the_client_and_is_warned_about( - capsys, platform_client, monkeypatch, tmp_path, args -): - """Both groups that run platform-key commands take the key as a flag, and a - key on the command line gets the same shell-history warning as `--api-key`. - """ - _config_with( - tmp_path, - monkeypatch, - 'default_profile = "cloud-us"\n[profiles.cloud-us.docstudio]\norg_id = "org_X"\n', - ) - client = platform_client(whoami=IDENTITY, list_deployments=_returns(_page())) - - code, out, err = run(capsys, *args) - - assert code == int(ExitCode.SUCCESS) - assert client.built_with["api_key"] == "pk-FROM-FLAG-0123" - assert "shell history" in err - assert "pk-FROM-FLAG-0123" not in out - - -def test_the_platform_key_never_reaches_a_stream( - capsys, platform_client, monkeypatch, tmp_path -): - """A refusal's reason comes from the server, so if the far end echoes the key - back it travels to stdout inside `error.message`. - - `PlatformClientError` carries no body attribute -- the released client folds - the reason into its message -- so that is now the only path, and the scrubber - is the only thing standing on it. - """ - _config_with(tmp_path, monkeypatch, 'default_profile = "cloud-us"\n') - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-SUPERSECRET-0987654321") - platform_client( - whoami=PlatformClientError( - 'whoami failed with 401: {"echoed": "pk-SUPERSECRET-0987654321"}' - ) - ) - - _, out, err = run(capsys, "auth", "whoami") - - assert "pk-SUPERSECRET-0987654321" not in out - assert "pk-SUPERSECRET-0987654321" not in err - - -def test_a_rejected_key_exits_auth_not_usage( - capsys, platform_client, monkeypatch, tmp_path -): - """`PlatformClientError` derives from `APIDeploymentsClientException`, whose - arm maps everything to USAGE, and the released client carries no status -- - only the prose `"whoami failed with 401: ..."`. Caught by the base arm a - rejected key would exit 2, contradicting the README's exit-code table and any - setup script branching on 3. - """ - _config_with(tmp_path, monkeypatch, 'default_profile = "cloud-us"\n') - platform_client(whoami=PlatformClientError("whoami failed with 401: nope")) - - code, out, _ = run(capsys, "auth", "whoami") - error = envelope(out)["error"] - - assert code == int(ExitCode.AUTH) - assert error["exit_code"] == int(ExitCode.AUTH) - assert "\n" not in error["message"] - - -@pytest.mark.parametrize( - ("message", "expected"), - [ - ("whoami failed with 401: bad key", ExitCode.AUTH), - ("whoami failed with 403: not yours", ExitCode.AUTH), - ("list_deployments failed with 404: gone", ExitCode.NOT_FOUND), - ("list_deployments failed with 429: slow down", ExitCode.RATE_LIMITED), - ("whoami failed with 500: boom", ExitCode.SERVER_ERROR), - # No status in the message: the failure is real and its status unknown, - # so it is reported as server-side rather than as the caller's mistake. - ("whoami returned something unreadable", ExitCode.SERVER_ERROR), - ], -) -def test_the_platform_status_is_recovered_from_the_message( - capsys, platform_client, monkeypatch, tmp_path, message, expected -): - """Pins the parse. The released client embeds the status in prose, so an - upstream wording change silently costs every one of these mappings -- this - is what would catch it. - """ - _config_with(tmp_path, monkeypatch, 'default_profile = "cloud-us"\n') - platform_client(whoami=PlatformClientError(message)) - - code, _, _ = run(capsys, "auth", "whoami") - - assert code == int(expected), message - - -# --------------------------------------------------------------------------- # -# auth login -# --------------------------------------------------------------------------- # - -PK, DK, LK = "pk-platform-000001", "dk-deployment-0001", "lk-whisperer-00001" - - -@pytest.fixture -def login_seams(monkeypatch, platform_client, tmp_path): - """Both client factories faked, prompts scripted, and a config file of our own. - - Returns a function that scripts the terminal: `answers` are what each prompt - returns in order, `confirm` what the yes/no questions return (one value for - all of them, or a list consumed in order), and `tty` whether stdin counts - as a terminal at all. - """ - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "config.toml")) - monkeypatch.delenv("UNSTRACT_PLATFORM_KEY", raising=False) - state = {"prompts": [], "answers": []} - - def prompt(text, **kwargs): - state["prompts"].append(text) - state["defaults"].append(kwargs.get("default")) - while True: - answer = state["answers"].pop(0) - if answer == "" and kwargs.get("default"): - answer = kwargs["default"] - try: - return kwargs.get("value_proc", lambda value: value)(answer) - except click.UsageError as exc: - click.echo(f"Error: {exc.message}", err=True) - - def install(answers=(), *, confirm=False, tty=True, whoami=None, usage=None): - state["answers"], state["prompts"], state["defaults"] = list(answers), [], [] - monkeypatch.setattr(platform_cmd, "_interactive", lambda: tty) - monkeypatch.setattr(platform_cmd, "_prompt", prompt) - confirms = list(confirm) if isinstance(confirm, list) else None - - def confirm_answer(text, **kwargs): - state["confirms"].append(text) - return confirms.pop(0) if confirms is not None else confirm - - state["confirms"] = [] - monkeypatch.setattr(platform_cmd, "_confirm", confirm_answer) - client = platform_client(whoami=whoami or IDENTITY) - build_platform = platform_cmd.platform_client - - def build_recording_host(config, org_id=None, *, timeout=None): - client.built_with["base_url"] = config.get(DOCSTUDIO, "base_url") - return build_platform(config, org_id, timeout=timeout) - - monkeypatch.setattr(platform_cmd, "platform_client", build_recording_host) - whisper = FakeWhisper(get_usage_info=usage if usage is not None else {"quota": 1}) - whisper.built_with = {} - - def build(config): - whisper.built_with["api_key"] = config.get(LLMWHISPERER, "api_key") - return whisper - - monkeypatch.setattr(platform_cmd, "llmwhisperer", build) - state["platform"], state["whisper"] = client, whisper - return state - - return install - - -def _written(tmp_path) -> str: - return (tmp_path / "config.toml").read_text(encoding="utf-8") - - -def test_login_asks_for_each_key_in_turn_and_stores_them_as_literals( - capsys, login_seams, tmp_path -): - """Interactive path: a visible host prompt per product, then platform, - deployment, LLMWhisperer, one hidden prompt each. The two keys with a - read-only endpoint are checked; the deployment key is stored as given and - said to be.""" - seams = login_seams(["", "", PK, DK, LK]) - - code, out, err = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert err.startswith("Welcome to Unstract") - assert [p.split(" (")[0] for p in seams["prompts"]] == [ - "docstudio base URL", - "llmwhisperer base URL", - "Platform key", - "Deployment key", - "LLMWhisperer key", - ] - assert seams["platform"].built_with["api_key"] == PK - assert seams["whisper"].built_with["api_key"] == LK - data = envelope(out)["data"] - assert data["profile"] == "cloud-us" - assert (data["platform"], data["deployment"], data["llmwhisperer"]) == ( - "verified", - "stored", - "verified", - ) - assert data["organization_id"] == "org_ABC123" - assert "stored as given" in data["note"] - text = _written(tmp_path) - assert f'platform_key = "{PK}"' in text - assert f'api_key = "{DK}"' in text - assert f'api_key = "{LK}"' in text - assert 'org_id = "org_ABC123"' in text - assert oct((tmp_path / "config.toml").stat().st_mode & 0o777) == "0o600" - # The keys reach the file and nowhere else. - for key in (PK, DK, LK): - assert key not in out and key not in err - - -def test_login_with_every_prompt_skipped_is_a_usage_error(capsys, login_seams, tmp_path): - login_seams(["", "", "", "", ""]) - - code, out, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.USAGE) - assert "at least one" in envelope(out)["error"]["message"] - assert not (tmp_path / "config.toml").exists() - - -def test_login_with_only_a_deployment_key_calls_nothing(capsys, login_seams, tmp_path): - seams = login_seams(["", "", "", DK, ""]) - - code, out, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert seams["platform"].calls == [] and seams["whisper"].calls == [] - data = envelope(out)["data"] - assert (data["platform"], data["deployment"], data["llmwhisperer"]) == ( - "skipped", - "stored", - "skipped", - ) - assert "org_id" not in _written(tmp_path) - - -def test_login_without_a_terminal_and_without_flags_fails_before_prompting( - capsys, login_seams, tmp_path -): - """A script that reaches a hidden prompt hangs; a script told which flags - to pass does not.""" - seams = login_seams([], tty=False) - - code, out, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.USAGE) - assert seams["prompts"] == [] - error = envelope(out)["error"] - assert "not a terminal" in error["message"] - assert "--platform-key" in error["hint"] and "--llmwhisperer-key" in error["hint"] - assert not (tmp_path / "config.toml").exists() - - -def test_login_flags_take_values_and_one_of_them_from_stdin( - capsys, login_seams, monkeypatch, tmp_path -): - """The non-interactive twin: zero prompts, even at a terminal.""" - import io - - seams = login_seams([], tty=True) - monkeypatch.setattr("sys.stdin", io.StringIO(f"{PK}\n")) - - code, out, _ = run( - capsys, "auth", "login", "--platform-key", "-", "--llmwhisperer-key", LK - ) - - assert code == int(ExitCode.SUCCESS) - assert seams["prompts"] == [] - assert seams["platform"].built_with["api_key"] == PK - assert seams["whisper"].built_with["api_key"] == LK - assert envelope(out)["data"]["deployment"] == "skipped" - assert f'platform_key = "{PK}"' in _written(tmp_path) - - -def test_a_key_quoted_back_by_a_rejected_login_is_scrubbed(capsys, login_seams): - """The deployment key is stored without being sent anywhere, so nothing - else in the run would ever register it for scrubbing.""" - login_seams([], whoami=PlatformClientError(f"whoami failed with 400: sent {DK}")) - - code, out, err = run( - capsys, "auth", "login", "--platform-key", PK, "--deployment-key", DK - ) - - assert code != int(ExitCode.SUCCESS) - assert DK not in out and DK not in err - assert "***REDACTED***" in out - - -def test_login_says_so_when_the_key_resolves_no_organisation(capsys, login_seams): - """Every docstudio command needs one, so a login that stored none has to - say it -- as `whoami` already does for the same answer.""" - login_seams([], whoami={"organization_name": "Acme"}) - - code, out, err = run(capsys, "auth", "login", "--platform-key", PK) - - assert code == int(ExitCode.SUCCESS) - assert envelope(out)["data"]["organization_id"] is None - assert "no organization_id" in err - - -def test_login_reads_at_most_one_key_from_stdin(capsys, login_seams, tmp_path): - login_seams([]) - - code, out, _ = run( - capsys, "auth", "login", "--platform-key", "-", "--deployment-key", "-" - ) - - assert code == int(ExitCode.USAGE) - assert "stdin" in envelope(out)["error"]["message"] - assert not (tmp_path / "config.toml").exists() - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes") -def test_login_creates_the_config_file_and_its_directory_owner_only( - capsys, login_seams, tmp_path, monkeypatch -): - """A fresh install has no file and no directory; login is the first thing - documented to run, so it must not need `config init` before it.""" - path = tmp_path / "fresh" / ".unstract" / "config.toml" - monkeypatch.setenv("UNSTRACT_CONFIG", str(path)) - login_seams([]) - - code, _, _ = run(capsys, "auth", "login", "--platform-key", PK) - - assert code == int(ExitCode.SUCCESS) - assert stat.S_IMODE(path.stat().st_mode) == 0o600 - assert f'platform_key = "{PK}"' in path.read_text(encoding="utf-8") - - -def test_login_takes_the_platform_key_from_the_group_flag_too( - capsys, login_seams, tmp_path -): - seams = login_seams([]) - - code, _, err = run(capsys, "auth", "--platform-key", PK, "login") - - assert code == int(ExitCode.SUCCESS) - assert seams["prompts"] == [] - assert "Welcome" not in err - assert f'platform_key = "{PK}"' in _written(tmp_path) - - -@pytest.mark.parametrize( - "rejected", - ["platform", "llmwhisperer"], -) -def test_login_writes_nothing_when_any_key_is_rejected( - capsys, login_seams, tmp_path, rejected -): - """Every check runs before the one write: a file holding one good key and - one bad one would report the bad one as configured.""" - kwargs = { - "whoami": PlatformClientError("whoami failed with 401: nope") - if rejected == "platform" - else None, - "usage": LLMWhispererClientException( - {"message": "bad key", "status_code": 401}, 401 - ) - if rejected == "llmwhisperer" - else None, - } - login_seams(["", "", PK, DK, LK], **kwargs) - - code, out, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.AUTH) - assert envelope(out)["ok"] is False - assert not (tmp_path / "config.toml").exists() - - -def test_login_again_replaces_the_keys_given_and_keeps_the_rest( - capsys, login_seams, tmp_path -): - """Rotation: the same profile, updated in place, and a same-organisation - re-run asks nothing.""" - login_seams(["", "", PK, DK, LK]) - run(capsys, "auth", "login") - seams = login_seams(["", "", "", "dk-rotated-000001", ""]) - - code, _, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert seams["prompts"][-1].startswith("LLMWhisperer") - text = _written(tmp_path) - assert 'api_key = "dk-rotated-000001"' in text and DK not in text - assert f'platform_key = "{PK}"' in text and f'api_key = "{LK}"' in text - assert text.count("[profiles.") == 2 - - -def test_login_refuses_to_repoint_a_profile_at_another_organisation( - capsys, login_seams, tmp_path -): - """Non-interactive: fail, name both organisations, name the way out.""" - login_seams([], whoami={**IDENTITY, "organization_id": "org_OLD"}) - run(capsys, "auth", "login", "--platform-key", PK) - login_seams([], whoami={**IDENTITY, "organization_id": "org_NEW"}) - - code, out, _ = run(capsys, "auth", "login", "--platform-key", PK) - - assert code == int(ExitCode.USAGE) - error = envelope(out)["error"] - assert "org_OLD" in error["message"] and "org_NEW" in error["message"] - assert "--force" in error["hint"] and "--profile" in error["hint"] - assert 'org_id = "org_OLD"' in _written(tmp_path) - - -def test_login_overwrites_the_organisation_only_when_forced( - capsys, login_seams, tmp_path -): - login_seams([], whoami={**IDENTITY, "organization_id": "org_OLD"}) - run(capsys, "auth", "login", "--platform-key", PK) - login_seams([], whoami={**IDENTITY, "organization_id": "org_NEW"}) - - code, _, _ = run(capsys, "auth", "login", "--platform-key", PK, "--force") - - assert code == int(ExitCode.SUCCESS) - assert 'org_id = "org_NEW"' in _written(tmp_path) - assert "org_OLD" not in _written(tmp_path) - - -def test_login_offers_a_new_profile_named_after_the_organisation( - capsys, login_seams, tmp_path -): - """Interactive: the profile the key belongs to is a new one, suggested - from the organisation's display name, and the old profile is untouched.""" - login_seams(["", "", PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"}) - run(capsys, "auth", "login") - seams = login_seams( - ["", "", PK, "", "", "beta-corp"], - confirm=True, - whoami={ - **IDENTITY, - "organization_id": "org_NEW", - "organization_name": "Beta Corp", - }, - ) - - code, out, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert envelope(out)["data"]["profile"] == "beta-corp" - text = _written(tmp_path) - assert "[profiles.beta-corp.docstudio]" in text and 'org_id = "org_NEW"' in text - assert 'org_id = "org_OLD"' in text - assert seams["prompts"][-1] == "Profile name" - - -def test_a_new_profile_offered_by_the_guard_keeps_the_host_the_key_was_checked_on( - capsys, login_seams, tmp_path -): - """The key was verified against the profile the login started from; a new - profile that does not record that host would send it to the built-in - default next time.""" - (tmp_path / "config.toml").write_text( - 'default_profile = "onprem"\n[profiles.onprem.docstudio]\n' - 'base_url = "https://onprem.example/"\norg_id = "org_OLD"\n', - encoding="utf-8", - ) - seams = login_seams( - ["", "", PK, "", "", "beta"], - confirm=True, - whoami={**IDENTITY, "organization_id": "org_NEW"}, - ) - - code, _, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert seams["platform"].built_with["base_url"] == "https://onprem.example/" - text = _written(tmp_path) - assert text.count('base_url = "https://onprem.example/"') == 2 - assert "[profiles.beta.docstudio]" in text - - -def test_a_profile_chosen_at_the_guard_is_replaced_not_merged_into( - capsys, login_seams, tmp_path -): - """The name typed at the guard may be an existing profile with a host and - keys of its own; none of those were checked against this key's host, so - the profile is confirmed and then rebuilt from what this login verified.""" - (tmp_path / "config.toml").write_text( - 'default_profile = "onprem"\n[profiles.onprem.docstudio]\n' - 'base_url = "https://onprem.example/"\norg_id = "org_OLD"\n' - '[profiles.beta.docstudio]\nbase_url = "https://stale.example/"\n' - 'api_key = "sk-stale"\n' - '[profiles.beta.deployments."invoice-parser"]\napi_key = "sk-stale-entry"\n', - encoding="utf-8", - ) - seams = login_seams( - ["", "", PK, "", "", "beta"], - confirm=True, - whoami={**IDENTITY, "organization_id": "org_NEW"}, - ) - - code, _, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert seams["confirms"][-1] == "Profile 'beta' already exists. Replace it?" - text = _written(tmp_path) - assert "stale" not in text - assert text.count('base_url = "https://onprem.example/"') == 2 - assert "[profiles.beta.docstudio]" in text and 'org_id = "org_NEW"' in text - - -def test_a_new_profile_name_that_belongs_to_a_third_organisation_is_confirmed( - capsys, login_seams, tmp_path -): - """The guard's own remedy must not be the overwrite it exists to prevent: - a typed name that is another organisation's profile is asked about again, - and declining re-prompts.""" - (tmp_path / "config.toml").write_text( - 'default_profile = "cloud-us"\n' - '[profiles.cloud-us.docstudio]\norg_id = "org_OLD"\n' - '[profiles.partner.docstudio]\norg_id = "org_PARTNER"\n', - encoding="utf-8", - ) - seams = login_seams( - ["", "", PK, "", "", "partner", "fresh"], - confirm=[True, False], - whoami={**IDENTITY, "organization_id": "org_NEW"}, - ) - - code, out, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert envelope(out)["data"]["profile"] == "fresh" - assert "partner" in seams["confirms"][1] and "org_PARTNER" in seams["confirms"][1] - text = _written(tmp_path) - assert 'org_id = "org_PARTNER"' in text and 'org_id = "org_OLD"' in text - assert "[profiles.fresh.docstudio]" in text and 'org_id = "org_NEW"' in text - - -def test_login_overwrites_when_a_new_profile_is_declined(capsys, login_seams, tmp_path): - login_seams(["", "", PK, "", ""], whoami={**IDENTITY, "organization_id": "org_OLD"}) - run(capsys, "auth", "login") - seams = login_seams( - ["", "", PK, "", ""], - confirm=False, - whoami={**IDENTITY, "organization_id": "org_NEW"}, - ) - - code, out, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert envelope(out)["data"]["profile"] == "cloud-us" - assert len(seams["prompts"]) == 5 - assert 'org_id = "org_NEW"' in _written(tmp_path) - assert "org_OLD" not in _written(tmp_path) - - -STRANDING_CONFIG = ( - 'default_profile = "p"\n[profiles.p.docstudio]\n' - 'base_url = "https://stored.example/"\norg_id = "org_ABC123"\n' - 'api_key = "DEPLOYMENT-KEY-AAAA"\n' - '[profiles.p.deployments.invoices]\napi_key = "ENTRY-KEY-BBBB"\n' -) - - -def test_login_drops_the_keys_a_host_change_leaves_unchecked( - capsys, login_seams, tmp_path -): - """The deployment keys were checked against the host the profile held. A - login that stores another one would send them somewhere they were never - accepted.""" - (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - seams = login_seams(["", "", PK, "", ""], confirm=True) - - code, _, _ = run(capsys, "auth", "--base-url", "https://moved.example/", "login") - - assert code == int(ExitCode.SUCCESS) - asked = " ".join(seams["confirms"]) - assert "docstudio api_key" in asked and "deployment invoices" in asked - text = _written(tmp_path) - assert 'base_url = "https://moved.example/"' in text - assert "DEPLOYMENT-KEY-AAAA" not in text - assert "ENTRY-KEY-BBBB" not in text - assert "invoices" not in text - - -def test_login_aborts_rather_than_drop_keys_the_answer_declined( - capsys, login_seams, tmp_path -): - """Declining is a decision about the whole login: the keys are worth more - than the host change, so nothing is written at all.""" - (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - login_seams(["", "", PK, "", ""], confirm=False) - - code, _, _ = run(capsys, "auth", "--base-url", "https://moved.example/", "login") - - assert code == int(ExitCode.USAGE) - assert _written(tmp_path) == STRANDING_CONFIG - - -def test_login_without_a_terminal_refuses_to_strand_keys_until_forced( - capsys, login_seams, tmp_path -): - """Nothing can be asked, so the keys are kept and the run fails; --force is - how a script says it accepts losing them.""" - (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - login_seams([], tty=False) - - code, _, err = run( - capsys, - "auth", - "--base-url", - "https://moved.example/", - "login", - "--platform-key", - PK, - ) - - assert code == int(ExitCode.USAGE) - assert "docstudio api_key" in err and "deployment invoices" in err - assert _written(tmp_path) == STRANDING_CONFIG - - code, _, _ = run( - capsys, - "auth", - "--base-url", - "https://moved.example/", - "login", - "--platform-key", - PK, - "--force", - ) - - assert code == int(ExitCode.SUCCESS) - text = _written(tmp_path) - assert "DEPLOYMENT-KEY-AAAA" not in text and "ENTRY-KEY-BBBB" not in text - - -@pytest.mark.parametrize( - "base_url", - ["https://stored.example/", "https://stored.example", "HTTPS://Stored.Example/"], -) -def test_a_rotation_against_the_same_host_keeps_the_other_keys( - capsys, login_seams, tmp_path, base_url -): - """Re-logging in against the host the profile already names checks nothing - new, so there is nothing to ask about and nothing to drop; a trailing slash - or letter case is the same host spelt differently.""" - (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - seams = login_seams([]) - - code, _, _ = run( - capsys, "auth", "--base-url", base_url, "login", "--platform-key", PK - ) - - assert code == int(ExitCode.SUCCESS) - assert seams["confirms"] == [] - text = _written(tmp_path) - assert "DEPLOYMENT-KEY-AAAA" in text and "ENTRY-KEY-BBBB" in text - assert f'base_url = "{base_url}"' in text - - -def test_a_host_the_environment_selects_strands_the_keys_too( - capsys, login_seams, tmp_path, monkeypatch -): - """The environment outranks the profile on both the host being checked and - the host the profile is read back with; only the file says where the keys - were going before.""" - (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - login_seams([], tty=False) - monkeypatch.setenv("UNSTRACT_BASE_URL", "https://moved.example/") - - code, _, err = run(capsys, "auth", "login", "--platform-key", PK) - - assert code == int(ExitCode.USAGE) - assert "deployment invoices" in err - assert _written(tmp_path) == STRANDING_CONFIG - - -def test_a_host_that_differs_beyond_spelling_still_strands_the_keys( - capsys, login_seams, tmp_path -): - (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - login_seams([], tty=False) - - code, _, err = run( - capsys, - "auth", - "--base-url", - "https://stored.example.org/", - "login", - "--platform-key", - PK, - ) - - assert code == int(ExitCode.USAGE) - assert "deployment invoices" in err - - -def test_a_host_typed_at_the_prompt_is_the_one_checked_and_stored( - capsys, login_seams, tmp_path -): - """The on-premises path: Enter keeps the host the run resolved, anything - typed replaces it for the check and for the profile.""" - seams = login_seams(["https://onprem.example/", "", PK, "", ""]) - - code, _, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert seams["defaults"][:2] == [ - "https://us-central.unstract.com", - "https://llmwhisperer-api.us-central.unstract.com/api/v2", - ] - assert seams["platform"].built_with["base_url"] == "https://onprem.example/" - assert 'base_url = "https://onprem.example/"' in _written(tmp_path) - - -def test_the_host_prompt_offers_the_profile_s_own_host(capsys, login_seams, tmp_path): - (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - seams = login_seams(["", "", PK, "", ""]) - - code, _, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert seams["defaults"][0] == "https://stored.example/" - assert seams["confirms"] == [] - - -def test_a_host_typed_at_the_prompt_is_stored_without_a_key_for_it( - capsys, login_seams, tmp_path -): - """A self-hosted LLMWhisperer whose key comes later: the host typed now must - not depend on a key being typed with it.""" - login_seams(["", "https://whisper.example/", PK, "", ""]) - - code, _, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - text = _written(tmp_path) - assert "[profiles.cloud-us.llmwhisperer]" in text - assert 'base_url = "https://whisper.example/"' in text - - -def test_the_host_prompt_refuses_anything_but_a_url(capsys, login_seams, tmp_path): - login_seams(["onprem.example", "", "", "", DK, ""]) - - code, _, err = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert "'onprem.example' is not an http(s) URL" in err - assert 'base_url = "onprem.example"' not in _written(tmp_path) - - -def test_enter_at_the_host_prompt_keeps_a_profile_s_variable_reference( - capsys, login_seams, tmp_path, monkeypatch -): - monkeypatch.setenv("MY_HOST", "https://stored.example/") - (tmp_path / "config.toml").write_text( - STRANDING_CONFIG.replace('"https://stored.example/"', '"env:MY_HOST"'), - encoding="utf-8", - ) - seams = login_seams(["", "", PK, "", ""]) - - code, _, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert seams["defaults"][0] == "https://stored.example/" - assert 'base_url = "env:MY_HOST"' in _written(tmp_path) - - -def test_a_host_typed_at_the_prompt_goes_through_the_stranding_guard( - capsys, login_seams, tmp_path -): - (tmp_path / "config.toml").write_text(STRANDING_CONFIG, encoding="utf-8") - seams = login_seams(["https://moved.example/", "", PK, "", ""], confirm=True) - - code, _, _ = run(capsys, "auth", "login") - - assert code == int(ExitCode.SUCCESS) - assert "deployment invoices" in seams["confirms"][0] - text = _written(tmp_path) - assert 'base_url = "https://moved.example/"' in text - assert "ENTRY-KEY-BBBB" not in text - - -def test_login_writes_the_profile_named_and_checks_against_its_own_host( - capsys, login_seams, tmp_path -): - """A profile that does not exist yet must not borrow the default profile's - host for the check: the key would be verified against a server the new - profile will never talk to.""" - (tmp_path / "config.toml").write_text( - 'default_profile = "cloud-us"\n[profiles.cloud-us.docstudio]\n' - 'base_url = "https://elsewhere.example/"\norg_id = "org_X"\n', - encoding="utf-8", - ) - seams = login_seams([]) - - code, out, _ = run( - capsys, - "auth", - "--base-url", - "https://staging.example/", - "login", - "--profile", - "staging", - "--platform-key", - PK, - ) - - assert code == int(ExitCode.SUCCESS) - assert envelope(out)["data"]["profile"] == "staging" - assert seams["platform"].built_with["base_url"] == "https://staging.example/" - text = _written(tmp_path) - assert "[profiles.staging.docstudio]" in text - assert 'base_url = "https://staging.example/"' in text - assert 'org_id = "org_X"' in text # the other profile is untouched - - -def test_login_that_cannot_write_exits_on_the_save_code( - capsys, login_seams, monkeypatch, tmp_path -): - """The keys were checked and accepted; only the write failed, and a setup - script branching on the exit code needs to tell that from a bad key.""" - login_seams([]) - monkeypatch.setattr( - platform_cmd, - "save_config", - lambda *a, **k: (_ for _ in ()).throw(OSError(28, "no space left on device")), - ) - - code, out, _ = run(capsys, "auth", "login", "--platform-key", PK) - - assert code == int(ExitCode.SAVE_FAILED) - assert "no space left on device" in envelope(out)["error"]["message"] - - -def test_login_moves_an_existing_profile_to_the_host_it_checked_against( - capsys, login_seams, tmp_path -): - """The key was verified against the flag's host; leaving the old one in - place would send it to a server it was never checked against.""" - (tmp_path / "config.toml").write_text( - 'default_profile = "p"\n[profiles.p.docstudio]\n' - 'base_url = "https://old.example/"\norg_id = "org_ABC123"\n', - encoding="utf-8", - ) - login_seams([]) - - code, _, _ = run( - capsys, - "auth", - "--base-url", - "https://new.example/", - "login", - "--platform-key", - PK, - ) - - assert code == int(ExitCode.SUCCESS) - text = _written(tmp_path) - assert 'base_url = "https://new.example/"' in text - assert "old.example" not in text - - -def test_login_moves_an_existing_profile_to_the_host_the_environment_chose( - capsys, login_seams, monkeypatch, tmp_path -): - """Same as with a flag: the host the key was checked against is the one - stored, whatever the profile said before.""" - (tmp_path / "config.toml").write_text( - 'default_profile = "p"\n[profiles.p.docstudio]\n' - 'base_url = "https://stored.example/"\norg_id = "org_ABC123"\n', - encoding="utf-8", - ) - monkeypatch.setenv("UNSTRACT_BASE_URL", "https://from-env.example/") - login_seams([]) - - code, _, _ = run(capsys, "auth", "login", "--platform-key", PK) - - assert code == int(ExitCode.SUCCESS) - text = _written(tmp_path) - assert 'base_url = "https://from-env.example/"' in text - assert "stored.example" not in text - - -def test_login_says_when_the_host_it_checked_against_came_from_a_reference( - capsys, login_seams, monkeypatch, tmp_path -): - """A profile holding `env:VAR` keeps holding it; the keys are stored beside - a host that can change without the file changing, and that is worth saying.""" - (tmp_path / "config.toml").write_text( - 'default_profile = "p"\n[profiles.p.docstudio]\n' - 'base_url = "env:DOCSTUDIO_HOST"\norg_id = "org_ABC123"\n', - encoding="utf-8", - ) - monkeypatch.setenv("DOCSTUDIO_HOST", "https://referenced.example/") - login_seams([]) - - code, _, err = run(capsys, "auth", "login", "--platform-key", PK) - - assert code == int(ExitCode.SUCCESS) - assert "$DOCSTUDIO_HOST" in err - assert "https://referenced.example/" in err - assert 'base_url = "env:DOCSTUDIO_HOST"' in _written(tmp_path) - - -def test_login_does_not_write_a_discovered_project_config( - capsys, login_seams, monkeypatch, tmp_path -): - project = tmp_path / "repo" - project.mkdir() - (project / ".unstract.toml").write_text( - "[profiles.team.docstudio]\n", encoding="utf-8" - ) - monkeypatch.chdir(project) - monkeypatch.delenv("UNSTRACT_CONFIG", raising=False) - login_seams([]) - - code, out, _ = run(capsys, "auth", "login", "--deployment-key", DK) - - assert code == int(ExitCode.USAGE) - assert "project-local" in envelope(out)["error"]["message"] - assert DK not in (project / ".unstract.toml").read_text() - - -# --------------------------------------------------------------------------- # -# --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"] - - -#: 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", - # A field the redactor would blank: the assertion on the rescued - # payload is only a check if redaction would have changed it. - "result": {"policy_key": "PK-1", "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": {}, - }, - # No status at all, only an error: the shape a tool that died before - # reporting leaves behind. - {"file": "noStatus.pdf", "file_execution_id": "f4", "error": "tool died"}, - ], -} - - -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", "noStatus.pdf"] - assert error["execution_id"] == "e1" - assert error["details"]["extraction_result"][0]["result"]["policy_key"] == "PK-1" - 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", - "noStatus.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", "noStatus.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.""" - 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_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) - ) - - 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.1", "--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 - - -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_clone_failure_keeps_the_response_body_out_of_the_message( - capsys, clone_raising -): - """`error.message` is published as a one-line summary, and the exception - appends the response body to its own; `details` carries it either way.""" - clone_raising(PlatformAPIError("forbidden", status_code=403, body="x" * 3000)) - code, out, _ = run(capsys, *CLONE_ARGS) - error = envelope(out)["error"] - - assert code == int(ExitCode.AUTH) - assert error["message"] == "forbidden" - assert error["details"] == "x" * 3000 - - -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 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 - - -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 - - -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.1", - "--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"] - - -def test_a_timed_out_run_asked_to_save_says_to_save_on_resume( - capsys, deployment_client, tmp_path, monkeypatch -): - doc = tmp_path / "doc.pdf" - doc.write_bytes(b"%PDF-") - target = tmp_path / "out.json" - 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.1", - "--timeout", - "0", - "--save", - str(target), - ) - - assert code == int(ExitCode.TIMEOUT) - assert ( - f"deployment status my-api e-1 --save {target}`" in envelope(out)["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() - - -# --- Global flags after the subcommand ------------------------------------- - - -def _capture_context(monkeypatch): - """Hand back the Context the entry point builds, to read the parsed globals.""" - import unstract_cli.__main__ as entry - from unstract_cli.app import Context - - seen = [] - - def build(**kwargs): - seen.append(Context(**kwargs)) - return seen[-1] - - monkeypatch.setattr(entry, "Context", build) - return seen - - -def test_trailing_output_flag_prints_the_same_as_leading(capsys, whisper_client): - whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}}) - - leading = main(["-q", "-o", "raw", "whisper", "retrieve", "h1"]) - leading_out = capsys.readouterr().out - trailing = main(["whisper", "retrieve", "h1", "-o", "raw", "-q"]) - trailing_out = capsys.readouterr().out - - assert leading == trailing == int(ExitCode.SUCCESS) - assert leading_out == trailing_out == "hello\n" - - -@pytest.mark.parametrize("spelling", [["-o", "json"], ["--output=json"], ["-ojson"]]) -def test_trailing_json_on_a_nested_command_is_parsed(capsys, deployment_client, spelling): - deployment_client( - check_execution_status={"status_code": 200, "execution_status": "COMPLETED"} - ) - - code = main(["docstudio", "deployment", "status", "my-api", "e1", *spelling]) - out = capsys.readouterr().out - - assert code == int(ExitCode.SUCCESS) - assert envelope(out)["data"]["execution_status"] == "COMPLETED" - - -def test_verbose_split_around_the_subcommand_still_counts_both( - capsys, whisper_client, monkeypatch -): - whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}}) - seen = _capture_context(monkeypatch) - - code = main(["-v", "whisper", "retrieve", "h1", "-v", "-o", "json"]) - - assert code == int(ExitCode.SUCCESS) - assert seen[0].verbosity == 2 - - -def test_a_double_dash_stops_the_hoisting(capsys, whisper_client): - """A positional that happens to look like a global flag stays where the - caller put it once `--` has been given.""" - client = whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}}) - - code = main(["-o", "json", "whisper", "retrieve", "--", "-o"]) - - assert code == int(ExitCode.SUCCESS) - name, args, kwargs = client.calls[0] - assert name == "whisper_retrieve" and "-o" in (*args, *kwargs.values()) - - -def test_a_trailing_profile_flag_still_reaches_the_leaf(capsys, tmp_path, monkeypatch): - """`config set -p` names the profile to write, not the root's profile to - read, so it is not hoisted.""" - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "c.toml")) - - code = main( - ["-o", "json", "config", "set", "docstudio", "org_id", "org_A", "-p", "eu"] - ) - - assert code == int(ExitCode.SUCCESS) - assert "[profiles.eu" in (tmp_path / "c.toml").read_text() - - -def test_a_trailing_config_path_reads_the_same_file_as_leading(capsys, tmp_path): - path = tmp_path / "elsewhere.toml" - path.write_text( - 'default_profile = "p"\n[profiles.p.docstudio]\norg_id = "org_A"\n', - encoding="utf-8", - ) - - leading = main( - ["-o", "json", "--config", str(path), "config", "get", "docstudio", "org_id"] - ) - leading_out = capsys.readouterr().out - trailing = main( - ["-o", "json", "config", "get", "docstudio", "org_id", "--config", str(path)] - ) - trailing_out = capsys.readouterr().out - - assert leading == trailing == int(ExitCode.SUCCESS) - assert leading_out == trailing_out - assert envelope(trailing_out)["data"]["value"] == "org_A" - - -@pytest.mark.parametrize( - ("leading", "trailing", "quiet", "verbosity"), - [ - (["-qojson"], ["-qojson"], True, 0), - (["-vo", "json"], ["-vo", "json"], False, 1), - (["-qvo", "json"], ["-qvo", "json"], True, 1), - ], -) -def test_a_cluster_ending_in_o_is_hoisted_whole( - capsys, whisper_client, monkeypatch, leading, trailing, quiet, verbosity -): - whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}}) - seen = _capture_context(monkeypatch) - - first = main([*leading, "whisper", "retrieve", "h1"]) - first_out = capsys.readouterr().out - second = main(["whisper", "retrieve", "h1", *trailing]) - second_out = capsys.readouterr().out - - assert first == second == int(ExitCode.SUCCESS) - assert first_out == second_out - assert envelope(second_out)["data"] == {"result_text": "hello"} - assert (seen[1].quiet, seen[1].verbosity) == (quiet, verbosity) - - -@pytest.mark.parametrize("dangling", ["-o", "--config", "-vo"]) -def test_a_dangling_valued_option_is_a_usage_error(capsys, whisper_client, dangling): - """Hoisted alone it would swallow the subcommand name as its value.""" - whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}}) - - code = main(["-o", "json", "whisper", "retrieve", "h1", dangling]) - out = capsys.readouterr().out - - assert code == int(ExitCode.USAGE) - assert "requires an argument" in envelope(out)["error"]["message"] - - -def test_a_cluster_carrying_p_is_left_where_it_is(capsys, whisper_client): - whisper_client(whisper_retrieve={"extraction": {"result_text": "hello"}}) - - code = main(["-o", "json", "whisper", "retrieve", "h1", "-qp", "x"]) - out = capsys.readouterr().out - - assert code == int(ExitCode.USAGE) - assert "No such option" in envelope(out)["error"]["message"] - - -@pytest.mark.parametrize("joined", ["-ojson", "-qojson"]) -def test_a_pre_parse_failure_still_renders_in_a_joined_format(capsys, joined): - """The envelope format is read from argv before Click parses, so a joined - spelling has to be understood there as well.""" - code = main(["whisper", "retrieve", "h1", joined, "-o"]) - out = capsys.readouterr().out - - assert code == int(ExitCode.USAGE) - assert envelope(out)["error"]["message"] == "Option '-o' requires an argument." - - -def test_the_pre_parse_format_scan_stops_at_a_double_dash(capsys): - code = main(["nosuch", "--", "-ojson"]) - out = capsys.readouterr().out - - assert code == int(ExitCode.USAGE) - assert "No such command" in out and not out.startswith("{") diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index ad0cb55..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,642 +0,0 @@ -"""Config resolution: flag > env > profile > built-in default.""" - -from __future__ import annotations - -import errno -import os -import stat -import tomllib -from pathlib import Path - -import pytest - -from unstract_cli import config as config_module -from unstract_cli.config import ( - DEFAULT_BASE_URLS, - DOCSTUDIO, - LLMWHISPERER, - PROJECT_CONFIG_NAME, - ConfigError, - ConfigFile, - ResolvedConfig, - config_path, - find_project_config, - init_path, - load_config, - save_config, - set_config_path, - starter_profiles, -) -from unstract_cli.core.output import diagnostic - -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."invoice-parser"] -api_key = "env:INVOICE_KEY" - -[profiles.p.deployments."receipt-parser"] -api_key = "entry-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" - - -@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") - 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_a_missing_setting_points_a_fresh_install_at_login(write_config): - """With no config file at all the hints name a file the reader has never - seen; `auth login` writes it. Once a file exists the hint would only - repeat what the file already shows.""" - with pytest.raises(ConfigError, match="run `unstract auth login`"): - resolved().require(DOCSTUDIO, "api_key") - - write_config('default_profile = "p"\n\n[profiles.p.docstudio]\norg_id = "x"\n') - with pytest.raises(ConfigError) as excinfo: - resolved().require(DOCSTUDIO, "api_key") - assert "auth login" not in str(excinfo.value) - - -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"): - 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_a_deployment_without_an_entry_uses_the_profile_key(write_config, monkeypatch): - write_config(PROFILE_TOML) - monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", "secret-value") - assert resolved().deployment_key("some-api") == "secret-value" - - -def test_a_deployment_entry_key_beats_the_profile_key(write_config): - write_config(PROFILE_TOML.replace("env:UNSTRACT_DEPLOYMENT_KEY", "profile-key")) - assert resolved().deployment_key("receipt-parser") == "entry-key" - assert resolved().deployment_key("some-api") == "profile-key" - - -def test_a_key_from_a_deployment_entry_is_registered_for_scrubbing(write_config, capsys): - """It is resolved on a path of its own, so registering the profile key is - not enough: an entry key would reach a diagnostic in full.""" - write_config(PROFILE_TOML.replace("entry-key", "dk-entry-abcdefghij")) - - assert resolved().deployment_key("receipt-parser") == "dk-entry-abcdefghij" - diagnostic("rejected key dk-entry-abcdefghij") - - assert "dk-entry-abcdefghij" not in capsys.readouterr().err - - -def test_a_deployment_entry_may_point_at_the_environment(write_config, monkeypatch): - write_config(PROFILE_TOML) - monkeypatch.setenv("INVOICE_KEY", "from-env") - assert resolved().deployment_key("invoice-parser") == "from-env" - - -def test_a_deployment_entry_pointing_at_an_unset_variable_is_an_error(write_config): - """Silently running with the profile's key reports success for a run the - entry was written to keep separate.""" - write_config(PROFILE_TOML) - with pytest.raises(ConfigError, match=r"INVOICE_KEY is not set"): - resolved().deployment_key("invoice-parser") - - -def test_deployment_names_are_the_entries_in_the_profile(write_config): - write_config(PROFILE_TOML) - assert resolved().deployment_names() == ("invoice-parser", "receipt-parser") - assert resolved(profile="p").deployment_names() == ( - "invoice-parser", - "receipt-parser", - ) - - -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_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_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_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_a_directory_that_will_not_sync_is_warned_about_not_hidden( - tmp_path, monkeypatch, warnings_seen -): - """The config is already renamed into place by then, so the write is a - success with a caveat, not a failure -- but not a silent success either.""" - path = tmp_path / "config.toml" - real_fsync = os.fsync - - def fsync(fd): - if os.fstat(fd).st_mode & stat.S_IFDIR: - raise OSError(errno.EINVAL, "Invalid argument") - real_fsync(fd) - - monkeypatch.setattr(config_module.os, "fsync", fsync) - - assert save_config(ConfigFile(profiles=starter_profiles()), path) == path - assert path.exists() - assert any( - "could not be synced" in note and str(path) in note for note in warnings_seen - ) - - -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) - 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" -platform_key = "project-platform-key" - -[profiles.p.deployments."invoice-parser"] -api_key = "entry-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 - # Both docstudio keys are credentials, whatever they are called. - assert cfg.get(DOCSTUDIO, "platform_key") is None - assert cfg.deployment_key("invoice-parser") is None - # Everything the file is legitimately for still applies. - assert cfg.get(DOCSTUDIO, "org_id") == "org_from_project" - assert cfg.deployment_names() == ("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 cfg.get(DOCSTUDIO, "platform_key") == "project-platform-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"]["invoice-parser"]["api_key"] == ( - "entry-literal-key" - ) - - -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" - save_config(load_config(), elsewhere) - - monkeypatch.setenv("UNSTRACT_CONFIG", str(elsewhere)) - 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() - 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_entry_key_is_reported_against_the_entry(tmp_path, monkeypatch): - _plant_project_config(tmp_path, monkeypatch) - cfg = resolved() - assert cfg.withheld_detail("deployments", "invoice-parser", "api_key") - assert cfg.withheld_detail("deployments", "receipt-parser", "api_key") is None - - -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:") - - -def test_a_discovered_file_cannot_choose_which_env_var_is_read( - 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 - 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 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): - 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_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 - - -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" diff --git a/tests/test_contract.py b/tests/test_contract.py deleted file mode 100644 index 4dff20d..0000000 --- a/tests/test_contract.py +++ /dev/null @@ -1,154 +0,0 @@ -"""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 json -import os -from dataclasses import asdict -from pathlib import Path -from typing import Any - -import pytest -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, -#: with the spec parameters that method cannot accept -- parameters the client -#: owns rather than lacks. -COMMANDS = [ - ( - "llmwhisperer", - "extract", - LLMWhispererClientV2.whisper, - {"url_in_post"}, - ), - ("llmwhisperer", "highlights", LLMWhispererClientV2.get_highlight_data, set()), - ("docstudio", "execute", APIDeploymentsClient.structure_file, {"files"}), - ( - "docstudio", - "status", - APIDeploymentsClient.check_execution_status, - {"execution_id"}, - ), -] - - -@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 - - -@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" - -#: 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, 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}": { - # Choices as a list, since the snapshot is compared against what a - # JSON reader gives back, and resolved through the overlay so a - # narrowing 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, - ) - } - 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, ( - "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." - ) diff --git a/tests/test_discover.py b/tests/test_discover.py deleted file mode 100644 index 63b83e8..0000000 --- a/tests/test_discover.py +++ /dev/null @@ -1,551 +0,0 @@ -"""`--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 click -import pytest - -from unstract_cli.__main__ import main -from unstract_cli.app import cli -from unstract_cli.commands import config_cmd -from unstract_cli.config import DOCSTUDIO -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 - - -def run(capsys, *args): - code = main(["-o", "json", *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"]} == { - "auth", - "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): - _, 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"] - # The type a caller can map, with the bound as its own key: Click's name - # for a bounded number 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"] - - -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_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.""" - 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") - 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_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.""" - 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 - - -@pytest.fixture -def platform_probe_client(monkeypatch): - def install(reply=None, live=()): - class Fake: - def whoami(self): - if isinstance(reply, Exception): - raise reply - return reply or {} - - def list_deployments(self, org_id, api_name=None): - if isinstance(live, Exception): - raise live - if isinstance(live, dict): - return live - results = [{"api_name": n} for n in live if n == api_name] - return {"count": len(results), "results": results} - - monkeypatch.setattr( - config_cmd, - "platform_client", - # Requires the key like the real factory, so an absent one is the - # ConfigError the probe reports rather than a call on the fake. - lambda config, org_id=None, *, timeout=None: ( - config.require(DOCSTUDIO, "platform_key"), - Fake(), - )[1], - ) - - 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): - """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)) - 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, - "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"] - # `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"] - - -def test_the_platform_probe_verifies_the_key_and_names_the_organisation( - capsys, probe_client, platform_probe_client, monkeypatch -): - """`whoami` reads nothing but the key, so unlike a deployment it can be - checked for real -- and the organisation it resolves is the reason to - hold the key at all. - """ - probe_client({"quota": 1}) - platform_probe_client({"organization_id": "org_ABC123"}) - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - - _, data = run(capsys, "config", "doctor", "--probe") - - assert data["probe"]["platform"] == { - "checked": True, - "ok": True, - "organization_id": "org_ABC123", - "detail": "The key was accepted, and resolved to an organisation.", - } - - -def test_an_absent_platform_key_is_reported_not_failed(capsys, probe_client): - """A platform key is optional -- holding only a deployment key is the - common case -- so not having one must not decide the exit code. - """ - probe_client({"quota": 1}) - - code, data = run(capsys, "config", "doctor", "--probe") - - assert code == int(ExitCode.SUCCESS) - entry = data["probe"]["platform"] - assert entry["checked"] is False - assert entry["ok"] is None - - -def test_a_rejected_platform_key_fails_the_probe( - capsys, probe_client, platform_probe_client, monkeypatch -): - """A key that is set and wrong is a real misconfiguration, unlike one that - is simply absent.""" - probe_client({"quota": 1}) - platform_probe_client(CLIError("bad key", ExitCode.AUTH)) - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - - code = main(["-o", "json", "config", "doctor", "--probe"]) - report = json.loads(capsys.readouterr().out)["error"]["details"] - - assert code == int(ExitCode.GENERIC) - assert report["probe"]["platform"] == { - "checked": True, - "ok": False, - "detail": "bad key", - "exit_code": int(ExitCode.AUTH), - } - - -def test_config_init_then_doctor_exits_zero_without_a_platform_key( - capsys, monkeypatch, tmp_path -): - """The property `test_an_absent_platform_key_is_reported_not_failed` claims, - checked the way a real user reaches it. - - That test runs with no config file. The starter profile written by - `config init` used to carry `api_key = "env:UNSTRACT_PLATFORM_KEY"`, and an - `env:` reference to an unset variable is a doctor *problem* -- so the test - passed while every user who ran the documented first command got exit 1. - """ - monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "config.toml")) - monkeypatch.setenv("LLMWHISPERER_API_KEY", "lw-123456789") - monkeypatch.setenv("UNSTRACT_DEPLOYMENT_KEY", "dk-123456789") - monkeypatch.setenv("UNSTRACT_ORG_ID", "acme") - monkeypatch.delenv("UNSTRACT_PLATFORM_KEY", raising=False) - - assert main(["-o", "json", "config", "init"]) == int(ExitCode.SUCCESS) - capsys.readouterr() - - assert main(["-o", "json", "config", "doctor"]) == int(ExitCode.SUCCESS) - - -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_a_deployment_entry_whose_key_does_not_arrive( - capsys, write_config, monkeypatch -): - """A listed entry says nothing about whether the key behind it resolves, - and a run would otherwise find out only from the server.""" - write_config( - """ - default_profile = "p" - [profiles.p.docstudio] - api_key = "dk-configured-key" - org_id = "org_ABC" - [profiles.p.deployments."invoice-parser"] - api_key = "dk-entry-key" - [profiles.p.deployments."broken"] - 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["deployments"]) == {"invoice-parser", "broken"} - assert any("broken" in problem for problem in report["problems"]) - - -STALE_CONFIG = """ -default_profile = "p" -[profiles.p.docstudio] -org_id = "org_ABC" -[profiles.p.deployments."invoice-parser"] -api_key = "dk-1" -[profiles.p.deployments."renamed-since"] -api_key = "dk-2" -""" - - -def test_the_probe_reports_entries_the_organisation_no_longer_has( - capsys, write_config, probe_client, platform_probe_client, monkeypatch -): - """An orphaned entry is a warning, not a failure: it does no harm until it - is run, and the exit code is what setup scripts branch on.""" - write_config(STALE_CONFIG) - probe_client({"quota": 1}) - platform_probe_client({"organization_id": "org_ABC"}, live={"invoice-parser"}) - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - - code = main(["-o", "json", "config", "doctor", "--probe"]) - out, err = capsys.readouterr() - - assert code == int(ExitCode.SUCCESS) - assert json.loads(out)["data"]["stale_deployments"] == ["renamed-since"] - assert "renamed-since" in err and "deployment ls" in err - - -def test_a_listing_that_fails_is_a_failed_check_not_a_clean_one( - capsys, write_config, probe_client, platform_probe_client, monkeypatch -): - """The check was asked for and did not run; exiting 0 would tell a setup - script the entries were verified.""" - write_config(STALE_CONFIG) - probe_client({"quota": 1}) - platform_probe_client( - {"organization_id": "org_ABC"}, - live=CLIError("listing timed out", ExitCode.TIMEOUT), - ) - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - - code = main(["-o", "json", "config", "doctor", "--probe"]) - report = json.loads(capsys.readouterr().out)["error"]["details"] - - assert code == int(ExitCode.GENERIC) - assert "stale_deployments" not in report - assert any("listing timed out" in p for p in report["problems"]) - - -def test_a_rejected_platform_key_is_not_sent_again_for_the_entry_check( - capsys, write_config, probe_client, platform_probe_client, monkeypatch -): - """The listing would carry the key that was just refused, so it can only - fail the same way and report the same failure twice.""" - write_config(STALE_CONFIG) - probe_client({"quota": 1}) - platform_probe_client( - CLIError("key rejected", ExitCode.AUTH), - live=CLIError("must not be called"), - ) - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - - code = main(["-o", "json", "config", "doctor", "--probe"]) - out, err = capsys.readouterr() - report = json.loads(out)["error"]["details"] - - assert code == int(ExitCode.GENERIC) - assert "stale_deployments" not in report - assert [p for p in report["problems"] if "key rejected" in p] == [ - "probe platform: key rejected" - ] - assert "did not pass" in err - - -def test_a_listing_that_is_not_a_list_fails_the_entry_check( - capsys, write_config, probe_client, platform_probe_client, monkeypatch -): - """A proxy answering in the API's place would otherwise read as an - organisation that has none of these deployments any more.""" - write_config(STALE_CONFIG) - probe_client({"quota": 1}) - platform_probe_client( - {"organization_id": "org_ABC"}, live={"results": "Sign in"} - ) - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - - code = main(["-o", "json", "config", "doctor", "--probe"]) - report = json.loads(capsys.readouterr().out)["error"]["details"] - - assert code == int(ExitCode.GENERIC) - assert "stale_deployments" not in report - assert any("list of deployments" in p for p in report["problems"]) - - -def test_the_probe_skips_the_entry_check_without_a_platform_key( - capsys, write_config, probe_client, platform_probe_client -): - """Only the platform key can answer the question, and most callers hold - none -- their entries are still theirs to keep.""" - write_config(STALE_CONFIG) - probe_client({"quota": 1}) - platform_probe_client(CLIError("must not be called")) - - code = main(["-o", "json", "config", "doctor", "--probe"]) - out, err = capsys.readouterr() - - assert code == int(ExitCode.SUCCESS) - assert "stale_deployments" not in json.loads(out)["data"] - assert "renamed-since" not in err - # --probe was explicit, so the skip is explained rather than silent. - assert "no platform key" in err - - -def test_doctor_asks_the_server_nothing_without_probe( - capsys, write_config, platform_probe_client, monkeypatch -): - write_config(STALE_CONFIG) - platform_probe_client(CLIError("must not be called")) - monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", "pk-123") - - code = main(["-o", "json", "config", "doctor"]) - out, err = capsys.readouterr() - - assert code == int(ExitCode.SUCCESS) - assert "stale_deployments" not in json.loads(out)["data"] - assert "platform key" not in err # bare doctor is silent about the skip - - -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) - - -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"] diff --git a/tests/test_errors.py b/tests/test_errors.py deleted file mode 100644 index 608ae65..0000000 --- a/tests/test_errors.py +++ /dev/null @@ -1,245 +0,0 @@ -"""The exit-code table, retry policy and redaction.""" - -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_value, - remember_secret, - scrub, - scrub_structure, - undeclared_status_error, -) - - -@pytest.mark.parametrize( - ("status", "expected"), - [ - # 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), - (404, ExitCode.NOT_FOUND), - (406, ExitCode.GENERIC), - (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] == [*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]) -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 exit_code_for_status(406, one_shot=True) is ExitCode.ALREADY_CONSUMED - assert "already retrieved" in hint_for(406, one_shot=True) - assert "--save" in hint_for(406, one_shot=True) - - -def test_a_406_outside_a_one_shot_read_is_not_reported_as_consumed(): - # Every other endpoint answers a 406 when it cannot serve the format asked - # for, which no resend of the same request will fix and no --save averts. - err = error_from_status(406, "Not Acceptable") - assert err.exit_code is ExitCode.GENERIC - assert "already retrieved" not in (err.hint or "") - assert "base_url" in (err.hint or "") - - -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_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}" - - -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() - - -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) - - -# --------------------------------------------------------------------------- # -# 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(warnings_seen): - remember_secret("short") - assert "short" not in known_secrets() - assert any("will not be redacted" in note for note in warnings_seen) - - -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(warnings_seen): - remember_secret("short") - remember_secret("short") - assert sum("too short to scrub" in note for note in warnings_seen) == 1 diff --git a/tests/test_exit_codes.py b/tests/test_exit_codes.py new file mode 100644 index 0000000..f1ece56 --- /dev/null +++ b/tests/test_exit_codes.py @@ -0,0 +1,63 @@ +"""Exit codes must reach the shell (SPEC §5.4). + +These run the CLI as a **subprocess**, deliberately. Click's `CliRunner` +invokes commands with `standalone_mode=True` and never executes +`unstract_cli.__main__.main`, so the entire class of "the contract is right but +the entry point drops it" is invisible to it — which is exactly the bug these +were written for: `main()` discarded the code Click returns under +`standalone_mode=False`, so every failure exited 0 while its error envelope +reported `"exit_code": 2`. `set -e` never tripped, and an agent branching on the +status code saw success. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +import pytest + + +def run_cli(*args: str, **env: str) -> int: + """Run the CLI in a subprocess and return its real process exit code.""" + return subprocess.run( + [sys.executable, "-m", "unstract_cli", *args], + capture_output=True, + env={ + **os.environ, + "UNSTRACT_PLATFORM_KEY": "k", + "UNSTRACT_ORG_ID": "o", + **env, + }, + ).returncode + + +#: (argv, expected code). Each row pins one documented code at the process +#: boundary; `--dry-run` keeps the success cases off the network. +CASES: list[tuple[list[str], int]] = [ + # Usage error (2): a constraint rejected before any request is sent. + ( + [ + "docstudio", "platform", "prompt-studio", "profile", "create", + "--tool-id", "t", "--profile-name", "p", "--llm", "l", + "--x2text", "x", "--chunk-size", "1024", + ], + 2, + ), + # Usage error (2): Click's own parse failure, routed through the same envelope. + (["whisper", "--definitely-not-a-flag"], 2), + (["docstudio", "platform", "prompt-studio", "nonexistent-command"], 2), + # Success (0): the paths that must not regress into a non-zero code. + (["docstudio", "platform", "prompt-studio", "list", "--dry-run"], 0), + (["--help"], 0), + (["--discover"], 0), +] + + +@pytest.mark.parametrize("argv,expected", CASES, ids=lambda v: None) +def test_exit_code_reaches_the_shell(argv: list[str], expected: int) -> None: + assert run_cli(*argv) == expected, ( + f"`unstract {' '.join(argv)}` should exit {expected}. A failure exiting 0 " + "silently breaks `set -e` and any agent branching on the status code." + ) diff --git a/tests/test_gotchas.py b/tests/test_gotchas.py new file mode 100644 index 0000000..e9c493e --- /dev/null +++ b/tests/test_gotchas.py @@ -0,0 +1,245 @@ +"""Regressions for the friction points recorded in GOTCHAS.md. + +Each test pins the *behaviour a user hit*, not the implementation that fixes it, +so a later refactor is free to change the mechanism but cannot quietly restore +the friction. Numbers refer to GOTCHAS.md sections. +""" + +from __future__ import annotations + +import pytest + +from unstract_cli.config.loader import ConfigFile, ResolvedConfig +from unstract_cli.core.errors import hint_for +from unstract_cli.core.http import build_request +from unstract_cli.core.model import RequiredUnless +from unstract_cli.endpoints import get_endpoint + +from .conftest import FAKE_KEY + + +def _config(monkeypatch, **env) -> ResolvedConfig: + for key, value in env.items(): + monkeypatch.setenv(key, value) + return ResolvedConfig(file=ConfigFile(exists=False)) + + +class TestProfileManagerOnPromptCreate: + """#1 - `fetch-response` reads the PROMPT's profile, never the project default.""" + + def test_prompt_create_accepts_a_profile(self): + param = get_endpoint("docstudio.platform.prompt-studio.prompt.create").param( + "profile_manager" + ) + assert param is not None, "prompt create must be able to set the LLM profile" + assert param.location.value == "body" + + def test_profile_reaches_the_body(self, monkeypatch): + config = _config(monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_1") + plan = build_request( + get_endpoint("docstudio.platform.prompt-studio.prompt.create"), + config, + {"tool_id": "t-1", "prompt_key": "invoice_no", "profile_manager": "p-9"}, + ) + assert plan.json_body["profile_manager"] == "p-9" + # The tool_id mirror must survive alongside it (the older BUG 2 fix). + assert plan.json_body["tool_id"] == "t-1" + + def test_the_misleading_error_gets_a_corrective_hint(self): + hint = hint_for(500, message="Default LLM profile is not configured.") + assert hint and "profile_manager" in hint + # The whole point: stop the reader chasing `profile set-default`. + assert "does NOT fall back" in hint or "not fall back" in hint.lower() + + +class TestChallengeLlm: + """#2 - the 422 that only surfaced at the final `deployment run`.""" + + def test_project_can_set_challenge_llm_before_export(self): + for command in ( + "docstudio.platform.prompt-studio.create", + "docstudio.platform.prompt-studio.patch", + ): + assert get_endpoint(command).param("challenge_llm") is not None + + def test_tool_validation_failure_explains_the_fix(self): + hint = hint_for(422, message="Tool validation failed") + assert hint and "challenge_llm" in hint + assert "set-metadata" in hint + + +class TestVectorStoreStaysRequired: + """#3 - asked for `--vector-store` to be optional at `--chunk-size 0`. + + It cannot be: `ProfileManager.vector_store` and `.embedding_model` are + `null=False` and the serializer is `fields = "__all__"`, so DRF derives + required=True and the server rejects the profile whatever chunk_size says. + Relaxing the local rule would only trade a fast exit-2 for a slow remote 400, + so the requirement stays and the help explains what to pass. These tests pin + that decision so it is not "re-fixed" into a regression. + """ + + @pytest.mark.parametrize( + "command", + [ + "docstudio.platform.prompt-studio.profile.create", + "docstudio.platform.prompt-studio.profile.update", + ], + ) + def test_both_adapters_stay_required(self, command): + for name in ("vector_store", "embedding_model"): + assert get_endpoint(command).param(name).required, ( + f"{name} must stay required: the server rejects a profile without " + "it even when chunk_size=0" + ) + + def test_help_explains_the_chunk_size_zero_case(self): + endpoint = get_endpoint("docstudio.platform.prompt-studio.profile.create") + assert "chunk-size 0" in endpoint.description + # The caller must learn that the value is stored but unused, so that + # supplying "any valid adapter id" reads as intended rather than a bodge. + assert "REQUIRES" in endpoint.description + + +class TestRequiredUnlessPrimitive: + """The conditional-required constraint itself, kept for cases that need it.""" + + def test_sentinel_value_relaxes_the_requirement(self): + constraint = RequiredUnless(("vector_store",), unless="chunk_size", unless_values=(0,)) + assert constraint.check({"chunk_size": 0}) is None + assert constraint.check({"chunk_size": 1024}) is not None + + def test_string_and_int_sentinels_agree(self): + # Click hands ints through typed, but config defaults and tests may not. + constraint = RequiredUnless(("vector_store",), unless="chunk_size", unless_values=(0,)) + assert constraint.check({"chunk_size": "0"}) is None + + def test_supplying_the_param_always_satisfies_it(self): + constraint = RequiredUnless(("vector_store",), unless="chunk_size", unless_values=(0,)) + assert constraint.check({"chunk_size": 1024, "vector_store": "v-1"}) is None + + +class TestSingleJsonDocumentOnStdout: + """#4 - reported as duplicate output; stdout must carry exactly one document.""" + + def test_stdout_parses_as_one_json_object(self, runner, cli, monkeypatch): + import json + + monkeypatch.setenv("UNSTRACT_PLATFORM_KEY", FAKE_KEY) + monkeypatch.setenv("UNSTRACT_ORG_ID", "org_1") + result = runner.invoke( + cli, + ["docstudio", "platform", "prompt-studio", "list", "--dry-run", "-o", "json"], + ) + assert result.exit_code == 0 + json.loads(result.stdout) # raises "Extra data" if the payload were doubled + + +class TestKeyCreateNeedsOneIdentifier: + """#6 - `--api-id` alone must suffice; the body's `api` is the same value.""" + + def test_api_key_create_mirrors_the_path_id(self, monkeypatch): + config = _config(monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_1") + plan = build_request( + get_endpoint("docstudio.platform.api-deployment.key.create"), + config, + {"api_id": "dep-1"}, + ) + assert plan.json_body["api"] == "dep-1" + assert "dep-1" in plan.url + + def test_pipeline_key_create_mirrors_the_path_id(self, monkeypatch): + config = _config(monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_1") + plan = build_request( + get_endpoint("docstudio.platform.pipeline.key.create"), + config, + {"pipeline_id": "pipe-1"}, + ) + assert plan.json_body["pipeline"] == "pipe-1" + + def test_no_second_flag_is_demanded(self): + # The old MutuallyExclusive(api, pipeline) rejected `--api-id` on its own. + assert get_endpoint("docstudio.platform.api-deployment.key.create").validate( + {"api_id": "dep-1"} + ) == [] + + +class TestOrgIdFallsBackToPlatform: + """#7 - the deployment/hitl config blocks start empty, but the org is the same.""" + + def test_deployment_run_uses_the_platform_org_id(self, monkeypatch): + config = _config( + monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_platform" + ) + plan = build_request( + get_endpoint("docstudio.deployment.run"), + config, + {"api_name": "invoice-api"}, + ) + assert "org_platform" in plan.url + + def test_an_explicit_org_id_still_wins(self, monkeypatch): + config = _config( + monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_platform" + ) + plan = build_request( + get_endpoint("docstudio.deployment.run"), + config, + {"api_name": "invoice-api", "org_id": "org_explicit"}, + ) + assert "org_explicit" in plan.url + + +class TestIndexDocumentSupportsWait: + """#8 - index-document was the only async command without --wait.""" + + def test_it_declares_a_poll_spec(self): + endpoint = get_endpoint("docstudio.platform.prompt-studio.index-document") + assert endpoint.poll is not None + assert endpoint.poll.handle_field == "task_id" + + def test_indexing_has_nothing_to_retrieve(self): + # Indexing writes no Output Manager row, so the terminal status IS the + # result; a retrieve step would fetch an unrelated prompt's output. + assert get_endpoint( + "docstudio.platform.prompt-studio.index-document" + ).poll.retrieve_endpoint is None + + def test_wait_flag_is_exposed(self, runner, cli): + result = runner.invoke( + cli, + ["docstudio", "platform", "prompt-studio", "index-document", "--help"], + ) + assert "--wait" in result.output + + +class TestDocumentedServerLimitations: + """#5, #9, #10 - not CLI-fixable; the help must say so rather than mislead.""" + + def test_export_tool_explains_the_new_registry_id(self): + description = get_endpoint( + "docstudio.platform.prompt-studio.export-tool" + ).description + assert "function_name" in description and "not" in description.lower() + + def test_project_delete_documents_the_registry_cascade(self): + description = get_endpoint("docstudio.platform.prompt-studio.delete").description + assert "cascade" in description.lower() or "registry" in description.lower() + + def test_default_triad_explains_an_empty_object(self): + description = get_endpoint( + "docstudio.platform.adapter.default-triad.get" + ).description + assert "{}" in description + + def test_adapter_choices_points_at_the_working_alternative(self): + description = get_endpoint( + "docstudio.platform.prompt-studio.adapter-choices" + ).description + assert "adapter list" in description + + def test_task_status_says_it_needs_the_tool_id(self): + description = get_endpoint( + "docstudio.platform.prompt-studio.task-status" + ).description + assert "--tool-id" in description diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..058f89b --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,414 @@ +"""HTTP layer: auth injection, exit-code mapping, retries and redaction.""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from unstract_cli.config.loader import ( + ConfigError, + ConfigFile, + ResolvedConfig, + load_config, +) +from unstract_cli.core.errors import ( + REDACTED, + CLIError, + ExitCode, + exit_code_for_status, + is_retryable, + redact_headers, + scrub, +) +from unstract_cli.core.http import ( + GATEWAY_INJECTED_HEADERS, + auth_headers, + build_request, + execute, + raise_for_status, +) +from unstract_cli.core.model import ApiGroup +from unstract_cli.endpoints import get_endpoint + +from .conftest import FAKE_KEY, WHISPER_BASE + + +def _config(monkeypatch, **env) -> ResolvedConfig: + for key, value in env.items(): + monkeypatch.setenv(key, value) + return ResolvedConfig(file=ConfigFile(exists=False)) + + +class TestExitCodeMapping: + """SPEC §5.4 - the table an agent branches on.""" + + @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), + (409, ExitCode.VALIDATION), + (422, ExitCode.VALIDATION), + (429, ExitCode.RATE_LIMITED), + (500, ExitCode.SERVER_ERROR), + (503, ExitCode.SERVER_ERROR), + ], + ) + def test_status_maps_to_exit_code(self, status, expected): + assert exit_code_for_status(status) is expected + + @pytest.mark.parametrize("status", [429, 500, 502, 503]) + def test_retryable(self, status): + assert is_retryable(status) + + @pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 422]) + def test_not_retryable(self, status): + """Retrying a 4xx re-sends a request already rejected on its merits -- + and for one-shot reads could consume a result the first call delivered.""" + assert not is_retryable(status) + + +class TestAuth: + """SPEC §4.4 - three products, three schemes.""" + + def test_llmwhisperer_uses_unstract_key(self, monkeypatch): + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + assert auth_headers(ApiGroup.LLMWHISPERER, cfg) == {"unstract-key": FAKE_KEY} + + def test_platform_uses_bearer(self, monkeypatch): + cfg = _config(monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY) + assert auth_headers(ApiGroup.PLATFORM, cfg)["Authorization"] == f"Bearer {FAKE_KEY}" + + def test_apihub_uses_apikey(self, monkeypatch): + cfg = _config(monkeypatch, UNSTRACT_APIHUB_KEY=FAKE_KEY) + assert auth_headers(ApiGroup.APIHUB, cfg)["apikey"] == FAKE_KEY + + def test_apihub_never_sends_gateway_headers(self, monkeypatch): + """Kong injects tenancy headers from its own Redis lookup. + + A client-set value would be overwritten at best, and treated as a tenancy + claim at worst, so the CLI must never emit them. + """ + cfg = _config( + monkeypatch, UNSTRACT_APIHUB_KEY=FAKE_KEY, UNSTRACT_APIHUB_BASE_URL="iframe.php?url=https%3A%2F%2Fhub.test" + ) + plan = build_request( + get_endpoint("apihub.status"), cfg, {"file_hash": "abc"} + ) + for header in GATEWAY_INJECTED_HEADERS: + assert header not in {k.lower() for k in plan.headers} + + +class TestEnumKeyedLookup: + """Callers pass `ApiGroup`; the resolver must accept it, not just strings. + + When only `Product` was normalised, an `ApiGroup` key fell through + unconverted: profile values were silently missed and error messages read + "ApiGroup.LLMWHISPERER.api_key". + """ + + def test_enum_and_string_agree(self, tmp_path, monkeypatch): + config = tmp_path / "c.toml" + config.write_text( + 'default_profile = "p"\n\n' + "[profiles.p.llmwhisperer]\n" + 'base_url = "https://from-file.example/api/v2"\n' + ) + monkeypatch.setenv("UNSTRACT_CONFIG", str(config)) + cfg = ResolvedConfig(file=load_config(config)) + + assert cfg.get(ApiGroup.LLMWHISPERER, "base_url") == cfg.get( + "llmwhisperer", "base_url" + ) == "https://from-file.example/api/v2" + + def test_error_message_uses_the_plain_name(self, monkeypatch): + monkeypatch.delenv("LLMWHISPERER_API_KEY", raising=False) + cfg = ResolvedConfig(file=ConfigFile(exists=False)) + with pytest.raises(ConfigError) as exc: + cfg.require(ApiGroup.LLMWHISPERER, "api_key") + assert "llmwhisperer.api_key" in str(exc.value) + assert "ApiGroup." not in str(exc.value) + + +class TestRedaction: + """SPEC §5.7 - secrets appear in no output stream.""" + + def test_headers_redacted(self): + out = redact_headers({"unstract-key": FAKE_KEY, "Authorization": f"Bearer {FAKE_KEY}"}) + assert FAKE_KEY not in str(out) + assert out["unstract-key"] == REDACTED + + def test_scrub_removes_literals(self): + assert FAKE_KEY not in scrub(f"failed with key {FAKE_KEY}", [FAKE_KEY]) + + def test_dry_run_describe_redacts(self, monkeypatch): + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + plan = build_request(get_endpoint("whisper.status"), cfg, {"whisper_hash": "h"}) + assert FAKE_KEY not in str(plan.describe()) + + +class TestRequestBuilding: + def test_query_params_and_url(self, monkeypatch): + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + plan = build_request(get_endpoint("whisper.status"), cfg, {"whisper_hash": "abc123"}) + assert plan.url == f"{WHISPER_BASE}/whisper-status" + assert plan.params["whisper_hash"] == "abc123" + + def test_booleans_serialise_lowercase(self, monkeypatch): + """Python's True would be rejected; the wire format is `true`.""" + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + plan = build_request( + get_endpoint("whisper.retrieve"), cfg, {"whisper_hash": "h", "text_only": True} + ) + assert plan.params["text_only"] == "true" + + def test_path_params_substituted_from_env(self, monkeypatch): + cfg = _config( + monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_abc" + ) + plan = build_request( + get_endpoint("docstudio.deployment.status"), + cfg, + {"api_name": "invoice-api", "execution_id": "e1"}, + ) + assert plan.url.endswith("/deployment/api/org_abc/invoice-api/") + + def test_missing_required_path_param_is_usage_error(self, monkeypatch): + cfg = _config(monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="o") + with pytest.raises(CLIError) as exc: + build_request(get_endpoint("docstudio.deployment.status"), cfg, {"execution_id": "e"}) + assert exc.value.exit_code is ExitCode.USAGE + + def test_freeform_ext_param(self, monkeypatch): + """P5 - `--ext-param foo=bar` must reach the wire as `ext_foo=bar`.""" + cfg = _config( + monkeypatch, UNSTRACT_APIHUB_KEY=FAKE_KEY, UNSTRACT_APIHUB_BASE_URL="iframe.php?url=https%3A%2F%2Fhub.test" + ) + plan = build_request( + get_endpoint("apihub.retrieve"), + cfg, + {"file_hash": "h", "ext_param": ["foo=bar", "baz=qux"]}, + ) + # `retrieve` has no ext_param; verify on `extract`, which does. + plan = build_request( + get_endpoint("apihub.extract"), + cfg, + {"vertical": "table", "sub_vertical": "extract_table", + "use_cached_file_hash": "h", "ext_param": ["foo=bar", "baz=qux"]}, + ) + assert plan.params["ext_foo"] == "bar" + assert plan.params["ext_baz"] == "qux" + + def test_freeform_rejects_malformed(self, monkeypatch): + cfg = _config( + monkeypatch, UNSTRACT_APIHUB_KEY=FAKE_KEY, UNSTRACT_APIHUB_BASE_URL="iframe.php?url=https%3A%2F%2Fhub.test" + ) + with pytest.raises(CLIError) as exc: + build_request( + get_endpoint("apihub.extract"), cfg, + {"vertical": "table", "sub_vertical": "extract_table", + "use_cached_file_hash": "h", "ext_param": ["novalue"]}, + ) + assert exc.value.exit_code is ExitCode.USAGE + + def test_share_resource_maps_to_path_segment(self, monkeypatch): + """P3 - the friendly name is not the URL segment.""" + cfg = _config(monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="o") + plan = build_request( + get_endpoint("docstudio.platform.share"), cfg, + {"resource": "api-deployment", "id": "abc"}, + ) + assert "/api/deployment/abc/share/" in plan.url + + def test_apihub_without_base_url_is_usage_error(self, monkeypatch): + """API Hub has no default base URL; inventing one would send documents + to a host we cannot vouch for (SPEC §11.1).""" + cfg = _config(monkeypatch, UNSTRACT_APIHUB_KEY=FAKE_KEY) + with pytest.raises(CLIError) as exc: + build_request(get_endpoint("apihub.status"), cfg, {"file_hash": "h"}) + assert exc.value.exit_code is ExitCode.USAGE + + +class TestJsonParams: + """BUG 1 - ParamType.JSON params must reach the wire as objects, not strings.""" + + def _cfg(self, monkeypatch): + return _config(monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="o") + + def test_body_json_param_is_parsed_to_object(self, monkeypatch): + """A BODY+JSON param becomes a dict in json_body, not an escaped string.""" + plan = build_request( + get_endpoint("docstudio.platform.prompt-studio.sync-prompts"), + self._cfg(monkeypatch), + {"tool_id": "t", "data": '{"prompts": [{"prompt_key": "k"}]}'}, + ) + assert plan.json_body["data"] == {"prompts": [{"prompt_key": "k"}]} + assert isinstance(plan.json_body["data"], dict) + + def test_dry_run_renders_json_param_as_object(self, monkeypatch): + plan = build_request( + get_endpoint("docstudio.platform.prompt-studio.sync-prompts"), + self._cfg(monkeypatch), + {"tool_id": "t", "data": '{"a": 1}'}, + ) + assert plan.describe()["body"]["data"] == {"a": 1} + + def test_json_param_accepts_file_reference(self, monkeypatch, tmp_path): + payload = tmp_path / "prompts.json" + payload.write_text('{"prompts": ["x"]}') + plan = build_request( + get_endpoint("docstudio.platform.prompt-studio.sync-prompts"), + self._cfg(monkeypatch), + {"tool_id": "t", "data": f"@{payload}"}, + ) + assert plan.json_body["data"] == {"prompts": ["x"]} + + def test_invalid_json_is_usage_error(self, monkeypatch): + with pytest.raises(CLIError) as exc: + build_request( + get_endpoint("docstudio.platform.prompt-studio.sync-prompts"), + self._cfg(monkeypatch), + {"tool_id": "t", "data": "{not json"}, + ) + assert exc.value.exit_code is ExitCode.USAGE + + def test_missing_json_file_is_usage_error(self, monkeypatch): + with pytest.raises(CLIError) as exc: + build_request( + get_endpoint("docstudio.platform.prompt-studio.sync-prompts"), + self._cfg(monkeypatch), + {"tool_id": "t", "data": "@/no/such/file.json"}, + ) + assert exc.value.exit_code is ExitCode.USAGE + + def test_form_json_param_is_serialized_back_to_string(self, monkeypatch): + """FORM+JSON (deployment run --custom-data) must be a *string* in the + multipart field -- httpx cannot form-encode a dict, and the server + json.loads it. This guards against a double-encode regression.""" + cfg = _config( + monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="o" + ) + plan = build_request( + get_endpoint("docstudio.deployment.run"), + cfg, + {"api_name": "inv", "custom_data": '{"k": "v"}'}, + ) + assert plan.data["custom_data"] == '{"k": "v"}' + assert isinstance(plan.data["custom_data"], str) + + +class TestResponseFieldGuards: + """BUG 2 - mirror tool_id into the prompt-create body so it links.""" + + def test_prompt_create_mirrors_tool_id_into_body(self, monkeypatch): + plan = build_request( + get_endpoint("docstudio.platform.prompt-studio.prompt.create"), + _config(monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="o"), + {"tool_id": "the-tool", "prompt_key": "k"}, + ) + # The path still carries it... + assert "/the-tool/" in plan.url + # ...and it is also sent in the body, defeating the orphaning defect. + assert plan.json_body["tool_id"] == "the-tool" + + def test_prompt_create_declares_required_response_field(self): + ep = get_endpoint("docstudio.platform.prompt-studio.prompt.create") + assert "tool_id" in ep.require_response_fields + + +class TestExecute: + @respx.mock + def test_success(self, monkeypatch): + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response(200, json={"subscription_plan": "free"}) + ) + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + plan = build_request(get_endpoint("whisper.usage"), cfg, {}) + assert execute(plan, max_retries=0).payload["subscription_plan"] == "free" + + @respx.mock + def test_concatenated_json_body_becomes_one_array(self, monkeypatch): + """CAPTURE2 DOC 6 - an endpoint that streams several JSON objects back to + back would break `| json`. The CLI recovers them into one array so the + contract 'exactly one JSON document' holds.""" + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response( + 200, headers={"content-type": "application/json"}, + content=b'{"a": 1}\n{"b": 2}', + ) + ) + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + plan = build_request(get_endpoint("whisper.usage"), cfg, {}) + payload = execute(plan, max_retries=0).payload + assert payload == [{"a": 1}, {"b": 2}] + + @respx.mock + def test_truly_malformed_json_falls_back_to_text(self, monkeypatch): + respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response( + 200, headers={"content-type": "application/json"}, + content=b'{"a": 1} not json at all', + ) + ) + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + plan = build_request(get_endpoint("whisper.usage"), cfg, {}) + assert execute(plan, max_retries=0).payload == '{"a": 1} not json at all' + + @respx.mock + def test_retries_on_500_then_succeeds(self, monkeypatch): + route = respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + side_effect=[httpx.Response(500), httpx.Response(200, json={"ok": True})] + ) + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + plan = build_request(get_endpoint("whisper.usage"), cfg, {}) + assert execute(plan, max_retries=2, sleep=lambda _: None).status == 200 + assert route.call_count == 2 + + @respx.mock + def test_does_not_retry_4xx(self, monkeypatch): + route = respx.get(f"{WHISPER_BASE}/get-usage-info").mock( + return_value=httpx.Response(403, json={"message": "Unauthorized"}) + ) + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + plan = build_request(get_endpoint("whisper.usage"), cfg, {}) + assert execute(plan, max_retries=3, sleep=lambda _: None).status == 403 + assert route.call_count == 1, "a 4xx must not be retried" + + @respx.mock + def test_already_consumed_maps_to_exit_9(self, monkeypatch): + """SPEC §5.6 - the one-shot footgun gets its own exit code.""" + respx.get(f"{WHISPER_BASE}/whisper-retrieve").mock( + return_value=httpx.Response(406, json={"message": "Whisper already delivered"}) + ) + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + endpoint = get_endpoint("whisper.retrieve") + plan = build_request(endpoint, cfg, {"whisper_hash": "h"}) + response = execute(plan, max_retries=0) + with pytest.raises(CLIError) as exc: + raise_for_status(response, endpoint) + assert exc.value.exit_code is ExitCode.ALREADY_CONSUMED + assert "already" in (exc.value.hint or "").lower() + + @respx.mock + def test_error_carries_hint_and_details(self, monkeypatch): + respx.get(f"{WHISPER_BASE}/whisper-status").mock( + return_value=httpx.Response( + 422, + json={"type": "validation_error", + "errors": [{"code": "invalid", "detail": "bad hash", "attr": "whisper_hash"}]}, + ) + ) + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + endpoint = get_endpoint("whisper.status") + plan = build_request(endpoint, cfg, {"whisper_hash": "x"}) + with pytest.raises(CLIError) as exc: + raise_for_status(execute(plan, max_retries=0), endpoint) + payload = exc.value.to_dict()["error"] + assert payload["message"] == "bad hash" + assert payload["retryable"] is False + assert payload["exit_code"] == 5 diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..ab59eb8 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,238 @@ +"""The schema expresses parameter patterns P1-P12. + +This is the load-bearing design work: if any pattern in the endpoint reference +cannot be expressed declaratively, generation degrades into per-command +special-casing and the Skill's diff stops being meaningful. Each test below pins +one pattern to a real record in the shipped surface. +""" + +from __future__ import annotations + +import pytest + +from unstract_cli.core.model import ( + ApiGroup, + AtLeastOneOf, + BodyKind, + Endpoint, + MutuallyExclusive, + Param, + ParamLocation, + ParamType, + derive_patch, + with_params, +) +from unstract_cli.endpoints import ALL_ENDPOINTS, get_endpoint + + +class TestP1MutuallyExclusive: + """P1 - exactly one of a set of flags.""" + + def test_rejects_both(self): + c = MutuallyExclusive(("file", "url")) + assert "mutually exclusive" in (c.check({"file": "a", "url": "b"}) or "") + + def test_rejects_neither_when_required(self): + assert "required" in (MutuallyExclusive(("file", "url")).check({}) or "") + + def test_accepts_exactly_one(self): + assert MutuallyExclusive(("file", "url")).check({"file": "a"}) is None + + def test_optional_variant_allows_neither(self): + assert MutuallyExclusive(("api", "pipeline"), required=False).check({}) is None + + def test_real_endpoint_uses_it(self): + endpoint = get_endpoint("whisper.extract") + assert any(isinstance(c, MutuallyExclusive) for c in endpoint.constraints) + assert endpoint.validate({"file": "a", "url": "b"}) + + +class TestP2AtLeastOneOf: + """P2 - guard against an unfiltered destructive operation.""" + + def test_requires_one(self): + assert AtLeastOneOf(("ids", "status")).check({}) is not None + assert AtLeastOneOf(("ids", "status")).check({"status": "ERROR"}) is None + + def test_file_history_clear_is_guarded(self): + # Without a filter this endpoint would delete every file history. + endpoint = get_endpoint("docstudio.platform.workflow.file-history.clear") + assert any(isinstance(c, AtLeastOneOf) for c in endpoint.constraints) + assert endpoint.validate({"workflow_id": "w"}) + + +class TestP3ChoiceMapping: + """P3 - friendly value maps to a different wire value.""" + + def test_mapping_translates(self): + param = Param("resource", choices={"api-deployment": "api/deployment"}) + assert param.to_wire("api-deployment") == "api/deployment" + + def test_plain_sequence_is_identity(self): + param = Param("mode", choices=["form", "table"]) + assert param.to_wire("form") == "form" + + def test_share_resource_mapping(self): + param = get_endpoint("docstudio.platform.share").param("resource") + assert param.choice_map()["api-deployment"] == "api/deployment" + + +class TestP4Repeatable: + def test_multiple_flag(self): + assert get_endpoint("docstudio.deployment.run").param("files").multiple + + +class TestP5Freeform: + """P5 - escape hatch for parameters newer than the CLI.""" + + def test_ext_param_declares_prefix(self): + param = get_endpoint("apihub.extract").param("ext_param") + assert param.freeform_prefix == "ext_" + assert param.multiple + + +class TestP6PathParamDefaults: + def test_org_id_defaults_from_profile(self): + param = get_endpoint("docstudio.deployment.run").param("org_id") + assert param.location is ParamLocation.PATH + assert param.default_sources[0] == "deployment.org_id" + + def test_org_id_falls_back_to_the_platform_block(self): + # The deployment/hitl config blocks start empty, but the organization is + # the same one the platform block already names (GOTCHAS #7). The block's + # own value must still win, so the fallback comes second. + for name in ("docstudio.deployment.run", "docstudio.hitl.approved.get"): + sources = get_endpoint(name).param("org_id").default_sources + assert sources[-1] == "platform.org_id" + assert len(sources) == 2 + + def test_api_name_has_no_default(self): + # One profile serves many deployments, so this cannot be defaulted. + param = get_endpoint("docstudio.deployment.run").param("api_name") + assert param.required and param.default_from is None + + +class TestP7Locations: + def test_all_locations_representable(self): + used = {p.location for e in ALL_ENDPOINTS for p in e.params} + assert {ParamLocation.QUERY, ParamLocation.BODY, ParamLocation.PATH, + ParamLocation.FORM} <= used + + def test_body_kinds_used(self): + used = {e.body for e in ALL_ENDPOINTS} + assert {BodyKind.NONE, BodyKind.JSON, BodyKind.MULTIPART, + BodyKind.BINARY_FILE} <= used + + +class TestP8DerivedPatch: + """P8 - PATCH derives from PUT so records cannot drift apart.""" + + def test_strips_required_but_keeps_path_params(self): + put = Endpoint( + name="update", group="g", method="PUT", path="/x/{id}/", + api=ApiGroup.PLATFORM, summary="s", + params=( + Param("id", location=ParamLocation.PATH, required=True), + Param("name", location=ParamLocation.BODY, required=True), + ), + ) + patch = derive_patch(put) + assert patch.method == "PATCH" + assert patch.param("id").required, "path params stay required" + assert not patch.param("name").required, "body fields become optional" + + def test_shipped_patches_mirror_their_put(self): + for group in ("docstudio.platform.prompt-studio", "docstudio.platform.workflow"): + put = get_endpoint(f"{group}.update") + patch = get_endpoint(f"{group}.patch") + assert {p.name for p in patch.params} >= {p.name for p in put.params}, ( + "a PATCH missing a PUT field means the records have drifted" + ) + + def test_with_params_appends(self): + base = Endpoint(name="a", group="g", method="PATCH", path="/x", + api=ApiGroup.PLATFORM, summary="s") + extended = with_params(base, Param("active", type=ParamType.BOOL)) + assert extended.param("active") is not None + assert base.param("active") is None, "the source record must be untouched" + + +class TestP9ConditionalApplicability: + def test_documented_not_enforced(self): + param = get_endpoint("whisper.extract").param("median_filter_size") + assert param.applies_when == "mode=low_cost" + # Deliberately no constraint: the server owns this rule, and enforcing it + # locally would guess wrong when the server's behaviour changes. + assert not get_endpoint("whisper.extract").validate({"file": "x"}) + + +class TestP10IdentifierTypes: + def test_group_ids_are_ints_not_uuids(self): + assert get_endpoint("docstudio.platform.group.patch").param("id").type is ParamType.INT + assert get_endpoint("docstudio.platform.workflow.get").param("id").type is ParamType.UUID + + +class TestP11LiteralPaths: + """P11 - upstream paths are inconsistent, and that inconsistency is load-bearing.""" + + @pytest.mark.parametrize( + "name,expected", + [ + ("docstudio.platform.prompt-studio.profile.create", + "/api/v1/unstract/{org_id}/prompt-studio/profilemanager/{tool_id}"), + ("docstudio.platform.prompt-studio.profile.get", + "/api/v1/unstract/{org_id}/prompt-studio/profile-manager/{profile_id}/"), + ("docstudio.platform.group.member.remove", + "/api/v1/unstract/{org_id}/groups/{id}/members/{user_id}"), + ("docstudio.platform.pipeline.postman-collection", + "/api/v1/unstract/{org_id}/pipeline/api/postman_collection/{id}/"), + ("docstudio.platform.api-deployment.postman-collection", + "/api/v1/unstract/{org_id}/api/postman_collection/{id}/"), + ], + ) + def test_exact_paths(self, name, expected): + assert get_endpoint(name).path == expected + + def test_missing_trailing_slash_is_declared(self): + """A path without a trailing slash must say so, so it reads as intent.""" + for endpoint in ALL_ENDPOINTS: + if endpoint.api is not ApiGroup.PLATFORM: + continue + if "{" in endpoint.path.split("/")[-1] and not endpoint.path.endswith("/"): + assert endpoint.no_trailing_slash, ( + f"{endpoint.dotted_name} lacks a trailing slash but does not " + "declare no_trailing_slash -- typo or intent?" + ) + + +class TestP12ReplaceSemantics: + def test_shared_users_marked_replace(self): + assert get_endpoint("docstudio.platform.share").param("shared_users").replace_semantics + + +class TestRegistryIntegrity: + def test_names_unique(self): + names = [e.dotted_name for e in ALL_ENDPOINTS] + assert len(names) == len(set(names)), "duplicate command paths" + + def test_every_endpoint_documented(self): + for endpoint in ALL_ENDPOINTS: + assert endpoint.summary, f"{endpoint.dotted_name} has no summary" + assert endpoint.doc_source, f"{endpoint.dotted_name} has no doc_source" + + def test_every_param_has_help(self): + for endpoint in ALL_ENDPOINTS: + for param in endpoint.params: + assert param.help, f"{endpoint.dotted_name}:{param.name} has no help" + + def test_poll_specs_reference_real_endpoints(self): + for endpoint in ALL_ENDPOINTS: + if not endpoint.poll: + continue + get_endpoint(endpoint.poll.status_endpoint) + if endpoint.poll.retrieve_endpoint: + get_endpoint(endpoint.poll.retrieve_endpoint) + + def test_methods_are_valid(self): + valid = {"GET", "POST", "PUT", "PATCH", "DELETE"} + assert {e.method for e in ALL_ENDPOINTS} <= valid diff --git a/tests/test_output.py b/tests/test_output.py index d415053..d0010cf 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,4 +1,10 @@ -"""The stdout envelope and its renderings.""" +"""Output rendering (SPEC.md §5.1). + +The table renderer is the human-facing format, so the property that matters most +is that it is **lossless**: a value shown in a table must be the whole value. +Truncation is the kind of defect you only discover after acting on a half-read +id or URL. +""" from __future__ import annotations @@ -6,224 +12,94 @@ 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, render, render_table, - resolve_format, ) -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, "contract_version": CONTRACT_VERSION}, - } - - -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"] == {"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): - emit_result({"text": "hello"}, OutputFormat.JSON) - out = capsys.readouterr() - assert json.loads(out.out) == { - "ok": True, - "data": {"text": "hello"}, - "error": None, - "meta": {"contract_version": CONTRACT_VERSION}, - } - 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_fields=("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)" - - -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 - - -# --------------------------------------------------------------------------- # -# 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_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": None}) - with pytest.raises(CLIError): - render(env, OutputFormat.RAW, raw_fields=("extraction_result", "execution_id")) - - -def test_raw_prints_an_empty_answer_when_empty_is_the_answer(): - """An extraction of a blank page is empty text, not a missing result, and - the hash beside it is not what the caller asked to see.""" - env = envelope(data={"result_text": "", "whisper_hash": "h1"}) - assert render(env, OutputFormat.RAW, raw_fields=("result_text", "whisper_hash")) == "" - - -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 +LONG_NOTE = ( + "Credentials use env: indirection, so this file holds no secrets. " + "Set the referenced environment variables to authenticate." +) -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 +class TestTableIsLossless: + def test_long_value_is_wrapped_not_truncated(self): + out = render_table({"note": LONG_NOTE}, max_width=80) + assert "..." not in out + # Every word survives, even though the cell spans several lines. + flattened = " ".join(out.split()) + for word in LONG_NOTE.split(): + assert word in flattened + + @pytest.mark.parametrize("width", [40, 60, 80, 100, 200]) + def test_lossless_at_any_width(self, width): + out = render_table({"note": LONG_NOTE}, max_width=width) + assert "..." not in out + assert "authenticate" in out, "the tail of the value must always survive" + + def test_unbroken_token_is_hard_wrapped(self): + """A long URL has no spaces to wrap on; it must break rather than overflow.""" + url = "https://ex" + "y" * 120 + ".com/end" + out = render_table({"url": url}, max_width=60) + assert "..." not in out + # Reassembling the value column's lines must reproduce the URL exactly. + value_lines = [ln.split(maxsplit=1)[-1] for ln in out.splitlines()[2:]] + assert "".join(part.strip() for part in value_lines) == url + assert all(len(line) <= 60 for line in out.splitlines()) + + +class TestTableLayout: + def test_respects_terminal_width(self): + out = render_table({"note": LONG_NOTE}, max_width=60) + assert all(len(line) <= 60 for line in out.splitlines()) + + def test_columns_stay_aligned_across_wrapped_rows(self): + rows = [ + {"id": "a1", "description": LONG_NOTE}, + {"id": "b2", "description": "short"}, + ] + lines = render_table(rows, max_width=70).splitlines() + # The id column is narrow, so every continuation line begins with the + # blank gutter that keeps the grid readable. + continuation = [ln for ln in lines[2:] if ln and not ln[0].isalnum()] + assert continuation, "expected wrapped continuation lines" + assert all(ln.startswith(" ") for ln in continuation) + + def test_narrow_column_not_squeezed_for_a_wide_neighbour(self): + """Only the widest column gives up space, so ids stay readable.""" + rows = [{"id": "9c1e-4f2a", "description": LONG_NOTE}] + out = render_table(rows, max_width=60) + assert "9c1e-4f2a" in out, "the narrow id column must not wrap" + + def test_key_value_shape_for_single_object(self): + out = render_table({"a": 1, "b": 2}) + assert out.splitlines()[0].split() == ["key", "value"] + + def test_list_of_objects_uses_keys_as_columns(self): + out = render_table([{"id": "x", "name": "y"}]) + assert out.splitlines()[0].split() == ["id", "name"] + + def test_empty_input(self): + assert render_table([]) == "(no results)" + + def test_booleans_render_lowercase(self): + assert "false" in render_table({"replaced_existing": False}) + + +class TestOtherFormats: + def test_json_is_untouched_by_table_wrapping(self): + """Only the human format wraps; machine formats stay verbatim.""" + payload = {"note": LONG_NOTE} + assert json.loads(render(payload, OutputFormat.JSON)) == payload + + def test_raw_returns_the_payload_alone(self): + out = render({"result_text": "EXTRACTED"}, OutputFormat.RAW, raw_field="result_text") + assert out == "EXTRACTED" + + def test_yaml_round_trips(self): + import yaml + + payload = {"note": LONG_NOTE, "n": 1} + assert yaml.safe_load(render(payload, OutputFormat.YAML)) == payload diff --git a/tests/test_params.py b/tests/test_params.py deleted file mode 100644 index 2bf44ad..0000000 --- a/tests/test_params.py +++ /dev/null @@ -1,373 +0,0 @@ -"""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 click.testing -import pytest -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.errors import CLIError, ExitCode -from unstract_cli.core.params import ( - Param, - check_overlay, - 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" - - -#: A spec carrying the marker on one query parameter and one body property. -MARKED_SPEC = { - "paths": { - "/run": { - "post": { - "operationId": "run", - "parameters": [ - {"name": "mode", "in": "query", "schema": {"type": "string"}}, - { - "name": "trace", - "in": "query", - "schema": {"type": "boolean"}, - "x-internal": True, - }, - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "properties": { - "timeout": {"type": "integer"}, - "use_file_history": { - "type": "boolean", - "default": False, - "x-internal": True, - }, - } - } - } - } - }, - } - } - } -} - - -def test_an_internal_parameter_is_not_offered(monkeypatch): - """The server accepts it and the generated clients keep it, so it stays in - the spec; the marker is the only thing saying it is not for callers.""" - monkeypatch.setattr(params_module, "load_spec", lambda product: MARKED_SPEC) - - assert {p.name for p in operation_params("docstudio", "run")} == {"mode", "timeout"} - - -def test_an_internal_parameter_is_unknown_to_the_parser(monkeypatch): - monkeypatch.setattr(params_module, "load_spec", lambda product: MARKED_SPEC) - command = click.Command( - "run", - params=[click_option(p, {}) for p in operation_params("docstudio", "run")], - callback=lambda **_: None, - ) - - for flag in ("--trace", "--use-file-history"): - result = click.testing.CliRunner().invoke(command, [flag]) - assert result.exit_code == 2, flag - assert "No such option" in result.output, flag - - -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 - # 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(): - """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_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." - - -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 - - -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_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 "form" 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") - - -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 - - -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 - - -@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 - 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 -# --------------------------------------------------------------------------- # - - -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_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_platform.py b/tests/test_platform.py deleted file mode 100644 index d23ebe3..0000000 --- a/tests/test_platform.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Where the platform key is sent, and the one flag the CLI validates itself. - -The transport and the two operations are generated into `unstract-client` and -covered there; what is left here is CLI-owned: which host and key the client -is built with, and refusing a `--transport-timeout` the connection layer would -reject deep inside itself. -""" - -from __future__ import annotations - -import pytest - -from unstract_cli.config import ( - DEFAULT_BASE_URLS, - DOCSTUDIO, - ConfigError, - ConfigFile, - ResolvedConfig, -) -from unstract_cli.core.errors import CLIError, ExitCode -from unstract_cli.core.platform import organisation, platform_client - -KEY = {"platform_key": "pk-000000000000"} - - -def _resolved(profiles, **overrides): - return ResolvedConfig( - file=ConfigFile(profiles=profiles, default_profile="p", exists=True), - overrides=overrides, - ) - - -def test_the_factory_threads_a_timeout() -> None: - """`--transport-timeout` was accepted on `deployment ls` and ignored: the - client applies no bound of its own, so a stalled connection never returns. - """ - config = _resolved({"p": {DOCSTUDIO: KEY}}) - - assert platform_client(config, timeout=3).transport_timeout == 3 - - -# --- which host the key is sent to ---------------------------------------- - - -def test_the_platform_host_is_docstudios() -> None: - """One deployment serves both the platform API and the deployments it - manages, so the host a caller named for docstudio is the host for this. - """ - config = _resolved({"p": {DOCSTUDIO: {**KEY, "base_url": "https://onprem.example"}}}) - - assert platform_client(config).base_url == "https://onprem.example" - - -def test_a_docstudio_base_url_flag_reaches_the_platform_call() -> None: - config = _resolved( - {"p": {DOCSTUDIO: {**KEY, "base_url": "https://profile.example"}}}, - **{"docstudio.base_url": "https://flag.example"}, - ) - - assert platform_client(config).base_url == "https://flag.example" - - -def test_the_built_in_default_is_the_last_resort() -> None: - assert ( - platform_client(_resolved({"p": {DOCSTUDIO: KEY}})).base_url - == (DEFAULT_BASE_URLS[DOCSTUDIO]) - ) - - -def test_the_platform_key_is_not_the_deployment_key() -> None: - """Both live on the docstudio block; the client must take the right one.""" - config = _resolved( - {"p": {DOCSTUDIO: {**KEY, "api_key": "dk-deployment-key"}}}, - ) - - assert platform_client(config).api_key == KEY["platform_key"] - - -def test_a_missing_platform_key_names_where_one_goes() -> None: - with pytest.raises(ConfigError, match="docstudio.platform_key") as caught: - platform_client(_resolved({"p": {}})) - - assert "UNSTRACT_PLATFORM_KEY" in str(caught.value) - # A secret flag exists but is never suggested: it lands in shell history. - assert "--platform-key" not in str(caught.value) - - -@pytest.mark.parametrize( - "profile", - [ - {DOCSTUDIO: {**KEY, "base_url": "us-central.unstract.com"}}, - {DOCSTUDIO: {"platform_key": " ", "base_url": "https://host.example"}}, - ], - ids=["base_url without a scheme", "a blank key"], -) -def test_a_client_the_settings_cannot_build_is_a_usage_error(profile) -> None: - """The client checks the host and the key before it sends anything, and - that failure matches no arm of the entry point: without this it is a - traceback where a misconfigured caller most needs an envelope.""" - with pytest.raises(CLIError) as caught: - platform_client(_resolved({"p": profile})) - - assert caught.value.exit_code == ExitCode.USAGE - - -@pytest.mark.parametrize("value", [0, 0.0, -1]) -def test_a_non_positive_timeout_is_refused_before_the_transport_sees_it(value) -> None: - """A negative timeout is rejected at send time by a bare ValueError that - matches no arm in `__main__`. Refused at the flag instead, so the caller - gets a usage error and an envelope. - """ - config = _resolved({"p": {DOCSTUDIO: KEY}}) - - with pytest.raises(CLIError) as caught: - platform_client(config, timeout=value) - - assert caught.value.exit_code == ExitCode.USAGE - - -def test_a_sub_second_timeout_survives_as_a_float() -> None: - """An earlier revision truncated this with `int()`, which sent - `--transport-timeout 0.5` as 0 -- the rejection above -- and silently - rounded 1.9 down to 1. - """ - config = _resolved({"p": {DOCSTUDIO: KEY}}) - - assert platform_client(config, timeout=0.5).transport_timeout == 0.5 - assert platform_client(config, timeout=1.9).transport_timeout == 1.9 - - -@pytest.mark.parametrize("configured", [" ", "\n", " org_ABC "]) -def test_an_organisation_is_read_without_the_whitespace_around_it(configured) -> None: - """A copied-in value carries the spaces it was copied with, and they reach - the deployment URL: either a 404 or a name no listing shows. - """ - config = _resolved({"p": {DOCSTUDIO: {**KEY, "org_id": configured}}}) - - if not configured.strip(): - with pytest.raises(CLIError) as caught: - organisation(config) - assert caught.value.exit_code == ExitCode.USAGE - else: - assert organisation(config) == "org_ABC" diff --git a/tests/test_poll.py b/tests/test_poll.py index fe98f45..d52039d 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -1,649 +1,343 @@ -"""The `--wait` engine, driven by a fake clock and fake responses. No network.""" +"""`--wait` polling, including the 422 defect (R4). -from __future__ import annotations - -import errno -import json -import os -import stat -import sys - -import pytest - -from unstract_cli.core import poll as poll_module -from unstract_cli.core.errors import REDACTED, ExitCode -from unstract_cli.core.poll import ( - MAX_TRANSIENT_POLLS, - CLIError, - PollSpec, - extract_handle, - extract_status, - persist, - preflight, - 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 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 - - -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={"status": "processed", "result_text": "done"}, - spec=SPEC, - poll=poll, - save=target, - sleep=Clock().sleep, - ) - 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(): - 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" - on_disk: 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, - # 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, - ) - 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"} - - -@pytest.mark.skipif(sys.platform == "win32", reason="no directory handles") -def test_a_saved_result_is_synced_through_the_directory_entry(tmp_path, monkeypatch): - """Syncing the file leaves the rename itself in cache, so a crash can take - back the only copy of a result the service will not serve again.""" - order: list[str] = [] - real_fsync, real_replace = os.fsync, os.replace - - def fsync(fd): - order.append("fsync-dir" if os.fstat(fd).st_mode & stat.S_IFDIR else "fsync") - real_fsync(fd) - - monkeypatch.setattr(poll_module.os, "fsync", fsync) - monkeypatch.setattr( - poll_module.os, - "replace", - lambda src, dst: (order.append("replace"), real_replace(src, dst))[1], - ) - - persist(tmp_path / "out.json", {"a": 1}) +The single most important test in this suite is +`TestDeployment422Defect::test_422_and_200_resolve_identically`. - assert order == ["fsync", "replace", "fsync-dir"] - - -@pytest.mark.skipif(sys.platform == "win32", reason="no directory handles") -def test_a_result_that_cannot_be_confirmed_on_disk_is_not_reported_as_saved( - tmp_path, monkeypatch -): - """Unlike the config, which can be written again, this is the last copy: - reporting success would invite the caller to drop it.""" - real_fsync = os.fsync +The Unstract deployment API currently returns **HTTP 422** for the in-progress +states `PENDING` and `EXECUTING` -- a documented server-side defect that is +scheduled to be fixed. If the CLI branched on the HTTP status code it would +either break today (reading in-progress as failure) or break the day the defect +is fixed. Branching on the response body's `status` field makes both behave +identically, and that is what these tests pin down. +""" - def fsync(fd): - if os.fstat(fd).st_mode & stat.S_IFDIR: - raise OSError(errno.EIO, "Input/output error") - real_fsync(fd) - - monkeypatch.setattr(poll_module.os, "fsync", fsync) - - with pytest.raises(CLIError) as caught: - persist(tmp_path / "out.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_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_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" - - -@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" +from __future__ import annotations +import httpx +import pytest +import respx -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 unstract_cli.config.loader import ConfigFile, ResolvedConfig +from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.poll import extract_handle, extract_status, wait_for_completion +from unstract_cli.endpoints import get_endpoint +from .conftest import FAKE_KEY, PLATFORM_BASE, WHISPER_BASE -# --------------------------------------------------------------------------- # -# Transient failures, and what may not be retried -# --------------------------------------------------------------------------- # +def _config(monkeypatch, **env) -> ResolvedConfig: + for key, value in env.items(): + monkeypatch.setenv(key, value) + return ResolvedConfig(file=ConfigFile(exists=False)) -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"} +class TestFieldExtraction: + def test_reads_bare_field(self): + assert extract_status({"status": "processed"}) == "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_reads_through_message_envelope(self): + """The deployment API nests its payload under `message`.""" + payload = {"message": {"execution_status": "COMPLETED"}} + assert extract_status(payload, "execution_status") == "COMPLETED" + def test_reads_handle_through_envelope(self): + payload = {"message": {"execution_id": "e-123"}} + assert extract_handle(payload, "execution_id") == "e-123" -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, +class TestWhisperWait: + @respx.mock + def test_polls_then_retrieves(self, monkeypatch): + respx.get(f"{WHISPER_BASE}/whisper-status").mock( + side_effect=[ + httpx.Response(200, json={"status": "processing"}), + httpx.Response(200, json={"status": "processed"}), + ] ) - 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, + respx.get(f"{WHISPER_BASE}/whisper-retrieve").mock( + return_value=httpx.Response(200, json={"result_text": "EXTRACTED"}) ) - 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 + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + result = wait_for_completion( + endpoint=get_endpoint("whisper.extract"), + initial={"whisper_hash": "h1", "status": "processing"}, + config=cfg, + sleep=lambda _: None, ) + assert result["result_text"] == "EXTRACTED" - 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, + @respx.mock + def test_error_status_raises(self, monkeypatch): + respx.get(f"{WHISPER_BASE}/whisper-status").mock( + return_value=httpx.Response(200, json={"status": "error", "message": "bad scan"}) + ) + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + with pytest.raises(CLIError) as exc: + wait_for_completion( + endpoint=get_endpoint("whisper.extract"), + initial={"whisper_hash": "h1"}, + config=cfg, + sleep=lambda _: None, + ) + assert exc.value.exit_code is ExitCode.VALIDATION + + @respx.mock + def test_timeout_exits_7_and_returns_handle(self, monkeypatch): + """On timeout an agent must be able to resume, not reprocess.""" + respx.get(f"{WHISPER_BASE}/whisper-status").mock( + return_value=httpx.Response(200, json={"status": "processing"}) + ) + cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) + clock = iter([0.0, 0.0, 100.0, 200.0, 400.0, 500.0, 600.0]) + + with pytest.raises(CLIError) as exc: + wait_for_completion( + endpoint=get_endpoint("whisper.extract"), + initial={"whisper_hash": "h-resume-me"}, + config=cfg, + timeout=300.0, + sleep=lambda _: None, + now=lambda: next(clock), + ) + assert exc.value.exit_code is ExitCode.TIMEOUT + assert exc.value.to_dict()["error"]["whisper_hash"] == "h-resume-me" + + +class TestDeployment422Defect: + """R4 - the highest-value test in the suite. + + `EXECUTING`/`PENDING` currently arrive with HTTP 422. When that defect is + fixed they will arrive with HTTP 200. Both must behave identically. + """ + + @staticmethod + def _run(monkeypatch, in_progress_status_code: int): + # The real status GET returns a TOP-LEVEL `status`, and its `message` holds + # the result -- NOT the nested `{message: {execution_status}}` shape the run + # POST uses. Testing the true shape is what protects CAPTURE2 BUG 2. + url = f"{PLATFORM_BASE}/deployment/api/org_test/my-api/" + respx.get(url).mock( + side_effect=[ + httpx.Response( + in_progress_status_code, + json={"status": "EXECUTING", "message": None}, + ), + httpx.Response( + 200, + json={"status": "COMPLETED", + "message": [{"file": "a.pdf", "status": "Success"}]}, + ), + ] + ) + cfg = _config( + monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_test" + ) + return wait_for_completion( + endpoint=get_endpoint("docstudio.deployment.run"), + initial={"message": {"execution_id": "e-1", "execution_status": "PENDING"}}, + config=cfg, + values={"api_name": "my-api"}, + sleep=lambda _: None, ) - 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, + @respx.mock + def test_current_behaviour_422_in_progress(self, monkeypatch): + """Today: 422 + EXECUTING means 'still running', not 'failed'.""" + result = self._run(monkeypatch, 422) + assert extract_status(result, ("status", "execution_status")) == "COMPLETED" + + @respx.mock + def test_future_behaviour_200_in_progress(self, monkeypatch): + """After the fix: 200 + EXECUTING must behave exactly the same.""" + result = self._run(monkeypatch, 200) + assert extract_status(result, ("status", "execution_status")) == "COMPLETED" + + @respx.mock + def test_both_paths_agree(self, monkeypatch): + a = self._run(monkeypatch, 422) + respx.reset() + b = self._run(monkeypatch, 200) + assert a == b, "the 422 defect must not change the observable outcome" + + @respx.mock + def test_genuine_422_without_status_still_fails(self, monkeypatch): + """A 422 carrying no recognisable state is a real validation failure. + + Reading the body must not become a way to swallow genuine errors. + """ + url = f"{PLATFORM_BASE}/deployment/api/org_test/my-api/" + respx.get(url).mock( + return_value=httpx.Response( + 422, json={"type": "client_error", + "errors": [{"detail": "Pipeline is inactive"}]} + ) + ) + cfg = _config( + monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_test" ) - assert caught.value.message == "upstream" - assert len(calls) == MAX_TRANSIENT_POLLS + 1 + with pytest.raises(CLIError) as exc: + wait_for_completion( + endpoint=get_endpoint("docstudio.deployment.run"), + initial={"message": {"execution_id": "e-1"}}, + config=cfg, + values={"api_name": "my-api"}, + sleep=lambda _: None, + ) + assert exc.value.exit_code is ExitCode.VALIDATION + assert "inactive" in exc.value.message + + @respx.mock + def test_error_status_is_terminal_failure(self, monkeypatch): + url = f"{PLATFORM_BASE}/deployment/api/org_test/my-api/" + respx.get(url).mock( + return_value=httpx.Response( + 422, json={"status": "ERROR", "message": "tool failed"} + ) + ) + cfg = _config( + monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_test" + ) + with pytest.raises(CLIError) as exc: + wait_for_completion( + endpoint=get_endpoint("docstudio.deployment.run"), + initial={"message": {"execution_id": "e-1"}}, + config=cfg, + values={"api_name": "my-api"}, + sleep=lambda _: None, + ) + assert exc.value.exit_code is ExitCode.VALIDATION + + @respx.mock + def test_first_poll_terminal_returns_result_not_406(self, monkeypatch): + """CAPTURE2 BUG 2 - fast completion: the very first status poll is already + COMPLETED. The status endpoint is the one-shot store, so that read consumes + the result. The loop must recognise the top-level `status` on that read and + return its body (the result) -- not fail to recognise it, discard it, and + 406 on a second poll. A single mocked read enforces 'no second read'.""" + url = f"{PLATFORM_BASE}/deployment/api/org_test/my-api/" + route = respx.get(url).mock( + return_value=httpx.Response( + 200, + json={"status": "COMPLETED", + "message": [{"file": "bill.pdf", "result": {"invoice_no": "X-1"}}]}, + ) + ) + cfg = _config( + monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_test" + ) + result = wait_for_completion( + endpoint=get_endpoint("docstudio.deployment.run"), + initial={"message": {"execution_id": "e-1", "execution_status": "PENDING"}}, + config=cfg, + values={"api_name": "my-api"}, + sleep=lambda _: None, + ) + assert route.call_count == 1, "must not re-read a one-shot result" + assert result["message"][0]["result"]["invoice_no"] == "X-1" -def test_the_retry_backoff_never_sleeps_past_the_deadline(): - """`--timeout 30` that returns at 35s has lied, backoff or not.""" - clock = Clock() +class TestPromptStudioWait: + """IMPROVEMENT 3 - fetch-response is fire-and-forget; --wait must poll + task-status to completion and then read the result from the Output Manager, + which is keyed by the *original request's* tool_id, not the poll handle.""" - def poll(handle): - raise CLIError("upstream", ExitCode.SERVER_ERROR, retryable=True) + _PS_BASE = f"{PLATFORM_BASE}/api/v1/unstract/org_test/prompt-studio" - 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, + @respx.mock + def test_polls_task_status_then_reads_output(self, monkeypatch): + respx.get(f"{self._PS_BASE}/the-tool/task-status/task-9").mock( + side_effect=[ + httpx.Response(200, json={"task_id": "task-9", "status": "processing"}), + httpx.Response(200, json={"task_id": "task-9", "status": "completed"}), + ] ) - 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, + output = respx.get(f"{self._PS_BASE}/prompt-output/").mock( + return_value=httpx.Response( + 200, json=[{"prompt_id": "p1", "output": "42", "modified_at": "t"}] + ) ) - 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",), + cfg = _config( + monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_test" ) - 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) - - -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} - - -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, + result = wait_for_completion( + endpoint=get_endpoint("docstudio.platform.prompt-studio.fetch-response"), + initial={"task_id": "task-9", "run_id": "r1", "status": "accepted"}, + config=cfg, + values={"tool_id": "the-tool", "document_id": "d1", "id": "p1"}, + sleep=lambda _: None, + ) + assert result[0]["output"] == "42" + # The retrieve is narrowed to the exact prompt + document this call ran + # (id->prompt_id, document_id->document_manager), keyed by the original + # tool_id, and does NOT leak the task_id handle into the output-list call. + url = str(output.calls.last.request.url) + assert "tool_id=the-tool" in url + assert "prompt_id=p1" in url + assert "document_manager=d1" in url + assert "task_id" not in url + + @respx.mock + def test_failed_task_is_terminal_failure(self, monkeypatch): + respx.get(f"{self._PS_BASE}/the-tool/task-status/task-9").mock( + return_value=httpx.Response( + 500, json={"task_id": "task-9", "status": "failed", "error": "boom"} + ) + ) + cfg = _config( + monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_test" + ) + with pytest.raises(CLIError) as exc: + wait_for_completion( + endpoint=get_endpoint("docstudio.platform.prompt-studio.fetch-response"), + initial={"task_id": "task-9", "status": "accepted"}, + config=cfg, + values={"tool_id": "the-tool"}, + sleep=lambda _: None, + ) + assert exc.value.exit_code is ExitCode.VALIDATION + + @respx.mock + def test_single_pass_retrieve_asks_for_single_pass_rows(self, monkeypatch): + respx.get(f"{self._PS_BASE}/the-tool/task-status/task-9").mock( + return_value=httpx.Response(200, json={"status": "completed"}) + ) + output = respx.get(f"{self._PS_BASE}/prompt-output/").mock( + return_value=httpx.Response(200, json=[{"output": "sp"}]) + ) + cfg = _config( + monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_test" ) - 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") - - -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, + endpoint=get_endpoint("docstudio.platform.prompt-studio.single-pass"), + initial={"task_id": "task-9", "status": "accepted"}, + config=cfg, + values={"tool_id": "the-tool"}, + sleep=lambda _: None, + ) + assert "is_single_pass_extract=true" in str(output.calls.last.request.url) + + +class TestApiHubWait: + @respx.mock + def test_traverses_three_stage_progression(self, monkeypatch): + base = "https://hub.test" + respx.get(f"{base}/api/v1/status").mock( + side_effect=[ + httpx.Response(200, json={"status": "QUEUED_FOR_WHISPER"}), + httpx.Response(200, json={"status": "QUEUED_FOR_EXTRACTION"}), + httpx.Response(200, json={"status": "COMPLETED"}), + ] + ) + respx.get(f"{base}/api/v1/retrieve").mock( + return_value=httpx.Response(200, json={"tables": [{"rows": 3}]}) + ) + cfg = _config( + monkeypatch, UNSTRACT_APIHUB_KEY=FAKE_KEY, UNSTRACT_APIHUB_BASE_URL=base + ) + result = wait_for_completion( + endpoint=get_endpoint("apihub.extract"), + initial={"file_hash": "fh-1", "status": "QUEUED_FOR_WHISPER"}, + config=cfg, + sleep=lambda _: None, ) - assert caught.value.exit_code is ExitCode.GENERIC - assert caught.value.retryable is False - assert len(calls) == 1 + assert result["tables"][0]["rows"] == 3 diff --git a/tests/test_skill_docdiff.py b/tests/test_skill_docdiff.py new file mode 100644 index 0000000..34572fd --- /dev/null +++ b/tests/test_skill_docdiff.py @@ -0,0 +1,215 @@ +"""Phase 6 acceptance: the Skill's docs-vs-records diff. + +The strongest signal available is the **zero-drift regression**: the records were +authored from exactly these documentation files, so running the diff against the +current docs must report no actionable drift. If it does, either a record is +wrong or the parser is. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from unstract_cli.endpoints import endpoints_for, get_endpoint +from unstract_cli.skill.docdiff import ( + DocEndpoint, + DocParam, + diff, + parse_docs, + parse_markdown_params, + report, +) + +#: Docs repos are siblings of this one; skip rather than fail when absent, so the +#: suite still runs in a checkout that has only unstract-cli. +_REPO_ROOT = Path(__file__).resolve().parents[2] +_LLMW_DOCS = _REPO_ROOT / "llmwhisperer-docs/docs/llm_whisperer/apis" +_DEPLOY_DOCS = _REPO_ROOT / "unstract-docs/docs/unstract_platform/api_deployment" + +_PLATFORM_DOCS = ( + _REPO_ROOT / "unstract-docs/docs/unstract_platform/api_documentation/versions" +) + +requires_docs = pytest.mark.skipif( + not _LLMW_DOCS.exists(), reason="documentation repos not checked out alongside" +) +requires_platform_docs = pytest.mark.skipif( + not _PLATFORM_DOCS.exists(), reason="unstract-docs not checked out alongside" +) + + +class TestMarkdownParsing: + def test_extracts_request_parameters(self): + text = """ +## Parameters + +| Parameter | Type | Default | Required | Description | +| --- | --- | --- | --- | --- | +| mode | string | `form` | No | The processing mode | +| whisper_hash | string | | Yes | The whisper hash | +""" + params = {p.name: p for p in parse_markdown_params(text)} + assert params["mode"].default == "form" + assert params["whisper_hash"].required + assert not params["mode"].required + + def test_ignores_response_tables(self): + """Response fields are documented in the same format as parameters. + + Without section scoping, every response field reads as a missing flag. + """ + text = """ +## Parameters + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| whisper_hash | string | Yes | The hash | + +## Response + +| Parameter | Type | Description | +| --- | --- | --- | +| result_text | string | The extracted text | +| confidence_metadata | array | Confidence scores | +""" + names = {p.name for p in parse_markdown_params(text)} + assert names == {"whisper_hash"} + + def test_ignores_status_code_tables(self): + text = """ +## Parameters + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| execution_id | string | Yes | Execution id | + +### Possible Execution status + +| Status | Description | +| --- | --- | +| PENDING | Queued | +| COMPLETED | Done | +""" + assert {p.name for p in parse_markdown_params(text)} == {"execution_id"} + + +class TestZeroDriftRegression: + """Exit criterion 1: current docs must produce no actionable drift.""" + + @requires_docs + def test_llmwhisperer_has_no_drift(self): + findings = [ + f + for f in diff(parse_docs(_LLMW_DOCS), endpoints_for("llmwhisperer")) + if f.severity == "action" + ] + assert not findings, "\n".join(f"{f.kind}: {f.message}" for f in findings) + + @requires_docs + def test_deployment_has_no_drift(self): + findings = [ + f + for f in diff(parse_docs(_DEPLOY_DOCS), endpoints_for("deployment")) + if f.severity == "action" + ] + assert not findings, "\n".join(f"{f.kind}: {f.message}" for f in findings) + + @requires_platform_docs + def test_platform_mdx_parser_extracts_the_surface(self): + """Assert positively before asserting absence. + + `missing_in_docs` is only informational, so a parser that silently + extracted nothing would make a "no action findings" assertion pass while + being completely broken. Pin the volume and a known parameter set first. + """ + docs = parse_docs(_PLATFORM_DOCS) + assert len(docs) > 90, f"MDX parser extracted only {len(docs)} endpoints" + + create = next( + d for d in docs if d.method == "POST" and d.path.endswith("/workflow/") + ) + names = {p.name for p in create.params} + assert {"workflow_name", "description", "deployment_type"} <= names + + # responseBody fields must not be mistaken for request parameters. + assert not names & {"id", "created_at", "created_by_email", "status"} + + @requires_platform_docs + def test_platform_has_no_drift(self): + findings = [ + f + for f in diff(parse_docs(_PLATFORM_DOCS), endpoints_for("platform")) + if f.severity == "action" + ] + assert not findings, "\n".join(f"{f.kind}: {f.message}" for f in findings) + + +class TestSyntheticDrift: + """Exit criterion 2: a genuinely new parameter must be detected and cited.""" + + def test_detects_new_parameter(self): + doc = DocEndpoint( + method="GET", + path="/whisper-status", + params=[DocParam(name="whisper_hash", required=True), + DocParam(name="include_progress", type="boolean")], + source="whisper_status.md", + ) + findings = diff([doc], endpoints_for("llmwhisperer")) + drift = [f for f in findings if f.kind == "param_drift"] + assert len(drift) == 1 + assert "include_progress" in drift[0].message + assert drift[0].citation == "whisper_status.md" + assert "Param(" in drift[0].suggestion + + def test_detects_new_endpoint(self): + doc = DocEndpoint(method="POST", path="/whisper-cancel", source="new.md") + findings = diff([doc], endpoints_for("llmwhisperer")) + assert any(f.kind == "missing_in_cli" for f in findings) + + +class TestSafetyRules: + """Exit criterion 3, plus the rules that prevent destructive 'fixes'.""" + + def test_missing_from_docs_is_informational_only(self): + """Docs lag implementation; absence is never grounds for deletion.""" + findings = diff([], endpoints_for("llmwhisperer")) + removals = [f for f in findings if f.kind == "missing_in_docs"] + assert removals, "expected the endpoints to be reported as undocumented" + assert all(f.severity == "info" for f in removals) + assert all("NEVER delete" in f.suggestion for f in removals) + + def test_doc_conflict_endpoints_are_exempt(self): + """`/whisper-detail` diverges from the docs index deliberately. + + It must not be reported as removable, or a future run would 'correct' a + decision that was verified against the official client. + """ + assert get_endpoint("whisper.detail").doc_conflict + reported = { + f.command for f in diff([], endpoints_for("llmwhisperer")) if f.kind == "missing_in_docs" + } + assert "unstract whisper detail" not in reported + + def test_draft_pages_are_excluded(self, tmp_path): + """`draft: true` marks an unstable contract; those endpoints stay out.""" + page = tmp_path / "chat.md" + page.write_text( + "---\nid: chat\ndraft: true\n---\n\n" + "| Endpoint | `/md/chat` |\n| Method | `POST` |\n\n" + "## Parameters\n\n| Parameter | Type | Required | Description |\n" + "| --- | --- | --- | --- |\n| query | string | Yes | The question |\n" + ) + assert parse_docs(tmp_path) == [] + + +class TestReport: + def test_report_is_valid_json(self): + import json + + doc = DocEndpoint(method="POST", path="/whisper-cancel", source="new.md") + payload = json.loads(report(diff([doc], endpoints_for("llmwhisperer")))) + assert payload["total"] >= 1 + assert "missing_in_cli" in payload["summary"] diff --git a/tests/test_specs.py b/tests/test_specs.py deleted file mode 100644 index 7e1699b..0000000 --- a/tests/test_specs.py +++ /dev/null @@ -1,44 +0,0 @@ -"""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 -import tomllib -from importlib import resources -from pathlib import Path - -import pytest - -from unstract_cli.core.params import SPEC_FILES - -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())) -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()) - - -@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" - ) diff --git a/tests/test_workflows.py b/tests/test_workflows.py deleted file mode 100644 index 698affd..0000000 --- a/tests/test_workflows.py +++ /dev/null @@ -1,168 +0,0 @@ -"""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. -""" - -import re -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" - - -#: `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 []: - 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: - """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 draft release" - ) - - -def test_the_release_goes_public_only_once_the_package_is_on_pypi() -> None: - """A release that names a version PyPI does not serve is a broken link on - the front page, and the tag guard stops the run that would fix it.""" - steps = _load(Path(__file__).resolve().parents[1] / ".github/workflows/release.yml")[ - "jobs" - ]["release-and-publish"]["steps"] - by_name = {step.get("name", ""): step for step in steps} - names = list(by_name) - - creates = by_name["Commit version bump and create draft release"]["run"] - assert creates.count("gh release create") == creates.count("--draft") - assert "--draft=false" in by_name["Publish release"]["run"] - assert names.index("Publish release") > names.index("Publish to PyPI") - - -def _release_steps() -> list[dict]: - return _load(Path(__file__).resolve().parents[1] / ".github/workflows/release.yml")[ - "jobs" - ]["release-and-publish"]["steps"] - - -def test_main_moves_only_once_the_package_is_on_pypi() -> None: - """A bump commit pushed before the publish is the one leftover of a failed - run that cannot be deleted without rewriting main.""" - steps = _release_steps() - names = [step.get("name", "") for step in steps] - by_name = {step.get("name", ""): step for step in steps} - - assert ( - "git push origin main" - not in by_name["Commit version bump and create draft release"]["run"] - ) - assert names.index("Push version bump") > names.index("Publish to PyPI") - - -def test_a_failed_publish_removes_what_it_created_and_nothing_published() -> None: - """The cleanup runs on failure and sits directly after the publish: a step - that fails later, with the version already on PyPI, must not delete the - tag that names it.""" - steps = _release_steps() - names = [step.get("name", "") for step in steps] - cleanup = "Remove the tag and draft release a failed publish leaves behind" - - assert "failure()" in steps[names.index(cleanup)]["if"] - assert names.index(cleanup) == names.index("Publish to PyPI") + 1 - run = steps[names.index(cleanup)]["run"] - assert "gh release delete" in run - assert ":refs/tags/" in run - assert run.index("pypi.org/pypi/unstract-cli/") < run.index("gh release delete") diff --git a/uv.lock b/uv.lock index 410dd7a..2dadcf9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "anyio" @@ -16,12 +20,44 @@ wheels = [ ] [[package]] -name = "attrs" -version = "26.1.0" +name = "ast-serialize" +version = "0.6.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" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] [[package]] @@ -33,67 +69,6 @@ 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" @@ -115,6 +90,75 @@ 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 = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -171,48 +215,137 @@ wheels = [ ] [[package]] -name = "llmwhisperer-client" -version = "2.10.0" +name = "librt" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "httpx" }, - { name = "requests" }, - { name = "tenacity" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9a/60/b4ba32f16363b3dfa6e37716db0d0e52efac41bebce1043a0ff690c65e0c/llmwhisperer_client-2.10.0.tar.gz", hash = "sha256:7b4f7921dcba8a51cd4315cfae425aeb14bd24a1935df5f27a406a86be3d4765", size = 3320161, upload-time = "2026-10-27T06:15:26.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/3d/cbb4cff42412117393c2b49c06f4bd4857322ac3310fe7d7ebf4893b54f2/llmwhisperer_client-2.10.0-py3-none-any.whl", hash = "sha256:e60e4877f021197dbb1b87624876cc607e99eedc8239c9ec563fafc645b66407", size = 69471, upload-time = "2026-10-27T06:15:25.636Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, ] [[package]] -name = "markdown-it-py" -version = "4.2.0" +name = "mypy" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl" }, + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, ] [[package]] -name = "mdurl" -version = "0.1.2" +name = "mypy-extensions" +version = "1.1.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] name = "packaging" -version = "26.3" +version = "26.2" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -249,6 +382,20 @@ 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 = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -296,74 +443,58 @@ wheels = [ ] [[package]] -name = "requests" -version = "2.34.2" +name = "respx" +version = "0.23.1" 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" }, + { name = "httpx" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" }, ] [[package]] name = "ruff" -version = "0.16.2" +version = "0.15.22" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, + { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, + { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, + { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, + { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, ] [[package]] -name = "tenacity" -version = "9.1.4" +name = "tomli-w" +version = "1.2.0" 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" } +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/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, + { 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 = "tomli-w" -version = "1.2.0" +name = "types-pyyaml" +version = "6.0.12.20260518" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, ] [[package]] @@ -377,57 +508,36 @@ wheels = [ [[package]] name = "unstract-cli" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "click" }, - { name = "llmwhisperer-client" }, - { name = "requests" }, + { name = "httpx" }, + { name = "pyyaml" }, { name = "tomli-w" }, - { name = "unstract-client" }, ] [package.optional-dependencies] dev = [ + { name = "mypy" }, { name = "pytest" }, - { name = "pyyaml" }, + { name = "pytest-cov" }, + { name = "respx" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1,<9" }, - { name = "llmwhisperer-client", specifier = "==2.10.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, - { name = "requests", specifier = ">=2.32.3" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, { name = "tomli-w", specifier = ">=1.0" }, - { name = "unstract-client", specifier = "==1.7.1" }, + { name = "types-pyyaml", marker = "extra == 'dev'" }, ] provides-extras = ["dev"] - -[[package]] -name = "unstract-client" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "click" }, - { name = "httpx" }, - { name = "requests" }, - { name = "rich" }, - { name = "tenacity" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/d0/d0a6704436baca70e5b261058c0cd9a3a4853556bf6ade9fe201eb3f4b88/unstract_client-1.7.1.tar.gz", hash = "sha256:d192295a8eba9f25268915e60cb0435683a1eb000eaf51bc4f87db0e6e9f1288", size = 222809, upload-time = "2026-09-18T10:56:05.187Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/7b/10584cb0c1cefd075990c2dac0f5e1cdbe05aebc9de41c4ef55c6f73f936/unstract_client-1.7.1-py3-none-any.whl", hash = "sha256:82125d4fb5876fcd872595205d30ad6a1ba544a2d49ea1d3177910bc34877274", size = 127922, upload-time = "2026-09-18T10:56:03.928Z" }, -] - -[[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" }, -]