Metadata-Version: 2.4
Name: gridrunner
Version: 0.6.0
Summary: Producer SDK for GRIDRUNNER — fire-and-forget job progress events
License: MIT
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# GRIDRUNNER Python SDK

Standard-library-only producer client for reporting bounded jobs and
service status pings — super-light health monitoring for services and
recurring tasks — to GRIDRUNNER. See the [repository README](../README.md)
for the job/item/chunk/unit model, the ping mechanic, authentication,
delivery guarantees, and installation.

## Configure

Initialize once near process startup with explicit credentials — the SDK
reads no environment variables and no config files:

```python
import gridrunner as gr

client = gr.init(token=read_secret("gridrunner_produce_token"),
                 url="https://gridrunner.example.com")
```

A token without `url=` raises `ValueError`. With neither, `init()` targets
the local unauthenticated dev core:

```python
client = gr.init()                             # http://127.0.0.1:7077
```

The token is also your identity: the name your producer was registered
with when the token was minted becomes the service name of every ping you
send.

All options:

```python
client = gr.init(
    token=token,
    url="https://gridrunner.example.com",
    flush_interval_s=0.2,
    queue_size=20_000,
    retry_interval_s=5.0,
)
```

## Preferred usage

Declare chunks up front and use context managers. Successful exits emit
completion; exceptions emit failure and are re-raised.

```python
import gridrunner as gr

gr.init(token=read_secret("gridrunner_produce_token"),
        url="https://gridrunner.example.com")

chunks = [
    {
        "chunk_id": f"orders:{month}",
        "item": "orders",
        "label": month,
        "units": estimated_rows,
    }
    for month, estimated_rows in monthly_estimates.items()
]

with gr.job(
    "etl.orders.monthly.v1",
    chunks=chunks,
    label="Export orders",
    meta={"service": "billing-export"},
    heartbeat_s=30,
) as job:
    for spec in chunks:
        with job.chunk(spec["chunk_id"], worker=worker_name):
            export_month(spec["label"])
```

## Manual lifecycle

Use manual methods when a context manager does not match the host framework:

```python
job = gr.job(
    "model.forecast.v3",
    chunks=[
        {"chunk_id": "load", "item": "prepare", "units": 1},
        {"chunk_id": "fit", "item": "model", "units": 10},
        {"chunk_id": "write", "item": "output", "units": 1},
    ],
    expected_silence_s=300,
).register()

try:
    with job.chunk("fit"):
        fit_model()
    job.complete()
except Exception as exc:
    job.fail(f"{type(exc).__name__}: {exc}")
    raise
```

For one genuinely indivisible chunk, report a measured fraction:

```python
with job.chunk("fit") as chunk:
    for completed, total in train():
        chunk.progress(completed / total)
```

## Service pings

For services and independent regular jobs — cron cycles, daemons,
schedulers — use the ping mechanic instead of jobs: one call per cycle,
and GRIDRUNNER learns the cadence and alarms on silence or errors by
itself.

The ping only names the recurring process (`ping_type` — an
understandable name like `daily-export` or `queue-sweep`). The service
name is resolved server-side from the produce token — the name the
producer was registered with — so producers can never contaminate each
other's series.

```python
def daily_cycle():
    try:
        run_export()
        gr.ping("daily-export")
    except Exception as exc:
        gr.ping("daily-export", status="error",
                description=f"{type(exc).__name__}: {exc}")
        raise
```

If the cadence is too sparse to learn quickly (weekly, monthly), declare
when the next ping is due — GRIDRUNNER alarms on that deadline instead of
waiting to learn the pattern:

```python
gr.ping("monthly-report", expected_next_ts=next_run_at)  # epoch ms or datetime
```

For cron scripts that shouldn't carry a client lifecycle at all, use the
one-shot call — token and ping in one line, no `init`, no queue:

```python
gr.ping_once("daily-export", token=tok, url=core_url)
```

See [Service pings](../README.md#service-pings) in the repository README
for the full contract.

## Chunk plan formats

Full dictionaries preserve item grouping and weights:

```python
chunks = [
    {"chunk_id": "users:0", "item": "users", "label": "0–49k", "units": 50_000},
    {"chunk_id": "users:1", "item": "users", "label": "50k–99k", "units": 50_000},
]
```

Convenience forms are accepted:

```python
chunks = {"users:0": 50_000, "users:1": 50_000}
chunks = ["load", "fit", "write"]  # each gets one unit
```

## API

### `init(token=None, url=None, **client_options)`

Configures the module-level client used by `emit()`, `job()`, and
`ping()`. A token requires an explicit `url` (`ValueError` otherwise);
with neither it targets a local core at `http://127.0.0.1:7077`. The
target never changes behind the caller's back. `connect(url=None,
token=None, ...)` remains as a deprecated alias.

### `job(job_type_id, chunks=None, **options) -> Job`

Options:

| Option | Meaning |
|---|---|
| `label` | Human-readable run label |
| `total_units` | Override sum of chunk units |
| `meta` | JSON metadata attached to registration |
| `job_id` | Stable caller-provided run ID; otherwise generated ULID |
| `heartbeat_s` | Automatic heartbeat interval |
| `expected_silence_s` | Expected quiet-work bound |

### `Job`

- `register()` — emits `job.registered` and starts heartbeat.
- `chunk(id, units=None, worker=None, item=None)` — returns a chunk context.
- `complete()` — stops heartbeat and emits `job.completed`.
- `fail(error="")` — stops heartbeat and emits `job.failed`.

### `Chunk`

- Context entry emits `chunk.started`.
- `progress(fraction)` emits a clamped measured fraction in `[0, 1]`.
- Normal context exit emits `chunk.completed` with duration.
- Exceptional context exit emits `chunk.failed` and re-raises.

### `ping(ping_type="default", status="ok", description=None, expected_next_ts=None)`

Reports a recurring status ping — a mechanic separate from jobs (see the
[repository README](../README.md#service-pings)). The service name is
resolved server-side from the produce token; `ping_type` names the
recurring process:

```python
gr.ping("daily-export")
gr.ping("daily-export", status="error", description="table locked")
gr.ping("monthly-report", expected_next_ts=next_run_at)
```

`expected_next_ts` (epoch ms or a `datetime`) optionally declares when the
next ping is due, for cadences too sparse to learn quickly.

### `ping_once(ping_type="default", *, token=None, status="ok", description=None, expected_next_ts=None, url=None, wait=False, timeout_s=5.0)`

One-shot stateless ping for cron scripts and one-liners: token and ping in
the same call, one direct HTTP POST — no `init`, no persistent client, no
queue, no backlog (outage-safe replay is what you give up).

Fire-and-forget by default: returns `None` immediately and delivers on a
short-lived non-daemon thread, so a script that exits right after pinging
doesn't lose the ping (the process lingers at most `timeout_s`).
`wait=True` performs the POST inline and returns `True`/`False`. Never
raises either way. `url` resolves like `init`: required alongside a token
(a token without `url` is logged and dropped), the local dev core without
one.

```python
gr.ping_once("daily-export", token=tok, url=core_url)                  # returns immediately
ok = gr.ping_once("daily-export", token=tok, url=core_url, wait=True)  # opt-in: block → bool
gr.ping_once("daily-export", token=tok, url=core_url, status="error",
             description="table locked")
```

### `emit(event)`

Queues a raw event on the configured module client. Prefer the structured
job/chunk API unless integrating an unsupported lifecycle.

### `Client`

An independent client for applications that cannot use module-level state:

```python
client = gr.Client(url, token=token)
client.emit({"type": "job.heartbeat", "job_id": job_id})
client.close()
```

Call `close()` during graceful shutdown to flush the in-memory queue.

## Failure behavior

- Connection and HTTP errors never propagate into host work.
- Undelivered events are held in a bounded, liveness-scoped in-memory
  backlog: live jobs keep compacted events (latest progress per chunk, no
  heartbeats; ok pings compact to the newest per series, error pings are
  all kept) and replay on reconnect; jobs that start and finish entirely
  while the core is unreachable are discarded, never sent late.
- Events are assigned idempotency IDs before queueing.
- Non-finite floats are converted to JSON `null`.
- A full queue drops new events and logs at increasing thresholds; held
  backlogs log a rate-limited warning (distinct message on 401).
