scorm-dispatch-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Alfocea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,236 @@
1
+ Metadata-Version: 2.4
2
+ Name: scorm-dispatch-client
3
+ Version: 0.1.0
4
+ Summary: Async Python client for the scorm-dispatch API
5
+ Keywords: scorm,scorm-dispatch,lms,elearning,httpx,asyncio,client
6
+ Author: Daniel Alfocea
7
+ Author-email: Daniel Alfocea <me@danielalfocea.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Education
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: httpx2>=2.13
22
+ Requires-Dist: pydantic>=2.10
23
+ Requires-Python: >=3.11
24
+ Project-URL: Homepage, https://github.com/cr0hn/scorm-dispatch-python
25
+ Project-URL: Repository, https://github.com/cr0hn/scorm-dispatch-python
26
+ Project-URL: Issues, https://github.com/cr0hn/scorm-dispatch-python/issues
27
+ Description-Content-Type: text/markdown
28
+
29
+ # scorm-dispatch-client
30
+
31
+ > LLM? Read [`llms.txt`](llms.txt) first: a concise manual of every method, error and a full example.
32
+
33
+ Async Python client for scorm-dispatch, the service that
34
+ builds lightweight SCORM packages from a neutral JSON course and, at run time, validates partners by id and
35
+ `Origin` and hands out short-lived signed URLs.
36
+
37
+ - Async only, on `httpx2`. Python 3.11+.
38
+ - Fully typed (`py.typed`, mypy strict). Requests are `TypedDict`s, responses are frozen pydantic models.
39
+ - Covers the admin API, the public runtime endpoint and the health routes.
40
+ - One exception, `ScormDispatchError`, carrying the HTTP status and the RFC 9457 problem body.
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ pip install scorm-dispatch-client
46
+ # or straight from git
47
+ pip install "scorm-dispatch-client @ git+https://github.com/cr0hn/scorm-dispatch-python"
48
+ ```
49
+
50
+ ## Quickstart
51
+
52
+ ```python
53
+ import asyncio
54
+ from scorm_dispatch import Client
55
+
56
+
57
+ async def main() -> None:
58
+ async with Client("https://scorm.example.com", "ADMIN_API_KEY") as sd:
59
+ await sd.put_partner(
60
+ "northwind",
61
+ {
62
+ "name": "Northwind Academy",
63
+ "contactEmail": "learning@northwind.example",
64
+ "seatCap": 500,
65
+ "allowedOrigins": ["https://lms.northwind.example"],
66
+ "active": True,
67
+ },
68
+ )
69
+ await sd.put_asset(
70
+ "vid-walkthrough", {"kind": "video", "uri": "stream-bunny://6f1c2a9e-..."}
71
+ )
72
+ zip_bytes = await sd.create_package(
73
+ "northwind",
74
+ "2004",
75
+ {
76
+ "id": "intro",
77
+ "title": "Intro",
78
+ "version": "1",
79
+ "language": "en",
80
+ "modules": [
81
+ {
82
+ "title": "Start",
83
+ "lessons": [
84
+ {
85
+ "id": "l1",
86
+ "title": "Welcome",
87
+ "type": "text",
88
+ "html": "<p>Hello</p>",
89
+ },
90
+ {
91
+ "id": "l2",
92
+ "title": "Video",
93
+ "type": "video",
94
+ "assetKey": "vid-walkthrough",
95
+ },
96
+ ],
97
+ }
98
+ ],
99
+ },
100
+ )
101
+ open("intro.zip", "wb").write(zip_bytes)
102
+
103
+
104
+ asyncio.run(main())
105
+ ```
106
+
107
+ ## Methods
108
+
109
+ `Client(base_url, admin_key, *, http_client=None, timeout=30.0)`. Use it as `async with`, or call `aclose()`.
110
+
111
+ | Method | Route | Returns |
112
+ |---|---|---|
113
+ | `put_partner(partner_id, partner)` | `PUT /v1/partners/{id}` | `Partner` |
114
+ | `get_partner(partner_id)` | `GET /v1/partners/{id}` | `Partner` |
115
+ | `list_partners(*, limit=50, offset=0)` | `GET /v1/partners` | `PartnerList` |
116
+ | `put_asset(asset_key, asset)` | `PUT /v1/assets/{key}` | `Asset` (has `asset_id`) |
117
+ | `get_asset(asset_key)` | `GET /v1/assets/{key}` | `Asset` |
118
+ | `create_package(partner_id, scorm_version, course)` | `POST /v1/packages` | `bytes` (the zip) |
119
+ | `get_usage_report(month)` | `GET /v1/reports/usage` | `UsageReport` |
120
+ | `get_usage_report_csv(month)` | same, `Accept: text/csv` | `str` |
121
+ | `get_runtime_asset(asset_id, *, partner, learner, origin)` | `GET /v1/runtime/assets/{id}` | `RuntimeAsset` |
122
+ | `healthz()` / `readyz()` | `GET /healthz`, `/readyz` | `None` (raises if not ok) |
123
+
124
+ Examples of the less obvious ones:
125
+
126
+ ```python
127
+ page = await sd.list_partners(limit=100)
128
+ for p in page.items:
129
+ print(p.partner_id, p.seat_cap, p.active)
130
+
131
+ report = await sd.get_usage_report("2026-11") # month is YYYY-MM
132
+ csv_text = await sd.get_usage_report_csv("2026-11")
133
+
134
+ # What a package does in the browser; useful to check a partner's setup.
135
+ # It sends the Origin you pass and never the admin key.
136
+ asset = await sd.get_runtime_asset(
137
+ "Zk3p9QwL7xYb2NcVd8TfRa",
138
+ partner="northwind",
139
+ learner="student-1",
140
+ origin="https://lms.northwind.example",
141
+ )
142
+ print(asset.kind, asset.url, asset.expires_at)
143
+ ```
144
+
145
+ ## Error handling
146
+
147
+ Every non-2xx answer raises `ScormDispatchError`:
148
+
149
+ ```python
150
+ from scorm_dispatch import ScormDispatchError
151
+
152
+ try:
153
+ await sd.create_package("northwind", "2004", course)
154
+ except ScormDispatchError as e:
155
+ e.status # int: 400, 401, 403, 404, 409, 413, 422, 429, 5xx
156
+ e.problem # Problem: type, title, status, detail, errors
157
+ e.field_errors # 422 only: [FieldError(path="course.modules[0]...", message="...")]
158
+ e.retry_after # 429 only: seconds, from Retry-After (seconds or HTTP-date form)
159
+ ```
160
+
161
+ | Status | Meaning |
162
+ |---|---|
163
+ | 400 | malformed request or parameter |
164
+ | 401 | wrong admin key |
165
+ | 403 | runtime: partner unknown/inactive or `Origin` not allowed |
166
+ | 404 | partner, asset or runtime asset not found |
167
+ | 409 | asset `kind` cannot change |
168
+ | 413 | package body above the server's limit |
169
+ | 422 | validation failed; see `field_errors` |
170
+ | 429 | rate limit; wait `retry_after` seconds |
171
+
172
+ A non-problem body (for example a proxy's HTML page) still raises, with the text in `problem.detail`.
173
+ A 2xx body that does not parse also raises `ScormDispatchError` (title `Unparseable response body`).
174
+ Unknown enum values in responses (for example a new asset `kind`) are accepted as plain strings.
175
+ Network failures are `httpx2` exceptions and are not wrapped.
176
+
177
+ **Redirects are never followed**: a 3xx answer raises `ScormDispatchError` naming the redirect target, even if
178
+ you pass an `http_client` with `follow_redirects=True`. This keeps the Bearer key on the configured host.
179
+ Fix `base_url` instead.
180
+
181
+ ## Timeouts and retries
182
+
183
+ `timeout` (seconds) applies to every request. If you omit it, an owned client uses 30 and an injected
184
+ `http_client` keeps its own timeout; pass `timeout` explicitly to override it. **The client never retries**: `PUT` upserts are
185
+ idempotent and safe for you to retry; `POST /v1/packages` is a pure function of its body, also safe.
186
+ Back off on 429 using `retry_after`. To tune pooling or add transport-level retries, pass your own
187
+ `httpx2.AsyncClient` as `http_client` (you then own and close it).
188
+
189
+ ## Typing
190
+
191
+ Request bodies are `TypedDict`s with the wire (camelCase) keys: your type checker catches typos and wrong
192
+ lesson shapes. Responses are frozen pydantic models with snake_case attributes and parsed datetimes.
193
+ Unknown response fields are ignored, so a newer server does not break an older client.
194
+
195
+ ## Versioning and compatibility
196
+
197
+ The client follows semantic versioning. It is built and tested against the contract in
198
+ [`spec/openapi.yaml`](spec/openapi.yaml) (API 1.x); a 1.x server is expected to work with any 0.x/1.x client.
199
+
200
+ What each suite checks:
201
+
202
+ - **Unit** (`tests/test_unit.py`): the exact requests the client builds and how it maps answers and errors,
203
+ against an in-memory transport; 100% line and branch coverage is enforced.
204
+ - **Contract** (`tests/test_contract.py`): the requests the client builds, and hand-written sample responses,
205
+ are validated against `spec/openapi.yaml`. The samples are written by hand, so this proves the client agrees
206
+ with the spec, not that the server does.
207
+ - **Integration** (`tests/test_integration.py`): the real server (built from source, with Postgres behind
208
+ PgBouncer and MinIO) answers every public method and error status. This is what covers real responses.
209
+
210
+ ## Development
211
+
212
+ ```sh
213
+ make check # lint, mypy, unit+contract on Python 3.11-3.14, integration
214
+ make matrix # only unit+contract on every supported Python (each in its own .venv-3.X)
215
+ make integration # needs Docker, Go, and the server source: SCORM_DISPATCH_SRC=../scorm-dispatch
216
+ ```
217
+
218
+ The integration suite builds the real server, starts Postgres behind PgBouncer and MinIO with
219
+ testcontainers, and runs every public method and documented error status against it. It fails, not skips,
220
+ if the server source is missing.
221
+
222
+ ## Contributing
223
+
224
+ Issues and pull requests welcome. CI (`.github/workflows/ci.yml`) runs lint, mypy and unit+contract on
225
+ Python 3.11-3.14. It cannot run the integration suite, which needs the private server source: run
226
+ `make integration` locally before sending a change. Keep `make check` green; a change in behaviour needs a unit test and an
227
+ integration test. When the server API changes, copy its `api/openapi.yaml` to `spec/openapi.yaml`.
228
+
229
+ ## Security
230
+
231
+ Report vulnerabilities privately to me@danielalfocea.com. Never put the admin key in a browser: this client
232
+ is for server-side use. Runtime calls are made by packages in browsers, not by this client in production.
233
+
234
+ ## License
235
+
236
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,208 @@
1
+ # scorm-dispatch-client
2
+
3
+ > LLM? Read [`llms.txt`](llms.txt) first: a concise manual of every method, error and a full example.
4
+
5
+ Async Python client for scorm-dispatch, the service that
6
+ builds lightweight SCORM packages from a neutral JSON course and, at run time, validates partners by id and
7
+ `Origin` and hands out short-lived signed URLs.
8
+
9
+ - Async only, on `httpx2`. Python 3.11+.
10
+ - Fully typed (`py.typed`, mypy strict). Requests are `TypedDict`s, responses are frozen pydantic models.
11
+ - Covers the admin API, the public runtime endpoint and the health routes.
12
+ - One exception, `ScormDispatchError`, carrying the HTTP status and the RFC 9457 problem body.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pip install scorm-dispatch-client
18
+ # or straight from git
19
+ pip install "scorm-dispatch-client @ git+https://github.com/cr0hn/scorm-dispatch-python"
20
+ ```
21
+
22
+ ## Quickstart
23
+
24
+ ```python
25
+ import asyncio
26
+ from scorm_dispatch import Client
27
+
28
+
29
+ async def main() -> None:
30
+ async with Client("https://scorm.example.com", "ADMIN_API_KEY") as sd:
31
+ await sd.put_partner(
32
+ "northwind",
33
+ {
34
+ "name": "Northwind Academy",
35
+ "contactEmail": "learning@northwind.example",
36
+ "seatCap": 500,
37
+ "allowedOrigins": ["https://lms.northwind.example"],
38
+ "active": True,
39
+ },
40
+ )
41
+ await sd.put_asset(
42
+ "vid-walkthrough", {"kind": "video", "uri": "stream-bunny://6f1c2a9e-..."}
43
+ )
44
+ zip_bytes = await sd.create_package(
45
+ "northwind",
46
+ "2004",
47
+ {
48
+ "id": "intro",
49
+ "title": "Intro",
50
+ "version": "1",
51
+ "language": "en",
52
+ "modules": [
53
+ {
54
+ "title": "Start",
55
+ "lessons": [
56
+ {
57
+ "id": "l1",
58
+ "title": "Welcome",
59
+ "type": "text",
60
+ "html": "<p>Hello</p>",
61
+ },
62
+ {
63
+ "id": "l2",
64
+ "title": "Video",
65
+ "type": "video",
66
+ "assetKey": "vid-walkthrough",
67
+ },
68
+ ],
69
+ }
70
+ ],
71
+ },
72
+ )
73
+ open("intro.zip", "wb").write(zip_bytes)
74
+
75
+
76
+ asyncio.run(main())
77
+ ```
78
+
79
+ ## Methods
80
+
81
+ `Client(base_url, admin_key, *, http_client=None, timeout=30.0)`. Use it as `async with`, or call `aclose()`.
82
+
83
+ | Method | Route | Returns |
84
+ |---|---|---|
85
+ | `put_partner(partner_id, partner)` | `PUT /v1/partners/{id}` | `Partner` |
86
+ | `get_partner(partner_id)` | `GET /v1/partners/{id}` | `Partner` |
87
+ | `list_partners(*, limit=50, offset=0)` | `GET /v1/partners` | `PartnerList` |
88
+ | `put_asset(asset_key, asset)` | `PUT /v1/assets/{key}` | `Asset` (has `asset_id`) |
89
+ | `get_asset(asset_key)` | `GET /v1/assets/{key}` | `Asset` |
90
+ | `create_package(partner_id, scorm_version, course)` | `POST /v1/packages` | `bytes` (the zip) |
91
+ | `get_usage_report(month)` | `GET /v1/reports/usage` | `UsageReport` |
92
+ | `get_usage_report_csv(month)` | same, `Accept: text/csv` | `str` |
93
+ | `get_runtime_asset(asset_id, *, partner, learner, origin)` | `GET /v1/runtime/assets/{id}` | `RuntimeAsset` |
94
+ | `healthz()` / `readyz()` | `GET /healthz`, `/readyz` | `None` (raises if not ok) |
95
+
96
+ Examples of the less obvious ones:
97
+
98
+ ```python
99
+ page = await sd.list_partners(limit=100)
100
+ for p in page.items:
101
+ print(p.partner_id, p.seat_cap, p.active)
102
+
103
+ report = await sd.get_usage_report("2026-11") # month is YYYY-MM
104
+ csv_text = await sd.get_usage_report_csv("2026-11")
105
+
106
+ # What a package does in the browser; useful to check a partner's setup.
107
+ # It sends the Origin you pass and never the admin key.
108
+ asset = await sd.get_runtime_asset(
109
+ "Zk3p9QwL7xYb2NcVd8TfRa",
110
+ partner="northwind",
111
+ learner="student-1",
112
+ origin="https://lms.northwind.example",
113
+ )
114
+ print(asset.kind, asset.url, asset.expires_at)
115
+ ```
116
+
117
+ ## Error handling
118
+
119
+ Every non-2xx answer raises `ScormDispatchError`:
120
+
121
+ ```python
122
+ from scorm_dispatch import ScormDispatchError
123
+
124
+ try:
125
+ await sd.create_package("northwind", "2004", course)
126
+ except ScormDispatchError as e:
127
+ e.status # int: 400, 401, 403, 404, 409, 413, 422, 429, 5xx
128
+ e.problem # Problem: type, title, status, detail, errors
129
+ e.field_errors # 422 only: [FieldError(path="course.modules[0]...", message="...")]
130
+ e.retry_after # 429 only: seconds, from Retry-After (seconds or HTTP-date form)
131
+ ```
132
+
133
+ | Status | Meaning |
134
+ |---|---|
135
+ | 400 | malformed request or parameter |
136
+ | 401 | wrong admin key |
137
+ | 403 | runtime: partner unknown/inactive or `Origin` not allowed |
138
+ | 404 | partner, asset or runtime asset not found |
139
+ | 409 | asset `kind` cannot change |
140
+ | 413 | package body above the server's limit |
141
+ | 422 | validation failed; see `field_errors` |
142
+ | 429 | rate limit; wait `retry_after` seconds |
143
+
144
+ A non-problem body (for example a proxy's HTML page) still raises, with the text in `problem.detail`.
145
+ A 2xx body that does not parse also raises `ScormDispatchError` (title `Unparseable response body`).
146
+ Unknown enum values in responses (for example a new asset `kind`) are accepted as plain strings.
147
+ Network failures are `httpx2` exceptions and are not wrapped.
148
+
149
+ **Redirects are never followed**: a 3xx answer raises `ScormDispatchError` naming the redirect target, even if
150
+ you pass an `http_client` with `follow_redirects=True`. This keeps the Bearer key on the configured host.
151
+ Fix `base_url` instead.
152
+
153
+ ## Timeouts and retries
154
+
155
+ `timeout` (seconds) applies to every request. If you omit it, an owned client uses 30 and an injected
156
+ `http_client` keeps its own timeout; pass `timeout` explicitly to override it. **The client never retries**: `PUT` upserts are
157
+ idempotent and safe for you to retry; `POST /v1/packages` is a pure function of its body, also safe.
158
+ Back off on 429 using `retry_after`. To tune pooling or add transport-level retries, pass your own
159
+ `httpx2.AsyncClient` as `http_client` (you then own and close it).
160
+
161
+ ## Typing
162
+
163
+ Request bodies are `TypedDict`s with the wire (camelCase) keys: your type checker catches typos and wrong
164
+ lesson shapes. Responses are frozen pydantic models with snake_case attributes and parsed datetimes.
165
+ Unknown response fields are ignored, so a newer server does not break an older client.
166
+
167
+ ## Versioning and compatibility
168
+
169
+ The client follows semantic versioning. It is built and tested against the contract in
170
+ [`spec/openapi.yaml`](spec/openapi.yaml) (API 1.x); a 1.x server is expected to work with any 0.x/1.x client.
171
+
172
+ What each suite checks:
173
+
174
+ - **Unit** (`tests/test_unit.py`): the exact requests the client builds and how it maps answers and errors,
175
+ against an in-memory transport; 100% line and branch coverage is enforced.
176
+ - **Contract** (`tests/test_contract.py`): the requests the client builds, and hand-written sample responses,
177
+ are validated against `spec/openapi.yaml`. The samples are written by hand, so this proves the client agrees
178
+ with the spec, not that the server does.
179
+ - **Integration** (`tests/test_integration.py`): the real server (built from source, with Postgres behind
180
+ PgBouncer and MinIO) answers every public method and error status. This is what covers real responses.
181
+
182
+ ## Development
183
+
184
+ ```sh
185
+ make check # lint, mypy, unit+contract on Python 3.11-3.14, integration
186
+ make matrix # only unit+contract on every supported Python (each in its own .venv-3.X)
187
+ make integration # needs Docker, Go, and the server source: SCORM_DISPATCH_SRC=../scorm-dispatch
188
+ ```
189
+
190
+ The integration suite builds the real server, starts Postgres behind PgBouncer and MinIO with
191
+ testcontainers, and runs every public method and documented error status against it. It fails, not skips,
192
+ if the server source is missing.
193
+
194
+ ## Contributing
195
+
196
+ Issues and pull requests welcome. CI (`.github/workflows/ci.yml`) runs lint, mypy and unit+contract on
197
+ Python 3.11-3.14. It cannot run the integration suite, which needs the private server source: run
198
+ `make integration` locally before sending a change. Keep `make check` green; a change in behaviour needs a unit test and an
199
+ integration test. When the server API changes, copy its `api/openapi.yaml` to `spec/openapi.yaml`.
200
+
201
+ ## Security
202
+
203
+ Report vulnerabilities privately to me@danielalfocea.com. Never put the admin key in a browser: this client
204
+ is for server-side use. Runtime calls are made by packages in browsers, not by this client in production.
205
+
206
+ ## License
207
+
208
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "scorm-dispatch-client"
3
+ version = "0.1.0"
4
+ description = "Async Python client for the scorm-dispatch API"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ authors = [{ name = "Daniel Alfocea", email = "me@danielalfocea.com" }]
10
+ keywords = ["scorm", "scorm-dispatch", "lms", "elearning", "httpx", "asyncio", "client"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Framework :: AsyncIO",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Education",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = [
25
+ "httpx2>=2.13",
26
+ "pydantic>=2.10",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/cr0hn/scorm-dispatch-python"
31
+ Repository = "https://github.com/cr0hn/scorm-dispatch-python"
32
+ Issues = "https://github.com/cr0hn/scorm-dispatch-python/issues"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=9",
37
+ "pytest-asyncio>=1.0",
38
+ "ruff>=0.15",
39
+ "mypy>=2",
40
+ "openapi-core>=0.19",
41
+ "pyyaml>=6",
42
+ "types-pyyaml",
43
+ "testcontainers>=4.9",
44
+ "pytest-cov>=6",
45
+ ]
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.11,<0.14"]
49
+ build-backend = "uv_build"
50
+
51
+ [tool.mypy]
52
+ strict = true
53
+ python_version = "3.11"
54
+ files = ["src"]
55
+ plugins = ["pydantic.mypy"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ asyncio_mode = "auto"
60
+ asyncio_default_fixture_loop_scope = "function"
61
+ pythonpath = ["."]
62
+ markers = ["integration: builds the Go binary and needs Docker (make python-sdk-check)"]
63
+
64
+ [tool.uv.build-backend]
65
+ module-name = "scorm_dispatch"
66
+
67
+ [tool.coverage.run]
68
+ source = ["scorm_dispatch"]
69
+ branch = true
@@ -0,0 +1,29 @@
1
+ """Async Python client for scorm-dispatch. Start with `Client`."""
2
+
3
+ from scorm_dispatch.client import Client
4
+ from scorm_dispatch.errors import ScormDispatchError
5
+ from scorm_dispatch.models import (
6
+ Asset,
7
+ AssetInput,
8
+ Course,
9
+ Partner,
10
+ PartnerInput,
11
+ PartnerList,
12
+ Problem,
13
+ RuntimeAsset,
14
+ UsageReport,
15
+ )
16
+
17
+ __all__ = [
18
+ "Asset",
19
+ "AssetInput",
20
+ "Client",
21
+ "Course",
22
+ "Partner",
23
+ "PartnerInput",
24
+ "PartnerList",
25
+ "Problem",
26
+ "RuntimeAsset",
27
+ "ScormDispatchError",
28
+ "UsageReport",
29
+ ]
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from datetime import UTC, datetime
5
+ from email.utils import parsedate_to_datetime
6
+ from types import TracebackType
7
+ from typing import Self, TypeVar
8
+ from urllib.parse import quote
9
+
10
+ import httpx2
11
+ from pydantic import BaseModel, ValidationError
12
+
13
+ from scorm_dispatch.errors import ScormDispatchError
14
+ from scorm_dispatch.models import (
15
+ Asset,
16
+ AssetInput,
17
+ Course,
18
+ PackageRequest,
19
+ Partner,
20
+ PartnerInput,
21
+ PartnerList,
22
+ Problem,
23
+ RuntimeAsset,
24
+ ScormVersion,
25
+ UsageReport,
26
+ )
27
+
28
+
29
+ def _seg(value: str) -> str:
30
+ return quote(value, safe="")
31
+
32
+
33
+ class Client:
34
+ """Async client of one scorm-dispatch deployment.
35
+
36
+ Pass `http_client` to share a connection pool (you then close it yourself); otherwise the
37
+ client owns one and `aclose()` / `async with` closes it. `timeout` (seconds) defaults to 30
38
+ for an owned client and to the injected client's own timeout; set it to override either.
39
+ Redirects are never followed, so the admin key cannot leave `base_url`.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ base_url: str,
45
+ admin_key: str,
46
+ *,
47
+ http_client: httpx2.AsyncClient | None = None,
48
+ timeout: float | None = None,
49
+ ) -> None:
50
+ self._base = base_url.rstrip("/")
51
+ self._admin_key = admin_key
52
+ self._owns_http = http_client is None
53
+ self._http = http_client or httpx2.AsyncClient(timeout=30.0 if timeout is None else timeout)
54
+ self._timeout = timeout
55
+
56
+ async def __aenter__(self) -> Self:
57
+ """Enter the async context; returns the client."""
58
+ return self
59
+
60
+ async def __aexit__(
61
+ self,
62
+ exc_type: type[BaseException] | None,
63
+ exc: BaseException | None,
64
+ tb: TracebackType | None,
65
+ ) -> None:
66
+ """Close the client (only the HTTP client it owns)."""
67
+ await self.aclose()
68
+
69
+ async def aclose(self) -> None:
70
+ """Close the HTTP client, unless it was injected."""
71
+ if self._owns_http:
72
+ await self._http.aclose()
73
+
74
+ async def _send(
75
+ self,
76
+ method: str,
77
+ path: str,
78
+ *,
79
+ admin: bool = True,
80
+ params: Mapping[str, str | int] | None = None,
81
+ headers: Mapping[str, str] | None = None,
82
+ json: object = None,
83
+ ) -> httpx2.Response:
84
+ h = dict(headers or {})
85
+ if admin: # the runtime and health routes are public: never leak the key there
86
+ h["Authorization"] = f"Bearer {self._admin_key}"
87
+ response = await self._http.request(
88
+ method,
89
+ self._base + path,
90
+ params=params,
91
+ headers=h,
92
+ json=json,
93
+ timeout=httpx2.USE_CLIENT_DEFAULT if self._timeout is None else self._timeout,
94
+ follow_redirects=False,
95
+ )
96
+ if response.is_success:
97
+ return response
98
+ raise _error(response)
99
+
100
+ # --- partners ---------------------------------------------------------------------------
101
+
102
+ async def put_partner(self, partner_id: str, partner: PartnerInput) -> Partner:
103
+ """Idempotent upsert; replaces every field."""
104
+ r = await self._send("PUT", f"/v1/partners/{_seg(partner_id)}", json=partner)
105
+ return _parse(Partner, r)
106
+
107
+ async def get_partner(self, partner_id: str) -> Partner:
108
+ """Fetch one partner (404 if missing)."""
109
+ r = await self._send("GET", f"/v1/partners/{_seg(partner_id)}")
110
+ return _parse(Partner, r)
111
+
112
+ async def list_partners(self, *, limit: int = 50, offset: int = 0) -> PartnerList:
113
+ """One page of partners ordered by id; `limit` 1-200."""
114
+ r = await self._send("GET", "/v1/partners", params={"limit": limit, "offset": offset})
115
+ return _parse(PartnerList, r)
116
+
117
+ # --- assets -----------------------------------------------------------------------------
118
+
119
+ async def put_asset(self, asset_key: str, asset: AssetInput) -> Asset:
120
+ """Idempotent upsert; `assetId` never changes once created."""
121
+ r = await self._send("PUT", f"/v1/assets/{_seg(asset_key)}", json=asset)
122
+ return _parse(Asset, r)
123
+
124
+ async def get_asset(self, asset_key: str) -> Asset:
125
+ """Fetch one asset (404 if missing)."""
126
+ r = await self._send("GET", f"/v1/assets/{_seg(asset_key)}")
127
+ return _parse(Asset, r)
128
+
129
+ # --- packages and reports ---------------------------------------------------------------
130
+
131
+ async def create_package(
132
+ self, partner_id: str, scorm_version: ScormVersion, course: Course
133
+ ) -> bytes:
134
+ """Generate a SCORM package; returns the zip."""
135
+ body: PackageRequest = {
136
+ "partnerId": partner_id,
137
+ "scormVersion": scorm_version,
138
+ "course": course,
139
+ }
140
+ r = await self._send("POST", "/v1/packages", json=body)
141
+ return r.content
142
+
143
+ async def get_usage_report(self, month: str) -> UsageReport:
144
+ """Active learners per partner for `month` (`YYYY-MM`)."""
145
+ r = await self._send("GET", "/v1/reports/usage", params={"month": month})
146
+ return _parse(UsageReport, r)
147
+
148
+ async def get_usage_report_csv(self, month: str) -> str:
149
+ """Same report as `get_usage_report`, as RFC 4180 CSV text."""
150
+ r = await self._send(
151
+ "GET", "/v1/reports/usage", params={"month": month}, headers={"Accept": "text/csv"}
152
+ )
153
+ return r.content.decode("utf-8")
154
+
155
+ # --- runtime and operations -------------------------------------------------------------
156
+
157
+ async def get_runtime_asset(
158
+ self, asset_id: str, *, partner: str, learner: str, origin: str
159
+ ) -> RuntimeAsset:
160
+ """What a package does in the browser. Public route: sends `Origin`, never the key."""
161
+ r = await self._send(
162
+ "GET",
163
+ f"/v1/runtime/assets/{_seg(asset_id)}",
164
+ admin=False,
165
+ params={"partner": partner, "learner": learner},
166
+ headers={"Origin": origin},
167
+ )
168
+ return _parse(RuntimeAsset, r)
169
+
170
+ async def healthz(self) -> None:
171
+ """Process alive. Raises `ScormDispatchError` otherwise."""
172
+ await self._send("GET", "/healthz", admin=False)
173
+
174
+ async def readyz(self) -> None:
175
+ """Database reachable. Raises `ScormDispatchError` (503) otherwise."""
176
+ await self._send("GET", "/readyz", admin=False)
177
+
178
+
179
+ _M = TypeVar("_M", bound=BaseModel)
180
+
181
+
182
+ def _parse(model: type[_M], response: httpx2.Response) -> _M:
183
+ try:
184
+ return model.model_validate_json(response.content)
185
+ except ValidationError as exc:
186
+ problem = Problem(
187
+ title="Unparseable response body",
188
+ status=response.status_code,
189
+ detail=f"{model.__name__}: {exc.error_count()} validation error(s)",
190
+ )
191
+ raise ScormDispatchError(response.status_code, problem) from exc
192
+
193
+
194
+ def _retry_after(value: str | None) -> int | None:
195
+ """Seconds from a `Retry-After` header, in its delta-seconds or HTTP-date form."""
196
+ if not value:
197
+ return None
198
+ if value.isascii() and value.isdigit():
199
+ return int(value)
200
+ try:
201
+ when = parsedate_to_datetime(value)
202
+ except (TypeError, ValueError):
203
+ return None
204
+ if when.tzinfo is None: # "-0000" means UTC with unknown local zone
205
+ when = when.replace(tzinfo=UTC)
206
+ return max(0, int((when - datetime.now(UTC)).total_seconds()))
207
+
208
+
209
+ def _error(response: httpx2.Response) -> ScormDispatchError:
210
+ if 300 <= response.status_code < 400:
211
+ target = response.headers.get("Location")
212
+ where = f"to {target}" if target else "(no Location header)"
213
+ problem = Problem(
214
+ title="Unexpected redirect",
215
+ status=response.status_code,
216
+ detail=f"The server answered with a redirect {where}. The SDK never follows "
217
+ "redirects, so the admin key stays on the configured host; fix base_url.",
218
+ )
219
+ return ScormDispatchError(response.status_code, problem)
220
+ try:
221
+ problem = Problem.model_validate_json(response.content)
222
+ except ValidationError: # a proxy's HTML page, an empty body, JSON that is no Problem...
223
+ problem = Problem(
224
+ title=response.reason_phrase,
225
+ status=response.status_code,
226
+ detail=response.text[:500] or None,
227
+ )
228
+ return ScormDispatchError(
229
+ response.status_code, problem, _retry_after(response.headers.get("Retry-After"))
230
+ )
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Self
4
+
5
+ from scorm_dispatch.models import FieldError, Problem
6
+
7
+
8
+ class ScormDispatchError(Exception):
9
+ """Any non-2xx answer. Branch on `status`:
10
+
11
+ 400 malformed request, 401 bad admin key, 403 runtime access refused, 404 not found,
12
+ 409 conflict (asset kind change), 422 validation (see `field_errors`), 429 rate limit
13
+ (see `retry_after`), 5xx server side.
14
+ """
15
+
16
+ def __init__(self, status: int, problem: Problem, retry_after: int | None = None) -> None:
17
+ self.status = status
18
+ self.problem = problem
19
+ self.retry_after = retry_after
20
+ message = f"{status} {problem.title}"
21
+ super().__init__(f"{message}: {problem.detail}" if problem.detail else message)
22
+
23
+ def __reduce__(self) -> tuple[type[Self], tuple[int, Problem, int | None]]:
24
+ # Exception.__reduce__ would replay the single formatted message as args: unpicklable.
25
+ return (type(self), (self.status, self.problem, self.retry_after))
26
+
27
+ @property
28
+ def field_errors(self) -> list[FieldError]:
29
+ """Per-field problems of a 422; empty for every other status."""
30
+ return self.problem.errors or []
@@ -0,0 +1,244 @@
1
+ """Typed bodies of the scorm-dispatch API.
2
+
3
+ Requests are TypedDicts with the wire (camelCase) keys, so a literal reads like the docs.
4
+ Responses are frozen pydantic models: they validate what the server sent and expose
5
+ snake_case attributes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Literal, TypedDict
12
+
13
+ from pydantic import BaseModel, ConfigDict
14
+ from pydantic.alias_generators import to_camel
15
+
16
+ AssetKind = Literal["video", "file"]
17
+ ScormVersion = Literal["1.2", "2004"]
18
+
19
+
20
+ # --- request bodies -------------------------------------------------------------------------
21
+
22
+
23
+ class PartnerInput(TypedDict):
24
+ """Body of `put_partner`; replaces every field of the partner."""
25
+
26
+ name: str
27
+ contactEmail: str
28
+ seatCap: int
29
+ allowedOrigins: list[str]
30
+ active: bool
31
+
32
+
33
+ class AssetInput(TypedDict):
34
+ """Body of `put_asset`; `kind` cannot change once the asset exists."""
35
+
36
+ kind: AssetKind
37
+ uri: str
38
+
39
+
40
+ class Option(TypedDict):
41
+ """One answer option of a question."""
42
+
43
+ id: str
44
+ html: str
45
+
46
+
47
+ class Question(TypedDict):
48
+ """A `single` (one correct option) or `multiple` choice question."""
49
+
50
+ id: str
51
+ type: Literal["single", "multiple"]
52
+ promptHtml: str
53
+ options: list[Option]
54
+ correct: list[str]
55
+
56
+
57
+ class Quiz(TypedDict):
58
+ """A quiz lesson body; at most one quiz per course may be `final`."""
59
+
60
+ final: bool
61
+ passingScore: int
62
+ questions: list[Question]
63
+
64
+
65
+ class TextLesson(TypedDict):
66
+ """Lesson with sanitised HTML."""
67
+
68
+ id: str
69
+ title: str
70
+ type: Literal["text"]
71
+ html: str
72
+
73
+
74
+ class VideoLesson(TypedDict):
75
+ """Lesson that plays a registered video asset."""
76
+
77
+ id: str
78
+ title: str
79
+ type: Literal["video"]
80
+ assetKey: str
81
+
82
+
83
+ class FileLesson(TypedDict):
84
+ """Lesson that offers a registered file asset."""
85
+
86
+ id: str
87
+ title: str
88
+ type: Literal["file"]
89
+ assetKey: str
90
+ fileName: str
91
+
92
+
93
+ class QuizLesson(TypedDict):
94
+ """Lesson that holds a quiz."""
95
+
96
+ id: str
97
+ title: str
98
+ type: Literal["quiz"]
99
+ quiz: Quiz
100
+
101
+
102
+ Lesson = TextLesson | VideoLesson | FileLesson | QuizLesson
103
+
104
+
105
+ class Module(TypedDict):
106
+ """A titled group of lessons."""
107
+
108
+ title: str
109
+ lessons: list[Lesson]
110
+
111
+
112
+ class Course(TypedDict):
113
+ """The neutral course format sent to `create_package`."""
114
+
115
+ id: str
116
+ title: str
117
+ version: str
118
+ language: str
119
+ modules: list[Module]
120
+
121
+
122
+ class PackageRequest(TypedDict):
123
+ """Body of `POST /v1/packages`."""
124
+
125
+ partnerId: str
126
+ scormVersion: ScormVersion
127
+ course: Course
128
+
129
+
130
+ # --- responses ------------------------------------------------------------------------------
131
+
132
+
133
+ class _Response(BaseModel):
134
+ # Unknown fields are ignored: a newer server must not break an older client.
135
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, frozen=True)
136
+
137
+
138
+ class Partner(_Response):
139
+ """A content partner as stored by the server."""
140
+
141
+ partner_id: str
142
+ name: str
143
+ contact_email: str
144
+ seat_cap: int
145
+ allowed_origins: list[str]
146
+ active: bool
147
+ created_at: datetime
148
+ updated_at: datetime
149
+
150
+
151
+ class PartnerList(_Response):
152
+ """One page of partners and the total count."""
153
+
154
+ items: list[Partner]
155
+ total: int
156
+
157
+
158
+ class Asset(_Response):
159
+ """A registered video or file; `asset_id` is opaque and stable."""
160
+
161
+ asset_key: str
162
+ asset_id: str
163
+ kind: str # `AssetKind` today; a plain str so a new server kind does not break parsing
164
+ uri: str
165
+ created_at: datetime
166
+ updated_at: datetime
167
+
168
+
169
+ class RuntimeAsset(_Response):
170
+ """Short-lived signed URL for an asset."""
171
+
172
+ kind: str # see `Asset.kind`
173
+ url: str
174
+ expires_at: datetime
175
+
176
+
177
+ class RejectedOrigin(_Response):
178
+ """An origin refused by the runtime and how many times."""
179
+
180
+ origin: str
181
+ count: int
182
+
183
+
184
+ class PartnerUsage(_Response):
185
+ """Usage of one partner in one month."""
186
+
187
+ partner_id: str
188
+ name: str
189
+ seat_cap: int
190
+ active_learners: int
191
+ rejected_origins: list[RejectedOrigin]
192
+
193
+
194
+ class UsageReport(_Response):
195
+ """Active learners per partner for one month."""
196
+
197
+ month: str
198
+ partners: list[PartnerUsage]
199
+
200
+
201
+ class FieldError(_Response):
202
+ """One validation problem and the path of the offending field."""
203
+
204
+ path: str
205
+ message: str
206
+
207
+
208
+ class Problem(_Response):
209
+ """RFC 9457 body. `errors` is present on 422 only."""
210
+
211
+ type: str = "about:blank"
212
+ title: str
213
+ status: int
214
+ detail: str | None = None
215
+ instance: str | None = None
216
+ errors: list[FieldError] | None = None
217
+
218
+
219
+ __all__ = [
220
+ "Asset",
221
+ "AssetInput",
222
+ "AssetKind",
223
+ "Course",
224
+ "FieldError",
225
+ "FileLesson",
226
+ "Lesson",
227
+ "Module",
228
+ "Option",
229
+ "PackageRequest",
230
+ "Partner",
231
+ "PartnerInput",
232
+ "PartnerList",
233
+ "PartnerUsage",
234
+ "Problem",
235
+ "Question",
236
+ "Quiz",
237
+ "QuizLesson",
238
+ "RejectedOrigin",
239
+ "RuntimeAsset",
240
+ "ScormVersion",
241
+ "TextLesson",
242
+ "UsageReport",
243
+ "VideoLesson",
244
+ ]