Metadata-Version: 2.5
Name: cronkit
Version: 0.2.0
Summary: Lightweight Python framework for production cron jobs
Author-email: Milind Shekhawat <milindshekhawat10@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# cronkit

A local tracker for batch cron jobs, like a small Sentry. cronkit handles locking and run tracking, records each Item your job processes and every error it hits, and reports success ratios, recurring errors, Stuck Items and Resolved failures from a SQLite database on the host.

cronkit sends nothing itself. It gives you Alerts and Digests as data, and you deliver them however you like (SMTP, HTTP, Slack, …).

> Terms are defined in [CONTEXT.md](./CONTEXT.md).

## Installation

```bash
pip install -e /path/to/cronkit
```

Python 3.11+, Linux, no runtime dependencies.

## Quickstart

```python
#!/usr/bin/env python3
import sys
from cronkit import cron_runner, item, record_error
from cronkit.errors import Skip, SoftFail, HardFail

JOB = "sync_orders"


def worker(run_id: int) -> None:
    orders = fetch_pending_orders()
    if not orders:
        raise Skip("nothing to process")          # whole Run skipped

    for order in orders:
        with item(order.id):                      # one Item per retryable unit
            if not order.ready:
                raise SoftFail("upstream not ready")  # expected to clear later
            if order.corrupt:
                raise HardFail("bad payload")         # needs a human
            process(order)                        # any other exception → hard


if __name__ == "__main__":
    sys.exit(cron_runner(JOB, worker, schedule="*/5 * * * *"))
```

`item()` records the outcome and swallows the exception, so the loop continues with the next order.

### Several errors on one Item

When one Item contains nested work (e.g. several sessions in one record), record each problem as its own Error so each one gets its own Fingerprint:

```python
with item(record.id):
    for session in record.sessions:
        try:
            handle(session)
        except Exception as e:
            record_error(e)          # attached to the current Item
    ...                              # then raise SoftFail/HardFail for the Item's outcome
```

## Outcomes

| Raised     | Item outcome | Counts as failure | Alerted |
| ---------- | ------------ | ----------------- | ------- |
| nothing    | ok           | —                 | —       |
| `Skip`     | skip         | no                | no      |
| `SoftFail` | soft         | yes               | no (unless Stuck and escalated) |
| `HardFail` | hard         | yes               | yes     |
| other      | hard         | yes               | yes     |

If an exception escapes *outside* any Item, the Run is **Crashed**, which is always alerted. If the previous Run still holds the lock, the new Run is recorded as **Locked out** and exits 0.

## Alerts

Pass a callback. It runs once at the end of any Run that Crashed or had hard failures:

```python
import smtplib
from email.message import EmailMessage


def send_alert(alert) -> None:
    msg = EmailMessage()
    msg["Subject"] = f"[{alert.env}] {alert.job}: {len(alert.items)} hard failure(s)"
    msg["From"] = "cron@example.com"
    msg["To"] = "ops@example.com"
    msg.set_content(alert.render())
    with smtplib.SMTP("localhost") as s:
        s.send_message(msg)


sys.exit(cron_runner(JOB, worker, on_alert=send_alert))
```

## Digests

A Digest is data (`build_digest`) plus a template (`render_digest`). To email one daily or weekly, run it as an ordinary cronkit Job:

```python
from cronkit import build_digest, cron_runner, render_digest


def worker(run_id: int) -> None:
    send_mail("Weekly cron digest", render_digest(build_digest(days=7)))


sys.exit(cron_runner("weekly_digest", worker, schedule="0 9 * * 1"))
```

## Retries

```python
from cronkit import with_retry

with_retry(lambda: call_external_api(), retries=5, backoff=1.5)
```

Each retried attempt is logged and stored against the current Item; the Item only fails if the last attempt does.

## CLI

```bash
cronkit list                         # known Jobs
cronkit summary [job] [--days N]     # Item counts and success ratio
cronkit failures [job] [--days N]
cronkit dangling [job] [--days N]     # errors recorded outside any Item
cronkit errors [job] [--show FP]     # recurring errors by Fingerprint
cronkit retries [job]                # retried attempts, rescued vs exhausted
cronkit latest [job]
cronkit stuck [job]
cronkit digest [job] [--days N]      # print the Digest
```

Every command accepts `--json`.

## Configuration

| Variable          | Default                            | Description                 |
| ----------------- | ---------------------------------- | --------------------------- |
| `APP_ENV`         | `dev`                              | Included in Alerts/Digests  |
| `CRONKIT_TZ`      | `UTC`                              | Display timezone |
| `CRON_TRACKER_DB` | `/var/log/cronkit/cron_tracker.db` | SQLite tracker path         |
| `CRON_LOG_DIR`    | `/var/log/cronkit`                 | Log file directory          |
| `CRON_LOCK_DIR`   | `/var/lock/cronkit`                | Lock file directory         |
| `CRON_LOG_SOURCE` | `1`                                | `0` hides the logger name (Job / `cronkit.*` / library) in log lines |

The default paths require root. Create them in advance and chown them to the cron user:

```bash
sudo mkdir -p /var/log/cronkit /var/lock/cronkit
sudo chown $USER:$USER /var/log/cronkit /var/lock/cronkit
```

## Logging

cronkit sets up logging automatically. Logs go to stderr and to one file per ISO week, `{CRON_LOG_DIR}/{job}.{YYYY}-W{WW}.log` (e.g. `sync_orders.2026-W39.log`). Timestamps and week boundaries are UTC. Each process picks its file when it starts and never renames it, so a Run's lines always stay in one file. cronkit never deletes log files; clean them up with your host's usual policy (logrotate, `find -mtime`, …). In your job:

```python
import logging
logger = logging.getLogger(JOB)
```
