Skip to main content

tengrade-client

Python SDK for 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:

pip install -e tengrade-client

Quick start

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'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):

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:

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.

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'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.

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:

$ 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:

tengrade list-domains
tengrade list-functions --domain exams

Development

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. Same license as pyvar-client (not MIT, unlike fibtec-tengrade-plugin — a different artifact, R10 design doc §1).

Release files for tengrade-client 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tengrade-client 0.1.0
File Size Uploaded
tengrade_client-0.1.0.tar.gz 35.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tengrade-client 0.1.0
File Interpreter ABI Platform
tengrade_client-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 63.4 kB

Release files / tengrade_client-0.1.0.tar.gz

Download URL tengrade_client-0.1.0.tar.gz
Size 35.1 kB
Tags Source
SHA-256 checksum
How to use checksums
5aa4a7903674320785da3c4259b1de13fc3ca86e8fdd68752da41d3080bfb1a4
BLAKE2b-256 checksum
How to use checksums
bdbbb53455fdd96fec5dd57be5a951d5a95122b2ac38b09aa9b03dfd159e5a0c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / tengrade_client-0.1.0-py3-none-any.whl

Download URL tengrade_client-0.1.0-py3-none-any.whl
Size 28.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5768d5c15460461d3ed8255470746e708e61d8a273c9792b4dfd4deeef5c41f5
BLAKE2b-256 checksum
How to use checksums
01eb77b19647c2fd321bb6df5a4c7ac45ceb4ca11adf503f50ed76c9e6f06658
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page