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.
- scorm_dispatch_client-0.1.0/LICENSE +21 -0
- scorm_dispatch_client-0.1.0/PKG-INFO +236 -0
- scorm_dispatch_client-0.1.0/README.md +208 -0
- scorm_dispatch_client-0.1.0/pyproject.toml +69 -0
- scorm_dispatch_client-0.1.0/src/scorm_dispatch/__init__.py +29 -0
- scorm_dispatch_client-0.1.0/src/scorm_dispatch/client.py +230 -0
- scorm_dispatch_client-0.1.0/src/scorm_dispatch/errors.py +30 -0
- scorm_dispatch_client-0.1.0/src/scorm_dispatch/models.py +244 -0
- scorm_dispatch_client-0.1.0/src/scorm_dispatch/py.typed +0 -0
|
@@ -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
|
+
]
|
|
File without changes
|