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/migrate.py ADDED
@@ -0,0 +1,645 @@
1
+ """Strict migration of simple QTI 1.2 single-answer MC questions to YAML.
2
+
3
+ Unsupported QTI item structures are rejected rather than silently discarded.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import csv
9
+ import json
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+ from zipfile import ZipFile
14
+
15
+ import yaml
16
+
17
+ from .archive_io import read_member, validate_archive
18
+ from .core import MIN_CHOICES, QTI, parse_bank, verify_qti
19
+ from .models import Bank
20
+ from .xml_io import parse_xml
21
+ from .yaml_data import YamlMapping, YamlValue, yaml_value
22
+
23
+ if TYPE_CHECKING:
24
+ from xml.etree.ElementTree import Element
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class _Presentation:
29
+ """Hold extracted question text before bank schema validation.
30
+
31
+ Attributes
32
+ ----------
33
+ stem
34
+ Text from the stem's mattext element, or an empty string when the
35
+ element has no text. Nonempty-content validation occurs later.
36
+ text_format
37
+ QTI MIME type: ``text/plain`` or ``text/html`` when constructed
38
+ by the presentation parser.
39
+
40
+ Notes
41
+ -----
42
+ Instances are frozen. Construction alone does not validate field values.
43
+
44
+ """
45
+
46
+ stem: str
47
+ text_format: str
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class _Choices:
52
+ """Hold ordered choices and translations of their source identifiers.
53
+
54
+ Attributes
55
+ ----------
56
+ values
57
+ Mapping from generated labels A through Z to extracted choice text,
58
+ in source order. Values still require bank schema validation.
59
+ labels
60
+ Mapping from original QTI choice identifiers to generated labels,
61
+ used to translate the correct answer.
62
+ shuffle_choices
63
+ Whether the QTI render_choice element requests choice shuffling.
64
+
65
+ Notes
66
+ -----
67
+ Attribute assignment is frozen, but the contained dictionaries remain
68
+ mutable. Construction alone does not validate their contents.
69
+
70
+ """
71
+
72
+ values: YamlMapping
73
+ labels: dict[str, str]
74
+ shuffle_choices: bool
75
+
76
+
77
+ def import_qti(
78
+ path: str | Path,
79
+ course: str,
80
+ bank_id: str,
81
+ title: str,
82
+ metadata_csv: str | Path | None = None,
83
+ ) -> Bank:
84
+ """Import supported single-answer QTI 1.2 items as a validated bank.
85
+
86
+ Parameters
87
+ ----------
88
+ path
89
+ Input ZIP containing imsmanifest.xml and its referenced QTI document.
90
+ course
91
+ Course identifier for the resulting bank.
92
+ bank_id
93
+ Bank identifier for the resulting bank; must satisfy the YAML schema.
94
+ title
95
+ Display title for the resulting bank.
96
+ metadata_csv
97
+ Optional UTF-8 CSV keyed by the case-sensitive ID column. Matching
98
+ rows provide authoring overrides. None means no CSV overrides.
99
+
100
+ Returns
101
+ -------
102
+ bank
103
+ Validated schema-version-1 bank with questions in document order.
104
+ Choices are relabeled A through Z in source order, the QTI shuffle
105
+ setting is retained per question, and legacy exam eligibility is
106
+ normalized to assessments.
107
+
108
+ Raises
109
+ ------
110
+ ValueError
111
+ If archive resource limits or member-name uniqueness checks fail,
112
+ XML is unsafe or malformed, supported QTI structural checks fail,
113
+ metadata cannot be interpreted, or the resulting bank is invalid.
114
+ Final schema errors are prefixed with "Imported data needs attention".
115
+ OSError
116
+ If the archive or metadata CSV cannot be read.
117
+ UnicodeError
118
+ If the metadata CSV cannot be decoded as UTF-8.
119
+ csv.Error
120
+ If metadata CSV parsing fails.
121
+ zipfile.BadZipFile
122
+ If the input is not a readable ZIP archive.
123
+
124
+ Notes
125
+ -----
126
+ No files are written. Missing metadata uses field-specific defaults:
127
+ Unclassified for area/topic, Moderate for difficulty, unrestricted
128
+ eligibility, 2.5 minutes, 2 points, and unshuffled choices when QTI omits
129
+ the shuffle attribute. CSV overrides and QTI metadata are resolved before
130
+ schema validation; missing explanations are rejected.
131
+
132
+ """ # noqa: DOC503
133
+ report = verify_qti(path)
134
+ extras = _read_metadata_csv(metadata_csv)
135
+ with ZipFile(path) as archive:
136
+ members = validate_archive(archive)
137
+ assessment = members.get(report["assessment_file"])
138
+ if assessment is None:
139
+ raise ValueError(
140
+ "Verified QTI assessment member is no longer present"
141
+ )
142
+ root = parse_xml(read_member(archive, assessment))
143
+ questions: list[YamlValue] = [
144
+ _import_item(item, extras) for item in root.findall(f".//{{{QTI}}}item")
145
+ ]
146
+ bank: YamlMapping = {
147
+ "schema_version": 1,
148
+ "course": course,
149
+ "bank_id": bank_id,
150
+ "title": title,
151
+ "questions": questions,
152
+ }
153
+ try:
154
+ return parse_bank(bank)
155
+ except ValueError as exc:
156
+ raise ValueError(f"Imported data needs attention:\n{exc}") from exc
157
+
158
+
159
+ def _read_metadata_csv(
160
+ metadata_csv: str | Path | None,
161
+ ) -> dict[str, dict[str, str]]:
162
+ """Read authoring overrides keyed by the CSV ID column.
163
+
164
+ Parameters
165
+ ----------
166
+ metadata_csv
167
+ UTF-8 CSV path, optionally with a byte-order mark. None or an empty
168
+ path string returns an empty mapping without accessing a file.
169
+
170
+ Returns
171
+ -------
172
+ extras
173
+ Mapping from question ID to string-valued cells, retaining the
174
+ original column names and text. Rows without a nonempty ID are
175
+ skipped; later rows replace earlier rows with the same ID.
176
+
177
+ Raises
178
+ ------
179
+ OSError
180
+ If the CSV cannot be opened or read.
181
+ UnicodeError
182
+ If its content cannot be decoded as UTF-8.
183
+ csv.Error
184
+ If CSV parsing fails.
185
+ ValueError
186
+ If the CSV lacks the required case-sensitive ID column or a row has
187
+ more cells than the header.
188
+
189
+ Notes
190
+ -----
191
+ Column names and IDs are case-sensitive and are not stripped.
192
+ Schema validation and metadata interpretation occur later.
193
+
194
+ """ # noqa: DOC503
195
+ extras: dict[str, dict[str, str]] = {}
196
+ if metadata_csv:
197
+ with open(metadata_csv, newline="", encoding="utf-8-sig") as inp:
198
+ reader = csv.DictReader(inp, strict=True)
199
+ if reader.fieldnames is None or "ID" not in reader.fieldnames:
200
+ raise ValueError(
201
+ "Metadata CSV must contain a case-sensitive ID column"
202
+ )
203
+ for row in reader:
204
+ if None in row:
205
+ raise ValueError(
206
+ f"Metadata CSV row {reader.line_num} has more cells "
207
+ "than its header"
208
+ )
209
+ if row.get("ID"):
210
+ extras[row["ID"]] = {
211
+ key: value
212
+ for key, value in row.items()
213
+ if isinstance(key, str) and isinstance(value, str)
214
+ }
215
+ return extras
216
+
217
+
218
+ def _read_item_metadata(item: Element) -> dict[str, str]:
219
+ """Read metadata fields beneath one QTI item.
220
+
221
+ Parameters
222
+ ----------
223
+ item
224
+ Parsed QTI item element to inspect without modification.
225
+
226
+ Returns
227
+ -------
228
+ metadata
229
+ Field labels mapped to entry text. Later fields replace earlier
230
+ values for the same label. Fields lacking a label or entry element
231
+ are skipped; present but empty elements retain empty strings.
232
+
233
+ """
234
+ metadata: dict[str, str] = {}
235
+ for f in item.findall(f".//{{{QTI}}}qtimetadatafield"):
236
+ key = f.findtext(f"{{{QTI}}}fieldlabel")
237
+ value = f.findtext(f"{{{QTI}}}fieldentry")
238
+ if key is not None and value is not None:
239
+ metadata[key] = value
240
+ return metadata
241
+
242
+
243
+ def _parse_presentation(item: Element, ident: str) -> _Presentation:
244
+ """Extract a single-answer presentation with a plain-text or HTML stem.
245
+
246
+ Parameters
247
+ ----------
248
+ item
249
+ Parsed QTI item containing the presentation to inspect.
250
+ ident
251
+ Question identifier used in error messages.
252
+
253
+ Returns
254
+ -------
255
+ presentation
256
+ Extracted stem text and its QTI MIME type. Missing text becomes an
257
+ empty string; a missing texttype attribute defaults to text/plain.
258
+
259
+ Raises
260
+ ------
261
+ ValueError
262
+ If there is not exactly one presentation response_lid with Single
263
+ cardinality, the stem mattext element is absent, or its texttype
264
+ is neither text/plain nor text/html.
265
+
266
+ Notes
267
+ -----
268
+ This checks supported presentation structure, not nonempty content or
269
+ HTML rendering. The XML item is not modified.
270
+
271
+ """
272
+ response_lids = item.findall(
273
+ f"./{{{QTI}}}presentation/{{{QTI}}}response_lid"
274
+ )
275
+ if (
276
+ len(response_lids) != 1
277
+ or response_lids[0].get("rcardinality") != "Single"
278
+ ):
279
+ raise ValueError(f"Unsupported response type in {ident}")
280
+ stemnode = item.find(
281
+ f"./{{{QTI}}}presentation/{{{QTI}}}material/{{{QTI}}}mattext"
282
+ )
283
+ if stemnode is None:
284
+ raise ValueError(f"Missing question stem in {ident}")
285
+ fmt = stemnode.get("texttype", "text/plain")
286
+ if fmt not in {"text/plain", "text/html"}:
287
+ raise ValueError(f"Unsupported mattext format {fmt} in {ident}")
288
+ return _Presentation(stemnode.text or "", fmt)
289
+
290
+
291
+ def _parse_choices(item: Element, ident: str, fmt: str) -> _Choices:
292
+ """Relabel choices in source order and retain identifier translations.
293
+
294
+ Parameters
295
+ ----------
296
+ item
297
+ Parsed QTI item containing presentation response labels.
298
+ ident
299
+ Question identifier used in error messages.
300
+ fmt
301
+ Expected QTI MIME type taken from the parsed stem. Each choice must
302
+ use the same type; omitted choice texttype defaults to text/plain.
303
+
304
+ Returns
305
+ -------
306
+ choices
307
+ Extracted choice text keyed by A through Z, a mapping from original
308
+ identifiers to those labels, and the effective QTI shuffle setting.
309
+ Missing text becomes an empty string, to be checked by subsequent
310
+ bank validation.
311
+
312
+ Raises
313
+ ------
314
+ ValueError
315
+ If render_choice has an invalid shuffle value, there are fewer than
316
+ two or more than 26 choices, a choice lacks a mattext element or
317
+ identifier, or its text format differs from fmt.
318
+
319
+ Notes
320
+ -----
321
+ The source item is unchanged. Duplicate source identifiers are not
322
+ rejected here; later occurrences replace earlier translation entries.
323
+ Empty or duplicate choice text is checked by bank schema validation.
324
+
325
+ """
326
+ render = item.find(
327
+ f"./{{{QTI}}}presentation/{{{QTI}}}response_lid/{{{QTI}}}render_choice"
328
+ )
329
+ if render is None:
330
+ raise ValueError(f"Unsupported number of choices in {ident}")
331
+ shuffle = render.get("shuffle", "No")
332
+ if shuffle not in {"Yes", "No"}:
333
+ raise ValueError(f"Unsupported shuffle value in {ident}: {shuffle}")
334
+ labels = render.findall(f"{{{QTI}}}response_label")
335
+ keys = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
336
+ if len(labels) < MIN_CHOICES or len(labels) > len(keys):
337
+ raise ValueError(f"Unsupported number of choices in {ident}")
338
+ choices: YamlMapping = {}
339
+ mapping: dict[str, str] = {}
340
+ for n, lab in enumerate(labels):
341
+ mat = lab.find(f"./{{{QTI}}}material/{{{QTI}}}mattext")
342
+ if mat is None:
343
+ raise ValueError(f"Missing answer text in {ident}")
344
+ if mat.get("texttype", "text/plain") != fmt:
345
+ raise ValueError(f"Inconsistent response text formats in {ident}")
346
+ choices[keys[n]] = mat.text or ""
347
+ label_id = lab.get("ident")
348
+ if not label_id:
349
+ raise ValueError(f"Missing choice identifier in {ident}")
350
+ mapping[label_id] = keys[n]
351
+ return _Choices(choices, mapping, shuffle == "Yes")
352
+
353
+
354
+ def _parse_correct_answer(
355
+ item: Element, ident: str, mapping: dict[str, str]
356
+ ) -> str:
357
+ """Translate the choice ID from one supported full-credit condition.
358
+
359
+ Parameters
360
+ ----------
361
+ item
362
+ Parsed QTI item containing response-processing conditions.
363
+ ident
364
+ Question identifier used in error messages.
365
+ mapping
366
+ Original choice identifiers mapped to generated YAML labels.
367
+
368
+ Returns
369
+ -------
370
+ answer
371
+ Generated label for the unique matching correct-answer condition.
372
+
373
+ Raises
374
+ ------
375
+ ValueError
376
+ If there is not exactly one condition with a varequal text value and
377
+ a setvar text value of 100, or its choice ID is absent from mapping.
378
+
379
+ Notes
380
+ -----
381
+ Score text is stripped before comparison with the literal "100".
382
+ Other score representations, such as "100.0", do not match. This helper
383
+ extracts the supported scoring pattern; it is not a general QTI scorer.
384
+
385
+ """
386
+ conditions = item.findall(
387
+ f"./{{{QTI}}}resprocessing/{{{QTI}}}respcondition"
388
+ )
389
+ matches: list[str] = []
390
+ for condition in conditions:
391
+ eq = condition.find(f"./{{{QTI}}}conditionvar/{{{QTI}}}varequal")
392
+ score = condition.find(f"./{{{QTI}}}setvar")
393
+ if (
394
+ eq is not None
395
+ and eq.text is not None
396
+ and score is not None
397
+ and (score.text or "").strip() == "100"
398
+ ):
399
+ matches.append(eq.text)
400
+ if len(matches) != 1 or matches[0] not in mapping:
401
+ raise ValueError(f"Cannot determine unique answer for {ident}")
402
+ return mapping[matches[0]]
403
+
404
+
405
+ def _parse_explanation(item: Element, ident: str) -> str:
406
+ """Extract required feedback without silently losing an explanation.
407
+
408
+ Parameters
409
+ ----------
410
+ item
411
+ Parsed QTI item containing itemfeedback/flow_mat/material/mattext.
412
+ ident
413
+ Question identifier used in error messages.
414
+
415
+ Returns
416
+ -------
417
+ explanation
418
+ Text from the first matching feedback element, or an empty string
419
+ if the element has no text. Bank validation checks for nonempty text.
420
+
421
+ Raises
422
+ ------
423
+ ValueError
424
+ If no matching feedback mattext element exists.
425
+
426
+ """
427
+ feedback = item.find(
428
+ f"./{{{QTI}}}itemfeedback/{{{QTI}}}flow_mat/{{{QTI}}}"
429
+ f"material/{{{QTI}}}mattext"
430
+ )
431
+ if feedback is None:
432
+ raise ValueError(
433
+ f"Missing explanation in {ident}; refusing lossy import"
434
+ )
435
+ return feedback.text or ""
436
+
437
+
438
+ def _resolve_eligibility(
439
+ e: dict[str, str], metadata: dict[str, str]
440
+ ) -> YamlMapping:
441
+ """Resolve assessment eligibility using CSV overrides before QTI fields.
442
+
443
+ Parameters
444
+ ----------
445
+ e
446
+ String-valued CSV cells for this question, or an empty mapping.
447
+ metadata
448
+ QTI field labels mapped to their entry text.
449
+
450
+ Returns
451
+ -------
452
+ eligibility
453
+ YAML mapping containing either assessments decoded from JSON or a
454
+ legacy exam string. Values remain unvalidated until bank parsing,
455
+ which also normalizes legacy eligibility.
456
+
457
+ Raises
458
+ ------
459
+ ValueError
460
+ If selected JSON is invalid or cannot be represented as supported
461
+ YAML data. Malformed JSON raises the JSONDecodeError subclass.
462
+
463
+ Notes
464
+ -----
465
+ Precedence is nonempty CSV Assessments, nonempty CSV Suggested exam,
466
+ QTI assessments when present, then QTI suggested_exam or Either.
467
+ Empty CSV strings supply no override. A JSON empty list explicitly
468
+ requests unrestricted eligibility. Neither input mapping is modified.
469
+
470
+ """ # noqa: DOC502
471
+ if e.get("Assessments"):
472
+ return {"assessments": yaml_value(json.loads(e["Assessments"]))}
473
+ elif e.get("Suggested exam"):
474
+ return {"exam": e["Suggested exam"]}
475
+ elif "assessments" in metadata:
476
+ return {"assessments": yaml_value(json.loads(metadata["assessments"]))}
477
+ else:
478
+ return {"exam": metadata.get("suggested_exam") or "Either"}
479
+
480
+
481
+ def _resolve_authoring_metadata(
482
+ item: Element, ident: str, e: dict[str, str], metadata: dict[str, str]
483
+ ) -> YamlMapping:
484
+ """Resolve authoring values, eligibility, and field-specific defaults.
485
+
486
+ Parameters
487
+ ----------
488
+ item
489
+ QTI item supplying its title attribute.
490
+ ident
491
+ Question identifier used as the fallback title and to remove a
492
+ leading identifier followed by a space from an existing title.
493
+ e
494
+ String-valued CSV cells for this question.
495
+ metadata
496
+ QTI metadata field labels and entry text.
497
+
498
+ Returns
499
+ -------
500
+ fields
501
+ Unvalidated YAML mapping with title, area, topic, difficulty,
502
+ eligibility, minutes, and points. Integral point values become
503
+ integers; other point values remain floating-point numbers.
504
+
505
+ Raises
506
+ ------
507
+ ValueError
508
+ If numeric text cannot be converted or eligibility decoding fails.
509
+
510
+ Notes
511
+ -----
512
+ Area uses CSV Area, QTI area, QTI topic, then Unclassified. Topic uses
513
+ CSV Topic, QTI topic, then Unclassified. Difficulty uses CSV Difficulty,
514
+ QTI difficulty, then Moderate; Easy becomes Basic and High becomes
515
+ Challenging. Minutes use CSV Suggested minutes or 2.5; points use QTI
516
+ points_possible or 2. Empty strings trigger these fallbacks.
517
+ Eligibility follows _resolve_eligibility. Neither input mapping nor the
518
+ item is modified. Schema constraints, including positivity and finiteness,
519
+ are checked later.
520
+
521
+ """ # noqa: DOC502
522
+ difficulty = e.get("Difficulty") or metadata.get("difficulty") or "Moderate"
523
+ difficulty = {"Easy": "Basic", "High": "Challenging"}.get(
524
+ difficulty, difficulty
525
+ )
526
+ pts = float(metadata.get("points_possible") or 2)
527
+ minutes = float(e.get("Suggested minutes") or 2.5)
528
+ raw_title = item.get("title") or ident
529
+ if raw_title.startswith(ident + " "):
530
+ raw_title = raw_title[len(ident) + 1 :]
531
+ eligibility = _resolve_eligibility(e, metadata)
532
+ return {
533
+ "title": raw_title,
534
+ "area": e.get("Area")
535
+ or metadata.get("area")
536
+ or metadata.get("topic")
537
+ or "Unclassified",
538
+ "topic": e.get("Topic") or metadata.get("topic") or "Unclassified",
539
+ "difficulty": difficulty,
540
+ **eligibility,
541
+ "minutes": minutes,
542
+ "points": int(pts) if pts.is_integer() else pts,
543
+ }
544
+
545
+
546
+ def _import_item(
547
+ item: Element, extras: dict[str, dict[str, str]]
548
+ ) -> YamlMapping:
549
+ """Extract one question before final bank schema validation.
550
+
551
+ Parameters
552
+ ----------
553
+ item
554
+ Parsed QTI item from an archive that has passed structural
555
+ verification.
556
+ extras
557
+ CSV overrides keyed by original question ID. A missing ID entry
558
+ supplies no overrides for that question.
559
+
560
+ Returns
561
+ -------
562
+ question
563
+ Unvalidated YAML mapping containing question content, relabeled
564
+ choices, effective shuffling, translated answer, feedback, and resolved
565
+ authoring fields. QTI text/plain and text/html become plain and html
566
+ respectively.
567
+
568
+ Raises
569
+ ------
570
+ ValueError
571
+ If the item lacks an identifier, a supported content or scoring
572
+ check fails, or authoring metadata cannot be interpreted.
573
+
574
+ Notes
575
+ -----
576
+ Checks run in presentation, choices, scoring, then feedback order before
577
+ authoring metadata is resolved. XML and overrides are unchanged.
578
+ The result must pass parse_bank before it is treated as a valid Question.
579
+
580
+ """
581
+ ident = item.get("ident")
582
+ if not ident:
583
+ raise ValueError("QTI item missing identifier")
584
+ metadata = _read_item_metadata(item)
585
+ presentation = _parse_presentation(item, ident)
586
+ choices = _parse_choices(item, ident, presentation.text_format)
587
+ answer = _parse_correct_answer(item, ident, choices.labels)
588
+ explanation = _parse_explanation(item, ident)
589
+ authoring = _resolve_authoring_metadata(
590
+ item, ident, extras.get(ident, {}), metadata
591
+ )
592
+ return {
593
+ "id": ident,
594
+ **authoring,
595
+ "format": "html"
596
+ if presentation.text_format == "text/html"
597
+ else "plain",
598
+ "stem": presentation.stem,
599
+ "choices": choices.values,
600
+ "shuffle_choices": choices.shuffle_choices,
601
+ "answer": answer,
602
+ "explanation": explanation,
603
+ }
604
+
605
+
606
+ def save_bank(bank: object, out: str | Path) -> Path:
607
+ """Serialize YAML without schema validation and return the output path.
608
+
609
+ Parameters
610
+ ----------
611
+ bank
612
+ Bank or draft data representable by yaml.safe_dump. Schema validation
613
+ is not performed; call parse_bank first when compliance is required.
614
+ out
615
+ Destination YAML path. Parent directories are created as needed,
616
+ and any existing file is overwritten.
617
+
618
+ Returns
619
+ -------
620
+ path
621
+ Destination as a Path, without resolving it to an absolute path.
622
+
623
+ Raises
624
+ ------
625
+ OSError
626
+ If directory creation or writing fails.
627
+ UnicodeError
628
+ If output cannot be encoded as UTF-8.
629
+ yaml.YAMLError
630
+ If data cannot be represented as safe YAML.
631
+
632
+ Notes
633
+ -----
634
+ Output preserves mapping insertion order, Unicode text, and supported
635
+ optional metadata. YAML comments and original source formatting are not
636
+ retained. Input data is not modified.
637
+
638
+ """ # noqa: DOC502
639
+ out = Path(out)
640
+ out.parent.mkdir(parents=True, exist_ok=True)
641
+ out.write_text(
642
+ yaml.safe_dump(bank, allow_unicode=True, sort_keys=False, width=110),
643
+ encoding="utf-8",
644
+ )
645
+ return out