diff --git a/README.md b/README.md index 8380be4f..f2c09a0c 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,24 @@ outcome it could not determine — as finished. `stop()` is accepted rather than awaited: what it returns is the execution read back after asking, often still `running`, so poll `get()` to see it reach `stopped`. Repeating a stop is safe. +`durable.approvals` reads and decides the approvals a durable function waits on: + +```python +approvals = owner_client.durable.approvals +page = approvals.list(project_id, status="pending", function="order-pipeline") +approval = approvals.approve(project_id, page.approvals[0].id, comment="Looks right") +stats = approvals.stats(project_id) +``` + +`list()`, `get()` and `stats()` accept the project owner's platform user token or +a project access token. `approve()` and `deny()` are for a person: a project +access token raises `PermissionDeniedError`, a subclass of `AuthenticationError`. +Repeating the same decision returns the approval unchanged; a conflicting one, or +deciding an approval that expired or whose execution ended, raises +`ConflictError` with `code` set to `approval_decided`, `approval_expired`, or +`approval_cancelled`. See the +[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#decide-approvals-from-a-backend). + ## Write a durable function `volcano_sdk.durable_authoring` is what the durable function itself is written @@ -332,6 +350,7 @@ the clock or a random value. | `ctx.step(name, fn, retry=..., at_most_once=...)` | Runs one atomic operation and records its result. `retry=False` fails on the first error; `RetryOptions` sets attempts and backoff. | | `ctx.wait(name, duration)` | Suspends the execution. `"30s"`, `"2h"`, `"1m30s"`, a whole number of seconds, or `{"hours": 2}`. | | `ctx.wait_until(check, options, name=None)` | Polls your own state until `options.until` holds, suspending between checks. `options.initial_state` is required. | +| `ctx.wait_for_approval(name, title=..., description=None, details=None, timeout=None)` | Suspends until a person approves or denies, and returns an `ApprovalDecision`. A timeout returns `status="expired"` rather than raising. | | `ctx.map(items, fn, name=None, options=None)` | Runs the same work over every item, each in its own child context. | | `ctx.parallel(branches, name=None, options=None)` | Runs independent branches at the same time. | | `ctx.child(name, fn)` | Groups operations under one recorded context. | @@ -372,8 +391,30 @@ volcano durable start order-pipeline --input '{"order_id":"order-9"}' Local waits resolve immediately by default while preserving checkpoint and replay behavior. Set `LOCAL_DURABLE_REAL_TIME=true` before `volcano start` when wait timing must match the deployed function. Local executions persist across -`volcano stop` and `volcano start`. Volcano does not expose externally completed -callbacks; use `ctx.wait_until` to poll application state instead. +`volcano stop` and `volcano start`. + +`ctx.wait_for_approval` registers the approval with Volcano and suspends until it +is decided, works the same locally, and replays the recorded decision on resume. +A denial or timeout is a value to branch on: + +```python +decision = ctx.wait_for_approval( + "ship-order", + title=f"Ship order {event['order_id']}?", + details={"total": event["total"]}, + timeout="24h", +) +if not decision.approved: + return {"shipped": False, "status": decision.status} +``` + +`decision` carries `approved`, `status` (`approved`, `denied`, or `expired`), +`comment`, `decided_by` (`id` and `email`, or `None`), and `decided_at`. Volcano +sets `VOLCANO_PLATFORM_API_URL` on durable functions; without it the call raises +`RuntimeError`. The function retries registering the approval for up to 30 +seconds. If Volcano refuses it, the call raises the durable runtime's +`CallbackSubmitterError` with the SDK error's message. See the +[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#wait-for-an-approval). `logs.search()` returns an immutable page of retained runtime or deployment log events. Pass `next_cursor` back as `cursor` to continue a search. `logs.activity()` diff --git a/docs/functions.md b/docs/functions.md index 7dec0bb8..aeda7c4a 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -128,7 +128,101 @@ functions: kind: durable ``` -Volcano records each context operation. Resumed executions replay recorded results instead of repeating completed work. Keep changing decisions inside `ctx.step()`. Use `ctx.wait_until()` to poll application state. Volcano does not expose externally completed callbacks. +Volcano records each context operation. Resumed executions replay recorded results instead of repeating completed work. Keep changing decisions inside `ctx.step()`. Use `ctx.wait_until()` to poll application state. + +## Wait for an approval + +`ctx.wait_for_approval()` pauses the execution until a person approves or denies it from the dashboard, the CLI, or the [owner client](#decide-approvals-from-a-backend): + +```python +from volcano_sdk.durable_authoring import durable + + +@durable +def handler(event, ctx): + order = ctx.step("load-order", lambda scope: load_order(event["order_id"])) + + decision = ctx.wait_for_approval( + "ship-order", + title=f"Ship order {order['id']}?", + description="Orders over $500 need a second look.", + details={"order_id": order["id"], "total": order["total"]}, + timeout="24h", + ) + + if not decision.approved: + ctx.step("release-stock", lambda scope: release_stock(order["id"])) + return {"shipped": False, "status": decision.status} + + ctx.step("ship", lambda scope: ship_order(order["id"])) + return {"shipped": True, "comment": decision.comment} +``` + +| Argument | Description | +|---|---| +| `name` | Required. Operation name in the execution's history, up to 237 printable ASCII characters. Keep it short. | +| `title` | Required. What the approver is asked, up to 200 characters. | +| `description` | Optional context, up to 4000 characters. | +| `details` | Optional JSON value shown with the approval. The whole approval, `details` included, must encode to at most 64 KiB of JSON. | +| `timeout` | Optional duration in the `ctx.wait()` format, from one second to 366 days. Without it, the approval stays open as long as the execution runs. | + +Limits count Unicode characters. A blank `name` or `title` raises `ValueError`, as does a value over its limit, a `name` outside printable ASCII, a NUL character anywhere in the text or `details`, or an approval over 64 KiB. A value that is not a string, or that cannot be encoded as JSON, raises `TypeError`. A timeout out of range raises `TypeError`, as it does for `ctx.wait()`. These checks run before anything is recorded. + +The call returns an immutable `ApprovalDecision`: + +| Field | Approved | Denied | Timed out | +|---|---|---|---| +| `approved` | `True` | `False` | `False` | +| `status` | `"approved"` | `"denied"` | `"expired"` | +| `comment` | The approver's comment, or `""` | The approver's comment, or `""` | `""` | +| `decided_by` | `DurableApprovalDecider` with `id` and `email`, or `None` if the account was deleted before the decision reached the execution | Same as approved | `None` | +| `decided_at` | RFC 3339 timestamp | RFC 3339 timestamp | `None` | + +A denial or a timeout is a value to branch on, not an exception. The execution costs nothing while it waits, and a resumed execution replays the recorded decision without asking again. + +Volcano sets `VOLCANO_PLATFORM_API_URL` on deployed and local durable functions. The function sends the approval there without a credential. Volcano accepts it only from the execution that is waiting. When Volcano does not answer, has not seen the execution or its approval yet, throttles the request, or fails, the function retries for up to 30 seconds. An approval whose timeout passes before Volcano records it returns the `expired` decision. If Volcano refuses the approval, or keeps failing for 30 seconds, `wait_for_approval()` raises the durable runtime's `CallbackSubmitterError`, carrying the SDK error's message. Unless the handler catches it, the execution fails. Calling `wait_for_approval()` without `VOLCANO_PLATFORM_API_URL` raises `RuntimeError`. + +## Decide approvals from a backend + +`client.durable.approvals` lists, reads, and decides a project's approvals: + +```python +import os +from datetime import UTC, datetime, timedelta + +from volcano_sdk import ConflictError, VolcanoClient + +owner_client = VolcanoClient( + anon_key=os.environ["VOLCANO_ANON_KEY"], + api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), + access_token=os.environ["VOLCANO_PLATFORM_TOKEN"], +) +approvals = owner_client.durable.approvals + +page = approvals.list(project_id, status="pending", function="charge-order") +for approval in page.approvals: + print(approval.id, approval.title, approval.details, approval.expires_at) + +try: + approval = approvals.approve(project_id, approval_id, comment="Address checked") +except ConflictError as error: + print(error.code) # approval_decided, approval_expired, or approval_cancelled + +stats = approvals.stats(project_id, from_=datetime.now(UTC) - timedelta(days=7)) +print(stats.counts.pending, stats.approval_rate, stats.median_seconds_to_decision) +``` + +| Method | Returns | +|---|---| +| `list(project_id, status=..., function=..., execution_id=..., from_=..., to=..., page=..., limit=...)` | `DurableApprovalPage` with `approvals`, `page`, `limit`, `total`, and `has_more`. | +| `get(project_id, approval_id)` | `DurableApproval`. | +| `stats(project_id, function=..., from_=..., to=...)` | `DurableApprovalStats` with counts by status, decision times, and per-function and daily counts. | +| `approve(project_id, approval_id, comment=None)` | The decided `DurableApproval`. | +| `deny(project_id, approval_id, comment=None)` | The decided `DurableApproval`. | + +`status` is one of `pending`, `approved`, `denied`, `expired`, or `cancelled`. An approval is `cancelled` when its execution ends first. `function` takes a durable function's id or name. `from_` and `to` must be timezone-aware datetimes. Stats default to the last 30 days and cover at most 366 days. A comment is up to 2000 characters. + +Read approvals with the project owner's platform user token or a project access token. Only a person decides: `approve()` and `deny()` need a platform user token, and a project access token raises `PermissionDeniedError`. Repeating the same decision returns the approval unchanged. A conflicting decision, or deciding an expired or cancelled approval, raises `ConflictError`. An unknown approval raises `NotFoundError`. `PermissionDeniedError` subclasses `AuthenticationError`, so existing handlers still catch it. ## Run durable functions locally @@ -142,4 +236,6 @@ volcano durable start charge-order --input '{"order_id":"order-9"}' Local waits resolve immediately by default while preserving checkpoint and replay behavior. Set `LOCAL_DURABLE_REAL_TIME=true` before `volcano start` when wait timing must match the deployed function. Local executions persist across `volcano stop` and `volcano start`. +Approvals work the same locally. Decide one with `volcano durable approvals approve` or `volcano durable approvals deny`, or point the owner client at the local server. Approval timeouts run in real time. + Running a decorated handler directly in a Python process still needs the optional test runtime: `python -m pip install 'volcano-sdk-python[durable]'`. diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 4103b58a..a0cc7369 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -16,7 +16,7 @@ servers: description: Development API server (use VOLCANO_API_URL env var) tags: - name: Sandboxes - description: Isolated Linux sessions and reusable templates. + description: 'Preview, not generally available: isolated Linux sessions and reusable templates. Where Sandboxes are not available, every endpoint answers `404` with code `feature_unavailable`. Where they are turned off after use, only creating or starting a session, template, execution, or deployment, and listing presets, answer that `404`, so existing ones can still be read and deleted. A `503` means the Sandbox service is temporarily unavailable.' - name: Projects description: Project management operations - name: Logs @@ -101,7 +101,7 @@ paths: schema: $ref: '#/components/schemas/SandboxPresetList' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -132,7 +132,7 @@ paths: schema: $ref: '#/components/schemas/SandboxTemplatePage' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -172,7 +172,7 @@ paths: schema: $ref: '#/components/schemas/SandboxTemplate' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -207,7 +207,7 @@ paths: schema: $ref: '#/components/schemas/SandboxTemplate' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -247,7 +247,7 @@ paths: schema: $ref: '#/components/schemas/SandboxTemplate' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -277,7 +277,7 @@ paths: '202': description: Retire a template and terminate its sessions default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -345,7 +345,7 @@ paths: schema: $ref: '#/components/schemas/SandboxDeployment' default: - description: Invalid source, conflicting retry, denied access, or unavailable deployment service + description: Invalid source (400), denied access (401, 403), conflicting retry (409), or a temporarily unavailable deployment service (503). Code `feature_unavailable` with 404 means sandboxes or custom templates are not available in this environment. content: application/json: schema: @@ -381,7 +381,7 @@ paths: schema: $ref: '#/components/schemas/SandboxDeploymentPage' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -422,7 +422,7 @@ paths: schema: $ref: '#/components/schemas/SandboxDeployment' default: - description: Denied access, missing resource, or unavailable deployment service + description: Denied access (401, 403), missing resource (404), or a temporarily unavailable deployment service (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -481,7 +481,7 @@ paths: schema: $ref: '#/components/schemas/SandboxBuildLogPage' default: - description: Denied access, missing resource, or unavailable deployment service + description: Denied access (401, 403), missing resource (404), or a temporarily unavailable deployment service (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -523,7 +523,7 @@ paths: type: string format: binary default: - description: Denied access, missing resource, or unavailable deployment service + description: Denied access (401, 403), missing resource (404), or a temporarily unavailable deployment service (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -554,7 +554,7 @@ paths: schema: $ref: '#/components/schemas/SandboxSessionPage' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -594,7 +594,7 @@ paths: schema: $ref: '#/components/schemas/SandboxSession' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -635,7 +635,7 @@ paths: schema: $ref: '#/components/schemas/SandboxExecutionResult' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -665,7 +665,7 @@ paths: schema: $ref: '#/components/schemas/SandboxSession' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -693,7 +693,7 @@ paths: schema: $ref: '#/components/schemas/SandboxSession' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -722,7 +722,7 @@ paths: schema: $ref: '#/components/schemas/SandboxSession' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -751,7 +751,7 @@ paths: schema: $ref: '#/components/schemas/SandboxSession' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -793,7 +793,7 @@ paths: schema: $ref: '#/components/schemas/SandboxCommandResult' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -829,7 +829,7 @@ paths: schema: $ref: '#/components/schemas/SandboxFileResult' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -861,7 +861,7 @@ paths: '204': description: Write a workspace file default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -898,7 +898,7 @@ paths: '204': description: Authorize an authenticated project user for this session default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -928,7 +928,7 @@ paths: '204': description: Revoke a project user session grant default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -964,7 +964,7 @@ paths: schema: $ref: '#/components/schemas/SandboxAccess' default: - description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and disabled or unavailable (503). + description: Request refused or unavailable. Errors include invalid input (400), unauthenticated (401), forbidden (403), not found (404), conflicting retry (409), capacity exhausted (429), and temporarily unavailable (503). Code `feature_unavailable` with 404 means sandboxes are not available in this environment. content: application/json: schema: @@ -5799,6 +5799,361 @@ paths: application/json: schema: $ref: '#/components/schemas/Error' + /durable-approvals: + post: + tags: + - Durable Functions + summary: Request an approval from inside a workflow + description: | + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + operationId: requestDurableApproval + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RequestDurableApprovalRequest' + responses: + '200': + description: The approval was already registered + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalRegistration' + '201': + description: Approval registered + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalRegistration' + '400': + description: The request body is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: No running durable execution matches `execution_ref` + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The approval cannot be registered now. `code` says why: + + - `approval_not_ready`: the workflow has not finished opening the + approval yet. Retry with a short backoff; the SDK does. + - `approval_closed`: the workflow is no longer waiting on it. + - `execution_ended`: the execution has finished. + - `too_many_pending_approvals`: the execution already has the + maximum number of pending approvals. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 64 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '429': + description: Too many approval requests from this execution or address + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '503': + description: | + The approval could not be confirmed with the workflow right now. + Retry with backoff. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals: + get: + tags: + - Durable Functions + summary: List durable approvals + description: | + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + operationId: listDurableApprovals + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Limit' + - name: status + in: query + required: false + description: Return only approvals in this status. + schema: + $ref: '#/components/schemas/DurableApprovalStatus' + - $ref: '#/components/parameters/DurableApprovalFunctionFilter' + - name: execution_id + in: query + required: false + description: Return only approvals requested by this durable execution. + schema: + type: string + format: uuid + - name: from + in: query + required: false + description: Return only approvals requested at or after this time. + schema: + type: string + format: date-time + - name: to + in: query + required: false + description: Return only approvals requested before this time. + schema: + type: string + format: date-time + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedDurableApprovals' + '400': + description: A filter is invalid, or `from` is after `to` + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/stats: + get: + tags: + - Durable Functions + summary: Get durable approval statistics + description: | + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + operationId: getDurableApprovalStats + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalFunctionFilter' + - name: from + in: query + required: false + description: Start of the window. Defaults to 30 days before `to`. + schema: + type: string + format: date-time + - name: to + in: query + required: false + description: End of the window. Defaults to now. + schema: + type: string + format: date-time + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalStats' + '400': + description: The window is invalid or longer than 366 days + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/{approvalId}: + get: + tags: + - Durable Functions + summary: Get a durable approval + description: | + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + operationId: getDurableApproval + security: + - UserToken: [] + - ProjectAccessToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalId' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApproval' + '404': + description: Approval not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/{approvalId}/approve: + post: + tags: + - Durable Functions + summary: Approve a durable approval + description: | + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + operationId: approveDurableApproval + security: + - UserToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalDecisionRequest' + responses: + '200': + description: Approved + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApproval' + '400': + description: The request body is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: The credential is not a person's + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Approval not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The approval was already denied, or has expired or been cancelled. + `code` is `approval_decided`, `approval_expired`, or + `approval_cancelled`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 16 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /projects/{id}/durable-approvals/{approvalId}/deny: + post: + tags: + - Durable Functions + summary: Deny a durable approval + description: | + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + operationId: denyDurableApproval + security: + - UserToken: [] + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/DurableApprovalId' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApprovalDecisionRequest' + responses: + '200': + description: Denied + content: + application/json: + schema: + $ref: '#/components/schemas/DurableApproval' + '400': + description: The request body is invalid + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: The credential is not a person's + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: Approval not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '409': + description: | + The approval was already approved, or has expired or been + cancelled. `code` is `approval_decided`, `approval_expired`, or + `approval_cancelled`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '413': + description: The request body is larger than 16 KiB + content: + application/json: + schema: + $ref: '#/components/schemas/Error' /projects/{id}/frontends: get: tags: @@ -6222,7 +6577,7 @@ paths: post: tags: - Frontends - summary: Configure frontend custom domain (SUPERAGENT) + summary: Configure frontend custom domain description: | Configures one custom domain for a frontend. The default Volcano-generated frontend URL remains active. @@ -6270,7 +6625,7 @@ paths: schema: $ref: '#/components/schemas/Error' '403': - description: Forbidden - custom domains require SUPERAGENT plan, or a managed TLS request would exceed the account's managed TLS certificate limit + description: Forbidden - the plan does not include custom domains, or a managed TLS request would exceed the account's managed TLS certificate limit content: application/json: schema: @@ -14685,6 +15040,26 @@ components: type: string format: uuid description: Durable execution ID + DurableApprovalId: + name: approvalId + in: path + required: true + schema: + type: string + format: uuid + description: Durable approval ID + DurableApprovalFunctionFilter: + name: function + in: query + required: false + schema: + type: string + minLength: 1 + maxLength: 255 + description: | + Only approvals requested by this durable function, given by id or by + name. A name also matches approvals from a deleted function of that + name. Limit: name: limit in: query @@ -15109,12 +15484,12 @@ components: type: integer minimum: 30 maximum: 28800 - default: 3600 + description: Inherits the template TTL when omitted (3600 seconds for a new template). idle_timeout_seconds: type: integer minimum: 0 maximum: 28800 - default: 0 + description: Inherits the template idle timeout when omitted, capped at the session duration. Set zero to disable idle timeout. required: - region oneOf: @@ -16415,7 +16790,6 @@ components: version its server runs. Any other value is rejected with 400. example: '18' enum: - - '15' - '16' - '17' - '18' @@ -17083,7 +17457,10 @@ components: values: type: object additionalProperties: true - description: Column values to insert + description: | + Column values to insert. JSON objects and arrays are stored as JSON, + so send them to `json` or `jsonb` columns. For a Postgres array + column, send an array literal string such as `"{a,b}"`. example: title: My New Post content: This is the content @@ -17101,7 +17478,10 @@ components: values: type: object additionalProperties: true - description: Column values to update + description: | + Column values to update. JSON objects and arrays are stored as JSON, + so send them to `json` or `jsonb` columns. For a Postgres array + column, send an array literal string such as `"{a,b}"`. example: title: Updated Title status: published @@ -20315,6 +20695,207 @@ components: - limit - total - has_more + DurableApproval: + type: object + description: An approval a durable workflow requested. + properties: + id: + type: string + format: uuid + status: + $ref: '#/components/schemas/DurableApprovalStatus' + name: + type: string + description: | + The name the workflow gave the approval in `ctx.waitForApproval`. + title: + type: string + description: + type: string + details: + description: | + The JSON value the workflow attached for the person deciding, as + given. + function: + $ref: '#/components/schemas/DurableApprovalFunction' + execution: + $ref: '#/components/schemas/DurableApprovalExecution' + requested_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + nullable: true + description: | + When the approval expires if nobody decides. Null when the + workflow set no timeout; the approval then lasts as long as its + execution. + decision: + $ref: '#/components/schemas/DurableApprovalDecision' + required: + - id + - status + - name + - title + - description + - function + - execution + - requested_at + - expires_at + - decision + DurableApprovalStatus: + type: string + enum: + - pending + - approved + - denied + - expired + - cancelled + description: | + `pending` means the workflow is waiting for a decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + DurableApprovalRegistration: + type: object + description: The registered approval, as the requesting workflow sees it. + properties: + id: + type: string + format: uuid + status: + $ref: '#/components/schemas/DurableApprovalStatus' + expires_at: + type: string + format: date-time + nullable: true + required: + - id + - status + - expires_at + DurableApprovalStats: + type: object + properties: + from: + type: string + format: date-time + to: + type: string + format: date-time + counts: + $ref: '#/components/schemas/DurableApprovalCounts' + approval_rate: + type: number + format: double + nullable: true + description: | + `approved / (approved + denied)`, from 0 to 1. Null when nothing in + the window was decided. + median_seconds_to_decision: + type: number + format: double + nullable: true + description: Median time from request to decision. Null when nothing was decided. + p90_seconds_to_decision: + type: number + format: double + nullable: true + description: 90th percentile time from request to decision. Null when nothing was decided. + functions: + type: array + description: | + The ten workflows that requested the most approvals, most first. + The rest are summed in `other_functions`. + items: + $ref: '#/components/schemas/DurableApprovalFunctionCounts' + other_functions: + $ref: '#/components/schemas/DurableApprovalCounts' + daily: + type: array + description: | + One entry per UTC day in the window that has approvals, oldest + first. Days with none are omitted. + items: + $ref: '#/components/schemas/DurableApprovalDailyCounts' + required: + - from + - to + - counts + - approval_rate + - median_seconds_to_decision + - p90_seconds_to_decision + - functions + - other_functions + - daily + DurableApprovalDecisionRequest: + type: object + properties: + comment: + type: string + maxLength: 2000 + description: A note for the workflow and the approval's history. + additionalProperties: false + RequestDurableApprovalRequest: + type: object + properties: + execution_ref: + type: string + minLength: 1 + maxLength: 2048 + description: Opaque reference to the running execution, supplied by the SDK. + callback_id: + type: string + minLength: 1 + maxLength: 1024 + description: Opaque approval reference, supplied by the SDK. + name: + type: string + minLength: 1 + maxLength: 255 + title: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + maxLength: 4000 + details: + description: | + Any JSON value to show the person deciding. The whole request is + limited to 64 KiB. + required: + - execution_ref + - callback_id + - name + - title + additionalProperties: false + PaginatedDurableApprovals: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/DurableApproval' + page: + type: integer + description: Current page number (1-indexed) + limit: + type: integer + description: Number of items per page + total: + type: integer + description: Total number of items across all pages + has_more: + type: boolean + description: Whether there are more pages available + required: + - data + - page + - limit + - total + - has_more PaginatedProjectCustomDomains: type: object properties: @@ -21055,7 +21636,7 @@ components: type: object additionalProperties: false description: | - Custom domain with managed or BYOC TLS (SUPERAGENT plan). `tls` is required + Custom domain with managed or BYOC TLS. `tls` is required when the domain is first created and optional afterwards. For an existing domain, omitting `tls` or sending only `tls.mode` keeps the stored certificate; new BYOC material for the same domain rotates the @@ -21094,7 +21675,10 @@ components: - '16' - '17' - '18' - description: PostgreSQL major version. Asserted, never written. + description: | + PostgreSQL major version. Asserted, never written. Also accepts + versions new databases can no longer be created on, so an export + of an existing database re-applies. database_type: type: string enum: @@ -23358,6 +23942,119 @@ components: description: Deployment stage that produced the log event, when available. required: - id + DurableApprovalFunction: + type: object + description: | + The durable function that requested the approval. `id` is null once the + function has been deleted; `name` is kept. + properties: + id: + type: string + format: uuid + nullable: true + name: + type: string + required: + - id + - name + DurableApprovalExecution: + type: object + description: | + The durable execution that requested the approval. `id` and `status` + are null once the execution is no longer retained; `name` is kept. + properties: + id: + type: string + format: uuid + nullable: true + name: + type: string + status: + allOf: + - $ref: '#/components/schemas/DurableExecutionStatus' + nullable: true + required: + - id + - name + - status + DurableApprovalDecider: + type: object + nullable: true + description: The person who decided. Null once their account is deleted. + properties: + id: + type: string + email: + type: string + required: + - id + - email + DurableApprovalDecision: + type: object + nullable: true + description: Who decided and when. Null unless the approval was approved or denied. + properties: + comment: + type: string + decided_by: + $ref: '#/components/schemas/DurableApprovalDecider' + decided_at: + type: string + format: date-time + required: + - comment + - decided_by + - decided_at + DurableApprovalCounts: + type: object + description: Approvals by status. `requested` is every approval, whatever its status. + properties: + requested: + type: integer + format: int64 + pending: + type: integer + format: int64 + approved: + type: integer + format: int64 + denied: + type: integer + format: int64 + expired: + type: integer + format: int64 + cancelled: + type: integer + format: int64 + required: + - requested + - pending + - approved + - denied + - expired + - cancelled + DurableApprovalFunctionCounts: + type: object + properties: + function: + $ref: '#/components/schemas/DurableApprovalFunction' + counts: + $ref: '#/components/schemas/DurableApprovalCounts' + required: + - function + - counts + DurableApprovalDailyCounts: + type: object + properties: + date: + type: string + format: date + counts: + $ref: '#/components/schemas/DurableApprovalCounts' + required: + - date + - counts DatabaseQueryFilter: type: object description: One WHERE condition. Conditions are combined with AND. diff --git a/scripts/generate_openapi.py b/scripts/generate_openapi.py index a5ee186e..f6521597 100755 --- a/scripts/generate_openapi.py +++ b/scripts/generate_openapi.py @@ -17,15 +17,20 @@ DEFAULT_OUTPUT = ROOT / "src" / "volcano_sdk" / "_generated" REQUIRED_OPERATION_MODULES = { "acquire_project_lock.py", + "approve_durable_approval.py", "auth_signin.py", "copy_storage_object.py", "delete_storage_object.py", + "deny_durable_approval.py", "download_storage_object.py", + "get_durable_approval.py", + "get_durable_approval_stats.py", "get_durable_execution.py", "force_release_project_lock.py", "get_project_log_activity.py", "get_project_lock.py", "invoke_function.py", + "list_durable_approvals.py", "list_durable_executions.py", "list_storage_objects.py", "move_storage_object.py", diff --git a/src/volcano_sdk/__init__.py b/src/volcano_sdk/__init__.py index c4cd895b..79596c0e 100644 --- a/src/volcano_sdk/__init__.py +++ b/src/volcano_sdk/__init__.py @@ -7,6 +7,7 @@ AuthenticationError, ConflictError, NotFoundError, + PermissionDeniedError, RateLimitedError, ServerError, SessionChangedError, @@ -19,6 +20,19 @@ AuthSession, AuthStateCallback, AuthSubscription, + DurableApproval, + DurableApprovalCounts, + DurableApprovalDailyCounts, + DurableApprovalDecider, + DurableApprovalDecision, + DurableApprovalExecution, + DurableApprovalFunction, + DurableApprovalFunctionCounts, + DurableApprovalListOptions, + DurableApprovalPage, + DurableApprovalStats, + DurableApprovalStatsOptions, + DurableApprovalStatus, DurableExecution, DurableExecutionFailure, DurableExecutionPage, @@ -58,6 +72,19 @@ "AuthSubscription", "AuthenticationError", "ConflictError", + "DurableApproval", + "DurableApprovalCounts", + "DurableApprovalDailyCounts", + "DurableApprovalDecider", + "DurableApprovalDecision", + "DurableApprovalExecution", + "DurableApprovalFunction", + "DurableApprovalFunctionCounts", + "DurableApprovalListOptions", + "DurableApprovalPage", + "DurableApprovalStats", + "DurableApprovalStatsOptions", + "DurableApprovalStatus", "DurableExecution", "DurableExecutionFailure", "DurableExecutionPage", @@ -73,6 +100,7 @@ "NotFoundError", "OAuthProviderName", "OAuthProviderTokenStatus", + "PermissionDeniedError", "PostgresChange", "RateLimitedError", "RealtimeConnectContext", diff --git a/src/volcano_sdk/_durable_approval_registration.py b/src/volcano_sdk/_durable_approval_registration.py new file mode 100644 index 00000000..fd939c5b --- /dev/null +++ b/src/volcano_sdk/_durable_approval_registration.py @@ -0,0 +1,404 @@ +"""Register a durable approval with Volcano, and read the decision it returns.""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from http import HTTPMethod +from time import monotonic, sleep +from typing import TYPE_CHECKING, TypeGuard + +import httpx + +from ._durable_response import json_value +from ._durable_results import ApprovalDecision +from ._transport_response import ( + invoke_async, + plain_json, + response_payload, + unparsed_response, +) +from ._transport_types import HTTP_CREATED, HTTP_OK, decode_json +from .errors import ( + ConflictError, + NotFoundError, + RateLimitedError, + ServerError, + TransportError, + VolcanoError, +) +from .models import DurableApprovalDecider + +if TYPE_CHECKING: + from ._transport_types import TransportResponse + from .models import JSONValue + +# 0.5 s doubling to a 5 s cap, within the deadline: long enough to outlast the +# moment the platform has not yet seen the execution start or the callback open. +_RETRY_DELAYS = (0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0) +_DEADLINE_SECONDS = 30.0 +_REQUEST_TIMEOUT_SECONDS = 10.0 +_NOT_READY = "approval_not_ready" +_CLOSED = "approval_closed" +_HEADERS = {"Content-Type": "application/json"} +_MAX_BODY_BYTES = 65536 +# Volcano answers in a few hundred bytes; more is not an answer worth reading. +_MAX_ANSWER_BYTES = 65536 +# The callback id is assigned once the callback opens, after the size check; +# the spec allows it 1024 characters. +_LONGEST_CALLBACK_ID = "x" * 1024 +_TOO_LARGE = ( + "the approval is larger than the 64 KiB Volcano accepts once encoded as " + "JSON; send less in details" +) +_INVALID_DECISION = "Expected a complete approval decision" +_API_URL_VARIABLE = "VOLCANO_PLATFORM_API_URL" +_MISSING_API_URL = ( + "wait_for_approval() needs VOLCANO_PLATFORM_API_URL. Volcano sets it on " + "deployed durable functions; set it yourself to run the handler elsewhere." +) +_INVALID_DETAILS = ( + "details must be JSON-serializable: mappings with string keys, lists, " + "tuples, strings, finite numbers, booleans, and None" +) +# The limits on what the approval shows the person deciding. The name is also +# the runtime's operation name, which allows 256 printable ASCII characters, +# and the runtimes add suffixes of up to 19 characters to it; every SDK holds +# names to the same limit. +_TEXT_LIMITS = {"name": 237, "title": 200, "description": 4000} +_NOT_PRINTABLE_ASCII = "name must be printable ASCII" +_NUL = "\0" +EXPIRED = ApprovalDecision(approved=False, status="expired") + + +def platform_api_url() -> str: + """Read the Volcano API address the platform gives durable functions. + + Returns: + The API base URL. + + Raises: + RuntimeError: The variable is unset or empty. + + """ + url = os.environ.get(_API_URL_VARIABLE, "").strip() + if not url: + raise RuntimeError(_MISSING_API_URL) + return url + + +def approval_body( + name: object, title: object, description: object, details: object +) -> dict[str, JSONValue]: + """Validate what the approval shows, before any callback is opened. + + Returns: + The registration body without the execution reference or callback id. + + """ + body: dict[str, JSONValue] = { + "name": _name(name), + "title": _text(title, "title", required=True), + } + if description is not None: + body["description"] = _text(description, "description", required=False) + if details is not None: + body["details"] = _details(details) + return body + + +def _name(value: object) -> str: + name = _text(value, "name", required=True) + if not (name.isascii() and name.isprintable()): + raise ValueError(_NOT_PRINTABLE_ASCII) + return name + + +def _details(value: object) -> JSONValue: + # A copy, so a change the caller makes while the callback opens cannot + # slip past the checks. + details = plain_json(json_value(value, _INVALID_DETAILS)) + if _has_nul(details): + message = "details must not contain NUL characters" + raise ValueError(message) + return details + + +def _has_nul(value: JSONValue) -> bool: + if isinstance(value, str): + return _NUL in value + if isinstance(value, dict): + return any(_NUL in key or _has_nul(item) for key, item in value.items()) + if isinstance(value, list): + return any(_has_nul(item) for item in value) + return False + + +def _text(value: object, field: str, *, required: bool) -> str: + if not isinstance(value, str): + message = f"{field} must be a string" + raise TypeError(message) + try: + _ = value.encode() + except UnicodeEncodeError as error: + message = f"{field} must be JSON-serializable: it contains a surrogate" + raise TypeError(message) from error + if required and not value.strip(): + message = f"{field} must not be empty" + raise ValueError(message) + return _recordable(value, field) + + +def _recordable(value: str, field: str) -> str: + limit = _TEXT_LIMITS[field] + if len(value) > limit: + message = f"{field} must be at most {limit} characters" + raise ValueError(message) + if _NUL in value: + message = f"{field} must not contain NUL characters" + raise ValueError(message) + return value + + +def check_size(body: Mapping[str, JSONValue]) -> None: + """Refuse an approval too large for Volcano, before any callback is opened. + + Raises: + ValueError: The registration, with the longest callback id the runtime + can assign, would encode to more than 64 KiB. + + """ + if len(_encode({**body, "callback_id": _LONGEST_CALLBACK_ID})) > _MAX_BODY_BYTES: + raise ValueError(_TOO_LARGE) + + +def _encode(body: Mapping[str, JSONValue]) -> bytes: + return json.dumps( + plain_json(body), ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode() + + +def register_approval(api_url: str, body: Mapping[str, JSONValue]) -> None: + """Ask Volcano to record the approval and hold the callback for a person. + + The route takes no credential: the execution reference and callback id in + the body are what Volcano checks against the running execution. + + What can clear on its own is retried until a 30 second deadline: no + answer, an execution or callback Volcano cannot see yet, throttling, and + server failures. An approval that already closed, normally because its + timeout passed first, needs no registration: the callback's own outcome + is what the execution resumes with. + + Each attempt, from connecting to the last byte of the answer, ends within + 10 seconds or what is left of the deadline, whichever is sooner. + + Raises: + VolcanoError: Volcano refused the approval, or kept failing until the + deadline passed or the retries ran out. + + """ + url = f"{api_url.removesuffix('/')}/durable-approvals" + content = _encode(body) + deadline = monotonic() + _DEADLINE_SECONDS + timeout = _REQUEST_TIMEOUT_SECONDS + for delay in _RETRY_DELAYS: + try: + _attempt(url, content, timeout) + except VolcanoError as error: + next_timeout = _next_timeout(error, delay, deadline) + if next_timeout is None: + raise + timeout = next_timeout + else: + return + _attempt(url, content, timeout) + + +def _attempt(url: str, content: bytes, timeout: float) -> None: + response = _send(url, content, timeout) + if response.status_code == HTTP_CREATED: + return + try: + _ = response_payload(response, HTTP_OK) + except ConflictError as error: + if error.code != _CLOSED: + raise + + +def _send(url: str, content: bytes, timeout: float) -> TransportResponse: + # httpx times each network read on its own, restarting with every chunk, + # so only cancelling the request bounds the attempt as a whole; its own + # timeouts still keep any one phase within it. The runtime calls the + # submitter on a thread without an event loop. asyncio.run() would wait on + # its way out for a host lookup that cancelling leaves running in the + # loop's executor; closing the loop leaves it behind instead. + client = httpx.AsyncClient(timeout=timeout) + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete( + asyncio.wait_for(invoke_async(_exchange, client, url, content), timeout) + ) + except TimeoutError as error: + raise _no_answer(timeout) from error + except TransportError as error: + # The client's timeouts spend the same budget. When a stalled loop + # fires both at once, Python 3.11's wait_for reports the client's. + if isinstance(error.__cause__, httpx.TimeoutException): + raise _no_answer(timeout) from error.__cause__ + raise + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + +def _no_answer(timeout: float) -> TransportError: + return TransportError(f"Volcano did not answer within {timeout:g} seconds") + + +async def _exchange( + client: httpx.AsyncClient, url: str, content: bytes +) -> TransportResponse: + async with ( + client, + client.stream( + HTTPMethod.POST, url, content=content, headers=_HEADERS + ) as response, + ): + answer = await _answer(response) + return unparsed_response(_Answer(response.status_code, answer, response.headers)) + + +async def _answer(response: httpx.Response) -> bytes: + """Read the answer, unless it runs past what Volcano would send. + + Returns: + The body, or nothing when it is too large to be one of Volcano's. + + """ + answer = bytearray() + async for chunk in response.aiter_bytes(): + answer += chunk + if len(answer) > _MAX_ANSWER_BYTES: + answer.clear() + break + return bytes(answer) + + +@dataclass(frozen=True, slots=True) +class _Answer: + status_code: int + content: bytes + headers: Mapping[str, str] + + +def _retryable(error: VolcanoError) -> bool: + # NotFoundError included: Volcano records an execution only once it has + # started, so an approval that is its first operation can arrive first. + if isinstance( + error, (TransportError, NotFoundError, RateLimitedError, ServerError) + ): + return True + return isinstance(error, ConflictError) and error.code == _NOT_READY + + +def _next_timeout(error: VolcanoError, delay: float, deadline: float) -> float | None: + """Wait to retry a failed attempt, if it is worth retrying in the time left. + + Returns: + The next attempt's timeout, or None when the failure is final. + + """ + if not _retryable(error) or monotonic() + delay >= deadline: + return None + sleep(delay) + remaining = deadline - monotonic() + if remaining <= 0: + return None + return min(_REQUEST_TIMEOUT_SECONDS, remaining) + + +def approval_decision(result: object) -> ApprovalDecision: + """Read the decision Volcano completed the callback with. + + Only the status has to be readable. The decision is already recorded, so + refusing it over a detail would fail the execution again on every replay; + an unreadable comment, decider, or decision time reads as absent instead. + + Returns: + The approved or denied decision. + + Raises: + TypeError: The callback result is not a decision. + + """ + values = _decision_fields(result) + status = values.get("status") + if status not in {"approved", "denied"}: + raise TypeError(_INVALID_DECISION) + approved = status == "approved" + comment = values.get("comment") + return ApprovalDecision( + approved=approved, + status="approved" if approved else "denied", + comment=comment if isinstance(comment, str) else "", + decided_by=_decider(values.get("decided_by")), + decided_at=_decided_at(values.get("decided_at")), + ) + + +def _decision_fields(result: object) -> Mapping[str, object]: + if isinstance(result, (str, bytes)): + document = result.encode() if isinstance(result, str) else result + try: + result = decode_json(document) + except ValueError as error: + raise TypeError(_INVALID_DECISION) from error + if not _is_fields(result): + raise TypeError(_INVALID_DECISION) + return result + + +def _is_fields(value: object) -> TypeGuard[Mapping[str, object]]: + return _is_mapping(value) and all(isinstance(key, str) for key in value) + + +def _is_mapping(value: object) -> TypeGuard[Mapping[object, object]]: + return isinstance(value, Mapping) + + +def _decider(value: object) -> DurableApprovalDecider | None: + if not _is_mapping(value): + return None + decider_id = value.get("id") + email = value.get("email") + if not isinstance(decider_id, str) or not isinstance(email, str): + return None + return DurableApprovalDecider(id=decider_id, email=email) + + +# RFC 3339 date-time, the form Volcano writes. fromisoformat alone also takes a +# bare date, a time without an offset, and other ISO 8601 shapes. +_TIMESTAMP = re.compile( + ( + r"\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3])(?::[0-5]\d){2}(?:\.\d+)?" + r"(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)" + ), + re.ASCII, +) + + +def _decided_at(value: object) -> str | None: + if not isinstance(value, str) or not _TIMESTAMP.fullmatch(value): + return None + # It also refuses an impossible day, such as February 30. + try: + _ = datetime.fromisoformat(value) + except ValueError: + return None + return value diff --git a/src/volcano_sdk/_durable_approval_response.py b/src/volcano_sdk/_durable_approval_response.py new file mode 100644 index 00000000..b7b1db6d --- /dev/null +++ b/src/volcano_sdk/_durable_approval_response.py @@ -0,0 +1,296 @@ +"""Validate durable approval responses before exposing immutable models.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from datetime import date, datetime +from typing import TypeGuard + +from ._durable_response import json_value +from .models import ( + DurableApproval, + DurableApprovalCounts, + DurableApprovalDailyCounts, + DurableApprovalDecider, + DurableApprovalDecision, + DurableApprovalExecution, + DurableApprovalFunction, + DurableApprovalFunctionCounts, + DurableApprovalPage, + DurableApprovalStats, + DurableApprovalStatus, + DurableExecutionStatus, +) + +_INVALID_APPROVAL = "Expected a complete durable approval" +_INVALID_PAGE = "Expected a complete durable approval page" +_INVALID_STATS = "Expected complete durable approval stats" +APPROVAL_STATUSES: tuple[DurableApprovalStatus, ...] = ( + "pending", + "approved", + "denied", + "expired", + "cancelled", +) +_EXECUTION_STATUSES: tuple[DurableExecutionStatus, ...] = ( + "pending", + "running", + "succeeded", + "failed", + "timed_out", + "stopped", + "unknown", +) +_COUNT_FIELDS = ("requested", "pending", "approved", "denied", "expired", "cancelled") + + +def _is_fields(value: object) -> TypeGuard[Mapping[str, object]]: + return _is_mapping(value) and all(isinstance(key, str) for key in value) + + +def _is_mapping(value: object) -> TypeGuard[Mapping[object, object]]: + return isinstance(value, Mapping) + + +def _is_sequence(value: object) -> TypeGuard[list[object] | tuple[object, ...]]: + return isinstance(value, (list, tuple)) + + +def _fields(value: object, message: str) -> Mapping[str, object]: + if not _is_fields(value): + raise TypeError(message) + return value + + +def _text(values: Mapping[str, object], key: str, message: str) -> str: + value = values.get(key) + if not isinstance(value, str): + raise TypeError(message) + return value + + +def _identifier(values: Mapping[str, object], key: str, message: str) -> str: + value = _text(values, key, message) + if not value: + raise TypeError(message) + return value + + +def _optional_identifier(values: Mapping[str, object], key: str) -> str | None: + if values.get(key) is None: + return None + return _text(values, key, _INVALID_APPROVAL) + + +def _timestamp(value: object, message: str) -> datetime: + if not isinstance(value, str) or not value: + raise TypeError(message) + try: + return datetime.fromisoformat(value) + except ValueError as error: + raise TypeError(message) from error + + +def _status(value: object) -> DurableApprovalStatus: + for status in APPROVAL_STATUSES: + if value == status: + return status + raise TypeError(_INVALID_APPROVAL) + + +def _execution_status(value: object) -> DurableExecutionStatus | None: + if value is None: + return None + for status in _EXECUTION_STATUSES: + if value == status: + return status + raise TypeError(_INVALID_APPROVAL) + + +def _function(payload: object, message: str) -> DurableApprovalFunction: + values = _fields(payload, message) + function_id = values.get("id") + if function_id is not None and not isinstance(function_id, str): + raise TypeError(message) + return DurableApprovalFunction( + id=function_id, name=_identifier(values, "name", message) + ) + + +def _execution(payload: object) -> DurableApprovalExecution: + values = _fields(payload, _INVALID_APPROVAL) + return DurableApprovalExecution( + id=_optional_identifier(values, "id"), + name=_identifier(values, "name", _INVALID_APPROVAL), + status=_execution_status(values.get("status")), + ) + + +def _decider(payload: object) -> DurableApprovalDecider | None: + if payload is None: + return None + values = _fields(payload, _INVALID_APPROVAL) + return DurableApprovalDecider( + id=_text(values, "id", _INVALID_APPROVAL), + email=_text(values, "email", _INVALID_APPROVAL), + ) + + +def _decision(payload: object) -> DurableApprovalDecision | None: + if payload is None: + return None + values = _fields(payload, _INVALID_APPROVAL) + return DurableApprovalDecision( + comment=_text(values, "comment", _INVALID_APPROVAL), + decided_by=_decider(values.get("decided_by")), + decided_at=_timestamp(values.get("decided_at"), _INVALID_APPROVAL), + ) + + +def _expires_at(value: object) -> datetime | None: + if value is None: + return None + return _timestamp(value, _INVALID_APPROVAL) + + +def durable_approval(payload: object) -> DurableApproval: + """Validate and snapshot one durable approval. + + Returns: + The complete approval model. + + """ + values = _fields(payload, _INVALID_APPROVAL) + return DurableApproval( + id=_identifier(values, "id", _INVALID_APPROVAL), + status=_status(values.get("status")), + name=_text(values, "name", _INVALID_APPROVAL), + title=_text(values, "title", _INVALID_APPROVAL), + description=_text(values, "description", _INVALID_APPROVAL), + function=_function(values.get("function"), _INVALID_APPROVAL), + execution=_execution(values.get("execution")), + requested_at=_timestamp(values.get("requested_at"), _INVALID_APPROVAL), + expires_at=_expires_at(values.get("expires_at")), + decision=_decision(values.get("decision")), + details=json_value(values.get("details"), _INVALID_APPROVAL), + ) + + +def durable_approval_page(payload: object) -> DurableApprovalPage: + """Validate and snapshot a page of approvals. + + Returns: + Approvals and validated pagination metadata. + + Raises: + TypeError: The page container or pagination fields are invalid. + + """ + values = _fields(payload, _INVALID_PAGE) + data: object = values.get("data") + if data is None: + data = list[object]() + if not _is_sequence(data): + raise TypeError(_INVALID_PAGE) + has_more = values.get("has_more", False) + if not isinstance(has_more, bool): + raise TypeError(_INVALID_PAGE) + return DurableApprovalPage( + approvals=tuple(durable_approval(entry) for entry in data), + page=_page_count(values.get("page")), + limit=_page_count(values.get("limit")), + total=_page_count(values.get("total")), + has_more=has_more, + ) + + +def _page_count(value: object) -> int: + if value is None: + return 0 + if type(value) is not int: + raise TypeError(_INVALID_PAGE) + return value + + +def _count(values: Mapping[str, object], key: str) -> int: + value = values.get(key) + if type(value) is not int: + raise TypeError(_INVALID_STATS) + return value + + +def _counts(payload: object) -> DurableApprovalCounts: + values = _fields(payload, _INVALID_STATS) + requested, pending, approved, denied, expired, cancelled = ( + _count(values, key) for key in _COUNT_FIELDS + ) + return DurableApprovalCounts( + requested=requested, + pending=pending, + approved=approved, + denied=denied, + expired=expired, + cancelled=cancelled, + ) + + +def _rate(value: object) -> float | None: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(_INVALID_STATS) + if not math.isfinite(value): + raise TypeError(_INVALID_STATS) + return float(value) + + +def _entries(value: object) -> tuple[Mapping[str, object], ...]: + if not _is_sequence(value): + raise TypeError(_INVALID_STATS) + return tuple(_fields(entry, _INVALID_STATS) for entry in value) + + +def _function_counts(values: Mapping[str, object]) -> DurableApprovalFunctionCounts: + return DurableApprovalFunctionCounts( + function=_function(values.get("function"), _INVALID_STATS), + counts=_counts(values.get("counts")), + ) + + +def _day(value: object) -> date: + if not isinstance(value, str): + raise TypeError(_INVALID_STATS) + try: + return date.fromisoformat(value) + except ValueError as error: + raise TypeError(_INVALID_STATS) from error + + +def _daily_counts(values: Mapping[str, object]) -> DurableApprovalDailyCounts: + return DurableApprovalDailyCounts( + day=_day(values.get("date")), counts=_counts(values.get("counts")) + ) + + +def durable_approval_stats(payload: object) -> DurableApprovalStats: + """Validate and snapshot approval stats. + + Returns: + The complete stats model. + + """ + values = _fields(payload, _INVALID_STATS) + return DurableApprovalStats( + from_=_timestamp(values.get("from"), _INVALID_STATS), + to=_timestamp(values.get("to"), _INVALID_STATS), + counts=_counts(values.get("counts")), + approval_rate=_rate(values.get("approval_rate")), + median_seconds_to_decision=_rate(values.get("median_seconds_to_decision")), + p90_seconds_to_decision=_rate(values.get("p90_seconds_to_decision")), + functions=tuple( + _function_counts(entry) for entry in _entries(values.get("functions")) + ), + other_functions=_counts(values.get("other_functions")), + daily=tuple(_daily_counts(entry) for entry in _entries(values.get("daily"))), + ) diff --git a/src/volcano_sdk/_durable_engine.py b/src/volcano_sdk/_durable_engine.py index 1d6e8a1d..ef199481 100644 --- a/src/volcano_sdk/_durable_engine.py +++ b/src/volcano_sdk/_durable_engine.py @@ -9,7 +9,13 @@ from typing_extensions import TypeVar from ._durable_duration import to_seconds -from ._durable_modules import load_config, load_retries, load_root, load_waits +from ._durable_modules import ( + load_config, + load_exceptions, + load_retries, + load_root, + load_waits, +) from ._durable_options import BatchOptions, RetryOptions, Unset if TYPE_CHECKING: @@ -22,6 +28,7 @@ ParallelConfig, StepConfig, StepSemantics, + WaitForCallbackConfig, ) from aws_durable_execution_sdk_python.config import Duration as EngineDuration from aws_durable_execution_sdk_python.retries import ( @@ -89,6 +96,7 @@ def __init__(self) -> None: retries = load_retries() waits = load_waits() root = load_root() + exceptions = load_exceptions() except ImportError as error: raise DurableRuntimeMissingError from error self.durable_execution: DurableExecution = root.durable_execution @@ -109,6 +117,10 @@ def __init__(self) -> None: self.create_wait_strategy: CreateWaitStrategy = waits.create_wait_strategy self.wait_strategy_config: WaitStrategyFactory = waits.WaitStrategyConfig self.wait_for_condition_config: WaitConfigFactory = waits.WaitForConditionConfig + self.wait_for_callback_config: type[WaitForCallbackConfig] = ( + config.WaitForCallbackConfig + ) + self.callback_timeout_error: type[Exception] = exceptions.CallbackTimeoutError def seconds(self, value: int) -> EngineDuration: """Build the runtime's duration from whole seconds. @@ -242,6 +254,22 @@ def _set_wait_timing( if options.backoff_rate is not None: config.backoff_rate = options.backoff_rate + def callback_options(self, timeout: int | None) -> WaitForCallbackConfig: + """Build the runtime's callback options. + + The submitter is not retried by the runtime: it already retries what is + worth retrying, and anything else it raises will not go away. + + Returns: + The runtime wait-for-callback configuration. + + """ + if timeout is None: + return self.wait_for_callback_config(retry_strategy=self._never_retry()) + return self.wait_for_callback_config( + timeout=self.seconds(timeout), retry_strategy=self._never_retry() + ) + def map_options(self, options: BatchOptions | None) -> object: """Build the runtime's map options. diff --git a/src/volcano_sdk/_durable_modules.py b/src/volcano_sdk/_durable_modules.py index 0434cfcb..2cfff4fe 100644 --- a/src/volcano_sdk/_durable_modules.py +++ b/src/volcano_sdk/_durable_modules.py @@ -18,7 +18,9 @@ ParallelConfig, StepConfig, StepSemantics, + WaitForCallbackConfig, ) + from aws_durable_execution_sdk_python.exceptions import CallbackTimeoutError from aws_durable_execution_sdk_python.retries import ( RetryDecision, RetryStrategyConfig, @@ -87,6 +89,14 @@ class ConfigModule(Protocol): ParallelConfig: type[ParallelConfig] CompletionConfig: type[CompletionConfig] ParallelBranch: type[ParallelBranch[object]] + WaitForCallbackConfig: type[WaitForCallbackConfig] + + +@runtime_checkable +class ExceptionsModule(Protocol): + """Runtime errors the authoring adapter turns into values.""" + + CallbackTimeoutError: type[CallbackTimeoutError] @runtime_checkable @@ -168,6 +178,26 @@ def load_waits() -> WaitsModule: return module +def load_exceptions() -> ExceptionsModule: + """Validate the optional runtime's exceptions module. + + Returns: + The validated module interface. + + Raises: + TypeError: The installed runtime lacks a required public export. + + """ + module = _import_module("aws_durable_execution_sdk_python.exceptions") + if not isinstance(module, ExceptionsModule): + message = ( + "aws_durable_execution_sdk_python.exceptions does not provide " + "ExceptionsModule" + ) + raise TypeError(message) + return module + + def load_root() -> RootModule: """Validate the optional runtime's root module. diff --git a/src/volcano_sdk/_durable_protocols.py b/src/volcano_sdk/_durable_protocols.py index 9b260cfe..74dbabf5 100644 --- a/src/volcano_sdk/_durable_protocols.py +++ b/src/volcano_sdk/_durable_protocols.py @@ -52,8 +52,12 @@ def exception( class DurableEngine(Protocol): """The operations the Volcano facade needs from the optional runtime.""" + callback_timeout_error: type[Exception] + def seconds(self, value: int) -> object: ... + def callback_options(self, timeout: int | None) -> object: ... + def step_options(self, *, retry: Retry, at_most_once: bool) -> object: ... def wait_condition_options(self, options: WaitUntilOptions[T]) -> object: ... @@ -95,8 +99,16 @@ def get_errors(self) -> list[object]: ... def throw_if_error(self) -> None: ... +class RuntimeExecution(Protocol): + @property + def durable_execution_arn(self) -> str: ... + + class RuntimeContext(Protocol): logger: DurableLogger + # The same for the root context and every child, map, and parallel context + # the runtime derives from it. + execution_context: RuntimeExecution def step( self, @@ -133,4 +145,11 @@ def parallel( config: object, ) -> RuntimeBatch[T]: ... + def wait_for_callback( + self, + submitter: Callable[[str, object], None], + name: str | None, + config: object, + ) -> object: ... + def set_logger(self, logger: object) -> None: ... diff --git a/src/volcano_sdk/_durable_response.py b/src/volcano_sdk/_durable_response.py index 73075fef..50a42d1d 100644 --- a/src/volcano_sdk/_durable_response.py +++ b/src/volcano_sdk/_durable_response.py @@ -58,12 +58,25 @@ def _execution_status(value: object) -> DurableExecutionStatus: def _json_result(value: object) -> JSONValue: + return json_value(value, _INVALID_EXECUTION_PAYLOAD) + + +def json_value(value: object, message: str) -> JSONValue: + """Require a JSON value that can be frozen and re-encoded. + + Returns: + The value, narrowed to JSON. + + Raises: + TypeError: The value is not JSON, or is nested too deeply to check. + + """ try: if _is_json_value(value, set()): return value except RecursionError as error: - raise TypeError(_INVALID_EXECUTION_PAYLOAD) from error - raise TypeError(_INVALID_EXECUTION_PAYLOAD) + raise TypeError(message) from error + raise TypeError(message) def _is_json_value(value: object, active: set[int]) -> TypeGuard[JSONValue]: diff --git a/src/volcano_sdk/_durable_results.py b/src/volcano_sdk/_durable_results.py index e8c808df..74037b04 100644 --- a/src/volcano_sdk/_durable_results.py +++ b/src/volcano_sdk/_durable_results.py @@ -3,16 +3,38 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Generic +from typing import TYPE_CHECKING, Generic, Literal from typing_extensions import TypeVar if TYPE_CHECKING: from ._durable_protocols import RuntimeBatch, RuntimeBatchItem + from .models import DurableApprovalDecider T = TypeVar("T", default=object) +@dataclass(frozen=True, slots=True) +class ApprovalDecision: + """What `ctx.wait_for_approval` resumes with. + + A denial is a decision like an approval, not an error; branch on + `approved`. An approval nobody decided before its timeout comes back + `expired`, with no comment, decider, or decision time. + """ + + approved: bool + status: Literal["approved", "denied", "expired"] + comment: str = "" + # None when the approval expired, when the deciding account was deleted + # before the decision reached the workflow, or when the decision does not + # name a decider this SDK can read. + decided_by: DurableApprovalDecider | None = None + # RFC 3339, as Volcano recorded it; None when the approval expired or the + # decision carries no readable time. + decided_at: str | None = None + + @dataclass(frozen=True, slots=True) class BatchFailure: """Why one item of a batch failed. diff --git a/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py new file mode 100644 index 00000000..62b17471 --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/approve_durable_approval.py @@ -0,0 +1,280 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval import DurableApproval +from ...models.durable_approval_decision_request import DurableApprovalDecisionRequest +from ...models.error import Error +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + approval_id: UUID | str, + *, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/projects/{id}/durable-approvals/{approval_id}/approve".format(id=quote(str(id), safe=""),approval_id=quote(str(approval_id), safe=""),), + } + + + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApproval | Error | None: + if response.status_code == 200: + response_200 = DurableApproval.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + + 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[DurableApproval | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> Response[DurableApproval | Error]: + """ Approve a durable approval + + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | 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[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, +body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> DurableApproval | Error | None: + """ Approve a durable approval + + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | 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: + DurableApproval | Error + """ + + + return sync_detailed( + id=id, +approval_id=approval_id, +client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> Response[DurableApproval | Error]: + """ Approve a durable approval + + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | 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[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, +body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> DurableApproval | Error | None: + """ Approve a durable approval + + Approves a pending approval. The workflow resumes with the decision. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Approving an approval that is already approved returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | 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: + DurableApproval | Error + """ + + + return (await asyncio_detailed( + id=id, +approval_id=approval_id, +client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py new file mode 100644 index 00000000..75e8e3a2 --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/deny_durable_approval.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval import DurableApproval +from ...models.durable_approval_decision_request import DurableApprovalDecisionRequest +from ...models.error import Error +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + approval_id: UUID | str, + *, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/projects/{id}/durable-approvals/{approval_id}/deny".format(id=quote(str(id), safe=""),approval_id=quote(str(approval_id), safe=""),), + } + + + if not isinstance(body, Unset): + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApproval | Error | None: + if response.status_code == 200: + response_200 = DurableApproval.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 403: + response_403 = Error.from_dict(response.json()) + + + + return response_403 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + + 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[DurableApproval | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> Response[DurableApproval | Error]: + """ Deny a durable approval + + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | 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[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, +body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> DurableApproval | Error | None: + """ Deny a durable approval + + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | 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: + DurableApproval | Error + """ + + + return sync_detailed( + id=id, +approval_id=approval_id, +client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> Response[DurableApproval | Error]: + """ Deny a durable approval + + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | 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[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, +body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + body: DurableApprovalDecisionRequest | Unset = UNSET, + +) -> DurableApproval | Error | None: + """ Deny a durable approval + + Denies a pending approval. The workflow resumes with the decision; a + denial is a value the workflow branches on, not an error. + + Only a person can decide: use the dashboard or a platform token from + `volcano login`. Project access tokens are refused with `403`. + + Denying an approval that is already denied returns it unchanged. + If the workflow cannot be reached right away, the decision still + stands and Volcano keeps delivering it. + + Args: + id (UUID): + approval_id (UUID): + body (DurableApprovalDecisionRequest | 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: + DurableApproval | Error + """ + + + return (await asyncio_detailed( + id=id, +approval_id=approval_id, +client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py new file mode 100644 index 00000000..b0637514 --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval.py @@ -0,0 +1,217 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval import DurableApproval +from ...models.error import Error +from typing import cast +from uuid import UUID + + + +def request_kwargs( + id: UUID | str, + approval_id: UUID | str, + +) -> dict[str, Any]: + + + + + + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/projects/{id}/durable-approvals/{approval_id}".format(id=quote(str(id), safe=""),approval_id=quote(str(approval_id), safe=""),), + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApproval | Error | None: + if response.status_code == 200: + response_200 = DurableApproval.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[DurableApproval | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[DurableApproval | Error]: + """ Get a durable approval + + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + + Args: + id (UUID): + approval_id (UUID): + + 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[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> DurableApproval | Error | None: + """ Get a durable approval + + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + + Args: + id (UUID): + approval_id (UUID): + + 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: + DurableApproval | Error + """ + + + return sync_detailed( + id=id, +approval_id=approval_id, +client=client, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> Response[DurableApproval | Error]: + """ Get a durable approval + + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + + Args: + id (UUID): + approval_id (UUID): + + 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[DurableApproval | Error] + """ + + + kwargs = request_kwargs( + id=id, +approval_id=approval_id, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + approval_id: UUID | str, + *, + client: AuthenticatedClient, + +) -> DurableApproval | Error | None: + """ Get a durable approval + + Returns one approval: what the workflow asked, the durable function + and execution that asked it, its deadline, and the decision once one + is made. + + Project access tokens can read approvals, including read-only ones. + + Args: + id (UUID): + approval_id (UUID): + + 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: + DurableApproval | Error + """ + + + return (await asyncio_detailed( + id=id, +approval_id=approval_id, +client=client, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval_stats.py b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval_stats.py new file mode 100644 index 00000000..a3a229ac --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/get_durable_approval_stats.py @@ -0,0 +1,267 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval_stats import DurableApprovalStats +from ...models.error import Error +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID +import datetime + + + +def request_kwargs( + id: UUID | str, + *, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> dict[str, Any]: + + + + + params: dict[str, Any] = {} + + params["function"] = function + + json_from_: str | Unset = UNSET + if not isinstance(from_, Unset): + json_from_ = from_.isoformat() + params["from"] = json_from_ + + json_to: str | Unset = UNSET + if not isinstance(to, Unset): + json_to = to.isoformat() + params["to"] = json_to + + + 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": "/projects/{id}/durable-approvals/stats".format(id=quote(str(id), safe=""),), + "params": params, + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApprovalStats | Error | None: + if response.status_code == 200: + response_200 = DurableApprovalStats.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + 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[DurableApprovalStats | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Response[DurableApprovalStats | Error]: + """ Get durable approval statistics + + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + + Args: + id (UUID): + function (str | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | 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[DurableApprovalStats | Error] + """ + + + kwargs = request_kwargs( + id=id, +function=function, +from_=from_, +to=to, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + *, + client: AuthenticatedClient, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> DurableApprovalStats | Error | None: + """ Get durable approval statistics + + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + + Args: + id (UUID): + function (str | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | 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: + DurableApprovalStats | Error + """ + + + return sync_detailed( + id=id, +client=client, +function=function, +from_=from_, +to=to, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Response[DurableApprovalStats | Error]: + """ Get durable approval statistics + + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + + Args: + id (UUID): + function (str | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | 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[DurableApprovalStats | Error] + """ + + + kwargs = request_kwargs( + id=id, +function=function, +from_=from_, +to=to, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + *, + client: AuthenticatedClient, + function: str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> DurableApprovalStats | Error | None: + """ Get durable approval statistics + + Counts the project's approvals by outcome over a time window, overall, + per workflow, and per day. The window defaults to the last 30 days and + can cover up to a year. An approval is counted on the day it was + requested. + + Project access tokens can read statistics, including read-only ones. + + Args: + id (UUID): + function (str | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | 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: + DurableApprovalStats | Error + """ + + + return (await asyncio_detailed( + id=id, +client=client, +function=function, +from_=from_, +to=to, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/list_durable_approvals.py b/src/volcano_sdk/_generated/api/durable_functions/list_durable_approvals.py new file mode 100644 index 00000000..2501866b --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/list_durable_approvals.py @@ -0,0 +1,352 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval_status import check_durable_approval_status +from ...models.durable_approval_status import DurableApprovalStatus +from ...models.error import Error +from ...models.paginated_durable_approvals import PaginatedDurableApprovals +from ...types import UNSET, Unset +from typing import cast +from uuid import UUID +import datetime + + + +def request_kwargs( + id: UUID | str, + *, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> dict[str, Any]: + + + + + params: dict[str, Any] = {} + + params["page"] = page + + params["limit"] = limit + + json_status: str | Unset = UNSET + if not isinstance(status, Unset): + json_status = status + + params["status"] = json_status + + params["function"] = function + + json_execution_id: str | Unset = UNSET + if not isinstance(execution_id, Unset): + json_execution_id = str(execution_id) + params["execution_id"] = json_execution_id + + json_from_: str | Unset = UNSET + if not isinstance(from_, Unset): + json_from_ = from_.isoformat() + params["from"] = json_from_ + + json_to: str | Unset = UNSET + if not isinstance(to, Unset): + json_to = to.isoformat() + params["to"] = json_to + + + 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": "/projects/{id}/durable-approvals".format(id=quote(str(id), safe=""),), + "params": params, + } + + + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Error | PaginatedDurableApprovals | None: + if response.status_code == 200: + response_200 = PaginatedDurableApprovals.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Error | PaginatedDurableApprovals]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Response[Error | PaginatedDurableApprovals]: + """ List durable approvals + + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + + Args: + id (UUID): + page (int | Unset): + limit (int | Unset): Default: 10. + status (DurableApprovalStatus | Unset): `pending` means the workflow is waiting for a + decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + function (str | Unset): + execution_id (UUID | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | PaginatedDurableApprovals] + """ + + + kwargs = request_kwargs( + id=id, +page=page, +limit=limit, +status=status, +function=function, +execution_id=execution_id, +from_=from_, +to=to, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + id: UUID | str, + *, + client: AuthenticatedClient, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Error | PaginatedDurableApprovals | None: + """ List durable approvals + + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + + Args: + id (UUID): + page (int | Unset): + limit (int | Unset): Default: 10. + status (DurableApprovalStatus | Unset): `pending` means the workflow is waiting for a + decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + function (str | Unset): + execution_id (UUID | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | PaginatedDurableApprovals + """ + + + return sync_detailed( + id=id, +client=client, +page=page, +limit=limit, +status=status, +function=function, +execution_id=execution_id, +from_=from_, +to=to, + + ).parsed + +async def asyncio_detailed( + id: UUID | str, + *, + client: AuthenticatedClient, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Response[Error | PaginatedDurableApprovals]: + """ List durable approvals + + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + + Args: + id (UUID): + page (int | Unset): + limit (int | Unset): Default: 10. + status (DurableApprovalStatus | Unset): `pending` means the workflow is waiting for a + decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + function (str | Unset): + execution_id (UUID | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Error | PaginatedDurableApprovals] + """ + + + kwargs = request_kwargs( + id=id, +page=page, +limit=limit, +status=status, +function=function, +execution_id=execution_id, +from_=from_, +to=to, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + id: UUID | str, + *, + client: AuthenticatedClient, + page: int | Unset = UNSET, + limit: int | Unset = 10, + status: DurableApprovalStatus | Unset = UNSET, + function: str | Unset = UNSET, + execution_id: UUID | str | Unset = UNSET, + from_: datetime.datetime | Unset = UNSET, + to: datetime.datetime | Unset = UNSET, + +) -> Error | PaginatedDurableApprovals | None: + """ List durable approvals + + Lists the approvals durable workflows in the project have requested, + newest first. Pending approvals are the ones a workflow is waiting on; + decided, expired, and cancelled ones are kept for a year. + + Project access tokens can list approvals, including read-only ones. + + Args: + id (UUID): + page (int | Unset): + limit (int | Unset): Default: 10. + status (DurableApprovalStatus | Unset): `pending` means the workflow is waiting for a + decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + function (str | Unset): + execution_id (UUID | Unset): + from_ (datetime.datetime | Unset): + to (datetime.datetime | Unset): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Error | PaginatedDurableApprovals + """ + + + return (await asyncio_detailed( + id=id, +client=client, +page=page, +limit=limit, +status=status, +function=function, +execution_id=execution_id, +from_=from_, +to=to, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/durable_functions/request_durable_approval.py b/src/volcano_sdk/_generated/api/durable_functions/request_durable_approval.py new file mode 100644 index 00000000..ba8dbc9c --- /dev/null +++ b/src/volcano_sdk/_generated/api/durable_functions/request_durable_approval.py @@ -0,0 +1,280 @@ +from http import HTTPStatus +from typing import Any, cast +from urllib.parse import quote + +import httpx + +from ...client import AuthenticatedClient, Client +from ...types import Response, UNSET +from ... import errors + +from ...models.durable_approval_registration import DurableApprovalRegistration +from ...models.error import Error +from ...models.request_durable_approval_request import RequestDurableApprovalRequest +from typing import cast + + + +def request_kwargs( + *, + body: RequestDurableApprovalRequest, + +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + + + + + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/durable-approvals", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + + +def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> DurableApprovalRegistration | Error | None: + if response.status_code == 200: + response_200 = DurableApprovalRegistration.from_dict(response.json()) + + + + return response_200 + + if response.status_code == 201: + response_201 = DurableApprovalRegistration.from_dict(response.json()) + + + + return response_201 + + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + + + return response_400 + + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + + + return response_404 + + if response.status_code == 409: + response_409 = Error.from_dict(response.json()) + + + + return response_409 + + if response.status_code == 413: + response_413 = Error.from_dict(response.json()) + + + + return response_413 + + if response.status_code == 429: + response_429 = Error.from_dict(response.json()) + + + + return response_429 + + if response.status_code == 503: + response_503 = Error.from_dict(response.json()) + + + + return response_503 + + 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[DurableApprovalRegistration | Error]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient | Client, + body: RequestDurableApprovalRequest, + +) -> Response[DurableApprovalRegistration | Error]: + """ Request an approval from inside a workflow + + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + + Args: + body (RequestDurableApprovalRequest): + + 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[DurableApprovalRegistration | Error] + """ + + + kwargs = request_kwargs( + body=body, + + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return build_response(client=client, response=response) + +def sync( + *, + client: AuthenticatedClient | Client, + body: RequestDurableApprovalRequest, + +) -> DurableApprovalRegistration | Error | None: + """ Request an approval from inside a workflow + + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + + Args: + body (RequestDurableApprovalRequest): + + 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: + DurableApprovalRegistration | Error + """ + + + return sync_detailed( + client=client, +body=body, + + ).parsed + +async def asyncio_detailed( + *, + client: AuthenticatedClient | Client, + body: RequestDurableApprovalRequest, + +) -> Response[DurableApprovalRegistration | Error]: + """ Request an approval from inside a workflow + + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + + Args: + body (RequestDurableApprovalRequest): + + 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[DurableApprovalRegistration | Error] + """ + + + kwargs = request_kwargs( + body=body, + + ) + + response = await client.get_async_httpx_client().request( + **kwargs + ) + + return build_response(client=client, response=response) + +async def asyncio( + *, + client: AuthenticatedClient | Client, + body: RequestDurableApprovalRequest, + +) -> DurableApprovalRegistration | Error | None: + """ Request an approval from inside a workflow + + Registers an approval request for a running durable execution. The + Volcano SDK calls this from `ctx.waitForApproval`; applications do not + call it directly. + + `execution_ref` and `callback_id` are opaque values the SDK reads from + the running workflow. Together they are the request's credential, so + the operation takes no other authentication. The approval belongs to + the project that owns the execution, and expires when the workflow's + own approval timeout does. + + Retrying is safe: a request for an approval that is already registered + returns it with `200`. + + Args: + body (RequestDurableApprovalRequest): + + 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: + DurableApprovalRegistration | Error + """ + + + return (await asyncio_detailed( + client=client, +body=body, + + )).parsed diff --git a/src/volcano_sdk/_generated/api/frontends/create_frontend_custom_domain.py b/src/volcano_sdk/_generated/api/frontends/create_frontend_custom_domain.py index 56667d0e..8c655110 100644 --- a/src/volcano_sdk/_generated/api/frontends/create_frontend_custom_domain.py +++ b/src/volcano_sdk/_generated/api/frontends/create_frontend_custom_domain.py @@ -132,7 +132,7 @@ def sync_detailed( body: CreateFrontendCustomDomainRequest, ) -> Response[Error | FrontendCustomDomainConflictError | FrontendCustomDomainResponse]: - """ Configure frontend custom domain (SUPERAGENT) + """ Configure frontend custom domain Configures one custom domain for a frontend. The default Volcano-generated frontend URL remains active. @@ -188,7 +188,7 @@ def sync( body: CreateFrontendCustomDomainRequest, ) -> Error | FrontendCustomDomainConflictError | FrontendCustomDomainResponse | None: - """ Configure frontend custom domain (SUPERAGENT) + """ Configure frontend custom domain Configures one custom domain for a frontend. The default Volcano-generated frontend URL remains active. @@ -239,7 +239,7 @@ async def asyncio_detailed( body: CreateFrontendCustomDomainRequest, ) -> Response[Error | FrontendCustomDomainConflictError | FrontendCustomDomainResponse]: - """ Configure frontend custom domain (SUPERAGENT) + """ Configure frontend custom domain Configures one custom domain for a frontend. The default Volcano-generated frontend URL remains active. @@ -295,7 +295,7 @@ async def asyncio( body: CreateFrontendCustomDomainRequest, ) -> Error | FrontendCustomDomainConflictError | FrontendCustomDomainResponse | None: - """ Configure frontend custom domain (SUPERAGENT) + """ Configure frontend custom domain Configures one custom domain for a frontend. The default Volcano-generated frontend URL remains active. diff --git a/src/volcano_sdk/_generated/models/__init__.py b/src/volcano_sdk/_generated/models/__init__.py index dc7871fe..cfd528b8 100644 --- a/src/volcano_sdk/_generated/models/__init__.py +++ b/src/volcano_sdk/_generated/models/__init__.py @@ -202,6 +202,18 @@ from .deployment_progress_current_phase import DeploymentProgressCurrentPhase from .deployment_reference import DeploymentReference from .device_authorization_response import DeviceAuthorizationResponse +from .durable_approval import DurableApproval +from .durable_approval_counts import DurableApprovalCounts +from .durable_approval_daily_counts import DurableApprovalDailyCounts +from .durable_approval_decider_type_0 import DurableApprovalDeciderType0 +from .durable_approval_decision_request import DurableApprovalDecisionRequest +from .durable_approval_decision_type_0 import DurableApprovalDecisionType0 +from .durable_approval_execution import DurableApprovalExecution +from .durable_approval_function import DurableApprovalFunction +from .durable_approval_function_counts import DurableApprovalFunctionCounts +from .durable_approval_registration import DurableApprovalRegistration +from .durable_approval_stats import DurableApprovalStats +from .durable_approval_status import DurableApprovalStatus from .durable_execution import DurableExecution from .durable_execution_error import DurableExecutionError from .durable_execution_invocation import DurableExecutionInvocation @@ -350,6 +362,7 @@ from .open_api_spec_document import OpenAPISpecDocument from .paginated_auth_users import PaginatedAuthUsers from .paginated_databases import PaginatedDatabases +from .paginated_durable_approvals import PaginatedDurableApprovals from .paginated_durable_executions import PaginatedDurableExecutions from .paginated_durable_functions import PaginatedDurableFunctions from .paginated_frontend_deployments import PaginatedFrontendDeployments @@ -509,6 +522,7 @@ from .render_default_managed_auth_page_action import RenderDefaultManagedAuthPageAction from .replace_frontend_shared_variables_body import ReplaceFrontendSharedVariablesBody from .replace_shared_variables_body import ReplaceSharedVariablesBody +from .request_durable_approval_request import RequestDurableApprovalRequest from .reset_database_password_response_200 import ResetDatabasePasswordResponse200 from .resolve_function_response import ResolveFunctionResponse from .resource_reference import ResourceReference @@ -801,6 +815,18 @@ "DeploySandboxBody", "DeploySandboxBodyMemoryMb", "DeviceAuthorizationResponse", + "DurableApproval", + "DurableApprovalCounts", + "DurableApprovalDailyCounts", + "DurableApprovalDeciderType0", + "DurableApprovalDecisionRequest", + "DurableApprovalDecisionType0", + "DurableApprovalExecution", + "DurableApprovalFunction", + "DurableApprovalFunctionCounts", + "DurableApprovalRegistration", + "DurableApprovalStats", + "DurableApprovalStatus", "DurableExecution", "DurableExecutionError", "DurableExecutionInvocation", @@ -949,6 +975,7 @@ "OpenAPISpecDocument", "PaginatedAuthUsers", "PaginatedDatabases", + "PaginatedDurableApprovals", "PaginatedDurableExecutions", "PaginatedDurableFunctions", "PaginatedFrontendDeployments", @@ -1108,6 +1135,7 @@ "RenderDefaultManagedAuthPageAction", "ReplaceFrontendSharedVariablesBody", "ReplaceSharedVariablesBody", + "RequestDurableApprovalRequest", "ResetDatabasePasswordResponse200", "ResolveFunctionResponse", "ResourceReference", diff --git a/src/volcano_sdk/_generated/models/create_database_request_pg_version.py b/src/volcano_sdk/_generated/models/create_database_request_pg_version.py index cea14af9..7fef71a6 100644 --- a/src/volcano_sdk/_generated/models/create_database_request_pg_version.py +++ b/src/volcano_sdk/_generated/models/create_database_request_pg_version.py @@ -1,8 +1,8 @@ from typing import Literal -CreateDatabaseRequestPgVersion = Literal['15', '16', '17', '18'] +CreateDatabaseRequestPgVersion = Literal['16', '17', '18'] -CREATE_DATABASE_REQUEST_PG_VERSION_VALUES: set[CreateDatabaseRequestPgVersion] = { '15', '16', '17', '18', } +CREATE_DATABASE_REQUEST_PG_VERSION_VALUES: set[CreateDatabaseRequestPgVersion] = { '16', '17', '18', } def check_create_database_request_pg_version(value: str) -> CreateDatabaseRequestPgVersion: if value in CREATE_DATABASE_REQUEST_PG_VERSION_VALUES: diff --git a/src/volcano_sdk/_generated/models/database_insert_request.py b/src/volcano_sdk/_generated/models/database_insert_request.py index ac79d22b..dc17f64a 100644 --- a/src/volcano_sdk/_generated/models/database_insert_request.py +++ b/src/volcano_sdk/_generated/models/database_insert_request.py @@ -26,8 +26,10 @@ class DatabaseInsertRequest: """ Attributes: table (str): Table name Example: posts. - values (DatabaseInsertRequestValues): Column values to insert Example: {'title': 'My New Post', 'content': 'This - is the content', 'status': 'draft'}. + values (DatabaseInsertRequestValues): Column values to insert. JSON objects and arrays are stored as JSON, + so send them to `json` or `jsonb` columns. For a Postgres array + column, send an array literal string such as `"{a,b}"`. + Example: {'title': 'My New Post', 'content': 'This is the content', 'status': 'draft'}. """ table: str diff --git a/src/volcano_sdk/_generated/models/database_insert_request_values.py b/src/volcano_sdk/_generated/models/database_insert_request_values.py index 2c169965..99997cb6 100644 --- a/src/volcano_sdk/_generated/models/database_insert_request_values.py +++ b/src/volcano_sdk/_generated/models/database_insert_request_values.py @@ -20,7 +20,9 @@ @_attrs_define class DatabaseInsertRequestValues: - """ Column values to insert + """ Column values to insert. JSON objects and arrays are stored as JSON, + so send them to `json` or `jsonb` columns. For a Postgres array + column, send an array literal string such as `"{a,b}"`. Example: {'title': 'My New Post', 'content': 'This is the content', 'status': 'draft'} diff --git a/src/volcano_sdk/_generated/models/database_update_request.py b/src/volcano_sdk/_generated/models/database_update_request.py index 8f1801ff..a6875529 100644 --- a/src/volcano_sdk/_generated/models/database_update_request.py +++ b/src/volcano_sdk/_generated/models/database_update_request.py @@ -27,8 +27,10 @@ class DatabaseUpdateRequest: """ Attributes: table (str): Table name Example: posts. - values (DatabaseUpdateRequestValues): Column values to update Example: {'title': 'Updated Title', 'status': - 'published'}. + values (DatabaseUpdateRequestValues): Column values to update. JSON objects and arrays are stored as JSON, + so send them to `json` or `jsonb` columns. For a Postgres array + column, send an array literal string such as `"{a,b}"`. + Example: {'title': 'Updated Title', 'status': 'published'}. filters (list[DatabaseQueryFilter]): WHERE conditions for which rows to update. At least one filter is required; a filterless update is rejected to avoid rewriting every row. diff --git a/src/volcano_sdk/_generated/models/database_update_request_values.py b/src/volcano_sdk/_generated/models/database_update_request_values.py index 155cc842..40876b70 100644 --- a/src/volcano_sdk/_generated/models/database_update_request_values.py +++ b/src/volcano_sdk/_generated/models/database_update_request_values.py @@ -20,7 +20,9 @@ @_attrs_define class DatabaseUpdateRequestValues: - """ Column values to update + """ Column values to update. JSON objects and arrays are stored as JSON, + so send them to `json` or `jsonb` columns. For a Postgres array + column, send an array literal string such as `"{a,b}"`. Example: {'title': 'Updated Title', 'status': 'published'} diff --git a/src/volcano_sdk/_generated/models/durable_approval.py b/src/volcano_sdk/_generated/models/durable_approval.py new file mode 100644 index 00000000..a270dd7a --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_approval_status import check_durable_approval_status +from ..models.durable_approval_status import DurableApprovalStatus +from ..types import UNSET, Unset +from typing import cast +from uuid import UUID +import datetime + +if TYPE_CHECKING: + from ..models.durable_approval_decision_type_0 import DurableApprovalDecisionType0 + from ..models.durable_approval_execution import DurableApprovalExecution + from ..models.durable_approval_function import DurableApprovalFunction + + + + + +T = TypeVar("T", bound="DurableApproval") + + + +@_attrs_define +class DurableApproval: + """ An approval a durable workflow requested. + + Attributes: + id (UUID): + status (DurableApprovalStatus): `pending` means the workflow is waiting for a decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + name (str): The name the workflow gave the approval in `ctx.waitForApproval`. + title (str): + description (str): + function (DurableApprovalFunction): The durable function that requested the approval. `id` is null once the + function has been deleted; `name` is kept. + execution (DurableApprovalExecution): The durable execution that requested the approval. `id` and `status` + are null once the execution is no longer retained; `name` is kept. + requested_at (datetime.datetime): + expires_at (datetime.datetime | None): When the approval expires if nobody decides. Null when the + workflow set no timeout; the approval then lasts as long as its + execution. + decision (DurableApprovalDecisionType0 | None): Who decided and when. Null unless the approval was approved or + denied. + details (Any | Unset): The JSON value the workflow attached for the person deciding, as + given. + """ + + id: UUID + status: DurableApprovalStatus + name: str + title: str + description: str + function: DurableApprovalFunction + execution: DurableApprovalExecution + requested_at: datetime.datetime + expires_at: datetime.datetime | None + decision: DurableApprovalDecisionType0 | None + details: Any | Unset = UNSET + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_decision_type_0 import DurableApprovalDecisionType0 # noqa: PLC0415 + from ..models.durable_approval_execution import DurableApprovalExecution # noqa: PLC0415 + from ..models.durable_approval_function import DurableApprovalFunction # noqa: PLC0415 + id = str(self.id) + + status: str = self.status + + name = self.name + + title = self.title + + description = self.description + + function = self.function.to_dict() + + execution = self.execution.to_dict() + + requested_at = self.requested_at.isoformat() + + expires_at: None | str + if isinstance(self.expires_at, datetime.datetime): + expires_at = self.expires_at.isoformat() + else: + expires_at = self.expires_at + + decision: dict[str, Any] | None + if isinstance(self.decision, DurableApprovalDecisionType0): + decision = self.decision.to_dict() + else: + decision = self.decision + + details = self.details + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "status": status, + "name": name, + "title": title, + "description": description, + "function": function, + "execution": execution, + "requested_at": requested_at, + "expires_at": expires_at, + "decision": decision, + }) + if details is not UNSET: + field_dict["details"] = details + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_decision_type_0 import DurableApprovalDecisionType0 # noqa: PLC0415 + from ..models.durable_approval_execution import DurableApprovalExecution # noqa: PLC0415 + from ..models.durable_approval_function import DurableApprovalFunction # noqa: PLC0415 + d = dict(src_dict) + id = UUID(d.pop("id")) + + + + + status = check_durable_approval_status(d.pop("status")) + + + + + name = d.pop("name") + + title = d.pop("title") + + description = d.pop("description") + + function = DurableApprovalFunction.from_dict(d.pop("function")) + + + + + execution = DurableApprovalExecution.from_dict(d.pop("execution")) + + + + + requested_at = datetime.datetime.fromisoformat(d.pop("requested_at")) + + + + + def _parse_expires_at(data: object) -> datetime.datetime | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + expires_at_type_0 = datetime.datetime.fromisoformat(data) + + + + return expires_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None, data) + + expires_at = _parse_expires_at(d.pop("expires_at")) + + + def _parse_decision(data: object) -> DurableApprovalDecisionType0 | None: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_durable_approval_decision_type_0 = DurableApprovalDecisionType0.from_dict(data) + + + + return componentsschemas_durable_approval_decision_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DurableApprovalDecisionType0 | None, data) + + decision = _parse_decision(d.pop("decision")) + + + details = d.pop("details", UNSET) + + durable_approval = cls( + id=id, + status=status, + name=name, + title=title, + description=description, + function=function, + execution=execution, + requested_at=requested_at, + expires_at=expires_at, + decision=decision, + details=details, + ) + + + durable_approval.additional_properties = d + return durable_approval + + @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/volcano_sdk/_generated/models/durable_approval_counts.py b/src/volcano_sdk/_generated/models/durable_approval_counts.py new file mode 100644 index 00000000..b1a19455 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_counts.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + + + + + + + +T = TypeVar("T", bound="DurableApprovalCounts") + + + +@_attrs_define +class DurableApprovalCounts: + """ Approvals by status. `requested` is every approval, whatever its status. + + Attributes: + requested (int): + pending (int): + approved (int): + denied (int): + expired (int): + cancelled (int): + """ + + requested: int + pending: int + approved: int + denied: int + expired: int + cancelled: int + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + requested = self.requested + + pending = self.pending + + approved = self.approved + + denied = self.denied + + expired = self.expired + + cancelled = self.cancelled + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "requested": requested, + "pending": pending, + "approved": approved, + "denied": denied, + "expired": expired, + "cancelled": cancelled, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + requested = d.pop("requested") + + pending = d.pop("pending") + + approved = d.pop("approved") + + denied = d.pop("denied") + + expired = d.pop("expired") + + cancelled = d.pop("cancelled") + + durable_approval_counts = cls( + requested=requested, + pending=pending, + approved=approved, + denied=denied, + expired=expired, + cancelled=cancelled, + ) + + + durable_approval_counts.additional_properties = d + return durable_approval_counts + + @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/volcano_sdk/_generated/models/durable_approval_daily_counts.py b/src/volcano_sdk/_generated/models/durable_approval_daily_counts.py new file mode 100644 index 00000000..2d76f504 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_daily_counts.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast +import datetime + +if TYPE_CHECKING: + from ..models.durable_approval_counts import DurableApprovalCounts + + + + + +T = TypeVar("T", bound="DurableApprovalDailyCounts") + + + +@_attrs_define +class DurableApprovalDailyCounts: + """ + Attributes: + date (datetime.date): + counts (DurableApprovalCounts): Approvals by status. `requested` is every approval, whatever its status. + """ + + date: datetime.date + counts: DurableApprovalCounts + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + date = self.date.isoformat() + + counts = self.counts.to_dict() + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "date": date, + "counts": counts, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + d = dict(src_dict) + date = datetime.date.fromisoformat(d.pop("date")) + + + + + counts = DurableApprovalCounts.from_dict(d.pop("counts")) + + + + + durable_approval_daily_counts = cls( + date=date, + counts=counts, + ) + + + durable_approval_daily_counts.additional_properties = d + return durable_approval_daily_counts + + @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/volcano_sdk/_generated/models/durable_approval_decider_type_0.py b/src/volcano_sdk/_generated/models/durable_approval_decider_type_0.py new file mode 100644 index 00000000..ad216085 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_decider_type_0.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + + + + + + + +T = TypeVar("T", bound="DurableApprovalDeciderType0") + + + +@_attrs_define +class DurableApprovalDeciderType0: + """ The person who decided. Null once their account is deleted. + + Attributes: + id (str): + email (str): + """ + + id: str + email: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id = self.id + + email = self.email + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "email": email, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = d.pop("id") + + email = d.pop("email") + + durable_approval_decider_type_0 = cls( + id=id, + email=email, + ) + + + durable_approval_decider_type_0.additional_properties = d + return durable_approval_decider_type_0 + + @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/volcano_sdk/_generated/models/durable_approval_decision_request.py b/src/volcano_sdk/_generated/models/durable_approval_decision_request.py new file mode 100644 index 00000000..7f383b9e --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_decision_request.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..types import UNSET, Unset + + + + + + +T = TypeVar("T", bound="DurableApprovalDecisionRequest") + + + +@_attrs_define +class DurableApprovalDecisionRequest: + """ + Attributes: + comment (str | Unset): A note for the workflow and the approval's history. + """ + + comment: str | Unset = UNSET + + + + + + def to_dict(self) -> dict[str, Any]: + comment = self.comment + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + }) + if comment is not UNSET: + field_dict["comment"] = comment + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + comment = d.pop("comment", UNSET) + + durable_approval_decision_request = cls( + comment=comment, + ) + + return durable_approval_decision_request + diff --git a/src/volcano_sdk/_generated/models/durable_approval_decision_type_0.py b/src/volcano_sdk/_generated/models/durable_approval_decision_type_0.py new file mode 100644 index 00000000..056b03f4 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_decision_type_0.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast +import datetime + +if TYPE_CHECKING: + from ..models.durable_approval_decider_type_0 import DurableApprovalDeciderType0 + + + + + +T = TypeVar("T", bound="DurableApprovalDecisionType0") + + + +@_attrs_define +class DurableApprovalDecisionType0: + """ Who decided and when. Null unless the approval was approved or denied. + + Attributes: + comment (str): + decided_by (DurableApprovalDeciderType0 | None): The person who decided. Null once their account is deleted. + decided_at (datetime.datetime): + """ + + comment: str + decided_by: DurableApprovalDeciderType0 | None + decided_at: datetime.datetime + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_decider_type_0 import DurableApprovalDeciderType0 # noqa: PLC0415 + comment = self.comment + + decided_by: dict[str, Any] | None + if isinstance(self.decided_by, DurableApprovalDeciderType0): + decided_by = self.decided_by.to_dict() + else: + decided_by = self.decided_by + + decided_at = self.decided_at.isoformat() + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "comment": comment, + "decided_by": decided_by, + "decided_at": decided_at, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_decider_type_0 import DurableApprovalDeciderType0 # noqa: PLC0415 + d = dict(src_dict) + comment = d.pop("comment") + + def _parse_decided_by(data: object) -> DurableApprovalDeciderType0 | None: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + componentsschemas_durable_approval_decider_type_0 = DurableApprovalDeciderType0.from_dict(data) + + + + return componentsschemas_durable_approval_decider_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DurableApprovalDeciderType0 | None, data) + + decided_by = _parse_decided_by(d.pop("decided_by")) + + + decided_at = datetime.datetime.fromisoformat(d.pop("decided_at")) + + + + + durable_approval_decision_type_0 = cls( + comment=comment, + decided_by=decided_by, + decided_at=decided_at, + ) + + + durable_approval_decision_type_0.additional_properties = d + return durable_approval_decision_type_0 + + @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/volcano_sdk/_generated/models/durable_approval_execution.py b/src/volcano_sdk/_generated/models/durable_approval_execution.py new file mode 100644 index 00000000..f6faaeee --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_execution.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_execution_status import check_durable_execution_status +from ..models.durable_execution_status import DurableExecutionStatus +from typing import cast +from uuid import UUID + + + + + + +T = TypeVar("T", bound="DurableApprovalExecution") + + + +@_attrs_define +class DurableApprovalExecution: + """ The durable execution that requested the approval. `id` and `status` + are null once the execution is no longer retained; `name` is kept. + + Attributes: + id (None | UUID): + name (str): + status (DurableExecutionStatus | None): + """ + + id: None | UUID + name: str + status: DurableExecutionStatus | None + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id: None | str + if isinstance(self.id, UUID): + id = str(self.id) + else: + id = self.id + + name = self.name + + status: None | str + if isinstance(self.status, str): + status = self.status + else: + status = self.status + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "name": name, + "status": status, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + def _parse_id(data: object) -> None | UUID: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + id_type_0 = UUID(data) + + + + return id_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | UUID, data) + + id = _parse_id(d.pop("id")) + + + name = d.pop("name") + + def _parse_status(data: object) -> DurableExecutionStatus | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + status_type_1 = check_durable_execution_status(data) + + + + return status_type_1 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(DurableExecutionStatus | None, data) + + status = _parse_status(d.pop("status")) + + + durable_approval_execution = cls( + id=id, + name=name, + status=status, + ) + + + durable_approval_execution.additional_properties = d + return durable_approval_execution + + @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/volcano_sdk/_generated/models/durable_approval_function.py b/src/volcano_sdk/_generated/models/durable_approval_function.py new file mode 100644 index 00000000..c9f5fcbf --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_function.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast +from uuid import UUID + + + + + + +T = TypeVar("T", bound="DurableApprovalFunction") + + + +@_attrs_define +class DurableApprovalFunction: + """ The durable function that requested the approval. `id` is null once the + function has been deleted; `name` is kept. + + Attributes: + id (None | UUID): + name (str): + """ + + id: None | UUID + name: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id: None | str + if isinstance(self.id, UUID): + id = str(self.id) + else: + id = self.id + + name = self.name + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "name": name, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + def _parse_id(data: object) -> None | UUID: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + id_type_0 = UUID(data) + + + + return id_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | UUID, data) + + id = _parse_id(d.pop("id")) + + + name = d.pop("name") + + durable_approval_function = cls( + id=id, + name=name, + ) + + + durable_approval_function.additional_properties = d + return durable_approval_function + + @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/volcano_sdk/_generated/models/durable_approval_function_counts.py b/src/volcano_sdk/_generated/models/durable_approval_function_counts.py new file mode 100644 index 00000000..ca5090c8 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_function_counts.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast + +if TYPE_CHECKING: + from ..models.durable_approval_counts import DurableApprovalCounts + from ..models.durable_approval_function import DurableApprovalFunction + + + + + +T = TypeVar("T", bound="DurableApprovalFunctionCounts") + + + +@_attrs_define +class DurableApprovalFunctionCounts: + """ + Attributes: + function (DurableApprovalFunction): The durable function that requested the approval. `id` is null once the + function has been deleted; `name` is kept. + counts (DurableApprovalCounts): Approvals by status. `requested` is every approval, whatever its status. + """ + + function: DurableApprovalFunction + counts: DurableApprovalCounts + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + from ..models.durable_approval_function import DurableApprovalFunction # noqa: PLC0415 + function = self.function.to_dict() + + counts = self.counts.to_dict() + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "function": function, + "counts": counts, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + from ..models.durable_approval_function import DurableApprovalFunction # noqa: PLC0415 + d = dict(src_dict) + function = DurableApprovalFunction.from_dict(d.pop("function")) + + + + + counts = DurableApprovalCounts.from_dict(d.pop("counts")) + + + + + durable_approval_function_counts = cls( + function=function, + counts=counts, + ) + + + durable_approval_function_counts.additional_properties = d + return durable_approval_function_counts + + @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/volcano_sdk/_generated/models/durable_approval_registration.py b/src/volcano_sdk/_generated/models/durable_approval_registration.py new file mode 100644 index 00000000..1bc3af7d --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_registration.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..models.durable_approval_status import check_durable_approval_status +from ..models.durable_approval_status import DurableApprovalStatus +from typing import cast +from uuid import UUID +import datetime + + + + + + +T = TypeVar("T", bound="DurableApprovalRegistration") + + + +@_attrs_define +class DurableApprovalRegistration: + """ The registered approval, as the requesting workflow sees it. + + Attributes: + id (UUID): + status (DurableApprovalStatus): `pending` means the workflow is waiting for a decision. `approved` and + `denied` are decisions a person made. `expired` means the workflow's + approval timeout passed first, and `cancelled` means the execution + ended while the approval was still pending. Every status but `pending` + is final. + expires_at (datetime.datetime | None): + """ + + id: UUID + status: DurableApprovalStatus + expires_at: datetime.datetime | None + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + id = str(self.id) + + status: str = self.status + + expires_at: None | str + if isinstance(self.expires_at, datetime.datetime): + expires_at = self.expires_at.isoformat() + else: + expires_at = self.expires_at + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "id": id, + "status": status, + "expires_at": expires_at, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + id = UUID(d.pop("id")) + + + + + status = check_durable_approval_status(d.pop("status")) + + + + + def _parse_expires_at(data: object) -> datetime.datetime | None: + if data is None: + return data + try: + if not isinstance(data, str): + raise TypeError() + expires_at_type_0 = datetime.datetime.fromisoformat(data) + + + + return expires_at_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(datetime.datetime | None, data) + + expires_at = _parse_expires_at(d.pop("expires_at")) + + + durable_approval_registration = cls( + id=id, + status=status, + expires_at=expires_at, + ) + + + durable_approval_registration.additional_properties = d + return durable_approval_registration + + @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/volcano_sdk/_generated/models/durable_approval_stats.py b/src/volcano_sdk/_generated/models/durable_approval_stats.py new file mode 100644 index 00000000..f6a09115 --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_stats.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast +import datetime + +if TYPE_CHECKING: + from ..models.durable_approval_counts import DurableApprovalCounts + from ..models.durable_approval_daily_counts import DurableApprovalDailyCounts + from ..models.durable_approval_function_counts import DurableApprovalFunctionCounts + + + + + +T = TypeVar("T", bound="DurableApprovalStats") + + + +@_attrs_define +class DurableApprovalStats: + """ + Attributes: + from_ (datetime.datetime): + to (datetime.datetime): + counts (DurableApprovalCounts): Approvals by status. `requested` is every approval, whatever its status. + approval_rate (float | None): `approved / (approved + denied)`, from 0 to 1. Null when nothing in + the window was decided. + median_seconds_to_decision (float | None): Median time from request to decision. Null when nothing was decided. + p90_seconds_to_decision (float | None): 90th percentile time from request to decision. Null when nothing was + decided. + functions (list[DurableApprovalFunctionCounts]): The ten workflows that requested the most approvals, most + first. + The rest are summed in `other_functions`. + other_functions (DurableApprovalCounts): Approvals by status. `requested` is every approval, whatever its + status. + daily (list[DurableApprovalDailyCounts]): One entry per UTC day in the window that has approvals, oldest + first. Days with none are omitted. + """ + + from_: datetime.datetime + to: datetime.datetime + counts: DurableApprovalCounts + approval_rate: float | None + median_seconds_to_decision: float | None + p90_seconds_to_decision: float | None + functions: list[DurableApprovalFunctionCounts] + other_functions: DurableApprovalCounts + daily: list[DurableApprovalDailyCounts] + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + from ..models.durable_approval_daily_counts import DurableApprovalDailyCounts # noqa: PLC0415 + from ..models.durable_approval_function_counts import DurableApprovalFunctionCounts # noqa: PLC0415 + from_ = self.from_.isoformat() + + to = self.to.isoformat() + + counts = self.counts.to_dict() + + approval_rate: float | None + approval_rate = self.approval_rate + + median_seconds_to_decision: float | None + median_seconds_to_decision = self.median_seconds_to_decision + + p90_seconds_to_decision: float | None + p90_seconds_to_decision = self.p90_seconds_to_decision + + functions = [] + for functions_item_data in self.functions: + functions_item = functions_item_data.to_dict() + functions.append(functions_item) + + + + other_functions = self.other_functions.to_dict() + + daily = [] + for daily_item_data in self.daily: + daily_item = daily_item_data.to_dict() + daily.append(daily_item) + + + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "from": from_, + "to": to, + "counts": counts, + "approval_rate": approval_rate, + "median_seconds_to_decision": median_seconds_to_decision, + "p90_seconds_to_decision": p90_seconds_to_decision, + "functions": functions, + "other_functions": other_functions, + "daily": daily, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval_counts import DurableApprovalCounts # noqa: PLC0415 + from ..models.durable_approval_daily_counts import DurableApprovalDailyCounts # noqa: PLC0415 + from ..models.durable_approval_function_counts import DurableApprovalFunctionCounts # noqa: PLC0415 + d = dict(src_dict) + from_ = datetime.datetime.fromisoformat(d.pop("from")) + + + + + to = datetime.datetime.fromisoformat(d.pop("to")) + + + + + counts = DurableApprovalCounts.from_dict(d.pop("counts")) + + + + + def _parse_approval_rate(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + approval_rate = _parse_approval_rate(d.pop("approval_rate")) + + + def _parse_median_seconds_to_decision(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + median_seconds_to_decision = _parse_median_seconds_to_decision(d.pop("median_seconds_to_decision")) + + + def _parse_p90_seconds_to_decision(data: object) -> float | None: + if data is None: + return data + return cast(float | None, data) + + p90_seconds_to_decision = _parse_p90_seconds_to_decision(d.pop("p90_seconds_to_decision")) + + + functions = [] + _functions = d.pop("functions") + for functions_item_data in (_functions): + functions_item = DurableApprovalFunctionCounts.from_dict(functions_item_data) + + + + functions.append(functions_item) + + + other_functions = DurableApprovalCounts.from_dict(d.pop("other_functions")) + + + + + daily = [] + _daily = d.pop("daily") + for daily_item_data in (_daily): + daily_item = DurableApprovalDailyCounts.from_dict(daily_item_data) + + + + daily.append(daily_item) + + + durable_approval_stats = cls( + from_=from_, + to=to, + counts=counts, + approval_rate=approval_rate, + median_seconds_to_decision=median_seconds_to_decision, + p90_seconds_to_decision=p90_seconds_to_decision, + functions=functions, + other_functions=other_functions, + daily=daily, + ) + + + durable_approval_stats.additional_properties = d + return durable_approval_stats + + @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/volcano_sdk/_generated/models/durable_approval_status.py b/src/volcano_sdk/_generated/models/durable_approval_status.py new file mode 100644 index 00000000..a17f9cab --- /dev/null +++ b/src/volcano_sdk/_generated/models/durable_approval_status.py @@ -0,0 +1,10 @@ +from typing import Literal + +DurableApprovalStatus = Literal['approved', 'cancelled', 'denied', 'expired', 'pending'] + +DURABLE_APPROVAL_STATUS_VALUES: set[DurableApprovalStatus] = { 'approved', 'cancelled', 'denied', 'expired', 'pending', } + +def check_durable_approval_status(value: str) -> DurableApprovalStatus: + if value in DURABLE_APPROVAL_STATUS_VALUES: + return value + raise TypeError(f"Unexpected value {value!r}. Expected one of {DURABLE_APPROVAL_STATUS_VALUES!r}") diff --git a/src/volcano_sdk/_generated/models/paginated_durable_approvals.py b/src/volcano_sdk/_generated/models/paginated_durable_approvals.py new file mode 100644 index 00000000..0855c22f --- /dev/null +++ b/src/volcano_sdk/_generated/models/paginated_durable_approvals.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from typing import cast + +if TYPE_CHECKING: + from ..models.durable_approval import DurableApproval + + + + + +T = TypeVar("T", bound="PaginatedDurableApprovals") + + + +@_attrs_define +class PaginatedDurableApprovals: + """ + Attributes: + data (list[DurableApproval]): + page (int): Current page number (1-indexed) + limit (int): Number of items per page + total (int): Total number of items across all pages + has_more (bool): Whether there are more pages available + """ + + data: list[DurableApproval] + page: int + limit: int + total: int + has_more: bool + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + + + + + def to_dict(self) -> dict[str, Any]: + from ..models.durable_approval import DurableApproval # noqa: PLC0415 + data = [] + for data_item_data in self.data: + data_item = data_item_data.to_dict() + data.append(data_item) + + + + page = self.page + + limit = self.limit + + total = self.total + + has_more = self.has_more + + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "data": data, + "page": page, + "limit": limit, + "total": total, + "has_more": has_more, + }) + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.durable_approval import DurableApproval # noqa: PLC0415 + d = dict(src_dict) + data = [] + _data = d.pop("data") + for data_item_data in (_data): + data_item = DurableApproval.from_dict(data_item_data) + + + + data.append(data_item) + + + page = d.pop("page") + + limit = d.pop("limit") + + total = d.pop("total") + + has_more = d.pop("has_more") + + paginated_durable_approvals = cls( + data=data, + page=page, + limit=limit, + total=total, + has_more=has_more, + ) + + + paginated_durable_approvals.additional_properties = d + return paginated_durable_approvals + + @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/volcano_sdk/_generated/models/project_config_custom_domain.py b/src/volcano_sdk/_generated/models/project_config_custom_domain.py index cba0e042..f74c5a75 100644 --- a/src/volcano_sdk/_generated/models/project_config_custom_domain.py +++ b/src/volcano_sdk/_generated/models/project_config_custom_domain.py @@ -25,7 +25,7 @@ @_attrs_define class ProjectConfigCustomDomain: - """ Custom domain with managed or BYOC TLS (SUPERAGENT plan). `tls` is required + """ Custom domain with managed or BYOC TLS. `tls` is required when the domain is first created and optional afterwards. For an existing domain, omitting `tls` or sending only `tls.mode` keeps the stored certificate; new BYOC material for the same domain rotates the diff --git a/src/volcano_sdk/_generated/models/project_config_database.py b/src/volcano_sdk/_generated/models/project_config_database.py index bff4cff6..115e0aef 100644 --- a/src/volcano_sdk/_generated/models/project_config_database.py +++ b/src/volcano_sdk/_generated/models/project_config_database.py @@ -35,7 +35,9 @@ class ProjectConfigDatabase: name (str): region (str): Deployed region ID (e.g. us-east-1). Asserted, never written; region IDs issued by earlier versions of the API match too. - pg_version (ProjectConfigDatabasePgVersion): PostgreSQL major version. Asserted, never written. + pg_version (ProjectConfigDatabasePgVersion): PostgreSQL major version. Asserted, never written. Also accepts + versions new databases can no longer be created on, so an export + of an existing database re-applies. database_type (ProjectConfigDatabaseDatabaseType | Unset): Compute tier. Asserted, never written - tier changes are not supported via the manifest; use the databases API/CLI/GUI instead. diff --git a/src/volcano_sdk/_generated/models/project_config_frontend.py b/src/volcano_sdk/_generated/models/project_config_frontend.py index c7a5b0c2..1210709a 100644 --- a/src/volcano_sdk/_generated/models/project_config_frontend.py +++ b/src/volcano_sdk/_generated/models/project_config_frontend.py @@ -38,8 +38,7 @@ class ProjectConfigFrontend: in builds and runtime. Omission preserves the stored selection. variables (list[str] | Unset): Names selected when variable_scope is scoped. Missing declared values reject deployment. Omission preserves the stored list; an empty list clears it. - custom_domain (ProjectConfigCustomDomain | Unset): Custom domain with managed or BYOC TLS (SUPERAGENT plan). - `tls` is required + custom_domain (ProjectConfigCustomDomain | Unset): Custom domain with managed or BYOC TLS. `tls` is required when the domain is first created and optional afterwards. For an existing domain, omitting `tls` or sending only `tls.mode` keeps the stored certificate; new BYOC material for the same domain rotates the diff --git a/src/volcano_sdk/_generated/models/request_durable_approval_request.py b/src/volcano_sdk/_generated/models/request_durable_approval_request.py new file mode 100644 index 00000000..043cff5e --- /dev/null +++ b/src/volcano_sdk/_generated/models/request_durable_approval_request.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar, BinaryIO, TextIO, TYPE_CHECKING, Generator + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +from ..types import UNSET, Unset + + + + + + +T = TypeVar("T", bound="RequestDurableApprovalRequest") + + + +@_attrs_define +class RequestDurableApprovalRequest: + """ + Attributes: + execution_ref (str): Opaque reference to the running execution, supplied by the SDK. + callback_id (str): Opaque approval reference, supplied by the SDK. + name (str): + title (str): + description (str | Unset): + details (Any | Unset): Any JSON value to show the person deciding. The whole request is + limited to 64 KiB. + """ + + execution_ref: str + callback_id: str + name: str + title: str + description: str | Unset = UNSET + details: Any | Unset = UNSET + + + + + + def to_dict(self) -> dict[str, Any]: + execution_ref = self.execution_ref + + callback_id = self.callback_id + + name = self.name + + title = self.title + + description = self.description + + details = self.details + + + field_dict: dict[str, Any] = {} + + field_dict.update({ + "execution_ref": execution_ref, + "callback_id": callback_id, + "name": name, + "title": title, + }) + if description is not UNSET: + field_dict["description"] = description + if details is not UNSET: + field_dict["details"] = details + + return field_dict + + + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + execution_ref = d.pop("execution_ref") + + callback_id = d.pop("callback_id") + + name = d.pop("name") + + title = d.pop("title") + + description = d.pop("description", UNSET) + + details = d.pop("details", UNSET) + + request_durable_approval_request = cls( + execution_ref=execution_ref, + callback_id=callback_id, + name=name, + title=title, + description=description, + details=details, + ) + + return request_durable_approval_request + diff --git a/src/volcano_sdk/_tests/fixtures/durable_context.py b/src/volcano_sdk/_tests/fixtures/durable_context.py index 46b35788..3826b91d 100644 --- a/src/volcano_sdk/_tests/fixtures/durable_context.py +++ b/src/volcano_sdk/_tests/fixtures/durable_context.py @@ -1,4 +1,4 @@ -"""A typed runtime context that records batch calls without scheduling them.""" +"""A typed runtime context that records calls without scheduling them.""" from __future__ import annotations @@ -17,6 +17,7 @@ DurableLogger, OperationScope, RuntimeBatchItem, + RuntimeExecution, ) T = TypeVar("T") @@ -98,17 +99,28 @@ def get_errors(self) -> list[object]: return [self._error] +@dataclass(frozen=True) +class RecordedExecution: + """The execution metadata the runtime attaches to every context.""" + + durable_execution_arn: str + + class RecordingContext(RuntimeContext): """Implement the runtime context and capture its batch calls.""" logger: DurableLogger = logging.getLogger(__name__) - def __init__(self) -> None: + def __init__(self, execution_arn: str = "arn:execution") -> None: + self.execution_context: RuntimeExecution = RecordedExecution(execution_arn) self.branches: list[object] | None = None self.name: str | None = None self.config: object = None self.map_items: list[object] | None = None self.map_result: object = None + self.submitter: Callable[[str, object], None] | None = None + self.callback_result: object = None + self.callback_error: Exception | None = None @override def step( @@ -173,6 +185,20 @@ def parallel( self.config = config return EmptyBatch[T]() + @override + def wait_for_callback( + self, + submitter: Callable[[str, object], None], + name: str | None, + config: object, + ) -> object: + self.name = name + self.config = config + self.submitter = submitter + if self.callback_error is not None: + raise self.callback_error + return self.callback_result + @override def set_logger(self, logger: object) -> None: _ = logger diff --git a/src/volcano_sdk/_tests/fixtures/durable_engine.py b/src/volcano_sdk/_tests/fixtures/durable_engine.py index bcbeed72..f38c2fa9 100644 --- a/src/volcano_sdk/_tests/fixtures/durable_engine.py +++ b/src/volcano_sdk/_tests/fixtures/durable_engine.py @@ -2,7 +2,7 @@ from __future__ import annotations -from aws_durable_execution_sdk_python import config, retries, waits +from aws_durable_execution_sdk_python import config, exceptions, retries, waits from volcano_sdk._durable_engine import load_engine from volcano_sdk._durable_modules import load_root @@ -27,3 +27,5 @@ def assert_runtime_surface() -> None: assert engine.create_wait_strategy is waits.create_wait_strategy assert engine.wait_strategy_config is waits.WaitStrategyConfig assert engine.wait_for_condition_config is waits.WaitForConditionConfig + assert engine.wait_for_callback_config is config.WaitForCallbackConfig + assert engine.callback_timeout_error is exceptions.CallbackTimeoutError diff --git a/src/volcano_sdk/_tests/fixtures/durable_inspection.py b/src/volcano_sdk/_tests/fixtures/durable_inspection.py index e3474d34..452d78b2 100644 --- a/src/volcano_sdk/_tests/fixtures/durable_inspection.py +++ b/src/volcano_sdk/_tests/fixtures/durable_inspection.py @@ -39,6 +39,9 @@ class InspectedDurableContext(DurableContext): def wait_duration(self, value: object) -> object: return self._wait_duration(value) + def approval_timeout(self, value: object) -> int: + return self._approval_timeout(value) + class ClosingScheduler(Scheduler): """Close the upstream scheduler loop after its worker thread stops.""" diff --git a/src/volcano_sdk/_tests/fixtures/invalid_arguments.py b/src/volcano_sdk/_tests/fixtures/invalid_arguments.py index 464a9bf6..fcbaba4b 100644 --- a/src/volcano_sdk/_tests/fixtures/invalid_arguments.py +++ b/src/volcano_sdk/_tests/fixtures/invalid_arguments.py @@ -7,6 +7,8 @@ from typing import BinaryIO from volcano_sdk.auth import Auth + from volcano_sdk.durable import DurableApprovals + from volcano_sdk.durable_authoring import DurableContext from volcano_sdk.logs import Logs from volcano_sdk.models import ( JSONValue, @@ -181,3 +183,22 @@ def unsupported_hosted_auth_action(auth: Auth) -> None: action="device", # type: ignore[arg-type] # pyright: ignore[reportArgumentType] state="state-value", ) + + +def non_string_approval_comment(approvals: DurableApprovals, approval_id: str) -> None: + project_id = "00000000-0000-4000-8000-000000000001" + _ = approvals.approve(project_id, approval_id, comment=5) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +def unknown_approval_status(approvals: DurableApprovals) -> None: + project_id = "00000000-0000-4000-8000-000000000001" + _ = approvals.list(project_id, status="waiting") # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +def non_string_approval_title(context: DurableContext) -> None: + _ = context.wait_for_approval("ship", title=None) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + + +def unknown_approval_option(approvals: DurableApprovals) -> None: + project_id = "00000000-0000-4000-8000-000000000001" + approvals.stats(project_id, state="pending", sort="asc") # type: ignore[call-arg] # pyright: ignore[reportCallIssue] diff --git a/src/volcano_sdk/_tests/test_durable_approval_registration.py b/src/volcano_sdk/_tests/test_durable_approval_registration.py new file mode 100644 index 00000000..f94417aa --- /dev/null +++ b/src/volcano_sdk/_tests/test_durable_approval_registration.py @@ -0,0 +1,1152 @@ +from __future__ import annotations + +import asyncio +import json +import socket +import threading +import time +from types import MappingProxyType +from typing import TYPE_CHECKING, NoReturn, Protocol, TypeAlias + +import httpx +import pytest +from typing_extensions import override + +from volcano_sdk import ( + ConflictError, + NotFoundError, + RateLimitedError, + ServerError, + TransportError, + ValidationError, + VolcanoError, +) +from volcano_sdk._durable_approval_registration import ( + approval_body, + approval_decision, + check_size, + platform_api_url, + register_approval, +) +from volcano_sdk.durable_authoring import ApprovalDecision +from volcano_sdk.models import DurableApprovalDecider + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable + + from volcano_sdk.models import JSONValue + +API_URL = "https://api.volcano.test" +BODY: dict[str, JSONValue] = { + "execution_ref": "arn:execution/order-1234", + "callback_id": "callback-1", + "name": "ship-order", + "title": "Ship order 1234?", +} +RETRY_DELAYS = [0.5, 1.0, 2.0, 4.0, 5.0, 5.0, 5.0, 5.0] +# The fixture replaces httpx.AsyncClient for the module under test, which is +# the same attribute everywhere. +REAL_CLIENT = httpx.AsyncClient +REGISTRATION = "volcano_sdk._durable_approval_registration" +INVALID_DETAILS = ( + "details must be JSON-serializable: mappings with string keys, lists, " + "tuples, strings, finite numbers, booleans, and None" +) +# Long past any attempt's deadline: a slow answer still running by then was +# never cut off, and ends rather than hang the suite. +_GIVE_UP_SECONDS = 10.0 +_TRICKLE_INTERVAL = 0.01 +_NEVER_CANCELLED = "the attempt outlived its deadline" + + +class Stall: + """Accept the request and never answer it.""" + + def __init__(self) -> None: + self.released: bool = False + + async def hold(self) -> NoReturn: + try: + await asyncio.sleep(_GIVE_UP_SECONDS) + finally: + self.released = True + raise AssertionError(_NEVER_CANCELLED) + + +class Trickle(httpx.AsyncByteStream): + """Send a body one byte at a time, each well inside any read timeout.""" + + def __init__(self) -> None: + self.sent: int = 0 + self.closed: bool = False + + @override + async def __aiter__(self) -> AsyncIterator[bytes]: + for _ in range(int(_GIVE_UP_SECONDS / _TRICKLE_INTERVAL)): + await asyncio.sleep(_TRICKLE_INTERVAL) + self.sent += 1 + yield b" " + + @override + async def aclose(self) -> None: + self.closed = True + + +class Flood(httpx.AsyncByteStream): + """Send a body as fast as it is read, forever.""" + + def __init__(self) -> None: + self.sent: int = 0 + + @override + async def __aiter__(self) -> AsyncIterator[bytes]: + while True: + self.sent += 4096 + yield b" " * 4096 + + +class Pieces(httpx.AsyncByteStream): + """Send a body split into the given pieces.""" + + def __init__(self, *pieces: bytes) -> None: + self.pieces: tuple[bytes, ...] = pieces + + @override + async def __aiter__(self) -> AsyncIterator[bytes]: + for piece in self.pieces: + yield piece + + +Answer: TypeAlias = "httpx.Response | Exception | Stall" + + +class Platform: + """Answer registrations in order and record what was sent. + + Time is the platform's own: it passes while the registration sleeps and + while a request runs into its timeout, and nowhere else. An attempt that + is cancelled at its deadline takes real time, not platform time. + """ + + def __init__(self, *answers: Answer) -> None: + self.answers: list[Answer] = list(answers) + self.requests: list[httpx.Request] = [] + self.timeouts: list[float] = [] + self.sleeps: list[float] = [] + self.now: float = 0.0 + # How much longer than asked each sleep takes. + self.oversleep: float = 0.0 + self.clock_runs: bool = True + + async def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + match request.extensions: + case {"timeout": {"read": float(timeout)}}: + self.timeouts.append(timeout) + case _: + message = f"expected a read timeout, got {request.extensions}" + raise AssertionError(message) + answer = self.answers[0] if len(self.answers) == 1 else self.answers.pop(0) + if isinstance(answer, Stall): + await answer.hold() + if isinstance(answer, httpx.TimeoutException): + self.advance(timeout) + if isinstance(answer, Exception): + raise answer + return answer + + def client(self, *, timeout: float) -> httpx.AsyncClient: + return REAL_CLIENT(transport=httpx.MockTransport(self.handle), timeout=timeout) + + def monotonic(self) -> float: + return self.now + + def sleep(self, delay: float) -> None: + self.sleeps.append(delay) + self.advance(delay + self.oversleep) + + def advance(self, seconds: float) -> None: + if self.clock_runs: + self.now += seconds + + +class InstallPlatform(Protocol): + def __call__(self, *answers: Answer) -> Platform: ... + + +@pytest.fixture +def platform(monkeypatch: pytest.MonkeyPatch) -> InstallPlatform: + def install(*answers: Answer) -> Platform: + fake = Platform(*answers) + monkeypatch.setattr(f"{REGISTRATION}.httpx.AsyncClient", fake.client) + monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) + monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) + return fake + + return install + + +def registered(status: int = 201) -> httpx.Response: + return httpx.Response( + status, + json={"id": "approval-1", "status": "pending", "expires_at": None}, + ) + + +def refused(status: int, code: str | None = None) -> httpx.Response: + body = {"error": f"refused with {status}"} + if code is not None: + body["code"] = code + return httpx.Response(status, json=body) + + +def test_registration_posts_the_body_without_credentials( + platform: InstallPlatform, +) -> None: + fake = platform(registered()) + + register_approval(f"{API_URL}/", BODY) + + assert len(fake.requests) == 1 + request = fake.requests[0] + assert request.method == "POST" + assert str(request.url) == f"{API_URL}/durable-approvals" + assert "authorization" not in request.headers + assert request.headers["content-type"] == "application/json" + assert json.loads(request.content) == BODY + assert request.extensions["timeout"] == dict.fromkeys( + ("connect", "read", "write", "pool"), 10.0 + ) + assert fake.sleeps == [] + + +def test_an_already_registered_approval_is_success( + platform: InstallPlatform, +) -> None: + fake = platform(registered(200)) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == 1 + assert fake.sleeps == [] + + +def test_frozen_details_are_sent_as_json(platform: InstallPlatform) -> None: + fake = platform(registered()) + details: JSONValue = MappingProxyType({"items": (1, 2)}) + + register_approval(API_URL, {**BODY, "details": details}) + + assert json.loads(fake.requests[0].content)["details"] == {"items": [1, 2]} + + +@pytest.mark.parametrize( + "first", + [ + pytest.param(refused(409, "approval_not_ready"), id="not ready"), + pytest.param(refused(404), id="execution not recorded yet"), + pytest.param(refused(429), id="rate limited"), + pytest.param(refused(500), id="server error"), + pytest.param(refused(503), id="unavailable"), + pytest.param(refused(502), id="bad gateway"), + pytest.param(httpx.ConnectError("connection refused"), id="network"), + pytest.param(httpx.ReadTimeout("timed out"), id="timeout"), + ], +) +def test_a_transient_failure_is_retried( + platform: InstallPlatform, first: httpx.Response | Exception +) -> None: + fake = platform(first, registered()) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == 2 + assert fake.sleeps == [0.5] + assert json.loads(fake.requests[1].content) == BODY + + +@pytest.mark.parametrize( + ("answers", "sleeps"), + [ + pytest.param([refused(409, "approval_closed")], [], id="at once"), + pytest.param( + [refused(404), refused(409, "approval_closed")], [0.5], id="on a retry" + ), + ], +) +def test_an_approval_that_already_closed_needs_no_registration( + platform: InstallPlatform, answers: list[httpx.Response], sleeps: list[float] +) -> None: + fake = platform(*answers) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == len(answers) + assert fake.sleeps == sleeps + + +def test_retries_back_off_until_the_deadline_and_then_raise( + platform: InstallPlatform, +) -> None: + fake = platform(refused(503)) + + with pytest.raises(ServerError, match="refused with 503") as caught: + register_approval(API_URL, BODY) + + assert caught.value.status == 503 + assert fake.sleeps == RETRY_DELAYS + assert len(fake.requests) == len(RETRY_DELAYS) + 1 + # The last attempt starts 27.5 s in and gets only what is left. + assert fake.timeouts[-1] == pytest.approx(2.5) + + +def test_the_last_attempt_before_the_deadline_can_still_succeed( + platform: InstallPlatform, +) -> None: + fake = platform(*([refused(429)] * len(RETRY_DELAYS)), registered()) + + register_approval(API_URL, BODY) + + assert fake.sleeps == RETRY_DELAYS + assert len(fake.requests) == len(RETRY_DELAYS) + 1 + + +def test_the_schedule_bounds_the_attempts_while_time_is_left( + platform: InstallPlatform, +) -> None: + fake = platform(refused(503)) + fake.clock_runs = False + + with pytest.raises(ServerError, match="refused with 503"): + register_approval(API_URL, BODY) + + assert fake.sleeps == RETRY_DELAYS + assert len(fake.requests) == len(RETRY_DELAYS) + 1 + assert fake.timeouts == [10.0] * (len(RETRY_DELAYS) + 1) + + +def test_each_attempt_is_given_what_is_left_of_the_deadline( + platform: InstallPlatform, +) -> None: + fake = platform(httpx.ReadTimeout("timed out")) + + with pytest.raises(TransportError, match=r"did not answer within 8\.5 seconds"): + register_approval(API_URL, BODY) + + assert fake.timeouts == [10.0, 10.0, 8.5] + assert fake.sleeps == [0.5, 1.0] + + +def test_a_late_wake_up_leaves_the_retry_only_the_time_left( + platform: InstallPlatform, +) -> None: + fake = platform(httpx.ReadTimeout("timed out"), registered()) + fake.oversleep = 19.0 + + register_approval(API_URL, BODY) + + assert fake.sleeps == [0.5] + assert fake.timeouts == [10.0, 0.5] + + +def test_waking_up_at_the_deadline_raises_the_last_failure( + platform: InstallPlatform, +) -> None: + fake = platform(httpx.ReadTimeout("timed out")) + fake.oversleep = 19.5 + + with pytest.raises(TransportError, match="did not answer within 10 seconds"): + register_approval(API_URL, BODY) + + assert fake.sleeps == [0.5] + assert fake.timeouts == [10.0] + + +def test_a_network_failure_keeps_its_own_message( + platform: InstallPlatform, +) -> None: + fake = platform(httpx.ConnectError("connection refused")) + fake.oversleep = 19.5 + + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + + assert str(caught.value) == "connection refused" + + +def test_a_retry_that_would_wake_at_the_deadline_is_not_slept_for( + platform: InstallPlatform, +) -> None: + # The retry wakes 29 s in; its attempt fails, and the next delay of 1 s + # would end exactly at the deadline. + fake = platform(refused(503)) + fake.oversleep = 28.5 + + with pytest.raises(ServerError, match="refused with 503"): + register_approval(API_URL, BODY) + + assert fake.sleeps == [0.5] + assert fake.timeouts == [10.0, 1.0] + + +# Nothing in a mock transport times out, so only the attempt's own deadline can +# end these early. The retry wakes 29.8 s in, which leaves its attempt 0.2 s. +_WAKE_LATE = 29.3 +_ENDED_PROMPTLY = 3.0 + + +def test_an_attempt_ends_by_its_deadline_when_volcano_never_answers( + platform: InstallPlatform, +) -> None: + stall = Stall() + fake = platform(refused(503), stall) + fake.oversleep = _WAKE_LATE + started = time.perf_counter() + + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + + assert time.perf_counter() - started < _ENDED_PROMPTLY + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert stall.released + assert fake.timeouts == [10.0, pytest.approx(0.2)] + assert fake.sleeps == [0.5] + + +def test_an_attempt_ends_by_its_deadline_while_the_answer_trickles_in( + platform: InstallPlatform, +) -> None: + # Each byte arrives long before any per-read timeout would fire. + trickle = Trickle() + fake = platform(refused(503), httpx.Response(503, stream=trickle)) + fake.oversleep = _WAKE_LATE + started = time.perf_counter() + + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + + assert time.perf_counter() - started < _ENDED_PROMPTLY + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert trickle.sent > 0 + assert trickle.closed + assert fake.timeouts == [10.0, pytest.approx(0.2)] + + +def test_an_attempt_ends_by_its_deadline_while_the_host_lookup_stalls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Only the clock is the platform's: the request goes through a real client, + # whose host lookup runs on a thread that cancelling cannot stop. + fake = Platform() + fake.oversleep = _WAKE_LATE + monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) + monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) + released = threading.Event() + lookups: list[object] = [] + + def lookup(host: object, *_args: object, **_kwargs: object) -> NoReturn: + lookups.append(host) + if len(lookups) > 1: + _ = released.wait(_GIVE_UP_SECONDS) + raise socket.gaierror(socket.EAI_NONAME, "unknown host") + + monkeypatch.setattr(socket, "getaddrinfo", lookup) + started = time.perf_counter() + + try: + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + finally: + released.set() + + assert time.perf_counter() - started < _ENDED_PROMPTLY + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert lookups == [b"api.volcano.test", b"api.volcano.test"] + + +def test_the_clients_own_timeout_reports_the_attempts_deadline( + platform: InstallPlatform, +) -> None: + # What httpx raises when its connect timeout, not the attempt's, fires. + fake = platform(refused(503), httpx.ConnectTimeout("")) + fake.oversleep = _WAKE_LATE + + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert fake.timeouts == [10.0, pytest.approx(0.2)] + + +def test_a_stalled_loop_still_reports_the_attempts_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Holding the loop past 0.2 s fires the attempt's deadline and the client's + # connect timeout in one pass, while the host lookup is still running. + fake = Platform() + fake.oversleep = _WAKE_LATE + monkeypatch.setattr(f"{REGISTRATION}.monotonic", fake.monotonic) + monkeypatch.setattr(f"{REGISTRATION}.sleep", fake.sleep) + released = threading.Event() + lookups: list[object] = [] + new_event_loop = asyncio.new_event_loop + + def lookup(host: object, *_args: object, **_kwargs: object) -> NoReturn: + lookups.append(host) + if len(lookups) > 1: + _ = released.wait(_GIVE_UP_SECONDS) + raise socket.gaierror(socket.EAI_NONAME, "unknown host") + + def stalling_loop() -> asyncio.AbstractEventLoop: + loop = new_event_loop() + _ = loop.call_later(0.1, time.sleep, 0.3) + return loop + + monkeypatch.setattr(socket, "getaddrinfo", lookup) + monkeypatch.setattr(f"{REGISTRATION}.asyncio.new_event_loop", stalling_loop) + + try: + with pytest.raises(TransportError) as caught: + register_approval(API_URL, BODY) + finally: + released.set() + + assert str(caught.value) == "Volcano did not answer within 0.2 seconds" + assert lookups == [b"api.volcano.test", b"api.volcano.test"] + + +def test_an_answer_too_large_to_be_volcanos_is_not_read_to_the_end( + platform: InstallPlatform, +) -> None: + flood = Flood() + fake = platform(httpx.Response(409, stream=flood)) + + with pytest.raises(ConflictError) as caught: + register_approval(API_URL, BODY) + + assert caught.value.code is None + assert flood.sent <= 65536 + 4096 + assert len(fake.requests) == 1 + + +def test_an_answer_at_the_size_limit_is_read(platform: InstallPlatform) -> None: + closed = refused(409, "approval_closed").content + fake = platform(httpx.Response(409, content=closed.ljust(65536))) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == 1 + + +def test_an_answer_that_arrives_in_pieces_is_read_whole( + platform: InstallPlatform, +) -> None: + closed = refused(409, "approval_closed").content + middle = len(closed) // 2 + fake = platform( + httpx.Response(409, stream=Pieces(closed[:middle], closed[middle:])) + ) + + register_approval(API_URL, BODY) + + assert len(fake.requests) == 1 + + +@pytest.mark.parametrize( + ("answer", "expected"), + [ + pytest.param(refused(404), NotFoundError, id="no execution"), + pytest.param(refused(429), RateLimitedError, id="rate limited"), + pytest.param( + httpx.ConnectError("connection refused"), TransportError, id="network" + ), + ], +) +def test_a_failure_that_persists_raises_its_own_error( + platform: InstallPlatform, + answer: httpx.Response | Exception, + expected: type[VolcanoError], +) -> None: + fake = platform(answer) + + with pytest.raises(expected) as caught: + register_approval(API_URL, BODY) + + assert type(caught.value) is expected + assert len(fake.requests) == len(RETRY_DELAYS) + 1 + + +@pytest.mark.parametrize( + ("answer", "expected"), + [ + pytest.param(refused(400), ValidationError, id="invalid"), + pytest.param(refused(409, "execution_ended"), ConflictError, id="ended"), + pytest.param( + refused(409, "too_many_pending_approvals"), ConflictError, id="too many" + ), + pytest.param(refused(409), ConflictError, id="conflict"), + pytest.param(refused(413), VolcanoError, id="too large"), + ], +) +def test_a_refusal_is_raised_without_retrying( + platform: InstallPlatform, + answer: httpx.Response, + expected: type[VolcanoError], +) -> None: + fake = platform(answer) + + with pytest.raises(expected) as caught: + register_approval(API_URL, BODY) + + assert type(caught.value) is expected + assert caught.value.status == answer.status_code + assert len(fake.requests) == 1 + assert fake.sleeps == [] + + +def test_the_platform_api_url_comes_from_the_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", f" {API_URL} ") + + assert platform_api_url() == API_URL + + +@pytest.mark.parametrize("value", [None, "", " "]) +def test_a_missing_platform_api_url_is_explained( + monkeypatch: pytest.MonkeyPatch, value: str | None +) -> None: + if value is None: + monkeypatch.delenv("VOLCANO_PLATFORM_API_URL", raising=False) + else: + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", value) + + with pytest.raises(RuntimeError) as caught: + _ = platform_api_url() + + assert str(caught.value) == ( + "wait_for_approval() needs VOLCANO_PLATFORM_API_URL. Volcano sets it on " + "deployed durable functions; set it yourself to run the handler elsewhere." + ) + + +def test_the_body_carries_only_what_was_given() -> None: + assert approval_body("ship", "Ship?", None, None) == { + "name": "ship", + "title": "Ship?", + } + assert approval_body("ship", "Ship?", "", {"a": [1]}) == { + "name": "ship", + "title": "Ship?", + "description": "", + "details": {"a": [1]}, + } + + +def test_text_at_its_limit_is_accepted() -> None: + body = approval_body(" ~" * 118 + "n", "t" * 200, "d" * 4000, details=False) + + assert body == { + "name": " ~" * 118 + "n", + "title": "t" * 200, + "description": "d" * 4000, + "details": False, + } + + +@pytest.mark.parametrize( + ("arguments", "error", "message"), + [ + pytest.param( + (3, "t", None, None), TypeError, "name must be a string", id="name type" + ), + pytest.param( + (" ", "t", None, None), ValueError, "name must not be empty", id="blank" + ), + pytest.param( + ("n" * 238, "t", None, None), + ValueError, + "name must be at most 237 characters", + id="long name", + ), + pytest.param( + ("aprobaci\u00f3n", "t", None, None), + ValueError, + "name must be printable ASCII", + id="non-ascii name", + ), + pytest.param( + ("ship\torder", "t", None, None), + ValueError, + "name must be printable ASCII", + id="control character in name", + ), + pytest.param( + ("ship\x7f", "t", None, None), + ValueError, + "name must be printable ASCII", + id="delete in name", + ), + pytest.param( + ("ship\0", "t", None, None), + ValueError, + "name must not contain NUL characters", + id="nul name", + ), + pytest.param( + ("n", "Ship\0?", None, None), + ValueError, + "title must not contain NUL characters", + id="nul title", + ), + pytest.param( + ("n", "t", "Express\0", None), + ValueError, + "description must not contain NUL characters", + id="nul description", + ), + pytest.param( + ("n", "t", None, "\0"), + ValueError, + "details must not contain NUL characters", + id="nul details", + ), + pytest.param( + ("n", "t", None, {"items": ["a", "b\0"]}), + ValueError, + "details must not contain NUL characters", + id="nul in a details list", + ), + pytest.param( + ("n", "t", None, {"order": {"a\0": 1}}), + ValueError, + "details must not contain NUL characters", + id="nul in a details key", + ), + pytest.param( + ("n", "t", None, (1, {"note": "\0"})), + ValueError, + "details must not contain NUL characters", + id="nul in a details tuple", + ), + pytest.param( + ("n", None, None, None), TypeError, "title must be a string", id="title" + ), + pytest.param( + ("n", "", None, None), ValueError, "title must not be empty", id="empty" + ), + pytest.param( + ("n", "t" * 201, None, None), + ValueError, + "title must be at most 200 characters", + id="long title", + ), + pytest.param( + ("n", "t", 1, None), + TypeError, + "description must be a string", + id="description type", + ), + pytest.param( + ("n", "t", "d" * 4001, None), + ValueError, + "description must be at most 4000 characters", + id="long description", + ), + pytest.param( + ("n\ud800", "t", None, None), + TypeError, + "name must be JSON-serializable: it contains a surrogate", + id="surrogate name", + ), + pytest.param( + ("n", "\udfff", None, None), + TypeError, + "title must be JSON-serializable: it contains a surrogate", + id="surrogate title", + ), + pytest.param( + ("n", "t", "\ud83d", None), + TypeError, + "description must be JSON-serializable: it contains a surrogate", + id="surrogate description", + ), + pytest.param( + ("n", "t", None, {"when": object()}), + TypeError, + INVALID_DETAILS, + id="details", + ), + pytest.param( + ("n", "t", None, float("nan")), + TypeError, + INVALID_DETAILS, + id="nan details", + ), + pytest.param( + ("n", "t", None, {"note": "\ud800"}), + TypeError, + INVALID_DETAILS, + id="surrogate details", + ), + ], +) +def test_an_invalid_body_is_refused( + arguments: tuple[object, object, object, object], + error: type[Exception], + message: str, +) -> None: + with pytest.raises(error) as caught: + _ = approval_body(*arguments) + + assert str(caught.value) == message + + +def test_details_are_copied_when_checked() -> None: + items: list[JSONValue] = [1] + details: dict[str, JSONValue] = {"items": items, "frozen": MappingProxyType({})} + + body = approval_body("ship", "Ship?", None, details) + items.append("x" * 70_000) + details["late"] = True + + assert body["details"] == {"items": [1], "frozen": {}} + + +def test_details_that_only_spell_out_an_escaped_nul_are_kept() -> None: + details: JSONValue = {"note": "\\u0000", "items": [1, None, True, 1.5]} + + assert approval_body("ship", "Ship?", None, details)["details"] == details + + +def test_whitespace_is_kept_and_only_blank_text_is_refused() -> None: + assert approval_body(" ship ", "\tShip?\n", " ", None) == { + "name": " ship ", + "title": "\tShip?\n", + "description": " ", + } + with pytest.raises(ValueError, match="name must not be empty"): + _ = approval_body("\u00a0\u2003", "t", None, None) + + +LONGEST_CALLBACK_ID = "c" * 1024 + + +def sized_body(size: int) -> dict[str, JSONValue]: + """Pad a body to encode, with the longest callback id, to `size` bytes. + + Returns: + The body, without the callback id. + """ + body: dict[str, JSONValue] = { + "name": "ship-order", + "title": "Ship?", + "execution_ref": "arn:" + "e" * 2044, + "details": "", + } + encoded = json.dumps( + {**body, "callback_id": LONGEST_CALLBACK_ID}, + ensure_ascii=False, + separators=(",", ":"), + ).encode() + # Three bytes each in UTF-8, so the body is measured as sent, not escaped. + euros, rest = divmod(size - len(encoded), 3) + body["details"] = "\u20ac" * euros + "d" * rest + return body + + +def test_an_approval_at_the_size_limit_is_sent_as_measured( + platform: InstallPlatform, +) -> None: + fake = platform(registered()) + body = sized_body(65536) + + check_size(body) + register_approval(API_URL, {**body, "callback_id": LONGEST_CALLBACK_ID}) + + assert len(fake.requests[0].content) == 65536 + assert json.loads(fake.requests[0].content)["details"] == body["details"] + + +def test_a_body_that_is_not_json_is_never_sent(platform: InstallPlatform) -> None: + fake = platform(registered()) + + with pytest.raises(ValueError, match="not JSON compliant"): + register_approval(API_URL, {**BODY, "details": float("nan")}) + + assert fake.requests == [] + + +def test_an_approval_over_the_size_limit_is_refused() -> None: + with pytest.raises(ValueError, match="64 KiB") as caught: + check_size(sized_body(65537)) + + assert str(caught.value) == ( + "the approval is larger than the 64 KiB Volcano accepts once encoded as " + "JSON; send less in details" + ) + + +def test_the_size_limit_counts_encoded_bytes() -> None: + body: dict[str, JSONValue] = { + "name": "ship-order", + "title": "Ship?", + "execution_ref": "arn:execution", + "details": "\u20ac" * 21_500, + } + + with pytest.raises(ValueError, match="64 KiB"): + check_size(body) + check_size({**body, "details": "d" * 21_500}) + + +DECIDED_BY = {"id": "user-7", "email": "ops@example.com"} + + +def as_bytes(value: object) -> object: + return json.dumps(value).encode() + + +def as_decoded(value: object) -> object: + return value + + +@pytest.mark.parametrize( + "encode", + [ + pytest.param(json.dumps, id="string"), + pytest.param(as_bytes, id="bytes"), + pytest.param(as_decoded, id="decoded"), + ], +) +def test_an_approval_decision_is_read(encode: Callable[[object], object]) -> None: + result = encode( + { + "status": "approved", + "approved": True, + "comment": "Ship it", + "decided_by": DECIDED_BY, + "decided_at": "2026-10-06T12:05:00Z", + } + ) + + assert approval_decision(result) == ApprovalDecision( + approved=True, + status="approved", + comment="Ship it", + decided_by=DurableApprovalDecider(id="user-7", email="ops@example.com"), + decided_at="2026-10-06T12:05:00Z", + ) + + +def test_a_denial_is_a_decision_and_the_comment_defaults_to_empty() -> None: + result = json.dumps( + { + "status": "denied", + "approved": False, + "decided_by": None, + "decided_at": "2026-10-06T12:05:00Z", + } + ) + + assert approval_decision(result) == ApprovalDecision( + approved=False, + status="denied", + comment="", + decided_by=None, + decided_at="2026-10-06T12:05:00Z", + ) + + +def test_an_expired_decision_has_nothing_but_its_status() -> None: + expired = ApprovalDecision(approved=False, status="expired") + + assert (expired.comment, expired.decided_by, expired.decided_at) == ( + "", + None, + None, + ) + + +def complete_decision(**overrides: object) -> dict[str, object]: + decision: dict[str, object] = { + "status": "approved", + "approved": True, + "comment": "", + "decided_by": DECIDED_BY, + "decided_at": "2026-10-06T12:05:00Z", + } + decision.update(overrides) + return decision + + +def without(field: str) -> dict[str, object]: + decision = complete_decision() + del decision[field] + return decision + + +@pytest.mark.parametrize( + "result", + [ + pytest.param(None, id="none"), + pytest.param("not json", id="not json"), + pytest.param(b"\xff", id="not utf-8"), + pytest.param("[]", id="list"), + pytest.param({1: "approved"}, id="key"), + pytest.param(complete_decision(status="expired"), id="expired"), + pytest.param(complete_decision(status="Approved"), id="status case"), + pytest.param(complete_decision(status=None), id="null status"), + pytest.param(complete_decision(status=True), id="status type"), + pytest.param(without("status"), id="no status"), + ], +) +def test_a_decision_without_a_readable_status_is_refused(result: object) -> None: + with pytest.raises(TypeError, match="Expected a complete approval decision"): + _ = approval_decision(result) + + +@pytest.mark.parametrize( + ("result", "expected"), + [ + pytest.param( + complete_decision(approved=False), + ApprovalDecision( + approved=True, + status="approved", + decided_by=DurableApprovalDecider(id="user-7", email="ops@example.com"), + decided_at="2026-10-06T12:05:00Z", + ), + id="approved disagrees with status", + ), + pytest.param( + complete_decision(status="denied", approved="yes"), + ApprovalDecision( + approved=False, + status="denied", + decided_by=DurableApprovalDecider(id="user-7", email="ops@example.com"), + decided_at="2026-10-06T12:05:00Z", + ), + id="denied whatever approved says", + ), + pytest.param( + {"status": "approved"}, + ApprovalDecision(approved=True, status="approved"), + id="status alone", + ), + ], +) +def test_the_status_alone_decides(result: object, expected: ApprovalDecision) -> None: + assert approval_decision(result) == expected + + +@pytest.mark.parametrize( + ("result", "expected"), + [ + pytest.param(without("comment"), {"comment": ""}, id="no comment"), + pytest.param(complete_decision(comment=None), {"comment": ""}, id="null"), + pytest.param(complete_decision(comment=7), {"comment": ""}, id="comment type"), + pytest.param(without("decided_at"), {"decided_at": None}, id="no time"), + pytest.param( + complete_decision(decided_at=None), {"decided_at": None}, id="null time" + ), + pytest.param( + complete_decision(decided_at=""), {"decided_at": None}, id="empty time" + ), + pytest.param( + complete_decision(decided_at="yesterday"), + {"decided_at": None}, + id="not a time", + ), + pytest.param( + complete_decision(decided_at=1_791_288_300), + {"decided_at": None}, + id="epoch seconds", + ), + *( + pytest.param( + complete_decision(decided_at=value), {"decided_at": None}, id=case + ) + for case, value in [ + ("bare year", "2026"), + ("date without a time", "2026-10-06"), + ("time without an offset", "2026-10-06T12:05:00"), + ("time without seconds", "2026-10-06T12:05Z"), + ("space for the T", "2026-10-06 12:05:00Z"), + ("basic format", "20261006T120500Z"), + ("text before it", "at 2026-10-06T12:05:00Z"), + ("text after it", "2026-10-06T12:05:00Z UTC"), + ("interval", "2026-10-06T12:05:00Z/2026-10-07T12:05:00Z"), + ("day zero", "2026-10-00T12:05:00Z"), + ("impossible day of any month", "2026-10-32T12:05:00Z"), + ("impossible day", "2026-02-30T12:05:00Z"), + ("impossible hour", "2026-10-06T24:00:00Z"), + ("three-digit hour", "2026-10-06T012:05:00Z"), + ("impossible month", "2026-13-06T12:05:00Z"), + ("impossible offset", "2026-10-06T12:05:00+24:00"), + ("leap second", "2026-10-06T23:59:60Z"), + ] + ), + *( + pytest.param( + complete_decision(decided_at=value), {"decided_at": value}, id=case + ) + for case, value in [ + ("last day of a month kept", "2026-10-31T12:05:00Z"), + ("latest time and widest offset kept", "2026-10-06T23:59:59-23:59"), + ] + ), + pytest.param( + complete_decision(decided_at="2026-10-06T12:05:00.123456+02:00"), + {"decided_at": "2026-10-06T12:05:00.123456+02:00"}, + id="offset time kept as sent", + ), + pytest.param( + complete_decision(decided_at="2028-02-29T12:05:00Z"), + {"decided_at": "2028-02-29T12:05:00Z"}, + id="leap day kept", + ), + pytest.param(without("decided_by"), {"decided_by": None}, id="no decider"), + pytest.param( + complete_decision(decided_by="ops"), {"decided_by": None}, id="decider type" + ), + pytest.param( + complete_decision(decided_by=["user-7", "ops@example.com"]), + {"decided_by": None}, + id="decider list", + ), + pytest.param( + complete_decision(decided_by={"id": "user-7"}), + {"decided_by": None}, + id="decider without email", + ), + pytest.param( + complete_decision(decided_by={"email": "ops@example.com"}), + {"decided_by": None}, + id="decider without id", + ), + pytest.param( + complete_decision(decided_by={"id": 7, "email": "ops@example.com"}), + {"decided_by": None}, + id="decider id type", + ), + ], +) +def test_an_unreadable_detail_reads_as_absent( + result: dict[str, object], expected: dict[str, object] +) -> None: + decision = approval_decision(json.dumps(result)) + + assert decision.approved is True + assert decision.status == "approved" + read = { + "comment": decision.comment, + "decided_by": decision.decided_by, + "decided_at": decision.decided_at, + } + assert read == { + "comment": "", + "decided_by": DurableApprovalDecider(id="user-7", email="ops@example.com"), + "decided_at": "2026-10-06T12:05:00Z", + **expected, + } diff --git a/src/volcano_sdk/_tests/test_durable_approvals.py b/src/volcano_sdk/_tests/test_durable_approvals.py new file mode 100644 index 00000000..3f6d56ef --- /dev/null +++ b/src/volcano_sdk/_tests/test_durable_approvals.py @@ -0,0 +1,896 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta, timezone +from types import MappingProxyType +from typing import TYPE_CHECKING + +import httpx +import pytest + +from volcano_sdk import ( + AuthenticationError, + ConflictError, + DurableApproval, + DurableApprovalCounts, + DurableApprovalDecider, + DurableApprovalPage, + DurableApprovalStats, + NotFoundError, + PermissionDeniedError, + Session, + VolcanoClient, +) +from volcano_sdk._durable_approval_response import ( + durable_approval, + durable_approval_page, + durable_approval_stats, +) +from volcano_sdk._transport import ( + DurableApprovalListRequest, + DurableApprovalStatsRequest, + GeneratedTransport, +) + +from .fixtures.invalid_arguments import ( + assign_snapshot_value, + non_string_approval_comment, + unknown_approval_option, + unknown_approval_status, +) +from .transport_fixtures import RejectingTransport + +if TYPE_CHECKING: + from collections.abc import Callable + + from volcano_sdk.durable import DurableApprovals + +NAIVE = datetime(2026, 10, 1, tzinfo=UTC).replace(tzinfo=None) +PROJECT_ID = "00000000-0000-4000-8000-000000000001" +APPROVAL_ID = "00000000-0000-4000-8000-0000000000a1" +EXECUTION_ID = "00000000-0000-4000-8000-0000000000e1" +FUNCTION_ID = "00000000-0000-4000-8000-000000000040" +API_URL = "https://api.volcano.test" + + +@dataclass(frozen=True) +class FakeResponse: + status_code: int + payload: object + headers: dict[str, str] + content: bytes = b"" + + +def pending_approval(**overrides: object) -> dict[str, object]: + approval: dict[str, object] = { + "id": APPROVAL_ID, + "status": "pending", + "name": "ship-order", + "title": "Ship order 1234?", + "description": "Customer asked for express shipping.", + "details": {"order_id": "1234", "items": [1, 2]}, + "function": {"id": FUNCTION_ID, "name": "order-pipeline"}, + "execution": {"id": EXECUTION_ID, "name": "order-1234", "status": "running"}, + "requested_at": "2026-10-06T12:00:00Z", + "expires_at": "2026-10-07T12:00:00Z", + "decision": None, + } + approval.update(overrides) + return approval + + +def approved_approval() -> dict[str, object]: + return pending_approval( + status="approved", + decision={ + "comment": "Looks good", + "decided_by": {"id": "user-7", "email": "ops@example.com"}, + "decided_at": "2026-10-06T12:05:00Z", + }, + ) + + +def counts(**overrides: object) -> dict[str, object]: + values: dict[str, object] = { + "requested": 4, + "pending": 1, + "approved": 2, + "denied": 1, + "expired": 0, + "cancelled": 0, + } + values.update(overrides) + return values + + +def stats_payload() -> dict[str, object]: + return { + "from": "2026-09-06T00:00:00Z", + "to": "2026-10-06T00:00:00Z", + "counts": counts(), + "approval_rate": 0.6667, + "median_seconds_to_decision": 12.5, + "p90_seconds_to_decision": 30, + "functions": [ + { + "function": {"id": FUNCTION_ID, "name": "order-pipeline"}, + "counts": counts(), + }, + {"function": {"id": None, "name": "deleted-fn"}, "counts": counts()}, + ], + "other_functions": counts(requested=0, pending=0, approved=0, denied=0), + "daily": [{"date": "2026-10-05", "counts": counts()}], + } + + +class FakeApprovalTransport(RejectingTransport): + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, object]]] = [] + self.list_response: FakeResponse = FakeResponse( + 200, + { + "data": [pending_approval(), approved_approval()], + "page": 1, + "limit": 10, + "total": 2, + "has_more": False, + }, + {}, + ) + self.get_response: FakeResponse = FakeResponse(200, pending_approval(), {}) + self.stats_response: FakeResponse = FakeResponse(200, stats_payload(), {}) + self.decide_response: FakeResponse = FakeResponse(200, approved_approval(), {}) + + def list_durable_approvals(self, **kwargs: object) -> FakeResponse: + self.calls.append(("listDurableApprovals", kwargs)) + return self.list_response + + def get_durable_approval(self, **kwargs: object) -> FakeResponse: + self.calls.append(("getDurableApproval", kwargs)) + return self.get_response + + def get_durable_approval_stats(self, **kwargs: object) -> FakeResponse: + self.calls.append(("getDurableApprovalStats", kwargs)) + return self.stats_response + + def approve_durable_approval(self, **kwargs: object) -> FakeResponse: + self.calls.append(("approveDurableApproval", kwargs)) + return self.decide_response + + def deny_durable_approval(self, **kwargs: object) -> FakeResponse: + self.calls.append(("denyDurableApproval", kwargs)) + return self.decide_response + + +def approvals_client( + transport: RejectingTransport | GeneratedTransport, *, session: bool = True +) -> VolcanoClient: + client = VolcanoClient( + anon_key="anon-key", service_key="service-key", _transport=transport + ) + if session: + _ = client.auth.set_session( + Session( + access_token="access-token", + refresh_token="refresh-token", + user_id="user-1", + ) + ) + return client + + +def wire_client( + respond: Callable[[httpx.Request], httpx.Response], +) -> tuple[VolcanoClient, list[httpx.Request]]: + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return respond(request) + + client = approvals_client( + GeneratedTransport(api_url=API_URL, httpx_transport=httpx.MockTransport(handle)) + ) + return client, requests + + +def test_approvals_require_a_transport_with_approval_methods() -> None: + client = approvals_client(RejectingTransport()) + + with pytest.raises(TypeError, match="Transport does not support durable approvals"): + _ = client.durable.approvals.get(PROJECT_ID, APPROVAL_ID) + + +def test_get_returns_a_pending_approval() -> None: + transport = FakeApprovalTransport() + + approval = approvals_client(transport).durable.approvals.get( + f" {PROJECT_ID} ", APPROVAL_ID + ) + + assert approval == DurableApproval( + id=APPROVAL_ID, + status="pending", + name="ship-order", + title="Ship order 1234?", + description="Customer asked for express shipping.", + function=approval.function, + execution=approval.execution, + requested_at=datetime(2026, 10, 6, 12, tzinfo=UTC), + expires_at=datetime(2026, 10, 7, 12, tzinfo=UTC), + decision=None, + details={"order_id": "1234", "items": (1, 2)}, + ) + assert approval.function.id == FUNCTION_ID + assert approval.function.name == "order-pipeline" + assert approval.execution.id == EXECUTION_ID + assert approval.execution.name == "order-1234" + assert approval.execution.status == "running" + assert approval.is_pending is True + assert transport.calls == [ + ( + "getDurableApproval", + { + "authorization": "access-token", + "project_id": PROJECT_ID, + "approval_id": APPROVAL_ID, + }, + ) + ] + + +def test_get_freezes_the_details_the_workflow_attached() -> None: + approval = approvals_client(FakeApprovalTransport()).durable.approvals.get( + PROJECT_ID, APPROVAL_ID + ) + + assert isinstance(approval.details, MappingProxyType) + with pytest.raises(TypeError): + assign_snapshot_value(approval.details) + + +def test_get_reports_who_decided_and_when() -> None: + transport = FakeApprovalTransport() + transport.get_response = FakeResponse(200, approved_approval(), {}) + + approval = approvals_client(transport).durable.approvals.get( + PROJECT_ID, APPROVAL_ID + ) + + assert approval.status == "approved" + assert approval.is_pending is False + assert approval.decision is not None + assert approval.decision.comment == "Looks good" + assert approval.decision.decided_by == DurableApprovalDecider( + id="user-7", email="ops@example.com" + ) + assert approval.decision.decided_at == datetime(2026, 10, 6, 12, 5, tzinfo=UTC) + + +def test_get_keeps_an_approval_whose_function_and_execution_are_gone() -> None: + transport = FakeApprovalTransport() + payload = pending_approval( + status="cancelled", + function={"id": None, "name": "order-pipeline"}, + execution={"id": None, "name": "order-1234", "status": None}, + expires_at=None, + decision={ + "comment": "", + "decided_by": None, + "decided_at": "2026-10-06T13:00:00Z", + }, + ) + del payload["details"] + transport.get_response = FakeResponse(200, payload, {}) + + approval = approvals_client(transport).durable.approvals.get( + PROJECT_ID, APPROVAL_ID + ) + + assert approval.function.id is None + assert approval.execution.id is None + assert approval.execution.status is None + assert approval.expires_at is None + assert approval.details is None + assert approval.decision is not None + assert approval.decision.decided_by is None + + +def test_list_returns_an_immutable_page_and_omits_unset_filters() -> None: + transport = FakeApprovalTransport() + + page = approvals_client(transport).durable.approvals.list(PROJECT_ID) + + assert isinstance(page, DurableApprovalPage) + assert [approval.status for approval in page.approvals] == ["pending", "approved"] + assert isinstance(page.approvals, tuple) + assert (page.page, page.limit, page.total, page.has_more) == (1, 10, 2, False) + assert transport.calls == [ + ( + "listDurableApprovals", + { + "authorization": "access-token", + "project_id": PROJECT_ID, + "request": DurableApprovalListRequest(), + }, + ) + ] + + +def test_list_forwards_every_filter() -> None: + transport = FakeApprovalTransport() + start = datetime(2026, 10, 1, tzinfo=UTC) + end = datetime(2026, 10, 6, tzinfo=UTC) + + _ = approvals_client(transport).durable.approvals.list( + PROJECT_ID, + status="pending", + function=" order-pipeline ", + execution_id=EXECUTION_ID, + from_=start, + to=end, + page=2, + limit=50, + ) + + assert transport.calls[0][1]["request"] == DurableApprovalListRequest( + status="pending", + function="order-pipeline", + execution_id=EXECUTION_ID, + from_=start, + to=end, + page=2, + limit=50, + ) + + +def test_stats_returns_counts_rates_and_breakdowns() -> None: + transport = FakeApprovalTransport() + + stats = approvals_client(transport).durable.approvals.stats( + PROJECT_ID, function="order-pipeline" + ) + + assert isinstance(stats, DurableApprovalStats) + assert stats.from_ == datetime(2026, 9, 6, tzinfo=UTC) + assert stats.to == datetime(2026, 10, 6, tzinfo=UTC) + assert stats.counts == DurableApprovalCounts( + requested=4, pending=1, approved=2, denied=1, expired=0, cancelled=0 + ) + assert stats.approval_rate == pytest.approx(0.6667) + assert stats.median_seconds_to_decision == pytest.approx(12.5) + assert stats.p90_seconds_to_decision == pytest.approx(30.0) + assert isinstance(stats.p90_seconds_to_decision, float) + assert [entry.function.name for entry in stats.functions] == [ + "order-pipeline", + "deleted-fn", + ] + assert stats.functions[1].function.id is None + assert stats.functions[0].counts.requested == 4 + assert stats.other_functions.requested == 0 + assert stats.daily[0].day == date(2026, 10, 5) + assert stats.daily[0].counts.approved == 2 + assert transport.calls == [ + ( + "getDurableApprovalStats", + { + "authorization": "access-token", + "project_id": PROJECT_ID, + "request": DurableApprovalStatsRequest(function="order-pipeline"), + }, + ) + ] + + +def test_stats_keeps_absent_rates_absent() -> None: + transport = FakeApprovalTransport() + payload = stats_payload() + payload.update( + approval_rate=None, + median_seconds_to_decision=None, + p90_seconds_to_decision=None, + functions=[], + daily=[], + ) + transport.stats_response = FakeResponse(200, payload, {}) + + stats = approvals_client(transport).durable.approvals.stats(PROJECT_ID) + + assert stats.approval_rate is None + assert stats.median_seconds_to_decision is None + assert stats.p90_seconds_to_decision is None + assert stats.functions == () + assert stats.daily == () + assert transport.calls[0][1]["request"] == DurableApprovalStatsRequest() + + +@pytest.mark.parametrize("decide", ["approve", "deny"]) +def test_a_decision_sends_its_comment(decide: str) -> None: + transport = FakeApprovalTransport() + approvals = approvals_client(transport).durable.approvals + method = approvals.approve if decide == "approve" else approvals.deny + + approval = method(PROJECT_ID, APPROVAL_ID, comment="Ship it") + + assert approval.status == "approved" + assert transport.calls == [ + ( + f"{decide}DurableApproval", + { + "authorization": "access-token", + "project_id": PROJECT_ID, + "approval_id": APPROVAL_ID, + "comment": "Ship it", + }, + ) + ] + + +@pytest.mark.parametrize("decide", ["approve", "deny"]) +def test_a_decision_without_a_comment_sends_none(decide: str) -> None: + transport = FakeApprovalTransport() + approvals = approvals_client(transport).durable.approvals + method = approvals.approve if decide == "approve" else approvals.deny + + _ = method(PROJECT_ID, APPROVAL_ID) + + assert transport.calls[0][1]["comment"] is None + + +def test_a_comment_at_the_limit_is_accepted() -> None: + transport = FakeApprovalTransport() + + _ = approvals_client(transport).durable.approvals.approve( + PROJECT_ID, APPROVAL_ID, comment="x" * 2000 + ) + + assert transport.calls[0][1]["comment"] == "x" * 2000 + + +@pytest.mark.parametrize("decide", ["approve", "deny"]) +def test_a_comment_over_the_limit_is_refused_before_transport(decide: str) -> None: + transport = FakeApprovalTransport() + approvals = approvals_client(transport).durable.approvals + method = approvals.approve if decide == "approve" else approvals.deny + + with pytest.raises(ValueError, match="comment must be at most 2000 characters"): + _ = method(PROJECT_ID, APPROVAL_ID, comment="x" * 2001) + assert transport.calls == [] + + +def test_a_comment_that_is_not_a_string_is_refused_before_transport() -> None: + transport = FakeApprovalTransport() + + with pytest.raises(TypeError) as caught: + non_string_approval_comment( + approvals_client(transport).durable.approvals, APPROVAL_ID + ) + assert str(caught.value) == "comment must be a string" + assert transport.calls == [] + + +INVALID_ARGUMENTS: dict[str, tuple[Callable[[DurableApprovals], object], str]] = { + "empty project": ( + lambda a: a.get("", APPROVAL_ID), + "project_id must be a non-empty string", + ), + "get project": ( + lambda a: a.get("project", APPROVAL_ID), + "project_id must be a UUID", + ), + "empty approval": ( + lambda a: a.get(PROJECT_ID, " "), + "approval_id must be a non-empty string", + ), + "get approval": ( + lambda a: a.get(PROJECT_ID, "approval"), + "approval_id must be a UUID", + ), + "approve approval": ( + lambda a: a.approve(PROJECT_ID, "approval"), + "approval_id must be a UUID", + ), + "deny project": ( + lambda a: a.deny("project", APPROVAL_ID), + "project_id must be a UUID", + ), + "list project": (lambda a: a.list("project"), "project_id must be a UUID"), + "stats project": (lambda a: a.stats("project"), "project_id must be a UUID"), + "list execution": ( + lambda a: a.list(PROJECT_ID, execution_id="execution"), + "execution_id must be a UUID", + ), + "blank function": ( + lambda a: a.list(PROJECT_ID, function=" "), + "function must be a non-empty string", + ), + "long function": ( + lambda a: a.stats(PROJECT_ID, function="f" * 256), + "function must be at most 255 characters", + ), + "naive list from": ( + lambda a: a.list(PROJECT_ID, from_=NAIVE), + "from_ must be a timezone-aware datetime", + ), + "naive list to": ( + lambda a: a.list(PROJECT_ID, to=NAIVE), + "to must be a timezone-aware datetime", + ), + "naive stats from": ( + lambda a: a.stats(PROJECT_ID, from_=NAIVE), + "from_ must be a timezone-aware datetime", + ), + "naive stats to": ( + lambda a: a.stats(PROJECT_ID, to=NAIVE), + "to must be a timezone-aware datetime", + ), +} + + +@pytest.mark.parametrize( + ("call", "message"), + [pytest.param(*case, id=name) for name, case in INVALID_ARGUMENTS.items()], +) +def test_invalid_arguments_are_refused_before_transport( + call: Callable[[DurableApprovals], object], message: str +) -> None: + transport = FakeApprovalTransport() + approvals = approvals_client(transport).durable.approvals + + with pytest.raises(ValueError, match=f"^{re.escape(message)}$"): + _ = call(approvals) + assert transport.calls == [] + + +def test_list_refuses_an_unknown_status_before_transport() -> None: + transport = FakeApprovalTransport() + + with pytest.raises( + ValueError, + match=r"^status must be one of pending, approved, denied, expired, cancelled$", + ): + unknown_approval_status(approvals_client(transport).durable.approvals) + assert transport.calls == [] + + +def test_an_unknown_option_is_refused_before_transport() -> None: + transport = FakeApprovalTransport() + + with pytest.raises(TypeError) as caught: + unknown_approval_option(approvals_client(transport).durable.approvals) + assert str(caught.value) == "unexpected option: sort, state" + assert transport.calls == [] + + +def test_a_function_at_the_name_limit_is_accepted() -> None: + transport = FakeApprovalTransport() + + _ = approvals_client(transport).durable.approvals.list( + PROJECT_ID, function="f" * 255 + ) + + assert transport.calls[0][1]["request"] == DurableApprovalListRequest( + function="f" * 255 + ) + + +def test_approvals_refuse_a_service_key_alone() -> None: + transport = FakeApprovalTransport() + client = approvals_client(transport, session=False) + + with pytest.raises(RuntimeError, match="No active session"): + _ = client.durable.approvals.get(PROJECT_ID, APPROVAL_ID) + assert transport.calls == [] + + +@pytest.mark.parametrize( + ("status", "code", "expected"), + [ + (403, None, PermissionDeniedError), + (404, None, NotFoundError), + (409, "approval_decided", ConflictError), + (409, "approval_expired", ConflictError), + (409, "approval_cancelled", ConflictError), + ], +) +def test_decision_failures_raise_typed_errors( + status: int, code: str | None, expected: type[Exception] +) -> None: + transport = FakeApprovalTransport() + body: dict[str, object] = {"error": "cannot decide"} + if code is not None: + body["code"] = code + transport.decide_response = FakeResponse(status, body, {}) + + with pytest.raises(expected, match="cannot decide") as caught: + _ = approvals_client(transport).durable.approvals.deny(PROJECT_ID, APPROVAL_ID) + + assert type(caught.value) is expected + assert getattr(caught.value, "code", None) == code + + +def test_permission_denied_is_an_authentication_error() -> None: + error = PermissionDeniedError("denied", status=403) + + assert isinstance(error, AuthenticationError) + + +def test_get_raises_not_found_for_another_project_s_approval() -> None: + transport = FakeApprovalTransport() + transport.get_response = FakeResponse(404, {"error": "approval not found"}, {}) + + with pytest.raises(NotFoundError, match="approval not found"): + _ = approvals_client(transport).durable.approvals.get(PROJECT_ID, APPROVAL_ID) + + +def test_get_sends_the_owner_request_over_the_wire() -> None: + client, requests = wire_client( + lambda _r: httpx.Response(200, json=pending_approval()) + ) + + approval = client.durable.approvals.get(PROJECT_ID, APPROVAL_ID) + + assert approval.id == APPROVAL_ID + assert len(requests) == 1 + assert requests[0].method == "GET" + assert str(requests[0].url) == ( + f"{API_URL}/projects/{PROJECT_ID}/durable-approvals/{APPROVAL_ID}" + ) + assert requests[0].headers["authorization"] == "Bearer access-token" + + +def test_list_sends_filters_as_query_parameters() -> None: + page = { + "data": [pending_approval()], + "page": 2, + "limit": 5, + "total": 6, + "has_more": True, + } + client, requests = wire_client(lambda _r: httpx.Response(200, json=page)) + plus_two = timezone(timedelta(hours=2)) + + result = client.durable.approvals.list( + PROJECT_ID, + status="approved", + function="order-pipeline", + execution_id=EXECUTION_ID, + from_=datetime(2026, 10, 1, 8, tzinfo=plus_two), + to=datetime(2026, 10, 6, tzinfo=UTC), + page=2, + limit=5, + ) + + assert result.page == 2 + assert result.has_more is True + assert requests[0].method == "GET" + assert requests[0].url.path == f"/projects/{PROJECT_ID}/durable-approvals" + assert dict(requests[0].url.params) == { + "status": "approved", + "function": "order-pipeline", + "execution_id": EXECUTION_ID, + "from": "2026-10-01T08:00:00+02:00", + "to": "2026-10-06T00:00:00+00:00", + "page": "2", + "limit": "5", + } + + +def test_list_sends_no_filters_it_was_not_given() -> None: + page: dict[str, object] = { + "data": [], + "page": 1, + "limit": 10, + "total": 0, + "has_more": False, + } + client, requests = wire_client(lambda _r: httpx.Response(200, json=page)) + + result = client.durable.approvals.list(PROJECT_ID) + + assert result.approvals == () + assert dict(requests[0].url.params) == {} + + +def test_stats_sends_its_window_as_query_parameters() -> None: + client, requests = wire_client(lambda _r: httpx.Response(200, json=stats_payload())) + + stats = client.durable.approvals.stats( + PROJECT_ID, + function=FUNCTION_ID, + from_=datetime(2026, 9, 6, tzinfo=UTC), + to=datetime(2026, 10, 6, tzinfo=UTC), + ) + + assert stats.counts.requested == 4 + assert stats.daily[0].day == date(2026, 10, 5) + assert requests[0].url.path == f"/projects/{PROJECT_ID}/durable-approvals/stats" + assert dict(requests[0].url.params) == { + "function": FUNCTION_ID, + "from": "2026-09-06T00:00:00+00:00", + "to": "2026-10-06T00:00:00+00:00", + } + + +def test_stats_sends_no_window_it_was_not_given() -> None: + client, requests = wire_client(lambda _r: httpx.Response(200, json=stats_payload())) + + _ = client.durable.approvals.stats(PROJECT_ID) + + assert dict(requests[0].url.params) == {} + + +@pytest.mark.parametrize( + ("decide", "comment", "body"), + [ + ("approve", "Ship it", {"comment": "Ship it"}), + ("approve", None, {}), + ("deny", "Out of stock", {"comment": "Out of stock"}), + ("deny", None, {}), + ], +) +def test_a_decision_posts_its_body_over_the_wire( + decide: str, comment: str | None, body: dict[str, str] +) -> None: + client, requests = wire_client( + lambda _r: httpx.Response(200, json=approved_approval()) + ) + approvals = client.durable.approvals + method = approvals.approve if decide == "approve" else approvals.deny + + approval = method(PROJECT_ID, APPROVAL_ID, comment=comment) + + assert approval.decision is not None + assert requests[0].method == "POST" + assert str(requests[0].url) == ( + f"{API_URL}/projects/{PROJECT_ID}/durable-approvals/{APPROVAL_ID}/{decide}" + ) + assert requests[0].headers["authorization"] == "Bearer access-token" + assert json.loads(requests[0].content) == body + + +@pytest.mark.parametrize( + ("status", "expected"), + [(403, PermissionDeniedError), (404, NotFoundError), (409, ConflictError)], +) +def test_wire_failures_map_to_typed_errors( + status: int, expected: type[Exception] +) -> None: + message = ( + "project access tokens cannot decide durable approvals; " + "a person decides in the dashboard or with a platform token" + ) + client, _requests = wire_client( + lambda _r: httpx.Response(status, json={"error": message, "code": "c"}) + ) + + with pytest.raises(expected) as caught: + _ = client.durable.approvals.approve(PROJECT_ID, APPROVAL_ID) + + assert type(caught.value) is expected + assert str(caught.value) == message + + +@pytest.mark.parametrize("payload", [None, [], "approval", 1]) +def test_an_approval_must_be_an_object(payload: object) -> None: + with pytest.raises(TypeError, match="Expected a complete durable approval"): + _ = durable_approval(payload) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("id", ""), + ("id", None), + ("status", "waiting"), + ("name", 3), + ("title", None), + ("description", None), + ("function", None), + ("function", {"id": FUNCTION_ID}), + ("function", {"id": 3, "name": "f"}), + ("execution", "e"), + ("execution", {"id": None, "name": "e", "status": "waiting"}), + ("execution", {"id": None, "name": None, "status": None}), + ("requested_at", "yesterday"), + ("requested_at", None), + ("expires_at", "tomorrow"), + ("decision", "approved"), + ("decision", {"comment": None, "decided_by": None, "decided_at": "2026"}), + ("decision", {"comment": "", "decided_by": "ops", "decided_at": "2026"}), + ( + "decision", + {"comment": "", "decided_by": {"id": "u"}, "decided_at": "2026-10-06"}, + ), + ("decision", {"comment": "", "decided_by": None, "decided_at": None}), + ("details", {1: "x"}), + ], +) +def test_an_approval_with_a_malformed_field_is_refused( + field: str, value: object +) -> None: + payload = pending_approval() + payload[field] = value + + with pytest.raises(TypeError, match="Expected a complete durable approval"): + _ = durable_approval(payload) + + +@pytest.mark.parametrize("field", ["id", "function", "execution", "requested_at"]) +def test_an_approval_missing_a_field_is_refused(field: str) -> None: + payload = pending_approval() + del payload[field] + + with pytest.raises(TypeError, match="Expected a complete durable approval"): + _ = durable_approval(payload) + + +def test_an_approval_with_non_string_keys_is_refused() -> None: + payload: dict[object, object] = {1: "x"} + payload.update(pending_approval()) + + with pytest.raises(TypeError, match="Expected a complete durable approval"): + _ = durable_approval(payload) + + +@pytest.mark.parametrize( + "payload", + [ + None, + [], + {"data": "x"}, + {"data": [], "has_more": "no"}, + {"data": [], "page": True}, + {"data": [], "total": 1.5}, + ], +) +def test_a_malformed_approval_page_is_refused(payload: object) -> None: + with pytest.raises(TypeError, match="Expected a complete durable approval page"): + _ = durable_approval_page(payload) + + +def test_an_approval_page_defaults_missing_metadata() -> None: + page = durable_approval_page({}) + + assert page == DurableApprovalPage( + approvals=(), page=0, limit=0, total=0, has_more=False + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("from", None), + ("to", "later"), + ("counts", None), + ("counts", {"requested": 1}), + ("counts", counts(pending=True)), + ("counts", counts(approved=1.5)), + ("approval_rate", "half"), + ("approval_rate", True), + ("median_seconds_to_decision", "fast"), + ("p90_seconds_to_decision", float("nan")), + ("functions", None), + ("functions", [{"function": None, "counts": counts()}]), + ("functions", [{"function": {"id": None, "name": "f"}, "counts": None}]), + ("other_functions", []), + ("daily", "today"), + ("daily", [{"date": "yesterday", "counts": counts()}]), + ("daily", [{"date": None, "counts": counts()}]), + ("daily", ["2026-10-05"]), + ], +) +def test_malformed_stats_are_refused(field: str, value: object) -> None: + payload = stats_payload() + payload[field] = value + + with pytest.raises(TypeError, match="Expected complete durable approval stats"): + _ = durable_approval_stats(payload) + + +@pytest.mark.parametrize("payload", [None, [], {1: "x"}]) +def test_stats_must_be_an_object(payload: object) -> None: + with pytest.raises(TypeError, match="Expected complete durable approval stats"): + _ = durable_approval_stats(payload) diff --git a/src/volcano_sdk/_tests/test_durable_authoring.py b/src/volcano_sdk/_tests/test_durable_authoring.py index 238c1d5e..4572ded3 100644 --- a/src/volcano_sdk/_tests/test_durable_authoring.py +++ b/src/volcano_sdk/_tests/test_durable_authoring.py @@ -10,11 +10,13 @@ import inspect import json import logging +import threading from collections.abc import Mapping from contextlib import contextmanager from types import ModuleType, SimpleNamespace -from typing import TYPE_CHECKING, TypeGuard +from typing import TYPE_CHECKING, TypeGuard, TypeVar +import httpx import pytest from aws_durable_execution_sdk_python.config import ( Duration, @@ -22,11 +24,17 @@ ParallelConfig, StepConfig, StepSemantics, + WaitForCallbackConfig, ) from aws_durable_execution_sdk_python.config import ( ParallelBranch as EngineParallelBranch, ) -from aws_durable_execution_sdk_python.exceptions import WaitForConditionError +from aws_durable_execution_sdk_python.exceptions import ( + CallbackError, + CallbackSubmitterError, + CallbackTimeoutError, + WaitForConditionError, +) from aws_durable_execution_sdk_python.retries import RetryDecision from aws_durable_execution_sdk_python.waits import ( WaitForConditionConfig, @@ -36,10 +44,12 @@ ) from aws_durable_execution_sdk_python_testing import DurableFunctionTestRunner +from volcano_sdk import ValidationError from volcano_sdk._durable_duration import to_seconds from volcano_sdk._durable_engine import Engine, load_engine from volcano_sdk._durable_results import completion_reason from volcano_sdk.durable_authoring import ( + ApprovalDecision, BatchFailure, BatchItem, BatchOptions, @@ -53,6 +63,7 @@ WaitUntilOptions, durable, ) +from volcano_sdk.models import DurableApprovalDecider from .fixtures.durable_context import ( RecordedBatch, @@ -65,6 +76,7 @@ InspectedDurableContext, InspectedEngine, ) +from .fixtures.invalid_arguments import non_string_approval_title from .fixtures.invalid_callbacks import ( decorate_non_callable, register_non_callable_branch, @@ -78,6 +90,11 @@ if TYPE_CHECKING: from collections.abc import Callable, Generator, Iterator + from volcano_sdk._durable_protocols import RuntimeContext + from volcano_sdk.models import JSONValue + +T = TypeVar("T") + # The local runner's scheduler calls asyncio.iscoroutinefunction, which Python # 3.14 deprecates, and the suite turns warnings into errors. It comes from the # runner rather than from the durable runtime a deployed function uses, so it @@ -1275,3 +1292,674 @@ def test_runtime_missing_error_preserves_an_explicit_cause() -> None: cause = ImportError("missing runtime") assert DurableRuntimeMissingError(cause).__cause__ is cause + + +PLATFORM_API_URL = "https://api.volcano.test" +_INVOCATION_TIMEOUT_SECONDS = 30 + + +class Registrations: + """Stand in for Volcano's registration route, from the runtime's thread.""" + + def __init__(self) -> None: + self.bodies: list[dict[str, JSONValue]] = [] + self.api_urls: list[str] = [] + self._lock: threading.Lock = threading.Lock() + + def register(self, api_url: str, body: Mapping[str, JSONValue]) -> None: + with self._lock: + self.api_urls.append(api_url) + self.bodies.append(dict(body)) + + def expect(self, count: int) -> list[dict[str, JSONValue]]: + with self._lock: + if len(self.bodies) != count: + message = f"expected {count} registrations, got {len(self.bodies)}" + raise AssertionError(message) + return list(self.bodies) + + +class Invocations: + """Count the invocations a handler has finished. + + The local runner invalidates an invocation's checkpoints when a callback + completes under it, so a decision is sent only once the invocation that + registered the approval has suspended and returned. On the platform a + person decides long after that. + """ + + def __init__(self, handler: FunctionHandler) -> None: + self._handler: FunctionHandler = handler + self._finished: int = 0 + self._changed: threading.Condition = threading.Condition() + + def __call__(self, event: object, function_context: object) -> object: + try: + return self._handler(event, function_context) + finally: + with self._changed: + self._finished += 1 + self._changed.notify_all() + + def wait_for_one(self) -> None: + with self._changed: + if not self._changed.wait_for( + lambda: self._finished > 0, timeout=_INVOCATION_TIMEOUT_SECONDS + ): + message = "expected the invocation to suspend" + raise AssertionError(message) + + +@contextmanager +def suspended_on_approvals( + handler: FunctionHandler, registrations: Registrations, count: int +) -> Generator[tuple[DurableFunctionTestRunner, str, list[dict[str, JSONValue]]]]: + """Start a handler and wait until it suspends on its approvals. + + The first invocation returns only once every approval it reached has + registered and suspended, or once it failed. + + Yields: + The runner, the execution reference, and the registration bodies. + """ + invocations = Invocations(handler) + with local_runner(invocations) as runner: + execution_ref = runner.run_async(input=json.dumps({})) + invocations.wait_for_one() + yield runner, execution_ref, registrations.expect(count) + + +@pytest.fixture +def registrations(monkeypatch: pytest.MonkeyPatch) -> Registrations: + recorder = Registrations() + monkeypatch.setattr( + "volcano_sdk.durable_authoring.register_approval", recorder.register + ) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + return recorder + + +def decided(*, approved: bool = True, comment: str = "Ship it") -> bytes: + return json.dumps( + { + "status": "approved" if approved else "denied", + "approved": approved, + "comment": comment, + "decided_by": {"id": "user-7", "email": "ops@example.com"}, + "decided_at": "2026-10-06T12:05:00Z", + } + ).encode() + + +def callback_id(body: Mapping[str, JSONValue]) -> str: + value = body["callback_id"] + assert isinstance(value, str) + assert value + return value + + +def decision_summary(decision: ApprovalDecision) -> dict[str, object]: + return { + "approved": decision.approved, + "status": decision.status, + "comment": decision.comment, + "decided_by": None if decision.decided_by is None else decision.decided_by.id, + "decided_at": decision.decided_at, + } + + +def decide_each( + handler: FunctionHandler, + registrations: Registrations, + count: int, + decision: bytes | None = None, +) -> tuple[str, object]: + """Run a handler, decide each approval it registers, and return its result. + + Returns: + The execution reference and the decoded result. + + Raises: + AssertionError: If the handler fails. + """ + with suspended_on_approvals(handler, registrations, count) as ( + runner, + execution_ref, + bodies, + ): + for body in bodies: + runner.send_callback_success(callback_id(body), decision or decided()) + result = runner.wait_for_result(execution_ref, timeout=60) + if result.error is not None: + raise AssertionError(result.error.message) + assert result.result is not None + return execution_ref, json.loads(result.result) + + +def test_an_approval_registers_and_resumes_with_the_decision( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval( + "ship-order", + title="Ship order 1234?", + description="Customer asked for express shipping.", + details={"order_id": "1234", "items": [1, 2]}, + timeout="1h", + ) + return decision_summary(decision) + + execution_ref, result = decide_each(handler, registrations, 1) + + assert result == { + "approved": True, + "status": "approved", + "comment": "Ship it", + "decided_by": "user-7", + "decided_at": "2026-10-06T12:05:00Z", + } + [body] = registrations.bodies + assert body == { + "execution_ref": execution_ref, + "callback_id": callback_id(body), + "name": "ship-order", + "title": "Ship order 1234?", + "description": "Customer asked for express shipping.", + "details": {"order_id": "1234", "items": [1, 2]}, + } + assert registrations.api_urls == [PLATFORM_API_URL] + + +def test_a_denial_is_a_decision_rather_than_an_error( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?") + return {"shipped": decision.approved, **decision_summary(decision)} + + _, result = decide_each( + handler, registrations, 1, decided(approved=False, comment="Out of stock") + ) + + assert result == { + "shipped": False, + "approved": False, + "status": "denied", + "comment": "Out of stock", + "decided_by": "user-7", + "decided_at": "2026-10-06T12:05:00Z", + } + [body] = registrations.bodies + assert set(body) == {"execution_ref", "callback_id", "name", "title"} + + +def test_a_decision_with_unreadable_details_still_resumes_the_execution( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?") + ctx.wait("after", "1s") + return decision_summary(decision) + + decision = json.dumps( + { + "status": "denied", + "approved": True, + "comment": None, + "decided_by": {"id": "user-7"}, + "decided_at": "soon", + } + ).encode() + + _, result = decide_each(handler, registrations, 1, decision) + + assert result == { + "approved": False, + "status": "denied", + "comment": "", + "decided_by": None, + "decided_at": None, + } + + +def test_an_approval_nobody_decides_expires_instead_of_raising( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?", timeout="1s") + return decision_summary(decision) + + # A lost timeout would otherwise leave the runner waiting for 15 minutes. + with local_runner(handler) as runner: + result = runner.run(input=json.dumps({}), timeout=15) + + assert result.error is None + assert result.result is not None + assert json.loads(result.result) == { + "approved": False, + "status": "expired", + "comment": "", + "decided_by": None, + "decided_at": None, + } + assert len(registrations.bodies) == 1 + + +def test_a_replay_does_not_register_the_approval_again( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?") + ctx.wait("after", "1s") + return ctx.step("ship", lambda _scope: decision.approved) + + _, result = decide_each(handler, registrations, 1) + + assert result is True + assert len(registrations.bodies) == 1 + + +def test_a_child_context_registers_against_the_same_execution( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return ctx.child( + "review", + lambda child: child.wait_for_approval("in-child", title="Ship?").status, + ) + + execution_ref, result = decide_each(handler, registrations, 1) + + assert result == "approved" + assert [body["execution_ref"] for body in registrations.bodies] == [execution_ref] + + +def test_map_items_register_against_the_same_execution( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + batch = ctx.map( + ["a", "b"], + lambda item, child, _index: ( + child.wait_for_approval(f"ship-{item}", title=f"Ship {item}?").approved + ), + "approvals", + ) + return {"results": list(batch.results), "completed": batch.completed} + + execution_ref, result = decide_each(handler, registrations, 2) + + assert result == {"results": [True, True], "completed": 2} + assert sorted(str(body["name"]) for body in registrations.bodies) == [ + "ship-a", + "ship-b", + ] + assert {str(body["execution_ref"]) for body in registrations.bodies} == { + execution_ref + } + + +def test_parallel_branches_register_against_the_same_execution( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + branches: list[Callable[[DurableContext], str] | ParallelBranch[str]] = [ + ParallelBranch( + lambda child: child.wait_for_approval("named", title="A?").status, + name="alpha", + ), + lambda child: child.wait_for_approval("bare", title="B?").status, + ] + batch = ctx.parallel(branches, "fan-out") + return sorted(batch.results) + + execution_ref, result = decide_each(handler, registrations, 2) + + assert result == ["approved", "approved"] + assert sorted(str(body["name"]) for body in registrations.bodies) == [ + "bare", + "named", + ] + assert {str(body["execution_ref"]) for body in registrations.bodies} == { + execution_ref + } + + +def test_a_refused_registration_fails_the_execution_without_retrying( + monkeypatch: pytest.MonkeyPatch, +) -> None: + attempts: list[str] = [] + + def refuse(_api_url: str, body: Mapping[str, JSONValue]) -> None: + attempts.append(str(body["name"])) + message = "title is too long" + raise ValidationError(message, status=400) + + monkeypatch.setattr("volcano_sdk.durable_authoring.register_approval", refuse) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return ctx.wait_for_approval("ship-order", title="Ship?").approved + + assert "title is too long" in failing_handler(handler) + assert attempts == ["ship-order"] + + +def test_a_refusal_reaches_the_handler_as_the_runtimes_submitter_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def refuse(_api_url: str, _body: Mapping[str, JSONValue]) -> None: + message = "title is too long" + raise ValidationError(message, status=400) + + monkeypatch.setattr("volcano_sdk.durable_authoring.register_approval", refuse) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + try: + _ = ctx.wait_for_approval("ship-order", title="Ship?") + except CallbackSubmitterError as error: + return str(error) + return None + + assert run_handler(handler) == "title is too long" + + +def test_an_approval_closed_before_it_registered_resumes_expired( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[httpx.Request] = [] + + def closed(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 409, + json={ + "error": "approval request is no longer open", + "code": "approval_closed", + }, + ) + + real_client = httpx.AsyncClient + + def client(*, timeout: float) -> httpx.AsyncClient: + return real_client(transport=httpx.MockTransport(closed), timeout=timeout) + + monkeypatch.setattr( + "volcano_sdk._durable_approval_registration.httpx.AsyncClient", client + ) + monkeypatch.setenv("VOLCANO_PLATFORM_API_URL", PLATFORM_API_URL) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + decision = ctx.wait_for_approval("ship-order", title="Ship?", timeout="1s") + return decision_summary(decision) + + with local_runner(handler) as runner: + result = runner.run(input=json.dumps({}), timeout=15) + + assert result.error is None + assert result.result is not None + assert json.loads(result.result) == { + "approved": False, + "status": "expired", + "comment": "", + "decided_by": None, + "decided_at": None, + } + assert len(requests) == 1 + assert str(requests[0].url) == f"{PLATFORM_API_URL}/durable-approvals" + + +def test_a_failed_callback_is_not_mistaken_for_an_expiry( + registrations: Registrations, +) -> None: + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return ctx.wait_for_approval("ship-order", title="Ship?").status + + with suspended_on_approvals(handler, registrations, 1) as ( + runner, + execution_ref, + [body], + ): + runner.send_callback_failure(callback_id(body)) + result = runner.wait_for_result(execution_ref, timeout=60) + + assert result.error is not None + assert result.result is None + + +@pytest.mark.usefixtures("registrations") +def test_a_missing_platform_api_url_fails_before_opening_a_callback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("VOLCANO_PLATFORM_API_URL") + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(RuntimeError, match="needs VOLCANO_PLATFORM_API_URL"): + _ = context.wait_for_approval("ship-order", title="Ship?") + assert runtime.submitter is None + + +def test_wait_for_approval_forwards_its_name_timeout_and_submitter( + registrations: Registrations, +) -> None: + runtime = RecordingContext() + runtime.callback_result = decided().decode() + context = DurableContext(runtime, Engine()) + + decision = context.wait_for_approval("ship-order", title="Ship?", timeout="2h") + + assert decision == ApprovalDecision( + approved=True, + status="approved", + comment="Ship it", + decided_by=DurableApprovalDecider(id="user-7", email="ops@example.com"), + decided_at="2026-10-06T12:05:00Z", + ) + assert runtime.name == "ship-order" + assert isinstance(runtime.config, WaitForCallbackConfig) + assert runtime.config.timeout.to_seconds() == 7200 + retry = runtime.config.retry_strategy + assert retry is not None + assert retry(RuntimeError("failed"), 1).should_retry is False + assert runtime.submitter is not None + assert registrations.bodies == [] + runtime.submitter("callback-9", None) + assert registrations.bodies == [ + { + "name": "ship-order", + "title": "Ship?", + "execution_ref": "arn:execution", + "callback_id": "callback-9", + } + ] + + +@pytest.mark.usefixtures("registrations") +def test_an_approval_without_a_timeout_lasts_as_long_as_the_execution() -> None: + runtime = RecordingContext() + runtime.callback_result = decided(approved=False).decode() + context = DurableContext(runtime, Engine()) + + decision = context.wait_for_approval("ship-order", title="Ship?") + + assert decision.status == "denied" + assert isinstance(runtime.config, WaitForCallbackConfig) + assert runtime.config.timeout.to_seconds() == 0 + assert runtime.config.retry_strategy is not None + + +@pytest.mark.usefixtures("registrations") +def test_only_a_callback_timeout_becomes_an_expired_decision() -> None: + runtime = RecordingContext() + runtime.callback_error = CallbackTimeoutError("timed out") + context = DurableContext(runtime, Engine()) + + expired = context.wait_for_approval("ship-order", title="Ship?", timeout=60) + + assert expired == ApprovalDecision(approved=False, status="expired") + runtime.callback_error = CallbackError("callback failed") + with pytest.raises(CallbackError, match="callback failed"): + _ = context.wait_for_approval("ship-order", title="Ship?") + + +@pytest.mark.parametrize( + ("timeout", "error", "message"), + [ + pytest.param("0s", TypeError, "timeout must be at least 1 second", id="0"), + pytest.param( + {"days": 367}, + TypeError, + r"timeout must be at most 31622400 seconds \(366 days\)", + id="367d", + ), + pytest.param("soon", ValueError, "^timeout must be a duration", id="text"), + pytest.param(1.5, TypeError, "^timeout must be a whole number", id="float"), + ], +) +def test_an_unusable_approval_timeout_is_refused( + timeout: object, error: type[Exception], message: str +) -> None: + context = InspectedDurableContext(RecordingContext(), Engine()) + + with pytest.raises(error, match=message): + _ = context.approval_timeout(timeout) + + +def test_an_approval_timeout_accepts_its_bounds() -> None: + context = InspectedDurableContext(RecordingContext(), Engine()) + + assert context.approval_timeout(1) == 1 + assert context.approval_timeout({"days": 366}) == 31_622_400 + + +@pytest.mark.parametrize("duration", ["0s", 0, {"days": 367}, 31_622_401]) +def test_an_approval_timeout_is_bounded_like_a_wait(duration: object) -> None: + context = InspectedDurableContext(RecordingContext(), Engine()) + + with pytest.raises(TypeError) as waited: + _ = context.wait_duration(duration) + with pytest.raises(TypeError) as timed: + _ = context.approval_timeout(duration) + + assert str(timed.value) == str(waited.value).replace("wait", "timeout", 1) + + +@pytest.mark.usefixtures("registrations") +@pytest.mark.parametrize( + ("title", "details", "timeout", "error", "message"), + [ + pytest.param( + "Ship?", + None, + 0, + TypeError, + "timeout must be at least 1 second", + id="timeout", + ), + pytest.param( + "Ship?", + {"total": float("nan")}, + None, + TypeError, + "details must be JSON-serializable", + id="details", + ), + pytest.param( + "Ship\ud800?", + None, + None, + TypeError, + "title must be JSON-serializable", + id="surrogate", + ), + pytest.param( + "Ship?", + {"lines": ["x" * 1000] * 65}, + None, + ValueError, + "larger than the 64 KiB Volcano accepts", + id="too large with the callback id", + ), + ], +) +def test_an_invalid_approval_is_refused_before_opening_a_callback( + title: str, + details: JSONValue, + timeout: int | None, + error: type[Exception], + message: str, +) -> None: + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(error, match=message): + _ = context.wait_for_approval( + "ship-order", title=title, details=details, timeout=timeout + ) + assert runtime.submitter is None + + +@pytest.mark.usefixtures("registrations") +def test_a_non_string_title_is_refused_before_opening_a_callback() -> None: + runtime = RecordingContext() + context = DurableContext(runtime, Engine()) + + with pytest.raises(TypeError, match="title must be a string"): + non_string_approval_title(context) + assert runtime.submitter is None + + +def test_one_runtime_wrapper_serves_every_invocation_with_its_own_execution( + monkeypatch: pytest.MonkeyPatch, registrations: Registrations +) -> None: + engine = Engine() + wrap = engine.durable_execution + built: list[object] = [] + invocations: list[object] = [] + + def build( + func: Callable[[T, RuntimeContext], object], / + ) -> Callable[[object, object], object]: + built.append(func) + run = wrap(func) + + def invoke(event: object, function_context: object) -> object: + invocations.append(event) + return run(event, function_context) + + return invoke + + engine.durable_execution = build + monkeypatch.setattr("volcano_sdk.durable_authoring.load_engine", lambda: engine) + + @durable + def handler(_event: object, ctx: DurableContext) -> object: + return ctx.wait_for_approval("ship-order", title="Ship?").status + + first, _ = decide_each(handler, registrations, 1) + later = Registrations() + monkeypatch.setattr( + "volcano_sdk.durable_authoring.register_approval", later.register + ) + second, _ = decide_each(handler, later, 1) + + # Each execution suspends on its approval and resumes once decided. + assert len(invocations) == 4 + assert len(built) == 1 + assert first != second + assert [body["execution_ref"] for body in registrations.bodies] == [first] + assert [body["execution_ref"] for body in later.bodies] == [second] diff --git a/src/volcano_sdk/_tests/test_durable_runtime_boundary.py b/src/volcano_sdk/_tests/test_durable_runtime_boundary.py index 5fed65ca..ea98b8bf 100644 --- a/src/volcano_sdk/_tests/test_durable_runtime_boundary.py +++ b/src/volcano_sdk/_tests/test_durable_runtime_boundary.py @@ -10,6 +10,7 @@ from volcano_sdk._durable_modules import ( load_config, + load_exceptions, load_retries, load_root, load_waits, @@ -26,6 +27,13 @@ load_config, "aws_durable_execution_sdk_python.config does not provide ConfigModule", ), + ( + load_exceptions, + ( + "aws_durable_execution_sdk_python.exceptions does not provide " + "ExceptionsModule" + ), + ), ( load_retries, "aws_durable_execution_sdk_python.retries does not provide RetriesModule", diff --git a/src/volcano_sdk/_tests/test_errors.py b/src/volcano_sdk/_tests/test_errors.py index aa120dc7..cdb1062e 100644 --- a/src/volcano_sdk/_tests/test_errors.py +++ b/src/volcano_sdk/_tests/test_errors.py @@ -13,6 +13,7 @@ AuthenticationError, ConflictError, NotFoundError, + PermissionDeniedError, RateLimitedError, ServerError, SessionChangedError, @@ -40,7 +41,7 @@ def client_for(handler: Callable[[httpx.Request], httpx.Response]) -> VolcanoCli [ (400, ValidationError), (401, AuthenticationError), - (403, AuthenticationError), + (403, PermissionDeniedError), (404, NotFoundError), (409, ConflictError), (418, VolcanoError), diff --git a/src/volcano_sdk/_tests/test_realtime.py b/src/volcano_sdk/_tests/test_realtime.py index 13b72156..272b38ac 100644 --- a/src/volcano_sdk/_tests/test_realtime.py +++ b/src/volcano_sdk/_tests/test_realtime.py @@ -791,7 +791,7 @@ def __call__( return self.client async def command(self) -> dict[str, object]: - return await asyncio.wait_for(self.commands.get(), timeout=0.2) + return await asyncio.wait_for(self.commands.get(), timeout=2) def subscription(self, channel: str) -> SDKNativeSubscription: subscription = self.client.get_subscription(channel) diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index a3f346a7..26c41533 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -3,6 +3,7 @@ from ._transport_auth_account import AuthAccountTransport from ._transport_auth_identity import AuthIdentityTransport from ._transport_database import DatabaseTransport +from ._transport_durable_approvals import DurableApprovalTransport from ._transport_execution import ExecutionTransport from ._transport_locks import LocksTransport from ._transport_response import invoke, invoke_async, response_payload @@ -44,6 +45,8 @@ AuthSignUpTransport, AuthUnlinkOAuthProviderTransport, AuthUpdateUserTransport, + DurableApprovalListRequest, + DurableApprovalStatsRequest, DurableExecutionListRequest, StorageUploadPartRequest, StorageUploadSessionReference, @@ -88,6 +91,8 @@ "AuthSignUpTransport", "AuthUnlinkOAuthProviderTransport", "AuthUpdateUserTransport", + "DurableApprovalListRequest", + "DurableApprovalStatsRequest", "DurableExecutionListRequest", "GeneratedTransport", "StorageUploadPartRequest", @@ -107,6 +112,7 @@ class GeneratedTransport( DatabaseTransport, StorageTransport, ExecutionTransport, + DurableApprovalTransport, LocksTransport, SandboxHTTPTransport, ): diff --git a/src/volcano_sdk/_transport_durable_approvals.py b/src/volcano_sdk/_transport_durable_approvals.py new file mode 100644 index 00000000..254becdc --- /dev/null +++ b/src/volcano_sdk/_transport_durable_approvals.py @@ -0,0 +1,121 @@ +"""Generated durable approval operation adapters.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from ._generated.api.durable_functions import ( + approve_durable_approval, + deny_durable_approval, + get_durable_approval, + get_durable_approval_stats, + list_durable_approvals, +) +from ._generated.models.durable_approval_decision_request import ( + DurableApprovalDecisionRequest, +) +from ._generated.types import UNSET +from ._transport_base import TransportBase +from ._transport_response import parsed_response + +if TYPE_CHECKING: + from ._transport_types import ( + DurableApprovalListRequest, + DurableApprovalStatsRequest, + TransportResponse, + ) + + +def _decision_body(comment: str | None) -> DurableApprovalDecisionRequest: + return DurableApprovalDecisionRequest(comment=UNSET if comment is None else comment) + + +class DurableApprovalTransport(TransportBase): + """Adapt generated durable approval operations to the SDK transport.""" + + def list_durable_approvals( + self, + *, + authorization: str, + project_id: str, + request: DurableApprovalListRequest, + ) -> TransportResponse: + execution_id = request.execution_id + with self._client(authorization) as client: + response = list_durable_approvals.sync_detailed( + UUID(project_id), + client=client, + page=UNSET if request.page is None else request.page, + limit=UNSET if request.limit is None else request.limit, + status=UNSET if request.status is None else request.status, + function=UNSET if request.function is None else request.function, + execution_id=UNSET if execution_id is None else UUID(execution_id), + from_=UNSET if request.from_ is None else request.from_, + to=UNSET if request.to is None else request.to, + ) + return parsed_response(response) + + def get_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = get_durable_approval.sync_detailed( + UUID(project_id), UUID(approval_id), client=client + ) + return parsed_response(response) + + def get_durable_approval_stats( + self, + *, + authorization: str, + project_id: str, + request: DurableApprovalStatsRequest, + ) -> TransportResponse: + with self._client(authorization) as client: + response = get_durable_approval_stats.sync_detailed( + UUID(project_id), + client=client, + function=UNSET if request.function is None else request.function, + from_=UNSET if request.from_ is None else request.from_, + to=UNSET if request.to is None else request.to, + ) + return parsed_response(response) + + def approve_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + comment: str | None, + ) -> TransportResponse: + with self._client(authorization) as client: + response = approve_durable_approval.sync_detailed( + UUID(project_id), + UUID(approval_id), + client=client, + body=_decision_body(comment), + ) + return parsed_response(response) + + def deny_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + comment: str | None, + ) -> TransportResponse: + with self._client(authorization) as client: + response = deny_durable_approval.sync_detailed( + UUID(project_id), + UUID(approval_id), + client=client, + body=_decision_body(comment), + ) + return parsed_response(response) diff --git a/src/volcano_sdk/_transport_types.py b/src/volcano_sdk/_transport_types.py index 665230f4..8f1685a1 100644 --- a/src/volcano_sdk/_transport_types.py +++ b/src/volcano_sdk/_transport_types.py @@ -16,6 +16,7 @@ AuthenticationError, ConflictError, NotFoundError, + PermissionDeniedError, RateLimitedError, ValidationError, VolcanoError, @@ -23,6 +24,7 @@ if TYPE_CHECKING: from collections.abc import Mapping + from datetime import datetime from ._generated.models.auth_link_o_auth_provider_provider import ( AuthLinkOAuthProviderProvider, @@ -42,7 +44,7 @@ from ._generated.models.refresh_o_auth_provider_token_provider import ( RefreshOAuthProviderTokenProvider, ) - from .models import DurableExecutionStatus, JSONValue + from .models import DurableApprovalStatus, DurableExecutionStatus, JSONValue HTTP_CREATED = 201 @@ -96,7 +98,7 @@ ERROR_TYPES_BY_STATUS: dict[int, type[VolcanoError]] = { 400: ValidationError, 401: AuthenticationError, - 403: AuthenticationError, + 403: PermissionDeniedError, HTTP_NOT_FOUND: NotFoundError, HTTP_CONFLICT: ConflictError, 422: ValidationError, @@ -159,6 +161,28 @@ class DurableExecutionListRequest: limit: int | None = None +@dataclass(frozen=True, slots=True) +class DurableApprovalListRequest: + """Filters and paging for a durable approval listing.""" + + status: DurableApprovalStatus | None = None + function: str | None = None + execution_id: str | None = None + from_: datetime | None = None + to: datetime | None = None + page: int | None = None + limit: int | None = None + + +@dataclass(frozen=True, slots=True) +class DurableApprovalStatsRequest: + """The function filter and window for durable approval stats.""" + + function: str | None = None + from_: datetime | None = None + to: datetime | None = None + + @dataclass(frozen=True, slots=True) class StorageUploadSessionRequest: """Values needed to create a resumable storage upload session.""" diff --git a/src/volcano_sdk/durable.py b/src/volcano_sdk/durable.py index 628cdbd2..d3973fb4 100644 --- a/src/volcano_sdk/durable.py +++ b/src/volcano_sdk/durable.py @@ -2,12 +2,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from datetime import datetime +from typing import TYPE_CHECKING, Literal, Protocol, Unpack, runtime_checkable from uuid import UUID from ._client_context import ClientContextSource, facade_context +from ._durable_approval_response import ( + APPROVAL_STATUSES, + durable_approval, + durable_approval_page, + durable_approval_stats, +) from ._durable_response import durable_execution, durable_execution_page from ._transport import ( + DurableApprovalListRequest, + DurableApprovalStatsRequest, DurableExecutionListRequest, Transport, TransportResponse, @@ -16,7 +25,15 @@ ) if TYPE_CHECKING: + from collections.abc import Mapping + from .models import ( + DurableApproval, + DurableApprovalListOptions, + DurableApprovalPage, + DurableApprovalStats, + DurableApprovalStatsOptions, + DurableApprovalStatus, DurableExecution, DurableExecutionPage, DurableExecutionStatus, @@ -24,6 +41,14 @@ ) _INVALID_DURABLE_TRANSPORT = "Transport does not support durable executions" +_INVALID_APPROVAL_TRANSPORT = "Transport does not support durable approvals" +# The spec's limits on a function filter and a decision comment. +_MAX_FUNCTION_LENGTH = 255 +_MAX_COMMENT_LENGTH = 2000 +_LIST_OPTIONS = frozenset( + {"status", "function", "execution_id", "from_", "to", "page", "limit"} +) +_STATS_OPTIONS = frozenset({"function", "from_", "to"}) # The spec's maxLength on X-Volcano-Execution-Name. Checked here so an # over-long name is refused before a request is spent on it, the way the # JavaScript SDK refuses it. @@ -34,6 +59,7 @@ "execution_name": "execution_name must be a non-empty string", "project_id": "project_id must be a non-empty string", "execution_id": "execution_id must be a non-empty string", + "approval_id": "approval_id must be a non-empty string", } # The two identifiers the transport converts to a UUID before sending. Checked # here so a malformed one is a facade ValidationError rather than a @@ -42,6 +68,7 @@ _UUID_IDENTIFIERS = { "project_id": "project_id must be a UUID", "execution_id": "execution_id must be a UUID", + "approval_id": "approval_id must be a UUID", } _HTTP_ACCEPTED = 202 _HTTP_OK = 200 @@ -112,12 +139,220 @@ def stop_durable_execution( ... +@runtime_checkable +class DurableApprovalsTransport(Protocol): + """Transport operations required by the durable approvals facade.""" + + def list_durable_approvals( + self, + *, + authorization: str, + project_id: str, + request: DurableApprovalListRequest, + ) -> TransportResponse: + """List a project's durable approvals.""" + ... + + def get_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + ) -> TransportResponse: + """Read one durable approval.""" + ... + + def get_durable_approval_stats( + self, + *, + authorization: str, + project_id: str, + request: DurableApprovalStatsRequest, + ) -> TransportResponse: + """Count a project's durable approvals by outcome.""" + ... + + def approve_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + comment: str | None, + ) -> TransportResponse: + """Approve a pending durable approval.""" + ... + + def deny_durable_approval( + self, + *, + authorization: str, + project_id: str, + approval_id: str, + comment: str | None, + ) -> TransportResponse: + """Deny a pending durable approval.""" + ... + + +class DurableApprovals: + """Review and decide the approvals durable workflows wait on. + + Owner-scoped, like reading an execution: every method takes the project id + and the session's platform token. Only a person decides: a project access + token can read approvals but is refused with `PermissionDeniedError` when it + approves or denies one. + """ + + def __init__(self, client: DurableClientContext) -> None: + """Bind approval operations to a Volcano client.""" + self._client: DurableClientContext = client + + def _transport(self) -> DurableApprovalsTransport: + transport = self._client.transport() + if not isinstance(transport, DurableApprovalsTransport): + raise TypeError(_INVALID_APPROVAL_TRANSPORT) + return transport + + def list( + self, project_id: str, **options: Unpack[DurableApprovalListOptions] + ) -> DurableApprovalPage: + """List a project's approvals, newest first. + + Decided, expired, and cancelled approvals are kept for a year. + + Returns + ------- + DurableApprovalPage + Approvals and pagination metadata. + + """ + project = _identifier(project_id, "project_id") + request = _list_request(options) + transport = self._transport() + response = invoke( + transport.list_durable_approvals, + authorization=self._client.session_token(), + project_id=project, + request=request, + ) + response_body: object = response_payload(response, _HTTP_OK) + return durable_approval_page(response_body) + + def get(self, project_id: str, approval_id: str) -> DurableApproval: + """Read one approval, including its decision once it has one. + + Returns + ------- + DurableApproval + The approval. + + """ + project = _identifier(project_id, "project_id") + approval = _identifier(approval_id, "approval_id") + transport = self._transport() + response = invoke( + transport.get_durable_approval, + authorization=self._client.session_token(), + project_id=project, + approval_id=approval, + ) + response_body: object = response_payload(response, _HTTP_OK) + return durable_approval(response_body) + + def stats( + self, project_id: str, **options: Unpack[DurableApprovalStatsOptions] + ) -> DurableApprovalStats: + """Count approvals by outcome, overall, per function, and per day. + + The window defaults to the last 30 days and may span at most 366. + + Returns + ------- + DurableApprovalStats + Counts, approval rate, and decision times over the window. + + """ + project = _identifier(project_id, "project_id") + request = _stats_request(options) + transport = self._transport() + response = invoke( + transport.get_durable_approval_stats, + authorization=self._client.session_token(), + project_id=project, + request=request, + ) + response_body: object = response_payload(response, _HTTP_OK) + return durable_approval_stats(response_body) + + def approve( + self, project_id: str, approval_id: str, *, comment: str | None = None + ) -> DurableApproval: + """Approve a pending approval, resuming the workflow waiting on it. + + Repeating the same decision returns the approval unchanged. Deciding + the other way, or deciding one that expired or was cancelled, raises + `ConflictError`. + + Returns + ------- + DurableApproval + The approval with its decision. + + """ + return self._decide("approve", project_id, approval_id, comment) + + def deny( + self, project_id: str, approval_id: str, *, comment: str | None = None + ) -> DurableApproval: + """Deny a pending approval; the workflow resumes with a denied decision. + + Repeats and conflicts behave as they do for `approve`. + + Returns + ------- + DurableApproval + The approval with its decision. + + """ + return self._decide("deny", project_id, approval_id, comment) + + def _decide( + self, + decision: Literal["approve", "deny"], + project_id: str, + approval_id: str, + comment: object, + ) -> DurableApproval: + project = _identifier(project_id, "project_id") + approval = _identifier(approval_id, "approval_id") + note = _comment(comment) + transport = self._transport() + operation = ( + transport.approve_durable_approval + if decision == "approve" + else transport.deny_durable_approval + ) + response = invoke( + operation, + authorization=self._client.session_token(), + project_id=project, + approval_id=approval, + comment=note, + ) + response_body: object = response_payload(response, _HTTP_OK) + return durable_approval(response_body) + + class Durable: """Start and follow executions of deployed durable functions.""" def __init__(self, client: DurableClientContext | ClientContextSource) -> None: """Bind durable operations to a Volcano client.""" self._client: DurableClientContext = facade_context(client) + # Approvals that workflows wait on with `ctx.wait_for_approval`. + self.approvals: DurableApprovals = DurableApprovals(self._client) def _durable_transport(self) -> DurableTransport: transport = self._client.transport() @@ -320,3 +555,89 @@ def _identifier(value: object, field: str) -> str: except ValueError as exc: raise ValueError(_UUID_IDENTIFIERS[field]) from exc return trimmed + + +def _known_options(options: Mapping[str, object], known: frozenset[str]) -> None: + unknown = sorted(options.keys() - known) + if unknown: + message = f"unexpected option: {', '.join(unknown)}" + raise TypeError(message) + + +def _list_request(options: DurableApprovalListOptions) -> DurableApprovalListRequest: + _known_options(options, _LIST_OPTIONS) + status = options.get("status") + execution_id = options.get("execution_id") + return DurableApprovalListRequest( + status=None if status is None else _approval_status(status), + function=_function_filter(options.get("function")), + execution_id=( + None if execution_id is None else _identifier(execution_id, "execution_id") + ), + from_=_moment(options.get("from_"), "from_"), + to=_moment(options.get("to"), "to"), + page=options.get("page"), + limit=options.get("limit"), + ) + + +def _stats_request( + options: DurableApprovalStatsOptions, +) -> DurableApprovalStatsRequest: + _known_options(options, _STATS_OPTIONS) + return DurableApprovalStatsRequest( + function=_function_filter(options.get("function")), + from_=_moment(options.get("from_"), "from_"), + to=_moment(options.get("to"), "to"), + ) + + +def _approval_status(value: object) -> DurableApprovalStatus: + for status in APPROVAL_STATUSES: + if value == status: + return status + message = f"status must be one of {', '.join(APPROVAL_STATUSES)}" + raise ValueError(message) + + +def _function_filter(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + message = "function must be a non-empty string" + raise ValueError(message) + function = value.strip() + if len(function) > _MAX_FUNCTION_LENGTH: + message = f"function must be at most {_MAX_FUNCTION_LENGTH} characters" + raise ValueError(message) + return function + + +def _moment(value: object, field: str) -> datetime | None: + """Require an aware datetime, so the window does not shift with the host's zone. + + Returns: + The datetime, or None when the filter is unset. + + Raises: + ValueError: The value is not a timezone-aware datetime. + + """ + if value is None: + return None + if not isinstance(value, datetime) or value.utcoffset() is None: + message = f"{field} must be a timezone-aware datetime" + raise ValueError(message) + return value + + +def _comment(value: object) -> str | None: + if value is None: + return None + if not isinstance(value, str): + message = "comment must be a string" + raise TypeError(message) + if len(value) > _MAX_COMMENT_LENGTH: + message = f"comment must be at most {_MAX_COMMENT_LENGTH} characters" + raise ValueError(message) + return value diff --git a/src/volcano_sdk/durable_authoring.py b/src/volcano_sdk/durable_authoring.py index fe37b432..b2b2af05 100644 --- a/src/volcano_sdk/durable_authoring.py +++ b/src/volcano_sdk/durable_authoring.py @@ -39,6 +39,14 @@ def handler(event, ctx): from typing_extensions import TypeVar from ._callbacks import named_operation, operation_callable, require_callable +from ._durable_approval_registration import ( + EXPIRED, + approval_body, + approval_decision, + check_size, + platform_api_url, + register_approval, +) from ._durable_duration import to_seconds from ._durable_engine import DurableRuntimeMissingError, load_engine from ._durable_options import ( @@ -54,13 +62,16 @@ def handler(event, ctx): OperationScope, RuntimeContext, ) -from ._durable_results import BatchFailure, BatchItem, BatchResult +from ._durable_results import ApprovalDecision, BatchFailure, BatchItem, BatchResult if TYPE_CHECKING: from collections.abc import Sequence + from .models import JSONValue + __all__ = [ + "ApprovalDecision", "BatchFailure", "BatchItem", "BatchOptions", @@ -144,10 +155,9 @@ class DurableContext: the operations Volcano supports, in Volcano's vocabulary, and lets durations be written as `"30s"` rather than `Duration.from_seconds(30)`. - Callbacks are the deliberate omission. The engine can suspend on one, but - completing it is an AWS API call, and nothing in Volcano -- not the - function's own role, not the API -- can make it. Wait on your own state - with `wait_until` instead. + Raw callbacks are the deliberate omission: completing one takes a call + nothing in a project can make. `wait_for_approval` is the callback Volcano + completes, when a person decides. """ __slots__: tuple[str, ...] = ("_context", "_engine", "log") @@ -224,10 +234,9 @@ def child( """ child_name, child_func = named_operation(name, func, "child") - engine = self._engine def run(context: RuntimeContext) -> T: - return child_func(DurableContext(context, engine)) + return child_func(self._derive(context)) return self._context.run_in_child_context(run, child_name) @@ -286,17 +295,16 @@ def map( # work per character rather than refuse. if isinstance(items, str): raise TypeError(_INVALID_ITEMS) - engine = self._engine def run(context: RuntimeContext, item: U, index: int, _all: list[U]) -> T: - return map_func(item, DurableContext(context, engine), index) + return map_func(item, self._derive(context), index) return BatchResult( self._context.map( list(items), run, name, - engine.map_options(options), + self._engine.map_options(options), ) ) @@ -335,31 +343,82 @@ def _branch( A named engine branch or a callable that wraps the engine context. """ - engine = self._engine if isinstance(branch, ParallelBranch): named: Callable[[DurableContext], T] = branch.run def run_named(context: RuntimeContext) -> T: - return named(DurableContext(context, engine)) + return named(self._derive(context)) - return engine.named_branch(run_named, branch.name) + return self._engine.named_branch(run_named, branch.name) require_callable(branch, _INVALID_BRANCH) bare: Callable[[DurableContext], T] = branch def run_bare(context: RuntimeContext) -> object: - return bare(DurableContext(context, engine)) + return bare(self._derive(context)) return run_bare + def _derive(self, context: RuntimeContext) -> DurableContext: + return DurableContext(context, self._engine) + + def wait_for_approval( + self, + name: str, + *, + title: str, + description: str | None = None, + details: JSONValue = None, + timeout: Duration | None = None, + ) -> ApprovalDecision: + """Suspend until a person approves or denies, or the timeout passes. + + Volcano records the approval under the execution, where the project's + owners see it in the dashboard, the CLI, or `client.durable.approvals`, + and resumes the execution with their decision. It is not running, and + not billed, while it waits. `title`, `description`, and `details` are + what the person deciding is shown. + + Without a timeout the approval lasts as long as the execution. An + approval nobody decides in time resumes with an `expired` decision + rather than raising, and a denial is a decision too: branch on + `approved`. + + The arguments, and the 64 KiB Volcano accepts for the whole approval, + are checked before the callback opens, as is `VOLCANO_PLATFORM_API_URL`: + without it the function is not running where Volcano can record the + approval, and the call raises `RuntimeError`. Registering it is retried + for up to 30 seconds; a refusal raises the runtime's + `CallbackSubmitterError` with the SDK error's message. + + Returns: + The decision, restored from its checkpoint during replay. + + """ + body = approval_body(name, title, description, details) + seconds = None if timeout is None else self._approval_timeout(timeout) + api_url = platform_api_url() + body["execution_ref"] = self._context.execution_context.durable_execution_arn + check_size(body) + + # Runs inside the runtime's checkpointed submitter step, so a replay + # does not register the approval again. + def submit(callback_id: str, _context: object) -> None: + register_approval(api_url, {**body, "callback_id": callback_id}) + + try: + result = self._context.wait_for_callback( + submit, name, self._engine.callback_options(seconds) + ) + except self._engine.callback_timeout_error: + return EXPIRED + return approval_decision(result) + + @staticmethod + def _approval_timeout(value: object) -> int: + return _bounded_seconds(value, "timeout") + def _wait_duration(self, value: object) -> object: - seconds = to_seconds(value, "wait") - if seconds < _MIN_WAIT_SECONDS: - message = f"wait must be at least {_MIN_WAIT_SECONDS} second" - raise TypeError(message) - if seconds > _MAX_WAIT_SECONDS: - message = f"wait must be at most {_MAX_WAIT_SECONDS} seconds (366 days)" - raise TypeError(message) - return self._engine.seconds(seconds) + return self._engine.seconds(_bounded_seconds(value, "wait")) @overload @@ -435,6 +494,17 @@ def run(input_value: T, context: RuntimeContext) -> object: return invoke +def _bounded_seconds(value: object, field_name: str) -> int: + seconds = to_seconds(value, field_name) + if seconds < _MIN_WAIT_SECONDS: + message = f"{field_name} must be at least {_MIN_WAIT_SECONDS} second" + raise TypeError(message) + if seconds > _MAX_WAIT_SECONDS: + message = f"{field_name} must be at most {_MAX_WAIT_SECONDS} seconds (366 days)" + raise TypeError(message) + return seconds + + def _validate_wait_options(options: WaitUntilOptions[T]) -> None: if not callable(options.until): raise TypeError(_REQUIRES_UNTIL) diff --git a/src/volcano_sdk/errors.py b/src/volcano_sdk/errors.py index 8ef7863d..3ca6184f 100644 --- a/src/volcano_sdk/errors.py +++ b/src/volcano_sdk/errors.py @@ -25,6 +25,14 @@ class AuthenticationError(VolcanoError): """The supplied credentials are missing or invalid.""" +class PermissionDeniedError(AuthenticationError): + """The credentials are valid but not allowed to perform the request. + + A subclass of `AuthenticationError`, which a 403 raised before this + existed, so existing handlers keep catching it. + """ + + class ValidationError(VolcanoError): """The request failed API validation.""" diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index 2a772783..767acbe6 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -2,9 +2,9 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass, field -from datetime import datetime +from datetime import date, datetime from types import MappingProxyType -from typing import Literal, TypeAlias +from typing import Literal, TypeAlias, TypedDict from ._json_values import JSONValue as _JSONValue from ._json_values import freeze_json @@ -39,6 +39,15 @@ DURABLE_TERMINAL_STATUSES: frozenset[DurableExecutionStatus] = frozenset( {"succeeded", "failed", "timed_out", "stopped", "unknown"} ) +# Every status but "pending" is final. "expired" means the approval's timeout +# passed first; "cancelled" means its execution ended while it was pending. +DurableApprovalStatus: TypeAlias = Literal[ + "pending", + "approved", + "denied", + "expired", + "cancelled", +] def _freeze_metadata( @@ -384,3 +393,162 @@ class DurableExecutionPage: def __post_init__(self) -> None: """Defensively snapshot the executions in this page.""" object.__setattr__(self, "executions", tuple(self.executions)) + + +class DurableApprovalStatsOptions(TypedDict, total=False): + """Narrow approval stats to one function and a window. + + The window defaults to the last 30 days and may span at most 366. + """ + + # A durable function's id or name. + function: str + from_: datetime + to: datetime + + +class DurableApprovalListOptions(TypedDict, total=False): + """Filter and page a project's durable approvals.""" + + status: DurableApprovalStatus + # A durable function's id or name. + function: str + execution_id: str + # Requested at or after `from_`, and before `to`. + from_: datetime + to: datetime + page: int + limit: int + + +@dataclass(frozen=True, slots=True) +class DurableApprovalDecider: + """The person who approved or denied an approval.""" + + id: str + email: str + + +@dataclass(frozen=True, slots=True) +class DurableApprovalDecision: + """Who decided an approval, when, and what they said.""" + + comment: str + # None once the deciding account no longer exists. + decided_by: DurableApprovalDecider | None + decided_at: datetime + + +@dataclass(frozen=True, slots=True) +class DurableApprovalFunction: + """The durable function that requested an approval.""" + + # None once the function has been deleted; the name is kept. + id: str | None + name: str + + +@dataclass(frozen=True, slots=True) +class DurableApprovalExecution: + """The durable execution that requested an approval.""" + + # Both None once the execution is no longer retained; the name is kept. + id: str | None + name: str + status: DurableExecutionStatus | None + + +@dataclass(frozen=True, slots=True) +class DurableApproval: + """An approval a durable workflow requested with `ctx.wait_for_approval`.""" + + id: str + status: DurableApprovalStatus + name: str + title: str + description: str + function: DurableApprovalFunction + execution: DurableApprovalExecution + requested_at: datetime + # None when the workflow set no timeout: the approval then lasts as long as + # its execution. + expires_at: datetime | None + # Set only once the approval is approved or denied. + decision: DurableApprovalDecision | None + details: JSONValue = field(default=None, repr=False, hash=False) + + def __post_init__(self) -> None: + """Defensively freeze the details the workflow attached.""" + object.__setattr__(self, "details", freeze_json(self.details)) + + @property + def is_pending(self) -> bool: + """Report whether the approval is still waiting for a decision.""" + return self.status == "pending" + + +@dataclass(frozen=True, slots=True) +class DurableApprovalPage: + """One page of a project's durable approvals, most recent first.""" + + approvals: tuple[DurableApproval, ...] + page: int + limit: int + total: int + has_more: bool + + def __post_init__(self) -> None: + """Defensively snapshot the approvals in this page.""" + object.__setattr__(self, "approvals", tuple(self.approvals)) + + +@dataclass(frozen=True, slots=True) +class DurableApprovalCounts: + """Approvals counted by outcome; `requested` is all of them.""" + + requested: int + pending: int + approved: int + denied: int + expired: int + cancelled: int + + +@dataclass(frozen=True, slots=True) +class DurableApprovalFunctionCounts: + """Approval counts for one durable function.""" + + function: DurableApprovalFunction + counts: DurableApprovalCounts + + +@dataclass(frozen=True, slots=True) +class DurableApprovalDailyCounts: + """Approval counts for one UTC day, by the day they were requested.""" + + day: date + counts: DurableApprovalCounts + + +@dataclass(frozen=True, slots=True) +class DurableApprovalStats: + """Approval outcomes and decision times over a window.""" + + from_: datetime + to: datetime + counts: DurableApprovalCounts + # Each None while there is nothing to compute it from. + approval_rate: float | None + median_seconds_to_decision: float | None + p90_seconds_to_decision: float | None + # The ten functions with the most requests; the rest are summed in + # other_functions. + functions: tuple[DurableApprovalFunctionCounts, ...] + other_functions: DurableApprovalCounts + # Days with no requests are left out. + daily: tuple[DurableApprovalDailyCounts, ...] + + def __post_init__(self) -> None: + """Defensively snapshot the per-function and per-day breakdowns.""" + object.__setattr__(self, "functions", tuple(self.functions)) + object.__setattr__(self, "daily", tuple(self.daily))