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(includingcreate_and_wait), andclient.dashboard()— is covered, plus atengradeCLI over the same surface. Seedocs/tengrade-r10-python-sdk-design.mdin 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)
| File | Size | Uploaded | |
|---|---|---|---|
| tengrade_client-0.1.0.tar.gz | 35.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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