QuestionBankToolkit 0.1.0__py3-none-any.whl
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.
- coursebanks/__init__.py +15 -0
- coursebanks/__main__.py +17 -0
- coursebanks/archive_io.py +158 -0
- coursebanks/assessments.py +114 -0
- coursebanks/cli.py +678 -0
- coursebanks/core.py +1392 -0
- coursebanks/migrate.py +645 -0
- coursebanks/models.py +212 -0
- coursebanks/py.typed +0 -0
- coursebanks/rotation.py +399 -0
- coursebanks/xml_io.py +50 -0
- coursebanks/yaml_data.py +147 -0
- questionbanktoolkit-0.1.0.dist-info/METADATA +125 -0
- questionbanktoolkit-0.1.0.dist-info/RECORD +18 -0
- questionbanktoolkit-0.1.0.dist-info/WHEEL +5 -0
- questionbanktoolkit-0.1.0.dist-info/entry_points.txt +2 -0
- questionbanktoolkit-0.1.0.dist-info/licenses/LICENSE +232 -0
- questionbanktoolkit-0.1.0.dist-info/top_level.txt +1 -0
coursebanks/core.py
ADDED
|
@@ -0,0 +1,1392 @@
|
|
|
1
|
+
"""YAML I/O, validation, and deterministic Canvas QTI 1.2 generation.
|
|
2
|
+
|
|
3
|
+
Supports single-answer multiple-choice questions with two or more choices.
|
|
4
|
+
Canvas import acceptance must still be checked in the institution's course.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import hashlib
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from collections.abc import Hashable, Sequence
|
|
17
|
+
from copy import deepcopy
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import cast
|
|
20
|
+
|
|
21
|
+
# Standard-library XML generation only; external parsing uses xml_io.
|
|
22
|
+
from xml.etree import ElementTree as ET # ruff: ignore[suspicious-xml-etree-import]
|
|
23
|
+
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from .archive_io import read_member, validate_archive
|
|
28
|
+
from .assessments import question_assessments
|
|
29
|
+
from .models import Bank, BuildReport, QTIReport, Question
|
|
30
|
+
from .xml_io import parse_xml
|
|
31
|
+
from .yaml_data import YamlMapping, YamlValue, yaml_value
|
|
32
|
+
|
|
33
|
+
QTI = "http://www.imsglobal.org/xsd/ims_qtiasiv1p2"
|
|
34
|
+
IMS = "http://www.imsglobal.org/xsd/imscp_v1p1"
|
|
35
|
+
ET.register_namespace("", QTI)
|
|
36
|
+
SAFE_ID = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]*$")
|
|
37
|
+
DIFFICULTIES = {"Basic", "Moderate", "Challenging"}
|
|
38
|
+
MIN_CHOICES = 2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _BankLoader(yaml.SafeLoader):
|
|
42
|
+
"""Load safe YAML while rejecting ambiguous mapping definitions.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
stream
|
|
47
|
+
YAML text, bytes, or a readable stream accepted by SafeLoader.
|
|
48
|
+
|
|
49
|
+
Notes
|
|
50
|
+
-----
|
|
51
|
+
Inherits SafeLoader's supported constructors and overrides mapping
|
|
52
|
+
construction to reject duplicate keys, merge keys, and unhashable keys.
|
|
53
|
+
This loader does not validate the question-bank schema. load_yaml handles
|
|
54
|
+
loader cleanup and converts YAML parser errors to ValueError.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def construct_mapping(
|
|
59
|
+
self, node: yaml.Node, deep: bool = False
|
|
60
|
+
) -> dict[Hashable, object]:
|
|
61
|
+
"""Construct a YAML mapping after checking keys for ambiguity.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
node
|
|
66
|
+
Mapping node supplied by the YAML constructor.
|
|
67
|
+
deep
|
|
68
|
+
Whether to request deep construction of child values. Defaults to
|
|
69
|
+
False and is forwarded to the underlying constructor.
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
mapping
|
|
74
|
+
Constructed dictionary, after key checks and SafeLoader conversion.
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
yaml.constructor.ConstructorError
|
|
79
|
+
If node is not a mapping, a merge key is present, a key is
|
|
80
|
+
unhashable, or a key compares equal to one already seen.
|
|
81
|
+
yaml.YAMLError
|
|
82
|
+
If constructing a key or value fails.
|
|
83
|
+
|
|
84
|
+
Notes
|
|
85
|
+
-----
|
|
86
|
+
Keys are checked before values can overwrite earlier entries.
|
|
87
|
+
Diagnostics include YAML source marks. Scalar-only normalization of
|
|
88
|
+
the resulting data is performed separately by yaml_value.
|
|
89
|
+
|
|
90
|
+
""" # noqa: DOC503
|
|
91
|
+
if not isinstance(node, yaml.MappingNode):
|
|
92
|
+
raise yaml.constructor.ConstructorError(
|
|
93
|
+
"while loading a bank",
|
|
94
|
+
node.start_mark,
|
|
95
|
+
"expected a mapping node",
|
|
96
|
+
node.start_mark,
|
|
97
|
+
)
|
|
98
|
+
seen: set[object] = set()
|
|
99
|
+
for key_node, _ in node.value:
|
|
100
|
+
if key_node.tag == "tag:yaml.org,2002:merge":
|
|
101
|
+
raise yaml.constructor.ConstructorError(
|
|
102
|
+
"while loading a bank",
|
|
103
|
+
node.start_mark,
|
|
104
|
+
"YAML merge keys (<<) are not supported; write fields "
|
|
105
|
+
"explicitly",
|
|
106
|
+
key_node.start_mark,
|
|
107
|
+
)
|
|
108
|
+
# PyYAML's stubs omit the node parameter type; key_node is a Node.
|
|
109
|
+
key: object = self.construct_object( # pyright: ignore[reportUnknownMemberType]
|
|
110
|
+
key_node, deep=deep
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
duplicate = key in seen
|
|
114
|
+
seen.add(key)
|
|
115
|
+
except TypeError as exc:
|
|
116
|
+
raise yaml.constructor.ConstructorError(
|
|
117
|
+
"while loading a bank",
|
|
118
|
+
node.start_mark,
|
|
119
|
+
"mapping keys must be scalar values",
|
|
120
|
+
key_node.start_mark,
|
|
121
|
+
) from exc
|
|
122
|
+
if duplicate:
|
|
123
|
+
raise yaml.constructor.ConstructorError(
|
|
124
|
+
"while loading a bank",
|
|
125
|
+
node.start_mark,
|
|
126
|
+
f"duplicate key {key!r}",
|
|
127
|
+
key_node.start_mark,
|
|
128
|
+
)
|
|
129
|
+
return super().construct_mapping(node, deep=deep)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_yaml(path: str | Path) -> YamlMapping:
|
|
133
|
+
"""Load one UTF-8 YAML mapping without validating its bank schema.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
path
|
|
138
|
+
Source file containing exactly one YAML document.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
data
|
|
143
|
+
Normalized mapping, including supported optional authoring metadata.
|
|
144
|
+
Use parse_bank to validate it or edit it as an incomplete draft.
|
|
145
|
+
|
|
146
|
+
Raises
|
|
147
|
+
------
|
|
148
|
+
ValueError
|
|
149
|
+
If YAML is malformed, uses unsupported constructs, has duplicate or
|
|
150
|
+
merge keys, contains cycles, or does not produce a top-level mapping.
|
|
151
|
+
Parser errors include the source path and available source marks.
|
|
152
|
+
OSError
|
|
153
|
+
If the source file cannot be read.
|
|
154
|
+
UnicodeError
|
|
155
|
+
If the source file cannot be decoded as UTF-8.
|
|
156
|
+
|
|
157
|
+
Notes
|
|
158
|
+
-----
|
|
159
|
+
Uses a SafeLoader subclass and copies supported containers through
|
|
160
|
+
yaml_value. Shared containers become independent copies. The source
|
|
161
|
+
file is not changed, and legacy eligibility is not migrated here.
|
|
162
|
+
|
|
163
|
+
""" # noqa: DOC503
|
|
164
|
+
path = Path(path)
|
|
165
|
+
try:
|
|
166
|
+
# _BankLoader subclasses SafeLoader and adds stricter key checks.
|
|
167
|
+
# Ruff does not recognize custom SafeLoader subclasses as safe.
|
|
168
|
+
obj: object = yaml.load(
|
|
169
|
+
path.read_text(encoding="utf-8"),
|
|
170
|
+
Loader=_BankLoader, # ruff: ignore[unsafe-yaml-load]
|
|
171
|
+
)
|
|
172
|
+
except yaml.YAMLError as exc:
|
|
173
|
+
raise ValueError(f"Invalid YAML in {path}: {exc}") from exc
|
|
174
|
+
data = yaml_value(obj)
|
|
175
|
+
if not isinstance(data, dict):
|
|
176
|
+
# Invalid file contents use the CLI's existing ValueError contract.
|
|
177
|
+
raise ValueError( # ruff: ignore[type-check-without-type-error]
|
|
178
|
+
f"Bank source must be a YAML mapping: {path}"
|
|
179
|
+
)
|
|
180
|
+
return data
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_bank(path: str | Path) -> Bank:
|
|
184
|
+
"""Load a UTF-8 YAML file and construct a validated bank.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
path
|
|
189
|
+
Source question-bank YAML file.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
bank
|
|
194
|
+
Validated bank with authoring metadata retained and legacy assessment
|
|
195
|
+
eligibility normalized. Omitted format remains omitted.
|
|
196
|
+
|
|
197
|
+
Raises
|
|
198
|
+
------
|
|
199
|
+
ValueError
|
|
200
|
+
If loading or schema validation fails. Schema errors are accumulated
|
|
201
|
+
in the validation diagnostic.
|
|
202
|
+
OSError
|
|
203
|
+
If the source file cannot be read.
|
|
204
|
+
UnicodeError
|
|
205
|
+
If the source file cannot be decoded as UTF-8.
|
|
206
|
+
|
|
207
|
+
Notes
|
|
208
|
+
-----
|
|
209
|
+
The file is not modified. Use load_yaml when loading incomplete drafts
|
|
210
|
+
that do not yet satisfy the bank schema.
|
|
211
|
+
|
|
212
|
+
""" # noqa: DOC502
|
|
213
|
+
return parse_bank(load_yaml(path))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def validate(bank: object) -> list[str]:
|
|
217
|
+
"""Collect schema diagnostics for external Python data.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
bank
|
|
222
|
+
Candidate bank data, including values not yet normalized as YAML.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
errors
|
|
227
|
+
Empty list for a valid bank; otherwise ordered diagnostic strings.
|
|
228
|
+
Normalization failures and non-mapping inputs return a single error.
|
|
229
|
+
|
|
230
|
+
Notes
|
|
231
|
+
-----
|
|
232
|
+
Normalizes supported YAML values before checking the schema. Expected
|
|
233
|
+
input-validation failures are returned rather than raised. Input data
|
|
234
|
+
is unchanged; successful validation does not replace it with a
|
|
235
|
+
normalized Bank or certify Canvas rendering.
|
|
236
|
+
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
data = yaml_value(bank)
|
|
240
|
+
except ValueError as exc:
|
|
241
|
+
return [str(exc)]
|
|
242
|
+
if not isinstance(data, dict):
|
|
243
|
+
return ["Bank source must be a YAML mapping."]
|
|
244
|
+
_, errors = _parse_bank(data)
|
|
245
|
+
return errors
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parse_bank(data: YamlMapping) -> tuple[Bank | None, list[str]]:
|
|
249
|
+
"""Collect bank and question diagnostics before constructing a bank.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
data
|
|
254
|
+
Normalized YAML mapping to check against the bank schema.
|
|
255
|
+
|
|
256
|
+
Returns
|
|
257
|
+
-------
|
|
258
|
+
bank
|
|
259
|
+
Constructed bank when every check succeeds; otherwise None.
|
|
260
|
+
errors
|
|
261
|
+
Ordered bank-field and question diagnostics, empty on success.
|
|
262
|
+
|
|
263
|
+
Notes
|
|
264
|
+
-----
|
|
265
|
+
A missing, empty, or non-list questions field prevents question
|
|
266
|
+
validation. Otherwise all question entries are checked. Input data
|
|
267
|
+
is not modified, but this helper does not detach all nested metadata;
|
|
268
|
+
parse_bank supplies the deep-copy boundary.
|
|
269
|
+
|
|
270
|
+
"""
|
|
271
|
+
errors = _validate_bank_fields(data)
|
|
272
|
+
items = data.get("questions")
|
|
273
|
+
if not isinstance(items, list) or not items:
|
|
274
|
+
return None, [*errors, "questions must be a nonempty list"]
|
|
275
|
+
questions, question_errors = _parse_questions(items)
|
|
276
|
+
errors.extend(question_errors)
|
|
277
|
+
if errors:
|
|
278
|
+
return None, errors
|
|
279
|
+
return _construct_bank(data, questions), []
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _validate_bank_fields(data: YamlMapping) -> list[str]:
|
|
283
|
+
"""Check top-level bank metadata without modifying the input.
|
|
284
|
+
|
|
285
|
+
Parameters
|
|
286
|
+
----------
|
|
287
|
+
data
|
|
288
|
+
Normalized mapping whose keys and bank metadata are checked.
|
|
289
|
+
|
|
290
|
+
Returns
|
|
291
|
+
-------
|
|
292
|
+
errors
|
|
293
|
+
Diagnostics for non-string field names, a version other than integer
|
|
294
|
+
1, missing or blank course/bank_id/title, invalid bank ID syntax, and
|
|
295
|
+
a nonboolean choice-shuffling default.
|
|
296
|
+
|
|
297
|
+
Notes
|
|
298
|
+
-----
|
|
299
|
+
Questions are validated separately. Multiple diagnostics can describe
|
|
300
|
+
one field, and additional string-keyed authoring metadata is allowed.
|
|
301
|
+
|
|
302
|
+
"""
|
|
303
|
+
errors: list[str] = []
|
|
304
|
+
if any(not isinstance(key, str) for key in data):
|
|
305
|
+
errors.append("Bank field names must be strings")
|
|
306
|
+
version = data.get("schema_version")
|
|
307
|
+
if type(version) is not int or version != 1:
|
|
308
|
+
errors.append("schema_version must be integer 1")
|
|
309
|
+
for field in ("course", "bank_id", "title"):
|
|
310
|
+
text = data.get(field)
|
|
311
|
+
if not isinstance(text, str) or not text.strip():
|
|
312
|
+
errors.append(f"{field} must be a nonempty string")
|
|
313
|
+
bank_id = data.get("bank_id")
|
|
314
|
+
if not isinstance(bank_id, str) or not SAFE_ID.fullmatch(bank_id):
|
|
315
|
+
errors.append("bank_id must match [A-Za-z][A-Za-z0-9_.-]*")
|
|
316
|
+
if (
|
|
317
|
+
"default_shuffle_choices" in data
|
|
318
|
+
and type(data["default_shuffle_choices"]) is not bool
|
|
319
|
+
):
|
|
320
|
+
errors.append("default_shuffle_choices must be a boolean")
|
|
321
|
+
return errors
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _parse_questions(
|
|
325
|
+
items: list[YamlValue],
|
|
326
|
+
) -> tuple[list[Question], list[str]]:
|
|
327
|
+
"""Validate question entries while tracking bank-wide duplicates.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
items
|
|
332
|
+
Question entries in source order, not yet checked as mappings.
|
|
333
|
+
|
|
334
|
+
Returns
|
|
335
|
+
-------
|
|
336
|
+
questions
|
|
337
|
+
Constructed questions whose own checks passed, in source order.
|
|
338
|
+
errors
|
|
339
|
+
All entry diagnostics in source order. Question references include
|
|
340
|
+
the one-based position and supplied ID when available.
|
|
341
|
+
|
|
342
|
+
Notes
|
|
343
|
+
-----
|
|
344
|
+
Checks run in identity, fields, choices, then duplicate-stem order.
|
|
345
|
+
Valid identifiers and string stems are tracked even when other fields
|
|
346
|
+
are invalid. Callers must reject the whole bank if errors is nonempty.
|
|
347
|
+
The input list and mappings are not modified.
|
|
348
|
+
|
|
349
|
+
"""
|
|
350
|
+
errors: list[str] = []
|
|
351
|
+
ids: set[str] = set()
|
|
352
|
+
norm_prompts: set[str] = set()
|
|
353
|
+
questions: list[Question] = []
|
|
354
|
+
for no, entry in enumerate(items, 1):
|
|
355
|
+
supplied_id = (
|
|
356
|
+
str(entry.get("id", "?")) if isinstance(entry, dict) else "?"
|
|
357
|
+
)
|
|
358
|
+
ref = f"question #{no} ({supplied_id})"
|
|
359
|
+
if not isinstance(entry, dict):
|
|
360
|
+
errors.append(f"{ref}: entry must be a mapping")
|
|
361
|
+
continue
|
|
362
|
+
question_errors = _validate_question_identity(entry, ref, ids)
|
|
363
|
+
question_errors.extend(_validate_question_fields(entry, ref))
|
|
364
|
+
question_errors.extend(_validate_choices(entry, ref))
|
|
365
|
+
question_errors.extend(_check_stem_uniqueness(entry, ref, norm_prompts))
|
|
366
|
+
if not question_errors:
|
|
367
|
+
questions.append(_construct_question(entry))
|
|
368
|
+
errors.extend(question_errors)
|
|
369
|
+
return questions, errors
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _validate_question_identity(
|
|
373
|
+
q: YamlMapping, ref: str, ids: set[str]
|
|
374
|
+
) -> list[str]:
|
|
375
|
+
"""Check question keys and identifiers, recording valid IDs.
|
|
376
|
+
|
|
377
|
+
Parameters
|
|
378
|
+
----------
|
|
379
|
+
q
|
|
380
|
+
Question mapping to inspect without modification.
|
|
381
|
+
ref
|
|
382
|
+
Question reference prefixed to each diagnostic.
|
|
383
|
+
ids
|
|
384
|
+
Previously encountered valid IDs. A new valid ID is added in place.
|
|
385
|
+
|
|
386
|
+
Returns
|
|
387
|
+
-------
|
|
388
|
+
errors
|
|
389
|
+
Diagnostics for non-string keys, invalid IDs, or repeated IDs.
|
|
390
|
+
|
|
391
|
+
Notes
|
|
392
|
+
-----
|
|
393
|
+
ID syntax must match SAFE_ID. IDs are recorded independently of other
|
|
394
|
+
question fields so later duplicates remain detectable.
|
|
395
|
+
|
|
396
|
+
"""
|
|
397
|
+
errors: list[str] = []
|
|
398
|
+
if any(not isinstance(key, str) for key in q):
|
|
399
|
+
errors.append(f"{ref}: question field names must be strings")
|
|
400
|
+
qid = q.get("id")
|
|
401
|
+
if not isinstance(qid, str) or not SAFE_ID.fullmatch(qid):
|
|
402
|
+
errors.append(f"{ref}: invalid id")
|
|
403
|
+
elif qid in ids:
|
|
404
|
+
errors.append(f"{ref}: duplicate id {qid}")
|
|
405
|
+
else:
|
|
406
|
+
ids.add(qid)
|
|
407
|
+
return errors
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _validate_question_fields(q: YamlMapping, ref: str) -> list[str]:
|
|
411
|
+
"""Check question content, eligibility, and grading fields.
|
|
412
|
+
|
|
413
|
+
Parameters
|
|
414
|
+
----------
|
|
415
|
+
q
|
|
416
|
+
Question mapping to inspect without modification.
|
|
417
|
+
ref
|
|
418
|
+
Question reference prefixed to each diagnostic.
|
|
419
|
+
|
|
420
|
+
Returns
|
|
421
|
+
-------
|
|
422
|
+
errors
|
|
423
|
+
Diagnostics for required text, difficulty, assessment eligibility,
|
|
424
|
+
positive finite points/minutes, optional rendering format, and an
|
|
425
|
+
optional boolean choice-shuffling override.
|
|
426
|
+
|
|
427
|
+
Notes
|
|
428
|
+
-----
|
|
429
|
+
Identity, choices, and cross-question uniqueness are checked elsewhere.
|
|
430
|
+
Eligibility errors are collected from question_assessments. Boolean
|
|
431
|
+
numeric values are rejected, and omitted format is treated as plain.
|
|
432
|
+
|
|
433
|
+
"""
|
|
434
|
+
errors: list[str] = []
|
|
435
|
+
for key in ("title", "stem", "topic", "area", "explanation"):
|
|
436
|
+
text = q.get(key)
|
|
437
|
+
if not isinstance(text, str) or not text.strip():
|
|
438
|
+
errors.append(f"{ref}: {key} must be nonempty text")
|
|
439
|
+
difficulty = q.get("difficulty")
|
|
440
|
+
if not isinstance(difficulty, str) or difficulty not in DIFFICULTIES:
|
|
441
|
+
errors.append(
|
|
442
|
+
f"{ref}: difficulty must be one of {sorted(DIFFICULTIES)}"
|
|
443
|
+
)
|
|
444
|
+
try:
|
|
445
|
+
question_assessments(q)
|
|
446
|
+
except ValueError as exc:
|
|
447
|
+
errors.append(f"{ref}: {exc}")
|
|
448
|
+
for field in ("points", "minutes"):
|
|
449
|
+
value = q.get(field)
|
|
450
|
+
if (
|
|
451
|
+
isinstance(value, bool)
|
|
452
|
+
or not isinstance(value, (int, float))
|
|
453
|
+
or value <= 0
|
|
454
|
+
or (isinstance(value, float) and not math.isfinite(value))
|
|
455
|
+
):
|
|
456
|
+
errors.append(f"{ref}: {field} must be a finite positive number")
|
|
457
|
+
fmt = q.get("format", "plain")
|
|
458
|
+
if not isinstance(fmt, str) or fmt not in {"plain", "html"}:
|
|
459
|
+
errors.append(f"{ref}: format must be plain or html")
|
|
460
|
+
if "shuffle_choices" in q and type(q["shuffle_choices"]) is not bool:
|
|
461
|
+
errors.append(f"{ref}: shuffle_choices must be a boolean")
|
|
462
|
+
return errors
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _validate_choices(q: YamlMapping, ref: str) -> list[str]:
|
|
466
|
+
"""Check choice labels, text, and correct-answer membership.
|
|
467
|
+
|
|
468
|
+
Parameters
|
|
469
|
+
----------
|
|
470
|
+
q
|
|
471
|
+
Question mapping containing choices and answer.
|
|
472
|
+
ref
|
|
473
|
+
Question reference prefixed to each diagnostic.
|
|
474
|
+
|
|
475
|
+
Returns
|
|
476
|
+
-------
|
|
477
|
+
errors
|
|
478
|
+
Ordered diagnostics for the choice mapping, labels, content,
|
|
479
|
+
duplicate text, and answer membership.
|
|
480
|
+
|
|
481
|
+
Notes
|
|
482
|
+
-----
|
|
483
|
+
If choices is not a mapping with at least two entries, only that
|
|
484
|
+
structural error is returned. Otherwise text must be nonblank and
|
|
485
|
+
unique after trimming outer whitespace, with case preserved. Labels
|
|
486
|
+
must match SAFE_ID and answer must select one label. Input is unchanged.
|
|
487
|
+
|
|
488
|
+
"""
|
|
489
|
+
errors: list[str] = []
|
|
490
|
+
choices = q.get("choices")
|
|
491
|
+
options = choices if isinstance(choices, dict) else {}
|
|
492
|
+
if len(options) < MIN_CHOICES:
|
|
493
|
+
errors.append(
|
|
494
|
+
f"{ref}: choices must be a mapping with at least two entries"
|
|
495
|
+
)
|
|
496
|
+
else:
|
|
497
|
+
labels = list(options)
|
|
498
|
+
if any(
|
|
499
|
+
not isinstance(label, str) or not SAFE_ID.fullmatch(label)
|
|
500
|
+
for label in labels
|
|
501
|
+
):
|
|
502
|
+
errors.append(f"{ref}: choice keys must be valid identifiers")
|
|
503
|
+
if any(
|
|
504
|
+
not isinstance(x, str) or not x.strip() for x in options.values()
|
|
505
|
+
):
|
|
506
|
+
errors.append(f"{ref}: choices must contain nonempty text")
|
|
507
|
+
texts = [v.strip() for v in options.values() if isinstance(v, str)]
|
|
508
|
+
if len(set(texts)) != len(texts):
|
|
509
|
+
errors.append(f"{ref}: duplicate answer texts")
|
|
510
|
+
answer = q.get("answer")
|
|
511
|
+
if not isinstance(answer, str) or answer not in options:
|
|
512
|
+
errors.append(f"{ref}: answer must select one of the choice keys")
|
|
513
|
+
return errors
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _check_stem_uniqueness(
|
|
517
|
+
q: YamlMapping, ref: str, norm_prompts: set[str]
|
|
518
|
+
) -> list[str]:
|
|
519
|
+
"""Record string stems and detect normalized duplicates.
|
|
520
|
+
|
|
521
|
+
Parameters
|
|
522
|
+
----------
|
|
523
|
+
q
|
|
524
|
+
Question mapping whose stem is inspected.
|
|
525
|
+
ref
|
|
526
|
+
Question reference prefixed to a duplicate diagnostic.
|
|
527
|
+
norm_prompts
|
|
528
|
+
Previously normalized stems. The current string stem is added
|
|
529
|
+
in place, even if other question checks have failed.
|
|
530
|
+
|
|
531
|
+
Returns
|
|
532
|
+
-------
|
|
533
|
+
errors
|
|
534
|
+
One diagnostic for a duplicate string stem, otherwise an empty list.
|
|
535
|
+
|
|
536
|
+
Notes
|
|
537
|
+
-----
|
|
538
|
+
Normalization lowercases text and collapses whitespace. Non-string
|
|
539
|
+
stems are ignored here; field validation handles their errors. The
|
|
540
|
+
question mapping and its original stem text are unchanged.
|
|
541
|
+
|
|
542
|
+
"""
|
|
543
|
+
errors: list[str] = []
|
|
544
|
+
stem = q.get("stem")
|
|
545
|
+
if isinstance(stem, str):
|
|
546
|
+
norm = " ".join(stem.lower().split())
|
|
547
|
+
if norm in norm_prompts:
|
|
548
|
+
errors.append(f"{ref}: duplicate question stem")
|
|
549
|
+
norm_prompts.add(norm)
|
|
550
|
+
return errors
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _construct_bank(data: YamlMapping, questions: list[Question]) -> Bank:
|
|
554
|
+
"""Assemble checked bank fields and retain authoring extensions.
|
|
555
|
+
|
|
556
|
+
Parameters
|
|
557
|
+
----------
|
|
558
|
+
data
|
|
559
|
+
Mapping whose bank fields have already passed validation.
|
|
560
|
+
questions
|
|
561
|
+
Validated questions to install as the bank's questions list.
|
|
562
|
+
|
|
563
|
+
Returns
|
|
564
|
+
-------
|
|
565
|
+
bank
|
|
566
|
+
New dictionary with schema version 1, checked identity fields, the
|
|
567
|
+
supplied questions list, an optional choice-shuffling default, and
|
|
568
|
+
additional string-keyed metadata.
|
|
569
|
+
|
|
570
|
+
Raises
|
|
571
|
+
------
|
|
572
|
+
KeyError
|
|
573
|
+
If a required identity field is missing.
|
|
574
|
+
TypeError
|
|
575
|
+
If a required identity field is not text.
|
|
576
|
+
|
|
577
|
+
Notes
|
|
578
|
+
-----
|
|
579
|
+
This is a construction helper, not full validation. The questions list
|
|
580
|
+
and extension values are retained by reference. parse_bank provides
|
|
581
|
+
the input-copy boundary before construction.
|
|
582
|
+
|
|
583
|
+
""" # noqa: DOC502
|
|
584
|
+
bank = Bank(
|
|
585
|
+
# Schema validation above accepts only integer 1.
|
|
586
|
+
schema_version=1,
|
|
587
|
+
course=_text(data, "course"),
|
|
588
|
+
bank_id=_text(data, "bank_id"),
|
|
589
|
+
title=_text(data, "title"),
|
|
590
|
+
questions=questions,
|
|
591
|
+
)
|
|
592
|
+
# TypedDict describes known fields, not authoring extensions. Copy only
|
|
593
|
+
# unknown keys using the ordinary dict API, never replacing checked fields.
|
|
594
|
+
dict[str, object].update(
|
|
595
|
+
bank,
|
|
596
|
+
{
|
|
597
|
+
key: value
|
|
598
|
+
for key, value in data.items()
|
|
599
|
+
if isinstance(key, str) and key not in bank
|
|
600
|
+
},
|
|
601
|
+
)
|
|
602
|
+
return bank
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _text(data: YamlMapping, key: str) -> str:
|
|
606
|
+
"""Read a string field without coercion.
|
|
607
|
+
|
|
608
|
+
Parameters
|
|
609
|
+
----------
|
|
610
|
+
data
|
|
611
|
+
Mapping containing the field.
|
|
612
|
+
key
|
|
613
|
+
Field name to read; the key must exist.
|
|
614
|
+
|
|
615
|
+
Returns
|
|
616
|
+
-------
|
|
617
|
+
value
|
|
618
|
+
Original string value.
|
|
619
|
+
|
|
620
|
+
Raises
|
|
621
|
+
------
|
|
622
|
+
KeyError
|
|
623
|
+
If the key is absent.
|
|
624
|
+
TypeError
|
|
625
|
+
If the field value is not a string.
|
|
626
|
+
|
|
627
|
+
Notes
|
|
628
|
+
-----
|
|
629
|
+
This checks representation only, not whether text is blank.
|
|
630
|
+
|
|
631
|
+
""" # noqa: DOC503
|
|
632
|
+
value = data[key]
|
|
633
|
+
if not isinstance(value, str):
|
|
634
|
+
raise TypeError(f"{key} must be text")
|
|
635
|
+
return value
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _number(data: YamlMapping, key: str) -> float:
|
|
639
|
+
"""Read an integer or float without accepting booleans.
|
|
640
|
+
|
|
641
|
+
Parameters
|
|
642
|
+
----------
|
|
643
|
+
data
|
|
644
|
+
Mapping containing the field.
|
|
645
|
+
key
|
|
646
|
+
Numeric field name to read; the key must exist.
|
|
647
|
+
|
|
648
|
+
Returns
|
|
649
|
+
-------
|
|
650
|
+
value
|
|
651
|
+
Original integer or floating-point value, without conversion.
|
|
652
|
+
|
|
653
|
+
Raises
|
|
654
|
+
------
|
|
655
|
+
KeyError
|
|
656
|
+
If the key is absent.
|
|
657
|
+
TypeError
|
|
658
|
+
If the field value is a boolean or is neither an integer nor a float.
|
|
659
|
+
|
|
660
|
+
Notes
|
|
661
|
+
-----
|
|
662
|
+
Positivity and finiteness are checked before construction, not here.
|
|
663
|
+
|
|
664
|
+
""" # noqa: DOC503
|
|
665
|
+
value = data[key]
|
|
666
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
667
|
+
raise TypeError(f"{key} must be a number, not a boolean")
|
|
668
|
+
return value
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _construct_question(data: YamlMapping) -> Question:
|
|
672
|
+
"""Assemble a question from fields that have passed validation.
|
|
673
|
+
|
|
674
|
+
Parameters
|
|
675
|
+
----------
|
|
676
|
+
data
|
|
677
|
+
Question mapping already checked for schema constraints.
|
|
678
|
+
|
|
679
|
+
Returns
|
|
680
|
+
-------
|
|
681
|
+
question
|
|
682
|
+
New question dictionary with copied choices, normalized assessments,
|
|
683
|
+
optional format and choice-shuffling override when supplied, and
|
|
684
|
+
additional authoring metadata. The legacy exam key is omitted.
|
|
685
|
+
|
|
686
|
+
Raises
|
|
687
|
+
------
|
|
688
|
+
KeyError
|
|
689
|
+
If a required construction field is absent.
|
|
690
|
+
TypeError
|
|
691
|
+
If choices or required text/numeric fields have invalid types.
|
|
692
|
+
ValueError
|
|
693
|
+
If assessment eligibility cannot be normalized.
|
|
694
|
+
|
|
695
|
+
Notes
|
|
696
|
+
-----
|
|
697
|
+
Does not repeat all schema checks. Extension values are retained by
|
|
698
|
+
reference; parse_bank deep-copies input before calling this helper.
|
|
699
|
+
|
|
700
|
+
""" # noqa: DOC503
|
|
701
|
+
raw_choices = data["choices"]
|
|
702
|
+
if not isinstance(raw_choices, dict):
|
|
703
|
+
raise TypeError("choices must be a mapping")
|
|
704
|
+
choices: dict[str, str] = {}
|
|
705
|
+
for label, text in raw_choices.items():
|
|
706
|
+
if not isinstance(label, str) or not isinstance(text, str):
|
|
707
|
+
raise TypeError("choice labels and text must be strings")
|
|
708
|
+
choices[label] = text
|
|
709
|
+
question = Question(
|
|
710
|
+
id=_text(data, "id"),
|
|
711
|
+
title=_text(data, "title"),
|
|
712
|
+
area=_text(data, "area"),
|
|
713
|
+
topic=_text(data, "topic"),
|
|
714
|
+
difficulty=_text(data, "difficulty"),
|
|
715
|
+
assessments=question_assessments(data),
|
|
716
|
+
minutes=_number(data, "minutes"),
|
|
717
|
+
points=_number(data, "points"),
|
|
718
|
+
stem=_text(data, "stem"),
|
|
719
|
+
choices=choices,
|
|
720
|
+
answer=_text(data, "answer"),
|
|
721
|
+
explanation=_text(data, "explanation"),
|
|
722
|
+
)
|
|
723
|
+
if "format" in data:
|
|
724
|
+
question["format"] = _text(data, "format")
|
|
725
|
+
dict[str, object].update(
|
|
726
|
+
question,
|
|
727
|
+
{
|
|
728
|
+
key: value
|
|
729
|
+
for key, value in data.items()
|
|
730
|
+
if isinstance(key, str) and key not in question and key != "exam"
|
|
731
|
+
},
|
|
732
|
+
)
|
|
733
|
+
return question
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def parse_bank(data: YamlMapping) -> Bank:
|
|
737
|
+
"""Construct a validated bank from normalized YAML data.
|
|
738
|
+
|
|
739
|
+
Parameters
|
|
740
|
+
----------
|
|
741
|
+
data
|
|
742
|
+
Normalized YAML mapping to validate and copy.
|
|
743
|
+
|
|
744
|
+
Returns
|
|
745
|
+
-------
|
|
746
|
+
bank
|
|
747
|
+
Validated schema-version-1 bank independent of the input, retaining
|
|
748
|
+
additional authoring metadata and original question/choice order.
|
|
749
|
+
|
|
750
|
+
Raises
|
|
751
|
+
------
|
|
752
|
+
ValueError
|
|
753
|
+
If schema checks fail. The message begins with Validation failed
|
|
754
|
+
and includes the accumulated diagnostics.
|
|
755
|
+
|
|
756
|
+
Notes
|
|
757
|
+
-----
|
|
758
|
+
Deep-copies the input before validation and construction. Legacy exam
|
|
759
|
+
values become assessments; omitted assessments become an unrestricted
|
|
760
|
+
empty list. Omitted format remains omitted. Revalidate after editing
|
|
761
|
+
the result; type annotations alone cannot enforce schema constraints.
|
|
762
|
+
|
|
763
|
+
"""
|
|
764
|
+
bank, errors = _parse_bank(deepcopy(data))
|
|
765
|
+
if bank is None:
|
|
766
|
+
raise ValueError("Validation failed:\n" + "\n".join(errors))
|
|
767
|
+
return bank
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def require_valid_bank(bank: object) -> Bank:
|
|
771
|
+
"""Normalize external Python data and construct a validated bank.
|
|
772
|
+
|
|
773
|
+
Parameters
|
|
774
|
+
----------
|
|
775
|
+
bank
|
|
776
|
+
Candidate bank data to normalize through yaml_value.
|
|
777
|
+
|
|
778
|
+
Returns
|
|
779
|
+
-------
|
|
780
|
+
validated
|
|
781
|
+
Validated bank copied from the input, with supported metadata kept.
|
|
782
|
+
|
|
783
|
+
Raises
|
|
784
|
+
------
|
|
785
|
+
ValueError
|
|
786
|
+
If data contains unsupported YAML values or cycles, is not a mapping,
|
|
787
|
+
or fails bank schema validation.
|
|
788
|
+
|
|
789
|
+
Notes
|
|
790
|
+
-----
|
|
791
|
+
Compatibility entry point for arbitrary Python data. Prefer load_bank
|
|
792
|
+
for files or parse_bank for normalized YAML mappings. Input is unchanged.
|
|
793
|
+
|
|
794
|
+
"""
|
|
795
|
+
data = yaml_value(bank)
|
|
796
|
+
if not isinstance(data, dict):
|
|
797
|
+
# File contents use the public ValueError diagnostic contract.
|
|
798
|
+
raise ValueError("Bank source must be a YAML mapping.") # ruff: ignore[type-check-without-type-error]
|
|
799
|
+
return parse_bank(data)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _element(
|
|
803
|
+
parent: ET.Element, name: str, text: object = None, **attrs: object
|
|
804
|
+
) -> ET.Element:
|
|
805
|
+
"""Append a QTI element, converting content and attributes to strings.
|
|
806
|
+
|
|
807
|
+
Parameters
|
|
808
|
+
----------
|
|
809
|
+
parent
|
|
810
|
+
Element to which the new child is appended.
|
|
811
|
+
name
|
|
812
|
+
Local tag name, qualified with the QTI namespace.
|
|
813
|
+
text
|
|
814
|
+
Optional content converted with str. None leaves element text unset.
|
|
815
|
+
**attrs
|
|
816
|
+
Attribute names and values; values are converted with str.
|
|
817
|
+
|
|
818
|
+
Returns
|
|
819
|
+
-------
|
|
820
|
+
node
|
|
821
|
+
New child element, already attached to parent.
|
|
822
|
+
|
|
823
|
+
Notes
|
|
824
|
+
-----
|
|
825
|
+
Mutates parent. It does not parse markup supplied as text.
|
|
826
|
+
|
|
827
|
+
"""
|
|
828
|
+
node = ET.SubElement(
|
|
829
|
+
parent, f"{{{QTI}}}{name}", {k: str(v) for k, v in attrs.items()}
|
|
830
|
+
)
|
|
831
|
+
if text is not None:
|
|
832
|
+
node.text = str(text)
|
|
833
|
+
return node
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _material(parent: ET.Element, text: str, html: bool = False) -> ET.Element:
|
|
837
|
+
"""Append a material/mattext pair with the requested content format.
|
|
838
|
+
|
|
839
|
+
Parameters
|
|
840
|
+
----------
|
|
841
|
+
parent
|
|
842
|
+
Element to receive a new material child.
|
|
843
|
+
text
|
|
844
|
+
Text assigned to the nested mattext element.
|
|
845
|
+
html
|
|
846
|
+
Whether texttype is text/html. False uses text/plain.
|
|
847
|
+
|
|
848
|
+
Returns
|
|
849
|
+
-------
|
|
850
|
+
node
|
|
851
|
+
Nested mattext element, not the enclosing material element.
|
|
852
|
+
|
|
853
|
+
Notes
|
|
854
|
+
-----
|
|
855
|
+
Mutates parent. HTML is stored as text for XML serialization; this
|
|
856
|
+
helper does not parse, sanitize, or render it.
|
|
857
|
+
|
|
858
|
+
"""
|
|
859
|
+
material = _element(parent, "material")
|
|
860
|
+
return _element(
|
|
861
|
+
material,
|
|
862
|
+
"mattext",
|
|
863
|
+
text,
|
|
864
|
+
texttype="text/html" if html else "text/plain",
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def question_to_xml(
|
|
869
|
+
q: Question, *, default_shuffle_choices: bool = False
|
|
870
|
+
) -> ET.Element:
|
|
871
|
+
"""Encode a validated question with single-answer scoring and feedback.
|
|
872
|
+
|
|
873
|
+
Parameters
|
|
874
|
+
----------
|
|
875
|
+
q
|
|
876
|
+
Validated question with ordered choices and a correct-answer label.
|
|
877
|
+
default_shuffle_choices
|
|
878
|
+
Bank-level shuffling behavior used when the question has no override.
|
|
879
|
+
Defaults to False.
|
|
880
|
+
|
|
881
|
+
Returns
|
|
882
|
+
-------
|
|
883
|
+
item
|
|
884
|
+
New QTI item element containing metadata, presentation, scoring,
|
|
885
|
+
and explanation feedback for correct and incorrect responses.
|
|
886
|
+
|
|
887
|
+
Notes
|
|
888
|
+
-----
|
|
889
|
+
Choice IDs combine the question ID and choice label. The render_choice
|
|
890
|
+
element receives ``shuffle="Yes"`` or ``shuffle="No"`` from the
|
|
891
|
+
question override or bank default. Correct answers score 100 and incorrect
|
|
892
|
+
answers 0; grading weight is stored separately in points_possible
|
|
893
|
+
metadata. Assessment eligibility is a JSON array. Omitted format means
|
|
894
|
+
plain text. Input is unchanged and is not revalidated; minutes and
|
|
895
|
+
additional authoring metadata are not exported.
|
|
896
|
+
|
|
897
|
+
"""
|
|
898
|
+
qid = q["id"]
|
|
899
|
+
item = ET.Element(f"{{{QTI}}}item", ident=qid, title=f"{qid} {q['title']}")
|
|
900
|
+
meta = _element(_element(item, "itemmetadata"), "qtimetadata")
|
|
901
|
+
for key, value in [
|
|
902
|
+
("question_type", "multiple_choice_question"),
|
|
903
|
+
("points_possible", q["points"]),
|
|
904
|
+
("assessment_question_identifierref", qid),
|
|
905
|
+
("topic", q["topic"]),
|
|
906
|
+
("area", q["area"]),
|
|
907
|
+
("difficulty", q["difficulty"]),
|
|
908
|
+
("assessments", json.dumps(q.get("assessments", []))),
|
|
909
|
+
]:
|
|
910
|
+
field = _element(meta, "qtimetadatafield")
|
|
911
|
+
_element(field, "fieldlabel", key)
|
|
912
|
+
_element(field, "fieldentry", value)
|
|
913
|
+
presentation = _element(item, "presentation")
|
|
914
|
+
html = q.get("format", "plain") == "html"
|
|
915
|
+
_material(presentation, q["stem"], html)
|
|
916
|
+
resp = _element(
|
|
917
|
+
presentation, "response_lid", ident="response1", rcardinality="Single"
|
|
918
|
+
)
|
|
919
|
+
shuffle = q.get("shuffle_choices", default_shuffle_choices)
|
|
920
|
+
render = _element(resp, "render_choice", shuffle="Yes" if shuffle else "No")
|
|
921
|
+
for key, choice in q["choices"].items():
|
|
922
|
+
_material(
|
|
923
|
+
_element(render, "response_label", ident=f"{qid}_{key}"),
|
|
924
|
+
choice,
|
|
925
|
+
html,
|
|
926
|
+
)
|
|
927
|
+
res = _element(item, "resprocessing")
|
|
928
|
+
_element(
|
|
929
|
+
_element(res, "outcomes"),
|
|
930
|
+
"decvar",
|
|
931
|
+
varname="SCORE",
|
|
932
|
+
vartype="Decimal",
|
|
933
|
+
minvalue="0",
|
|
934
|
+
maxvalue="100",
|
|
935
|
+
)
|
|
936
|
+
correct = _element(res, "respcondition", **{"continue": "No"})
|
|
937
|
+
_element(
|
|
938
|
+
_element(correct, "conditionvar"),
|
|
939
|
+
"varequal",
|
|
940
|
+
f"{qid}_{q['answer']}",
|
|
941
|
+
respident="response1",
|
|
942
|
+
)
|
|
943
|
+
_element(correct, "setvar", "100", varname="SCORE", action="Set")
|
|
944
|
+
_element(
|
|
945
|
+
correct,
|
|
946
|
+
"displayfeedback",
|
|
947
|
+
feedbacktype="Response",
|
|
948
|
+
linkrefid="general_feedback",
|
|
949
|
+
)
|
|
950
|
+
incorrect = _element(res, "respcondition", **{"continue": "No"})
|
|
951
|
+
_element(_element(incorrect, "conditionvar"), "other")
|
|
952
|
+
_element(incorrect, "setvar", "0", varname="SCORE", action="Set")
|
|
953
|
+
_element(
|
|
954
|
+
incorrect,
|
|
955
|
+
"displayfeedback",
|
|
956
|
+
feedbacktype="Response",
|
|
957
|
+
linkrefid="general_feedback",
|
|
958
|
+
)
|
|
959
|
+
_material(
|
|
960
|
+
_element(
|
|
961
|
+
_element(item, "itemfeedback", ident="general_feedback"), "flow_mat"
|
|
962
|
+
),
|
|
963
|
+
q["explanation"],
|
|
964
|
+
html,
|
|
965
|
+
)
|
|
966
|
+
_element(
|
|
967
|
+
item,
|
|
968
|
+
"qticomment",
|
|
969
|
+
f"Area: {q['area']}; topic: {q['topic']}; difficulty: "
|
|
970
|
+
f"{q['difficulty']}.",
|
|
971
|
+
)
|
|
972
|
+
return item
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def qti_xml(bank: Bank, questions: Sequence[Question] | None = None) -> bytes:
|
|
976
|
+
"""Serialize an assessment as UTF-8 XML in the QTI 1.2 namespace.
|
|
977
|
+
|
|
978
|
+
Parameters
|
|
979
|
+
----------
|
|
980
|
+
bank
|
|
981
|
+
Validated bank supplying assessment identity and title.
|
|
982
|
+
questions
|
|
983
|
+
Optional question sequence replacing the bank's full question list.
|
|
984
|
+
None uses all bank questions in their existing order.
|
|
985
|
+
|
|
986
|
+
Returns
|
|
987
|
+
-------
|
|
988
|
+
payload
|
|
989
|
+
XML bytes including the declaration, assessment, and root section.
|
|
990
|
+
|
|
991
|
+
Notes
|
|
992
|
+
-----
|
|
993
|
+
Inputs are not revalidated or modified. An empty supplied sequence
|
|
994
|
+
produces an empty section; create_qti rejects empty selections. Registers
|
|
995
|
+
QTI as ElementTree's default namespace before serialization.
|
|
996
|
+
|
|
997
|
+
"""
|
|
998
|
+
ET.register_namespace("", QTI)
|
|
999
|
+
questions = bank["questions"] if questions is None else questions
|
|
1000
|
+
root = ET.Element(f"{{{QTI}}}questestinterop")
|
|
1001
|
+
assessment = _element(
|
|
1002
|
+
root, "assessment", ident=bank["bank_id"], title=bank["title"]
|
|
1003
|
+
)
|
|
1004
|
+
section = _element(
|
|
1005
|
+
assessment, "section", ident="root_section", title=bank["title"]
|
|
1006
|
+
)
|
|
1007
|
+
default_shuffle = bank.get("default_shuffle_choices", False)
|
|
1008
|
+
for q in questions:
|
|
1009
|
+
section.append(
|
|
1010
|
+
question_to_xml(q, default_shuffle_choices=default_shuffle)
|
|
1011
|
+
)
|
|
1012
|
+
# ElementTree stubs return Any here; explicit UTF-8 guarantees bytes.
|
|
1013
|
+
return cast(
|
|
1014
|
+
bytes, ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def manifest_xml(bank: Bank) -> bytes:
|
|
1019
|
+
"""Serialize an IMS manifest referencing the QTI assessment document.
|
|
1020
|
+
|
|
1021
|
+
Parameters
|
|
1022
|
+
----------
|
|
1023
|
+
bank
|
|
1024
|
+
Validated bank supplying the bank ID for manifest/resource IDs.
|
|
1025
|
+
|
|
1026
|
+
Returns
|
|
1027
|
+
-------
|
|
1028
|
+
payload
|
|
1029
|
+
UTF-8 XML bytes with a declaration and one imsqti_xmlv1p2 resource
|
|
1030
|
+
referencing questions.xml.
|
|
1031
|
+
|
|
1032
|
+
Notes
|
|
1033
|
+
-----
|
|
1034
|
+
Does not write files or modify the bank. Registers IMS as
|
|
1035
|
+
ElementTree's default namespace before serialization.
|
|
1036
|
+
|
|
1037
|
+
"""
|
|
1038
|
+
ET.register_namespace("", IMS)
|
|
1039
|
+
root = ET.Element(
|
|
1040
|
+
f"{{{IMS}}}manifest", identifier=bank["bank_id"] + "_manifest"
|
|
1041
|
+
)
|
|
1042
|
+
ET.SubElement(root, f"{{{IMS}}}metadata")
|
|
1043
|
+
resources = ET.SubElement(root, f"{{{IMS}}}resources")
|
|
1044
|
+
res = ET.SubElement(
|
|
1045
|
+
resources,
|
|
1046
|
+
f"{{{IMS}}}resource",
|
|
1047
|
+
identifier=bank["bank_id"] + "_assessment",
|
|
1048
|
+
type="imsqti_xmlv1p2",
|
|
1049
|
+
href="questions.xml",
|
|
1050
|
+
)
|
|
1051
|
+
ET.SubElement(res, f"{{{IMS}}}file", href="questions.xml")
|
|
1052
|
+
# ElementTree stubs return Any here; explicit UTF-8 guarantees bytes.
|
|
1053
|
+
return cast(
|
|
1054
|
+
bytes, ET.tostring(root, encoding="utf-8", xml_declaration=True)
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def _zip_write(zf: ZipFile, name: str, payload: bytes) -> None:
|
|
1059
|
+
"""Write a ZIP member with fixed timestamp and permission metadata.
|
|
1060
|
+
|
|
1061
|
+
Parameters
|
|
1062
|
+
----------
|
|
1063
|
+
zf
|
|
1064
|
+
Open ZIP archive accepting writes.
|
|
1065
|
+
name
|
|
1066
|
+
Member name to store inside the archive.
|
|
1067
|
+
payload
|
|
1068
|
+
Bytes to store using DEFLATE compression.
|
|
1069
|
+
|
|
1070
|
+
Returns
|
|
1071
|
+
-------
|
|
1072
|
+
None
|
|
1073
|
+
Return after writing the member.
|
|
1074
|
+
|
|
1075
|
+
Raises
|
|
1076
|
+
------
|
|
1077
|
+
OSError
|
|
1078
|
+
If writing to the underlying archive fails.
|
|
1079
|
+
ValueError
|
|
1080
|
+
If the archive is closed or not open for writing.
|
|
1081
|
+
|
|
1082
|
+
Notes
|
|
1083
|
+
-----
|
|
1084
|
+
Uses a 1980-01-01 timestamp and permission bits 0644 to avoid
|
|
1085
|
+
source-file metadata affecting reproducibility. The archive remains open.
|
|
1086
|
+
|
|
1087
|
+
""" # noqa: DOC502
|
|
1088
|
+
info = ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
1089
|
+
info.compress_type = ZIP_DEFLATED
|
|
1090
|
+
info.external_attr = 0o644 << 16
|
|
1091
|
+
zf.writestr(info, payload)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def create_qti(
|
|
1095
|
+
bank: Bank,
|
|
1096
|
+
dest: str | Path,
|
|
1097
|
+
questions: Sequence[Question] | None = None,
|
|
1098
|
+
) -> Path:
|
|
1099
|
+
"""Write a QTI ZIP with reproducible member metadata.
|
|
1100
|
+
|
|
1101
|
+
Parameters
|
|
1102
|
+
----------
|
|
1103
|
+
bank
|
|
1104
|
+
Validated bank supplying assessment identity and default questions.
|
|
1105
|
+
dest
|
|
1106
|
+
Destination ZIP path. Parent directories are created as needed,
|
|
1107
|
+
and existing output is overwritten.
|
|
1108
|
+
questions
|
|
1109
|
+
Optional ordered sequence replacing the bank's questions. None uses
|
|
1110
|
+
all bank questions; an empty sequence is rejected.
|
|
1111
|
+
|
|
1112
|
+
Returns
|
|
1113
|
+
-------
|
|
1114
|
+
path
|
|
1115
|
+
Destination as a Path, without resolving it to an absolute path.
|
|
1116
|
+
|
|
1117
|
+
Raises
|
|
1118
|
+
------
|
|
1119
|
+
ValueError
|
|
1120
|
+
If the selected question sequence is empty.
|
|
1121
|
+
OSError
|
|
1122
|
+
If directory creation or archive writing fails.
|
|
1123
|
+
|
|
1124
|
+
Notes
|
|
1125
|
+
-----
|
|
1126
|
+
Writes imsmanifest.xml and questions.xml with fixed timestamps and
|
|
1127
|
+
permissions. Inputs must already be validated; no schema validation or
|
|
1128
|
+
post-write verification occurs here. The input bank is unchanged.
|
|
1129
|
+
|
|
1130
|
+
""" # noqa: DOC503
|
|
1131
|
+
questions = bank["questions"] if questions is None else questions
|
|
1132
|
+
if not questions:
|
|
1133
|
+
raise ValueError("Cannot create a package without questions")
|
|
1134
|
+
dest = Path(dest)
|
|
1135
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
1136
|
+
with ZipFile(dest, "w") as archive:
|
|
1137
|
+
_zip_write(archive, "imsmanifest.xml", manifest_xml(bank))
|
|
1138
|
+
_zip_write(archive, "questions.xml", qti_xml(bank, questions))
|
|
1139
|
+
return dest
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def verify_qti(path: str | Path) -> QTIReport:
|
|
1143
|
+
"""Check supported archive structure, identifiers, and scoring shape.
|
|
1144
|
+
|
|
1145
|
+
Parameters
|
|
1146
|
+
----------
|
|
1147
|
+
path
|
|
1148
|
+
QTI ZIP archive to read without extracting files.
|
|
1149
|
+
|
|
1150
|
+
Returns
|
|
1151
|
+
-------
|
|
1152
|
+
report
|
|
1153
|
+
Question count, identifiers in document order, and the verified
|
|
1154
|
+
assessment member path from the manifest.
|
|
1155
|
+
|
|
1156
|
+
Raises
|
|
1157
|
+
------
|
|
1158
|
+
ValueError
|
|
1159
|
+
If archive resource limits or member-name uniqueness checks fail,
|
|
1160
|
+
XML is unsafe or malformed, the manifest/resource reference is
|
|
1161
|
+
invalid, the QTI root is incorrect, or item checks fail.
|
|
1162
|
+
OSError
|
|
1163
|
+
If the archive cannot be read.
|
|
1164
|
+
zipfile.BadZipFile
|
|
1165
|
+
If the input is not a readable ZIP archive.
|
|
1166
|
+
|
|
1167
|
+
Notes
|
|
1168
|
+
-----
|
|
1169
|
+
Applies the shared QTI archive limits before reading XML. Requires unique
|
|
1170
|
+
nonempty item IDs, at least two response labels, one varequal referencing
|
|
1171
|
+
a choice ID, two setvar elements per item, and any supplied render_choice
|
|
1172
|
+
shuffle value to be Yes or No. Checks the first manifest resource and
|
|
1173
|
+
rejects absent, absolute, or parent-relative member paths. XML parsing
|
|
1174
|
+
forbids DTDs and entities. This is not complete QTI validation: score
|
|
1175
|
+
values, supported presentation content, bank schema constraints, and
|
|
1176
|
+
Canvas rendering require other checks.
|
|
1177
|
+
|
|
1178
|
+
""" # noqa: DOC503
|
|
1179
|
+
with ZipFile(path) as zf:
|
|
1180
|
+
members = validate_archive(zf)
|
|
1181
|
+
manifest = members.get("imsmanifest.xml")
|
|
1182
|
+
if manifest is None:
|
|
1183
|
+
raise ValueError("QTI package missing root imsmanifest.xml")
|
|
1184
|
+
meta = parse_xml(read_member(zf, manifest))
|
|
1185
|
+
resource = meta.find(f".//{{{IMS}}}resource")
|
|
1186
|
+
if resource is None or resource.get("type") != "imsqti_xmlv1p2":
|
|
1187
|
+
raise ValueError("Missing/incorrect QTI 1.2 assessment resource")
|
|
1188
|
+
href = resource.get("href")
|
|
1189
|
+
if (
|
|
1190
|
+
not href
|
|
1191
|
+
or href not in members
|
|
1192
|
+
or href.startswith("/")
|
|
1193
|
+
or ".." in Path(href).parts
|
|
1194
|
+
):
|
|
1195
|
+
raise ValueError("Manifest QTI href missing or invalid")
|
|
1196
|
+
root = parse_xml(read_member(zf, members[href]))
|
|
1197
|
+
if root.tag != f"{{{QTI}}}questestinterop":
|
|
1198
|
+
raise ValueError("Incorrect QTI 1.2 namespace or root")
|
|
1199
|
+
items = root.findall(f".//{{{QTI}}}item")
|
|
1200
|
+
if not items:
|
|
1201
|
+
raise ValueError("No questions found")
|
|
1202
|
+
ids: set[str] = set()
|
|
1203
|
+
ordered_ids: list[str] = []
|
|
1204
|
+
for item in items:
|
|
1205
|
+
iid = item.get("ident")
|
|
1206
|
+
if not iid:
|
|
1207
|
+
raise ValueError("QTI item missing identifier")
|
|
1208
|
+
if iid in ids:
|
|
1209
|
+
raise ValueError(f"Duplicate QTI identifier {iid}")
|
|
1210
|
+
ids.add(iid)
|
|
1211
|
+
ordered_ids.append(iid)
|
|
1212
|
+
renders = item.findall(f".//{{{QTI}}}render_choice")
|
|
1213
|
+
if any(
|
|
1214
|
+
render.get("shuffle", "No") not in {"Yes", "No"}
|
|
1215
|
+
for render in renders
|
|
1216
|
+
):
|
|
1217
|
+
raise ValueError(f"Unsupported shuffle value for {iid}")
|
|
1218
|
+
lids = item.findall(f".//{{{QTI}}}response_label")
|
|
1219
|
+
if len(lids) < MIN_CHOICES:
|
|
1220
|
+
raise ValueError(f"Incomplete response choices for {iid}")
|
|
1221
|
+
val = item.findall(
|
|
1222
|
+
f".//{{{QTI}}}respcondition/{{{QTI}}}conditionvar/{{{QTI}}}varequal"
|
|
1223
|
+
)
|
|
1224
|
+
if len(val) != 1 or val[0].text not in {x.get("ident") for x in lids}:
|
|
1225
|
+
raise ValueError(f"Missing or invalid correct-answer key for {iid}")
|
|
1226
|
+
if (
|
|
1227
|
+
len(item.findall(f".//{{{QTI}}}respcondition/{{{QTI}}}setvar"))
|
|
1228
|
+
!= MIN_CHOICES
|
|
1229
|
+
):
|
|
1230
|
+
raise ValueError(f"Unexpected scoring rules for {iid}")
|
|
1231
|
+
return {
|
|
1232
|
+
"count": len(items),
|
|
1233
|
+
"ids": ordered_ids,
|
|
1234
|
+
"assessment_file": href,
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def instructor_markdown(bank: Bank) -> str:
|
|
1239
|
+
"""Render instructor review text including answers and explanations.
|
|
1240
|
+
|
|
1241
|
+
Parameters
|
|
1242
|
+
----------
|
|
1243
|
+
bank
|
|
1244
|
+
Validated bank supplying identity, question content, and metadata.
|
|
1245
|
+
|
|
1246
|
+
Returns
|
|
1247
|
+
-------
|
|
1248
|
+
markdown
|
|
1249
|
+
Markdown string with an instructor-only notice, ordered questions
|
|
1250
|
+
and choices, metadata summaries, correct answers, and explanations.
|
|
1251
|
+
Unrestricted assessment eligibility is displayed as Any.
|
|
1252
|
+
|
|
1253
|
+
Notes
|
|
1254
|
+
-----
|
|
1255
|
+
Authored text is inserted without escaping or HTML conversion. The
|
|
1256
|
+
result contains answer keys. No files are written and input is unchanged.
|
|
1257
|
+
|
|
1258
|
+
"""
|
|
1259
|
+
out = [
|
|
1260
|
+
f"# {bank['course']} — {bank['title']}",
|
|
1261
|
+
"",
|
|
1262
|
+
"**INSTRUCTOR ONLY: contains answer keys.**",
|
|
1263
|
+
"",
|
|
1264
|
+
]
|
|
1265
|
+
for q in bank["questions"]:
|
|
1266
|
+
out.extend([
|
|
1267
|
+
f"## {q['id']}: {q['title']}",
|
|
1268
|
+
"",
|
|
1269
|
+
(
|
|
1270
|
+
f"Area: {q['area']} | Topic: {q['topic']} | Difficulty: "
|
|
1271
|
+
f"{q['difficulty']} | Assessments: "
|
|
1272
|
+
f"{', '.join(q.get('assessments', [])) or 'Any'} | Time: "
|
|
1273
|
+
f"{q['minutes']} min"
|
|
1274
|
+
),
|
|
1275
|
+
"",
|
|
1276
|
+
q["stem"],
|
|
1277
|
+
"",
|
|
1278
|
+
])
|
|
1279
|
+
for k, v in q["choices"].items():
|
|
1280
|
+
out.append(f"- **{k}.** {v}")
|
|
1281
|
+
out.extend(["", f"**Answer: {q['answer']}.** {q['explanation']}", ""])
|
|
1282
|
+
return "\n".join(out) + "\n"
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def question_catalog_csv(bank: Bank) -> str:
|
|
1286
|
+
"""Render an ordered CSV catalog including instructor answers.
|
|
1287
|
+
|
|
1288
|
+
Parameters
|
|
1289
|
+
----------
|
|
1290
|
+
bank
|
|
1291
|
+
Validated bank containing the questions to list.
|
|
1292
|
+
|
|
1293
|
+
Returns
|
|
1294
|
+
-------
|
|
1295
|
+
catalog
|
|
1296
|
+
CSV text with a header and one row per question in bank order.
|
|
1297
|
+
Columns are id, title, area, topic, difficulty, assessments, minutes,
|
|
1298
|
+
points, answer, stem, and explanation.
|
|
1299
|
+
|
|
1300
|
+
Notes
|
|
1301
|
+
-----
|
|
1302
|
+
Assessment lists are encoded as JSON arrays. Choices, rendering format,
|
|
1303
|
+
and additional authoring metadata are omitted. CSV quoting is handled by
|
|
1304
|
+
csv.DictWriter. No files are written and the input is unchanged.
|
|
1305
|
+
|
|
1306
|
+
"""
|
|
1307
|
+
fields = [
|
|
1308
|
+
"id",
|
|
1309
|
+
"title",
|
|
1310
|
+
"area",
|
|
1311
|
+
"topic",
|
|
1312
|
+
"difficulty",
|
|
1313
|
+
"assessments",
|
|
1314
|
+
"minutes",
|
|
1315
|
+
"points",
|
|
1316
|
+
"answer",
|
|
1317
|
+
"stem",
|
|
1318
|
+
"explanation",
|
|
1319
|
+
]
|
|
1320
|
+
stream = io.StringIO(newline="")
|
|
1321
|
+
writer = csv.DictWriter(stream, fieldnames=fields)
|
|
1322
|
+
writer.writeheader()
|
|
1323
|
+
for q in bank["questions"]:
|
|
1324
|
+
row = {f: q.get(f, "") for f in fields}
|
|
1325
|
+
row["assessments"] = json.dumps(q.get("assessments", []))
|
|
1326
|
+
writer.writerow(row)
|
|
1327
|
+
return stream.getvalue()
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def build(bank: object, output_dir: str | Path) -> tuple[Path, BuildReport]:
|
|
1331
|
+
"""Validate a bank and write its QTI archive and instructor artifacts.
|
|
1332
|
+
|
|
1333
|
+
Parameters
|
|
1334
|
+
----------
|
|
1335
|
+
bank
|
|
1336
|
+
Candidate Python bank data, normalized and validated before writing.
|
|
1337
|
+
output_dir
|
|
1338
|
+
Destination directory, created as needed. Matching output filenames
|
|
1339
|
+
are overwritten.
|
|
1340
|
+
|
|
1341
|
+
Returns
|
|
1342
|
+
-------
|
|
1343
|
+
archive
|
|
1344
|
+
Path to the generated {course}_{bank_id}_QTI12.zip archive.
|
|
1345
|
+
report
|
|
1346
|
+
Structural verification results, bank identity, difficulty counts,
|
|
1347
|
+
and the SHA-256 digest of the generated archive bytes.
|
|
1348
|
+
|
|
1349
|
+
Raises
|
|
1350
|
+
------
|
|
1351
|
+
ValueError
|
|
1352
|
+
If bank validation or generated-archive verification fails.
|
|
1353
|
+
OSError
|
|
1354
|
+
If creating directories, writing artifacts, or reading output fails.
|
|
1355
|
+
UnicodeError
|
|
1356
|
+
If text artifacts cannot be encoded as UTF-8.
|
|
1357
|
+
csv.Error
|
|
1358
|
+
If rendering the question catalog fails.
|
|
1359
|
+
zipfile.BadZipFile
|
|
1360
|
+
If the generated archive cannot be read during verification.
|
|
1361
|
+
|
|
1362
|
+
Notes
|
|
1363
|
+
-----
|
|
1364
|
+
Bank validation completes before output is created. Writes the QTI ZIP,
|
|
1365
|
+
instructor_review.md, question_catalog.csv, and validation.json.
|
|
1366
|
+
The bank is unchanged. Later I/O or verification failures may leave
|
|
1367
|
+
partial output; generated files are not rolled back.
|
|
1368
|
+
|
|
1369
|
+
""" # noqa: DOC502
|
|
1370
|
+
bank = require_valid_bank(bank)
|
|
1371
|
+
output_dir = Path(output_dir)
|
|
1372
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
1373
|
+
name = f"{bank['course']}_{bank['bank_id']}_QTI12.zip"
|
|
1374
|
+
path = create_qti(bank, output_dir / name)
|
|
1375
|
+
checked = verify_qti(path)
|
|
1376
|
+
(output_dir / "instructor_review.md").write_text(
|
|
1377
|
+
instructor_markdown(bank), encoding="utf-8"
|
|
1378
|
+
)
|
|
1379
|
+
(output_dir / "question_catalog.csv").write_text(
|
|
1380
|
+
question_catalog_csv(bank), encoding="utf-8"
|
|
1381
|
+
)
|
|
1382
|
+
report: BuildReport = {
|
|
1383
|
+
**checked,
|
|
1384
|
+
"course": bank["course"],
|
|
1385
|
+
"bank_id": bank["bank_id"],
|
|
1386
|
+
"difficulty": dict(Counter(q["difficulty"] for q in bank["questions"])),
|
|
1387
|
+
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
1388
|
+
}
|
|
1389
|
+
(output_dir / "validation.json").write_text(
|
|
1390
|
+
json.dumps(report, indent=2) + "\n", encoding="utf-8"
|
|
1391
|
+
)
|
|
1392
|
+
return path, report
|