diff --git a/.claude/skills/spec-upgrade/SKILL.md b/.claude/skills/spec-upgrade/SKILL.md index b3d66d4..e177cb0 100644 --- a/.claude/skills/spec-upgrade/SKILL.md +++ b/.claude/skills/spec-upgrade/SKILL.md @@ -71,6 +71,31 @@ Upstream, the spec is produced by the backend that serves these endpoints the facade is where it becomes public API. Fixes belong here or upstream in the spec, never in the generated tree — regeneration overwrites that wholesale. + A new operation belongs to `APIDeploymentsClient` if it takes a deployment + key, `PlatformKeyClient` if it takes a platform key, otherwise a new subclass + of `_HttpxFacade` — never a free-standing class, or the transport, retry and + exception translation get reimplemented and drift. The method is two lines: + + ```python + def list_widgets(self, org_id: str, *, page: int | None = None) -> dict[str, Any]: + kwargs = list_widgets._get_kwargs(org_id, **{"page": page} if page else {}) + return self._read_or_raise(self._request_with_retry(**kwargs), "list_widgets") + ``` + + - Build from `_get_kwargs`, not `sync_detailed`: the generated + `_parse_response` calls `from_dict` on an error body unguarded, so an + undeclared one raises before the facade sees the status. Omit unset + parameters rather than passing `None` — the builder renders some before it + filters `None` out. Both are private to the generator, so pin them in + `tests/test_compat.py`. + - Send through `_request_with_retry`, and read through `_read_or_raise`, + which checks the status first. + - Return `dict[str, Any]`. The generated models are exported for callers who + want typing; a hand-written `TypedDict` would not survive regeneration. + - A new error type subclasses `UnstractError`. + + `PlatformKeyClient.whoami` is the smallest example in the tree. + 6. **Run the tests:** `uv run pytest tests/`. `tests/test_compat.py` compares this client against the last released one, vendored under `tests/baseline/`. Refresh that baseline only when you mean to move the parity reference point, @@ -87,8 +112,10 @@ fail the same way. If it is red, run step 3 and commit the result. Choose the bump by what changed for callers: **major** when the spec removed or renamed something callers depend on, **minor** for new endpoints or new -behaviour, **patch** for fixes that keep the surface identical. A generated diff -with removals in it is the signal for major — spec upgrades produce those. +behaviour — a new facade method included — **patch** for fixes that keep the +surface identical. A generated diff with removals in it is the signal for major +— spec upgrades produce those. Behaviour the baseline pinned that has moved goes +in `ACCEPTED_DIVERGENCES` in the same commit. Do not touch `__version__` in `src/unstract/api_deployments/__init__.py` in your PR. The in-repo value is the *last released* version; `main.yml` reads it, diff --git a/README.md b/README.md index fc8c92a..8cd5c6c 100644 --- a/README.md +++ b/README.md @@ -113,12 +113,47 @@ client = APIDeploymentsClient( The retry logic uses exponential backoff with full jitter and respects the `Retry-After` header on 429 responses. +## Listing deployments with a platform key + +`PlatformKeyClient` takes a **platform** API key, not a deployment key, and reads +the account that key belongs to. It cannot run a deployment. + +```python +from unstract.api_deployments import PlatformKeyClient + +with PlatformKeyClient("https://us-central.unstract.com", "your_platform_key") as client: + org_id = client.whoami()["organization_id"] + page = client.list_deployments(org_id, page_size=50) + for deployment in page["results"]: + print(deployment["api_name"], deployment["api_endpoint"]) +``` + +Follow `next` for further pages. `api_key` falls back to `$UNSTRACT_PLATFORM_KEY`. + +## Errors + +Every error either client raises derives from `UnstractError`: + +| Exception | Raised by | +|-----------|-----------| +| `UnstractError` | base of both — catch this to catch everything | +| `APIDeploymentError` | `APIDeploymentsClient` | +| `PlatformClientError` | `PlatformKeyClient` | + +`APIDeploymentsClientException` is an alias of `UnstractError`, so existing +`except` clauses keep working. + +Transport failures are raised as the `requests` exception types +(`ConnectionError`, `Timeout`, and friends) rather than the httpx ones. + ## Internals `unstract.api_deployments._sdk_docstudio` is generated from the deployment API's OpenAPI spec by `tools/gen_sdk.sh` and is an implementation detail of the -transport. `APIDeploymentsClient` is the supported surface — import from it, not -from the generated tree, which is regenerated wholesale whenever the spec moves. +transport. `APIDeploymentsClient` and `PlatformKeyClient` are the supported +surface — import from those, or from the response models re-exported alongside +them, not from the generated tree, which is regenerated wholesale whenever the +spec moves. ## Cloning an organization diff --git a/specs/docstudio-oss.json b/specs/docstudio-oss.json index c7f8ebb..7bd536c 100644 --- a/specs/docstudio-oss.json +++ b/specs/docstudio-oss.json @@ -1,6 +1,102 @@ { "components": { "schemas": { + "APIDeploymentSummary": { + "description": "One API deployment, as it appears in an organisation's listing.\n\n`api_name` and `api_endpoint` are what an execution call needs;\n`display_name` and `description` say what the deployment is for.", + "properties": { + "api_endpoint": { + "readOnly": true, + "type": "string" + }, + "api_name": { + "maxLength": 30, + "readOnly": true, + "type": "string" + }, + "co_owners_count": { + "readOnly": true, + "type": "integer" + }, + "created_by": { + "nullable": true, + "readOnly": true, + "type": "integer" + }, + "created_by_email": { + "description": "Get the email of the creator.", + "nullable": true, + "readOnly": true, + "type": "string" + }, + "description": { + "maxLength": 255, + "readOnly": true, + "type": "string" + }, + "display_name": { + "maxLength": 30, + "readOnly": true, + "type": "string" + }, + "id": { + "format": "uuid", + "readOnly": true, + "type": "string" + }, + "is_active": { + "readOnly": true, + "type": "boolean" + }, + "is_owner": { + "readOnly": true, + "type": "boolean" + }, + "last_5_run_statuses": { + "description": "Fetch the last 5 execution statuses with timestamps for this API deployment.", + "items": { + "additionalProperties": {}, + "type": "object" + }, + "readOnly": true, + "type": "array" + }, + "last_run_time": { + "nullable": true, + "readOnly": true, + "type": "string" + }, + "run_count": { + "readOnly": true, + "type": "integer" + }, + "workflow": { + "format": "uuid", + "type": "string" + }, + "workflow_name": { + "readOnly": true, + "type": "string" + } + }, + "required": [ + "api_endpoint", + "api_name", + "co_owners_count", + "created_by", + "created_by_email", + "description", + "display_name", + "id", + "is_active", + "is_owner", + "last_5_run_statuses", + "last_run_time", + "run_count", + "workflow", + "workflow_name" + ], + "type": "object" + }, "AcknowledgedResponse": { "description": "The execution's result was handed to an earlier call and discarded.", "properties": { @@ -17,6 +113,15 @@ ], "type": "object" }, + "ApiKeyPermission": { + "description": "* `read` - Read\n* `read_write` - Read/Write\n* `full_access` - Full Access", + "enum": [ + "read", + "read_write", + "full_access" + ], + "type": "string" + }, "ErrorDetail": { "description": "One problem found with the request.", "properties": { @@ -209,6 +314,50 @@ ], "type": "object" }, + "PaginatedAPIDeploymentSummaryList": { + "properties": { + "count": { + "example": 123, + "type": "integer" + }, + "next": { + "example": "http://api.example.org/accounts/?page=4", + "format": "uri", + "nullable": true, + "type": "string" + }, + "previous": { + "example": "http://api.example.org/accounts/?page=2", + "format": "uri", + "nullable": true, + "type": "string" + }, + "results": { + "items": { + "$ref": "#/components/schemas/APIDeploymentSummary" + }, + "type": "array" + } + }, + "required": [ + "count", + "results" + ], + "type": "object" + }, + "PlatformKeyError": { + "description": "Why a platform-key request was refused.\n\nProduced by the authentication middleware rather than by the project's\nexception handler, so it carries a single human-readable message and none\nof the per-field structure the organisation-scoped endpoints return. It is\nthe shape of this operation's credential failures specifically, not of\nevery failure it can return.", + "properties": { + "message": { + "description": "Human-readable reason for the refusal.", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, "StatusResponse": { "properties": { "message": { @@ -226,11 +375,48 @@ "status" ], "type": "object" + }, + "WhoAmIResponse": { + "description": "The organisation a platform API key belongs to, and what it may do.", + "properties": { + "key_name": { + "description": "The key's name, as it was minted.", + "type": "string" + }, + "organization_id": { + "description": "The organisation's identifier, as it appears in web-app URLs and in every organisation-scoped API path.", + "type": "string" + }, + "organization_name": { + "description": "The organisation's display name.", + "type": "string" + }, + "permission": { + "allOf": [ + { + "$ref": "#/components/schemas/ApiKeyPermission" + } + ], + "description": "The key's permission tier, which decides the HTTP methods it may issue.\n\n* `read` - Read\n* `read_write` - Read/Write\n* `full_access` - Full Access" + } + }, + "required": [ + "key_name", + "organization_id", + "organization_name", + "permission" + ], + "type": "object" } }, "securitySchemes": { "deploymentKey": { - "description": "The API deployment's own key.", + "description": "A key that runs an API deployment: the deployment's own key, or a global API deployment key with access to it.", + "scheme": "bearer", + "type": "http" + }, + "platformKey": { + "description": "An organisation-wide platform API key, minted under Settings. It carries the organisation it belongs to, but cannot execute an API deployment.", "scheme": "bearer", "type": "http" } @@ -242,9 +428,172 @@ }, "openapi": "3.0.3", "paths": { + "/api/v1/unstract/whoami/": { + "get": { + "description": "Takes a platform API key and nothing else: the organisation is read from the key itself, so this route carries no organisation segment. Call it once and store `organization_id`; every other endpoint takes it as a path segment.\n\nAn API deployment key is rejected as unauthenticated.", + "operationId": "whoami", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoAmIResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformKeyError" + } + } + }, + "description": "No usable platform API key was supplied \u2014 absent, malformed, unknown, or revoked." + }, + "500": { + "description": "The request could not be served. The body is not guaranteed to be JSON." + } + }, + "security": [ + { + "platformKey": [] + } + ], + "summary": "Resolve the organisation a platform key belongs to", + "tags": [ + "identity" + ] + } + }, + "/api/v1/unstract/{org_id}/api/deployment/": { + "get": { + "description": "Lists what an organisation has deployed, authenticated by a platform API key that belongs to it.\n\nA deployment key is not part of this listing; executing a deployment still needs one.\n\nOrdered by most recent run first, then by identifier, so paging is stable.", + "operationId": "list_deployments", + "parameters": [ + { + "description": "Return only the deployment with exactly this `api_name`.", + "in": "query", + "name": "api_name", + "schema": { + "type": "string" + } + }, + { + "description": "Which field to use when ordering the results.", + "in": "query", + "name": "ordering", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "A page number within the paginated result set.", + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Number of results to return per page.", + "in": "query", + "name": "page_size", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Return only deployments whose display name contains this text, case-insensitively.", + "in": "query", + "name": "search", + "schema": { + "type": "string" + } + }, + { + "description": "Return only the deployments of this workflow.", + "in": "query", + "name": "workflow", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "The organisation the request is scoped to, as `whoami` reports it in `organization_id`.", + "in": "path", + "name": "org_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedAPIDeploymentSummaryList" + } + } + }, + "description": "One page of the organisation's API deployments." + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + }, + "description": "A query parameter was malformed." + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformKeyError" + } + } + }, + "description": "No usable platform API key was supplied \u2014 absent, malformed, unknown, or revoked." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformKeyError" + } + } + }, + "description": "The key was recognised but refused: it does not belong to the organisation named in the path, or its permission tier is not one this deployment knows." + }, + "500": { + "description": "The request could not be served. The body is not guaranteed to be JSON." + } + }, + "security": [ + { + "platformKey": [] + } + ], + "summary": "List an organisation's API deployments", + "tags": [ + "deployment" + ] + } + }, "/deployment/api/{org_name}/{api_name}/": { "get": { - "description": "Read the result of a previously started execution.\n\nThis read is one-shot: the first call that observes a completed execution acknowledges it and the stored result is discarded, so every later call for that execution answers 406. Poll while the execution is pending, and keep the payload of the call that returns it \u2014 it cannot be fetched again.\n\nA still-running execution answers 422 carrying its current `status`, so a polling loop should treat 422 as the normal reply and stop on 200. Clients that raise on any non-2xx need to allow for that.", + "description": "Reads a previously started execution, taking the same deployment key that ran it. The read is one-shot: the first call that observes a completed execution acknowledges it and the stored result is discarded, so every later call for that execution answers 406. Keep the payload of the call that returns it \u2014 it cannot be fetched again.\n\nA still-running execution answers 422 carrying its current `status`, so a polling loop should treat 422 as the normal reply and stop on 200.", "operationId": "status", "parameters": [ { @@ -470,12 +819,13 @@ "deploymentKey": [] } ], + "summary": "Read the result of an execution", "tags": [ "deployment" ] }, "post": { - "description": "Execute an API deployment against one or more documents.\n\nSupply the documents either as `files` (multipart upload) or as `presigned_urls` (HTTPS S3 URLs), or both \u2014 a request carrying neither is rejected, and the two together may not exceed 32 documents.\n\nWith the default `timeout` of -1 the call returns as soon as the execution is queued; read the outcome from the status endpoint.", + "description": "Runs an API deployment. Takes a deployment key \u2014 either the deployment's own key, or a global API deployment key that has access to it.\n\nSupply the documents either as `files` (multipart upload) or as `presigned_urls` (HTTPS S3 URLs), or both \u2014 a request carrying neither is rejected, and the two together may not exceed 32 documents.\n\nWith the default `timeout` of -1 the call returns as soon as the execution is queued; read the outcome from the status endpoint.", "operationId": "execute", "parameters": [ { @@ -707,6 +1057,7 @@ "deploymentKey": [] } ], + "summary": "Execute an API deployment against documents", "tags": [ "deployment" ] @@ -715,8 +1066,12 @@ }, "tags": [ { - "description": "Run an API deployment against one or more documents and poll the result.", + "description": "Discover an organisation's API deployments, run one against one or more documents, and poll the result.", "name": "deployment" + }, + { + "description": "Resolve what a platform API key is scoped to, so a client can discover its organisation rather than being told it.", + "name": "identity" } ] } diff --git a/src/unstract/api_deployments/__init__.py b/src/unstract/api_deployments/__init__.py index 08ac17a..b0fe340 100644 --- a/src/unstract/api_deployments/__init__.py +++ b/src/unstract/api_deployments/__init__.py @@ -1,6 +1,22 @@ __version__ = "1.6.0" +from ._sdk_docstudio.models import ( + APIDeploymentSummary as APIDeploymentSummary, +) +from ._sdk_docstudio.models import ( + PaginatedAPIDeploymentSummaryList as PaginatedAPIDeploymentSummaryList, +) +from ._sdk_docstudio.models import ( + WhoAmIResponse as WhoAmIResponse, +) +from .client import APIDeploymentError as APIDeploymentError from .client import APIDeploymentsClient as APIDeploymentsClient +from .client import ( + APIDeploymentsClientException as APIDeploymentsClientException, +) +from .client import PlatformClientError as PlatformClientError +from .client import PlatformKeyClient as PlatformKeyClient +from .client import UnstractError as UnstractError def get_sdk_version(): diff --git a/src/unstract/api_deployments/_sdk_docstudio/api/deployment/execute.py b/src/unstract/api_deployments/_sdk_docstudio/api/deployment/execute.py index fabe9ab..cbd6b63 100644 --- a/src/unstract/api_deployments/_sdk_docstudio/api/deployment/execute.py +++ b/src/unstract/api_deployments/_sdk_docstudio/api/deployment/execute.py @@ -120,7 +120,10 @@ def sync_detailed( client: AuthenticatedClient, body: ExecuteRequest | Unset = UNSET, ) -> Response[ErrorResponse | ExecuteResponse]: - """Execute an API deployment against one or more documents. + """Execute an API deployment against documents + + Runs an API deployment. Takes a deployment key — either the deployment's own key, or a global API + deployment key that has access to it. Supply the documents either as `files` (multipart upload) or as `presigned_urls` (HTTPS S3 URLs), or both — a request carrying neither is rejected, and the two together may not exceed 32 documents. @@ -164,7 +167,10 @@ def sync( client: AuthenticatedClient, body: ExecuteRequest | Unset = UNSET, ) -> ErrorResponse | ExecuteResponse | None: - """Execute an API deployment against one or more documents. + """Execute an API deployment against documents + + Runs an API deployment. Takes a deployment key — either the deployment's own key, or a global API + deployment key that has access to it. Supply the documents either as `files` (multipart upload) or as `presigned_urls` (HTTPS S3 URLs), or both — a request carrying neither is rejected, and the two together may not exceed 32 documents. @@ -203,7 +209,10 @@ async def asyncio_detailed( client: AuthenticatedClient, body: ExecuteRequest | Unset = UNSET, ) -> Response[ErrorResponse | ExecuteResponse]: - """Execute an API deployment against one or more documents. + """Execute an API deployment against documents + + Runs an API deployment. Takes a deployment key — either the deployment's own key, or a global API + deployment key that has access to it. Supply the documents either as `files` (multipart upload) or as `presigned_urls` (HTTPS S3 URLs), or both — a request carrying neither is rejected, and the two together may not exceed 32 documents. @@ -245,7 +254,10 @@ async def asyncio( client: AuthenticatedClient, body: ExecuteRequest | Unset = UNSET, ) -> ErrorResponse | ExecuteResponse | None: - """Execute an API deployment against one or more documents. + """Execute an API deployment against documents + + Runs an API deployment. Takes a deployment key — either the deployment's own key, or a global API + deployment key that has access to it. Supply the documents either as `files` (multipart upload) or as `presigned_urls` (HTTPS S3 URLs), or both — a request carrying neither is rejected, and the two together may not exceed 32 documents. diff --git a/src/unstract/api_deployments/_sdk_docstudio/api/deployment/list_deployments.py b/src/unstract/api_deployments/_sdk_docstudio/api/deployment/list_deployments.py new file mode 100644 index 0000000..c4036ad --- /dev/null +++ b/src/unstract/api_deployments/_sdk_docstudio/api/deployment/list_deployments.py @@ -0,0 +1,309 @@ +# Generated by tools/gen_sdk.sh from specs/docstudio-oss.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote +from uuid import UUID + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error_response import ErrorResponse +from ...models.paginated_api_deployment_summary_list import ( + PaginatedAPIDeploymentSummaryList, +) +from ...models.platform_key_error import PlatformKeyError +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + org_id: str, + *, + api_name: str | Unset = UNSET, + ordering: str | Unset = UNSET, + page: int | Unset = UNSET, + page_size: int | Unset = UNSET, + search: str | Unset = UNSET, + workflow: UUID | Unset = UNSET, +) -> dict[str, Any]: + + params: dict[str, Any] = {} + + params["api_name"] = api_name + + params["ordering"] = ordering + + params["page"] = page + + params["page_size"] = page_size + + params["search"] = search + + json_workflow: str | Unset = UNSET + if not isinstance(workflow, Unset): + json_workflow = str(workflow) + params["workflow"] = json_workflow + + 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/v1/unstract/{org_id}/api/deployment/".format( + org_id=quote(str(org_id), safe=""), + ), + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError | None: + if response.status_code == 200: + response_200 = PaginatedAPIDeploymentSummaryList.from_dict(response.json()) + + return response_200 + + if response.status_code == 400: + response_400 = ErrorResponse.from_dict(response.json()) + + return response_400 + + if response.status_code == 401: + response_401 = PlatformKeyError.from_dict(response.json()) + + return response_401 + + if response.status_code == 403: + response_403 = PlatformKeyError.from_dict(response.json()) + + return response_403 + + if response.status_code == 500: + response_500 = cast(Any, None) + return response_500 + + 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[ + Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError +]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + org_id: str, + *, + client: AuthenticatedClient, + api_name: str | Unset = UNSET, + ordering: str | Unset = UNSET, + page: int | Unset = UNSET, + page_size: int | Unset = UNSET, + search: str | Unset = UNSET, + workflow: UUID | Unset = UNSET, +) -> Response[ + Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError +]: + """List an organisation's API deployments + + Lists what an organisation has deployed, authenticated by a platform API key that belongs to it. + + A deployment key is not part of this listing; executing a deployment still needs one. + + Ordered by most recent run first, then by identifier, so paging is stable. + + Args: + org_id (str): + api_name (str | Unset): + ordering (str | Unset): + page (int | Unset): + page_size (int | Unset): + search (str | Unset): + workflow (UUID | 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[Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError] + """ + + kwargs = _get_kwargs( + org_id=org_id, + api_name=api_name, + ordering=ordering, + page=page, + page_size=page_size, + search=search, + workflow=workflow, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + org_id: str, + *, + client: AuthenticatedClient, + api_name: str | Unset = UNSET, + ordering: str | Unset = UNSET, + page: int | Unset = UNSET, + page_size: int | Unset = UNSET, + search: str | Unset = UNSET, + workflow: UUID | Unset = UNSET, +) -> Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError | None: + """List an organisation's API deployments + + Lists what an organisation has deployed, authenticated by a platform API key that belongs to it. + + A deployment key is not part of this listing; executing a deployment still needs one. + + Ordered by most recent run first, then by identifier, so paging is stable. + + Args: + org_id (str): + api_name (str | Unset): + ordering (str | Unset): + page (int | Unset): + page_size (int | Unset): + search (str | Unset): + workflow (UUID | 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: + Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError + """ + + return sync_detailed( + org_id=org_id, + client=client, + api_name=api_name, + ordering=ordering, + page=page, + page_size=page_size, + search=search, + workflow=workflow, + ).parsed + + +async def asyncio_detailed( + org_id: str, + *, + client: AuthenticatedClient, + api_name: str | Unset = UNSET, + ordering: str | Unset = UNSET, + page: int | Unset = UNSET, + page_size: int | Unset = UNSET, + search: str | Unset = UNSET, + workflow: UUID | Unset = UNSET, +) -> Response[ + Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError +]: + """List an organisation's API deployments + + Lists what an organisation has deployed, authenticated by a platform API key that belongs to it. + + A deployment key is not part of this listing; executing a deployment still needs one. + + Ordered by most recent run first, then by identifier, so paging is stable. + + Args: + org_id (str): + api_name (str | Unset): + ordering (str | Unset): + page (int | Unset): + page_size (int | Unset): + search (str | Unset): + workflow (UUID | 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[Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError] + """ + + kwargs = _get_kwargs( + org_id=org_id, + api_name=api_name, + ordering=ordering, + page=page, + page_size=page_size, + search=search, + workflow=workflow, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + org_id: str, + *, + client: AuthenticatedClient, + api_name: str | Unset = UNSET, + ordering: str | Unset = UNSET, + page: int | Unset = UNSET, + page_size: int | Unset = UNSET, + search: str | Unset = UNSET, + workflow: UUID | Unset = UNSET, +) -> Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError | None: + """List an organisation's API deployments + + Lists what an organisation has deployed, authenticated by a platform API key that belongs to it. + + A deployment key is not part of this listing; executing a deployment still needs one. + + Ordered by most recent run first, then by identifier, so paging is stable. + + Args: + org_id (str): + api_name (str | Unset): + ordering (str | Unset): + page (int | Unset): + page_size (int | Unset): + search (str | Unset): + workflow (UUID | 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: + Any | ErrorResponse | PaginatedAPIDeploymentSummaryList | PlatformKeyError + """ + + return ( + await asyncio_detailed( + org_id=org_id, + client=client, + api_name=api_name, + ordering=ordering, + page=page, + page_size=page_size, + search=search, + workflow=workflow, + ) + ).parsed diff --git a/src/unstract/api_deployments/_sdk_docstudio/api/deployment/status.py b/src/unstract/api_deployments/_sdk_docstudio/api/deployment/status.py index 5946b09..79b5219 100644 --- a/src/unstract/api_deployments/_sdk_docstudio/api/deployment/status.py +++ b/src/unstract/api_deployments/_sdk_docstudio/api/deployment/status.py @@ -117,14 +117,15 @@ def sync_detailed( include_metadata: bool | Unset = False, include_metrics: bool | Unset = False, ) -> Response[AcknowledgedResponse | ErrorResponse | StatusResponse]: - """Read the result of a previously started execution. + """Read the result of an execution - This read is one-shot: the first call that observes a completed execution acknowledges it and the - stored result is discarded, so every later call for that execution answers 406. Poll while the - execution is pending, and keep the payload of the call that returns it — it cannot be fetched again. + Reads a previously started execution, taking the same deployment key that ran it. The read is one- + shot: the first call that observes a completed execution acknowledges it and the stored result is + discarded, so every later call for that execution answers 406. Keep the payload of the call that + returns it — it cannot be fetched again. A still-running execution answers 422 carrying its current `status`, so a polling loop should treat - 422 as the normal reply and stop on 200. Clients that raise on any non-2xx need to allow for that. + 422 as the normal reply and stop on 200. Args: org_name (str): @@ -168,14 +169,15 @@ def sync( include_metadata: bool | Unset = False, include_metrics: bool | Unset = False, ) -> AcknowledgedResponse | ErrorResponse | StatusResponse | None: - """Read the result of a previously started execution. + """Read the result of an execution - This read is one-shot: the first call that observes a completed execution acknowledges it and the - stored result is discarded, so every later call for that execution answers 406. Poll while the - execution is pending, and keep the payload of the call that returns it — it cannot be fetched again. + Reads a previously started execution, taking the same deployment key that ran it. The read is one- + shot: the first call that observes a completed execution acknowledges it and the stored result is + discarded, so every later call for that execution answers 406. Keep the payload of the call that + returns it — it cannot be fetched again. A still-running execution answers 422 carrying its current `status`, so a polling loop should treat - 422 as the normal reply and stop on 200. Clients that raise on any non-2xx need to allow for that. + 422 as the normal reply and stop on 200. Args: org_name (str): @@ -214,14 +216,15 @@ async def asyncio_detailed( include_metadata: bool | Unset = False, include_metrics: bool | Unset = False, ) -> Response[AcknowledgedResponse | ErrorResponse | StatusResponse]: - """Read the result of a previously started execution. + """Read the result of an execution - This read is one-shot: the first call that observes a completed execution acknowledges it and the - stored result is discarded, so every later call for that execution answers 406. Poll while the - execution is pending, and keep the payload of the call that returns it — it cannot be fetched again. + Reads a previously started execution, taking the same deployment key that ran it. The read is one- + shot: the first call that observes a completed execution acknowledges it and the stored result is + discarded, so every later call for that execution answers 406. Keep the payload of the call that + returns it — it cannot be fetched again. A still-running execution answers 422 carrying its current `status`, so a polling loop should treat - 422 as the normal reply and stop on 200. Clients that raise on any non-2xx need to allow for that. + 422 as the normal reply and stop on 200. Args: org_name (str): @@ -263,14 +266,15 @@ async def asyncio( include_metadata: bool | Unset = False, include_metrics: bool | Unset = False, ) -> AcknowledgedResponse | ErrorResponse | StatusResponse | None: - """Read the result of a previously started execution. + """Read the result of an execution - This read is one-shot: the first call that observes a completed execution acknowledges it and the - stored result is discarded, so every later call for that execution answers 406. Poll while the - execution is pending, and keep the payload of the call that returns it — it cannot be fetched again. + Reads a previously started execution, taking the same deployment key that ran it. The read is one- + shot: the first call that observes a completed execution acknowledges it and the stored result is + discarded, so every later call for that execution answers 406. Keep the payload of the call that + returns it — it cannot be fetched again. A still-running execution answers 422 carrying its current `status`, so a polling loop should treat - 422 as the normal reply and stop on 200. Clients that raise on any non-2xx need to allow for that. + 422 as the normal reply and stop on 200. Args: org_name (str): diff --git a/src/unstract/api_deployments/_sdk_docstudio/api/identity/__init__.py b/src/unstract/api_deployments/_sdk_docstudio/api/identity/__init__.py new file mode 100644 index 0000000..c7e8df6 --- /dev/null +++ b/src/unstract/api_deployments/_sdk_docstudio/api/identity/__init__.py @@ -0,0 +1,2 @@ +# Generated by tools/gen_sdk.sh from specs/docstudio-oss.json. DO NOT EDIT. +"""Contains endpoint functions for accessing the API""" diff --git a/src/unstract/api_deployments/_sdk_docstudio/api/identity/whoami.py b/src/unstract/api_deployments/_sdk_docstudio/api/identity/whoami.py new file mode 100644 index 0000000..631cbb5 --- /dev/null +++ b/src/unstract/api_deployments/_sdk_docstudio/api/identity/whoami.py @@ -0,0 +1,163 @@ +# Generated by tools/gen_sdk.sh from specs/docstudio-oss.json. DO NOT EDIT. +from http import HTTPStatus +from typing import Any, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.platform_key_error import PlatformKeyError +from ...models.who_am_i_response import WhoAmIResponse +from ...types import Response + + +def _get_kwargs() -> dict[str, Any]: + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/api/v1/unstract/whoami/", + } + + return _kwargs + + +def _parse_response( + *, client: AuthenticatedClient | Client, response: httpx.Response +) -> Any | PlatformKeyError | WhoAmIResponse | None: + if response.status_code == 200: + response_200 = WhoAmIResponse.from_dict(response.json()) + + return response_200 + + if response.status_code == 401: + response_401 = PlatformKeyError.from_dict(response.json()) + + return response_401 + + if response.status_code == 500: + response_500 = cast(Any, None) + return response_500 + + 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[Any | PlatformKeyError | WhoAmIResponse]: + 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, +) -> Response[Any | PlatformKeyError | WhoAmIResponse]: + """Resolve the organisation a platform key belongs to + + Takes a platform API key and nothing else: the organisation is read from the key itself, so this + route carries no organisation segment. Call it once and store `organization_id`; every other + endpoint takes it as a path segment. + + An API deployment key is rejected as unauthenticated. + + 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[Any | PlatformKeyError | WhoAmIResponse] + """ + + kwargs = _get_kwargs() + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, +) -> Any | PlatformKeyError | WhoAmIResponse | None: + """Resolve the organisation a platform key belongs to + + Takes a platform API key and nothing else: the organisation is read from the key itself, so this + route carries no organisation segment. Call it once and store `organization_id`; every other + endpoint takes it as a path segment. + + An API deployment key is rejected as unauthenticated. + + 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: + Any | PlatformKeyError | WhoAmIResponse + """ + + return sync_detailed( + client=client, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, +) -> Response[Any | PlatformKeyError | WhoAmIResponse]: + """Resolve the organisation a platform key belongs to + + Takes a platform API key and nothing else: the organisation is read from the key itself, so this + route carries no organisation segment. Call it once and store `organization_id`; every other + endpoint takes it as a path segment. + + An API deployment key is rejected as unauthenticated. + + 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[Any | PlatformKeyError | WhoAmIResponse] + """ + + kwargs = _get_kwargs() + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, +) -> Any | PlatformKeyError | WhoAmIResponse | None: + """Resolve the organisation a platform key belongs to + + Takes a platform API key and nothing else: the organisation is read from the key itself, so this + route carries no organisation segment. Call it once and store `organization_id`; every other + endpoint takes it as a path segment. + + An API deployment key is rejected as unauthenticated. + + 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: + Any | PlatformKeyError | WhoAmIResponse + """ + + return ( + await asyncio_detailed( + client=client, + ) + ).parsed diff --git a/src/unstract/api_deployments/_sdk_docstudio/models/__init__.py b/src/unstract/api_deployments/_sdk_docstudio/models/__init__.py index e04b133..0399d8a 100644 --- a/src/unstract/api_deployments/_sdk_docstudio/models/__init__.py +++ b/src/unstract/api_deployments/_sdk_docstudio/models/__init__.py @@ -2,6 +2,11 @@ """Contains all the data models used in inputs/outputs""" from .acknowledged_response import AcknowledgedResponse +from .api_deployment_summary import APIDeploymentSummary +from .api_deployment_summary_last_5_run_statuses_item import ( + APIDeploymentSummaryLast5RunStatusesItem, +) +from .api_key_permission import ApiKeyPermission from .error_detail import ErrorDetail from .error_response import ErrorResponse from .error_type import ErrorType @@ -9,10 +14,16 @@ from .execute_response import ExecuteResponse from .execution_message import ExecutionMessage from .file_result import FileResult +from .paginated_api_deployment_summary_list import PaginatedAPIDeploymentSummaryList +from .platform_key_error import PlatformKeyError from .status_response import StatusResponse +from .who_am_i_response import WhoAmIResponse __all__ = ( "AcknowledgedResponse", + "APIDeploymentSummary", + "APIDeploymentSummaryLast5RunStatusesItem", + "ApiKeyPermission", "ErrorDetail", "ErrorResponse", "ErrorType", @@ -20,5 +31,8 @@ "ExecuteResponse", "ExecutionMessage", "FileResult", + "PaginatedAPIDeploymentSummaryList", + "PlatformKeyError", "StatusResponse", + "WhoAmIResponse", ) diff --git a/src/unstract/api_deployments/_sdk_docstudio/models/api_deployment_summary.py b/src/unstract/api_deployments/_sdk_docstudio/models/api_deployment_summary.py new file mode 100644 index 0000000..643af06 --- /dev/null +++ b/src/unstract/api_deployments/_sdk_docstudio/models/api_deployment_summary.py @@ -0,0 +1,220 @@ +# Generated by tools/gen_sdk.sh from specs/docstudio-oss.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast +from uuid import UUID + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +if TYPE_CHECKING: + from ..models.api_deployment_summary_last_5_run_statuses_item import ( + APIDeploymentSummaryLast5RunStatusesItem, + ) + + +T = TypeVar("T", bound="APIDeploymentSummary") + + +@_attrs_define +class APIDeploymentSummary: + """One API deployment, as it appears in an organisation's listing. + + `api_name` and `api_endpoint` are what an execution call needs; + `display_name` and `description` say what the deployment is for. + + Attributes: + api_endpoint (str): + api_name (str): + co_owners_count (int): + created_by (int | None): + created_by_email (None | str): Get the email of the creator. + description (str): + display_name (str): + id (UUID): + is_active (bool): + is_owner (bool): + last_5_run_statuses (list[APIDeploymentSummaryLast5RunStatusesItem]): Fetch the last 5 execution statuses with + timestamps for this API deployment. + last_run_time (None | str): + run_count (int): + workflow (UUID): + workflow_name (str): + """ + + api_endpoint: str + api_name: str + co_owners_count: int + created_by: int | None + created_by_email: None | str + description: str + display_name: str + id: UUID + is_active: bool + is_owner: bool + last_5_run_statuses: list[APIDeploymentSummaryLast5RunStatusesItem] + last_run_time: None | str + run_count: int + workflow: UUID + workflow_name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + api_endpoint = self.api_endpoint + + api_name = self.api_name + + co_owners_count = self.co_owners_count + + created_by: int | None + created_by = self.created_by + + created_by_email: None | str + created_by_email = self.created_by_email + + description = self.description + + display_name = self.display_name + + id = str(self.id) + + is_active = self.is_active + + is_owner = self.is_owner + + last_5_run_statuses = [] + for last_5_run_statuses_item_data in self.last_5_run_statuses: + last_5_run_statuses_item = last_5_run_statuses_item_data.to_dict() + last_5_run_statuses.append(last_5_run_statuses_item) + + last_run_time: None | str + last_run_time = self.last_run_time + + run_count = self.run_count + + workflow = str(self.workflow) + + workflow_name = self.workflow_name + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "api_endpoint": api_endpoint, + "api_name": api_name, + "co_owners_count": co_owners_count, + "created_by": created_by, + "created_by_email": created_by_email, + "description": description, + "display_name": display_name, + "id": id, + "is_active": is_active, + "is_owner": is_owner, + "last_5_run_statuses": last_5_run_statuses, + "last_run_time": last_run_time, + "run_count": run_count, + "workflow": workflow, + "workflow_name": workflow_name, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.api_deployment_summary_last_5_run_statuses_item import ( + APIDeploymentSummaryLast5RunStatusesItem, + ) + + d = dict(src_dict) + api_endpoint = d.pop("api_endpoint") + + api_name = d.pop("api_name") + + co_owners_count = d.pop("co_owners_count") + + def _parse_created_by(data: object) -> int | None: + if data is None: + return data + return cast(int | None, data) + + created_by = _parse_created_by(d.pop("created_by")) + + def _parse_created_by_email(data: object) -> None | str: + if data is None: + return data + return cast(None | str, data) + + created_by_email = _parse_created_by_email(d.pop("created_by_email")) + + description = d.pop("description") + + display_name = d.pop("display_name") + + id = UUID(d.pop("id")) + + is_active = d.pop("is_active") + + is_owner = d.pop("is_owner") + + last_5_run_statuses = [] + _last_5_run_statuses = d.pop("last_5_run_statuses") + for last_5_run_statuses_item_data in _last_5_run_statuses: + last_5_run_statuses_item = ( + APIDeploymentSummaryLast5RunStatusesItem.from_dict( + last_5_run_statuses_item_data + ) + ) + + last_5_run_statuses.append(last_5_run_statuses_item) + + def _parse_last_run_time(data: object) -> None | str: + if data is None: + return data + return cast(None | str, data) + + last_run_time = _parse_last_run_time(d.pop("last_run_time")) + + run_count = d.pop("run_count") + + workflow = UUID(d.pop("workflow")) + + workflow_name = d.pop("workflow_name") + + api_deployment_summary = cls( + api_endpoint=api_endpoint, + api_name=api_name, + co_owners_count=co_owners_count, + created_by=created_by, + created_by_email=created_by_email, + description=description, + display_name=display_name, + id=id, + is_active=is_active, + is_owner=is_owner, + last_5_run_statuses=last_5_run_statuses, + last_run_time=last_run_time, + run_count=run_count, + workflow=workflow, + workflow_name=workflow_name, + ) + + api_deployment_summary.additional_properties = d + return api_deployment_summary + + @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/api_deployments/_sdk_docstudio/models/api_deployment_summary_last_5_run_statuses_item.py b/src/unstract/api_deployments/_sdk_docstudio/models/api_deployment_summary_last_5_run_statuses_item.py new file mode 100644 index 0000000..2285cf8 --- /dev/null +++ b/src/unstract/api_deployments/_sdk_docstudio/models/api_deployment_summary_last_5_run_statuses_item.py @@ -0,0 +1,48 @@ +# Generated by tools/gen_sdk.sh from specs/docstudio-oss.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="APIDeploymentSummaryLast5RunStatusesItem") + + +@_attrs_define +class APIDeploymentSummaryLast5RunStatusesItem: + """ """ + + 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: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + api_deployment_summary_last_5_run_statuses_item = cls() + + api_deployment_summary_last_5_run_statuses_item.additional_properties = d + return api_deployment_summary_last_5_run_statuses_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/api_deployments/_sdk_docstudio/models/api_key_permission.py b/src/unstract/api_deployments/_sdk_docstudio/models/api_key_permission.py new file mode 100644 index 0000000..c8d8d83 --- /dev/null +++ b/src/unstract/api_deployments/_sdk_docstudio/models/api_key_permission.py @@ -0,0 +1,18 @@ +# Generated by tools/gen_sdk.sh from specs/docstudio-oss.json. DO NOT EDIT. +from typing import Literal + +ApiKeyPermission = Literal["full_access", "read", "read_write"] + +API_KEY_PERMISSION_VALUES: set[ApiKeyPermission] = { + "full_access", + "read", + "read_write", +} + + +def check_api_key_permission(value: str) -> ApiKeyPermission: + if value in API_KEY_PERMISSION_VALUES: + return value + raise TypeError( + f"Unexpected value {value!r}. Expected one of {API_KEY_PERMISSION_VALUES!r}" + ) diff --git a/src/unstract/api_deployments/_sdk_docstudio/models/paginated_api_deployment_summary_list.py b/src/unstract/api_deployments/_sdk_docstudio/models/paginated_api_deployment_summary_list.py new file mode 100644 index 0000000..6bcaa99 --- /dev/null +++ b/src/unstract/api_deployments/_sdk_docstudio/models/paginated_api_deployment_summary_list.py @@ -0,0 +1,126 @@ +# Generated by tools/gen_sdk.sh from specs/docstudio-oss.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +if TYPE_CHECKING: + from ..models.api_deployment_summary import APIDeploymentSummary + + +T = TypeVar("T", bound="PaginatedAPIDeploymentSummaryList") + + +@_attrs_define +class PaginatedAPIDeploymentSummaryList: + """ + Attributes: + count (int): Example: 123. + results (list[APIDeploymentSummary]): + next_ (None | str | Unset): Example: http://api.example.org/accounts/?page=4. + previous (None | str | Unset): Example: http://api.example.org/accounts/?page=2. + """ + + count: int + results: list[APIDeploymentSummary] + next_: None | str | Unset = UNSET + previous: None | str | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + count = self.count + + results = [] + for results_item_data in self.results: + results_item = results_item_data.to_dict() + results.append(results_item) + + next_: None | str | Unset + if isinstance(self.next_, Unset): + next_ = UNSET + else: + next_ = self.next_ + + previous: None | str | Unset + if isinstance(self.previous, Unset): + previous = UNSET + else: + previous = self.previous + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "count": count, + "results": results, + } + ) + if next_ is not UNSET: + field_dict["next"] = next_ + if previous is not UNSET: + field_dict["previous"] = previous + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.api_deployment_summary import APIDeploymentSummary + + d = dict(src_dict) + count = d.pop("count") + + results = [] + _results = d.pop("results") + for results_item_data in _results: + results_item = APIDeploymentSummary.from_dict(results_item_data) + + results.append(results_item) + + def _parse_next_(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + next_ = _parse_next_(d.pop("next", UNSET)) + + def _parse_previous(data: object) -> None | str | Unset: + if data is None: + return data + if isinstance(data, Unset): + return data + return cast(None | str | Unset, data) + + previous = _parse_previous(d.pop("previous", UNSET)) + + paginated_api_deployment_summary_list = cls( + count=count, + results=results, + next_=next_, + previous=previous, + ) + + paginated_api_deployment_summary_list.additional_properties = d + return paginated_api_deployment_summary_list + + @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/api_deployments/_sdk_docstudio/models/platform_key_error.py b/src/unstract/api_deployments/_sdk_docstudio/models/platform_key_error.py new file mode 100644 index 0000000..2e8e6bf --- /dev/null +++ b/src/unstract/api_deployments/_sdk_docstudio/models/platform_key_error.py @@ -0,0 +1,69 @@ +# Generated by tools/gen_sdk.sh from specs/docstudio-oss.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="PlatformKeyError") + + +@_attrs_define +class PlatformKeyError: + """Why a platform-key request was refused. + + Produced by the authentication middleware rather than by the project's + exception handler, so it carries a single human-readable message and none + of the per-field structure the organisation-scoped endpoints return. It is + the shape of this operation's credential failures specifically, not of + every failure it can return. + + Attributes: + message (str): Human-readable reason for the refusal. + """ + + message: str + 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( + { + "message": message, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + message = d.pop("message") + + platform_key_error = cls( + message=message, + ) + + platform_key_error.additional_properties = d + return platform_key_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/api_deployments/_sdk_docstudio/models/who_am_i_response.py b/src/unstract/api_deployments/_sdk_docstudio/models/who_am_i_response.py new file mode 100644 index 0000000..4783de2 --- /dev/null +++ b/src/unstract/api_deployments/_sdk_docstudio/models/who_am_i_response.py @@ -0,0 +1,92 @@ +# Generated by tools/gen_sdk.sh from specs/docstudio-oss.json. DO NOT EDIT. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..models.api_key_permission import ApiKeyPermission, check_api_key_permission + +T = TypeVar("T", bound="WhoAmIResponse") + + +@_attrs_define +class WhoAmIResponse: + """The organisation a platform API key belongs to, and what it may do. + + Attributes: + key_name (str): The key's name, as it was minted. + organization_id (str): The organisation's identifier, as it appears in web-app URLs and in every organisation- + scoped API path. + organization_name (str): The organisation's display name. + permission (ApiKeyPermission): * `read` - Read + * `read_write` - Read/Write + * `full_access` - Full Access + """ + + key_name: str + organization_id: str + organization_name: str + permission: ApiKeyPermission + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + key_name = self.key_name + + organization_id = self.organization_id + + organization_name = self.organization_name + + permission: str = self.permission + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update( + { + "key_name": key_name, + "organization_id": organization_id, + "organization_name": organization_name, + "permission": permission, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + key_name = d.pop("key_name") + + organization_id = d.pop("organization_id") + + organization_name = d.pop("organization_name") + + permission = check_api_key_permission(d.pop("permission")) + + who_am_i_response = cls( + key_name=key_name, + organization_id=organization_id, + organization_name=organization_name, + permission=permission, + ) + + who_am_i_response.additional_properties = d + return who_am_i_response + + @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/api_deployments/client.py b/src/unstract/api_deployments/client.py index 156bff5..df57093 100644 --- a/src/unstract/api_deployments/client.py +++ b/src/unstract/api_deployments/client.py @@ -3,8 +3,15 @@ Classes: APIDeploymentsClient: A class to invoke APIs deployed on the Unstract platform. - APIDeploymentsClientException: A class to handle exceptions raised by the - APIDeploymentsClient class. + PlatformKeyClient: A class to read the account a platform API key belongs to + and the deployments in it. + UnstractError: Base of the exceptions both clients raise, aliased as + ``APIDeploymentsClientException`` for callers who catch that name. + +The two clients take different credentials and are not interchangeable. A +deployment key runs deployments and cannot describe the account; a platform key +describes the account and lists what is in it but cannot run anything. That +split is the API's, not this module's. """ import json @@ -13,8 +20,9 @@ import os import threading import time -from typing import Any +from typing import Any, Self from urllib.parse import parse_qs, urljoin, urlparse +from uuid import UUID import attrs import httpx @@ -46,7 +54,12 @@ from tenacity.wait import wait_base from unstract.api_deployments._sdk_docstudio import AuthenticatedClient -from unstract.api_deployments._sdk_docstudio.api.deployment import execute, status +from unstract.api_deployments._sdk_docstudio.api.deployment import ( + execute, + list_deployments, + status, +) +from unstract.api_deployments._sdk_docstudio.api.identity import whoami from unstract.api_deployments._sdk_docstudio.models import ExecuteRequest from unstract.api_deployments._sdk_docstudio.types import UNSET, File, Unset from unstract.api_deployments.utils import UnstractUtils @@ -113,7 +126,7 @@ def _query_value(url: str, key: str) -> str: if not value: # Only the path is reported: the query is the service's to shape, and # the documented usage prints this exception straight to a log. - raise APIDeploymentsClientException( + raise APIDeploymentError( f"No {key} in the query of {parsed.path!r}. The status endpoint the " "service returned carries it; pass that endpoint unmodified." ) @@ -167,18 +180,21 @@ def _error_text(body: Any, response) -> str: return (response.text or "").strip()[:_ERROR_TEXT_LIMIT] -class APIDeploymentsClientException(Exception): - """A class to handle exceptions raised by the APIClient class.""" +class UnstractError(Exception): + """Base for every error the clients in this package raise.""" + + +class APIDeploymentError(UnstractError): + """Raised by :class:`APIDeploymentsClient`.""" - def __init__(self, message): - def __init__(self, value): - self.value = value - def __str__(self): - return repr(self.value) +class PlatformClientError(UnstractError): + """Raised by :class:`PlatformKeyClient`.""" - def error_message(self): - return self.value + +#: The name this exception shipped under. Aliased to the base, not a leaf, so +#: it keeps catching everything either client raises. +APIDeploymentsClientException = UnstractError class _WaitRetryAfterOrExponentialJitter(wait_base): @@ -227,89 +243,18 @@ def __call__(self, retry_state: RetryCallState) -> float: _STATUS_SEND_ONLY = frozenset({"execution_id", "include_metadata"}) -class APIDeploymentsClient: - """A class to invoke APIs deployed on the Unstract platform.""" - - 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 = "" - api_timeout = 300 - in_progress_statuses = ["PENDING", "EXECUTING", "READY", "QUEUED", "INITIATED"] - - def __init__( - self, - api_url: str, - api_key: str, - api_timeout: int = 300, - logging_level: str = "INFO", - include_metadata: bool = False, - verify: bool = True, - max_retries: int = 4, - initial_delay: float = 2.0, - max_delay: float = 60.0, - backoff_factor: float = 2.0, - jitter: float = 1.0, - *, - transport_timeout: float | None = None, - ): - """Initializes the APIClient class. - - Args: - api_key (str): The API key to authenticate the API request. - api_timeout (int): Backend execution mode sent with the request — - see ``timeout`` on ``structure_file``. ``0`` or below queues the - execution and returns; above it the call runs synchronously and - the value bounds how long the backend waits. - logging_level (str): The logging level to log messages. - max_retries (int): Maximum number of retry attempts for failed requests. - initial_delay (float): Initial delay in seconds before the first retry. - max_delay (float): Maximum delay in seconds between retries. - backoff_factor (float): Multiplier applied to delay for each retry. - jitter (float): Maximum additive jitter in seconds added to each delay. - transport_timeout (float | None): Socket timeout in seconds. Unset - means a stalled connection blocks forever, which is what the - released client did; ``api_timeout`` cannot serve here because - it is an execution mode, not a socket timeout. - """ - if logging_level == "": - logging_level = os.getenv("UNSTRACT_API_CLIENT_LOGGING_LEVEL", "INFO") - 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: " + logging_level) +class _HttpxFacade: + """Transport shared by the clients in this module. - if api_key == "": - self.api_key = os.getenv("UNSTRACT_API_DEPLOYMENT_KEY", "") - else: - self.api_key = api_key - self.logger.debug("API key set to: " + UnstractUtils.redact_key(self.api_key)) + The pooled transport, the retry policy and the translation of httpx + failures into the ``requests`` types callers catch live here, so the + clients cannot drift apart on them. A subclass sets ``base_url``, + ``api_key``, ``verify``, ``transport_timeout`` and the retry knobs in its + own ``__init__``, and ``_error_class`` to the exception it raises. + """ - self.api_timeout = api_timeout - self.api_url = api_url - self.__save_base_url(api_url) - self.include_metadata = include_metadata - self.verify = verify - self.max_retries = max_retries - self.initial_delay = initial_delay - self.max_delay = max_delay - self.backoff_factor = backoff_factor - self.jitter = jitter - self.transport_timeout = transport_timeout - self._transport_client = None - self._transport_lock = threading.Lock() + #: Exception raised for anything this transport reports. Subclasses narrow it. + _error_class: type[UnstractError] = UnstractError def _is_retryable_status(self, status_code: int) -> bool: """Checks whether a status code should trigger a retry. @@ -322,33 +267,19 @@ def _is_retryable_status(self, status_code: int) -> bool: """ return status_code >= 500 or status_code == 429 - def __save_base_url(self, full_url: str): - """Extracts the base URL from the full URL and saves it. - - Args: - full_url (str): The full URL of the API. - """ - parsed_url = urlparse(full_url) - self.base_url = parsed_url.scheme + "://" + parsed_url.netloc - self.logger.debug("Base URL: " + self.base_url) - @property - def _transport(self): - """The HTTP client, built on first use. - - Untimed by default, matching the previous behaviour. ``api_timeout`` is - a backend execution mode (0 selects async execution), never a socket - timeout; feeding it to the transport fails deep in the connection layer - for the negative values the API accepts. ``transport_timeout`` is the - way to bound a stalled connection. + def _transport(self) -> AuthenticatedClient: + """The HTTP client and its connection pool, built on first use. - Built under a lock: two threads racing the first call would otherwise - each build a pool and one would be dropped still holding its sockets. + Both the wrapper and the pool inside it are built under the lock. The + pool is what holds sockets, and ``AuthenticatedClient`` builds it lazily + without synchronising, so two threads racing the first call would + otherwise each build one and drop the loser still holding its sockets. """ if self._transport_client is None: with self._transport_lock: if self._transport_client is None: - self._transport_client = AuthenticatedClient( + transport = AuthenticatedClient( base_url=self.base_url, token=self.api_key, verify_ssl=self.verify, @@ -360,6 +291,8 @@ def _transport(self): # finished-and-empty job. follow_redirects=True, ) + transport.get_httpx_client() + self._transport_client = transport return self._transport_client def close(self) -> None: @@ -375,67 +308,12 @@ def close(self) -> None: if transport is not None: transport.get_httpx_client().close() - def __enter__(self) -> "APIDeploymentsClient": + def __enter__(self) -> Self: return self def __exit__(self, exc_type, exc_value, traceback) -> None: self.close() - @property - def _deployment_route(self) -> tuple[str, str]: - """Organisation and API name, from the deployment URL's last two - segments.""" - segments = urlparse(self.api_url).path.strip("/").split("/") - if len(segments) < 2: - raise APIDeploymentsClientException( - f"Cannot derive organisation and API name from api_url: {self.api_url}" - ) - return segments[-2], segments[-1] - - def _spec_route(self) -> str: - """The path the spec routes a poll to, or ``""`` when the deployment URL - carries no organisation and API name to build one from. - - Built through the generated builder so it follows the spec rather than a - copy of it. - """ - try: - org_name, api_name = self._deployment_route - except APIDeploymentsClientException: - return "" - return status._get_kwargs(org_name, api_name, execution_id="")["url"] - - def _status_url(self, endpoint: str) -> str: - """Absolute URL to poll, under the deployment's own path prefix. - - ``base_url`` is scheme and host only, so a deployment served under a path - prefix would execute -- the execute call sends the caller's URL verbatim - -- and then never poll. The prefix is whatever precedes the spec route - inside the deployment URL. Where the two do not line up there is no - prefix to derive, and the path the service returned is used as it came: - a guessed path polls nothing, and the execution behind it has already - been paid for. - - A deployment URL with no organisation and API name in it -- an ingress - rewrite short enough to have neither -- has no route to line up against - and takes that same branch. The released client polled those, and the - execution has already been submitted by the time this runs. - - Only the path is taken. A scheme and host in the reply would otherwise - decide where the deployment key is sent, and the reply is not the thing - that gets to choose that. - """ - path = self._spec_route() - route = path.rstrip("/") - prefix = urlparse(self.api_url).path.rstrip("/") - if route and prefix.endswith(route): - return self.base_url + prefix[: -len(route)] + path - # Joined rather than concatenated: the query travels as params. - return urljoin( - self.base_url, - urlparse(endpoint)._replace(scheme="", netloc="", query="").geturl(), - ) - def _send(self, method: str, url: str, **kwargs) -> httpx.Response: """Issue one request, translating transport failures on the way out. @@ -451,9 +329,19 @@ def _send(self, method: str, url: str, **kwargs) -> httpx.Response: **(kwargs.get("headers") or {}), "Authorization": f"Bearer {self.api_key}", } - return _translate_transport_errors( - self._transport.get_httpx_client().request, method, url, **kwargs - ) + + def _issue() -> httpx.Response: + client = self._transport.get_httpx_client() + try: + return client.request(method, url, **kwargs) + except RuntimeError as e: + # A close on another thread mid-send. httpx raises a bare + # RuntimeError for it, which the translator does not cover. + if not client.is_closed: + raise + raise ConnectionError(str(e)) from e + + return _translate_transport_errors(_issue) @staticmethod def _read_body(response): @@ -569,6 +457,157 @@ def _retry_error_callback(retry_state: RetryCallState): return retrier(self._send, method, url, **kwargs) + +class APIDeploymentsClient(_HttpxFacade): + """A class to invoke APIs deployed on the Unstract platform.""" + + _error_class = APIDeploymentError + + 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 = "" + api_timeout = 300 + in_progress_statuses = ["PENDING", "EXECUTING", "READY", "QUEUED", "INITIATED"] + + def __init__( + self, + api_url: str, + api_key: str, + api_timeout: int = 300, + logging_level: str = "INFO", + include_metadata: bool = False, + verify: bool = True, + max_retries: int = 4, + initial_delay: float = 2.0, + max_delay: float = 60.0, + backoff_factor: float = 2.0, + jitter: float = 1.0, + *, + transport_timeout: float | None = None, + ): + """Initializes the APIClient class. + + Args: + api_key (str): The API key to authenticate the API request. + api_timeout (int): Backend execution mode sent with the request — + see ``timeout`` on ``structure_file``. ``0`` or below queues the + execution and returns; above it the call runs synchronously and + the value bounds how long the backend waits. + logging_level (str): The logging level to log messages. + max_retries (int): Maximum number of retry attempts for failed requests. + initial_delay (float): Initial delay in seconds before the first retry. + max_delay (float): Maximum delay in seconds between retries. + backoff_factor (float): Multiplier applied to delay for each retry. + jitter (float): Maximum additive jitter in seconds added to each delay. + transport_timeout (float | None): Socket timeout in seconds. Unset + means a stalled connection blocks forever, which is what the + released client did; ``api_timeout`` cannot serve here because + it is an execution mode, not a socket timeout. + """ + if logging_level == "": + logging_level = os.getenv("UNSTRACT_API_CLIENT_LOGGING_LEVEL", "INFO") + 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.debug("Logging level set to: " + logging_level) + + if api_key == "": + self.api_key = os.getenv("UNSTRACT_API_DEPLOYMENT_KEY", "") + else: + self.api_key = api_key + self.logger.debug("API key set to: " + UnstractUtils.redact_key(self.api_key)) + + self.api_timeout = api_timeout + self.api_url = api_url + self.__save_base_url(api_url) + self.include_metadata = include_metadata + self.verify = verify + self.max_retries = max_retries + self.initial_delay = initial_delay + self.max_delay = max_delay + self.backoff_factor = backoff_factor + self.jitter = jitter + self.transport_timeout = transport_timeout + self._transport_client = None + self._transport_lock = threading.Lock() + + def __save_base_url(self, full_url: str): + """Extracts the base URL from the full URL and saves it. + + Args: + full_url (str): The full URL of the API. + """ + parsed_url = urlparse(full_url) + self.base_url = parsed_url.scheme + "://" + parsed_url.netloc + self.logger.debug("Base URL: " + self.base_url) + + @property + def _deployment_route(self) -> tuple[str, str]: + """Organisation and API name, from the deployment URL's last two + segments.""" + segments = urlparse(self.api_url).path.strip("/").split("/") + if len(segments) < 2: + raise APIDeploymentError( + f"Cannot derive organisation and API name from api_url: {self.api_url}" + ) + return segments[-2], segments[-1] + + def _spec_route(self) -> str: + """The path the spec routes a poll to, or ``""`` when the deployment URL + carries no organisation and API name to build one from. + + Built through the generated builder so it follows the spec rather than a + copy of it. + """ + try: + org_name, api_name = self._deployment_route + except APIDeploymentError: + return "" + return status._get_kwargs(org_name, api_name, execution_id="")["url"] + + def _status_url(self, endpoint: str) -> str: + """Absolute URL to poll, under the deployment's own path prefix. + + ``base_url`` is scheme and host only, so a deployment served under a path + prefix would execute -- the execute call sends the caller's URL verbatim + -- and then never poll. The prefix is whatever precedes the spec route + inside the deployment URL. Where the two do not line up there is no + prefix to derive, and the path the service returned is used as it came: + a guessed path polls nothing, and the execution behind it has already + been paid for. + + A deployment URL with no organisation and API name in it -- an ingress + rewrite short enough to have neither -- has no route to line up against + and takes that same branch. The released client polled those, and the + execution has already been submitted by the time this runs. + + Only the path is taken. A scheme and host in the reply would otherwise + decide where the deployment key is sent, and the reply is not the thing + that gets to choose that. + """ + path = self._spec_route() + route = path.rstrip("/") + prefix = urlparse(self.api_url).path.rstrip("/") + if route and prefix.endswith(route): + return self.base_url + prefix[: -len(route)] + path + # Joined rather than concatenated: the query travels as params. + return urljoin( + self.base_url, + urlparse(endpoint)._replace(scheme="", netloc="", query="").geturl(), + ) + def structure_file( self, file_paths: list[str], @@ -668,7 +707,7 @@ def structure_file( if isinstance(e, FileNotFoundError) else "Cannot read file" ) - raise APIDeploymentsClientException(f"{reason}: {e}") from e + raise APIDeploymentError(f"{reason}: {e}") from e body = ExecuteRequest( files=[ @@ -923,3 +962,190 @@ def check_execution_status( ) return obj_to_return + + +class PlatformKeyClient(_HttpxFacade): + """Read the account a platform API key belongs to, and its deployments. + + Separate from `APIDeploymentsClient` because the credential and the URL + shape are both different: that class takes a deployment key and derives an + organisation and API name from a deployment URL, while these operations take + a platform key and address the account. Folding them together would mean a + class whose required `api_url` is meaningless for half its methods. + + The transport, retry policy and error translation are the shared ones in + `_HttpxFacade`, so a caller catching the `requests` exception types or + relying on retries gets the same behaviour from either client. + """ + + _error_class = PlatformClientError + + def __init__( + self, + base_url: str, + api_key: str | None = None, + *, + verify: bool = True, + transport_timeout: float | None = None, + logging_level: str = "INFO", + max_retries: int = 4, + initial_delay: float = 2.0, + max_delay: float = 60.0, + backoff_factor: float = 2.0, + jitter: float = 1.0, + ) -> None: + """ + Args: + base_url (str): Scheme and host of the Unstract deployment, e.g. + ``https://us-central.unstract.com``. A path is ignored -- these + operations carry their own, which httpx resolves against the + origin -- and dropping a non-empty one is logged, because an + install served under a path prefix is unreachable this way. + api_key (str | None): Platform API key. Falls back to + ``$UNSTRACT_PLATFORM_KEY``. + verify (bool): Verify TLS certificates. + transport_timeout (float | None): Seconds before a stalled + connection is given up on. Unset means no bound. + logging_level (str): Level for this client's logger. + max_retries (int): Maximum number of retry attempts for failed requests. + initial_delay (float): Initial delay in seconds before the first retry. + max_delay (float): Maximum delay in seconds between retries. + backoff_factor (float): Multiplier applied to delay for each retry. + jitter (float): Maximum additive jitter in seconds added to each delay. + """ + # Its own logger: the module one is shared, so levelling it here would + # re-level a live instance of the sibling client. + self.logger = logging.getLogger(f"{__name__}.PlatformKeyClient") + self.logger.setLevel(getattr(logging, logging_level.upper(), logging.INFO)) + + if api_key is None: + self.api_key = os.getenv("UNSTRACT_PLATFORM_KEY", "") + else: + self.api_key = api_key + if not self.api_key.strip(): + raise PlatformClientError( + "A platform API key is required: pass api_key or set " + "$UNSTRACT_PLATFORM_KEY." + ) + self.logger.debug( + "Platform key set to: " + UnstractUtils.redact_key(self.api_key) + ) + + parsed = urlparse(base_url) + if not parsed.scheme or not parsed.netloc: + raise PlatformClientError( + f"base_url must include a scheme and host, got {base_url!r}." + ) + if parsed.path.strip("/"): + self.logger.warning( + "Ignoring path %r on base_url: these operations carry their own. " + "An install served under a path prefix is not reachable this way.", + parsed.path, + ) + self.base_url = parsed.scheme + "://" + parsed.netloc + + self.verify = verify + self.transport_timeout = transport_timeout + self.max_retries = max_retries + self.initial_delay = initial_delay + self.max_delay = max_delay + self.backoff_factor = backoff_factor + self.jitter = jitter + self._transport_client: AuthenticatedClient | None = None + self._transport_lock = threading.Lock() + + def _read_or_raise(self, response: httpx.Response, what: str) -> dict[str, Any]: + """The JSON object of a 2xx, or an exception naming why it was refused. + + Read directly rather than through the generated model, which is built + only for the statuses the spec declares and raises on any other body. + """ + body = self._read_body(response) + self.logger.debug("%s returned %d", what, response.status_code) + if not 200 <= response.status_code < 300: + raise self._error_class( + f"{what} failed with {response.status_code}: " + f"{_error_text(body, response)}" + ) + if body is None: + self.logger.error( + "%s returned %d with a body that could not be read as JSON: %s", + what, + response.status_code, + _error_text(None, response), + ) + raise self._error_class( + f"{what} returned {response.status_code} with an unreadable body " + f"(content-type {response.headers.get('content-type')!r}): " + f"{_error_text(None, response)}" + ) + if not isinstance(body, dict): + raise self._error_class( + f"{what} returned {response.status_code} with a JSON " + f"{type(body).__name__} where an object was expected: " + f"{_error_text(None, response)}" + ) + return body + + def whoami(self) -> dict[str, Any]: + """The organisation this key belongs to, and the key's own scope. + + Returns: + dict: ``organization_id``, ``organization_name``, ``permission`` + and ``key_name``, the shape `WhoAmIResponse` models. Use the + ``organization_id`` as the ``org_id`` other operations take. + """ + self.logger.debug("Resolving identity via the whoami operation") + response = self._request_with_retry(**whoami._get_kwargs()) + return self._read_or_raise(response, "whoami") + + def list_deployments( + self, + org_id: str, + *, + api_name: str | None = None, + search: str | None = None, + ordering: str | None = None, + page: int | None = None, + page_size: int | None = None, + workflow: UUID | str | None = None, + ) -> dict[str, Any]: + """The API deployments in one organisation, one page at a time. + + A filter left as ``None`` is not sent. Follow ``next`` rather than + assuming ``results`` is the whole set. + + Args: + org_id (str): Organisation to list within. `whoami` resolves this + from the key. + api_name (str): Return only the deployment with this exact API name. + search (str): Free-text filter. + ordering (str): Field to order by. + page (int): 1-based page number. + page_size (int): Rows per page. + workflow (UUID | str): Return only deployments of this workflow. + + Returns: + dict: ``count``, ``next``, ``previous`` and ``results``, the shape + `PaginatedAPIDeploymentSummaryList` models. + """ + if not org_id.strip(): + raise PlatformClientError( + "org_id is required; whoami() resolves it from the key." + ) + self.logger.debug("Listing deployments for organisation: %s", org_id) + # Omitted rather than passed as None: the builder renders some + # parameters before it filters None out, sending the string "None". + filters = { + "api_name": api_name, + "search": search, + "ordering": ordering, + "page": page, + "page_size": page_size, + "workflow": workflow, + } + request_kwargs = list_deployments._get_kwargs( + org_id, **{k: v for k, v in filters.items() if v is not None} + ) + response = self._request_with_retry(**request_kwargs) + return self._read_or_raise(response, "list_deployments") diff --git a/tests/test_compat.py b/tests/test_compat.py index 8cd95fa..8fe3bf4 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -25,10 +25,13 @@ import inspect import io import json +import logging +import os import re import socket import threading import tomllib +from contextlib import contextmanager from pathlib import Path from unittest.mock import MagicMock, patch from urllib.parse import parse_qs, urlparse @@ -50,13 +53,22 @@ ) from unstract import api_deployments +from unstract.api_deployments import ( + PaginatedAPIDeploymentSummaryList, + WhoAmIResponse, +) +from unstract.api_deployments._sdk_docstudio import AuthenticatedClient +from unstract.api_deployments._sdk_docstudio.types import UNSET from unstract.api_deployments.client import ( _EXECUTE_SEND_ONLY, _STATUS_SEND_ONLY, + APIDeploymentError, APIDeploymentsClient, APIDeploymentsClientException, + PlatformClientError, + PlatformKeyClient, + UnstractError, ) -from unstract.api_deployments._sdk_docstudio.types import UNSET BASELINE_VERSION = "1.5.3" BASELINE_PATH = Path(__file__).parent / "baseline" / "client_1_5_3.py" @@ -72,6 +84,14 @@ #: has to be added here deliberately rather than arriving unnoticed. WRAPPED_OPERATIONS = frozenset({"execute", "status"}) +#: The platform-key operations, kept apart from the set above rather than merged +#: into it. They take a different credential, reach a different facade class, +#: and declare a different error family -- `whoami` declares no `ErrorResponse` +#: at all, so the "both families are in play" assertion below is false for them +#: by construction. `_declared_responses` also cannot read them: their 500 +#: carries no body, and it indexes `content` unconditionally. +PLATFORM_OPERATIONS = frozenset({"whoami", "list_deployments"}) + #: Every accepted divergence from the baseline, named as the module docstring #: names it. A divergence pinned by a test but missing from that list is only #: findable by reading all of them. @@ -1501,6 +1521,10 @@ def _body_for(schema: str) -> tuple[dict, str]: }, "the reason" if schema == "AcknowledgedResponse": return {"status": "COMPLETED", "message": "the reason"}, "the reason" + if schema == "PlatformKeyError": + # What CustomAuthMiddleware sends: a bare {"message": ...}, before DRF + # is entered, so it never carries the handler's {type, errors[]} shape. + return {"message": "the reason"}, "the reason" # StatusResponse. The status endpoint's own envelope carries per-file # results, never a reason: on these statuses the execution's state is the # answer, and any reason is inside a file's own entry. @@ -1877,6 +1901,11 @@ def test_every_declared_operation_is_wrapped(): Compared whole rather than after subtracting an exception list: an entry excusing an operation the spec no longer declares keeps passing forever, and nothing about a green run says the list is still describing anything. + + Two sets, unioned, because the spec now serves two credentials: the + deployment-key operations reached through `APIDeploymentsClient` and the + platform-key ones through `PlatformKeyClient`. The union keeps the whole + comparison intact -- an operation belonging to neither still fails here. """ spec = json.loads(SPEC_PATH.read_text()) declared = { @@ -1885,7 +1914,7 @@ def test_every_declared_operation_is_wrapped(): for method, operation in path.items() if method in {"get", "post", "put", "patch", "delete"} } - assert declared == WRAPPED_OPERATIONS + assert declared == WRAPPED_OPERATIONS | PLATFORM_OPERATIONS def test_the_baseline_is_the_released_client_unmodified(): @@ -1893,3 +1922,709 @@ def test_the_baseline_is_the_released_client_unmodified(): # any provenance it likes, and every parity test here would still pass. assert BASELINE_PATH.name == f"client_{BASELINE_VERSION.replace('.', '_')}.py" assert hashlib.sha256(BASELINE_PATH.read_bytes()).hexdigest() == BASELINE_SHA256 + + +# --------------------------------------------------------------------------- # +# The platform-key facade +# --------------------------------------------------------------------------- # + +#: Every error status each platform operation declares, and the shape it carries. +#: A bodyless status maps to None -- the spec declares a 500 with no content on +#: both, so there is nothing for a caller to parse and nothing to assert a reason +#: from. Kept explicit rather than derived so a spec that starts declaring a body +#: there fails here instead of silently gaining an unread branch. +PLATFORM_ERROR_STATUSES = { + "whoami": {401: "PlatformKeyError", 500: None}, + "list_deployments": { + 400: "ErrorResponse", + 401: "PlatformKeyError", + 403: "PlatformKeyError", + 500: None, + }, +} + + +def _platform_declared(operation_id: str) -> dict[int, str | None]: + """``{status: schema name or None}`` for one platform operation. + + Separate from `_declared_responses` because that one indexes `content` + unconditionally and these operations declare a bodyless 500. + """ + spec = json.loads(SPEC_PATH.read_text()) + for path in spec["paths"].values(): + for method, operation in path.items(): + if method != "get" or operation.get("operationId") != operation_id: + continue + out: dict[int, str | None] = {} + for code, body in operation["responses"].items(): + ref = ( + body.get("content", {}) + .get("application/json", {}) + .get("schema", {}) + .get("$ref", "") + ) + out[int(code)] = ref.split("/")[-1] or None + return out + raise AssertionError(f"{operation_id} not declared in the spec") + + +def _deployment_page() -> dict: + """One page of the listing, with every field `APIDeploymentSummary` requires. + + Shared so the facade test and the generated-model tests read the same body: + a row that satisfies one and not the other would prove nothing about either. + """ + return { + "count": 1, + "next": None, + "previous": None, + "results": [ + { + "id": "11111111-1111-1111-1111-111111111111", + "api_name": "invoice-parser", + "display_name": "Invoice Parser", + "description": "", + "is_active": True, + "api_endpoint": ( + "https://example.unstract.com/deployment/api/org-a/invoice-parser/" + ), + "workflow": "22222222-2222-2222-2222-222222222222", + "workflow_name": "wf", + "created_by": 1, + "created_by_email": "a@b.c", + "co_owners_count": 0, + "is_owner": True, + "last_run_time": None, + "run_count": 0, + "last_5_run_statuses": [], + } + ], + } + + +def _identity_body() -> dict: + """The identity body `whoami` answers with, in the shape the spec declares.""" + return { + "organization_id": "org-a", + "organization_name": "Org A", + "permission": "read", + "key_name": "cli-key", + } + + +@contextmanager +def caplog_at_error(): + """Collect this client's own ERROR records. + + `PlatformKeyClient` configures its logger itself, so the level and handlers + are not the ones `caplog` attaches to the root. + """ + records = [] + + class _Collect(logging.Handler): + def emit(self, record): + records.append(record) + + handler = _Collect() + logger = logging.getLogger(f"{PlatformKeyClient.__module__}.PlatformKeyClient") + logger.addHandler(handler) + try: + yield records + finally: + logger.removeHandler(handler) + + +def _platform_client(**kwargs): + kwargs.setdefault("base_url", "https://example.unstract.com") + kwargs.setdefault("api_key", "pk-test") + kwargs.setdefault("logging_level", "ERROR") + # Retries off by default: the error statuses exercised below include ones + # the client retries, and the backoff is real time. + kwargs.setdefault("max_retries", 0) + return PlatformKeyClient(**kwargs) + + +@contextmanager +def _platform_reply(status_code, json_data): + """Answer the next generated request with this response. + + Patched at `get_httpx_client`, the lowest seam the facade owns, so the URL + the generated `_get_kwargs` built and the header `_send` attached are both + real and observable on `transport.request`. + + It does **not** exercise the generated `_parse_response` or the response + models: `PlatformKeyClient` reads the body itself, deliberately, because + those parsers raise on an error body the spec did not declare. Their + coverage is `test_the_generated_platform_models_read_the_bodies_the_server_sends` + and `test_the_generated_platform_parsers_read_a_declared_response`. + """ + transport = MagicMock() + transport.request.return_value = _httpx_response(status_code, json_data) + with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport): + yield transport + + +#: How each platform operation is reached on the facade. Kept beside the +#: manifest and asserted against it: dispatching on a name meant an operation +#: with no method behind it could be listed and silently never called. +PLATFORM_CALLS = { + "whoami": lambda client: client.whoami(), + "list_deployments": lambda client: client.list_deployments("org-a"), +} + + +def test_every_platform_operation_has_a_method_behind_it(): + assert set(PLATFORM_CALLS) == PLATFORM_OPERATIONS + assert not (WRAPPED_OPERATIONS & PLATFORM_OPERATIONS) + + +@pytest.mark.parametrize("operation", sorted(PLATFORM_OPERATIONS)) +def test_the_platform_operations_declare_the_statuses_pinned_here(operation): + """The spec is the source; this manifest is the pin. A status the spec adds + or drops arrives as a failure rather than as an unread branch.""" + declared = _platform_declared(operation) + errors = {code: schema for code, schema in declared.items() if code != 200} + assert errors == PLATFORM_ERROR_STATUSES[operation], operation + + +@pytest.mark.parametrize("operation", sorted(PLATFORM_OPERATIONS)) +def test_every_platform_error_status_is_reported_with_its_reason(operation): + """A refusal has to reach the caller as an exception naming the reason. + + `raise_on_unexpected_status` is off on the shared transport, so a non-2xx + arrives as an ordinary response; without the facade's own check a 401 would + read as an empty result rather than a rejected key. + """ + for status_code, schema in PLATFORM_ERROR_STATUSES[operation].items(): + body, expected = _body_for(schema) if schema else (None, "") + with _platform_reply(status_code, body): + with pytest.raises(APIDeploymentsClientException) as caught: + PLATFORM_CALLS[operation](_platform_client()) + message = str(caught.value) + assert str(status_code) in message, (operation, status_code) + if expected: + assert expected in message, (operation, status_code, schema) + + +def test_whoami_returns_the_four_fields_the_spec_declares(): + """The organisation is read from the key server-side, so this is the call + that turns a bare key into the `org_id` every other operation needs.""" + with _platform_reply(200, _identity_body()) as transport: + result = _platform_client().whoami() + + assert result == _identity_body() + # Issued through `_send`, which passes method and url positionally the way + # the sibling client does. + method, url = transport.request.call_args.args[:2] + assert method == "get", method + # No organisation segment: putting one there would defeat the point. + assert url.endswith("/api/v1/unstract/whoami/"), url + sent = transport.request.call_args.kwargs["headers"]["Authorization"] + assert sent == "Bearer pk-test", sent + + +def test_list_deployments_sends_the_organisation_and_reads_the_page(): + page = _deployment_page() + with _platform_reply(200, page) as transport: + result = _platform_client().list_deployments("org-a", api_name="invoice-parser") + + assert result["count"] == 1 + assert result["results"][0]["api_name"] == "invoice-parser" + url = transport.request.call_args.args[1] + assert "/org-a/" in url, url + assert transport.request.call_args.kwargs["params"]["api_name"] == "invoice-parser" + + +def test_a_missing_platform_key_is_refused_at_construction(): + """Rather than at the first call, where it would look like a server refusal.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(APIDeploymentsClientException) as caught: + PlatformKeyClient(base_url="https://example.unstract.com") + assert "UNSTRACT_PLATFORM_KEY" in str(caught.value) + + +def test_the_platform_key_is_taken_from_the_environment_when_unset(): + with patch.dict(os.environ, {"UNSTRACT_PLATFORM_KEY": "pk-from-env"}, clear=True): + client = PlatformKeyClient(base_url="https://example.unstract.com") + assert client.api_key == "pk-from-env" + + +def test_a_base_url_without_a_host_is_refused(): + with pytest.raises(APIDeploymentsClientException): + _platform_client(base_url="not-a-url") + + +@pytest.mark.parametrize( + ("label", "body_text"), + [ + ("gateway_html", "401 Unauthorized"), + ("drf_shaped", '{"detail": "Invalid token."}'), + ("empty", ""), + ], +) +def test_an_error_body_the_model_cannot_parse_is_still_reported(label, body_text): + """The generated `_parse_response` builds `PlatformKeyError.from_dict(...)` + on a 401 with no guard: HTML raises `JSONDecodeError` and a DRF-shaped body + raises `KeyError: 'message'`, both out of the parser and before the facade + sees the response. Reading the body directly is what keeps a refused key a + reported refusal rather than a crash. + """ + transport = MagicMock() + transport.request.return_value = httpx.Response(401, text=body_text) + with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport): + with pytest.raises(APIDeploymentsClientException) as caught: + _platform_client().whoami() + assert "401" in str(caught.value), label + + +def test_a_transport_failure_arrives_as_the_requests_exception(monkeypatch): + """`APIDeploymentsClient` routes every request through + `_translate_transport_errors` so callers catch the `requests` classes they + document. Calling the generated `sync_detailed` directly would let raw + `httpx.ConnectError` escape, contradicting the module docstring. + """ + transport = MagicMock() + transport.request.side_effect = httpx.ConnectError("nope") + with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport): + with pytest.raises(ConnectionError): + _platform_client().whoami() + + +def test_the_platform_key_is_read_per_request_not_captured(): + """`AuthenticatedClient` bakes its auth header on first use, so a key + reassigned after the transport was built would silently keep sending the + old one. `_send` sets the header per call for exactly this reason. + """ + identity = { + "organization_id": "org-a", + "organization_name": "Org A", + "permission": "read", + "key_name": "k", + } + client = _platform_client(api_key="pk-first") + transport = MagicMock() + transport.request.return_value = _httpx_response(200, identity) + with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport): + client.whoami() + first = transport.request.call_args.kwargs["headers"]["Authorization"] + client.api_key = "pk-second" + client.whoami() + second = transport.request.call_args.kwargs["headers"]["Authorization"] + + assert first == "Bearer pk-first" + assert second == "Bearer pk-second" + + +def test_close_releases_the_pool_and_the_next_call_rebuilds_it(): + """Nothing else releases the transport's sockets, and the CLI builds one + client per job.""" + client = _platform_client() + inner = MagicMock() + with patch.object(AuthenticatedClient, "get_httpx_client", return_value=inner): + assert client._transport is not None + client.close() + inner.close.assert_called_once() + assert client._transport_client is None + # Safe twice, and idempotent. + client.close() + + +def test_the_platform_client_is_a_context_manager(): + with patch.object( + AuthenticatedClient, "get_httpx_client", return_value=MagicMock() + ): + with _platform_client() as client: + assert client._transport is not None + assert client._transport_client is None + + +def test_both_clients_are_reachable_from_the_package_root(): + """A class only importable from the private module is not a published + surface, and the sibling is re-exported.""" + from unstract import api_deployments as pkg + + assert pkg.PlatformKeyClient is PlatformKeyClient + assert pkg.APIDeploymentsClient is APIDeploymentsClient + + # The models these operations answer with, so a caller can type a response + # without importing from the generated tree. + assert pkg.WhoAmIResponse is WhoAmIResponse + assert pkg.PaginatedAPIDeploymentSummaryList is PaginatedAPIDeploymentSummaryList + assert issubclass(pkg.APIDeploymentError, pkg.UnstractError) + assert issubclass(pkg.PlatformClientError, pkg.UnstractError) + + +def test_the_generated_platform_models_read_the_bodies_the_server_sends(): + """`PlatformKeyClient` reads bodies itself, so nothing else here would + notice a generated model that silently lost a field. They are still public + surface for anyone importing them, and the parity tests above cover only the + deployment models. + """ + from unstract.api_deployments._sdk_docstudio.models import ( + APIDeploymentSummary, + PaginatedAPIDeploymentSummaryList, + PlatformKeyError, + WhoAmIResponse, + ) + + # Only the `Literal` alias is re-exported from `models`; the value set and + # the validator live in the submodule. + from unstract.api_deployments._sdk_docstudio.models.api_key_permission import ( + API_KEY_PERMISSION_VALUES, + check_api_key_permission, + ) + + identity = WhoAmIResponse.from_dict( + { + "organization_id": "org-a", + "organization_name": "Org A", + "permission": "read", + "key_name": "cli-key", + } + ) + assert identity.organization_id == "org-a" + assert identity.organization_name == "Org A" + assert identity.key_name == "cli-key" + # The spec declares this a ChoiceField, and this generator renders such a + # field as a `Literal` alias plus a validator -- not an Enum class. So the + # value stays a plain string and the tier names are pinned separately. + assert identity.permission == "read" + assert API_KEY_PERMISSION_VALUES == {"read", "read_write", "full_access"} + assert check_api_key_permission("read_write") == "read_write" + with pytest.raises(TypeError): + check_api_key_permission("superuser") + assert not identity.additional_properties + + # The middleware's own shape: a bare message, never the handler envelope. + refusal = PlatformKeyError.from_dict({"message": "the reason"}) + assert refusal.message == "the reason" + + page = PaginatedAPIDeploymentSummaryList.from_dict(_deployment_page()) + assert page.count == 1 + assert page.next_ is None + row = page.results[0] + assert isinstance(row, APIDeploymentSummary) + assert row.api_name == "invoice-parser" + assert row.is_active is True + + +def test_the_generated_platform_parsers_read_a_declared_response(): + """The parsers are what a caller reaching for `sync_detailed` gets, and this + PR generated two of them. Exercised directly rather than through the facade, + which reads the body itself. + """ + from unstract.api_deployments._sdk_docstudio.api.deployment import list_deployments + from unstract.api_deployments._sdk_docstudio.api.identity import whoami + from unstract.api_deployments._sdk_docstudio.models import ( + PaginatedAPIDeploymentSummaryList, + PlatformKeyError, + WhoAmIResponse, + ) + + identity_body = { + "organization_id": "org-a", + "organization_name": "Org A", + "permission": "read_write", + "key_name": "k", + } + client = AuthenticatedClient(base_url="https://example.unstract.com", token="pk") + + parsed = whoami._parse_response( + client=client, response=_httpx_response(200, identity_body) + ) + assert isinstance(parsed, WhoAmIResponse) + assert parsed.organization_id == "org-a" + + refused = whoami._parse_response( + client=client, response=_httpx_response(401, {"message": "nope"}) + ) + assert isinstance(refused, PlatformKeyError) + assert refused.message == "nope" + + listing = list_deployments._parse_response( + client=client, response=_httpx_response(200, _deployment_page()) + ) + assert isinstance(listing, PaginatedAPIDeploymentSummaryList) + assert listing.results[0].api_name == "invoice-parser" + + +def test_the_generated_parsers_raise_on_an_undeclared_error_body(): + """The reason `PlatformKeyClient` does not use them. `from_dict` indexes + required keys with no default and `response.json()` is unguarded, so a + gateway's HTML 401 or a DRF-shaped body reaches a caller of `sync_detailed` + as an exception rather than a refusal. Pinned so the facade's decision to + read the body itself stays justified rather than looking arbitrary. + """ + from unstract.api_deployments._sdk_docstudio.api.identity import whoami + + client = AuthenticatedClient(base_url="https://example.unstract.com", token="pk") + + with pytest.raises(ValueError): + whoami._parse_response( + client=client, response=httpx.Response(401, text="401") + ) + with pytest.raises(KeyError): + whoami._parse_response( + client=client, response=_httpx_response(401, {"detail": "Invalid token."}) + ) + + +def test_the_generated_request_builders_still_return_what_the_facade_splats(): + """The facade builds requests from the generated `_get_kwargs`, which is + private to the generator. A generator upgrade that renames it or changes + the keys it returns has to fail here rather than at a customer's call.""" + from unstract.api_deployments._sdk_docstudio.api.deployment import ( + list_deployments as list_deployments_op, + ) + from unstract.api_deployments._sdk_docstudio.api.identity import ( + whoami as whoami_op, + ) + + identity_kwargs = whoami_op._get_kwargs() + assert identity_kwargs["method"] == "get" + assert identity_kwargs["url"] == "/api/v1/unstract/whoami/" + + listing_kwargs = list_deployments_op._get_kwargs("org-a", api_name="x") + assert listing_kwargs["method"] == "get" + assert "/org-a/" in listing_kwargs["url"] + assert listing_kwargs["params"] == {"api_name": "x"} + # Unset parameters are dropped rather than sent as a sentinel. + assert list_deployments_op._get_kwargs("org-a")["params"] == {} + + +def test_a_platform_request_carries_the_current_key_over_a_real_transport(): + """Asserted on the request as httpx composed it, not on a mock's recorded + kwargs: `AuthenticatedClient` bakes a header of its own at construction, so + which one wins is httpx's merge behaviour rather than this client's.""" + seen = [] + + def handler(request): + seen.append(request) + return httpx.Response(200, json=_identity_body()) + + client = _platform_client() + transport = client._transport + transport.get_httpx_client()._transport = httpx.MockTransport(handler) + + client.whoami() + assert seen[-1].headers["Authorization"] == "Bearer pk-test" + + client.api_key = "pk-rotated" + client.whoami() + assert seen[-1].headers["Authorization"] == "Bearer pk-rotated" + assert str(seen[-1].url).endswith("/api/v1/unstract/whoami/") + + +def test_closing_the_platform_client_releases_the_pool_and_the_next_call_rebuilds(): + """The mock-based check cannot see a pool that was dropped rather than + closed, nor that the rebuild path still works.""" + client = _platform_client() + httpx_client = client._transport.get_httpx_client() + client.close() + assert httpx_client.is_closed + assert client._transport_client is None + client.close() + assert client._transport.get_httpx_client() is not httpx_client + client.close() + + +def test_the_platform_transport_is_built_once_under_contention(): + """Both the wrapper and the pool inside it are built under the lock; a pool + built twice leaves one holding sockets that nothing closes.""" + client = _platform_client() + barrier = threading.Barrier(8) + seen = [] + + def build(): + barrier.wait() + seen.append(client._transport) + + threads = [threading.Thread(target=build) for _ in range(8)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert len({id(transport) for transport in seen}) == 1 + client.close() + + +def test_the_platform_client_retries_a_retryable_status(): + """Two idempotent GETs, and the README promises those are always retried. + Without this the sibling retries and this client does not.""" + replies = [ + _httpx_response(503, None), + _httpx_response(503, None), + _httpx_response(200, _identity_body()), + ] + transport = MagicMock() + transport.request.side_effect = replies + with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport): + result = _platform_client(max_retries=2, initial_delay=0.01).whoami() + + assert result == _identity_body() + assert transport.request.call_count == 3 + + +def test_the_platform_transport_uses_the_settings_it_was_given(): + """A dropped `verify_ssl` disables certificate checking silently.""" + client = _platform_client(transport_timeout=7.5, verify=False) + httpx_client = client._transport.get_httpx_client() + assert httpx_client.timeout.connect == 7.5 + assert client._transport_client._verify_ssl is False + client.close() + + +def test_a_json_body_that_is_not_an_object_is_refused(): + """Nothing else checks the shape, so a bare list would reach the caller as + a `dict` and fail on their first subscript instead of here.""" + with _platform_reply(200, [{"id": 1}]): + with pytest.raises(APIDeploymentsClientException) as caught: + _platform_client().list_deployments("org-a") + assert "list" in str(caught.value) + + +def test_a_2xx_body_that_is_not_json_reports_what_arrived(): + """An SSO or maintenance page answering 200 is the everyday cause, and the + status alone does not distinguish it from a wrong host.""" + response = httpx.Response( + 200, + text="login", + headers={"content-type": "text/html"}, + request=httpx.Request("GET", "https://example.unstract.com/"), + ) + transport = MagicMock() + transport.request.return_value = response + with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport): + with pytest.raises(APIDeploymentsClientException) as caught: + _platform_client().whoami() + message = str(caught.value) + assert "text/html" in message + assert "login" in message + + +def test_an_unfiltered_listing_sends_no_filters_at_all(): + """The builder renders `workflow` with `str()` before it drops the + parameters that are None, so a None default reaches the server as the + literal string "None" -- a filter matching no workflow, on every otherwise + unfiltered call.""" + with _platform_reply(200, _deployment_page()) as transport: + _platform_client().list_deployments("org-a") + + assert transport.request.call_args.kwargs["params"] == {} + + with _platform_reply(200, _deployment_page()) as transport: + _platform_client().list_deployments( + "org-a", workflow="22222222-2222-2222-2222-222222222222" + ) + + params = transport.request.call_args.kwargs["params"] + assert params == {"workflow": "22222222-2222-2222-2222-222222222222"} + + +def test_a_close_during_a_call_arrives_as_the_documented_exception(): + """httpx answers a send on a closed client with a bare RuntimeError, which + is not in the subtree the translator covers. A caller catching the + documented `requests` classes would not catch it.""" + client = _platform_client() + httpx_client = client._transport.get_httpx_client() + + def close_then_send(*args, **kwargs): + httpx_client.close() + return original(*args, **kwargs) + + original = httpx_client.request + with patch.object(httpx_client, "request", side_effect=close_then_send): + with pytest.raises(ConnectionError): + client.whoami() + + +def test_an_unreadable_body_is_bounded_in_the_log_as_well_as_the_error(): + """The error truncates it and the log did not, so the default level was the + wider disclosure of the two.""" + body = "x" * 4000 + response = httpx.Response( + 200, + text=body, + headers={"content-type": "text/plain"}, + request=httpx.Request("GET", "https://example.unstract.com/"), + ) + transport = MagicMock() + transport.request.return_value = response + with caplog_at_error() as records: + with patch.object( + AuthenticatedClient, "get_httpx_client", return_value=transport + ): + with pytest.raises(APIDeploymentsClientException): + _platform_client().whoami() + + logged = "".join(record.getMessage() for record in records) + assert "x" in logged + assert len(logged) < len(body) + + +def test_a_whitespace_organisation_is_refused_before_the_request(): + """It is not empty, so the emptiness check passed it, and `quote` then + encoded it into the path as %20 segments.""" + with pytest.raises(APIDeploymentsClientException) as caught: + _platform_client().list_deployments(" ") + assert "whoami()" in str(caught.value) + + +def test_an_empty_organisation_is_refused_before_the_request(): + """An empty segment builds a path the router answers for something else.""" + with pytest.raises(APIDeploymentsClientException) as caught: + _platform_client().list_deployments("") + assert "whoami()" in str(caught.value) + + +def test_a_path_on_the_base_url_is_discarded_and_the_drop_is_reported(caplog): + """Discarding it is deliberate -- these operations carry their own paths, and + a pasted deployment URL would otherwise build one no deployment serves. But + an install served under a path prefix becomes unreachable that way, and a + bare 404 from the proxy does not say so.""" + client = _platform_client( + base_url="https://example.unstract.com/deployment/api/x/y/" + ) + assert client.base_url == "https://example.unstract.com" + + with caplog.at_level(logging.WARNING): + _platform_client( + base_url="https://internal.corp/unstract/", logging_level="WARNING" + ) + assert "/unstract/" in caplog.text + + +def test_the_published_exception_name_still_catches_both_clients(): + """It is the name callers already catch, so it has to stay the widest one.""" + assert APIDeploymentsClientException is UnstractError + assert issubclass(APIDeploymentError, UnstractError) + assert issubclass(PlatformClientError, UnstractError) + + with _platform_reply(401, {"message": "bad key"}): + with pytest.raises(APIDeploymentsClientException): + _platform_client().whoami() + + with pytest.raises(APIDeploymentsClientException): + _client(api_url="https://example.com/").check_execution_status("") + + +def test_the_exception_carries_its_message(): + """The released class accepted a message and dropped it, leaving `str()` + working only through `BaseException.args`.""" + error = UnstractError("something went wrong") + assert str(error) == "something went wrong" + assert error.args == ("something went wrong",) + + +def test_the_two_clients_do_not_share_a_logger(): + """`APIDeploymentsClient.logger` is the module logger. Levelling that one + from here would re-level a live instance of the sibling, turning its debug + output -- which includes response bodies -- on or off as a side effect.""" + deployment = _client(logging_level="DEBUG") + assert deployment.logger.level == logging.DEBUG + + platform = _platform_client(logging_level="ERROR") + assert platform.logger is not deployment.logger + assert deployment.logger.level == logging.DEBUG + assert platform.logger.level == logging.ERROR diff --git a/tools/gen_sdk.sh b/tools/gen_sdk.sh index ac1c4cf..6af0723 100755 --- a/tools/gen_sdk.sh +++ b/tools/gen_sdk.sh @@ -21,8 +21,8 @@ # both this script and the drift gate report clean either way. # # SPEC_SOURCE: Zipstack/unstract specs/docstudio-oss.json -# @ eddd4b746765c77a3d6f64b428fd35d2261e60e7 -# sha256 e453d4f7444d3757a24a1da73373b11c3d362ceb2d7e13e8658a5b3c068b86f5 +# @ 520b98d7acf5a6d138b24d9787bd788b89150b76 +# sha256 68a31eaf72e54daf8173ae3ef42e4174b257c93b1afc608114582a81be135bd1 set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"