Metadata-Version: 2.4
Name: tidefold-client
Version: 0.0.231
Summary: Python client for starting and following tidefold workflow runs
Author: tidefold
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.13
Classifier: Operating System :: OS Independent
Classifier: Typing :: Typed
Requires-Dist: httpx>=0.27.0,<1.0.0
Requires-Python: >=3.13, <4.0
Description-Content-Type: text/markdown

# tidefold-client

A small Python client for starting workflow runs on a tidefold deployment,
waiting for them, and reading what they produced.

```sh
pip install "tidefold-client==<your platform version>"
```

Install the version that matches your deployment's platform release. The
client warns when it talks to a server on a different release. There is no
compatibility promise across versions.

## Quick start

```python
import asyncio

from tidefold_client import AsyncClient


async def main() -> None:
    async with AsyncClient.from_env() as client:  # TIDEFOLD_API_URL, TIDEFOLD_API_TOKEN
        run = await client.workflows.start(
            "invoice_review",
            inputs={"reference": "INV-1042"},
            files={"document": "invoice.pdf"},
            subject="INV-1042",
        )
        await run.wait(timeout=1800)
        print((await run.results())["marked_results"])


asyncio.run(main())
```

`TIDEFOLD_API_URL` is the API root: `https://<your host>/api` on a
deployment, `http://localhost:8000` on a local stack. There is no default.

## The API token

An administrator creates tokens under **Settings → API tokens**. A token is
shown once; keep it in a secret store and pass it through the environment.
Grant only the scopes your script needs:

| Scope | Needed for |
| --- | --- |
| `workflow_read` | `workflows.list()`, `workflows.get_id()`, `workflows.start()` by name |
| `run_create` | `workflows.start()` |
| `run_file_write` | `files.upload()`, `files.upload_bytes()`, `workflows.start()` with `files` |
| `run_read` | `runs.get()`, `runs.wait()`, `runs.results()`, `runs.usage()`, `runs.list()`, `files.download()`, `files.download_bytes()` |
| `run_cancel` | `runs.cancel()` |
| `inbox_read` | `runs.requests()` |

"Own data only" visibility suits scripts: the token then sees only the runs
it started. A local stack running without auth takes no token.

## The subclients

The client has one subclient per resource, all sharing one session. One
client may be shared by concurrent tasks. `async with` closes it, and so
does `await client.aclose()`. `http=` takes your own `httpx.AsyncClient`,
which the client never closes.

**`client.workflows`**

- `start(name, *, inputs, files, file_ids, subject, correlation_id)`
  uploads each file in `files` and starts a run on the workflow's latest
  published release (`channel="draft"` runs the current draft). It
  returns a `Run` at once. `workflow_id=` starts by id instead of name.
- `file_ids` takes files uploaded earlier, by id, in the same shape as
  `files`: one id or a list per input. A start with `file_ids` only
  uploads nothing, so uploading and starting can be separate steps. An
  input goes in `files` or `file_ids`, not both.
- `list()` returns the workflows the token can see; `get_id(name)` resolves
  a name to its stable id.

**`client.runs`**

These take a run id:

- `get()` reads the run as it stands.
- `wait()` polls until the run ends. It returns the run on `COMPLETED` and
  raises `RunFailed`, `RunCancelled` or `WaitTimeout`. A server restarting
  during a deploy is waited out. Cancelling the waiting task, like a
  timeout, leaves the run going on the server.
- `results()` returns `results` per step and `marked_results`, the outputs
  the workflow declares. `usage()`, `requests()` and `cancel()` do what
  their names say.

`list(workflow_id=None, *, correlation_id, correlation_tags, status)`
finds runs again without their ids. It yields runs newest first and
follows the pages for you; stop iterating to stop.

- `correlation_id` matches the id given at `start()` exactly.
- `correlation_tags` matches the parts of that id between `&`s, each one
  whole and in any position. Every tag given must match, so
  `case_id=4711&doc=12` is found by `["case_id=4711"]`.
- A `correlation_id` takes at most four tags, none empty, and 512
  characters. A `&` inside a value must be percent-encoded.

```python
async for run in client.runs.list(correlation_tags=["case_id=4711"]):
    print(run["id"], run["status"], run["correlation_id"])
```

**`client.files`**

- `upload_bytes(name, content, *, mime_type=None)` declares the file,
  uploads it to the signed URL (never with the token) and finalizes it;
  it returns the file's record, whose `id` goes into `file_ids`. Without
  `mime_type`, the type is guessed from `name`.
- `upload(path)` does the same for a file on disk.
- `download(file_id, path)` streams a file, such as a produced document,
  to disk; `download_bytes(file_id)` returns its content.

A `Run` is `client.runs` with the id filled in: `await run.wait()`,
`await run.results()` and so on. `client.run(run_id)` gives one for a run
started earlier.

**Starting is not idempotent.** The client never retries `start()`, and
calling it twice starts two runs, even with the same `correlation_id`.

## Receiving webhooks

A workflow's Notifications tab can POST run events to your endpoint,
signed per Standard Webhooks.
`webhooks.verify(body, headers, secret)` checks the signature and the
timestamp and returns the event: `id`, `type`, `version`,
`occurred_at` and `data`.

- `body` is the raw request body, exactly as received. Never decode or
  re-serialise it first.
- `secret` is the subscription's `whsec_…` signing secret. During a
  rotation, pass a list of the old and the new one.
- `tolerance=300` is how many seconds the timestamp may be off.

A minimal receiver, here with FastAPI:

```python
import os

from fastapi import BackgroundTasks, FastAPI, Request, Response

from tidefold_client import AsyncClient, webhooks

WEBHOOK_SECRET = os.environ["TIDEFOLD_WEBHOOK_SECRET"]
app = FastAPI()
seen: set[str] = set()  # use your database: deliveries repeat across restarts


async def handle(event: webhooks.WebhookEvent) -> None:
    async with AsyncClient.from_env() as client:
        run = await client.runs.get(event.data["run_id"])
        ...


@app.post("/hooks/tidefold")
async def receive(request: Request, tasks: BackgroundTasks) -> Response:
    try:
        event = webhooks.verify(
            await request.body(), request.headers, WEBHOOK_SECRET
        )
    except webhooks.WebhookVerificationError:
        return Response(status_code=400)
    except webhooks.WebhookVersionError:
        return Response(status_code=503)  # retried for 72 h: upgrade the client
    if event.id not in seen:
        seen.add(event.id)
        tasks.add_task(handle, event)
    return Response(status_code=204)
```

Answer fast: anything that takes longer than 10 s is retried. The same
event can arrive more than once, so dedupe on `event.id`. Match on
`event.type` and ignore types you do not handle, because new ones can
appear in any release. The body carries no run content; fetch it with
the client. Webhooks do not replace polling: a receiver that must not
miss a run end still checks `runs.get()` for the runs it is waiting on.

## Errors

Every exception derives from `TidefoldError`.

| Exception | Meaning |
| --- | --- |
| `AuthError` | 401: token missing, wrong, expired or revoked |
| `PermissionDenied` | 403: the token lacks the scope named in the message |
| `NotFound` | 404: no such run or file; for a workflow, also a category the token cannot see |
| `Conflict` | 409: workflow never published, or results read while the run is still running |
| `InvalidRequest` | 400, 413, 422: a missing input, an oversized or unsupported file |
| `UploadError` | a file did not upload; no run was started |
| `RunFailed`, `RunCancelled`, `WaitTimeout` | raised by `wait()` |
| `NetworkError` | the server could not be reached after retries |
| `UnsupportedByServer` | the server's release predates the call, such as `list()` with correlation filters |
| `WebhookVerificationError` | `webhooks.verify()`: not an authentic event; do not trust it |
| `WebhookVersionError` | `webhooks.verify()`: authentic, but a body `version` this client does not know |

## License

Apache-2.0.
