From db20a257c0b06ab0d5e27dbc6a759c24d764051d Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Thu, 10 Sep 2026 15:04:32 +0530 Subject: [PATCH 1/4] feat: pick up the whoami and deployment-listing operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the `spec-upgrade` pipeline against Zipstack/unstract main at 520b98d7a, which merged the two operations the CLI needs (unstract#2269 and unstract#2278). The spec is copied byte-for-byte and `SPEC_SOURCE` moves with it in this same commit — revision and sha256 both — so a current copy stays distinguishable from one the backend has moved past. I verified the *previous* record before moving it: the vendored file matched its recorded sha256 and was byte-identical to the backend at `eddd4b746`, so this upgrade starts from an honest baseline. Regeneration is purely additive: new `api/identity/whoami.py` and `api/deployment/list_deployments.py`, six new models, and prose-only changes to `execute`/`status` where #2278 reworded the descriptions. No operation, field or model was removed, so this is a **minor** bump rather than a major one. The generator exited clean, and regenerating a second time produces byte-identical output, so `sdk-drift` will pass. **The new facade class.** `whoami` and `list_deployments` both authenticate with a platform key, and neither fits `APIDeploymentsClient`: that class takes a *deployment* URL and derives an organisation and API name from its last two segments, which `whoami` has neither of. So `PlatformAPIClient` sits alongside it, sharing the generated transport — both schemes are HTTP bearer, only the token differs — and raising the same exception type. Folding them together would have meant a class whose required `api_url` is meaningless for half its methods. Without this the operations are generated but unreachable, and `unstract-cli` keeps reaching into `unstract.clone.PlatformClient` — the org-cloning tool's hand-written admin client — which is how the CLI drifted off the generated surface to begin with. **A defect the new tests caught.** `_error_text` fell back to `response.text`, but the generated `Response` is an attrs wrapper carrying `.content`, and `parsed` is a model instance rather than a mapping. A 401 through the new client would have raised `AttributeError` while trying to report the refusal. Both halves are fixed; the `_error_text` change is additive, so httpx callers are unaffected. **Test coverage.** `test_every_declared_operation_is_wrapped` fired exactly as designed when the spec grew. Extending `WRAPPED_OPERATIONS` was not the right answer, though: `whoami` declares no `ErrorResponse`, so that suite's "both families are in play" assertion is false for it by construction, and `_declared_responses` indexes `content` unconditionally, which its bodyless 500 would `KeyError` on. A parallel `PLATFORM_OPERATIONS` manifest carries its own status pins and error-reporting coverage, and the whole-set comparison now unions the two — so an operation belonging to neither still fails there, which is the property that test exists for. `__version__` and the compat baseline are deliberately untouched: the release workflow reads the former as the last released version and applies the bump at dispatch, and a spec upgrade is not a reason to move the parity reference point. 430 tests pass, up from 419. `ruff check` and `format --check` clean on everything this touches. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y7U4ggFchu91zKYcRxFRNZ --- specs/docstudio-oss.json | 363 +++++++++++++++++- .../_sdk_docstudio/api/deployment/execute.py | 20 +- .../api/deployment/list_deployments.py | 309 +++++++++++++++ .../_sdk_docstudio/api/deployment/status.py | 44 ++- .../_sdk_docstudio/api/identity/__init__.py | 2 + .../_sdk_docstudio/api/identity/whoami.py | 163 ++++++++ .../_sdk_docstudio/models/__init__.py | 14 + .../models/api_deployment_summary.py | 220 +++++++++++ ...oyment_summary_last_5_run_statuses_item.py | 48 +++ .../models/api_key_permission.py | 18 + .../paginated_api_deployment_summary_list.py | 126 ++++++ .../models/platform_key_error.py | 69 ++++ .../models/who_am_i_response.py | 92 +++++ src/unstract/api_deployments/client.py | 195 +++++++++- tests/test_compat.py | 202 +++++++++- tools/gen_sdk.sh | 4 +- 16 files changed, 1853 insertions(+), 36 deletions(-) create mode 100644 src/unstract/api_deployments/_sdk_docstudio/api/deployment/list_deployments.py create mode 100644 src/unstract/api_deployments/_sdk_docstudio/api/identity/__init__.py create mode 100644 src/unstract/api_deployments/_sdk_docstudio/api/identity/whoami.py create mode 100644 src/unstract/api_deployments/_sdk_docstudio/models/api_deployment_summary.py create mode 100644 src/unstract/api_deployments/_sdk_docstudio/models/api_deployment_summary_last_5_run_statuses_item.py create mode 100644 src/unstract/api_deployments/_sdk_docstudio/models/api_key_permission.py create mode 100644 src/unstract/api_deployments/_sdk_docstudio/models/paginated_api_deployment_summary_list.py create mode 100644 src/unstract/api_deployments/_sdk_docstudio/models/platform_key_error.py create mode 100644 src/unstract/api_deployments/_sdk_docstudio/models/who_am_i_response.py 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/_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..2ab5ca2 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. + PlatformAPIClient: A class to read the account a platform API key belongs to + and the deployments in it. + APIDeploymentsClientException: A class to handle exceptions raised by both + client classes. + +The two clients take different credentials and are not interchangeable. A +deployment key runs one deployment 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 @@ -46,7 +53,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 @@ -164,7 +176,14 @@ def _error_text(body: Any, response) -> str: value = body.get(key) if isinstance(value, str) and value: return value - return (response.text or "").strip()[:_ERROR_TEXT_LIMIT] + # `.text` on an httpx response, `.content` on the generated `Response` + # wrapper -- which is an attrs class, not an httpx one, and carries only + # bytes. Without this the platform facade raises AttributeError while + # reporting a refusal, turning a 401 into a crash. + text = getattr(response, "text", None) + if text is None: + text = (getattr(response, "content", b"") or b"").decode("utf-8", "replace") + return (text or "").strip()[:_ERROR_TEXT_LIMIT] class APIDeploymentsClientException(Exception): @@ -923,3 +942,171 @@ def check_execution_status( ) return obj_to_return + + +class PlatformAPIClient: + """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. + + Both credentials are HTTP bearer, so the generated transport is shared. + """ + + def __init__( + self, + base_url: str, + api_key: str | None = None, + *, + verify: bool = True, + transport_timeout: float | None = None, + logging_level: str = "INFO", + ) -> 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, and the generated transport joins + them onto the origin. + api_key (str | None): Platform API key. Falls back to + ``$UNSTRACT_PLATFORM_KEY``, matching how `APIDeploymentsClient` + falls back for the deployment 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. + """ + self.logger = logging.getLogger(__name__) + 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: + raise APIDeploymentsClientException( + "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 APIDeploymentsClientException( + f"base_url must include a scheme and host, got {base_url!r}." + ) + self.base_url = parsed.scheme + "://" + parsed.netloc + + self.verify = verify + self.transport_timeout = transport_timeout + self._transport_client: AuthenticatedClient | None = None + self._transport_lock = threading.Lock() + + @property + def _transport(self) -> AuthenticatedClient: + """The HTTP client, built on first use and under a lock. + + Same reasoning as `APIDeploymentsClient._transport`: two threads racing + the first call would each build a pool and one would be dropped while + still holding its sockets. + """ + if self._transport_client is None: + with self._transport_lock: + if self._transport_client is None: + self._transport_client = AuthenticatedClient( + base_url=self.base_url, + token=self.api_key, + verify_ssl=self.verify, + timeout=httpx.Timeout(self.transport_timeout), + raise_on_unexpected_status=False, + follow_redirects=True, + ) + return self._transport_client + + def _parsed_or_raise(self, response, what: str) -> Any: + """The parsed body of a 2xx, or an exception naming why it was refused. + + `raise_on_unexpected_status` is off on the shared transport, so a + refusal arrives as an ordinary response. Without this every caller would + have to re-derive that check, and a 401 would read as an empty result. + """ + if not 200 <= response.status_code < 300: + # `parsed` is a generated model, and `_error_text` reads mappings; + # handed the model it would fall through to the raw body and drop + # the reason the endpoint actually sent. + body = response.parsed + if hasattr(body, "to_dict"): + body = body.to_dict() + raise APIDeploymentsClientException( + f"{what} failed with {response.status_code}: " + f"{_error_text(body, response)}" + ) + if response.parsed is None: + raise APIDeploymentsClientException( + f"{what} returned {response.status_code} with no readable body." + ) + return response.parsed + + def whoami(self) -> dict: + """The organisation this key belongs to, and the key's own scope. + + The organisation is read from the key row server-side, so this takes no + organisation argument -- resolving one is the point of the call. Use it + to obtain the ``org_id`` that `list_deployments` needs. + + Returns: + dict: ``organization_id``, ``organization_name``, ``permission`` + and ``key_name``. + """ + self.logger.debug("Resolving identity via /unstract/whoami/") + response = whoami.sync_detailed(client=self._transport) + return self._parsed_or_raise(response, "whoami").to_dict() + + def list_deployments( + self, + org_id: str, + *, + api_name: str | Unset = UNSET, + search: str | Unset = UNSET, + ordering: str | Unset = UNSET, + page: int | Unset = UNSET, + page_size: int | Unset = UNSET, + workflow: Any | Unset = UNSET, + ) -> dict: + """The API deployments in one organisation. + + The keyword arguments are the query parameters the endpoint accepts, + named as the API names them; one left unset is not sent, so the server + picks its own default. The result is paginated -- read ``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): Return only deployments of this workflow. + + Returns: + dict: ``count``, ``next``, ``previous`` and ``results``. + """ + self.logger.debug("Listing deployments for organisation: " + org_id) + response = list_deployments.sync_detailed( + org_id, + client=self._transport, + api_name=api_name, + search=search, + ordering=ordering, + page=page, + page_size=page_size, + workflow=workflow, + ) + return self._parsed_or_raise(response, "list_deployments").to_dict() diff --git a/tests/test_compat.py b/tests/test_compat.py index 8cd95fa..9d485ec 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -25,10 +25,12 @@ import inspect import io import json +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 +52,15 @@ ) from unstract import api_deployments +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, APIDeploymentsClient, APIDeploymentsClientException, + PlatformAPIClient, ) -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 +76,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 +1513,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 +1893,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 `PlatformAPIClient`. 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 +1906,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 +1914,180 @@ 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 _platform_client(**kwargs): + kwargs.setdefault("base_url", "https://example.unstract.com") + kwargs.setdefault("api_key", "pk-test") + kwargs.setdefault("logging_level", "ERROR") + return PlatformAPIClient(**kwargs) + + +@contextmanager +def _platform_reply(status_code, json_data): + """Answer the next generated request with this response. + + Patched at `get_httpx_client` rather than at `sync_detailed`, so the + generated parsing and model construction still run -- that is the layer a + regeneration changes, and mocking above it would test nothing about it. + """ + transport = MagicMock() + transport.request.return_value = _httpx_response(status_code, json_data) + with patch.object(AuthenticatedClient, "get_httpx_client", return_value=transport): + yield transport + + +@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: + if operation == "whoami": + _platform_client().whoami() + else: + _platform_client().list_deployments("org-a") + 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.""" + identity = { + "organization_id": "org-a", + "organization_name": "Org A", + "permission": "read", + "key_name": "cli-key", + } + with _platform_reply(200, identity) as transport: + result = _platform_client().whoami() + + assert result == identity + url = transport.request.call_args.kwargs["url"] + # No organisation segment: putting one there would defeat the point. + assert url.endswith("/api/v1/unstract/whoami/"), url + + +def test_list_deployments_sends_the_organisation_and_reads_the_page(): + page = { + "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": [], + } + ], + } + 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" + called = transport.request.call_args.kwargs + assert "/org-a/" in called["url"], called["url"] + assert called["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: + PlatformAPIClient(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 = PlatformAPIClient(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") + + +def test_a_path_on_the_base_url_is_discarded(): + """These operations carry their own paths; keeping a caller's would produce + a URL no deployment serves.""" + client = _platform_client( + base_url="https://example.unstract.com/deployment/api/x/y/" + ) + assert client.base_url == "https://example.unstract.com" 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)" From 3e9717ec6a60607dead3d3be7c918fb9d0bb94b2 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Thu, 10 Sep 2026 16:45:59 +0530 Subject: [PATCH 2/4] fix: give PlatformAPIClient the contract APIDeploymentsClient already has Six findings from the review on this PR. Five were real contract gaps and one was a missed export; all are pinned by tests that fail when the fix is reverted. **The body is read as JSON, not through the generated model.** This is the one that mattered. `sync_detailed` reaches `_parse_response`, which does `PlatformKeyError.from_dict(response.json())` on a 401 with no guard: a gateway answering 401 with HTML raises `JSONDecodeError`, and a DRF-shaped `{"detail": ...}` raises `KeyError: 'message'` -- both out of the generated parser, before this facade sees the response. So a rejected key crashed instead of being reported. The request is now issued from the generated `_get_kwargs` and the body read through `APIDeploymentsClient._read_body`, which is exactly why that helper exists. This is the same defect class as the `_error_text` fix in the previous commit. I fixed the half where reporting a refusal crashed and missed the half where building the model crashed first. **Transport failures are translated.** The class called `sync_detailed` directly, so an unreachable host raised raw `httpx.ConnectError` -- contradicting the module docstring this PR added, which promises the `requests` exception types callers catch. It now goes through `_send`, like every deployment-key request. **The credential is read per request.** `AuthenticatedClient` bakes its auth header on first use, so a key assigned after the transport was built kept sending the old one. `_send` sets the header per call. **`close`, `__enter__` and `__exit__`** -- pooled connections had nothing to release them, and the CLI builds one client per job. **Re-exported from the package root**, so it is reachable as `unstract.api_deployments.PlatformAPIClient` rather than only from the private module. Also corrected the `_error_text` comment from the previous commit: it described the platform facade raising AttributeError, which is no longer a path that exists now that both facades hand it an httpx response. 438 tests pass, up from 430. Five mutations killed: unguarded `response.json()`, dropping the transport translation, capturing the key with the transport, neutering `close`, and removing the re-export. The generated tree is untouched, so `sdk-drift` is unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y7U4ggFchu91zKYcRxFRNZ --- src/unstract/api_deployments/__init__.py | 1 + src/unstract/api_deployments/client.py | 93 +++++++++++++------ tests/test_compat.py | 110 ++++++++++++++++++++++- 3 files changed, 174 insertions(+), 30 deletions(-) diff --git a/src/unstract/api_deployments/__init__.py b/src/unstract/api_deployments/__init__.py index 08ac17a..6a4c528 100644 --- a/src/unstract/api_deployments/__init__.py +++ b/src/unstract/api_deployments/__init__.py @@ -1,6 +1,7 @@ __version__ = "1.6.0" from .client import APIDeploymentsClient as APIDeploymentsClient +from .client import PlatformAPIClient as PlatformAPIClient def get_sdk_version(): diff --git a/src/unstract/api_deployments/client.py b/src/unstract/api_deployments/client.py index 2ab5ca2..0343f62 100644 --- a/src/unstract/api_deployments/client.py +++ b/src/unstract/api_deployments/client.py @@ -176,10 +176,10 @@ def _error_text(body: Any, response) -> str: value = body.get(key) if isinstance(value, str) and value: return value - # `.text` on an httpx response, `.content` on the generated `Response` - # wrapper -- which is an attrs class, not an httpx one, and carries only - # bytes. Without this the platform facade raises AttributeError while - # reporting a refusal, turning a 401 into a crash. + # Both facades hand this an httpx response, which has `.text`. The + # `.content` fallback is for the generated `Response` wrapper -- an attrs + # class carrying only bytes -- so passing one here reports the reason + # instead of raising AttributeError on the way to reporting it. text = getattr(response, "text", None) if text is None: text = (getattr(response, "content", b"") or b"").decode("utf-8", "replace") @@ -953,7 +953,11 @@ class PlatformAPIClient: a platform key and address the account. Folding them together would mean a class whose required `api_url` is meaningless for half its methods. - Both credentials are HTTP bearer, so the generated transport is shared. + Everything else about the contract is deliberately the same as that class: + the request is issued through `_send`, so transport failures arrive as the + `requests` exception types callers already catch; the body is read as JSON + rather than through the generated response model; the credential is read per + request; and the pooled connections are released by `close`. """ def __init__( @@ -969,8 +973,8 @@ def __init__( 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, and the generated transport joins - them onto the origin. + operations carry their own, and the generated builders join them + onto the origin. api_key (str | None): Platform API key. Falls back to ``$UNSTRACT_PLATFORM_KEY``, matching how `APIDeploymentsClient` falls back for the deployment key. @@ -1014,6 +1018,9 @@ def _transport(self) -> AuthenticatedClient: Same reasoning as `APIDeploymentsClient._transport`: two threads racing the first call would each build a pool and one would be dropped while still holding its sockets. + + The token given here is not what authenticates a request -- `_send` + sets the header per call -- but `AuthenticatedClient` requires one. """ if self._transport_client is None: with self._transport_lock: @@ -1028,29 +1035,62 @@ def _transport(self) -> AuthenticatedClient: ) return self._transport_client - def _parsed_or_raise(self, response, what: str) -> Any: - """The parsed body of a 2xx, or an exception naming why it was refused. + def close(self) -> None: + """Release the pooled connections this client holds. + + As on `APIDeploymentsClient`: the transport is kept between calls so + connections are reused, nothing else releases its sockets, and a client + built per job would otherwise accumulate pools. Safe to call twice. + """ + with self._transport_lock: + transport, self._transport_client = self._transport_client, None + if transport is not None: + transport.get_httpx_client().close() + + def __enter__(self) -> "PlatformAPIClient": + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + self.close() + + def _send(self, method: str, url: str, **kwargs) -> httpx.Response: + """Issue one request, translating transport failures on the way out. + + The credential is read per request rather than captured with the + transport, so assigning ``api_key`` takes effect on the next call -- + `AuthenticatedClient` bakes its own header on first use, which would + otherwise pin whatever key the client was built with. + """ + kwargs["headers"] = { + **(kwargs.get("headers") or {}), + "Authorization": f"Bearer {self.api_key}", + } + return _translate_transport_errors( + self._transport.get_httpx_client().request, method, url, **kwargs + ) + + def _read_or_raise(self, response: httpx.Response, what: str) -> Any: + """The JSON body of a 2xx, or an exception naming why it was refused. - `raise_on_unexpected_status` is off on the shared transport, so a - refusal arrives as an ordinary response. Without this every caller would - have to re-derive that check, and a 401 would read as an empty result. + The body is read directly rather than through the generated response + model, for the reason `APIDeploymentsClient._read_body` gives: a model + is built only for the statuses the spec declares, and its `from_dict` + indexes required keys with no default. A gateway answering 401 with HTML, + or the API answering with a DRF-shaped ``{"detail": ...}``, would raise + `JSONDecodeError` or `KeyError` out of the generated parser -- before + this facade ever sees the response -- instead of reporting a refusal. """ + body = APIDeploymentsClient._read_body(response) if not 200 <= response.status_code < 300: - # `parsed` is a generated model, and `_error_text` reads mappings; - # handed the model it would fall through to the raw body and drop - # the reason the endpoint actually sent. - body = response.parsed - if hasattr(body, "to_dict"): - body = body.to_dict() raise APIDeploymentsClientException( f"{what} failed with {response.status_code}: " f"{_error_text(body, response)}" ) - if response.parsed is None: + if body is None: raise APIDeploymentsClientException( - f"{what} returned {response.status_code} with no readable body." + f"{what} returned {response.status_code} with a body that is not JSON." ) - return response.parsed + return body def whoami(self) -> dict: """The organisation this key belongs to, and the key's own scope. @@ -1064,8 +1104,9 @@ def whoami(self) -> dict: and ``key_name``. """ self.logger.debug("Resolving identity via /unstract/whoami/") - response = whoami.sync_detailed(client=self._transport) - return self._parsed_or_raise(response, "whoami").to_dict() + request_kwargs = whoami._get_kwargs() + response = self._send(**request_kwargs) + return self._read_or_raise(response, "whoami") def list_deployments( self, @@ -1099,9 +1140,8 @@ def list_deployments( dict: ``count``, ``next``, ``previous`` and ``results``. """ self.logger.debug("Listing deployments for organisation: " + org_id) - response = list_deployments.sync_detailed( + request_kwargs = list_deployments._get_kwargs( org_id, - client=self._transport, api_name=api_name, search=search, ordering=ordering, @@ -1109,4 +1149,5 @@ def list_deployments( page_size=page_size, workflow=workflow, ) - return self._parsed_or_raise(response, "list_deployments").to_dict() + response = self._send(**request_kwargs) + return self._read_or_raise(response, "list_deployments") diff --git a/tests/test_compat.py b/tests/test_compat.py index 9d485ec..4b8d212 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -2025,9 +2025,14 @@ def test_whoami_returns_the_four_fields_the_spec_declares(): result = _platform_client().whoami() assert result == identity - url = transport.request.call_args.kwargs["url"] + # 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(): @@ -2060,9 +2065,9 @@ def test_list_deployments_sends_the_organisation_and_reads_the_page(): assert result["count"] == 1 assert result["results"][0]["api_name"] == "invoice-parser" - called = transport.request.call_args.kwargs - assert "/org-a/" in called["url"], called["url"] - assert called["params"]["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(): @@ -2091,3 +2096,100 @@ def test_a_path_on_the_base_url_is_discarded(): base_url="https://example.unstract.com/deployment/api/x/y/" ) assert client.base_url == "https://example.unstract.com" + + +# The findings below were raised on PR #29 and each fix is pinned here, so a +# regression to the pre-review behaviour fails rather than passing quietly. + + +@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.PlatformAPIClient is PlatformAPIClient + assert pkg.APIDeploymentsClient is APIDeploymentsClient From 67e01976c0e0ee327249070d71a36ec90d5da1d8 Mon Sep 17 00:00:00 2001 From: pk-zipstack Date: Thu, 10 Sep 2026 17:05:44 +0530 Subject: [PATCH 3/4] test: cover the generated parsers the facade deliberately bypasses Greptile, on PR #29. Two real problems, and the first is mine twice over. **A stale docstring.** `_platform_reply` claimed that patching at `get_httpx_client` meant "the generated parsing and model construction still run". That was true of the first commit, which called `sync_detailed`. The review fixes moved the facade to `_get_kwargs()` plus its own body read, and the sentence survived the change it described -- the same failure mode as the `openapi_schema` docstring this PR's backend counterpart had to fix. **And the coverage the sentence was standing in for did not exist.** With the facade reading bodies itself, nothing exercised `whoami._parse_response`, `list_deployments._parse_response`, or any of the six new models. A regeneration that broke them would have passed this suite. Added, exercised directly rather than through the facade: - every field of `WhoAmIResponse`, `PlatformKeyError` and the paginated listing, including the nested `APIDeploymentSummary` row; - both new `_parse_response` functions, on a declared 200 and a declared 401; - and the reason the facade does not use them -- a gateway's HTML 401 raises `ValueError` and a DRF-shaped body raises `KeyError` out of the parser. Pinning that keeps the facade's decision justified instead of looking arbitrary. The tier field is a correction too: the spec declares a ChoiceField, and I had described that as giving the client "a real enum". This generator renders it as a `Literal` alias plus a `check_api_key_permission` validator, so the value stays a plain string. The test now asserts what is actually emitted, and exercises the validator in both directions. `_deployment_page()` is shared between the facade test and the model tests: a row that satisfied one and not the other would prove nothing about either. 441 tests pass, up from 438. Two mutations killed to confirm the new coverage is real -- a model reading the wrong key, and a parser dropping its 401 branch. An earlier mutation of mine (adding a default to a required `d.pop`) survived because the test supplies the field, so it was equivalent rather than a miss. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y7U4ggFchu91zKYcRxFRNZ --- tests/test_compat.py | 186 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 159 insertions(+), 27 deletions(-) diff --git a/tests/test_compat.py b/tests/test_compat.py index 4b8d212..98ee284 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1960,6 +1960,40 @@ def _platform_declared(operation_id: str) -> dict[int, str | None]: 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 _platform_client(**kwargs): kwargs.setdefault("base_url", "https://example.unstract.com") kwargs.setdefault("api_key", "pk-test") @@ -1971,9 +2005,15 @@ def _platform_client(**kwargs): def _platform_reply(status_code, json_data): """Answer the next generated request with this response. - Patched at `get_httpx_client` rather than at `sync_detailed`, so the - generated parsing and model construction still run -- that is the layer a - regeneration changes, and mocking above it would test nothing about it. + 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: `PlatformAPIClient` 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) @@ -2036,30 +2076,7 @@ def test_whoami_returns_the_four_fields_the_spec_declares(): def test_list_deployments_sends_the_organisation_and_reads_the_page(): - page = { - "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": [], - } - ], - } + page = _deployment_page() with _platform_reply(200, page) as transport: result = _platform_client().list_deployments("org-a", api_name="invoice-parser") @@ -2193,3 +2210,118 @@ def test_both_clients_are_reachable_from_the_package_root(): assert pkg.PlatformAPIClient is PlatformAPIClient assert pkg.APIDeploymentsClient is APIDeploymentsClient + + +def test_the_generated_platform_models_read_the_bodies_the_server_sends(): + """`PlatformAPIClient` 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 `PlatformAPIClient` 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."}) + ) From 8768db3fe6715fea793645ac457b6978364159dc Mon Sep 17 00:00:00 2001 From: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:12:44 +0530 Subject: [PATCH 4/4] refactor: fold the platform client into a shared facade base (#30) PR #29 gave the platform client its own copy of the transport: the pool, the close/reopen handling and the httpx-to-requests exception translation were duplicated from APIDeploymentsClient, and the copy had no retry policy at all. Two copies of that code drift; only one of them was getting fixes. Both clients now inherit `_HttpxFacade`, which owns the lazily built pooled transport, `close`/context-manager support, the exception translation and the retry-with-Retry-After policy. A client subclass supplies its error class and its methods, nothing else. That drops ~184 duplicated lines and gives the platform operations the retry behaviour the README already promised. Three defects fall out of sharing the code: - The transport pool is now built inside the lock. `AuthenticatedClient .get_httpx_client()` builds lazily and unsynchronised, so publishing the client before warming it let two threads build two pools. - A close during flight no longer escapes untranslated. httpx answers a send on a closed client with a bare `RuntimeError`, which is not in the subtree `_translate_transport_errors` covers, so it reached callers catching the documented `requests` types. It is translated at the send. - `list_deployments` no longer sends `workflow=None`. The generated builder renders that parameter with `str()` before it filters `None` out, so the literal string "None" went on the wire as a filter matching no workflow on every otherwise unfiltered call. Unset filters are omitted instead, which also holds if the generator special-cases another parameter later. Exceptions get a hierarchy. `APIDeploymentsClientException` never worked -- its `__init__` nested three more `def`s that were never bound to the class, so `message` was dropped and `Exception.__init__` was never called, leaving `str(e)` empty and the documented `error_message()` non-existent. It is now an alias of a new `UnstractError` base, with `APIDeploymentError` and `PlatformClientError` beneath it. Catching the old name still catches both clients, including anything added later. Also here: - The generated models are re-exported, so callers who want typing can `WhoAmIResponse.from_dict(...)` instead of us hand-writing a mirror of the spec that regeneration would not update. Facade methods keep returning `dict[str, Any]`. - The platform client gets its own logger. Both clients shared the module logger, so levelling one re-levelled the other, switching a live sibling's debug output -- which includes response bodies -- on or off as a side effect. - A 2xx body that is unreadable, or JSON that is not an object, is now an error naming what arrived rather than an `AttributeError` downstream. The ERROR log for it is bounded to the same excerpt the exception carries. - An `org_id` that is empty or blank is refused before the request, and a path on `base_url` is warned about rather than silently discarded by `urljoin`. - `.claude/skills/spec-upgrade/SKILL.md` step 5 gains the recipe for adding an operation: which class it belongs to, the method shape, and why it builds from `_get_kwargs` rather than `sync_detailed`. No runtime breaking change. The one visible shift is `type(e).__name__`, which becomes `APIDeploymentError` where it was `APIDeploymentsClientException`; `except APIDeploymentsClientException` is unaffected. Claude-Session: https://claude.ai/code/session_01CnhmFYFRBrK376tZyDtwUM Co-authored-by: Claude Opus 5 (1M context) --- .claude/skills/spec-upgrade/SKILL.md | 31 +- README.md | 39 +- src/unstract/api_deployments/__init__.py | 17 +- src/unstract/api_deployments/client.py | 608 +++++++++++------------ tests/test_compat.py | 371 ++++++++++++-- 5 files changed, 722 insertions(+), 344 deletions(-) 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/src/unstract/api_deployments/__init__.py b/src/unstract/api_deployments/__init__.py index 6a4c528..b0fe340 100644 --- a/src/unstract/api_deployments/__init__.py +++ b/src/unstract/api_deployments/__init__.py @@ -1,7 +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 PlatformAPIClient as PlatformAPIClient +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/client.py b/src/unstract/api_deployments/client.py index 0343f62..df57093 100644 --- a/src/unstract/api_deployments/client.py +++ b/src/unstract/api_deployments/client.py @@ -3,14 +3,14 @@ Classes: APIDeploymentsClient: A class to invoke APIs deployed on the Unstract platform. - PlatformAPIClient: A class to read the account a platform API key belongs to + PlatformKeyClient: A class to read the account a platform API key belongs to and the deployments in it. - APIDeploymentsClientException: A class to handle exceptions raised by both - client classes. + 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 one deployment and cannot describe the account; a platform -key describes the account and lists what is in it but cannot run anything. That +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. """ @@ -20,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 @@ -125,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." ) @@ -176,28 +177,24 @@ def _error_text(body: Any, response) -> str: value = body.get(key) if isinstance(value, str) and value: return value - # Both facades hand this an httpx response, which has `.text`. The - # `.content` fallback is for the generated `Response` wrapper -- an attrs - # class carrying only bytes -- so passing one here reports the reason - # instead of raising AttributeError on the way to reporting it. - text = getattr(response, "text", None) - if text is None: - text = (getattr(response, "content", b"") or b"").decode("utf-8", "replace") - return (text or "").strip()[:_ERROR_TEXT_LIMIT] + 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.""" - def __init__(self, message): - def __init__(self, value): - self.value = value - def __str__(self): - return repr(self.value) +class APIDeploymentError(UnstractError): + """Raised by :class:`APIDeploymentsClient`.""" - def error_message(self): - return self.value + +class PlatformClientError(UnstractError): + """Raised by :class:`PlatformKeyClient`.""" + + +#: 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): @@ -246,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. @@ -341,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, @@ -379,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: @@ -394,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. @@ -470,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): @@ -588,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], @@ -687,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=[ @@ -944,7 +964,7 @@ def check_execution_status( return obj_to_return -class PlatformAPIClient: +class PlatformKeyClient(_HttpxFacade): """Read the account a platform API key belongs to, and its deployments. Separate from `APIDeploymentsClient` because the credential and the URL @@ -953,13 +973,13 @@ class PlatformAPIClient: a platform key and address the account. Folding them together would mean a class whose required `api_url` is meaningless for half its methods. - Everything else about the contract is deliberately the same as that class: - the request is issued through `_send`, so transport failures arrive as the - `requests` exception types callers already catch; the body is read as JSON - rather than through the generated response model; the credential is read per - request; and the pooled connections are released by `close`. + 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, @@ -968,30 +988,42 @@ def __init__( 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, and the generated builders join them - onto the origin. + ``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``, matching how `APIDeploymentsClient` - falls back for the deployment key. + ``$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. """ - self.logger = logging.getLogger(__name__) + # 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: - raise APIDeploymentsClientException( + if not self.api_key.strip(): + raise PlatformClientError( "A platform API key is required: pass api_key or set " "$UNSTRACT_PLATFORM_KEY." ) @@ -1001,130 +1033,87 @@ def __init__( parsed = urlparse(base_url) if not parsed.scheme or not parsed.netloc: - raise APIDeploymentsClientException( + 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() - @property - def _transport(self) -> AuthenticatedClient: - """The HTTP client, built on first use and under a lock. - - Same reasoning as `APIDeploymentsClient._transport`: two threads racing - the first call would each build a pool and one would be dropped while - still holding its sockets. - - The token given here is not what authenticates a request -- `_send` - sets the header per call -- but `AuthenticatedClient` requires one. - """ - if self._transport_client is None: - with self._transport_lock: - if self._transport_client is None: - self._transport_client = AuthenticatedClient( - base_url=self.base_url, - token=self.api_key, - verify_ssl=self.verify, - timeout=httpx.Timeout(self.transport_timeout), - raise_on_unexpected_status=False, - follow_redirects=True, - ) - return self._transport_client - - def close(self) -> None: - """Release the pooled connections this client holds. - - As on `APIDeploymentsClient`: the transport is kept between calls so - connections are reused, nothing else releases its sockets, and a client - built per job would otherwise accumulate pools. Safe to call twice. - """ - with self._transport_lock: - transport, self._transport_client = self._transport_client, None - if transport is not None: - transport.get_httpx_client().close() - - def __enter__(self) -> "PlatformAPIClient": - return self - - def __exit__(self, exc_type, exc_value, traceback) -> None: - self.close() - - def _send(self, method: str, url: str, **kwargs) -> httpx.Response: - """Issue one request, translating transport failures on the way out. + 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. - The credential is read per request rather than captured with the - transport, so assigning ``api_key`` takes effect on the next call -- - `AuthenticatedClient` bakes its own header on first use, which would - otherwise pin whatever key the client was built with. + Read directly rather than through the generated model, which is built + only for the statuses the spec declares and raises on any other body. """ - kwargs["headers"] = { - **(kwargs.get("headers") or {}), - "Authorization": f"Bearer {self.api_key}", - } - return _translate_transport_errors( - self._transport.get_httpx_client().request, method, url, **kwargs - ) - - def _read_or_raise(self, response: httpx.Response, what: str) -> Any: - """The JSON body of a 2xx, or an exception naming why it was refused. - - The body is read directly rather than through the generated response - model, for the reason `APIDeploymentsClient._read_body` gives: a model - is built only for the statuses the spec declares, and its `from_dict` - indexes required keys with no default. A gateway answering 401 with HTML, - or the API answering with a DRF-shaped ``{"detail": ...}``, would raise - `JSONDecodeError` or `KeyError` out of the generated parser -- before - this facade ever sees the response -- instead of reporting a refusal. - """ - body = APIDeploymentsClient._read_body(response) + body = self._read_body(response) + self.logger.debug("%s returned %d", what, response.status_code) if not 200 <= response.status_code < 300: - raise APIDeploymentsClientException( + raise self._error_class( f"{what} failed with {response.status_code}: " f"{_error_text(body, response)}" ) if body is None: - raise APIDeploymentsClientException( - f"{what} returned {response.status_code} with a body that is not JSON." + 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: + def whoami(self) -> dict[str, Any]: """The organisation this key belongs to, and the key's own scope. - The organisation is read from the key row server-side, so this takes no - organisation argument -- resolving one is the point of the call. Use it - to obtain the ``org_id`` that `list_deployments` needs. - Returns: dict: ``organization_id``, ``organization_name``, ``permission`` - and ``key_name``. + and ``key_name``, the shape `WhoAmIResponse` models. Use the + ``organization_id`` as the ``org_id`` other operations take. """ - self.logger.debug("Resolving identity via /unstract/whoami/") - request_kwargs = whoami._get_kwargs() - response = self._send(**request_kwargs) + 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 | Unset = UNSET, - search: str | Unset = UNSET, - ordering: str | Unset = UNSET, - page: int | Unset = UNSET, - page_size: int | Unset = UNSET, - workflow: Any | Unset = UNSET, - ) -> dict: - """The API deployments in one organisation. - - The keyword arguments are the query parameters the endpoint accepts, - named as the API names them; one left unset is not sent, so the server - picks its own default. The result is paginated -- read ``next`` rather - than assuming ``results`` is the whole set. + 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 @@ -1134,20 +1123,29 @@ def list_deployments( ordering (str): Field to order by. page (int): 1-based page number. page_size (int): Rows per page. - workflow (UUID): Return only deployments of this workflow. + workflow (UUID | str): Return only deployments of this workflow. Returns: - dict: ``count``, ``next``, ``previous`` and ``results``. + dict: ``count``, ``next``, ``previous`` and ``results``, the shape + `PaginatedAPIDeploymentSummaryList` models. """ - self.logger.debug("Listing deployments for organisation: " + org_id) + 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, - api_name=api_name, - search=search, - ordering=ordering, - page=page, - page_size=page_size, - workflow=workflow, + org_id, **{k: v for k, v in filters.items() if v is not None} ) - response = self._send(**request_kwargs) + 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 98ee284..8fe3bf4 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -25,6 +25,7 @@ import inspect import io import json +import logging import os import re import socket @@ -52,14 +53,21 @@ ) 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, - PlatformAPIClient, + PlatformClientError, + PlatformKeyClient, + UnstractError, ) BASELINE_VERSION = "1.5.3" @@ -1896,7 +1904,7 @@ def test_every_declared_operation_is_wrapped(): Two sets, unioned, because the spec now serves two credentials: the deployment-key operations reached through `APIDeploymentsClient` and the - platform-key ones through `PlatformAPIClient`. The union keeps the whole + 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()) @@ -1994,11 +2002,46 @@ def _deployment_page() -> dict: } +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") - return PlatformAPIClient(**kwargs) + # 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 @@ -2010,7 +2053,7 @@ def _platform_reply(status_code, json_data): real and observable on `transport.request`. It does **not** exercise the generated `_parse_response` or the response - models: `PlatformAPIClient` reads the body itself, deliberately, because + 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`. @@ -2021,6 +2064,20 @@ def _platform_reply(status_code, json_data): 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 @@ -2042,10 +2099,7 @@ def test_every_platform_error_status_is_reported_with_its_reason(operation): body, expected = _body_for(schema) if schema else (None, "") with _platform_reply(status_code, body): with pytest.raises(APIDeploymentsClientException) as caught: - if operation == "whoami": - _platform_client().whoami() - else: - _platform_client().list_deployments("org-a") + PLATFORM_CALLS[operation](_platform_client()) message = str(caught.value) assert str(status_code) in message, (operation, status_code) if expected: @@ -2055,16 +2109,10 @@ def test_every_platform_error_status_is_reported_with_its_reason(operation): 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.""" - identity = { - "organization_id": "org-a", - "organization_name": "Org A", - "permission": "read", - "key_name": "cli-key", - } - with _platform_reply(200, identity) as transport: + with _platform_reply(200, _identity_body()) as transport: result = _platform_client().whoami() - assert result == identity + 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] @@ -2091,13 +2139,13 @@ 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: - PlatformAPIClient(base_url="https://example.unstract.com") + 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 = PlatformAPIClient(base_url="https://example.unstract.com") + client = PlatformKeyClient(base_url="https://example.unstract.com") assert client.api_key == "pk-from-env" @@ -2106,19 +2154,6 @@ def test_a_base_url_without_a_host_is_refused(): _platform_client(base_url="not-a-url") -def test_a_path_on_the_base_url_is_discarded(): - """These operations carry their own paths; keeping a caller's would produce - a URL no deployment serves.""" - client = _platform_client( - base_url="https://example.unstract.com/deployment/api/x/y/" - ) - assert client.base_url == "https://example.unstract.com" - - -# The findings below were raised on PR #29 and each fix is pinned here, so a -# regression to the pre-review behaviour fails rather than passing quietly. - - @pytest.mark.parametrize( ("label", "body_text"), [ @@ -2208,12 +2243,19 @@ def test_both_clients_are_reachable_from_the_package_root(): surface, and the sibling is re-exported.""" from unstract import api_deployments as pkg - assert pkg.PlatformAPIClient is PlatformAPIClient + 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(): - """`PlatformAPIClient` reads bodies itself, so nothing else here would + """`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. @@ -2307,7 +2349,7 @@ def test_the_generated_platform_parsers_read_a_declared_response(): def test_the_generated_parsers_raise_on_an_undeclared_error_body(): - """The reason `PlatformAPIClient` does not use them. `from_dict` indexes + """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 @@ -2325,3 +2367,264 @@ def test_the_generated_parsers_raise_on_an_undeclared_error_body(): 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