Skip to main content

scorm-dispatch-client

LLM? Read llms.txt first: a concise manual of every method, error and a full example.

Async Python client for scorm-dispatch, the service that builds lightweight SCORM packages from a neutral JSON course and, at run time, validates partners by id and Origin and hands out short-lived signed URLs.

  • Async only, on httpx2. Python 3.11+.
  • Fully typed (py.typed, mypy strict). Requests are TypedDicts, responses are frozen pydantic models.
  • Covers the admin API, the public runtime endpoint and the health routes.
  • One exception, ScormDispatchError, carrying the HTTP status and the RFC 9457 problem body.

Install

pip install scorm-dispatch-client
# or straight from git
pip install "scorm-dispatch-client @ git+https://github.com/cr0hn/scorm-dispatch-python"

Quickstart

import asyncio
from scorm_dispatch import Client


async def main() -> None:
    async with Client("https://scorm.example.com", "ADMIN_API_KEY") as sd:
        await sd.put_partner(
            "northwind",
            {
                "name": "Northwind Academy",
                "contactEmail": "learning@northwind.example",
                "seatCap": 500,
                "allowedOrigins": ["https://lms.northwind.example"],
                "active": True,
            },
        )
        await sd.put_asset(
            "vid-walkthrough", {"kind": "video", "uri": "stream-bunny://6f1c2a9e-..."}
        )
        zip_bytes = await sd.create_package(
            "northwind",
            "2004",
            {
                "id": "intro",
                "title": "Intro",
                "version": "1",
                "language": "en",
                "modules": [
                    {
                        "title": "Start",
                        "lessons": [
                            {
                                "id": "l1",
                                "title": "Welcome",
                                "type": "text",
                                "html": "<p>Hello</p>",
                            },
                            {
                                "id": "l2",
                                "title": "Video",
                                "type": "video",
                                "assetKey": "vid-walkthrough",
                            },
                        ],
                    }
                ],
            },
        )
        open("intro.zip", "wb").write(zip_bytes)


asyncio.run(main())

Methods

Client(base_url, admin_key, *, http_client=None, timeout=30.0). Use it as async with, or call aclose().

Method Route Returns
put_partner(partner_id, partner) PUT /v1/partners/{id} Partner
get_partner(partner_id) GET /v1/partners/{id} Partner
list_partners(*, limit=50, offset=0) GET /v1/partners PartnerList
put_asset(asset_key, asset) PUT /v1/assets/{key} Asset (has asset_id)
get_asset(asset_key) GET /v1/assets/{key} Asset
create_package(partner_id, scorm_version, course) POST /v1/packages bytes (the zip)
get_usage_report(month) GET /v1/reports/usage UsageReport
get_usage_report_csv(month) same, Accept: text/csv str
get_runtime_asset(asset_id, *, partner, learner, origin) GET /v1/runtime/assets/{id} RuntimeAsset
healthz() / readyz() GET /healthz, /readyz None (raises if not ok)

Examples of the less obvious ones:

page = await sd.list_partners(limit=100)
for p in page.items:
    print(p.partner_id, p.seat_cap, p.active)

report = await sd.get_usage_report("2026-11")  # month is YYYY-MM
csv_text = await sd.get_usage_report_csv("2026-11")

# What a package does in the browser; useful to check a partner's setup.
# It sends the Origin you pass and never the admin key.
asset = await sd.get_runtime_asset(
    "Zk3p9QwL7xYb2NcVd8TfRa",
    partner="northwind",
    learner="student-1",
    origin="https://lms.northwind.example",
)
print(asset.kind, asset.url, asset.expires_at)

Error handling

Every non-2xx answer raises ScormDispatchError:

from scorm_dispatch import ScormDispatchError

try:
    await sd.create_package("northwind", "2004", course)
except ScormDispatchError as e:
    e.status  # int: 400, 401, 403, 404, 409, 413, 422, 429, 5xx
    e.problem  # Problem: type, title, status, detail, errors
    e.field_errors  # 422 only: [FieldError(path="course.modules[0]...", message="...")]
    e.retry_after  # 429 only: seconds, from Retry-After (seconds or HTTP-date form)
Status Meaning
400 malformed request or parameter
401 wrong admin key
403 runtime: partner unknown/inactive or Origin not allowed
404 partner, asset or runtime asset not found
409 asset kind cannot change
413 package body above the server's limit
422 validation failed; see field_errors
429 rate limit; wait retry_after seconds

A non-problem body (for example a proxy's HTML page) still raises, with the text in problem.detail. A 2xx body that does not parse also raises ScormDispatchError (title Unparseable response body). Unknown enum values in responses (for example a new asset kind) are accepted as plain strings. Network failures are httpx2 exceptions and are not wrapped.

Redirects are never followed: a 3xx answer raises ScormDispatchError naming the redirect target, even if you pass an http_client with follow_redirects=True. This keeps the Bearer key on the configured host. Fix base_url instead.

Timeouts and retries

timeout (seconds) applies to every request. If you omit it, an owned client uses 30 and an injected http_client keeps its own timeout; pass timeout explicitly to override it. The client never retries: PUT upserts are idempotent and safe for you to retry; POST /v1/packages is a pure function of its body, also safe. Back off on 429 using retry_after. To tune pooling or add transport-level retries, pass your own httpx2.AsyncClient as http_client (you then own and close it).

Typing

Request bodies are TypedDicts with the wire (camelCase) keys: your type checker catches typos and wrong lesson shapes. Responses are frozen pydantic models with snake_case attributes and parsed datetimes. Unknown response fields are ignored, so a newer server does not break an older client.

Versioning and compatibility

The client follows semantic versioning. It is built and tested against the contract in spec/openapi.yaml (API 1.x); a 1.x server is expected to work with any 0.x/1.x client.

What each suite checks:

  • Unit (tests/test_unit.py): the exact requests the client builds and how it maps answers and errors, against an in-memory transport; 100% line and branch coverage is enforced.
  • Contract (tests/test_contract.py): the requests the client builds, and hand-written sample responses, are validated against spec/openapi.yaml. The samples are written by hand, so this proves the client agrees with the spec, not that the server does.
  • Integration (tests/test_integration.py): the real server (built from source, with Postgres behind PgBouncer and MinIO) answers every public method and error status. This is what covers real responses.

Development

make check          # lint, mypy, unit+contract on Python 3.11-3.14, integration
make matrix         # only unit+contract on every supported Python (each in its own .venv-3.X)
make integration    # needs Docker, Go, and the server source: SCORM_DISPATCH_SRC=../scorm-dispatch

The integration suite builds the real server, starts Postgres behind PgBouncer and MinIO with testcontainers, and runs every public method and documented error status against it. It fails, not skips, if the server source is missing.

Contributing

Issues and pull requests welcome. CI (.github/workflows/ci.yml) runs lint, mypy and unit+contract on Python 3.11-3.14. It cannot run the integration suite, which needs the private server source: run make integration locally before sending a change. Keep make check green; a change in behaviour needs a unit test and an integration test. When the server API changes, copy its api/openapi.yaml to spec/openapi.yaml.

Security

Report vulnerabilities privately to me@danielalfocea.com. Never put the admin key in a browser: this client is for server-side use. Runtime calls are made by packages in browsers, not by this client in production.

License

MIT, see LICENSE.

Metadata

Release files for scorm-dispatch-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 scorm-dispatch-client 0.1.0
File Size Uploaded Provenance Actions
scorm_dispatch_client-0.1.0.tar.gz 9.9 kB Without provenance Details for scorm_dispatch_client-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for scorm-dispatch-client 0.1.0
File Interpreter ABI Platform Actions
scorm_dispatch_client-0.1.0-py3-none-any.whl Python 3 none any Details for scorm_dispatch_client-0.1.0-py3-none-any.whl

Total release size: 21.9 kB

Release files / scorm_dispatch_client-0.1.0.tar.gz

Download URL scorm_dispatch_client-0.1.0.tar.gz
Size 9.9 kB
Tags Source
SHA-256 checksum
How to use checksums
42a5e40c8fa8c333187dda1467ca1ea0a070c428ff9967d9a1ce3196cd07198d
BLAKE2b-256 checksum
How to use checksums
5d5f7982f1c5f035e7521a6eb4ef775fe402a97a6513bba3ac01d44abd3f25fa
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.13

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

Download URL scorm_dispatch_client-0.1.0-py3-none-any.whl
Size 12.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d6bbcf926c2ee3503aad4b72cb5add5ef6fea3c9415f1b14804118fe46edcd00
BLAKE2b-256 checksum
How to use checksums
9904741b27fa3715b73c6a92ce30b672ddb69b5bcf5d7e6e3eb441983fbaa68a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.13

Security for scorm-dispatch-client 0.1.0

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.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

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