From 88bceb400a28a687143baa8741aa4fb4acb3d588 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Tue, 21 Jul 2026 23:52:52 +0530 Subject: [PATCH 01/21] feat: implement Unstract CLI core, endpoints, and test suite Generated command tree from declarative Endpoint records covering LLMWhisperer, deployments, Platform v1, HITL and API Hub. Co-Authored-By: Claude Opus 4.8 (1M context) --- IMPLEMENTATION_PLAN.md | 310 ++++++ README.md | 129 +++ SPEC.md | 643 ++++++++++++ pyproject.toml | 66 ++ src/unstract_cli/__init__.py | 5 + src/unstract_cli/__main__.py | 21 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 352 bytes .../__pycache__/__main__.cpython-312.pyc | Bin 0 -> 681 bytes .../__pycache__/app.cpython-312.pyc | Bin 0 -> 10838 bytes src/unstract_cli/app.py | 240 +++++ src/unstract_cli/commands/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 177 bytes .../__pycache__/config_cmd.cpython-312.pyc | Bin 0 -> 12957 bytes src/unstract_cli/commands/config_cmd.py | 260 +++++ src/unstract_cli/config/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 175 bytes .../config/__pycache__/loader.cpython-312.pyc | Bin 0 -> 13588 bytes src/unstract_cli/config/loader.py | 282 +++++ src/unstract_cli/core/__init__.py | 0 .../core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 173 bytes .../core/__pycache__/errors.cpython-312.pyc | Bin 0 -> 9500 bytes .../core/__pycache__/generate.cpython-312.pyc | Bin 0 -> 17564 bytes .../core/__pycache__/http.cpython-312.pyc | Bin 0 -> 20909 bytes .../core/__pycache__/model.cpython-312.pyc | Bin 0 -> 19794 bytes .../core/__pycache__/output.cpython-312.pyc | Bin 0 -> 9411 bytes .../core/__pycache__/poll.cpython-312.pyc | Bin 0 -> 7181 bytes src/unstract_cli/core/errors.py | 222 ++++ src/unstract_cli/core/generate.py | 332 ++++++ src/unstract_cli/core/http.py | 463 +++++++++ src/unstract_cli/core/model.py | 411 ++++++++ src/unstract_cli/core/output.py | 189 ++++ src/unstract_cli/core/poll.py | 175 ++++ src/unstract_cli/endpoints/__init__.py | 46 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 2347 bytes .../__pycache__/apihub.cpython-312.pyc | Bin 0 -> 9051 bytes .../__pycache__/deployment.cpython-312.pyc | Bin 0 -> 6557 bytes .../__pycache__/hitl.cpython-312.pyc | Bin 0 -> 3622 bytes .../__pycache__/platform.cpython-312.pyc | Bin 0 -> 49177 bytes .../__pycache__/whisper.cpython-312.pyc | Bin 0 -> 10405 bytes src/unstract_cli/endpoints/apihub.py | 214 ++++ src/unstract_cli/endpoints/deployment.py | 159 +++ src/unstract_cli/endpoints/hitl.py | 101 ++ src/unstract_cli/endpoints/platform.py | 979 ++++++++++++++++++ src/unstract_cli/endpoints/whisper.py | 297 ++++++ tests/__init__.py | 0 tests/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 157 bytes .../conftest.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 3187 bytes .../test_cli.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 61295 bytes .../test_http.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 38491 bytes .../test_model.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 47535 bytes .../test_poll.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 24285 bytes tests/conftest.py | 70 ++ tests/test_cli.py | 316 ++++++ tests/test_http.py | 263 +++++ tests/test_model.py | 229 ++++ tests/test_poll.py | 231 +++++ 56 files changed, 6653 insertions(+) create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 pyproject.toml create mode 100644 src/unstract_cli/__init__.py create mode 100644 src/unstract_cli/__main__.py create mode 100644 src/unstract_cli/__pycache__/__init__.cpython-312.pyc create mode 100644 src/unstract_cli/__pycache__/__main__.cpython-312.pyc create mode 100644 src/unstract_cli/__pycache__/app.cpython-312.pyc create mode 100644 src/unstract_cli/app.py create mode 100644 src/unstract_cli/commands/__init__.py create mode 100644 src/unstract_cli/commands/__pycache__/__init__.cpython-312.pyc create mode 100644 src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc create mode 100644 src/unstract_cli/commands/config_cmd.py create mode 100644 src/unstract_cli/config/__init__.py create mode 100644 src/unstract_cli/config/__pycache__/__init__.cpython-312.pyc create mode 100644 src/unstract_cli/config/__pycache__/loader.cpython-312.pyc create mode 100644 src/unstract_cli/config/loader.py create mode 100644 src/unstract_cli/core/__init__.py create mode 100644 src/unstract_cli/core/__pycache__/__init__.cpython-312.pyc create mode 100644 src/unstract_cli/core/__pycache__/errors.cpython-312.pyc create mode 100644 src/unstract_cli/core/__pycache__/generate.cpython-312.pyc create mode 100644 src/unstract_cli/core/__pycache__/http.cpython-312.pyc create mode 100644 src/unstract_cli/core/__pycache__/model.cpython-312.pyc create mode 100644 src/unstract_cli/core/__pycache__/output.cpython-312.pyc create mode 100644 src/unstract_cli/core/__pycache__/poll.cpython-312.pyc create mode 100644 src/unstract_cli/core/errors.py create mode 100644 src/unstract_cli/core/generate.py create mode 100644 src/unstract_cli/core/http.py create mode 100644 src/unstract_cli/core/model.py create mode 100644 src/unstract_cli/core/output.py create mode 100644 src/unstract_cli/core/poll.py create mode 100644 src/unstract_cli/endpoints/__init__.py create mode 100644 src/unstract_cli/endpoints/__pycache__/__init__.cpython-312.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/apihub.cpython-312.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/deployment.cpython-312.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/hitl.cpython-312.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/platform.cpython-312.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/whisper.cpython-312.pyc create mode 100644 src/unstract_cli/endpoints/apihub.py create mode 100644 src/unstract_cli/endpoints/deployment.py create mode 100644 src/unstract_cli/endpoints/hitl.py create mode 100644 src/unstract_cli/endpoints/platform.py create mode 100644 src/unstract_cli/endpoints/whisper.py create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-312.pyc create mode 100644 tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/__pycache__/test_http.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/__pycache__/test_model.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/__pycache__/test_poll.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_http.py create mode 100644 tests/test_model.py create mode 100644 tests/test_poll.py diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..7ccf7fe --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,310 @@ +# Unstract CLI — Implementation Plan + +**Companion to:** [`SPEC.md`](./SPEC.md) (Draft v1) +**Status:** Ready to execute +**Scope:** Build sequence, de-risking order, and per-phase acceptance criteria. + +> This plan deliberately does **not** restate the endpoint tables (SPEC §6), the file layout (§7), or the phase list (§10). Those are the contract; this document is the *order of operations* and the definition of "done" for each step. Where the two disagree, SPEC.md wins. + +--- + +## 1. The central bet, and how we de-risk it + +Everything in the spec rests on one architectural bet: + +> The command tree, `--help`, validation, `--dump-commands`, and the Skill's docs-diff are all **generated** from declarative `Endpoint` records (§7.1). + +If that abstraction does not hold, the design collapses into per-command special-casing — which would also destroy the Skill's diffability (§8.5), because the definitions would no longer be a faithful description of behaviour. Every sequencing decision below follows from protecting that bet. + +**Two command categories.** The bet covers endpoint-backed commands, which is nearly all of them. It is worth stating the exception explicitly, because an endpoint-centric plan makes the non-generated commands easy to lose: + +| Category | Commands | Source | Built in | +| --- | --- | --- | --- | +| **Generated** | `whisper`, `deployment`, `platform`, `hitl`, `apihub` | `Endpoint` records → `generate.py` | Phases 2–5 | +| **Hand-authored** | `config {init,list,get,set,use,current,path}`, `completion {bash,zsh,fish}` | Plain Typer commands; map to no API endpoint | Phase 1 (M1.5), Phase 6 | + +Both appear in `--dump-commands`; hand-authored ones are flagged as non-endpoint so an agent can tell local operations from remote calls. + +Two consequences drive the plan's shape: + +1. **Model before breadth.** The `Endpoint`/`Param` schema must be proven against the *hardest* parameter patterns in §6 before we add a hundred endpoints on top of it. See §2. +2. **Walking skeleton before product surfaces.** One trivial endpoint flows end-to-end before any real surface is built. See Phase 1, M1.3. + +### 1.1 Risk register + +| # | Risk | Impact if unhandled | Mitigation | Resolved by | +| --- | --- | --- | --- | --- | +| R1 | `Param` schema cannot express §6's special cases | Generation degrades into per-command hacks; Skill diff breaks | Pattern enumeration is the *first* task; schema must encode all of §2 | Phase 1 exit | +| R2 | `--output table` has no universal shape for nested JSON | Either ugly output or per-command rendering code | Decide the strategy in Phase 1 — it may add a field to the data model | Phase 1 exit | +| R3 | `--dump-commands` can't recover full flag metadata | The core LLM-discoverability promise (§5.3) fails | **Retired** — verified, see §1.2 | ✅ | +| R4 | Deployment 422 defect (§6.2) misread as failure | `--wait` breaks now, or breaks later when the defect is fixed | Poll on **body `status`**, never HTTP code; assert both in tests | Phase 3 | +| R5 | One-shot retrieval consumed by a retry (§5.6) | Silent, unrecoverable data loss for an agent | Retries never replay a consumed read; `--save` writes before exit | Phase 2 | +| R6 | API Hub public base URL unknown (§11.1) | `apihub` group unusable out of the box | `--base-url` / env required; no baked default | Phase 5 entry | +| R7 | `full_access` may not be UI-creatable (§11.2) | Every `delete` command unusable with a UI-issued key | Confirm at Phase 4 entry; if confirmed, say so in help text | Phase 4 entry | + +### 1.2 Pre-verified during planning + +**R3 is retired.** Click 8.3.1 / Typer 0.24.1 `Parameter.to_info_dict()` returns exactly what §5.3 requires: + +``` +{'name': 'mode', 'opts': ['--mode'], + 'type': {'param_type': 'Choice', 'choices': ('form','table')}, + 'required': False, 'multiple': False, 'default': 'form', + 'help': 'Processing mode.', 'is_flag': False, ...} +``` + +Type, choices, default, required-ness, repeatability, and help text all round-trip. `--dump-commands` can therefore be built by walking the generated Click tree. + +**Design note.** Even so, `--dump-commands` emits from the **`Endpoint` records plus** the introspected tree, because the records carry what Click cannot know: the underlying HTTP method/path, `doc_source`, permission level, and one-shot semantics. Introspection alone would produce a CLI description; the records make it an *API* description, which is what an agent needs. + +--- + +## 2. Task 0 — Parameter pattern enumeration (do this first) + +The `Endpoint`/`Param` sketch in §7.1 ends in `...`, and the ellipsis hides the hard part. Before writing `generate.py`, confirm the schema expresses every pattern present in §6. **This is the load-bearing design work of the whole project.** + +| # | Pattern | Instances in §6 | Schema requirement | +| --- | --- | --- | --- | +| P1 | Mutually exclusive (XOR) | `whisper extract` `--file`\|`--url`; `apihub extract` `--file`\|`--use-cached-file-hash`; `key create` `--api`\|`--pipeline` | Endpoint-level `constraints=[MutuallyExclusive(...)]`, validated pre-flight → exit 2 | +| P2 | At-least-one-of | `file-history clear` (≥1 filter) | `constraints=[AtLeastOneOf(...)]` | +| P3 | Enum → path-segment mapping | `share --resource` (`api-deployment` → `api/deployment`) | `Param.choices` as `{friendly: wire}` mapping, not a flat list | +| P4 | Repeatable | `--file`, `--presigned-url`, `--shared-users`, `--user-id`, `--ids` | `Param.multiple=True` | +| P5 | Arbitrary key=value passthrough | `apihub --ext-param KEY=VALUE` (§6.5) | `Param.freeform_prefix="ext_"`; merged into query at request build | +| P6 | Path param w/ profile default | `--org-id` (defaults from profile), `--api-name` (required, no default) | `location="path"` + `default_from="profile..org_id"` | +| P7 | Location variants | query / JSON body / multipart / binary octet-stream / header | `Param.location ∈ {query,body,path,header,form}`; `Endpoint.body ∈ {json,multipart,binary_file,text,none}` | +| P8 | PATCH = PUT minus required | Prompt Studio, workflow, pipeline, adapter, connector | **Derive**: `Endpoint.derive_patch_from=` strips required-ness. Never duplicate records — duplication is how definitions drift | +| P9 | Conditional applicability (help-only) | `--median-filter-size` / `--gaussian-blur-radius` (`low_cost` only); `--allow-rotated-text` (form/high_quality/table only) | `Param.applies_when="mode=low_cost"` — rendered into help, **never enforced** (server owns that rule) | +| P10 | Int vs UUID identifiers | `group` IDs are `int`; nearly everything else is UUID | `Param.type` distinguishes; validation surfaces mismatch as exit 2, not a 404 | +| P11 | Trailing-slash sensitivity | `profilemanager` vs `profile-manager`; `groups/{id}/members/{user_id}` (no slash) | `Endpoint.path` is **literal and authoritative**; no normalization anywhere in the stack | +| P12 | Replace-vs-append semantics | `shared_users`, `shared_groups` (replace) | `--add-user`/`--remove-user` are read-modify-write helpers layered *above* the raw flag; raw flag documented as replace | + +**Exit criterion:** a written mapping from each of P1–P12 to a concrete field on `Endpoint`/`Param`, with one representative record hand-written per pattern. If a pattern cannot be expressed declaratively, that is a design finding to resolve *now*, not at Phase 4. + +--- + +## 3. Phases + +Phases match SPEC §10. Each has entry conditions, work items, and a **testable** exit criterion. + +### Phase 0 — Scaffolding + +Repo skeleton per §7, matching sibling-repo conventions (verified: `unstract` and `unstract-verticals` both use Python 3.12, ruff `line-length = 90`, mypy). + +- `pyproject.toml`: `requires-python = ">=3.12"`, deps `typer`, `httpx`, `pyyaml`, `rich`; dev deps `pytest`, `respx`, `ruff`, `mypy`. Stdlib `tomllib` for reading, `tomli-w` for writing config. +- **Pin `click` and `typer` to compatible major versions.** R3's retirement rests specifically on Click's `Parameter.to_info_dict()` shape (§1.2); a future Click major that reshapes that dict would silently degrade `--dump-commands`. Treat `to_info_dict()` as a tested contract surface — a Phase 1 test asserts the keys we depend on still exist, so a dependency bump fails loudly rather than quietly. +- Console entry point `unstract = unstract_cli.__main__:main`. +- Package skeleton per §7 (empty modules), `.claude/skills/` placeholder, pre-commit with ruff + mypy. +- CI: lint, type-check, test on 3.12. + +**Exit:** `unstract --version` runs; `ruff` and `mypy` pass clean on the skeleton. + +--- + +### Phase 1 — Core (the whole spine, proven on one endpoint) + +**Entry:** Task 0 complete. + +This is the phase that matters. Everything after it is mostly adding records. + +#### M1.1 — Data model +`Endpoint`, `Param`, `PollSpec`, `Constraint` types encoding all of P1–P12. Frozen dataclasses; no runtime mutation (the records are a contract, and the Skill edits them as source). + +#### M1.2 — Config & profiles (§4) +- TOML load from `~/.config/unstract/config.toml`, `UNSTRACT_CONFIG` override. +- Resolution chain **flag → env → profile → default**, implemented once as a single resolver used by every parameter. Not re-implemented per command. +- `env:VAR_NAME` indirection; `0600` on write; warn on broader permissions. +- Per-product config blocks (`whisper`, `platform`, `deployment`, `apihub`) — they have genuinely different hosts and keys. +- **Fully usable with zero config file** (§4.3) — asserted by a test that runs with only env vars set. + +#### M1.3 — Walking skeleton ⭐ +The single most important milestone. Take **`whisper usage`** — GET, no parameters, no path params, trivial response — and drive it end-to-end: + +``` +Endpoint record → generate.py → Typer command → auth injection + → http.py → response → output.py → exit code +``` + +Nothing else is built until this works. It proves the spine with the least possible code. + +#### M1.4 — Cross-cutting spine +Built once, against the skeleton, because these touch every command: + +- **Auth injection** — strategy per product (§4.4): `unstract-key`, `Bearer`, `apikey`. API Hub sends `apikey` **only**; the `X-Subscription-*` / `X-User-Id` headers are Kong-injected and must never be sent (§4.4). +- **Path-param substitution** — literal paths, profile-default resolution for `--org-id`. +- **`http.py`** — httpx, retry with exponential backoff + jitter on 429/5xx only, never on 4xx; `--max-retries`, `--no-retry`, `--timeout`. +- **`errors.py`** — HTTP status → §5.4 exit code + §5.5 structured JSON on stderr, with `hint` and `retryable`. +- **`output.py`** — json/yaml/table/raw; **TTY detection** (json when not a TTY); stdout carries payload *only*, diagnostics to stderr. +- **Redaction** — a single choke point applied to logs, errors, and `--dry-run`. Centralized so it cannot be forgotten per-command. +- **`--dry-run`** — resolved request as JSON, exit 0, nothing sent. + +#### M1.5 — `config` command group (hand-authored) +`config {init,list,get,set,use,current,path}` (§3). These map to **no API endpoint** — they operate on the M1.2 config layer — so they are hand-authored Typer commands rather than generated ones. They belong in Phase 1 because they are how a user or agent bootstraps every other command. + +- `init` scaffolds a config file at the default path (`0600`), pre-populated with cloud-us/cloud-eu profile stubs using `env:` indirection — never inline secrets. +- `use` sets `default_profile`; `current` prints the resolved active profile; `path` prints the file location. +- `list`/`get`/`set` read and write profile values. +- Per §5.2, `init` never prompts: an existing file is left untouched unless `--force` is given. + +#### M1.6 — R2 spike: `--output table` +Decide and implement the strategy for heterogeneous nested JSON. Recommended: generic rule (list-of-objects → columns from shared keys; single object → key/value pairs; nested → JSON-encoded cell), with an optional `Endpoint.table_columns` hint for responses where the generic rule reads poorly. **If a hint field is needed, it must be added to the model now**, not retrofitted. + +#### M1.7 — `--dump-commands` +Emit records + introspected tree (per §1.2). Include: command path, summary, HTTP method + path, every flag with type/default/enum/required/repeatable, `doc_source`, permission level, one-shot flag. + +**Exit criteria (all testable):** +1. `unstract whisper usage` succeeds end-to-end against a respx fixture. +2. `unstract --dump-commands` emits valid JSON containing that command with complete flag metadata. +3. Exit-code table (§5.4) asserted table-driven: each HTTP status → correct code. +4. Zero-config operation works with env vars only. +5. No secret appears in `--dry-run`, `-vv`, or error output. +6. Schema expresses P1–P12 (one representative record each, unit-tested). +7. `config init` writes a `0600` file; `config use` switches the default profile; `config current` reflects the change. Both categories appear in `--dump-commands`, with hand-authored commands flagged as non-endpoint. + +--- + +### Phase 2 — LLMWhisperer (§6.1) + +**Entry:** Phase 1 exit criteria met. + +Smallest *complete* product surface — 11 commands — and the first real test of "adding an endpoint is adding a record." + +- All §6.1 records: `extract`, `status`, `retrieve`, `detail`, `highlights`, `usage`, `usage-by-tag`, `webhook {create,get,update,delete}`. +- `--file` XOR `--url` (P1); `url_in_post=true` set automatically when `--url` is used. +- Binary `application/octet-stream` upload path (P7). +- **`--wait`** state machine (`core/poll.py`): poll `/whisper-status` until `processed`/`error`, then retrieve. `--poll-interval` (3s), `--timeout` (300s). On timeout → exit 7 with `whisper_hash` on stdout so the agent can resume. +- **One-shot handling (R5)**, mandatory: `--save` writes atomically *before* exit; a consumed result → exit 9 with an explanatory `hint`; retry logic must never replay a consumed read; help text states the one-shot behaviour. +- Pin `/whisper-detail` singular, carrying a `doc_conflict` note (§11 resolved) so the Skill won't "correct" it back. + +**Exit:** +1. All 11 commands appear in `--dump-commands` with full metadata. +2. `--wait` reaches a terminal state against respx fixtures. +3. Second `retrieve` of the same hash → exit 9; `--save` file exists and is complete. +4. Every §6.1 parameter is reachable as a flag (asserted by diffing flags against the record set). + +--- + +### Phase 3 — Deployments runtime (§6.2) + HITL (§6.4) + +**Entry:** Phase 2 exit criteria met. + +- `deployment {run,status,highlight}` with `--api-name` required and `--org-id` profile-defaulted (P6). +- Multipart upload, ≤32 files combined across `--file` and `--presigned-url`; validate client-side → exit 2 rather than a wasted round trip. +- **R4 — the 422 defect.** `--wait` polls on the **response-body `status` field, never the HTTP status code**. Tests must assert *both* current behaviour (`422` + `EXECUTING`) and post-fix behaviour (`200` + `EXECUTING`) resolve identically. This is the single highest-value test in the suite. +- `--wait` uses `timeout=0` + polling (sync mode is deprecated upstream). +- HITL: `approved get` (dequeue — **consumes one item per call**, same one-shot treatment as R5), `bulk-download`, `download-status`. + +**Exit:** +1. `--wait` handles 422-with-EXECUTING and 200-with-EXECUTING identically (explicit test). +2. >32 files rejected client-side with exit 2. +3. `hitl approved get` documents and implements dequeue semantics; `--save` persists before exit. + +--- + +### Phase 4 — Platform Management v1 (§6.3) + +**Entry:** Phase 3 exit; **R7 confirmed** — check whether `full_access` keys are UI-creatable. If not, every `delete` command's help text must state that it requires a key the UI cannot issue. + +Largest surface (~80 endpoints) but the *least* new machinery — if Phase 1 was done right, this is predominantly record authoring plus fixtures. + +- Groups: prompt-studio, workflow, api-deployment, pipeline, adapter, connector, group, user, share. +- **P8 in practice:** derive every PATCH from its PUT. Duplicated records here would be the main drift risk. +- **P11 in practice:** the trailing-slash and `profilemanager`/`profile-manager` inconsistencies are real and load-bearing. Add a test asserting each literal path matches §6.3 exactly. +- `share --resource` enum → path-segment mapping (P3). +- `--add-user`/`--remove-user` read-modify-write helpers over replace-semantics flags (P12). +- Pagination (`--page`, `--page-size`) as a shared param group. +- Permission levels surfaced in help for destructive commands (§4.4). + +**Exit:** +1. Every §6.3 endpoint present in `--dump-commands`. +2. Literal-path test passes (no normalization; slash inconsistencies preserved). +3. PATCH records are derived, not duplicated (asserted structurally). +4. `share` rejects an unmapped `--resource` with exit 2, not a 404. + +--- + +### Phase 5 — API Hub (§6.5) + +**Entry:** Phase 4 exit; **R6** — obtain the public base URL, or confirm none exists yet. No default is baked in; `--base-url`/`UNSTRACT_APIHUB_BASE_URL` is required until resolved. + +- `apihub {extract,status,retrieve}`, `doc-splitter {upload,status,download}`. +- `conv_*` and `ext_*` parameter families; **`--ext-param KEY=VALUE`** escape hatch (P5) — this exists precisely because API Hub has no public docs and the Skill will lag (§8.4). +- Auth: `apikey` only, plus optional BYO `--llmwhisperer-key` / `--anthropic-key`. **Assert in tests that no `X-Subscription-*` or `X-User-Id` header is ever emitted.** +- `--wait` over `QUEUED_FOR_WHISPER → QUEUED_FOR_EXTRACTION → COMPLETED`. + +**Exit:** +1. `--ext-param foo=bar` reaches the wire as `ext_foo=bar`. +2. No gateway-injected header is ever sent (explicit negative test). +3. `--wait` traverses the three-state progression. + +--- + +### Phase 6 — Claude Skill, packaging, completions + +**Entry:** Phases 1–5 exit criteria met. + +The Skill (§8) is built **last on purpose**: it operates on `endpoints/*.py`, so it needs the full record set to be meaningfully testable. + +- `.claude/skills/update-unstract-cli/SKILL.md` implementing §8.3's seven steps. +- Doc parsers: Markdown tables (LLMWhisperer, API deployment, HITL) and ``/`` MDX components (Platform v1). MDX is the harder parser — it is JSX-ish, not Markdown, so parse component props rather than table cells. +- Record parser producing the same normalized `(method, path, params[])` shape for a symmetric diff. +- Three-axis diff (new / missing / per-param drift), reported with doc citations **before** any edit. +- Safety rules from §8.5 encoded explicitly: never auto-delete; never invent params; renames → alias + deprecation, never silent; preserve richer hand-written help; honour `doc_conflict` notes; treat `draft: true` as an exclusion marker (multi-doc chat). +- API Hub changes **flagged for human review**, never auto-applied (§8.4). +- Shell completions; packaging and publish workflow. + +**Exit:** +1. **Regression harness:** run the Skill against the *current* docs → it must report **zero drift**. This is the strongest possible correctness signal, because the records were authored from exactly these docs. +2. Synthetic drift test: add a parameter to a fixture doc → the Skill detects it, cites the file, and proposes the correct record edit. +3. A `doc_conflict`-marked definition (`/whisper-detail`) is **not** reverted when the Skill sees the conflicting index page. +4. After a Skill-applied edit, `--dump-commands` still parses and the suite passes. + +--- + +## 4. Testing strategy + +Per SPEC §9, with the sequencing that matters: + +| Layer | When | Notes | +| --- | --- | --- | +| Definition integrity | Phase 1, grows each phase | Unique `(group,name)`; valid `doc_source`; complete param metadata | +| Command generation | Phase 1 | Tree builds; `--help` renders at every level; `--dump-commands` valid JSON | +| HTTP per endpoint | Each phase | `respx` fixtures built from the **documented sample payloads** in the docs repos | +| Exit codes | Phase 1 | Table-driven across §5.4 | +| Polling | Phases 2, 3, 5 | Body-status not HTTP code; 422 defect asserted explicitly | +| One-shot | Phases 2, 3 | Second read → exit 9; `--save` persists before exit | +| Redaction | Phase 1 | No secret in any stream, including `--dry-run` and `-vv` | +| Non-interactivity | Phase 1 | No TTY prompt anywhere; stdin only via explicit `--file -` | +| Skill round-trip | Phase 6 | Zero-drift regression against current docs | + +**Two invariants worth asserting globally**, because they are the promises an agent relies on and they are easy to break in any single command: + +- **Stdout purity** — for every command, in `--output json`, stdout parses as JSON and nothing else is written to it. +- **Flag coverage** — every `Param` in every record surfaces as a CLI flag (mechanically checkable, and it *is* SPEC goal §1.1.3). + +--- + +## 5. Sequencing summary + +``` +Task 0 Parameter pattern enumeration (P1–P12) ← load-bearing design +Phase 0 Scaffolding +Phase 1 Core spine, proven on `whisper usage` ← de-risks the whole design +Phase 2 LLMWhisperer + --wait + one-shot +Phase 3 Deployments + HITL ← R4: the 422 defect +Phase 4 Platform v1 (~80 endpoints) ← mostly records, if Phase 1 held +Phase 5 API Hub + --ext-param +Phase 6 Claude Skill + packaging ← zero-drift regression +``` + +The plan front-loads *design* risk (Task 0, Phase 1) and back-loads *volume* (Phase 4). If Phase 1's exit criteria are met honestly, Phase 4 is the least risky phase in the project despite being the largest — that is the intended payoff of the generation bet. + +--- + +## 6. Open questions gated to phases + +| Question (SPEC §11) | Gates | If unresolved | +| --- | --- | --- | +| API Hub public base URL | Phase 5 entry | Ship with no default; `--base-url`/env required. Not a blocker for Phases 0–4 | +| `full_access` UI-creatable? | Phase 4 entry | Ship `delete` commands with help text stating the requirement and the limitation | + +Neither blocks the critical path. Both must be answered *at their phase entry*, not deferred silently past it. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d61948 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Unstract CLI + +A single, LLM-friendly command-line interface across the Unstract product suite: +LLMWhisperer text extraction, deployed API workflows, the Platform Management +API, Human Quality Review, and API Hub. + +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 +uv pip install -e ".[dev]" # development +pip install unstract-cli # once published +``` + +Requires Python 3.12+. + +## Quick start + +The CLI works with **no config file** — environment variables are enough: + +```bash +export LLMWHISPERER_API_KEY=your-key +unstract whisper usage +``` + +For repeated use, create profiles: + +```bash +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 +``` + +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. + +## For agents + +Discover the entire surface without documentation: + +```bash +unstract --dump-commands # full command tree as JSON +``` + +Each entry carries the underlying HTTP method and path, every flag with type, +default, enum and required-ness, plus whether the command supports `--wait`, and +whether its result is one-shot. + +### Output + +`--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. + +### 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 `--dump-commands`, 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 +unstract deployment run --api-name invoice-api --file invoice.pdf --wait +``` + +On timeout the CLI exits `7` and prints the job handle, so work can resume +without reprocessing the document. + +## Command groups + +| Group | Covers | +| --- | --- | +| `whisper` | LLMWhisperer v2: extraction, status, retrieve, highlights, usage, webhooks | +| `deployment` | Run deployed API workflows; status; highlight data | +| `platform` | Prompt Studio, workflows, deployments, pipelines, adapters, connectors, groups, sharing | +| `hitl` | Human Quality Review: approved results, bulk download | +| `apihub` | Vertical extraction: bank statements, tables, document splitting | +| `config` | Local profile management (no network calls) | + +Every group and command has full `--help` with worked examples. + +## Development + +```bash +uv run pytest # tests +uv run ruff check . # lint +uv run mypy src # types +``` + +### Architecture + +Commands are **generated from declarative `Endpoint` records** in +`src/unstract_cli/endpoints/`. The command tree, flags, help text, validation and +`--dump-commands` 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. + +See [`SPEC.md`](./SPEC.md) for the full specification and +[`IMPLEMENTATION_PLAN.md`](./IMPLEMENTATION_PLAN.md) for the build sequence. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..9adbd77 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,643 @@ +# Unstract CLI — Specification + +**Status:** Draft v1 +**Repo:** `Zipstack/unstract-cli` +**Binary:** `unstract` + +--- + +## 1. Purpose + +A single, LLM-friendly command-line interface over the entire Unstract product suite: + +| Product | Surface covered | +| --- | --- | +| **LLMWhisperer v2** | Text extraction, status/retrieve, detail, highlights, usage, webhook management | +| **Unstract Platform — API Deployments** | Execute a deployed API, poll status, fetch highlight data | +| **Unstract Platform — Management API (v1)** | Prompt Studio, Workflows, API Deployments, ETL/Task Pipelines, Adapters, Connectors, User Groups, Org Users | +| **Unstract — Human Quality Review (HITL)** | Push to queue, retrieve approved results, bulk download | +| **API Hub** (internal name: *Verticals*) | Vertical extraction (bank statement, table discovery/extraction), doc-splitter, status/retrieve | + +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 four products 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 + Typer + +Type-hint-driven help generation, an introspectable command tree (required for `--dump-commands`, §5.3), and alignment with the all-Python ecosystem of the surrounding repos. + +### 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 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 +│ +├── deployment # Unstract API Deployments (runtime) +│ ├── 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 # Unstract 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, +│ │ 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, +│ │ execution {list,get,logs}, +│ │ file-history {list,get,delete,clear}} +│ ├── 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} +├── completion {bash,zsh,fish} +└── --dump-commands / --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 deployment run --api-name invoice-api --file invoice.pdf --wait +unstract apihub extract --vertical table --sub-vertical bank_statement --file stmt.pdf --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`) on stdout so the agent can resume. + +--- + +## 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 + +`~/.config/unstract/config.toml` (override: `UNSTRACT_CONFIG`). Named profiles; `--profile/-p` or `UNSTRACT_PROFILE` selects one. + +```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. + +### 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. +- JSON goes to **stdout and nothing else** — no banners, spinners, or progress on stdout. Diagnostics go to stderr. +- `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 --dump-commands` emits the **entire command tree as JSON** — every command, flag, type, default, enum, required-ness, and the underlying endpoint. This is the machine-readable index that makes "auto-discover from help text" operational, and it is generated from the same endpoint definitions that drive execution, so it cannot drift. +- `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) | +| `9` | Result already consumed (see §5.6) | + +### 5.5 Structured errors + +Every failure emits a JSON object on **stderr** (even in `table` mode): + +```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 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). + +### 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` | +| `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. + +### 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 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`\*, `--prompt-key`\*, `--enforce-type {text,number,email,date,boolean,json,line-item,table}`, `--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`, `--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` | +| `single-pass` | `/prompt-studio/single-pass-extraction/{tool_id}` | POST | `--tool-id`\*, `--document-id`\*, `--run-id` | +| `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`\* | + +#### Workflows — `unstract 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` | + +#### API Deployments (management) — `unstract 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 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 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 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 `--dump-commands`: +> +> | `--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 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 # Typer root; 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 → Typer 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_cmd="whisper status", terminal=["processed", "error"], + retrieve_cmd="whisper retrieve", one_shot=True), +) +``` + +The command tree, `--help`, validation, `--dump-commands`, 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. + +--- + +## 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. +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 `--dump-commands` changes are needed — this is why the diff can be applied mechanically. +6. **Verify:** run `unstract --dump-commands` 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. + +--- + +## 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; `--dump-commands` 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) | +| One-shot semantics | Second retrieve yields exit `9`; `--save` persists before exit | +| Redaction | No secret appears in any output stream, including `--dry-run` and `-vv` | +| 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, `--dump-commands` | +| **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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..747a2c4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "unstract-cli" +version = "0.1.0" +description = "Unified, LLM-friendly CLI for the Unstract suite of products" +readme = "README.md" +requires-python = ">=3.12" +license = { text = "Unstract" } + +dependencies = [ + # Click is pinned deliberately: `--dump-commands` depends on the shape of + # `click.Parameter.to_info_dict()` (see IMPLEMENTATION_PLAN.md R3). A Click + # major bump could reshape that dict and silently degrade command discovery, + # so `tests/test_dump_commands.py` asserts the keys we rely on. + "click>=8.1,<9", + "typer>=0.12,<1", + "httpx>=0.27", + "pyyaml>=6.0", + "rich>=13.0", + "tomli-w>=1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "respx>=0.21", + "ruff>=0.6", + "mypy>=1.11", + "types-PyYAML", +] + +[project.scripts] +unstract = "unstract_cli.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/unstract_cli"] + +[tool.ruff] +line-length = 90 +target-version = "py312" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"] +ignore = [ + "E501", + # `str, Enum` is deliberate: members must serialise as plain strings in JSON + # output and `--dump-commands`. 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 new file mode 100644 index 0000000..8337c5a --- /dev/null +++ b/src/unstract_cli/__init__.py @@ -0,0 +1,5 @@ +"""Unstract CLI — a unified, LLM-friendly interface to the Unstract product suite.""" + +from unstract_cli.app import __version__ + +__all__ = ["__version__"] diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py new file mode 100644 index 0000000..7171380 --- /dev/null +++ b/src/unstract_cli/__main__.py @@ -0,0 +1,21 @@ +"""Console entry point.""" + +from __future__ import annotations + +import sys + + +def main() -> int: + """Run the CLI. + + Click exits the process itself via SystemExit, carrying the exit code set by + the command, so this normally does not return. + """ + from unstract_cli.app import build_cli + + build_cli()(standalone_mode=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/unstract_cli/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3486771554dfa12d6305d637cd13f079d9abee33 GIT binary patch literal 352 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py new file mode 100644 index 0000000..5f7dff3 --- /dev/null +++ b/src/unstract_cli/app.py @@ -0,0 +1,240 @@ +"""CLI entry point and the machine-readable command index. + +`--dump-commands` 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 + +import json +from typing import Any + +import click + +from unstract_cli.commands.config_cmd import CONFIG_COMMANDS, config_group +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 `--dump-commands` 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", + } +) + + +def _param_info(param: click.Parameter) -> dict[str, Any]: + """Describe one flag using Click's own introspection. + + Uses `to_info_dict()` rather than reading private attributes, so the shape is + a supported contract; `tests/test_dump_commands.py` asserts the keys we rely + on still exist, so a Click upgrade fails loudly rather than silently. + """ + info = param.to_info_dict() + type_info = info.get("type", {}) + entry: dict[str, Any] = { + "name": info.get("name"), + "flags": info.get("opts", []), + "type": type_info.get("param_type", "String").lower(), + "required": info.get("required", False), + "multiple": info.get("multiple", False), + "is_flag": info.get("is_flag", False), + "help": info.get("help") or "", + } + if (default := info.get("default")) is not None: + entry["default"] = default + if choices := type_info.get("choices"): + entry["choices"] = list(choices) + return entry + + +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, + "product": endpoint.product.value, + "api": {"method": endpoint.method, "path": endpoint.path}, + "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 + + +def dump_commands() -> dict[str, Any]: + """Build the machine-readable index of every command.""" + 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)) + + for hand in CONFIG_COMMANDS: + commands.append( + { + "command": "unstract " + " ".join(hand.command_path), + "path": list(hand.command_path), + # Flagged so an agent can distinguish local operations from API calls. + "kind": "local", + "summary": hand.summary, + "flags": [ + { + "name": p.name, + "flags": [p.cli_flag], + "required": p.required, + "help": p.help, + } + for p in hand.params + ], + } + ) + + return { + "cli": "unstract", + "version": __version__, + "description": "Unified CLI for the Unstract suite of products.", + "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." + ), + }, + "commands": commands, + } + + +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 + + +@click.group( + cls=UnstractCLI, + # `--dump-commands` 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"], "max_content_width": 100}, +) +@click.version_option(__version__, "-V", "--version", prog_name="unstract") +@click.option( + "--dump-commands", + "dump", + is_flag=True, + default=False, + help="Emit the full command tree as JSON, for programmatic discovery.", +) +@click.option("--profile", "-p", default=None, help="Config profile to use.") +@click.pass_context +def cli(ctx: click.Context, dump: bool, profile: str | None) -> None: + """Unified, LLM-friendly CLI for the Unstract suite. + + Products: LLMWhisperer text extraction (`whisper`), deployed API workflows + (`deployment`), platform management (`platform`), human review (`hitl`) and + API Hub vertical extraction (`apihub`). + + Machine-readable discovery: + + unstract --dump-commands + + Output defaults to JSON whenever stdout is not a terminal, so piping the CLI + needs no extra flags. + """ + if dump: + click.echo(json.dumps(dump_commands(), indent=2)) + ctx.exit(0) + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + ctx.exit(0) + + +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) + return cli + + +__all__ = ["__version__", "build_cli", "cli", "dump_commands"] diff --git a/src/unstract_cli/commands/__init__.py b/src/unstract_cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unstract_cli/commands/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/commands/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e44363bdc595987ce8d7aeda309020ef94e4938 GIT binary patch literal 177 zcmX@j%ge<81X0`Kvq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!3eeBU&rQ|ONG!_K z56>^kE-pw+PSuafEGRBXOwQIX%_}Y`N=z=%P0q>GFD^=k3&ev2lJj$O6Z2Av_2c6+ p^D;}~=b?E&>AhvI;u02L|_v-S0Iy%cT#DU`_9 z#5ok`d$U}UlIbJ`3UmO@&d$!fc{B6go8NoGe<&`tGmzfB>l^v$28Q`Pz8I-gFR)7| z%?xvuIl)MbmyuYB3$bC&%duECg-l`I%hNI+61)P`2_bXX;xYAom->sq5;VN%cxY}DCuJP8e z49Cb7l1;KpMNq1g98&S^l3Se4Z5}<-GJ5quuYtB}gmz+HyXCW32kqA9@mV1?LCprE zX3*lTl{U(CQu7{|D9~Fo%*fVvS*hi9e6x(~)aRd{-^#pnGDhiL_7%1|#w@GdqIb5KRO=`_a0;PPU zKr0ulb(_?FdyBCb@;d3Dv^7WH7{_>en;$b?sq|ECJfPAoZF`i8x8*Ut%cbqP-jv(^ z0^C;6FUKDJBG()Hb^ANUIz5(3wbAymtFlAdDe=;-+q?5t<&gCBqw_IVW#!Ym=`r)H zmFoVV=2>Vx*L=&B*t2XU4oiE#{Yn@s<9+fmD^f2te2W!nG&lvxIwI|R{P^C@j~Qp3 zw134oh4ew+`Dd2Rw@2#un(CV1PB2cV(_q8 zRHFKD(BDqvK$J+bBnBhm>DE?hEIitpnWAzUIE`)=2Sfg$A!y-OfbB@D!84NaM>0bO zprp#7kX>h1pGYtoQN%$K4U0q$M^#t_e<-BDn)CxV1N~iYS6C9?e{Z{ME3VpbFft?p zpRza#T}D-9w|F`j396@|_aIDA!F3CSqA$M;yO{f=Qf#J}&!zp#5^PT9H2jWBXO)6<$ppk%?ZXRVzN=>2vq^ zIy{Fv2D%0`NB^CM(g{+f6X^QI*7bqpjFTWX%v)=n25kzf#ShD&QH>A5M#R>6Ajhqvvq5n}+0AS3(o93L z8WRU}ktLaB6BSv-ZER7*v;NSS?9wbiL>ddI$799)dP#g4#Oe&L5RqRQ3xb}JYS=!_ zbViaxA^Rtf}H9d^YU z)v?i#{M`cEzgcv-T&J{BcVF+p!`(i2-_fHTy`2Mr3|yi|GeXQJhGCanQ0QS;0LpRZ zR51V{9TEX_Mo9E$>{XU!E?NN;mGaC4^E1;pdz0ZRlkfbPz zUma^3j)vv7VLu7BJs%}!lu>^`Zu165L7)O>+QuSid;)4~AQWs5{CH>{FF72{X6sJB$Hg-H`lUvru&2Od+V^Zji70JMSw5n94wXzqK4Z(cIp<0 zTI&%7jYG;Gf&(v zUUli@g_A$?r0RCv^vu38v*r4Zw|3m{%+>8m74N>+nJzx`zyh5Xt&GLGWaAySMTQq_ z5C5fZmjb>7bE~4GyfW5y)E|KpR|K=2F?3^a)Bwwd$xy^6GUs6=G~slCV2RL~PBQ?7 zWr@lJZthiPf^~ASE>GUEZ_owg0Rx7?-cSgz99WzIlLW{@R>wgnGiPltX$F#d)`lf2 zzmAOVQ4B+NK|MMz3X0!(hG4r```4Wk;&@OUHYN#sJNB~Tidiy8@4?vmZ#+A2?=3;a zhtPs-h4J(?$(Mf{)Y3Jng4(O!pe2R|CpZ{pw_NPlIb>YIJ_g^aIQ=SeU^!HBWO@oB(=h*`1)}Si)B24-q+8k zNL_7Ej+=~L5msUnyd+5G5Cl9D0tPMOf@D$A$kJr+8#hVT+qPR7JCiW0^+t=h8R%v9 zC}B~vb5!!zu=nny^ktTFhsEB@5)L2(+XFotd0O=Id;5i@7qMD$(84;2ZxH8B`vt|kk!8vPY3R9I>4D?9? zy$$+93c_`O{%3asHql1}n~{O#U?C|d!_kn0#uDIBfXJ%i5)D`aXc$0pBmjsUXaq?# zg7yHw4hi~S1|W&kM0W%S3jok_fkm|d4fIC{T19dI8RNka%UU^+M?-LWOFjcmJ1fak z_`L$anlA%x5VWfDIhCN25I1H3Bp3o`45JAr20FUC2ud^HO~9?12^S7^N2@ci03Q7= z-Rigy6U&6`M+&G{qzIdu05oW3B?_Qe%5VsNOo*PcPAjRxhIrs;rhs}*Ut3dPaEOx8 zOc8lpvmjhm<%ohu9^e$3bc1Jras5`Tk{Os8q&n6vh_Z^j&dN1^*%X{FAu-U(`GDW2q zHe75@TZ=APFIcZsTzTQ@OP60tm2a50Zv3RN>H4eJUVS@p@yTgv-oEC}hDrBt`0c;5 zSEg&$-!EU6DsM^`m#0fB9@vB;^Q8NMgDI_>X_`?}B^xHY@AKAAtLtWVycth7I`7vs z%qZ96*W#&~&5Jx+?qMI8nf1-Hfp=>^E4SI~4=Nd3@zirabo`;(A((YD?p%(_L0E$k z-Pv1-&X<7{RBdW}BY=AHjjv%O5JVP&DYQ#~G=Ws!J@|K?pL>4MJ73cC^WK}T8!dOXPmRBAo*8?qc&>Eg-QEvNdgiP> z>9y^P0wWYJF+kvfl@V;8R#wlLu3N8J-#nVGs=YdLc_iJqexY&eT;tYsjrf^O0I7Ev zQeSEn%nz3=OsR)`_y-e^puqg6YY(*9e`&5d;H>=RS~Ha2cVZsf`4~SG;vLaYC^}9f z3jj~VeQ0JO&)9XBlOUEa=|yIo1()h3r}22;YmSUC`7p?^%ou~1FkmX#C4};S z%4U6eIOfm{U*OI%886;$c<|JZrKta=i6Dy7*kQ63o*5s0k{*lH(~i~a$FdL3d)FT7 zt|#!^4P289 z?n1;Po?d1aGhE|>ku&i5hwxMW5i*EWfHhhUCQ529MJ_~Uo}V2`mAEFm7df*~dcR`b zk6(Q^ac{?bMbDHqT~>3|aoGV)0;#f1Q^JEXro1vm*BSCuS;ypIfB~OWH>4}7KHhcU zofDVO{00AOAzjxvt0wE7EMNq`d{E9fsy}B;FqF0IO2zcRn>BCmyuR<+zLa&-g0*eV z+BR?9QV_1(x0d{I(E=U6qMQD+mIKAi`^7f*F4Ox}9kuRlrVq9W@Di)cvvzuvP>s3* z`G-Gl88vYK`M)!8LIDpB{El<1QU!uzR3i;n5XK+!8nYP)li25gOAJHycJEVFeRl%Xv&+XFf?qVSiD|`bG*T z2|x`cULVJTW;W03vl`>ZZIT)MsI0lm`B#Q1gf$hu!Q7!%(=>ugyCNAQ8Bgk$S$fGc z(C+`vijkLq+}GGH2>KSVU4Mg}@@$uV=Un}c8Fp&RwCN%iAClA1g7_he@#v4mUeN=> zG#V_4Apltft}G^8Vn6k|#M9X7xsGGL-j1VPr!yBR`9W^Dk3ql9k9QWjm$vNo0YSHJ zSj3>J$e}@S&d3llk+Y1{ZM2O0NdzEBzFE`_U`V6^zj2 zAwz_MIqRr9ZkB-)Zg#YIUGL1#X^TUW8WGHx8XYbaCKH%|iLQIq=$LPcgEv( z#magj(eYelc()jP!X;*ctk72vsh&3CC5BA_RXXh=c*_8u1XX=B506>}Y7qDlxJ%Z3 zLfkT{<%PhJPKcTe3Zl?BIL$=FKFhUU6V4#lRPPlOL>&m}K@~&Y@$z{bzWo}0%3nhU z+72<1Wib_9+dr|`--y2!pW$cS?|I(w%+<7~%POzhF5BL2o~?Lq?K^8zb*@xd+lOVl z?tEuT0PK=3shU=&hm#Giytqq6(`?u5j^w6e$?}136M?B~m~_wc)j8t;aBAlG?Wb;x z-`X>0+xd$PchCOq=8yPpJz}yodm~ri9uJImfxEm|g|YA8`voJ)D8vw5g)PG0udoP@ zUP8s0JgVXl#6$wJuX7DMq-dnt&&Vo;g;FKotDNX31UHpmRldbq_g@62kZ>-N5 z+Qp4_P5?Pxpnw0_DuUHzf8cCnVJ z6d$aG)BBT#=7omsa}C??uW6V$cRg_}vCz0>u5n9h%~l-s)U{I!^;_oZw-gQh{r{4a$DcPaM)V{nR*{ z#KwWwDj5$LIQZ;deN)XlmwK6Z5Y*M-j~MFT#cX7~99$6z^RpO2VuCCqSR4n~3S z$p2HA^??R{g(KccDD^iHy ztpLHGcOf$R4SepO6S`bAVe3+#FYjMgFq5NLYo`CfQM59j@#iY{p}xc5kdPBNj*J-! zBfZVngxp;<+-B+jf+E}S^8jWToz|>=GBk#lVPrejoxu$K5Q?lFWB~Hkwf7#yNuA>|49+>o4NZv=Tz&AXKl!vebU;Ti4#2RXJ#Z# z+aGycdphKu%H$r;eC^RifvfF<&L*Ls{R{%^na*x(boLH^ci(*Nkz|br8SABt*=Bbp zTTi9b_*l)3b?%mZ#@h zo}O>nn}Q0y9~iFWl1v%6QO&z%Pc3ZRIk$1={KnlM6?^73?!FwGer2ZRgYLVpO!=qF zFG%;gr~Ju{yHmxUN&AwSWuIX`w+gInv5eu0Cs)6*=KPxJCz9)q&9eiab1d5rGh(>o z>>@40e>S+11$OluyLx&s*>obwu1>MfrCIE__WatZ;B4iMv2?|b2PT%=^$A=0IoHIR zkwDp!05w>8o?Wy;<)Q~}$@56{Qc`_s>{DX&f#&_}A`kD2>vOH;#+ zn6}M2W`*}0?>LgJdsE`Rd3Jy9n~-9wax9*i*>69Uz8qD@Bnz3?mLvBjJWV4r2`0gXD9iC^8P(pQi%Y-Vz za3|SCGt@5b&JhBU*iQ*PL;G-SS8nhQ*hs7ZA$8QxM3VN_^OlDXPMDdJqbzlQer2un Hv{?TKFZ(_C literal 0 HcmV?d00001 diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py new file mode 100644 index 0000000..3429856 --- /dev/null +++ b/src/unstract_cli/commands/config_cmd.py @@ -0,0 +1,260 @@ +"""The `config` command group -- hand-authored, not generated. + +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, so they are +recorded in `--dump-commands` too, flagged as non-endpoint so an agent can tell +local operations from remote calls. + +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 typing import Any + +import click + +from unstract_cli.config.loader import ( + BLOCK_ALIASES, + PRODUCT_KEYS, + ConfigError, + ResolvedConfig, + config_path, + load_config, + save_config, + starter_profiles, +) +from unstract_cli.core.errors import CLIError, ExitCode +from unstract_cli.core.model import HandAuthoredCommand, Param +from unstract_cli.core.output import OutputFormat, default_format, emit + +#: Declared so `--dump-commands` can describe the whole surface, not just the +#: generated half. +CONFIG_COMMANDS: tuple[HandAuthoredCommand, ...] = ( + HandAuthoredCommand("init", "config", "Create a starter config file.", + (Param("force", help="Overwrite an existing config file"),)), + HandAuthoredCommand("list", "config", "List profiles in the config file."), + HandAuthoredCommand("get", "config", "Show a resolved setting's value.", + (Param("product", required=True, help="Product block"), + Param("key", required=True, help="Setting name"))), + HandAuthoredCommand("set", "config", "Set a value in the config file.", + (Param("product", required=True, help="Product block"), + Param("key", required=True, help="Setting name"), + Param("value", required=True, help="Value to store"))), + HandAuthoredCommand("use", "config", "Set the default profile.", + (Param("name", required=True, help="Profile name"),)), + HandAuthoredCommand("current", "config", "Show the active profile and resolved settings."), + HandAuthoredCommand("path", "config", "Show the config file path."), +) + + +#: Product names accepted on the command line, including config-file aliases. +_PRODUCT_CHOICES = sorted( + {*PRODUCT_KEYS, *(a for aliases in BLOCK_ALIASES.values() for a in aliases)} +) + +#: Alias -> canonical product, so `config get whisper api_key` resolves. +_CANONICAL = {a: p for p, aliases in BLOCK_ALIASES.items() for a in aliases} + + +def _fmt(output: str | None) -> OutputFormat: + return OutputFormat(output) if output else default_format() + + +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).") +def config_group() -> None: + """Local configuration management. These commands make no network calls.""" + + +@config_group.command("init", help="Create a starter config file with profile stubs.") +@click.option("--force", is_flag=True, default=False, + help="Overwrite an existing config file.") +@_output_option +def config_init(force: bool, output: str | None) -> None: + ctx = click.get_current_context() + path = config_path() + + if path.exists() and not force: + # Never prompt: state the situation and the exact flag that resolves it. + 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 + + new = ConfigFile( + default_profile="cloud-us", + profiles=starter_profiles(), + path=path, + exists=True, + ) + written = save_config(new, path) + emit( + { + "created": str(written), + "default_profile": "cloud-us", + "profiles": sorted(new.profiles), + "note": ( + "Credentials use env: indirection, so this file holds no secrets. " + "Set the referenced environment variables to authenticate." + ), + "replaced_existing": bool(cfg), + }, + _fmt(output), + ) + + +@config_group.command("list", help="List profiles defined in the config file.") +@_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": profiles, + }, + _fmt(output), + ) + + +@config_group.command("get", help="Show a resolved setting, following flag > env > profile.") +@click.argument("product", type=click.Choice(_PRODUCT_CHOICES)) +@click.argument("key") +@click.option("--profile", "-p", default=None, help="Profile to read from.") +@_output_option +def config_get(product: str, key: str, profile: str | None, output: str | None) -> None: + ctx = click.get_current_context() + product = _CANONICAL.get(product, product) + try: + resolved = ResolvedConfig(file=load_config(), profile_name=profile) + value = resolved.get(product, key) + except ConfigError as exc: + 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 else value, + "configured": value is not None, + }, + _fmt(output), + ) + + +@config_group.command("set", help="Set a value in the config file.") +@click.argument("product", type=click.Choice(_PRODUCT_CHOICES)) +@click.argument("key") +@click.argument("value") +@click.option("--profile", "-p", default=None, help="Profile to write to.") +@_output_option +def config_set(product: str, key: str, value: str, profile: str | None, output: str | None) -> None: + ctx = click.get_current_context() + cfg = load_config() + name = profile or cfg.default_profile or "cloud-us" + + cfg.profiles.setdefault(name, {}).setdefault(product, {})[key] = value + if not cfg.default_profile: + cfg.default_profile = name + written = save_config(cfg) + + warning = None + if 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." + ) + + emit( + {"profile": name, "product": product, "key": key, "path": str(written), + "warning": warning}, + _fmt(output), + ) + ctx.exit(int(ExitCode.SUCCESS)) + + +@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() + + 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] = {} + + for product in PRODUCT_KEYS: + block: dict[str, Any] = {} + for key in ("base_url", "org_id"): + try: + if (value := resolved.get(product, key)) is not None: + block[key] = value + except ConfigError: + continue + try: + block["api_key_configured"] = bool(resolved.get(product, "api_key")) + except ConfigError: + block["api_key_configured"] = False + settings[product] = block + + emit( + { + "active_profile": resolved.active_profile, + "config_path": str(resolved.file.path), + "config_exists": resolved.file.exists, + "settings": settings, + }, + _fmt(output), + ) + + +@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)) + + +__all__ = ["CONFIG_COMMANDS", "config_group"] 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/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/config/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af6bdd6ed14282102e5235a1833e386d22a0cefd GIT binary patch literal 175 zcmX@j%ge<81X0`Kvq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!^3%`A&rQ|ONG!_K z56>^kE-pw+PSuafEGRBXOwQIX%_}Y`N=z=%P0q>GFD^=k3&ev2lJoP@GSl_r<1_Oz nOXB183My}L*yQG?l;)(`6|n-1X9VJ65aS~=BO_xGGmr%US)MJH literal 0 HcmV?d00001 diff --git a/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc b/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..71c37f00c9b0983e521379282af298b4e99b4f70 GIT binary patch literal 13588 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 Hgvb8^HZbC3 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/config/loader.py b/src/unstract_cli/config/loader.py new file mode 100644 index 0000000..341f47d --- /dev/null +++ b/src/unstract_cli/config/loader.py @@ -0,0 +1,282 @@ +"""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 Product + +#: Products whose settings can appear in a profile block. +PRODUCT_KEYS = tuple(p.value for p in Product) + +#: Alternative block names accepted in a profile, per product. `whisper` matches +#: the command group users actually type. +BLOCK_ALIASES: dict[str, tuple[str, ...]] = { + Product.LLMWHISPERER.value: ("whisper",), +} + +#: 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] = { + Product.LLMWHISPERER.value: "https://llmwhisperer-api.us-central.unstract.com/api/v2", + Product.PLATFORM.value: "https://us-central.unstract.com", + Product.DEPLOYMENT.value: "https://us-central.unstract.com", + Product.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, ...]] = { + (Product.LLMWHISPERER.value, "api_key"): ("LLMWHISPERER_API_KEY",), + (Product.LLMWHISPERER.value, "base_url"): ("LLMWHISPERER_BASE_URL",), + (Product.PLATFORM.value, "api_key"): ("UNSTRACT_PLATFORM_KEY",), + (Product.PLATFORM.value, "base_url"): ("UNSTRACT_BASE_URL",), + (Product.PLATFORM.value, "org_id"): ("UNSTRACT_ORG_ID",), + (Product.DEPLOYMENT.value, "api_key"): ("UNSTRACT_DEPLOYMENT_KEY",), + (Product.DEPLOYMENT.value, "base_url"): ("UNSTRACT_BASE_URL",), + (Product.DEPLOYMENT.value, "org_id"): ("UNSTRACT_ORG_ID",), + (Product.HITL.value, "api_key"): ("UNSTRACT_DEPLOYMENT_KEY", "UNSTRACT_PLATFORM_KEY"), + (Product.HITL.value, "base_url"): ("UNSTRACT_BASE_URL",), + (Product.HITL.value, "org_id"): ("UNSTRACT_ORG_ID",), + (Product.APIHUB.value, "api_key"): ("UNSTRACT_APIHUB_KEY",), + (Product.APIHUB.value, "base_url"): ("UNSTRACT_APIHUB_BASE_URL",), + (Product.APIHUB.value, "anthropic_key"): ("UNSTRACT_ANTHROPIC_API_KEY",), + (Product.APIHUB.value, "llmwhisperer_key"): ("LLMWHISPERER_API_KEY",), +} + + +class ConfigError(Exception): + """Configuration could not be loaded or resolved.""" + + +def config_path() -> Path: + """Location of the config file, honouring ``UNSTRACT_CONFIG``.""" + if override := os.environ.get("UNSTRACT_CONFIG"): + return Path(override).expanduser() + xdg = os.environ.get("XDG_CONFIG_HOME") + base = Path(xdg).expanduser() if xdg else Path.home() / ".config" + return base / "unstract" / "config.toml" + + +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 {} + # The config file calls the LLMWhisperer block `whisper`, matching the + # command group users type, while the product enum is `llmwhisperer`. + # Accept both so a hand-written config is never silently ignored. + for key in (product, *BLOCK_ALIASES.get(product, ())): + block = profile.get(key) + if isinstance(block, dict): + return block + return {} + + def get(self, product: str | Product, key: str, default: Any = None) -> Any: + """Resolve one setting: **flag > env > profile > built-in default**.""" + product = product.value if isinstance(product, 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 | 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, Product) else product + hints: list[str] = [] + if env_vars := ENV_VARS.get((product, key)): + hints.append(f"set ${env_vars[0]}") + hints.append(f"add `{key}` to the [profiles..{product}] block") + hints.append(f"or pass --{key.replace('_', '-')}") + raise ConfigError( + f"Missing required setting {product}.{key}. To fix: {'; '.join(hints)}." + ) + + +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": { + "whisper": { + "base_url": DEFAULT_BASE_URLS[Product.LLMWHISPERER.value], + "api_key": "env:LLMWHISPERER_API_KEY", + }, + "platform": { + "base_url": DEFAULT_BASE_URLS[Product.PLATFORM.value], + "org_id": "", + "api_key": "env:UNSTRACT_PLATFORM_KEY", + }, + "deployment": { + "base_url": DEFAULT_BASE_URLS[Product.DEPLOYMENT.value], + "org_id": "", + "api_key": "env:UNSTRACT_DEPLOYMENT_KEY", + }, + "apihub": {"base_url": "", "api_key": "env:UNSTRACT_APIHUB_KEY"}, + }, + "cloud-eu": { + "whisper": { + "base_url": "https://llmwhisperer-api.eu-west.unstract.com/api/v2", + "api_key": "env:LLMWHISPERER_API_KEY", + }, + }, + } + + +__all__ = [ + "DEFAULT_BASE_URLS", + "ENV_VARS", + "PRODUCT_KEYS", + "ConfigError", + "ConfigFile", + "ResolvedConfig", + "config_path", + "load_config", + "save_config", + "starter_profiles", +] diff --git a/src/unstract_cli/core/__init__.py b/src/unstract_cli/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/unstract_cli/core/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1469d92f0c3c7d8e3693c110152d2c04017ee82 GIT binary patch literal 173 zcmX@j%ge<81X0`Kvq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!^3l)8&rQ|ONG!_K z56>^kE-pw+PSuafEGRBXOwQIX%_}Y`N=z=%P0q>GFD^=k3&ev2lJkpF_2c6+^D;}~ lIiWD!RmJ~@DvwBrf#X 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 J&Cb{Te*xw0(SHB{ literal 0 HcmV?d00001 diff --git a/src/unstract_cli/core/__pycache__/generate.cpython-312.pyc b/src/unstract_cli/core/__pycache__/generate.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24d92cd7dd77e4c4dd68d8197ad399e1c6135d17 GIT binary patch literal 17564 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/core/__pycache__/model.cpython-312.pyc b/src/unstract_cli/core/__pycache__/model.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b476e1d85fdb2faf7c49fefee61d75a8559db2e3 GIT binary patch literal 19794 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 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, + 429: ExitCode.RATE_LIMITED, +} + +_ERROR_CODES: dict[ExitCode, str] = { + ExitCode.GENERIC: "error", + ExitCode.USAGE: "usage_error", + ExitCode.AUTH: "auth_error", + ExitCode.NOT_FOUND: "not_found", + ExitCode.VALIDATION: "validation_error", + ExitCode.RATE_LIMITED: "rate_limited", + ExitCode.TIMEOUT: "timeout", + ExitCode.SERVER_ERROR: "server_error", + ExitCode.ALREADY_CONSUMED: "already_consumed", +} + + +def exit_code_for_status(status: int) -> ExitCode: + """Map an HTTP status onto its exit code.""" + if code := _STATUS_MAP.get(status): + return code + if 500 <= status < 600: + return ExitCode.SERVER_ERROR + if 400 <= status < 500: + return ExitCode.GENERIC + return ExitCode.SUCCESS + + +def is_retryable(status: int) -> bool: + """Retry only on rate limiting and server faults -- never on 4xx. + + Retrying a 4xx re-sends a request the server 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 == 429 or 500 <= status < 600 + + +# --------------------------------------------------------------------------- # +# Redaction +# --------------------------------------------------------------------------- # + +_SECRET_HEADERS = {"unstract-key", "authorization", "apikey"} +_SECRET_HEADER_PREFIXES = ("x-", ) +_SECRET_KEY_HINTS = ("key", "token", "secret", "password", "credential", "auth") +REDACTED = "***REDACTED***" + + +def redact_headers(headers: dict[str, Any]) -> dict[str, Any]: + """Redact credential-bearing headers.""" + out: dict[str, Any] = {} + for key, value in headers.items(): + low = key.lower() + secret = low in _SECRET_HEADERS or ( + low.startswith(_SECRET_HEADER_PREFIXES) + and any(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 { + 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): + return [redact_value(v) for v in value] + return value + + +def scrub(text: str, secrets: list[str]) -> str: + """Remove known secret literals from free text. + + 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) >= 8: + text = text.replace(secret, REDACTED) + text = re.sub(re.escape(secret), REDACTED, text) + return text + + +# --------------------------------------------------------------------------- # +# CLIError +# --------------------------------------------------------------------------- # + + +@dataclass +class CLIError(Exception): + """A failure that maps onto an exit code and a structured stderr payload.""" + + message: str + exit_code: ExitCode = ExitCode.GENERIC + http_status: int | None = None + details: Any = None + endpoint: str | None = None + hint: str | None = None + retryable: bool = False + code: str | None = None + extra: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + super().__init__(self.message) + + def to_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "code": self.code or _ERROR_CODES.get(self.exit_code, "error"), + "message": self.message, + "exit_code": int(self.exit_code), + "retryable": self.retryable, + } + if self.http_status is not None: + payload["http_status"] = self.http_status + if self.details is not None: + payload["details"] = self.details + if self.endpoint: + payload["endpoint"] = self.endpoint + if self.hint: + payload["hint"] = self.hint + payload.update(self.extra) + return {"error": payload} + + def emit(self, secrets: list[str] | None = None) -> None: + """Write the structured error to stderr. Never to stdout.""" + text = json.dumps(self.to_dict(), indent=2, default=str) + if secrets: + text = scrub(text, secrets) + print(text, file=sys.stderr) + + +def hint_for(status: int, endpoint: str | None = None) -> str | None: + """A short, actionable next step for common failures.""" + match status: + case 401 | 403: + return ( + "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 --org-id matches the resource's " + "organization. For deployments, confirm --api-name is correct." + ) + case 406: + return ( + "This result was already retrieved. Results can be read exactly " + "once; re-running the request cannot recover them. Use --save next " + "time to persist on first read." + ) + case 409: + return "The resource is in use or conflicts with an existing one." + case 429: + return "Rate limited. The CLI retries automatically; raise --max-retries if needed." + if 500 <= status < 600: + return "Server-side failure. Retried automatically; if it persists, check service status." + return None + + +__all__ = [ + "REDACTED", + "CLIError", + "ExitCode", + "exit_code_for_status", + "hint_for", + "is_retryable", + "redact_headers", + "redact_value", + "scrub", +] diff --git a/src/unstract_cli/core/generate.py b/src/unstract_cli/core/generate.py new file mode 100644 index 0000000..963796c --- /dev/null +++ b/src/unstract_cli/core/generate.py @@ -0,0 +1,332 @@ +"""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 `--dump-commands` 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 `--dump-commands` 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 + + 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=f"{part} commands.") + parent.add_command(existing) + parent = existing + + parent.add_command(build_command(endpoint)) + + return groups + + +_GROUP_HELP = { + "whisper": "LLMWhisperer v2 — convert documents to LLM-ready text.", + "deployment": "Run deployed Unstract API workflows and check their status.", + "platform": "Unstract Platform Management API (v1) — manage platform resources.", + "hitl": "Human Quality Review — retrieve approved results (Enterprise).", + "apihub": "API Hub — vertical extraction (tables, bank statements, doc splitting).", +} + + +__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..6a0ece3 --- /dev/null +++ b/src/unstract_cli/core/http.py @@ -0,0 +1,463 @@ +"""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 BodyKind, Endpoint, ParamLocation, Product + +#: 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: Product, config: ResolvedConfig) -> dict[str, str]: + """Build product-specific auth headers (SPEC.md §4.4). + + Three products, three schemes -- unifying them is the CLI's core promise. + """ + match product: + case Product.LLMWHISPERER: + return {"unstract-key": config.require(product, "api_key")} + case Product.PLATFORM | Product.DEPLOYMENT | Product.HITL: + return {"Authorization": f"Bearer {config.require(product, 'api_key')}"} + case Product.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 product: {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 Product: + 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.product, "base_url") + if not base: + raise CLIError( + f"No base URL configured for {endpoint.product.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 and param.default_from: + product, _, key = param.default_from.partition(".") + value = config.get(product, key) + 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 _guess_content_type(path: Path) -> str: + import mimetypes + + return mimetypes.guess_type(path.name)[0] or "application/octet-stream" + + +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.product, 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 or param.location is ParamLocation.PATH: + 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 and param.default_from: + product, _, key = param.default_from.partition(".") + raw = config.get(product, key) + 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 = 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))) + ) + 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: + return response.text + if ctype.startswith("text/") or not ctype: + return response.text + return response.content + + +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 + 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), + 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..04ad0a9 --- /dev/null +++ b/src/unstract_cli/core/model.py @@ -0,0 +1,411 @@ +"""Declarative endpoint model — the single source of truth for the CLI. + +Every command, flag, help string, validation rule and `--dump-commands` 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, IMPLEMENTATION_PLAN.md §1). + +The dataclasses here are frozen: records are a contract, not runtime state. + +Parameter patterns P1-P12 (IMPLEMENTATION_PLAN.md §2) 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): + """A product surface. Determines base URL and auth strategy (SPEC.md §4.4).""" + + LLMWHISPERER = "llmwhisperer" + PLATFORM = "platform" + DEPLOYMENT = "deployment" + HITL = "hitl" + APIHUB = "apihub" + + +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 ``--dump-commands``.""" + 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 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"``. + default_from: str | None = None + #: 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 + + @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 + status_field: str = "status" + terminal_success: tuple[str, ...] = () + terminal_failure: tuple[str, ...] = () + handle_field: str = "" + handle_param: str = "" + retrieve_endpoint: str | None = None + #: 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 + product: Product + 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`` (IMPLEMENTATION_PLAN.md M1.6). + table_columns: tuple[str, ...] = () + #: Response key holding the payload for ``--output raw``. + raw_field: str | None = None + + @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)) + + +@dataclass(frozen=True) +class HandAuthoredCommand: + """A command with no API endpoint behind it (IMPLEMENTATION_PLAN.md §1). + + The `config` and `completion` groups operate purely locally. They are recorded + so `--dump-commands` can describe the whole surface while still letting an + agent tell local operations from remote calls. + """ + + name: str + group: str + summary: str + params: tuple[Param, ...] = () + description: str = "" + examples: tuple[str, ...] = () + + @property + def command_path(self) -> tuple[str, ...]: + return (self.group, self.name) + + @property + def dotted_name(self) -> str: + return ".".join(self.command_path) + + +__all__ = [ + "AtLeastOneOf", + "BodyKind", + "Constraint", + "Endpoint", + "HandAuthoredCommand", + "MutuallyExclusive", + "Param", + "ParamLocation", + "ParamType", + "Permission", + "PollSpec", + "Product", + "derive_patch", + "field", + "with_params", +] diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py new file mode 100644 index 0000000..f4ace57 --- /dev/null +++ b/src/unstract_cli/core/output.py @@ -0,0 +1,189 @@ +"""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 + +import json +import os +import sys +from enum import Enum +from typing import Any, TextIO + +import yaml + + +class OutputFormat(str, Enum): + JSON = "json" + YAML = "yaml" + TABLE = "table" + RAW = "raw" + + +def default_format(stream: TextIO | None = None) -> OutputFormat: + """JSON unless a human is watching (SPEC.md §5.1). + + ``UNSTRACT_OUTPUT`` overrides. TTY detection means an agent gets JSON without + passing a flag, while a human at a terminal gets a table. + """ + if env := os.environ.get("UNSTRACT_OUTPUT"): + try: + return OutputFormat(env.lower()) + except ValueError: + 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 + + +def _flatten(value: Any) -> str: + """Render a cell. Nested structures become compact JSON rather than Python reprs.""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (dict, list)): + return json.dumps(value, default=str) + return str(value) + + +def _rows_and_columns( + data: Any, columns: tuple[str, ...] = () +) -> tuple[list[str], list[list[str]]]: + """Derive table columns and rows from arbitrary JSON. + + The generic rule (IMPLEMENTATION_PLAN.md M1.6): + + * 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: + data = inner + break + + if isinstance(data, list): + if not data: + return [], [] + if all(isinstance(item, dict) for item in data): + if columns: + headers = list(columns) + else: + headers = [] + for item in data: + headers.extend(k for k in item if k not in headers) + return headers, [[_flatten(item.get(h)) for h in headers] for item in data] + return ["value"], [[_flatten(item)] for item in data] + + if isinstance(data, dict): + keys = list(columns) if columns else list(data) + return ["key", "value"], [[k, _flatten(data.get(k))] for k in keys] + + return ["value"], [[_flatten(data)]] + + +def render_table(data: Any, columns: tuple[str, ...] = ()) -> str: + """Render as an aligned plain-text table. + + 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. + """ + headers, rows = _rows_and_columns(data, columns) + if not headers: + return "(no results)" + + widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(widths): + widths[i] = max(widths[i], len(cell)) + widths = [min(w, 60) for w in widths] + + def fmt(cells: list[str]) -> str: + return " ".join( + (c[:57] + "..." if len(c) > w else c).ljust(w) + for c, w in zip(cells, widths, strict=False) + ).rstrip() + + lines = [fmt(headers), fmt(["-" * w for w in widths])] + lines.extend(fmt(row) for row in rows) + return "\n".join(lines) + + +def render( + data: Any, + fmt: OutputFormat, + *, + columns: tuple[str, ...] = (), + raw_field: str | None = None, +) -> str: + """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( + data: Any, + fmt: OutputFormat, + *, + columns: tuple[str, ...] = (), + raw_field: str | None = None, +) -> None: + """Write a payload to stdout -- and nothing else to stdout.""" + print(render(data, fmt, columns=columns, raw_field=raw_field)) + + +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``. + """ + if quiet or verbosity < level: + return + print(message, file=sys.stderr) + + +__all__ = [ + "OutputFormat", + "default_format", + "diagnostic", + "emit", + "render", + "render_table", +] diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py new file mode 100644 index 0000000..696ae4a --- /dev/null +++ b/src/unstract_cli/core/poll.py @@ -0,0 +1,175 @@ +"""`--wait` state machines (SPEC.md §3.1, §6.2). + +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 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 time +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 +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 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: + return payload[field] + for envelope in ("message", "data", "result"): + inner = payload.get(envelope) + if isinstance(inner, dict) and field in inner: + return inner[field] + return None + + +def extract_status(payload: Any, field: str = "status") -> str | None: + """Read the status from a response body, case-normalised.""" + value = _dig(payload, field) + return str(value) if value is not None else None + + +def extract_handle(payload: Any, field: str) -> str | None: + """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 _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, + config: ResolvedConfig, + values: dict[str, Any] | None = None, + poll_interval: float = 3.0, + timeout: float = 300.0, + 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 endpoint defines a retrieve step. + + On timeout, exits 7 *with the job handle*, so an agent can resume with a + plain `status`/`retrieve` call rather than reprocessing the document. + """ + spec = endpoint.poll + if spec is None: # pragma: no cover - guarded by the caller + return initial + + from unstract_cli.endpoints import get_endpoint + + handle = extract_handle(initial, spec.handle_field) + if not handle: + 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 + + # 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: + 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: + diagnostic(f"--wait: status={status}", quiet=quiet, verbosity=verbosity, level=1) + last_status = status + + 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, + 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 normalised in {s.lower() for s in spec.terminal_success}: + break + + if now() >= deadline: + raise CLIError( + f"Timed out after {timeout:g}s waiting for completion " + f"(last status: {status!r}).", + ExitCode.TIMEOUT, + endpoint=f"{endpoint.method} {endpoint.path}", + hint=( + f"The job is still running. Resume with the {spec.handle_field} " + f"below rather than resubmitting the document." + ), + extra={spec.handle_field: handle, "last_status": status}, + ) + + sleep(poll_interval) + + if not spec.retrieve_endpoint: + return response.payload + + retrieve_endpoint = get_endpoint(spec.retrieve_endpoint) + plan = http.build_request( + retrieve_endpoint, + config, + {**_carry_path_values(values or {}, retrieve_endpoint), spec.handle_param: handle}, + ) + 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..2e10d4c --- /dev/null +++ b/src/unstract_cli/endpoints/__init__.py @@ -0,0 +1,46 @@ +"""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 `--dump-commands` 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(group: str) -> tuple[Endpoint, ...]: + """All endpoints in one product group.""" + return tuple(e for e in ALL_ENDPOINTS if e.group == group) + + +__all__ = ["ALL_ENDPOINTS", "endpoints_for", "get_endpoint"] diff --git a/src/unstract_cli/endpoints/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5428b7dd1967d53cacc7d78fd490f1522fd37868 GIT binary patch literal 2347 zcmZ`*UuYCZ7@yg@y}wB=nrKW_98p@2CVQ3^QHd%3$wO+AD$$B3ac|w3+-q`}}^--Tnp>OR&(omGe2k9TIZ)ZY5ed;%R*{G!s+{}D4-}n2z z`Q|s@-p|d=83ZkNOC5VCBlL$5q6V^PcwrMp=sY@zC@La~sbu1+RFtro$IMtYUW~&$ zPGwWBCW?t_vY4!uqS!} z&Cx83%gTs0UB$)awPqc%A+#q@zSZ9X>FTqnV;h!7I4c{j$7|&G>2t)ZFyb0k*@VIN zxz30^N<8j+6*6k`aC`SaKXH9Ns_9J0Wcog0V5-~Is%BAQSfPr?85nY-Y*?DdTvB12 zDdYfPAM-70GD>!vnok)S9yd&rFluo%u=q7l8KS!EVR zNgQrFS~-lGiK9)3*oo#5+Y;w}B=VofK~}RMI?Xd|%LOqNW%Cha7Ukt2p*cpyKNe&t zb4W^@QgB4W+vM`B>XK@xL=TRDY(GJk56XwHc1M;M3 zaUWItC5cKjHn#Y{q8%}M$w+#RdSlUhG+siNLo;Ph60tRaqBdN?9SL(qeuT7ZUBer4rDMKs)GyBA5uSI<{#t z;H6DQ4PZ=2-%z{2na2&d!X+k z>())9;BtLwK@|&8a@~Te8kXUys^ZjwrZV%?Xp6Ulo7M1h_dz#}>P@oTem~c7Av>Gf zIFs7=rj-W#zpVa%J_E+_l>6_8&U$bpEvEmrZlKhv&8(nAv)8rs;!)n3T&r z$hFmFDcxR=fz2~F4WOq|S@hY~ow3+U1dYQ^NYHm+at0{sEz$QAG>R!coqA5sJ?I8# zi55|680DqO4Le2USY(hZDvxaeA2`$x-6Lgg`;PLGQ^D`I#jDr_ldtezgu(F?8iBHZ z0#D&F;o(2+Q)zq?WCx#Zo5Ek?^BAMEtKz7JZpMJic<4F~$D=dnccVt3m-#dQ4l_Ot)8A(s)x?uZ8Z(-{{FSnrZF5IQYef*YGL^4Ya@6AgY5-z#G&I=WB z^WfpnZ0lTW@8v=0=CZvr@!kbkNBLL-OlXH35%gUSLJ$MS1qpasKDW5IMc5EW=Ica9 zL??<*zAZ?qDz$Z04Kk`a>VrO0RW1?}bVR%rG*zGoGX;u1Gef)p@q|DTMTU!Wa={`l z{sIK)rD7O{7vxm+g(?j2BE;~Sg+Ygh6>-2PKvnaYzXsc3ZZ=FY}S@!MPZ`R(zv+Liwy?hX4Qo literal 0 HcmV?d00001 diff --git a/src/unstract_cli/endpoints/__pycache__/apihub.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/apihub.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a855cd059cd6ba7eb8d97eb6abe9693995382ebf GIT binary patch literal 9051 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/endpoints/__pycache__/deployment.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/deployment.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3db6c99363b078bf6a5c692b573d80db0a9bfb4d GIT binary patch literal 6557 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/endpoints/__pycache__/whisper.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/whisper.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8f7dd3afceb990fd7028a2ae71fe99dee5010b79 GIT binary patch literal 10405 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/endpoints/apihub.py b/src/unstract_cli/endpoints/apihub.py new file mode 100644 index 0000000..103fcd2 --- /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 ( + BodyKind, + Endpoint, + MutuallyExclusive, + Param, + ParamLocation, + ParamType, + PollSpec, + Product, +) + +_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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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..1970bf7 --- /dev/null +++ b/src/unstract_cli/endpoints/deployment.py @@ -0,0 +1,159 @@ +"""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 ( + BodyKind, + Endpoint, + Param, + ParamLocation, + ParamType, + PollSpec, + Product, +) + +_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, + default_from="deployment.org_id", + help="Organization identifier", + ), + 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="deployment", + method="POST", + path="/deployment/api/{org_id}/{api_name}/", + product=Product.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" + "Synchronous mode (--timeout > 0) is deprecated upstream; --wait uses " + "asynchronous execution plus polling, which is the supported path." + ), + 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"), + ), + body=BodyKind.MULTIPART, + poll=PollSpec( + status_endpoint="deployment.status", + status_field="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", + "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="deployment", + method="GET", + path="/deployment/api/{org_id}/{api_name}/", + product=Product.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="deployment", + method="GET", + path="/deployment/api/{org_id}/{api_name}/highlight/", + product=Product.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..b8dc264 --- /dev/null +++ b/src/unstract_cli/endpoints/hitl.py @@ -0,0 +1,101 @@ +"""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 Endpoint, Param, ParamLocation, ParamType, Product + +_DOCS = "unstract-docs/docs/unstract_platform/human_quality_review" + +_ORG = Param( + "org_id", + location=ParamLocation.PATH, + default_from="hitl.org_id", + help="Organization identifier", +) + +_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="hitl", + subgroup="approved", + method="GET", + path="/mr/api/{org_id}/approved/result/{class_id}/", + product=Product.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="hitl", + method="GET", + path="/mr/api/{org_id}/approved/result/{class_id}/", + product=Product.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="hitl", + method="GET", + path="/mr/api/{org_id}/approved/download-status/{job_id}/", + product=Product.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..8cf04b8 --- /dev/null +++ b/src/unstract_cli/endpoints/platform.py @@ -0,0 +1,979 @@ +"""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 unstract_cli.core.model import ( + AtLeastOneOf, + BodyKind, + Endpoint, + MutuallyExclusive, + Param, + ParamLocation, + ParamType, + Permission, + Product, + 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, ...] = (), +) -> Endpoint: + """Construct a Platform endpoint, filling in the org-scoped base path.""" + return Endpoint( + name=name, + group="platform", + subgroup=subgroup, + method=method, + path=f"{_BASE}{path}", + product=Product.PLATFORM, + summary=summary, + params=(_ORG, *params), + body=body, + doc_source=f"{_DOCS}/{doc}", + permission=permission, + description=description, + examples=examples, + constraints=constraints, + no_trailing_slash=no_trailing_slash, + table_columns=table_columns, + ) + + +# --------------------------------------------------------------------------- # +# Prompt Studio +# --------------------------------------------------------------------------- # + +_PS = "prompt-studio" +_TOOL_ID = Param("tool_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Prompt Studio project (tool) identifier") + +_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"), + 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"), + 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"), +) + +_PROFILE_FIELDS: tuple[Param, ...] = ( + Param("profile_name", location=ParamLocation.BODY, required=True, + help="Profile name, unique within the project"), + Param("vector_store", type=ParamType.UUID, location=ParamLocation.BODY, required=True, + help="Vector DB adapter id"), + Param("embedding_model", type=ParamType.UUID, location=ParamLocation.BODY, required=True, + help="Embedding adapter id"), + 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"), + Param("chunk_overlap", type=ParamType.INT, location=ParamLocation.BODY, + help="Overlap between chunks"), + 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."), + _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), + _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, *_PROMPT_FIELDS), + subgroup=f"{_PS} prompt", body=BodyKind.JSON, permission=Permission.READ_WRITE), + _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), + _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), + _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="Override the project's default LLM profile")), + subgroup=_PS, body=BodyKind.JSON, permission=Permission.READ_WRITE, + no_trailing_slash=True), + _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, + description="Requires single_pass_extraction_mode enabled on the project."), + _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), + _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"), + Param("file", type=ParamType.FILE, location=ParamLocation.FORM, multiple=True, + 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." + )), +) + + +# --------------------------------------------------------------------------- # +# 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.", + (Param("api_id", type=ParamType.UUID, location=ParamLocation.PATH, required=True, + help="Deployment identifier"), + Param("api", type=ParamType.UUID, location=ParamLocation.BODY, + help="Deployment to attach the key to"), + Param("pipeline", type=ParamType.UUID, location=ParamLocation.BODY, + help="Pipeline to attach the key to"), + 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, + constraints=(MutuallyExclusive(("api", "pipeline")),)), + _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.", + (Param("pipeline_id", type=ParamType.UUID, location=ParamLocation.PATH, + required=True, help="Pipeline identifier"), + Param("pipeline", type=ParamType.UUID, location=ParamLocation.BODY, + help="Pipeline to attach the key to"), + Param("api", type=ParamType.UUID, location=ParamLocation.BODY, + help="Deployment to attach the key to"), + 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, + constraints=(MutuallyExclusive(("pipeline", "api")),)), +) + + +# --------------------------------------------------------------------------- # +# 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, + table_columns=("id", "adapter_name", "adapter_type", "model", "is_available")), + _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), + # 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="platform", + subgroup="connector", + method="GET", + path="/api/v1/oauth/cache-key/{backend}", + product=Product.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="platform", + method="POST", + path=f"{_BASE}/{{resource}}/{{id}}/share/", + product=Product.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, + *_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..f2a39c4 --- /dev/null +++ b/src/unstract_cli/endpoints/whisper.py @@ -0,0 +1,297 @@ +"""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 ( + BodyKind, + Endpoint, + MutuallyExclusive, + Param, + ParamLocation, + ParamType, + PollSpec, + Product, +) + +_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", + ), + 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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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", + product=Product.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/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..396730f9ae2b530bfaa22f894c12b57a0e02e3d2 GIT binary patch literal 157 zcmX@j%ge<81o7MBvq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!veD1T&rQ|ONG!_K z56>^kE-pw+PSuafEGRBXOwQIX%_}Y`N=z=%P0q>GFG(#fDb|mV&&1j%DD><<{QhrYz9hXlk16#K;96nJ~t)6StpN-1JsBfxvl zJ?D2n&+nXjKhI`U1inB2M!Bsg3Hb{)!Y?)ygwtyRA@_(*T0|Fg(G z)bVaY16_Bjl^j{7Thm~X%v3ksN(Z{QndxR*S&$R08GWiXt0!AIkLJXFpPv4NJRFLo^|dqbnF-9fGcef+%-k86nF-AN8JO7#%)%L%+yrLvFt++q@Aums zl~G-88+TogQC>);$_JDk$}Z)u%sh+B>Yi#?&`>Se;I^r{0M~4*ZS2X8>UKb{%PMMI zW}7A$sdZp5+v-xwy&!WtrOG-zpr-9m_H8aRs+#ggKQ0!AV-#Tum#Lat9JULt-8Daw zHJHtUUQ9Nuw&_tzqtKIkrpr?+Hj{U$e1{pXOD);8R|~0sqAOMfKdoAp?W(R}TfF)} z@TW_r(eNybGHmR6hN&x>Y4n!)ejc}&-!7LL<>n99Dm&%;TNf|A{mys4clq7-dY3z{ z>+mbZqG@*TbPNt_r!22JM#1BG4R*#ia(U|8V6->Ulb3N$z@>i7+ zd}(Z{Ha8o!`c^4g^~7{L&H7H$PgZM6saB~qHqfn1HMn8z`D0kAR^8aT(MwNodo|`K zbZSD7_z6ydzz2Q|A^_A$x9cbvQhvH?Tl@47WUb@J4phd`ud*!v=W4NIcWDuV)+l~p zvwiNU8ZEXAhr6n_9|cn$I9^14^B}BHDmaIJCJbN&re&|em<#apG8BDsnuzBTKaUME zWP0vNa_Km^^y%C8jQy(0~8#OR%Xj+OOMi0;L0~Z^GyUS zLD47Ac`EWqo zn1#Ym+iXukB14j9csa9R!xJZxao}k|)x8PsfV4 zK3h4F-k(sc+|NIMWH3bZAyorPW9-}`ZbbL+IRMKSdK+-@X#04B!re3E&pxFNm$|D` z#$-+%6&Z*(Bl^qXq>LC&a_ldkJFbO7kKmx}N{hk;O$-V=I%kc9I6n*lq1j)?* z^8Lrs!qCZ}jK2SA#Z{Jo4(v@VWGn)f#UPQeJT$)#KgV}jpZqmFH!)GPeXwDc3XbLy z2*@T~z}x0y;l+2DE{6Az0CyK0TddkkakcBf#vQ%Q(aUet*EjMP3-8Enb)U+*t$Fya z2oeAu5~N$mylH$3P-XWJRI4KUI`s1;%%mK*4nZ}HnBmY01}D1+hU@Ti8493B`rNP0 zmB5aIkk2>FEkH2WuV{%_=41K{I@e)7xN&3@)-?qWX+ z3NhDT_}TJLmOqtFgjb)5DPit3M#SX-N`oaLCeipzoEqwL1C$1HkzR`QAVG=?@uIMZ c;{YC{K`PP?UUh0|@k3$vWAM9lgyv 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 mUGGao@9(a0ZF*k literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_model.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_model.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..efb61a8c08f228c92dc6f9c3050007b94315fdac GIT binary patch literal 47535 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_poll.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_poll.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c351edefd0976edb6be203bf20cb5c98a301d8f GIT binary patch literal 24285 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 literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..502ed2a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,70 @@ +"""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). +""" + +from __future__ import annotations + +import pytest +from click.testing import CliRunner + +from unstract_cli.app import build_cli + +#: 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 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) + monkeypatch.setenv("UNSTRACT_CONFIG", str(tmp_path / "config.toml")) + return tmp_path + + +@pytest.fixture +def whisper_env(monkeypatch): + """Credentials for LLMWhisperer, supplied only through the environment. + + 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. + """ + monkeypatch.setenv("LLMWHISPERER_API_KEY", FAKE_KEY) + return FAKE_KEY + + +@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 + + +@pytest.fixture +def runner(): + """Click runner that keeps stderr separate, so stdout purity is testable.""" + return CliRunner() + + +@pytest.fixture +def cli(): + return build_cli() + + +@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/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..ad98a0f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,316 @@ +"""End-to-end CLI behaviour: the promises an agent actually depends on.""" + +from __future__ import annotations + +import json +import os +import stat + +import httpx +import pytest +import respx + +from unstract_cli.app import dump_commands +from unstract_cli.endpoints import ALL_ENDPOINTS + +from .conftest import FAKE_KEY, WHISPER_BASE + + +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" + + @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 + + +class TestExitCodes: + """SPEC §5.4 end-to-end, not just at the mapping layer.""" + + @respx.mock + @pytest.mark.parametrize( + "status,expected", + [(401, 3), (403, 3), (404, 4), (400, 5), (422, 5), (429, 6), (500, 8)], + ) + 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_go_to_stderr_only(self, runner, cli, whisper_env): + 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"]) + assert result.stdout.strip() == "", "stdout must stay empty on failure" + 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"} + + +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 + + +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 TestDumpCommands: + """SPEC §5.3 - the machine-readable index agents discover the CLI through.""" + + def test_valid_json_covering_every_endpoint(self, runner, cli): + result = runner.invoke(cli, ["--dump-commands"]) + 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_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 dump_commands()["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 = dump_commands() + 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 = dump_commands() + assert data["exit_codes"]["9"].startswith("result already consumed") + assert "never_interactive" in data["conventions"] + + def test_doc_conflict_recorded(self): + """The whisper-detail divergence must be visible, so it is not 'fixed'.""" + detail = next( + c for c in dump_commands()["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", "deployment", "platform", "hitl", "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, ["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 = dump_commands() + 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 + + 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 diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 0000000..1361633 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,263 @@ +"""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 ConfigFile, ResolvedConfig +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 Product +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(Product.LLMWHISPERER, cfg) == {"unstract-key": FAKE_KEY} + + def test_platform_uses_bearer(self, monkeypatch): + cfg = _config(monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY) + assert auth_headers(Product.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(Product.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="https://hub.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 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("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("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="https://hub.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="https://hub.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("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 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_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..681b97f --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,229 @@ +"""Task 0 acceptance: the schema expresses parameter patterns P1-P12. + +IMPLEMENTATION_PLAN.md §2 calls this 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 ( + AtLeastOneOf, + BodyKind, + Endpoint, + MutuallyExclusive, + Param, + ParamLocation, + ParamType, + Product, + 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("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("platform.share").param("resource") + assert param.choice_map()["api-deployment"] == "api/deployment" + + +class TestP4Repeatable: + def test_multiple_flag(self): + assert get_endpoint("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("deployment.run").param("org_id") + assert param.location is ParamLocation.PATH + assert param.default_from == "deployment.org_id" + + def test_api_name_has_no_default(self): + # One profile serves many deployments, so this cannot be defaulted. + param = get_endpoint("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}/", + product=Product.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 ("platform.prompt-studio", "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", + product=Product.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("platform.group.patch").param("id").type is ParamType.INT + assert get_endpoint("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", + [ + ("platform.prompt-studio.profile.create", + "/api/v1/unstract/{org_id}/prompt-studio/profilemanager/{tool_id}"), + ("platform.prompt-studio.profile.get", + "/api/v1/unstract/{org_id}/prompt-studio/profile-manager/{profile_id}/"), + ("platform.group.member.remove", + "/api/v1/unstract/{org_id}/groups/{id}/members/{user_id}"), + ("platform.pipeline.postman-collection", + "/api/v1/unstract/{org_id}/pipeline/api/postman_collection/{id}/"), + ("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.product is not Product.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("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_poll.py b/tests/test_poll.py new file mode 100644 index 0000000..833eaed --- /dev/null +++ b/tests/test_poll.py @@ -0,0 +1,231 @@ +"""`--wait` polling, including the 422 defect (R4). + +The single most important test in this suite is +`TestDeployment422Defect::test_422_and_200_resolve_identically`. + +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. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +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 + + +def _config(monkeypatch, **env) -> ResolvedConfig: + for key, value in env.items(): + monkeypatch.setenv(key, value) + return ResolvedConfig(file=ConfigFile(exists=False)) + + +class TestFieldExtraction: + def test_reads_bare_field(self): + assert extract_status({"status": "processed"}) == "processed" + + 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" + + +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"}), + ] + ) + respx.get(f"{WHISPER_BASE}/whisper-retrieve").mock( + return_value=httpx.Response(200, json={"result_text": "EXTRACTED"}) + ) + 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" + + @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): + url = f"{PLATFORM_BASE}/deployment/api/org_test/my-api/" + respx.get(url).mock( + side_effect=[ + httpx.Response( + in_progress_status_code, + json={"message": {"execution_status": "EXECUTING"}}, + ), + httpx.Response( + 200, + json={"message": {"execution_status": "COMPLETED", + "result": [{"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("deployment.run"), + initial={"message": {"execution_id": "e-1", "execution_status": "PENDING"}}, + config=cfg, + values={"api_name": "my-api"}, + sleep=lambda _: None, + ) + + @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, "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, "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" + ) + with pytest.raises(CLIError) as exc: + wait_for_completion( + endpoint=get_endpoint("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={"message": {"execution_status": "ERROR", "error": "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("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 + + +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 result["tables"][0]["rows"] == 3 From e707d64eb4848ea7dfeb929c9efa2d15fa7a322a Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Tue, 21 Jul 2026 23:58:04 +0530 Subject: [PATCH 02/21] feat: add Claude Skill, docs-diff tooling, CI and completions Completes Phase 6: update-unstract-cli Skill with docs cross-referencing, zero-drift regression tests, GitHub Actions CI, shell completions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/update-unstract-cli/SKILL.md | 157 ++++++++ .github/workflows/ci.yml | 34 ++ .../__pycache__/__main__.cpython-312.pyc | Bin 681 -> 769 bytes .../__pycache__/app.cpython-312.pyc | Bin 10838 -> 11877 bytes src/unstract_cli/app.py | 21 + .../__pycache__/whisper.cpython-312.pyc | Bin 10405 -> 10542 bytes src/unstract_cli/endpoints/whisper.py | 7 + src/unstract_cli/skill/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 174 bytes .../skill/__pycache__/docdiff.cpython-312.pyc | Bin 0 -> 15096 bytes src/unstract_cli/skill/docdiff.py | 358 ++++++++++++++++++ ...skill_docdiff.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 20433 bytes tests/test_skill_docdiff.py | 179 +++++++++ 13 files changed, 756 insertions(+) create mode 100644 .claude/skills/update-unstract-cli/SKILL.md create mode 100644 .github/workflows/ci.yml create mode 100644 src/unstract_cli/skill/__init__.py create mode 100644 src/unstract_cli/skill/__pycache__/__init__.cpython-312.pyc create mode 100644 src/unstract_cli/skill/__pycache__/docdiff.cpython-312.pyc create mode 100644 src/unstract_cli/skill/docdiff.py create mode 100644 tests/__pycache__/test_skill_docdiff.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/test_skill_docdiff.py diff --git a/.claude/skills/update-unstract-cli/SKILL.md b/.claude/skills/update-unstract-cli/SKILL.md new file mode 100644 index 0000000..ecf2568 --- /dev/null +++ b/.claude/skills/update-unstract-cli/SKILL.md @@ -0,0 +1,157 @@ +--- +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 `--dump-commands` all derive from those records, so a +correct edit to one record propagates everywhere. You never touch command wiring. + +## 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. + +Each record's `doc_source` field names the file it was authored from — that is +your anchor for diffing. + +## 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 --dump-commands` 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. + +### 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`, `choices={friendly: wire}`, `multiple`, `default_from`, + `freeform_prefix`, `applies_when`, `replace_semantics`, `derive_patch`, + `with_params`. +- **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 --dump-commands | 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. + + The live example: `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. + +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: `--dump-commands` 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 new file mode 100644 index 0000000..8ea56dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + 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. + - name: Verify command index + run: unstract --dump-commands | python -c "import json,sys; json.load(sys.stdin)" diff --git a/src/unstract_cli/__pycache__/__main__.cpython-312.pyc b/src/unstract_cli/__pycache__/__main__.cpython-312.pyc index 58e66511b31a51acce482296fdcd9129d2add8ba..6dbb8a9fd43b6ce49f736941aa273d023193d22c 100644 GIT binary patch delta 231 zcmZ3<+Q`OxnwOW00SI32h|iidk#`@X&crj?^^6RajGC-f(}GI#6iPBu6`XxM^|-hc zfWSE?GdWu!wIZ{m7%Wpzl%JehT&w`(r{<(7lw~F=1XmW9q~^K;mFOrWCl(b|X6B`X z)q->>B#R_zBY{EeQ?-V0c+vZ&tR=9&D9v4fS9k@thd Id7WiY5T)dGR#s<}6-ab!JEOODw=1)ImWOp7 z1V#^$Y)}!ENMK-d$Nj^YER|e!rj3cfRxe&c5yW zc-r@}qQV2%TJten zxajYN8*t;}z_d{CI;`D)Gx!&7aw2WZBmDpeC*d?KL>|kfOn4jKd|8?<3Z`p|pcmm* z+{}vG#Dt+q*y|q_L)pQFf$V`xA68(uFQ>EIV6_z38 zJ;=Kpf-L5xvx=F>iy3C5KFv%pLJcFR#nPMtT+E69z%MZdZUXCfY(N0^3jeC?2*B{% zP5`JE3nSnTz~Wu8bU6ryU07QBCA*a!EJZY>S;$I|V+N(i4w#A31tB+7sQA zsfwFMnyTb?C9*%$>L`LlA3{v?uY}KjnJ6X;bby^kbIh=pai47_pFW~8ci$@*_VhpT z$Tnt1!PoV`$l)XI7H9$6&zeFzILu2iLnn;RFO(PAunG+R;}zK% zugafXct17LgL3_h6s(4|t(;!2(EfCa=vLkxNf??+3T>FO*U9ys7mUFhWFE0Q<)0_# zLqMn;tr@G$)m|By_3xX5(RYwL>T-40%Wfrak^9Np$=A&tZ|Wj*aNmMZ3Ef{rAVeXz Vop4^_js`cZT&M!x_FS2B&tKF@O^N^j delta 261 zcmaDFb1j7LG%qg~0}!m)7N6y=G?7n&=@{cgjaosq6z&$5DB%?K6doWglAHCc-CfK<`K$q%&RgcgCAi$TIn zMG*Gn0Bs!w5c3v`Uw&RHRJ@8iIVUqYThF~HzqCM8e)A@65k|&^lOO9Sb8P~uWd!14 Zj>!VL{{=tmurl(05@6uAZ)68s2mmQ_LB0S0 diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 5f7dff3..bc387f0 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -229,11 +229,32 @@ def cli(ctx: click.Context, dump: bool, profile: str | None) -> None: ctx.exit(0) +@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. + + Click generates these from the same command tree, so completions cover every + generated command without a separate registry. + + \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 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 diff --git a/src/unstract_cli/endpoints/__pycache__/whisper.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/whisper.cpython-312.pyc index 8f7dd3afceb990fd7028a2ae71fe99dee5010b79..a5d852bacf1aaba0b6353282b756fb7793593500 100644 GIT binary patch delta 1981 zcmZ{kTW}Lq7{_-v-Ly@brXi#^dfCL%5Vhk_5eRKnPz%^gY#T%?HZ=J*X-a6yCJ|cH z6hsh3ksj|@MHFv)L?n2@2SEyw2M#u3L@r@VHIceJ6opyKTZ1P=x-~ap0 zp1m+|anO6sX0veQI({`Y{?mGn`;-1DUJZpG{Ef*o-dt8LA8#spmY!h4m8z-mU*P0Y zutQah1IKwDR$-@FqVcQ-7rCor0@N5hwNMB3um(5i;v1m}H><(|L8NdA?d5BnZt&n+ zbfGP%Nuc(%274>Dukpesk*a982s3yM?x*&VE!FUhct7)9}y%n|@Ece0v2Fn90O6fK9kA1& z`awEEUk~y0E_tHW!>gcAc0zwAX@=~(2myEq24HZuw8lLoSDfV_I48g^{IFWK*sch> zNzfzkChM3koPq`US;ClsBlxJUfoB-w z7-^vISvbC2I~*|@=X6K)UE&na z!F%{T`p&$z@qL2*0AElmG(-H5VFwu2|4|VNA8VQ|Y(Gu@1b?cVp30IjOD_Mcm`oL2 zTu}7+@`^5L6n%j&>$Ww=pjQ~Q>&qf^1w~&iDT-Q$YvF5huD+q?YU--*+g!TLE%aD3 zN+J}W2u&ta>1qFvJONUAOqNo~j1rNhU4y++e1cgN`9MZarKNBZraGkf{>emK1}Qux zwY3q1Pf%>=l%i`~6}twE(7{pIfD$B^3-vrLoX^lO)DL9?E*+A2=8%$K?5ZPlkoPL9mmcKlab oBATyTayLDPdFOVan{O3n?7uoZH@I$|7p@0$CtB)DN)8di|Kh~ZegFUf delta 1746 zcmah}TW}Lq7|z*TdP&+QjUgA>(xkK@2qVZvYlD?r<LP5#|g%=1EF{fhDQj#f=11>1W z+nti&1w+OIv%m{J=Fh?hU^XjJB%MMjeIR+=WiW@8XCZ^MjqrHL5U=3z@LZ^5Ray8j z%!3GGszhFQNOQqQ`X8~oRvQ{xgU-Aj8?1#ogSR(%Tak5`SwEdgdRmHM zL&6Okqo|0BV}*KXfJTVXa*H>(Dd8A1LDLxtn%QP0SF0^haYSu_tsG_E2HWXvbLqyn z6rv%L1Tp#00z04;-i9{3+rh@(nNkp{9jLk!IvFU1I^7+jdV=d#Cm|(%mo~7$F4i@* zNJ%32-N=8J^(f+Rob>*lVDIMa(q7mz6I;aI=j*pxHq2cg*4fMYGj+_O&IiKh0Nbb7 zbv_40rTxO^ftl(|x;%*NL+~LxoaJ*!u#a$d!!Ud_6JNwWMn^|=olC;eC#dgn8|L|yhJ#I3Hij}qcjA3C<-gDxtJ<9M|2zRibNIY?WI z?qIgWnX+1zVTArZ%TLEWFS%;)Bb_IA474Y^57Sk_Q;>kOp0gYHz=3k~rZ*yG zvK~XWNkG(Ae~e@>{tP+ofZopb&feY0p03Wla#Vd3xJw)nNiN_tCl2bx5|xl@8(wJ0^m@RfE8NHtnm2lU&X7IBkUJ;~`0SbT*jY{aLu1}4 z@_LcF;4`;*ePcXrt*ei%jHfNDVzGL8F^z|!Bt=I;sr*4K$&a8uJC!PS5r;Zlae%l6 bNo|!hRP?|VdTOdA(!X`8voc_@3?T41%@m_- diff --git a/src/unstract_cli/endpoints/whisper.py b/src/unstract_cli/endpoints/whisper.py index f2a39c4..3d57147 100644 --- a/src/unstract_cli/endpoints/whisper.py +++ b/src/unstract_cli/endpoints/whisper.py @@ -36,6 +36,13 @@ 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", 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/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/skill/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a821e8dff0da9b3d448af4d82e45b06072a9de8c GIT binary patch literal 174 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/skill/docdiff.py b/src/unstract_cli/skill/docdiff.py new file mode 100644 index 0000000..8576c9a --- /dev/null +++ b/src/unstract_cli/skill/docdiff.py @@ -0,0 +1,358 @@ +"""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 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 = [ + DocParam( + name=name, + required="required: true" in props or "required={true}" in props, + type=(t.group(1) if (t := re.search(r'type:\s*"([^"]+)"', props)) else ""), + ) + # Prop arrays list one object per parameter. + for name, props in ( + (m.group(1), m.group(0)) + for m in re.finditer(r'\{\s*name:\s*"([^"]+)"[^}]*\}', block) + ) + # responseBody entries describe output, not input. + if "." not in name + ] + 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 + + cli_names = {p.name for p in cli.params} + for param in doc.params: + if param.name in cli_names: + 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/tests/__pycache__/test_skill_docdiff.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_skill_docdiff.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8345b0d4ef3dd04d2755ba11a21232b9b6d56e8f GIT binary patch literal 20433 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 1 + assert "missing_in_cli" in payload["summary"] From 497d5c6a9311e440403e70310deac9f92d725145 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Wed, 22 Jul 2026 00:02:30 +0530 Subject: [PATCH 03/21] fix: scope MDX parser to request-side props; correct workflow execute files param parse_mdx_doc scanned all props, so responseBody fields were read as request parameters (358 false positives across platform v1). Now reads only pathParams/queryParams/requestBody. This surfaced a real bug: workflow.execute used 'file' where the API expects 'files'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/__pycache__/http.cpython-312.pyc | Bin 20909 -> 20911 bytes src/unstract_cli/core/http.py | 4 + .../__pycache__/platform.cpython-312.pyc | Bin 49177 -> 49232 bytes src/unstract_cli/endpoints/platform.py | 5 +- .../skill/__pycache__/docdiff.cpython-312.pyc | Bin 15096 -> 16218 bytes src/unstract_cli/skill/docdiff.py | 74 ++++++++++++++---- ...skill_docdiff.cpython-312-pytest-9.1.1.pyc | Bin 20433 -> 25063 bytes tests/test_skill_docdiff.py | 36 +++++++++ 8 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/unstract_cli/core/__pycache__/http.cpython-312.pyc b/src/unstract_cli/core/__pycache__/http.cpython-312.pyc index b42282912deb76d6dd79387f991f59c2ded72065..dd9a0b7d2404cac98963ab4b7e86ba2f7bea2558 100644 GIT binary patch delta 168 zcmZ3xm~s7LM!wU$yj%=GAiq04OT}>`-!dz)&5R5T4GbUT7=$HfxJ=M%@czKXz$dc# zgw;?xOX|L zgFPeL4KC3S%s~1B8w0=G2X+vPgMm-@11E^Z#ULd5fg8l)Vc=ExzzbptFtG7`76h_B LyD&|z3)BYyfGRUL delta 166 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 cVmT3#zM*sW7IlRjJ@K list[DocEndpoint]: ] +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") @@ -196,20 +227,35 @@ def parse_mdx_doc(path: Path) -> list[DocEndpoint]: if not method or not path_match: continue - params = [ - DocParam( - name=name, - required="required: true" in props or "required={true}" in props, - type=(t.group(1) if (t := re.search(r'type:\s*"([^"]+)"', props)) else ""), - ) - # Prop arrays list one object per parameter. - for name, props in ( - (m.group(1), m.group(0)) - for m in re.finditer(r'\{\s*name:\s*"([^"]+)"[^}]*\}', block) - ) - # responseBody entries describe output, not input. - if "." not in name - ] + 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(), diff --git a/tests/__pycache__/test_skill_docdiff.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_skill_docdiff.cpython-312-pytest-9.1.1.pyc index 8345b0d4ef3dd04d2755ba11a21232b9b6d56e8f..10f32e4babc2e299d1c2b2e61c38322ab4e6487e 100644 GIT binary patch delta 3575 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 S#9RUj!N&q(ZaLl!Rr~?iv;JNH diff --git a/tests/test_skill_docdiff.py b/tests/test_skill_docdiff.py index d3948e6..86f678e 100644 --- a/tests/test_skill_docdiff.py +++ b/tests/test_skill_docdiff.py @@ -28,9 +28,16 @@ _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: @@ -109,6 +116,35 @@ def test_deployment_has_no_drift(self): ] 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.""" From 1511ba3fb07cff87514c746fb85a2ec86f4ab0e1 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Wed, 22 Jul 2026 00:07:26 +0530 Subject: [PATCH 04/21] fix: don't suggest a nonexistent --api-key flag in config errors Credentials intentionally have no CLI flag (shell history/process listings), and the block name in the hint now matches what config init writes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../config/__pycache__/loader.cpython-312.pyc | Bin 13588 -> 13704 bytes src/unstract_cli/config/loader.py | 9 +++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc b/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc index 71c37f00c9b0983e521379282af298b4e99b4f70..fb35a11f209e601afe01d4f504b5039f24050d23 100644 GIT binary patch delta 498 zcmbP|)sfA2nwOW00SG+z#Alt;+{h=S!FXn}qQ=Gg5+0B+2xKurXk@a6v4$~?aWxY} zEQLuD$f#wjVaeh~Rt1%1u3?(Zkit^K1|(VMvaW`j$H-8_l){#6&cINlf}t}QtT%yF zwN$x_+JNk0b|9fJ`Kp#7UkSZ{Ff z_VaY|OsH7EJfrl2irtRV9q9+cFNpeH;qd#w!N9{mLGu!~^bH=texFXC8CDCDmuD}` z-k`jp__CVy1y!31lD1cP>^85`&R}E|+AOTA%FOtAv!%gi7L5Q#My?MGAo4Ra0~7ZL YCNMLNNsf{2lM^F1H&Y{fkrvRE0Qd)MqW}N^ delta 353 zcmeCko|46PnwOW00SFRz#Aof&*vKcO!PqxhQRCv|1WobDwwfG*jI$Y1Sm&~?W(28X zU|?hb$<(veFlF&VM3Bi8_7slQOc0UT40D+fx=Q$A>QXpsKxT5)Fab&K6rLaP8rB42)8OX>w*;`9%a+Q`dBj@COT5^*gYKbtiP5!2(%390@ zG-|S%wjtw%$r0Ka);BnK`*}KfCX~(yp5b~y(Rzpb4$A}X7esxoaQMRHsun0O*IuZ- zB6)-HWhL_qiWV0{Ew6A`ZI;u?U}WUo+^(z2%=m8eT7%6j8s3bITpt)f Any: hints: list[str] = [] if env_vars := ENV_VARS.get((product, key)): hints.append(f"set ${env_vars[0]}") - hints.append(f"add `{key}` to the [profiles..{product}] block") - hints.append(f"or pass --{key.replace('_', '-')}") + block = BLOCK_ALIASES.get(product, (product,))[0] + 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)}." ) From f4d7a3b0501f794038287fcbe218968e8e3c07fe Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Wed, 22 Jul 2026 01:02:04 +0530 Subject: [PATCH 05/21] feat: filter --dump-commands by group/command and detail level Unfiltered full output was ~63k tokens, too large for an agent to read speculatively. Selection (--group/--command) and verbosity (--detail) are independent axes; default is now names+summaries (~4.5k tokens). Also: compact JSON when piped, global boilerplate omitted from filtered views, exit 2 with clean stdout when no command matches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/update-unstract-cli/SKILL.md | 8 + .github/workflows/ci.yml | 8 +- README.md | 29 +++- .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 352 bytes .../__pycache__/__main__.cpython-313.pyc | Bin 0 -> 751 bytes .../__pycache__/app.cpython-312.pyc | Bin 11877 -> 15835 bytes .../__pycache__/app.cpython-313.pyc | Bin 0 -> 12020 bytes src/unstract_cli/app.py | 145 ++++++++++++++++-- .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 177 bytes .../__pycache__/config_cmd.cpython-313.pyc | Bin 0 -> 12946 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 175 bytes .../config/__pycache__/loader.cpython-313.pyc | Bin 0 -> 14070 bytes .../core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 173 bytes .../core/__pycache__/errors.cpython-313.pyc | Bin 0 -> 9774 bytes .../core/__pycache__/generate.cpython-313.pyc | Bin 0 -> 17913 bytes .../core/__pycache__/http.cpython-313.pyc | Bin 0 -> 21699 bytes .../core/__pycache__/model.cpython-313.pyc | Bin 0 -> 20087 bytes .../core/__pycache__/output.cpython-313.pyc | Bin 0 -> 9689 bytes .../core/__pycache__/poll.cpython-313.pyc | Bin 0 -> 7249 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 2354 bytes .../__pycache__/apihub.cpython-313.pyc | Bin 0 -> 8775 bytes .../__pycache__/deployment.cpython-313.pyc | Bin 0 -> 6437 bytes .../__pycache__/hitl.cpython-313.pyc | Bin 0 -> 3544 bytes .../__pycache__/platform.cpython-313.pyc | Bin 0 -> 47390 bytes .../__pycache__/whisper.cpython-313.pyc | Bin 0 -> 10230 bytes .../test_cli.cpython-312-pytest-9.1.1.pyc | Bin 61295 -> 76035 bytes tests/test_cli.py | 72 ++++++++- 27 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 src/unstract_cli/__pycache__/__init__.cpython-313.pyc create mode 100644 src/unstract_cli/__pycache__/__main__.cpython-313.pyc create mode 100644 src/unstract_cli/__pycache__/app.cpython-313.pyc create mode 100644 src/unstract_cli/commands/__pycache__/__init__.cpython-313.pyc create mode 100644 src/unstract_cli/commands/__pycache__/config_cmd.cpython-313.pyc create mode 100644 src/unstract_cli/config/__pycache__/__init__.cpython-313.pyc create mode 100644 src/unstract_cli/config/__pycache__/loader.cpython-313.pyc create mode 100644 src/unstract_cli/core/__pycache__/__init__.cpython-313.pyc create mode 100644 src/unstract_cli/core/__pycache__/errors.cpython-313.pyc create mode 100644 src/unstract_cli/core/__pycache__/generate.cpython-313.pyc create mode 100644 src/unstract_cli/core/__pycache__/http.cpython-313.pyc create mode 100644 src/unstract_cli/core/__pycache__/model.cpython-313.pyc create mode 100644 src/unstract_cli/core/__pycache__/output.cpython-313.pyc create mode 100644 src/unstract_cli/core/__pycache__/poll.cpython-313.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/__init__.cpython-313.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/apihub.cpython-313.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/deployment.cpython-313.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/hitl.cpython-313.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/platform.cpython-313.pyc create mode 100644 src/unstract_cli/endpoints/__pycache__/whisper.cpython-313.pyc diff --git a/.claude/skills/update-unstract-cli/SKILL.md b/.claude/skills/update-unstract-cli/SKILL.md index ecf2568..dc68f12 100644 --- a/.claude/skills/update-unstract-cli/SKILL.md +++ b/.claude/skills/update-unstract-cli/SKILL.md @@ -55,6 +55,14 @@ Read the relevant `endpoints/*.py`. `unstract --dump-commands` 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 --dump-commands --group whisper --detail full +unstract --dump-commands --command 'whisper extract' --detail full +``` + ### 4. Diff on three axes 1. **In docs, missing from CLI** → new capability to add. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ea56dd..e716d19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,10 @@ jobs: 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. + # 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: unstract --dump-commands | python -c "import json,sys; json.load(sys.stdin)" + run: | + unstract --dump-commands | python -c "import json,sys; json.load(sys.stdin)" + unstract --dump-commands --detail full | python -c "import json,sys; json.load(sys.stdin)" + unstract --dump-commands --group whisper --detail full | python -c "import json,sys; json.load(sys.stdin)" diff --git a/README.md b/README.md index 0d61948..793b743 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,34 @@ themselves. ## For agents -Discover the entire surface without documentation: +Discover the entire surface without documentation. Start cheap, then drill in — +selection (`--group` / `--command`) and verbosity (`--detail`) are independent, +so any combination works: ```bash -unstract --dump-commands # full command tree as JSON +unstract --dump-commands # all 143, names + summaries +unstract --dump-commands --group whisper # one product +unstract --dump-commands --command 'whisper webhook' # one subtree +unstract --dump-commands --command 'whisper extract' --detail full +unstract --dump-commands --detail full # everything ``` -Each entry carries the underlying HTTP method and path, every flag with type, -default, enum and required-ness, plus whether the command supports `--wait`, and -whether its result is one-shot. +At `--detail full` each entry carries the underlying HTTP method and path, every +flag with type, default, enum and required-ness, plus whether the command +supports `--wait` and whether its result is one-shot. + +**Token cost** matters when an agent reads this into context, so the default is +the cheap index: + +| Invocation | Tokens | +| --- | --- | +| `--dump-commands` (default) | ~4,500 | +| `--group whisper` | ~470 | +| `--command 'whisper extract' --detail full` | ~1,900 | +| `--group whisper --detail full` | ~4,200 | +| `--detail full` (everything) | ~50,000 | + +Output is compact JSON when piped and pretty-printed on a terminal. ### Output diff --git a/src/unstract_cli/__pycache__/__init__.cpython-313.pyc b/src/unstract_cli/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c161d42ef2561436d5ad3f4caf61b978ff7c9bd2 GIT binary patch literal 352 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 delta 2095 zcmZ8ieQZ-z6uE2E4J5IVk;PYv!5Ni^&kBQ6JkRA%SeoQl1Kt3_=j<08Y9ufdtdw5Vv~2yx%Zs& zyXT&d+n-+eu}w-+2qharo3XTgU^mlp5V?^DHD?**MJ;p8 zf^VL!0~#iS=0#Sx>_)A~j{*yA^V}-SO*kF!wgpi;!3&`^3mx$iX;^OL1Ax6?^d$hMnJQ2$cxkGS9sZP%2kG7Y!39Xd==tU zU_IpV>w$F!4GD;^@=A!tLNzBre^VtuJDjXF(UP!Dp3FjsuU*3$wqLRs*ydJeA*P5#xvlqV@+*y zw+A{GW56A}^h*Ld#Lragt9=oszdU)aQ`)y*dSrdrkb@7pX(dL@9_g@Gl zEJiz_f@9Gm@D!ej4!|n@A{v2L@ek3@eP@aF14kyG%Z$hOl!|5fL|L7|m!zEoRie=y zsZ=JP*0hYA&y$-#^%WWxeIs@ok*gIV8suZ_h;_gU?vI^^(e#iOsb@Juw<(dnrn)19oB>NafoQ)a zI?h>Nv%b0MLgIX4uJCT@)AUX6)|=wC+TEc9125z7mVDpC;79>4Q2?5V2J}TDs+8OV zwa>P^40q9EbXyuBvmaGj9m9mUj}dv5yWVuHlFJj*d``VWRQ~{ceCsprBK4qaBWP8_ zKW{yJ`a`<@h!D~SE3#O>_^VV&Z?AL5N~OG!gOgURtVXJlky24MJamqV5@%dOXNk5B zM2p`5J!z}=@&P)LQnxxwA+4#@a}^v&*u8JlrBI%l$Q83HeP2}tZ%@p^BEFI6aMQU{ z$0;$|dJMaVLj(2+(*GnE_1Q2gLI1{dF|B4R1=0h$uxG54%g9EG)GPStaCi7CVnLIv zJxS;y3n5%$oRIr70$}$YV8ibX4_cS^!~GBne>}zDp6wa<1irVur|&-Fg)Pf0U<@(> Xmt6wG(Dk9ZxY$MXi~abO9f$t|k{8@q diff --git a/src/unstract_cli/__pycache__/app.cpython-313.pyc b/src/unstract_cli/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26163e10a50aac683d70df873d1e59b772844dd3 GIT binary patch literal 12020 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index bc387f0..570f4cd 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -17,6 +17,7 @@ from __future__ import annotations import json +import sys from typing import Any import click @@ -98,8 +99,42 @@ def _endpoint_info(endpoint: Endpoint, command: click.Command) -> dict[str, Any] return entry -def dump_commands() -> dict[str, Any]: - """Build the machine-readable index of every command.""" +#: 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 all 143 commands is ~63k tokens +#: -- far too much for an agent to read speculatively -- so the default is the +#: cheap index and the agent drills in from there. +DETAIL_LEVELS = ("summary", "full") + + +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 dump_commands( + *, + 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]] = [] @@ -131,10 +166,47 @@ def dump_commands() -> dict[str, Any]: } ) - return { + commands = [c for c in commands if _matches(c, group, command)] + 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": "Unified CLI for the Unstract suite of products.", + "detail": detail, + "count": len(commands), + } + if group: + envelope["group"] = group + if command: + envelope["command_filter"] = command + + 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 --dump-commands --group --detail full", + "one_command": "unstract --dump-commands --command ' ' --detail full", + "everything": "unstract --dump-commands --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, "exit_codes": { "0": "success", "1": "generic error", @@ -203,26 +275,81 @@ def resolve_command(self, ctx: click.Context, args: list[str]): "dump", is_flag=True, default=False, - help="Emit the full command tree as JSON, for programmatic discovery.", + help="Emit the command tree as JSON, for programmatic discovery.", +) +@click.option( + "--group", + default=None, + help="Limit --dump-commands to one product group (whisper, platform, ...).", +) +@click.option( + "--command", + "command_filter", + default=None, + help="Limit --dump-commands to one command or command prefix, e.g. 'whisper extract'.", +) +@click.option( + "--detail", + type=click.Choice(DETAIL_LEVELS), + default="summary", + show_default=True, + help="How much per command: 'summary' is names and one-liners; 'full' adds flags and API paths.", ) @click.option("--profile", "-p", default=None, help="Config profile to use.") @click.pass_context -def cli(ctx: click.Context, dump: bool, profile: str | None) -> None: +def cli( + ctx: click.Context, + dump: bool, + group: str | None, + command_filter: str | None, + detail: str, + profile: str | None, +) -> None: """Unified, LLM-friendly CLI for the Unstract suite. Products: LLMWhisperer text extraction (`whisper`), deployed API workflows (`deployment`), platform management (`platform`), human review (`hitl`) and API Hub vertical extraction (`apihub`). - Machine-readable discovery: - - unstract --dump-commands + \b + Machine-readable discovery -- start cheap, then drill in: + unstract --dump-commands # all names + summaries + unstract --dump-commands --group whisper # one product + unstract --dump-commands --command 'whisper extract' --detail full + unstract --dump-commands --detail full # everything (large) Output defaults to JSON whenever stdout is not a terminal, so piping the CLI needs no extra flags. """ if dump: - click.echo(json.dumps(dump_commands(), indent=2)) + payload = dump_commands( + group=group, command=command_filter, detail=detail + ) + if not payload["commands"]: + click.echo( + json.dumps( + { + "error": { + "code": "usage_error", + "message": ( + "No commands matched" + + (f" --group {group}" if group else "") + + (f" --command {command_filter!r}" if command_filter else "") + + "." + ), + "exit_code": 2, + "hint": "Run `unstract --dump-commands` to list valid groups.", + } + }, + indent=2, + ), + err=True, + ) + 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()) diff --git a/src/unstract_cli/commands/__pycache__/__init__.cpython-313.pyc b/src/unstract_cli/commands/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0db9983bb0a36c3b002b2f6cbeba6ff9afccda5 GIT binary patch literal 177 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/config/__pycache__/__init__.cpython-313.pyc b/src/unstract_cli/config/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..91377eb5aadb780b15f2a42770b4416c8d6ed720 GIT binary patch literal 175 zcmey&%ge<81X0`Kvq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iKenx(7s(wad zQKo))eo=ODL1J>MepF^baYRpZlH1y?#<&ZY6L{{1@M=|L7v*cX*>;ZCZtS z@_Uw$ACN;tAQBN6!ElASY>*5*){R$;l9B$h5=(zMiGyGEis`ahGGDex7D~rmDZ6Zy zth8>rV!LdY?AhnbrE;J*UvXS^N=}~Ktmq(urHR~hR+Cd%Nlr0ZMXJ2%^mOXt<$pbg z^L4!@lpSNF8o_$ARFI+UKCJ1N!>sm{pXg;W_Aad7kyF>GC}5mqU3P zL7r!&*X4OudR?CWyawsDlA46_G=V(NN{{k1KL>pa(6>GZeH+kM9&<{~LOW|ICpNf$iX{kbO(3MYi4bcD`6BJ_Nfkq#{yT_@v%FvN@$T?mE6-dlcI z4Dx|!WFfQ|m;A9%G{ScYGo!=qp-X96_aK9pn%hsi+j6ASuJ}NExLcw|7 zFY|uh>zU-cfgkV@7nj9IteXdUmP850^GASSA?#mt^Hct15Ez6(@nvh*?f5M*5DWMD z*JNIft%pSyzZ4Avg($&*RAgw7V?F$Bab1%{TBI)}f}CiC6GNPN^X841`qNPYzHBF3piLoZulI_%kX?w%Hhcgq+W=J!|TA(kMq42 zf_duhjzqJcj>p;H{W0Dj4tIC=@IfiGDn{TPVj)R{$8N1#;eA3ZprRZT?6k8lzexPq_?g zS$U;6Q8$_vb|MlWd<%4T~e(kRp|4jxbgZxQD6N9h5wiJRj zAtjoEp+Ia7MufE#2j$VKZnyj8m(}u%lheajd_$8HL&B(#cyTEfTagF)`oiJm?Bk%= z{*{nBF82oDvq=81TbJGqiqi*ueXA!D?fQdXK$mdjg#bxjRx3wF$A-Lu)=9lYr}32OZTVl_)^`5?jBe05M;I^j1*7LA{Ib!f>*q|m zOdcA)=y%$0M=o*@jJ-BOA_lKPV6i4}SauSZDRFi(8qlnGbOEqkEVK_jUa8k@K@KkqJBb6T6~ygqWtr`3Jq(^p3mW`G!U6;z9kISHHA;*Ld^!=PY> zsH~c^wnjBBiZKaaO0@!7g8cxCD5)$$vCF7hqG11|P*7BjcY=#5i(n$7I`BMCHA&6# z_MuxK4M5K&_$NzHY>5g8er4|8byjbUf9JKdvn%QB zN;$g~OZTqTey{s(_dWMrx6;_1vL4+S{_UZz@AhvQzjG#SYfswRAKMQ6DERlGzYYD} z?e`~=?$M2rjLo^#owT)Ve=BM0QP>`;zWb~e>LS(Le}oEXZqA~4$y`o%b+0U^8Pf!8 z+n4Kcj`j@ba9$@YFig?qf$v^?d5K^Uj4bh#7x$2y2I)F`KBuukkQicDV<$)ln#3ml zk4HleP!;%`0{~pUsiCW*^Yh>s1i`BaP`^pu=YhzY?*(p&ECwVo2Cjm2D8hrThv#b10jYl6kQ zkI{Tjxy-6H1Oo&1fq$nOK_=A-H&RSSM@%{aMAAttFdR`ilzBzMPa~sFWR=~pJ<3zIK#G@C|9RtaZfn8_iy~N$bz1Qx( zwk_@q?Ofary%l{knriDyIgh2Cryq?dmeUzq#m1zjpujVCq401qPnMwEZ(AVkk+m)Y zd($LvG#oc~kee34bcT`21T(Z+uoWzjRVafyE4{NxHo*>e<@C-W*##%uRb=nVA;73q zt(s*T1E2Kco4_J*aKH6Q22(2W-{estRA<)9_GKOL_rN32 zo8WuLqSE?jop}$YR1N&im%*X3WI4#6+0YZ!2VfCX#sGAp(XeU~?}Q*Mp@uBsEHLHh zlc|<9zZ3!OkzEc6-&4Z%DM4O|NZnW*#bOK!6)b0gM+}w`Awp$QI_WY}O=2;H1;7B2 zFdst))odfRp2OIN1^QtJz4f&ed-&I<27cL}JT@eI&xsQK~;biY}$6=JS0Klh-td{yV)qb$TuTThNPsTG7=DZ`BJcU7rCzd{-~$ zX)YpzTfyvQ9FPet;D@ej(bYk`CVftTy6hyU-|^&{(bHZ;4!45!7}4h;VxBvoQ+UmD z9eQ2w*QE@Cy3JMUy@HKOW3;d#{oW|&nU^%kMw?5MdJJ<1bW5Jlbavnb@M}mZ4BU}(y~9?L!E9& zQGhcOmE2=4Lw=d72IBg zFjf-g5+s@LqLF!`!F3jy$lyCk{Cr>uTKN}_9h-Mklu0<|b<9I!%_l1P<+x1a0zV%^ zwr&bnvo4PQmVzc%HTAbNtQv;NSJ~)_7*WmW=7mGIR2D-F)jTcGh(vYHPG6lI5d)Ao zpbgajS6Pe}RI?nzP(xJBf^Wie?fNCXGOe2YE5J3VT5w8{hFVYwQBV3Rj>=Imc3J3@ zswS|x;8LoFg(cM}`PWpVcqc%iRAuSY2(mIF2)K~+%KDKgtWTSENrdhtq?-pt+#ny8 zSEkE5zCZV8FK>)xY?b%U-#vft!rcqI&c^N4hhoy%uUPu`SeBb%GUe6x=I+jI*E}w7 z+v(V?YJAZ1y`F6;Rn@UM{Hc*R+V`xaxpiZF&tT!oGtSz3-?;mY?|dum>`Xd4H(Br} zzu*7h-2HR!biUj3c2Ba}owoI5>^1kM?@ni`+O}7>m-YyAq;a>g^{v*wXx)jW8hez6 zUZr<*&&X6>VxAg}<&HfEsi?lUc6V(nv2!BjJiIZ!YpdN_dWTKh4kc}eb{&n|Qx9Kw z*rl8pR+>gqj#0%j`jM?R)6o2u`AxIJA5S%$$W%7Mn`E5TyOniYcmAyX6Pw9y+9M{; z^r;J8;#cKufBeW^{rzj{+MZ->k7Db6LKt{^;H|WrdbIH9`um;_jPJ*lrqPsR?3cre zW%`dGAyEABX&tdwBX#+&Ep}`x|4bf-nf^)TsDb>9s~qmzAMqeO5KW-hl zVE*}8BQ*W|0+zopG>x7#{-UL7)NTBQo5T7G)-j{;zZ)1R139l6n4(_{tCxAw53OLq zm;wt12^k)}Y#=GV3aB(Y2RyV(w-6Xtd9tpP-iN;>ZCBR1@#GkZ7-2-zy`rltd+!Il zEluGy3PyA+pciB+zMz-I-aI~fN}wXx3_}40rlpFmg5I31%afoDE1)Q0C6Pkl^h`FO zZw{|ZSxk_J6->I#0n5(Kmb)p>C`;fN&GK=Uoci_duag4vf2S(9=we& zu{cLGOW?93PF#~9%qWI>ExQ_Bi$JC=8iDL70RR4%%)s)hQEzUb{d zj)pb>RaC>;ttppNvjZ5nswV9bm0P|I!Bd`GiydZ>#4_!>=9!+FyaA}{8}^J2&0=NN z;JH+Wn(yF zwx-QZ@RvEznLf~;JkY;sRP3!uOIxO{@j>{0cw>0e`ug-!3xOEC{@e9ibw8|ntNG35 zccga{ZzqzC$A5D1rztQw19)WI)6 z8V_P3Fy*V+)HU3Fy8-FDCBjb_r~>K+-s{#7Ef;Wd)=JwqcYADQXr*Tqw*9I3T2jZ>~qX zYU_U8aPRN_Ti_xitZa}y3jzNH)|-um5jEhV;R zX3MNvvY^nO;>^*nH>TOsl3x#Pqi)YIV{{C1Mev;q`2ZWdp7m=M#&cd{V4GUOQv6DM zSzQu8%=fcPk$Y+$>={!kgEp7ToIN{NT_^S50^NkS0gGk;i-`d~y#%AzaLxd+9s30% zl(64G&C&QK7aNKUoqwi}DTpV*N*7-co8>I=SD6=0u(Jla_z34Umhx%xv0KD6SF7`a z2ssxQ>y}oZNz{a^kOlFYWCITxfqQZ&naeztF!b;$3#m3SVbPac!a*aZg=if1NHIRC za`RUrU~l!^R(XJ@SrM0AdL72smJYi1T6BQ<6P0~68j7eUIV!=1rMBfTi4^GC0FT4wWh4t-VnD8c8}=Kk*g z43EqI8;T7AE_T}4k#u%!vcGj!XR2#ACo)xa4_x8j3TRcE@YD_Pa`uqIXI-ZW=y zRd+AI6WTg$PdnRpM&6xxdm`QW0{lUa=dH3g%icNsu;%X@-)l^}&L&-FQyu404d>Gh zmp-W5;&$r}Wa`?sV{b0)L>|rl^u~{G{PdL{zoPVyC?hW^*KR5=y{y!{^2BJUui7Jq z%BnpRsj1tX0QjhFQL4}Xa5>%9mu%~MWPZOY)pqW2_4&==OilBHEBCML7*jQ!o1+*H z9eSrG?K})&N2b2zd-hMvq-GEpR8(!2X>gLYukiAOU;>E32?6EUmmFvEkYB7welXvV zBr4=XnGrG#$tOra5f3^uMOUd!6`BrUys*Ub2@TjDVsC*oUY3H^vvSHMoC=?x%Q@<$ zM!$~7O^o{6>C3Iai7ny4m*SIOia7S?mK&R2ZuwLQ%u$K-m-kDzpz}V4gRy{stD`fkHKBZ8>E_H?MrFkgqOso3-42 zRgqzaY{V~thy1^wfIX>$y$=_EvX(x0A$jn^){zIj-|PM0+NSx#+QtX3+`VV+!JLdk=P>q1_+Vw?Fvi{crAE z+nIWF@t^8n+`P2g(Dat|P3v2ZHyt}`sfIqq*|!-_JNvX1%8`cC;Ca`zY>%W{`jRcM zzEdry(=F$cE$7}BQY}NN+KY<)A`UQ32l&udm9({Gs+%^aGB(FncgohXTiuzi?n+j7 zJ*-bvA6M+h0i?9$4$C`t;c}qM{kKfk_k-W!>w1;GBtV^WN;Q|`<7MV@OJAqdl|Bm% zB^Ip)4Uqd~3-X2~NAE9O%3O{!Kna0rN*i3XLRYc zry;*wnm>2})CVx3Z77AOkd1?eJNAtSn`KpJI&9-Aut)v4Dn!VX{2!n+*b&r zm!fO9=9hVD zg(XCvgcY7yfzw8C5=&)#D$|>2yqeu2l*F&cVV5$f3&=}b^a(dV8wC&k4&+@8XL*;k zAmL~M0u4$ifV_;i=uW6+Nn8oTCZpzN{5xpM`W1N)(XfMq$cm-~BC8Zar0)Xl=RFGG zM}<@CiqIt6{#$%%4+@CK0T3V*-(F1B9#!l|aiyNtprAC=WSUzyx!pWWr0P#9wv(Hu ze`PzFu~*)kygRvlYNz4hfw!A?%9Td>9esu7WoH~3~ zIdo2GJpb4}2sy)yUWThqb2_)m+sdx@)+aPkX93Exx0?1Hd+SA$(Gp{sUAB58LE@ zvQpkxDX$CP9=G<~hPfR30xr0iimor@f*X09+gOrM&deboul{;psY~M%biNCz|)s?ms}6 zsNdIFaBPm1&}e{ahy5`~LoKV;AV6Vs9Z!+s=1piRev6VM`t|dMh3DhwK}9j%yC%x9 zQl}Il31bO_qw!#GT-Ix199AbfY7(lDYA>7z4P`C*IYQN%e~+TIrB=}M8KR^|XR`Dz zuzLdkWCR2_cTq)J-9K7PyDui)7t`+XB>c5ZWSUQX!WkPJPp#SB!K8aI?H)t(XfL_6tdi`h_ zp1pt|tF;(MEIo%s} z&=}61W*d@hLz-<%vTZxCiDu|EY?Lcb*Arr340{zt6bgSM!`jkpO_HrivrS31NohGx zTk4W*U7Br4vMoy61!#F=^7Y9yi>*q-nY}XZkU=4pTZ2ys2W+c|sXWa!Cb`Bm*Pi6s zcN!J;sM0%@0eK3Wo8TOr;vT2XwMniv%{3>v=It8_dqi;!7dL-RVY`&>uM{_5SJ=Z! z*E!l;pXBP(Tx*hR-CkE%mvZz{adSdpyOo}c#m#pp??GsOd| z1erUP!#G?4WrMGu~Io6YdLnJVS~1(!&p5dZ)H literal 0 HcmV?d00001 diff --git a/src/unstract_cli/core/__pycache__/__init__.cpython-313.pyc b/src/unstract_cli/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..245f6f35a9a74226505da7ff9cfd2f3e97ac4469 GIT binary patch literal 173 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 YG^|j=A10)Bsp;{4kJt8fr`Y%Z1r%zmvH$=8 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/core/__pycache__/generate.cpython-313.pyc b/src/unstract_cli/core/__pycache__/generate.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f2ba0a8d30fc534dea73b0d7db93792381b3738 GIT binary patch literal 17913 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/core/__pycache__/http.cpython-313.pyc b/src/unstract_cli/core/__pycache__/http.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6b7fbefd5bb417b8f1f1ab37ee6dbb55026fd614 GIT binary patch literal 21699 zcmch9dvIIVdFQ=&6K@jWn<6h?5;I*I$w z{=Rc@0SJcew14zSJonslzw`dicfQxbJ&VQ2A=LbE9i)!}Nh#L0PidsB12X(!AQQvD24Gd0uu%Oo{8d+L*(9~-d z&8hMh(E@n=L2Iu~w6Q$HL3?kZSlC-67WFzrN3Td3n-TP0Qr+_80a+-A;WJi~8Ce<_I79+OyeOkc*;aUOF8=do;8$zQh14^9+3 z^&V>lcUmLXdKx@7ma6kKdh9G!?^*9DWT^&^+Ee7_tGV2No(&!cL7Y~Jjh-ftlR>Xf zwONcdOFq-)29L{A%HWz%Yni9qqduk`987g7>*1+zYjfL^-#yKq%2cb(o{gR=R@+8T zi)S56wRozVDP-y=Go+_W%--hI8WWVtb_h= zX!5LotTPlG4@|i9lA-g^fo@R@iISoFd?3;p8uLp|KT;#3NRFWOkuchr2}_1kfnbC( zBy%7Nc`F&%4F)W!=?`M6JGk%vwGVBeF%^t+)&_Zx*I)qvz%VD4B zn>rL4Wxbd5hsDqsR%N#aBm1=AeCtfuFSZ88J~f|jot8h*TjBXun(|RGFinG|EJsdO z&^P6uE1IEI#_YF_P6paGxi`BvjnbScfm%V}@BMegFLK8?kp~S>d3aIX$az%Q@xWV; zW&^&ml$KJ*bamV@y+^l`7Y!agz8i9K3p@qLHL_AB(daSb&60XEu{UeFgqhW2@z?-k zPr+C{g?KBy zPhM#x8155B`o?C3NJt3#fmcem=;1xjYn#J6XbU!+?}Sei(%u2l;_N z1Vjq0lrr>eO1=0V0xGHU!#KDjsw3*Kx{V8~99&O1$IYN`)AdK|J!&n=57UgX;8lAx zl%urk(Yn=#RehB-EP)HFgX*Aa6Gk*}5G@%}g|Z$e%g_s5R(*M)l-gNM>roRsNVBRz zGCi*1oLR~PtJKG4+WcTW^oAC!pUoXERPyk>NJ(d&O05s9ku&pMNhxq%9L8>K1Ljzn zsd3I_nAr=sK}M>KwBWBxRmAqxE9-Vn0R3U)~JO9fNd6#?LQM5L4W zmxOaJwMeTa83<#EFiBd5w37K`Am|fkN7$Q7D^>wYTt|^W@LKFw5gUWHhsz|^(+3+x zjr3KGy2SPL+CWjJEKMS-K&(RwgjW;xPmYV5@FHng^BQbsUzpHTK#+RcPKBoYZKr%< zpzVo}cqTmU8}+w66_^IWA3f9d01*v~qv?zhqN<}I5k(`BY4`N3WRR!pB#w?Og5*|m zA4QAdzehCB-Eo#JXp$z|#oZTn$4rf@j+$l7b?Y^2+|hQSD`_db_|(Ox?$|5utZ%-4 z`rD`PYt_|7|9kzWE5;?wnwqPrzw!9%`nT(EjlH}1t%;ws+;$Fpuzu6W1hl5sI;|JF z*36u>=;E;p$6}Voq_ghj&K1pT=2y(|hV4KIZ|*tJW|;WM zxAa2iF2gGv&R-p<&5RfnYmx0Lk_;mwH0vWHl5u2Y3g_D-rOhKF&)|qpeX)&S`aBDa}EmT`mdK++K5V;CYM2klT1RELv_N7xA- z7#;CNB4Xg=45%sU+ktQQ@W+Y7Gm0QPazv*{IE3i;xPQ_N-Z9&rKbR~qKi~5~b^VR5 zSoO~N{R_wAnw=l4tGlr+wrm{W=FEPeEz^fIIbyA+MV-V3-xi0^Mi*TdABR} z(Bt#nQPbAA=J8~efWpV(8sVSHt5+hi@@;s1GOpS7D@}>kD0V767^NMg1e#-zVg8;O zxi5lPwDZHtH*PQ+GL^gQD98CgmWZGB@`JPkbAlJ7Sm9ka@eQc)fjU#tL3SZlC${uJ z%L9;kNNgIi^a02`WHAj1f;3PrP=7(*zr4p*ZYzj<1D7=md59{j{V>SA_RDJop;rNl zNK8(^OzD)FYUhLMG}#9YIl9H8C$8jkOB%kQWC1Iw!YI;Y-edG~G`8V1u^-Gw&QmMy z=Ut|mc{ESF^cdI}#y_@(r~RXW@xZ9eucvsCY;q0Oo_PA9Q=;Fmlmc%NVejG5Q~oJ` zSZHkpM-&*J4Fo6f#Xltk!t@ltDsKpbIS0#*2B!jHzuP!xPSeRV{#i-yn+}Xn!qNeW zAtVOo7>7Hj-|P2@;ISkf0O-4Ya&qe2sQ@~RY$`EVa|I5{X>$b=fKb*Nr#quyn7`zlPMj?I z)O<@i@LiB27)6rQ=wAACVN^vjliZ|82BH_$&|mnU5zTXJM$S}}wA6m%iG-#0XO>zJ z?B#ve`>*xic=+alc*DM^V}I1VKWXWRTGkQFx}RCrMTIRd>l4D3p9x!T9DC!5H;?_r zvACrp>8xKqw{rHiufFotn-jNY<86;e-Gk9aPt-9KH4m*?oEMM({_#7dHOnQ}tFKkx z(BCYN*LFrrx)$~)OR6rPzI1xIG}`=Vyrey9YhMVzZ);C(dpKIQDQ4fC{=U){FWD8f z?V``SlAE_h%UWZ0chXU@(7$RdiWaY5KDQi*IySx0l-RO2wqLrPsu>=Ec+Za7+>wYxOwec23lEKTven&C7pQ=rq3 zKo0C&THT!B)hH~J!1Z-;Tp#+;*CVr-eUHmeFX&G#H=L$wdd>%mAaFHY-_sm7&b6y+ zxN+X4n(OR73syz|A3g@|JOI%)034n4&4Qf|389(D^bCY@NVz8=1&@j|Cr|nznT!j* z$w_+9=5vFM>P8<$^nnu(Aady#YKtImoMa4y1CaK75T->i!W?a|X~;KHLHGGlKa&%| zVR@)5ja?*OUmPMpEfY|~G+o3F2{OjoMnjX6kn%@T(t}8J_B8&&&maN=Q(ig0{~xjJ z`ky;G7dqEe8cTU{UH#Rkt~|A(kFVPvb#7nme&4zMN4swM|IU+mu({)$PJvzw8oZIhdIcFD-S7+s#l`}i$`+t2OS?lg`yro>Sm#wMs{4`AT@eg-* zRBPUe=-`dC{9?ixGT0?e1uE{4PKZuhmHvo(BoRuD4!Hb~Uo2Kq#o3pWqi7k1` z4yJJ{)0P!y*jDwhLCMJ{I(Rf5?M}6y#I8K&R#1cdJSP$HC={?S-p9VsdR2KxsU5V- zaM`hf&q1A6H@XLSugxb=5Oz@TwS6AMV6hC59edPD?K4<|>GQz??5aHTe{?oI)6@$L zC$mpII`jzQW^VYcW>f#UWer=^@=SXQ00Yr4HxQ@iF;W~fdNqTzdvb!;I80&*3#g+; zNoSs3oihIPGleTbOI9!Wgjv$-p)z6H zrfqJcWT5emKsPg2*&hPK|fA3PfnL%}H;$yrE-q~de45%&&70kuU2f7llCp44 z=nI5Fo+p?N3Ty`$wFlNgnA0AHx>A^{YoXqxyA3rb9Tc;n8Id>|nk=`(|Bl2zEAefv zB1ZXTO3v7mr!Y2|N6~sc9s9c_4K*W~C?1jZPGDJPnK&ky(<&^ma8dz@>;cjyN(P9v zBjc0432`gxBUT9qm2#@+BcYLV#5n07twH}N=_c9JWs(dyZJ1V*oidV6K2{*UW1ht^ zs!GfHAhK@K`6KZo@)2ZWEu0EXlH!z9Q|us;Bl$QgI7$31t*{uu_jC9QKMwi=?g@uR z(ZyXCb}gPtl&p`HtY0z4ObO-HHFvI> zofrEr^sj07!Vdm!f!+p*R$pLTE8_5>{X+YESF*4?QP>zOZ2Vc_h6QcXUUJ!Z$+)!r zYWtP;xZQ;ull?;bk~gug4gZtX^@-|DvFc5U>RqwwT{pMJs~@{Cu&{qgope@Q?!DBz zbS~~}S?FekR*Nc~J^SvvTtW5xU0)2}>zb9=nC zBktN8w{?D9bGMu;YPw&=*@_k$UI-+t)iG=JQaEm{k6Id*J0RenxpwA8eSG~x(Z+|P zrX5kuj?Z#xpy%?r3W1WT3O(FGaBHCN^d%?Y)B*>T8x(rx;fc#==an;N3^Wq*9^@aF zx}PbGJ}m>=G8vHd;ccPOh(FRQQyZ5W%B27_H?!0Juwt^)6Q+aF%?G2J zgKS!`pH2SrplDECfSpToA9T)H@Q3-}?@8bWNj}ea`gv8#OhAgvaH}2J%0o|nSv}2- zW_a0rs!g<;=0?$@RKPhxO4mGm1|&_jM@@W)M>CvOKW^eP@+iusB~hh5;+d6myqgbd zH*rB7{`B}WfRiuqXmOHq`Q%myw_n{;fs@wcHRU_&)0lzt77%$(>tU3Z!yxJWa>60b zht==fhSI9M=d@{1$SHzXWmSS^v|0Dr?@F(He}a0=>9_A<-3WtN@S2C$E9qecgxi+Z zuqav7Kd*T}(RDKN989nG=qu-s=J-p@{o5#vpe*J;fcKkc`xdZ#$!S?yr**-_> za>yD-ddDl{2|B&bLEuVmQ1+aHA7h}AS@>b|I+@-uEsy3q9~2zNzAhN-&TWCsO`g?5 zfAZ(<|7;CPFNaA*!GhPRjBJ}4^pKZx1+MQKsok&cquu5>`a-@|80M(^Fjg||Wu$Xo zADw_Z;+HHIRR(LYp6T#`QfoD{l^JjtgM9Z7*-I&%JV;IZJmQ(urfiT$L`g73Tb8s9{HSc7AZZ(I7`ywtj9PrXH|&;Ge~-+oS@(V zT5~9^d7NIIr6z$C^fLEmqOu?l`%EIbRvMCs?&fwGxmIlkbrGrm=ez{@nb2&T$ zEF z$ePm!*~VKsJR;YIbeS@P=pQUkDf4w%u+*zh&pA%3FU8^$N{yMP%n5n*13YXWhR^Wa zT5kcg8lwt4_&LlN1y9M&g0y)UJsDR_#VqYAoqJ;l#=VSN1xbpKkm2JJhO7!#3LnX@ z6mfKUF;N%R1T6B57zOh#{`e+4muat{?&p1bVqk5YJMLW=sglBdtRu!GO4-PZ7u#ZIFZm zK}iGWEWc!exH}REj!a|xu7WHRw?IwSQB+S+14YD$W=x*0Es_C?5`a^QpV=-X&1rZX zG3|gjN}yE~jUkeZUQhSn2=oR0L(DWGYZRz@$pl>hNloDZ+@TDUsdW)aAb?9%$54-~ zf*7ZF+c<1VH1d&Y(LWwIFVaR8i7HB3=EcJF(&8z~4X|9MIS?l(txstqBtyzFGbWxP zz^5o;dI$}Z_2_VubijphBrMWymyG1jBfAiYB-cs0nQ1oaO;nWBppt3tf&PxcCr9=j zIMj{8P*Pzu+CaoV1rM>w&^f;-mF@2s>VBf*$&my7z1^Kd-CZL+-5p)sgC5x+Ogaac zuc^bq30TA=4Y^1#ow%!DxLy0e=#5Medy&9%ZDdS426UFOeFy)+u!9S{Z&(FQDcat*3Y`=Km!hyxpadT}- zf08T{uG+5HqNPnM{7Tbnt*^AM)I^JSLTNz?6%73d=9oyja7uED2K;{x56EKjMwn#Fnnu zmag}Nt`E%4J67k#(-%%J6<=LFiuImWU0Jfmsf$b23|uF_kAKJYmg^@Q7j=n>wpc}5w0zSI z$D8G^m;YJi4NJ6icijHiT^m<^fdAOeS&HwNtQQ_x+McLv#sAOK87)2ztx-|g<>pJx zOHVA1#*3O4I+Ii|SyXzt^-^oU!5o<9ALk>aQBF z7;oF_{}B+5vdg@j-z$ewe?2#kGkG&yxE>`9f-LOT+>Dmd*40xo^WJI zvsxv*>{uT9PWc}XEb+-2;p+J-=daFPnOm)Ej<&Yn^xgDCH+HX?f9Afu*Bd?ZM11ejsO!mS)l;9SRpk};3%J@R z_%%D%)VkPpwc<*}ZD+$>KhG7_esYTE434D1deL&hvUujUq3({}_IuBM?b(+MiL#bh zS<8F+mOJG&RAzZ!ynJKSv~gkA`=*VncIUS|OBLT7iQ5|&I+6}2dCJh3G3C+P%{OXq zIHEP%-#Gu~S6}~XykuY0zAsr)dU^8FEOuy3(FX08Rw)izzXT(i7vyKQ~? z+qNs3C4Oo1jm>X9{QARxw)1w^vDnsQFGLoeS*-nftJivMQ2BuYfpggFX95S+U9_hN@~nSSfI;E}F%2i$PK^5|!~PR3jOjQ$3-5~{yfsb?$shNzk8bgI(LQ`g z>CI(eM>RRxg(lg7Exv%S;u1xB2u?$8s*-kWW@7bTV{JkSce2I^<79Perl1?&y%^6?jV>*x3V7&x z6$U?7(E(0%3O>9M@UuNbLx;g$j-K)bCt$u&g9ny$vM;&p1gxL-%}$1VV2O@CEq=F06?o06u zdbqkXB(N(Zb*FL~X|*Bg&}!Stmm03NTxogL`D$dv9Jg(c8n!ddIrFlEZ@vh13@2Vk z1b%iGr5~Y)lnY`zMWmP!A44P=VDJKK9GMlNe&IwKGOI0vVW!}u+krhMnw4Uqs$RytXh zlC|S}SFH}gq~g_NiS5O-Qo{h0L%t1;95D(8t8-y8bEND#Uo+f1pWlVv_#n18Hyh4S+)umSaHh`jyCGBKoE8QzdpCeIF6T7Y^t-}rSq zHDMKYye>U9Qln}HT}rK)r$U_sP=N+n`I;#O#^=jQWfX-lI;F4=1}KA94>h%BxJ79x zpP>q?oQJBRbOFBP!9Ox+0rQO>(@w3QhfAJPIhW6j0lK#lm~ZqNgL7@_k>jFIlU7CWfg*0klYRC;Zr9eQ3;HLs^)Gy*8`|HqE5!^>=g#r&c zG!!LQos;kb8p9i|2a>O&F$^_EFoIs;SP9Qe`Ppd0xIe;bXPQ9)uKNC|X(;>T4#4AP zGJsC`$K1yLA@CFb0}CzL$Kk6OItNNS2GNev@>L#XWK@*ZmW<1%YM%=+*9T;Ill)0%N0!ZfhwWE_EU;xQIOlv5ap(Q*5MPc{g z;J_gKeKb&224&qBt%CTM6#W%Nw-8AtxM7FsiVW6RGQ#;QGU8|VEA^>06@NfrYH-H+ zU4HcPom9UV2i!y05#j%a2o68hoTGfHYtcP_5FRmBXToyuTQirx@`qnp*?(&+R(cQ$ zC0FZw@0v!?R<6RGW_V@um61d#BuPt;yvb@sgHANyi(8o2Bth9k=a!Kd_g5ISr4ZW)dd&00TW>}XR z`(pbtk9$2$*P50qqJ=HVqLQR#A1?QpAj9jlx?kVl$T`a|KX&P{duSWZy~3gqe%^71 z>qBQb4eS(rno2g@wl{rfFZ;JO6JY-BlZH%3QXTv>+=KP|ZqfdF?gt$^_SLF>ym>E= zm%lct5dXB+xnI!!bi00krS_+f>XGw~x)AAioGe|b#n*QPHRab=>~B}6*(TIU%#zGD z9YrEr9wL^a6$eO$A@`^=`h)zelXBdFO~Ua_tWqmjGk7Lx=MHtBhj0fxCdcj6b`pq< z6;200pXknKh|x77v||vrBSLsKIEu>(LEMcHe4}D03<5~jhOm1AHB;D=O6*n=cOLsC ziPPYU)l)D5=&<+4LQ{~%V#v*s#l*kCJB!ZKrf9+&L@$$dA-uRBj$lJ^Qymn8`(Eq& zUf=fzZu(*^kHywM7BlUMYW6UZ=Yc6CSwKx9B`K|6grt(K#K<>M`7RP=OFjz!@t~xU zafRNd!s&|^$w_`9bLOf0=S++kwaUck&(R&1777Xo#;J>5Bw9!XWG`WqP5cW2;TaXl zo}Ir%wn)*lG&>|&f@G+0EpFB3Ts)d6Zj2Q-CW>9LV%JLR&7yemqj6h%)X@HGXve-a z7d6#^``0=@usOi?Yn`8liN*Teh7H_x`!4kl_+4r!U+`oY~j`P_F{6*@nF^p@1&$vy4BLer58%PdNR8Pv9?+vq#NATe$YdyV21ky}O^mr8C41Fmc(*$Z^9O_y#1{Yhm%xgswd1|EJv?X~RJ=IxQY2w&h?yFo!e6x(E^fHI@zTbnz)DfP zs3mS~O<1?atXt#O?eqIrP1f7ij`!R4#H}51Q%6+OA#ZAGQVFz>m?i$+|3Um9TIJi) z%7PLlq2Rj$(Il;|c;K3Jb}KS)TA<7le*i^dU1rngW7&bWdBm4tC?w>7?|E7qwkzKi zOb!uheyxE zMlAP)>OU}(Og#gUDeqj7w@*+tOcp;D@QZYw>89cNC@v!j&rD?FWTK(027T z0M7VZz+s)fc>ROw432qK-p}dXZ;ubdRjFyn=X3d=iW~XoIST`+H+k`la7@ALVbTV( zkiR}=Z(z_hPKaY;es&EEwt`vwU`zH#MF*F1 z-v|nfPj}CiL)A;tm~ytIPce5tlYat#1hn3VQ`$sQ3%gv6pGScuKLW`qEK3F?*N8ts z6j%iCoR%rWaXVBN=Lnd|b1pk0i!uo%kwdbNFi&e-KvT{a68j~4$Du(O=ATT{Us;CB zP;0^h*%h3qi%6u9(QVnTK{62JK9W4-{t>yBG~^JFb7Rwvdn>q70zo!4F!BEZ^b!UU zo-4bth-A|K4+7dv3`Q*H^)$`4K$)&nIfyVl{1X>!yUY?}qFgi|;+Y zYAa3Hnqs!51r5m;23yi#hJL%b`o5zcehLzy9pEpS?8}w;Wt8 z!XU0bcjdWwQ5(ie5{W^p`z-rPj1N2M_Tj8}@TAM|`^t_=p8%kk*i-2f4t1o0Lg_wx zmgQLY(kk@ax@G3fLqA~h5ZMjU4xw{S$k-4;zkyA1@Txw)&&-!?Bl@jUM!|BnQ4T5e zQVP1ft!3`!f#zmfQ)lk2=8nR57WJm@LHgj;;#I>Y3e{oCCfbRyX|n42f*ZE1pB1P< zlW!(6=ja3Dg8Avo;9-63PJ8reyjrEauM8)Yc93>@PVnd|puEk;$WFl8D{x_`&0%5H z&vhNfF9!)e;nWOnp+T?3_*=TAo4SjO9}WVcg)GPjCA`#puw0PR3c?=l#Ifsh?tW-_ z7JJ@zgvEbB@5HYldVpuvAcOJDJCMlYm~ouK2%TBHw7Xu4e+D~~!#_hb z0;dCUQv47P@hYNh>xy^>8B%>NmCeI6|20}=Of%z|tG0g6yTZTZSuVVKWa-HFyfV}L zyJZz=rkPq5zd_L+iVjoMLD7Ff1f?8flZPmS*^%!eCGqgbq8n&oX2yl55&~nN#s5oy z6#Y4MLqbBfDsOmcYeHWW)7KlD{m@;qBZD4Vi!Tme7+xxiTk7Wbk@Px&gP{tD5YL#|ZTxGKodsf^ji4=Z8l{L`MGqN?*z;V#v=> zo+1A%<*~i6L@740Kcv(xirB9B8%q5xMZcivBZ`QdV|0anY6w5GkoqMc#$w2VyWvs! z$BK4~YLFiMhf@n;=!JOxu1dpe{?*L!^}pauKj$nz=S=^|HT?ru_77aiFF5l*bK8HV zGVrQjaft3x^s6F{AK>F$|6L#I~zu57J{v)0WUVWw7@ex+*Q!Pg(Gyu*TrvjHxU9R}1CdKcP18g{XrXHS!O^ZHp)82x=6m`xOOz6*TR&jrc{Q zWNBHlZhg{dz1V!A`L69TG-xbdQ{#zdY}IReN`Y=V)(R+v_KMd`lrnP`Va-A*JG?Ib ywGgTMT`Cpd!QVG(cqe$*;wShhS9o8i;rH^NSZndUhcD#qA2%6Is;}wUYW#ove_$p6 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/core/__pycache__/model.cpython-313.pyc b/src/unstract_cli/core/__pycache__/model.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1154bd52cd55c7a7f9108f606a98de92b900d43c GIT binary patch literal 20087 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 DL6A={ literal 0 HcmV?d00001 diff --git a/src/unstract_cli/core/__pycache__/poll.cpython-313.pyc b/src/unstract_cli/core/__pycache__/poll.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c6c78843f551a5c88c5b26c2afc0b9a2822cf1e GIT binary patch literal 7249 zcmcIJS!^3;djIeiha#zimn7T2WjPjQiLxvoYg_i(mgW1@h*>3amKu>GX<~AQ`wwYb z!eUqZCN~A_bP>b>7L8hYhpR-?bTy!!ghaHz(7aLduhn<()7eyvs^e_*9cU@|{=w)6GH(c^v z^fN!eLCM`l-nX$PDI|p@5#jd*)(o(R&$mbssgXmil2!6bKFKdNNzIaQ(>mHyLwx-g zW_#D)YLL_tYuHZytAC_6sg>hlVWEB^1#So|JY$Wv=YMG-WU{aCj;!V;sgaX&3eCvL zSyfXE+AUol8IEOA^zVK>7#rw?pF^?z(U{wPTF=eW%a^bGXjV0{3R4)BwG@qOMvlqJ z9G%wFY5fjW?kdSVoY!}PW?=?@nUZ6wa$AYfQ!|Q|GpI4E=hG>-rswFad|RP8of=7| zW^*(*E9Yoh*R#FU&}kjT6jN^lYAP(ysZ4VU%cz>1uAmO6_4JI*DzHyZ^-U=hM+}9yCi~G55Eq=25{EWes#g#udg@c(&ZkBX`x@FbD*`x<<5AR#&wg!0wbP&uF>%$I^6+Ok@0Pqo+0Dn;# zaWYX~cd!a|SHGoT)r>IiKL+ozjgWBxkLe%vMwSL3b;hA^M8I;1#^@uO5BS^bPI)x{7!@Brh)k& z{iL4B0N)i2X7sFLfbjrVxt<&JHkgi{H$cuZLyMzRGE;hKUQ23qa{B7Ow5d(Igx}@64*n*_iuk)M`4br`~1=qHJY8_Z~mHnZQ#Sg`Y=U4o@7wxNJ z;DPe1v69%iB6cq2|Kexmmhgu|k3Bn{IZ0sunu~b+FW_wQicTUuTql13z($=M7iyXa z8fKXi37LR(m9ef{poc;iiCX3lp@DOAuG-@?(=(tU!18s+)(a9cls-*o89A*Q;1{lb z2H^q@HLc*;KeMuCfLRMp*l)|}ykg*;z-|`AIj?e;`~O7c4z?X8L1D=Y3XcWyjmQ5W zwBn%)Bjw2UVx;Gf2N&}n94*)mJhH)qLE!%Xq5VXi_V@KEh%#C#tvfjz~*o+p9m3%kwZ_}4&mailCZ-9LKo=mX{B%!ip3vFnAM zH1sXn?>F9Sd~o~I%qN**sQ0m}_qT!QGu*RAY<9rIliwna)#AZkR9|(Vry1@j`5t)21`0W)wZ2GsTR2H&IE84AaMypMX{6 z61A`PpX61bz5&Adly0cGIn!>W6(wt0HT_N!Z^q$)e~1>`EWC(;qJesmQT(!LXr8zS zl_CyaL9#&R3t6z%NXdk=ws5>-)K!Dmf9t-+8~4^DUpx35@H>?u+2Wq9c6y0qAGF3@ z@dn9p#7gqo%l;FD`bHnlZ%feXvgROM=!k;j3+lTaOEMT!1D@_K~=+ zj%o9Dd~Y5R;3j`<&-!oNyH6Mqw3Z2UX?%>f)}Zy@PBQ*H-f|En3PWq%uo^wyrU6v7 z#(i;T+#xxS*tEbzq_!X)7)2+tX#i&ERW^+}1;KbQ397u|PIRc72HY7~ry3;rzrHix zbo377GQhJ9(sjjyUNUGIzb7?F?jtskV^C{~2RBPV5=YSsZW=W?YN2=tXztwf8D7EN zg$>I<#oa-C(ZccY_no9#t_121D$H}Q>wqsB8XuA;9vrfO?llftC*G>fj@HzrHUYn2 zCtFlpu+s})wy3&*3)ZcrDfxi5Eo=uhP4XXcC<3QGw&C(B_gpzCeD>mal`nMp( zcV6L2Q`{~!`-s$1|bGY)d~7kHjGy{{in4soC!L zd!7H5KZ%Ur=H8@j1BP4sfwp-4ez4EBtvpUWl!vElK^>rIWPbHJ7U+2>?$Y%#xfk
(VagY_&7DrqR~ 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 L@2}Fni|qD4!Fb_V literal 0 HcmV?d00001 diff --git a/src/unstract_cli/endpoints/__pycache__/deployment.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/deployment.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a1ce535225de6445fbca221cc10ec1c91ada7b4 GIT binary patch literal 6437 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 literal 0 HcmV?d00001 diff --git a/src/unstract_cli/endpoints/__pycache__/platform.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/platform.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95d45e16eb15ef36fefa1f08f9ba95be8fdfbec5 GIT binary patch literal 47390 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc index 845babf652987a81d6db8165a272f93c3a5637d8..b69de850ae37a0ef8434d0aad2a219c46795a07e 100644 GIT binary patch delta 11955 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 M@>j2h)O}g~DWL9(qm!1rlorw-UeaY- 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index ad98a0f..34ca29f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -193,7 +193,8 @@ def test_click_to_info_dict_contract(self): def test_flags_carry_full_metadata(self): extract = next( - c for c in dump_commands()["commands"] if c["command"] == "unstract whisper extract" + c for c in dump_commands(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"] @@ -203,7 +204,7 @@ def test_flags_carry_full_metadata(self): def test_local_commands_flagged(self): """An agent must be able to tell local operations from API calls.""" - data = dump_commands() + data = dump_commands(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) @@ -213,10 +214,73 @@ def test_documents_exit_codes_and_conventions(self): assert data["exit_codes"]["9"].startswith("result already consumed") assert "never_interactive" in data["conventions"] + def test_summary_is_the_default(self): + """Full detail for 143 commands is ~50k tokens -- too much to read + speculatively, so the cheap index is what an unadorned call returns.""" + data = dump_commands() + 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 = dump_commands() + assert "--group" in data["drill_down"]["one_group"] + assert set(data["groups"]) >= {"whisper", "platform", "config"} + + def test_filter_by_group(self): + data = dump_commands(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 = dump_commands(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 dump_commands(command="whisper webhook")["count"] == 4 + + def test_filter_tolerates_unstract_prefix(self): + """`command` values are copied from output, which includes 'unstract '.""" + assert dump_commands(command="unstract whisper extract")["count"] == 1 + + def test_group_and_command_combine(self): + assert dump_commands(group="platform", command="platform adapter")["count"] == 13 + assert dump_commands(group="whisper", command="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 = dump_commands(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 = dump_commands(group="whisper") + assert "exit_codes" not in narrow + assert "exit_codes" in dump_commands() + + def test_no_match_exits_2_with_clean_stdout(self, runner, cli): + result = runner.invoke(cli, ["--dump-commands", "--group", "nope"]) + assert result.exit_code == 2 + assert result.stdout.strip() == "" + 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, ["--dump-commands", "--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 dump_commands()["commands"] if c["command"] == "unstract whisper detail" + c for c in dump_commands(detail="full")["commands"] + if c["command"] == "unstract whisper detail" ) assert "singular" in detail["doc_conflict"] @@ -250,7 +314,7 @@ 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 = dump_commands() + data = dump_commands(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)] From 290d1c55f37ae97032b78ff01d48cb9bdff47b32 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Wed, 22 Jul 2026 13:57:01 +0530 Subject: [PATCH 06/21] refactor: rename --dump-commands to --discover Keeps --dump-commands as a hidden deprecated alias that warns on stderr, since it is referenced in the Skill, README and CI. Internal function dump_commands() renamed to discover(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/update-unstract-cli/SKILL.md | 12 ++-- .github/workflows/ci.yml | 6 +- IMPLEMENTATION_PLAN.md | 26 ++++---- README.md | 16 ++--- SPEC.md | 18 +++--- pyproject.toml | 6 +- .../__pycache__/app.cpython-312.pyc | Bin 15835 -> 16199 bytes src/unstract_cli/app.py | 53 ++++++++++------ .../__pycache__/config_cmd.cpython-312.pyc | Bin 12957 -> 12952 bytes src/unstract_cli/commands/config_cmd.py | 4 +- .../core/__pycache__/generate.cpython-312.pyc | Bin 17564 -> 17559 bytes .../core/__pycache__/model.cpython-312.pyc | Bin 19794 -> 19779 bytes src/unstract_cli/core/generate.py | 4 +- src/unstract_cli/core/model.py | 6 +- src/unstract_cli/endpoints/__init__.py | 2 +- .../__pycache__/__init__.cpython-312.pyc | Bin 2347 -> 2342 bytes .../test_cli.cpython-312-pytest-9.1.1.pyc | Bin 76035 -> 80102 bytes tests/test_cli.py | 59 +++++++++++------- 18 files changed, 123 insertions(+), 89 deletions(-) diff --git a/.claude/skills/update-unstract-cli/SKILL.md b/.claude/skills/update-unstract-cli/SKILL.md index dc68f12..5df7645 100644 --- a/.claude/skills/update-unstract-cli/SKILL.md +++ b/.claude/skills/update-unstract-cli/SKILL.md @@ -10,7 +10,7 @@ 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 `--dump-commands` all derive from those records, so a +text, validation and `--discover` all derive from those records, so a correct edit to one record propagates everywhere. You never touch command wiring. ## Sources of truth @@ -51,7 +51,7 @@ Extract `(method, path, params[])` from each source. Two formats: ### 3. Parse the current records -Read the relevant `endpoints/*.py`. `unstract --dump-commands` gives the same +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. @@ -59,8 +59,8 @@ Filter it rather than reading the whole thing: unfiltered `--detail full` is ~50k tokens. ```bash -unstract --dump-commands --group whisper --detail full -unstract --dump-commands --command 'whisper extract' --detail full +unstract --discover --group whisper --detail full +unstract --discover --command 'whisper extract' --detail full ``` ### 4. Diff on three axes @@ -105,7 +105,7 @@ Edit `endpoints/*.py` only. Follow the conventions already in those files: uv run pytest # includes definition-integrity and flag-coverage tests uv run ruff check . uv run mypy src -unstract --dump-commands | python -c "import json,sys; json.load(sys.stdin)" +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. @@ -160,6 +160,6 @@ These exist because each one has a specific failure mode behind it. 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: `--dump-commands` now lists `--ocr-engine` with its default. Tests and +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 e716d19..7cdf6d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,6 @@ jobs: # detail level too: that is where flag introspection actually runs. - name: Verify command index run: | - unstract --dump-commands | python -c "import json,sys; json.load(sys.stdin)" - unstract --dump-commands --detail full | python -c "import json,sys; json.load(sys.stdin)" - unstract --dump-commands --group whisper --detail full | python -c "import json,sys; json.load(sys.stdin)" + 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/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 7ccf7fe..ce27965 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -12,7 +12,7 @@ Everything in the spec rests on one architectural bet: -> The command tree, `--help`, validation, `--dump-commands`, and the Skill's docs-diff are all **generated** from declarative `Endpoint` records (§7.1). +> The command tree, `--help`, validation, `--discover`, and the Skill's docs-diff are all **generated** from declarative `Endpoint` records (§7.1). If that abstraction does not hold, the design collapses into per-command special-casing — which would also destroy the Skill's diffability (§8.5), because the definitions would no longer be a faithful description of behaviour. Every sequencing decision below follows from protecting that bet. @@ -23,7 +23,7 @@ If that abstraction does not hold, the design collapses into per-command special | **Generated** | `whisper`, `deployment`, `platform`, `hitl`, `apihub` | `Endpoint` records → `generate.py` | Phases 2–5 | | **Hand-authored** | `config {init,list,get,set,use,current,path}`, `completion {bash,zsh,fish}` | Plain Typer commands; map to no API endpoint | Phase 1 (M1.5), Phase 6 | -Both appear in `--dump-commands`; hand-authored ones are flagged as non-endpoint so an agent can tell local operations from remote calls. +Both appear in `--discover`; hand-authored ones are flagged as non-endpoint so an agent can tell local operations from remote calls. Two consequences drive the plan's shape: @@ -36,7 +36,7 @@ Two consequences drive the plan's shape: | --- | --- | --- | --- | --- | | R1 | `Param` schema cannot express §6's special cases | Generation degrades into per-command hacks; Skill diff breaks | Pattern enumeration is the *first* task; schema must encode all of §2 | Phase 1 exit | | R2 | `--output table` has no universal shape for nested JSON | Either ugly output or per-command rendering code | Decide the strategy in Phase 1 — it may add a field to the data model | Phase 1 exit | -| R3 | `--dump-commands` can't recover full flag metadata | The core LLM-discoverability promise (§5.3) fails | **Retired** — verified, see §1.2 | ✅ | +| R3 | `--discover` can't recover full flag metadata | The core LLM-discoverability promise (§5.3) fails | **Retired** — verified, see §1.2 | ✅ | | R4 | Deployment 422 defect (§6.2) misread as failure | `--wait` breaks now, or breaks later when the defect is fixed | Poll on **body `status`**, never HTTP code; assert both in tests | Phase 3 | | R5 | One-shot retrieval consumed by a retry (§5.6) | Silent, unrecoverable data loss for an agent | Retries never replay a consumed read; `--save` writes before exit | Phase 2 | | R6 | API Hub public base URL unknown (§11.1) | `apihub` group unusable out of the box | `--base-url` / env required; no baked default | Phase 5 entry | @@ -53,9 +53,9 @@ Two consequences drive the plan's shape: 'help': 'Processing mode.', 'is_flag': False, ...} ``` -Type, choices, default, required-ness, repeatability, and help text all round-trip. `--dump-commands` can therefore be built by walking the generated Click tree. +Type, choices, default, required-ness, repeatability, and help text all round-trip. `--discover` can therefore be built by walking the generated Click tree. -**Design note.** Even so, `--dump-commands` emits from the **`Endpoint` records plus** the introspected tree, because the records carry what Click cannot know: the underlying HTTP method/path, `doc_source`, permission level, and one-shot semantics. Introspection alone would produce a CLI description; the records make it an *API* description, which is what an agent needs. +**Design note.** Even so, `--discover` emits from the **`Endpoint` records plus** the introspected tree, because the records carry what Click cannot know: the underlying HTTP method/path, `doc_source`, permission level, and one-shot semantics. Introspection alone would produce a CLI description; the records make it an *API* description, which is what an agent needs. --- @@ -91,7 +91,7 @@ Phases match SPEC §10. Each has entry conditions, work items, and a **testable* Repo skeleton per §7, matching sibling-repo conventions (verified: `unstract` and `unstract-verticals` both use Python 3.12, ruff `line-length = 90`, mypy). - `pyproject.toml`: `requires-python = ">=3.12"`, deps `typer`, `httpx`, `pyyaml`, `rich`; dev deps `pytest`, `respx`, `ruff`, `mypy`. Stdlib `tomllib` for reading, `tomli-w` for writing config. -- **Pin `click` and `typer` to compatible major versions.** R3's retirement rests specifically on Click's `Parameter.to_info_dict()` shape (§1.2); a future Click major that reshapes that dict would silently degrade `--dump-commands`. Treat `to_info_dict()` as a tested contract surface — a Phase 1 test asserts the keys we depend on still exist, so a dependency bump fails loudly rather than quietly. +- **Pin `click` and `typer` to compatible major versions.** R3's retirement rests specifically on Click's `Parameter.to_info_dict()` shape (§1.2); a future Click major that reshapes that dict would silently degrade `--discover`. Treat `to_info_dict()` as a tested contract surface — a Phase 1 test asserts the keys we depend on still exist, so a dependency bump fails loudly rather than quietly. - Console entry point `unstract = unstract_cli.__main__:main`. - Package skeleton per §7 (empty modules), `.claude/skills/` placeholder, pre-commit with ruff + mypy. - CI: lint, type-check, test on 3.12. @@ -148,17 +148,17 @@ Built once, against the skeleton, because these touch every command: #### M1.6 — R2 spike: `--output table` Decide and implement the strategy for heterogeneous nested JSON. Recommended: generic rule (list-of-objects → columns from shared keys; single object → key/value pairs; nested → JSON-encoded cell), with an optional `Endpoint.table_columns` hint for responses where the generic rule reads poorly. **If a hint field is needed, it must be added to the model now**, not retrofitted. -#### M1.7 — `--dump-commands` +#### M1.7 — `--discover` Emit records + introspected tree (per §1.2). Include: command path, summary, HTTP method + path, every flag with type/default/enum/required/repeatable, `doc_source`, permission level, one-shot flag. **Exit criteria (all testable):** 1. `unstract whisper usage` succeeds end-to-end against a respx fixture. -2. `unstract --dump-commands` emits valid JSON containing that command with complete flag metadata. +2. `unstract --discover` emits valid JSON containing that command with complete flag metadata. 3. Exit-code table (§5.4) asserted table-driven: each HTTP status → correct code. 4. Zero-config operation works with env vars only. 5. No secret appears in `--dry-run`, `-vv`, or error output. 6. Schema expresses P1–P12 (one representative record each, unit-tested). -7. `config init` writes a `0600` file; `config use` switches the default profile; `config current` reflects the change. Both categories appear in `--dump-commands`, with hand-authored commands flagged as non-endpoint. +7. `config init` writes a `0600` file; `config use` switches the default profile; `config current` reflects the change. Both categories appear in `--discover`, with hand-authored commands flagged as non-endpoint. --- @@ -176,7 +176,7 @@ Smallest *complete* product surface — 11 commands — and the first real test - Pin `/whisper-detail` singular, carrying a `doc_conflict` note (§11 resolved) so the Skill won't "correct" it back. **Exit:** -1. All 11 commands appear in `--dump-commands` with full metadata. +1. All 11 commands appear in `--discover` with full metadata. 2. `--wait` reaches a terminal state against respx fixtures. 3. Second `retrieve` of the same hash → exit 9; `--save` file exists and is complete. 4. Every §6.1 parameter is reachable as a flag (asserted by diffing flags against the record set). @@ -215,7 +215,7 @@ Largest surface (~80 endpoints) but the *least* new machinery — if Phase 1 was - Permission levels surfaced in help for destructive commands (§4.4). **Exit:** -1. Every §6.3 endpoint present in `--dump-commands`. +1. Every §6.3 endpoint present in `--discover`. 2. Literal-path test passes (no normalization; slash inconsistencies preserved). 3. PATCH records are derived, not duplicated (asserted structurally). 4. `share` rejects an unmapped `--resource` with exit 2, not a 404. @@ -256,7 +256,7 @@ The Skill (§8) is built **last on purpose**: it operates on `endpoints/*.py`, s 1. **Regression harness:** run the Skill against the *current* docs → it must report **zero drift**. This is the strongest possible correctness signal, because the records were authored from exactly these docs. 2. Synthetic drift test: add a parameter to a fixture doc → the Skill detects it, cites the file, and proposes the correct record edit. 3. A `doc_conflict`-marked definition (`/whisper-detail`) is **not** reverted when the Skill sees the conflicting index page. -4. After a Skill-applied edit, `--dump-commands` still parses and the suite passes. +4. After a Skill-applied edit, `--discover` still parses and the suite passes. --- @@ -267,7 +267,7 @@ Per SPEC §9, with the sequencing that matters: | Layer | When | Notes | | --- | --- | --- | | Definition integrity | Phase 1, grows each phase | Unique `(group,name)`; valid `doc_source`; complete param metadata | -| Command generation | Phase 1 | Tree builds; `--help` renders at every level; `--dump-commands` valid JSON | +| Command generation | Phase 1 | Tree builds; `--help` renders at every level; `--discover` valid JSON | | HTTP per endpoint | Each phase | `respx` fixtures built from the **documented sample payloads** in the docs repos | | Exit codes | Phase 1 | Table-driven across §5.4 | | Polling | Phases 2, 3, 5 | Body-status not HTTP code; 422 defect asserted explicitly | diff --git a/README.md b/README.md index 793b743..aa1fd6b 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,11 @@ selection (`--group` / `--command`) and verbosity (`--detail`) are independent, so any combination works: ```bash -unstract --dump-commands # all 143, names + summaries -unstract --dump-commands --group whisper # one product -unstract --dump-commands --command 'whisper webhook' # one subtree -unstract --dump-commands --command 'whisper extract' --detail full -unstract --dump-commands --detail full # everything +unstract --discover # all 143, names + summaries +unstract --discover --group whisper # one product +unstract --discover --command 'whisper webhook' # one subtree +unstract --discover --command 'whisper extract' --detail full +unstract --discover --detail full # everything ``` At `--detail full` each entry carries the underlying HTTP method and path, every @@ -65,7 +65,7 @@ the cheap index: | Invocation | Tokens | | --- | --- | -| `--dump-commands` (default) | ~4,500 | +| `--discover` (default) | ~4,500 | | `--group whisper` | ~470 | | `--command 'whisper extract' --detail full` | ~1,900 | | `--group whisper --detail full` | ~4,200 | @@ -96,7 +96,7 @@ Failures also emit a JSON object on stderr with `code`, `message`, `hint` and 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 `--dump-commands`, and all accept +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`. @@ -137,7 +137,7 @@ uv run mypy src # types Commands are **generated from declarative `Endpoint` records** in `src/unstract_cli/endpoints/`. The command tree, flags, help text, validation and -`--dump-commands` all derive from those records, so adding an endpoint means +`--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 diff --git a/SPEC.md b/SPEC.md index 9adbd77..fe8a04d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -49,7 +49,7 @@ Three incompatible auth schemes, plus region-specific and on-prem hosts, plus `o ### D3 — Python + Typer -Type-hint-driven help generation, an introspectable command tree (required for `--dump-commands`, §5.3), and alignment with the all-Python ecosystem of the surrounding repos. +Type-hint-driven help generation, an introspectable command tree (required for `--discover`, §5.3), and alignment with the all-Python ecosystem of the surrounding repos. ### D4 — API Hub is code-derived, and this is a documented gap @@ -121,7 +121,7 @@ unstract │ ├── config {init,list,get,set,use,current,path} ├── completion {bash,zsh,fish} -└── --dump-commands / --version / --help +└── --discover / --version / --help ``` ### 3.1 Convenience composition @@ -230,7 +230,7 @@ No command prompts for input under any circumstance. Missing required input is a - 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 --dump-commands` emits the **entire command tree as JSON** — every command, flag, type, default, enum, required-ness, and the underlying endpoint. This is the machine-readable index that makes "auto-discover from help text" operational, and it is generated from the same endpoint definitions that drive execution, so it cannot drift. +- `unstract --discover` emits the **entire command tree as JSON** — every command, flag, type, default, enum, required-ness, and the underlying endpoint. This is the machine-readable index that makes "auto-discover from help text" operational, and it is generated from the same endpoint definitions that drive execution, so it cannot drift. - `unstract --help` lists sub-commands with descriptions; unknown commands produce a "did you mean" suggestion on stderr. ### 5.4 Exit codes @@ -454,7 +454,7 @@ Pagination (list endpoints): `--page` (default 1), `--page-size` (default 50, ma | `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 `--dump-commands`: +> **`--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 | > | --- | --- | @@ -553,7 +553,7 @@ Endpoint( ) ``` -The command tree, `--help`, validation, `--dump-commands`, 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. +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. --- @@ -586,8 +586,8 @@ Repo paths are configurable; the Skill falls back to the GitHub API when a repo - 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. 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 `--dump-commands` changes are needed — this is why the diff can be applied mechanically. -6. **Verify:** run `unstract --dump-commands` and confirm it parses and includes the new surface; run the test suite; run `ruff`/`mypy`. +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 @@ -609,7 +609,7 @@ API Hub has **no public documentation site**. The Skill cannot cross-reference i | 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; `--dump-commands` is valid JSON and covers every endpoint | +| 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) | @@ -623,7 +623,7 @@ API Hub has **no public documentation site**. The Skill cannot cross-reference i | Phase | Scope | | --- | --- | -| **1** | Core: config/profiles, HTTP layer, output/errors/exit codes, definition→command generation, `--dump-commands` | +| **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 | diff --git a/pyproject.toml b/pyproject.toml index 747a2c4..ebfc59d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,10 @@ requires-python = ">=3.12" license = { text = "Unstract" } dependencies = [ - # Click is pinned deliberately: `--dump-commands` depends on the shape of + # Click is pinned deliberately: `--discover` depends on the shape of # `click.Parameter.to_info_dict()` (see IMPLEMENTATION_PLAN.md R3). A Click # major bump could reshape that dict and silently degrade command discovery, - # so `tests/test_dump_commands.py` asserts the keys we rely on. + # so `tests/test_cli.py` asserts the keys we rely on. "click>=8.1,<9", "typer>=0.12,<1", "httpx>=0.27", @@ -49,7 +49,7 @@ select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"] ignore = [ "E501", # `str, Enum` is deliberate: members must serialise as plain strings in JSON - # output and `--dump-commands`. StrEnum would work on 3.12 but the explicit + # output and `--discover`. StrEnum would work on 3.12 but the explicit # form documents the intent and matches the sibling repos. "UP042", ] diff --git a/src/unstract_cli/__pycache__/app.cpython-312.pyc b/src/unstract_cli/__pycache__/app.cpython-312.pyc index 3cf541051b9b02a09b42feb6b5a2532c8ea2a0ce..8e1b0b38090e0dddbc643d5b52a5b9ca7491567a 100644 GIT binary patch delta 3637 zcmZ`*eQaA-6@T~n`zvuA$N5fPk~WF+;pVHQ%SW3wZCbjtUAMMtT_*R{_a$+7@w4u; z+r~J}Dpj;p2)f;gXftYIrrRi&;8wV-kYEK)s>#R9*S$-`_*9I zZIeT&{Neq$_L?=^EDDDN?7t@9I>olcqNyCzjy$N97EnWz(uxDP9yctAxDhwq7PHOk zrlKj8Obrc6EpEZBxGmehZZ3*hthS|TDmTB z^`rW_Ad9~*+h5*p%WwSv9%Q-@(|v5uM|N<#{Y7t;RB0^dj%6i)hqA*TkI6fNN15lb zP0#clv7Tnc7J5rGoAC_ZNpFk$>1(3h9`52B9fpI}?!uqQ?p_xwj&KCzkw^YtFbe`6 zzq5In*@^O6d;m{gGq0E8F8_zS@j*PrF13fP+j9qndsj@s+0={lifP&w)1%r&h2(x? z`i|&w;+VV;*Ysp8np?GE5jE3y?cX#-)fj!n@p_+&IW~l#jno5UnOEfnodg(9nt`+c zX$9hQOGqyiB<+51N2%YhEpmwTw{~G{5LD3LHLk3FdkN`;{eKc7! zj4sfx)jYMf9bD`LvWpSJa#2=O3K<5$2nehRnaIm}F(6N5=J?U0$B#v3=k^_Xg20{x zkZCv*3R#ZndQ6LxaS+(CqDM*Wf?}8xnyQjXFr5Oj2gqKqG0E|jJwXc@0?8y*oF618lAXEF`qRh_2Ox0f zSrVe|z_wl7wPk^5sRZB9uwIhmNd@Zw)2*%Sk!3jnGozu(c0CaKY}y<-U?Z6Y=Zh2sLMA?X6bk9?R)oo&-#Wmx4zg53wd1p+rk z4gujnIszOAu$|#L0wCVX1UXBubasYLb0@k+SoX;&HVJx4X@$j*2NDAUn~@=UzcXOv z;CPJwqqEE3$D|Bmr38>0Y`T`~?)o{hA4U1*Gx?^e-22-?^Ra7++2_o_`lj>0Wr#W27O~!KaAL{9Av~u{|qyv3dS|dyo zM1~_lw1r4qURKi1ip#v$H@n7PxC9OWJLzhsPAw+}OIXki7fd6nvLHuS($WDXL6oS> z5-G`QOx7j#I4a-~Vu;*yXjm6x7%TCjW82^$e+%iobFt;vCZ>RqunpU>BV!^K?97;65?&VL2)oj3MaIlz?u_{*aZ83hputtR z8hdY>V+dDeYs!&fbJkbpkw8N|0s5NVDKhr!^=;x(E&Zpz$xTFD#Wtv2wI?mq=x!;7 z(OPfI*ta~Dn_tn37yGva8DYs!_l4>ZUu|a7dhqMqvVe{Kl}lD?S89WG@}G0R2%SOh z(nEm?C(+>0OiNi%0)TXBF{ZN;UNpEd6f8F_`op2ArY&QvB;}YY&8JkgXzCxnbt1j@ zl1$>U_`*1#t`ewHOqcK`Iwz!*u2cY1im_Uz$T&31o{yVl_#_E4N|xyQ@b1wz#-?4& zyo7qmK%u`wEl=-wJQbJDKdh`ZkIb}@FEX2Byy^$t!~qVWJdg~K=jr8;rwK2I<1e!LCJ7po&zVI4@hPo7vN?WTX* zygxNYKt3Jm>k|51g!{Ng3&w7FUW*@;=zY0>gYk(^R&C zUl14bF3LIHD@|-|(f1m6?k!&JE8v0O@9%))m&~~>NBp`s9g*LI9b7!A*d0P*=YzZ# z4frYkz^3dQ1NgM7*dPuO&6-LCgX9ja9x8Lgg?)8!Ou-Eswn#*V-u9|M z2Eee78%{23IHf9k$h&L`)mpznKQ|FXJLtD2?jbK-ooqW;mp9ks-QK*rChw}wJNiHH z*hI156~wBi>#nz4ZONNGpmrSjz-DF&XN96VZ}xNDDf*+y6Km55`SXrI!350P&Z>e1 zn1WUC1qwFK+68x@;NYxNsB15{IO`TX-hzj-RYFxmp_;Q^!Rma!2G|XgAaZgHZB$!D oal>O_#Kof7s9~`dWhTKV&K32A(};VR73uYh($ delta 3490 zcma(TZA@F&_1)+1Pd;s8z+k=t4*~%K2_(cJr6FpHnl{a{#2I^ia35f1`+4`-$uMr9 zQ>HFI)+)NwKGa0jvi?c1Hcd{mRg<=8tF$iJ*H83XO_L%`n)-)~Xth*g1u}FU|%eV{joYchAY^954NyhC9b+*aS1zr_$aQvYLA6P z;iQ0Tt_rwTbu12>;z*fgVrl~p;2^G>7x5k(x*-3Gn2rsH-LKz`<@zJFYaS*_iwwUNUWpx#6~NQ;(ls~&sbtDB7Iw| zTfqZ(uy9~a+;QGX({XvlLuPbkS_zvM@bIneDk&T^E#YJM&{gZ&mbkgb+VMCZVb#*X z^hR%?m}BpfMLM2+o4#)u_hysn-dQb~R8m+kdaU=v;vembh&t$i>&HIKxU$Kmk6b_L zhI%#x$w7jE*ya+-yiNiPC-ndt05k${(_gqR*qZ^3(6HwfTQ@_yi(mKHY-oUfv$6#> z(L0r&1i_9Z8e(6+fzmo$NhCrf`d-xlQs~E3(^5N_fYK$y3>dacN+PS0ejp40!Jbx# zlGI6%ezm%01-uaU)C9wwR!K6h>v1hbMuFhOsy<8NGpb=tYl#Fo0-{hq<7XifC&Iyz(TV+6lerKA_2uOt0|n;;;D=rPt9rMML`gSiK4CME~@6r2S6D@ zmqapDd{~=9UI>}MDJMxUeJR*}klVJ+6D^zO9u50tC6!UJ4z*kM<&=|3W_Cf<$$3WC z=vweg)jePf%-^<Ys8%ce5iiQ(U>2YD`@$fzWDFq_gdM48P*dV8T>wyTN08Ey`ZKau;Z zuoT++MDAzdchF$*ou=n4$V)d`n$Zzj+1k+(hrsKJ10v&v;R37`0Kb8g^jvGxI7k_G zC7ovZfvgAwGDqNzl`82uz<32aXA^N5sOvbS58ez#GXW2&bZQU{` z!?lnF0N{)ljA2b_7>Y|F8N<539#pA%PSZ$RTS#JJ#_k72UT3r`#V@wKkDO=Fdc*X3 z{WHb3jt@l?rk{0QM9cJI*KssSZ+6uoO5g43v4atEo&L2ef2BbAjPR+ConiJX zeG?k1o3CZa_vxRcD7sE7x&!q$7+;5JGR{kuTmu6D{z;?VAv>?2YjnE%O6!*yQ9_0* zO|*GAr6ko{#ee7XU)_^^{B84B4K_^i#X7we+wvl@VS8SD zOSmbf5O(C4MBc(^&b;Lc^P8T_>tYohAE_TAB6c&+m9IMf|Krfd{+elf-cgRhoOLDtWhW+vt9D6LoAmO! zI=s_Ac(4eI0UqKmRZm&P#C95As$8m+s+Et5bNkPuU9*Wk=$~lZ*1F5C`C51+4>E6&_zgg46p^bu(=rT0}AW=FqoM4{aYFLT-9tID!Jj z-0*FLeofmCCH9tLVFn~b?z4rrf-U2}WP1W&p58lj81)n%Gg!0TR1G^Hv^tS#bTrrr zg3JhGJ5ACg>_`=B%=XQ~442=e|F6vsn|#M_$lUYH55u1X)lptTURHU>f|UIhoHlj`1mG zVY(&TE8Muhl%AVlf#>LVfR)vGwzk#D?|}<>BRhOTAna84fy@PX?0;ZeHuC|`c}m^+ zBusdzll*|u49P9abJpqNzc%2;rjp%s(AE zae>GxD7of{mQz!@njxov*bRVBz7=5mo;`8)_;XY8)QNK^re5ZMAxg!X2f1qi&e7=D z;7J)!xHiM7kojy-+}dG%BDr(+U* zH1+@m=#P#zwfWY)mFuq4|8Q7EaltEdd4I5iG?1DGA;Q-7j1e!NofVl-<<%S0^ui)Ra;RDPs*xi4x7@ dict[str, Any]: """Describe one flag using Click's own introspection. Uses `to_info_dict()` rather than reading private attributes, so the shape is - a supported contract; `tests/test_dump_commands.py` asserts the keys we rely + a supported contract; `tests/test_cli.py` asserts the keys we rely on still exist, so a Click upgrade fails loudly rather than silently. """ info = param.to_info_dict() @@ -123,7 +124,7 @@ def _matches(entry: dict[str, Any], group: str | None, command: str | None) -> b return True -def dump_commands( +def discover( *, group: str | None = None, command: str | None = None, @@ -188,9 +189,9 @@ def dump_commands( # 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 --dump-commands --group --detail full", - "one_command": "unstract --dump-commands --command ' ' --detail full", - "everything": "unstract --dump-commands --detail full", + "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." @@ -264,29 +265,37 @@ def resolve_command(self, ctx: click.Context, args: list[str]): @click.group( cls=UnstractCLI, - # `--dump-commands` and a bare `unstract` must both work without a + # `--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"], "max_content_width": 100}, ) @click.version_option(__version__, "-V", "--version", prog_name="unstract") @click.option( - "--dump-commands", + "--discover", "dump", is_flag=True, default=False, help="Emit the command tree as JSON, for programmatic discovery.", ) +@click.option( + "--dump-commands", + "dump_legacy", + is_flag=True, + default=False, + hidden=True, + help="Deprecated alias for --discover.", +) @click.option( "--group", default=None, - help="Limit --dump-commands to one product group (whisper, platform, ...).", + help="Limit --discover to one product group (whisper, platform, ...).", ) @click.option( "--command", "command_filter", default=None, - help="Limit --dump-commands to one command or command prefix, e.g. 'whisper extract'.", + help="Limit --discover to one command or command prefix, e.g. 'whisper extract'.", ) @click.option( "--detail", @@ -300,6 +309,7 @@ def resolve_command(self, ctx: click.Context, args: list[str]): def cli( ctx: click.Context, dump: bool, + dump_legacy: bool, group: str | None, command_filter: str | None, detail: str, @@ -313,16 +323,23 @@ def cli( \b Machine-readable discovery -- start cheap, then drill in: - unstract --dump-commands # all names + summaries - unstract --dump-commands --group whisper # one product - unstract --dump-commands --command 'whisper extract' --detail full - unstract --dump-commands --detail full # everything (large) + 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. """ + if dump_legacy and not dump: + diagnostic( + "warning: --dump-commands is deprecated; use --discover instead.", + quiet=False, + ) + dump = True + if dump: - payload = dump_commands( + payload = discover( group=group, command=command_filter, detail=detail ) if not payload["commands"]: @@ -338,7 +355,7 @@ def cli( + "." ), "exit_code": 2, - "hint": "Run `unstract --dump-commands` to list valid groups.", + "hint": "Run `unstract --discover` to list valid groups.", } }, indent=2, @@ -385,4 +402,4 @@ def build_cli() -> click.Group: return cli -__all__ = ["__version__", "build_cli", "cli", "dump_commands"] +__all__ = ["__version__", "build_cli", "cli", "discover"] diff --git a/src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc b/src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc index ed97f1d3903cea15a648ac21d274718adeff19ff..4421276a27034543679122cf3fc936ba0a05873c 100644 GIT binary patch delta 41 vcmbQ6IwO_$G%qg~0}#m9C1fqw$Xmn4cz$vh+k5uR;^h3Y)S}I;>=N1l5rYk> delta 46 zcmbP{IyaT~G%qg~0}w3U5udecBX12GZ0~tWa|?8n^K)|(^HPd8H?T`+0|0fR B5K906 diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 3429856..1e2394c 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -2,7 +2,7 @@ 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, so they are -recorded in `--dump-commands` too, flagged as non-endpoint so an agent can tell +recorded in `--discover` too, flagged as non-endpoint so an agent can tell local operations from remote calls. Per SPEC.md §5.2 nothing here prompts: `init` refuses to clobber an existing file @@ -29,7 +29,7 @@ from unstract_cli.core.model import HandAuthoredCommand, Param from unstract_cli.core.output import OutputFormat, default_format, emit -#: Declared so `--dump-commands` can describe the whole surface, not just the +#: Declared so `--discover` can describe the whole surface, not just the #: generated half. CONFIG_COMMANDS: tuple[HandAuthoredCommand, ...] = ( HandAuthoredCommand("init", "config", "Create a starter config file.", diff --git a/src/unstract_cli/core/__pycache__/generate.cpython-312.pyc b/src/unstract_cli/core/__pycache__/generate.cpython-312.pyc index 24d92cd7dd77e4c4dd68d8197ad399e1c6135d17..dad77e9acbc1dfaaa1d7206d3ef2a25683040550 100644 GIT binary patch delta 43 xcmbQ!$vC}}k@qw&FBbz4$k!!gnQY{p!NkZjc^T7c_RQks{Ib-d&9Th(jsORc48H&X delta 48 zcmbQ<$vCHzk@qw&FBbz4+}#nMWw()c1{0&;+i}BJdM&8rByj%=GAiX0#%WNaB2NUD>$ze>bJf*n>y2<&uxruox#hV{774tK? bO%4+>M-`tU#BRcp(l_wTF#`(z click.Option: "type": ptype, } # A default is applied server-side too; sending it explicitly is harmless and - # keeps `--dump-commands` honest about what the value will be. + # 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 diff --git a/src/unstract_cli/core/model.py b/src/unstract_cli/core/model.py index 04ad0a9..cb72f99 100644 --- a/src/unstract_cli/core/model.py +++ b/src/unstract_cli/core/model.py @@ -1,6 +1,6 @@ """Declarative endpoint model — the single source of truth for the CLI. -Every command, flag, help string, validation rule and `--dump-commands` entry is +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, IMPLEMENTATION_PLAN.md §1). @@ -109,7 +109,7 @@ def check(self, supplied: Mapping[str, object]) -> str | None: raise NotImplementedError def describe(self) -> str: - """Human-readable rule, rendered into ``--help`` and ``--dump-commands``.""" + """Human-readable rule, rendered into ``--help`` and ``--discover``.""" raise NotImplementedError @@ -372,7 +372,7 @@ class HandAuthoredCommand: """A command with no API endpoint behind it (IMPLEMENTATION_PLAN.md §1). The `config` and `completion` groups operate purely locally. They are recorded - so `--dump-commands` can describe the whole surface while still letting an + so `--discover` can describe the whole surface while still letting an agent tell local operations from remote calls. """ diff --git a/src/unstract_cli/endpoints/__init__.py b/src/unstract_cli/endpoints/__init__.py index 2e10d4c..cf96346 100644 --- a/src/unstract_cli/endpoints/__init__.py +++ b/src/unstract_cli/endpoints/__init__.py @@ -1,7 +1,7 @@ """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 `--dump-commands` are +modules and nothing else: because commands, help text and `--discover` are all generated from these records, a change here propagates everywhere at once. """ diff --git a/src/unstract_cli/endpoints/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/__init__.cpython-312.pyc index 5428b7dd1967d53cacc7d78fd490f1522fd37868..8f40ab128624c281aa333af1de69f3a7999218f3 100644 GIT binary patch delta 41 vcmZ22v`mQiG%qg~0}#m9C1gF?$m_w#XgE2H@dkTladLiHYSCsZrjKj@?t2WL delta 46 zcmZ1`v|5PwG%qg~0}$|Uh|hYyk=KKf(QI-U;|-qD+ydR?{M_8cyp-b2rc58%05;hU Ap8x;= diff --git a/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc index b69de850ae37a0ef8434d0aad2a219c46795a07e..703dcd66f6e4b0906c7082db77404818018e7e5e 100644 GIT binary patch delta 4624 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 34ca29f..f39b98b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,7 @@ import pytest import respx -from unstract_cli.app import dump_commands +from unstract_cli.app import discover from unstract_cli.endpoints import ALL_ENDPOINTS from .conftest import FAKE_KEY, WHISPER_BASE @@ -166,11 +166,11 @@ def test_verbose_error_path_stays_clean(self, runner, cli, whisper_env): assert FAKE_KEY not in result.stderr, "the key leaked through the API's own message" -class TestDumpCommands: +class TestDiscover: """SPEC §5.3 - the machine-readable index agents discover the CLI through.""" def test_valid_json_covering_every_endpoint(self, runner, cli): - result = runner.invoke(cli, ["--dump-commands"]) + result = runner.invoke(cli, ["--discover"]) assert result.exit_code == 0 data = json.loads(result.stdout) endpoints = [c for c in data["commands"] if c["kind"] == "endpoint"] @@ -193,7 +193,7 @@ def test_click_to_info_dict_contract(self): def test_flags_carry_full_metadata(self): extract = next( - c for c in dump_commands(detail="full")["commands"] + 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") @@ -204,82 +204,99 @@ def test_flags_carry_full_metadata(self): def test_local_commands_flagged(self): """An agent must be able to tell local operations from API calls.""" - data = dump_commands(detail="full") + 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 = dump_commands() + data = discover() assert data["exit_codes"]["9"].startswith("result already consumed") assert "never_interactive" in data["conventions"] + def test_legacy_flag_still_works(self, runner, cli): + """`--dump-commands` was the original name and is documented elsewhere. + + It keeps working, but warns on stderr so stdout stays parseable. + """ + result = runner.invoke(cli, ["--dump-commands", "--group", "whisper"]) + assert result.exit_code == 0 + assert json.loads(result.stdout)["count"] == 11 + assert "deprecated" in result.stderr.lower() + assert "--discover" in result.stderr + + def test_legacy_flag_is_hidden_from_help(self, runner, cli): + """Deprecated spellings should not be advertised to new users.""" + out = runner.invoke(cli, ["--help"]).stdout + assert "--discover" in out + assert "--dump-commands" not in out + def test_summary_is_the_default(self): """Full detail for 143 commands is ~50k tokens -- too much to read speculatively, so the cheap index is what an unadorned call returns.""" - data = dump_commands() + data = discover() 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 = dump_commands() + data = discover() assert "--group" in data["drill_down"]["one_group"] assert set(data["groups"]) >= {"whisper", "platform", "config"} def test_filter_by_group(self): - data = dump_commands(group="whisper") + 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 = dump_commands(command="whisper extract", detail="full") + 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 dump_commands(command="whisper webhook")["count"] == 4 + assert discover(command="whisper webhook")["count"] == 4 def test_filter_tolerates_unstract_prefix(self): """`command` values are copied from output, which includes 'unstract '.""" - assert dump_commands(command="unstract whisper extract")["count"] == 1 + assert discover(command="unstract whisper extract")["count"] == 1 def test_group_and_command_combine(self): - assert dump_commands(group="platform", command="platform adapter")["count"] == 13 - assert dump_commands(group="whisper", command="platform adapter")["count"] == 0 + assert discover(group="platform", command="platform adapter")["count"] == 13 + assert discover(group="whisper", command="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 = dump_commands(group=group, detail=detail) + 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 = dump_commands(group="whisper") + narrow = discover(group="whisper") assert "exit_codes" not in narrow - assert "exit_codes" in dump_commands() + assert "exit_codes" in discover() def test_no_match_exits_2_with_clean_stdout(self, runner, cli): - result = runner.invoke(cli, ["--dump-commands", "--group", "nope"]) + result = runner.invoke(cli, ["--discover", "--group", "nope"]) assert result.exit_code == 2 assert result.stdout.strip() == "" 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, ["--dump-commands", "--group", "whisper"]).stdout + 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 dump_commands(detail="full")["commands"] + c for c in discover(detail="full")["commands"] if c["command"] == "unstract whisper detail" ) assert "singular" in detail["doc_conflict"] @@ -314,7 +331,7 @@ 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 = dump_commands(detail="full") + 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)] From 7007f71911af029d68b42e8ee6b0dd7f4b23d2bb Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Wed, 22 Jul 2026 18:02:44 +0530 Subject: [PATCH 07/21] refactor: remove the deprecated --dump-commands alias entirely --discover is now the only spelling. A test pins the removal so the old flag cannot creep back via a copied example. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__pycache__/app.cpython-312.pyc | Bin 16199 -> 15784 bytes src/unstract_cli/app.py | 23 +++--------------- .../test_cli.cpython-312-pytest-9.1.1.pyc | Bin 80102 -> 78117 bytes tests/test_cli.py | 20 +++++---------- 4 files changed, 9 insertions(+), 34 deletions(-) diff --git a/src/unstract_cli/__pycache__/app.cpython-312.pyc b/src/unstract_cli/__pycache__/app.cpython-312.pyc index 8e1b0b38090e0dddbc643d5b52a5b9ca7491567a..b2067ce8e0eecbe8186d2823b5e73c115d0e1c48 100644 GIT binary patch delta 3065 zcma(SZERat_1$Iskn zcO>!ZYO2KgV@TDN_*yGOL4r1(G~}*he_#S3U}eD1>1jepllU_UxkN(RelX6t&WlqC zsk|qhbMCq4>)!Kme}DDo8$%BR0Uv?y&39f}xp0(_k8xpt4K;#pox3@dEqUY33ej7Z z3G@AgZqnLbyplMw(C0Hbkyilyc^uRS0?LQHl=s75DT;RC53gc&Ra^k%T|&a$85Pe z)|**3+rxUwy_?P&S4vLTC2WfAWf8F6$4dR>eVeW)2oJzn2g~~(bN|1L4zXd7)Ijn{ z?xQlW8A6`7BeJpHZYKe*H^gG)15XTZgpGpRgF9{oRocY^Wsj51utUO9zUYYeDdI;; zdx;%pW91{8%C7UCwjI|o8@HpY^J>%*VH09lh?{+!nWSe1OfeNvM`V z(3`qLi@tlze*TlQchiOuL(tJr9 zKoeMxd;);wx{}NkbbbgKhmoP?l03O;^04@3OKS1z#IdeuP!;hmV76m_n08&dVM>4ynx%fe3j3JmqFpOI?Wn$Gu@Z%bCf(bp#az;9v zPo%Sp20urLLgp&2*88+YDjz{*3|&+Bc;#W+I&Hv^IiB(~9}!;;4?HDpyOy|7$Vrcu z`f@Ux*O`e0S@-2htS0lRW!>a20N)Us;jg#E&=#7%>);DWk5xL`@44nikRAnKxiUs7 znbDjwTf7(fGEJC8IHn9zMzj-YO=w)GJYZ@9&@gD#%!>X>hnCejXNDVEgo_jN$TS1n5jA(sE`EhDlHIyyaX@ zf^V7C=R{=RzBUcSpfu8E%DAF){%u%Zt$c0Y&z!VJd_HuMZitJ+C+TT%d$^qn@%C^` zMGbya{9`ymKNW86e(3wa_$&Ycr@k>dXF&44mFGVYf7NE_P0<()b=`t}6X*1-tQ&p< z4Iucpn2C0(GL1LHrRY`qqWCD<6AakV$O{nviO%|EOUY*Pi94}#-8B+Stb0dBQpV~k zp6W8L=fzvG4l#J(hn=!3QP65ImyMSbOkYeEGI{9C4-dpjDs0#vmIF+8x)p)S{2nP% z{xHA&t`JgGijJbQOzm0vids~e(g`Z&#r*c0K*-AW{IpFX&SN>qZ^dI%Hhz&1n1D{^ zDk|K?)S~hRxvgX=bFYI$(E&V9(eZ{-=P@ttFdy@?hFi`wWxjG?XP5=cjW$h5k*xT| z>z+ORl;pQxPKYmPd#>Y^W-&Y2HO>|01J6ybxc~p-h)2QJOKQG8M zCedQ^>-gHlZvW`PK~_Q-;@D%V{KeoH?B|`69W_D)F&=XL};k8K5>`Q-->~wnfja422Y~2|%mMWhFlPAQ?RCovl!3ajb%ZTN@Tu5hF0=ks{ z0k$^8#?)7OvGrg&UIQe#GIV(#`2}w)e zpZUx@j}#-%8d<$IXdj|1Dp=0ThLHgcdBc(|C$XgDymf%C6y+spJXlN1ZL}bB^CzV0 z>M~0^>ih%T#eDHyJ~0qD)qBX60y6eL!xCO6q}Tnm5q1XOB5jEO7 cjz(2cwj4w`OSh1$o~9^qg^I12b54xvzwsw%_y7O^ delta 3283 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 delta 10732 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 Date: Wed, 22 Jul 2026 20:12:04 +0530 Subject: [PATCH 09/21] fix: wrap long cells in table output instead of truncating Table mode capped cells at 60 chars and appended '...', silently hiding the tail of notes, URLs and ids. Cells now wrap across lines within the terminal width, so the human format is lossless like json/yaml/raw. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__pycache__/app.cpython-313.pyc | Bin 15969 -> 16349 bytes .../config/__pycache__/loader.cpython-313.pyc | Bin 14070 -> 15924 bytes .../core/__pycache__/output.cpython-312.pyc | Bin 9411 -> 11671 bytes src/unstract_cli/core/output.py | 70 +++++++++--- .../test_output.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 20413 bytes tests/test_output.py | 105 ++++++++++++++++++ 6 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 tests/__pycache__/test_output.cpython-312-pytest-9.1.1.pyc create mode 100644 tests/test_output.py diff --git a/src/unstract_cli/__pycache__/app.cpython-313.pyc b/src/unstract_cli/__pycache__/app.cpython-313.pyc index e0615367e3269899aca29d6ba530d090e492dfb5..2d13f005af98576e9d88a8da60224a8cfd4b05f6 100644 GIT binary patch delta 2957 zcmYioYiwIr`JC(Pcbtdq*lC)_t(}+iaN4Fx>o#rEH6&fyrcF*WvZk7QYdfhM$Jd-| zuS?V}WT5fbkF`7HHINYenf7xSG$h23__d)4RM=R!2(f7bA%Re)z>4~d?>ml@-YDPw z&Ue1|>-v*Be_8L|_W3*np1?1z-|9;c@+lswPhAC}+h4ukonNQSeZFDJ&;1%<9v-+B zjF3Zwd6(%ht?=gbnyl ziOaOHF2Gt?D{Euz2^DB#;$zy_ps@oW5n)l*k+4^(zE1F`i*-v`$ye9IVnFK!+ODj< z5BQ%#&Eb93h#`s)>t_Rdy6WO=5F`&iDd{}K4ukBt^dQ295)?R&uwf}RA*PhLC@8AR z#@UFtql}Atip#+}Kw=6dyhqtFmPjaYzJO=@arEOgTln;encdX0(S*Xjz)oWNhASx| z)i>{E6YP{sea|3ar)MaSE!kp| zc^DD#g&hxB8L-c@n!*ku<2WVfp#+$K64+Cnc%h`O|a+;-; z6`ikI&H^{mrBu4hwBFF&Yv(hwSnDV7pHXwibHz)911uXwBB5WOKjZMe!hfy6N$ zJ_5k9Kd#*7rRn6vg)@_kqeC1!$nq32d?jm|StHMn zi+ldy$x*yJiQp81)5vAh^Q$pCKZt}O1mg%|ctumD9o`x~;NK_ypnVq3-l0PZK}Xj(G7^@O0MX| z)Xj{^ufnk)eipjs?*~k(f78b25g#f?!_;v$j`$z|%bqh*dM>8QT<~_J%aUP5AuQ>n z^k~=93e&JyYQ5ygJn?H%Ps%9ei+sNPZtEzeLuGsWc^mbKnMgYw6<>;U9#}?&&D@0& zvOc&+coc#3>}m0Kq-_kjEQekwWb$c_$>Nwnehz^w=y}9sm$_28Y!W+d&tZO5G)5y+ zUj!b@VHzBg#Gipf%(oLg3ZuLH76LRT3umc$Bb~8SXp5qy-qgXitm2H=h(?3Gz-GD9 zSupZFo%o46gvxRiYb6(D$o7xe)C z_cHmDI{8TT+5P_|0JyC1E8_Fs7wL6T>Ra~8G1Gt*X6>JSmqQg4&TH<0fs_Gb1<|F& zO#j>aXZYR^3MZJ_Og5ZIH-t{#X^5dXe-kpJ8%;qvNin-uPV_9Ean}jHU zLBk8JXlNxf)33=aWz*UGlGZUd&92OzIeP_WY zw#v5HbvEV9fM3oTIv0H8MwgcKG!_60)$f`3aei&Z&$h zYFC^Di)8LfK=UBePP0czw{j4-Ua<2z&3;h_vd65V#M6+Gepj=&RT*zdrbm;#dG? zzATI{oNTX|7M6%+ArQe za)f#X9oA^592ve(>HFfRBe|}MN1z}^`Frq^%?!H_^}bsLMKN*oIPEWQ98J(U7gBfk zf#r~^+LTUMUOktB~m~F$!fW7t-${|(i zl!*gsmsn2>uFIv3d60WTwkIwtxhu}0w+WXul{73YmsrI* zx&-lI@++(|ijUw3Ul_#Kyp{bk$&H~>A=b;^0HWndCKpS^63--)94CkW3c;`O%28Y` zz$|kWDR;$($H#m)^KtAXScgtQPiXB}Mk`z$YOF*&W$;XV1@>x=yT#avmQI{vSq{s+ z$c?p39)3#j3rOrkFpFSAY@7%*;s>nK;x8g5*WkQ(^F+sU>Sg%NVbv6L)5KpPa)ZjQ z#UxuUoiCMEGVmRe&4yLCoXMn~%RyK_LRE5IVmn%{QShyl%beo>fFrazF>|7GG)hOx z6QdXD`qx@sYS~n4H`RtsckQdq-)#Qw+(UPCv!;I2J^Pv0p(tCmL~(vZ0}p9nOF2n{ zo9@t-4c;FI!drHDZ#hU!Xv>M1i!`=xxe@aaU;UOBu^Lj_wBJY0Z4$pQ2 fVB0nqQG(lbU`G7yWS8p-#p^Le1Wrw<7{mVo*(0$N delta 2741 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 EAA|l=3;+NC delta 1691 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 delta 2647 zcma)8T}&L;6~1?Vc6auVh2>`f2i75M7>sQkH#Xo{jSHq}Yf(9N(r9fOk-cNQcvlTtHw_~XLc88 z+J_F}%(?e|_uTJ(_ntGmaO$_;3Vsp@cnLg*KYVrMQQdqnB0M7VZF_T4$~EeayD4Fm z$tlmM5?4mOaj(-BQ@*%QAm1d+HA9$N^K0%U34;(453p)h!#p!`JjiO9G9$$+6vFBh zQvPER=7Z4?t7G-d4_+nnGLZ#t2xSDm2Z{~Wz=AWbc-8-cln{-q0uW&q0-}^v-VoUi zRs}uP!-R$3qw$(aQGH_hB>h65b<2HHlSrGE6YdR4Rcla*%g+Gm&gEY!XJpqdaQSYl zt>PFxX=N(LclBULH5gUohu~$?bGG}eHpDcp>mqbSqtm<=L5HnSNTqSBC$#sqeTWp# zX_Km(7egYV5zQWFXegDkUD`!MOEZqks|r7e1Mb9FDl?ka`4Mc%$#h!d{5kaLSzCgD zI{!Mf`195$p?3KNa1Sj%4$ae?0B-s5fCJR%1Dg_Z$$QDSKn(DTk4a3rR6?JI_mnUt znu1OzsVVf4Yf?fOCJA9M03(ut8+0C1S}H}D7v?G?Kuk$je5Tk(27%9%oHOK2s9c7^ z;9f$UMO;&E)3p_D)>p=y0}48wt1;Z=`LjOLRS%6VwCOIR%fu0bs?6<^$lMW$%;tQ+ z-BaQo1V$c1DF-w?OejwT_92J8=ZcTHFZ<3=CeHY;cm*=+HHGpr*qYrQ5y(w>m}Gjg zuuX`e6l0X}!-6!Wn2OcutJ&#=kn)TNrCBO5D8nFeTq6N%r7^r)DfgWKDXAmpMA`Ff z_f${zp7q_jzja69$@<&UV^OZ@nUtZc_fUbv=D|V(4?c_se-@l~=mI3}2(|uHRoynT zT~0C!yZ%60DxL0jqEx290F)4%98weA!Fgs06IwIoEts9NXu^-a~O37TOL?+44#0jd31NXWCmKFEx z*`o@!n9|a=oEpjK#%9_jY&ua&xM(Tia7Kr%E^x zy5^-k&F}kCB+-__zMovUMc>U{K7Lux3zuKcYgfL1>-?wwgDd`nzo4J`dp?VFZRl9U zUjNzw+OJaEr5ik%01mb^GL}r+Vm3K$yEyia15G)hrBb@>nn*I^tXtPHtD@GEO|iZS z+d674`k?gK*W&uUoPUSoQ+*=TXTy_nAgBO zo9;&GPh+)shi;1RhkHojb_$TgrGqbN87ynb|Epq&d~AZhf{eaWufC{%D)$5I$NKfi zk?nS<{pQKV#C$k^Vj*(#WKoVcs%ofBy{g1t24Gf6C3WKzh%g9RXhw#oc_;Kksl$52 z2f)~hmd=c7e8|vjadhY+m}Hvw;_wNC`kiXW`v{a&{ya{QfTYg*5pfFJG-*pHFF%eg zIh9Ony0Z!XJ?PLu2x9#+8m-d6!GHZ9U^b@7mq9DKv$pQ@rqS{o}8CyJ#LGmxYHByMJ1k~yNhms_R*Zm||nXkKg`uhHlYge1QKCZoWq#zcC-gkdg zyVBgX+Sq;D-!pymj-*^V_QPYVC@h%YNzb+4@l{;wp6$-RwCZcTEj4c3ZzjQ~H|~2$ zuzllxh(x>fO4!Wxs@^8)*9XGAEz)nA{k?78p9;Kh`Q0Z!6DsfFO@j;QN~W2XHh8-= z(|X(mwU+Qs>uzg3dkNhtG^h=Snn1R`L$>_dq@lsRWm;m4X{ua247?$5B4CEm;3rIn zY6l-Uc$|#GL?2fenemi{ce^7BrD%{3T0d%kI`>r^pZd>m z-feLl8FA^NmJSjHee8v(Q8PL)2A}jTCjZaWevVn{OtK}MuX>Bp;9iRQbfAYK*&-eT z) str: +def _terminal_width(default: int = 100) -> int: + """Usable width for table output.""" + try: + return max(shutil.get_terminal_size((default, 24)).columns, 40) + except Exception: # pragma: no cover - detached terminal + return default + + +def render_table( + data: Any, columns: tuple[str, ...] = (), *, max_width: int | None = None +) -> str: """Render as an aligned plain-text table. 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 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: return "(no results)" - widths = [len(h) for h in headers] + gutter = 2 + total_width = max_width or _terminal_width() + + natural = [len(h) for h in headers] for row in rows: for i, cell in enumerate(row): - if i < len(widths): - widths[i] = max(widths[i], len(cell)) - widths = [min(w, 60) for w in widths] - - def fmt(cells: list[str]) -> str: - return " ".join( - (c[:57] + "..." if len(c) > w else c).ljust(w) - for c, w in zip(cells, widths, strict=False) - ).rstrip() - - lines = [fmt(headers), fmt(["-" * w for w in widths])] - lines.extend(fmt(row) for row in rows) - return "\n".join(lines) + if i < len(natural): + natural[i] = max(natural[i], max((len(p) for p in cell.split("\n")), default=0)) + + # Shrink only the widest columns, and only as far as the terminal requires, + # so a narrow column is never squeezed on behalf of a wide neighbour. + widths = list(natural) + budget = total_width - gutter * (len(headers) - 1) + while sum(widths) > budget and max(widths) > 8: + widest = widths.index(max(widths)) + widths[widest] -= 1 + + def fmt(cells: list[str]) -> list[str]: + """Lay one logical row out over as many physical lines as it needs.""" + wrapped = [ + textwrap.wrap(cell, width=w, break_long_words=True, break_on_hyphens=False) + or [""] + for cell, w in zip(cells, widths, strict=False) + ] + height = max(len(parts) for parts in wrapped) + lines = [] + for line_no in range(height): + pieces = [ + (parts[line_no] if line_no < len(parts) else "").ljust(w) + for parts, w in zip(wrapped, widths, strict=False) + ] + lines.append((" " * gutter).join(pieces).rstrip()) + return lines + + out = fmt(headers) + out.append((" " * gutter).join("-" * w for w in widths).rstrip()) + for row in rows: + out.extend(fmt(row)) + return "\n".join(out) def render( diff --git a/tests/__pycache__/test_output.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_output.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93c805c34b8519ddaf73cf273df90b50d9eeaee6 GIT binary patch literal 20413 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 Date: Thu, 23 Jul 2026 00:24:55 +0530 Subject: [PATCH 10/21] fix: --discover advertised positional args as flags for the config group The config commands were described by a hand-maintained parallel data structure (CONFIG_COMMANDS/HandAuthoredCommand) instead of being introspected, so the index claimed --product/--key/--value for 'config set', whose parameters are positional. An agent following the index would build a command line the parser rejects. Now introspected from the real Click commands, like every generated command. Each entry gains kind (argument|option) and a usage line. The duplicate description model is deleted so it cannot drift again, and a test cross-checks every advertised parameter against the real parser. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__pycache__/app.cpython-312.pyc | Bin 16180 -> 18553 bytes src/unstract_cli/app.py | 86 +++++++++++++----- .../__pycache__/config_cmd.cpython-312.pyc | Bin 12952 -> 12980 bytes src/unstract_cli/commands/config_cmd.py | 62 +++++++------ .../core/__pycache__/model.cpython-312.pyc | Bin 19779 -> 18602 bytes .../core/__pycache__/output.cpython-313.pyc | Bin 9689 -> 11924 bytes src/unstract_cli/core/model.py | 26 ------ .../test_cli.cpython-312-pytest-9.1.1.pyc | Bin 87744 -> 95110 bytes tests/test_cli.py | 59 ++++++++++++ 9 files changed, 157 insertions(+), 76 deletions(-) diff --git a/src/unstract_cli/__pycache__/app.cpython-312.pyc b/src/unstract_cli/__pycache__/app.cpython-312.pyc index 7a4dc871f8a0dd6144b794224fc2ca88af7134e9..9c694fca45915d8fb65f8bde130e63e15a2f4015 100644 GIT binary patch delta 6440 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 AA^-pY diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index fd70282..e945652 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -22,7 +22,7 @@ import click -from unstract_cli.commands.config_cmd import CONFIG_COMMANDS, config_group +from unstract_cli.commands.config_cmd import config_group 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 @@ -41,23 +41,34 @@ def _param_info(param: click.Parameter) -> dict[str, Any]: - """Describe one flag using Click's own introspection. + """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"), - "flags": info.get("opts", []), + "kind": kind, "type": type_info.get("param_type", "String").lower(), "required": info.get("required", False), "multiple": info.get("multiple", False), - "is_flag": info.get("is_flag", 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"] = str(info.get("name", "")).upper() if (default := info.get("default")) is not None: entry["default"] = default if choices := type_info.get("choices"): @@ -65,6 +76,26 @@ def _param_info(param: click.Parameter) -> dict[str, Any]: 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 + name = str(info.get("name", "")).upper() + if 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] = { @@ -74,6 +105,7 @@ def _endpoint_info(endpoint: Endpoint, command: click.Command) -> dict[str, Any] "summary": endpoint.summary, "product": endpoint.product.value, "api": {"method": endpoint.method, "path": endpoint.path}, + "usage": _usage_line(endpoint.command_path, command), "flags": [ _param_info(p) for p in command.params @@ -100,6 +132,10 @@ def _endpoint_info(endpoint: Endpoint, command: click.Command) -> dict[str, Any] 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") @@ -147,25 +183,29 @@ def discover( if node is not None: commands.append(_endpoint_info(endpoint, node)) - for hand in CONFIG_COMMANDS: - commands.append( - { - "command": "unstract " + " ".join(hand.command_path), - "path": list(hand.command_path), - # Flagged so an agent can distinguish local operations from API calls. - "kind": "local", - "summary": hand.summary, - "flags": [ - { - "name": p.name, - "flags": [p.cli_flag], - "required": p.required, - "help": p.help, - } - for p in hand.params - ], - } - ) + # 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)] if detail == "summary": diff --git a/src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc b/src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc index 4421276a27034543679122cf3fc936ba0a05873c..90377007d352da56f80623196408c23e788bef7d 100644 GIT binary patch delta 4627 zcmb_gdu&u$8NYYl9r^&;(g%H<4zy)xXIjd#tX*U~l=5(S6bg$}!}K!eOy|-&_m1b@ zVJHhbkkmwDV%9xzHAbWBD?xB$QvJg}jI73N;wuo1>)yl|SNBg7h-=~+{_y+GondHg ziGMgrzd4WZe6RESzVA%G%zns*e$~(rRp9f^ChgL}ZHn?&oK!z@M^V&&#Da?Qr7t8F zI`gfwt*YWv*p{5aBHvPJWC>^COG)vtwM#rwo5SsLJ_hq{vD^1txsyIa>y`quTWNGj zneBqz;HB}ZhT1pXKH}(8IQd_BxSo>|&8{g?(;lWx`daAr@`v@-fQnG~xXy2XK6Fxpiw@Dx4<^FYC z+`dBaTB`pq4qTusJy=Uo{|Zwks&T4)qmSU?zzP>XLI z5Z5wcXVnC_>~T8e;p5}8gX;(#rioQT*S9*k!s_Sgh$s9Lvpd!iK1z4hgiG*^tpUC` z-94|)?pzFHe5#TOT=!j9uPY@bzOVRMpZHwpUO5?!&Lt95w=>pd#^(rSc82R0nN6G= z%aeQ z=49X$9wh~h+l(hk$|OwVhF)eI*EGXmMn!kQ(Jhl$S!qC&ay{!XNiWg#hPQ(q0K)C! z(S}k(GmPRs-UMHKqqtGuvaub8ZXlh_I~+=lG9^$_?b0QHTfKU_n6c3+$t9M5UlToKO4*JO@$+$8_-SN5id85wZv84 z2P;>@i3Ef>!_FMK8WY#+`h?rm5F)U{`18`Md#1BM>)ub|!UrifT0VQCSc!u>c9H+0dkG7GjFu7J}G$^|Lc4g&`vqznEkmsm9z zZR00lJ1bsm`{fqbKhGR~9Oe-4UX<$xh>Nea$16b=zi$71(7zO+0r8!Vp-n;4Pkp9( zEp#nJeMOaq#9uo4RG;v7zTM-y9)2af=%ezxq?VK`{_{#njfb_pfF^OFeQ%lU2YhF> zqTxU>K;x!QpFDVW@{G*67pKlcZpd7zrV1p8X*ncoX!CZGOmapcZ*+ z4!|J~;^xyjeF~?n5n0_}Bx6`bnqWn&1yIU3xv1GJUE~Iw?jYor&kZ1-TMze!gEVH% z330bKHHaB4ljfG6x8hx>IerShZ9fn~Zpbv^9pd8FnEFNW>8-&{Bn8A)Q+iy+QsxlE$b>GMu{d=65(JFl zp0v>eIWZvMHsMhDL9mn4OnJDnsXI0^vcp~@2Udqc*C(>M!9&5eh=EUuXg?Mp-6V@v zkrNyGqd_2L9g8m}r7FG~@FO#``*CLSbBL)``Pf5Z>d!=q^o7pDE}s=SQZCQo5DTl9 zezJ>%OBy%fY4c?6d0Fumi6Y=^w`6zg_oQ@Fhz(y*}6gTr11WlRip8 zGA&N@ZCYWj#EAx}g9n9P5r zb2(Z|RJ|AECk`RI%mTSKhtqXWR{$kB*N`3qPo6GdNPIZFBNRg=FNnd!w3taWi|;4G z8@<;$uFJbf2{+MGei7#vkl>BT_q7|Wy^`H<<<}42hTHhslrK0pQvD^tx8oYNO_`t) z(fk@t@zUIIIt@Qa(&@P0RSSFzuD1b^Wo&eGH14H&WM3YB7>i?bEC4Mn_P$eR|tE;X1L6oSW2Z!LlGL7%3 zt@qW|SDD8#EUaAv>VSBCS6^NCgZcq=`-9V}xV!5o9i8{0oj1cDesDCbG#*!Fp1w8O I^08>-KbcfB3;+NC delta 4482 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 diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 1e2394c..1d06504 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -1,9 +1,12 @@ """The `config` command group -- hand-authored, not generated. 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, so they are -recorded in `--discover` too, flagged as non-endpoint so an agent can tell -local operations from remote calls. +layer. They are how a user or agent bootstraps every other command. + +`--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. @@ -26,29 +29,8 @@ starter_profiles, ) from unstract_cli.core.errors import CLIError, ExitCode -from unstract_cli.core.model import HandAuthoredCommand, Param from unstract_cli.core.output import OutputFormat, default_format, emit -#: Declared so `--discover` can describe the whole surface, not just the -#: generated half. -CONFIG_COMMANDS: tuple[HandAuthoredCommand, ...] = ( - HandAuthoredCommand("init", "config", "Create a starter config file.", - (Param("force", help="Overwrite an existing config file"),)), - HandAuthoredCommand("list", "config", "List profiles in the config file."), - HandAuthoredCommand("get", "config", "Show a resolved setting's value.", - (Param("product", required=True, help="Product block"), - Param("key", required=True, help="Setting name"))), - HandAuthoredCommand("set", "config", "Set a value in the config file.", - (Param("product", required=True, help="Product block"), - Param("key", required=True, help="Setting name"), - Param("value", required=True, help="Value to store"))), - HandAuthoredCommand("use", "config", "Set the default profile.", - (Param("name", required=True, help="Profile name"),)), - HandAuthoredCommand("current", "config", "Show the active profile and resolved settings."), - HandAuthoredCommand("path", "config", "Show the config file path."), -) - - #: Product names accepted on the command line, including config-file aliases. _PRODUCT_CHOICES = sorted( {*PRODUCT_KEYS, *(a for aliases in BLOCK_ALIASES.values() for a in aliases)} @@ -136,12 +118,22 @@ def config_list(output: str | None) -> None: ) -@config_group.command("get", help="Show a resolved setting, following flag > env > profile.") +@config_group.command("get") @click.argument("product", type=click.Choice(_PRODUCT_CHOICES)) @click.argument("key") @click.option("--profile", "-p", default=None, help="Profile to read from.") @_output_option def config_get(product: str, key: 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 + configured or not, never echoed. + + \b + Examples: + unstract config get platform org_id + unstract config get --profile cloud-eu whisper base_url + """ ctx = click.get_current_context() product = _CANONICAL.get(product, product) try: @@ -166,13 +158,29 @@ def config_get(product: str, key: str, profile: str | None, output: str | None) ) -@config_group.command("set", help="Set a value in the config file.") +@config_group.command("set") @click.argument("product", type=click.Choice(_PRODUCT_CHOICES)) @click.argument("key") @click.argument("value") @click.option("--profile", "-p", default=None, help="Profile to write to.") @_output_option def config_set(product: str, key: str, value: 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 + profile unless --profile names another. + + \b + Examples: + unstract config set platform org_id org_ABC123 + unstract config set whisper api_key 'env:LLMWHISPERER_API_KEY' + unstract config set --profile cloud-eu whisper base_url https://…/api/v2 + + \b + Prefer `env:VAR_NAME` for credentials: the file then records where the + secret lives rather than the secret itself, and a literal value also lands + in your shell history. + """ ctx = click.get_current_context() cfg = load_config() name = profile or cfg.default_profile or "cloud-us" @@ -257,4 +265,4 @@ def config_path_cmd(output: str | None) -> None: emit({"path": str(path), "exists": path.exists()}, _fmt(output)) -__all__ = ["CONFIG_COMMANDS", "config_group"] +__all__ = ["config_group"] diff --git a/src/unstract_cli/core/__pycache__/model.cpython-312.pyc b/src/unstract_cli/core/__pycache__/model.cpython-312.pyc index c9b3323be6e4510176754231cad3e0cd7ce452e4..057dcecf9d870618a30df956d572ff85a978c046 100644 GIT binary patch delta 270 zcmX>+i*eONM!wU$yj%=G5Ga_Km0&iJPlD+W<3^3AjMAzpN-bx>^Q+o1Uuc>Tc171$9^;YA0 c50qjA;^KnMYrS(Ac|Hm8b2Bxv7fA!f0IPINnE(I) delta 1172 zcmZvbO>7%Q6vt;aw&U3KN1VEGniP-Hka$xif=E&MXpo}RX#=P&qL*5N+VxEAMfR>W zyQXyyIJ5y#keWs#MBxC4TPYXh3rOuLMMC00TBBlt1dRkrMHJyQLcQ|dwp1KCho9g7 z&CGj$&&+n(414b-j_&{+^^)8i{A_Y4mm@`237Uuc)I_-$$FU zUbYawB7ReAV?B-MUO4CtuAzPf@rSxFGf3|?zM8p4>AEt~Lv%x3Oh2u*$0yas#Ll`e z;g!~F+fIq%B&xCScVDqp6P2SM#XCHy?fLyMkAF1sMp&BOo81ZojI_Dj#Qq@l9kVuO0{v^Tw^&Ym=t+S za=`;w(zaN3Y>XRr-g;BWEX(Ii#)X}i#wo!Z=y$qkl?1cvRXLx_qcl)DMybT0v16D8 zaN?HA6$JQp1kPa7hfcto&E}<5hQ?(JjG-TU+}$Ggg`*2=~7SA(}&5!?^%$$;|;-=<{jDYAii)LqqYws%Q;Irb{Cy=A~pbeK(4Fc>>j$N^x0%`9rNEH{zANuc>TYeKA;M- z9|qk5xL0l}?a*-G9|-Zh?Td|xLnS)86(d^k($G(|_r4aV``QEm&a^}u<-Mn(LGA5M G*WkZ)k45kR diff --git a/src/unstract_cli/core/__pycache__/output.cpython-313.pyc b/src/unstract_cli/core/__pycache__/output.cpython-313.pyc index eec24d6d013cd7427a67f936fe601e195b6e309d..f6a43cde3f045979a6fe08103fb0f5257ad739f3 100644 GIT binary patch delta 4891 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 w_-!dRyj?GNh(%s6n;nU`=M5NB_&5GV% Endpoint: return replace(source, params=(*source.params, *extra)) -@dataclass(frozen=True) -class HandAuthoredCommand: - """A command with no API endpoint behind it (IMPLEMENTATION_PLAN.md §1). - - The `config` and `completion` groups operate purely locally. They are recorded - so `--discover` can describe the whole surface while still letting an - agent tell local operations from remote calls. - """ - - name: str - group: str - summary: str - params: tuple[Param, ...] = () - description: str = "" - examples: tuple[str, ...] = () - - @property - def command_path(self) -> tuple[str, ...]: - return (self.group, self.name) - - @property - def dotted_name(self) -> str: - return ".".join(self.command_path) - - __all__ = [ "AtLeastOneOf", "BodyKind", "Constraint", "Endpoint", - "HandAuthoredCommand", "MutuallyExclusive", "Param", "ParamLocation", diff --git a/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc index 214d0ca5c733328bdc0523e89f8b06cd27c477ab..10b6d931299f619bda0fc4333ec78a22d87a4277 100644 GIT binary patch delta 7304 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 delta 2244 zcmZXU4^ULs6~^~_`}QsWSvDdd1X%@f7h}W-%Hkgkilype1(PNw;))BbU3THVr6|Ef zQnizb!GzoPrma)8ZETW>Ci-GtqPD@#)M|064yL1u{+VqoJ{GcJ4nOqv{q~qG5RS4f z{KnfekjkeFZA2yi{?NSaxv*H+59M-f5#d2XF26X`s$8`g^H2Ujg@<4G-CPv#{k5|g!zm^>7V`eF zg~&IC#_Fw@%2RGM=Xxl+n6gU=(^qK2oDe*B0NH9CbSR&giTx>ccG0>8+RM63cn%B9eW|z$v%4tPP3R@`5uPCU>1gW-%LpNg z5j^69GB&B7YBSMe5!{w&;A5K}irg5g+NOQfU2 zAKuv9>5sO@v`#}NtA&*#`+-jt_gOaGck=(EtIya4|GrHNulx{JbcEL0B2r?o)zmP1FY`AIn<*8lhZ zC3*X)q-hGQKQC^3C46BcFUYT z_MCKyS^c4C;t6(&z01*vD)H%ZgkXyGD{uyno1T@}XRCRG>bP?*3%b1hq^XS&!q|uX9Npfq*-`3n330wBOL&F>$t0_RCY|x0=w9gkOvO zk6>S-G^behD425t$#zQ%A3H$9U&W6{p(Z5qv%ib?AboK_~s z?(D>~Sxtq^CmbaVk?aqIW8$TCD8&JBVV!IqZa&eBfM#{WC$0tX-Fse>8r>#SKHp|J zIX%&tXDDx%jKx&?*nRAX47kxdi$?}?_=0HW#GoF311sG+8zJ#LLX|k(ib{-#+pQ?I zpQF$p!S_#|As%W&h9h25ytsJECDGFcfmh7TcAUD?&F^+#v-*NerB`W^*%ihF*x!=B z^|jR1Ip!}Sh+xegGT%%1IpIaZ%Y=i3*9pf7za?BCyid47@YDHx^=J%GjIdg?b;)KJ w5XZZ)QR5UI6eSG1QmKXG-!dBzCm8NlK8ARg;j#3sz??A)alA$qEjp(D2Tgcp<^TWy diff --git a/tests/test_cli.py b/tests/test_cli.py index bd5a226..e0c9871 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -224,6 +224,65 @@ def test_flag_is_discover_only(self, runner, cli): 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"] + ) + by_name = {f["name"]: f for f in entry["flags"]} + for name in ("product", "key", "value"): + assert by_name[name]["kind"] == "argument" + assert "flags" not in by_name[name], f"{name} is positional, not a flag" + assert entry["usage"] == "unstract config set PRODUCT 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="config set", detail="full")["commands"] + ) + product = next(f for f in entry["flags"] if f["name"] == "product") + assert "whisper" in product["choices"] + assert "llmwhisperer" in product["choices"] + def test_summary_is_the_default(self): """Full detail for 143 commands is ~50k tokens -- too much to read speculatively, so the cheap index is what an unadorned call returns.""" From 0e33f8c94a1bdc45cf742f4c25ced4358ce6d8c7 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Thu, 23 Jul 2026 01:28:50 +0530 Subject: [PATCH 11/21] refactor: model Unstract's three products; nest Document Studio APIs Unstract is the company and the CLI name. It builds three products: Document Studio (formerly named Unstract), LLMWhisperer and API Hub. - Product enum now has exactly three members; a new ApiGroup enum carries the five API surfaces, with the product derived from it. - platform/deployment/hitl nest under 'unstract docstudio ...'. - Config blocks nest by product: [profiles.X.docstudio.platform]. - --discover exposes product, product_name and api_group per command. Wire paths and UNSTRACT_* env vars are unchanged: the product was renamed, not the API. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/update-unstract-cli/SKILL.md | 16 +++ README.md | 34 +++-- SPEC.md | 47 +++---- src/unstract_cli/__init__.py | 7 +- .../__pycache__/__init__.cpython-312.pyc | Bin 352 -> 516 bytes .../__pycache__/app.cpython-312.pyc | Bin 18553 -> 19037 bytes src/unstract_cli/app.py | 18 ++- .../__pycache__/config_cmd.cpython-312.pyc | Bin 12980 -> 13124 bytes src/unstract_cli/commands/config_cmd.py | 9 +- .../config/__pycache__/loader.cpython-312.pyc | Bin 15549 -> 16519 bytes src/unstract_cli/config/loader.py | 124 +++++++++++------- .../core/__pycache__/generate.cpython-312.pyc | Bin 17559 -> 17831 bytes .../core/__pycache__/http.cpython-312.pyc | Bin 20911 -> 21051 bytes .../core/__pycache__/model.cpython-312.pyc | Bin 18602 -> 20342 bytes src/unstract_cli/core/generate.py | 23 +++- src/unstract_cli/core/http.py | 24 ++-- src/unstract_cli/core/model.py | 55 +++++++- src/unstract_cli/endpoints/__init__.py | 10 +- .../__pycache__/__init__.cpython-312.pyc | Bin 2342 -> 2569 bytes .../__pycache__/apihub.cpython-312.pyc | Bin 9051 -> 9048 bytes .../__pycache__/deployment.cpython-312.pyc | Bin 6557 -> 6605 bytes .../__pycache__/hitl.cpython-312.pyc | Bin 3622 -> 3598 bytes .../__pycache__/platform.cpython-312.pyc | Bin 49232 -> 49316 bytes .../__pycache__/whisper.cpython-312.pyc | Bin 10542 -> 10539 bytes src/unstract_cli/endpoints/apihub.py | 14 +- src/unstract_cli/endpoints/deployment.py | 19 +-- src/unstract_cli/endpoints/hitl.py | 18 +-- src/unstract_cli/endpoints/platform.py | 20 +-- src/unstract_cli/endpoints/whisper.py | 24 ++-- .../test_cli.cpython-312-pytest-9.1.1.pyc | Bin 95110 -> 99518 bytes .../test_http.cpython-312-pytest-9.1.1.pyc | Bin 38491 -> 38512 bytes .../test_model.cpython-312-pytest-9.1.1.pyc | Bin 47535 -> 47666 bytes .../test_poll.cpython-312-pytest-9.1.1.pyc | Bin 24285 -> 24295 bytes ...skill_docdiff.cpython-312-pytest-9.1.1.pyc | Bin 25063 -> 25068 bytes tests/test_cli.py | 35 ++++- tests/test_http.py | 14 +- tests/test_model.py | 36 ++--- tests/test_poll.py | 6 +- tests/test_skill_docdiff.py | 12 +- 39 files changed, 378 insertions(+), 187 deletions(-) diff --git a/.claude/skills/update-unstract-cli/SKILL.md b/.claude/skills/update-unstract-cli/SKILL.md index 5df7645..79ddb44 100644 --- a/.claude/skills/update-unstract-cli/SKILL.md +++ b/.claude/skills/update-unstract-cli/SKILL.md @@ -13,6 +13,22 @@ 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** (formerly named Unstract) | `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. Wire paths still say `platform`/`deployment` because the product +was renamed, not the API. + ## Sources of truth | Product | Documentation source | CLI file | diff --git a/README.md b/README.md index 246c917..47bf578 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ # Unstract CLI -A single, LLM-friendly command-line interface across the Unstract product suite: -LLMWhisperer text extraction, deployed API workflows, the Platform Management -API, Human Quality Review, and API Hub. +**Unstract** is the company, and `unstract` is the name of this CLI. It builds +three products, and this one tool covers all of them: + +| Product | Group | Covers | +| --- | --- | --- | +| **Document Studio** | `docstudio` | Document extraction platform — Platform Management API, deployed API workflows, Human Quality Review. *Formerly named Unstract.* | +| **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. @@ -93,7 +98,7 @@ so any combination works: ```bash unstract --discover # all 143, names + summaries -unstract --discover --group whisper # one product +unstract --discover --group docstudio # one product unstract --discover --command 'whisper webhook' # one subtree unstract --discover --command 'whisper extract' --detail full unstract --discover --detail full # everything @@ -149,7 +154,7 @@ Execute → poll → retrieve flows accept `--wait` so an agent needn't script t loop: ```bash -unstract deployment run --api-name invoice-api --file invoice.pdf --wait +unstract docstudio deployment run --api-name invoice-api --file invoice.pdf --wait ``` On timeout the CLI exits `7` and prints the job handle, so work can resume @@ -157,14 +162,17 @@ without reprocessing the document. ## Command groups -| Group | Covers | -| --- | --- | -| `whisper` | LLMWhisperer v2: extraction, status, retrieve, highlights, usage, webhooks | -| `deployment` | Run deployed API workflows; status; highlight data | -| `platform` | Prompt Studio, workflows, deployments, pipelines, adapters, connectors, groups, sharing | -| `hitl` | Human Quality Review: approved results, bulk download | -| `apihub` | Vertical extraction: bank statements, tables, document splitting | -| `config` | Local profile management (no network calls) | +| 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) | + +Document Studio exposes three API groups, each with its own host and +credentials, so they remain separate under one product. Every group and command has full `--help` with worked examples. diff --git a/SPEC.md b/SPEC.md index d2e68b6..4f97498 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8,21 +8,23 @@ ## 1. Purpose -A single, LLM-friendly command-line interface over the entire Unstract product suite: +**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 | Surface covered | -| --- | --- | -| **LLMWhisperer v2** | Text extraction, status/retrieve, detail, highlights, usage, webhook management | -| **Unstract Platform — API Deployments** | Execute a deployed API, poll status, fetch highlight data | -| **Unstract Platform — Management API (v1)** | Prompt Studio, Workflows, API Deployments, ETL/Task Pipelines, Adapters, Connectors, User Groups, Org Users | -| **Unstract — Human Quality Review (HITL)** | Push to queue, retrieve approved results, bulk download | -| **API Hub** (internal name: *Verticals*) | Vertical extraction (bank statement, table discovery/extraction), doc-splitter, status/retrieve | +| 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 was previously named Unstract**, which is why its APIs still use `platform`/`deployment` paths on the wire and `UNSTRACT_*` environment variables. The product name changed; the wire contract did not. + +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 four products that today have three incompatible authentication schemes and four different base URLs. +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. @@ -59,7 +61,7 @@ API Hub has **no public documentation site**. Its contract is derived from `Zips ## 3. Command tree -Grouped by product, then resource, then action. Verb-last, consistent across groups. +Grouped by product, then API group, then resource, then action. Verb-last, consistent across groups. ``` unstract @@ -73,12 +75,13 @@ unstract │ ├── usage-by-tag # GET /usage │ └── webhook {create,get,update,delete} # /whisper-manage-callback │ -├── deployment # Unstract API Deployments (runtime) +├── docstudio # Document Studio (formerly named Unstract) +│ └── 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 # Unstract Platform Management API v1 +│ └── platform # Platform Management API v1 │ ├── prompt-studio {list,get,create,update,patch,delete, │ │ export-project,import-project,sync-prompts, │ │ export-tool,export-info, @@ -108,7 +111,7 @@ unstract │ ├── user list │ └── share # POST /{resource}/{id}/share/ │ -├── hitl # Human Quality Review (Enterprise) +│ └── 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}/ @@ -130,7 +133,7 @@ Both LLMWhisperer and Unstract are execute → poll → retrieve. Raw sub-comman ```bash unstract whisper extract --file invoice.pdf --mode form --wait --output json -unstract deployment run --api-name invoice-api --file invoice.pdf --wait +unstract docstudio deployment run --api-name invoice-api --file invoice.pdf --wait unstract apihub extract --vertical table --sub-vertical bank_statement --file stmt.pdf --wait ``` @@ -271,7 +274,7 @@ Every failure emits a JSON object on **stderr** (even in `table` mode): "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 platform pipeline patch --active true`.", + "hint": "Activate the pipeline in the Unstract UI, or use `unstract docstudio platform pipeline patch --active true`.", "retryable": false } } @@ -344,7 +347,7 @@ Base: `{host}/api/v1/unstract/{org_id}` · Auth: `Authorization: Bearer ` (§6.2), which returns `QUEUED` + `execution_id`. +Pushing to HITL is `unstract docstudio deployment run --hitl-queue-name ` (§6.2), which returns `QUEUED` + `execution_id`. ### 6.5 API Hub (Verticals) diff --git a/src/unstract_cli/__init__.py b/src/unstract_cli/__init__.py index 8337c5a..791b89c 100644 --- a/src/unstract_cli/__init__.py +++ b/src/unstract_cli/__init__.py @@ -1,4 +1,9 @@ -"""Unstract CLI — a unified, LLM-friendly interface to the Unstract product suite.""" +"""Unstract CLI -- one LLM-friendly interface to the three products built by +Unstract: Document Studio, LLMWhisperer and API Hub. + +Unstract is the company name, and stays the name of this CLI. Document Studio +was previously named Unstract itself. +""" from unstract_cli.app import __version__ diff --git a/src/unstract_cli/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/__pycache__/__init__.cpython-312.pyc index 3486771554dfa12d6305d637cd13f079d9abee33..ed37f2c11f9ba003d844f1113b79d1809eaedf75 100644 GIT binary patch delta 278 zcmaFB)WX7dnwOW00SI0yBxX4s%_ zPXj6hss_4B58W!R^2B1G(^AVa^Gl0!Af}`!z+9$~SyG&ulcvWtan4G4F`#chFf%eT aeq>^0WckX+z^M6vTcSh1k-dlmC&TSq}orhD0wm_Q&@^F)|0O?v<3Jl};+%|5nea-PT zZ8Dc25pB}`fpJi~NoIew-6X22o409$ZDP_!sX&5Apk~&_OPb0y(42M>OQoWH=QyU_ zuH@hO&hLD$^PTV9>x~!DUtdNoZ`kO1_*u(+Zi@q^20=hKzM_ZRAJEU; zKAOJUC6$C@ZFJS)GD>Ap_fXXxrgI)U^3wNw?j0|PY4NC#7Qfvg2&cr7c+Ah7{_mp) z8}nxnvFagu-qT^QoHEd-yk08%j5O_ab(aj-Gy-}MO>U^)9!Y@lH0X^7I&ZTLiS)y2 zC(DPP^tPfN`kox5XT2U2p|5$jwnZ9xUypRm=dnRV>t@70+T!z}K3em7nr)lh0Otlc zXW!&vbji0B#c0{r)f#Vj!;Te}HLJ%3WTB`0oqk)MXt|+Je{k2gK&V>s3dv>)g^ZRX2N+?- zN+C@$^Ga39YpO~PGMhsT9%XQtUI>m^6cE$!E4)F!4BpdQGYRhYtFEq9SJzcnbk!AI z^UGKLu~mO;%@aEF#1Edh>giqe^wQBS`^M*0PA(u#1oGvY{mEcY`5+ zO46&SE@4mEbjnc@h_NJ;9N5cijbMgg=HoLFn~!gUUG2tx9Kb=m1-F+aSPijNJyiDt zy+eQ0|q|e!Yk@+JHPe z`{1F4OaV?^A#y5*<*~ULdAc|sTXNi?yUdiU%~(qpxZTn>_i7}o5Oq;zork}U5R)H1 zkt^_I@@h&y0$KwyR`RO0$iuP-3mIMIVR)++Vo?JLKxIi5pep4wU8&mcOsm>-V!57T zRJ^UZ)U=st)dX$7P@D%hI`d#t)$EDJmdHt$W@b!l z#hhMGq9v_mvyf`?2oz8uOYur?#Ep!k2#S@l-bDjCNMG;kK`Zo=zMzTaO`fEvKkQs# z;y43#Bgj!Y)IWnXy4c^jRb|9+fT)uPA$u9YLX+nitkB=~e*>hX>jn^b(pFlN>5a?SAbg%i9^(HCU0`tg@UuRa^r`I~RUAkx0JF@ODdn~m!!4>#R ztNX=AX=uHxO*TC@y&j>~*cXqTKvLMdaos66C(*`rlVHDhqIBS;^QO=!@yCw1~GdTn_0;ft_RwI`ElHB~62 z6;(|pqlo;5?Va7)`^k1da=rmWKo*=Z`_GE-XKCt7$!=_07XZjGSE=ZCJ%-S;m3McX zH<|K`TA|^tIg~>gQ}V#*I?-ldSLvp^A%>mraTjJmi})1DfAf)PXw3$ z!Pw6j{2d@_CPNLGyS{0G7m8exAA&bYy%+!4FsMrNnx=vUF9oN0vvP?SCE8G}`*spu zzSkJbYxub!EN@nl8}V}fpE)lq=U8Eq%vk$m;=tIk~nPaL9Mj|w0G}tJRu^}$5v0x$OQOOw)ksJTr>-IXRVow7Qy7cVP)dK M=cLN__x=n07yG$@F#rGn delta 1856 zcmZ8iZERCz6zvp$w9~U+@UOH@J|^*6Xb{SJ#Sf1Z*rgeoadbL zp40Q5q&FwYU&l$=C8yJF;?FU6VnptlEnDNYJGoh8GLeU8VY~N=L@iTh-3_X@7LNOz z@Pq7u8K0-oi&_7DbpO1)moSH31^0Y)#kPY*InziG1_J>SgwBAkUV4^?ajj-P`stTd zp$@zJi=ol)F|&mr`~C2d-$UwQmKS!dErz>(ADr@gpfpg9U-L+qpBf^~qCzuPC@FnX zp%pdg2>45!Pm~4lNuZuA5Qp6lxXRn-Yt!;qc~5Xz_QHhA1Ch$thz&lgY%m^G_7HLe zRtBRa07rrfd>#xh+rZQE_OTQjO>0^@n_-(!u+fy3Wa*()-ZGX|RknqTY%9VxgzZpO z-D4l(G|6A>G|=i5<<~sbS3QlFJ&jjA%4LrNUsP|jxt$^DaNk^wQB&h4_Ofu)SMjAg z5Dgm3L#<}3%9AI7*6k)E@LgTA`ELtc>|J8C$Zr&F%9-`$+%2A*1a~*3vpE)A}hpZJ>=bOq*ykZJDy>EVLD_hgaE^ z1mP|Aj;B>{TLMO;shw=@*qTWXr&Cm3I}nqHvrN`^r{t{}O=pRuE^FgyJtb#{-kW0LD)4SthC|+(%#LStHe!6!q7W%>$z=7E@u+p8h-?AY)>MCh z2Pq*|j0p=7N)TEQMi5?vkK1Zp7{z)K@ahV9pf4-t3w!m_}$$}Vh#=4{n=8{-P>4j1oqSwlmuLGgjO54Q_ z&W*@o*-VbYwxv7CJ211flT5>%r7~#%bJqvH_xZ7poqTY~J&V?6GkR*D&d$K4uI|oh z?#Vmj@uZs2v}8(E<8g(s8EhT*={eTPk)51}I(Q~hOHLX)BD)CD4P)8ck~E6W0}x+5 z6MBo&L1yNtlrjl*90WUx@Gi7<4-8@_>@0#9x{geYungHb4*BA-#DtnnP=t*;z>0M{U6YuArs#y7fH&8@bm(Wa6%LBTo=0v& z5PNb5negA{OtG;J6p)L0kL`Tt<(xd2&7=wmzn}{f@|K}&R^=IDVnP-t3MOWU6*2Ts z;XuBhV$8EBi&_7Q>}k4~`v20!{DpQ7g9R&8$Cea}n5p<}gBow_#R!7J~5Wgyv!`99EGm+r^zJTTwf~z-FeRTWr)ZWMV5vU`;gQ6DuL656$AM)NYcz4;@JV*jHp^`tOr}0ChLf A-2eap diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index e945652..1c8f914 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -103,7 +103,11 @@ def _endpoint_info(endpoint: Endpoint, command: click.Command) -> dict[str, Any] "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": [ @@ -216,7 +220,19 @@ def discover( envelope: dict[str, Any] = { "cli": "unstract", "version": __version__, - "description": "Unified CLI for the Unstract suite of products.", + "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": "Formerly named Unstract. Owns the platform, deployment and hitl API groups.", + }, + "llmwhisperer": {"name": "LLMWhisperer", "group": "whisper"}, + "apihub": {"name": "API Hub", "group": "apihub"}, + }, "detail": detail, "count": len(commands), } diff --git a/src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc b/src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc index 90377007d352da56f80623196408c23e788bef7d..fc2b03754d17a9fdbd4373156246234a8174de06 100644 GIT binary patch delta 3054 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 diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 1d06504..b6bf622 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -20,6 +20,7 @@ from unstract_cli.config.loader import ( BLOCK_ALIASES, + GROUP_PATH, PRODUCT_KEYS, ConfigError, ResolvedConfig, @@ -185,7 +186,13 @@ def config_set(product: str, key: str, value: str, profile: str | None, output: cfg = load_config() name = profile or cfg.default_profile or "cloud-us" - cfg.profiles.setdefault(name, {}).setdefault(product, {})[key] = value + # 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 = save_config(cfg) diff --git a/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc b/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc index f4102bc59905f0d38fc404b28027e4ba95ed205f..8345e0968c2055c61d558735468d8b00ffc91ed9 100644 GIT binary patch delta 4534 zcmaJEZA@F&_1)+9Y-4P`!5DBb3122)ibF_4NE*UN0tp`@L=0NyIlKorajqC7U9ZcGWg1Y1NKy-H&e4eoR`o{&-e8nY~S|@@vz6Y)(PbI&Ip{xetP2 z)1J(E=bn4M?z!jV@v|#0Es>nxI2<+so@r0;!rN`jIi=)p%XOjJOL-G{L=XivU$I^n z)ZA|29&*8Rj~MTX0yXzlQY2oeXAheT3$sFGqFAx1B}#S&FsQi~e5|Ujknmk%XLH19 z;Iaeu{M&n`oJ>7(F6_2$@8;))(WG(a#(4E_gil=v z0a(yO0D5)ld?XUq&FVrZ7LU;u_IznoaT9L)5Hurr0>RVly;4t808tdAK?LU!v>@OS z<8F`R62Vf{azfNq|}1b-t&8ZPY7MeAEA-+gnI6PKL2@Ht9Y< z8rib@kPFXF&mvF&=yH5M8dkkldWb!Am$jl>x^yW5F+im;D*(^tW87YLI1*Ggua7Rk z_77K^_hdLj<{IZmzjSjnNFgD0`O-xtM7P-~zoi-W z)xACK{LnGvHbHvp;8%W5Gqry5XZGFNF5+jO)VjnQ zA{(kHTP>`cB`i_zVIMZztusV1Ul-yo5$1>_{C^!W zaPD7i?0bG}Z;ZQIZLp>g&_$hX@t52{`yzYdrBOI9uhGWNk9W&~j zx)7zuBru=R>l|Il>JxVk-k85R3&`g&34~v8!R{^jOy?)C&u*D7# zCN}&av0)P&&Q}J%HhAaI%GgT(1ABAYB$e47I=bE*NL3$9Rv&z4=BM=I>JtwfU1=G1 z(qjseV)XZEnq)Gr#=R?6ecrmI`?VH?pXIHQjJhOz_6C2mJJA z8?~N=XLca?CITL*2E?|T;utPpLr@ODYo^PHe+PhWx~N{!Es8oFoDavj8r`DKT@G9h z(%r?#RhL{X7*S)9@MTr$;(gvXqsH?H`>5#=G~=K9A|k0^zLpm9I{R`w zXVEiEBg*_#+;E1oe-Am)vLT2V#1&=e+X%SZ+lnn0kRgXQ;ohzxGKDnWs&Llw#N}5B zNCZlbNn4>nu) zh$8DJ2s__;N>WUBP3(Sar%V*U*=Ma?7LEZm-Rgp`W%zPtue6rdm=()iQ6Y+zzq9U| z6q}N*aB7Gs_PZi99S3{n;C<$6^JHVPQtUIdf{nL%^1UXC-#oMg0ro>?ZF9GCt_oOP zb;1R=pBp?oI?~xS8t5GEJ9X|v;B?P8$Q7XowoU5V!mhTmhgKI$wfPekp57D(n{FLG z(>dBZG%{f1j;*_A_{`AwK+oW)E}iNdJ)_&toEaE9)pvHdXQXFjS1|RwSUd>*g$6dK zN{s#llpetr_G3V<2^*zCmH)l^l)o$K?@IamlJHA-`q%b&)+*Z{StNJChJBNxC+Y7= z`A;X|=Q+a}I)I^ai{bsIcbigvWJ-E^A8)$~9y#;MayF_3x9|RJ%GZ_jb)|fLN%&Rt zuazHsB%9nhX{%6OtAXBmU2Rg64lFv-QXM~ABH=Cn{Hn|T^|9Y&NI zFiQ5}k)iH$5M>DOS$Y~s^f&_iY%}U{95J3-INSMG0mV7Y0|Etvz4vN9uabmk7(V%+ zS_UI?DlZFNLKH>@FD^_sqqrnGfcOCfdl$QpU1`Mj^@^OxBty+~ofh)5&=gW3YYoRgBvl5(OfC3}*xXT_^YjhgS| z8k`F0TjT~!YSNnfIJqP#m!#y1q+D?)q)GK!L-%g-1x@m5`#X1&XEmu#^B(2o(xhCP zlJ_L#Ju6w7)R52`Pwl4LG-W}ZF_H%!GcM^wqfChFDwF;jmHES*XtfnJc|3yM< dX#wH7Nf5KsB1bQab;SJHD9Ondmn^)i{0BS$W)A=W delta 3922 zcmai0T~Hg>72aL#u7rLNKN1!oK*oSr48ngg;9%^4!MMf-V-gqHjv{jxEL%qON_K3< z8R5kF>(A8AO==#P*7jtkO`Xhi!b_%2`_`v6omFWoy3IqU({?hMP7SfA?R5IkbMAt{ zG~;TdPv@TdopaAU_nf=?`Ze*VR|yocr^(n}q?MVIZo@-#@&eCu}LX7%|Q}+{BCrm$yTkeM0=Jh@-+43ZM zZvK6f$eoHFwoO`*!=8j3EhmMrfcO6`P*pT8ktc>*RRWCCCxyX%_@2l>AO0sXlrms! zdbAw{!wReox&7LZlh-!P+2!lToW5>s@xLpsB#84X*xDHRm1|>mM-EzP%~r!qal#v| zBsGxE5T;WS4hm%Rb%E6C!Fx9AJfc;^7Oy5-fK!JYvV+uLm!magCuxBH5D7;&lU?K~ z(s*5o){@;s1ML>w&V4n3udVka%hPHtMeBgy)=iqhpdQ3_(gIS$ib(`aTK~r+OghLm zaNIZ{knMNHsCLDs?U}DtuS%lG-gBH6r z)`@pc+?imv3ieCWZR~zW`TU^MBdS3l(!l%~SHHN$i!v5BhaM2p3cz*2l-p?&*RxOD zixI3^O*G}Hc;;*x8zV%~)bytHOl&MQIi8q^;T+ut+5xuU35!kaPQdq;N168(Mr>8+ zz?4#H)2>fmO3>7#X`9e9R0TDSuvl^5ChpI*-d7h)4zrJo=ftM@(UPA_Vh5}7?*%;V zuk-TA+tC~$$!_>(fL2^qDD8X-wYw4808AyFp@}K#0(pJtYK-VqAE$$$&$8jbQ82#~ z2#6J1(Xthw&y>%kQb|+QFDKHOG}YKb`DTATPB$Qg5gHMWvx16H%Lx?Gkd7im5i|sz zNFMMp93p&!Jy+4E@`+>fcPsYWRLhWkR+SM?u+!Dy$Z?>JXbQ!D`X#=#Qi>U}7CXj1 ztllZsvAuqlgdO^G6zA z5#_7mgvbmn6d>wqv)>gzP!9;Fp^#ouuG-j8p^w$HI(NOKX0V(2=c<}1Ta{PM(&9Jk zuc~8`C>$4LApxk_nc38Fn3tR8re6mOD`;Lfydbb9P5E3ZF=?vl6wT9FR+zT)`V~_-lT3}BPuqd>s3h2U^A7u;K$HGlVBc&0{=p@O;13$5d*?3X zt6Ou`tv|AVRI*Ur`J2+cZ}cqsDsLUWdHA+%!B_W2@2B4K+4^^Vzx6gOZVJBTUa||m zF0d{td82?f&Ht|Dpp@lZgWJ|zR#ccv>PC1Cfp4f5rL|rk#Nj&#r2v{r-$eP_2n@iq z6MZ~>F`40R_%#%}6sH@Dhr2GDE*aL-spKV{^zgea0v+>Dfql4b87_%;x2Gi0$9%iC zXR!ySosrN;Dm0$ByeBj*Jsr}VR(L#{YjN{v&tf!~Ytv5ksbqXix6a^Opu(i%^rMKa zK$WwJ$xIqLY4Q?}m%a_8m9wz(Wa7bFhA2U1d5CvWi^p7GC^jM!g?-xA6SfhR zD04PqBX$C_#4#t60^%ehjuIkq%}LN=Zg!*neKy-sRe+UAeE_VpBjnD=#k!|BkQxmX7-e2-v{ejxLDISL6{L9mIzgm z_b2j^?p&lhAL-A*H+X2V@~LIT7ASn^T$%04MY{5l13CBxdlxH$ib=wip~~F`LuU|huTayi z@Hbs-6)yYeX?GSY(3Fp+CUs4+%9X!e@H&@iFPd&e;FXFJIB}z5Lw#GW*KsitsQQX_^t<@&M3DUg`&#F2ahm<0vo?DU6(1nHhrnat7cg&; zAENFafGPJM8X4d@Gu3B?4)s6VO?mDLdM}UZQ<+3+(#ljbTHuS9m8rF44x)}nyq3L} zg5xAJtyXZ$SgsKwKjJ>uuoc)Wiby*dP{5gEXT<}E} zypb6<)Pmy9%YmF6$jj9^x%#$y$tJZ(GakdMJrE>OS}NMWFhZk?vMVq9aV0$Q)GFyv+<($CfYoZ`9vMH}r<&>(~D~7BYTMuqjPaATR(cHaJeVIG&0`=>suARy&=x7$g-eS~` zKCXnI>x>3mZj~7WYQT_djk+wk1O}_1Pwf dict[str, Any]: f"Profile {name!r} not found in {self.file.path}. Known profiles: {known}" ) return {} - # The config file calls the LLMWhisperer block `whisper`, matching the - # command group users type, while the product enum is `llmwhisperer`. - # Accept both so a hand-written config is never silently ignored. - for key in (product, *BLOCK_ALIASES.get(product, ())): - block = profile.get(key) - if isinstance(block, dict): - return block + # Preferred shape nests the API group 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. + for candidate in ( + GROUP_PATH.get(product, (product,)), + *((alias,) for alias in BLOCK_ALIASES.get(product, ())), + (product,), + ): + node: Any = profile + for segment in candidate: + node = node.get(segment) if isinstance(node, dict) else None + if node is None: + break + if isinstance(node, dict): + return node return {} def get(self, product: str | Product, key: str, default: Any = None) -> Any: @@ -295,24 +320,33 @@ def starter_profiles() -> dict[str, dict[str, dict[str, Any]]]: """ return { "cloud-us": { - "whisper": { - "base_url": DEFAULT_BASE_URLS[Product.LLMWHISPERER.value], - "api_key": "env:LLMWHISPERER_API_KEY", + # 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", + }, }, - "platform": { - "base_url": DEFAULT_BASE_URLS[Product.PLATFORM.value], - "org_id": "", - "api_key": "env:UNSTRACT_PLATFORM_KEY", - }, - "deployment": { - "base_url": DEFAULT_BASE_URLS[Product.DEPLOYMENT.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": { - "whisper": { + "llmwhisperer": { "base_url": "https://llmwhisperer-api.eu-west.unstract.com/api/v2", "api_key": "env:LLMWHISPERER_API_KEY", }, diff --git a/src/unstract_cli/core/__pycache__/generate.cpython-312.pyc b/src/unstract_cli/core/__pycache__/generate.cpython-312.pyc index dad77e9acbc1dfaaa1d7206d3ef2a25683040550..12aae4db02607dacb0ecec1eb22125910a98a98f 100644 GIT binary patch delta 899 zcmZ9KO-vI(6vt<}OIx6{P+BdNg#opth@c?GNTGa*Xh14Tj6{Pgv&v$-TW5C#v`9^i z#;6H68ZREy#OP6@2NExyH8B!DXzEAQc=4k3^XzPm9-PDP|7PaR%>U)hm9y~HIp};` zR8#=i7_1T}J~z)heK2)5SYw$**ma5@sl(N{nCWUez@-fyjf zQ}lUjoQvojjk+uNWS;J(;Z}jmOXktNtv>6FHOWtS>HDHmdLdkFJH~_DywhI_m(zuC z1wRMR+vv;iD0I*bZM`n%=+5D;EklFD(b2xm{ZV?n?TGgUvr`8F(0;KX1>UU+rg}e8 z*nV*~EmXf2Rxb&Si$dcae!;U~U6^>fVe2zt+mAdBEt~7h+JV=<VEZ^?-cWlD&M7~6n8wB3UY#ng8Qv+lZd(F7c(4&qSaY@n9 zPTjz=iaw++A#ASAnv2quPQ)=?RuzQRYk zNFSF_^l$orsECK8+!LMAt@Q{?iI_Ug&aoMqRLS9kF?CX_N1}pJpAi=ow8Ic%vR*!o zhNPpiG}&OZFrWSX1G~p%Eg=z!(4Xxg`YAG4!)5ZBS1so}W1IZ5pRtMwdE7W)bg^Sp zj?~uvUn7w;)gWV%)^Mz8$WZ=SqAigiy&35VhPaIVZ@rmBOgK#Rn|N#Dw23neA)Y*?XFJ!ymU|C6s~~($UvjNdjFzxP5JGOtd@or1X`OOIm LJK#xMuBpEP3sM(5 delta 623 zcmZ49%{aZ2k?%AwFBbz4$k!!gnHWvvlVGgasIJ5mEtkUI!V;yGBA+4vq_tBNQUrmt zPKsiR5RleQQA!a8(t0V%DI!2xKSd=)6i6GSsHTVkX~Pup6p0kc6sZ*H6qz}!QAX(u zDYAvx<=a9O{AvMQ#g~VlP zqpKXo9~2oxK0dzT8JWcesYR(p3S~x<-`i`7S6K&@<|(A4 z7Ubktrlu%_<`tI|B_@|lzUiRO>jKmR6Hu7k>7XH4kds)FmS2>sP?TDnUs{x$I{B}I z`(z0Re{A*vn1yhTVg?GMro2JbI~NA;YC*_XFGmT zyA5JSfe0QD;R7P>fQU8_(GDauS&Gh2ZgnzbG}*k}NuH7M*5vEXxon`oD>^mV-$jk< zEszNeMuuX+$@MNDG{qQ&KA12Fcro4(^1mSvc7w<724CbyZZ>|V2JTNn4BQTkjqKoX F0RWuTyfOd) diff --git a/src/unstract_cli/core/__pycache__/http.cpython-312.pyc b/src/unstract_cli/core/__pycache__/http.cpython-312.pyc index dd9a0b7d2404cac98963ab4b7e86ba2f7bea2558..bc2f3281c4bc7897fe887dc8360fa120353b6c0f 100644 GIT binary patch delta 1931 zcmZ`)YfMvT814tOAmt!XxsP(Vw}^lhH*G*JmZBh~h)xq?*cm<8N@?*sEre$V@! z@4cMUixcF^BoV$7gm@LZs&l17b8|vhbXZU2cac z`F&2&DN7ESDq@e9ie8T_*4uZA2f4>tK|W^jcKiEek5BCI`5i8Ai6Z)(RDMFGh>BC9 zGBe9!gUlEem2RipCyOuoeWEONJH>8^QkSg6i|lgw6wy2A5qssKGEwo0-Bfn4@Gi-% zh!{rkx!i7%%8J)LAUni0{Vd+%2Vrq-dzxW7eR@q$XS%dwxyo|0$`Xoi48}ADa+6GiB*E@vkIv1yqc|Ci zs8%wwN1E>HvLTe*K%NPtrBtick!Y#k1^d#+i3ES9H#Zz+${}=VXI&dnJxo+3q?|Ax zH=lIA<38mcYYu5EMl83qYwskcg%gd_hKpqv%9cy3Z_@Mq)9j z4?44o)B}v33%s6{qb0pCQ8YpnC@g+i`vNMm5hR!`P9qx5Bl+FFNF0?9`(3QF)Cs#vOgemGMC0#wNIscXpl>pq zDzTEo5H86{>P15v0@j8Og4&RqG>o(mp$NePRfYoMh5d%k_;F^KU_YfDJ~tdlwtZB+ z7DhV(E8S3qAjq&*sLbU;7ECn<_&Z{1MipTWRIas6a!JDRowg-bb(NLIU#2u zs`3z-fxb#JDS_$A4P+92t~BaDLESclS_JMpjSN8#IUCJnIn$Z@3Lb7rP5KbUGYH)6ETnENB}bud zYb|Ti$kx*u{=@~yFjuDXf?Y=KX9!miuEGIxF`0+g%q8j{nPE#{*8Epg)(?y-Y;sip z=KLjU5stzaHDNLb=eFhNeahBGLg(4-N$m~PA{+~>Z0jaWY^k$kevDSEhA}!4RbCm< z^gF4f$TSS&b+zOq{90FE1Z(b8q#vAe#bvBMa(nr0Z9>hsih znQ}DHSKk-IlD*xSujRRQ;xn#6oV8Gc0y+aGtErZkr~}v42&W(ma6YFGX#FJ&PC^k8I5QhvQKikBWjsaFO6vUO`#7k#1Kq1+lw$?R-7!n zMbQXue8AwTNxB1BEe3J}wzq6re;gJ3fV}nmFt4DqW;Jg+?mv(48;rN;;qAz9;3<9x=spB&FU3SyNE`AbxWB8ii)WdFYyK|9A>%KUi0oMeoJPwcnyEKVvVfw5F!=QCvbJpT`Txu0Xs^Uw}EVF5nbW zZsx~8>Eqr!gce(7%?t`+5x8Iy885tMwS&ACJi*@|iy|>L_HV_)%)q$Kq?tl{2yWS` z$#qC;d#{rh@>R6*lk!6H^6^3r;0CYj8Du;@zMu3e0C4BtpFUa^H-bZ#1wDw~1 bH<;{JwVi}iZ7^)#tXW5_?H3&_ delta 1828 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 CS~`jV delta 4201 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 Na4%sMJ-73!@_&KYmGb}q diff --git a/src/unstract_cli/core/generate.py b/src/unstract_cli/core/generate.py index e4c3baa..1172a62 100644 --- a/src/unstract_cli/core/generate.py +++ b/src/unstract_cli/core/generate.py @@ -311,7 +311,10 @@ def build_group_tree(endpoints: list[Endpoint]) -> dict[str, click.Group]: for part in endpoint.subgroup.split(): existing = parent.commands.get(part) if not isinstance(existing, click.Group): - existing = click.Group(name=part, help=f"{part} commands.") + existing = click.Group( + name=part, + help=_SUBGROUP_HELP.get(part, f"{part} commands."), + ) parent.add_command(existing) parent = existing @@ -320,13 +323,23 @@ def build_group_tree(endpoints: list[Endpoint]) -> dict[str, click.Group]: return groups +#: Top-level group help. One group per product built by Unstract. _GROUP_HELP = { - "whisper": "LLMWhisperer v2 — convert documents to LLM-ready text.", - "deployment": "Run deployed Unstract API workflows and check their status.", - "platform": "Unstract Platform Management API (v1) — manage platform resources.", - "hitl": "Human Quality Review — retrieve approved results (Enterprise).", + "docstudio": ( + "Document Studio — document extraction platform " + "(formerly named Unstract). 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).", +} + __all__ = ["build_command", "build_group_tree", "GLOBAL_FLAGS"] diff --git a/src/unstract_cli/core/http.py b/src/unstract_cli/core/http.py index 1503d63..867a982 100644 --- a/src/unstract_cli/core/http.py +++ b/src/unstract_cli/core/http.py @@ -25,7 +25,7 @@ redact_headers, redact_value, ) -from unstract_cli.core.model import BodyKind, Endpoint, ParamLocation, Product +from unstract_cli.core.model import ApiGroup, BodyKind, Endpoint, ParamLocation #: 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 @@ -82,17 +82,19 @@ def describe(self) -> dict[str, Any]: } -def auth_headers(product: Product, config: ResolvedConfig) -> dict[str, str]: - """Build product-specific auth headers (SPEC.md §4.4). +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 Product.LLMWHISPERER: + case ApiGroup.LLMWHISPERER: return {"unstract-key": config.require(product, "api_key")} - case Product.PLATFORM | Product.DEPLOYMENT | Product.HITL: + case ApiGroup.PLATFORM | ApiGroup.DEPLOYMENT | ApiGroup.HITL: return {"Authorization": f"Bearer {config.require(product, 'api_key')}"} - case Product.APIHUB: + 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"): @@ -100,13 +102,13 @@ def auth_headers(product: Product, config: ResolvedConfig) -> dict[str, str]: if key := config.get(product, "anthropic_key"): headers["X-Anthropic-API-Key"] = key return headers - raise CLIError(f"Unknown product: {product}", ExitCode.GENERIC) # pragma: no cover + 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 Product: + for product in ApiGroup: for key in ("api_key", "llmwhisperer_key", "anthropic_key"): try: if (value := config.get(product, key)) and isinstance(value, str): @@ -123,10 +125,10 @@ def build_url(endpoint: Endpoint, config: ResolvedConfig, values: dict[str, Any] inconsistent about trailing slashes and about `profilemanager` vs `profile-manager`, and "tidying" a path yields a 404. """ - base = config.get(endpoint.product, "base_url") + base = config.get(endpoint.api, "base_url") if not base: raise CLIError( - f"No base URL configured for {endpoint.product.value}.", + f"No base URL configured for {endpoint.api.value}.", ExitCode.USAGE, hint=( "Set --base-url or the corresponding environment variable. " @@ -177,7 +179,7 @@ def build_request( endpoint=f"{endpoint.method} {endpoint.path}", ) - headers = {"User-Agent": USER_AGENT, **auth_headers(endpoint.product, config)} + headers = {"User-Agent": USER_AGENT, **auth_headers(endpoint.api, config)} params: dict[str, Any] = {} body: dict[str, Any] = {} form: dict[str, Any] = {} diff --git a/src/unstract_cli/core/model.py b/src/unstract_cli/core/model.py index 9ebd3a7..94ef7ac 100644 --- a/src/unstract_cli/core/model.py +++ b/src/unstract_cli/core/model.py @@ -39,15 +39,54 @@ class Product(str, Enum): - """A product surface. Determines base URL and auth strategy (SPEC.md §4.4).""" + """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 was previously called Unstract itself, which is why its APIs still + use `platform`/`deployment` paths on the wire. + """ + + 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).""" @@ -274,7 +313,9 @@ class Endpoint: group: str method: str path: str - product: Product + #: 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 @@ -301,6 +342,16 @@ class Endpoint: #: Response key holding the payload for ``--output raw``. raw_field: str | None = None + @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")``.""" diff --git a/src/unstract_cli/endpoints/__init__.py b/src/unstract_cli/endpoints/__init__.py index cf96346..abb4694 100644 --- a/src/unstract_cli/endpoints/__init__.py +++ b/src/unstract_cli/endpoints/__init__.py @@ -38,9 +38,13 @@ def get_endpoint(dotted_name: str) -> Endpoint: ) from None -def endpoints_for(group: str) -> tuple[Endpoint, ...]: - """All endpoints in one product group.""" - return tuple(e for e in ALL_ENDPOINTS if e.group == group) +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/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/__init__.cpython-312.pyc index 8f40ab128624c281aa333af1de69f3a7999218f3..7c925a1b196a115b1e300bf54e5e49a7999b046b 100644 GIT binary patch delta 527 zcmYL^PiPZC6vp36(%q6KwJoKELiIHVaiLj-;<1=0DE^7)p1LX2+<(r@wgd{T zcq;TDtXg_dL_sVfp|@T=_fp6$XAhpMhxX=dsQ59wH+=KWZ{B>F*q$6cDi-sg)w#sl zpN)f2!`S>;^*rK1BMRL>NeTnKI8`6rB^irV=J0vqcGISMy%c#&t%kxc)$0_B)bTuj zz2(Y?3of*4=kug>+w~L|l2$qcPE&+&M1rXn7o=J&ASd*F7BnbgYO^&@E8JnR#6AxcQ?8y*CqmI=fh&1~n1*XoxjYzK2PMfc77iypRQkL1SEw_J`J(7jrAy&)C=c$i;N>%~-s1RdeK=E~mfXbPJBhDtoopFTOtRF6}RM zm-m+sZ}rMI-i+_n&g8I8y}D@OC(}X=9a3@RaZ%8&g^6WaGgB#M3;qt-UDoNX1!0F_Jd4%1hJ^XRjz$%_hz{uiD^X%k0%-ut2=-WRoPu{$c`wRGDmlps4 delta 293 zcmeAaSti7HnwOW00SM&l60#n#ZscQQ^HO%q$x%qnODV|D%quBY$jnp7&r4M(D9TSM zO)gPLFUl`1&`V|nX@UY~AZ^UR!1&n-$e7CL&A`YoouP&yR&267yVPV~c1|tf8pdgi z(-|2VdL&pFDjC)?mosWI`mJQV#R@h~llc~tp201~)FO$=t?agr+CX!PWr4(x7Ysid z7%m9d&Gem6xFT?U_{#7dte16drwGpU{aMTbRM9LV&p3l!o>7zO7HdgqK~8EB4^VV6 zCx@nl2*`j2h9}$tmj&$R`T~i|+;$gO>?Zqg7&A&tZs0i0xNfo!=K>ifM*B}(4BWPj X>_0#0GAMd67PA4h9#j)?;%5N>1#M2v diff --git a/src/unstract_cli/endpoints/__pycache__/apihub.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/apihub.cpython-312.pyc index a855cd059cd6ba7eb8d97eb6abe9693995382ebf..4e072367c717ad4b911d2c521d9ed017ea2582a1 100644 GIT binary patch delta 844 zcmZ8eOKTHR7@c=+rWqS;#~31-HcchgRIvDridJb6)M)9(R!lfB>kw4%{1Vy)s8`lLlE?kP=+{uv4%wjnAJLjJ7eE0h~@_oemY+0s6UnO0d z@2gozF&K!nfL>;rPxvV59Qkm73lLdxO#IfGlP*+<7y3h?fRNIJzv#GAMRJC7r zf?*4`2Fpm7O&*i8Izu0!Jp%O;ni6QcK#6SwPOd!*b|8zz8sg1z};HPd0>940V{^JR^HP*b}ZE?^u}C^^%C$!9Q5`vL8z>{J5G zEV;)uTaNO6ElXKWYN?z)Kj^KX{ZHGBV4*I1wTevV4*p0Rq6`-)!wuXN8SKa{+;%t5 z*R(uwcLlfPamjlwgUgihj|&U~{oqUSMLlmBSSFVhn)9LO6I~TPt?X-h zR@Qmk7$B{iW`*)R@wEC=k!IS+5%;3Wy=>-rC48Pyp69_ljgQ8Al^w0ljb6EQt;(;O zV~wxo+nA=7s&kE{#EGa8>s~EBDDrRVA3l87Gwj2^Jyjnr^iKHjOK-u4R_6472%g0! delta 849 zcmZ8eOK8+k6rFtEByFbA8sjjcZO5sMbt+iuZ&aj8L8v-PH?~4+=S+)5w2FT#)s3rG z9Uof3&%&h%xG3&jiK5`v&&GAZjqb&pmkBdT7UA4`PR>0y_uKH#Ve7MHnJIegcN4RN zbqhHL9c(6r^lJ%*Z+dFLrc!dM#_EgNGD8P!tnt@+N|Qh**1<{i*2DETcyd!!|K+$0 zo3J_FMpwP4kVNZNqJ4qQ?f&ZUdeg#8^Ym)>PYCvf6KPS_&+&I`eW4Fs02Hm zXt3i-hN_cc)p065n?F|aZ5mEv4C6RcYvb>g+#tgQMlp%A{#0!9Dn{u(p!?MP=@>IZ z?zZYkY2K?AD9ebNE2^{o;R#ybbWI)SJt^!>OH`Zukvc#bE>MQ+xFIq)>?Upnm*=Z$ ziMR#9-3__yy@0_b%6QwKZ~c?JqGdZ7s<lusSr=D^I&HVBIfZ@eF diff --git a/src/unstract_cli/endpoints/__pycache__/deployment.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/deployment.cpython-312.pyc index 3db6c99363b078bf6a5c692b573d80db0a9bfb4d..d08a2401627e69ada6c35815b69825a8779f4e4f 100644 GIT binary patch delta 1306 zcmZ`%OHUI~6rMZnOdr!h3Z2?o+EU9K1hrs6iaZn%@rfdWtI8P)4W&&V3*FQhe}H9N zm>4#0XN-&4_!CTwiAIgJF5S7pXyQ`u%ygJi?Va@A)9-%gJKw$c>_*;3y5F#@oxo$g zJ)X?@2;!UaM<1LPpr4DS?itDg6mKnUcjCZ?gQroJhGuX=3$%iZHw$|v&dGs0LGbKz zL=wCO4)eAh#1*Z{4qee$!44kqN=HOm!%A9;k8i|o1HTjysRQgFZ%cL=ntF6lqo~7x zpdCWe(YlghEYhQMjP;<1jzb4@N?mm}x*KqNq+ZcPX!Wb->%+zRbTMOXAkZ%jh-Qsd z%|C|o2aG&DYLGvuiNoP-Dr!wCND(kaoR z?eokt0;0Plj7no7t6`W=19Mt!l5xFBs2EJZ8DZEIDo*a2YT9Mw|968sWlEZ^yN3pk zhM#9fI$Os*hZSa}^Y|&$p&m~$I~TxiFG%wuQ{(nx)$D@S(Zx$}xyIrZEOk}7W=N@h zejTHW|DZQ8db1YAx2lv~!c}h>Ro6|cURdUv;C2k#xVO@~T399tVre^<&&Jo}N9GOE zI-S{^%cct%;X6G^-j4{c%@H|ap^@DN>9t(G0Gnxf%bFl*KbFV_gfDj9)ux0FJXI{f zug42pdAza6++p@NvLWFWvxC}%1&7S|_LTv`i!E&_kJ?gR@(>3^5JW)42O>TYA5lU)12_@djAfw(Y%z-Lder!!Sc>?__e%oco>ceCOPI=aaB4w0-n=+yqD0 z`$Z`jA>>E?!yJCYpnqmI+a_2JSkbk)ock2uX04i*=#pAY+nT`*K?tegENA#O4UT#{Ih^N6}{K&UKEkYtGJFL<}DG+j@x0lAy&h}2)66-&5m2zQ)ik{cDPYq zcg@L3*tnm5A}F7og0}ABsFtZ%X1E5#%8klTx9G?J0Fm=sq`N>|UfOt=#s%Q-@Ouk# zRQbTaq!HzAl}3G9#Pg0Os&wc2(weBVD{^uprE_aydNm2UGqsdn)mWyh^aGcU!WjfRuU8Mai_gkWb_2fK(Ts30-cC4}$deJn{h?tQABxFKH_vnJgfY|H~%F`N*Q?`Rs07YBqAVLa6 zNP`Gj5CQUbk^E*)ZY4&^9gGYNAD9^#89y{!&Jcc;A$)QRkEfvI4MBw) z{317at#9y%^!s-DPQJ$z1QHcT6}987l{b}TW&3Euz{-1rSMCNMT#XY<=XG8aX<2?& cwhzUOo{Y*&4V)La<-ag^GBPq1nFAdJ04gws4FCWD delta 685 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 diff --git a/src/unstract_cli/endpoints/apihub.py b/src/unstract_cli/endpoints/apihub.py index 103fcd2..ea3a269 100644 --- a/src/unstract_cli/endpoints/apihub.py +++ b/src/unstract_cli/endpoints/apihub.py @@ -19,6 +19,7 @@ from __future__ import annotations from unstract_cli.core.model import ( + ApiGroup, BodyKind, Endpoint, MutuallyExclusive, @@ -26,7 +27,6 @@ ParamLocation, ParamType, PollSpec, - Product, ) _SRC = "unstract-verticals/src/api_v1/api.py" @@ -96,7 +96,7 @@ group="apihub", method="POST", path="/api/v1/extract", - product=Product.APIHUB, + api=ApiGroup.APIHUB, summary="Submit a document for vertical extraction.", description=( "Processing runs in stages: QUEUED_FOR_WHISPER -> " @@ -142,7 +142,7 @@ group="apihub", method="GET", path="/api/v1/status", - product=Product.APIHUB, + api=ApiGroup.APIHUB, summary="Check the processing status of a submitted document.", description="Statuses: QUEUED_FOR_WHISPER, QUEUED_FOR_EXTRACTION, COMPLETED.", params=( @@ -156,7 +156,7 @@ group="apihub", method="GET", path="/api/v1/retrieve", - product=Product.APIHUB, + api=ApiGroup.APIHUB, summary="Retrieve extraction results for a processed document.", params=( Param("file_hash", required=True, help="Hash returned by `apihub extract`"), @@ -174,7 +174,7 @@ subgroup="doc-splitter", method="POST", path="/doc-splitter/documents/upload", - product=Product.APIHUB, + api=ApiGroup.APIHUB, summary="Upload a document for splitting.", params=( Param("file", type=ParamType.FILE, location=ParamLocation.FORM, @@ -190,7 +190,7 @@ subgroup="doc-splitter", method="GET", path="/doc-splitter/jobs/status", - product=Product.APIHUB, + 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", @@ -202,7 +202,7 @@ subgroup="doc-splitter", method="GET", path="/doc-splitter/jobs/download", - product=Product.APIHUB, + api=ApiGroup.APIHUB, summary="Download the output of a completed splitting job.", params=( Param("job_id", required=True, help="Job identifier from `upload`"), diff --git a/src/unstract_cli/endpoints/deployment.py b/src/unstract_cli/endpoints/deployment.py index 1970bf7..a0acaf4 100644 --- a/src/unstract_cli/endpoints/deployment.py +++ b/src/unstract_cli/endpoints/deployment.py @@ -15,13 +15,13 @@ from __future__ import annotations from unstract_cli.core.model import ( + ApiGroup, BodyKind, Endpoint, Param, ParamLocation, ParamType, PollSpec, - Product, ) _DOCS = "unstract-docs/docs/unstract_platform/api_deployment" @@ -48,10 +48,11 @@ ENDPOINTS: tuple[Endpoint, ...] = ( Endpoint( name="run", - group="deployment", + group="docstudio", + subgroup="deployment", method="POST", path="/deployment/api/{org_id}/{api_name}/", - product=Product.DEPLOYMENT, + 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 " @@ -84,7 +85,7 @@ ), body=BodyKind.MULTIPART, poll=PollSpec( - status_endpoint="deployment.status", + status_endpoint="docstudio.deployment.status", status_field="execution_status", terminal_success=("COMPLETED",), terminal_failure=("ERROR", "STOPPED"), @@ -101,10 +102,11 @@ ), Endpoint( name="status", - group="deployment", + group="docstudio", + subgroup="deployment", method="GET", path="/deployment/api/{org_id}/{api_name}/", - product=Product.DEPLOYMENT, + api=ApiGroup.DEPLOYMENT, summary="Check the status of an execution and retrieve its result.", description=( f"Execution statuses: {', '.join(_EXECUTION_STATUSES)}.\n\n" @@ -131,10 +133,11 @@ ), Endpoint( name="highlight", - group="deployment", + group="docstudio", + subgroup="deployment", method="GET", path="/deployment/api/{org_id}/{api_name}/highlight/", - product=Product.DEPLOYMENT, + api=ApiGroup.DEPLOYMENT, summary="Fetch line coordinates for highlighting extracted values.", description=( "Requires 'Enable Highlight' on the exported tool. The whisper_hash " diff --git a/src/unstract_cli/endpoints/hitl.py b/src/unstract_cli/endpoints/hitl.py index b8dc264..635abe1 100644 --- a/src/unstract_cli/endpoints/hitl.py +++ b/src/unstract_cli/endpoints/hitl.py @@ -8,7 +8,7 @@ from __future__ import annotations -from unstract_cli.core.model import Endpoint, Param, ParamLocation, ParamType, Product +from unstract_cli.core.model import ApiGroup, Endpoint, Param, ParamLocation, ParamType _DOCS = "unstract-docs/docs/unstract_platform/human_quality_review" @@ -30,11 +30,11 @@ ENDPOINTS: tuple[Endpoint, ...] = ( Endpoint( name="get", - group="hitl", - subgroup="approved", + group="docstudio", + subgroup="hitl approved", method="GET", path="/mr/api/{org_id}/approved/result/{class_id}/", - product=Product.HITL, + 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 " @@ -56,10 +56,11 @@ ), Endpoint( name="bulk-download", - group="hitl", + group="docstudio", + subgroup="hitl", method="GET", path="/mr/api/{org_id}/approved/result/{class_id}/", - product=Product.HITL, + api=ApiGroup.HITL, summary="Page through approved results, optionally including file content.", description=( "Unlike `hitl approved get`, this is a paginated read. With " @@ -85,10 +86,11 @@ ), Endpoint( name="download-status", - group="hitl", + group="docstudio", + subgroup="hitl", method="GET", path="/mr/api/{org_id}/approved/download-status/{job_id}/", - product=Product.HITL, + api=ApiGroup.HITL, summary="Check the status of an asynchronous bulk download job.", params=( _ORG, diff --git a/src/unstract_cli/endpoints/platform.py b/src/unstract_cli/endpoints/platform.py index f8cc4c0..1abdaed 100644 --- a/src/unstract_cli/endpoints/platform.py +++ b/src/unstract_cli/endpoints/platform.py @@ -23,6 +23,7 @@ from __future__ import annotations from unstract_cli.core.model import ( + ApiGroup, AtLeastOneOf, BodyKind, Endpoint, @@ -31,7 +32,6 @@ ParamLocation, ParamType, Permission, - Product, derive_patch, with_params, ) @@ -92,11 +92,12 @@ def _ep( """Construct a Platform endpoint, filling in the org-scoped base path.""" return Endpoint( name=name, - group="platform", - subgroup=subgroup, + group="docstudio", + # Every Platform API command lives under `docstudio platform ...`. + subgroup=f"platform {subgroup}" if subgroup else "platform", method=method, path=f"{_BASE}{path}", - product=Product.PLATFORM, + api=ApiGroup.PLATFORM, summary=summary, params=(_ORG, *params), body=body, @@ -859,11 +860,11 @@ def _ep( #: Not org-scoped, unlike everything else in this module. _OAUTH_CACHE_KEY = Endpoint( name="oauth-cache-key", - group="platform", - subgroup="connector", + group="docstudio", + subgroup="platform connector", method="GET", path="/api/v1/oauth/cache-key/{backend}", - product=Product.PLATFORM, + api=ApiGroup.PLATFORM, summary="Generate a cache key to associate an OAuth flow with a connector.", params=( Param("backend", location=ParamLocation.PATH, required=True, @@ -936,10 +937,11 @@ def _ep( )), Endpoint( name="share", - group="platform", + group="docstudio", + subgroup="platform", method="POST", path=f"{_BASE}/{{resource}}/{{id}}/share/", - product=Product.PLATFORM, + 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 " diff --git a/src/unstract_cli/endpoints/whisper.py b/src/unstract_cli/endpoints/whisper.py index 3d57147..c030d32 100644 --- a/src/unstract_cli/endpoints/whisper.py +++ b/src/unstract_cli/endpoints/whisper.py @@ -8,6 +8,7 @@ from __future__ import annotations from unstract_cli.core.model import ( + ApiGroup, BodyKind, Endpoint, MutuallyExclusive, @@ -15,7 +16,6 @@ ParamLocation, ParamType, PollSpec, - Product, ) _DOCS = "llmwhisperer-docs/docs/llm_whisperer/apis" @@ -101,7 +101,7 @@ group="whisper", method="GET", path="/get-usage-info", - product=Product.LLMWHISPERER, + api=ApiGroup.LLMWHISPERER, summary="Show usage metrics for your LLMWhisperer account.", doc_source=f"{_DOCS}/usage_api.md", examples=("unstract whisper usage",), @@ -111,7 +111,7 @@ group="whisper", method="POST", path="/whisper", - product=Product.LLMWHISPERER, + api=ApiGroup.LLMWHISPERER, summary="Convert a document to LLM-ready text.", description=( "Accepts PDFs, scanned documents, images, Office documents and " @@ -143,7 +143,7 @@ group="whisper", method="GET", path="/whisper-status", - product=Product.LLMWHISPERER, + api=ApiGroup.LLMWHISPERER, summary="Check the status of a conversion.", description="Statuses: accepted, processing, processed, error, retrieved.", params=( @@ -157,7 +157,7 @@ group="whisper", method="GET", path="/whisper-retrieve", - product=Product.LLMWHISPERER, + 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 " @@ -183,7 +183,7 @@ group="whisper", method="GET", path="/whisper-detail", - product=Product.LLMWHISPERER, + api=ApiGroup.LLMWHISPERER, summary="Show processing details and metadata for a conversion.", params=( Param("whisper_hash", required=True, help="Hash returned by `whisper extract`"), @@ -204,7 +204,7 @@ group="whisper", method="GET", path="/highlights", - product=Product.LLMWHISPERER, + api=ApiGroup.LLMWHISPERER, summary="Retrieve line metadata (bounding boxes) for highlighting.", description="Requires the conversion to have been run with --add-line-nos.", params=( @@ -219,7 +219,7 @@ group="whisper", method="GET", path="/usage", - product=Product.LLMWHISPERER, + api=ApiGroup.LLMWHISPERER, summary="Show usage metrics filtered by audit tag.", description="Without dates, usage covers the last 30 days.", params=( @@ -236,7 +236,7 @@ subgroup="webhook", method="POST", path="/whisper-manage-callback", - product=Product.LLMWHISPERER, + 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 " @@ -262,7 +262,7 @@ subgroup="webhook", method="GET", path="/whisper-manage-callback", - product=Product.LLMWHISPERER, + 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", @@ -274,7 +274,7 @@ subgroup="webhook", method="PUT", path="/whisper-manage-callback", - product=Product.LLMWHISPERER, + api=ApiGroup.LLMWHISPERER, summary="Update a registered webhook.", params=( Param("webhook_name", required=True, location=ParamLocation.BODY, @@ -295,7 +295,7 @@ subgroup="webhook", method="DELETE", path="/whisper-manage-callback", - product=Product.LLMWHISPERER, + 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", diff --git a/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc index 10b6d931299f619bda0fc4333ec78a22d87a4277..26b6372db22ca5d25bfef22f6ea5bba67afc89ed 100644 GIT binary patch delta 4102 zcma)94^SNC72mgax3_n2>;VTHaD^+iUiPIKN4e_Pjq zX|6dKXz{no1!%NL!Dc1YCHaFR=HGQgZ(YAKurVoI7w7B7%IYY_sa#f<5b7p*JSJu> zx|+rWPa}0LIVXzEceCzquN(+pJjJr8y2(B?hUmB z>hAZt^4q&m@n>#H>YElbWi_V_JwV&qwNm^#1sY9pggn+3tjJSDbH* z?`=e^p~~LIgwQz2<1s-^nnhQWnySlVT8u8w$8o-Kq+*&iWN4G-wMnb%&JUVXx_ar{ z!jY@zUonELj{fl(I5x8X<9wT0Tiz~Ek4K6}24~iCD?2c<8zGEvKf)1&?;{*V=tFn_ z;W)x0s_}{+>eaxN5@;IfzjB-{GU|(rmq%Vw{-3+Sux8f1u%NxyhlZ>&`{Om;~I}ETT>qp4)7zM>bFBjRl81l%70XmFL$?Scx zB}VuG%jrI`7dQZ}au5SfB0PogG{Q3oUcCI<5N<;_fwmLmqfDrT6EV9DR-3rz(DrO> zdk(B)i(a@!&?&T?B0~;v!6`z63PDn<4*85dc6WAcRUT|KLm;NO;3W`7sqeJ8-m`7T z&YD}~mtyM|Kn2HXtMXzjum~ci0$rhgZiBvDFCzbMW3Uu_ob4Nf9WE_d5}e%OQ4W5wlb38DfP2QD(sRm%16u* z{$M%7L@f~ud2l%_wpdy~;?PW9UJj?puA9L^?ktBV)e9L`b>C^L`eBJfohh)8U2Z5R zXY-*{t;)1!TTYt89Obb}Rw*N5^#f^i!j^)}!w@yn(?m~>o@RPlqP)7>xqw8sf|IA^ z*q(yZELl+pMda-S{ZgJN6J-)KDjLqE3nZMnGBL6oWU^n7L#foitN!GDKBcHG&%P% zQOZt+ZiIyy`4MCOlu7J4u@n3iF^nm%@B57gJv#x9E)hC&S9q#lD$3J zqGYF51+Q1g7m#lR_4&}Pt~2QS2nBbZybk5{2sa^YCsiw8nR(izv<15ZDkKXtX zCSwtf+xlz+&4XKqTH?hO<9y|mkas-V7oA~POW}0(wiyHc?~6`l3i0f1pBq@aRU4sT zw_dd7_PJ+F)Zs#bQ~O7=gDSn3Osu`|3UX-f7tPLu+4(uT&bnMd$Q36pbf=$|;9fACCp9OF&aZ}R1@0e*ya17klcYmC(8Y-o5?EazBgitlf<1wM@x{^di7st?96MIvhX_pIj z*I+bZUp^_UNeF9_!rEiTDPc{V-xL?tjIN3cnAgBMQTV;@Ac{fmkY^eKjg75 zz})62u7RZsYM7?eblWjWrike=M&cefM7anTG1Be995HL#gM|t$An_4S9X_*+bVy(& zM{j}UWWWRqaJS4?hre=Y+hz{!*A~f|+8uZ)L&_vy<-={gxE*m(bN_QjrpM;a3U8QKakK3|8Z_&U6@m-g#s+Wx4Q-`lQ;*tOe&3T@4E?N56r zpf4j@=d((_j`m;=(oP%O{Gs-6Nvi*>r_K2qeQX+<<~o?(GrrltYUOcIXLdR=wqv8w zno5qk|KkF+=E1xri?o)K?)V0v62XI5V_FpkwRz6Ki1skcEw-}jaoYULOixC7pWPssl5a07X>9!klJ+d+Y` z*wP&^WXkw6HemhzjZD-*Y5u2Z_#U0+w1sYftsdMq<-ZVXJ-iH`kvHn056;JSH^5FF z{!X53gdqr!%}vmq(~p72X<)7V7K(4{@0A}l!7JP)G<-px-UrL!Et1#=&%wo5uLmx0 z6-L^w6%!5n*AeE|<@(F?Rdnyi8jRgR`CzU59yPcjT%`sdE>Jgw1e;$G7ngVRtadB1 zktMITz$Vtno@j+KGei00uR(pXbUC@F6|%Cm+`ph?)RTgRC5Kz#1k127dmFrNnq9B= z55Zw0USs^UD2jCkzyW3(7Pl8pEQ5mBPl9j{Ko042K%Vp)tnhV&vk31X#1SSC-beTd z;ZF#EMfejYYVPyl47woTN0G9dw0FXHt#;F$aCwo*v>C{wov;wjkY9Gv pcVXP>84 z3J#rup71=T<#<8T9N z-L2Zj(u6WHD!b!_Zq6vzh3=T*J|WZHbK8?L`xujXX39$R%Wm1du{7n4n~D9pP~Xv# zE&d?8^>LlKk&#DM+Mz|-x+aOx^i%Qs(D-?xN|08-Vo^1{t*c-_x!Op^E6@uP*^!++ z8KSeEJCzYd zY_5%tpW3ZVJGdmKlt<-?sHcL+wWlJcRGg6M?z!zrnF-TO>A0Da(sz=_Q1zPVxGCMr z)PwV4N?BC)L}z=5N>LeCF~xI2rn`q+;5c%NDz3PsEiJ#DV6hvN@_= zetNV0maT6)SP)4+v)_uB^<%$$jMpRKb3+}HMuFkT#!GXhM}nl65t;}q39AXsgtdeg zLM!2Ig3e-tkgY$|Tbch9uB3^!1tu#v1)B?3WWn~d$vKg>-VMToQtA~;^Z7${!KQ$} zKCIGDmb6yagzJ)$H&j!-^hGtGyKb%%RIt!+jLLJ|cW#P9kk0 zy@_2Mgom-oxW|EsHtAFH?Kb9Sp+rC=6}O9gyV%ab$jc?Yn7=Q57q{uc)o1nMzGTx4?(FN0iz`k?NHizNYXxM(e$#JQ>Ur>j9J4`Bn=EuQG>9Qxm+kPcHWOl+ps>j zQ&ju3+QH-4su>t%xJIKya`dfG6l*C)1&*=P#dr^$`u6Mh8XLypoS1i46m>dEc%#3k zpdK;yOu&=Y{4C*>R>}XyX?a-c5)#!v>P*CK$~sgDcVS?1nXUl{Gn&a z(af7B2UOL0mn^EPsq?Epu`xb;jbGUXAJ*erL-S*%jC1Tz0Br~`Up*Q!-=xG1Jh5Eu zBzc^kMGg~#*f0G?j!W!t5M$8Et_HCi$Bi8!{45py!Iv}}Py1_)<(pCJbgtAtDZG{T zU~LGhFO{n&xxA! zF>FO6(lgEGd#UaXTKW%cOC#PF&chhrgbtf@Yx;jzq0xGbN3eJDFwD?bV=(O3xUD@0 zHg*znjE*&!4`i_XW(-+yh-w`sd{5{i#0cGlB;hRKcS0}0#{c(Jnc$_(n3K9m?WI-1 zTl}LTL{?EzH=5rL3bIAS)}$;mKhc+B^o>AxcQGQZ&ljf?!&7dYH6@LTVxxXAB*odblf zLIoha%U~Xa4JUzm_Ef;-VNAF*ka2~>eX?z>^k!M5$*jC#jGs7|SYyGY$L782r{uV$ zQ}UCGOG;BR^Yu1&xIJcMF}uuTzB%9H4XdEbWhvJK#V6t}#3o$uNxZ_7G}$(J&t?sO h7G7=%Bwe~OTNv4lFY}meNZ$NBR@;J6ce3y_4*-wG=D`2} delta 563 zcmeychUxYiCf?J$yj%=G;I%zIt9c`DDH8{KKv8~5X>!TtZl)*V^~%>}3@*wTT;K@l zsktCyutE7UzZsCT!|sg51*;Gs>jIE(6>^b3WgiBc=Bc?n4kAy0Ou*O=(BEb(1x29owQXOiIUKA0QpYkp)Rc zkOcV%u-sq}7M1vce~7~yq$d8Dzl@MbyLaOyBm`qj)L&#mm;nLLx$kJ-D$UK)=RWtm z=bU@*x##P<#&7N!7hZKb9R~PqD~S%qmR(#}ZL&*&p@G-qvEd;WFuldknG$9q@owps z75?hz4;CV^2a6kE;?HZcSS9$lOk!&W-0aDyg@0MF0}Oa>$z40KFx!fk0sVQEe9MYr zi(Jtg+p_!J;ogB*1$%$xA!6Zj3#*R<;iU_S$Ty?6e zKIN)U>{VPLe#<{>%ChPV93!JONhy46U(!>f&|08LTB}ljiu!>k`T&3?`V`tM)|yoc zLRiRQRmzMRbfO7LhE9V&PmLx|X}-lbg8PN;4Z|eSr?Y9+@k{_s1O)eNtTAYBC-DHB zV*Da=McsU^k;=ruY+(j?HvCAx`4Ppnj-PLT)v6c8iQIX!z)T$6vB_a3R-PAGZMVd? zfm7buf1DK9(7hSprqupMpsP9D(Hgg-=3oy82FN3}b?}-*~l;Lx??8)urKy3 zwPIfJhy3R~Ze3>a$EZ3HL}oH0BegjgfF+LJ8O<>o4Eqf?Jieq9!MgJ)SZk3a^{ccoMH_)n1_6Lh1{K;O z)>>5BmZELIr#m*O9g$Q=MCffvAT**%IIg1?RJ3UtQy55_Dbi?~PlLI~r|DB#0>X{p zjcI){n5IeEqT_8EPvd-Y!iS%m` zvI_v{WS2riVlDLa1xBYk-&Q-Lsm`d-!$}}Cs!BMnqZd@PX&O^fbQF&Qbp?ZJbQ;V( z@ElQ|(h|sU*#5je*_EbA8q#rirCE_6Axut)5GKtrPQm|@((E8*c-Hx=-Onh$#i${C z3dOh^d*?yJU8B*`Gv5Lhvj@lT%PVy?H+y1GekE&O%-%cMDlgY*Kb_o=)mxt*>zAuD z>-^|gu9-Z?KRMNFmc`@4RuF&87M@vUd}3e?XTC4K56XVS@A)sBc7Cw#23>Mu-{F0q z*QraZQ!r>XFFu<~iiUc zkasGyRXl)Jm4bke0We*PK%lZRMW3dk=Aj{@si^Z>9ft}4-HQu}ojSc$$FpK+I@CJe zTus$8wpflD!jJl!BbLqcH&?Fh``WqNmIcevb~QpRVXnbaJmg#F-K2)konK{H^bk+K z3_-Ne<%I1zRl>S2ILV8=|3chk)nxw51hGKN{Pj{FY2l%7DXFMKpKB1-A_Nga2>Yvbj0g#@fvb+X%V~kT?=B;4}i&ArO<{ zVj=?J;zyRhjN?H?i>3Eh7gU3KTITC;*8vU-}8;G;_~I`bDnd~ z`<}~l-hF*r`RbP9KJRilP57st>g)fdbi^Go^JV64)xJEwaoxb$x5a1XQ483`YW1oO zf9lxxR^Qev=bz=w;W*MYQ;jtCElP|S5a(*0N?x6 z2VfET!A7vOYPoG<<)U*IP(`5TpbgyOhlV0xRhp+-_e9d3NMe=dX%aV|9WY~Gym=1{ zR;JY0o_ACJN{v+^O|dGSh0`pIJkf@LG|{H92HDr3GZbP{j_K;mcZq@+f;K}ZFrL5C zkjIVKa*pI-sfT0ONVH{*X~^O;2@DYt!}CN-)X@s@W;~<#2v3#zM1GXzfP*jS%NFH1xO)mM;1K1joffc)NZ(?Ixu>PdB^^IW&3QtbK@z+1iX2Z z&jx^#?(;R9K8Gl`Y`z9D@!RI>7P!YJwv^iLP{_OD-WFe0%J>6PT?8_vlLxof&M{%2 za%X%I$xaID;M=wyVkKpINiba!OqSj@gQroHYJZIN=8#%Y!LFVJf3LX5PT?@d=7w$f1Q)!ly-`RbAP*F^# z9j43vP)hA0|AaL1OjnA9byk;Vb;y$e1fmeNZO4q;_5Qw9(j@kq{)cJnq*(o!xz*u=DS#Qwn&Px zGes%ZqpS2yQUnx?RtD3Q!)R$l2Ms=fqy6}jXy&+)dxqC%^kgJMQ>-bA;{%({YY<${ zn9rb?<{`x_k1mM6;%EGumRu+#JI(cyF$g|I>%2vFecyCTQLMewuhh%i_x%O)@nQ7C zJj7!D>;87|^ZEn5Si+7T*Z>v6^HHt^ibUYxI!lgx5cKZi4>&&@qE~eK@ON|nMrDWT z_k#I{Tsxn;%pTKOaher>@s3_ro5qjTisMIe!B$MGDVIL?6ZP|nPhyH+`kY@F@`+_f z|1?wOlg>3NANnp+`MMGv*hiJGLt`z{q%Ar_p-alsUNzi-7I?&(8E0bUBQD)omBsPC zP!Cf_q9aRh$>RUXVd!W{tb7F!qK(a~ri~Gt&OULlc2A#pwehM~9KU9Dm(kc7f~OI9 zu%?&}bj`32YQ^^9kahN5I_Yu(DlhRe;2qu)e)WV4o)mwah?{e!W$c^c?cTZK!;zn< zcD;IXJ-i@(I?2FWM-v+enh0V9FUl*35RGtWG6)g=OtKFu_>tttURi;pZ{!`x{Y3cl zqffv~eEsMudn?>9DbUK#j5bvFA~!=XOwB5SIX6rQQ%gaCZaJhIrkp;gziC2{{}cWN D7(jnH diff --git a/tests/__pycache__/test_poll.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_poll.cpython-312-pytest-9.1.1.pyc index 1c351edefd0976edb6be203bf20cb5c98a301d8f..68299ad9a28c77ffd96b1c4b85fde2a12608af5f 100644 GIT binary patch delta 75 zcmcb+m+|>tM&8rByj%=G&@P{tb#x=In-RA}N`7*2Noh)EzTV~#BPJ#mo6DlMo1;u_ biLp3bmUP@~;cdnNW=*#6zO>mTWF{8?pMS-({o8w+-4h zm&zz9u*Y4Nx4A5AyLp}s8ylRn+R2ZTUGK7t3O+Ihg)$%TDxL%QW`^?NF!gW*L zZHD3r#q(+>)h>jjUiM1uaP0T&_PikPwplnNg^M@uvVreq6~D`}{+ERUHn+qjGXnsb C3^;ZG delta 185 zcmaEJnDO~xM&8rByj%=GAig_3D|{nwwWKh6c}8Y&L2A)0w#4L;%>2Atj9ilo<#IOj zNIhlb=e%s>bw$+sGb@V-*F#b7Niw30-jkyhQZ`q~C@HWVvW&@PVbjg5K5yC8GB0a5T#U hyddwmSt2Bbi#PMKfyZSP&&#r2mxa7Hcg7_%0|4p!L3aQE diff --git a/tests/test_cli.py b/tests/test_cli.py index e0c9871..8c8deee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -295,7 +295,7 @@ def test_summary_is_the_default(self): def test_summary_explains_how_to_drill_down(self): data = discover() assert "--group" in data["drill_down"]["one_group"] - assert set(data["groups"]) >= {"whisper", "platform", "config"} + assert set(data["groups"]) >= {"whisper", "docstudio", "apihub", "config"} def test_filter_by_group(self): data = discover(group="whisper") @@ -316,8 +316,8 @@ def test_filter_tolerates_unstract_prefix(self): assert discover(command="unstract whisper extract")["count"] == 1 def test_group_and_command_combine(self): - assert discover(group="platform", command="platform adapter")["count"] == 13 - assert discover(group="whisper", command="platform adapter")["count"] == 0 + 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.""" @@ -356,7 +356,7 @@ def test_doc_conflict_recorded(self): class TestHelp: def test_every_group_renders_help(self, runner, cli): - for group in ("whisper", "deployment", "platform", "hitl", "apihub", "config"): + for group in ("whisper", "docstudio", "apihub", "config"): result = runner.invoke(cli, [group, "--help"]) assert result.exit_code == 0, f"{group} --help failed" @@ -366,7 +366,7 @@ def test_leaf_help_shows_api_and_examples(self, runner, cli): assert "Examples:" in result.stdout def test_destructive_command_states_permission(self, runner, cli): - result = runner.invoke(cli, ["platform", "workflow", "delete", "--help"]) + result = runner.invoke(cli, ["docstudio", "platform", "workflow", "delete", "--help"]) assert "full_access" in result.stdout def test_one_shot_help_warns(self, runner, cli): @@ -438,6 +438,31 @@ def test_get_never_echoes_a_secret(self, runner, cli, whisper_env): assert payload["configured"] is True assert FAKE_KEY not in result.stdout + 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", "platform", "org_id", "org_ROUNDTRIP"] + ).exit_code == 0 + + shown = json.loads(runner.invoke(cli, ["config", "get", "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" diff --git a/tests/test_http.py b/tests/test_http.py index 1361633..1d04426 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -23,7 +23,7 @@ execute, raise_for_status, ) -from unstract_cli.core.model import Product +from unstract_cli.core.model import ApiGroup from unstract_cli.endpoints import get_endpoint from .conftest import FAKE_KEY, WHISPER_BASE @@ -73,15 +73,15 @@ class TestAuth: def test_llmwhisperer_uses_unstract_key(self, monkeypatch): cfg = _config(monkeypatch, LLMWHISPERER_API_KEY=FAKE_KEY) - assert auth_headers(Product.LLMWHISPERER, cfg) == {"unstract-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(Product.PLATFORM, cfg)["Authorization"] == f"Bearer {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(Product.APIHUB, cfg)["apikey"] == 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. @@ -136,7 +136,7 @@ def test_path_params_substituted_from_env(self, monkeypatch): monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_abc" ) plan = build_request( - get_endpoint("deployment.status"), + get_endpoint("docstudio.deployment.status"), cfg, {"api_name": "invoice-api", "execution_id": "e1"}, ) @@ -145,7 +145,7 @@ def test_path_params_substituted_from_env(self, monkeypatch): 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("deployment.status"), cfg, {"execution_id": "e"}) + 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): @@ -184,7 +184,7 @@ 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("platform.share"), cfg, + get_endpoint("docstudio.platform.share"), cfg, {"resource": "api-deployment", "id": "abc"}, ) assert "/api/deployment/abc/share/" in plan.url diff --git a/tests/test_model.py b/tests/test_model.py index 681b97f..a082afe 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -11,6 +11,7 @@ import pytest from unstract_cli.core.model import ( + ApiGroup, AtLeastOneOf, BodyKind, Endpoint, @@ -18,7 +19,6 @@ Param, ParamLocation, ParamType, - Product, derive_patch, with_params, ) @@ -56,7 +56,7 @@ def test_requires_one(self): def test_file_history_clear_is_guarded(self): # Without a filter this endpoint would delete every file history. - endpoint = get_endpoint("platform.workflow.file-history.clear") + 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"}) @@ -73,13 +73,13 @@ def test_plain_sequence_is_identity(self): assert param.to_wire("form") == "form" def test_share_resource_mapping(self): - param = get_endpoint("platform.share").param("resource") + 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("deployment.run").param("files").multiple + assert get_endpoint("docstudio.deployment.run").param("files").multiple class TestP5Freeform: @@ -93,13 +93,13 @@ def test_ext_param_declares_prefix(self): class TestP6PathParamDefaults: def test_org_id_defaults_from_profile(self): - param = get_endpoint("deployment.run").param("org_id") + param = get_endpoint("docstudio.deployment.run").param("org_id") assert param.location is ParamLocation.PATH assert param.default_from == "deployment.org_id" def test_api_name_has_no_default(self): # One profile serves many deployments, so this cannot be defaulted. - param = get_endpoint("deployment.run").param("api_name") + param = get_endpoint("docstudio.deployment.run").param("api_name") assert param.required and param.default_from is None @@ -121,7 +121,7 @@ class TestP8DerivedPatch: def test_strips_required_but_keeps_path_params(self): put = Endpoint( name="update", group="g", method="PUT", path="/x/{id}/", - product=Product.PLATFORM, summary="s", + api=ApiGroup.PLATFORM, summary="s", params=( Param("id", location=ParamLocation.PATH, required=True), Param("name", location=ParamLocation.BODY, required=True), @@ -133,7 +133,7 @@ def test_strips_required_but_keeps_path_params(self): assert not patch.param("name").required, "body fields become optional" def test_shipped_patches_mirror_their_put(self): - for group in ("platform.prompt-studio", "platform.workflow"): + 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}, ( @@ -142,7 +142,7 @@ def test_shipped_patches_mirror_their_put(self): def test_with_params_appends(self): base = Endpoint(name="a", group="g", method="PATCH", path="/x", - product=Product.PLATFORM, summary="s") + 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" @@ -159,8 +159,8 @@ def test_documented_not_enforced(self): class TestP10IdentifierTypes: def test_group_ids_are_ints_not_uuids(self): - assert get_endpoint("platform.group.patch").param("id").type is ParamType.INT - assert get_endpoint("platform.workflow.get").param("id").type is ParamType.UUID + 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: @@ -169,15 +169,15 @@ class TestP11LiteralPaths: @pytest.mark.parametrize( "name,expected", [ - ("platform.prompt-studio.profile.create", + ("docstudio.platform.prompt-studio.profile.create", "/api/v1/unstract/{org_id}/prompt-studio/profilemanager/{tool_id}"), - ("platform.prompt-studio.profile.get", + ("docstudio.platform.prompt-studio.profile.get", "/api/v1/unstract/{org_id}/prompt-studio/profile-manager/{profile_id}/"), - ("platform.group.member.remove", + ("docstudio.platform.group.member.remove", "/api/v1/unstract/{org_id}/groups/{id}/members/{user_id}"), - ("platform.pipeline.postman-collection", + ("docstudio.platform.pipeline.postman-collection", "/api/v1/unstract/{org_id}/pipeline/api/postman_collection/{id}/"), - ("platform.api-deployment.postman-collection", + ("docstudio.platform.api-deployment.postman-collection", "/api/v1/unstract/{org_id}/api/postman_collection/{id}/"), ], ) @@ -187,7 +187,7 @@ def test_exact_paths(self, name, 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.product is not Product.PLATFORM: + if endpoint.api is not ApiGroup.PLATFORM: continue if "{" in endpoint.path.split("/")[-1] and not endpoint.path.endswith("/"): assert endpoint.no_trailing_slash, ( @@ -198,7 +198,7 @@ def test_missing_trailing_slash_is_declared(self): class TestP12ReplaceSemantics: def test_shared_users_marked_replace(self): - assert get_endpoint("platform.share").param("shared_users").replace_semantics + assert get_endpoint("docstudio.platform.share").param("shared_users").replace_semantics class TestRegistryIntegrity: diff --git a/tests/test_poll.py b/tests/test_poll.py index 833eaed..3a28f4a 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -130,7 +130,7 @@ def _run(monkeypatch, in_progress_status_code: int): monkeypatch, UNSTRACT_DEPLOYMENT_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_test" ) return wait_for_completion( - endpoint=get_endpoint("deployment.run"), + endpoint=get_endpoint("docstudio.deployment.run"), initial={"message": {"execution_id": "e-1", "execution_status": "PENDING"}}, config=cfg, values={"api_name": "my-api"}, @@ -174,7 +174,7 @@ def test_genuine_422_without_status_still_fails(self, monkeypatch): ) with pytest.raises(CLIError) as exc: wait_for_completion( - endpoint=get_endpoint("deployment.run"), + endpoint=get_endpoint("docstudio.deployment.run"), initial={"message": {"execution_id": "e-1"}}, config=cfg, values={"api_name": "my-api"}, @@ -196,7 +196,7 @@ def test_error_status_is_terminal_failure(self, monkeypatch): ) with pytest.raises(CLIError) as exc: wait_for_completion( - endpoint=get_endpoint("deployment.run"), + endpoint=get_endpoint("docstudio.deployment.run"), initial={"message": {"execution_id": "e-1"}}, config=cfg, values={"api_name": "my-api"}, diff --git a/tests/test_skill_docdiff.py b/tests/test_skill_docdiff.py index 86f678e..34572fd 100644 --- a/tests/test_skill_docdiff.py +++ b/tests/test_skill_docdiff.py @@ -102,7 +102,7 @@ class TestZeroDriftRegression: def test_llmwhisperer_has_no_drift(self): findings = [ f - for f in diff(parse_docs(_LLMW_DOCS), endpoints_for("whisper")) + 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) @@ -157,7 +157,7 @@ def test_detects_new_parameter(self): DocParam(name="include_progress", type="boolean")], source="whisper_status.md", ) - findings = diff([doc], endpoints_for("whisper")) + 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 @@ -166,7 +166,7 @@ def test_detects_new_parameter(self): def test_detects_new_endpoint(self): doc = DocEndpoint(method="POST", path="/whisper-cancel", source="new.md") - findings = diff([doc], endpoints_for("whisper")) + findings = diff([doc], endpoints_for("llmwhisperer")) assert any(f.kind == "missing_in_cli" for f in findings) @@ -175,7 +175,7 @@ class TestSafetyRules: def test_missing_from_docs_is_informational_only(self): """Docs lag implementation; absence is never grounds for deletion.""" - findings = diff([], endpoints_for("whisper")) + 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) @@ -189,7 +189,7 @@ def test_doc_conflict_endpoints_are_exempt(self): """ assert get_endpoint("whisper.detail").doc_conflict reported = { - f.command for f in diff([], endpoints_for("whisper")) if f.kind == "missing_in_docs" + f.command for f in diff([], endpoints_for("llmwhisperer")) if f.kind == "missing_in_docs" } assert "unstract whisper detail" not in reported @@ -210,6 +210,6 @@ 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("whisper")))) + payload = json.loads(report(diff([doc], endpoints_for("llmwhisperer")))) assert payload["total"] >= 1 assert "missing_in_cli" in payload["summary"] From e78020c42077e871a60563c78b20d987d3f60d13 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Thu, 23 Jul 2026 02:46:44 +0530 Subject: [PATCH 12/21] refactor: config targets must be product-qualified config get/set now accept only docstudio.platform (or 'docstudio platform'), docstudio.deployment, docstudio.hitl, llmwhisperer, apihub. Bare group names and the 'whisper' alias are rejected with exit 2 and a hint listing the valid targets, so a setting always says which product it configures. Also: config current keys its output by the same target names, and the variadic arguments declare metavars so --help and --discover both show 'TARGET... KEY VALUE' rather than an opaque 'ARGS...'. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 13 ++- .../__pycache__/__init__.cpython-313.pyc | Bin 352 -> 516 bytes .../__pycache__/app.cpython-312.pyc | Bin 19037 -> 19191 bytes .../__pycache__/app.cpython-313.pyc | Bin 16349 -> 19288 bytes src/unstract_cli/app.py | 12 ++- .../__pycache__/config_cmd.cpython-312.pyc | Bin 13124 -> 15058 bytes .../__pycache__/config_cmd.cpython-313.pyc | Bin 12941 -> 13093 bytes src/unstract_cli/commands/config_cmd.py | 91 ++++++++++++------ .../config/__pycache__/loader.cpython-312.pyc | Bin 16519 -> 17301 bytes .../config/__pycache__/loader.cpython-313.pyc | Bin 15924 -> 16910 bytes src/unstract_cli/config/loader.py | 36 +++++-- .../core/__pycache__/generate.cpython-313.pyc | Bin 17908 -> 18180 bytes .../core/__pycache__/http.cpython-313.pyc | Bin 21699 -> 21831 bytes .../core/__pycache__/model.cpython-313.pyc | Bin 20072 -> 20642 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 2349 -> 2564 bytes .../__pycache__/apihub.cpython-313.pyc | Bin 8775 -> 8772 bytes .../__pycache__/deployment.cpython-313.pyc | Bin 6437 -> 6485 bytes .../__pycache__/hitl.cpython-313.pyc | Bin 3544 -> 3520 bytes .../__pycache__/platform.cpython-313.pyc | Bin 47390 -> 47484 bytes .../__pycache__/whisper.cpython-313.pyc | Bin 10230 -> 10227 bytes .../test_cli.cpython-312-pytest-9.1.1.pyc | Bin 99518 -> 103948 bytes tests/test_cli.py | 47 ++++++--- 22 files changed, 148 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 47bf578..175c790 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,17 @@ For repeated use, create profiles: 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 ``` +Valid targets: `docstudio.platform`, `docstudio.deployment`, `docstudio.hitl`, +`llmwhisperer`, `apihub`. + 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 @@ -77,12 +86,12 @@ A project file is ordinary config, so it can hold several profiles too: ```toml default_profile = "dev" -[profiles.dev.platform] +[profiles.dev.docstudio.platform] base_url = "https://dev.internal/" org_id = "org_dev" api_key = "env:UNSTRACT_PLATFORM_KEY" -[profiles.prod.platform] +[profiles.prod.docstudio.platform] base_url = "https://us-central.unstract.com" org_id = "org_prod" api_key = "env:UNSTRACT_PROD_KEY" diff --git a/src/unstract_cli/__pycache__/__init__.cpython-313.pyc b/src/unstract_cli/__pycache__/__init__.cpython-313.pyc index c161d42ef2561436d5ad3f4caf61b978ff7c9bd2..7a67709b90ba70bf433d8b2779b44d370be11158 100644 GIT binary patch delta 278 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 O*i~5?d(b0MgZvi;dXjPg diff --git a/src/unstract_cli/__pycache__/app.cpython-313.pyc b/src/unstract_cli/__pycache__/app.cpython-313.pyc index 2d13f005af98576e9d88a8da60224a8cfd4b05f6..aaad3b891bed502da202ac3e3d923a6d3f7c16b9 100644 GIT binary patch delta 7111 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 KFE1F-$o~Tb_Pv+@ delta 4575 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 dict[str, Any]: entry["is_flag"] = info.get("is_flag", False) else: # Positional: how it is written on the command line, not a flag name. - entry["usage"] = str(info.get("name", "")).upper() + 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"): @@ -83,8 +85,12 @@ def _usage_line(path: tuple[str, ...], command: click.Command) -> str: info = param.to_info_dict() if info.get("param_type_name") != "argument": continue - name = str(info.get("name", "")).upper() - if info.get("multiple") or info.get("nargs", 1) == -1: + # 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( diff --git a/src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc b/src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc index fc2b03754d17a9fdbd4373156246234a8174de06..0ab85e19191c6aad1476d5edf398e274b4054f1a 100644 GIT binary patch delta 6742 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 delta 4899 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 Q;7QDL{hjUJLvF`^0NhglvH$=8 diff --git a/src/unstract_cli/commands/__pycache__/config_cmd.cpython-313.pyc b/src/unstract_cli/commands/__pycache__/config_cmd.cpython-313.pyc index 4afeda1834961125c3627676b6d63149f669bab2..ce78968ea0ac216e53cd1a96e1402e3092c11110 100644 GIT binary patch delta 4825 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 delta 4637 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 Aq5uE@ diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index b6bf622..58c02cd 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -19,26 +19,43 @@ import click from unstract_cli.config.loader import ( - BLOCK_ALIASES, GROUP_PATH, - PRODUCT_KEYS, + TARGET_NAMES, ConfigError, ResolvedConfig, config_path, load_config, + resolve_target, save_config, starter_profiles, ) from unstract_cli.core.errors import CLIError, ExitCode from unstract_cli.core.output import OutputFormat, default_format, emit -#: Product names accepted on the command line, including config-file aliases. -_PRODUCT_CHOICES = sorted( - {*PRODUCT_KEYS, *(a for aliases in BLOCK_ALIASES.values() for a in aliases)} -) -#: Alias -> canonical product, so `config get whisper api_key` resolves. -_CANONICAL = {a: p for p, aliases in BLOCK_ALIASES.items() for a in aliases} +def _resolve_or_fail(target: str) -> str: + """Turn a `TARGET` argument into an API group, or exit 2 with the valid set. + + 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 _fmt(output: str | None) -> OutputFormat: @@ -120,23 +137,31 @@ def config_list(output: str | None) -> None: @config_group.command("get") -@click.argument("product", type=click.Choice(_PRODUCT_CHOICES)) -@click.argument("key") +@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(product: str, key: str, profile: str | None, output: str | None) -> None: +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 platform org_id - unstract config get --profile cloud-eu whisper 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 """ ctx = click.get_current_context() - product = _CANONICAL.get(product, product) + *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: resolved = ResolvedConfig(file=load_config(), profile_name=profile) value = resolved.get(product, key) @@ -160,22 +185,21 @@ def config_get(product: str, key: str, profile: str | None, output: str | None) @config_group.command("set") -@click.argument("product", type=click.Choice(_PRODUCT_CHOICES)) -@click.argument("key") -@click.argument("value") +@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(product: str, key: str, value: str, profile: str | None, output: str | None) -> None: +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 platform org_id org_ABC123 - unstract config set whisper api_key 'env:LLMWHISPERER_API_KEY' - unstract config set --profile cloud-eu whisper base_url https://…/api/v2 + 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 --profile cloud-eu llmwhisperer base_url https://…/api/v2 \b Prefer `env:VAR_NAME` for credentials: the file then records where the @@ -183,6 +207,17 @@ def config_set(product: str, key: str, value: str, profile: str | None, output: in your shell history. """ 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="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" @@ -240,19 +275,21 @@ def config_current(profile: str | None, output: str | None) -> None: resolved = ResolvedConfig(file=load_config(), profile_name=profile) settings: dict[str, Any] = {} - for product in PRODUCT_KEYS: + # 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(product, key)) is not None: + if (value := resolved.get(group, key)) is not None: block[key] = value except ConfigError: continue try: - block["api_key_configured"] = bool(resolved.get(product, "api_key")) + block["api_key_configured"] = bool(resolved.get(group, "api_key")) except ConfigError: block["api_key_configured"] = False - settings[product] = block + settings[target] = block emit( { diff --git a/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc b/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc index 8345e0968c2055c61d558735468d8b00ffc91ed9..ad61b0654ff0fcc7208d5c7fe909be8adc4c7aac 100644 GIT binary patch delta 4077 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 delta 3496 zcmaJ@ZA@F&8NTQG`U4wdQw*2@$%RnE<-_Kyknj;mnviyBDNAV)&N9pKy#^Yh!> z2ppHT&;UML#Qu#xW(3YhSX*sfXP3RQ6ZNVLiP!61qe1sgfC6lzXj@;&wwi5_M$*Nm z0SD2lsghMqdB2L<0BDEzWE0FdZ1jQqns&CB4hozvZw`PW*uGNUFL9Hr;5WAJTg^m3W(eSnq}3cD*ltP#@FB z_2{NE-l0$EF<5=vaEn;|5Nlvde$ft9KCJNwI85q;;Lr)u5q$`xu9C|zxID2X@AA;} zDSZS2cP~l$=({A|v*ysIi*v4a><^x~PzWBlS2yxmbM2y$D`+wevEx;-V%XnB zTpggLYH_CK7||YpEKwjS(cUu5gJ3xUHYKhcqJv_ceH{306f@HZSMtfivW2;;!j%Us zqmW3MxpZbJfjW8!bZ6OT!A>$*{9W**qZK{5Y+BrH4Mx$rBXEjN4ncR}4RntCnjUvPl`Vr2sKeniS2^29X zO(L8_z<74CMQ)4ui0~F0Xq^$c(Z%Am)@g^!c4R+S3nak~wRJ|%fZ4p}r1(oK#MbI4 z7SrBrk=Jv+Y$%&)3k?BMOx;FKqM5Y{1lTdG*|^7O?Z5VYec6!ENHAXG`qU$cZtL zriGClx|!l~TBikgvT$f@79|o^@{&;|@hT{PRUGR4J5jNCTuEoL#w?AaT%D&TJ|=R> ziw6Bh@ub#9`d$YOeRxvncW@0k;jmjqHeH?Yf%yg!<4Hdk2TC z27b6Rnaw0E3$Fv!J|DD}JrjL`DC~N)b6uGF>37jh>?8Um8iBLGmGfpM$6c053x;kV zN>PY?=un67|9(NEZlQ}C^3qpQ1+HLX;t|v2&I>tnHAm+}*c`NxyDb)pg-DQ%#|GsG zQqhc!6Ubz%|CqeaNp#j8!u{Z&`RskFa7RDF8wi4DH%i+jTfoP+5t;!smmo#o1erT7 z7;D_E8|maqwjjcY3m|bRNq4tVrc*3G)Ck;O9r_z^`qr>1lNR>Zk%7cr2zA99Rp(7L zow+=wUXjOD&1-WJ#n>h(sO6ChmWsP6+=0oIVb}2vENSj-dh}g1^t08`F4*>+(GC(} zUyS}|9nU{pz%FVOR%lMlj;4r&=sbFSh;Rb|n-5*UT>tHdOF+G<&e^@NV+-@|qoo@` zIHCkaLLd4Of?%;7*u9PhH5G4<6xxf4#rs(tTG1Eoff2|Xw6JCmmgt7))eq2a*VXbG zt|9y$zSh$KFH2u44yWh7SMt^0V?R4QY2P=m73Hx$*@yE$+YwsW*u+5}zJ+K5LL*zA zP+dab!rquT5DejJ7@>?aea>v1e7ULM>t_sr4xndeSF3svu!?1(%}j0dek62_h; zN(a@VpMlpn%T4~&Zi-)w7{xC0>bcX;9e?4;`NXLci_g|N)XJC3%j~( zZ?VG`8A0z~Mq9y79PRB`c(s-H{3go1;;q5GiG7Lm3QVJsNKpKDWFNmQO3UoUsS$FW z-I(fJr?~KAgdZUYPIwTg7@SW~Dk6Lez+Fe@o_^|uV^rKMqDP{xRVOZ|jC>(u=IkmC zqk|}_sN!~^7f>OxC|CAysodB#?oK3<*(~tg)nv~Eyir!N#u55Cn8Eu%vc8hp+O!t> q(klh)e}n&Q?uBCa0Av(MFs(~Wl32Hn`GP( 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 delta 3897 zcmZuzZ%kX)6@Tyfy=O2s25f_E3^>FX4A>9|36Mbk5GX8!5FRW|!{Rz%2#K+sYm+9d z8X$FP)=1g1yDr&UWwe^KsjIYUbXB!!lcsIjm#%tNCYe2T(pGIB`e7-QDB1RD=iFz) zpVhLD&;8xsIrrRi&$;j2d4+%P<-!e%#l*qq!(YC5`5(=93!Q>Tx?6Y2=jFzWsyQXk z&5c`aTpLp0)5h!nqm}S;*-&OS4E^N+CG0{=bv_qWyxhYuWq~z-&96GoF1HhCIhkOezJe>{%cZx_533 znZ?$Sk+(u+Y}d9iqi-AQ{0Dq)YmD!vTVv#xZ;k0&qDN22LM6-;H(N=$QU!_dVj`Rz zoWveCr&N!7+6=i9s89@7E0w*#@gYZPQEJ@WWsy`Vt%{#%UZs}oQT8isN*#_b^Q2li zpaj_5-f<)IS`S_u+63Jvp7NJbB|b1as5IuxYGBl^1al*Q&NKw3P1{YGWv$YoG_xh@ znmA?O8J+~D4S~*;1MSUP5(LuY|D z8juM7lP_2xU?Z3}eZ^6FxvG`-(FaurU6>0pgyKaInv|YP#-f2d(n23qx%XpinmC() zoS@TG9tdylqaSubED@Q^oi<6s^u3kC-Y#*d7cKKxTEdAdTXD@SLZxWJOBaU%Ry_$A zmnJ78BpOd^((IMV8L~wu?9?XcAAP-@Y|{y38zvJInlLpNO>Nc<{asxP{}=kCuBVC>c&_h($#Ix5xLf#*K0sfmmDOb{iNJUGqA|XS zKVQNqAV!RIy~$S2EAnRNpWzS6Hm)E1#icQWK2H~$^NZs0dB>*Nn0&sJK5eLQKy{7D zDZ$J6xB|}0HN$_vNCS->Cs|Pi%;cM3qwj!K2Rn8wKTW)xn2Bq0DnZiGNxkh^qA|9u z!U!129eOob?bZxeqBk_@Vk|LnC1nK8vudF42V0Dv0FwF_M?VYx=u2yPoWrBqy55}4 zRyJiSn|@~epyXlY;Xm8DZuP9%%kQ1Mdve+E(C)j{`?1w^zvhSbtkwU}>R&DMeA~Qk zMjx?OS*NCiA9{cQoOh znz`QTofi&y115c8)Fj-EMUFqM=WSEiKgdBoK&d)XXadAp!czO!sy!)2c%i#aN5P4npJRU2tHXw6LJmVjv86vq}1> z(|dHj3@68il+lr6J)_}c!vp>n3k_@YrbI>5)^K=|u1F;OTRxdWS}L zJyEoxR5}7}PQtk>FGYRLdUbA{tSFQ1FPk2>kE>> zS@_tLo9@npy0f7Z8Tfj7SKXe~@&oHe(N*}^oFg2~gpOuIeHr+Ao@0b|Ae0xbnK++c zZ8@1K9bB&}a+ub9oTYTtGlEOt3Tp<*VOpv#8>e)+%-d%XZvm((SdH7Hw_!wZDg_PsBm6`1?wl&z!|%Kb~TH>#5OKQ1TD0L#waJdhBk(?opQB(g;k83p>#d;n9tchB9~d2E zb)?C973w+7@4Y?|O{QlOaXo`gXn}8SdIqld%#lqp; zb(eP-ULodU(Qfivph3BDDM~FzY8U>=HLS~Akt1Udy*2&N9(rgEEt;W5By(1DW<+OJ ztjvg&%jPwM5EK?Isx`2|3B0gYw2Ps7&#j6DS<#*m?OD;C5#6e%iy@^Mu{0}sGNMPV zJOaq8gG+;15mD9Iv9Oje)d(tA{H^W{P6EFroNUQTu8ic$N>v%DYT2cV4QjA&6$080 zyH&AK4fQkFk&zr(sXQZ<-#@2{wQ8VeH~fMs)~WSJcf-%C3!-1GJIwI1j8vADDl$^V z{Tr$nP#d1x4bQ7$z1nzuH+-Er?*{nQ@ul&sgpTe**n8EQb2~u@+Nb()xlLvms7_U^ zR(r 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. @@ -357,8 +376,9 @@ def starter_profiles() -> dict[str, dict[str, dict[str, Any]]]: __all__ = [ "DEFAULT_BASE_URLS", "ENV_VARS", - "PRODUCT_KEYS", "PROJECT_CONFIG_NAME", + "TARGET_NAMES", + "resolve_target", "find_project_config", "set_config_path", "ConfigError", diff --git a/src/unstract_cli/core/__pycache__/generate.cpython-313.pyc b/src/unstract_cli/core/__pycache__/generate.cpython-313.pyc index ab2dfaf6fec4a1db21e49abc85979e2542633440..7d8b97f2c922b68826a8481ca93c969f4e434c0a 100644 GIT binary patch delta 899 zcmZ9J&rcIU6vt<_1qv-K^oN#0Wk97AYYS9}Mp`LWKm&puH)=fSN>?eiyEwbc4~?NF z9yCUyj2<)|^rVRw2^Wug@#I0&q)jzSj4|<|t?&mpU7`oy;hWFAH}mHE^44#_tGA$a zt+uuXuo!C=rhbO+Sl#gUlCPCpK-lI1Q%2H(TX3CDJ8&zm*C~LvV!KW|@iy$xDTudY zr%oZ9&n<-$tiFrZDhhl?iQYRoy?R-A2*<~}$(^9?PFm8E*n z&|P$bYFp0aSuuGYMxMIgH+JVt;gS!ywiG*n#hx7{d)Z&wV9nApl}QG*msKR`c_axLF^PCd(J%RfXumuo5(TM~A{zgD zeN>QybE5u5Z2VvVCB^BqJkQ>-6PlID`Lk(xRtX?MN}^#cBS`3oCZtp9JaQ+*nUpvi z)OwiDk&)43lPP6dBqE`Sem@b&gG9fC#i`@xL`e%tQ_WLLoi~IbBg6Z$Pg_;e=gsmf^Qvew^LVD5)ks`NGf0lN|k A1ONa4 delta 691 zcmY+A%TE(g6o=29mQvbM`h-4^4hXG9?FdCWZEjm%3$2MJ z7^6|I?p(N25*8XZE?v4XhDHNTQ;pF-z*aD>G|mjs#IyMF<>Z`S?mai}q1`)3yHj7U z0-+q&BFkRS1FZ$EzI3%Q*IRId4VLAk6Faa@vV)k#ddUvqR&0>$FmA&}$-3}ZR}JxW1KxK6k-kNl+Zoj`04H;VXCnbVo=zqBY-D4T zxiDa@6CNf5Ksz8zJcb_NQ3#AM^@D~7pP^gy_h5hRA^=|#l2g`grfY*n8XuZO2k81x zo8fdw3mEk=m>FPrMMyqxq*1Mr+Wk650zeHch&}Q1|JQ<(eXI?D-T) zvV_Z|awNvlvtyGqH#*l}Q>wkR7hh8C1tvK@TT=erD9M)+*_Dzanoca`&bejek&sWe z=$o-!=SNW&L})|=MSK#mD&n>Px11E{m*c(2yI~r4AXKK_KvE%IkG!Sp0jKJlpu!y? sqo?IS#odSu6))(fQPD70)P;+h*`oGLRjp`X^6KvfP>-M+wbE(+0RKe5pa1{> diff --git a/src/unstract_cli/core/__pycache__/http.cpython-313.pyc b/src/unstract_cli/core/__pycache__/http.cpython-313.pyc index 6b7fbefd5bb417b8f1f1ab37ee6dbb55026fd614..957eaa7fa83834a836a6b8f96744362353bf857a 100644 GIT binary patch delta 1905 zcmZ9Ndu-EH6vunFwaj(xx|IQ=Q0_L^+A_ze9pdQRlfBGJN5L0?rM5rTj?!Pbzn1sh zhzW{90FMzckPOAJ8E{~cR}ewa?SGot%Lr^`_B$2r5>dHlGpJ2kx~5^Q90ECTPQ7RFDe&dIP$ASzXDEEw`r zZ!8+10m|jLKn&*0`H#hZvEmU*26W`(mr5aFlwYF@I1lR%kT9q^DF0RRUfn zakoTkC2ldQ_yW>uiM|nwQpxKJP@k8vprm93qY9PRh3P73LkU%6>SL0hPaX7z6pB+Q z(O@V2Ghh{b;jE#xR#t~h8LT5&e`G(G%$afr?GMw+Yk?%Oxbz=Rg zu9ICux_Y>uMb)`G+jq2s(Xe8YrEhZIGYO;pMANXN{+gqHC}UwFZ6WBZP4SNnm4q1J zc6Qhp;u%-gaP+8d=b3sMZy8PC&3TDTiyzG?SF4k$-bfJcPCiH^*fyo5HpZ13a7Zh! zM33r$UX_`%vtnDtuE3tq?oe;bkakW_{Y`DwPx?tCdTXC$prpTK*yg-ub0(~nr`r;l zRq?#M>qH~*IoNvmZR#xbMs6O6n@kV2LhO^0wu3J0aET(}>@ z2(hv;Vg@$1h3to~=5~?+u-Gd|G5G9u(gz>fOAEh1aV=s#q7ETuIf53#40rjMBhcGA zF+T6PVnX_1g<~@3T8B%0jtffQvg0t3<7>)gH8~G=DvFExaqvvUlTfW`>~nMsAShx# zlrFH5H{rDfRlH#P7QC+!Yo3F-&azyQyz?l#fEYwvgtg8hauyCai`74J$NczZ=RYY^ zuX9u3r%3fE{_jzX*a$bPlH??usV>Mr#oJcG&hppO+AF9vIx`!IXDfq!P zR=yXTc?ePVrD)+5VEAcZ%Me&(hOzLq7Nc!|u|<#0Mh>>c?Mg>00yXa3atTdbm%V@x zF=mV>=Kp%^&qr*6_uQ5IR9$w{@~h|&d%|S?owYFRkH|s(<=;$Q{C|Oo1DQmTIF(1y z8U%Bjxe#OEg}9C$;T9V}>ErS|g!Z<)$^jHiKnOt}TB0JF@gj;`h)VwTSd>h|wfQsy z@MW7_BktA+{MzOu--2!NN6SPa-$u3Axk#i)pGf3J>=1d~hnAQhZ!Nos5E&L1<_b1{ nMu@YBS!efPhNp<6L4&73?IuI2MX=jbu9-+0$e*Qf#q;oAAh8W! delta 1838 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 delta 5451 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 diff --git a/src/unstract_cli/endpoints/__pycache__/__init__.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/__init__.cpython-313.pyc index 1eb83a2c03c19012000cc2311fd34f9d7b037191..2acca3f3a773abf155451fbde836df0d11ce9305 100644 GIT binary patch delta 519 zcmYjM&ubGw6n>L5o483!(nOt}!cx^q{n{SJPk72&Y_rCYdH+$1B&Q2XqPMScqae+U6 zXMCKh7@I%ZL4YFaC$S%Cg>ghjVEf@cRH+tQT&CJ4f( zBVQ##3Q4SEn#+Emg;cn#rJuS6d?8Iy}`pT;GjLKUad6G&n5rybK{`B(o|k#GTk z9Y3PxCc6X(+t7uXCfscS#Eq4I!vAokkd2Jy;(35BTZiouLgz9-l~#vvqc~s|dtepD zMV|QM(mD@PVHL*(=h~PFIhTDhmX@!QnL@gL|AZ|#sJlCzmrwS)yWRcO-PO-`zS*~4 zH(oV%Iwz_^qUTMUy+30!@+|2z352W>Yq9Kl**Z6yyKKR)8q^x!>+YM*Vck8fx(5?( srpgO!F8fga%JwoSu4c%nU|M~0SM&l60#n#Zsg-&Q&D!z$x%qnODV|D%quBY$jnp7&r4M(D9TSM zO)gPLFUl`1(3||8O-#ZIsLzg3nL&YJDnlrPFoTB#14F)G5W{3cc3A@v28K|^GKQ(F z3Jktd3=G^rRp|`o%xR38jD9N_Z?S^S(PX~Gq-Su8F||lyax=TFgEr8_Vi_Ru<0V4_ z!vz7mS-ukr*N3kR-@$rO*LJ$#M8R3UKZ`kl3YvwL8E0`SGioy3Vl62x$Vn~Y0g6s$ z@IWLU0|`B?8#xwC^5N~<22)%$)23^WSAK3KXEZ| Z+cvWQ{G`jE=*3vf2Gn{`P0UG{1ppsmOjQ5? diff --git a/src/unstract_cli/endpoints/__pycache__/apihub.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/apihub.cpython-313.pyc index e532417c1fa77fe0dc11043e99eaef556ce4c7e0..02fd07f6d090b32c2bb2706d0f07dc7ed7c16a3b 100644 GIT binary patch delta 995 zcmZ`%%}*0S9PMs*+uhRT!_sPP*LDFdqSC}ju%aT+1o5l3kAvbU6cj=wT1^Zl8bMJL z6OrgdF^WeoCY*Y~kE4lygC6}CJn6~VnceNFvxoW3n>TOX`_2Byf6p6#3?t-YV>Vr` zbiFjDm$VKa0tjY((25dtgmMb8u_lDsXJlnGdsquv(S~g~KW9YHj;ObaVLNu@WWE!} zP9%_o>1gdj%G*nmP$_9U5Q8RVy~RQYIvs8o3DIRK%COxYwufO;a+D5ATF~~^Bi3dH z`>@wJw;u=4>$%Bt6_c|M3Pt)^ibd?x{W$Eo$qA+S5ezt~j^dcNdz|gI$_cJ=g4fab zkjEUxh{w!x9W!C;kK!c8Sl>?JG|o8g&Wh7?Qx*vdoF6sLdum021WN*;|73G6z|<~c z0+&FDQtEblR-bVhS9r%KF^(yeG40$pgK_qvSJ{hR!>ohNvCj9@&$<=S=>xwxXgjaq zx}7MikhY>kB1Ne`K-5yN%dF2N$k+>gl0Xhgq>29WXAIl#4d!WslG_7`KT=T2U{_6%}Z-_=Ks@Z3A-}8$XvC)A0fnU7NBE1!nYTwk4er>2aKYIJ- zohqHwOSLchr?BELSLbUBu`xXmZeJ@rE6}g0pKkdsJ?xgh(^a>;U`@E?mNnp(f30)> E0rQ^3H2?qr delta 1000 zcmZ`%Pfrs;9A&rLZntGiv9?&-wOvq)s5CJ)SW!`EqWHJ%!-F^qQj}PXqS0WY5ft?# z8k}fN#G@AzPlQCHM-xATT>KCw9y~cavpYR?_AtMB^XAQazu6z9pC$8;X@=U^h^|l6 z3oGW-g5KSR0Q7ttf)xb@LPd=@SQzc>GxI7U66-(|o!C_LaYhWA5tpk3y0E3F@|`5M zB84<8S8E$GaxY7aYT9ui22HC*hmCIZxZLf;po>bJVLK$YlVOu;oDL~L-SO5Z)>aO? zvCBQT2Yb;k-Q>B7#o7CWBK@i)V$SIS9FT5`LMeF=gKnxrI4pOMu-&Md;wndZ9Zw#U z%wdd3W{K-qDM$Y}PGFSv?Icd&wCnDSI88sRBEdP%kDFzwRuPC^6$t&OS~Gs8b^+tK z2tvZuUax1(8JBRGcYFe4n8X!Kx%bsD#$I%qz35e3bFmrL`M%~^uOdPE$Y%{X&g+(I*<%CER`%j~cH z8AYR6e^1Uczs&5vX(9HO=(O&)3J&)@zjz+2Ew~@}#SIqeortvd-T%=?XN|J4p_lL6 zn!Z=NyY|ia)E?C5+|~8Os1b;EH6kk!`X%$rD;wEiul${z^UCw~xL2;*gI@X9KKmbm C#>7eh diff --git a/src/unstract_cli/endpoints/__pycache__/deployment.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/deployment.cpython-313.pyc index 0a1ce535225de6445fbca221cc10ec1c91ada7b4..51f55ec6beec0fbd74ab953d606f19b7ac8309bc 100644 GIT binary patch delta 1061 zcmZ`$O;giQ6wOPTv`w1QN<)jKEu}#D5|n}^0s<=Hx6msh5*C$~uc5S)wi`D(Zrp;; zt?pR3a>o7z7cOPgv6HP^XV8Hk;7ik_-S{$j@80{)J@?FcN25PRiD_ ztti#Kjmd-|YXX6myBNdG{i&y4EGK^T&4b>szt95U}4 zr-5u4ZbFV2HMnTZNpcFC8g6u$k-`(lF2N`yU<}5|$HQgWt_W@lk_j3nK$b;C_xwuD z^GVZlb_%B9D*jDJy)y}>$^CJ~iNlP4eHP{{)&KDay5SmJw@^38?0LAUyF%3J3Koch zTX0(zY6|Yu72GvmeeE9HZ<_mnbU%be%hM&2r@2AHasjK{4E;QT7j22k3CAJcnaOVyOXbW?zH(;YqPY<1Qo}gS zXMG)d=pvhD&tx~#<-HQQ(D*gMx=^EdR(OLt@He3%1TCiF%4^q0G$?Ae{d6v?v0M6N z%~32rGa@dREp6vBnyrv7ZEH-ruv1xc$7sUy!ZX9w*rWKf=Q|ScdvCWVsq2`~w@H1Q zs(kUjWzalcmv;G8QbFV|F5_eARcm|I8>+TQRWVU@`F^@0$F4|aqiqf$86WuvVrewa za)0*_#fnu?s=5uhuNjU{{0Grwn82>qP=O}Yv_*>F1#-cWAj|#9(JG43FP&e7$Qh*~ JM5`-A&Od49-9i8W delta 1021 zcmZ`%T~E_s6z6zdkQ*bkKL^c`P~(s6WPdaI3;^+WDg>Hoc+pbA!~c9kbPEd zCT;Z@WEF3&uflL5#fP4qhBMF)8OY+x!+^{w{vZWsGc*jsIXR%1mdj}QkZn0L3?neA z9nq~%&SyMT=4bzX5ELDQarY4au?vzg0T*1s%6xgCYvw>+ zxL&>l^EY6sYL1)8y#=>jgQqc{smgPhgO%IbcTjuQ)XsX;2DqyPVJ?Lpt=#Lnue07F zt?jU*K7*T93rpJ%iu!wYnCM*1GR-z2N{wk-e7+8EGo=RbXK+jT<;CqsMcnwo&->jC zHLm6SdnBrT^Xnv}zYe@5?E%BPvACW$m=(3Ky=k!P`QmC}$?$G27FP{!({A`Un52>S zN*rU$YH{tmc*yc<8rx~_LT#*UHXqV|hF*Heq_!ii@pD)q_-##Tf23z(W2sgu37L`@ z{vswmi3$CA{RAOt?PIt*zCf}J`)iF*j8GD!QV_9q)mW`5@}hYlhM18uB;r6uT{-PT eWIfv3$goEb=>kd8Pkjgemfuu?5L%tZDgFXxBF>ut diff --git a/src/unstract_cli/endpoints/__pycache__/hitl.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/hitl.cpython-313.pyc index f78b75e59971b9a6228e2a853693571f0e624358..0c0cc2cc0821ffb5cd2174822396344f31b79291 100644 GIT binary patch delta 641 zcmca1eL!0MGcPX}0}$}bCuV)*V_;-nP7xaK$(1&}$0O|$9 z7#5%xCSPO}0|%8cIH;64L5fX-%`CY#OEDQTayS-bx)EGH zRgnaUkOUE+04$OQ5waixPI=QTaJT_*I7R z$!0vBf|54`6>jj0+~Bpo!6VY|+vz)b0#6V~R2)_G15d5IsVpnoM;iuK-W$AfH~8Rc moM1XTc}=8c`B~XM6f=4O@+_12 z?pGajLZO!Ff^N>+Qv>b^f#ndmmjU-dzpIA810nEWWnOKF;qoDiBud|KPT|>M7;zOp zBMf4?5GiO6@~pSHyt}ls=h73wrB{(lSzKM)*?5TgMbamkqN#6lhw|)QL=4QioR^)X`B-dl{?M( MK+_lt-Y5h81pAtLrvLx| diff --git a/src/unstract_cli/endpoints/__pycache__/platform.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/platform.cpython-313.pyc index 95d45e16eb15ef36fefa1f08f9ba95be8fdfbec5..3350e30d9c34d3b2140f1f7db2472f4db8b4dd09 100644 GIT binary patch delta 7493 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 Ng#P&ShFT2H{{x6w<4FJj delta 7383 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c8deee..107077f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -263,11 +263,10 @@ def test_positional_args_are_not_advertised_as_flags(self): entry = next( c for c in discover(command="config set", detail="full")["commands"] ) - by_name = {f["name"]: f for f in entry["flags"]} - for name in ("product", "key", "value"): - assert by_name[name]["kind"] == "argument" - assert "flags" not in by_name[name], f"{name} is positional, not a flag" - assert entry["usage"] == "unstract config set PRODUCT KEY VALUE" + 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.""" @@ -277,11 +276,10 @@ def test_usage_line_present_for_every_command(self): 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="config set", detail="full")["commands"] + c for c in discover(command="whisper extract", detail="full")["commands"] ) - product = next(f for f in entry["flags"] if f["name"] == "product") - assert "whisper" in product["choices"] - assert "llmwhisperer" in product["choices"] + 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_is_the_default(self): """Full detail for 143 commands is ~50k tokens -- too much to read @@ -438,6 +436,33 @@ def test_get_never_echoes_a_secret(self, runner, cli, whisper_env): assert payload["configured"] is True assert FAKE_KEY not in result.stdout + @pytest.mark.parametrize( + "target", [["docstudio.platform"], ["docstudio", "platform"]] + ) + 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" + def test_set_writes_where_get_reads(self, runner, cli, isolated_env): """A write must land in the block the reader consults. @@ -446,10 +471,10 @@ def test_set_writes_where_get_reads(self, runner, cli, isolated_env): """ runner.invoke(cli, ["config", "init"]) assert runner.invoke( - cli, ["config", "set", "platform", "org_id", "org_ROUNDTRIP"] + cli, ["config", "set", "docstudio.platform", "org_id", "org_ROUNDTRIP"] ).exit_code == 0 - shown = json.loads(runner.invoke(cli, ["config", "get", "platform", "org_id"]).stdout) + 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() From 3d90544a0a622a8a44dde46d2a7c9d566a9cd2e9 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Thu, 23 Jul 2026 03:12:45 +0530 Subject: [PATCH 13/21] refactor: remove config block read aliases and the flat fallback Profile blocks now have exactly one accepted layout: the API group nested under its product. A block written any other way is ignored rather than half-applied, so a config that looks applied but is not cannot mislead. Fixes a latent bug found while removing them: ResolvedConfig.get/require normalised only Product, so an ApiGroup key fell through unconverted -- profile values were silently missed and errors read 'ApiGroup.LLMWHISPERER'. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 10 ++++ SPEC.md | 2 + .../config/__pycache__/loader.cpython-312.pyc | Bin 17301 -> 16918 bytes src/unstract_cli/config/loader.py | 50 +++++++----------- .../test_cli.cpython-312-pytest-9.1.1.pyc | Bin 103948 -> 106845 bytes .../test_http.cpython-312-pytest-9.1.1.pyc | Bin 38512 -> 44396 bytes tests/test_cli.py | 30 ++++++++++- tests/test_http.py | 38 ++++++++++++- 8 files changed, 98 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 175c790..d580e81 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,16 @@ unstract config get llmwhisperer base_url Valid targets: `docstudio.platform`, `docstudio.deployment`, `docstudio.hitl`, `llmwhisperer`, `apihub`. +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: + +```toml +[profiles.cloud-us.docstudio.platform] # ✓ read +[profiles.cloud-us.platform] # ✗ ignored +[profiles.cloud-us.whisper] # ✗ ignored (use llmwhisperer) +``` + 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 diff --git a/SPEC.md b/SPEC.md index 4f97498..ccc00d1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -160,6 +160,8 @@ Named profiles; `--profile/-p` or `UNSTRACT_PROFILE` selects one. 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" diff --git a/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc b/src/unstract_cli/config/__pycache__/loader.cpython-312.pyc index ad61b0654ff0fcc7208d5c7fe909be8adc4c7aac..30c2b6624a5d50be25e9a7ca76b700bbf445cc29 100644 GIT binary patch delta 3637 zcmai0eN0=|6@TyfJ%50~4mQ{(kQgvv0}kQKd?taEK(clu%}157c8=jagTb-ceUBuh zrVxp0*|ba{cWKt9U9!()V%n(OrJK}EtJY2X`Xf@KE_%wU{?WhMNo8yD&sMcN=RS-{ z7im}W&*z?d&bjBFkNfWBukkM|@x^~BDk|V$NZqliPrH_jy}}35a_}WlaZJ_Gsx2XC~SuUy;*+5)HeWXBSxc&0k&vag)J$GcoxWhbm}!F9P? zE?E*sE0%9JN-i)~oiir6AKD0K8ygk66XIy4Tna|YmjBu;dE$cX1q<{eVW$lzTQ^&> z`G8aL5T_D2+cw*0TH7Du3}P+7Q%vVlhfm>HY+F6x^*TQ z)8c7zG9(bJA-zCWW|HZ-lS)cM79F(J&=0MZbkg>&{Yyzp0-y9 zOM$J50Dc&x46PIsMNKD9DN!v(CKQd-0JA;&rhS1o?*NiCWQU3$>^AeLSJ><4etVGh$n0}9_aP09IB#4$J42JVj?;d(o$isXt+-zh77E|<^`(-fFSNeN_KR^ZVF2H_o zz`;D;kLn2Lsp?%cqodZ!#N-^ml%ZpQK$?^*dw`I~tM* z{*y=84!H=%%h+s;UZ`s0BlOLxPR}rMzl?Aap#mUeC8g9+UEYpP>Eg*WLK9VIXxY3r#dVo%brS2xXxQr^iM@4&(GAbh*yu%)8B! zGDBah+t^JWdt!A3^Xu*TTPT?b@KMxsxK zOAnY=a1R(MUgqYdjC79wW+=gp3p{s(6S@CmMaW1;xCBUs%rqPBUVz$r32YfBVfDZg z&1%gB3%XfN6HSqgu4a*TqWnfBge>Gml(Rt0^lW=ZEhF zTUXoPaNh~;zSw@HYq{$?!*^VP-wvK1yz6j%J#*LNyJTClaNgegR?b^}J@8ZUjwgIx zyKU`QZ3Q3!y0X(3{0GGE3a70{k>fJFwth=6J!Iv?O ztF1>CNu-F_Lc#tI-*7ug$c1YJ3n9^&?PT6JqA3UMifrdi z^mt@UASLwmNE=^AKaA{r`fR}t|IxHRo;cIvKP&Y5Lxo1Z9+gd&!#VJTpj(8sQzcRn zfc17_IV3+qZj_$x3_{6V?+lomF+=~Mf9|YxJt{e$bVhu4ovtg^W$Vl3*S5X5?WSe5 zezp8|St?n+jPm0`+xFz@frV^>Z zP`Q*9dcr~>wjgOjsG~i7HHC#p+7XKAWS`&6*6ZlE`nEgqfklcDu!>1B{dM2r(pn_3 zcXjKbCkBoV9X$L+NMB%ApzdMsbf*fBDNP~K^$(3oeg#^CG_~tDMv97ALlagiN6@Md z0iRy%ox&bQ#+A%I{_M2uyS<1a_-$hrA-8^{^SQ>+q*K~hK3DFw0z zDbhux;mOFV0o$6a8fwRo?n7WpNP#hSM}02&#ul4o(N8Fe3#iO|X5W&$E!+mTWZ|E{ zNtiu@^nz$KJ`2^VM5Dxn+WQb@5xz>F+7scwK`-p7U-$(wm?yW8dL7}H0J?eL(1HEO z_8NW0imQULPsNh6in=MVEPEJn-L~({xH6+9(kUax`06x5&SK0GvLAUT5ZGx37LjCu zXUV{P>Xv9UmP~@*pG&{B58g_%No6;A3&ik4q<$>Wx&8*;o&7`q4I%gx&ljw5h##9c Yp>R!LBoXR)^S_SrjzZy_g%$390Zo)LUH||9 delta 4150 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 diff --git a/src/unstract_cli/config/loader.py b/src/unstract_cli/config/loader.py index 85abf63..a09d6f6 100644 --- a/src/unstract_cli/config/loader.py +++ b/src/unstract_cli/config/loader.py @@ -25,14 +25,6 @@ from unstract_cli.core.model import ApiGroup, Product -#: Alternative block names accepted when *reading* a hand-written config file. -#: `whisper` matches the command group users type; `llmwhisperer` is the API -#: group name. Reading is lenient so an existing file is never silently ignored; -#: writing always uses the canonical nested path. -BLOCK_ALIASES: dict[str, tuple[str, ...]] = { - ApiGroup.LLMWHISPERER.value: ("whisper",), -} - #: Config blocks are nested by product, then by API group: #: #: [profiles.cloud-us.docstudio.platform] @@ -271,26 +263,24 @@ def _profile_block(self, product: str) -> dict[str, Any]: f"Profile {name!r} not found in {self.file.path}. Known profiles: {known}" ) return {} - # Preferred shape nests the API group 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. - for candidate in ( - GROUP_PATH.get(product, (product,)), - *((alias,) for alias in BLOCK_ALIASES.get(product, ())), - (product,), - ): - node: Any = profile - for segment in candidate: - node = node.get(segment) if isinstance(node, dict) else None - if node is None: - break - if isinstance(node, dict): - return node - return {} - - def get(self, product: str | Product, key: str, default: Any = None) -> Any: + # 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, Product) else product + product = product.value if isinstance(product, (ApiGroup, Product)) else product if (value := self.overrides.get(f"{product}.{key}")) is not None: return value @@ -310,16 +300,16 @@ def get(self, product: str | Product, key: str, default: Any = None) -> Any: return DEFAULT_BASE_URLS.get(product) return None - def require(self, product: str | Product, key: str) -> Any: + 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, Product) else product + 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 = BLOCK_ALIASES.get(product, (product,))[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 diff --git a/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc index ce32b4f046e9b5a88c891df96f7d81e30a41fb7a..fa918bc1e77a4d6f527a2edd4ec22915f6c08a75 100644 GIT binary patch delta 2426 zcmai#Z*Ut&6~OPEbf?ovmj6k%{Qu&PYb$o_#yF4!HzZKTNz*!QNt4oyQO>%veDztU z-kt1Nl`Na-G!7(FwLy_ulTleRuJP{JX#81HY}P5E*=azF&UkZFN5IMga05_9!@eCl3;+ zgO@3~AFqxxO3im+nWbYHMyY+CSuTsr(^X3FdFJVAC4{U|>X5ZcJu;|-ks+l4S*J82 z>y;*Cc&0hhlK*ub96ofs$c^eoY$D=H2hSu;V@%Ukt2fy@J+4_v)f`EOc50KlY>ye{ z|&Mvb~A=gNLbO{81mdkJo-+;rP@j1KPjr>E*B8-6Z(GxA5o~*z!8Ba#I zXD;higshnHX2gllF-+E%@mV+QO6S>|&0)ox@r{8}>KtR2-DUp@Pi~qJalZMLQ!XAy z9&xu^?H3*2$xbqK3EIeG9UzjUm!Q>IeAnxA|E0k>ezMCs%Lj9TbCC7mE8y$J*OwI+ z;iB8k%mgBpauu%c#TleVfzx;$f~Y-!IPDPc&muE~~a z^-y^X_f{H{b$wKhP4tSQboV(Tl@3UnHZ|K;a|uaKph+8@NYnC+G?}t&Y52rRX;hUg zJiG+;nABnu=qr^xD9KXHNQ`OmZF}}O`q2$z!jj}n?W zj>#$Aj^0f(=|QPI*)EFd4sjHBA(}FEsv5WLq;;^bZ`w2x@m}1+$)v9KwTq^SdutB8 zjVub5YTFo+RTS<`P1pVJiDo}eh(P*SM^|#@vk^;tt|T~r=$B5%Ra?w<(e^;ZiilZh z*Mkym9f(-wix{%GNL^k=X*g0@6zK`5QCmG{n?c&UpQ4xI0HVlG$$Cn)%-zmwU+w;K zBw!w-U7x2IqKrqo=kc_nU^5^#Qz(&ihiw+(EJ>;8Dzw|YZV1=$GWh$Ane@;bbg;( znqKLBa>ak@j*In*cX+0@p2!2xo(s1viG^_Ydbqz3?q3fNEF9gaZ6bqL23Pz;OCsXR z;F7pj+l|sn-w-9reM5!Xp>;p~-MFVIH@8{v4=wI5x7I!u-$8M(tX-w{*9S^QP)4)c zdbGc7XQzNRZ@ZX=ea`P6Y6`rF3;sk!@+<5zYU%S|mB@ME7Do>31D~^=3zq6oYu24{ zD{fkcvYf(Y`3#rgC%UlZ$#^WC%*UZMtne8R)))`%`?$u~J~F=>I>ATwo`(Aaitq}X z=5eB+comVn6@w6V{iFq2$k{6JI#pLIokN@_H^W4H!{rPk>L}=?cxvIy6IqFL<5nAJ%wEdHiQ^R({vy489&}Yc8edCsH%2Sy5A!m2_VzD|4UHO+&Eg1)F0w zyrCVbxXH2=9d}%AOhJ*U4iWK+xZ$t4{OE;8uidxW_Sl;L@pb=G1^-j;yVz=Rf!pMn zs?g%;3$rUi&r%a&VRoshP}#FC(BBQ=fs&%!TBE&Xag~lPi#z#_(DUY#a9*e||8{SS z%zuKDT&d6hYy<`YJ|zG68qCA3{Bu$0AH92B8B zKRplc@u35#vp5XkgE{6GUFI>G7qvuRfMd`|-dupg`1kh(==79wp^kN4YwKxEC3Gz@ z5fRJ9tz`BMIHu2NeIJ_%{Vd z=lUta6!%j^i0Z&Dc#oWS;9~z~nBo2B&&;1$=b8&#^M+8lT74Rip674#e(or|??G|1 iByKf(xjkET`OvrEVSvw&voFD`l{pq7TMT7sng0P@;?bZ0 delta 543 zcmW;IKWGzC90&0GKAP)U%$ekJxdb#NOVd=*qAp_4E(&oF993vjz1SpJ4R@zC4w|S` zYC@Nb{uWJjb8x6id4mY*P!SQ-F(4``9UQF1Kebr&z25M-_jq4^eBXQbOl{s%^_Rh5 zk0QPo+egdK^L2er2RE_e50399#BgJ)9loL*3E2^0*fxX_J1R76Qy8^lLesW{v1&Y% z;D&{vJ$zRlm$YQ2%W2{e9C>N>?z&V3fEw;KcK!E z9Y!xZ$s$)!q_;?)$HTDObY z$41@4$QkD(g&NqozSA?|l)S=-S2*WUt^r&9Cws1=hYg54pKjuVYHnhR;s(UMmut$? zekUhiK%>h(a){6?A4B4&w~+4EMbHT{|4#1~Dz>19)~3zLd_*2uG}!_n!IRfH*pxSC~v}~R2$je@3P4c$%+JWKuD65q-e5@6lUpe8%?$p=O<=x0N70T QZ{ya6;|!T4MY`7ZKj({~od5s; diff --git a/tests/__pycache__/test_http.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_http.cpython-312-pytest-9.1.1.pyc index 21f0d59cc7942e0cea48230e6c71fa138a5d96eb..e72d7943a2bd75eae9555035083abd3c7b938136 100644 GIT binary patch delta 9853 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 delta 5564 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 HD}3@_$9?M5 diff --git a/tests/test_cli.py b/tests/test_cli.py index 107077f..1d4797c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -463,6 +463,34 @@ def test_unqualified_target_is_rejected(self, runner, cli, bare): 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]"] + ) + 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. @@ -520,7 +548,7 @@ def test_project_config_found_from_subdirectory(self, runner, cli, tmp_path, mon (project / "deep" / "nested").mkdir(parents=True) (project / ".unstract.toml").write_text( 'default_profile = "proj"\n\n' - '[profiles.proj.whisper]\n' + '[profiles.proj.llmwhisperer]\n' 'base_url = "https://project.example/api/v2"\n' ) monkeypatch.chdir(project / "deep" / "nested") diff --git a/tests/test_http.py b/tests/test_http.py index 1d04426..21ceb20 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -6,7 +6,12 @@ import pytest import respx -from unstract_cli.config.loader import ConfigFile, ResolvedConfig +from unstract_cli.config.loader import ( + ConfigError, + ConfigFile, + ResolvedConfig, + load_config, +) from unstract_cli.core.errors import ( REDACTED, CLIError, @@ -99,6 +104,37 @@ def test_apihub_never_sends_gateway_headers(self, monkeypatch): 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.""" From 10fab44c583d046f90ea89219e1f9c7293d1639d Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Fri, 24 Jul 2026 04:40:57 +0530 Subject: [PATCH 14/21] chore: untrack build artifacts and add .gitignore The repo tracked 50 `__pycache__/*.pyc` files, so every commit that ran the test suite carried dozens of modified binaries and buried the real diff. They are build output: regenerated on any run, machine- and interpreter-specific (both cpython-312 and -313 copies were committed), and never useful to a reader. `git rm --cached` drops them from the index while leaving them on disk, so nothing needs rebuilding locally. Also adds a .gitignore, which the repo had been missing entirely -- covering caches, build output, virtualenvs, and `.unstract.toml`, which is a local config file that can hold credentials. --- .gitignore | 24 ++++++++++++++++++ .../__pycache__/__init__.cpython-312.pyc | Bin 516 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 516 -> 0 bytes .../__pycache__/__main__.cpython-312.pyc | Bin 769 -> 0 bytes .../__pycache__/__main__.cpython-313.pyc | Bin 751 -> 0 bytes .../__pycache__/app.cpython-312.pyc | Bin 19191 -> 0 bytes .../__pycache__/app.cpython-313.pyc | Bin 19288 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 177 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 177 -> 0 bytes .../__pycache__/config_cmd.cpython-312.pyc | Bin 15058 -> 0 bytes .../__pycache__/config_cmd.cpython-313.pyc | Bin 13093 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 175 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 175 -> 0 bytes .../config/__pycache__/loader.cpython-312.pyc | Bin 16918 -> 0 bytes .../config/__pycache__/loader.cpython-313.pyc | Bin 16910 -> 0 bytes .../core/__pycache__/__init__.cpython-312.pyc | Bin 173 -> 0 bytes .../core/__pycache__/__init__.cpython-313.pyc | Bin 173 -> 0 bytes .../core/__pycache__/errors.cpython-312.pyc | Bin 9500 -> 0 bytes .../core/__pycache__/errors.cpython-313.pyc | Bin 9774 -> 0 bytes .../core/__pycache__/generate.cpython-312.pyc | Bin 17831 -> 0 bytes .../core/__pycache__/generate.cpython-313.pyc | Bin 18180 -> 0 bytes .../core/__pycache__/http.cpython-312.pyc | Bin 21051 -> 0 bytes .../core/__pycache__/http.cpython-313.pyc | Bin 21831 -> 0 bytes .../core/__pycache__/model.cpython-312.pyc | Bin 20342 -> 0 bytes .../core/__pycache__/model.cpython-313.pyc | Bin 20642 -> 0 bytes .../core/__pycache__/output.cpython-312.pyc | Bin 11671 -> 0 bytes .../core/__pycache__/output.cpython-313.pyc | Bin 11924 -> 0 bytes .../core/__pycache__/poll.cpython-312.pyc | Bin 7181 -> 0 bytes .../core/__pycache__/poll.cpython-313.pyc | Bin 7249 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 2569 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 2564 -> 0 bytes .../__pycache__/apihub.cpython-312.pyc | Bin 9048 -> 0 bytes .../__pycache__/apihub.cpython-313.pyc | Bin 8772 -> 0 bytes .../__pycache__/deployment.cpython-312.pyc | Bin 6605 -> 0 bytes .../__pycache__/deployment.cpython-313.pyc | Bin 6485 -> 0 bytes .../__pycache__/hitl.cpython-312.pyc | Bin 3598 -> 0 bytes .../__pycache__/hitl.cpython-313.pyc | Bin 3520 -> 0 bytes .../__pycache__/platform.cpython-312.pyc | Bin 49316 -> 0 bytes .../__pycache__/platform.cpython-313.pyc | Bin 47484 -> 0 bytes .../__pycache__/whisper.cpython-312.pyc | Bin 10539 -> 0 bytes .../__pycache__/whisper.cpython-313.pyc | Bin 10227 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 174 -> 0 bytes .../skill/__pycache__/docdiff.cpython-312.pyc | Bin 16218 -> 0 bytes tests/__pycache__/__init__.cpython-312.pyc | Bin 157 -> 0 bytes .../conftest.cpython-312-pytest-9.1.1.pyc | Bin 3187 -> 0 bytes .../test_cli.cpython-312-pytest-9.1.1.pyc | Bin 106845 -> 0 bytes .../test_http.cpython-312-pytest-9.1.1.pyc | Bin 44396 -> 0 bytes .../test_model.cpython-312-pytest-9.1.1.pyc | Bin 47666 -> 0 bytes .../test_output.cpython-312-pytest-9.1.1.pyc | Bin 20413 -> 0 bytes .../test_poll.cpython-312-pytest-9.1.1.pyc | Bin 24295 -> 0 bytes ...skill_docdiff.cpython-312-pytest-9.1.1.pyc | Bin 25068 -> 0 bytes 51 files changed, 24 insertions(+) create mode 100644 .gitignore delete mode 100644 src/unstract_cli/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/unstract_cli/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/unstract_cli/__pycache__/__main__.cpython-312.pyc delete mode 100644 src/unstract_cli/__pycache__/__main__.cpython-313.pyc delete mode 100644 src/unstract_cli/__pycache__/app.cpython-312.pyc delete mode 100644 src/unstract_cli/__pycache__/app.cpython-313.pyc delete mode 100644 src/unstract_cli/commands/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/unstract_cli/commands/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/unstract_cli/commands/__pycache__/config_cmd.cpython-312.pyc delete mode 100644 src/unstract_cli/commands/__pycache__/config_cmd.cpython-313.pyc delete mode 100644 src/unstract_cli/config/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/unstract_cli/config/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/unstract_cli/config/__pycache__/loader.cpython-312.pyc delete mode 100644 src/unstract_cli/config/__pycache__/loader.cpython-313.pyc delete mode 100644 src/unstract_cli/core/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/unstract_cli/core/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/unstract_cli/core/__pycache__/errors.cpython-312.pyc delete mode 100644 src/unstract_cli/core/__pycache__/errors.cpython-313.pyc delete mode 100644 src/unstract_cli/core/__pycache__/generate.cpython-312.pyc delete mode 100644 src/unstract_cli/core/__pycache__/generate.cpython-313.pyc delete mode 100644 src/unstract_cli/core/__pycache__/http.cpython-312.pyc delete mode 100644 src/unstract_cli/core/__pycache__/http.cpython-313.pyc delete mode 100644 src/unstract_cli/core/__pycache__/model.cpython-312.pyc delete mode 100644 src/unstract_cli/core/__pycache__/model.cpython-313.pyc delete mode 100644 src/unstract_cli/core/__pycache__/output.cpython-312.pyc delete mode 100644 src/unstract_cli/core/__pycache__/output.cpython-313.pyc delete mode 100644 src/unstract_cli/core/__pycache__/poll.cpython-312.pyc delete mode 100644 src/unstract_cli/core/__pycache__/poll.cpython-313.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/__init__.cpython-313.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/apihub.cpython-312.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/apihub.cpython-313.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/deployment.cpython-312.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/deployment.cpython-313.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/hitl.cpython-312.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/hitl.cpython-313.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/platform.cpython-312.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/platform.cpython-313.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/whisper.cpython-312.pyc delete mode 100644 src/unstract_cli/endpoints/__pycache__/whisper.cpython-313.pyc delete mode 100644 src/unstract_cli/skill/__pycache__/__init__.cpython-312.pyc delete mode 100644 src/unstract_cli/skill/__pycache__/docdiff.cpython-312.pyc delete mode 100644 tests/__pycache__/__init__.cpython-312.pyc delete mode 100644 tests/__pycache__/conftest.cpython-312-pytest-9.1.1.pyc delete mode 100644 tests/__pycache__/test_cli.cpython-312-pytest-9.1.1.pyc delete mode 100644 tests/__pycache__/test_http.cpython-312-pytest-9.1.1.pyc delete mode 100644 tests/__pycache__/test_model.cpython-312-pytest-9.1.1.pyc delete mode 100644 tests/__pycache__/test_output.cpython-312-pytest-9.1.1.pyc delete mode 100644 tests/__pycache__/test_poll.cpython-312-pytest-9.1.1.pyc delete mode 100644 tests/__pycache__/test_skill_docdiff.cpython-312-pytest-9.1.1.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3301047 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# 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/ +.coverage +htmlcov/ + +# Local config; may hold credentials +.unstract.toml diff --git a/src/unstract_cli/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index ed37f2c11f9ba003d844f1113b79d1809eaedf75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 516 zcmZ8e!Ab)$5KXo%%1XtPh^M(0+TFW|C<-c65kv~TgfvOr*xh7FvdVh%<{$VCev5z5 zligj2nU(8r7*$(RUG!tj^U^pCGlbkq@A%dU6fSS zplBhwGpu#A{_TYt=&{Fnu-BXX|0%wq)+-a&Qdib%4+9+L!azDJ@^KQs275YqxlcUfH-$@0QH6Z~Xw}VxYqS diff --git a/src/unstract_cli/__pycache__/__init__.cpython-313.pyc b/src/unstract_cli/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 7a67709b90ba70bf433d8b2779b44d370be11158..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 516 zcmZ8eJx?4l5ViLXfff-_Aw)IpN!+@Rq6m~oeB_9bRtPDL<@5TohFyC#_8wZgbom4M z5&WgCkf`W_4tnehN0BM)@ywg&r#FkOt(1g#1aWmh33-T*RU~i8VlO0rNKTOCG*7OdQ>a3-VzZ^4)0Dp4tD z&=#%q4yLWHJWRWE#q<%rTG`ghc*uQQXuBT?FH7wjg$hBK0uIkl;JBS;>H8FDw`7;L zZiMN;h+6G0vCfO`UoY0c&H^rggWl}_pVAxQf-*JNwsk>!94KHV477Kunq}!@v}Z*7 zP0r^EowkN^9E95U(Xe0Vys(n(zIZ) zmOgUKNH;dBS5Zs=tsLNTX?vWcF^|$@3r4VP9cYMDE6Fn`(h15lV`ZHYk@J|I$a7N% zrm4YV{dn208{trAG8_t%g!i=>*<3_293(l2MWb-ezXJTwn&|I_7YsSiM4~wF5^hgd5pBJkd?x3QX=|_lVq<^f)z<5KM|5RM5M8S@e**>^&2az# diff --git a/src/unstract_cli/__pycache__/__main__.cpython-313.pyc b/src/unstract_cli/__pycache__/__main__.cpython-313.pyc deleted file mode 100644 index 30428db85b019dac5358a7582d997f678c352d1d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 751 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 diff --git a/src/unstract_cli/commands/__pycache__/config_cmd.cpython-313.pyc b/src/unstract_cli/commands/__pycache__/config_cmd.cpython-313.pyc deleted file mode 100644 index ce78968ea0ac216e53cd1a96e1402e3092c11110..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13093 zcmb_iYj7Lab-oMi;zjUHN_=xgQlcP}ASGL-NK3LIih7U~Nn;6FHWfnW4kS}65UF1K-)U+OqH&K)B=9T{ z5^eDjzxkMzkRl?IW0m?<gjVA@~UZbYmwS`DUl2a<~ zHA%ar63lP3oHn0q_Ih$TjXSASDwE24O(5@pk(RrHyb7sOs*e%!#omJ~xjf*7E(I31lA z17cj2sTieV0BVkjr=!uB8l!Ps=J+ zHY=W95TQXds?N#5SXi0DROmo|BpjSEM5tmYOyPB8p+i)o4#)vzLh@NL5Sb4wU?*ci zD8*Vo7ogBMx9CwsIY1*}Xb2<)A`v-~sd_FJhMq4NtPnaH^+q zgkW$%HjU2C#ne9WR9FeePC@mPFe@+yQ85^ao<0qw126<Jz>jx(-2Fptfs54NH+wm(@p)O7<3Mf!_0K1~XquHY@jJWJt@qyvd z{vp3-_^3x3kTl0(@2GFgKjs-fqB+OBqX&Kcq09D$&-P2B<6=M<1;VMhGW5u!Dc4H3~?(V-1@`pMg;ibLsMJ| zrnsCLWeXbRGvo~c%&{$FmcekNd7dIY=1J}vH*$?Y)h2hhW(`Eb0hkGm3#ces6h+P3 zH65LmyQTv)-1S71&Zu*NpxpITcn&%hoau@yxP5}L&R`_mrP5$F!H)?U4WPmeZb^S| zHsqdL(CmA0+n=4I`)M6ivlad--u#Lzl9lSw>+SoN4u3TN{+8DRAIx70z~dJxKr8a% z=34Hzx)1H-*8AWsc6~csF$4gUO=JhkM-2VjMkcUdEErn7jnFDcaG7WsTqq0!eyw&x}-X%+_sCk8d^qoISbv+8#b$CVYYAIqw z*3!R*n>WY=W?;b!N^@fXGywQfGUw&O?r#V!WCA8v50&INV6rY9mzBVj6E95m#xQ0& z23^FdS4~QGOe6xRKf@0xbw+6e2u}3sOBHnrtWCFg zP+zNT7eAwdmYScI6;X|apdfBmRQ3UJeEdl_EH*y2Fehu)%=*^&X*n|I5;OtUOmGIq zp6NNw(mx#y2W2)dn!t1z%nUb~KPe@CyHGcqb=MUKtGCKbU9 zMpX9*tp<^L)P#3z9Xm6dk(p(RYk3d+Ra}TK5Q_OU9u;$pYdLysVa-UhUN%me|u}q*wWZ^_`cV^ZU>qNHK z6V1kZpVg=bB;ri7;InvJjQiX*o6|9Y=79wtpG_@t3lr_x^sEz83OMW^Bb9NX~tY1C@o) z54#I0$)Q(s2)4zSHh?hkP-k2nckx=OVZk#xL9-dkLE{-{urQRPCK9mKLa?#JfryF_ z72xifK17NPJc3q1^aw2hMar5o9gT!gry>fYa!hrLlB~BBz!YFC0BIqV5vEZE6^3{Q z?04WaU=QpkLU61s2)o-2de;gv5Q9^kU5Z!&bX7DaYvoj)i-5fk`3*GYs-msB6r7x5f;6)t z&udnk+nB7VXd40Bu%PQi1IeM@qi8zz!C(CuM2qB(gA|pmI$EzgT2qd#i~To?Hhpv7 z;=xd1wWbotP2D{psvW+O$V z7usIgmbMkWYP(>2z4G-FZ$5qL=~Q{!4cpdR&8=6SyZqdh=Py71$^#ceHyrivwk`Jm z2fzEj998L>mYd~UQsu4b;_`H9y~<6JC|;D z-K=R^Qm>r5d@fbfvBq=d!`vMUY1y_M{BG^-a=YDer;6B%&maG$^V7{v!IIJS5y06A z*D%C}ItQmko(wCngu=RCrB98nuJ6J%IK@me$H0n#W=31af}tlAV227}9B)=L8zo4V zl>#(lI&MDB9~V?1!!-dj#ki){6Rp3aB$hI7V5kOs{6tpg`vgOa_)S@c%B%e@1?W)$ZEr}0x48N@1V}}Axgq#066fY~+CGO=^EDa6VyuOe zIxZ8iwb(vDXQmMs&sMv>@v=MEXfE_AHGw5Cxhf87!F_~lijKkCDfp|)5WzmO5ZF1Li~VaB(kNaT zzC66z;7&HUZ!~oOpI|Pt{Gy@z!tnWnYi3xZ|2X{P;h&7$C>i?uk!$X&?dRv;vMj~_ zqWF60)*p{tFB!US8~XQ#?lpl3#p?u$-mwwE{z=v5CG!>AW!r1V(wl1EeCEE@Qz z=3U9=UFjMTwhpX+r?LJ^ZGz>Cbt@?y=DzsU3?dkw|3UqMU5*cgngbnGA2izF{=*In z#F`nj9UXyQ0KyUBqM5<3PS}~Fjrtg1@m!*MOn2{4!=kwnC&AAJ+?NMx5aAgC$Bu0s z2UwA(xvv0g_=&8XLQv)eF&2+Pn(|;vZUGyc(`S;nURdNlp^zLO8RxQ+^31%Cdk83P zAct%cpQDnE<*(Q#&@*BICHQPvNCg|nXURT6uO(sBESW|*p9MC8X_%8Nuo-f2Aqz{4 zR)ASIZ6Ooc&1WTnAR%{wjVzeTfsF~YJuG<9(c2IV+1_lca~X_r0vN=55j$@PIau^N z>d2@?V~j-74DeC0daP3>{W{4_l6~f8GRe8DfqKX;>1-9v5DrM!AQ{pD{#GOsokuPO z0kgOttvtjTTmg3($bmGBPGm5C2*?aZg5~i#K**s}hGGCemzn@E2hBh=3zv=qrMRDStS0k6hUu zg-j5@C^?Q46%cGxJPpi*KTac#Yv4)Yrqi66PW#cX8oA3Z!NwwhAh`7oxY^IOf=RJDAU#8iz_LW0uhSun3t%48H8b4=nKW*idf}QOL>3udV6=tSsE>;uy64`@ zd_8r)J4oDp;VNTrjN&PZ=J5A>Mn*@D_Irlu<5&be7TpRU;?Y5w z2k+2qQ-nCWHQ0}Lk3yuG1ImJqsqDF0neN9C*KNZ9X0#bAQ*V-4J{#2R;KgO|DAp{V zy2~_VT8*PcC*gGh{_4L#^f{OYaRbCEsd-hope#L+Dse9!UNc#Q(wmiAzW3aBpMSq6 zSvhpxmM*J#(|O6c^u%&7Rkq{2aHouvSKX-eB)2`5s`RAFJc~yG7~R_3l&;+L^WFpB z_g$L#TmGMfbY1guY^Cmj0>q^?X zZrFAf1aLQPCBIp-LcuT98tBgt+YVHd533yqP38}qJ+=M&%pdI&-~li!bRB0fQI%&Z zbcZ7rbqDhy5l$Gh4yV7Wbr1?Ty%wK&A7^OZ5G+d2Xcn&p>evwSazVX#*r-?`C$?ro z@Mhsq?uF0VowGf*oSrTOh+4=A@=(^k0A~rUQtUokLAmyPxyL|L9a$@L zVs4z9$l4U2!)S}INV0-+n{~KzPOo99VD1EO*6GewowJ-1qLF$(*V?ENt*XlwM;L-m zWB`0cI4vjy{j(;eLuB>@=>OxMVc)?0Ez1+g{xH82Pd|Vw4xf=7hP}%0`Z>Lix*R-o zFfzdDgAu#GWq}>F(XucD<2lg3bJv3fj0_eqEJ|Q5><1htwxhE>JbdhlBS+!X!aLyg z!+{gh|Lyk>kZbKdt&}(&i_NKhU0uIe{C*cy)OBVT_GOH@=i(`>@wmt9AMqR;(9dPS z1vdx)cEqLo`RJ#EDGo*{xck7Uf?akcmejH5dSlNLchUT3c=H9FfH(9cL*jbj<_2FAY&LW`gV-qQ zW8H8g!leQIJ)Rw@NVevAZn*nt)@tiSi&gUV_YX*t%bC{>v^IL%khG_0y7PQHAbCPS z!>jTXazyk9zMX+c#rm`68?3lak{A{onoOUC9N)pLzkvuXxO;;IcT}URmoBPUE$X=# zT;1eKZgRox)u$E@t~p3U^Oe1q_pa9OO4jds_o4SHQuTWm58kY7d~^Sw?_X}e+V^jj z51+RJ;qmI%FMNGz{;DZe`oPQlZ8NFrxm8wuzVd~m>7tU=q9*vIn_6zz>Tj0RA|KMU zc=%RH1&|_E#|_7xWMfatv1iq~Cu!aDe)X#7@ucVR74L~v&yz{dlR%FYKZ?F)8Sp!T zJzZ7vuJqH^_oqJUNLSV0HgopIPi#)4O72*B!2(SdY%d*o;m9k}>ou$8TarM^6t{fL zx9S{<87R9%jRd*{mr`av4NUH4j&u;zYc^zkzcua;LTDGTWGw@M`P7L9++|x7A1u?zk!F-G?vapq%^9q=1 z(S#XnHPa?4E*#wpxLbwbR~Yl~ZWaFOk01gK0wn2OG^?&bZdn~KoqOrr694w_w}(?T z-RZKbH|>|~Z*5zye7oV>4XHYJs;q0ZtoPlA&kF$D(GvUI>zZ!xn{z4!Jc*_8cOJYt|5tmH_B}sod+*FYbbQPo*0;VjLxLxEF_#_p zzYcMX?avG_7-O&|`o{x?FR!{2mB!=)WfWG}cO>q@isD~&MFFuMe)}BT2OAm498)(@ z%sGMxfb}o{YxrhQ5hVO-56<@#OA~G#R6-lVi9-g++y}t_G|b+KxX*$h`xulvChEW& zNTn?JFD%E$OJ+lovAukP#G|AWl8;@Ffwe>!pHmVbE2|oE4a)=UCU2I?$-OT~>SV?V zBo-=xe*K03=UGTTaQVU8^5gSdyjl~-+S}P(!+~K(#=~IkNi(%GXMaw<-G8- zO4s@RH4|T4n{M2C<>cj)SKCvKJu6i`7YCA+2h&@&Ew#Qjh|pkwyDgBa9cxarz5C`4 z_tmq1bMD%?_YS}RWU6a4wPWo3z)Hooq_Z8_=a!3`Q?}aMwWLbC(*V~0R@1iCrrpV= z-8bu-md@fy%xd$_Wb@8c{VuHZYnQ*a+PE{>xU(>ijMPC@^E%;*YqhePH8Wg)1KO;t z9+K?c=^b6H>J(HBu%i1*wH3Pk{hEG`|3GN!uP}YYIr>YjAC+(zSCsa*2_Lm`5VI*r z)MeF5K0?LAL-$>b7iwBFGG1rkrtnEjPG{sOf}Dwh6N&;F0qq9{IHxe>&O!>GQ{Y*I zDYtL{6|)0Dwz}BDVn>5)Ihk~$fs0HPA2h<%R&#rgT5yiUjAcz#0>OLA<77&8&VzmHefwu+koAj#!@V2 zAxRmGzM;%EEc1Jm&e&a+vygO)zFF^CdxvKA`$N&7-w&Lje=-ikA^ZIlmJgiwz^6H& zG$@a4!IerM!$`mg*BE6Agy{}OFS02{bHL7N*@>-IV?dJ3xh|F6aH1Qfs!l;7`&cNC z{v{8a7xo#Ju9psAZVU5URjtf#e4W>SWb`PwI~1%Np{FonP7y8>O~`%P0%zO$SE#fN z^Blzp&!se*@mV9?ifNM=p;NlH_gvq) z=f>8)kBf(|Z|%F3xcKZ(55M>9#qyU!9~{0ANN(**6%Q{u?pQePG49tkfwQlb5tH?$ z`WNdjZeQ8rO>)w@iQ~q&bu%%Ib8GA#zI`&4ta8;!uKMESN~>>$t6t%br#Z_@4KFsF z4=-0;ji)Pnz&$ec-r`EvO|6^-iFna?2^M0V`~JvFW0HS$Ny|9p_5tzLdB>$vtYM3n^}s(WsfFhptwwJT#Q#hM9OV zPy8{~xy{(H$JlM-ngiai9m;FnSE&!%WKD538|ofRazm`{4z5if4ybULRalG9cQwEX znbw}pZ@@~+UN$`YjP6-d+-4@Plsm$tl%bRwjP|ctFmbIdH(=qFZBMcC6C0X!G|3Gz zF?D%EGQZy4IHy1JnH|6Z04=W8wyKReNBXFaZ{xvu?#v_k#6{J3cEZImR)B M|G}=Bqq}YY4~iSu$p8QV diff --git a/src/unstract_cli/config/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/config/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index af6bdd6ed14282102e5235a1833e386d22a0cefd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 175 zcmX@j%ge<81X0`Kvq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!^3%`A&rQ|ONG!_K z56>^kE-pw+PSuafEGRBXOwQIX%_}Y`N=z=%P0q>GFD^=k3&ev2lJoP@GSl_r<1_Oz nOXB183My}L*yQG?l;)(`6|n-1X9VJ65aS~=BO_xGGmr%US)MJH diff --git a/src/unstract_cli/config/__pycache__/__init__.cpython-313.pyc b/src/unstract_cli/config/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 91377eb5aadb780b15f2a42770b4416c8d6ed720..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 175 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 diff --git a/src/unstract_cli/config/__pycache__/loader.cpython-313.pyc b/src/unstract_cli/config/__pycache__/loader.cpython-313.pyc deleted file mode 100644 index 254a732a6a7a78770e114c816088222f3e02002f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16910 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 J&Cb{Te*xw0(SHB{ diff --git a/src/unstract_cli/core/__pycache__/errors.cpython-313.pyc b/src/unstract_cli/core/__pycache__/errors.cpython-313.pyc deleted file mode 100644 index e728d82c7e77146066fd44c195da1444d3527bdc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9774 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 YG^|j=A10)Bsp;{4kJt8fr`Y%Z1r%zmvH$=8 diff --git a/src/unstract_cli/core/__pycache__/generate.cpython-312.pyc b/src/unstract_cli/core/__pycache__/generate.cpython-312.pyc deleted file mode 100644 index 12aae4db02607dacb0ecec1eb22125910a98a98f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17831 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 F{$FK8*k%9# diff --git a/src/unstract_cli/core/__pycache__/generate.cpython-313.pyc b/src/unstract_cli/core/__pycache__/generate.cpython-313.pyc deleted file mode 100644 index 7d8b97f2c922b68826a8481ca93c969f4e434c0a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18180 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 diff --git a/src/unstract_cli/core/__pycache__/http.cpython-312.pyc b/src/unstract_cli/core/__pycache__/http.cpython-312.pyc deleted file mode 100644 index bc2f3281c4bc7897fe887dc8360fa120353b6c0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21051 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 OA2*pTny(w#YW#o7-H^@z diff --git a/src/unstract_cli/core/__pycache__/model.cpython-312.pyc b/src/unstract_cli/core/__pycache__/model.cpython-312.pyc deleted file mode 100644 index 18276e46a758594808e638a7c0b2dd3b0bae075e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20342 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 diff --git a/src/unstract_cli/core/__pycache__/model.cpython-313.pyc b/src/unstract_cli/core/__pycache__/model.cpython-313.pyc deleted file mode 100644 index 5abed5bd953bf9aa7dc3c7876df88b415f3e5de3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20642 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 diff --git a/src/unstract_cli/core/__pycache__/output.cpython-312.pyc b/src/unstract_cli/core/__pycache__/output.cpython-312.pyc deleted file mode 100644 index 269656b6c1f28f64aedac8ddb46e945483ac2df1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11671 zcmb_iYj6}-cJ7|;d1^*8Vk87eKnuiUgd~jdYr$rXKwxaZUL>PJHW>}mEosDPM!wyH zkai}KH&iUyY$4WOBga%$UXwB`Yz0?sRd6alZ0}~XRrwq&V8Id_i@ho&bjwr0s)_Z=c(zAp2yY*!tdyZ{kW`*cyF^P2p0rZ zXctsbbtJ`JN4rC$yp)vMCH|J%W&U=yJNety?&5EEyBlvg>FM>hdwCyc($`zkUefDt z_vdK@+5-+@zo5F#2&!8v)x1|_>&xvyb-7xpdd@i8L+T3Edq!?A^9t%puVDXESE(ha zU8Yv4)v6!4Fx9J)8n`IhZLGJtS}i@}ZZB7Ts106pEUcwp>KZk4#?@Z&M_Q~^%g|!E zx(s6y)$m10Rn&5nRCWn!#ZSfd6$4VFCi5#y*NYjWFJmZ7OQ{-5q`H*NEiX0iY3x;% z&p&#+aa*L(=WFfOlz2L2uvpwsVksrorKJo-)%ws!SJJ5+K3|>E(b3S5wrKU}=~PFD zlF$_cJyoqUmPr~)f47#>PH9Zh4HXTkx~?vjHtOn>n9|yMtX|R6N?)Rnct|s!qQql; zMuxLQWcD09pbR9mB)-@ii+3kdS_9K!YV1T(Q}W;KPZ-@wDy?Xz!K%`kjCJXa;H<7L z|HXKWv4m!E)fXE`rei9GqoRuedR>Z^)U`&i(-cdkG^W=p{V|q81#*2X-NiIr2V2xb zO~krVY28S~btRTeDqU&CNYlVH#`rioGAZ4fug1nkC9Cwj)`5oou>oD_i!oi}jCFLd zSU)C8>rEJoxJsr|nzES>ByMP`Vjx?ubS5y^69a~(N0iPq%gv)-6t%CyL)JP_+mnofgO!IUtX!x6XXiAGbgUM(6mebH!dTFoRW z?~g`b%fyoT8u|H_7Z02AvE7Fbn$FhUO$VDz>B#Q3E5beE=iBGEcBgx_t=%z}*xHt6 zC-uHqT-(~7=+li@{Nz@9avS2w#8#cf3k6Xsh^Lvh)!Gt`eFMw`f;?G?jJ_Pn8DXZR zY-q2Q_`^fZGrr*Kdsv0tFrJ?l{1ya~aEYdQY$b?6pjgJU+Nc5OUHzM5w$eI{3*8Ubf9Gz z+!w#S1ns!vn9w5Flp>DIuR(^kiIPbrA$rgf-I-pfA1IFgm_aIFDP^VL>F9X*a7*iv z-FsT2FTULR(#x$7vNTi=OQ?`MQZ~>^h88D5QF^sl3dC)-)}=uf`DaO;U>iZ?6$q`a z)Aua30?p7(qG)pzgIa`$vfe~024z4L@SB99k@q%(i};E7O-EWc-CF8YfP<8S7 ziIPnych<}Xt8NFC$)Ix6qo9Ga=36}i20vWUBnqFEt!)YkpKS{w{ao}m1*FgYBBi0V zO^-^SZxfL&5M&ZfOoDjt8Zs9kQbXc^XcR;}D;NcVzM9W5S5{yS)zKoT5=3Ku+q~fy z=56XuN1$ z>zs+hM#83@(BjaTFw?zIm4>x}Sqz4g!3bk>zchf&oPwF}V|t@0m@>L*rn55!n|DPt z-T4gx_3cYwr$f)C;+iR+NT-vgtR~{n_{jvmN8>V`YNoeO$Hp`mtc>#%?Nyy-C%?Ts zQ5avt&{AwW3LnLv{ydT);f~usl$!~b&AQ7*H(V^AcCYzj<(lDB6P_*O`;l11wU^|v z>S_1J*;nm)e7Gbnw-b7A3< z6oRxBdD5OMr;nxeg z&+fs-vw|*n30*=bHdt}PsE#w(oO=aZXWJaGlUb4Z=i3gu=1os(eV6d2hk6}{CC|wt zo}LoS#iD1}(+MPe#*>vFrY;I+*T}*Mr*Td`=Zs5Tm|H$Kpte^)iG!CAedoN5Dla*2 zPMbITecnOzdKauxWIO=o{ixc+Yt&vXt=d z!+e!2HD4*;yi&oYo^z=HJ6(>f3%t1Vyuj1QxwGzh+$_CM+%^^a-Q@tU3g^{rk8(bh zLOZcbr207g6q=oWE5~OipLgWsLeAYUL|n1If~@TYT0f;(Rt939%=B_ArYbDmuPdD_ z-K)gdi3C6>8{p~yK%OiafqCF}^o}bG&`jBU;LuA4n-4V~Zr$B_;Kjqymk#beOz{8E zw#FwSfcDg>P9fn}(w$2BL=Wtyt~Bf_e3XBSAbKW6_7sgyY6HN`i4*`U(~SmQ(@@L+ zj`PIGloD*`v_KxjjQ(_kH45szm8%r~Q8dl>ilv~#$Oscow|d2tJgJTj-UD{tB4V-K z(UG5+`Sw5GLeaHk9sv-Ph7RomYKA6-pTaP&i>8V5Kn@20d^*jN1D0ivI88UY!oPtv z?*&SMZ$z2i6L27)IAcc4^mQk?x|8@b^oI-*^rLw1X~^jd&>3Wujzg*C=9?w)VYBqE zctM1`E|-M?;pbAnctt!Maj<$&VU0)t_#KUpm~z6ODiWI7IOu&c4q>L~i^E0FxB@Xe4gQTpV^wU=wp4vs-V-wtm0xb}fgR`3U_ zs>KE4BBa5`MI8NDEQ|~qDJN#d5yvSJdeV~>^WzBE<6t|{fss~^NQ@@M6i=G&ZcI8R zGY?>fIDlchQ2W0d#H(nj6ZHSa8pXQ(SJca8*JC&OZ$7c_(zYw=1D!mCzx0-iKXjLi zjI5O@VGl;!#VKV~C}mrD75qKZ1y>3dnVYwWmpw%l?)*fuCi>+}kPpe8p}g2_$|Mr( zS^Q$Vk?3Sbv4#2btcA(S9$FH10Ii6E9)-YT3GP=5o@Jx87v*X9>feQ*AKo)lzT(1j z=bsyEdHc|C^K5YC*oI5xw}Kmg_V`R?^{D(ssAA45l)oSX&lUQ85e(0SD@NJq6T^pK zP$y|sr%IokE_w2c;PTm3YsUKDZ#eJ1v#NU3{bjW>x_73$>cZjkho@-XklZTYKPL&R zYwmiB#$kChCZ4=?P z34hy6sB(DV6M1UYGoQ%U<%`dax4wV)X4RG(^5m*#CW6mQc%JzWKm%)pUnrZ-8l(Hq z=B~9~uf5tnEYE~0M*Am%Yd=$`YF_wE{j_X++okTy#^0U3a{7jN_4`vBcivq0^rz}% z%?lGjLYQ~_;ob02Y&lPWTwdeClO><@GFMUqrf4M5$yw3G2WqtD|*FRNK$%4I3R-Ge`EHW1mnNaLvm)kB0Oo zRK!6@sc{!1A027@iX{1~%}AiMq~6$R_zB$^Bat-A;2%V7_a&+)1~t}ETYC7ZVD9)v41h?$^wuEYp}6n{oScAIR;$-Pir@Y~+dC3kDnhYT51VSv zKN8CNPdiRKXhy(CC2+qKchFe=2p7SnXy&;P&+b+I!@=QDrvuSie-3foY#>`0Ypyg~ z+7q@$H$PQmG2s__0^X9wFqJ_7H}b^We)g%ta69udt_z z{mxd&K+sF^lP$Gd70{&%=rIsmjm>8JlUCpZ;3NfivM(7+q#7vHXa_HRWT5tH$pqj` z%+QhpRvR9UROY*vM-uUFX*QcrViWQB9ADoI~Y-(NI4kaJ8p%7C{IhD}*>5~Kn3CPv+ zK_I}2x@20{!E&az7yMW(=!giV3`~*A(tSLNO&yI`B1uF*j=W?$D1I`5fu%b+MGMpp zq%#PmqdPv3h$FVtiE%6N8WNAv$J(+Cl$jMA7rGHu4p=i?G1xkvZc4kFZh!-a#EK z1g=-}#w`!?$`-&p#2h5d7jP>{u_^Y=N2(UQTWg(3M6E#D!|-$%9d!~e3)pRInQS_8 z<9okYe?9j18MOFQ3&sSMCP3iDd%s5gj~xpQ$%s!C zuQ{$tXoCmFK|JTkJIL$vs$I-Eil$^qgD++sIM5Lh?;*bMzyL`&SaNc=b^}a_lp`vI z;4mFCkttfNEOH1Mzy&Wvn6mTK|M3h~!BqqyS?M9(-38twdEVU!f4Gbiiz`5rf-xYo z#GEi2F49OTQFZndOorVW?F%plCUFbK;ekTLKVdRq8GULQalA%8=H}y-)O=qvMgpc;*=jQ|%AHdypSk7VHCH06*f8f8LKVaQ2luML;e&f2 z^yc&S_U1j6uFtkL?pf_&DyCz>RwYHp75qkI3JM+yCnu^xRwMz=EAqX0Ny%d;w z1^eqbwwZ=lc!~|8JQCo}Gj~g@o$1wZ7Rpgfe%I=KNkqrCqA>#5nO^ePRqZtEK><5S zNs^MiNFrt2!{<&BJ3^(qk(e@tP|OkoC$34$Ibi_$2n3{J;3JmAfoQtl6ca2(rLGeh z6@C`qyolbjekzopix^b4z)m62NhuaKw3T@_A06<|-_eXrAD`aWV67hTz(B>uarN^4 zsq)8OKQy#|xb=&YWi!FmcLJqD`(~C^-CXv_#lM(Z`^eA>zg@d=s&vzP{bMy}_0erV zJT=xow11*>)0e^Uuryn#jECR-^O60-yN8XLVCbFgqy29`Jr!I#yIL8qyj=Fds%tHi z+gd)Y{pY&R>VDHY-E?GXTg$}pKbvldPOa`3b&rWZ^4)a`D>h;`hsy7l3Z=_NH@$Tn z90XR3J#ne!a^;7wUf(^r;ql3;Cnf_=4DFls1%^A{+A`+4>05JY`}nC3cU~|1>33(! zDlb%@uf7-`-}wH?sj@oAk*9R{)LW%vHItsIw@M+HI0#tW@5Ar@mnZ0~xVw%jyWXlF z+A|}&-+2D@=SQW{&h!4;!F7}PPupo~-Oj1t&fCGJ$spX&z0-0tAN`D{3f}3^n2CppJS1Mm|K;ZG5mv;b1fqfd}# z(ZlI)0{wy+U+Nqg^d(gSP$4~lXDNTpHph#;xy_n7d^c9uu<@caCElHZer75{;b4&St%==66nY-D^c^DoT^&TuP|dqwM}8(ksW%Y z2hM?T_E7wqTx?tTS~x5BthFf=-#9&RU31&5Fs}Jj3g5QtnIkKs%^zQ9+*De6o&Q9? zKe#^ti6TerqeS}(416te3z+sAPVvZfuw1i7){4PFue&Utf^EUiefaIw)69rcK+{}S zI7h~@byPP7lG>m%V{|q=MPYIHlW~mz6at}Xrbj~7T?{(4=I4m)5z1|&gft(c4GZeA zPAy6YjFtkX9mVw>BY(UYk~N+DB8=%$aq_8Z5vQf2Nv&Fn`c29`iiC?R*QQpW6?TGr zBlZsJen$28kRW6gg65bBtU3GY?ZBE_fi<(?$_qQr?-)}rZ~H}jI=o}TzhhDDuJgOb zYp-wkP4jg4$nEg4$?&nM@GBGkS7rihiOAYpfwgo}E{}Ijg&QaQjkAH3w*#xE1FI=g ziy-=RaKn|z4ezI|lTRI(Sy4T9?7iT5=WJQk*xpOquQ|u-r^~jY_`LJZit16P72U0v z4sJqh_d@o3c6|T!1Eblg%3TwoT~;NINjFg7*Kf0~UEci*eLB2z!oPDCifqToj!|`d z+qL*~$)gkUqx=wcfxe<$%JtP_$jo3Kj^1#YdY@Hj+;;Qmv2U<{T zJ!QH-#V(-TZ}7)GLRiF*;Pjo{I9ficW10wg z($;-7?9aNodYpx6Jd}{X!PbYf{(M}cql0tYQMhM^%O{E6OfOx;JB8ACI)h7kB-9C} zsXLTM?E7bWHysdewl3B1m;qhg;}g=+5vfPQCKzyx$;fg$VfVGP;h2lbUxi zJq!n>z&O?oIIP+eR5nJ*dzAb&l0~cLE8d}Fe}n2NYC>vtNVtpRiomFMtodfB2Bv>j z_6~2lDE-9up6_4f^?VH@E=!ecKzrVsFF)Thmad?aB(Sj#ufc$I@eQ}|4#O2>-={vb z(@{_AfsqcjN=X$(L<_{atewXzn^FF4(8%;9HG-P;3NG~>=UoPCtkMDst>=f9uM~bC zKOPakQi-n26{soE-sU`fK&75VpJ6{iDF>N@KD1oGG-uB@uLD8v4P_{e7c;<34mBC!}GLtFL4*Uf-+!eL2p2c0|-&P>+p*5@0SbW`rip9UkctY zg}|4B?@J;0Z$ibt3u{n#-zAHo(d}dMeF2&Kr9rXgUad!5KDR>H(l94&5+gIdfc0Ln ziNEhSH{Nri{C=zG6f5VJ@pey%t7Z-yx+~F}+S8lb)BEXt{C&six#z_1xjz>LW!+4* za<`;P+|FC$eaBld=foTCVCm{PFXeo=QZ+|y;P<(__X3pLTj=W%_lUeRWq5DOo5pNt81>prDBrqW) zhAhX#qZVe7DQ`VyWmf){n8e>UX5()=v-7uuIq;T_Igh%Si}$e|a~~~ZWk<_dc^-y` zc~%SSg^+!%5OO?H@rY}OWRA?dp;e*UkaMkYRyr#(Uubp6MR``)AcWR52!+4U+E7`( zO;u=Js4i5V|MFA2kTv89iiNi3J5(R4;B72`TOD$vjW=kyy$yYZ8bUs^9jo~>tr|m> z`Btk!RhX|B@&~OUIaHl5t!)=VH4lnx^{_S2l=(H5^F$_{%A{qcYB80?wPAU4=z-3I z!O@uf%hz`Vw+4c4ci)IAN0VBbDbcj7XtFY_YH2y9rqD;1lUj${-69VTwzVZqSZDR5 zHaIB9bvccmF?C4EB+~NOh^ndQR3_``7#dP_ON*9Fx3tKL+}HO|tE?yGR6IpEB$->5 zqe?2B;V2Q9gU64^<8d{C5l5BiNL*9fn5x8-GYM7Bk2@AmkH}h5R?h=fc_^U_>p@`F z(vlxBsxTH;O{`MNcp|C9Fr65>qycYORuj4!1Ud&4O;efPDvv2lLj`gvmKS#Q@468&^Q{~NkBGI%Olheqy%0qEX_RM%%)dTWSlI2$`=)Oi&8W4>#Af8Ms z>3CApd+;rFYMIf1#jx(z#tnNPU>rFSH7VLmvH$dlH=2>YAUq;45zJ)?iC`uR7|9AI zlE6SVFpr%%haCZ@QEpO9SCWk?>1%7y(U54z4QX5%O&GQ`ZHZyU)ZP$!0xrWDiD=5G z8i^S0NMtk_%OogY9*I1jQ4;wYZzM7lXL>q;ZPk*fmX3x_^cd1Z`@4@Dw!Zxbj&~Z? z-u?YXO(dez1fvlpoo4Yf8LUMl!YY8p4g3V-X!}TVRBazoSiHSI$sW^FN>pusIG#ec z=wt1LZE1@p;_W(%77HR&5KS_*-6W=9YMeO%kS8=reHD_w5$4M(C+;_sa{ol_!b0`9adW0XSviG;Dwa*T8i0Xrq@=?7BeAnM;_i@Q4=@k%ayeP<%8rO7zc@KFe*|^4;oMq=&BmFAq5)t4bgDq z$tB=0+;Q*#WV04k4e3lWnJ}bSJeoG7L>%MMOblBrGn&#twrK{PW;;ietkW9hiO(5< zs!yj?jqOC?J^1MlAej)ZI?A8?`n(RZM1{>6n#jOes%-GFseJe9$6tBIrZ< zsPl!}x3AdpjxDE$5?Cd2c{wcQpGpZd210Dd-*zILqP_`P)`GD7MU7Cvw=lXg4j4tPqksTQ z^;V(vsPn|ec45>>kPgH4*Q7pYFW6)G6LxNgr4LKi8J6xGQx>{qL7@5daY!enlQ!Mj zF6b6O4VqB>0?I7;vU-%+N|-=eaSv<-EDP@kEZvRfIJ!l%;p^`FmsPWA60J{2Y8PoP zC#B+8UK+o2tOB%l5fGqb32(sT{cqc!KTSd1x2-MKYo)b4;VvmHvbhG64p>^kc3|l! zVu`g+j+J1!GF3VXxEJ2-7R({P_LzgCum+`&ifzSqk?M5|pRaAm%<;9$=dlXKoMT7` z*p;)QAlwh{y)X?$Fz4Mc>)r5n^DP|$Pk5@nQM_$_knsYaAE}ysV#y4l7?PEJmOUU!L4Pocv(+RmzJahD7Af?#CDFM52=x0GaaVtEL@TdB@^2NGV}2 zHP^5*e}pYqCpfF7nlDLnj{2;l{&)VPlLzOkSHF1p;^FDgx4S1h7rbkxH@sFo=iQj| zZv4rP%RA<4>!zgN_-d}Zgz94=Y{}C8pLzZB{+cN^wR5r?x;k62>!;1Rid}PMyRv1w zKJ%_xSlci?_Hx@r$JMoUQ;tvTO0Zy!@B-ifB$TG z|Gckua{OH>w|4K(r7O~ zFPXSk%l(tPFF9wbU#^&~-88#u^K9AXS!wgF&%HuJmv}2z*MG}cyZ6@TPGS9VQE$e& z|H|d=TI>9kzqzZX;#a#I$p6~4s!O*2bKQon4fcPjv7zKP5+_HB8#EKhyg;rdHqLyO zn6{U=W7$JCW(dN)AnJRIF48MwIV!=?$Kmw+=mza9!J1mAQqu#Kk#MOr&g2_jMi$5eA=+SIpE0n*z zsQe+oH2Xj?3=y^$_7TcGFTga1W+&6@FyyD^EHNycg|?`JfZ&*YH|#2bRx}zq5FX$H zaup1gq9J)|oCaR{CNnSWUdadyNlRlD<^p5JzPm_Sr^Yt{RyRZ#3El2f=DWlgDdIBf z3)}jMcPRkzT*{tPBCBUgf&#{|^U4QxW?VQ<*C-NFOd&a;x{ z346e~#hGgr;fh`g1T1?(?mjQAM{mut3Vc$~yU%y0N!|;*mp_F)E6f!9Vu^&X(hvajxYRO9E!lISe?++b3azxfM z>>TW3xa0_8!{j}l#31Cho8z~%j4>sZQe&;M8GMH$u0=7QSW8QXoDV2b)Ju`i#nmwy zimXGj@mk%8C8Io%)ODbl865=<=FmCiZeW(4Le!GEU~6;z{BX+ zunZkp5WaC%G<*?==cv%JfVs4$ajVc2hY_d?x-XP(#vwu8!VO#_JMuOKNUDw7c=sYx zV&Opu-fqJd2rO^qL9i{e@(}8yhO~%-kp}}ByixCA2Riq!q-ImZIytAurFX{*v}TRfWSV9I@?|O07wP5L6h3aK`-ivaEatUoWW+;mn@U8wFG-d3HE_J_AG24D25cmMGZjO zNWlu8I!BWBgc!2*Qb1?fvlNJdF5Hrzg%jG)61MdgHN#S;kfYDVWhEr?@+VxBWGhM8 z%2D4O*;4ufDfOhLf|jekTjHuFOJ1qv;hqzn1`zALVd=f3-OfUBy~U{)Q~7;4^p*_DcZ2g=s*YY^|Kj0ZP-c3YB8LI zCF7c5M>K%NQ-LzW#znCq(aR9W3`brZ8+J%7c+coha?NniE21UY2m!UH;%XG7_TdcT zn#{2C&CqosVAJ11I$=7Ah7}Vv*8Ise_!j!;pCcjP%PrJ3PG@F@u7p2ax96rq^1DB` z3w8HhbWBM%ZGxxfx$bXvPdC5QJp16uYt~d8rl|W>!weB=j)S)ZrX*W=9xp6PrlRnp?u(`&AO`MKji}# z4^MS2T7~8+5>&@lSk5ej^`P1SAdfGyX*4fGutj#{&3gZUHLdeRkaw9 zn2t7Ix+o$v00KGy6^js=Vce!zrCrdq*K>X_-G)AyIGfMt@ zOg+z1C}59M!YDb6Bv8ryeeMr27_g>CZ%7n6G0M_7Lr$1}2|I>L!=Wj_LrLINH#ugA zah9f1`y=Dqb-JrxsO^HuBSs_wq@*SW^KCyxELabvDx(|5q@aU9Nrgkt_SN8+K&Z?aX?1PIN7}J(EMvY?-#_+zqd7n>qLDo-38Fer3M0_Qkr3 zb(f+u8()4bSJ?s#4=?Gp!Q` z=OxGA9)0rYlyz$8V)>kReHQ=g_x!Xgw|-B~yJyaOAnS$mdH+XJC!hShv+Ozd)9#Dg zru1*$`-!uT&*W;E_qp9q@16=hvu{>fbL;a%qTt=Elj8enP1kzq4NKlXh8>IJcwDt8 zdZk3x#kjx^@rAISxK|_zXg(_)X?MW$Qr2Prg#pqC{8=d`V8#uWVgY1>G~=+k1UmR} z`NN^ZHUV_`BU)eH|M_)#1byjQm;F3f#DmLdU5En{2{+PD_<@f_E&@{@$1x(g5vEHPWFS|HFxyT4g5k(W z=)$*4PqK7`VxLA;@t7N@>bQt9o=`8?GU=hVdnsZL|1qkP6@{2-lIa1Td4Ys{X?~E& z;8X|D6uWz@Y%^|z29l;wkLnfEk2`^cQ9;S*28g1`2~j&F5L$1ZRE zSv2eKm@V&ER=e+8`(~Q2Z1|uv>+hZOKa}-9l=BbFmJiH(8VN|_C!R(+$d_{d;B0wt z!Lw%0Q~$B2p5nP?U_I;I@Z-QcuH3yx=2zFf~lv>;RJ5iP~cPKl@P3W>Xm z{s%(&mVoubz5NU)5we0SUFKyn^9~qU-ED2$M=!lumcJo}+8~?4P&nBzY$@DtOY^OP z`X>hGT6Aeg`6&c#rn#3AT@_>BL%R>~(}}d8Dt&^@{p`l6>X}1X_x_Ki1ALBMWW+z7 zi9@Qt&K0n;9#It-fH?hc^IwoJtf}& z8g%o^b9CDdM>KzrWnoQ7_?;Obi=Em{W#6Y{hLTs1EZZsf(iK7W161dzNj;JY;rBSf z@JzXKz9!fM3zBPc(exFRt10-x??D%(j3 zot>EmKbglS1rYOdsvtCQj+ejvXx>gCEo?aNGjCb$V^{DA))n+Nq&S8U#p@QAD1EU? z5bye(Q1+?d`c&|ID!4xty#FrL{D;th!kczU^i6G>mTn5j{Jz2~Hr;G?imMh^3tQS2 zt((NayxU{GS8w9)tG10_*iioaKG7!DE>`h&_lj%hk91$R(wo}To7&U+zApa0YIFYH zhVR9{5(Rnve4Tu~Y@N7`x5oRbt7g%LH(bDq`b8Jz+_>GfNNwQj*U?B-NA zElW$yD1$u6hyg2;2P>Nd5dsWWfrA-iUN(=x0*f7BUr5Wd#ERuCDs;|NrV=8yo!u+VB1`HT`dKLcYKswZ`5M!aZ_ugxn$$86^@Y zIaDs=7=PTWW1wZHuk8#Ov7jc8+%oMh9BiKfzbf_?Nb{w z!OjaZcr*;-sN{dsHQFS_q`1Vt=^kx{X8>keq&BG$o~@Eo3Q8dnwHQoGdpre`!ZNugU|>d6%rU_* zMN<|sVtPu^WJBn^d~x_>G9wB9^bh-zd-~yVAh|b@^!rcf=9KWl3qO8&N-?rBm8l?V zlJJsdm{d%g!i27>`ivl7lhZlaZ{Vnqg&BNO*`$hmRZa@WCuPkv1Y=6isghsQO<_vB zDhsAA7-_0xO~ITJO+nT5Y`P#%191nk*&#mt;{wQ6aa;qmYqB zAPs+>eBm4{MCE|gB{)Dyiz<*mE(5(XQ@l6@cSbwx8_?3WaAQqv7!Bn`%2Z*CYlF*dBg z;1)3(3TfCzn3+=2Q%Uyze8OpYtGih)+`#gHiRV-k zUicM5f#@M>X&K%rY148}%4Wi4d9&gyI z1?&vItkWw-R!qx-qe>RGO^hK*&A5$7QL}@D7uup{_}!w+rPK&S@e~|v0MDx{PNkg z@Xkf|eLk`zzd!N^zI!Ei$NkaI%PsNc$lF7A1KTTJ64_hvkwEwn>`tDrL*$6r;d3x} z(hl=n&Hli`9B=R@th3v?Wq}oPmr24gcL1H5xp377C#aqQdjX-Z2eW?AlOYdiI?afx zVn8f7|FMJdZaE>Ef9#~qZ~$$A&T?NB)tqeLnILc)#krt-jm7+weFl9BCU?XCqi*OH z$d`feQfztWT0mGBF1I~ZXzRPNZ!!1Qp@M7w9T#*I67>HM_ebm8PwV55TQo_Pg{TLpo}%t)Cv-o+u)QVif)#sk>2~bC$BqYPnKFL^ zgIf?Nrt4t^CaUdr^?8cibfg^$2;}vH)gV$=aBVFidB-%G$fi~cEN`@L{T_nUmfvCE zddG3&Z(MNdyyG(T92?C*^qm_%tkP|UphHim?J4HK7kzK{eu6vQhBeyHy`8<+X94_-sHxFGuv?Slkyq#I& zdkXwB6*p-J-fX_GkWYRq_8@D{@Ce61KkM|RwKrd6a-($!lG(`ZFI?xLQsW0s^Xb~$G&=vL2A;45asmqt!kaD7 z6Y%ZaT60ne_}Ric(6T-W-^QR0?Ux+mJ8A#g*#8xLbDI8ySA?&;1{!;HgEX4*ZmIb_ zm(;?f()xjWgH)c`NQFKAzeq)jY|TH&G72*L0oh2gd{l9K62FZx6WT91;KZMI)%=|! zOWchI@bzB%{l?x1Y^{lU9NFTV+P?E`8@y;MPjAaZQr$A9wAH-vFUOMqMU-gU6eZd> z(6*I_x996LfX}sW6-VkxIX+WM&oD}~&Hd*^gw;8KWNIDU_G87T)q%F4Ot2DAuHqlW zgACh?>q`t%&Q@W;1x=_Xq<$!l6aeT4g}xaDl0PQ<^-2mbFo1GV6YQ!#4I`}OGf?s4 zV$qK!c~%tx<<&6t;8+d(kFl~qppeRK*oYqk;!Ok8sDaQLnyq37im(f?TL9{VRE?K8 z-oCduBxt%Y*MpFh5?<_ZiR~DJuegSGphE1V#)D#1twatIE;I_D=#R+pK&S_9|0tg?I zWK&dB1FjdUXcxX8z^YS_5wbFf1pqyLWxX;5TsLVX6E3^*t9J zff7R!pvVwxC~k3+bGr-yFLh7?cpBiqtSU34-K&ZQAT@T?aY&eRB$9LN(gf9uR|H^A zDqNaqD7_>i#kyY62<8R=`s4M^lln7q;~B+d$Kk5F{wy9(tHJ{iBeFpcV>g6yZMR%l zE?LnUK0`Q`tf|GI`*0=Z9xGrM+$n|wDHcIi$mXGL5Ue7sFJx6D#aL}Mj^`9rg5rzS zT$Z;0EH?<0D2UEdagci{<86IlBGNURQ&^;wRzQV({~5_sag#j)TP=2iKy{ zEnN6xsQInj(#-W=ESxPr)xGfiV?POXtwcZST;03+i?!g77S25M2g}i+ej>iq| zR{x^!aga0%E54HOOi_5|PETp?$>QFVYfYyXJqZ4r7mwZ#giC?0VxViKclE-(z|j53 zj+L{ep8dt1{dc6&fiuMeXVxNT7f(Oro0eX^%M14d(dF%{(N$j|w&&Bx?-PYrU;9Jg z^>Vwg`253Y=daWiM=6phMiOh0-HZM*A6XqK4V<_;aAK|PWO3lcD!1JA;jVXgeUK;& zoG8K+fAW5Fd%1bfou}`#f4Zj-K5{=CFNJp$!#m2IJs)0p_d=ngfA#q4)NeB%XI4{% zwj<@ZP>S~!6Db`zz^{zf$i0*mZ=xqsBh_^9VX@;wk|MuW=py_iy_|~&a zXYcZzjEe4+gR6U1c2@|O>?(KdTJf)TSDai^@=K&iD29a}py^@s*k`S!xEr?`rT#5-$5-Wm8TT8N%~+~P+<{f)t|;{XE}GxvO*U#B_3hbj{s+{l#p zuDg6!ITR~}wiiR&3%>22$9vHB4i|SGUW*SET#@^HsKjq8^4rS3wz4l+_Kn`-w=GZn zy}Ifs^dDK{j~0BRpSeoMUMwDav2gj7qHpw(7r^AgDX_F)^Uc?;zgG4I3bEchgTMc~ z$4*DkvvB&chqxO`uC}79ZTaa!_r80s{Rmu-UO!rJwSD`|WsbC;;=cW+6(0tIs()^d zJ-^TMFTvRJ&v_uOVvMxo8M;)$W8d2#R$-Sn6kQZA+%PLroKA4`l^+oMlwJh$E~F*+z4b zCpW4d!{Qs<^}&^&X@|KKeu0VEaze(l10I{hsAsvxbzNoIoQ~l_Mg-%o&GM#FkcCny z%b!Y3}}aNRJQg$&22v)Z4?KX$}T;>9B*W+d8b+Oz;dKT9p~@69J! zE;lx0hS>MlWZ+&}WrDM|*a0$mHfORV$L`{6#WEEm?qPA>YJhuP-PAQD&7vss3%4z) z)W`NUlpS<^LvRw@_aCJie2Art@db3?R~+}XgXdg-ZYSK1F9`pDME^vdeL%YZgB*N7 z`X7+NN1h-Te%$Tko_!Qxorm*OI?2vNx#OvFq`e%Am$!9cu#H5^{@~5N>wRF^(KZ}@^HV3`Pjhd0*3amKu>GX<~AQ`wwYb z!eUqZCN~A_bP>b>7L8hYhpR-?bTy!!ghaHz(7aLduhn<()7eyvs^e_*9cU@|{=w)6GH(c^v z^fN!eLCM`l-nX$PDI|p@5#jd*)(o(R&$mbssgXmil2!6bKFKdNNzIaQ(>mHyLwx-g zW_#D)YLL_tYuHZytAC_6sg>hlVWEB^1#So|JY$Wv=YMG-WU{aCj;!V;sgaX&3eCvL zSyfXE+AUol8IEOA^zVK>7#rw?pF^?z(U{wPTF=eW%a^bGXjV0{3R4)BwG@qOMvlqJ z9G%wFY5fjW?kdSVoY!}PW?=?@nUZ6wa$AYfQ!|Q|GpI4E=hG>-rswFad|RP8of=7| zW^*(*E9Yoh*R#FU&}kjT6jN^lYAP(ysZ4VU%cz>1uAmO6_4JI*DzHyZ^-U=hM+}9yCi~G55Eq=25{EWes#g#udg@c(&ZkBX`x@FbD*`x<<5AR#&wg!0wbP&uF>%$I^6+Ok@0Pqo+0Dn;# zaWYX~cd!a|SHGoT)r>IiKL+ozjgWBxkLe%vMwSL3b;hA^M8I;1#^@uO5BS^bPI)x{7!@Brh)k& z{iL4B0N)i2X7sFLfbjrVxt<&JHkgi{H$cuZLyMzRGE;hKUQ23qa{B7Ow5d(Igx}@64*n*_iuk)M`4br`~1=qHJY8_Z~mHnZQ#Sg`Y=U4o@7wxNJ z;DPe1v69%iB6cq2|Kexmmhgu|k3Bn{IZ0sunu~b+FW_wQicTUuTql13z($=M7iyXa z8fKXi37LR(m9ef{poc;iiCX3lp@DOAuG-@?(=(tU!18s+)(a9cls-*o89A*Q;1{lb z2H^q@HLc*;KeMuCfLRMp*l)|}ykg*;z-|`AIj?e;`~O7c4z?X8L1D=Y3XcWyjmQ5W zwBn%)Bjw2UVx;Gf2N&}n94*)mJhH)qLE!%Xq5VXi_V@KEh%#C#tvfjz~*o+p9m3%kwZ_}4&mailCZ-9LKo=mX{B%!ip3vFnAM zH1sXn?>F9Sd~o~I%qN**sQ0m}_qT!QGu*RAY<9rIliwna)#AZkR9|(Vry1@j`5t)21`0W)wZ2GsTR2H&IE84AaMypMX{6 z61A`PpX61bz5&Adly0cGIn!>W6(wt0HT_N!Z^q$)e~1>`EWC(;qJesmQT(!LXr8zS zl_CyaL9#&R3t6z%NXdk=ws5>-)K!Dmf9t-+8~4^DUpx35@H>?u+2Wq9c6y0qAGF3@ z@dn9p#7gqo%l;FD`bHnlZ%feXvgROM=!k;j3+lTaOEMT!1D@_K~=+ zj%o9Dd~Y5R;3j`<&-!oNyH6Mqw3Z2UX?%>f)}Zy@PBQ*H-f|En3PWq%uo^wyrU6v7 z#(i;T+#xxS*tEbzq_!X)7)2+tX#i&ERW^+}1;KbQ397u|PIRc72HY7~ry3;rzrHix zbo377GQhJ9(sjjyUNUGIzb7?F?jtskV^C{~2RBPV5=YSsZW=W?YN2=tXztwf8D7EN zg$>I<#oa-C(ZccY_no9#t_121D$H}Q>wqsB8XuA;9vrfO?llftC*G>fj@HzrHUYn2 zCtFlpu+s})wy3&*3)ZcrDfxi5Eo=uhP4XXcC<3QGw&C(B_gpzCeD>mal`nMp( zcV6L2Q`{~!`-s$1|bGY)d~7kHjGy{{in4soC!L zd!7H5KZ%Ur=H8@j1BP4sfwp-4ez4EBtvpUWl!vElK^>rIWPbHJ7U+2>?$Y%#xfk(VagY_&7DrqR~ zWsO!Ug(N^&17x5|z{RpVo0g$YtX1XxlePB2Bu^qJgTP-YJaHq`%tD_7BfU1r+N{-Tb zOEfmmTP`S8Y%qWdrJ?1bA@^c*bb~xm`15_xIKbx6tFkllQyDeKuftV!*_k5VRxK!i zjYtMVf6pL6CDAt1hG~@xOUZaKt?^Qr+_$6@D00+d=fR>bdFuPGIA|GW;WFf zqE1A#y(((ZsGJIkrX$HaBs>Xa80yxQm0?=7u9*x20K*E#w4ugv?wO8ib#of$T`4Fd z(_r=N1im^k|5lY0r1F^DR#SExSgmHdYQ|(bD$FsV$yX~ZO~GK-@HQ_z3`iughlA?bEmDrz2+wV(4zON zVCT|-(yqhBU58hKKUlc-TVL}B`3HCI{e0nkd3)!=rI&8v+qo3{b;t7l<)5#3f4Fe& zsoPr)4m}JNf-#@Hw&FXtXj^RweLVChLuHS@ z9Ox>y?kb0OJ!`PHx))t9y`-5gxk_|jk?wocRoXvX+&{e1bY{_kt%BxzCssZFl4s{* z&(5Xp9;Hf$&J_=xTM3+BJo{8^dN8pf(p69J;g03tva1jp z_&iXUxcS)gUb&4fUV0kr_&B{}DFvd%Ky)RrchOxI1IuHjzSEEUPOn6U;khh4-1+IA zPxkz2wAgpL7#Us>hgX~1%FP3h-ukPy&j$+rCzl&)mzMYgVBt_6_1hi+=hBoSp_xe$iSw zHCjA1S~w}e_Ay~tfSm3%vjz8wYEj<3R9OJk+o$BMg;t%QdPw!o_BD~a14i`&YsNZI8r zyWV{wZhJWWX?odG=smt7o+!B9{lZo{d82spMnQU~=z8~s6Kc+dGoWSO=KDAQ=w{jF zDTKNo^*^&(ypDylFCE0*P_jiH+aeF&Ds&D$u^qtD_Qbst1zY61=aN9$&IsQZo*hu%*D;JPb_}8*>;98BRw9207&$R(~v1 z?D)Wg<^!)DRK14#HRuUKYcN-FZwcIi0ok-dq^meOK8Nw!v`y)Hnn!Uqi3>RroVtkV zOe7%sBod}Ok(kZ{-AW?CumCh|nA1Q*VlE4jjbmrFy%KjSo;HRJN(jh^wAr+20l0h~ zQo^sz$9N1kHbjKb>uWO5%}#U1nOmFy5j>mE@!-b0b``;L5o4On{kz!!JzqViYig2v zPox+6F^R-ym6lb-*}8_{Ciq{16HJ4Rn8Fxe!vm~J5T02?!S?$$BJBE_h+mT6@5sR~ zN$0GPhgb0bd#6=6_|n544#Bb3L3T&W?c2+NwsI(3-nJ9nY#>;6d++z$ z>j9O%@4e@J)}9uGwx{0UnjHbKKyTBUlY=hO*uK`lK{sjaToXCyA+3?MMh<$3w`I-8 zK|g5@tTl14nFQO{S~%DW_PG||V37D*)fsV)lXh3*C1MIvp(YimkWvgwV0M^ux;yRc%y?$T zvQ14Yny8UP6E8>z3F3noWAG84jEQgh(ApTtga_g!>f1^+0iOKNY-g)RPIk`aKmYCg z=llP&-*$9l5VXdF`t&C%LO+Q`+?3V?uWZ8zok5d`qB5eGN*1n5WeJP9Y{~URIRWzo zRV<~hmeqQ)oUEtHsd~Dcu4l>_jH-xbrZ>cD*T##OCGSB`--DjI2VMPt`WI?BM^)BA zlfz(^<@TU=kRh4y9m^y13YtgL8`u1aXY=Si>2!AjxL!f!JWYXoTFBE*nt44@?xJ~` zg|WMeXzn5|uWNJ|5DmdSiwa%AqY$o9o4Sr^`-HQq>G`}teq4B;_%%j6)2>=DH~}{p zai)mR1HVS593E}&-Zw_PfKTZLD`qmIFEbD|oO)fiDKYJc#petJxmh)B-De)DG0uwQ z5a>P;*wkW_?6&lPGBPn^S{7l{^gSWyu}s~efdwK$o9*~D@P#nTV^5P4%+Ldm#kRab zP;0rwXD9uLyD~IHP0w(^p$gGC%jlrG%528L2TG>6Q*YUT6B`bvpg<=^O}DEoa*(*( zarJ8CFcYVm6>byVCyp(S`nw2z0f$-L2FG;YbZifnsW_0oLr_sc302)SYr%;yLz!zi zjXJaaFi|soD@?hT?n4;sVRE)+dM@L4d;P$**wI+WASo7$$HI=0efzZ0@jVA#7#lw{ zG1o&)!#@glG)Q8`V_`~r_J}qG(O^i&Rj@Px~Z${+z#Mvcrc=6aD%fjG(xO9$MT zpacd00_X>~YdWVxhC>-GMtbJfjXP~Ct48M1MY7j+X6>iRoHR(r3kluS!0xQ zhlh!2NW4(>91iCeh-yVZ6A|!4JjvaIrJ7S`C9r3fUUc}3=R&%b%BBkeF=k4E4QS8} zf5@=RlE;lUK@)^HxL!%qOxyG|t>`wwT$TA+yv2LL%>D3r2cTO(&77k2-pKcTlwHbi zT}*AgeIwg*3&}w1+;F{kd5$boiUYQt!AoN=2g6(~pmtj(5HUj-NAKwSmabEf4p z?g?xOJyq@iPXBrHafNdM4TJ4k1fcMV8PzTlK%@lFJwqiJYJAO8H!h^ysTRRE6AZ#d?dt-_kjjaxIoAP z?}0H)4~xQj(&ann)-FPW(pDU{97DIfoy9gsNl0&k?)Go!_5xb$+I4pSyDwcjczN=( z$;(GSJ9=gJ^3XHycc0yV$4dd%YY!Cg#bg14GfGq(c@C%r-10(zrPA`Dqm2Mzm=6dp z_&o89gPlKfDA&E*wd?$T=$5m)78AQxUIKTyVFGJUqF~+wf1t0eU%n+mL&DpEVIDSb;yUo$DzDWqoJIR5JKW?HN=NCnyTOUn8`vanw0Sj=x(K`NI1 z=t9F|c$th{-SMSU}n!_#2!Fqul@i diff --git a/src/unstract_cli/endpoints/__pycache__/__init__.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 2acca3f3a773abf155451fbde836df0d11ce9305..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2564 zcmZ`*O>7&-6@I(q@@GkjlBq~=5+`F@ZAw5S6(mk#Lw2RODk8~JZLOKMq2uLxcSx?a z+}+OX+L2C5d+4E;6sVCRK!FAg(B@Di$E25{K+#hVQkB5QB8Q+sQ254JF5u*pH%l#* z6!m~RfA7sVZ{B?G4fjVzRKT+Nq;dTZ8Gz5kASueABkj*h0Jq>fpaBC-(&QCsS!Oa3 z{nScoInC18PixAGvYcU=0Tz#g`jq$}Bn%;z66pL@uB z=Ks$>pJ7EUb7e%!K84#}DVE`7;PNbfWq7Fs;KBOihxR1B2I%tYI6wgiYP!HmS`IPt z0z(_s)JNfZnvH2Ctq}Ld>!1}cN$f~xgrir)iZ)cn!l%(~i>~E+wi^&eJ2ns4Ci(aF zZ%ELkgxhY%!Nv=jNr~4c0SkjJX?rXdpTDp~c*xp@No%UQc!T0n)9dvN*CMtXs|1YV zLS}bt*9a&lUCL;UTt@6`p=&wRBIg|=v?$RwY{wzgvI8y_a;o+`D|B#)Ky$sIi?$Hz zaC(MZqoxsZn(W1A5Z3WYKwk@D?N+sF+1&KdpcXM0RSkshP?s{a!6I$u^#*(3iA|4L z2w)JiYq%X68%TWS`9>!;n2J-~6lN16Af79ZdRJURMT!cBi-sA2?YSI>xx|ro(Wy{T zqKx6&-SAqZTGV&E%^r1wDBZOKC(8Mb5uh7;QTArn=00U#O$4Fu&}M=$Lu$3!tI^29 zg$w%P+7B-NXld=TwpF%lGic%+&5%U#)hMUG_)~ptVRg|I`zED;ofLqy{|0t$!4uF- zB+vj6Hq&q%nsPFk1!ESFOHEueD4NuOIe8Q`Sxc=S#91N}Z8MvU4v|kCSdg@%@Ju@C zYiTOC;cV(jXiF7k>mL_9Z-azB-WZ_?1Fvlo%L~LMxkiu95L)ZhNUL?=qZ&tNhrHE7 zwlz2^p}L@{?>P=dN_wPiBUxhR#+r3T17_nD4tL-&a7<4_ZC%8LnorHv48bkTF4>^e z=P{mOn?+5>K+f0*fDMuWvC-VF!K|w3S=6ejTSwNsA^HLMR=!47T<@lPhHS|*WUZ2B zBj~Frw?a1;8S_|_zK#JGWw^)itb$2{Q|sc?T=);h8a{%$F6f`4kvUmkvNpsb=`LDK-X*jW>oCZKu<($0=Ib#KYkIbZRi&j zW#W_4)Nc#BrRkmA^n*_d<$Xxu-00+;QS$r8-Y)%V^Mi@M9{up>-=F*E_x8?fd#5k& zywKPwzPz83OX{bka$k}26a5sf`HJTe^yj$({OZhtlKK`g( zNs};PXNS+f(tet4JFcl%4PB>2Q zfKrsu`%rFBX9Rgy<8HAij#7bJ9r+&|4V3_%NKnhhcnO zxaEJ!`Y@yq1()SBKLM~%vibx-L%IRC6auT$gXg0I&!;dXrL~G2r7&eh$_>K_X(biO zwb@9bYzmRMFolq9R>n5U&xv&Un$ONfhnJvZLaWU=o@qGzTy3aP7pni6Fq|MmD=t6%aQD*fVEReC31mCzO?&WNmtvlVGzgkVZ* zV8Y1o^2NRk&jl6yxOl@KY{UH`6vsaroB!SF-q`$JVSXn)zmF2Aq+-BWKMnyAokvg* zr7)nP3}%dwxlE801>)q4i0ZhgL{6=gqpYsu)#-Yq>UujweX8qB_$Qi5!cs(G4c``8 z!)J^-FvBHYIRj8#7+Nzy%8k<$Mi1?lAHF!LWc{V|;G=Q2;pca-97 zWpY=U?5A+_sZzSF9Nkrp_7xoUGmsg*txWAIQ~j(M=OCm0^6KkX`*|@^A%kll*;O9- zqJZP=k)6`XeaJ}i7h^EDB<+!-f_jp4}x&+qoU}*d8q1&lIGI h`;TX&1?k=qD4cyg`{lh;aC}+f!drj)?&OQ*^w)3nkFWp$ diff --git a/src/unstract_cli/endpoints/__pycache__/apihub.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/apihub.cpython-312.pyc deleted file mode 100644 index 4e072367c717ad4b911d2c521d9ed017ea2582a1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9048 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 diff --git a/src/unstract_cli/endpoints/__pycache__/apihub.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/apihub.cpython-313.pyc deleted file mode 100644 index 02fd07f6d090b32c2bb2706d0f07dc7ed7c16a3b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8772 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 La5!SFBE0?|mQUtm diff --git a/src/unstract_cli/endpoints/__pycache__/deployment.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/deployment.cpython-313.pyc deleted file mode 100644 index 51f55ec6beec0fbd74ab953d606f19b7ac8309bc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6485 zcmbVQ%X8bvndeiYC{hpG8jYVqEXNWZ0hA@n4&@o!vP9c@%K~L(vPw}9*%C2B5=;ZI zElxR=KVdGp+b7SQvX`xz287kEuU)pdB(|Ks1uopcT&d5iS`Nq z?TnL3$I0{=dnR4E?se@%Hx-{=`?(W`g>S1<>a==Cox$I@dNUzz!7oFKy^b*_cU8P-y7sE^h8 z)~*Gd%BoMu2GEt{e+v1Zsf(FjA^(ym|MD4LyrQmlNVW!Q>*_|!qR%nk?3k;V=iIr+ zZ42C95Vt#Feo@^<6#a^#=-`XgukGznAh1<6Ev^+TX>MatT41%ZaadthOX7Cb(ko12 z)nd)itClH^s~g#QrBams{rz2KDy78ZbGB77I4er~+^9&qwns`X8U-^g27f7ED{I!i z!7FL4rss<-HtAiE$-Oe9Jyz0Q>W0lt>6O8)lJr&8c%@el@Bzn|#FX8yR!+=>3I&^U2wpyf z;+D;;!26YMl|cPjF1I1wnVOO~6l5HtYgK7#a#E6INt0jzn>5CLArzNQy~rTaJ}X#? zkfmmn%Q#2inx5)=M)7crL?YR26EN=UtX!Os%nl<-x+&S!0@xjpG$mFg-WDzI>#tc+ ziT{~GD;0El1yq`)8&xya?ZoD4`crP$H7E8MjF$Ck(TQa}7jvQ;8rLdLQao0Sf;i^% zh-vPy#+=v&1XgRTP@nca>mP77r_1$~`e55Einlbp&JVPzUKcDS-~{i7;ye|0Vr5^F z1hCWWvXv))cOoTLh917d=G>~9iaOn%-#9%jr*V4b*H<@I zvbpSn)2-&#H#YDR&2Db4Z#rQFwbMg^Y1u{HaN=zooXEzynycSPx0fhX(_g#zyh(rU z>-i>KpUJ*v1>0ieWFnM3l@YmL?U#*L2nZ1q2A3)nyc9&HqCG^uohuYr4avl=A=;$r zDfgRHgBKPMh!ax5pu~enmt{I8qFP48FjGH(>&rGT`OQ z6eKKERt8bx z3cV)~PI{q4~MUxhRCYc!BsJKY{?_4Bgag20EeKC2?RSBp^Ikc2%tQ`h*UJUuy?Qlv>L zPancrK1@aV72+adsoM+H4#op|wNSQ;EMH-kRs=>*>h~AjC23`4HO(q}tSE|%Z6Yd# z=M@aNjlbWIVW0pkQ;{}1{$5rSKtkby4PnTZn)h8lL~7-h@Ihb)53}I? z<7K@9_q&}+giU{w?0tz!AZf)SG&0F$NJDU|P=q3Lef@REAqDaE=8e-|(yekHF=R7Q zXX{Iw2mva}ZY08PLg!~TN}%-I*2=ndh&X+zvsaQh1}3J3iX$2OUPbc?d!>H4m7A1y z9#Y&&X*t$DwVaT4BwsA|ptps(mvt(-S) zWG2(>SlQR~vdx*3K$$C+S>8S2&uXGn#Zdb5NbpvDP;@pe?&yYc(#JnbN%LrkRJuo#dhVZ->9t3R%r6T&y5e(T*;LYr zP@;9B2X1vY?LGIc3(-?DicW;`mlKATPPb`SDjGj@dW*~~aJ@z;*69+6nofi|5Nt;} zM1e92c@te>0kxmK)+isFPM7Bp;iuVLJu_co)Vfiw6den>6+EwH2@%KWoVd==(&_MQ z*HxAJg={MWyGNNhE7e0N+D}lakn`8qvaeop%3WYOs{8~yeq_FuSuQ^jEwY-eo&T84MI{rXWZ10dqBTJ<&^mB z>l`c8tUdt(wAM)ZNA z*XvBoZcmAh6EzNtVq|au{)?x%U#ZKqev}U(!sp7YHHZ zov+%c(6}JNE(iv?a*{1usCiaJ_)?HiHB$V_t^6Iqk2iEOP@F{Pe&|oUK0eX8#qSJ0 zmIbA$<;6~-lSlp5JiJSjO-5;t`a1!yr{aXflmkT?o0^!OxI5;Yw}5QkL)YML-JQx| z!G|1{Kuc4jM#8;!i1TP3WKT@S<(E_SOTuN*Jm*_>6Agjuw1+?Cw5^SB#m<_AsYhBC zRw`kERFHiu3E8novZK)xo?&peX#AaE7u>s!e-resJ3Y7+Z>%q_<Y z;`#i(jcm&Dc^)R;9`*&#BS;neIKUKY(aStS?jNP0i-v9*Vl?#7KtF*x(fq>tyo&1& zE>?VyR{Hb#j!P@}ozHuzb$u$pSUXNk10oeb^LCNH!WNZqvG+29wK*3T+R?a+yR1`O;nd%(W{4-~Y3xdpVE z;b1WMzoA4h@|P=t;OJi?v0&)00Su=!G%o~t9{fIf8Vf|@jo3hA@Y>1X=<(oaV`#jQ zym{Iajdq$rr(94{hng zmTyvTQ}5+>^7rz|?8@=%%F&DM#_&XASf!0y!Dc^gydY@Z_%`#$2j4yTVfAF;#qq+6 z#-))qy&>9miRj&Y`}xV}%<H_94ANqD>>A-l4)H0MdK>|-ELeQqklsePy1ti@#eX}@Re`2kFLl^=aptCaxQ+V z1!rKva!{CK$T!D@56!Xs-+kBL-g%#SmpPIj{paZ1(e=m8K-YY524ixjIS})#geO@^ zd3*T&ckh1pL!>dhL{hH6H2CD1#)lI?J)y79U={kBa8WF_@HkYfGkY6k47qVX_^<9Gx5cS9s4d qt*-_@l;-Zy`FqV!7}BIKN4uJp5D&u7dyaZX{x86T!4Tg@c>N8STf?{j diff --git a/src/unstract_cli/endpoints/__pycache__/hitl.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/hitl.cpython-312.pyc deleted file mode 100644 index d58c7e29db6e93ecbea5fdb1d0b4e686dff2ae63..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3598 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 Spxw?5?n=|KSe#vj4E_&CQE5N` diff --git a/src/unstract_cli/endpoints/__pycache__/hitl.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/hitl.cpython-313.pyc deleted file mode 100644 index 0c0cc2cc0821ffb5cd2174822396344f31b79291..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3520 zcmb7HO;8)j6&^_=pBWou|OEosdB$lWu> z8kehl%_+X5DmleDZT6HLecZ|+XE>x*nbU6NkaFb$adOY=5hK7AwkjN2Ju|Pr_uluu z_h$G|C}bu0>lq?Xce^F&@8Tl*8)$E0ALO_s{aU&tVTnmNhT~tvX5%c5#6IywVpeAI zY?38sQ!F*x#kywGEDg_OmRU`mmvC~mYw36dx&Mn(xC^JtGRuWlnIo*SIEV9Km0wK) zRsnb89^8xjR^_EL`&iKq_v2%$xz&7#cpRU=C(H40KY+9N6h2)}jLEB+ZV8{c5yzk4 zv*lcfdJa$re*|?1qYmm%R?|yoBY*q9_&mOVFP7!d+odCHF5@d5n_;jS+&>@fa1mea z*nEmd4rNdZGw2F4P#^=flfkvU3`URO6VE+UB224TddsNz^~UcZYB7oKK2 zK9Novrqi^j?IPycs8p&Ou338KQID2vVo`KYtoOmwdr_}qEXWZXz=>-(HZLZF^mxN~ zz#Oj;q^I_H2$GB7)d~vX&zw^Uje|^hx!i0}zZ@m_f9D*e99Gi|-Oq_YmA#9B1y&=r z;fIcpp+h=G)u61H2-0TcKd;j&@l00}v6U$*mk1sCn-Dx(}iR$oSOzEb_`li&dAgB+W7fKc0T zKhCy9P-v<6|D2q9^myv=)X1JN7$qPEN-A|!A*KnZEeMB)kcLaGeVizgHd2^1qab9P zgO(Ju43vZiKyx(_P^f7^6sPPtWr$Rv12zfM`3T|;Y6y-?B~G4GE5#MIjfL-cMPvThK!j@Bu3 zDMj>!;fgqh85JD|gMsSBVm!!IOanBjagb&R?w^Coy3C-@sTMH|T`g+OVSW^2{&dtL z=#XF#$O-rhVYg$2ofN` zpbLn!2x|s8o$?AZ8lugEl$atG53(R*jXNHz&>&4;5UXKQ?iUV~3mOa9_s16xRxAoA zD%}WjLS&F$6B%~Jgw_Dss)i{>70gWARs6p^woPLr(nJRW2v{3wQxEJD;J;?rFynLp zC%S@GKovklB%hK11v}_gf=t3dM{t4)gpNin(nK881l<5tg*i_+G&ZQsc07)rI_n2y z4=CaWP+uych6B9=BULDIPrDtz9A>1)*Azi-jlPF>h~(!&R&38&4`-9?!Y~ZF_U2Ms zbs1p|Lh*C0%C6E$yk7$)Jax^?zx_RwdG3bt5X<>>#_b<+bOhOMH;1TEzQw4D~ zpie*s)FOuI-z;UaIC>PEGRHtQ4!*y8|2KExXYs_jC(hxR)n0u{$Q&EWT` zLJ?YYw5{j4!+JiXnI99hpaYG9h1iq?FX-kqzb67OwQ2Tm96drmGF4wf{b1t5B)v6J zrI%Gxd4p7_`ow5Johut^6dMdcL*=Z}xgmm8J0D`I zm_bUT8Kmz)?4C*QvofHI9^~&}-ATk^v46!=G5J5eQtZM%q?Nx)E8nLlUMGL(%D3cv zt8nJK!q9eMsMR;v%ADWLB-8QNGW@ep=x@p0`%epft;{SuEyQ1EcKfAdp#=urr(1of z)pNX6_-QMD{8#h8oNx6`?#PLPvXhtKME=Cz@_k$V7q+hcVmto?_HQn~)msBkb`!C} zYHTNy>do$SO9N-$tZbbvZyme46OZ?2cT1;}sok;A#o5rsz(+2yaB=y7i#L~l)BN45 z-@f`2d1r0s=Y*qA-_CqJ|NHrO*INUl!rhG@L?q|`aQcsf-wb|xqBZa{arZO0`{v3P zda|AWaxc8C(WUJ?-Z_?q*iP=nR@yqIv}3z@4r03(#x{5$w$D51Pk*296-dKxzxwXd z?d?mqzrENRm=?ZfV>?M1K(hxxTYZze=X#Q<9UhyBT>xsgazp=?W@528yA2imF9=~) AL;wH) diff --git a/src/unstract_cli/endpoints/__pycache__/platform.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/platform.cpython-312.pyc deleted file mode 100644 index 9ac2ca60c87c1b979dc47d095a8485aaa153d2cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49316 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 K4zqpUvzQBB@yIgBMl*nTSK#G(JkMEP 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 diff --git a/src/unstract_cli/endpoints/__pycache__/whisper.cpython-312.pyc b/src/unstract_cli/endpoints/__pycache__/whisper.cpython-312.pyc deleted file mode 100644 index ddaa8e99f0ad8e0386e0206546286b9eec63371b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10539 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 diff --git a/src/unstract_cli/endpoints/__pycache__/whisper.cpython-313.pyc b/src/unstract_cli/endpoints/__pycache__/whisper.cpython-313.pyc deleted file mode 100644 index b503b196388f54f5be7b147d434fbabadcbb4b08..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10227 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 E|MhEPt^fc4 diff --git a/src/unstract_cli/skill/__pycache__/__init__.cpython-312.pyc b/src/unstract_cli/skill/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index a821e8dff0da9b3d448af4d82e45b06072a9de8c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174 zcmX@j%ge<81i3rovq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!^3~7C&rQ|ONG!_K z56>^kE-pw+PSuafEGRBXOwQIX%_}Y`N=z=%P0q>GFD^=k3&ev3vNLmX^yA|*^D;}~ ll%U>{e}RYOBDlSnZBdt5m#O>-l2~8D(~y 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 diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 396730f9ae2b530bfaa22f894c12b57a0e02e3d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 157 zcmX@j%ge<81o7MBvq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!veD1T&rQ|ONG!_K z56>^kE-pw+PSuafEGRBXOwQIX%_}Y`N=z=%P0q>GFG(#fDb|mV&&1j%DD><<{QhrYz9hXlk16#K;96nJ~t)6StpN-1JsBfxvl zJ?D2n&+nXjKhI`U1inB2M!Bsg3Hb{)!Y?)ygwtyRA@_(*T0|Fg(G z)bVaY16_Bjl^j{7Thm~X%v3ksN(Z{QndxR*S&$R08GWiXt0!AIkLJXFpPv4NJRFLo^|dqbnF-9fGcef+%-k86nF-AN8JO7#%)%L%+yrLvFt++q@Aums zl~G-88+TogQC>);$_JDk$}Z)u%sh+B>Yi#?&`>Se;I^r{0M~4*ZS2X8>UKb{%PMMI zW}7A$sdZp5+v-xwy&!WtrOG-zpr-9m_H8aRs+#ggKQ0!AV-#Tum#Lat9JULt-8Daw zHJHtUUQ9Nuw&_tzqtKIkrpr?+Hj{U$e1{pXOD);8R|~0sqAOMfKdoAp?W(R}TfF)} z@TW_r(eNybGHmR6hN&x>Y4n!)ejc}&-!7LL<>n99Dm&%;TNf|A{mys4clq7-dY3z{ z>+mbZqG@*TbPNt_r!22JM#1BG4R*#ia(U|8V6->Ulb3N$z@>i7+ zd}(Z{Ha8o!`c^4g^~7{L&H7H$PgZM6saB~qHqfn1HMn8z`D0kAR^8aT(MwNodo|`K zbZSD7_z6ydzz2Q|A^_A$x9cbvQhvH?Tl@47WUb@J4phd`ud*!v=W4NIcWDuV)+l~p zvwiNU8ZEXAhr6n_9|cn$I9^14^B}BHDmaIJCJbN&re&|em<#apG8BDsnuzBTKaUME zWP0vNa_Km^^y%C8jQy(0~8#OR%Xj+OOMi0;L0~Z^GyUS zLD47Ac`EWqo zn1#Ym+iXukB14j9csa9R!xJZxao}k|)x8PsfV4 zK3h4F-k(sc+|NIMWH3bZAyorPW9-}`ZbbL+IRMKSdK+-@X#04B!re3E&pxFNm$|D` z#$-+%6&Z*(Bl^qXq>LC&a_ldkJFbO7kKmx}N{hk;O$-V=I%kc9I6n*lq1j)?* z^8Lrs!qCZ}jK2SA#Z{Jo4(v@VWGn)f#UPQeJT$)#KgV}jpZqmFH!)GPeXwDc3XbLy z2*@T~z}x0y;l+2DE{6Az0CyK0TddkkakcBf#vQ%Q(aUet*EjMP3-8Enb)U+*t$Fya z2oeAu5~N$mylH$3P-XWJRI4KUI`s1;%%mK*4nZ}HnBmY01}D1+hU@Ti8493B`rNP0 zmB5aIkk2>FEkH2WuV{%_=41K{I@e)7xN&3@)-?qWX+ z3NhDT_}TJLmOqtFgjb)5DPit3M#SX-N`oaLCeipzoEqwL1C$1HkzR`QAVG=?@uIMZ c;{YC{K`PPAQ!$Q|cb2mw{!Typ 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≦-EO6pG*=DD@%Hzh!N0wICymggXvi_TQ&1|l$acu;6DKVEtRaT-U zXaW)s7;)!h&eTrWQ@W83i0Nc>5bZ4LRBR8k*14%D(^vS!$>o#0gA!O2sN`j`i& z&zJ#8;3b{WZU%%Wi#!8lAB;cv;SRc5nv?(M6B{7MfCeL`MSCfaGAvb*?+PLBV2hw5 z{S}lPA-y}&A8qsdP2s_z)1gQRaUiYxXr$+f0t*s$F&K6rdhjX;AYcOW7y9hA4 ze*h)>_{ZEbnq4f#_!I938A!8W&&gJKvCb*{9L;}+&NQEq_S;Bq(eT4y{Tr!^sT-9I z!;g+W`S$adpC8|F%{{(4yJ*vO{oOhUld2cO2)f$;BY)$?HD7GI>2;J>-iEEq)6U_& zH|iQkmybL&9oU24RAA4@hSBAGN5Bl#^1ZB86o)4Qd(194Y!-1C{nAUfUEgqhWjaKo z%Rih6*M%$iVX$uGz{S+)p-k}pw6bx0H9$Ie|M==maO1Q>zbT`F?`7E@FHcZ=UX^Ej< z=>oDXV`$dRvvz}!x>Fu=5evgbLAGtvYfvHwPEtJ+yX6p;KV;L2-*))KTIas8`3U$7^K!l_Sl>C(mF^tVu zrm!!q9J-{19eHSv)(aa5?X5%MFsx-^{N^|bE|^vms)MFpbrL$#;O+Jm=UYyvSf$5c5z8xX3kf}=B zncO*($er$qoPnMtqG7pvdH_ktVKfU_G=lVO=Q+oBwu%0KL_@Ct__5=^z3RB>a5wpX z(6nu2$&Fz3Nb|*1dhynY;MR}aj>@V`)3%Rq(=z7^^^HgbPXi+pi%-Sau^#O&aln3x z*5innBD_z9Q^Z7=<|NgalZ0d~AEKl7=K$thXl@XQob$9i6`BWipL7!5#YuGXhhUSC zt~;1nymoZSwf1XIeShmazU+nrZ|+9T$G7RL`9furbEK@vne(z~)_#Jz7H_VCa=%djEUUg}I zO8q?)i6d935A-wUFMJ^q!x`g!QOYBqNR}C5BqrYBdk_e5;ncsNu^t0xDF?SoZyJ4N z5;M1nb~h7v5FqD6u0qI5a#dz9=u`1{PrSdSk~4I#QbT$`+~8>!l^D~upGw5JaS;!h zDaoAPMjk2TEmZ5YhBTJ|>_68b-j~3VAw27goP6Le-ugeJ^ZZlB*u7ud_#wUG?>8+T zJ3rafHlCbl+IHKGn%fR{oqxFOBgIi&pQ$@IdSUFs#NxHtx`Wx^K}c1=P93_4Xd9oK zSo%3Yinj5&Oz?Bl3jL;x3ci=6JzktpK8JR^EJHLABd!_)gSKk;l`#ZXUNx=IZ;J2p zohX3ixB7>96xaV(ae&AD3L(tpq$v9RfxpyB$A{B;7wR^(XV0SzY0r8rw$?7+59BpuTIA2yo=JU3AX>0lMma^=2@)-s z%QBXL(K53UasX*08)<{BnN1bQotZfeDpHyulSxOycp>dc335(3kMu8-`Kjd1V?(aZ zQ6LLymafZ2QekT_L)4aScFg?D9*_;mN!;z`Tik(kHFoW+`K~o}g_e9o>T5T{f|-#R z3!33_WJ0SlvTM+!*F&eZcy9=1 zBh1fQZ_|9ha#|SyqQ}}_0Z`Bg_%CZbr>FWJIxc`A{aLw1FV>Khrk(Mt7#-eE@47=iV`@!^~&!yLe zjoo$OOfWpH&~J)2SZC~J*)BgcfwEc8YdA!kAJAjV)*_{N`|EnTwmlo9qHuBH-Yal) zA(aIAyui!FEW(=w9e6byUecyp46osBKfdL4)EsbroC~gDHrj7i?qBKoh6ms~f&FXp zwFpAw(NRE^Of1JJl9R0gTx<~r%3|k_J>*7SP!Hk|L3Cl_2!>G;sz+M>*x@BX{fe{` zLYE@##QBS~v+$wUh$kdvx0L$Y;X^ywImP~S;g3Wb9^~uvfeOPNhRs@8%9SdEj)a!4 zvOvd1R08V_H)w7-^{^G_e3Cg7cwF@x0v&}Sgi5Y27R|L5t#&gYsE+xx4S^1u0V7{s z&M)%w>N)4uWRnq6!wisNMt`_-L_$|Kq4=RcE>t7~iNgmY0)g>`SfbmC#}9Od=u5N) zlvKh?v74*uBb_NktB9NdnLXAwxe8psdCE>?o68Mer=g&{{|ut#hXa*nhnmib3YH~I;M{A$lQ;#EL)N8C5=K5T3Gr`nKgVj z%XWEr0_FRSX1s>ONo6bN#0A%do20iH`_9|1f?Sg36knzDEGi2(#D(Hap zyzJTuoL{7!g=5RElreE0%sGM2t(>5x+V2SAr1^w^W67b7C=7}7Xnn&%(BsXUEvt8h zWK~&_s<6w?Hxz)gq6ao`o^z0tu(*N6a(W}SAl}_7>SNI;ST^0OCoOExiau%45>HyT ztg?H8LOfdJ30m)NwXssVmljyeB@s`qa>z62$2#fK3$VL_61Ual|6d4Qi16e9$Ov~P z))(mskv0~-Q7UB(AYmn}{-eF|^H?KTO6SL7eSI*(KQ$PNFm)p7ienVDs*fBT;c^x2 z51ooYB@N~5`B((4y5jvY^nebVlDA6Q;{OOFaR%QumuARxBM%G?LNqE0a;&#tK^nmWaP#29cWKb z={P_~GFULJOl(L?!6iGcI+N{mJK`3sJHTbP3F3;dTTA##5%<9T2FM^>^2ao?Zf~X9 zbp+Z8Fis$UnPf>KS%ij}aS!)WN5&X1^RVBf-De0ej-iuE&k?Y!&34i5ZUV$b=p?Ji zlRjdH3n1q+=79u|EXRXkCdO09t(>{AvTc~7Q=DY$s+)dji_^-!(evENVBfVZ*PxT# zcX??1__fv9#c+w-mtMSMdhx!@;(aI&L-M{a9o#pq(C8?7T8(1K%W|Vy*m3Vr^=JfIhvo%{jbsIP{ z{8yLETM)%|6Swl~j9Y<<%q6LootErSHROgP&XZSw#lh2%3ei+hpInPNuO;P4c~rN_ z&u9zajE2z*H#_?;%Q=pC0Gz?|E6&%~IvssZj|2NYa7w-b9@gMrTGFMnHY*5~GJ<1$ zL_H)La_@o%2rORI5=*qiJ>5<40B(IG_pq_p!ZTnt?ubHGA=zyOnB7*TwDM{)=UFg4l>l2)(d0*;0 z_N1HD`Igk#&OgMQ|DTzS;e113U0Je)ps++Y?*;s)7i)X3WH-Jb@+Tk)C*Ui@U4vNK zY&EKPX)#EAFHo`-awK9Iw6zcx-UARef<@`-io&)`VqZ>>IURn~`pf{5nq#3J4H0i- z(5n}~sKm(j4ie;kEq*?xM%zM%5-`QGP>^BoJ5wm4pu)#i2PW7IiXb;N(qC(D)i0O2~)>d4pie775TVV$W2xIPn=nG;@T(Egr2pN@tHQk(eUV(-sn9_0#bl=w@LJE=FUyZTNHGri7;kVu)! zdKMRs$6!ucq13$r54WQQK{D`Uz(zub-Y9JRc$^GFMGGA)f>fC%e#h`-a4G!~dZyBp zoEk{5@ETh53?3%M%`R{ZB~B|u-AQGh3$lp_laXSWnItL^?F^HFpqt!{ra5PS&I5P69#}1ap_5h7>vdul%qPkJa|Un?V*stE02fxg zUV50_X&CP^f^9{5s8)|^$*nV4!Vk-+M-p!hT^gEhT9s*9HC_*8cKY!rM?5#` znl5esL4EVhGDq|MHx)-{c{=di$i~qG0$@!Cp2Kg7@ADl2GgQm>vQ}|2@Z4xZv>Eq~ z$1Z?8sIOeYy*?qIZQB(U?)upOz zb!e{3BOk(mfAJ5SmwZ^}u2~9pdH#xy7&Rrt4Q^>Q~%Y5E^UF zEO;P2f6e%w@x-+QS5xDC*N@#;fEZbqw~w}sS6xf|tCzn2(mS4ap8EYj_Q9j+%}Cem zY<|r7U)}D8MrZ<>SKeIWXxw^pjidfPD9x3P7ojWP_@07Ue0%Wn;Q0LPV#A11*hj)R za%3CdgDIt16o+9_`SGnqxD%bET7PukhC?1-p0$inD*QDV*?d)Zz8$6N{*K3FLRBvj ze@jXGZM;RasoS)Kikk%Na7G~3*Q0+=TISmc;1uSJP)x}Q!2DqXSNlM>9G2)e1Tpqo~Tk5cd5 zj^SqaP9iw4L&27&Hx)d0J?$3~XCzB|+Om|;g3sB$L#oWCMQhVc=w@*+*WrV#i`+*sZ%V^zyG0EONDN~r;UBr&H(Fz;lsmKQ z4!+rbx$k4Gl5U#uCX;9ii4$X-NPtF6hv6Ur%~sBhCxV$0!mZ@|i2N>b#tUx{y*}Dj z8a0FgJ6M#D^2p};Z>9Y(vvVY7&e46MwSk?EQIdrx^7P8%anlOf!r z=_QW>lFi$rnc$<-3jL;x3ci=6Jzkt(?Ri;-lggtr&C&>S;zM-a9>J{S)9RU`rIWv7 ze4NY_jf~7@F$I8N$i@`l*CdQ>Lk&Pjn2&@>VUXYw7bo?x!}5Y67HKEwc9C{M6jY?0 zP#URwJn;JO0@s(f_>+5I7|mk}qTCjW%7{nGPHt**RUeCR4Wt#qs9=P#k{_96T#- zoems+FL3z!rgxe%4;{u1ep9RuD^3Isr|Z`6YLv&Z0HGGFnz|PJu&8J>)Y-T&u?cSb z-+jv5Hbl{GRRRC!;=w(5U=|p;z_1j2piiq*{qW~7kCvyDR5^}9{K&*>|5N%(Y_I|1 zv6AU26pV7<7O9UNHn{t**L1tBP$@IGnFKVDa-S7nvzTdz73KooB0eoFU5)J>yyC|l zRKKFUB8QZ>cy+rw5YDaA*mms%PpZ}!s{n+&r||WLkPmLcws#=nq~>|0*kLKRT5qn+ z46l^Jp!HzY_$FPe&H5uHqNACZ9czas;5%}&WyVtY@53Jnu}Q7%A1WVgYMGaOc(>qA zLx$fc=dAHvL*hr~q9o(vQj0Z=tztUO!BG4(1aY5$!Pbrt!iwPTX0hSiGm`@`t`L%x z(>;)>SyGi~-$1VoQ{70|r2(ffoK+Z8{Um3qpZr)mioY_9h1HwEl4_x|A{BbH2OD zwRicl*VAMEG0vICU_(6i&i#{3hu&R1(ez{ihfQuO`$q@Hp8k!OvvvEk!TrOYA1EEj zFQ;^j){n+6FGgYb{OICzaMh&JF%GYlj_Zkc4*ly_P#FK*^;goZ9n*^Oqjr2JOPzRe z0s|Sfq9F=Xyt`;V4*wP!2F1AO#Mo~1#Ce1AguB<*b#K?+EVd;SFJP>3HGgG{z`k@8>EAx}57roRcY05(85Hy`r`J1YI}qoIhNEOplLqOIYYXoeT2UeFf(X%iDt7OIXbBNwut-~rotk@0 z!L%S^m4{ip`B=m%(@orOX;P&#+PyP&7H_7d8zOQb4ha^Wu(9L<-pkTxcJDzw`%W6D zhxHyveeC8(EGR$wrz7H<b1a=YFM?i?w__EnzB^ZAo zZ6K8~c_Eam_l3{#f3{>UpNo_#-5=T0PbbF#Iy~S3y`~ z(GNLcXrJ#O-wR2ssD6SP^P&tXBTRMuNM{DBwwg@!vSBa9`r7oxWLkM>V$nl@!^!lb zhcdy3rWN{485Mj_z>M+`xzA9GtW^|;CzOYfC&xT&7AI*eUW*1ei)b$j7>tPlmq82| zO$V1vEA*RE*goIM(he^Qm{yjJM#W*XC=R1AsVtj=qxh&`nQzi9xTJx$f~qn<9c~TA z`3fU8S215tC~QDAy+32F!Y8;2ic@-TxC+zrM&c@F;V0bSC;W_`fc5>k=M8@1Pb8XP zM@K3cKLMdY9zRi8GWGs+{6s6IyRf5dg7Oz>XNgKnrTSF{)%UAGL?W~DJC@2R3L9>T zPSGd|X|{Vy5TH;Ul9b2Gm$wTZ{or3R2X0(sU(-#8hKA{C}mB{tE(+ z5jabO1-OKBZbZ%&hEGx-hRyd-=`8>j2hgOn^q;TXcx*@$F(ZCPPXuAV!VnWv1SxBN z>|#R}@3jJ#t$%>CBV(HB4KxoJ7q$uuGYi|sKc8915b$6c|78SB6#|mxg}#QjeIGWVY+GQlUN75Yt?HGEgV4E5xDS*tjyJOR-x z>o~zWTIKtV0mTR?yr(=d2g-f50OhKZ*{$WWkA)l|pu1VfA(S;xuJ31za?1qDB{!6N zj&dnJImk{$zWv5*2pA5KUnV#If9S|ltukCkq=1eR;1%!h8u2>!!$ zN7X`?WXLOFG01g3_8#yXX9|=~$9iB47(O+~F}aPrl+e1q3>Rl;-Ji`o^2|H!Bkd#4 zUfMogw>nd|I{mAIb$HXPya3px2>VJW(D*Yy|CUt5md7Nt~_tdscNM86#M@&s1} zmz-Qu^sY!JrvD!pjv>|2*ko`O_p&^A5qYeZj=2DahsIo);L>S@ep7s(?__C*7bmDO zFUk-teo*`DyPkJDCmwkwTl;KUd6pc^pUnoTm;-sR&i2!79Du?1j_k+ zP>;5AV9IqfFeQtYhg#a$duHg4VMu&;xDu`fz?I}k{j(H$DFRoPBkajN$CW0YR0D)9 z)k=b(8xRFSbaJ<1z<&@k!WcX4S^7d*zz?R}c!_pNhB_-^v!nnl;D!}hN+ap>RiCXr z%M6wCoDA7amGeR-EVsUU7W~x=+mURU>-*1Z3V#`g{` zttANh`;%``-0uT92cY z#(wMZOc4H4^qVqk_->Z%^6~`Atwu9m!{MZId=6AVT9Ua9O0Z%@T%wdSI*swaK*a$xAQ?f))kS~e++O3Z)$)CwEd6?g>C+1`_%}++i$?MaJKSXPS*JlNk zn{Z-?o{qf)YdO*v!gM2~51c~kyC@T&yTE<{gt$K&VVw16y%u@4c0o;d5g@j}5-c(6UQYKo)mv2X_8Zm}WOsqvNUomi z_0Mm+k>3AKoQ=h3!TUcMTz~CB$Z^ujuIn!XU`28L#Y}M5v_iirqk`{c*&Z)XP(dIsP>6S7PZ zSGH+RJYwxt0>yDTe;>79p|^rQO!=kb8Nm&dn}{RUU<9vW*km}(mv8;7FW;qH~5j{JqCW1{97wz_EF4A;1_XLz$jIAXgc27xo+pU%bI)gjM%p+ersFjt&m4An8R_TH zQe>JF1tg%G`Dv4|6U+-TzOgls>f8s1(vy2=)uXytc`Nby*2_&FDI$rRYa_5f#tZJj zXU~(Ifg+a_rV$o*Y;BZpD`&A%cI-~6%3UFqfTb&UcrIg~{k;j(Sb@*p2-ArBXR9Le z-7#1~3Q<{1$%|;@xc4gRQ!+~^?)|P3e^~QrV?JORSQJ&k3q}(v_<$NTX0@DUKDUCz zlqx*8Q<<|_9rD4Uc{_7!-VcdQMN4gR9|7NgUJs8RXc>$yV3sgb0CVM2C^b1w8WOI=FBG|ql7C`;P8nw)f{ z~MFQp~12hXQqgAF##Zh(6H z6tjCbsQ|6Jw3*$6xGwW9k$BJQVME=PUzY5iDKt$fV40<}kj`%RRD`P4 zyj~-47Y~l;g8-l=x%(Kx-cMou8Cxh_Nzew{Um z^ywF(S{rBH&J&VcNnjGw#LQ4~zCL&z#xE3L(GJssw#CHHh6OE*RCv%P6pWZnz`?6v zAH&gZk_KKvU@5>X$T;72MY4c3B<-)$CGMp`z|}Y`oXag>!6!LvB<&9{nFGHYM4pdo zF)aS#y|G02Oi%n2T-Hy;5%)nOE;yRpGIJ4FTF*ytX?hx002Ex)JXStgvzmhcEJ!OY z!>O^&kd#d;^qb=Qd?!mgyeJ?#jBOT&&7wGr0($X*ue|hC*s)-U(PaMkqcA(6lg%>z z6{Jfx4x7bErN!*Tk8y}85qay9Z^H6II z?xIYG z+5!SJb6S4fKkap@`!xdp1mI4dvj|K}xpT?Cq2nV2$aTfeFzKhtOba=JL;Df|!U5VA z0x5u&TCUT6h$^-dU{}uUikO?pE~?v2fXFR3GkS_jUm-xYM%pL=i%JVKQ=MK(gUiRE!?%oHS%qxbigE@6z&V8)ktM8A zoMgkAMzOGf;w@kxi!nrb9J+sFbh&^_gN4?W`nTs_of2qubz00_S5^M;NMIg9!DjI3KN5t+4|vqrL8I0#P)31JbE zH#3>|nQ+XF#W9J-zBe}kmRx9MlL_kz=DcStm+J7%v(1j#FxiQMcy%`HhxPP8-se8M zOx}_8-V}{Vy~ugJU{gh&bG4e5Dj-Xi7_HQq7eu>o=0PUPAv)Xt7#tN6gNWybfNu!j z0*x5OqI-G6<`tpdID~12n*_O0kQZtnY%F<*xCHl5g+oQ6O(r&c`No82gTQXH%IG_i zZa<7Z#p+J`KJJk%?UL!*yg7rLW;A&zw^02_rIiG)s^c-9Ia zg2Y#CG!UaE79m+jb?pQ;5!g)NK>`KqyEjp1CPTFd4!bl)K6X(bw$c+c#9H&&CqZpu zi#_8G!gL4W{#buhO)j6gu$$TH1d)jwIP0s9A6C`B(R;CXbnnEH{n@Gm(^W?^RY!;2 zW`gl$Uwma08Udt$Y8_t>fOJpe>odXDX@!1MMg`x?vOQj&p!U2dLo{KS_eppj!m1)P ztvrd}l*0D;PL_6fQNXnF?O zLrWvs;WfcjO5b+?nAPw`91OHnCik6yJGo(Z(Q3FwsYn!|AwgkRl>46J8#^DKHlRd)N*mI_4buw!ri=={ zm!&;koKQBP9WTppQrW;h{*IRU+DV$A&k+a{c!9tN1pWsB^2pN2c3&eKGi?)rLj;Z! zc#6O&0_O;ja9I01ftLwD_eezH;v_=dDJscm1s@w|Cnuf0=j1t=bTd>~kvK!_HgvZtoH2 zEx*ru%z3N2%)9(nt=oGX6>e`QgC_u8-rdeyip%>bgC_xf-gURCJ>J!~0v_*n0#7+_ z2YgxfQEul>x9S1}Qwiu*J%B#S+T2+-=tC}a zg+8T65DLGGK4)_ilC=MhktvM`!X5tvA2uZuG0tT+rT>H;kxs-V^t~jj0oz=-A#so! z%$+yoW6~K4MEu7RYO_PY9u`O&>FeaR_+9BXECg?dIt3eCR5~MCl5Rt~YCCCwV491x z6NnqS5;v2kC{hnQCfbc4(25jlyPPqFeks(pU~Hw4hy^OxtmJ?a(IEMO5pMhSkD*ru z7u3<>;mjOaXJ{`H&pl56+Oh{J2rIr#b7_p1Be@6|M0)VYzoY`8cu=ue828^+1 z#D!Trni-PK;>VZPPVl4SRebOz1l`xEJAqllPk+1kXc1TRYMg>%WC7 zeAV%PHS9+4*MC*tZ^pnW73D=^%N`R1LvhuNG z$4`XT8oW1W6Q}7Y!(I9M$7K0_p0GyW2CsT+4QpXah8hddrC=hjmfK2689T|Or2FW+ zy9w+8&`Dq+1fN{q$&FGW*gVbfVVK3{3&X3Fna3IFbo69fKAJ4no~&K;{|9HwlXLFN z*6b%a=Y45q?b!X_YW;dE3dpdGRqNVmg?>|fpYLR8hZhB8J;h)-m6=D!*Qd0-Sd$(~?Yy_Jv4^oOejpBz~-?I6UU##Yv23 zbP_F5K=m)b8i!pDl{`tawWzV9ct4lC3jb;h>iEy`o^k4^9P8NMdi;@NC*~%MbAKE2 z*tbEk5yyYAy&m@)TFBr#L+N6fZ&xW_FU>qk4|_er_IJbxL^;S1oLMG5ljY}}r6W!fNfU)ES<`7Ld z1xHSURALPK=czOVV6W}rGKPV^bA1TnV$tb=GiN})^+FlLZ0*SUQNN9oNKErs=h%^K zP5WnK9e^MyEu#nCK6?2mS@#W&9?i4v!@e-zqu?lpVIeQDp5pN60bV5QKBJS|-n8!P zKqP|#neY{QN@E0mi@-Pm5@2Z&dLowO_BW`G07Z#laamt;+;X|SdzsL64ar>FNaXs! zZN=^7sFyd#Fk(Yj+MKr=0cEkODO4SFw|H{Z#B3cvgz~*eTB=3PLReZM1j?5XQakaK2yJ=k5{W5uw!p%i2gpKdJa?CirN#r3Ko;lR2Pfr5Iy*no*&(7!LP%+u7f&zR!Hhsf zg7>q6#lTsOE#BAtyB2wS;)bO~1vMyIQCFg@V1x3bI6>9P8uGhl9}@r2uilz7NT${c|UuLtGec;fFq!ut}}3EUnJkAd);2fJ$Qm z9Cm2jlB8leW3{ArreL#LLWopRq^(fe!;eQGzfX9j0y!4FQqdAu3j00-DF>u$wlP+~ z)0|&$%h-j)x(d&cP;0@4Bf+m(Vm;Hq!LlV>Y<7^&8#3#x|Ebw*3H?KOKVDzS{Dk^R zo^2%eb6+q^Wfx#t)={A1;~s`j;`7frLp%)Phd3J}0`TcsOD5*Cq}N4+V@l{3}SI!Vqhr*8WZo$$v1IX|M;80Xafnrd0l=KxNueV@QJ z0?Zn37aiSCfY9KqY1>#D=P+FU&3tY8fOb046&1O9lI?|fm9y}3bRk1J{eJ=AF=)evB(VK?25 zd71|J@>!13G8<)Xa{~cWrT_d|4Ke5#eNIZVpYS#=kqRlzGE_zmHepjJ!c1n**;8jMpbjZ-I$Q?1tH9&6MF zz*@Btuug3PtXJm&HmJ>jjp}^BCUpVeyupPni*mK}8SaH?MGOoM6FBL~^+(&=Hi4_j zPeE$n2P8YEZ0>z5MHtdOA(ufv5*B!**&|IX<795&sm(41RpDLyQ)!ipEp30{w8|{l zi7SEO#j&)@Hag}ZFMKn+=J&udqx>3uF_OunO(YMq zJu|)(T&SI*FC}=SJVCPEQdqPkenz@$yS8F6TBM!ugcJOc#8XOrZMe(SY+*r>f;b^K zM>|}EMCXFXn%y79ni-_14%qQtLzVPwEY4}D3Z520_&fxeZANvf8l3eT(p?z%Rr-n@ zFg&h?P=fW6IP21-a%N#u!~AV(41b$iYi%W+(e8__ zHJ_yUU&4iy;fD+^EAdDEH`PNy2$~u?n5j(86YGm5X0?^EoKLmG__*%umU6S$MChGm z-NDm``-hlgYXdkSw#bORiV{{V-(pU&qH8-!hA*tBv#Jn9zU_GU(9vW2_G3KO6II-a zwjCv-C&I+@Iuwj5dtci&(oY#9ZDx__Q4{Wk7qEYb!qgj)5!%r^=b^SaS6qi9?tPdI zjaGm>|4oQWv>IZ$Z?w0m^f&;^U9%}ar40fWv+4(bDj%d1>?D}ET$wQs+V8PFroEry-&y3Om>@mTlyoEu+46}J2*bcUM%vY_#q@$>2Bo3k|!em1hk1*5&0 z-~%jf;2WR6`1!Q5@Z#sc2-l>A7olHVD1KAQ!r=tp%UZ=r6rsGQW)mpp_26}6FQ2-Y zBA=o?Zy&sT5QWPJ$M$^{lFf%8l3duHS=f#uS}bhO24PA6(6uN)8o}J6A}IfRMuqH5 zgfa!?$2F155rROQMT)u)pK7WVKa6-~58p#evxt_X2SpoRK+CaxqNQ1+mXM#)faKh? zu-z&LVa;#UUqeWK>ztxIx7*WFCS#n<-ZP&wN5;5W>_YR~kqmMcsD%jv3A4qnLd3aFOlbXA^c0B&KM z9Y2XDX0swTN8^#Pjo3y$D5og;2Bfn^eQYb4Ity|^*{-+9tccY}k#-hWWX2z=VDX1U z2E>wSzTjHS`!#4jS0<513N&-yk zXE-sHU@E(r`arKc!)w;avSJy{&P}BD(h&OySOPqfXj=paB~Ej=ISQjBh>uK$-$|UQuRjV#mbSc(MQHkrx(Clr0qsc z(_4!!EgIFwK4XFf<=BBM``&)^@}pztzcuvrq3ps<*Un@X?!10Jv+w|p4I_KNfpid> zFZxXx6?`vCd%QTI96&o>mf@swV5Wfpt)7`2_TJ+LpubWkk=M(`w-+7*-%quY|W^o6s#FnOJAqmn$Zg_428KxFKNwKR`SZLK^os? z&6pA~h>Buv(Gr-uOd8b=BMU6AD);P4MXaWxS1MZKN@WiLyDPnG8dK!X1vV|x&XR_d zUnyfNEl5boV8K{v`Af#SLRM3NbY47xag~zyS#Z!}!+1#(zvGmEK_Pp>UK+ z79#AR-6sef2QZCo?*`Y-^boa}|0ikSrw9;_uRTkklfZKT_BI3DlZzo#hBd>aP3G); zvUR2mR^sFfn}3xM<_9y_gL=l{~(Uli4B6))vEzKfY;tF{61iuK@ zgq0r^^)t>Zj?94>pYi+>GvZai_DjrY;*%nntW^2$&oyQ&!qv~dUV}c%r2BJm{$zV$ z$XG9@EPAMO~mr*0)juB>Z7gCklZSA`Gi3%lsb3YHACIL%u!&<x zKfCC_^rEAgMMtx>9m8dm!?9^)Cb%lCY#s+qN4URL;~?*wrxp5585Mjl%l3GAg4*+< z3@4S%Gf6E>yZ9pHuQC01wFp7`!Y$?Eix3k9%^@=K81~FqE3=3(#Y9HrLQD&fJfA}_ z{$y6iHak;c?gOJVIG>SK`7@*7rzc1hiK%6YXh#Mi}7@0t1*# zJEvet(eWo}2MPZ+YH1;#Ib{=eE-~DOh|q!-Jxh5;kUN2!+#1M}JHpI{OJ?cx&v0@2 zC@#$&m6-B|iRC-8H9J3b;rOJooPUDgYsDr$!N0jXIRN75lK%cIWC(?u`QjrS6@6&1 z3N>7NFH7{-etpkBGxV^f?MmU9z?lk;3!fvdhi`TofAgveYy;+82j_i`c4rr#7=4bj zMuPvOpO1IQr&bs<$wOQfo5=t}Sd}#Qznn>WCrphii*8sYAt9zYO?skz=eIF&>QyQ5 zx?zWjpuga;;(ZD5SOu|zlWzT7s}+OFlm+|_&L|mE&gpnN6Pf-0-CYZC8^?JD2!K35 zAOHd+#fM_TdReq2QkM0IZCSP~SrV<7a%@VGQjUgO!W33OjjbsqTkrH7`Z+H$zI{ni#~nb@dD+Oy#Y_3(q4@D3s%5Oyz$ z;7`k0@F&E7iq7(5l<>{a0WS)O4wsgT(`HeeMghI%g0)B%eQxB^zP#+xI2P%n&y83U zN25&HrR8a}Xl*32OWSUa@1V9geFD0}8Wy;C2$PC}5GLj54i^hy0;R3XL9I<`n zZFD8KL~L~B0?djn*|Oc+jVYL55$ga3m57+NDNO%zFs!tv|tG%C?x%sivn#0<>L z9Tw~mb6>G8T8>SyD-~1QPL;ndu#qN@d0q zD4|R{VcUaP{9NPeW3l*s2Kn&btkKDim8^tJZEF?E-Z7;N8CG1c5R~t{`IeU?*+87B zAx5%QBYbOMw0bna-ucBu@UJo=_@j?}NI#CrGXLC4xV#I$041#{y>qt#zrr z$Hg`!@*y54r@=G*F$#N}K+w8)6e2J!nmjw)n|O#N(Sds(wdsis4h;_U#NurhwfISF zZ@J1Lt*?7P&3WkW5ewQcsU`2!813L;xl`?Q5?UIAFAUZHCTh8JAPYiOQaPUUP{gUh z;>rcG%`QOCIOG_br_?zygxLIvUUIrj4k4~zOjR`m-|0^FL+b$N%x;#!F}1UpQhjm# z{|q;k2x7nfnB#`e;SDY@OyYWW`2Kkt=fX{w9#7ZooY)I6xgTJv58#Fi=f=n8{6QSt za#e(UtTjN$cik5mZ@u9`gEyBqu)kDYi-Vh#vqy)QdsA$BY?$)6gD&lE>QSE1OU(&Od+qeB@$`S?r!+cmBFR9OuNb4ssV-@eQFSzc%OkO znP3BQdJ=5BY{6r8jC>6fY=qxu!A8UsY*=O^7Qbmy>z7CfLaS{ELC#jU zE>HdWt|;66qrfqU!g1m(a43{T>Im`*u(Y6vfldzmD)6W_G=ijb$ZX*AnFwcBlz!LI z<0i*&Jla1%`N6?T_w`Jrj7pV7c0(%H7f@NRD%?$=$7{TqF z96-neen*t*l&k-cf?-=RLSUq1zrVqR6BO7T1VF%!05CavSSF85saN-3>q)PEB2#nV z*TUO}Y6&e~+V-V~KmTwh)H->O9@>1)GZ%zZ{m8_~vQnk++TG>R~aGN1}I+m)l)|x-7 zW2qWzHKEL7Jsv!B^F#U*AWG-0?{S5vOLa0uCZ0y&M};9X0*$!7D75ZQhb6@&So*>b zoLNYrTJQlHNvxbXycB(Y+j=WRlUi|{u{iYJ*NxT=Ui;dVi7%sDbes~o@HypsHj>V-E3jP}j?hv5HjnFiK)?h4zg+!VTAQ&)j2({ajjGv7nWMPz|hYllPOEexk3%3dc zS4Y;HD7Lg1{4O?7`6RN0BG`I7ej?U$Dj#9d&o4@b#87>*RM=pTfNRdg&hqWP^I#Wc z*WpM97aShwIfYA&3?p$iCHsLM(r4noBypYbp`lZVP&GUp$A%g`f%#y%l!eC#;gY~K zDXydIaJq7MrO%gI<2!*h=pNDHRMnqM4ksRLZ$F~}=e8NairZKEIO-&$HDvGLh&ho{ z%$})w`+ge5g@bUrgM47&xys#LQ83(Hi}=n$xM+C0!hM$$(Zc~ZS!EZwchT5zCmP- zQhCx^JArt)p3|6L;=eHipw?Q4ul`cC?p>2r?$ed~<^uOjdM3`K+YY7uhu;R{=>yLf zSzg{0uAybZr#Gz6Hf+`#HfI~QAmC4=`PCg4cclIMCVT)FcTD&)k@YC0+xHPfxqY7= z*_ZXxch;=ohXU&UeHT8+k2112gW?WeJ5A$@B1Vr4y+{)E{7B!FmxeZHxt%TijR0{lO9;)5i^NUa8~`e zrH`F(Sh8{gzuLyk6}U;5^}vl1$zJWW({dy`_kV#u%vCjwQD{eJ1}U(LV|&$yitJTZ zibe?agzn*(kBj7J8>7TWIiI zZ)tQyNb!WOE57P~WNS`Fxp)#v|=enU9L+%xV2})#% zvOAj*X6TMxYAs`U1v4V|jmt)HEUebSFQcx)6$Aa zJ7xC@(Gq(_gW71Uc|>hen@1yt7PNNJMpQxuX+iO9&Buc33S%XqnZrtc%ksY1$yPtW zR`Ra@`zvX=Pa76jZ829$jWVh260bTESKgiahh2<$HBwZ@DYPI=dP&5}I1W}q^i*W< z;+bjVNytonCN09b58f0^Dnc2O%-59ou9Gy`kv1Eeto0#wZOC!OfGjXG24P$^EOV2% zbo7jwqE;}`1`7o4JxY_a@UzSYi^4@9940H1LROM?xoV>7o<4Y}b5GaP9ZxM@HTw%1 zhUh}=T>`7H3lw;aaq@Jnyl3vDHj=QrbuJ^G#ZWmnV@{u@Q7Mm)7RAu16*j{N@triL zPBkE32_y|nKEW5w5Jj+Ty}wj^CP~aP3$Q<-JN*nmYn?6Ox>%pKk_4{ypLFNafV&RY zGvBeizg!XB!bo3;8q9!9$oLD!m<++_bwC8oV$^T(KE%E!u5pF4-%j3aQ8?#np0O&I zpZWii{UlGX<*MXO6D?_F6Ih8BJ-8{W z(07&}^Mec>@Zz+B#=K~SXz^xf#icPlv>_YZt_Qbgf)7wQtnF##k#unT)h+2DT;}OJ zYgF)~3_Idw0nz6wr25nv=gp!x&8z2v8GQm|lS=xkDJ@0&*x#2p4*wA5Pc%CV|fz6sHFb!#B? z@E#+zLG&4BQ+ZE<>sb@gQ@y>z(1tQ=9OQ5pVqi#+fy(ToyE@n5O{R{26 z{|b=$fDN{h_&Vo2Cean&sq?W_ryXWdQPu)MYX6l7> zu@FTO}dDC^Mdr&tkjdf{ta( zXh#Np6Z%feNJL5&d`3l}3G=;#q}Mdsp|C@y{?3PY&utenuORF-Sn$N}lV8u0y<_4R zQ})S;mUwbO%YECew0OtLv=hP*1lZu7<^icow)C}ITVXs~xVB@}NwQ~RAG;(XMFUJ0 z+evErMEEkh@Ym+`iJez#m?>4rZciGk84Zoru>B=G8czJea(^~xAG=E!yGxk*$lb-# z$8Oz4m=QIy=r^R%GN#RMTc-6f?-SlrC4XlNd*F?U{KmhtHRk=PwO??vU@sB14#s0r z__Nqwy`Aufe)S>z50BL_tb+|Y_gDLxCcvPK09!2A$2^IT)jwCD+@yholf zNGx6)>WL+ho3kyY#Nx@eDYvx=Si>H&J_`+XzrZ%y7 z&7`8{N@p9jH9VnNRA;W8)?UU1p=DY_*F8vJErE3e`iUs|4oW7QPHnYz0-FfjN1&u8 zTI-@Fv=eac6EW-y+4Mi9^A))B#Ttogk5Z4v2}rJ;X|WmgwwF$P1R${}Hl07QZip!Q zU^X6Sfh1zx^l*!_>Bak0;XV?u+9C2SP=BdO53D-pf*gzHJXjM}1`|iksl4FA{1h%f7Yv_Iji=H|^LXl0 zh^*W^j&R4&q#55?rTJWfA7x~52E`PwoklUQ2dYu`Q*OB?4Z3|-c2Bu3@1N?wR+(3rAapy$guY21=drXe$7>InoPfU zi_>Nimfz->*7l}BxV`n7VeAc;FcRiNhh9N{$0}hzR?LycWx-N{)?$`v0^h;)%w?Cp z4K`OU{3zH*hTJfzY*-0M3}J)Y?s!I377R=rLczz}xrw^x>EhsAB4wUd;$-@w7v9yWG>s-z$TOl=Sxl~lS=Xjx`xqk6EV(kVC_ zS=NCvhsSfMGo>IR2t&0gFYWHEs5uGIFv&Yf;d8-5ozHYVy=!+@_wIw8dpq`NEK)yF zgqn+5zKl|ib!R9+Lv&n6Y9$TF&F5f`r^N7haRaw9WbPrpW}HAl<9Cr@~(49cVz!$zXbQ<_@Q zo^j)eZj7GVR7}`WtVb9#NrdqLV9s&Z+Q}yUuKTBsTwA7ZJ#eA*oFe0vK5@l0*>O2= zRk^k{-P|#wJTa$4vPzS#G^Lw2T;vcnchcrSOt==x;3<;je{GwVO3`wuNd z5?w$N9bXNkl`bHOfF5kiDiCINz0QyML52=^Q9yJc%s_2%+ANCG(@NJwhd6B(XHXju zZPJ^lcfnUzy10B)==JeDXgkK%=)AQO+D6Dn-Opp;3P0ol}>Bslq@O?cBD8(s@@iw|StKl7ZHMUc{Cp?+ixQEW?_4 zS9UfN<`qb$Yeb?)=rdTrAks9uf3MU4%M6?_qOKbCF~-i!B3g3eSF2eT(E#ZnSLoHXC13Y{BMz1Fe*t#ljvW3_w<-#m=bCGQQ1>Bq;&3 zQgSA;C1wyR(^kBXW!j1NQLXuchp~WuW^-YeMO4l%D&H})WNWZp3v@yPAMzn$M%!a$ zywOX%$8{Fx0UN@_e4eoG2&nb+aNq#ohJXUz%;tLvV@i1sHUbvoEnQB1?))vajBWQe z%Untg!n&nVZL(Om$b7AGmUb&G*mm=tAz~9NjXMsg&2<0j3I;#G*8)P|mFBaOf5(=o zcZ~s=z>iH>gN#K+BZ;KiQs#cdud_@$8O}%EmEHP?>z5fXV&1hHH3+8KvnO=|^LEive(a}U3Ie8M^0T6Wb(zyZ6^n!zr zLIRianRJELJ3K_*>5!Y8K8?-;Nr+0$403G{8wy4cQ6q_jkvki1?1+YoyxEBaO_T^Q zt8*N&-FsV|xhgF-Kn;-p3HP6Kt584XD#`Ez@oMNi6jAU)NW2@!UzAjjo!KsifR}+( zKGB$M*3db}Z2b?6r57Fu&Cw6n-lfmjmtq@Q)Q-x` zODwSKAgWgL;fZUt1PWw8pQE#f2{37paA)AR{wSSbq9Z0Ik`W0W!-;YNBOYMDSPU~r z-%0v?be}|?)9%E`3EVb*bwk?O;sFL0*M9|WEpZwEP7tv7s1;N%Wuj^V(MwSU4(s7n zh|}W@UD&338!zqGy{ogzdR8t+I6L!jHP!lV`&OnfH50& zrSZ}|6MOZhdnb?ROb%p_bRBWp zEY85-p=~FxMhjfApc_B@>7B((-aLtCY0-DqsNhE#cErom3fl9c6`~13 zFkeXN!Icv`G0fD1dT=KNK=7r5JF~$KJ=npH=KO2OT*rrF6os*l4{;YlJ_>B$yYBFQ zoKirC9-#;bEKY>1d%{^*i3kj9$sQ)Jp%HC?g?GT!{9d|n2%1> zezM|K#7R^u;*QhKF&7JRWWyvR!KUz!>XKytxeCZ-+@BDH-DTq{PRDqKUF>DfwAir) zs*q$*U$u&n`4rwj`1YCCcZhV%PKOto^--79H$xAta9J$Xd&(EEaThAMMi~(DhO#2Zq`;CCR&nw24?b0H3Ok9EIR|y5;LeO zGh3-yEPYqvsj8+VNl_6E&*H5^vFO;xa>cvMI{8?%={T#bD*eWm9pQ}QCD*f#GtSnU)I+f(ITkRUO`>XO(bcj_9ILWbQ05RffC}x5ef!z zgnXooHoA|yS`P5=gP@PJCK{=aZceDNi2m~#4yoNkr&?=rO(tQC%w+>@TvpWlvQ)2{*)M#;gq~iu9%^%P@W{&=Yq>G zt<{53*f1*o^S*K4EB+f5E^mkP2a&ZC$xLKJ+P`JW4Ump(z~9K0te?KKMg>0-P^ap( z{4irFiw4vFEfYy`-Ym|r(RejlOu0pSQNU<#R@a^H9`8=KwCmOFS^sw3zx~>lw14}o zfB!q@jubofZh{0@-)-6B-s^IF&vp0S)vi3>O@T*W#JjY3BS)zfu(N^M>d5o%6nGai zF+Qrm6?QryiJfgRFd`nPl>^QzRi*$=iV2x@7i}p8AC|$vD;;?#7J_YbgGv^O*OELg z@%I*wdLibL!^3TfQpX`Ix-}NBT5VysZA2n@_*c*ec9>d5YqQP$`AP0Rd?KhJQ%^NcLn^At2n+2<)*;(10av$lKo@8Nl>wd(uu zJX8n$Gk>9=PaLZp_c;>IGZjw9v)CV~#-KNhl;j#|zEZnQ=H4pM*a%rgTkpumGmw}_ z2?^vh?Oce_4j+b%BZ*jF@5X^aWS%2IruH>}Tm`Znk$_AiVicjzmQ$G)Fy-C25y(kq zGXf&9&k*u`IzjK2MyhD-y9EA{z}*0dE69WhtyHm|z`X=E5ZFkdoj_?E%yeg?)J|Yy z?XPgSFqa>TAk3~!zcGtp%A<6+i@;+5_PP9;O@vJMENn`&-aZ%rrP@m1V4L=jRp2Vm z;d$A{?EhhP`25jN9sOZ#9;i?N<4}{0n*F0Yfd^s?gxVrY*>Z^CnME9Rl zGl359kkw(tysKaF>amN*ChnhJcPLYL_}t#PNMjaqg2*c1xrrA5AT^kHL658h{=;|H zsNhEe0yAFN#*Z?xIKvwA>S-EIw4Yr2t&LybC=Nvd*PDwpm=jn>6IdrFfH|z2H7ZcF zes%x43tM?bMiysSV_rS&$8gpzqV?@+N1);S(eb12Cde@VkM=wFx4ZtX-LwCLRh_Mk z+A(^@zeS*%z;Ob9jpv~KErIV7&#Zu3Zcyn)eO|9iho{#(o$)+#v%=?j)_Ke2sO)myVC5SPc@5{3;B?JL zj1m2w{Z3;@D$cL)dN$72hdhU!^Q%3c$DH#4Txq^u@f>o_*Dm+?<~J|%bU1G{DV|n- z^K{FtE7{uTeBIqAM~D&X?$$_hfB-Hd191ddOE3+2uCaUgEdB1hKUE zsUH}+nXAkYi>FKZf|RLK77*waC=9T>Hu!-xDqGIFVy8qHDS*X_&_6M0QdirtwL4xuC#ol*T=A>*(aPhZL2v zVSCzs|GhJJW*378Ysrlp55a%#z4PBYcV_4Q_q+f7|9k(mqQWoX*t|X3|3|Ho^w$(I z4mS@t?(KI-(p!=u^+<|CaSk{JojpzmgsPjIlRr;71=2C4^ZdTIu1duj(4^eh;x>!}-D*t2kOQO}~m z`ks2m;~i)iT->vGa7oXS!N#7(!KFP*2OsHqgkAFuEE^2 z5>GYvCY4xdFgi38Pn>NEsj-wAk7=Q3LP1!GGR~F{sor5np*)&MBvVn!t#yUnh4Q`0 z#F_ZnPE}2+1wW7Mj}OEOmB(UQa^QSS;c12Pfn-#P^fKoD>FDn4rOU?QL7-fc9QVF}z*|zr(Qmz~bG@sd_esw$lB84kUdEhbu8h?0(l1y} z8E3|2{Mrub^#-?;qI<9-+PaaY>6-{HVO) za{5}`Lp?vK^}12bK>Q`CB>v32SkliarC%i?tUho3|4;buPBaF zOmQl%n6JNT`jsX9wpTp3vcUF=cg8C|Tv=$FN5++U+bdhp9lw86R~|@FCuC@T{a%{f0x7Q*9}y>LJUiv6NG5DW`gLLAb6^ zdmP`OA$*?Zf^fJA4~%xaH}t)4Y;M^UY7V9PVxbg?9*D+J^lU7V3Y}J?iQYa4%VeS@ z9pqxFDK;_$@fuS;ynzxvd=oA2!!Hr}6(V0I@~cGNCh}WEzDeX`BKJXhtqq+TG2)JU z=h?TK!gYRR97aQiaByXu!_t^L<9^ejtxSph{d$LL8p-obT!!ipvxi z{j!yxhmGIwyreZ)%C~7NDUsQzpK8q!5SK+@dX5A-dc{5O8r98SBlAtER!*Cmn4GAA z+fu3>uMnh)=M$@`5YuzkRB)oE9>sgvoraVi^C&(=&UoSu7W1ZbNhe8`XR(o38L#nM z8RsuRB91u5Tx0H0DO}ctzQGyS!jANY)$4{v+rygwWjzqu6{66Fu(poQZDB3!9}C4b zmby8tsp#({^d!6kmyM=@p2US*MB0e76WRQrGOB4YHN`S-4QrIRHC)Ea@INs$N?k=H zsurpvz0rXI^xQ*gZ*p)bs>TWdT#itOo{A^XbqyrJp%v;QT3>RQb@D@ML78btEiBr?2-8Ei2#CWkyH}*)xl4?%|^hM0SE)k@>|AO4vi>aUy$(>?6`iWIqVLr2`aZZS^2R)<(EVpj6%hD_L_A!;18Z z8JnEop&Dp$kk3m$3NE>X7Pc}gugp=)G5>DKD__s>guEFP95>~am(Y@kAD4~bq`Wd) zTnw(~E@jZUC`wEZ1gjECGq&!_S)xULQMt!Y1RMcaw1& zO~p8)$#~G^Vyrjn347DOa*PJL7?6jep-m$r7>PG)7;|D+jvyPbhN1&1hQ6bS({Mhf z0EC9(DJ?V@Q|Z*)?B_jAhz;e@AAtd-^d(cs5=BwH)HQ-8wBbR3TBK^j11W}c&%{+N z6{21?q{If|=h0ItE$Iz);wYJgCng=PY_PmkS zuttj8n&bcUni(5v5g$nfjifj_L|Ix88u#XF*JkClS2DzviWyQUi;<8ctYL` z3XYrd+HtSAYy>A+d4fq?XYIBtTvL!E40c2!1%D(mm{f)bVh9H!k(Y*}0|ZDA^F<;` zvKIj_M>+-fU{pOo|VsNLRt#apL&F zLRr_5uv*WvK3B6o8)&jA zVXDSj-c-%nT+P~SV4cos8@sb8Q0Bkq33|PELk`cjd&?2JL!mof$r~^q;L@`<*Y3=F z9=q#wcvjtWN#0#|C}hLcnfL6!>vVb65!V3+3-Y%`FRB;+#er8vhvObyDb_S7&Q~R< zGF_k^#SQ9JJfOZ&FDb|9)9!$-YM5TRJ_y~U4?@*gEHtFz6Y5QAO=zqX*DwejjA<=J zT|gP~y6EnkK#TEGO5m!iVthU3Ou3PR9sPv0N{%sCib`Y$jUJ70`IsAMOOzLPjCnHd zjOQF4dCZ&fVno`VDmU`=+l-_XSH_DGDMqguk7Y#aF-I?UT9$Y9BH4)4EZ;`wk&=zO z8=@8n&djmExN4rK=isBPPpn4bJ;@yaQvO!;9c$wot#k&Tj!voYdD7wpD= z@0bVZwVX!23l1NCT36{pT6P>gL|Wbl0inZ${_~3Cfpga`jE|qALGTu1e2hTJ_}Hey z#E`kgdI$el6YE_~~8!EUr{_6mx#0sLbwj5|f& z@D=ITozPOcGH%v8crqSsz|v0b`UdI~0E{V***947%sLvKgHK2u=+B&7W5)DnUR|2_ zQq}rHOO}%Cq@vG|BF5PwO+2~|E$k@No#^U5ehjl-k)ww@j_*Hm>}fV(R6#v`67Y^` znjZM}#GuepLuxG^qs zwd0{XXrmxTGp(Pj+=FHsT!JRk*qm!@p3w5a_G?FS!F^fz)Qz>1$9l4#c`keK6k=JV z9Xyo_o|=;B@0O9mqj?tP;hP8_)Y*6nr<3xjSsKudPrU(UEDeY?8vrHk>y{7FuGvs? zAxLG(`|!;Nifz#V>?V_DfM9ljN{9fXffPY7n?`I34I>AKV0uF>Rdmm{+nM>|ssR8y$+Hf0?OCqFZMjs?jbzWs_W{4;08$Qh&A%CiLsJPdh&hZSDmj=0Xl&v&3HCT?6eg5oj)lk$P- zZII*)>%;>1x@PLc*xWa`=)eHS?8b!RR1wO{3?^?Vl! zNpE<1oqf>2J&*1I10avxl(ErXe%bl@0yhRnY@p;l?-=uCeA?TyLv+QP@u5ZgFi7&6 zEm@IMwfxy6$!=QNxFZHhb(ZpNCX+LwGX$^~(?)ia%O)pUq&x?&Xm3iH-g0HduaswG zy^AeNEivkEDamH4Ia6lH0ppA=HUOaR8}q*YXKraUK-NIL&N0sgG_v#$o=QS<#_e3R z5Udz5O*`@W1%F}VHcodL%E?SCV??nZ(=&O6!dDOh>Mb%PQR-tv3DREA0++@N$A#^LRyk( z+F<<>8XShQEfGVpq1a%YY2XH1M8*eIWEY|BXl+HqW&I@N%rD@*73xc6)t-*-&d7;l zhaXh%M%JxY&o?a*FSr$2T%gRo5b*eg{n{UA_(X<0rHo@l?e)e8`zUwzUR#?AlceIQvv$B72pXxCTNb5c`R=R zCs_f4Nd#^N_S}ViOzYnlmhaIB#DCYhXrIH|6_(W#h^e0?LV{j>mdFT^SBd-*kzXg$ zOJog^RYYP2IgEg|6omAGn?G_pJ)d=aBs)FNGkVZ*Pp%Oqy`M` z&|09Uhbm8lfr-ZS^sKNI#xoV^3R=+0Y23Sl3yUJl5o(F($Q<|n#!c@v<4_#wavG7L z3)9~@y?5hKgl5MfWt?`#W*Etd?$gvQ8CpPxWX=&<^@Q*0_qr-N7_w8b>qN&qDFJ2s zZ6M(oG^Arbr0%{9g}A$txWNb`H`a>Im$BrjIc)<3uNR>fI;aB#Wz-&31li+ zESRZKU6~*>mj8Bk;0qgzicAo8US(8Lrt*wS*wOe?BnH`GXR}q*A)DaZPD3T$7dm8< z)5bols6+P8!E0*J%I96v21}VXH8p=VT-|1$jd<9|8+2gWL)u?=>my}d1|>DhQwVpN_)pcUT>O(sqRbxd<2J7Fv*p)t8|?x{^|1J^Zn$(XSH0?@_qJR)B{$^chC5DY#qN(??lS)!zf`t> zTYzVmG+phzw&vY)lk$!aDjVNfKXE3%V)JC>mW!?*25a89@Y;ntlCx~Xmq(@+HRTpH zvB*R}JH2)xyQpcRKOfxAqSw3s@R{F#h6Qg27T<9p*X>4_mgD^U&miT}lK}F&{fr^Y z^6d8Cm7G-@SjrD8TJLzkipXw1B6mHwa-VMf5s@E1(wcju^<&~N@`C9;#rgGc>y?F; z=%Uu#A`~c9)v_{hAC$4C^=ol6jpsAc7Fqc0%KF$N)rTxRl+R2nDyzPk@v+A;?%BG1 zdRo;EOL=zpL|u1xI4n$hWsSt5yYBAfoR-_Vq>3f?>HQa_EnQoh=D{m3sa{rpxh&aI zgT~feBY*AY6*KEBx~A^{0?*{RL1u^Wuh4a!0K|ab{4jwtI0Ue}D!>>tQ$iorFQ%)F z+%Vsz;VnCij&U;qd8eJ0DJ7t@-L$#lHS4Fq!%C4Xao?ExbFO}y2@XJW0c$g6t-Yj; zIxms_3ZU6gWQOJq*6d48EB< zx|PP;v6Ms_fRe*jM@MGlG@(w=&4VFakm*n#_8Vh5(s_JiRqPw9{`ogn^3zD>m-GMs zH#RD*l-eZCaRD0Plad4f-gLg69C4kLE;zz9U1}d*&VEeC`pC}@AVnp~!@;{xSE!^H zSh2gIW?^m+q%wWVn39brn7S0Gmt$lEOvug9xQ#IgP-Y9$DEr5NzSVEy?r^0W>4jb7 z4yp;$w9?cro6044yRF!m1L&L~*$wG3p%zmA1u~#&M~tdjw7EY?^}@9UFDyWd`~cOH zRAn!7lhjnGm!R?`qtrO2tx39aL`+hHJ)8v_@1wL1B72DJCDKV`KL{qR4^TKnWPr$b ziM&FDAfY-;klw*w{f??MHA ziI|$=-q*t$kWcs`ZiO$R7VCvO0;ZXUhG0m6i5zIBL&@RP&@j_x$D)HRMXtvLFI`lF z9cZrB$x?HoU2&LUveZ$WmxWGv%wa-U(?lMyRR_BgbjFy-gl-v~bnEP37WSSlGET7O zwM=PxW;bmp0(vHB7N8f;sUK$f7NBS5pxO75V|ot2EO^&_Vl^UB$&Pu~Xj%@`$XjGj zDRkyW*^=mnYrOhYouI9h}Ov zaZgg{DI(O*o0c|qhN^W8Z0J+q@K+%j9#hgQizGVNew8xORI9d-fd~MGt9>`NPIf<+ zJ$5R4==t2z7xI-6Actiu#@oL#0>72lM=rskQ+Hf>pT|o)P;!(ioKcLRV1h5pWldZ* zg4WC9Fgm3>z!Sy@!qw%HUZ&tg0Jpjwta)>HF1R8qqvv1oPG2sFE}8yr=?OfRXE7cY z6nWn16W5KPxQxK0+`{!TdS{APB}w%Avu^F5qa(tKF%D9a@`D+hTH~s~~ay>_>6``24w3K9{ zsW7J^jpx@zBA; z_OXR{Ao&JiD5XUPFpZ+Z89yDik%0EBzf6UH3j`AYYqx$!zLw6e-jkJ&{PDBzJ&Duh zVMO11GQaEyBCn5RaSk4tlIib#J%Psr%~3Ls<;|eDe3Qx)myO`0d<6I5$>4bJNfE>y zMP3oWt*0xFNeD0jbq3UmyE7KFUF3r$nzq^do3xJ7v_V$WhTZ>LB$YQ(&@sGDYoo#~=k zH=~h3?3pXY?8!>mylYfvdEy1e-OY26 zQg7CGV{)PfWtLKH-lJ(xG6$cjV4nTnCR+CEUR$zK4*YE@mTB~Vf+HR z;3efU3129fA$Tjg)UznBF^ISL=@=&FA+?3z{w6A?{u+qk!c~HH#RRrGM4T@XXQiFBWkELXP^xE}73faa zvr%O@+Wj_#2taV$d!9lUKy1}&eDM-R9gV2N37A{(r_~{^mR@fo?`LABF@*MCKny1j z-4`p^c==TJ(H;4vJFj=Yw>#VY+3cQY^1){>x^J&s`;A>++m)5uzqac)AIIskHvV{i zSvx}1BeZ9OfUflSzMjBig61fh$MR-ST)s&KipxgOdU<@4xNHO`<@WJiJQexJ9~VK? zM`RTNRE+f(UFd7=^icnv-u+D?w~6!;F_lq2pePX<{b+R{WPG{fBe%=b`jOw^IqJCQ zb$K@N9K=WY#i3(p$2}S`Srt-nL3YjLnyh$nRaShUWr_^yA1w>}xl!gZz9zvQT2T)w zb$f3!`KR0mn$xCVkrWSn&x$;gwQpLajSY;3$vycclgYi+FM0m`$)_W|WlOC;5$O%n ze7@d`kD2D1zO?g|%EFSaL@Mikv_L40;=>aG#l>_tWr`bK;$;~x)7$tnes~0$y@~y= z;>WKNpgbn-u;j6OC!*h&gU{@#Ds(MMIcZcf<+|P~kecs15w>$B?}S;bv0kc*_fqO4 zUWs}Oq~L{Re%r?O2fxzHW4{>4~A zhxISi(k+UVF^c{ZBA+9Y0I@geFSt;)N_L;~9XnwC3RLUrGzN&01?*DurUNv zt+2s`ooc0vM*BOE zIr^RFvU^VDgU_Si3D&+b@*4CnJ1-AhdST-6>=O7q?YuaGGu)r1Wcs_MC-9h{IV7ie zEN=!UiJ7O~ly{;S!7l>frrW;!+RK-qUwJeuZ<*)=$p#;tfRbg)luUoO^aLKuvltKG zBz_*W(n)#C^zjO593iD0_Xf~Kd{W~T#e6{{05Jt%ka`5}cKC-MZ5eME#3^~$s*0a%-V zg_u}#KS&|7ebe|@v~S+Fmopv0>11*s7ENey*-_)n>mtIOiF#q9o8CNQ8=o!PZ{S`U zNfj*HW%c(DHo{jY+ps<#XqxDKZ*TUqJ-PPhbZ;H3|5CMpwLCO$tQOQZu>}Nb*NH8; zAiVVH@0OmxV|fFqm zv)%qVny4t(;ryc`0RY5jax?LcJ~(8 zU=BXve*l@Gn^T!)QHt=i?ro5}*ORBJ~g(IJ{ut->;@9R8z_{hnp zJG+iEpJmfL{m8Kckwg0)EWzqhT54E{CowV|8c2=~!u?1e9H-0UiSx;LZ>$*>Fa>9< zjh3_~V;HtrW`Hk^jTJH@aV5Q~)y&SU;q}V2T8d~*qoInmJZ)5)P-$`;gMdDYo(E;+QEBFAb|R{a$&6=Wl?#qxy7uf4chBEO9JpAcOt!7NOEnYz#$ zV6mJ3S)SyL>oS>rQ#{9Ev`h!p8$)8pHFX}QHITx^|$H9-vMEm1-^&BQ-5Q7 ziR)puVM{)+6^13Z8`fNR=K{O#`P}4v2$N~ZGX`J|0(JyLxnL+OAIvU407~KI2lBy# zER3Z|%)eXmLBcMSkT-*q2m-rM>P-|944L8OaOW23W;r|&bUO490&p9|;KK?)yBQkO zd)p9RWHoC-19LDivky4d@Gr5Fg@vrmu!MwqX_^AA-;P&IDbK+tL@`v9 z^L-+#?XJxenQ!0t28yhGZE8C`2kUIi_hysFoDnjGeu;{6rSbAC?_iaYW}auYp`|VW z9kPvmqvQ3%x_x6sSHY1~{}v^x|CPvHkb)zoZpQhFBYl>}kn@+lx|t$;pY#=p&BdK< zWIBX$%Zcue1D%-H|1RB}^xrCpXLB;@O07kcd6F)Dp9tYV)s)-xwhCq9u6)@;4=dXO zJv%7V9wIi99O$)V^vSBkMxRWQ4^u`a&PkH<2`#FyfTQ}tQ+#Uz^3f%S7TwNKVa_{T zz(=qx#W4BnMws_*dhd*qeg1{~E-IqY4`_@_w`ET}J6U#n1jQbb2Oo@c#*UG zvU@IktpxP3ZN)>5Z7bQ>Hux!zZL7rCwpyvNtffcIksW52*)KXX;~Fgg#G5fhEM{eD z&9SZh45hL_exGU#rZsADElt0(G;O{9<@OrV$zHyKae zBz~(QlwdJ*f}dci!pI5|RvgqR0+KuKJpuav7MPRm>sbI!Q|A5y1L$SUI>Jw&-U86e z=M87A4q!HL)`Z;;1<=bapfeIfkOxiVS9ULyk}L1CEMJ|kS6ITOuIYg9i&1yQp6QRM>W-XbvS2UU&P zrOo-OmTRutOIKY>-VQfiKXiR3#ANkB$HyLbMI{5QcOWTie?}l{MG<6Oy1&--{aVld zrNxj{OsD(}-qpj3P7C-HDpI~iU9nOZN?KZ%j`Sxb2ZvVh(8W|Xrn?DTv%}8L6#lt1 z-Ti+#kpll*0T=B+;=B5tnM;CM4?9Jxcvgrwlha1$VhTfy(EW4pnxd;*Om_#A3Jcs7 zqR&#&e3i77CfpsZ3rGv)u_kJZhVArXX~JvGrUF*|GRsAY<20juO{1(aBW-5t?U% zGgib{$*NSr`S^^|bAAQ<2o=}<76i*@da-=wo9B!Lg>|=Rfg3M3jejn`co4Ef zo85hMGVqz7WzFWh4f`uy->>xSuZQ<>d8yUsf27~@Z;0F@@;{0Afg9BdBDC+0T1%vt zE|`{}1&R{+3jS;BL0*+Ua=JZRKMHs~8$YTl^Bi(~w9xI@{869=wxR3%2-W+r6;6%U zvzeDexv5lf=)-@r;f<&?1vb;{|B?d1DA7j=jNp!2^fYsS9N3dOeqD5F{ZvZHF}t%B z?FUGH99RNKzt2@6;IMFzu&jWWhycD<%{4EbCfGLpIu)UZ%{b$dsgt%W-Q!ZquKJBT z+O1EiTV=Xom04?Gtah~YQw&Q@Ay*H1@lX+?AFXCbeiJ{NZ>F^C{)uVKCJnNQe*`|>k?zT zY^WizCWyOVOD1p*K>0eXCL(f^YcQ4%Mn?cp$wW(=Q7sy-E_eW9 zhep(YiCoM86B~jhd(Ra-w36Tmc2LJv?A;c#OsJVNXr!nw_=cjRwC#E+1{a`vMm=kQ z^#|H87R%MNbiJkda9c|K3e8Yy{{{q5Sgv@Z?6tBlpL(-vyfs_15mxn6^0u73?SqC# zzWT`9k4(6-?fY+dvm1|0HXOxH-DTT9#%gAMy#p}zULkw`iPrqm9oP51SCbF!yXgJ^ z{X=S^dJ~u zq%$sy^l&SF>?&OTDpUv-IRabDjQ46l$QT$e*h!NyS#gcg8o6bLbvIUr*l1~^i}B@@ z92k<5eRn10vS?6)^ImML!BT#xq;N#1FXjb<08{>~m`@G>@}N>R?;6!v%D1VJDF>nO zn6)M*Cu&eN2d|KtbMT3NXAVB`h0VbydZ9V^#P_CDU-q$O$I6u&rPg9=zuNdMEF}@_ zDLFDT+1fLUIRfPbgTkjxfg3Xb1#I-OeK_M<}YFYl~&>_T|jv( zUuoSZ_Du+EG3PfSIPp!)_5D;BQo{0dHebADu(eQEmIakXN_{36=R1p6rRMwFR2g?P z&br4Q**;e?KZu4dbrGT3jrbvWSL;lkJFYH9S{PlW7FS?bapnwmIAu1@>La-DAql>s z^=;@!t))3CHv(@-qv&S47*Ixc>xgJ19CdRJ`LTiM;AthgJI#hMIw<75n%*bFL^`s0 z!-fddbcqP->0*l3@)+M!d^ZBT0Gi`u_xrqbeIM&at~k{YZdJg(Rx#`>-VX-Y+`L5U zH5-RQ8%IL8axfo5=;fM17L>sJ#oPdVlT$;^PkD zJ1Ai%5vIA@O`#-_uY(k?E(Yr$^|z3Uns1!NI#akB(jPKeNWs=y%@sqJ)D`f<`cxyL1Yh+y+rmA=_Ilrq~JFz zb*0*3{CVOn!CO(N4New(tUh+HS~uc;8W#4gQ^L{r8RyLxOhcZ&Q1v6_lVs<1JV z7iX&}`)pqocUPJBM@SPv+Hd1&fVZmZ-YWZ2+2vE0x+Yq)i+AL!c1~42k*j(_;H`W^ z)799w5?2z}yKlH}DDPz^TaIVzPXK#WcR21z&T9Wf&z$hYx zmdRiTV%cEFR1hYkfMQ25Hm8JxH}>W%j`%D=xX1GVIpSa7L2|^`gMtZe_HRP1N#6Ci zYy{zmKT*w7!Eg<;;>3U;)xiwI%2ZFkv6lx+C~kf`xL_){EEimsl~;{phrdL6-j!8s9vFux_-I!Dj{vx6F|1ZpI!HPb_rdJ=Y6p7hf~jkP7H@sBL`v_p-wSI9pq z)M$Ahj~c&=c~%w~s8Fd-BUx$qR1oH39bq=%76qTa{%0X@A`!*DleF($Q!DD+l_XZgfqtK4sj@}FXL!LNmu!ITRe1t zMcD#t;e|g9pr&p^H4D|SWF(Xo;q@5Q&Z^sy&Q{hGK*|VkK*i1froTkKy4oCX+Chb1 z0hteYy0-m#BD?YF$%d|<<0X!lItVXyn0N`$tK*iQfS~m+CwCBDqO`mj#4@?9_zL(sYZG<9<`W2cAH*PB{;=;1J8)2vm7UyHeVbAa zZ5s`GaloZWn5x(HdNxD6T5CUgRPfUUC>yJHn)_FrN!6YSq&mS}{= zsxnpB3$99YD+|Y}Gt~&o{o9PI=6S3J{>(MTH#1fX4PGq_nk8FG zBn?!_k*QRc7&0g>F>UZ7OAZ^It>Qx9B#BFi2_E`Tz&v9+EmcoCsmU6Lx_3PaEQr*IlSLsl-~+ z)nX+YjvC?gK|)B~4G@)Xc&0C|4Pjf&=l~p5lu_8g48*}P9Zml=<+(-VeInG4smo9V zw_6E2((0$}mDq9kSZBw+lM!sx)_vmX&V92uC>DJB^Ax1?WAoSCLiK5E>d?{`C#7iE zRibN6%TyfG&wia=0R!y1HRd69$+X676Z&Eb8QZQ2jd7c~_FNm?nJtZ>FEi(6W}0k+ zh|P>{WpsP-R%g&KQ}V{P@UnR${yM5P)6cR&$VrNRp9p#8sRU-!l|=rYh@Z%dMCPl# zb<=xO-$oWJg@kqO6H1}d7$VTR9?7BbVvACj^tsHpe<(@$evL(G#+=QGf0yojnFv$Y z4pT@Ya)Zd95P5}&jT*RUL$obLv2A?m=kt_v2fpAUW$`9!3>(Z@6v-PmYJ|#+!OZkC zAKt8Dv+nx$2jzM~gQb`vOjwye>~_9ganV?3jvVEvskRl(j@et4RT}g3*%j^C8u)c? zo|1Ruuy2|%QGacFcK6Bb#-7QB=V+pSr`<$-?XqlWW4^Ziq70+gOV5sL6E9rv&Ng-A zgL^P%U%U9yrisr?*0$nuiA@(Vk4y$O6x(q3lWVG;%LpPDZdt;L=q>s2+rj#q@{UX0 zUp@IYw&U1w>Eu^mc>4tk5PAEB2^FUa6>oB?aa*o&8)BgO#vKS;9L?e!+<`Z3{R8i% zZk~`ggV@9F5;oo-PP*R*g+qjK~Q8enr!_z6s zr>!%%S8f2`?)T~{x?HY5b9uTdihUfYp+i;TxOdI{(;t$N6E2*0UCb%Wd9};{5ioPY zM^DF$!!vV3V2e4GA|{ik4bQwvh5hi%&h#NAJO_+=U=Zdpou(|qF}G5AHE7hkXlPct z@`T53Xx6`+Kc|%%@K&j6Kr(rMDAg?F!-mM%hb<#BgN(9F8J624kyeu@6{jsF;becSaizM(_prCsV@;v-XO5KyRs>0Vrk zrpUyPt>sYg!Hk>I(%9BlA!F}824usof}0>R?TwYf8t(;nG?h}ZW|BKKI)qe~Su52l zPtrXcBPIYYco-wY(=!?Z7Rm^Gk48@q#P%~oZvf^R0T(sqyhC&Le~;2X#Ehc!5IS5F z(VaxbQZUC*9yQ^DFO`faahPD3SZ*I>=p?cq1iNk=pm2x?Av3iLEs}vU?zp&`qOZ^e zhR4_xd^bhE2*UPevVtNGhgLE;#9k3R%vCa%j1Wd-Yd!K8ucce3*(T1OfecHuZ-dNj zvUt0y_VyziZa><7J)P~2=9Zt%hI-+$xG+X8i;F7>8^MUt3nRu#X2ot8F%mY~kgsjC z$3~@i=<+k;$FIFKxpe#WZIgk092V8oy%qjac&cV)u4ZMvX7%{lTusYG-))#ej^={P zvvTWr4@fq+e7q+YY@L$n@0OmxV|f03{=uV9#eUFjD=h@@1TnTt~>Nya) z(d3xNZH-& z_rKTux@XWBK#JsYvg#WA`1|#{`gs5K{`bE>uBoYX;OGuUdXuXij=!Q4<8X6Cx}TOD zj%yCZamJxYiZdbgInOvH!d(g188`dy>GMXB*5^AT>sKnyRN#s`QQ22@rmC;{Om$z) znVP;iXXfx=Sj@#n}240--0s>B!|;+z@d0A zITYWkPTrR@3t5_svYvT9LMtrPU#= zUZgE!X>*Y_PoynpY4ee`K%@m(+Cro)5@{<~+G3c4&`Xa&DGXrW&(_&h1AfiV4VyT!4 zEQNplTJTtN6q)UHzpk9LKR9p2Ry>QjQh|LZNmXe5!)P-C1x zESN|}l*Y5Mh#K!d7gSNU( zP-B08G8H@<6P;5~Q#678rQ#Q2iJ^_bbFuyyohAEm<(wKpL#Qy73=YK9#%QvyFVe3B zwSibP9!WGtBN}yvlBkC#&&Lyqby`q~cXtQ1RB}K=QP8}YF*p#%<4E?$jP3`~Rs?;HBmkmGRSD|%qxHlG24ZM@Q0 zu|8{E4V49X*FgM$nj9P`RPRb1jzzT8(f-)c?t;8KsSG_H?^g=)KK^ginysvBTv7-mOPMs`NpNpl!#+!)p5yK(AA3CJ_A;4>nnB$Cu;p|kTGcLt>$$>%a z0`w?uK(FEf^wGdOBMR5BI5wABAO(lldb4}vw2x_rZ5J-0- za!w0H^|o0De1IUvs3yHAQ+=$45Y7Mx5syBd%eoS0DDQtPV!dR|Q1pCFh98 z=@@nodqhd^uy@2a?A6?gXG9*BfjftN7aSuM!xdL0ZL{K4d?}AnSFhK=%>S?I=mm#1 zFI8cr_c9V`obms|qQ9y&XLWHdIx}(SxO)o2roRR}iaD!5zRF<#%IoT)?msrhJ)6`sL!hHLaYnUk7lv}LJlzL94B zAD&~>W&Hml#?>>@h!cMm`19eYC9~1IP|4 zXl4c4)BXsUj+$Z}YDRM`dFzd8RYpw(59>^!`q6=*@Y%t5A_ekW@Fqa!5?Y}WsYIH? z5w)-|to0-Z6G~W(4XELBiR9TxBHRx?R;UPT5#lHVYQY-;Nd`fk6Yfr`eUVi7Y%-Zh z4isv`#JpHh@C*>gSYggC)-5oWeX5#N3-v}3@X17^pHbNcf5ENA65Z+|>S`7Bk>AWF z>q)EiWQ+BrgWne~(_B<&bJ3;E)>19}62F7y;xW^wxN}mQ5Os;b8Ic{?4YJ zWM8bQ2VA@9Q()HG09aSknfQQ~ibT&h4fbm(6{NZ`nus^ilh+sw_a&8BA~Y~msAaE1 zjrGQ&DGg6I)st?RhV+M~WL=I9Y5x=8dB>a8OGlHr>dS+23}pPP=z{-vUM~7&sV<(DGto()KAwukP_K!v zKO|~ES+v1VSv%rN34-d-K2NmbCE0L#A9w30;%`LV8GrjMAVg+qL;@#sf}^=iUN`JA zsKtnT*lo+>JaNZ}7u?M=EGRg*nGame2kzz~js{#BmciXBhbzI|qTq?1S#!5V7P@A` z-BNU8>@a$&T1(ha)u|=G?L3xNY|5C=VTODk8&{m6U7vCHiuWb2&f}c6+|$D2@P;-h zK1D8H_j46S-8#>y9=5N!+Q>H}_jH;nk=KRoV198;@me<0WRwAD#&W=bAxd;rl|G! zTGfRB=_un`;DkEE1g3LGTP_No#WMmixhP|I#O*3aiWqA1W|qddTZ30!OszgdU`;kDszCrd?%Dz-5ALe2)N5&JF5<2&Xqu{R*{rUi8UkuF{;4el zHWO$i&_-YjKoJMh*^JJrTGjT_4DISxD!4-=e}y0avyrHEyC74xBBP<@0- ztt{dZ{V}L6F*SU#C)N*M&EH#k+pPGDZP`_5L-RpmdERl?zX+87rXO^Da#_<&e{R{M z#6OljnqRg*yKF!3TPgt1TWW3@V>%3Gmsyz3?pMxJz8I#f|UJHG) z;X)Q|Y>|bt*>IAn+z0&1)b!@yIg}hzOMqW_%a<`Vxf${a4P%CULJpWApLp^!<7QwwLEd7d0nwA7vT(8uL{CC~Mz&j~l5J36Q1W10<9Z4xDWl*w ze5A^3BG5vhmB1DP9gKi)Qum^R?-%JfU?=K6>UJfoLS0Mu(LjJqE9yD|>j^weU;}|B z0-FF(hG@1`9cbpX*rI*3SueL+PqwNDsWj83bW+}2q$9L40K;5j^*8{;(B~M{1H@4Pk$zaK5|~KP!n#PB-cgo(&QO~8hW32`BBiYv`BUSA0HCLt6|Lig zlm2};%lP-@{f}q;j{|>WHvrH#cIW&@>DtLpu^dMU@qa2W)9((Ip%i{9V3yU)PjeQ0 z68TrO^7IMnfKIW#%_V4B@>_0B)^0XO(m~lCLjPJJN-<+CD_mH$m(8) zAsvDwi@`rEBrhVz+P|imf)usHj$ZoIN)DqISo{~I=rdqPubqtPHm({&5HhA&!e;oV zrj&q;=`LT!yn`9?3E5HcyyRk}#cd%bX20yDHbD-JJ29-*Y|e!wvyuZAEXwW)4%*A3JG!%P=S#66nJNn|G^h@`NhCS|(FoK31 zPvOlKYj`Q4)fr;SFH6@X$??K`w`0ihZP!KVhSUWXQ5n}@nP;=gTrhSPoS{w3Fs@M* zhDKkpgJx_o)x0ykuPoi88C5Y886L#EbU2<0?ckRGogq;T$;=uVQ2vMG@1J*M>P}=k zkB>`V+cp}RT+x!R-;C4u?$a>(;^HOJ`y08`%Y^tD6#($P{-GPLQpQ3mhq{0OnT*~) zWU10d%@BOiWQ$;7p$m~jT#2M&`ZNpYbOqU%dcEN;ZI#&BjVhIl_Jyiwk{RML(XFW| z>WQf|Fh#;fb12A0lQ(>v$z7y^g41y|fg5ew7~8X^v}-grbq2@vfyIou*}kT9%Ljk8 z8&SXZLjV~6>z8MO&AIxP%kuZ?PK-a9?>s)yc|2ElA|s!8LBlJ~`3cSXPvm9ucbhKq zgq(>^$|q2OCrz6Xe$9OIlkv^eAn&toRHp? zC4yebbCkg+rTbo&X9q7%C8PKg2hHcT1_G2xW;6FDQ zfoDYIT!e@O958}|{g}LjZwVYE;IffY2czWC5ln(yv8lDoeJ1`FuiT zn4w+47H7yOSRlj~!vqBp61#OLzhQ9$b8feTKxq2!SGF%PJTr|n0eU;DrENRThdtVsN zENRU7LqIeBP~P8~^+TK9dlQ1jUZ6K2b94Z`d1_79B<10-w?dzk>Sj(oGyUZvwMW;wFyY?78L3v^_@C zSGcLR$8vu1pLpXGK*oO%|BV@|J9CgE@)5nKv96!sNKG>bTo)1kUs;l`58>=RRmEjhBfzxeL11uz)*nqct`ww;>IJ&Ybm3=!=f@63-(2@aDc4>poIjqbRiYI2$v#$xmb}U`!Q2wu^I&Z zFqK#=bYY$nuel97+lXXux8}ZpB{o#@=<|>#`+3Y9!?VJx-L?qBcGH36M*)8YDr1=r zw0f75(p_@As-H9Oc2nJ(i8h-#%POR>!CJlR?edzmxqO`_Z7!eh1FHnEV0)OY+(#Dc zVV^z?CtEDHW{Y-~bK&!D7%dFTxy4J(%PhE6%sWj>4yw`E`*Sb_*Qmq>63L-Hd|>Rn z$vlS(gfGfIRZ;|;ot-zN^br!yXK#^ACByX978xXio!n8J+_F7v&BzUponewWuN8Ya zz!i;ov%zDb0$)wRr`JDgPlpO_rTkSCHm#I=K90GlJx#STPX^u7Y?HbTNgB1L8!8Q> z^MVvPiDR77AP&P8-migA#p9+|jcVEWz8D+_t z=p@h)o;tx=FmV#QDL19J=#}#n0nz$YnR*6Fbk6yqDG^rHd_sxvov`EMGfw<;DUq98 z{ITK$vSq=OikwZv)UV?P)o&2^6#{Iaev_~<0_4f6zD(d-0CuBtIUALbSmXU+4b%R# zzfPYtCvPdhrNFLZz!BR0iHY4_NL>lZU?+b07ZmN6O{eIg@=u8fR6k@Lfy zD0JNmknyj+?#=YLkYQFERC((TsYHz$gLw#$i;eW5zD>WZ_H^b9OhluQhWxNzW;s2xJZ@ za7&>XJ2p59m7GHpBeHP84RpPU06F+I=#z77G?A3c-B3tDP)7)S9$>0!@(>$KtoQJY z<}!o9@X}4wjwAa5k5dz*4AB@`l3CiG3v}cIJF|hEt$FAAmH3PSOzeVCKu|X}S;an8fu2${59;I56DkknWS{X4Mn0lGQcj zY;Y9<>|wT^hz-Odj1Cs5(_*6PF(%)Ch^YeS)QPHLPp_MGs|YYD|F1t%{}fTbF>Ge^ zPnl9;7XB&1Yy78dhcRXSDx=%B6I#MvGeiFbkr5M6K8qG$wqd6Q2)91tNn6&f6nQ-} zk7sX}l~|cle@GqdFL=lxp{0MpLVcQr(*a?+=N)-N>mGW*rgz7i(Jdmx6aigN;C|7e zzCoM6%mkRUQ8sFrg)74ic@|!JMXJGOjl^9;E8zYMb&O;h z&4y{Id3sgp;MSD^{q9(%0RSC8 z#~hb&ZGYa61bE#M%**EQHdVqCawa;7`j)_SLir|m-6q~C(#OvUlsPJk>}LHu10ZVG zX{)=Gwtfe9R7y;LlhOzr!ykKummK$B@ zY!>p89g&4mrbI&ewng@!ud3vLpEXHH9+Ij|2?+~BOoCuzE_qe2g&9^%DalT@5#$rj zZD!6uNjGp>ulj5ky)qrvz^A5qCc z9v<3|Qu)v4dBVAm%d)=IY@duRa~N^5Szkz*1M!~0vxZM!p#si*Je)_tO{gKQwv)6f z_<+d<$oZ^WA9-O!eCduqgLN&sNK4+-BzYD&y^)O__9krtW&cFkaKd*FSF#+RDV%qHFxu^X%09~AJ%mqUEKx;P8dh;{6z%E^!UY-4@ zRMDA~PAINqt@`|x&u8SNS3aMYmx|wQc`2#4l#nygNuWj9a5*b4)j5f$l#_gkUci4P zaEX9vz5iW0B|zT+o6aZMeCK_)%d?DUql@+oWg)Iu84AhcJnjr*DLlT+mSd4r&oK&Z zvp3cq0n^rsgmb|q+@SOyTC29@5-DL~w{sEa90V(MNW=a~c}@WTGU6ZZ68_;yk*vgy zUK?6p#6L_t3I9X*77828w=M11$O>3) zPhSX@`xmX``e*ZnSnkg5eF*NS)ac6+WMz)xxA^tV_I*sZw3HMqH(T8l=u&?R@1x*N zs^?&LG*&bnD#JU;0X>EFJmz9hn<*CPP@!YHPCW*7h%qKI3#epKDKZj4cVY{a(zOO_G+I-pUy7R5H&w zaJiz?H*FQnJ5z01&*TD+apN#!iJMW5fIyIk@nU<^Y> zwqf04|G9~83F9cnvkvz}u;?~vu$FXdS!Oa#DFrPVo?5nZBj1yETpr}g)ef)^dB>H{ zkFCkCY|XBOKk<%n_z&;MtZc>ql{@ls(Jx0Od71^CL@M}*o-<4J@v{lNB5{o)$bt4) ze$-;1s0FQxg5&OzQq!HHis{rm(rDF50+$GwYTg@kN`TaHjRe?Bj(43d&(`~uE>9=V zNS8m9qUUEXt^Kerg-xU2wQ@5c8sW!Y$CsrqJEN{1N7VV$a$4hP?_0Ik@f64*TSjEV zsJ`NQA>ekru)>YVq9YjIrn+IL^jWTws8w=UuG)zBma8`8xyMZ`5RS9k>KM6|1v6Lnj|e{Y*>sLt4DFAguvZvqxKXr*;?Z^d+ms&V25C`W=^}-MNTMi zSb?oGPsEjl$C_6s(!LatE_PUITV|x0|IHu@r9_(itSIPo)HUK9kuExJ_`1k_Y6rPb ziRs=Qe7ctf&%!!XMW6JgATOJX>|$g+quWMcEqZGOFPkXQ)IY`v7q>mfPwzYN>4JOr z(Y>Em|Ag|m_a8lR#3Fz*nrRYKMn_GW$&FnFDOQjM)HkVGMlWf0*eY(fsw8RK%z3at z#uR@bY^VVm3wjyT;BrAu9eOMmb9`~hx=e}l)Y1gGxR-j|Pzer~+^^^Wg$ z+>Bh6u2p`ua=hbe<=BzS?yP_Bn}MbIz^ZIu)hkcs*R*HXw10PLe(U4et&g({E3<)> z@3^GE>USJc#YTX@>bvVU+(XL72bA*G;tkow8}7R-7a!2cJ$7;BhL;;yX_l9#t=aJM z6D-TSEKB`{Z2g9@R7oK{ffZxmKFhptLw4Z?mh~re{@#6;Bd`yFs&KErxO(qguf2p^ zZ9{sZPDXf$v`j;IzTza(LLZ^I|{iR>{MSb zZ^r>qAu9xCIfk7UqGH;9YzUFX*5Wcl{Mp(0i-?L@rw#cry-Pq;%$#%^zS`wt%8H*2z>`;5GedP}H+`Ztuur~z3&bs33EM{}8U zL_G?hN0f1m7TGIlzI>{b0F>k!Kwa7$01WydxjDXEGun*7?)|EFtmZP-MupxCEWB3p zV$Dm>d~@XMBd_)3Lyu-dk0LQ2Se?cA#*ki1gFHloeC^$aMjGTH8hoL*mNaIUG}0g! z7ikcNXh4VFTHW~aK~|dO#=JHLo!{ukrfal0td{E5-l~^3Yq08?%cW zSyme4q4(a!Aa7!0{7U^^$=%g3#k@_lPW@W~mk5}04?Pc+Krj9*a?eJRd$^MqyKFD_ ztVAKJf!i=|Tf?d|$8^5Px??X3>aM`z9qmbPJJx|2RM>L%Jci}e6zyGumwK`u6#mDrYx1%<#jT5KUF_jH6FAX#rK;0D%zsMQmVpI%0@7K2VW;Ry} zK=N&KcCOb(Ac3`&M5r1nV^TY1&Ib^Ugw}5>8VTA2DrV-iqdOuesMwqt@(M9(hJ3;` zM)B(!w^s=$wH8qeLNh-sH3f|{^Z&G>76oAS;F&vn=|e9$@IK)3&O$^A8v#W%>s?)v z5vLTmSo>p3FV;@YqtqFKo>HI2<$p2nNjx3P{o8#b!kRQgK4BqO=IYBDDpRw4)56+f zJZs(Z!QM39wv0D5-P^ACAaA?cF;X>LHRC&4!)Z3wFn235B++!FB}{HHBuRB8l{Q(LrNz{>RwKxhgSV)eTUP zu@TTIrpl5*o+-Obzg7P`of0Eg-y^_`gCWA!5m--v>9|bqZK6{$4)R$u_FdE(`!4V& z->03^Er-y*2UF_*q1?rOA57e3(@d7=eSb#zm^JYgVAB~4A7&yk!qH&E0}){w`%a|7 z=VLJ>(WjyBos;flmZ%~*j4R8tDn3NL_*;q1N6ndkw|3E3{q@u1iH` zWTY5s@~zdAfdjPcsQZ?v~ah6;kE5AB5YIF=mmfmw~t=P)vu+fvGr^7^-bCOCWbd<{Dfrv zO$gyiA!M7ocq~@|5j6H{8$Yw42=#gu(VQqVLdK3>5GYk|BxmZW){-9r(lT|S$Z$YH zG84IFh}IO^2sX(3WGFD#h7L>La461S5rSWF2w7ZnS*}eJL>3lMx5W$r1>a79*Ub=I zP!pm)dZ-^Cs1`58J*+Z4|8uw-1X$9(Xo~rE_EP%@-LOwn(YHyx|ETx( zdvk5af3Npe?aj#VbpKZOtzLey?KrMy+Ky-Ik3XQ?f9L+8*S z1-iXizh|5G_v-8SJW^}~6}0R%2n8R;eDIq0RTv)V0R7WddvatpL#$zixS#`eW=t3r zbh>5yXGQ=rY?K^OI(<{u+K7D#SWq~uqBrL$Y)p!Xcuy1Yl_deWsy={m)2x3c0d_Szw0xOtrLpeI^yI*40g!?fYWx8Zg@#9ihY+RNA6^ z8at(7${n+OANq2qUu)_0&-k0S^fXxV zG2?Wq3CH39_z{04P*>-6v7IDTNL~*0e*>hQP0y%527*0$NF`MnOJX|A`*cdmsQM28 z*i(*$uEfTk?i9A$RDThPV6sPbHu$0SLE3A=(Bdfmf(!?9gdM?7Mc<^h#|Y4aFq{@2 z|0o)yI;b)C*@zZPucE~^d@-r9xoO{^#x`Uf#2%}IV5&;GwutdF_mWyuGgSXCb&>ot zSQrSk3K-3Q9zD!;yxA&*BqtcsK?Mm0*d%!RxDYBSc|071opivrvaoD~8s_@~8UZ0} zlscQtUq%~R0^NX1p?|^X$?LA02X3u?bIIyFo58rL{Rnd!J1)E5u3t8qdgb)>j_)?S z9{Nsba?Qb9{h`Y;0`G7)kA{39| zd8nfn&%+5BmGcZhQH8*-r%LMLr+5AJUrt}aHby5#*8^NhkFL+rPI@QC&?!XEU4{St z4LqDK0`9T|ekNd6K5-e9Q_Nk&;Wbdi;iX-OC>0TRN7oCKZIq`ckLl%bG1EwCB|o&t z6sYNz221o!xCvAdxI`bpE&`7bAaRX>3qZuu;Jxpxwlj_O|ZrVyf$;Y|l6D#r!|w!#Xy29F9QqA4c3-8+avz3V)Y+ z9?!B+*B4W;@kbag?1*?B*5c_{x`yJZbRs}sIGTjP@Egbnp`o0+KUh>E4kea$zbmq1 zb4L0)YA~(XD)qh~J(KR&4c3Mv@zJdUH^RvmEd*!UK18PJ%-3u$mZH^v)z1b;x-9})-?c$z?jz*z#@0N4b*X*Mtn2i#>8W|ct-vlUa| zFiIPuW8q*Fvd;GM6rS6Erzt#2jcx%K(N`dZa)G9Npe-9{%WOH63p}o`1LWTRtz+Sg z+zMy^u`pt`=4JZb))V-dfLXbf%%_wiXQGoVGf$q7QJu&y5afQx>sZl}kq>=s2M@Rp zG*gdV6SimTVLv@I{=}`5Zc3-VQ~?q8!S0W z_69p!o`+z~A?*$3rv1b$-VL)~Q8v>`KG8daZqB%pZ%PSpo|)Dl3Psp*w{}!qaON5E z3CUrGd?qI&?S>t8J|q@nb@_azndG0_T1s>6p6{g!+uh0*7*ELZOzjL`(|H%r#=ge7 z3d7K&?^cF<q&!#<#`{fHspEFaUnI@n&OK`B3An>XY$id=2#9^bWOr8+POOlkuiM zHd8i+Zu3(gZ#*8wGMgzV=Doouoz$P8uZD^0TV!7P=?5nwvz%oClvuCZY4SSK zM~c>c(Vp4dl;^@jo2}Gda8p&Y+GCNg@8Y^ka~t1mF-K4OFo6ALi+uSo`}DAsTMHwQ zUzj$?ViNq|Ad>Xyz9~W+7u(B~e3@FU0}r8?PdhSlm|{Nd$Wff9aK_)6_wUL2_W-}O z7Xaw3y=;#KTsxTe!^NNr$&)8OlRpv8o`7JF^sWpLKcsT{-7!-5seoB(h@a(5bP~Cj zK-8v^6TE#B-y_m*?G-3>!^{G=1if93=zU{}XomX9aBAm7QBG0twj92@1UDa{=g^t+ z?*y9h@67wLR|XzIH~O&m9`r{6jyQ0j(}%(k5@Y6$(IzOf8wM&Q)WlrHgX z=f%7n+*}**195RMWG3?f*zqQ8_xJ zxB-2U{>ZtQ+VpHHnM{B_J~r+GrlfAXTdTtY3-@2qWmth(_!%rF?z|O9QMmrtGniOs9=DplD6Nju^Ky8zESeiU>BmW)COL*SGaSW)x-weWvs_}c61v;LO6OusvH5efWEz^vRt z;s)i&nW(rtA-9Y|ywEQj=p=85B_jvw6$Lng){T}>tAScGp*u{PC^9ONKNKTy4|4*C zQ-17qT$P@}u$XR1sbw)J)lqT`N~-k23M}H3?0hkMg@MW}W(F7M!NW1(B)zbEn)0?{ zi1F#8<fw4NaI<3q^2MK zBaZa(U?LJdkE^Biz&{?Ys#tPJlT3Q7MBud125T+gLK9C05!#j4{z$sL$aiF# zr4mzWf|7m_&q4h?fUbsGwzQTz=nu-6RI(#(GZ!Xu zr;&-}dB+bHt{JVl{1~~F!1e!?j{K_DEPnphyMcvw{dHG+uJygxm!IE|o!@YMRc?Oh zx|*HeGU0C-?|8lQJDvIM$FtjyXHJ~Hz5R*13zp{>G-nqyzvFP#*5Ut;=>G$+L#oR< zYT;qJFqmJkA-iBhWmR&f5cTQe-poR@YtG^1z3Lcd78-d4K45$A^Ls=)ZbiPo< zR@}s}v?Co;mk~!=Pv8<2+(m%-h&)7T1pY0OG-6j2qVmb_d#ZlnGhg^j-m@g@S#noi z@UF9}qVm3Hj@Nr{ndI4ge>t#s80M|;)baYLmMAI^2hQmz<3PfrST?sHxIBhh>?A2J zsd%(_mWdpi7(@6Z#5_GtJ5L8w8nsxGZRBT*uYzEBw)L43Yx#UaVx6H~(_yxJedbJ% zvB&Yp7h+z04NtRBtjVTS_(Q`~emLd1?2Ho|rb4x5>t zKHe`-wmG24UepB71Jj^RZl}z_Hxtz?p`?wJM&OT(r_5u=Kdsxk=#o8oq*rCNb(x`l zt+5c8-O9)8R`Y0X{zU8?Oi}94K?pAA)KYgl8fJ`WUUf4w0Z#crk)fh6kRdJ<2*h-< z^owSA7NN45#AC!|x(!)UEo!hCO$qUuuz@@?Iv|n}j1Kq9tG1BPX`em{vtr$+&t(Zq zXG7Rt)$fiug+WK)hn;(zsU;Y4B#aJb2^;x)Y6;!wAY^_!5^CwuJWKsHPG{y5gsfe* zCuUA&bSR%!kZ>4mX4_Nild$RO)F<&FpPlC;>ygm(X8ks&lwjr#YhcUr-~m@W*)x<7M|N1;tx>acTO5Uz;ETd){=$XUTH z;*PNpL0Ii)n11@nm@}iD#|YO?&i33QM3`<8g1v~DgkZkm*MR7kFyF~8!7zjc=lB&| zi5PZ}A(OmTaA}y0lx=N8PlA9j0}(R_v1JG81l(ggvy8x~X& z%eLuDQr8k=Xd}>0pn=jt1ndk-q?lQj2kLu5zD=p*O2@5AZS0f)voCEUj980C-v%jU z?BH4%-!PutaCopkj$P-{>ulKv=jGOne@azuM5_pTw7xAD*m9$0todr~wWVKOdcEed zCllE6Ce*C9e+BRP1FzJNo&MHhATOO9Tl@{M?E{z7qhQ?|`A%1N__=l4E8toA0Bzz+ z`(|OIECUspuwfQ0N7gSJD7MQY$9hT?Ki00u`O-;ofg`Bsm3nd6Kt(289y={A8z|Zt zTP(_tLO{^l%Y@8^MI!o7p_FRd^-xL&U2c1;)}Pr~wLUXFCnjeeScn7{whNMGZ*?m*r0E@|qSRBLH zT8MZM=K1)NOOAiibM;uBtM~uc=L*Z)XK6imgRd*?$J%HzhXwU$CF#c57`pV#R`XW6 z&Zr49Y+=3>E4^tpm6`cL>8m;E{Rg~s)8NG^u^rVFDpH}+tVUf2(pO^R(l<)E5L1WX zB&`f2Vb}|!i0(YiL+nnG%sFX|O;;+5UTWm*@x0?L-KJIlP`-XuwtiKn`UqgY`pBK? zBiH5p`W@NzJ2LA!$HSR5N04g$<*JWd?cfO(bh7$LhE-tAidyed3v06o6H$ExI`R?Kbf8m;wo%^%(W33P3b|Q9JL#!R$^!42bCmKQprJ75@DNcXd$jR ze?`b$ii?eNw;6WLPP-_gpFX3hUK_2VNKQ0!+R-&Z6PeUsKCdbD8v)qe=A3}#`PfNu z;$E#%+=%Oi|BM*Gf5t|-x2qs+OII61v?rDrNIyKA!AUtW%*l4atk{NZ^nD~0+;p{c zm_DGaWydOl2AM`_REXqP-v+g&Q5r*sv+(FIS7XjE{h532W zif=A|Y57&p-33dorC&^E0;gY0=L4tj1Wu3jp z_Ozo>Q_h`8nJK8SH*v40D=);y!xz3B&%dKlCJ;;LVz`j%Jj|g)gJ9_dI zTaaJy?%j8I->H4-_mHmA7gO-CC1~njP{LaT76ZU8%+yS_RNquCH_-JG`YFU5W7Fk> z_&C(JDQ61-=CbfNg#7`4M&A%4qQ>}`&&CIjEXVy#S%=aa%Z!F8x&Ml)z5wvNqY&77 z+4C1Gn{xh5FE@@JM?78mtMXW5M&6qBZ@RfJ>))D}>33V+nxO=KmSble7Z7DE*9}x$ z1~MsceQ(0QiRov*RKLfEP`9%BJo;goM(d#Gv7Nvp1ojd*MBozT+eLuQnr)^u0uWdn z+A;u&_)ps`zbm^ur=)ig0QU(%uV?4|fYY;$7o`FXYt%0xd!aHM?jB4H!euwCVw@tN z+}S~_mW=mn?4=bJh52M&h)+nsZ4Hic7h&d&6)Hqh!KbHj=GlPy3kwVv!EiJY4@Hw| zEJQ0W6D(|_y4$eGw9T^M@^NXr7#9fVlZp_by#)JCv1inV# zHwi#Wi}Rc{{I0V+^tmr diff --git a/tests/__pycache__/test_output.cpython-312-pytest-9.1.1.pyc b/tests/__pycache__/test_output.cpython-312-pytest-9.1.1.pyc deleted file mode 100644 index 93c805c34b8519ddaf73cf273df90b50d9eeaee6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20413 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 Date: Fri, 24 Jul 2026 04:42:18 +0530 Subject: [PATCH 15/21] feat: config doctor, richer --discover, and dependency trim Work in progress that predates the gotcha-fix pass; committing it as its own change so the fixes that follow are reviewable in isolation. - `config doctor` reports where each setting resolves from (flag, env var, profile literal, or profile `env:` reference), which answers the question that costs the most time: "the CLI says the key is missing, but I set it -- where is it looking?" Backed by `ResolvedConfig.resolution_source`, which reports the winning source without echoing the secret. - `--discover` gains the navigable-groups overview: a ~1k-token map of the command tree with per-group counts and the exact `drill` command for each subtree, so an agent reads the map and follows one drill rather than pulling all 156 commands into context. - `PollSpec.status_field` accepts a tuple of candidate field names, so a poll can match a status endpoint whose body spells the state differently from the run response. - Drops the unused `typer` dependency and documents why each remaining one is there. `uv.lock` is committed so installs are reproducible and hash-pinned. --- pyproject.toml | 14 +- src/unstract_cli/__init__.py | 3 +- src/unstract_cli/app.py | 220 +++++++--- src/unstract_cli/commands/config_cmd.py | 68 +++ src/unstract_cli/config/loader.py | 34 ++ src/unstract_cli/core/generate.py | 46 +- src/unstract_cli/core/poll.py | 35 +- tests/test_cli.py | 166 +++++++- tests/test_http.py | 115 +++++ tests/test_poll.py | 124 +++++- uv.lock | 543 ++++++++++++++++++++++++ 11 files changed, 1275 insertions(+), 93 deletions(-) create mode 100644 uv.lock diff --git a/pyproject.toml b/pyproject.toml index ebfc59d..4517c4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,15 +7,23 @@ requires-python = ">=3.12" license = { text = "Unstract" } dependencies = [ + # Deliberately minimal, to keep the supply-chain surface small. Every entry + # below is actually imported by src/; anything unused has been removed. + # # Click is pinned deliberately: `--discover` depends on the shape of # `click.Parameter.to_info_dict()` (see IMPLEMENTATION_PLAN.md R3). A Click # major bump could reshape that dict and silently degrade command discovery, - # so `tests/test_cli.py` asserts the keys we rely on. + # so `tests/test_cli.py` asserts the keys we rely on. Click's only transitive + # dependency is colorama (Windows-only). "click>=8.1,<9", - "typer>=0.12,<1", + # 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", - "rich>=13.0", "tomli-w>=1.0", ] diff --git a/src/unstract_cli/__init__.py b/src/unstract_cli/__init__.py index 791b89c..add0f52 100644 --- a/src/unstract_cli/__init__.py +++ b/src/unstract_cli/__init__.py @@ -1,8 +1,7 @@ """Unstract CLI -- one LLM-friendly interface to the three products built by Unstract: Document Studio, LLMWhisperer and API Hub. -Unstract is the company name, and stays the name of this CLI. Document Studio -was previously named Unstract itself. +Unstract is the company name, and stays the name of this CLI. """ from unstract_cli.app import __version__ diff --git a/src/unstract_cli/app.py b/src/unstract_cli/app.py index 2434922..5a362d9 100644 --- a/src/unstract_cli/app.py +++ b/src/unstract_cli/app.py @@ -149,10 +149,17 @@ def _endpoint_info(endpoint: Endpoint, command: click.Command) -> dict[str, Any] #: 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 all 143 commands is ~63k tokens -#: -- far too much for an agent to read speculatively -- so the default is the -#: cheap index and the agent drills in from there. -DETAIL_LEVELS = ("summary", "full") +#: 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: @@ -170,6 +177,65 @@ def _matches(entry: dict[str, Any], group: str | None, command: str | None) -> b 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, @@ -218,6 +284,12 @@ def discover( ) 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 @@ -234,19 +306,43 @@ def discover( "docstudio": { "name": "Document Studio", "group": "docstudio", - "note": "Formerly named Unstract. Owns the platform, deployment and hitl API groups.", + "note": "Owns the platform, deployment and hitl API groups.", }, "llmwhisperer": {"name": "LLMWhisperer", "group": "whisper"}, "apihub": {"name": "API Hub", "group": "apihub"}, }, "detail": detail, - "count": len(commands), } 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. @@ -270,42 +366,50 @@ def discover( return { **envelope, - "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." - ), - }, + **_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." + ), + }, +} + + class UnstractCLI(click.Group): """Root group that suggests a correction for an unknown command.""" @@ -354,9 +458,12 @@ def resolve_command(self, ctx: click.Context, args: list[str]): @click.option( "--detail", type=click.Choice(DETAIL_LEVELS), - default="summary", + default="groups", show_default=True, - help="How much per command: 'summary' is names and one-liners; 'full' adds flags and API paths.", + 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("--profile", "-p", default=None, help="Config profile to use.") @click.option( @@ -400,26 +507,19 @@ def cli( payload = discover( group=group, command=command_filter, detail=detail ) - if not payload["commands"]: - click.echo( - json.dumps( - { - "error": { - "code": "usage_error", - "message": ( - "No commands matched" - + (f" --group {group}" if group else "") - + (f" --command {command_filter!r}" if command_filter else "") - + "." - ), - "exit_code": 2, - "hint": "Run `unstract --discover` to list valid groups.", - } - }, - indent=2, - ), - err=True, - ) + # 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. diff --git a/src/unstract_cli/commands/config_cmd.py b/src/unstract_cli/commands/config_cmd.py index 58c02cd..5e29fdf 100644 --- a/src/unstract_cli/commands/config_cmd.py +++ b/src/unstract_cli/commands/config_cmd.py @@ -309,4 +309,72 @@ def config_path_cmd(output: str | None) -> None: emit({"path": str(path), "exists": path.exists()}, _fmt(output)) +#: 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.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:` 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. + """ + from unstract_cli.core import http + from unstract_cli.endpoints import get_endpoint + + 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"} + + 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) + + emit( + { + "active_profile": resolved.active_profile, + "config_path": str(resolved.file.path), + "config_exists": resolved.file.exists, + "groups": groups, + }, + _fmt(output), + ) + + +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/config/loader.py b/src/unstract_cli/config/loader.py index a09d6f6..208ad34 100644 --- a/src/unstract_cli/config/loader.py +++ b/src/unstract_cli/config/loader.py @@ -320,6 +320,40 @@ def require(self, product: str | ApiGroup | Product, key: str) -> Any: 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`. diff --git a/src/unstract_cli/core/generate.py b/src/unstract_cli/core/generate.py index 1172a62..ca30a06 100644 --- a/src/unstract_cli/core/generate.py +++ b/src/unstract_cli/core/generate.py @@ -261,6 +261,22 @@ def _run_endpoint(ctx: click.Context, endpoint: Endpoint, kwargs: dict[str, Any] 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, @@ -326,9 +342,8 @@ def build_group_tree(endpoints: list[Endpoint]) -> dict[str, click.Group]: #: Top-level group help. One group per product built by Unstract. _GROUP_HELP = { "docstudio": ( - "Document Studio — document extraction platform " - "(formerly named Unstract). Covers the Platform Management API, " - "deployed API workflows, and Human Quality Review." + "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).", @@ -339,6 +354,31 @@ def build_group_tree(endpoints: list[Endpoint]) -> dict[str, click.Group]: "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.", } diff --git a/src/unstract_cli/core/poll.py b/src/unstract_cli/core/poll.py index 696ae4a..c047050 100644 --- a/src/unstract_cli/core/poll.py +++ b/src/unstract_cli/core/poll.py @@ -40,10 +40,20 @@ def _dig(payload: Any, field: str) -> Any: return None -def extract_status(payload: Any, field: str = "status") -> str | None: - """Read the status from a response body, case-normalised.""" - value = _dig(payload, field) - return str(value) if value is not None else None +def extract_status(payload: Any, field: str | tuple[str, ...] = "status") -> str | None: + """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) + if value is not None: + return str(value) + return None def extract_handle(payload: Any, field: str) -> str | None: @@ -160,11 +170,18 @@ def wait_for_completion( return response.payload retrieve_endpoint = get_endpoint(spec.retrieve_endpoint) - plan = http.build_request( - retrieve_endpoint, - config, - {**_carry_path_values(values or {}, retrieve_endpoint), spec.handle_param: handle}, - ) + 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 ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 1d4797c..cc8a2fd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,8 @@ import json import os import stat +import subprocess +import sys from pathlib import Path import httpx @@ -72,12 +74,16 @@ class TestStdoutPurity: """SPEC §5.1 - `unstract ... | jq` must always parse.""" @respx.mock - def test_errors_go_to_stderr_only(self, runner, cli, whisper_env): + 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"]) - assert result.stdout.strip() == "", "stdout must stay empty on failure" + # 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 @@ -97,6 +103,20 @@ def test_json_is_default_when_not_a_tty(self, runner, cli, whisper_env): 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.""" @@ -132,6 +152,36 @@ def test_no_retry_on_consumed_result(self, runner, cli, whisper_env): 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 + ): + """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): @@ -171,12 +221,57 @@ class TestDiscover: """SPEC §5.3 - the machine-readable index agents discover the CLI through.""" def test_valid_json_covering_every_endpoint(self, runner, cli): - result = runner.invoke(cli, ["--discover"]) + # 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. @@ -281,17 +376,17 @@ def test_choices_reflect_the_real_enum(self): 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_is_the_default(self): - """Full detail for 143 commands is ~50k tokens -- too much to read - speculatively, so the cheap index is what an unadorned call returns.""" - data = discover() + 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() + data = discover(detail="summary") assert "--group" in data["drill_down"]["one_group"] assert set(data["groups"]) >= {"whisper", "docstudio", "apihub", "config"} @@ -331,10 +426,12 @@ def test_narrow_queries_omit_global_boilerplate(self): assert "exit_codes" not in narrow assert "exit_codes" in discover() - def test_no_match_exits_2_with_clean_stdout(self, runner, cli): + 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 result.stdout.strip() == "" + assert "hint" in json.loads(result.stdout)["error"] assert "hint" in json.loads(result.stderr)["error"] def test_compact_when_piped(self, runner, cli): @@ -584,3 +681,52 @@ def test_profile_selects_region(self, runner, cli, whisper_env): 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_http.py b/tests/test_http.py index 21ceb20..058f89b 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -234,6 +234,93 @@ def test_apihub_without_base_url_is_usage_error(self, monkeypatch): 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): @@ -244,6 +331,34 @@ def test_success(self, monkeypatch): 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( diff --git a/tests/test_poll.py b/tests/test_poll.py index 3a28f4a..d52039d 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -112,17 +112,20 @@ class TestDeployment422Defect: @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={"message": {"execution_status": "EXECUTING"}}, + json={"status": "EXECUTING", "message": None}, ), httpx.Response( 200, - json={"message": {"execution_status": "COMPLETED", - "result": [{"file": "a.pdf", "status": "Success"}]}}, + json={"status": "COMPLETED", + "message": [{"file": "a.pdf", "status": "Success"}]}, ), ] ) @@ -141,13 +144,13 @@ def _run(monkeypatch, in_progress_status_code: int): 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, "execution_status") == "COMPLETED" + 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, "execution_status") == "COMPLETED" + assert extract_status(result, ("status", "execution_status")) == "COMPLETED" @respx.mock def test_both_paths_agree(self, monkeypatch): @@ -188,7 +191,7 @@ 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={"message": {"execution_status": "ERROR", "error": "tool failed"}} + 422, json={"status": "ERROR", "message": "tool failed"} ) ) cfg = _config( @@ -204,6 +207,115 @@ def test_error_status_is_terminal_failure(self, monkeypatch): ) 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" + + +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.""" + + _PS_BASE = f"{PLATFORM_BASE}/api/v1/unstract/org_test/prompt-studio" + + @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"}), + ] + ) + output = respx.get(f"{self._PS_BASE}/prompt-output/").mock( + return_value=httpx.Response( + 200, json=[{"prompt_id": "p1", "output": "42", "modified_at": "t"}] + ) + ) + cfg = _config( + monkeypatch, UNSTRACT_PLATFORM_KEY=FAKE_KEY, UNSTRACT_ORG_ID="org_test" + ) + 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" + ) + wait_for_completion( + 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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2dadcf9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,543 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { 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/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/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 = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +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/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.2" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "respx" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +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/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.15.22" +source = { registry = "https://pypi.org/simple" } +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/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 = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "unstract-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "httpx" }, + { name = "pyyaml" }, + { name = "tomli-w" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "respx" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1,<9" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { 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 = "types-pyyaml", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] From a6caf699eb3d1fbd96ffd30e265eba409d59cc34 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Fri, 24 Jul 2026 04:43:28 +0530 Subject: [PATCH 16/21] fix: exit codes never reached the shell -- every failure exited 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI runs Click with `standalone_mode=False` so that Click's own parse errors reach us and can be rendered through the structured error envelope (SPEC §5.5). That mode also changes how `ctx.exit(code)` behaves: instead of raising SystemExit, Click *returns* the code from the invocation. `main()` ignored the return value and unconditionally returned 0. So every documented exit code -- 2 usage, 3 auth, 4 not-found, 7 timeout, 9 already-consumed (SPEC §5.4) -- was reported to the shell as success. A validation error printed `"exit_code": 2` inside its own error envelope while the process exited 0. `set -e` never tripped, `cmd || handle` never fired, and an agent branching on the status code saw every failure as a pass. This defeats the exit-code contract the README advertises as a core promise for agent use. Return the invocation's result when it is an int (a command that falls off the end returns None, which is success). Tested by subprocess, which is the only way to catch this: Click's `CliRunner` invokes with `standalone_mode=True` and never runs `main()`, so the existing suite asserted the right contract against a code path the real binary does not take -- 213 tests passed throughout. --- src/unstract_cli/__main__.py | 40 ++++++++++++++++++++--- tests/test_exit_codes.py | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/test_exit_codes.py diff --git a/src/unstract_cli/__main__.py b/src/unstract_cli/__main__.py index 7171380..30fcd60 100644 --- a/src/unstract_cli/__main__.py +++ b/src/unstract_cli/__main__.py @@ -4,17 +4,47 @@ import sys +import click + def main() -> int: """Run the CLI. - Click exits the process itself via SystemExit, carrying the exit code set by - the command, so this normally does not return. + 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). + + 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). """ from unstract_cli.app import build_cli - - build_cli()(standalone_mode=True) - return 0 + from unstract_cli.core.errors import CLIError, ExitCode + + try: + 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: + # 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__": 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." + ) From c9aafad78953e0a423520adcb6542d45d4d81f9e Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Fri, 24 Jul 2026 04:44:02 +0530 Subject: [PATCH 17/21] fix: resolve the friction points from a full end-to-end run Fixes for the issues hit while driving a document through the CLI end to end (extract, Prompt Studio, export tool, deploy API, run). Root causes were traced in the backend source rather than inferred, which changed the fix in two cases and ruled one out entirely. `prompt create --profile-manager`. `fetch_response` resolves a prompt's LLM profile from the prompt's own `profile_manager` field and never falls back to the project default -- unlike `index_document` and `single_pass`, which both call `get_default_llm_profile(tool)`. A prompt created without one is unrunnable, and fails with "Default LLM profile is not configured", which names the *project* default -- genuinely set, and never read. The flag makes prompts runnable from birth; the 500 now carries a hint naming the real fix instead of sending the reader back to `profile set-default`. `prompt-studio --challenge-llm` / `--monitor-llm`. A deployed run ended in `ERROR` with a 422 at the very last step. The exported tool's metadata carries `challenge_llm: ""`, which fails schema validation: the property is `adapterType: LLM`, so the schema gains an `enum` of real adapter ids and "" is not among them. (Not the `required` rule, as it first appears -- a present-but-empty key satisfies `required`; verified by running the validator.) Export already resolves a valid value, so setting it on the project before `export-tool` keeps the metadata non-empty. `Param.mirror_as` for the API-key routes. `api-deployment key create` needed both `--api-id` (URL) and `--api` (body) -- the same value twice. The path value is now mirrored into the body under its own name, so `--api-id` alone works. Same fix for the `pipeline key create` sibling. Both records carry `doc_conflict` so the docs-diff skill does not restore the flags; the diff also now recognises a mirrored param as present. `default_from` fallback chains. `deployment` and `hitl` `org_id` fall back to `platform.org_id` -- the same organization, in a block that starts empty. Credentials deliberately do not chain: reusing a key across API groups would be credential confusion, not convenience. `--wait` on `index-document`, which returns the same `{task_id, status}` shape as its siblings and was the only async command without it. Indexing writes no Output Manager row, so the terminal status is the result and there is no retrieve step. Not fixed, deliberately: making `--vector-store`/`--embedding-model` optional at `--chunk-size 0`. Both columns are NOT NULL server-side and the serializer derives required=True, so relaxing the CLI would trade a fast local exit-2 for a slower remote 400 -- moving the friction, not removing it. `RequiredUnless` is kept as a tested primitive for a case where the server genuinely accepts the omission. Help text now explains that the values are stored but never queried. Remaining items are server-side and cannot be fixed here; their help text now describes the real behaviour instead of implying a workaround exists: the registry's missing back-reference to the Prompt Studio tool, the absent DELETE route, `adapter-choices` (routed to a method that does not exist anywhere in the backend, so it 500s on every call), and the empty default-triad response meaning "unset" rather than an error. --- src/unstract_cli/core/errors.py | 77 +++- src/unstract_cli/core/http.py | 120 ++++++- src/unstract_cli/core/model.py | 109 +++++- src/unstract_cli/endpoints/deployment.py | 34 +- src/unstract_cli/endpoints/hitl.py | 6 +- src/unstract_cli/endpoints/platform.py | 424 +++++++++++++++++++++-- src/unstract_cli/skill/docdiff.py | 13 + tests/test_gotchas.py | 245 +++++++++++++ tests/test_model.py | 11 +- 9 files changed, 980 insertions(+), 59 deletions(-) create mode 100644 tests/test_gotchas.py diff --git a/src/unstract_cli/core/errors.py b/src/unstract_cli/core/errors.py index 9495be7..3a1cd89 100644 --- a/src/unstract_cli/core/errors.py +++ b/src/unstract_cli/core/errors.py @@ -174,15 +174,86 @@ def to_dict(self) -> dict[str, Any]: return {"error": payload} def emit(self, secrets: list[str] | None = None) -> None: - """Write the structured error to stderr. Never to stdout.""" + """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. + """ + 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 ( + "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." + ) -def hint_for(status: int, endpoint: str | None = None) -> str | None: - """A short, actionable next step for common failures.""" match status: case 401 | 403: return ( diff --git a/src/unstract_cli/core/http.py b/src/unstract_cli/core/http.py index 867a982..bb0d38c 100644 --- a/src/unstract_cli/core/http.py +++ b/src/unstract_cli/core/http.py @@ -25,7 +25,14 @@ redact_headers, redact_value, ) -from unstract_cli.core.model import ApiGroup, BodyKind, Endpoint, ParamLocation +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 @@ -139,9 +146,8 @@ def build_url(endpoint: Endpoint, config: ResolvedConfig, values: dict[str, Any] path = endpoint.path for param in endpoint.path_params(): value = values.get(param.py_name) - if value is None and param.default_from: - product, _, key = param.default_from.partition(".") - value = config.get(product, key) + if value is None: + value = _config_default(param, config) if value is None: raise CLIError( f"Missing required path parameter {param.cli_flag}.", @@ -154,12 +160,60 @@ def build_url(endpoint: Endpoint, config: ResolvedConfig, values: dict[str, Any] 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, @@ -187,7 +241,17 @@ def build_request( content: bytes | None = None for param in endpoint.params: - if param.client_side or param.location is ParamLocation.PATH: + 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) @@ -205,9 +269,8 @@ def build_request( params[f"{param.freeform_prefix}{key.strip()}"] = val.strip() continue - if raw is None and param.default_from: - product, _, key = param.default_from.partition(".") - raw = config.get(product, key) + 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): @@ -217,7 +280,7 @@ def build_request( ) continue - value = param.to_wire(raw) + value = _parse_json_param(param, param.to_wire(raw)) match param.location.value: case "query": @@ -235,6 +298,13 @@ def build_request( 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 @@ -303,12 +373,42 @@ def _parse(response: httpx.Response) -> Any: 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, *, @@ -431,7 +531,7 @@ def raise_for_status(response: Response, endpoint: Endpoint | None = None) -> No 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), + hint=hint_for(response.status, endpoint.path if endpoint else None, message), retryable=is_retryable(response.status), ) diff --git a/src/unstract_cli/core/model.py b/src/unstract_cli/core/model.py index 94ef7ac..47dd8be 100644 --- a/src/unstract_cli/core/model.py +++ b/src/unstract_cli/core/model.py @@ -43,8 +43,8 @@ class Product(str, Enum): Unstract is the company, and the name of this CLI. It builds exactly three products: **Document Studio**, **LLMWhisperer** and **API Hub**. Document - Studio was previously called Unstract itself, which is why its APIs still - use `platform`/`deployment` paths on the wire. + Studio's API groups use `platform`/`deployment` paths on the wire and + `UNSTRACT_*` environment variables. """ DOCUMENT_STUDIO = "docstudio" @@ -176,6 +176,47 @@ def describe(self) -> str: 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). @@ -219,8 +260,17 @@ class Param: 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"``. + #: 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, @@ -232,6 +282,26 @@ class Param: 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: @@ -290,12 +360,38 @@ class PollSpec: """ status_endpoint: str - status_field: str = "status" + #: 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 @@ -341,6 +437,10 @@ class Endpoint: 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: @@ -430,6 +530,7 @@ def with_params(source: Endpoint, *extra: Param) -> Endpoint: "Permission", "PollSpec", "Product", + "RequiredUnless", "derive_patch", "field", "with_params", diff --git a/src/unstract_cli/endpoints/deployment.py b/src/unstract_cli/endpoints/deployment.py index a0acaf4..efec84e 100644 --- a/src/unstract_cli/endpoints/deployment.py +++ b/src/unstract_cli/endpoints/deployment.py @@ -31,8 +31,12 @@ Param( "org_id", location=ParamLocation.PATH, - default_from="deployment.org_id", - help="Organization identifier", + # 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", @@ -58,8 +62,18 @@ "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." + "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, @@ -82,11 +96,21 @@ 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="execution_status", + status_field=("status", "execution_status"), terminal_success=("COMPLETED",), terminal_failure=("ERROR", "STOPPED"), handle_field="execution_id", @@ -95,7 +119,7 @@ ), doc_source=f"{_DOCS}/api_execution.md", examples=( - "unstract deployment run --api-name invoice-api --file invoice.pdf --wait", + "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", ), diff --git a/src/unstract_cli/endpoints/hitl.py b/src/unstract_cli/endpoints/hitl.py index 635abe1..22967fa 100644 --- a/src/unstract_cli/endpoints/hitl.py +++ b/src/unstract_cli/endpoints/hitl.py @@ -15,8 +15,10 @@ _ORG = Param( "org_id", location=ParamLocation.PATH, - default_from="hitl.org_id", - help="Organization identifier", + # 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( diff --git a/src/unstract_cli/endpoints/platform.py b/src/unstract_cli/endpoints/platform.py index 1abdaed..459b93a 100644 --- a/src/unstract_cli/endpoints/platform.py +++ b/src/unstract_cli/endpoints/platform.py @@ -22,16 +22,18 @@ from __future__ import annotations +from dataclasses import replace + from unstract_cli.core.model import ( ApiGroup, AtLeastOneOf, BodyKind, Endpoint, - MutuallyExclusive, Param, ParamLocation, ParamType, Permission, + PollSpec, derive_patch, with_params, ) @@ -88,8 +90,20 @@ def _ep( 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.""" + """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", @@ -101,13 +115,16 @@ def _ep( summary=summary, params=(_ORG, *params), body=body, - doc_source=f"{_DOCS}/{doc}", + 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, ) @@ -119,6 +136,58 @@ def _ep( _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"), @@ -134,6 +203,19 @@ def _ep( 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, @@ -149,30 +231,54 @@ def _ep( 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"), + 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"), + 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"), + 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"), + 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"), + 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"), + 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"], @@ -217,7 +323,13 @@ def _ep( 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."), + 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")), @@ -246,7 +358,19 @@ def _ep( 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), + 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, @@ -270,8 +394,20 @@ def _ep( 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, *_PROMPT_FIELDS), - subgroup=f"{_PS} prompt", body=BodyKind.JSON, permission=Permission.READ_WRITE), + "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"),), @@ -303,7 +439,16 @@ def _ep( _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), + 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"),), @@ -320,7 +465,13 @@ def _ep( (_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), + 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, @@ -331,9 +482,22 @@ def _ep( Param("run_id", type=ParamType.UUID, location=ParamLocation.BODY, help="Execution tracking identifier"), Param("profile_manager", type=ParamType.UUID, location=ParamLocation.BODY, - help="Override the project's default LLM profile")), + 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), + 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, @@ -342,8 +506,54 @@ def _ep( 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, - description="Requires single_pass_extraction_mode enabled on the project."), + 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/", @@ -354,7 +564,12 @@ def _ep( permission=Permission.READ), _ep("adapter-choices", "GET", "/prompt-studio/adapter-choices/", "List adapters available for LLM profiles.", subgroup=_PS, - permission=Permission.READ), + 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), @@ -510,6 +725,121 @@ def _ep( ) +# --------------------------------------------------------------------------- # +# 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) # --------------------------------------------------------------------------- # @@ -573,18 +903,26 @@ def _ep( 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, - help="Deployment identifier"), - Param("api", type=ParamType.UUID, location=ParamLocation.BODY, - help="Deployment to attach the key to"), - Param("pipeline", type=ParamType.UUID, location=ParamLocation.BODY, - help="Pipeline to attach the key to"), + 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, - constraints=(MutuallyExclusive(("api", "pipeline")),)), + 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"),), @@ -686,18 +1024,24 @@ def _ep( 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, help="Pipeline identifier"), - Param("pipeline", type=ParamType.UUID, location=ParamLocation.BODY, - help="Pipeline to attach the key to"), - Param("api", type=ParamType.UUID, location=ParamLocation.BODY, - help="Deployment to attach the key to"), + 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, - constraints=(MutuallyExclusive(("pipeline", "api")),)), + 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 ",)), ) @@ -755,7 +1099,12 @@ def _ep( _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, - table_columns=("id", "adapter_name", "adapter_type", "model", "is_available")), + 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), @@ -775,7 +1124,13 @@ def _ep( _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), + 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.", @@ -975,6 +1330,7 @@ def _ep( ENDPOINTS: tuple[Endpoint, ...] = ( *_PROMPT_STUDIO, *_WORKFLOWS, + *_WORKFLOW_ASSEMBLY, *_API_DEPLOYMENTS, *_PIPELINES, *_ADAPTERS, diff --git a/src/unstract_cli/skill/docdiff.py b/src/unstract_cli/skill/docdiff.py index d511dcf..4c197f1 100644 --- a/src/unstract_cli/skill/docdiff.py +++ b/src/unstract_cli/skill/docdiff.py @@ -329,10 +329,23 @@ def diff( ) 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 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_model.py b/tests/test_model.py index a082afe..416b243 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -95,7 +95,16 @@ 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_from == "deployment.org_id" + 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. From 1706164df9e8a39f463d76dce1d2701327256e52 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Fri, 24 Jul 2026 04:44:21 +0530 Subject: [PATCH 18/21] docs: record the new record-model mechanisms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive reconciliation of SPEC, README and the update-unstract-cli Skill against the fixes in the previous commit. Nothing here contradicts what was already written -- the gaps were mechanisms the docs did not yet mention. SKILL.md matters most: it is what a future skill run reads to learn which encodings exist, so a gap causes real errors later. It now covers `mirror_as`, the `default_from` fallback chain, and `RequiredUnless` (listed as available but explicitly *unused*, with the reason, so it is not reached for where the server enforces the field anyway). The `doc_conflict` guidance gains the key-create case as a second worked example beside whisper-detail, so the deliberately dropped `api` and `pipeline` flags are not "restored" on a later sync. SPEC §7.1 generalises the mirroring sentence and adds the fallback chain and the conditional constraint; §8.3/§8.5 record that `doc_conflict` now suppresses param drift too, and that a mirrored parameter is not drift. README gains one line: the org_id fallback, in the resolution-order section. Per-endpoint flags stay out of it -- below its altitude. The exit-code fix needs no doc change: SPEC §5.4 and the README table always specified the correct behaviour, and the fix aligned the implementation to them. A spec describes the contract, not its regressions. --- .claude/skills/update-unstract-cli/SKILL.md | 106 ++++++++++++++-- README.md | 104 +++++++++++++--- SPEC.md | 127 ++++++++++++++++---- 3 files changed, 284 insertions(+), 53 deletions(-) diff --git a/.claude/skills/update-unstract-cli/SKILL.md b/.claude/skills/update-unstract-cli/SKILL.md index 79ddb44..d65ad53 100644 --- a/.claude/skills/update-unstract-cli/SKILL.md +++ b/.claude/skills/update-unstract-cli/SKILL.md @@ -19,15 +19,15 @@ Unstract is the company and the CLI's name. It builds three products: | Product | CLI group | API groups it owns | | --- | --- | --- | -| **Document Studio** (formerly named Unstract) | `docstudio` | `platform`, `deployment`, `hitl` | +| **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. Wire paths still say `platform`/`deployment` because the product -was renamed, not the API. +stored twice. Document Studio's wire paths use `platform`/`deployment`; the CLI +preserves them verbatim under the `docstudio` group. ## Sources of truth @@ -43,8 +43,38 @@ Repos are expected as siblings of `unstract-cli`. If one is missing locally, rea 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. +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 @@ -105,9 +135,52 @@ Edit `endpoints/*.py` only. Follow the conventions already in those files: 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`, `choices={friendly: wire}`, `multiple`, `default_from`, - `freeform_prefix`, `applies_when`, `replace_semantics`, `derive_patch`, - `with_params`. + `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" @@ -145,12 +218,19 @@ These exist because each one has a specific failure mode behind it. 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. - - The live example: `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. + 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`) diff --git a/README.md b/README.md index d580e81..c1cf1aa 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@ # Unstract CLI -**Unstract** is the company, and `unstract` is the name of this CLI. It builds -three products, and this one tool covers all of them: +The `unstract` CLI tool covers all products built by **Unstract** team: | Product | Group | Covers | | --- | --- | --- | -| **Document Studio** | `docstudio` | Document extraction platform — Platform Management API, deployed API workflows, Human Quality Review. *Formerly named Unstract.* | +| **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 | @@ -25,6 +24,59 @@ pip install unstract-cli # once published 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 lock # resolve + write uv.lock with hashes for the whole tree +uv lock --check # CI: fail if uv.lock is stale vs pyproject.toml +``` + +Install from the lockfile — never re-resolving, so what runs is what was +reviewed: + +```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. + +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 +uv export --frozen --no-dev --no-emit-project -o requirements.txt +pip install --require-hashes --no-deps -r requirements.txt +``` + +`--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: @@ -66,6 +118,12 @@ 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. +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. + ### Multiple configs Nothing is global-only. There are three independent ways to select a config, and @@ -111,30 +169,34 @@ Because credentials use `env:` indirection, such a file is safe to commit. ## For agents -Discover the entire surface without documentation. Start cheap, then drill in — -selection (`--group` / `--command`) and verbosity (`--detail`) are independent, -so any combination works: +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 # all 143, names + summaries -unstract --discover --group docstudio # one product -unstract --discover --command 'whisper webhook' # one subtree +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 ``` -At `--detail full` each entry carries the underlying HTTP method and path, every -flag with type, default, enum and required-ness, plus whether the command -supports `--wait` and whether its result is one-shot. +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 cheap index: +the cheapest useful view: | Invocation | Tokens | | --- | --- | -| `--discover` (default) | ~4,500 | -| `--group whisper` | ~470 | +| `--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 | @@ -214,5 +276,17 @@ 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 + +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). + See [`SPEC.md`](./SPEC.md) for the full specification and [`IMPLEMENTATION_PLAN.md`](./IMPLEMENTATION_PLAN.md) for the build sequence. diff --git a/SPEC.md b/SPEC.md index ccc00d1..4ba66df 100644 --- a/SPEC.md +++ b/SPEC.md @@ -16,7 +16,7 @@ | **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 was previously named Unstract**, which is why its APIs still use `platform`/`deployment` paths on the wire and `UNSTRACT_*` environment variables. The product name changed; the wire contract did not. +> **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. @@ -49,9 +49,11 @@ The CLI implements HTTP directly against documented endpoints. Each endpoint is 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 + Typer +### D3 — Python + Click (a deliberately minimal dependency set) -Type-hint-driven help generation, an introspectable command tree (required for `--discover`, §5.3), and alignment with the all-Python ecosystem of the surrounding repos. +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 @@ -75,7 +77,7 @@ unstract │ ├── usage-by-tag # GET /usage │ └── webhook {create,get,update,delete} # /whisper-manage-callback │ -├── docstudio # Document Studio (formerly named Unstract) +├── docstudio # Document Studio │ └── deployment # Deployed API workflow execution │ ├── run # POST /deployment/api/{org}/{api_name}/ │ ├── status # GET /deployment/api/{org}/{api_name}/?execution_id= @@ -88,13 +90,17 @@ unstract │ │ 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, +│ │ 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}} @@ -122,7 +128,7 @@ unstract │ ├── retrieve # GET /api/v1/retrieve?file_hash= │ └── doc-splitter {upload,status,download} │ -├── config {init,list,get,set,use,current,path} +├── config {init,list,get,set,use,current,path,doctor} ├── completion {bash,zsh,fish} └── --discover / --version / --help ``` @@ -133,11 +139,16 @@ Both LLMWhisperer and Unstract are execute → poll → retrieve. Raw sub-comman ```bash unstract whisper extract --file invoice.pdf --mode form --wait --output json -unstract docstudio deployment run --api-name invoice-api --file invoice.pdf --wait +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`) on stdout so the agent can resume. +`--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). --- @@ -191,6 +202,8 @@ default_profile = "cloud-us" 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 | @@ -232,7 +245,8 @@ 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. -- JSON goes to **stdout and nothing else** — no banners, spinners, or progress on stdout. Diagnostics go to stderr. +- 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. @@ -244,7 +258,11 @@ No command prompts for input under any circumstance. Missing required input is a - 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 the **entire command tree as JSON** — every command, flag, type, default, enum, required-ness, and the underlying endpoint. This is the machine-readable index that makes "auto-discover from help text" operational, and it is generated from the same endpoint definitions that drive execution, so it cannot drift. +- `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 @@ -261,12 +279,16 @@ Stable and documented, so an agent can branch without parsing prose: | `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) | +| `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): +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 { @@ -307,6 +329,8 @@ Mitigations, all mandatory: 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` @@ -333,7 +357,7 @@ Base: `https://us-central.unstract.com` · Auth: `Authorization: Bearer` · Path | 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` | +| `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`) | @@ -342,6 +366,8 @@ Base: `https://us-central.unstract.com` · Auth: `Authorization: Bearer` · Path 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 @@ -366,22 +392,35 @@ Pagination (list endpoints): `--page` (default 1), `--page-size` (default 50, ma | `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`\*, `--prompt-key`\*, `--enforce-type {text,number,email,date,boolean,json,line-item,table}`, `--prompt`, `--sequence-number`, `--prompt-type {PROMPT,NOTES}`, `--active` | +| `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`, `--chunk-overlap`, `--retrieval-strategy {simple,subquestion,fusion,recursive,router,keyword_table,automerging}`, `--similarity-top-k` (max 4 profiles/project) | +| `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` | -| `single-pass` | `/prompt-studio/single-pass-extraction/{tool_id}` | POST | `--tool-id`\*, `--document-id`\*, `--run-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 | @@ -402,6 +441,30 @@ Pagination (list endpoints): `--page` (default 1), `--page-size` (default 50, ma | `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 | @@ -516,7 +579,7 @@ Statuses: `QUEUED_FOR_WHISPER` → `QUEUED_FOR_EXTRACTION` → `COMPLETED`. The ``` unstract_cli/ ├── __main__.py # entry point -├── app.py # Typer root; command tree built from definitions +├── 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 @@ -525,7 +588,7 @@ unstract_cli/ │ ├── 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 → Typer command +│ └── generate.py # endpoint definition → Click command ├── endpoints/ # ← SINGLE SOURCE OF TRUTH (the Skill's edit target) │ ├── whisper.py │ ├── deployment.py @@ -562,13 +625,23 @@ Endpoint( ], body="binary_file", returns="whisper_hash", - poll=PollSpec(status_cmd="whisper status", terminal=["processed", "error"], - retrieve_cmd="whisper retrieve", one_shot=True), + 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` @@ -598,7 +671,7 @@ Repo paths are configurable; the Skill falls back to the GitHub API when a repo 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. + - 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`. @@ -614,7 +687,7 @@ API Hub has **no public documentation site**. The Skill cannot cross-reference i - 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. +- **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). --- @@ -626,9 +699,13 @@ API Hub has **no public documentation site**. The Skill cannot cross-reference i | 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) | +| 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 | -| Redaction | No secret appears in any output stream, including `--dry-run` and `-vv` | +| 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 | --- From 9af43f405f227f0e4b71bff5aded5673d249c412 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Fri, 24 Jul 2026 12:19:51 +0530 Subject: [PATCH 19/21] docs: remove IMPLEMENTATION_PLAN.md and scrub its references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build-sequence plan has served its purpose now that the CLI is implemented; SPEC.md remains the living specification. Removing it leaves six dangling citations, all fixed here so nothing points at a file that no longer exists: - README dropped the link to it from the closing "see also" line. - pyproject, model.py (x3) and output.py cited section labels (R3, §1, §2, M1.6) that existed only in the plan. The surrounding prose stands on its own, so the parentheticals are simply removed -- the P1-P12 pattern framework they referenced is defined inline in model.py's own docstring, so nothing is lost. - test_model.py's module docstring opened by quoting the plan ("§2 calls this the load-bearing design work"); reworded to state the point directly. Verified: `grep -rn IMPLEMENTATION_PLAN` returns nothing. Tests, ruff and mypy all green. --- IMPLEMENTATION_PLAN.md | 310 -------------------------------- README.md | 3 +- pyproject.toml | 8 +- src/unstract_cli/core/model.py | 6 +- src/unstract_cli/core/output.py | 2 +- tests/test_model.py | 10 +- 6 files changed, 14 insertions(+), 325 deletions(-) delete mode 100644 IMPLEMENTATION_PLAN.md diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index ce27965..0000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,310 +0,0 @@ -# Unstract CLI — Implementation Plan - -**Companion to:** [`SPEC.md`](./SPEC.md) (Draft v1) -**Status:** Ready to execute -**Scope:** Build sequence, de-risking order, and per-phase acceptance criteria. - -> This plan deliberately does **not** restate the endpoint tables (SPEC §6), the file layout (§7), or the phase list (§10). Those are the contract; this document is the *order of operations* and the definition of "done" for each step. Where the two disagree, SPEC.md wins. - ---- - -## 1. The central bet, and how we de-risk it - -Everything in the spec rests on one architectural bet: - -> The command tree, `--help`, validation, `--discover`, and the Skill's docs-diff are all **generated** from declarative `Endpoint` records (§7.1). - -If that abstraction does not hold, the design collapses into per-command special-casing — which would also destroy the Skill's diffability (§8.5), because the definitions would no longer be a faithful description of behaviour. Every sequencing decision below follows from protecting that bet. - -**Two command categories.** The bet covers endpoint-backed commands, which is nearly all of them. It is worth stating the exception explicitly, because an endpoint-centric plan makes the non-generated commands easy to lose: - -| Category | Commands | Source | Built in | -| --- | --- | --- | --- | -| **Generated** | `whisper`, `deployment`, `platform`, `hitl`, `apihub` | `Endpoint` records → `generate.py` | Phases 2–5 | -| **Hand-authored** | `config {init,list,get,set,use,current,path}`, `completion {bash,zsh,fish}` | Plain Typer commands; map to no API endpoint | Phase 1 (M1.5), Phase 6 | - -Both appear in `--discover`; hand-authored ones are flagged as non-endpoint so an agent can tell local operations from remote calls. - -Two consequences drive the plan's shape: - -1. **Model before breadth.** The `Endpoint`/`Param` schema must be proven against the *hardest* parameter patterns in §6 before we add a hundred endpoints on top of it. See §2. -2. **Walking skeleton before product surfaces.** One trivial endpoint flows end-to-end before any real surface is built. See Phase 1, M1.3. - -### 1.1 Risk register - -| # | Risk | Impact if unhandled | Mitigation | Resolved by | -| --- | --- | --- | --- | --- | -| R1 | `Param` schema cannot express §6's special cases | Generation degrades into per-command hacks; Skill diff breaks | Pattern enumeration is the *first* task; schema must encode all of §2 | Phase 1 exit | -| R2 | `--output table` has no universal shape for nested JSON | Either ugly output or per-command rendering code | Decide the strategy in Phase 1 — it may add a field to the data model | Phase 1 exit | -| R3 | `--discover` can't recover full flag metadata | The core LLM-discoverability promise (§5.3) fails | **Retired** — verified, see §1.2 | ✅ | -| R4 | Deployment 422 defect (§6.2) misread as failure | `--wait` breaks now, or breaks later when the defect is fixed | Poll on **body `status`**, never HTTP code; assert both in tests | Phase 3 | -| R5 | One-shot retrieval consumed by a retry (§5.6) | Silent, unrecoverable data loss for an agent | Retries never replay a consumed read; `--save` writes before exit | Phase 2 | -| R6 | API Hub public base URL unknown (§11.1) | `apihub` group unusable out of the box | `--base-url` / env required; no baked default | Phase 5 entry | -| R7 | `full_access` may not be UI-creatable (§11.2) | Every `delete` command unusable with a UI-issued key | Confirm at Phase 4 entry; if confirmed, say so in help text | Phase 4 entry | - -### 1.2 Pre-verified during planning - -**R3 is retired.** Click 8.3.1 / Typer 0.24.1 `Parameter.to_info_dict()` returns exactly what §5.3 requires: - -``` -{'name': 'mode', 'opts': ['--mode'], - 'type': {'param_type': 'Choice', 'choices': ('form','table')}, - 'required': False, 'multiple': False, 'default': 'form', - 'help': 'Processing mode.', 'is_flag': False, ...} -``` - -Type, choices, default, required-ness, repeatability, and help text all round-trip. `--discover` can therefore be built by walking the generated Click tree. - -**Design note.** Even so, `--discover` emits from the **`Endpoint` records plus** the introspected tree, because the records carry what Click cannot know: the underlying HTTP method/path, `doc_source`, permission level, and one-shot semantics. Introspection alone would produce a CLI description; the records make it an *API* description, which is what an agent needs. - ---- - -## 2. Task 0 — Parameter pattern enumeration (do this first) - -The `Endpoint`/`Param` sketch in §7.1 ends in `...`, and the ellipsis hides the hard part. Before writing `generate.py`, confirm the schema expresses every pattern present in §6. **This is the load-bearing design work of the whole project.** - -| # | Pattern | Instances in §6 | Schema requirement | -| --- | --- | --- | --- | -| P1 | Mutually exclusive (XOR) | `whisper extract` `--file`\|`--url`; `apihub extract` `--file`\|`--use-cached-file-hash`; `key create` `--api`\|`--pipeline` | Endpoint-level `constraints=[MutuallyExclusive(...)]`, validated pre-flight → exit 2 | -| P2 | At-least-one-of | `file-history clear` (≥1 filter) | `constraints=[AtLeastOneOf(...)]` | -| P3 | Enum → path-segment mapping | `share --resource` (`api-deployment` → `api/deployment`) | `Param.choices` as `{friendly: wire}` mapping, not a flat list | -| P4 | Repeatable | `--file`, `--presigned-url`, `--shared-users`, `--user-id`, `--ids` | `Param.multiple=True` | -| P5 | Arbitrary key=value passthrough | `apihub --ext-param KEY=VALUE` (§6.5) | `Param.freeform_prefix="ext_"`; merged into query at request build | -| P6 | Path param w/ profile default | `--org-id` (defaults from profile), `--api-name` (required, no default) | `location="path"` + `default_from="profile..org_id"` | -| P7 | Location variants | query / JSON body / multipart / binary octet-stream / header | `Param.location ∈ {query,body,path,header,form}`; `Endpoint.body ∈ {json,multipart,binary_file,text,none}` | -| P8 | PATCH = PUT minus required | Prompt Studio, workflow, pipeline, adapter, connector | **Derive**: `Endpoint.derive_patch_from=` strips required-ness. Never duplicate records — duplication is how definitions drift | -| P9 | Conditional applicability (help-only) | `--median-filter-size` / `--gaussian-blur-radius` (`low_cost` only); `--allow-rotated-text` (form/high_quality/table only) | `Param.applies_when="mode=low_cost"` — rendered into help, **never enforced** (server owns that rule) | -| P10 | Int vs UUID identifiers | `group` IDs are `int`; nearly everything else is UUID | `Param.type` distinguishes; validation surfaces mismatch as exit 2, not a 404 | -| P11 | Trailing-slash sensitivity | `profilemanager` vs `profile-manager`; `groups/{id}/members/{user_id}` (no slash) | `Endpoint.path` is **literal and authoritative**; no normalization anywhere in the stack | -| P12 | Replace-vs-append semantics | `shared_users`, `shared_groups` (replace) | `--add-user`/`--remove-user` are read-modify-write helpers layered *above* the raw flag; raw flag documented as replace | - -**Exit criterion:** a written mapping from each of P1–P12 to a concrete field on `Endpoint`/`Param`, with one representative record hand-written per pattern. If a pattern cannot be expressed declaratively, that is a design finding to resolve *now*, not at Phase 4. - ---- - -## 3. Phases - -Phases match SPEC §10. Each has entry conditions, work items, and a **testable** exit criterion. - -### Phase 0 — Scaffolding - -Repo skeleton per §7, matching sibling-repo conventions (verified: `unstract` and `unstract-verticals` both use Python 3.12, ruff `line-length = 90`, mypy). - -- `pyproject.toml`: `requires-python = ">=3.12"`, deps `typer`, `httpx`, `pyyaml`, `rich`; dev deps `pytest`, `respx`, `ruff`, `mypy`. Stdlib `tomllib` for reading, `tomli-w` for writing config. -- **Pin `click` and `typer` to compatible major versions.** R3's retirement rests specifically on Click's `Parameter.to_info_dict()` shape (§1.2); a future Click major that reshapes that dict would silently degrade `--discover`. Treat `to_info_dict()` as a tested contract surface — a Phase 1 test asserts the keys we depend on still exist, so a dependency bump fails loudly rather than quietly. -- Console entry point `unstract = unstract_cli.__main__:main`. -- Package skeleton per §7 (empty modules), `.claude/skills/` placeholder, pre-commit with ruff + mypy. -- CI: lint, type-check, test on 3.12. - -**Exit:** `unstract --version` runs; `ruff` and `mypy` pass clean on the skeleton. - ---- - -### Phase 1 — Core (the whole spine, proven on one endpoint) - -**Entry:** Task 0 complete. - -This is the phase that matters. Everything after it is mostly adding records. - -#### M1.1 — Data model -`Endpoint`, `Param`, `PollSpec`, `Constraint` types encoding all of P1–P12. Frozen dataclasses; no runtime mutation (the records are a contract, and the Skill edits them as source). - -#### M1.2 — Config & profiles (§4) -- TOML load from `~/.config/unstract/config.toml`, `UNSTRACT_CONFIG` override. -- Resolution chain **flag → env → profile → default**, implemented once as a single resolver used by every parameter. Not re-implemented per command. -- `env:VAR_NAME` indirection; `0600` on write; warn on broader permissions. -- Per-product config blocks (`whisper`, `platform`, `deployment`, `apihub`) — they have genuinely different hosts and keys. -- **Fully usable with zero config file** (§4.3) — asserted by a test that runs with only env vars set. - -#### M1.3 — Walking skeleton ⭐ -The single most important milestone. Take **`whisper usage`** — GET, no parameters, no path params, trivial response — and drive it end-to-end: - -``` -Endpoint record → generate.py → Typer command → auth injection - → http.py → response → output.py → exit code -``` - -Nothing else is built until this works. It proves the spine with the least possible code. - -#### M1.4 — Cross-cutting spine -Built once, against the skeleton, because these touch every command: - -- **Auth injection** — strategy per product (§4.4): `unstract-key`, `Bearer`, `apikey`. API Hub sends `apikey` **only**; the `X-Subscription-*` / `X-User-Id` headers are Kong-injected and must never be sent (§4.4). -- **Path-param substitution** — literal paths, profile-default resolution for `--org-id`. -- **`http.py`** — httpx, retry with exponential backoff + jitter on 429/5xx only, never on 4xx; `--max-retries`, `--no-retry`, `--timeout`. -- **`errors.py`** — HTTP status → §5.4 exit code + §5.5 structured JSON on stderr, with `hint` and `retryable`. -- **`output.py`** — json/yaml/table/raw; **TTY detection** (json when not a TTY); stdout carries payload *only*, diagnostics to stderr. -- **Redaction** — a single choke point applied to logs, errors, and `--dry-run`. Centralized so it cannot be forgotten per-command. -- **`--dry-run`** — resolved request as JSON, exit 0, nothing sent. - -#### M1.5 — `config` command group (hand-authored) -`config {init,list,get,set,use,current,path}` (§3). These map to **no API endpoint** — they operate on the M1.2 config layer — so they are hand-authored Typer commands rather than generated ones. They belong in Phase 1 because they are how a user or agent bootstraps every other command. - -- `init` scaffolds a config file at the default path (`0600`), pre-populated with cloud-us/cloud-eu profile stubs using `env:` indirection — never inline secrets. -- `use` sets `default_profile`; `current` prints the resolved active profile; `path` prints the file location. -- `list`/`get`/`set` read and write profile values. -- Per §5.2, `init` never prompts: an existing file is left untouched unless `--force` is given. - -#### M1.6 — R2 spike: `--output table` -Decide and implement the strategy for heterogeneous nested JSON. Recommended: generic rule (list-of-objects → columns from shared keys; single object → key/value pairs; nested → JSON-encoded cell), with an optional `Endpoint.table_columns` hint for responses where the generic rule reads poorly. **If a hint field is needed, it must be added to the model now**, not retrofitted. - -#### M1.7 — `--discover` -Emit records + introspected tree (per §1.2). Include: command path, summary, HTTP method + path, every flag with type/default/enum/required/repeatable, `doc_source`, permission level, one-shot flag. - -**Exit criteria (all testable):** -1. `unstract whisper usage` succeeds end-to-end against a respx fixture. -2. `unstract --discover` emits valid JSON containing that command with complete flag metadata. -3. Exit-code table (§5.4) asserted table-driven: each HTTP status → correct code. -4. Zero-config operation works with env vars only. -5. No secret appears in `--dry-run`, `-vv`, or error output. -6. Schema expresses P1–P12 (one representative record each, unit-tested). -7. `config init` writes a `0600` file; `config use` switches the default profile; `config current` reflects the change. Both categories appear in `--discover`, with hand-authored commands flagged as non-endpoint. - ---- - -### Phase 2 — LLMWhisperer (§6.1) - -**Entry:** Phase 1 exit criteria met. - -Smallest *complete* product surface — 11 commands — and the first real test of "adding an endpoint is adding a record." - -- All §6.1 records: `extract`, `status`, `retrieve`, `detail`, `highlights`, `usage`, `usage-by-tag`, `webhook {create,get,update,delete}`. -- `--file` XOR `--url` (P1); `url_in_post=true` set automatically when `--url` is used. -- Binary `application/octet-stream` upload path (P7). -- **`--wait`** state machine (`core/poll.py`): poll `/whisper-status` until `processed`/`error`, then retrieve. `--poll-interval` (3s), `--timeout` (300s). On timeout → exit 7 with `whisper_hash` on stdout so the agent can resume. -- **One-shot handling (R5)**, mandatory: `--save` writes atomically *before* exit; a consumed result → exit 9 with an explanatory `hint`; retry logic must never replay a consumed read; help text states the one-shot behaviour. -- Pin `/whisper-detail` singular, carrying a `doc_conflict` note (§11 resolved) so the Skill won't "correct" it back. - -**Exit:** -1. All 11 commands appear in `--discover` with full metadata. -2. `--wait` reaches a terminal state against respx fixtures. -3. Second `retrieve` of the same hash → exit 9; `--save` file exists and is complete. -4. Every §6.1 parameter is reachable as a flag (asserted by diffing flags against the record set). - ---- - -### Phase 3 — Deployments runtime (§6.2) + HITL (§6.4) - -**Entry:** Phase 2 exit criteria met. - -- `deployment {run,status,highlight}` with `--api-name` required and `--org-id` profile-defaulted (P6). -- Multipart upload, ≤32 files combined across `--file` and `--presigned-url`; validate client-side → exit 2 rather than a wasted round trip. -- **R4 — the 422 defect.** `--wait` polls on the **response-body `status` field, never the HTTP status code**. Tests must assert *both* current behaviour (`422` + `EXECUTING`) and post-fix behaviour (`200` + `EXECUTING`) resolve identically. This is the single highest-value test in the suite. -- `--wait` uses `timeout=0` + polling (sync mode is deprecated upstream). -- HITL: `approved get` (dequeue — **consumes one item per call**, same one-shot treatment as R5), `bulk-download`, `download-status`. - -**Exit:** -1. `--wait` handles 422-with-EXECUTING and 200-with-EXECUTING identically (explicit test). -2. >32 files rejected client-side with exit 2. -3. `hitl approved get` documents and implements dequeue semantics; `--save` persists before exit. - ---- - -### Phase 4 — Platform Management v1 (§6.3) - -**Entry:** Phase 3 exit; **R7 confirmed** — check whether `full_access` keys are UI-creatable. If not, every `delete` command's help text must state that it requires a key the UI cannot issue. - -Largest surface (~80 endpoints) but the *least* new machinery — if Phase 1 was done right, this is predominantly record authoring plus fixtures. - -- Groups: prompt-studio, workflow, api-deployment, pipeline, adapter, connector, group, user, share. -- **P8 in practice:** derive every PATCH from its PUT. Duplicated records here would be the main drift risk. -- **P11 in practice:** the trailing-slash and `profilemanager`/`profile-manager` inconsistencies are real and load-bearing. Add a test asserting each literal path matches §6.3 exactly. -- `share --resource` enum → path-segment mapping (P3). -- `--add-user`/`--remove-user` read-modify-write helpers over replace-semantics flags (P12). -- Pagination (`--page`, `--page-size`) as a shared param group. -- Permission levels surfaced in help for destructive commands (§4.4). - -**Exit:** -1. Every §6.3 endpoint present in `--discover`. -2. Literal-path test passes (no normalization; slash inconsistencies preserved). -3. PATCH records are derived, not duplicated (asserted structurally). -4. `share` rejects an unmapped `--resource` with exit 2, not a 404. - ---- - -### Phase 5 — API Hub (§6.5) - -**Entry:** Phase 4 exit; **R6** — obtain the public base URL, or confirm none exists yet. No default is baked in; `--base-url`/`UNSTRACT_APIHUB_BASE_URL` is required until resolved. - -- `apihub {extract,status,retrieve}`, `doc-splitter {upload,status,download}`. -- `conv_*` and `ext_*` parameter families; **`--ext-param KEY=VALUE`** escape hatch (P5) — this exists precisely because API Hub has no public docs and the Skill will lag (§8.4). -- Auth: `apikey` only, plus optional BYO `--llmwhisperer-key` / `--anthropic-key`. **Assert in tests that no `X-Subscription-*` or `X-User-Id` header is ever emitted.** -- `--wait` over `QUEUED_FOR_WHISPER → QUEUED_FOR_EXTRACTION → COMPLETED`. - -**Exit:** -1. `--ext-param foo=bar` reaches the wire as `ext_foo=bar`. -2. No gateway-injected header is ever sent (explicit negative test). -3. `--wait` traverses the three-state progression. - ---- - -### Phase 6 — Claude Skill, packaging, completions - -**Entry:** Phases 1–5 exit criteria met. - -The Skill (§8) is built **last on purpose**: it operates on `endpoints/*.py`, so it needs the full record set to be meaningfully testable. - -- `.claude/skills/update-unstract-cli/SKILL.md` implementing §8.3's seven steps. -- Doc parsers: Markdown tables (LLMWhisperer, API deployment, HITL) and ``/`` MDX components (Platform v1). MDX is the harder parser — it is JSX-ish, not Markdown, so parse component props rather than table cells. -- Record parser producing the same normalized `(method, path, params[])` shape for a symmetric diff. -- Three-axis diff (new / missing / per-param drift), reported with doc citations **before** any edit. -- Safety rules from §8.5 encoded explicitly: never auto-delete; never invent params; renames → alias + deprecation, never silent; preserve richer hand-written help; honour `doc_conflict` notes; treat `draft: true` as an exclusion marker (multi-doc chat). -- API Hub changes **flagged for human review**, never auto-applied (§8.4). -- Shell completions; packaging and publish workflow. - -**Exit:** -1. **Regression harness:** run the Skill against the *current* docs → it must report **zero drift**. This is the strongest possible correctness signal, because the records were authored from exactly these docs. -2. Synthetic drift test: add a parameter to a fixture doc → the Skill detects it, cites the file, and proposes the correct record edit. -3. A `doc_conflict`-marked definition (`/whisper-detail`) is **not** reverted when the Skill sees the conflicting index page. -4. After a Skill-applied edit, `--discover` still parses and the suite passes. - ---- - -## 4. Testing strategy - -Per SPEC §9, with the sequencing that matters: - -| Layer | When | Notes | -| --- | --- | --- | -| Definition integrity | Phase 1, grows each phase | Unique `(group,name)`; valid `doc_source`; complete param metadata | -| Command generation | Phase 1 | Tree builds; `--help` renders at every level; `--discover` valid JSON | -| HTTP per endpoint | Each phase | `respx` fixtures built from the **documented sample payloads** in the docs repos | -| Exit codes | Phase 1 | Table-driven across §5.4 | -| Polling | Phases 2, 3, 5 | Body-status not HTTP code; 422 defect asserted explicitly | -| One-shot | Phases 2, 3 | Second read → exit 9; `--save` persists before exit | -| Redaction | Phase 1 | No secret in any stream, including `--dry-run` and `-vv` | -| Non-interactivity | Phase 1 | No TTY prompt anywhere; stdin only via explicit `--file -` | -| Skill round-trip | Phase 6 | Zero-drift regression against current docs | - -**Two invariants worth asserting globally**, because they are the promises an agent relies on and they are easy to break in any single command: - -- **Stdout purity** — for every command, in `--output json`, stdout parses as JSON and nothing else is written to it. -- **Flag coverage** — every `Param` in every record surfaces as a CLI flag (mechanically checkable, and it *is* SPEC goal §1.1.3). - ---- - -## 5. Sequencing summary - -``` -Task 0 Parameter pattern enumeration (P1–P12) ← load-bearing design -Phase 0 Scaffolding -Phase 1 Core spine, proven on `whisper usage` ← de-risks the whole design -Phase 2 LLMWhisperer + --wait + one-shot -Phase 3 Deployments + HITL ← R4: the 422 defect -Phase 4 Platform v1 (~80 endpoints) ← mostly records, if Phase 1 held -Phase 5 API Hub + --ext-param -Phase 6 Claude Skill + packaging ← zero-drift regression -``` - -The plan front-loads *design* risk (Task 0, Phase 1) and back-loads *volume* (Phase 4). If Phase 1's exit criteria are met honestly, Phase 4 is the least risky phase in the project despite being the largest — that is the intended payoff of the generation bet. - ---- - -## 6. Open questions gated to phases - -| Question (SPEC §11) | Gates | If unresolved | -| --- | --- | --- | -| API Hub public base URL | Phase 5 entry | Ship with no default; `--base-url`/env required. Not a blocker for Phases 0–4 | -| `full_access` UI-creatable? | Phase 4 entry | Ship `delete` commands with help text stating the requirement and the limitation | - -Neither blocks the critical path. Both must be answered *at their phase entry*, not deferred silently past it. diff --git a/README.md b/README.md index c1cf1aa..abf0d7c 100644 --- a/README.md +++ b/README.md @@ -288,5 +288,4 @@ widely-reviewed packages. The remaining risk — a *compromised release* of one those packages — is addressed by hash-pinned installs, not by removing audited dependencies: see [Reproducible, hash-pinned installs](#reproducible-hash-pinned-installs-recommended). -See [`SPEC.md`](./SPEC.md) for the full specification and -[`IMPLEMENTATION_PLAN.md`](./IMPLEMENTATION_PLAN.md) for the build sequence. +See [`SPEC.md`](./SPEC.md) for the full specification. diff --git a/pyproject.toml b/pyproject.toml index 4517c4e..243c0a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,10 +11,10 @@ dependencies = [ # below is actually imported by src/; anything unused has been removed. # # Click is pinned deliberately: `--discover` depends on the shape of - # `click.Parameter.to_info_dict()` (see IMPLEMENTATION_PLAN.md R3). A Click - # major bump could reshape that dict and silently degrade command discovery, - # so `tests/test_cli.py` asserts the keys we rely on. Click's only transitive - # dependency is colorama (Windows-only). + # `click.Parameter.to_info_dict()`. A Click major bump could reshape that + # dict and silently degrade command discovery, so `tests/test_cli.py` + # asserts the keys we rely on. Click's only transitive dependency is + # colorama (Windows-only). "click>=8.1,<9", # httpx owns TLS, redirects, connection reuse and -- the part with no stdlib # equivalent -- multipart/form-data encoding for file uploads. Kept because diff --git a/src/unstract_cli/core/model.py b/src/unstract_cli/core/model.py index 47dd8be..1820381 100644 --- a/src/unstract_cli/core/model.py +++ b/src/unstract_cli/core/model.py @@ -3,11 +3,11 @@ 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, IMPLEMENTATION_PLAN.md §1). +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 (IMPLEMENTATION_PLAN.md §2) map onto these fields as: +Parameter patterns P1-P12 map onto these fields as: === ========================== =================================================== P Pattern Encoding @@ -433,7 +433,7 @@ class Endpoint: #: 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`` (IMPLEMENTATION_PLAN.md M1.6). + #: Optional column hints for ``--output table``. table_columns: tuple[str, ...] = () #: Response key holding the payload for ``--output raw``. raw_field: str | None = None diff --git a/src/unstract_cli/core/output.py b/src/unstract_cli/core/output.py index ad48f91..4e6a377 100644 --- a/src/unstract_cli/core/output.py +++ b/src/unstract_cli/core/output.py @@ -63,7 +63,7 @@ def _rows_and_columns( ) -> tuple[list[str], list[list[str]]]: """Derive table columns and rows from arbitrary JSON. - The generic rule (IMPLEMENTATION_PLAN.md M1.6): + The generic rule: * list of objects -> columns from the union of keys, in first-seen order * single object -> two-column key/value listing diff --git a/tests/test_model.py b/tests/test_model.py index 416b243..ab59eb8 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,9 +1,9 @@ -"""Task 0 acceptance: the schema expresses parameter patterns P1-P12. +"""The schema expresses parameter patterns P1-P12. -IMPLEMENTATION_PLAN.md §2 calls this 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. +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 From 6e93b2d610b3db2575ad1c6d6d158c2860f9ee83 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Fri, 24 Jul 2026 12:20:08 +0530 Subject: [PATCH 20/21] docs: add E2E.md test-suite scenario spec A human-runnable end-to-end scenario for the `unstract` CLI: extract from an invoice, build a Prompt Studio project, export it as a tool, deploy an API, and run it -- with setup (temp config) and teardown (delete created resources) steps and a summary-report checklist. This is the same flow whose friction was captured in the recent gotcha-fix pass. --- E2E.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 E2E.md 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 From 62a531cb19dfb2c445dbc750dd1d1a4a14bcf158 Mon Sep 17 00:00:00 2001 From: Hari John Kuriakose Date: Fri, 24 Jul 2026 14:10:08 +0530 Subject: [PATCH 21/21] ci: install via uv sync --frozen instead of uv pip --system The install step ran `uv pip install --system -e ".[dev]"`, which targets the runner's `/usr` interpreter. `setup-uv` with `python-version` also provisions a project venv and exports `VIRTUAL_ENV`, and that interpreter is externally managed (PEP 668), so `--system` aborts with "externally managed environment" before any test runs. Switch to the project workflow the README documents for CI: `uv sync --frozen --extra dev` installs the exact hash-pinned tree from uv.lock into the venv (and fails if the lock is stale), and every step runs through `uv run`. Reproduced green locally: sync, ruff, mypy, pytest (245), and all three `--discover` index checks. --- .github/workflows/ci.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cdf6d8..963c9f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,23 +16,28 @@ jobs: with: python-version: "3.12" + # `uv sync --frozen` installs the exact, hash-pinned tree from uv.lock into + # a project venv -- the install path the README documents for CI. The + # earlier `uv pip install --system` targeted the runner's externally + # managed /usr interpreter and failed; `--frozen` also fails the build if + # uv.lock is stale versus pyproject.toml, which is what we want. - name: Install dependencies - run: uv pip install --system -e ".[dev]" + run: uv sync --frozen --extra dev - name: Lint - run: ruff check . + run: uv run ruff check . - name: Type check - run: mypy src + run: uv run mypy src - name: Test - run: pytest -q + run: uv 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: | - 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)" + uv run unstract --discover | python -c "import json,sys; json.load(sys.stdin)" + uv run unstract --discover --detail full | python -c "import json,sys; json.load(sys.stdin)" + uv run unstract --discover --group whisper --detail full | python -c "import json,sys; json.load(sys.stdin)"