Metadata-Version: 2.4
Name: tengrade-client
Version: 0.1.0
Summary: Python SDK for TenGrade's Examiner API
Author: Fibtec Limited
License: Apache-2.0
Project-URL: Repository, https://github.com/fibtecltd/tengrade
Project-URL: Bug Tracker, https://github.com/fibtecltd/tengrade/issues
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: ruff>=0.15.0; extra == "dev"
Requires-Dist: PyYAML>=6.0; extra == "dev"
Dynamic: license-file

# tengrade-client

Python SDK for [TenGrade](https://github.com/fibtecltd/tengrade)'s Examiner API (R9) —
one typed client for managing exams and pulling candidate results into your own systems.

> **Status:** Early development (R10 Slice 3). Every route R9 ships —
> `client.exams`, `client.instances` (including `create_and_wait`), and
> `client.dashboard()` — is covered, plus a `tengrade` CLI over the same
> surface. See `docs/tengrade-r10-python-sdk-design.md` in the main repo
> for the full plan. Not yet published to PyPI (Slice 4).

## Install

Not yet published. For now, from a checkout of the main repo:

```bash
pip install -e tengrade-client
```

## Quick start

```python
from tengrade_client import Client

client = Client(
    api_key="tgak_...",  # created by an organisation Owner from Org settings
    base_url="https://abc123.execute-api.eu-west-1.amazonaws.com",  # your TenGradeExaminerApi's own ExaminerApiUrl
)

with client:
    exams = client.exams.list()

    draft = client.exams.create({"title": "Backend hire"})
    client.exams.update(draft["exam_id"], {"title": "Backend hire", "subjects": [...]})
    published = client.exams.publish(draft["exam_id"])  # or dry_run=True to preview the price first

    invited = client.instances.create(published["exam_id"], {"scheduled_at": "2026-10-01T09:00:00Z"})
    result = client.instances.get_result(published["exam_id"], invited["instance_id"])  # one check, no blocking

    rollup = client.dashboard()
```

Unlike [`pyvar-client`](https://github.com/fibtecltd/pyvar/tree/master/pyvar-client)'s
own `Client`, `base_url` has no default — `TenGradeExaminerApi` is deployed
per organisation via CDK with no fixed public domain, so there's no single
correct address to fall back to. Ask whoever administers your
organisation's TenGrade deployment for the `ExaminerApiUrl` CDK stack
output.

## Domains

Two namespaces, mirroring R9's own URL structure — not one flat `Client`
and not eight sparse ones (R9's contract is 9 methods total, shaped
around one lifecycle, not many domains):

```python
client.exams.list()
client.exams.get(exam_id)
client.exams.create(payload)                      # write access required
client.exams.update(exam_id, payload)              # write access required
client.exams.publish(exam_id, dry_run=False)       # write access required

client.instances.list(exam_id)
client.instances.get_result(exam_id, instance_id)
client.instances.create(exam_id, payload=None, dry_run=False)   # write access required
client.instances.create_and_wait(exam_id, payload=None, poll_interval_seconds=2.0, poll_timeout_seconds=300.0)

client.dashboard()   # one method, no namespace earns its own name for one call
```

`create`/`publish`/`create_instance`'s real (`dry_run=False`) calls
require a write-enabled API key (`TenGradeWriteAccessError` otherwise)
and share one 60-requests/60-seconds-per-organisation write budget with
the examiner portal and the MCP surface (`TenGradeRateLimitError` if
exhausted) — a write is a write regardless of which surface made it.

### `create_and_wait`

Every `create_instance` call is async the same way: it returns as soon
as the instance is created, and grading only starts once the candidate
actually submits their answers (off a DynamoDB stream, not on a timer).
`create_and_wait` submits once, then polls `get_result` until grading
finishes:

```python
instance = client.instances.create(exam_id, payload)                  # returns immediately
result = client.instances.get_result(exam_id, instance["instance_id"])  # check once, no blocking

result = client.instances.create_and_wait(
    exam_id, payload, poll_interval_seconds=2.0, poll_timeout_seconds=300.0,
)  # submit + poll + return, blocking
```

**The default `poll_timeout_seconds=300.0` (5 minutes) is very likely
too short for a real "invite now, candidate sits it later" workflow** —
it only bounds how long `create_and_wait` will block, not how long a
candidate actually takes to open an invite link and sit a 30-45 minute
exam, which is unbounded and has nothing to do with how fast grading
compute itself runs. Pass a larger `poll_timeout_seconds` when you
already expect the candidate to finish within the window (e.g. a
same-session practice run); for a real invite-and-check-later flow,
prefer `create()` now and a separately-scheduled `get_result()` check,
not blocking a process on `create_and_wait` for however long a candidate
takes.

## Errors

Every non-2xx response raises a typed exception, not a generic HTTP error:

| Exception | Status | Notes |
|---|---|---|
| `TenGradeAuthError` | 401, or 403 with no `error`-shaped body | The key itself is missing, invalid, or revoked — the authorizer denied the request before any route logic ran. |
| `TenGradeWriteAccessError` | 403, with an `error`-shaped body | The key is valid but not write-enabled. No pyvar equivalent — pyvar has no read/write key split. |
| `TenGradeInsufficientCreditError` | 402 | The organisation's credit balance can't cover a `create_instance` charge. |
| `TenGradeConflictError` | 409 | Either "already published" (`PUT`/`.../publish` against a published exam) or "not yet published" (`.../instances` against a draft) — `.response_body["error"]` says which. |
| `TenGradeValidationError` | 422 | `.violations` — a flat list of strings, not FastAPI's `{loc, msg, type}` dict shape. |
| `TenGradeRateLimitError` | 429 | `.retry_after` (seconds) from the response's `Retry-After` header. |
| `TenGradeTimeoutError` | — | A candidate instance didn't finish grading within `create_and_wait`'s poll timeout. `.instance_id` — poll `get_result` again later; the instance may still finish. |
| `TenGradeError` | any other 4xx/5xx | Base class for everything above; catch this if you just want "did it fail". |

The 403 split above (auth failure vs. write-disabled) is real, not
incidental — see `tengrade_client/exceptions.py`'s own module docstring
for why one status code covers two unrelated causes here, and how this
client tells them apart.

```python
from tengrade_client import TenGradeRateLimitError, TenGradeValidationError

try:
    client.exams.publish(exam_id)
except TenGradeValidationError as e:
    print(e.violations)
except TenGradeRateLimitError as e:
    print(f"retry after {e.retry_after}s")
```

## Retries

Reads, `client.exams.update` (a whole-payload replace — safe to retry),
and any `dry_run=True` preview call are idempotent — connection errors,
timeouts, and 5xx responses are retried automatically with exponential
backoff. `client.exams.create`, and the real (`dry_run=False`) calls of
`client.exams.publish`/`client.instances.create`, are **never**
auto-retried: retrying blindly risks creating a duplicate draft or
double-spending the organisation's credit balance, since none of those
routes has an idempotency-key mechanism to de-duplicate a resubmitted
write on.

## CLI

`pip install tengrade-client` also installs a `tengrade` command — stdlib
`argparse` only, no extra install step. It's a thin, generic dispatcher
over the same `Client` namespaces above: `tengrade <domain> <function>
--params file.json` resolves to `client.<domain>.<function>(**params)`,
so every current and future method works without the CLI needing its own
copy of the method catalogue. Unlike
[`pyvar-client`](https://github.com/fibtecltd/pyvar/tree/master/pyvar-client)'s
own CLI, there's no special-cased subcommand tree for anything here —
`create_and_wait`'s own parameters dispatch through the exact same
generic mechanism as `list`/`get`/`create`, since nothing in this API has
a genuinely different call shape the way pyvar's one async function does.

```bash
export TENGRADE_API_KEY="tgak_..."      # or pass --api-key on every call
export TENGRADE_BASE_URL="https://..."  # or pass --base-url -- required, no default

tengrade exams list
tengrade exams publish --params-json '{"exam_id": "e1", "dry_run": true}'
tengrade instances create_and_wait --params-json '{"exam_id": "e1", "poll_timeout_seconds": 900}'
tengrade dashboard
```

Calling a domain function with neither `--params` nor `--params-json`
prints its docstring and signature instead of making a doomed API call
with missing required fields — handy when you don't remember what a
function needs:

```bash
$ tengrade exams publish --api-key "$TENGRADE_API_KEY" --base-url "$TENGRADE_BASE_URL"
publish(exam_id: str, *, dry_run: bool = False) -> dict[str, Any]

POST /v1/exams/{id}/publish. Validates the draft, freezes its
expected triangle and price, and marks it published -- irreversible.
...
```

`exams list` is the one function in this whole surface with no required
arguments — it still shows help with no `--params`, same as every other
function, rather than a special case; pass `--params-json '{}'` to call
it. `dashboard` takes no arguments and no `--params` at all — it's a
plain top-level command, not part of the generic `<domain> <function>`
dispatch (it isn't inside a namespace, same as `client.dashboard()`
itself).

Exit codes distinguish failure modes for scripting, extending pyvar's
own table to R9's full error set (`exceptions.py`'s own table above):

| Exit code | Meaning |
|---|---|
| `0` | Success (or a docstring/help display) |
| `1` | Bad input — unknown domain/function, malformed `--params`, missing/wrong keyword arguments, or any other `TenGradeError` not listed below |
| `2` | `TenGradeAuthError` |
| `3` | `TenGradeWriteAccessError` |
| `4` | `TenGradeValidationError` — `.violations` printed to stderr |
| `5` | `TenGradeRateLimitError` — `.retry_after` printed to stderr |
| `6` | `TenGradeConflictError` |
| `7` | `TenGradeInsufficientCreditError` |
| `8` | `TenGradeTimeoutError` — `.instance_id` printed to stderr |
| `130` | Interrupted (Ctrl-C) |

Discover what's available without any credentials at all:

```bash
tengrade list-domains
tengrade list-functions --domain exams
```

## Development

```bash
pip install -e ".[dev]"
pytest -v --cov=tengrade_client --cov-report=term-missing
ruff check .
```

No real HTTP calls anywhere in the test suite — `httpx.MockTransport`
intercepts every request, so the real retry/error-mapping/auth logic runs
against a handler the tests control, never a live API Gateway endpoint.
See `tests/conftest.py`.

## License

Apache License 2.0 — see [`LICENSE`](LICENSE). Same license as
[`pyvar-client`](https://github.com/fibtecltd/pyvar/tree/master/pyvar-client)
(not MIT, unlike `fibtec-tengrade-plugin` — a different artifact, R10
design doc §1).
