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/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