diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..382e90e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +src/unstract/llmwhisperer/sdk_llmwhisperer/** linguist-generated=true diff --git a/.github/workflows/ci_test.yaml b/.github/workflows/ci_test.yaml index c6490a6..04894ef 100644 --- a/.github/workflows/ci_test.yaml +++ b/.github/workflows/ci_test.yaml @@ -9,6 +9,33 @@ on: branches: [main] jobs: + sdk-drift: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.6.14" + python-version: 3.12.9 + + # The generated tree is committed, so an edit to it reviews like any + # other change and then disappears on the next regeneration. Same for a + # spec change that never had the generator run over it. + - name: Regenerate from the committed spec + run: ./tools/gen_sdk.sh + + # `git add -N` first: a diff alone cannot see a file the generator has + # newly created, which is exactly what a spec growing an endpoint does. + - name: Fail if the committed SDK is not what the spec generates + run: | + git add -N -- src/unstract/llmwhisperer/sdk_llmwhisperer + git diff --exit-code -- src/unstract/llmwhisperer/sdk_llmwhisperer + test: if: github.event.pull_request.draft == false runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 5e2bde3..536688b 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,4 @@ cython_debug/ .pdm-python .python-version +.gen-venv diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fba8841..94754b9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,16 +6,22 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: + # Excluded per hook rather than globally: a formatter's fix to generated + # or vendored code is lost on the next refresh, but the security hooks + # below have to read every file in the tree. - id: trailing-whitespace + exclude: &generated "^(src/unstract/llmwhisperer/sdk_llmwhisperer/|tests/baseline/)" exclude_types: - "markdown" - id: end-of-file-fixer + exclude: *generated - id: check-yaml args: [--unsafe] - id: check-added-large-files args: ["--maxkb=10240"] - id: check-case-conflict - id: check-docstring-first + exclude: *generated - id: check-ast - id: check-json exclude: ".vscode/launch.json" @@ -29,13 +35,18 @@ repos: - id: destroyed-symlinks - id: forbid-new-submodules - id: mixed-line-ending + exclude: *generated - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.4 + # Kept in step with the pinned ruff in the dev group: two versions disagree + # on import order and docstrings, and each undoes the other's fixes. + rev: v0.11.9 hooks: - id: ruff args: [--fix] + exclude: *generated - id: ruff-format + exclude: *generated - repo: https://github.com/hadialqattan/pycln rev: v2.4.0 @@ -43,18 +54,21 @@ repos: - id: pycln entry: uv run pycln args: [--config=pyproject.toml] + exclude: *generated - repo: https://github.com/pycqa/docformatter rev: v1.7.5 hooks: - id: docformatter language: python + exclude: *generated - repo: https://github.com/asottile/pyupgrade rev: v3.17.0 hooks: - id: pyupgrade entry: pyupgrade --py39-plus --keep-runtime-typing + exclude: *generated types: - python diff --git a/README.md b/README.md index 7249953..0db015e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,22 @@ This package provides **LLMWhispererClientV2**, the client for LLMWhisperer API Documentation is available [here](https://docs.unstract.com/llmwhisperer/). +### Covered surface + +`specs/llmwhisperer.json` describes the whole service, because it is generated +from the service's own source. This client wraps a subset of it, unchanged from +what it has always wrapped. The operations it deliberately does not expose are +listed as `UNWRAPPED_OPERATIONS` in `tests/unit/compat_test.py`, which fails if +the two disagree — so that list, not this paragraph, is what to read. + +### Service version note + +A custom `page_separator` needs LLMWhisperer **v2.64.2 or later**. The query +parameter was renamed in that release; an older service reads only the previous +spelling, so it falls back to the default `<<<` separator and reports no error. +Check the service version before relying on a custom separator against a +self-hosted deployment. + ## Running Tests Install test dependencies and run all tests: diff --git a/pyproject.toml b/pyproject.toml index 24a4ec5..ec2de8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,13 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] requires-python = ">=3.12" -dependencies = ["requests>=2", "tenacity>=8.0"] +# `requests` remains a dependency for its exception classes: callers catch +# ConnectionError and Timeout by name, and the httpx equivalents are not +# subclasses of them. +# httpx is upper-bounded because the generated transport is written against +# one minor series: a bump has to be regenerated and re-tested, not resolved +# into. +dependencies = ["httpx>=0.27,<0.29", "attrs>=23.2", "requests>=2", "tenacity>=8.0"] [dependency-groups] test = [ @@ -33,10 +39,14 @@ dev = [ "mypy~=1.2.0", "pre-commit~=3.3.1", "yamllint>=1.35.1", - "ruff<1.0.0,>=0.2.2", + "ruff==0.11.9", "pytest>=8.0.1", "pycln>=2.5.0", "poethepoet>=0.34.0", + # Stub-only packages. Without them mypy reports the import itself as an + # error and cannot check any use of what it names. + "types-requests>=2.32", + "types-setuptools>=75", ] [tool.poe.tasks] @@ -86,6 +96,10 @@ exclude = [ "dist", "node_modules", "venv", + # Generated and vendored code is overwritten wholesale by its refresh + # script, so a lint finding there can never be fixed in place. + "src/unstract/llmwhisperer/sdk_llmwhisperer", + "tests/baseline", ] [tool.ruff.lint] @@ -106,6 +120,11 @@ select = [ fixable = ["ALL"] ignore = [ "D205", + # docformatter pulls a closing quote back onto the last line and ruff moves + # it off again, so with both enabled every multi-line docstring flips on + # every run and pre-commit never converges. docformatter wins here because + # it is the one that also rewraps. + "D209", "D100", # Missing docstring in public module "D101", # Missing docstring in public class "D102", # Missing docstring in public method @@ -118,6 +137,13 @@ ignore = [ "N818", ] +[tool.ruff.lint.per-file-ignores] +# The client mirrors a service that takes and returns arbitrary JSON, and its +# published signatures say so. Narrowing these annotations would describe an API +# that is not the one callers have. +"src/unstract/llmwhisperer/client_v2.py" = ["ANN401"] +"tests/**" = ["ANN401"] + [tool.ruff.format] quote-style = "double" indent-style = "space" @@ -172,4 +198,6 @@ ignore_missing_imports = true pretty = true show_column_numbers = true show_error_codes = true -exclude = ["venv", ".venv"] +# Generated and vendored code is overwritten wholesale by its refresh script, +# so a finding there can never be fixed in place. +exclude = ["venv", ".venv", "src/unstract/llmwhisperer/sdk_llmwhisperer/", "tests/baseline/"] diff --git a/specs/llmwhisperer.json b/specs/llmwhisperer.json new file mode 100644 index 0000000..8cc8dfe --- /dev/null +++ b/specs/llmwhisperer.json @@ -0,0 +1,1977 @@ +{ + "components": { + "schemas": { + "Error": { + "properties": { + "message": { + "type": "string" + } + }, + "type": "object" + }, + "WebhookConfig": { + "properties": { + "auth_token": { + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + }, + "webhook_name": { + "type": "string" + } + }, + "required": [ + "url", + "auth_token", + "webhook_name" + ], + "type": "object" + }, + "WhisperAccepted": { + "properties": { + "message": { + "type": "string" + }, + "status": { + "type": "string" + }, + "whisper_hash": { + "type": "string" + } + }, + "type": "object" + }, + "WhisperResult": { + "properties": { + "confidence_metadata": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "line_metadata": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "metadata": { + "additionalProperties": true, + "type": "object" + }, + "result_text": { + "type": "string" + }, + "webhook_metadata": { + "type": "string" + }, + "whisper_metadata": { + "additionalProperties": true, + "type": "object" + } + }, + "type": "object" + }, + "WhisperStatus": { + "properties": { + "detail": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "type": "object" + } + }, + "securitySchemes": { + "unstract_key": { + "in": "header", + "name": "unstract-key", + "type": "apiKey" + } + } + }, + "info": { + "description": "The hosted regions are listed under `servers`; a self-hosted deployment serves the same API from its own URL, which every client takes as a configuration option.", + "title": "Unstract LLMWhisperer", + "version": "v2" + }, + "openapi": "3.0.3", + "paths": { + "/api/v2/convert-to-pdf": { + "post": { + "operationId": "convert_to_pdf", + "parameters": [ + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, + { + "in": "query", + "name": "url", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/pdf": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Convert a document to PDF", + "tags": [ + "convert" + ] + } + }, + "/api/v2/convert-xlsb-to-xlsx": { + "post": { + "operationId": "convert_xlsb_to_xlsx", + "parameters": [ + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, + { + "in": "query", + "name": "url", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Convert an XLSB workbook to XLSX", + "tags": [ + "convert" + ] + } + }, + "/api/v2/document-insights": { + "post": { + "operationId": "document_insights", + "parameters": [ + { + "in": "query", + "name": "file_name", + "required": false, + "schema": { + "default": "sample.pdf", + "type": "string" + } + }, + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, + { + "in": "query", + "name": "pages_to_extract", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "default": "default", + "type": "string" + } + }, + { + "in": "query", + "name": "url", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "use_webhook", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "webhook_metadata", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": false + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhisperAccepted" + } + } + }, + "description": "Accepted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Run document insights over a file", + "tags": [ + "insights" + ] + } + }, + "/api/v2/document-insights-retrieve": { + "get": { + "operationId": "document_insights_retrieve", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Retrieve document insights result (destructive \u2014 one shot)", + "tags": [ + "insights" + ] + } + }, + "/api/v2/get-usage-info": { + "get": { + "operationId": "usage_info", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Subscription usage summary", + "tags": [ + "account" + ] + } + }, + "/api/v2/highlights": { + "get": { + "operationId": "highlights", + "parameters": [ + { + "in": "query", + "name": "extract_all_lines", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "description": "Line numbers or ranges, e.g. `1-5,9`. Not required when `extract_all_lines=true`.", + "in": "query", + "name": "lines", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, + { + "in": "query", + "name": "whisper_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Line-level highlight geometry for an extraction", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/pdf-to-images": { + "post": { + "operationId": "pdf_to_images", + "parameters": [ + { + "in": "query", + "name": "file_name", + "required": false, + "schema": { + "default": "sample.pdf", + "type": "string" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "default": "png", + "type": "string" + } + }, + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "type": "string" + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "default": "default", + "type": "string" + } + }, + { + "in": "query", + "name": "url", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": false + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhisperAccepted" + } + } + }, + "description": "Accepted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Render a PDF's pages as images", + "tags": [ + "convert" + ] + } + }, + "/api/v2/pdf-to-images-retrieve": { + "get": { + "operationId": "pdf_to_images_retrieve", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/zip": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Retrieve rendered images as a zip (destructive \u2014 one shot)", + "tags": [ + "convert" + ] + } + }, + "/api/v2/pdf-to-images-status": { + "get": { + "operationId": "pdf_to_images_status", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Poll PDF-to-images status", + "tags": [ + "convert" + ] + } + }, + "/api/v2/test-connection": { + "get": { + "operationId": "test_connection", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Verify credentials", + "tags": [ + "account" + ] + } + }, + "/api/v2/usage": { + "get": { + "operationId": "usage", + "parameters": [ + { + "in": "query", + "name": "from_date", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "tag", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "to_date", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Detailed usage statistics", + "tags": [ + "account" + ] + } + }, + "/api/v2/whisper": { + "post": { + "operationId": "extract", + "parameters": [ + { + "in": "query", + "name": "add_line_nos", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "allow_rotated_text", + "required": false, + "schema": { + "default": true, + "type": "boolean" + } + }, + { + "in": "query", + "name": "checkbox_confidence_threshold", + "required": false, + "schema": { + "default": 0.3, + "type": "number" + } + }, + { + "in": "query", + "name": "derotate_threshold", + "required": false, + "schema": { + "default": 10.0, + "type": "number" + } + }, + { + "in": "query", + "name": "file_name", + "required": false, + "schema": { + "default": "sample.pdf", + "type": "string" + } + }, + { + "in": "query", + "name": "gaussian_blur_radius", + "required": false, + "schema": { + "default": 0, + "type": "number" + } + }, + { + "in": "query", + "name": "horizontal_stretch_factor", + "required": false, + "schema": { + "default": 1.0, + "type": "number" + } + }, + { + "in": "query", + "name": "ignore_vertical_text", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "include_line_confidence", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "lang", + "required": false, + "schema": { + "default": "eng", + "type": "string" + } + }, + { + "in": "query", + "name": "line_splitter_strategy", + "required": false, + "schema": { + "default": "left-priority", + "enum": [ + "left-priority", + "mid-priority", + "right-priority" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "line_splitter_tolerance", + "required": false, + "schema": { + "default": 0.75, + "type": "number" + } + }, + { + "in": "query", + "name": "mark_horizontal_lines", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "mark_vertical_lines", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "median_filter_size", + "required": false, + "schema": { + "default": 0, + "type": "integer" + } + }, + { + "in": "query", + "name": "min_table_width", + "required": false, + "schema": { + "default": 0.0, + "type": "number" + } + }, + { + "in": "query", + "name": "mode", + "required": false, + "schema": { + "default": "form", + "enum": [ + "document_insights", + "excel", + "form", + "high_quality", + "low_cost", + "native_text", + "pdf_to_images", + "table" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "output_mode", + "required": false, + "schema": { + "default": "layout_preserving", + "enum": [ + "dump-text", + "layout_preserving", + "line-printer", + "text" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "page_separator", + "required": false, + "schema": { + "default": "<<<", + "type": "string" + } + }, + { + "in": "query", + "name": "pages_to_extract", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "tag", + "required": false, + "schema": { + "default": "default", + "type": "string" + } + }, + { + "description": "Fetch the document from this URL instead of sending a body.", + "in": "query", + "name": "url", + "required": false, + "schema": { + "format": "uri", + "type": "string" + } + }, + { + "description": "Read the URL to fetch from the request body.", + "in": "query", + "name": "url_in_post", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "use_webhook", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "watermark_angle_threshold", + "required": false, + "schema": { + "default": 25.0, + "type": "number" + } + }, + { + "in": "query", + "name": "webhook_metadata", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "word_confidence_threshold", + "required": false, + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": false + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhisperAccepted" + } + } + }, + "description": "Accepted" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Submit a document for text extraction", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/whisper-detail": { + "get": { + "operationId": "detail", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Metadata about a whisper job", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/whisper-manage-callback": { + "delete": { + "operationId": "webhook_delete", + "parameters": [ + { + "in": "query", + "name": "webhook_name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Manage extraction webhooks", + "tags": [ + "webhook" + ] + }, + "get": { + "operationId": "webhook_get", + "parameters": [ + { + "in": "query", + "name": "webhook_name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Manage extraction webhooks", + "tags": [ + "webhook" + ] + }, + "post": { + "operationId": "webhook_post", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookConfig" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Manage extraction webhooks", + "tags": [ + "webhook" + ] + }, + "put": { + "operationId": "webhook_put", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookConfig" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Manage extraction webhooks", + "tags": [ + "webhook" + ] + } + }, + "/api/v2/whisper-retrieve": { + "get": { + "operationId": "retrieve", + "parameters": [ + { + "in": "query", + "name": "text_only", + "required": false, + "schema": { + "default": false, + "type": "boolean" + } + }, + { + "in": "query", + "name": "whisper_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhisperResult" + } + }, + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Retrieve extraction result (destructive \u2014 one shot)", + "tags": [ + "whisper" + ] + } + }, + "/api/v2/whisper-status": { + "get": { + "operationId": "status", + "parameters": [ + { + "in": "query", + "name": "whisper_hash", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhisperStatus" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The request was rejected -- see `message`." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The API key is missing or not valid." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "The key is valid but not entitled to this operation." + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "No such resource." + } + }, + "summary": "Poll extraction status", + "tags": [ + "whisper" + ] + } + } + }, + "security": [ + { + "unstract_key": [] + } + ], + "servers": [ + { + "description": "US region (the default of the published clients).", + "url": "https://llmwhisperer-api.us-central.unstract.com" + }, + { + "description": "EU region.", + "url": "https://llmwhisperer-api.eu-west.unstract.com" + } + ] +} diff --git a/src/unstract/llmwhisperer/client_v2.py b/src/unstract/llmwhisperer/client_v2.py index cf97f4b..62a126f 100644 --- a/src/unstract/llmwhisperer/client_v2.py +++ b/src/unstract/llmwhisperer/client_v2.py @@ -23,14 +23,143 @@ import os import time import warnings +from types import ModuleType from typing import IO, Any +import httpx + +# `requests` remains a dependency for its exception classes. Callers catch +# ConnectionError and Timeout by name around these calls, and the httpx +# equivalents are not subclasses, so they are translated at the transport seam. import requests import tenacity from tenacity import retry_if_exception, stop_after_attempt, stop_after_delay, wait_exponential_jitter +from unstract.llmwhisperer.sdk_llmwhisperer.api.account import usage_info +from unstract.llmwhisperer.sdk_llmwhisperer.api.webhook import ( + webhook_delete, + webhook_get, + webhook_post, + webhook_put, +) +from unstract.llmwhisperer.sdk_llmwhisperer.api.whisper import detail, extract, highlights, retrieve, status +from unstract.llmwhisperer.sdk_llmwhisperer.models import WebhookConfig +from unstract.llmwhisperer.sdk_llmwhisperer.types import UNSET, File, Unset + BASE_URL_V2 = "https://llmwhisperer-api.us-central.unstract.com/api/v2" +#: The spec's paths are absolute from the service root, while ``base_url`` +#: already carries this prefix. Stripping it keeps the URL identical to the one +#: the client built by hand, for any ``base_url`` a caller configures. +_SPEC_PREFIX = "/api/v2" + +#: Query parameters each call sends. The generated builder writes every +#: spec-declared parameter, including ones this client has never sent, and +#: sending a default is not the same as omitting it: it pins a value the service +#: would otherwise choose. A new parameter must be added here to be sent at all. +_SEND_ONLY: dict[str, frozenset[str]] = { + "extract": frozenset( + { + "mode", + "output_mode", + "page_separator", + "pages_to_extract", + "median_filter_size", + "gaussian_blur_radius", + "line_splitter_tolerance", + "horizontal_stretch_factor", + "mark_vertical_lines", + "mark_horizontal_lines", + "line_splitter_strategy", + "add_line_nos", + "include_line_confidence", + "word_confidence_threshold", + "lang", + "tag", + "file_name", + "webhook_metadata", + "use_webhook", + "allow_rotated_text", + "watermark_angle_threshold", + "ignore_vertical_text", + "derotate_threshold", + "checkbox_confidence_threshold", + "min_table_width", + # In URL mode the URL travels in the body; it is not also a query + # parameter, so `url` is deliberately absent here. + "url_in_post", + } + ), + "status": frozenset({"whisper_hash"}), + "detail": frozenset({"whisper_hash"}), + "retrieve": frozenset({"whisper_hash"}), + "highlights": frozenset({"whisper_hash", "lines", "extract_all_lines"}), + "usage_info": frozenset(), + "webhook_get": frozenset({"webhook_name"}), + "webhook_delete": frozenset({"webhook_name"}), + "webhook_post": frozenset(), + "webhook_put": frozenset(), +} + + +#: Headers the released client put on the wire without being asked, which httpx +#: spells differently. `Accept-Encoding` is load-bearing: it asks for no +#: compression, and a service that gzips its response has never been exercised +#: against this client. Overridable via `custom_headers`. +_TRANSPORT_HEADERS = {"Accept-Encoding": "identity"} + + +#: httpx failure -> the ``requests`` class callers catch. First match wins, so a +#: subclass has to precede the base it derives from, and ``RequestError`` last is +#: what keeps a novel httpx failure from escaping untranslated. +_TRANSLATIONS: tuple[tuple[type[Exception], type[Exception]], ...] = ( + # requests.ConnectTimeout is both a ConnectionError and a Timeout; the plain + # Timeout httpx implies would stop matching half the callers. + (httpx.ConnectTimeout, requests.ConnectTimeout), + (httpx.ReadTimeout, requests.ReadTimeout), + # Neither had a Timeout equivalent: a send that failed and a pool that could + # not hand out a connection both surfaced as ConnectionError. + (httpx.WriteTimeout, requests.ConnectionError), + (httpx.PoolTimeout, requests.ConnectionError), + (httpx.TimeoutException, requests.Timeout), + # A URL rejected before any socket is opened. Deliberately not a + # ConnectionError: retrying a malformed URL cannot start working. + (httpx.UnsupportedProtocol, requests.exceptions.MissingSchema), + (httpx.ProxyError, requests.exceptions.ProxyError), + (httpx.ConnectError, requests.ConnectionError), + (httpx.TooManyRedirects, requests.TooManyRedirects), + (httpx.DecodingError, requests.exceptions.ContentDecodingError), + (httpx.InvalidURL, requests.exceptions.InvalidURL), + (httpx.RequestError, requests.ConnectionError), +) + + +def _translate_transport_errors(fn: Any, *args: Any, **kwargs: Any) -> Any: + """Re-raise httpx transport failures as their ``requests`` equivalents. + + Callers document and catch the ``requests`` classes, and the retry policy + keys off them too, so the class chosen here decides whether a failure is + retried. + + ``RequestError`` is the catch-all for the transport subtree, which is where + a novel failure appears. httpx puts three families outside it: ``InvalidURL``, + translated here because ``requests`` raised its own, and ``StreamError`` and + ``CookieConflict``, which propagate as themselves. + """ + try: + return fn(*args, **kwargs) + except (httpx.RequestError, httpx.InvalidURL) as e: + for failure, equivalent in _TRANSLATIONS: + if isinstance(e, failure): + raise equivalent(str(e)) from e + raise + + +def _wire_value(value: Any) -> Any: + """Render a query value the way ``requests`` did: httpx lowercases + bools.""" + return str(value) if isinstance(value, bool) else value + class LLMWhispererClientException(Exception): """Exception raised for errors in the LLMWhispererClient. @@ -70,7 +199,7 @@ class _RetryableHTTPError(Exception): """Internal exception wrapping an HTTP response with a retryable status code (429, 5xx).""" - def __init__(self, response: requests.Response) -> None: + def __init__(self, response: httpx.Response) -> None: self.response = response super().__init__(f"HTTP {response.status_code}") @@ -163,6 +292,47 @@ def __init__( self.retry_min_wait = retry_min_wait self.retry_max_wait = retry_max_wait + @property + def _transport(self) -> httpx.Client: + """The HTTP client, built on first use. + + ``follow_redirects`` is on because the previous transport followed them + by default; without it a 30x from a proxy or an http->https upgrade + surfaces as an empty response body. Timeouts are set per request. + """ + if getattr(self, "_transport_client", None) is None: + self._transport_client = httpx.Client( + headers={**_TRANSPORT_HEADERS, **self.headers}, + follow_redirects=True, + timeout=httpx.Timeout(None), + ) + return self._transport_client + + def _build_request( + self, module: ModuleType, send_only: frozenset[str] | None = None, **kwargs: Any + ) -> httpx.Request: + """Build a request from the generated builder for an operation. + + The generated code owns the URL, the parameter names and the body + encoding. What it must not own is which parameters go out: it writes + every spec-declared default, so the set is narrowed to what this client + actually sets. Its ``Content-Type`` is dropped too — the transport + derives the same one the previous client sent. + + ``send_only`` narrows further for a call whose parameter set varies with + its arguments; it must stay within the operation's declared set. + """ + declared = _SEND_ONLY[module.__name__.rsplit(".", 1)[-1]] + if send_only is None: + send_only = declared + elif not send_only <= declared: + raise LLMWhispererClientException(f"Undeclared parameters: {sorted(send_only - declared)}", 1) + built = module._get_kwargs(**kwargs) + params = {k: _wire_value(v) for k, v in built.pop("params", {}).items() if k in send_only} + built.pop("headers", None) + url = self.base_url + built.pop("url").removeprefix(_SPEC_PREFIX) + return self._transport.build_request(built.pop("method").upper(), url, params=params, **built) + @staticmethod def _is_retryable(exc: BaseException) -> bool: """Return True if the exception represents a transient/retryable @@ -198,13 +368,26 @@ def _retry_wait(self, retry_state: tenacity.RetryCallState) -> float: max=self.retry_max_wait, )(retry_state=retry_state) + def _send(self, request: httpx.Request, *, timeout: float, stream: bool = False) -> httpx.Response: + """Issue one request, translating transport failures on the way out. + + Translation happens here rather than around the retry loop, so + the retry predicate still sees the exception types it is + configured to retry. + """ + request.extensions = {**request.extensions, "timeout": httpx.Timeout(timeout).as_dict()} + response: httpx.Response = _translate_transport_errors(self._transport.send, request, stream=stream) + if stream: + _translate_transport_errors(response.read) + return response + def _send_request( self, - prepared: requests.PreparedRequest, + prepared: httpx.Request, timeout: int | None = None, stream: bool = False, deadline: float | None = None, - ) -> requests.Response: + ) -> httpx.Response: """Send an HTTP request with optional tenacity retry on transient errors. @@ -235,12 +418,10 @@ def _effective_timeout() -> int | float: return req_timeout if self.max_retries == 0: - s = requests.Session() - return s.send(prepared, timeout=_effective_timeout(), stream=stream) + return self._send(prepared, timeout=_effective_timeout(), stream=stream) - def _attempt() -> requests.Response: - s = requests.Session() - response = s.send(prepared, timeout=_effective_timeout(), stream=stream) + def _attempt() -> httpx.Response: + response = self._send(prepared, timeout=_effective_timeout(), stream=stream) if response.status_code == 429 or response.status_code >= 500: raise _RetryableHTTPError(response) return response @@ -277,10 +458,8 @@ def get_usage_info(self) -> Any: the error message and status code returned by the API. """ self.logger.debug("get_usage_info called") - url = f"{self.base_url}/get-usage-info" - self.logger.debug("url: %s", url) - req = requests.Request("GET", url, headers=self.headers) - prepared = req.prepare() + prepared = self._build_request(usage_info) + self.logger.debug("url: %s", prepared.url) response = self._send_request(prepared) if response.status_code != 200: err = json.loads(response.text) @@ -310,15 +489,13 @@ def get_highlight_data(self, whisper_hash: str, lines: str, extract_all_lines: b the error message and status code returned by the API. """ self.logger.debug("highlight called") - url = f"{self.base_url}/highlights" - params = { - "whisper_hash": whisper_hash, - "lines": lines, - "extract_all_lines": extract_all_lines, - } - self.logger.debug("url: %s", url) - req = requests.Request("GET", url, headers=self.headers, params=params) - prepared = req.prepare() + prepared = self._build_request( + highlights, + whisper_hash=whisper_hash, + lines=lines, + extract_all_lines=extract_all_lines, + ) + self.logger.debug("url: %s", prepared.url) response = self._send_request(prepared) if response.status_code != 200: err = json.loads(response.text) @@ -347,13 +524,10 @@ def whisper_detail(self, whisper_hash: str) -> Any: the error message and status code returned by the API. """ self.logger.debug("whisper_detail called") - url = f"{self.base_url}/whisper-detail" - params = {"whisper_hash": whisper_hash} - self.logger.debug("url: %s", url) + prepared = self._build_request(detail, whisper_hash=whisper_hash) + self.logger.debug("url: %s", prepared.url) self.logger.debug("whisper_hash: %s", whisper_hash) - req = requests.Request("GET", url, headers=self.headers, params=params) - prepared = req.prepare() response = self._send_request(prepared) if response.status_code != 200: if not (response.text or "").strip(): @@ -410,7 +584,7 @@ def _resolve_deprecated_param( warnings.warn(message, DeprecationWarning, stacklevel=3) return deprecated_value if forward else default - def whisper( + def whisper( # noqa: C901 self, file_path: str = "", stream: IO[bytes] | None = None, @@ -440,6 +614,13 @@ def whisper( page_separator: str | None = None, line_splitter_strategy: str | None = None, file_name: str | None = None, + *, + allow_rotated_text: bool | Unset = UNSET, + watermark_angle_threshold: float | Unset = UNSET, + ignore_vertical_text: bool | Unset = UNSET, + derotate_threshold: float | Unset = UNSET, + checkbox_confidence_threshold: float | Unset = UNSET, + min_table_width: float | Unset = UNSET, ) -> Any: """Sends a request to the LLMWhisperer API to process a document. Refer to https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_text_extraction_api. @@ -490,6 +671,21 @@ def whisper( line_splitter_strategy (str, optional): The line splitter strategy. Defaults to "left-priority". file_name (str, optional): The name of the file to store in reports. Defaults to "". + allow_rotated_text (bool, optional): Whether to keep words whose own orientation is + rotated. With this off, a word angled further than watermark_angle_threshold is + treated as a watermark and excluded. Defaults to True. + watermark_angle_threshold (float, optional): The angle in degrees beyond which a + rotated word counts as a watermark. Only applies when allow_rotated_text is off. + Defaults to 25.0. + ignore_vertical_text (bool, optional): Whether to drop vertically oriented text + instead of extracting it. Defaults to False. + derotate_threshold (float, optional): The page rotation in degrees beyond which the + page is straightened and re-read. Defaults to 10.0. + checkbox_confidence_threshold (float, optional): The minimum confidence a detected + checkbox mark must have to be reported as marked. Accepts a value in the range + [0.0, 1.0]. Defaults to 0.3. + min_table_width (float, optional): The minimum width a table must span, as a + fraction of the page width, to be extracted as a table. Defaults to 0. Returns: Dict[Any, Any]: The response from the API as a dictionary. @@ -535,6 +731,25 @@ def whisper( "webhook_metadata": webhook_metadata, "use_webhook": use_webhook, } + # Only what the caller asked for. These have no default here on purpose: + # sending one pins a value the service would otherwise choose, and the + # two diverge the moment the service's own default moves. ``None`` is + # dropped with ``UNSET``: a query string carries no null, so it would go + # out as the literal string "None". + params.update( + { + name: value + for name, value in ( + ("allow_rotated_text", allow_rotated_text), + ("watermark_angle_threshold", watermark_angle_threshold), + ("ignore_vertical_text", ignore_vertical_text), + ("derotate_threshold", derotate_threshold), + ("checkbox_confidence_threshold", checkbox_confidence_threshold), + ("min_table_width", min_table_width), + ) + if not isinstance(value, Unset) and value is not None + } + ) self.logger.debug("api_url: %s", api_url) self.logger.debug("params: %s", params) @@ -553,28 +768,18 @@ def whisper( if stream is not None: should_stream = True data = b"".join(stream) - req = requests.Request( - "POST", - api_url, - params=params, - headers=self.headers, - data=data, - ) - else: with open(file_path, "rb") as f: data = f.read() - req = requests.Request( - "POST", - api_url, - params=params, - headers=self.headers, - data=data, - ) else: + # The URL travels in the body, not on the query string; url_in_post + # is what tells the service to read it from there. params["url_in_post"] = True - req = requests.Request("POST", api_url, params=params, headers=self.headers, data=url) - prepared = req.prepare() + data = url.encode() + # The wire carries exactly the parameters assembled above — url_in_post + # only exists in URL mode, and the generated default would otherwise + # send it on every upload. + prepared = self._build_request(extract, frozenset(params), body=File(payload=data), **params) start_time = time.time() deadline = start_time + wait_timeout post_timeout = min(self.api_timeout, wait_timeout) @@ -676,11 +881,8 @@ def whisper_status(self, whisper_hash: str) -> Any: the error message and status code returned by the API. """ self.logger.debug("whisper_status called") - url = f"{self.base_url}/whisper-status" - params = {"whisper_hash": whisper_hash} - self.logger.debug("url: %s", url) - req = requests.Request("GET", url, headers=self.headers, params=params) - prepared = req.prepare() + prepared = self._build_request(status, whisper_hash=whisper_hash) + self.logger.debug("url: %s", prepared.url) response = self._send_request(prepared) if response.status_code != 200: if not (response.text or "").strip(): @@ -721,11 +923,8 @@ def whisper_retrieve(self, whisper_hash: str, encoding: str = "utf-8") -> Any: the error message and status code returned by the API. """ self.logger.debug("whisper_retrieve called") - url = f"{self.base_url}/whisper-retrieve" - params = {"whisper_hash": whisper_hash} - self.logger.debug("url: %s", url) - req = requests.Request("GET", url, headers=self.headers, params=params) - prepared = req.prepare() + prepared = self._build_request(retrieve, whisper_hash=whisper_hash) + self.logger.debug("url: %s", prepared.url) response = self._send_request(prepared) response.encoding = encoding if response.status_code != 200: @@ -758,14 +957,8 @@ def register_webhook(self, url: str, auth_token: str, webhook_name: str) -> Any: LLMWhispererClientException: If the API request fails, it raises an exception with the error message and status code returned by the API. """ - data = { - "url": url, - "auth_token": auth_token, - "webhook_name": webhook_name, - } - url = f"{self.base_url}/whisper-manage-callback" - req = requests.Request("POST", url, headers=self.headers, json=data) - prepared = req.prepare() + body = WebhookConfig(url=url, auth_token=auth_token, webhook_name=webhook_name) + prepared = self._build_request(webhook_post, body=body) response = self._send_request(prepared) if response.status_code != 201: err = json.loads(response.text) @@ -793,14 +986,8 @@ def update_webhook_details(self, webhook_name: str, url: str, auth_token: str) - LLMWhispererClientException: If the API request fails, it raises an exception with the error message and status code returned by the API. """ - data = { - "url": url, - "auth_token": auth_token, - "webhook_name": webhook_name, - } - url = f"{self.base_url}/whisper-manage-callback" - req = requests.Request("PUT", url, headers=self.headers, json=data) - prepared = req.prepare() + body = WebhookConfig(url=url, auth_token=auth_token, webhook_name=webhook_name) + prepared = self._build_request(webhook_put, body=body) response = self._send_request(prepared) if response.status_code != 200: err = json.loads(response.text) @@ -826,10 +1013,7 @@ def get_webhook_details(self, webhook_name: str) -> Any: LLMWhispererClientException: If the API request fails, it raises an exception with the error message and status code returned by the API. """ - url = f"{self.base_url}/whisper-manage-callback" - params = {"webhook_name": webhook_name} - req = requests.Request("GET", url, headers=self.headers, params=params) - prepared = req.prepare() + prepared = self._build_request(webhook_get, webhook_name=webhook_name) response = self._send_request(prepared) if response.status_code != 200: err = json.loads(response.text) @@ -855,10 +1039,7 @@ def delete_webhook(self, webhook_name: str) -> Any: LLMWhispererClientException: If the API request fails, it raises an exception with the error message and status code returned by the API. """ - url = f"{self.base_url}/whisper-manage-callback" - params = {"webhook_name": webhook_name} - req = requests.Request("DELETE", url, headers=self.headers, params=params) - prepared = req.prepare() + prepared = self._build_request(webhook_delete, webhook_name=webhook_name) response = self._send_request(prepared) if response.status_code != 200: err = json.loads(response.text) diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/__init__.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/__init__.py new file mode 100644 index 0000000..fb34241 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/__init__.py @@ -0,0 +1,9 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""A client library for accessing Unstract LLMWhisperer""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/__init__.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/__init__.py new file mode 100644 index 0000000..7254a95 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/__init__.py @@ -0,0 +1,2 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""Contains methods for accessing the API""" diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/__init__.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/__init__.py new file mode 100644 index 0000000..7e2ea57 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/__init__.py @@ -0,0 +1,2 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""Contains endpoint functions for accessing the API""" diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/test_connection.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/test_connection.py new file mode 100644 index 0000000..4f93e88 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/test_connection.py @@ -0,0 +1,146 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.test_connection_response_200 import TestConnectionResponse200 +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/test-connection", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | TestConnectionResponse200 | None: + if response.status_code == 200: + response_200 = TestConnectionResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | TestConnectionResponse200]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Error | TestConnectionResponse200]: + """Verify credentials + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | TestConnectionResponse200] + """ + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, +) -> Error | TestConnectionResponse200 | None: + """Verify credentials + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | TestConnectionResponse200 + """ + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Error | TestConnectionResponse200]: + """Verify credentials + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | TestConnectionResponse200] + """ + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, +) -> Error | TestConnectionResponse200 | None: + """Verify credentials + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | TestConnectionResponse200 + """ + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/usage.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/usage.py new file mode 100644 index 0000000..7b129b0 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/usage.py @@ -0,0 +1,208 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.usage_response_200 import UsageResponse200 +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + from_date: str | Unset = UNSET, + tag: str, + to_date: str | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["from_date"] = from_date + + params["tag"] = tag + + params["to_date"] = to_date + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/usage", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | UsageResponse200 | None: + if response.status_code == 200: + response_200 = UsageResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | UsageResponse200]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + from_date: str | Unset = UNSET, + tag: str, + to_date: str | Unset = UNSET, +) -> Response[Error | UsageResponse200]: + """Detailed usage statistics + + Args: + from_date (str | Unset): + tag (str): + to_date (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | UsageResponse200] + """ + kwargs = _get_kwargs( + from_date=from_date, + tag=tag, + to_date=to_date, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + from_date: str | Unset = UNSET, + tag: str, + to_date: str | Unset = UNSET, +) -> Error | UsageResponse200 | None: + """Detailed usage statistics + + Args: + from_date (str | Unset): + tag (str): + to_date (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | UsageResponse200 + """ + return sync_detailed( + client=client, + from_date=from_date, + tag=tag, + to_date=to_date, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + from_date: str | Unset = UNSET, + tag: str, + to_date: str | Unset = UNSET, +) -> Response[Error | UsageResponse200]: + """Detailed usage statistics + + Args: + from_date (str | Unset): + tag (str): + to_date (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | UsageResponse200] + """ + kwargs = _get_kwargs( + from_date=from_date, + tag=tag, + to_date=to_date, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + from_date: str | Unset = UNSET, + tag: str, + to_date: str | Unset = UNSET, +) -> Error | UsageResponse200 | None: + """Detailed usage statistics + + Args: + from_date (str | Unset): + tag (str): + to_date (str | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | UsageResponse200 + """ + return ( + await asyncio_detailed( + client=client, + from_date=from_date, + tag=tag, + to_date=to_date, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/usage_info.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/usage_info.py new file mode 100644 index 0000000..75e8e39 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/account/usage_info.py @@ -0,0 +1,146 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.usage_info_response_200 import UsageInfoResponse200 +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/get-usage-info", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | UsageInfoResponse200 | None: + if response.status_code == 200: + response_200 = UsageInfoResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | UsageInfoResponse200]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Error | UsageInfoResponse200]: + """Subscription usage summary + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | UsageInfoResponse200] + """ + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, +) -> Error | UsageInfoResponse200 | None: + """Subscription usage summary + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | UsageInfoResponse200 + """ + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, +) -> Response[Error | UsageInfoResponse200]: + """Subscription usage summary + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | UsageInfoResponse200] + """ + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, +) -> Error | UsageInfoResponse200 | None: + """Subscription usage summary + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | UsageInfoResponse200 + """ + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/__init__.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/__init__.py new file mode 100644 index 0000000..7e2ea57 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/__init__.py @@ -0,0 +1,2 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""Contains endpoint functions for accessing the API""" diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/convert_to_pdf.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/convert_to_pdf.py new file mode 100644 index 0000000..334eb18 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/convert_to_pdf.py @@ -0,0 +1,223 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from io import BytesIO +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import UNSET, File, Response, Unset + + +def _get_kwargs( + *, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + params: dict[str, Any] = {} + + params["mode"] = mode + + params["url"] = url_query + + params["url_in_post"] = url_in_post + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v2/convert-to-pdf", + "params": params, + } + + if not isinstance(body, Unset): + _kwargs["content"] = body.payload + headers["Content-Type"] = "application/octet-stream" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | File | None: + if response.status_code == 200: + response_200 = File(payload=BytesIO(response.content)) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Error | File]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Response[Error | File]: + """Convert a document to PDF + + Args: + mode (str | Unset): Default: 'form'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | File] + """ + kwargs = _get_kwargs( + body=body, + mode=mode, + url_query=url_query, + url_in_post=url_in_post, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Error | File | None: + """Convert a document to PDF + + Args: + mode (str | Unset): Default: 'form'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | File + """ + return sync_detailed( + client=client, + body=body, + mode=mode, + url_query=url_query, + url_in_post=url_in_post, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Response[Error | File]: + """Convert a document to PDF + + Args: + mode (str | Unset): Default: 'form'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | File] + """ + kwargs = _get_kwargs( + body=body, + mode=mode, + url_query=url_query, + url_in_post=url_in_post, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Error | File | None: + """Convert a document to PDF + + Args: + mode (str | Unset): Default: 'form'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | File + """ + return ( + await asyncio_detailed( + client=client, + body=body, + mode=mode, + url_query=url_query, + url_in_post=url_in_post, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/convert_xlsb_to_xlsx.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/convert_xlsb_to_xlsx.py new file mode 100644 index 0000000..c3ec4bc --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/convert_xlsb_to_xlsx.py @@ -0,0 +1,223 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from io import BytesIO +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import UNSET, File, Response, Unset + + +def _get_kwargs( + *, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + params: dict[str, Any] = {} + + params["mode"] = mode + + params["url"] = url_query + + params["url_in_post"] = url_in_post + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v2/convert-xlsb-to-xlsx", + "params": params, + } + + if not isinstance(body, Unset): + _kwargs["content"] = body.payload + headers["Content-Type"] = "application/octet-stream" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | File | None: + if response.status_code == 200: + response_200 = File(payload=BytesIO(response.content)) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Error | File]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Response[Error | File]: + """Convert an XLSB workbook to XLSX + + Args: + mode (str | Unset): Default: 'form'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | File] + """ + kwargs = _get_kwargs( + body=body, + mode=mode, + url_query=url_query, + url_in_post=url_in_post, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Error | File | None: + """Convert an XLSB workbook to XLSX + + Args: + mode (str | Unset): Default: 'form'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | File + """ + return sync_detailed( + client=client, + body=body, + mode=mode, + url_query=url_query, + url_in_post=url_in_post, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Response[Error | File]: + """Convert an XLSB workbook to XLSX + + Args: + mode (str | Unset): Default: 'form'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | File] + """ + kwargs = _get_kwargs( + body=body, + mode=mode, + url_query=url_query, + url_in_post=url_in_post, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + mode: str | Unset = "form", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Error | File | None: + """Convert an XLSB workbook to XLSX + + Args: + mode (str | Unset): Default: 'form'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | File + """ + return ( + await asyncio_detailed( + client=client, + body=body, + mode=mode, + url_query=url_query, + url_in_post=url_in_post, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/pdf_to_images.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/pdf_to_images.py new file mode 100644 index 0000000..51a8047 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/pdf_to_images.py @@ -0,0 +1,272 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.whisper_accepted import WhisperAccepted +from ...types import UNSET, File, Response, Unset + + +def _get_kwargs( + *, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + format_: str | Unset = "png", + mode: str | Unset = "form", + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + params: dict[str, Any] = {} + + params["file_name"] = file_name + + params["format"] = format_ + + params["mode"] = mode + + params["tag"] = tag + + params["url"] = url_query + + params["url_in_post"] = url_in_post + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v2/pdf-to-images", + "params": params, + } + + if not isinstance(body, Unset): + _kwargs["content"] = body.payload + headers["Content-Type"] = "application/octet-stream" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | WhisperAccepted | None: + if response.status_code == 202: + response_202 = WhisperAccepted.from_dict(response.json()) + + return response_202 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | WhisperAccepted]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + format_: str | Unset = "png", + mode: str | Unset = "form", + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Response[Error | WhisperAccepted]: + """Render a PDF's pages as images + + Args: + file_name (str | Unset): Default: 'sample.pdf'. + format_ (str | Unset): Default: 'png'. + mode (str | Unset): Default: 'form'. + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperAccepted] + """ + kwargs = _get_kwargs( + body=body, + file_name=file_name, + format_=format_, + mode=mode, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + format_: str | Unset = "png", + mode: str | Unset = "form", + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Error | WhisperAccepted | None: + """Render a PDF's pages as images + + Args: + file_name (str | Unset): Default: 'sample.pdf'. + format_ (str | Unset): Default: 'png'. + mode (str | Unset): Default: 'form'. + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperAccepted + """ + return sync_detailed( + client=client, + body=body, + file_name=file_name, + format_=format_, + mode=mode, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + format_: str | Unset = "png", + mode: str | Unset = "form", + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Response[Error | WhisperAccepted]: + """Render a PDF's pages as images + + Args: + file_name (str | Unset): Default: 'sample.pdf'. + format_ (str | Unset): Default: 'png'. + mode (str | Unset): Default: 'form'. + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperAccepted] + """ + kwargs = _get_kwargs( + body=body, + file_name=file_name, + format_=format_, + mode=mode, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + format_: str | Unset = "png", + mode: str | Unset = "form", + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, +) -> Error | WhisperAccepted | None: + """Render a PDF's pages as images + + Args: + file_name (str | Unset): Default: 'sample.pdf'. + format_ (str | Unset): Default: 'png'. + mode (str | Unset): Default: 'form'. + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperAccepted + """ + return ( + await asyncio_detailed( + client=client, + body=body, + file_name=file_name, + format_=format_, + mode=mode, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/pdf_to_images_retrieve.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/pdf_to_images_retrieve.py new file mode 100644 index 0000000..dc9ec1d --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/pdf_to_images_retrieve.py @@ -0,0 +1,174 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from io import BytesIO +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import UNSET, File, Response + + +def _get_kwargs( + *, + whisper_hash: str, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["whisper_hash"] = whisper_hash + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/pdf-to-images-retrieve", + "params": params, + } + + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | File | None: + if response.status_code == 200: + response_200 = File(payload=BytesIO(response.content)) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Error | File]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[Error | File]: + """Retrieve rendered images as a zip (destructive — one shot) + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | File] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Error | File | None: + """Retrieve rendered images as a zip (destructive — one shot) + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | File + """ + return sync_detailed( + client=client, + whisper_hash=whisper_hash, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[Error | File]: + """Retrieve rendered images as a zip (destructive — one shot) + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | File] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Error | File | None: + """Retrieve rendered images as a zip (destructive — one shot) + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | File + """ + return ( + await asyncio_detailed( + client=client, + whisper_hash=whisper_hash, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/pdf_to_images_status.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/pdf_to_images_status.py new file mode 100644 index 0000000..0664810 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/convert/pdf_to_images_status.py @@ -0,0 +1,178 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.pdf_to_images_status_response_200 import PdfToImagesStatusResponse200 +from ...types import UNSET, Response + + +def _get_kwargs( + *, + whisper_hash: str, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["whisper_hash"] = whisper_hash + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/pdf-to-images-status", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | PdfToImagesStatusResponse200 | None: + if response.status_code == 200: + response_200 = PdfToImagesStatusResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | PdfToImagesStatusResponse200]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[Error | PdfToImagesStatusResponse200]: + """Poll PDF-to-images status + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | PdfToImagesStatusResponse200] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Error | PdfToImagesStatusResponse200 | None: + """Poll PDF-to-images status + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | PdfToImagesStatusResponse200 + """ + return sync_detailed( + client=client, + whisper_hash=whisper_hash, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[Error | PdfToImagesStatusResponse200]: + """Poll PDF-to-images status + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | PdfToImagesStatusResponse200] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Error | PdfToImagesStatusResponse200 | None: + """Poll PDF-to-images status + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | PdfToImagesStatusResponse200 + """ + return ( + await asyncio_detailed( + client=client, + whisper_hash=whisper_hash, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/insights/__init__.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/insights/__init__.py new file mode 100644 index 0000000..7e2ea57 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/insights/__init__.py @@ -0,0 +1,2 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""Contains endpoint functions for accessing the API""" diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/insights/document_insights.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/insights/document_insights.py new file mode 100644 index 0000000..e914b42 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/insights/document_insights.py @@ -0,0 +1,302 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.whisper_accepted import WhisperAccepted +from ...types import UNSET, File, Response, Unset + + +def _get_kwargs( + *, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + mode: str | Unset = "form", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + webhook_metadata: str | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + params: dict[str, Any] = {} + + params["file_name"] = file_name + + params["mode"] = mode + + params["pages_to_extract"] = pages_to_extract + + params["tag"] = tag + + params["url"] = url_query + + params["url_in_post"] = url_in_post + + params["use_webhook"] = use_webhook + + params["webhook_metadata"] = webhook_metadata + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v2/document-insights", + "params": params, + } + + if not isinstance(body, Unset): + _kwargs["content"] = body.payload + headers["Content-Type"] = "application/octet-stream" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | WhisperAccepted | None: + if response.status_code == 202: + response_202 = WhisperAccepted.from_dict(response.json()) + + return response_202 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | WhisperAccepted]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + mode: str | Unset = "form", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + webhook_metadata: str | Unset = UNSET, +) -> Response[Error | WhisperAccepted]: + """Run document insights over a file + + Args: + file_name (str | Unset): Default: 'sample.pdf'. + mode (str | Unset): Default: 'form'. + pages_to_extract (str | Unset): + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + use_webhook (str | Unset): + webhook_metadata (str | Unset): + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperAccepted] + """ + kwargs = _get_kwargs( + body=body, + file_name=file_name, + mode=mode, + pages_to_extract=pages_to_extract, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + use_webhook=use_webhook, + webhook_metadata=webhook_metadata, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + mode: str | Unset = "form", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + webhook_metadata: str | Unset = UNSET, +) -> Error | WhisperAccepted | None: + """Run document insights over a file + + Args: + file_name (str | Unset): Default: 'sample.pdf'. + mode (str | Unset): Default: 'form'. + pages_to_extract (str | Unset): + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + use_webhook (str | Unset): + webhook_metadata (str | Unset): + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperAccepted + """ + return sync_detailed( + client=client, + body=body, + file_name=file_name, + mode=mode, + pages_to_extract=pages_to_extract, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + use_webhook=use_webhook, + webhook_metadata=webhook_metadata, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + mode: str | Unset = "form", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + webhook_metadata: str | Unset = UNSET, +) -> Response[Error | WhisperAccepted]: + """Run document insights over a file + + Args: + file_name (str | Unset): Default: 'sample.pdf'. + mode (str | Unset): Default: 'form'. + pages_to_extract (str | Unset): + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + use_webhook (str | Unset): + webhook_metadata (str | Unset): + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperAccepted] + """ + kwargs = _get_kwargs( + body=body, + file_name=file_name, + mode=mode, + pages_to_extract=pages_to_extract, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + use_webhook=use_webhook, + webhook_metadata=webhook_metadata, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + file_name: str | Unset = "sample.pdf", + mode: str | Unset = "form", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + webhook_metadata: str | Unset = UNSET, +) -> Error | WhisperAccepted | None: + """Run document insights over a file + + Args: + file_name (str | Unset): Default: 'sample.pdf'. + mode (str | Unset): Default: 'form'. + pages_to_extract (str | Unset): + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + use_webhook (str | Unset): + webhook_metadata (str | Unset): + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperAccepted + """ + return ( + await asyncio_detailed( + client=client, + body=body, + file_name=file_name, + mode=mode, + pages_to_extract=pages_to_extract, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + use_webhook=use_webhook, + webhook_metadata=webhook_metadata, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/insights/document_insights_retrieve.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/insights/document_insights_retrieve.py new file mode 100644 index 0000000..20baf37 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/insights/document_insights_retrieve.py @@ -0,0 +1,178 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.document_insights_retrieve_response_200 import DocumentInsightsRetrieveResponse200 +from ...models.error import Error +from ...types import UNSET, Response + + +def _get_kwargs( + *, + whisper_hash: str, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["whisper_hash"] = whisper_hash + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/document-insights-retrieve", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> DocumentInsightsRetrieveResponse200 | Error | None: + if response.status_code == 200: + response_200 = DocumentInsightsRetrieveResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[DocumentInsightsRetrieveResponse200 | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[DocumentInsightsRetrieveResponse200 | Error]: + """Retrieve document insights result (destructive — one shot) + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DocumentInsightsRetrieveResponse200 | Error] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> DocumentInsightsRetrieveResponse200 | Error | None: + """Retrieve document insights result (destructive — one shot) + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DocumentInsightsRetrieveResponse200 | Error + """ + return sync_detailed( + client=client, + whisper_hash=whisper_hash, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[DocumentInsightsRetrieveResponse200 | Error]: + """Retrieve document insights result (destructive — one shot) + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DocumentInsightsRetrieveResponse200 | Error] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> DocumentInsightsRetrieveResponse200 | Error | None: + """Retrieve document insights result (destructive — one shot) + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DocumentInsightsRetrieveResponse200 | Error + """ + return ( + await asyncio_detailed( + client=client, + whisper_hash=whisper_hash, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/__init__.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/__init__.py new file mode 100644 index 0000000..7e2ea57 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/__init__.py @@ -0,0 +1,2 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""Contains endpoint functions for accessing the API""" diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_delete.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_delete.py new file mode 100644 index 0000000..bf3f7dd --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_delete.py @@ -0,0 +1,178 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.webhook_delete_response_200 import WebhookDeleteResponse200 +from ...types import UNSET, Response + + +def _get_kwargs( + *, + webhook_name: str, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["webhook_name"] = webhook_name + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "delete", + "url": "/api/v2/whisper-manage-callback", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | WebhookDeleteResponse200 | None: + if response.status_code == 200: + response_200 = WebhookDeleteResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | WebhookDeleteResponse200]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + webhook_name: str, +) -> Response[Error | WebhookDeleteResponse200]: + """Manage extraction webhooks + + Args: + webhook_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WebhookDeleteResponse200] + """ + kwargs = _get_kwargs( + webhook_name=webhook_name, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + webhook_name: str, +) -> Error | WebhookDeleteResponse200 | None: + """Manage extraction webhooks + + Args: + webhook_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WebhookDeleteResponse200 + """ + return sync_detailed( + client=client, + webhook_name=webhook_name, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + webhook_name: str, +) -> Response[Error | WebhookDeleteResponse200]: + """Manage extraction webhooks + + Args: + webhook_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WebhookDeleteResponse200] + """ + kwargs = _get_kwargs( + webhook_name=webhook_name, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + webhook_name: str, +) -> Error | WebhookDeleteResponse200 | None: + """Manage extraction webhooks + + Args: + webhook_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WebhookDeleteResponse200 + """ + return ( + await asyncio_detailed( + client=client, + webhook_name=webhook_name, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_get.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_get.py new file mode 100644 index 0000000..093cf6d --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_get.py @@ -0,0 +1,178 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.webhook_get_response_200 import WebhookGetResponse200 +from ...types import UNSET, Response + + +def _get_kwargs( + *, + webhook_name: str, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["webhook_name"] = webhook_name + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/whisper-manage-callback", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | WebhookGetResponse200 | None: + if response.status_code == 200: + response_200 = WebhookGetResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | WebhookGetResponse200]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + webhook_name: str, +) -> Response[Error | WebhookGetResponse200]: + """Manage extraction webhooks + + Args: + webhook_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WebhookGetResponse200] + """ + kwargs = _get_kwargs( + webhook_name=webhook_name, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + webhook_name: str, +) -> Error | WebhookGetResponse200 | None: + """Manage extraction webhooks + + Args: + webhook_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WebhookGetResponse200 + """ + return sync_detailed( + client=client, + webhook_name=webhook_name, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + webhook_name: str, +) -> Response[Error | WebhookGetResponse200]: + """Manage extraction webhooks + + Args: + webhook_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WebhookGetResponse200] + """ + kwargs = _get_kwargs( + webhook_name=webhook_name, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + webhook_name: str, +) -> Error | WebhookGetResponse200 | None: + """Manage extraction webhooks + + Args: + webhook_name (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WebhookGetResponse200 + """ + return ( + await asyncio_detailed( + client=client, + webhook_name=webhook_name, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_post.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_post.py new file mode 100644 index 0000000..4556683 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_post.py @@ -0,0 +1,178 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.webhook_config import WebhookConfig +from ...models.webhook_post_response_201 import WebhookPostResponse201 +from ...types import Response + + +def _get_kwargs( + *, + body: WebhookConfig, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v2/whisper-manage-callback", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | WebhookPostResponse201 | None: + if response.status_code == 201: + response_201 = WebhookPostResponse201.from_dict(response.json()) + + return response_201 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | WebhookPostResponse201]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: WebhookConfig, +) -> Response[Error | WebhookPostResponse201]: + """Manage extraction webhooks + + Args: + body (WebhookConfig): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WebhookPostResponse201] + """ + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: WebhookConfig, +) -> Error | WebhookPostResponse201 | None: + """Manage extraction webhooks + + Args: + body (WebhookConfig): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WebhookPostResponse201 + """ + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: WebhookConfig, +) -> Response[Error | WebhookPostResponse201]: + """Manage extraction webhooks + + Args: + body (WebhookConfig): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WebhookPostResponse201] + """ + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: WebhookConfig, +) -> Error | WebhookPostResponse201 | None: + """Manage extraction webhooks + + Args: + body (WebhookConfig): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WebhookPostResponse201 + """ + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_put.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_put.py new file mode 100644 index 0000000..f843dcc --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/webhook/webhook_put.py @@ -0,0 +1,178 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.webhook_config import WebhookConfig +from ...models.webhook_put_response_200 import WebhookPutResponse200 +from ...types import Response + + +def _get_kwargs( + *, + body: WebhookConfig, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "put", + "url": "/api/v2/whisper-manage-callback", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | WebhookPutResponse200 | None: + if response.status_code == 200: + response_200 = WebhookPutResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | WebhookPutResponse200]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: WebhookConfig, +) -> Response[Error | WebhookPutResponse200]: + """Manage extraction webhooks + + Args: + body (WebhookConfig): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WebhookPutResponse200] + """ + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: WebhookConfig, +) -> Error | WebhookPutResponse200 | None: + """Manage extraction webhooks + + Args: + body (WebhookConfig): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WebhookPutResponse200 + """ + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: WebhookConfig, +) -> Response[Error | WebhookPutResponse200]: + """Manage extraction webhooks + + Args: + body (WebhookConfig): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WebhookPutResponse200] + """ + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: WebhookConfig, +) -> Error | WebhookPutResponse200 | None: + """Manage extraction webhooks + + Args: + body (WebhookConfig): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WebhookPutResponse200 + """ + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/__init__.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/__init__.py new file mode 100644 index 0000000..7e2ea57 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/__init__.py @@ -0,0 +1,2 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""Contains endpoint functions for accessing the API""" diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/detail.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/detail.py new file mode 100644 index 0000000..8cec0b6 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/detail.py @@ -0,0 +1,178 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.detail_response_200 import DetailResponse200 +from ...models.error import Error +from ...types import UNSET, Response + + +def _get_kwargs( + *, + whisper_hash: str, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["whisper_hash"] = whisper_hash + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/whisper-detail", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> DetailResponse200 | Error | None: + if response.status_code == 200: + response_200 = DetailResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[DetailResponse200 | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[DetailResponse200 | Error]: + """Metadata about a whisper job + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DetailResponse200 | Error] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> DetailResponse200 | Error | None: + """Metadata about a whisper job + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DetailResponse200 | Error + """ + return sync_detailed( + client=client, + whisper_hash=whisper_hash, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[DetailResponse200 | Error]: + """Metadata about a whisper job + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[DetailResponse200 | Error] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> DetailResponse200 | Error | None: + """Metadata about a whisper job + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + DetailResponse200 | Error + """ + return ( + await asyncio_detailed( + client=client, + whisper_hash=whisper_hash, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/extract.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/extract.py new file mode 100644 index 0000000..ebad4ca --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/extract.py @@ -0,0 +1,602 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.extract_line_splitter_strategy import ExtractLineSplitterStrategy +from ...models.extract_mode import ExtractMode +from ...models.extract_output_mode import ExtractOutputMode +from ...models.whisper_accepted import WhisperAccepted +from ...types import UNSET, File, Response, Unset + + +def _get_kwargs( + *, + body: File | Unset = UNSET, + add_line_nos: bool | Unset = False, + allow_rotated_text: bool | Unset = True, + checkbox_confidence_threshold: float | Unset = 0.3, + derotate_threshold: float | Unset = 10.0, + file_name: str | Unset = "sample.pdf", + gaussian_blur_radius: float | Unset = 0.0, + horizontal_stretch_factor: float | Unset = 1.0, + ignore_vertical_text: bool | Unset = False, + include_line_confidence: bool | Unset = False, + lang: str | Unset = "eng", + line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", + line_splitter_tolerance: float | Unset = 0.75, + mark_horizontal_lines: bool | Unset = False, + mark_vertical_lines: bool | Unset = False, + median_filter_size: int | Unset = 0, + min_table_width: float | Unset = 0.0, + mode: ExtractMode | Unset = "form", + output_mode: ExtractOutputMode | Unset = "layout_preserving", + page_separator: str | Unset = "<<<", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + watermark_angle_threshold: float | Unset = 25.0, + webhook_metadata: str | Unset = UNSET, + word_confidence_threshold: float | Unset = UNSET, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + params: dict[str, Any] = {} + + params["add_line_nos"] = add_line_nos + + params["allow_rotated_text"] = allow_rotated_text + + params["checkbox_confidence_threshold"] = checkbox_confidence_threshold + + params["derotate_threshold"] = derotate_threshold + + params["file_name"] = file_name + + params["gaussian_blur_radius"] = gaussian_blur_radius + + params["horizontal_stretch_factor"] = horizontal_stretch_factor + + params["ignore_vertical_text"] = ignore_vertical_text + + params["include_line_confidence"] = include_line_confidence + + params["lang"] = lang + + json_line_splitter_strategy: str | Unset = UNSET + if not isinstance(line_splitter_strategy, Unset): + json_line_splitter_strategy = line_splitter_strategy + + params["line_splitter_strategy"] = json_line_splitter_strategy + + params["line_splitter_tolerance"] = line_splitter_tolerance + + params["mark_horizontal_lines"] = mark_horizontal_lines + + params["mark_vertical_lines"] = mark_vertical_lines + + params["median_filter_size"] = median_filter_size + + params["min_table_width"] = min_table_width + + json_mode: str | Unset = UNSET + if not isinstance(mode, Unset): + json_mode = mode + + params["mode"] = json_mode + + json_output_mode: str | Unset = UNSET + if not isinstance(output_mode, Unset): + json_output_mode = output_mode + + params["output_mode"] = json_output_mode + + params["page_separator"] = page_separator + + params["pages_to_extract"] = pages_to_extract + + params["tag"] = tag + + params["url"] = url_query + + params["url_in_post"] = url_in_post + + params["use_webhook"] = use_webhook + + params["watermark_angle_threshold"] = watermark_angle_threshold + + params["webhook_metadata"] = webhook_metadata + + params["word_confidence_threshold"] = word_confidence_threshold + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/api/v2/whisper", + "params": params, + } + + if not isinstance(body, Unset): + _kwargs["content"] = body.payload + headers["Content-Type"] = "application/octet-stream" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | WhisperAccepted | None: + if response.status_code == 202: + response_202 = WhisperAccepted.from_dict(response.json()) + + return response_202 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | WhisperAccepted]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + add_line_nos: bool | Unset = False, + allow_rotated_text: bool | Unset = True, + checkbox_confidence_threshold: float | Unset = 0.3, + derotate_threshold: float | Unset = 10.0, + file_name: str | Unset = "sample.pdf", + gaussian_blur_radius: float | Unset = 0.0, + horizontal_stretch_factor: float | Unset = 1.0, + ignore_vertical_text: bool | Unset = False, + include_line_confidence: bool | Unset = False, + lang: str | Unset = "eng", + line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", + line_splitter_tolerance: float | Unset = 0.75, + mark_horizontal_lines: bool | Unset = False, + mark_vertical_lines: bool | Unset = False, + median_filter_size: int | Unset = 0, + min_table_width: float | Unset = 0.0, + mode: ExtractMode | Unset = "form", + output_mode: ExtractOutputMode | Unset = "layout_preserving", + page_separator: str | Unset = "<<<", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + watermark_angle_threshold: float | Unset = 25.0, + webhook_metadata: str | Unset = UNSET, + word_confidence_threshold: float | Unset = UNSET, +) -> Response[Error | WhisperAccepted]: + """Submit a document for text extraction + + Args: + add_line_nos (bool | Unset): Default: False. + allow_rotated_text (bool | Unset): Default: True. + checkbox_confidence_threshold (float | Unset): Default: 0.3. + derotate_threshold (float | Unset): Default: 10.0. + file_name (str | Unset): Default: 'sample.pdf'. + gaussian_blur_radius (float | Unset): Default: 0.0. + horizontal_stretch_factor (float | Unset): Default: 1.0. + ignore_vertical_text (bool | Unset): Default: False. + include_line_confidence (bool | Unset): Default: False. + lang (str | Unset): Default: 'eng'. + line_splitter_strategy (ExtractLineSplitterStrategy | Unset): Default: 'left-priority'. + line_splitter_tolerance (float | Unset): Default: 0.75. + mark_horizontal_lines (bool | Unset): Default: False. + mark_vertical_lines (bool | Unset): Default: False. + median_filter_size (int | Unset): Default: 0. + min_table_width (float | Unset): Default: 0.0. + mode (ExtractMode | Unset): Default: 'form'. + output_mode (ExtractOutputMode | Unset): Default: 'layout_preserving'. + page_separator (str | Unset): Default: '<<<'. + pages_to_extract (str | Unset): + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + use_webhook (str | Unset): + watermark_angle_threshold (float | Unset): Default: 25.0. + webhook_metadata (str | Unset): + word_confidence_threshold (float | Unset): + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperAccepted] + """ + kwargs = _get_kwargs( + body=body, + add_line_nos=add_line_nos, + allow_rotated_text=allow_rotated_text, + checkbox_confidence_threshold=checkbox_confidence_threshold, + derotate_threshold=derotate_threshold, + file_name=file_name, + gaussian_blur_radius=gaussian_blur_radius, + horizontal_stretch_factor=horizontal_stretch_factor, + ignore_vertical_text=ignore_vertical_text, + include_line_confidence=include_line_confidence, + lang=lang, + line_splitter_strategy=line_splitter_strategy, + line_splitter_tolerance=line_splitter_tolerance, + mark_horizontal_lines=mark_horizontal_lines, + mark_vertical_lines=mark_vertical_lines, + median_filter_size=median_filter_size, + min_table_width=min_table_width, + mode=mode, + output_mode=output_mode, + page_separator=page_separator, + pages_to_extract=pages_to_extract, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + use_webhook=use_webhook, + watermark_angle_threshold=watermark_angle_threshold, + webhook_metadata=webhook_metadata, + word_confidence_threshold=word_confidence_threshold, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + add_line_nos: bool | Unset = False, + allow_rotated_text: bool | Unset = True, + checkbox_confidence_threshold: float | Unset = 0.3, + derotate_threshold: float | Unset = 10.0, + file_name: str | Unset = "sample.pdf", + gaussian_blur_radius: float | Unset = 0.0, + horizontal_stretch_factor: float | Unset = 1.0, + ignore_vertical_text: bool | Unset = False, + include_line_confidence: bool | Unset = False, + lang: str | Unset = "eng", + line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", + line_splitter_tolerance: float | Unset = 0.75, + mark_horizontal_lines: bool | Unset = False, + mark_vertical_lines: bool | Unset = False, + median_filter_size: int | Unset = 0, + min_table_width: float | Unset = 0.0, + mode: ExtractMode | Unset = "form", + output_mode: ExtractOutputMode | Unset = "layout_preserving", + page_separator: str | Unset = "<<<", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + watermark_angle_threshold: float | Unset = 25.0, + webhook_metadata: str | Unset = UNSET, + word_confidence_threshold: float | Unset = UNSET, +) -> Error | WhisperAccepted | None: + """Submit a document for text extraction + + Args: + add_line_nos (bool | Unset): Default: False. + allow_rotated_text (bool | Unset): Default: True. + checkbox_confidence_threshold (float | Unset): Default: 0.3. + derotate_threshold (float | Unset): Default: 10.0. + file_name (str | Unset): Default: 'sample.pdf'. + gaussian_blur_radius (float | Unset): Default: 0.0. + horizontal_stretch_factor (float | Unset): Default: 1.0. + ignore_vertical_text (bool | Unset): Default: False. + include_line_confidence (bool | Unset): Default: False. + lang (str | Unset): Default: 'eng'. + line_splitter_strategy (ExtractLineSplitterStrategy | Unset): Default: 'left-priority'. + line_splitter_tolerance (float | Unset): Default: 0.75. + mark_horizontal_lines (bool | Unset): Default: False. + mark_vertical_lines (bool | Unset): Default: False. + median_filter_size (int | Unset): Default: 0. + min_table_width (float | Unset): Default: 0.0. + mode (ExtractMode | Unset): Default: 'form'. + output_mode (ExtractOutputMode | Unset): Default: 'layout_preserving'. + page_separator (str | Unset): Default: '<<<'. + pages_to_extract (str | Unset): + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + use_webhook (str | Unset): + watermark_angle_threshold (float | Unset): Default: 25.0. + webhook_metadata (str | Unset): + word_confidence_threshold (float | Unset): + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperAccepted + """ + return sync_detailed( + client=client, + body=body, + add_line_nos=add_line_nos, + allow_rotated_text=allow_rotated_text, + checkbox_confidence_threshold=checkbox_confidence_threshold, + derotate_threshold=derotate_threshold, + file_name=file_name, + gaussian_blur_radius=gaussian_blur_radius, + horizontal_stretch_factor=horizontal_stretch_factor, + ignore_vertical_text=ignore_vertical_text, + include_line_confidence=include_line_confidence, + lang=lang, + line_splitter_strategy=line_splitter_strategy, + line_splitter_tolerance=line_splitter_tolerance, + mark_horizontal_lines=mark_horizontal_lines, + mark_vertical_lines=mark_vertical_lines, + median_filter_size=median_filter_size, + min_table_width=min_table_width, + mode=mode, + output_mode=output_mode, + page_separator=page_separator, + pages_to_extract=pages_to_extract, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + use_webhook=use_webhook, + watermark_angle_threshold=watermark_angle_threshold, + webhook_metadata=webhook_metadata, + word_confidence_threshold=word_confidence_threshold, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + add_line_nos: bool | Unset = False, + allow_rotated_text: bool | Unset = True, + checkbox_confidence_threshold: float | Unset = 0.3, + derotate_threshold: float | Unset = 10.0, + file_name: str | Unset = "sample.pdf", + gaussian_blur_radius: float | Unset = 0.0, + horizontal_stretch_factor: float | Unset = 1.0, + ignore_vertical_text: bool | Unset = False, + include_line_confidence: bool | Unset = False, + lang: str | Unset = "eng", + line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", + line_splitter_tolerance: float | Unset = 0.75, + mark_horizontal_lines: bool | Unset = False, + mark_vertical_lines: bool | Unset = False, + median_filter_size: int | Unset = 0, + min_table_width: float | Unset = 0.0, + mode: ExtractMode | Unset = "form", + output_mode: ExtractOutputMode | Unset = "layout_preserving", + page_separator: str | Unset = "<<<", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + watermark_angle_threshold: float | Unset = 25.0, + webhook_metadata: str | Unset = UNSET, + word_confidence_threshold: float | Unset = UNSET, +) -> Response[Error | WhisperAccepted]: + """Submit a document for text extraction + + Args: + add_line_nos (bool | Unset): Default: False. + allow_rotated_text (bool | Unset): Default: True. + checkbox_confidence_threshold (float | Unset): Default: 0.3. + derotate_threshold (float | Unset): Default: 10.0. + file_name (str | Unset): Default: 'sample.pdf'. + gaussian_blur_radius (float | Unset): Default: 0.0. + horizontal_stretch_factor (float | Unset): Default: 1.0. + ignore_vertical_text (bool | Unset): Default: False. + include_line_confidence (bool | Unset): Default: False. + lang (str | Unset): Default: 'eng'. + line_splitter_strategy (ExtractLineSplitterStrategy | Unset): Default: 'left-priority'. + line_splitter_tolerance (float | Unset): Default: 0.75. + mark_horizontal_lines (bool | Unset): Default: False. + mark_vertical_lines (bool | Unset): Default: False. + median_filter_size (int | Unset): Default: 0. + min_table_width (float | Unset): Default: 0.0. + mode (ExtractMode | Unset): Default: 'form'. + output_mode (ExtractOutputMode | Unset): Default: 'layout_preserving'. + page_separator (str | Unset): Default: '<<<'. + pages_to_extract (str | Unset): + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + use_webhook (str | Unset): + watermark_angle_threshold (float | Unset): Default: 25.0. + webhook_metadata (str | Unset): + word_confidence_threshold (float | Unset): + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperAccepted] + """ + kwargs = _get_kwargs( + body=body, + add_line_nos=add_line_nos, + allow_rotated_text=allow_rotated_text, + checkbox_confidence_threshold=checkbox_confidence_threshold, + derotate_threshold=derotate_threshold, + file_name=file_name, + gaussian_blur_radius=gaussian_blur_radius, + horizontal_stretch_factor=horizontal_stretch_factor, + ignore_vertical_text=ignore_vertical_text, + include_line_confidence=include_line_confidence, + lang=lang, + line_splitter_strategy=line_splitter_strategy, + line_splitter_tolerance=line_splitter_tolerance, + mark_horizontal_lines=mark_horizontal_lines, + mark_vertical_lines=mark_vertical_lines, + median_filter_size=median_filter_size, + min_table_width=min_table_width, + mode=mode, + output_mode=output_mode, + page_separator=page_separator, + pages_to_extract=pages_to_extract, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + use_webhook=use_webhook, + watermark_angle_threshold=watermark_angle_threshold, + webhook_metadata=webhook_metadata, + word_confidence_threshold=word_confidence_threshold, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: File | Unset = UNSET, + add_line_nos: bool | Unset = False, + allow_rotated_text: bool | Unset = True, + checkbox_confidence_threshold: float | Unset = 0.3, + derotate_threshold: float | Unset = 10.0, + file_name: str | Unset = "sample.pdf", + gaussian_blur_radius: float | Unset = 0.0, + horizontal_stretch_factor: float | Unset = 1.0, + ignore_vertical_text: bool | Unset = False, + include_line_confidence: bool | Unset = False, + lang: str | Unset = "eng", + line_splitter_strategy: ExtractLineSplitterStrategy | Unset = "left-priority", + line_splitter_tolerance: float | Unset = 0.75, + mark_horizontal_lines: bool | Unset = False, + mark_vertical_lines: bool | Unset = False, + median_filter_size: int | Unset = 0, + min_table_width: float | Unset = 0.0, + mode: ExtractMode | Unset = "form", + output_mode: ExtractOutputMode | Unset = "layout_preserving", + page_separator: str | Unset = "<<<", + pages_to_extract: str | Unset = UNSET, + tag: str | Unset = "default", + url_query: str | Unset = UNSET, + url_in_post: bool | Unset = False, + use_webhook: str | Unset = UNSET, + watermark_angle_threshold: float | Unset = 25.0, + webhook_metadata: str | Unset = UNSET, + word_confidence_threshold: float | Unset = UNSET, +) -> Error | WhisperAccepted | None: + """Submit a document for text extraction + + Args: + add_line_nos (bool | Unset): Default: False. + allow_rotated_text (bool | Unset): Default: True. + checkbox_confidence_threshold (float | Unset): Default: 0.3. + derotate_threshold (float | Unset): Default: 10.0. + file_name (str | Unset): Default: 'sample.pdf'. + gaussian_blur_radius (float | Unset): Default: 0.0. + horizontal_stretch_factor (float | Unset): Default: 1.0. + ignore_vertical_text (bool | Unset): Default: False. + include_line_confidence (bool | Unset): Default: False. + lang (str | Unset): Default: 'eng'. + line_splitter_strategy (ExtractLineSplitterStrategy | Unset): Default: 'left-priority'. + line_splitter_tolerance (float | Unset): Default: 0.75. + mark_horizontal_lines (bool | Unset): Default: False. + mark_vertical_lines (bool | Unset): Default: False. + median_filter_size (int | Unset): Default: 0. + min_table_width (float | Unset): Default: 0.0. + mode (ExtractMode | Unset): Default: 'form'. + output_mode (ExtractOutputMode | Unset): Default: 'layout_preserving'. + page_separator (str | Unset): Default: '<<<'. + pages_to_extract (str | Unset): + tag (str | Unset): Default: 'default'. + url_query (str | Unset): + url_in_post (bool | Unset): Default: False. + use_webhook (str | Unset): + watermark_angle_threshold (float | Unset): Default: 25.0. + webhook_metadata (str | Unset): + word_confidence_threshold (float | Unset): + body (File | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperAccepted + """ + return ( + await asyncio_detailed( + client=client, + body=body, + add_line_nos=add_line_nos, + allow_rotated_text=allow_rotated_text, + checkbox_confidence_threshold=checkbox_confidence_threshold, + derotate_threshold=derotate_threshold, + file_name=file_name, + gaussian_blur_radius=gaussian_blur_radius, + horizontal_stretch_factor=horizontal_stretch_factor, + ignore_vertical_text=ignore_vertical_text, + include_line_confidence=include_line_confidence, + lang=lang, + line_splitter_strategy=line_splitter_strategy, + line_splitter_tolerance=line_splitter_tolerance, + mark_horizontal_lines=mark_horizontal_lines, + mark_vertical_lines=mark_vertical_lines, + median_filter_size=median_filter_size, + min_table_width=min_table_width, + mode=mode, + output_mode=output_mode, + page_separator=page_separator, + pages_to_extract=pages_to_extract, + tag=tag, + url_query=url_query, + url_in_post=url_in_post, + use_webhook=use_webhook, + watermark_angle_threshold=watermark_angle_threshold, + webhook_metadata=webhook_metadata, + word_confidence_threshold=word_confidence_threshold, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/highlights.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/highlights.py new file mode 100644 index 0000000..89480cf --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/highlights.py @@ -0,0 +1,223 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.highlights_response_200 import HighlightsResponse200 +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + extract_all_lines: bool | Unset = False, + lines: str, + mode: str | Unset = "form", + whisper_hash: str, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["extract_all_lines"] = extract_all_lines + + params["lines"] = lines + + params["mode"] = mode + + params["whisper_hash"] = whisper_hash + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/highlights", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Error | HighlightsResponse200 | None: + if response.status_code == 200: + response_200 = HighlightsResponse200.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | HighlightsResponse200]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + extract_all_lines: bool | Unset = False, + lines: str, + mode: str | Unset = "form", + whisper_hash: str, +) -> Response[Error | HighlightsResponse200]: + """Line-level highlight geometry for an extraction + + Args: + extract_all_lines (bool | Unset): Default: False. + lines (str): + mode (str | Unset): Default: 'form'. + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | HighlightsResponse200] + """ + kwargs = _get_kwargs( + extract_all_lines=extract_all_lines, + lines=lines, + mode=mode, + whisper_hash=whisper_hash, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + extract_all_lines: bool | Unset = False, + lines: str, + mode: str | Unset = "form", + whisper_hash: str, +) -> Error | HighlightsResponse200 | None: + """Line-level highlight geometry for an extraction + + Args: + extract_all_lines (bool | Unset): Default: False. + lines (str): + mode (str | Unset): Default: 'form'. + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | HighlightsResponse200 + """ + return sync_detailed( + client=client, + extract_all_lines=extract_all_lines, + lines=lines, + mode=mode, + whisper_hash=whisper_hash, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + extract_all_lines: bool | Unset = False, + lines: str, + mode: str | Unset = "form", + whisper_hash: str, +) -> Response[Error | HighlightsResponse200]: + """Line-level highlight geometry for an extraction + + Args: + extract_all_lines (bool | Unset): Default: False. + lines (str): + mode (str | Unset): Default: 'form'. + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | HighlightsResponse200] + """ + kwargs = _get_kwargs( + extract_all_lines=extract_all_lines, + lines=lines, + mode=mode, + whisper_hash=whisper_hash, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + extract_all_lines: bool | Unset = False, + lines: str, + mode: str | Unset = "form", + whisper_hash: str, +) -> Error | HighlightsResponse200 | None: + """Line-level highlight geometry for an extraction + + Args: + extract_all_lines (bool | Unset): Default: False. + lines (str): + mode (str | Unset): Default: 'form'. + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | HighlightsResponse200 + """ + return ( + await asyncio_detailed( + client=client, + extract_all_lines=extract_all_lines, + lines=lines, + mode=mode, + whisper_hash=whisper_hash, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/retrieve.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/retrieve.py new file mode 100644 index 0000000..99d198b --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/retrieve.py @@ -0,0 +1,191 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.whisper_result import WhisperResult +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + text_only: bool | Unset = False, + whisper_hash: str, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["text_only"] = text_only + + params["whisper_hash"] = whisper_hash + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/whisper-retrieve", + "params": params, + } + + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | WhisperResult | None: + if response.status_code == 200: + response_200 = WhisperResult.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | WhisperResult]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + text_only: bool | Unset = False, + whisper_hash: str, +) -> Response[Error | WhisperResult]: + """Retrieve extraction result (destructive — one shot) + + Args: + text_only (bool | Unset): Default: False. + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperResult] + """ + kwargs = _get_kwargs( + text_only=text_only, + whisper_hash=whisper_hash, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + text_only: bool | Unset = False, + whisper_hash: str, +) -> Error | WhisperResult | None: + """Retrieve extraction result (destructive — one shot) + + Args: + text_only (bool | Unset): Default: False. + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperResult + """ + return sync_detailed( + client=client, + text_only=text_only, + whisper_hash=whisper_hash, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + text_only: bool | Unset = False, + whisper_hash: str, +) -> Response[Error | WhisperResult]: + """Retrieve extraction result (destructive — one shot) + + Args: + text_only (bool | Unset): Default: False. + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperResult] + """ + kwargs = _get_kwargs( + text_only=text_only, + whisper_hash=whisper_hash, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + text_only: bool | Unset = False, + whisper_hash: str, +) -> Error | WhisperResult | None: + """Retrieve extraction result (destructive — one shot) + + Args: + text_only (bool | Unset): Default: False. + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperResult + """ + return ( + await asyncio_detailed( + client=client, + text_only=text_only, + whisper_hash=whisper_hash, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/status.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/status.py new file mode 100644 index 0000000..f1eaa61 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/api/whisper/status.py @@ -0,0 +1,176 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.whisper_status import WhisperStatus +from ...types import UNSET, Response + + +def _get_kwargs( + *, + whisper_hash: str, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["whisper_hash"] = whisper_hash + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v2/whisper-status", + "params": params, + } + + return _kwargs + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | WhisperStatus | None: + if response.status_code == 200: + response_200 = WhisperStatus.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Response[Error | WhisperStatus]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[Error | WhisperStatus]: + """Poll extraction status + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperStatus] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Error | WhisperStatus | None: + """Poll extraction status + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperStatus + """ + return sync_detailed( + client=client, + whisper_hash=whisper_hash, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Response[Error | WhisperStatus]: + """Poll extraction status + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | WhisperStatus] + """ + kwargs = _get_kwargs( + whisper_hash=whisper_hash, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient | Client, + whisper_hash: str, +) -> Error | WhisperStatus | None: + """Poll extraction status + + Args: + whisper_hash (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | WhisperStatus + """ + return ( + await asyncio_detailed( + client=client, + whisper_hash=whisper_hash, + ) + ).parsed diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/client.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/client.py new file mode 100644 index 0000000..51022a6 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/client.py @@ -0,0 +1,269 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +import ssl +from typing import Any + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: httpx.Client | None = field(default=None, init=False) + _async_client: httpx.AsyncClient | None = field(default=None, init=False) + + def with_headers(self, headers: dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout configuration""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: object, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually set the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: object, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + + Attributes: + raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a + status code that was not documented in the source OpenAPI document. Can also be provided as a keyword + argument to the constructor. + token: The token to use for authentication + prefix: The prefix to use for the Authorization header + auth_header_name: The name of the Authorization header + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: httpx.Client | None = field(default=None, init=False) + _async_client: httpx.AsyncClient | None = field(default=None, init=False) + + token: str + prefix: str = "Bearer" + auth_header_name: str = "Authorization" + + def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout configuration""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: object, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": + """Manually set the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: object, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/errors.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/errors.py new file mode 100644 index 0000000..1984f61 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/errors.py @@ -0,0 +1,17 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""Contains shared errors types that can be raised from API functions""" + + +class UnexpectedStatus(Exception): + """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content + + super().__init__( + f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}" + ) + + +__all__ = ["UnexpectedStatus"] diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/__init__.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/__init__.py new file mode 100644 index 0000000..4135b36 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/__init__.py @@ -0,0 +1,54 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""Contains all the data models used in inputs/outputs""" + +from .detail_response_200 import DetailResponse200 +from .document_insights_retrieve_response_200 import DocumentInsightsRetrieveResponse200 +from .error import Error +from .extract_line_splitter_strategy import ExtractLineSplitterStrategy +from .extract_mode import ExtractMode +from .extract_output_mode import ExtractOutputMode +from .highlights_response_200 import HighlightsResponse200 +from .pdf_to_images_status_response_200 import PdfToImagesStatusResponse200 +from .test_connection_response_200 import TestConnectionResponse200 +from .usage_info_response_200 import UsageInfoResponse200 +from .usage_response_200 import UsageResponse200 +from .webhook_config import WebhookConfig +from .webhook_delete_response_200 import WebhookDeleteResponse200 +from .webhook_get_response_200 import WebhookGetResponse200 +from .webhook_post_response_201 import WebhookPostResponse201 +from .webhook_put_response_200 import WebhookPutResponse200 +from .whisper_accepted import WhisperAccepted +from .whisper_result import WhisperResult +from .whisper_result_confidence_metadata_item import WhisperResultConfidenceMetadataItem +from .whisper_result_line_metadata_item import WhisperResultLineMetadataItem +from .whisper_result_metadata import WhisperResultMetadata +from .whisper_result_whisper_metadata import WhisperResultWhisperMetadata +from .whisper_status import WhisperStatus +from .whisper_status_detail_item import WhisperStatusDetailItem + +__all__ = ( + "DetailResponse200", + "DocumentInsightsRetrieveResponse200", + "Error", + "ExtractLineSplitterStrategy", + "ExtractMode", + "ExtractOutputMode", + "HighlightsResponse200", + "PdfToImagesStatusResponse200", + "TestConnectionResponse200", + "UsageInfoResponse200", + "UsageResponse200", + "WebhookConfig", + "WebhookDeleteResponse200", + "WebhookGetResponse200", + "WebhookPostResponse201", + "WebhookPutResponse200", + "WhisperAccepted", + "WhisperResult", + "WhisperResultConfidenceMetadataItem", + "WhisperResultLineMetadataItem", + "WhisperResultMetadata", + "WhisperResultWhisperMetadata", + "WhisperStatus", + "WhisperStatusDetailItem", +) diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/detail_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/detail_response_200.py new file mode 100644 index 0000000..72b9d37 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/detail_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DetailResponse200") + + +@_attrs_define +class DetailResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + detail_response_200 = cls() + + detail_response_200.additional_properties = d + return detail_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/document_insights_retrieve_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/document_insights_retrieve_response_200.py new file mode 100644 index 0000000..42004a2 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/document_insights_retrieve_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="DocumentInsightsRetrieveResponse200") + + +@_attrs_define +class DocumentInsightsRetrieveResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + document_insights_retrieve_response_200 = cls() + + document_insights_retrieve_response_200.additional_properties = d + return document_insights_retrieve_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/error.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/error.py new file mode 100644 index 0000000..bdd2e8b --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/error.py @@ -0,0 +1,62 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="Error") + + +@_attrs_define +class Error: + """ + Attributes: + message (str | Unset): + """ + + message: str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + message = self.message + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if message is not UNSET: + field_dict["message"] = message + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + message = d.pop("message", UNSET) + + error = cls( + message=message, + ) + + error.additional_properties = d + return error + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/extract_line_splitter_strategy.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/extract_line_splitter_strategy.py new file mode 100644 index 0000000..c62c229 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/extract_line_splitter_strategy.py @@ -0,0 +1,16 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from typing import Literal + +ExtractLineSplitterStrategy = Literal["left-priority", "mid-priority", "right-priority"] + +EXTRACT_LINE_SPLITTER_STRATEGY_VALUES: set[ExtractLineSplitterStrategy] = { + "left-priority", + "mid-priority", + "right-priority", +} + + +def check_extract_line_splitter_strategy(value: str) -> ExtractLineSplitterStrategy: + if value in EXTRACT_LINE_SPLITTER_STRATEGY_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {EXTRACT_LINE_SPLITTER_STRATEGY_VALUES!r}") diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/extract_mode.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/extract_mode.py new file mode 100644 index 0000000..c6a22a2 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/extract_mode.py @@ -0,0 +1,23 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from typing import Literal + +ExtractMode = Literal[ + "document_insights", "excel", "form", "high_quality", "low_cost", "native_text", "pdf_to_images", "table" +] + +EXTRACT_MODE_VALUES: set[ExtractMode] = { + "document_insights", + "excel", + "form", + "high_quality", + "low_cost", + "native_text", + "pdf_to_images", + "table", +} + + +def check_extract_mode(value: str) -> ExtractMode: + if value in EXTRACT_MODE_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {EXTRACT_MODE_VALUES!r}") diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/extract_output_mode.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/extract_output_mode.py new file mode 100644 index 0000000..87757bc --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/extract_output_mode.py @@ -0,0 +1,17 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from typing import Literal + +ExtractOutputMode = Literal["dump-text", "layout_preserving", "line-printer", "text"] + +EXTRACT_OUTPUT_MODE_VALUES: set[ExtractOutputMode] = { + "dump-text", + "layout_preserving", + "line-printer", + "text", +} + + +def check_extract_output_mode(value: str) -> ExtractOutputMode: + if value in EXTRACT_OUTPUT_MODE_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {EXTRACT_OUTPUT_MODE_VALUES!r}") diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/highlights_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/highlights_response_200.py new file mode 100644 index 0000000..11ebec8 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/highlights_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="HighlightsResponse200") + + +@_attrs_define +class HighlightsResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + highlights_response_200 = cls() + + highlights_response_200.additional_properties = d + return highlights_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/pdf_to_images_status_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/pdf_to_images_status_response_200.py new file mode 100644 index 0000000..d21c47b --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/pdf_to_images_status_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="PdfToImagesStatusResponse200") + + +@_attrs_define +class PdfToImagesStatusResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + pdf_to_images_status_response_200 = cls() + + pdf_to_images_status_response_200.additional_properties = d + return pdf_to_images_status_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/test_connection_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/test_connection_response_200.py new file mode 100644 index 0000000..54d9b65 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/test_connection_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="TestConnectionResponse200") + + +@_attrs_define +class TestConnectionResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + test_connection_response_200 = cls() + + test_connection_response_200.additional_properties = d + return test_connection_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/usage_info_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/usage_info_response_200.py new file mode 100644 index 0000000..1890ac5 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/usage_info_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="UsageInfoResponse200") + + +@_attrs_define +class UsageInfoResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + usage_info_response_200 = cls() + + usage_info_response_200.additional_properties = d + return usage_info_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/usage_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/usage_response_200.py new file mode 100644 index 0000000..232d6fa --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/usage_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="UsageResponse200") + + +@_attrs_define +class UsageResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + usage_response_200 = cls() + + usage_response_200.additional_properties = d + return usage_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_config.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_config.py new file mode 100644 index 0000000..e9458ca --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_config.py @@ -0,0 +1,78 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WebhookConfig") + + +@_attrs_define +class WebhookConfig: + """ + Attributes: + auth_token (str): + url (str): + webhook_name (str): + """ + + auth_token: str + url: str + webhook_name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + auth_token = self.auth_token + + url = self.url + + webhook_name = self.webhook_name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "auth_token": auth_token, + "url": url, + "webhook_name": webhook_name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + auth_token = d.pop("auth_token") + + url = d.pop("url") + + webhook_name = d.pop("webhook_name") + + webhook_config = cls( + auth_token=auth_token, + url=url, + webhook_name=webhook_name, + ) + + webhook_config.additional_properties = d + return webhook_config + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_delete_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_delete_response_200.py new file mode 100644 index 0000000..276303e --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_delete_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WebhookDeleteResponse200") + + +@_attrs_define +class WebhookDeleteResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + webhook_delete_response_200 = cls() + + webhook_delete_response_200.additional_properties = d + return webhook_delete_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_get_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_get_response_200.py new file mode 100644 index 0000000..8aba08c --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_get_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WebhookGetResponse200") + + +@_attrs_define +class WebhookGetResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + webhook_get_response_200 = cls() + + webhook_get_response_200.additional_properties = d + return webhook_get_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_post_response_201.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_post_response_201.py new file mode 100644 index 0000000..1c70203 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_post_response_201.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WebhookPostResponse201") + + +@_attrs_define +class WebhookPostResponse201: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + webhook_post_response_201 = cls() + + webhook_post_response_201.additional_properties = d + return webhook_post_response_201 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_put_response_200.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_put_response_200.py new file mode 100644 index 0000000..24891ee --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/webhook_put_response_200.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WebhookPutResponse200") + + +@_attrs_define +class WebhookPutResponse200: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + webhook_put_response_200 = cls() + + webhook_put_response_200.additional_properties = d + return webhook_put_response_200 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_accepted.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_accepted.py new file mode 100644 index 0000000..d8d4073 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_accepted.py @@ -0,0 +1,80 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="WhisperAccepted") + + +@_attrs_define +class WhisperAccepted: + """ + Attributes: + message (str | Unset): + status (str | Unset): + whisper_hash (str | Unset): + """ + + message: str | Unset = UNSET + status: str | Unset = UNSET + whisper_hash: str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + message = self.message + + status = self.status + + whisper_hash = self.whisper_hash + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if message is not UNSET: + field_dict["message"] = message + if status is not UNSET: + field_dict["status"] = status + if whisper_hash is not UNSET: + field_dict["whisper_hash"] = whisper_hash + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + message = d.pop("message", UNSET) + + status = d.pop("status", UNSET) + + whisper_hash = d.pop("whisper_hash", UNSET) + + whisper_accepted = cls( + message=message, + status=status, + whisper_hash=whisper_hash, + ) + + whisper_accepted.additional_properties = d + return whisper_accepted + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result.py new file mode 100644 index 0000000..66128da --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result.py @@ -0,0 +1,157 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.whisper_result_confidence_metadata_item import WhisperResultConfidenceMetadataItem + from ..models.whisper_result_line_metadata_item import WhisperResultLineMetadataItem + from ..models.whisper_result_metadata import WhisperResultMetadata + from ..models.whisper_result_whisper_metadata import WhisperResultWhisperMetadata + + +T = TypeVar("T", bound="WhisperResult") + + +@_attrs_define +class WhisperResult: + """ + Attributes: + confidence_metadata (list[WhisperResultConfidenceMetadataItem] | Unset): + line_metadata (list[WhisperResultLineMetadataItem] | Unset): + metadata (WhisperResultMetadata | Unset): + result_text (str | Unset): + webhook_metadata (str | Unset): + whisper_metadata (WhisperResultWhisperMetadata | Unset): + """ + + confidence_metadata: list[WhisperResultConfidenceMetadataItem] | Unset = UNSET + line_metadata: list[WhisperResultLineMetadataItem] | Unset = UNSET + metadata: WhisperResultMetadata | Unset = UNSET + result_text: str | Unset = UNSET + webhook_metadata: str | Unset = UNSET + whisper_metadata: WhisperResultWhisperMetadata | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + confidence_metadata: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.confidence_metadata, Unset): + confidence_metadata = [] + for confidence_metadata_item_data in self.confidence_metadata: + confidence_metadata_item = confidence_metadata_item_data.to_dict() + confidence_metadata.append(confidence_metadata_item) + + line_metadata: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.line_metadata, Unset): + line_metadata = [] + for line_metadata_item_data in self.line_metadata: + line_metadata_item = line_metadata_item_data.to_dict() + line_metadata.append(line_metadata_item) + + metadata: dict[str, Any] | Unset = UNSET + if not isinstance(self.metadata, Unset): + metadata = self.metadata.to_dict() + + result_text = self.result_text + + webhook_metadata = self.webhook_metadata + + whisper_metadata: dict[str, Any] | Unset = UNSET + if not isinstance(self.whisper_metadata, Unset): + whisper_metadata = self.whisper_metadata.to_dict() + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if confidence_metadata is not UNSET: + field_dict["confidence_metadata"] = confidence_metadata + if line_metadata is not UNSET: + field_dict["line_metadata"] = line_metadata + if metadata is not UNSET: + field_dict["metadata"] = metadata + if result_text is not UNSET: + field_dict["result_text"] = result_text + if webhook_metadata is not UNSET: + field_dict["webhook_metadata"] = webhook_metadata + if whisper_metadata is not UNSET: + field_dict["whisper_metadata"] = whisper_metadata + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.whisper_result_confidence_metadata_item import WhisperResultConfidenceMetadataItem + from ..models.whisper_result_line_metadata_item import WhisperResultLineMetadataItem + from ..models.whisper_result_metadata import WhisperResultMetadata + from ..models.whisper_result_whisper_metadata import WhisperResultWhisperMetadata + + d = dict(src_dict) + _confidence_metadata = d.pop("confidence_metadata", UNSET) + confidence_metadata: list[WhisperResultConfidenceMetadataItem] | Unset = UNSET + if _confidence_metadata is not UNSET: + confidence_metadata = [] + for confidence_metadata_item_data in _confidence_metadata: + confidence_metadata_item = WhisperResultConfidenceMetadataItem.from_dict(confidence_metadata_item_data) + + confidence_metadata.append(confidence_metadata_item) + + _line_metadata = d.pop("line_metadata", UNSET) + line_metadata: list[WhisperResultLineMetadataItem] | Unset = UNSET + if _line_metadata is not UNSET: + line_metadata = [] + for line_metadata_item_data in _line_metadata: + line_metadata_item = WhisperResultLineMetadataItem.from_dict(line_metadata_item_data) + + line_metadata.append(line_metadata_item) + + _metadata = d.pop("metadata", UNSET) + metadata: WhisperResultMetadata | Unset + if isinstance(_metadata, Unset): + metadata = UNSET + else: + metadata = WhisperResultMetadata.from_dict(_metadata) + + result_text = d.pop("result_text", UNSET) + + webhook_metadata = d.pop("webhook_metadata", UNSET) + + _whisper_metadata = d.pop("whisper_metadata", UNSET) + whisper_metadata: WhisperResultWhisperMetadata | Unset + if isinstance(_whisper_metadata, Unset): + whisper_metadata = UNSET + else: + whisper_metadata = WhisperResultWhisperMetadata.from_dict(_whisper_metadata) + + whisper_result = cls( + confidence_metadata=confidence_metadata, + line_metadata=line_metadata, + metadata=metadata, + result_text=result_text, + webhook_metadata=webhook_metadata, + whisper_metadata=whisper_metadata, + ) + + whisper_result.additional_properties = d + return whisper_result + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_confidence_metadata_item.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_confidence_metadata_item.py new file mode 100644 index 0000000..50805b3 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_confidence_metadata_item.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WhisperResultConfidenceMetadataItem") + + +@_attrs_define +class WhisperResultConfidenceMetadataItem: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + whisper_result_confidence_metadata_item = cls() + + whisper_result_confidence_metadata_item.additional_properties = d + return whisper_result_confidence_metadata_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_line_metadata_item.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_line_metadata_item.py new file mode 100644 index 0000000..2e65a1e --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_line_metadata_item.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WhisperResultLineMetadataItem") + + +@_attrs_define +class WhisperResultLineMetadataItem: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + whisper_result_line_metadata_item = cls() + + whisper_result_line_metadata_item.additional_properties = d + return whisper_result_line_metadata_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_metadata.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_metadata.py new file mode 100644 index 0000000..0b0dc7f --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_metadata.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WhisperResultMetadata") + + +@_attrs_define +class WhisperResultMetadata: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + whisper_result_metadata = cls() + + whisper_result_metadata.additional_properties = d + return whisper_result_metadata + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_whisper_metadata.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_whisper_metadata.py new file mode 100644 index 0000000..a7f1712 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_result_whisper_metadata.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WhisperResultWhisperMetadata") + + +@_attrs_define +class WhisperResultWhisperMetadata: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + whisper_result_whisper_metadata = cls() + + whisper_result_whisper_metadata.additional_properties = d + return whisper_result_whisper_metadata + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_status.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_status.py new file mode 100644 index 0000000..b701e82 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_status.py @@ -0,0 +1,98 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.whisper_status_detail_item import WhisperStatusDetailItem + + +T = TypeVar("T", bound="WhisperStatus") + + +@_attrs_define +class WhisperStatus: + """ + Attributes: + detail (list[WhisperStatusDetailItem] | Unset): + message (str | Unset): + status (str | Unset): + """ + + detail: list[WhisperStatusDetailItem] | Unset = UNSET + message: str | Unset = UNSET + status: str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + detail: list[dict[str, Any]] | Unset = UNSET + if not isinstance(self.detail, Unset): + detail = [] + for detail_item_data in self.detail: + detail_item = detail_item_data.to_dict() + detail.append(detail_item) + + message = self.message + + status = self.status + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if detail is not UNSET: + field_dict["detail"] = detail + if message is not UNSET: + field_dict["message"] = message + if status is not UNSET: + field_dict["status"] = status + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + from ..models.whisper_status_detail_item import WhisperStatusDetailItem + + d = dict(src_dict) + _detail = d.pop("detail", UNSET) + detail: list[WhisperStatusDetailItem] | Unset = UNSET + if _detail is not UNSET: + detail = [] + for detail_item_data in _detail: + detail_item = WhisperStatusDetailItem.from_dict(detail_item_data) + + detail.append(detail_item) + + message = d.pop("message", UNSET) + + status = d.pop("status", UNSET) + + whisper_status = cls( + detail=detail, + message=message, + status=status, + ) + + whisper_status.additional_properties = d + return whisper_status + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_status_detail_item.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_status_detail_item.py new file mode 100644 index 0000000..420f295 --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/models/whisper_status_detail_item.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, Self, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="WhisperStatusDetailItem") + + +@_attrs_define +class WhisperStatusDetailItem: + """ """ + + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + + return field_dict + + @classmethod + def from_dict(cls, src_dict: Mapping[str, Any]) -> Self: + d = dict(src_dict) + whisper_status_detail_item = cls() + + whisper_status_detail_item.additional_properties = d + return whisper_status_detail_item + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/src/unstract/llmwhisperer/sdk_llmwhisperer/types.py b/src/unstract/llmwhisperer/sdk_llmwhisperer/types.py new file mode 100644 index 0000000..68a0e3f --- /dev/null +++ b/src/unstract/llmwhisperer/sdk_llmwhisperer/types.py @@ -0,0 +1,55 @@ +# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT. +"""Contains some shared types for properties""" + +from collections.abc import Mapping, MutableMapping +from http import HTTPStatus +from typing import IO, BinaryIO, Generic, Literal, TypeVar + +from attrs import define + + +class Unset: + def __bool__(self) -> Literal[False]: + return False + + +UNSET: Unset = Unset() + +# The types that `httpx.Client(files=)` can accept, copied from that library. +FileContent = IO[bytes] | bytes | str +FileTypes = ( + # (filename, file (or bytes), content_type) + tuple[str | None, FileContent, str | None] + # (filename, file (or bytes), content_type, headers) + | tuple[str | None, FileContent, str | None, Mapping[str, str]] +) +RequestFiles = list[tuple[str, FileTypes]] + + +@define +class File: + """Contains information for file uploads""" + + payload: BinaryIO + file_name: str | None = None + mime_type: str | None = None + + def to_tuple(self) -> FileTypes: + """Return a tuple representation that httpx will accept for multipart/form-data""" + return self.file_name, self.payload, self.mime_type + + +T = TypeVar("T") + + +@define +class Response(Generic[T]): + """A response from an endpoint""" + + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: T | None + + +__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"] diff --git a/tests/baseline/client_v2_2_8_0.py b/tests/baseline/client_v2_2_8_0.py new file mode 100644 index 0000000..b1e27d6 --- /dev/null +++ b/tests/baseline/client_v2_2_8_0.py @@ -0,0 +1,898 @@ +# Vendored from the released llmwhisperer-client 2.8.0 wheel on PyPI. DO NOT EDIT. +# Refresh with tools/refresh_baseline.sh when the parity baseline is intentionally moved. +"""This module provides a Python client for interacting with the LLMWhisperer +API. + +Note: This is for the LLMWhisperer API v2.x + +Prepare documents for LLM consumption +LLMs are powerful, but their output is as good as the input you provide. +LLMWhisperer is a technology that presents data from complex documents +(different designs and formats) to LLMs in a way that they can best understand. + +LLMWhisperer is available as an API that can be integrated into your existing +systems to preprocess your documents before they are fed into LLMs. It can handle +a variety of document types, including PDFs, images, and scanned documents. + +This client simplifies the process of making requests to the API and handling the responses. + +Classes: + LLMWhispererClientException: Exception raised for errors in the LLMWhispererClient. +""" + +import json +import logging +import os +import time +import warnings +from typing import IO, Any + +import requests +import tenacity +from tenacity import retry_if_exception, stop_after_attempt, stop_after_delay, wait_exponential_jitter + +BASE_URL_V2 = "https://llmwhisperer-api.us-central.unstract.com/api/v2" + + +class LLMWhispererClientException(Exception): + """Exception raised for errors in the LLMWhispererClient. + + Attributes: + message (str): Explanation of the error. + status_code (int): HTTP status code returned by the LLMWhisperer API. + + Args: + message (str): Explanation of the error. + status_code (int, optional): HTTP status code returned by the LLMWhisperer API. Defaults to None. + """ + + def __init__(self, value: str, status_code: int | None = None) -> None: + """Initialize the LLMWhispererClientException. + + Args: + value: The error message or value. + status_code: The HTTP status code returned by the LLMWhisperer API. + """ + self.value = value + self.status_code = status_code + + def __str__(self) -> str: + """Return string representation of the exception. + + Returns: + String representation of the error value. + """ + return repr(self.value) + + def error_message(self) -> str: + return self.value + + +class _RetryableHTTPError(Exception): + """Internal exception wrapping an HTTP response with a retryable status + code (429, 5xx).""" + + def __init__(self, response: requests.Response) -> None: + self.response = response + super().__init__(f"HTTP {response.status_code}") + + +class LLMWhispererClientV2: + """A client for interacting with the LLMWhisperer API. + + Note: This is for the LLMWhisperer API v2.x + + This client uses the requests library to make HTTP requests to the + LLMWhisperer API. It also includes a logger for tracking the + client's activities and errors. + """ + + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") + logger = logging.getLogger(__name__) + log_stream_handler = logging.StreamHandler() + log_stream_handler.setFormatter(formatter) + logger.addHandler(log_stream_handler) + + api_key: str = "" + base_url: str = "" + api_timeout: int = 120 + + def __init__( + self, + base_url: str = "", + api_key: str = "", + logging_level: str = "", + custom_headers: dict[str, str] | None = None, + max_retries: int = 3, + retry_min_wait: float = 1.0, + retry_max_wait: float = 60.0, + ) -> None: + """Initializes the LLMWhispererClient with the given parameters. + + Args: + base_url (str, optional): The base URL for the LLMWhisperer API. Defaults to "". + If the base_url is not provided, the client will use + the value of the LLMWHISPERER_BASE_URL_V2 environment + variable,or the default value. + api_key (str, optional): The API key for the LLMWhisperer API. Defaults to "". + If the api_key is not provided, the client will use the + value of the LLMWHISPERER_API_KEY environment variable. + logging_level (str, optional): The logging level for the client. Can be "DEBUG", + "INFO", "WARNING" or "ERROR". Defaults to the + value of the LLMWHISPERER_LOGGING_LEVEL + environment variable, or "DEBUG" if the + environment variable is not set. + custom_headers (Optional[Dict[str, str]], optional): Custom headers to add to + every request. These will + be merged with default + headers, with custom + headers taking precedence. + Defaults to None. + max_retries (int, optional): Maximum number of retry attempts for transient + HTTP errors. Set to 0 to disable retries. + Defaults to 3. + retry_min_wait (float, optional): Minimum backoff wait in seconds. Defaults to 1.0. + retry_max_wait (float, optional): Maximum backoff wait in seconds. Defaults to 60.0. + """ + if logging_level == "": + logging_level = os.getenv("LLMWHISPERER_LOGGING_LEVEL", "DEBUG") + if logging_level == "DEBUG": + self.logger.setLevel(logging.DEBUG) + elif logging_level == "INFO": + self.logger.setLevel(logging.INFO) + elif logging_level == "WARNING": + self.logger.setLevel(logging.WARNING) + elif logging_level == "ERROR": + self.logger.setLevel(logging.ERROR) + self.logger.setLevel(logging_level) + self.logger.debug("logging_level set to %s", logging_level) + if base_url == "": + self.base_url = os.getenv("LLMWHISPERER_BASE_URL_V2", BASE_URL_V2) + else: + self.base_url = base_url + self.logger.debug("base_url set to %s", self.base_url) + + if api_key == "": + self.api_key = os.getenv("LLMWHISPERER_API_KEY", "") + else: + self.api_key = api_key + + self.headers = {"unstract-key": self.api_key} + if custom_headers: + self.headers.update(custom_headers) + + self.max_retries = max_retries + self.retry_min_wait = retry_min_wait + self.retry_max_wait = retry_max_wait + + @staticmethod + def _is_retryable(exc: BaseException) -> bool: + """Return True if the exception represents a transient/retryable + error.""" + if isinstance(exc, requests.ConnectionError | requests.Timeout): + return True + if isinstance(exc, _RetryableHTTPError): + return bool(exc.response.status_code == 429 or exc.response.status_code >= 500) + return False + + def _log_retry(self, retry_state: tenacity.RetryCallState) -> None: + """Log a warning before each retry sleep.""" + exc = retry_state.outcome.exception() if retry_state.outcome else None + attempt = retry_state.attempt_number + if isinstance(exc, _RetryableHTTPError): + self.logger.warning("Retry attempt %d: HTTP %d", attempt, exc.response.status_code) + elif isinstance(exc, requests.ConnectionError | requests.Timeout): + self.logger.warning("Retry attempt %d: %s", attempt, type(exc).__name__) + + def _retry_wait(self, retry_state: tenacity.RetryCallState) -> float: + """Compute wait time, respecting Retry-After header on 429 + responses.""" + exc = retry_state.outcome.exception() if retry_state.outcome else None + if isinstance(exc, _RetryableHTTPError) and exc.response.status_code == 429: + retry_after = exc.response.headers.get("Retry-After") + if retry_after is not None: + try: + return float(retry_after) + except (ValueError, TypeError): + pass + return wait_exponential_jitter( + initial=self.retry_min_wait, + max=self.retry_max_wait, + )(retry_state=retry_state) + + def _send_request( + self, + prepared: requests.PreparedRequest, + timeout: int | None = None, + stream: bool = False, + deadline: float | None = None, + ) -> requests.Response: + """Send an HTTP request with optional tenacity retry on transient + errors. + + Args: + prepared: The prepared request to send. + timeout: Request timeout in seconds. Defaults to self.api_timeout. + stream: Whether to stream the response. Defaults to False. + deadline: Absolute time (time.time()) by which all attempts must finish. + When set, each attempt's HTTP timeout is capped to the remaining time + and retries stop once the deadline is exceeded. Defaults to None + (no deadline). + + Returns: + The HTTP response. + + Raises: + requests.ConnectionError: If connection fails after all retries. + requests.Timeout: If request times out after all retries. + """ + if timeout is None: + timeout = self.api_timeout + req_timeout: int = timeout + + def _effective_timeout() -> int | float: + if deadline is not None: + remaining = max(0.1, deadline - time.time()) + return min(req_timeout, remaining) + return req_timeout + + if self.max_retries == 0: + s = requests.Session() + return s.send(prepared, timeout=_effective_timeout(), stream=stream) + + def _attempt() -> requests.Response: + s = requests.Session() + response = s.send(prepared, timeout=_effective_timeout(), stream=stream) + if response.status_code == 429 or response.status_code >= 500: + raise _RetryableHTTPError(response) + return response + + stop_condition: tenacity.stop.stop_base = stop_after_attempt(self.max_retries + 1) + if deadline is not None: + max_duration = max(0, deadline - time.time()) + stop_condition = stop_condition | stop_after_delay(max_duration) + + retrying = tenacity.Retrying( + retry=retry_if_exception(self._is_retryable), + stop=stop_condition, + wait=self._retry_wait, + before_sleep=self._log_retry, + reraise=True, + ) + try: + return retrying(_attempt) + except _RetryableHTTPError as e: + return e.response + + def get_usage_info(self) -> Any: + """Retrieves the usage information of the LLMWhisperer API. + + This method sends a GET request to the '/get-usage-info' endpoint of the LLMWhisperer API. + The response is a JSON object containing the usage information. + Refer to https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_usage_api + + Returns: + Dict[Any, Any]: A dictionary containing the usage information. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + self.logger.debug("get_usage_info called") + url = f"{self.base_url}/get-usage-info" + self.logger.debug("url: %s", url) + req = requests.Request("GET", url, headers=self.headers) + prepared = req.prepare() + response = self._send_request(prepared) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + return json.loads(response.text) + + def get_highlight_data(self, whisper_hash: str, lines: str, extract_all_lines: bool = False) -> Any: + """Retrieves the highlight information of the LLMWhisperer API. + + This method sends a GET request to the '/highlights' endpoint of the LLMWhisperer API. + The response is a JSON object containing the usage information. + Refer to https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_usage_api + + Args: + whisper_hash (str): The hash of the whisper operation. + lines (str): Define which lines metadata to retrieve. + You can specify which lines metadata to retrieve with this parameter. + Example 1-5,7,21- will retrieve lines metadata 1,2,3,4,5,7,21,22,23,24... + till the last line meta data. + + Returns: + Dict[Any, Any]: A dictionary containing the highlight information. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + self.logger.debug("highlight called") + url = f"{self.base_url}/highlights" + params = { + "whisper_hash": whisper_hash, + "lines": lines, + "extract_all_lines": extract_all_lines, + } + self.logger.debug("url: %s", url) + req = requests.Request("GET", url, headers=self.headers, params=params) + prepared = req.prepare() + response = self._send_request(prepared) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + return json.loads(response.text) + + def whisper_detail(self, whisper_hash: str) -> Any: + """Retrieves the details of a text extraction process. + + This method sends a GET request to the '/whisper-detail' endpoint of the LLMWhisperer API. + The response is a JSON object containing metadata about the extraction job. + Refer to https://docs.unstract.com/llmwhisperer/llm_whisperer/apis/llm_whisperer_text_extraction_detail_api + + Args: + whisper_hash (str): The identifier returned when starting the extraction process. + + Returns: + Dict[Any, Any]: A dictionary containing the extraction details including + completed_at, mode, processed_pages, processing_started_at, + processing_time_in_seconds, requested_pages, tag, total_pages, + upload_file_size_in_kb, and whisper_hash. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + self.logger.debug("whisper_detail called") + url = f"{self.base_url}/whisper-detail" + params = {"whisper_hash": whisper_hash} + self.logger.debug("url: %s", url) + self.logger.debug("whisper_hash: %s", whisper_hash) + + req = requests.Request("GET", url, headers=self.headers, params=params) + prepared = req.prepare() + response = self._send_request(prepared) + if response.status_code != 200: + if not (response.text or "").strip(): + raise LLMWhispererClientException("API error: empty response body", response.status_code) + try: + err = json.loads(response.text) + except json.JSONDecodeError as e: + response_preview = response.text[:500] + "..." if len(response.text) > 500 else response.text + raise LLMWhispererClientException( + f"API error: non-JSON response - {response_preview}", response.status_code + ) from e + raise LLMWhispererClientException(err, response.status_code) + return json.loads(response.text) + + def _resolve_deprecated_param( + self, + name: str, + value: str | None, + deprecated_name: str, + deprecated_value: str | None, + default: str, + *, + forward: bool, + ) -> str: + """Resolves a renamed parameter, warning when the old name is used. + + Args: + name: The supported parameter name. + value: Value passed under the supported name, None when unset. + deprecated_name: The deprecated parameter name. + deprecated_value: Value passed under the deprecated name, None when unset. + default: Value to use when neither name is passed. + forward: Whether the deprecated value is honoured. False for parameters the + service never received, where applying the value now would silently + change extraction output. + + Returns: + The resolved value. + + Raises: + LLMWhispererClientException: If both names are passed. + """ + if deprecated_value is None: + return default if value is None else value + if value is not None: + raise LLMWhispererClientException( + f"Cannot pass both '{deprecated_name}' and '{name}', use '{name}' only", + 1, + ) + message = f"'{deprecated_name}' is deprecated and will be removed in a future release, use '{name}' instead" + if not forward: + message += f". The value passed is ignored: '{deprecated_name}' never reached the service" + self.logger.warning(message) + warnings.warn(message, DeprecationWarning, stacklevel=3) + return deprecated_value if forward else default + + def whisper( + self, + file_path: str = "", + stream: IO[bytes] | None = None, + url: str = "", + mode: str = "form", + output_mode: str = "layout_preserving", + page_seperator: str | None = None, + pages_to_extract: str = "", + median_filter_size: int = 0, + gaussian_blur_radius: int = 0, + line_splitter_tolerance: float = 0.4, + horizontal_stretch_factor: float = 1.0, + mark_vertical_lines: bool = False, + mark_horizontal_lines: bool = False, + line_spitter_strategy: str | None = None, + add_line_nos: bool = False, + include_line_confidence: bool = False, + word_confidence_threshold: float = 0.3, + lang: str = "eng", + tag: str = "default", + filename: str | None = None, + webhook_metadata: str = "", + use_webhook: str = "", + wait_for_completion: bool = False, + wait_timeout: int = 180, + encoding: str = "utf-8", + page_separator: str | None = None, + line_splitter_strategy: str | None = None, + file_name: str | None = None, + ) -> Any: + """Sends a request to the LLMWhisperer API to process a document. + Refer to https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_text_extraction_api. + + Args: + file_path (str, optional): The path to the file to be processed. Defaults to "". + stream (IO[bytes], optional): A stream of bytes to be processed. Defaults to None. + url (str, optional): The URL of the file to be processed. Defaults to "". + mode (str, optional): The processing mode. Can be "high_quality", "form", "low_cost", "native_text" + or "table". Defaults to "high_quality". + output_mode (str, optional): The output mode. Can be "layout_preserving" or "text". + Defaults to "layout_preserving". + page_seperator (str, optional): Deprecated misspelling of page_separator, still + honoured. Defaults to None. + pages_to_extract (str, optional): The pages to extract. Defaults to "". + median_filter_size (int, optional): The size of the median filter. Defaults to 0. + gaussian_blur_radius (int, optional): The radius of the Gaussian blur. Defaults to 0. + line_splitter_tolerance (float, optional): The line splitter tolerance. Defaults to 0.4. + horizontal_stretch_factor (float, optional): The horizontal stretch factor. Defaults to 1.0. + mark_vertical_lines (bool, optional): Whether to mark vertical lines. Defaults to False. + mark_horizontal_lines (bool, optional): Whether to mark horizontal lines. Defaults to False. + line_spitter_strategy (str, optional): Deprecated misspelling of + line_splitter_strategy. The value is ignored, since it was never sent under a + name the service reads. Defaults to None. + add_line_nos (bool, optional): Adds line numbers to the extracted text and saves line metadata, + which can be queried later using the highlights API. + include_line_confidence (bool, optional): Adds line confidence to the line metadata returned by + the highlights API. Requires add_line_nos to be enabled. Defaults to False. + word_confidence_threshold (float, optional): The minimum OCR confidence score a word must have to be + included in the extracted text. Accepts a value in the range [0.0, 1.0], where higher values are + stricter. Any word whose confidence value falls below the configured threshold is ignored and + excluded from the final output. This parameter works only with "form", "high_quality" and "table" + modes. Defaults to 0.3. + lang (str, optional): The language of the document. Defaults to "eng". + tag (str, optional): The tag for the document. Defaults to "default". + filename (str, optional): Deprecated name for file_name, still honoured. + Defaults to None. + webhook_metadata (str, optional): The webhook metadata. This data will be passed to the webhook if + webhooks are used Defaults to "". + use_webhook (str, optional): Webhook name to call. Defaults to "". If not provided, then + no webhook will be called. + wait_for_completion (bool, optional): Whether to wait for the whisper operation to complete. + Defaults to False. + wait_timeout (int, optional): The number of seconds to wait for the whisper operation to complete. + Defaults to 180. + encoding (str): The character encoding to use for processing the text. Defaults to "utf-8". + page_separator (str, optional): The page separator. Defaults to "<<<". + line_splitter_strategy (str, optional): The line splitter strategy. + Defaults to "left-priority". + file_name (str, optional): The name of the file to store in reports. Defaults to "". + + Returns: + Dict[Any, Any]: The response from the API as a dictionary. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + Also raised when a parameter is passed under both its + deprecated and its supported name. + """ + self.logger.debug("whisper called") + page_separator = self._resolve_deprecated_param( + "page_separator", page_separator, "page_seperator", page_seperator, "<<<", forward=True + ) + line_splitter_strategy = self._resolve_deprecated_param( + "line_splitter_strategy", + line_splitter_strategy, + "line_spitter_strategy", + line_spitter_strategy, + "left-priority", + forward=False, + ) + file_name = self._resolve_deprecated_param("file_name", file_name, "filename", filename, "", forward=True) + api_url = f"{self.base_url}/whisper" + params = { + "mode": mode, + "output_mode": output_mode, + "page_separator": page_separator, + "pages_to_extract": pages_to_extract, + "median_filter_size": median_filter_size, + "gaussian_blur_radius": gaussian_blur_radius, + "line_splitter_tolerance": line_splitter_tolerance, + "horizontal_stretch_factor": horizontal_stretch_factor, + "mark_vertical_lines": mark_vertical_lines, + "mark_horizontal_lines": mark_horizontal_lines, + "line_splitter_strategy": line_splitter_strategy, + "add_line_nos": add_line_nos, + "include_line_confidence": include_line_confidence, + "word_confidence_threshold": word_confidence_threshold, + "lang": lang, + "tag": tag, + "file_name": file_name, + "webhook_metadata": webhook_metadata, + "use_webhook": use_webhook, + } + + self.logger.debug("api_url: %s", api_url) + self.logger.debug("params: %s", params) + + if use_webhook != "" and wait_for_completion: + raise LLMWhispererClientException("Cannot wait for completion when using webhook", 1) + + if url == "" and file_path == "" and stream is None: + raise LLMWhispererClientException( + "Either url, stream or file_path must be provided", + 1, + ) + + should_stream = False + if url == "": + if stream is not None: + should_stream = True + data = b"".join(stream) + req = requests.Request( + "POST", + api_url, + params=params, + headers=self.headers, + data=data, + ) + + else: + with open(file_path, "rb") as f: + data = f.read() + req = requests.Request( + "POST", + api_url, + params=params, + headers=self.headers, + data=data, + ) + else: + params["url_in_post"] = True + req = requests.Request("POST", api_url, params=params, headers=self.headers, data=url) + prepared = req.prepare() + start_time = time.time() + deadline = start_time + wait_timeout + post_timeout = min(self.api_timeout, wait_timeout) + response = self._send_request(prepared, timeout=post_timeout, stream=should_stream, deadline=deadline) + response.encoding = encoding + if response.status_code not in (200, 202): + try: + message = json.loads(response.text) + if not isinstance(message, dict): + message = {"message": str(message)} + except (json.JSONDecodeError, ValueError): + message = {"message": response.text} + message["status_code"] = response.status_code + message["extraction"] = {} + raise LLMWhispererClientException(message) + if response.status_code == 202: + try: + message = json.loads(response.text) + if not isinstance(message, dict): + message = {"message": str(message)} + except (json.JSONDecodeError, ValueError): + message = {"message": response.text} + message["status_code"] = response.status_code + message["extraction"] = {} + if not wait_for_completion: + return message + whisper_hash = message["whisper_hash"] + while time.time() - start_time < wait_timeout: + status = self.whisper_status(whisper_hash=whisper_hash) + if status["status_code"] != 200: + message["status_code"] = -1 + message["message"] = "Whisper client operation failed" + message["extraction"] = {} + return message + if status["status"] == "accepted": + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: {status['status']}...") + if status["status"] == "processing": + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: processing...") + + elif status["status"] == "error": + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: failed...") + self.logger.error(f"Whisper-hash:{whisper_hash} | STATUS: failed with {status['message']}") + message["status_code"] = -1 + message["message"] = status["message"] + message["status"] = "error" + message["extraction"] = {} + return message + elif "error" in status["status"]: + # for backward compatabity + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: failed...") + self.logger.error(f"Whisper-hash:{whisper_hash} | STATUS: failed with {status['status']}") + message["status_code"] = -1 + message["message"] = status["status"] + message["status"] = "error" + message["extraction"] = {} + return message + elif status["status"] == "processed": + self.logger.debug(f"Whisper-hash:{whisper_hash} | STATUS: processed!") + resultx = self.whisper_retrieve(whisper_hash=whisper_hash) + if resultx["status_code"] == 200: + message["status_code"] = 200 + message["message"] = "Whisper operation completed" + message["status"] = "processed" + message["extraction"] = resultx["extraction"] + else: + message["status_code"] = -1 + message["message"] = "Whisper client operation failed" + message["extraction"] = {} + return message + time.sleep(5) + message["status_code"] = -1 + message["message"] = "Whisper client operation timed out" + message["extraction"] = {} + return message + + # Will not reach here if status code is 202 + message = json.loads(response.text) + message["status_code"] = response.status_code + return message + + def whisper_status(self, whisper_hash: str) -> Any: + """Retrieves the status of the whisper operation from the LLMWhisperer + API. + + This method sends a GET request to the '/whisper-status' endpoint of the LLMWhisperer API. + The response is a JSON object containing the status of the whisper operation. + + Refer https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_text_extraction_status_api + + Args: + whisper_hash (str): The hash of the whisper (returned by whisper method) + + Returns: + dict: A dictionary containing the status of the whisper operation. The keys in the + dictionary include 'status_code' and the status details. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + self.logger.debug("whisper_status called") + url = f"{self.base_url}/whisper-status" + params = {"whisper_hash": whisper_hash} + self.logger.debug("url: %s", url) + req = requests.Request("GET", url, headers=self.headers, params=params) + prepared = req.prepare() + response = self._send_request(prepared) + if response.status_code != 200: + if not (response.text or "").strip(): + self.logger.error(f"API error - empty response body, status code: {response.status_code}") + raise LLMWhispererClientException("API error: empty response body", response.status_code) + try: + err = json.loads(response.text) + except json.JSONDecodeError as e: + # Truncate response text if too long to avoid log pollution + response_preview = response.text[:500] + "..." if len(response.text) > 500 else response.text + self.logger.error(f"API error - JSON decode failed: {e}; Response preview: {response_preview!r}") + raise LLMWhispererClientException( + f"API error: non-JSON response - {response_preview}", response.status_code + ) from e + raise LLMWhispererClientException(err, response.status_code) + message = json.loads(response.text) + message["status_code"] = response.status_code + return message + + def whisper_retrieve(self, whisper_hash: str, encoding: str = "utf-8") -> Any: + """Retrieves the result of the whisper operation from the LLMWhisperer + API. + + This method sends a GET request to the '/whisper-retrieve' endpoint of the LLMWhisperer API. + The response is a JSON object containing the result of the whisper operation. + + Refer to https://docs.unstract.com/llm_whisperer/apis/llm_whisperer_text_extraction_retrieve_api + + Args: + whisper_hash (str): The hash of the whisper operation. + encoding (str): The character encoding to use for processing the text. Defaults to "utf-8". + + Returns: + dict: A dictionary containing the status code and the extracted text from the whisper operation. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + self.logger.debug("whisper_retrieve called") + url = f"{self.base_url}/whisper-retrieve" + params = {"whisper_hash": whisper_hash} + self.logger.debug("url: %s", url) + req = requests.Request("GET", url, headers=self.headers, params=params) + prepared = req.prepare() + response = self._send_request(prepared) + response.encoding = encoding + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + + return { + "status_code": response.status_code, + "extraction": json.loads(response.text), + } + + def register_webhook(self, url: str, auth_token: str, webhook_name: str) -> Any: + """Registers a webhook with the LLMWhisperer API. + + This method sends a POST request to the '/whisper-manage-callback' endpoint of the LLMWhisperer API. + The response is a JSON object containing the status of the webhook registration. + + Refer to https://docs.unstract.com/llm_whisperer/apis/ + + Args: + url (str): The URL of the webhook. + auth_token (str): The authentication token for the webhook. + webhook_name (str): The name of the webhook. + + Returns: + Any: A dictionary containing the status code and the response from the API. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + data = { + "url": url, + "auth_token": auth_token, + "webhook_name": webhook_name, + } + url = f"{self.base_url}/whisper-manage-callback" + req = requests.Request("POST", url, headers=self.headers, json=data) + prepared = req.prepare() + response = self._send_request(prepared) + if response.status_code != 201: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + return json.loads(response.text) + + def update_webhook_details(self, webhook_name: str, url: str, auth_token: str) -> Any: + """Updates the details of a webhook from the LLMWhisperer API. + + This method sends a PUT request to the '/whisper-manage-callback' endpoint of the LLMWhisperer API. + The response is a JSON object containing the status of the webhook update. + + Refer to https://docs.unstract.com/llm_whisperer/apis/ + + Args: + webhook_name (str): The name of the webhook. + url (str): The URL of the webhook. + auth_token (str): The authentication token for the webhook. + + Returns: + dict: A dictionary containing the status code and the response from the API. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + data = { + "url": url, + "auth_token": auth_token, + "webhook_name": webhook_name, + } + url = f"{self.base_url}/whisper-manage-callback" + req = requests.Request("PUT", url, headers=self.headers, json=data) + prepared = req.prepare() + response = self._send_request(prepared) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + return json.loads(response.text) + + def get_webhook_details(self, webhook_name: str) -> Any: + """Retrieves the details of a webhook from the LLMWhisperer API. + + This method sends a GET request to the '/whisper-manage-callback' endpoint of the LLMWhisperer API. + The response is a JSON object containing the details of the webhook. + + Refer to https://docs.unstract.com/llm_whisperer/apis/ + + Args: + webhook_name (str): The name of the webhook. + + Returns: + dict: A dictionary containing the status code and the response from the API. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + url = f"{self.base_url}/whisper-manage-callback" + params = {"webhook_name": webhook_name} + req = requests.Request("GET", url, headers=self.headers, params=params) + prepared = req.prepare() + response = self._send_request(prepared) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + return json.loads(response.text) + + def delete_webhook(self, webhook_name: str) -> Any: + """Deletes a webhook from the LLMWhisperer API. + + This method sends a DELETE request to the '/whisper-manage-callback' endpoint of the LLMWhisperer API. + The response is a JSON object containing the status of the webhook deletion. + + Refer to https://docs.unstract.com/llm_whisperer/apis/ + + Args: + webhook_name (str): The name of the webhook. + + Returns: + dict: A dictionary containing the status code and the response from the API. + + Raises: + LLMWhispererClientException: If the API request fails, it raises an exception with + the error message and status code returned by the API. + """ + url = f"{self.base_url}/whisper-manage-callback" + params = {"webhook_name": webhook_name} + req = requests.Request("DELETE", url, headers=self.headers, params=params) + prepared = req.prepare() + response = self._send_request(prepared) + if response.status_code != 200: + err = json.loads(response.text) + err["status_code"] = response.status_code + raise LLMWhispererClientException(err) + return json.loads(response.text) + + def get_highlight_rect( + self, + line_metadata: list[int], + target_width: int, + target_height: int, + ) -> tuple[int, int, int, int, int]: + """Given the line metadata and the line number, this function returns + the bounding box of the line in the format (page,x1,y1,x2,y2). + + Args: + line_metadata (list[int]): The line metadata returned by the LLMWhisperer API. + target_width (int): The width of your target image/page in UI. + target_height (int): The height of your target image/page in UI. + + Returns: + tuple: The bounding box of the line in the format (page,x1,y1,x2,y2) + """ + page = line_metadata[0] + x1 = 0 + y1 = line_metadata[1] - line_metadata[2] + x2 = target_width + y2 = line_metadata[1] + original_height = line_metadata[3] + + y1 = int((float(y1) / float(original_height)) * float(target_height)) + y2 = int((float(y2) / float(original_height)) * float(target_height)) + + return (page, x1, y1, x2, y2) diff --git a/tests/conftest.py b/tests/conftest.py index 8eea76e..0ac4a6b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ import os import pytest + from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 diff --git a/tests/integration/client_v2_test.py b/tests/integration/client_v2_test.py index 2c2e863..61fdd90 100644 --- a/tests/integration/client_v2_test.py +++ b/tests/integration/client_v2_test.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest + from unstract.llmwhisperer.client_v2 import ( LLMWhispererClientException, LLMWhispererClientV2, @@ -37,9 +38,9 @@ def test_get_usage_info(client_v2: LLMWhispererClientV2) -> None: "today_page_count", "current_page_count_table", ] - assert set(expected_keys).issubset( - usage_info.keys() - ), f"usage_info is missing expected keys: {set(expected_keys) - set(usage_info.keys())}" + assert set(expected_keys).issubset(usage_info.keys()), ( + f"usage_info is missing expected keys: {set(expected_keys) - set(usage_info.keys())}" + ) @pytest.mark.parametrize( @@ -70,7 +71,7 @@ def test_whisper_v2( file_path=file_path, wait_for_completion=True, ) - logger.debug(f"Result for '{output_mode}', '{mode}', " f"'{input_file}: {whisper_result}") + logger.debug(f"Result for '{output_mode}', '{mode}', '{input_file}: {whisper_result}") exp_basename = f"{Path(input_file).stem}.{mode}.{output_mode}.txt" exp_file = os.path.join(data_dir, "expected", exp_basename) @@ -165,7 +166,7 @@ def test_whisper_v2_url_in_post( ) -> None: usage_before = client_v2.get_usage_info() whisper_result = client_v2.whisper(mode=mode, output_mode=output_mode, url=url, wait_for_completion=True) - logger.debug(f"Result for '{output_mode}', '{mode}', " f"'{input_file}: {whisper_result}") + logger.debug(f"Result for '{output_mode}', '{mode}', '{input_file}: {whisper_result}") exp_basename = f"{Path(input_file).stem}.{mode}.{output_mode}.txt" exp_file = os.path.join(data_dir, "expected", exp_basename) @@ -243,7 +244,8 @@ def test_webhook(client_v2: LLMWhispererClientV2, url: str, token: str, webhook_ def test_whisper_detail(client_v2: LLMWhispererClientV2, data_dir: str) -> None: - """Test whisper_detail returns extraction metadata after a whisper operation.""" + """Test whisper_detail returns extraction metadata after a whisper + operation.""" file_path = os.path.join(data_dir, "credit_card.pdf") whisper_result = client_v2.whisper( mode="native_text", @@ -269,9 +271,9 @@ def test_whisper_detail(client_v2: LLMWhispererClientV2, data_dir: str) -> None: "upload_file_size_in_kb", "whisper_hash", ] - assert set(expected_keys).issubset( - detail.keys() - ), f"whisper_detail is missing expected keys: {set(expected_keys) - set(detail.keys())}" + assert set(expected_keys).issubset(detail.keys()), ( + f"whisper_detail is missing expected keys: {set(expected_keys) - set(detail.keys())}" + ) assert detail["mode"] == "native_text" assert detail["processed_pages"] > 0 assert detail["total_pages"] > 0 @@ -287,10 +289,9 @@ def test_whisper_detail_not_found(client_v2: LLMWhispererClientV2) -> None: assert "message" in error -def test_whisper_line_splitter_strategy_reaches_service( - client_v2: LLMWhispererClientV2, data_dir: str -) -> None: - """An unknown strategy is rejected, which only happens if the param arrives.""" +def test_whisper_line_splitter_strategy_reaches_service(client_v2: LLMWhispererClientV2, data_dir: str) -> None: + """An unknown strategy is rejected, which only happens if the param + arrives.""" file_path = os.path.join(data_dir, "credit_card.pdf") with pytest.raises(LLMWhispererClientException) as exc_info: @@ -344,19 +345,19 @@ def assert_extracted_text(file_path: str, whisper_result: dict, mode: str, outpu def verify_usage(before_extract: dict, after_extract: dict, page_count: int, mode: str = "form") -> None: all_modes = ["form", "high_quality", "low_cost", "native_text"] all_modes.remove(mode) - assert ( - after_extract["today_page_count"] == before_extract["today_page_count"] + page_count - ), "today_page_count calculation is wrong" - assert ( - after_extract["current_page_count"] == before_extract["current_page_count"] + page_count - ), "current_page_count calculation is wrong" + assert after_extract["today_page_count"] == before_extract["today_page_count"] + page_count, ( + "today_page_count calculation is wrong" + ) + assert after_extract["current_page_count"] == before_extract["current_page_count"] + page_count, ( + "current_page_count calculation is wrong" + ) if after_extract["overage_page_count"] > 0: - assert ( - after_extract["overage_page_count"] == before_extract["overage_page_count"] + page_count - ), "overage_page_count calculation is wrong" - assert ( - after_extract[f"current_page_count_{mode}"] == before_extract[f"current_page_count_{mode}"] + page_count - ), f"{mode} mode calculation is wrong" + assert after_extract["overage_page_count"] == before_extract["overage_page_count"] + page_count, ( + "overage_page_count calculation is wrong" + ) + assert after_extract[f"current_page_count_{mode}"] == before_extract[f"current_page_count_{mode}"] + page_count, ( + f"{mode} mode calculation is wrong" + ) for i in range(len(all_modes)): assert ( after_extract[f"current_page_count_{all_modes[i]}"] == before_extract[f"current_page_count_{all_modes[i]}"] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d7b5c5d..56fa811 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,4 +1,5 @@ import pytest + from unstract.llmwhisperer.client_v2 import LLMWhispererClientV2 diff --git a/tests/unit/client_v2_test.py b/tests/unit/client_v2_test.py index 9f7a7fa..45c41f5 100644 --- a/tests/unit/client_v2_test.py +++ b/tests/unit/client_v2_test.py @@ -2,9 +2,11 @@ from unittest.mock import MagicMock from urllib.parse import parse_qs, urlparse +import httpx import pytest import requests from pytest_mock import MockerFixture + from unstract.llmwhisperer.client_v2 import LLMWhispererClientException, LLMWhispererClientV2 WEBHOOK_URL = "http://test-webhook.com/callback" @@ -15,7 +17,7 @@ def test_register_webhook(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_response = MagicMock() mock_response.status_code = 201 mock_response.text = '{"message": "Webhook registered successfully"}' # noqa: E501 @@ -28,7 +30,7 @@ def test_register_webhook(mocker: MockerFixture, client_v2: LLMWhispererClientV2 def test_get_webhook_details(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = '{"status": "success", "webhook_details": {"url": "http://test-webhook.com/callback"}}' # noqa: E501 @@ -42,7 +44,7 @@ def test_get_webhook_details(mocker: MockerFixture, client_v2: LLMWhispererClien def test_whisper_detail_success(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: """Test whisper_detail returns extraction details on success.""" - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = ( @@ -65,7 +67,7 @@ def test_whisper_detail_success(mocker: MockerFixture, client_v2: LLMWhispererCl def test_whisper_detail_not_found(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: """Test whisper_detail raises exception when record is not found.""" - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_response = MagicMock() mock_response.status_code = 400 mock_response.text = '{"message": "Record not found"}' @@ -83,7 +85,7 @@ def test_whisper_detail_not_found(mocker: MockerFixture, client_v2: LLMWhisperer def test_whisper_json_string_response_error(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: """Test whisper method handles JSON string responses correctly for error cases.""" - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_response = MagicMock() mock_response.status_code = 400 mock_response.text = '"Error message as JSON string"' @@ -102,7 +104,7 @@ def test_whisper_json_string_response_error(mocker: MockerFixture, client_v2: LL def test_whisper_json_string_response_202(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: """Test whisper method handles JSON string responses correctly for 202 status.""" - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_response = MagicMock() mock_response.status_code = 202 mock_response.text = '"Processing in progress"' @@ -119,7 +121,7 @@ def test_whisper_json_string_response_202(mocker: MockerFixture, client_v2: LLMW def test_whisper_invalid_json_response_error(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: """Test whisper method handles invalid JSON responses correctly for error cases.""" - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_response = MagicMock() mock_response.status_code = 500 mock_response.text = "Invalid JSON response" @@ -138,7 +140,7 @@ def test_whisper_invalid_json_response_error(mocker: MockerFixture, client_v2: L def test_whisper_invalid_json_response_202(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: """Test whisper method handles invalid JSON responses correctly for 202 status.""" - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_response = MagicMock() mock_response.status_code = 202 mock_response.text = "Invalid JSON response" @@ -155,20 +157,20 @@ def test_whisper_invalid_json_response_202(mocker: MockerFixture, client_v2: LLM def test_whisper_default_word_confidence_threshold(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: """Whisper() sends the default word_confidence_threshold when not specified.""" - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_send.return_value = _mock_response(200, '{"status_code": 200, "extraction": {"text": "ok"}}') client_v2.whisper(url="https://example.com/test.pdf", wait_for_completion=False) prepared_request = mock_send.call_args[0][0] - query = parse_qs(urlparse(prepared_request.url).query) + query = parse_qs(urlparse(str(prepared_request.url)).query) assert query["word_confidence_threshold"] == ["0.3"] def test_whisper_custom_word_confidence_threshold(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: """Whisper() forwards a custom word_confidence_threshold as a request param.""" - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_send.return_value = _mock_response(200, '{"status_code": 200, "extraction": {"text": "ok"}}') client_v2.whisper( @@ -178,7 +180,7 @@ def test_whisper_custom_word_confidence_threshold(mocker: MockerFixture, client_ ) prepared_request = mock_send.call_args[0][0] - query = parse_qs(urlparse(prepared_request.url).query) + query = parse_qs(urlparse(str(prepared_request.url)).query) assert query["word_confidence_threshold"] == ["0.75"] @@ -188,12 +190,12 @@ def test_whisper_custom_word_confidence_threshold(mocker: MockerFixture, client_ def _whisper_query(mocker: MockerFixture, client_v2: LLMWhispererClientV2, **kwargs: object) -> dict[str, list[str]]: """Calls whisper() with a mocked transport and returns the query params sent.""" - mock_send = mocker.patch("requests.Session.send") + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send") mock_send.return_value = _mock_response(200, '{"status_code": 200, "extraction": {"text": "ok"}}') client_v2.whisper(url="https://example.com/test.pdf", wait_for_completion=False, **kwargs) - return parse_qs(urlparse(mock_send.call_args[0][0].url).query) + return parse_qs(urlparse(str(mock_send.call_args[0][0].url)).query) def test_whisper_sends_corrected_param_names(mocker: MockerFixture, client_v2: LLMWhispererClientV2) -> None: @@ -302,8 +304,9 @@ def _mock_response(status_code: int = 200, text: str = '{"status": "ok"}') -> Ma def test_retry_on_connection_error(mocker: MockerFixture, retry_client: LLMWhispererClientV2) -> None: """ConnectionError triggers retry, succeeds on 3rd attempt.""" success_resp = _mock_response(200, '{"pages_processed": 1}') - mock_send = mocker.patch( - "requests.Session.send", + mock_send = mocker.patch.object( + LLMWhispererClientV2, + "_send", side_effect=[ requests.ConnectionError("connection refused"), requests.ConnectionError("connection refused"), @@ -320,8 +323,9 @@ def test_retry_on_connection_error(mocker: MockerFixture, retry_client: LLMWhisp def test_retry_on_timeout(mocker: MockerFixture, retry_client: LLMWhispererClientV2) -> None: """Timeout triggers retry, succeeds on 2nd attempt.""" success_resp = _mock_response(200, '{"pages_processed": 1}') - mock_send = mocker.patch( - "requests.Session.send", + mock_send = mocker.patch.object( + LLMWhispererClientV2, + "_send", side_effect=[ requests.Timeout("request timed out"), success_resp, @@ -338,8 +342,9 @@ def test_retry_on_429(mocker: MockerFixture, retry_client: LLMWhispererClientV2) """HTTP 429 triggers retry, succeeds on 2nd attempt.""" rate_limit_resp = _mock_response(429, '{"error": "rate limited"}') success_resp = _mock_response(200, '{"pages_processed": 1}') - mock_send = mocker.patch( - "requests.Session.send", + mock_send = mocker.patch.object( + LLMWhispererClientV2, + "_send", side_effect=[rate_limit_resp, success_resp], ) @@ -353,8 +358,9 @@ def test_retry_on_500(mocker: MockerFixture, retry_client: LLMWhispererClientV2) """HTTP 500 triggers retry, succeeds on 2nd attempt.""" server_err_resp = _mock_response(500, '{"error": "internal server error"}') success_resp = _mock_response(200, '{"pages_processed": 1}') - mock_send = mocker.patch( - "requests.Session.send", + mock_send = mocker.patch.object( + LLMWhispererClientV2, + "_send", side_effect=[server_err_resp, success_resp], ) @@ -367,7 +373,7 @@ def test_retry_on_500(mocker: MockerFixture, retry_client: LLMWhispererClientV2) def test_no_retry_on_400(mocker: MockerFixture, retry_client: LLMWhispererClientV2) -> None: """HTTP 400 does NOT retry (client error).""" bad_request_resp = _mock_response(400, '{"error": "bad request"}') - mock_send = mocker.patch("requests.Session.send", return_value=bad_request_resp) + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send", return_value=bad_request_resp) with pytest.raises(LLMWhispererClientException): retry_client.get_usage_info() @@ -378,7 +384,7 @@ def test_no_retry_on_400(mocker: MockerFixture, retry_client: LLMWhispererClient def test_no_retry_on_401(mocker: MockerFixture, retry_client: LLMWhispererClientV2) -> None: """HTTP 401 does NOT retry (auth error).""" unauth_resp = _mock_response(401, '{"error": "unauthorized"}') - mock_send = mocker.patch("requests.Session.send", return_value=unauth_resp) + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send", return_value=unauth_resp) with pytest.raises(LLMWhispererClientException): retry_client.get_usage_info() @@ -388,8 +394,9 @@ def test_no_retry_on_401(mocker: MockerFixture, retry_client: LLMWhispererClient def test_retries_exhausted_raises(mocker: MockerFixture, retry_client: LLMWhispererClientV2) -> None: """After all retries exhausted on ConnectionError, raises the exception.""" - mock_send = mocker.patch( - "requests.Session.send", + mock_send = mocker.patch.object( + LLMWhispererClientV2, + "_send", side_effect=requests.ConnectionError("connection refused"), ) @@ -404,7 +411,7 @@ def test_retries_exhausted_500_returns_response(mocker: MockerFixture, retry_cli """After all retries exhausted on 500, returns the error response (caller raises exception).""" server_err_resp = _mock_response(500, '{"error": "internal server error"}') - mock_send = mocker.patch("requests.Session.send", return_value=server_err_resp) + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send", return_value=server_err_resp) with pytest.raises(LLMWhispererClientException): retry_client.get_usage_info() @@ -415,8 +422,9 @@ def test_retries_exhausted_500_returns_response(mocker: MockerFixture, retry_cli def test_retry_disabled(mocker: MockerFixture, no_retry_client: LLMWhispererClientV2) -> None: """max_retries=0 means single attempt only, no retry on failure.""" - mock_send = mocker.patch( - "requests.Session.send", + mock_send = mocker.patch.object( + LLMWhispererClientV2, + "_send", side_effect=requests.ConnectionError("connection refused"), ) @@ -443,7 +451,7 @@ def test_whisper_post_uses_min_of_api_timeout_and_wait_timeout( # api_timeout defaults to 120, wait_timeout will be 180 # So POST timeout should be min(120, 180) = 120 mock_response = _mock_response(200, '{"status_code": 200, "extraction": {"text": "ok"}}') - mock_send = mocker.patch("requests.Session.send", return_value=mock_response) + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send", return_value=mock_response) client.whisper(url="https://example.com/test.pdf", wait_timeout=180) @@ -466,7 +474,7 @@ def test_whisper_post_uses_wait_timeout_when_smaller( max_retries=0, ) mock_response = _mock_response(200, '{"status_code": 200, "extraction": {"text": "ok"}}') - mock_send = mocker.patch("requests.Session.send", return_value=mock_response) + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send", return_value=mock_response) client.whisper(url="https://example.com/test.pdf", wait_timeout=10) @@ -485,12 +493,11 @@ def test_send_request_deadline_caps_timeout(mocker: MockerFixture) -> None: max_retries=0, ) mock_response = _mock_response(200) - mock_send = mocker.patch("requests.Session.send", return_value=mock_response) + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send", return_value=mock_response) # Set deadline 2 seconds from now, but request timeout=300 deadline = time.time() + 2.0 - req = requests.Request("GET", "http://localhost/test", headers={}) - prepared = req.prepare() + prepared = httpx.Request("GET", "http://localhost/test") client._send_request(prepared, timeout=300, deadline=deadline) @@ -513,13 +520,12 @@ def test_send_request_deadline_stops_retries(mocker: MockerFixture) -> None: ) server_err_resp = _mock_response(500, '{"error": "internal server error"}') - mock_send = mocker.patch("requests.Session.send", return_value=server_err_resp) + mock_send = mocker.patch.object(LLMWhispererClientV2, "_send", return_value=server_err_resp) # Deadline is 0.3s from now — with 0.1-0.2s waits between retries, # only a few attempts should fit before the deadline expires deadline = time.time() + 0.3 - req = requests.Request("GET", "http://localhost/test", headers={}) - prepared = req.prepare() + prepared = httpx.Request("GET", "http://localhost/test") response = client._send_request(prepared, timeout=1, deadline=deadline) diff --git a/tests/unit/compat_test.py b/tests/unit/compat_test.py new file mode 100644 index 0000000..4c56f0f --- /dev/null +++ b/tests/unit/compat_test.py @@ -0,0 +1,771 @@ +"""Parity tests against the published client. + +The transport underneath ``LLMWhispererClientV2`` changed; its published +behaviour must not. These tests pin the seams where that could silently break: +the constructor and method signatures, what goes out on the wire, which +exceptions come back out, and what each method returns — the last two by running +the published client side by side over the same responses. + +The baseline is vendored under ``tests/baseline`` rather than imported from an +installed distribution, so the comparison is against a fixed published client +instead of whatever the working tree currently says. +""" + +import ast +import hashlib +import importlib.util +import inspect +import io +import json +import socket +import threading +import time +from collections.abc import Callable +from pathlib import Path +from types import ModuleType +from typing import Any +from unittest.mock import MagicMock, patch +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +import requests + +from unstract.llmwhisperer.client_v2 import ( + _SEND_ONLY, + LLMWhispererClientException, + LLMWhispererClientV2, +) + +BASELINE_VERSION = "2.8.0" +BASELINE_PATH = Path(__file__).parents[1] / "baseline" / "client_v2_2_8_0.py" +BASELINE_SHA256 = "0c5c60d6c5bd6bab61ac9889b55764f692818badf0a8015f9bbfca022db75d0c" +SPEC_PATH = Path(__file__).parents[2] / "specs" / "llmwhisperer.json" + +Call = Callable[[Any, str], Any] + +BASE_URL = "https://x.test/api/v2" + +# Operations the spec declares that this client does not expose. The published +# client exposes none of them either, so the facade is at parity — the spec +# surface is simply wider than the hand-written one. Listing them here keeps the +# coverage check honest instead of passing on whatever happens to be wrapped. +UNWRAPPED_OPERATIONS = frozenset( + { + "usage", + "test_connection", + "pdf_to_images", + "pdf_to_images_status", + "pdf_to_images_retrieve", + "document_insights", + "document_insights_retrieve", + "convert_to_pdf", + "convert_xlsb_to_xlsx", + } +) + + +def _load_baseline() -> ModuleType: + """Import the vendored published client under its own module name.""" + spec = importlib.util.spec_from_file_location("baseline_client_v2", BASELINE_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +baseline = _load_baseline() + + +def _client(**kwargs: Any) -> LLMWhispererClientV2: + kwargs.setdefault("base_url", BASE_URL) + kwargs.setdefault("api_key", "test-key") + kwargs.setdefault("logging_level", "ERROR") + kwargs.setdefault("max_retries", 0) + return LLMWhispererClientV2(**kwargs) + + +def _baseline_client(**kwargs: Any) -> Any: + kwargs.setdefault("base_url", BASE_URL) + kwargs.setdefault("api_key", "test-key") + kwargs.setdefault("logging_level", "ERROR") + kwargs.setdefault("max_retries", 0) + return baseline.LLMWhispererClientV2(**kwargs) + + +def _mock_response(status_code: int = 200, text: str = '{"ok": true}') -> MagicMock: + """A response both transports can be fed, so only the client differs.""" + response = MagicMock() + response.status_code = status_code + response.text = text + response.headers = {} + return response + + +@pytest.fixture +def sample_file(tmp_path: Path) -> str: + path = tmp_path / "sample.pdf" + path.write_bytes(b"PDFBYTES") + return str(path) + + +# -------------------------------------------------------------------------- +# What goes out on the wire, compared against the published client +# -------------------------------------------------------------------------- + +# Every call the client can make, as (name, callable). Each runs against both +# clients with the same mocked response, and the two requests must agree. +CALLS = { + "usage_info": (lambda c, f: c.get_usage_info(), 200), + "whisper_status": (lambda c, f: c.whisper_status("hash-1"), 200), + "whisper_retrieve": (lambda c, f: c.whisper_retrieve("hash-1"), 200), + "whisper_detail": (lambda c, f: c.whisper_detail("hash-1"), 200), + "highlights": (lambda c, f: c.get_highlight_data("hash-1", "1-5"), 200), + "highlights_all_lines": (lambda c, f: c.get_highlight_data("hash-1", "-1", True), 200), + "webhook_get": (lambda c, f: c.get_webhook_details("wh"), 200), + "webhook_delete": (lambda c, f: c.delete_webhook("wh"), 200), + "webhook_put": (lambda c, f: c.update_webhook_details("wh", "http://cb", "tok"), 200), + "webhook_post": (lambda c, f: c.register_webhook("http://cb", "tok", "wh"), 201), + "whisper_file": (lambda c, f: c.whisper(file_path=f, wait_for_completion=False), 200), + "whisper_stream": (lambda c, f: c.whisper(stream=io.BytesIO(b"STREAM"), wait_for_completion=False), 200), + "whisper_url": (lambda c, f: c.whisper(url="https://e.test/a.pdf", wait_for_completion=False), 200), + "whisper_every_param": ( + lambda c, f: c.whisper( + file_path=f, + wait_for_completion=False, + mode="high_quality", + output_mode="text", + page_separator="---", + pages_to_extract="1-2", + median_filter_size=2, + gaussian_blur_radius=1, + line_splitter_tolerance=0.9, + horizontal_stretch_factor=1.5, + mark_vertical_lines=True, + mark_horizontal_lines=True, + line_splitter_strategy="mid-priority", + add_line_nos=True, + include_line_confidence=True, + word_confidence_threshold=0.75, + lang="deu", + tag="t", + file_name="invoice.pdf", + webhook_metadata="meta", + use_webhook="wh", + ), + 200, + ), +} + +_WHISPER_OK = '{"status_code": 200, "extraction": {"result_text": "ok"}}' + + +def _sent(call: Call, status_code: int, sample_file: str) -> tuple[httpx.Request, Any]: + """Run the call against both clients and return the two requests.""" + text = _WHISPER_OK + with patch.object(LLMWhispererClientV2, "_send", return_value=_mock_response(status_code, text)) as ours: + call(_client(), sample_file) + with patch("requests.Session.send", return_value=_mock_response(status_code, text)) as theirs: + call(_baseline_client(), sample_file) + return ours.call_args[0][0], theirs.call_args[0][0] + + +@pytest.mark.parametrize("name", list(CALLS)) +def test_request_matches_the_published_client(name: str, sample_file: str) -> None: + """Method, path, query and body must be identical. + + Query-parameter *order* is not compared: httpx sorts what the + previous transport emitted in insertion order, and order carries no + meaning. + """ + call, status_code = CALLS[name] + ours, theirs = _sent(call, status_code, sample_file) + + ours_url, theirs_url = urlparse(str(ours.url)), urlparse(theirs.url) + assert ours.method.upper() == theirs.method.upper() + assert (ours_url.scheme, ours_url.netloc, ours_url.path) == ( + theirs_url.scheme, + theirs_url.netloc, + theirs_url.path, + ) + assert parse_qs(ours_url.query) == parse_qs(theirs_url.query) + + their_body = theirs.body.encode() if isinstance(theirs.body, str) else theirs.body + if ours.headers.get("content-type") == "application/json": + # Both send the same object; the encoders differ on separators and key + # order, neither of which a JSON reader can observe. + assert json.loads(ours.read()) == json.loads(their_body) + else: + assert ours.read() == (their_body or b"") + + +def test_the_auth_header_is_unchanged(sample_file: str) -> None: + ours, theirs = _sent(*CALLS["usage_info"], sample_file) + assert ours.headers["unstract-key"] == theirs.headers["unstract-key"] == "test-key" + + +def _wire_heads(*calls: Callable[[str], Any]) -> list[dict[str, str]]: + """Run each call against a loopback server and return its request headers. + + Below the client, the transport adds headers of its own -- and drops none + of them into any object the client can be asked for. A socket is the only + place both clients can be compared on what they actually send. One server + serves every call, so the `Host` header is the same for all of them. + """ + heads: list[bytes] = [] + server = socket.socket() + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + server.listen(len(calls)) + + def serve() -> None: + for _ in calls: + conn, _address = server.accept() + data = b"" + while b"\r\n\r\n" not in data: + chunk = conn.recv(65536) + if not chunk: + break + data += chunk + heads.append(data.split(b"\r\n\r\n")[0]) + body = b'{"ok":true}' + conn.sendall( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s" % (len(body), body) + ) + conn.close() + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + try: + url = f"http://127.0.0.1:{server.getsockname()[1]}/api/v2" + for call in calls: + call(url) + finally: + thread.join(timeout=10) + server.close() + + return [ + { + name.lower(): value.strip() + for name, _, value in (line.partition(":") for line in head.decode().split("\r\n")[1:]) + } + for head in heads + ] + + +def test_wire_headers_match_the_published_client() -> None: + """`Accept-Encoding` is the load-bearing one: the published client asked + for no compression, so a response this client has never seen decoded is not + something a transport swap should start requesting.""" + ours, theirs = _wire_heads( + lambda url: _client(base_url=url).get_usage_info(), + lambda url: _baseline_client(base_url=url).get_usage_info(), + ) + + assert theirs["accept-encoding"] == "identity" + assert {name: ours[name] for name in theirs if name != "user-agent"} == { + name: value for name, value in theirs.items() if name != "user-agent" + } + # The two httpx adds mean what their absence meant: `*/*` is the default + # Accept and keep-alive the default in HTTP/1.1. It also names itself, + # which is the one value that changes. + assert ours.keys() - theirs.keys() == {"accept", "connection"} + assert ours["user-agent"].startswith("python-httpx/") + + +def test_custom_headers_override_the_transport_defaults() -> None: + (ours,) = _wire_heads( + lambda url: _client(base_url=url, custom_headers={"Accept-Encoding": "gzip"}).get_usage_info() + ) + assert ours["accept-encoding"] == "gzip" + + +def test_custom_headers_still_reach_the_request() -> None: + client = _client(custom_headers={"x-trace": "abc", "unstract-key": "override"}) + with patch.object(LLMWhispererClientV2, "_send", return_value=_mock_response()) as send: + client.get_usage_info() + request = send.call_args[0][0] + assert request.headers["x-trace"] == "abc" + assert request.headers["unstract-key"] == "override" + + +def test_url_mode_does_not_put_the_url_on_the_query_string() -> None: + """The URL travels in the body. + + The spec declares a `url` parameter, and + sending it as well would be a parameter the published client never sent. + """ + client = _client() + with patch.object(LLMWhispererClientV2, "_send", return_value=_mock_response(200, _WHISPER_OK)) as send: + client.whisper(url="https://e.test/a.pdf", wait_for_completion=False) + request = send.call_args[0][0] + assert "url" not in parse_qs(urlparse(str(request.url)).query) + assert request.read() == b"https://e.test/a.pdf" + + +def test_upload_mode_does_not_send_url_in_post(sample_file: str) -> None: + """`url_in_post` only exists in URL mode; its generated default would + otherwise ride along on every upload.""" + client = _client() + with patch.object(LLMWhispererClientV2, "_send", return_value=_mock_response(200, _WHISPER_OK)) as send: + client.whisper(file_path=sample_file, wait_for_completion=False) + assert "url_in_post" not in parse_qs(urlparse(str(send.call_args[0][0].url)).query) + + +def test_booleans_are_sent_the_way_the_previous_transport_sent_them() -> None: + """Httpx renders bools lowercase; the previous transport used + `str(bool)`.""" + client = _client() + with patch.object(LLMWhispererClientV2, "_send", return_value=_mock_response(200, _WHISPER_OK)) as send: + client.whisper(url="https://e.test/a.pdf", wait_for_completion=False, add_line_nos=True) + query = parse_qs(urlparse(str(send.call_args[0][0].url)).query) + assert query["add_line_nos"] == ["True"] + assert query["url_in_post"] == ["True"] + assert query["mark_vertical_lines"] == ["False"] + + +def test_send_only_covers_every_parameter_whisper_builds(sample_file: str) -> None: + """The guard is only as good as its declared set; this pins the two + together so a new whisper parameter cannot reach the wire undeclared.""" + client = _client() + with patch.object(LLMWhispererClientV2, "_send", return_value=_mock_response(200, _WHISPER_OK)) as send: + client.whisper(url="https://e.test/a.pdf", wait_for_completion=False) + query = set(parse_qs(urlparse(str(send.call_args[0][0].url)).query)) + assert query <= _SEND_ONLY["extract"] + + +_ADDED_PARAMS = { + "allow_rotated_text": (False, "False"), + "watermark_angle_threshold": (0.0, "0.0"), + "ignore_vertical_text": (True, "True"), + "derotate_threshold": (0, "0"), + "checkbox_confidence_threshold": (0.0, "0.0"), + "min_table_width": (0.5, "0.5"), +} + + +def _whisper_query(client: Any, **kwargs: Any) -> dict[str, list[str]]: + with patch.object(LLMWhispererClientV2, "_send", return_value=_mock_response(200, _WHISPER_OK)) as send: + client.whisper(url="https://e.test/a.pdf", wait_for_completion=False, **kwargs) + return parse_qs(urlparse(str(send.call_args[0][0].url)).query) + + +def test_an_unrequested_parameter_is_not_sent() -> None: + """Sending one pins a value the service would otherwise choose, and the two + diverge the moment the service's own default moves.""" + assert not set(_whisper_query(_client())) & set(_ADDED_PARAMS) + + +@pytest.mark.parametrize(("name", "value", "expected"), [(n, v, e) for n, (v, e) in _ADDED_PARAMS.items()]) +def test_a_requested_parameter_is_sent(name: str, value: Any, expected: str) -> None: + """Every value here is falsy or off: a truthiness filter would drop them and + hand the decision back to the service without saying so. + """ + assert _whisper_query(_client(), **{name: value})[name] == [expected] + + +def test_undeclared_parameters_are_refused() -> None: + from unstract.llmwhisperer.sdk_llmwhisperer.api.whisper import status as status_module + + client = _client() + with pytest.raises(LLMWhispererClientException): + client._build_request(status_module, frozenset({"whisper_hash", "made_up"}), whisper_hash="h") + + +def test_no_operation_sends_a_spec_default_the_client_never_set() -> None: + """The generated builder writes every declared parameter; each call must + narrow that to what it asked for.""" + from unstract.llmwhisperer.sdk_llmwhisperer.api.whisper import extract, retrieve + + assert "text_only" in inspect.signature(retrieve._get_kwargs).parameters + assert "text_only" not in _SEND_ONLY["retrieve"] + declared = set(inspect.signature(extract._get_kwargs).parameters) - {"body"} + # In URL mode the URL travels in the body, so the query parameter the + # generator writes for it is the one extract must never send. + assert declared - _SEND_ONLY["extract"] == {"url_query"} + assert _SEND_ONLY["extract"] <= declared + + +# -------------------------------------------------------------------------- +# Return values and exceptions, compared against the published client +# -------------------------------------------------------------------------- + +ERROR_BODIES = [ + ("json_error", '{"message": "denied"}'), + ("nested_json", '{"message": {"detail": "denied"}}'), + ("json_string", '"denied"'), + ("html", "gateway"), + ("empty", ""), +] + + +def _outcome(call: Call, client: Any, sample_file: str, status_code: int, text: str) -> tuple[Any, ...]: + """Normalise a call into a comparable value: returned, or raised.""" + with ( + patch.object(LLMWhispererClientV2, "_send", return_value=_mock_response(status_code, text)), + patch("requests.Session.send", return_value=_mock_response(status_code, text)), + ): + try: + return ("return", call(client, sample_file)) + except Exception as exc: # noqa: BLE001 - the comparison is the point + return ("raise", type(exc).__name__, getattr(exc, "status_code", None), str(exc)[:160]) + + +@pytest.mark.parametrize("status_code", [200, 201, 400, 401, 404, 500]) +@pytest.mark.parametrize("name", list(CALLS)) +def test_return_value_matches_the_published_client(name: str, status_code: int, sample_file: str) -> None: + call = CALLS[name][0] + ours = _outcome(call, _client(), sample_file, status_code, '{"message": "x", "status": "processed"}') + theirs = _outcome(call, _baseline_client(), sample_file, status_code, '{"message": "x", "status": "processed"}') + assert ours == theirs + + +@pytest.mark.parametrize(("body_name", "text"), ERROR_BODIES, ids=[b[0] for b in ERROR_BODIES]) +@pytest.mark.parametrize("name", list(CALLS)) +def test_error_handling_matches_the_published_client(name: str, body_name: str, text: str, sample_file: str) -> None: + """Including the published client's own rough edges: several methods parse + an error body unguarded and leak ``JSONDecodeError``. + + A drop-in inherits the contract, bugs included. + """ + call = CALLS[name][0] + ours = _outcome(call, _client(), sample_file, 500, text) + theirs = _outcome(call, _baseline_client(), sample_file, 500, text) + assert ours == theirs + + +def test_whisper_poll_loop_matches_the_published_client(sample_file: str) -> None: + """wait_for_completion drives status and retrieve; the assembled result + dict must be identical.""" + accepted = '{"whisper_hash": "h-1", "message": "queued"}' + processed = '{"status": "processed", "message": "done"}' + extraction = '{"result_text": "hello"}' + + def run(client: Any, patch_target: Any) -> Any: + responses = [ + _mock_response(202, accepted), + _mock_response(200, processed), + _mock_response(200, extraction), + ] + with patch_target(side_effect=responses): + return client.whisper(file_path=sample_file, wait_for_completion=True, wait_timeout=30) + + ours = run(_client(), lambda **kw: patch.object(LLMWhispererClientV2, "_send", **kw)) + theirs = run(_baseline_client(), lambda **kw: patch("requests.Session.send", **kw)) + assert ours == theirs + assert ours["extraction"] == {"result_text": "hello"} + + +# -------------------------------------------------------------------------- +# Transport behaviour +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("raised", "expected"), + [ + (httpx.ConnectTimeout("connect timed out"), requests.ConnectTimeout), + (httpx.ReadTimeout("read timed out"), requests.ReadTimeout), + # Neither had a Timeout equivalent: a send that failed and a pool that + # could not hand out a connection both surfaced as ConnectionError. + (httpx.WriteTimeout("write timed out"), requests.ConnectionError), + (httpx.PoolTimeout("pool timed out"), requests.ConnectionError), + (httpx.ConnectError("refused"), requests.ConnectionError), + (httpx.ReadError("reset"), requests.ConnectionError), + (httpx.WriteError("broken pipe"), requests.ConnectionError), + (httpx.ProtocolError("bad framing"), requests.ConnectionError), + (httpx.ProxyError("proxy exploded"), requests.exceptions.ProxyError), + (httpx.UnsupportedProtocol("no scheme"), requests.exceptions.MissingSchema), + (httpx.TooManyRedirects("looping"), requests.TooManyRedirects), + (httpx.DecodingError("bad gzip"), requests.exceptions.ContentDecodingError), + ], +) +def test_transport_errors_are_translated(raised: Exception, expected: type[Exception]) -> None: + """Callers catch the ``requests`` classes by name; httpx's are not + subclasses of them. + + The exact class matters, not just the family: a caller that catches + ``ReadTimeout`` sees nothing if a broader ``Timeout`` is raised in its + place. + """ + client = _client() + with patch.object(client._transport, "send", side_effect=raised): + with pytest.raises(expected) as caught: + client.get_usage_info() + assert type(caught.value) is expected + + +def _httpx_request_errors() -> list[type[Exception]]: + """Every httpx request failure, discovered rather than listed. + + A hand-written list is exactly as complete as it was the day it was + written; this one grows when httpx does. + """ + found, stack = [], [httpx.RequestError] + while stack: + cls = stack.pop() + found.append(cls) + stack.extend(cls.__subclasses__()) + return sorted(found, key=lambda cls: cls.__name__) + + +_REQUEST_ERRORS = _httpx_request_errors() + + +@pytest.mark.parametrize("cls", _REQUEST_ERRORS, ids=[cls.__name__ for cls in _REQUEST_ERRORS]) +def test_no_httpx_failure_escapes_untranslated(cls: type[Exception]) -> None: + """An httpx class reaching a caller is a class no caller catches.""" + client = _client() + with patch.object(client._transport, "send", side_effect=cls("boom")): + with pytest.raises(requests.RequestException): + client.get_usage_info() + + +@pytest.mark.parametrize( + ("raised", "retried"), + [ + (httpx.PoolTimeout("pool timed out"), True), + (httpx.ProxyError("proxy exploded"), True), + # Retrying these cannot start working: the URL stays malformed, the + # redirect chain stays a loop, the body stays undecodable. + (httpx.UnsupportedProtocol("no scheme"), False), + (httpx.TooManyRedirects("looping"), False), + (httpx.DecodingError("bad gzip"), False), + ], +) +def test_translation_decides_what_gets_retried(raised: Exception, retried: bool) -> None: + client = _client(max_retries=2, retry_min_wait=0, retry_max_wait=0) + with patch.object(client._transport, "send", side_effect=raised) as send: + with pytest.raises(requests.RequestException): + client.get_usage_info() + assert (send.call_count > 1) is retried + + +def test_a_connect_timeout_is_still_a_connection_error() -> None: + """``requests.ConnectTimeout`` is both a ``ConnectionError`` and a + ``Timeout``. + + Mapping it to a plain ``Timeout`` would stop matching every + caller that catches the connection family. + """ + client = _client() + with patch.object(client._transport, "send", side_effect=httpx.ConnectTimeout("connect timed out")): + with pytest.raises(requests.ConnectionError): + client.get_usage_info() + + +def test_translated_errors_keep_the_original_cause() -> None: + client = _client() + original = httpx.ConnectError("refused") + with patch.object(client._transport, "send", side_effect=original): + with pytest.raises(requests.ConnectionError) as excinfo: + client.get_usage_info() + assert excinfo.value.__cause__ is original + + +def test_transport_failures_are_still_retried() -> None: + """Retry matches on the ``requests`` types, so translation has to happen + inside the retried call or transport-error retry silently stops working.""" + client = _client(max_retries=2, retry_min_wait=0.001, retry_max_wait=0.002) + with patch.object(client._transport, "send", side_effect=httpx.ConnectError("refused")) as send: + with pytest.raises(requests.ConnectionError): + client.get_usage_info() + assert send.call_count == 3 + + +def test_redirects_are_followed() -> None: + """The previous transport followed them by default. + + Without this a 30x surfaces as 'API error: empty response body'. + """ + assert _client()._transport.follow_redirects is True + + +def test_the_request_timeout_reaches_the_transport() -> None: + """Unlike the other client's ``api_timeout``, this one is a real socket + timeout and must not be dropped on the way down.""" + client = _client() + with patch.object(client._transport, "send", return_value=_mock_response()) as send: + client.get_usage_info() + request = send.call_args[0][0] + assert request.extensions["timeout"]["read"] == client.api_timeout + + +def test_the_deadline_still_caps_each_attempt() -> None: + client = _client() + request = httpx.Request("GET", "http://localhost/test") + with patch.object(client._transport, "send", return_value=_mock_response()) as send: + client._send_request(request, timeout=300, deadline=time.time() + 2.0) + assert send.call_args[0][0].extensions["timeout"]["read"] <= 3.0 + + +def test_the_deadline_still_stops_retries() -> None: + client = _client(max_retries=10, retry_min_wait=0.1, retry_max_wait=0.2) + request = httpx.Request("GET", "http://localhost/test") + with patch.object(client._transport, "send", return_value=_mock_response(500, '{"e": 1}')) as send: + response = client._send_request(request, timeout=1, deadline=time.time() + 0.3) + assert response.status_code == 500 + assert send.call_count < 11 + + +def test_encoding_is_applied_to_a_real_response(sample_file: str) -> None: + """``whisper`` and ``whisper_retrieve`` set the response encoding before + reading text; on a real response that has to still be legal.""" + body = '{"result_text": "café"}'.encode("latin-1") + client = _client() + with patch.object(LLMWhispererClientV2, "_send", return_value=httpx.Response(200, content=body)): + result = client.whisper_retrieve("hash-1", encoding="latin-1") + assert result["extraction"] == {"result_text": "café"} + + +# -------------------------------------------------------------------------- +# Construction and surface +# -------------------------------------------------------------------------- + + +def _baseline_class_node() -> ast.ClassDef: + tree = ast.parse(BASELINE_PATH.read_text()) + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == "LLMWhispererClientV2": + return node + raise AssertionError("LLMWhispererClientV2 not found in the baseline") + + +def _baseline_method(name: str) -> ast.FunctionDef: + for node in _baseline_class_node().body: + if isinstance(node, ast.FunctionDef) and node.name == name: + return node + raise AssertionError(f"{name} not found in the baseline") + + +def _params(node: ast.FunctionDef) -> list[tuple[str, object]]: + args = node.args.args[1:] + node.args.kwonlyargs + defaults = [None] * (len(node.args.args[1:]) - len(node.args.defaults)) + [ + ast.literal_eval(d) for d in node.args.defaults + ] + defaults += [ast.literal_eval(d) if d is not None else None for d in node.args.kw_defaults] + return list(zip([a.arg for a in args], defaults, strict=False)) + + +def _live_params(func: Any, *, keyword_only: bool = True) -> list[tuple[str, object]]: + """Parameters in order, with their defaults. + + ``keyword_only=False`` drops keyword-only parameters, for the comparisons + where a keyword-only addition is allowed: none is reachable from a released + call shape, so adding one leaves every existing call intact. + """ + return [ + (name, None if p.default is inspect.Parameter.empty else p.default) + for name, p in inspect.signature(func).parameters.items() + if name not in ("self", "cls") and (keyword_only or p.kind is not inspect.Parameter.KEYWORD_ONLY) + ] + + +def test_constructor_is_unchanged() -> None: + """Names, order and defaults all matter: callers pass some positionally.""" + assert _live_params(LLMWhispererClientV2.__init__) == _params(_baseline_method("__init__")) + + +def test_public_methods_are_unchanged() -> None: + methods = { + node.name: node + for node in _baseline_class_node().body + if isinstance(node, ast.FunctionDef) and not node.name.startswith("_") + } + assert len(methods) == 11, "the published surface is 11 public methods" + + for name, node in methods.items(): + live = getattr(LLMWhispererClientV2, name, None) + assert live is not None, f"{name} disappeared from the client" + assert _live_params(live, keyword_only=False) == _params(node), name + + +def test_the_deprecated_parameter_resolver_is_unchanged() -> None: + """Which renames forward and which stay dead is a service-side fact, not a + style choice: applying a dead one would change extraction output. + """ + assert _live_params(LLMWhispererClientV2._resolve_deprecated_param) == _params( + _baseline_method("_resolve_deprecated_param") + ) + assert _body_dump(_baseline_method("_resolve_deprecated_param")) == _body_dump( + _method_node(LLMWhispererClientV2, "_resolve_deprecated_param") + ) + + +def _method_node(cls: type, name: str) -> ast.FunctionDef: + source = inspect.getsource(getattr(cls, name)) + node = ast.parse(inspect.cleandoc(source)).body[0] + assert isinstance(node, ast.FunctionDef) + return node + + +def _body_dump(node: ast.FunctionDef) -> str: + """The statements, minus the docstring — re-indenting changes its text.""" + body = node.body[1:] if isinstance(node.body[0], ast.Expr) else node.body + return ast.dump(ast.Module(body=body, type_ignores=[])) + + +def test_get_highlight_rect_is_unchanged() -> None: + """Pure geometry, no request: it should survive the port verbatim.""" + assert _body_dump(_baseline_method("get_highlight_rect")) == _body_dump( + _method_node(LLMWhispererClientV2, "get_highlight_rect") + ) + + +def test_class_attributes_are_unchanged() -> None: + for node in _baseline_class_node().body: + target, assigned = None, None + if isinstance(node, ast.AnnAssign) and node.value is not None: + target, assigned = getattr(node.target, "id", None), node.value + elif isinstance(node, ast.Assign) and len(node.targets) == 1: + target, assigned = getattr(node.targets[0], "id", None), node.value + if target is None or assigned is None: + continue + try: + value = ast.literal_eval(assigned) + except ValueError: + continue # logger and friends: identity, not value + assert getattr(LLMWhispererClientV2, target) == value, target + + +def test_retry_policy_attributes_are_unchanged() -> None: + """Retry is ungenerated and untested by a signature sweep, and has drifted + before.""" + ours, theirs = _client(max_retries=3), _baseline_client(max_retries=3) + for attribute in ("max_retries", "retry_min_wait", "retry_max_wait", "api_timeout", "base_url", "headers"): + assert getattr(ours, attribute) == getattr(theirs, attribute), attribute + assert LLMWhispererClientV2._is_retryable(requests.ConnectionError()) is True + assert LLMWhispererClientV2._is_retryable(requests.Timeout()) is True + assert LLMWhispererClientV2._is_retryable(ValueError()) is False + + +def test_defaults_match_a_default_constructed_published_client(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LLMWHISPERER_BASE_URL_V2", raising=False) + monkeypatch.setenv("LLMWHISPERER_API_KEY", "env-key") + ours = LLMWhispererClientV2(logging_level="ERROR") + theirs = baseline.LLMWhispererClientV2(logging_level="ERROR") + for attribute in ("base_url", "api_key", "api_timeout", "headers", "max_retries"): + assert getattr(ours, attribute) == getattr(theirs, attribute), attribute + + +def test_every_wrapped_operation_is_covered() -> None: + """A new spec operation shows up here as a failure, not as silence.""" + spec = json.loads(SPEC_PATH.read_text()) + declared = { + operation["operationId"] + for path in spec["paths"].values() + for method, operation in path.items() + if method in {"get", "post", "put", "patch", "delete"} + } + # Checked before the subtraction: an entry excusing an operation the spec no + # longer declares keeps this passing forever, and nothing about a green run + # says the list is still describing anything. + assert UNWRAPPED_OPERATIONS <= declared + assert declared - UNWRAPPED_OPERATIONS == set(_SEND_ONLY) + + +def test_the_baseline_is_the_released_client_unmodified() -> None: + # A digest, not a version string in a comment: an edited baseline can claim + # any provenance it likes, and every parity test here would still pass. + assert BASELINE_PATH.name == f"client_v2_{BASELINE_VERSION.replace('.', '_')}.py" + assert hashlib.sha256(BASELINE_PATH.read_bytes()).hexdigest() == BASELINE_SHA256 diff --git a/tests/utils_test.py b/tests/utils_test.py index 5968f76..fd482f1 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -1,4 +1,5 @@ import pytest + from unstract.llmwhisperer.utils import LLMWhispererUtils diff --git a/tools/gen_sdk.sh b/tools/gen_sdk.sh new file mode 100755 index 0000000..9442b28 --- /dev/null +++ b/tools/gen_sdk.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Regenerate the transport layer from the committed OpenAPI spec. +# +# The generated tree is committed but NEVER hand-edited: regeneration overwrites +# it wholesale, so a fix applied there is lost on the next run. Fixes belong in +# the facade (client_v2.py) or upstream in the spec. +# +# ./tools/gen_sdk.sh && git diff --stat src/unstract/llmwhisperer/sdk_llmwhisperer +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VENV="$REPO/.gen-venv" +# The generator shells out to ruff for its own post-processing. Without this it +# finds whatever ruff the caller happens to have, or none, and reports the miss +# as a warning -- which the gate below reads as an unparsable spec. +export PATH="$VENV/bin:$PATH" +OUT="src/unstract/llmwhisperer/sdk_llmwhisperer" +# Pinned: unpinned, a generator upgrade and a spec change produce the same diff, +# and the drift gate can no longer tell them apart. +GENERATOR="openapi-python-client==0.29.0" + +if [ ! -x "$VENV/bin/openapi-python-client" ]; then + uv venv "$VENV" + uv pip install --python "$VENV/bin/python" "$GENERATOR" +fi + +want="${GENERATOR#*==}" +have="$("$VENV/bin/openapi-python-client" --version | awk '{print $NF}')" +if [ "$have" != "$want" ]; then + echo "generator is $have, expected $want — reinstalling" >&2 + uv pip install --python "$VENV/bin/python" "$GENERATOR" +fi + +rm -rf "${REPO:?}/$OUT" +log="$(mktemp)" +trap 'rm -f "$log"' EXIT +(cd "$REPO" && "$VENV/bin/openapi-python-client" generate \ + --path "$REPO/specs/llmwhisperer.json" --output-path "$REPO/$OUT" \ + --config "$REPO/tools/openapi-client.yaml" --overwrite --meta none) 2>&1 | tee "$log" + +# The generator downgrades a schema it cannot parse to a warning, drops +# the endpoint or model it belongs to, writes the rest and exits 0. The +# result is a client missing an operation and a spec that still looks fine. +if grep -qi warning "$log"; then + echo "the generator reported a problem above and still exited 0; whatever it could not parse is missing from the output" >&2 + exit 1 +fi + +# Stamp every file, so the rule survives contact with a reader who arrived via +# grep rather than via this script. +header='# Generated by tools/gen_sdk.sh from specs/llmwhisperer.json. DO NOT EDIT.' +find "$REPO/$OUT" -name '*.py' -print0 | while IFS= read -r -d '' f; do + printf '%s\n%s\n' "$header" "$(cat "$f")" > "$f.tmp" && mv "$f.tmp" "$f" +done + +echo "generated $OUT ($(find "$REPO/$OUT" -name '*.py' | wc -l) files)" diff --git a/tools/openapi-client.yaml b/tools/openapi-client.yaml new file mode 100644 index 0000000..f9741b8 --- /dev/null +++ b/tools/openapi-client.yaml @@ -0,0 +1,12 @@ +# openapi-python-client config. Kept minimal on purpose: every knob here is +# maintenance surface, and post-processing the generated code is a kill criterion. +literal_enums: true + +# The service returns these as bytes. The generator knows how to hand back a +# binary body but only recognises a handful of content types by name, and a +# response it cannot parse is dropped rather than reported. Mapping them here +# keeps the spec truthful about what comes back. +content_type_overrides: + application/pdf: application/octet-stream + application/zip: application/octet-stream + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet: application/octet-stream diff --git a/tools/refresh_baseline.sh b/tools/refresh_baseline.sh new file mode 100755 index 0000000..9ee2efa --- /dev/null +++ b/tools/refresh_baseline.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Refresh the vendored parity baseline in tests/baseline/. +# +# The compat suite compares this client against the last RELEASED one, not +# against the working tree — a baseline that moves with local edits measures +# nothing. It is taken from the published wheel rather than from a git ref +# because the wheel is what callers actually have installed, and it is vendored +# rather than downloaded at test time so the suite stays offline. +# +# ./tools/refresh_baseline.sh 2.8.0 +set -euo pipefail + +VERSION="${1:?usage: refresh_baseline.sh }" +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SLUG="${VERSION//./_}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +(cd "$WORK" && pip download "llmwhisperer-client==$VERSION" --no-deps -q && unzip -o -q ./*.whl -d x) + +OUT="$REPO/tests/baseline/client_v2_$SLUG.py" +{ + echo "# Vendored from the released llmwhisperer-client $VERSION wheel on PyPI. DO NOT EDIT." + echo "# Refresh with tools/refresh_baseline.sh when the parity baseline is intentionally moved." + cat "$WORK/x/unstract/llmwhisperer/client_v2.py" +} > "$OUT" + +echo "wrote $OUT" +echo "in tests/unit/compat_test.py set:" +echo " BASELINE_VERSION = \"$VERSION\"" +echo " BASELINE_SHA256 = \"$(sha256sum "$OUT" | cut -d' ' -f1)\"" diff --git a/uv.lock b/uv.lock index 6ed9922..caec324 100644 --- a/uv.lock +++ b/uv.lock @@ -1,7 +1,29 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "certifi" version = "2025.4.26" @@ -164,6 +186,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[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 = "identify" version = "2.6.10" @@ -220,6 +279,8 @@ wheels = [ name = "llmwhisperer-client" source = { editable = "." } dependencies = [ + { name = "attrs" }, + { name = "httpx" }, { name = "requests" }, { name = "tenacity" }, ] @@ -233,6 +294,8 @@ dev = [ { name = "pycln" }, { name = "pytest" }, { name = "ruff" }, + { name = "types-requests" }, + { name = "types-setuptools" }, { name = "yamllint" }, ] test = [ @@ -245,6 +308,8 @@ test = [ [package.metadata] requires-dist = [ + { name = "attrs", specifier = ">=23.2" }, + { name = "httpx", specifier = ">=0.27,<0.29" }, { name = "requests", specifier = ">=2" }, { name = "tenacity", specifier = ">=8.0" }, ] @@ -257,7 +322,9 @@ dev = [ { name = "pre-commit", specifier = "~=3.3.1" }, { name = "pycln", specifier = ">=2.5.0" }, { name = "pytest", specifier = ">=8.0.1" }, - { name = "ruff", specifier = ">=0.2.2,<1.0.0" }, + { name = "ruff", specifier = "==0.11.9" }, + { name = "types-requests", specifier = ">=2.32" }, + { name = "types-setuptools", specifier = ">=75" }, { name = "yamllint", specifier = ">=1.35.1" }, ] test = [ @@ -736,6 +803,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/20/9d953de6f4367163d23ec823200eb3ecb0050a2609691e512c8b95827a9b/typer-0.15.3-py3-none-any.whl", hash = "sha256:c86a65ad77ca531f03de08d1b9cb67cd09ad02ddddf4b34745b5008f43b239bd", size = 45253, upload-time = "2025-04-28T21:40:56.269Z" }, ] +[[package]] +name = "types-requests" +version = "2.33.0.20260712" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" }, +] + +[[package]] +name = "types-setuptools" +version = "84.0.0.20260812" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/cd/3b2a3362a526f91c33f785a291462b2ec448ae531101c62372fc30a21f53/types_setuptools-84.0.0.20260812.tar.gz", hash = "sha256:09bedc248ebbb7a232c9419dfcdca329706e61bf2aa5743e9424d027f1d956b4", size = 46545, upload-time = "2026-08-12T03:52:42.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/e5/3a41cab4066465593facd660b2683c017f3d88d3297cdd26db0677b8e479/types_setuptools-84.0.0.20260812-py3-none-any.whl", hash = "sha256:799c08e4bc6a288e8a0b538afb5f5ff320a08927ba8dbedb30c74ee3ba5d867b", size = 70320, upload-time = "2026-08-12T03:52:41.154Z" }, +] + [[package]] name = "typing-extensions" version = "4.13.2"