Metadata-Version: 2.4
Name: tay-client
Version: 1.4.3
Summary: Python SDK for producing and executing Tay jobs
Author: Andrii Sydorenko
License-Expression: MIT
Project-URL: Documentation, https://github.com/AndriiSydorenko1904/tay/tree/v1.8.0/clients/python
Project-URL: Issues, https://github.com/AndriiSydorenko1904/tay/issues
Project-URL: Source, https://github.com/AndriiSydorenko1904/tay
Keywords: background-jobs,job-queue,scheduler,unix-socket
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: http-pkcs12
Requires-Dist: cryptography>=42; extra == "http-pkcs12"
Dynamic: license-file

# Tay Python SDK

`tay` provides producer and worker clients for Tay's local Unix-domain socket
and HTTP/JSON API. Both transports use only the Python standard library;
encrypted PKCS#12 credentials are an optional extra. `Tay()` needs no socket
setting: it uses the same discovery
contract as the Elixir Engine (explicit path, `TAY_SOCKET_PATH`, an existing
shared `/run/tay` directory, XDG runtime,
`TMPDIR`, then `/tmp/tay-<uid>`). It is intentionally a separately installable
package:

```sh
python -m pip install tay-client
```

## HTTP/JSON producer and remote worker

Enable the Bandit listener with `TAY_HTTP_PORT=8080` for a local deployment.
`TayHTTP` is an async producer client for enqueue, status, cancellation,
result retrieval, and durable schedules. `TayHTTPWorker` runs remote workers
over the same HTTP listener. HTTP-only deployments support the complete
schedule API without a shared socket.

```python
from tay import TayHTTP

async with TayHTTP("http://127.0.0.1:8080") as tay:
    job = await tay.enqueue("reports.rebuild.v1", {"report_id": "42"})
    status = await job.status()
```

`await job.result()` retrieves an available result; it does not wait for the
job to finish. Check `await job.status()` for completion first. If a result is
not ready, retrieval raises `ServerError` with code `result_not_ready`.

For remote access, set `TAY_HTTP_IP` to a non-loopback address and configure
the server certificate, key and client CA. Supply PEM client credentials:

```python
async with TayHTTP(
    "https://tay.example:8080",
    tls_ca_file="/etc/tay/server-ca.pem",
    tls_cert_file="/etc/tay/client.pem",
    tls_key_file="/etc/tay/client.key",
) as tay:
    job = await tay.enqueue("reports.rebuild.v1", {"report_id": "42"})
```

Remote targets require mTLS. The server certificate must match the target
hostname or IP address. Keep keys protected and restrict network access.
Alternatively install `tay-client[http-pkcs12]` and supply a single encrypted
bundle containing the client key, client certificate and trusted server CA:

```python
async with TayHTTP(
    "https://tay.example:8080",
    tls_pkcs12_file="/etc/tay/client.p12",
    tls_pkcs12_password="from-a-secret-manager",
) as tay:
    job = await tay.enqueue("reports.rebuild.v1", {"report_id": "42"})
```

The PEM key and certificate are extracted into a private temporary directory
only while Python loads them into its TLS context, then removed. The CA private
key must never be included in the bundle.

A worker can connect from another host without a shared filesystem:

```python
import asyncio
from tay import TayHTTPWorker

worker = TayHTTPWorker(
    "https://tay.example:8080",
    tls_pkcs12_file="/etc/tay/client.p12",
    tls_pkcs12_password="from-a-secret-manager",
)


@worker.task(name="reports.rebuild.v1", retries=3, backoff="exponential")
def rebuild(report_id: str) -> dict:
    return {"report_id": report_id, "rebuilt": True}


asyncio.run(worker.serve_forever())
```

The worker continuously long-polls, acknowledges starts and reports bounded
results. Keep it polling while executions run; session expiry causes in-flight
work to be retried at least once. Make external effects idempotent.

From a Tay source checkout, use `python -m pip install ./clients/python`.

Create a `Tay(mode="client")` producer, or a `Tay(mode="worker")` process that
only executes registered tasks. The SDK sends JSON-object arguments and bounded
JSON results; it does not serialize arbitrary Python objects.

The producer surface is asynchronous: `enqueue`, `JobHandle.status()`,
`JobHandle.result()`, and `JobHandle.cancel()` map directly to the currently
supported Protocol v1 requests. Use a stable `submission_id` when retrying an
enqueue whose connection outcome is unknown.

Choose job durability per submission with `durability="persistent"` (the
default) or `durability="volatile"`:

```python
durable = await tay.enqueue("reports.rebuild.v1", {}, durability="persistent")
ephemeral = await tay.enqueue("cache.warm.v1", {}, durability="volatile")
assert ephemeral.durability == "volatile"
```

Volatile jobs use Tay's normal execution, retry, cancellation, and accounting
paths, but never enter persistent storage and disappear with the Engine's
in-memory generation.

For `TayHTTPWorker`, tasks declared before `await worker.register()` are
advertised when it registers. `register()` only creates the remote worker
session; use `serve_forever()` to register and then continuously claim and
execute jobs.

Tasks declared before `await tay.start()` are advertised during the local
socket handshake.

## Decorated tasks

Use `@tay.task` for an ergonomic handler and producer API.  The default job
name is `<module>.<function_name>`; choose an explicit name for a public,
versioned contract.  The decorated object still calls the original function
locally, while `enqueue()` serializes its call arguments and submits the job.

```python
from tay import Tay

tay = Tay()


@tay.task
def refresh(account_id: str, force: bool = False) -> None:
    # default name: "my_module.refresh"
    ...


@tay.task(
    name="accounts.refresh.v1",
    retries=3,
    backoff="exponential",
    durability="volatile",
)
def public_refresh(account_id: str) -> None: ...


job = await refresh.enqueue("acct-42", force=True)
```

`retries`, `backoff`, and `durability` declared on either `Tay.task` or
`TayHTTPWorker.task` become defaults when the decorated task enqueues a job.
Pass an option such as `retries=0` to `task.enqueue()` to override a default for
one submission.

Registered handlers are available in `tay.tasks`, keyed by job name.  Calling
`await tay.enqueue("accounts.refresh.v1", {"account_id": "acct-42"})`
remains fully supported.

For payments and other external effects, pass the same stable business
operation identifier as Tay's `submission_id` and the provider's idempotency
key. A Tay retry can then repeat delivery without repeating the external
effect. See the repository's
[idempotency guide](https://github.com/AndriiSydorenko1904/tay/blob/v1.8.0/docs/idempotency.md)
for crash cases and a complete financial-operation example.

For a long-running worker that adds or removes capabilities later, explicitly
sync the change:

```python
@tay.task(name="reports.rebuild.v1")
def rebuild(report_id: str) -> dict:
    return {"report_id": report_id}


await tay.register_tasks(rebuild)
# ... stop accepting new work for this task
await tay.unregister_tasks(rebuild)
```

Both changes are retained as desired capability state and reconciled on the
next connection. `client` mode cannot register task capabilities.

## Schedules

`schedule()` uses the five-field Unix cron form (minute, hour, day of month,
month, day of week). Its default timezone is `+00`; pass a fixed UTC offset
such as `+02`, `-05`, or `+05:30` when the schedule should be evaluated in a
different local clock. The API accepts `catch_up="latest"` (default) or
`catch_up="all"`, and defaults to `overlap="skip"`.

```python
# Every weekday at 08:00 in UTC+02.
await tay.schedule(
    "reports.rebuild.v1",
    cron="0 8 * * 1-5",
    timezone="+02",
    catch_up="latest",
)

# First run in ten minutes, then every fifteen minutes.
await tay.every("reports.rebuild.v1", minutes=15, delay=600)
```

Use `start_at=<UTC milliseconds>` instead of `delay` when the first occurrence
has an absolute timestamp. The two options are mutually exclusive. Schedule
registration, advancement, and cancellation are durable: declarations survive
Engine restart, compaction, and client-process exit. Startup declarations may
be repeated with the same stable `declaration_id` to reconcile registration.
`catch_up="all"` delivers missed occurrences serially; `"latest"` coalesces
later missed times after delivery. `overlap="allow"`, `"skip"`, and `"queue"`
are enforced by the Engine. See [durable schedules over either transport](#durable-schedules-over-either-transport)
for inspection, cancellation, and policy details.

Run a dedicated worker module with:

```sh
tay-worker myapp.jobs
```

The imported module must create exactly one `Tay(mode="worker")` instance and
register its tasks. Pass `socket_path=` or set `TAY_SOCKET_PATH` only to override
automatic discovery. In Docker, mount the same socket volume into Tay at
`/run/tay` and the worker at `/run/tay:ro`; no `TAY_SOCKET_PATH` is needed.
The mount is required even with `network_mode: "service:tay"`, which shares
networking but not the filesystem. The shared directory selects
`/run/tay/tay.sock` even before the listener creates it, so worker startup and
reconnects do not depend on scanning for socket files. Both containers need
compatible socket ownership/group permissions. See the repository README for the current durable-result/
scheduling compatibility boundary.

## License

The `tay-client` Python package is licensed under the MIT License. The Tay
engine is distributed separately under the Elastic License 2.0.


## Bounded result waiting

```python
job = await producer.enqueue("billing.capture.v1", {"invoice_id": "inv-42"})
value = await job.wait_result(timeout=30, poll_interval=0.1)
```

Both HTTP and socket handles use the same wait contract. The deadline includes
network requests and polling. `None` is a successful stored JSON null;
`ResultMissing`, `JobCancelled`, and `RemoteTaskError` are terminal. A timeout
raises `TimeoutError`; cancelling the awaiting coroutine cancels only the wait.
Neither operation cancels or resubmits the job. Connection/unavailability errors
propagate so callers can make an explicit retry decision.

Submission options follow the complete Protocol v1 contract. `priority` is not
supported. `delay`/`timeout` are seconds, `delay_ms`/`timeout_ms` are milliseconds,
`run_at` is an absolute Unix millisecond timestamp, and conflicting aliases are
rejected locally. Server-side admission, clock and storage limits still apply.

## Worker CLI and diagnostics

```sh
tay worker app.jobs:worker
# Backwards-compatible spelling:
tay-worker app.jobs:worker
python -m tay worker app.jobs:worker

tay doctor --json
tay doctor --socket-path /run/tay/tay.sock
tay doctor --http-url http://127.0.0.1:8080 --timeout 5 --json
```

`worker` may be `Tay(mode="worker")` or `TayHTTPWorker`. Module-only discovery
requires exactly one exported worker instance. SIGINT/SIGTERM also interrupt
startup and registration retries; execution/transport cleanup is awaited.

Doctor does not submit jobs, register workers, change permissions or create
storage. Socket output includes the exact discovery origin, UID/GID, socket
ownership and access checks; the connectivity probe sends no protocol frames.
HTTP uses only `GET /health`. Exit status is zero on success, one for a failed
check, and two for invalid CLI/configuration. Credentials and passwords are
never printed. Remote HTTP requires HTTPS and mTLS; pass `--tls-ca-file`,
`--tls-cert-file`, `--tls-key-file`, or `--tls-pkcs12-file` with optional
`TAY_TLS_PKCS12_PASSWORD` in the environment.

A lost HTTP session retires old async tasks and waits for old synchronous calls
before registering again. A synchronous Python thread cannot be forcibly
stopped; a blocked call continues to occupy its slot. Late outcomes never use
a replacement token or socket connection, and no execution outcome is replayed.

## Durable schedules over either transport

`tay-client` 1.4.2 provides the same schedule API on `Tay` and `TayHTTP`:

```python
async with TayHTTP("http://127.0.0.1:4080") as tay:
    schedule = await tay.every(
        "reports.build.v1",
        minutes=5,
        declaration_id="reports-five-minutes",
        overlap="queue",
        catch_up="all",
    )
    print(await schedule.status())
    print(await tay.schedules())
    print(await tay.get_schedule(schedule.id))
    await schedule.cancel()
```

`allow` admits independent occurrences. The default `skip` advances past due
times while the preceding job is nonterminal. `queue` holds the due occurrence
until that job finishes; `all` catches up serially and `latest` coalesces later
missed times after delivery. Schedule state survives restart and compaction.
Use a stable `declaration_id` to reconcile a lost registration reply. Cancellation
withdraws a declaration before sending the request, so automatic reconnect does
not resurrect it after an unknown outcome. Explicit registration can enable it
again. HTTP worker decorators accept `cron`, `every`, `overlap` and
`declaration_id`, using the same validation as Unix workers.

Schedule listing keeps the `await client.schedules()` list API and automatically
follows bounded server pages for both HTTP and Unix transports. All retained Tay
data shares the server's `max_state_bytes`; admission may return `capacity` when
the common budget is full. No schedule/result memory split is required.
