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/models.py ADDED
@@ -0,0 +1,212 @@
1
+ """Shared dictionary contracts for validated banks and workflow results.
2
+
3
+ These types describe existing dictionaries; they do not construct or validate
4
+ values. Use ``core.load_bank`` for files or ``core.parse_bank`` for typed YAML
5
+ data. Runtime dictionaries retain additional authoring metadata.
6
+ """
7
+
8
+ from typing import NotRequired, TypedDict
9
+
10
+
11
+ class Question(TypedDict):
12
+ """Describe a validated single-answer question with ordered choices.
13
+
14
+ Attributes
15
+ ----------
16
+ format
17
+ Optional rendering mode, plain or html. Omission means plain text.
18
+ shuffle_choices
19
+ Optional override for the bank's choice-shuffling default.
20
+ id
21
+ Unique question identifier following the schema's identifier rules.
22
+ title
23
+ Nonempty short title used in instructor materials and QTI items.
24
+ area
25
+ Nonempty coverage area used to balance question selection.
26
+ topic
27
+ Nonempty topic describing the question's subject.
28
+ difficulty
29
+ Difficulty label: Basic, Moderate, or Challenging.
30
+ assessments
31
+ Unique, nonblank assessment names restricting eligibility. An empty
32
+ list permits any assessment. Names are matched exactly, including
33
+ case and whitespace.
34
+ minutes
35
+ Finite positive estimate of the time needed to answer the question.
36
+ points
37
+ Finite positive grading weight.
38
+ stem
39
+ Nonempty question text, interpreted according to format.
40
+ choices
41
+ Ordered mapping of at least two valid choice identifiers to nonempty
42
+ answer text. Choice text is unique after trimming outer whitespace.
43
+ answer
44
+ Identifier of the single correct choice, present in choices.
45
+ explanation
46
+ Nonempty instructor explanation included in QTI feedback.
47
+
48
+ Notes
49
+ -----
50
+ Instances are ordinary dictionaries, accessed by key. TypedDict supplies
51
+ static contracts, not runtime validation. Use parse_bank or load_bank to
52
+ enforce constraints, and revalidate after editing. Loading normalizes
53
+ omitted eligibility to an empty list and retains authoring extensions.
54
+
55
+ """
56
+
57
+ format: NotRequired[str]
58
+ shuffle_choices: NotRequired[bool]
59
+ id: str
60
+ title: str
61
+ area: str
62
+ topic: str
63
+ difficulty: str
64
+ assessments: list[str]
65
+ minutes: float
66
+ points: float
67
+ stem: str
68
+ choices: dict[str, str]
69
+ answer: str
70
+ explanation: str
71
+
72
+
73
+ class Bank(TypedDict):
74
+ """Describe a validated schema-version-1 question bank.
75
+
76
+ Attributes
77
+ ----------
78
+ schema_version
79
+ Schema version, currently the integer 1.
80
+ course
81
+ Nonempty course identifier used to scope question usage history.
82
+ bank_id
83
+ Stable bank identifier following the schema's identifier rules.
84
+ Together with course, it identifies a bank's usage records.
85
+ title
86
+ Nonempty display title used for the QTI assessment.
87
+ default_shuffle_choices
88
+ Optional default controlling choice shuffling during QTI export.
89
+ Omission means False.
90
+ questions
91
+ Nonempty ordered list of validated questions. IDs are unique, and
92
+ stems are unique after lowercasing and collapsing whitespace.
93
+
94
+ Notes
95
+ -----
96
+ Instances are ordinary dictionaries. Type annotations do not enforce
97
+ schema constraints; use parse_bank or load_bank and revalidate edits.
98
+ Additional authoring metadata is retained in runtime dictionaries.
99
+
100
+ """
101
+
102
+ schema_version: int
103
+ course: str
104
+ bank_id: str
105
+ title: str
106
+ default_shuffle_choices: NotRequired[bool]
107
+ questions: list[Question]
108
+
109
+
110
+ class QTIReport(TypedDict):
111
+ """Describe successful structural verification of a QTI archive.
112
+
113
+ Attributes
114
+ ----------
115
+ count
116
+ Number of question items found in the assessment document.
117
+ ids
118
+ Question identifiers in document order.
119
+ assessment_file
120
+ Verified ZIP member path referenced by the manifest's QTI resource.
121
+
122
+ Notes
123
+ -----
124
+ Returned by verify_qti after its structural and scoring checks pass.
125
+ This report does not certify full QTI conformance, Canvas acceptance,
126
+ or rendered appearance.
127
+
128
+ """
129
+
130
+ count: int
131
+ ids: list[str]
132
+ assessment_file: str
133
+
134
+
135
+ class BuildReport(QTIReport):
136
+ """Describe archive verification and provenance for a completed build.
137
+
138
+ Attributes
139
+ ----------
140
+ course
141
+ Course identifier from the validated source bank.
142
+ bank_id
143
+ Identifier of the validated source bank.
144
+ difficulty
145
+ Counts of questions for each difficulty present in the bank.
146
+ sha256
147
+ Hexadecimal SHA-256 digest of the exact generated archive bytes.
148
+
149
+ Notes
150
+ -----
151
+ The build function returns this dictionary and saves its contents to
152
+ validation.json alongside the archive and instructor artifacts.
153
+
154
+ """
155
+
156
+ course: str
157
+ bank_id: str
158
+ difficulty: dict[str, int]
159
+ sha256: str
160
+
161
+
162
+ class UsageRecord(TypedDict):
163
+ """Describe one administered question, scoped by course and bank.
164
+
165
+ Attributes
166
+ ----------
167
+ course
168
+ Course identifier associated with the administration.
169
+ bank_id
170
+ Bank identifier associated with the administration.
171
+ term
172
+ Label identifying when the question was administered.
173
+ assessment
174
+ Name of the assessment on which the question was administered.
175
+ id
176
+ Identifier of the administered question.
177
+
178
+ Notes
179
+ -----
180
+ Values are dictionary entries, not object attributes. History readers
181
+ normalize the legacy exam column to assessment. Proposed selections do
182
+ not become usage records in the history file until explicitly recorded.
183
+
184
+ """
185
+
186
+ course: str
187
+ bank_id: str
188
+ term: str
189
+ assessment: str
190
+ id: str
191
+
192
+
193
+ class PlannedUse(UsageRecord):
194
+ """Describe proposed question use with coverage metadata.
195
+
196
+ Attributes
197
+ ----------
198
+ area
199
+ Coverage area from the selected question.
200
+ difficulty
201
+ Difficulty label from the selected question.
202
+
203
+ Notes
204
+ -----
205
+ Inheritance shares the usage-record dictionary structure; it does not
206
+ indicate that the question has been administered. Creating a plan does
207
+ not append records to usage history.
208
+
209
+ """
210
+
211
+ area: str
212
+ difficulty: str
coursebanks/py.typed ADDED
File without changes
@@ -0,0 +1,399 @@
1
+ """Assessment selection and recording without unintentional question reuse."""
2
+
3
+ import csv
4
+ import random
5
+ from collections import Counter
6
+ from collections.abc import Sequence, Set
7
+ from pathlib import Path
8
+
9
+ from .assessments import assessment_name, is_eligible
10
+ from .models import Bank, PlannedUse, Question, UsageRecord
11
+
12
+ HISTORY_FIELDS = ["course", "bank_id", "term", "assessment", "id"]
13
+
14
+
15
+ def read_history(path: str | Path, bank: Bank) -> list[UsageRecord]:
16
+ """Read recorded question use for the supplied course and bank.
17
+
18
+ Parameters
19
+ ----------
20
+ path
21
+ Usage-history CSV encoded as UTF-8, with an optional byte-order mark.
22
+ A missing file or a zero-byte file is treated as empty history.
23
+ bank
24
+ Validated bank whose course and bank ID identify the rows to return.
25
+ Other banks' rows are skipped.
26
+
27
+ Returns
28
+ -------
29
+ records
30
+ New usage records in file order. A legacy ``exam`` column is exposed
31
+ as ``assessment``. Additional CSV columns are omitted from records.
32
+
33
+ Raises
34
+ ------
35
+ ValueError
36
+ If the header is invalid, a matching row lacks a value for a declared
37
+ column, or a matching row has a blank assessment name.
38
+ OSError
39
+ If the file cannot be inspected or read.
40
+ UnicodeError
41
+ If the file cannot be decoded as UTF-8.
42
+ csv.Error
43
+ If CSV parsing fails.
44
+
45
+ Notes
46
+ -----
47
+ Reading does not modify the file or bank. It does not check whether
48
+ recorded question IDs still exist in the bank or reject repeated IDs.
49
+ Column presence is checked for matching rows; empty cell text is allowed
50
+ except for the assessment name.
51
+
52
+ """ # noqa: DOC503
53
+ path = Path(path)
54
+ if not path.exists() or path.stat().st_size == 0:
55
+ return []
56
+ with path.open(newline="", encoding="utf-8-sig") as stream:
57
+ reader = csv.DictReader(stream)
58
+ fields = _history_fields(reader.fieldnames)
59
+ assessment_field = "assessment" if "assessment" in fields else "exam"
60
+ records: list[UsageRecord] = []
61
+ for row in reader:
62
+ if (
63
+ row["course"] != bank["course"]
64
+ or row["bank_id"] != bank["bank_id"]
65
+ ):
66
+ continue
67
+ if any(not isinstance(row.get(field), str) for field in fields):
68
+ raise ValueError("Usage history contains an incomplete row")
69
+ records.append(
70
+ UsageRecord(
71
+ course=row["course"],
72
+ bank_id=row["bank_id"],
73
+ term=row["term"],
74
+ assessment=assessment_name(row[assessment_field]),
75
+ id=row["id"],
76
+ )
77
+ )
78
+ return records
79
+
80
+
81
+ def choose(
82
+ bank: Bank, assessment: str, count: int, used: Set[str], seed: str | int = 0
83
+ ) -> list[Question]:
84
+ """Propose unused questions, balancing areas before difficulties.
85
+
86
+ Parameters
87
+ ----------
88
+ bank
89
+ Validated bank containing the candidate questions.
90
+ assessment
91
+ Nonblank assessment name, matched exactly, including case and
92
+ whitespace. Questions with unrestricted eligibility may also be used.
93
+ count
94
+ Nonnegative integer number of questions to select. Zero returns an
95
+ empty list; booleans and non-integer values are rejected.
96
+ used
97
+ Question IDs to exclude. The caller supplies these IDs, typically
98
+ from this bank's usage history; no history file is read here.
99
+ seed
100
+ Seed for local random tie breaking, converted to text. Defaults to 0.
101
+ Results are reproducible for the same inputs and question order.
102
+
103
+ Returns
104
+ -------
105
+ questions
106
+ Selected questions in selection order, without repetition. Entries
107
+ reference the original bank dictionaries rather than copies.
108
+
109
+ Raises
110
+ ------
111
+ ValueError
112
+ If the assessment name or count is invalid, or fewer eligible,
113
+ unused questions remain than requested.
114
+
115
+ Notes
116
+ -----
117
+ Selection is greedy. At each step, prefer the least represented area in
118
+ the current selection, then the least represented difficulty, then break
119
+ ties randomly. This is a balancing heuristic, not a coverage guarantee.
120
+ The bank and used set are unchanged, and no usage is recorded.
121
+
122
+ """
123
+ assessment_name(assessment)
124
+ _validate_count(count)
125
+ candidates = [
126
+ q
127
+ for q in bank["questions"]
128
+ if q["id"] not in used and is_eligible(q, assessment)
129
+ ]
130
+ if len(candidates) < count:
131
+ raise ValueError(
132
+ f"Only {len(candidates)} unused {assessment}-eligible "
133
+ "questions remain; "
134
+ f"cannot choose {count} without reuse"
135
+ )
136
+ rand = random.Random(str(seed)) # ruff: ignore[suspicious-non-cryptographic-random-usage]
137
+ chosen: list[Question] = []
138
+ areas: Counter[str] = Counter()
139
+ difficulties: Counter[str] = Counter()
140
+ while len(chosen) < count:
141
+ scored: list[tuple[tuple[int, int, float], Question]] = []
142
+ for q in candidates:
143
+ if q in chosen:
144
+ continue
145
+ # Balance areas first, then difficulty; draw among equivalent
146
+ # candidates reproducibly.
147
+ score = (
148
+ areas[q["area"]],
149
+ difficulties[q["difficulty"]],
150
+ rand.random(),
151
+ )
152
+ scored.append((score, q))
153
+ q = min(scored, key=lambda p: p[0])[1]
154
+ chosen.append(q)
155
+ areas[q["area"]] += 1
156
+ difficulties[q["difficulty"]] += 1
157
+ return chosen
158
+
159
+
160
+ def append_record(
161
+ path: str | Path, bank: Bank, term: str, assessment: str, ids: Sequence[str]
162
+ ) -> None:
163
+ """Append actually administered question IDs to usage history.
164
+
165
+ Parameters
166
+ ----------
167
+ path
168
+ Destination CSV. Parent directories are created as needed. New or
169
+ zero-byte files receive the standard header; existing files retain
170
+ their column order and any legacy ``exam`` column.
171
+ bank
172
+ Validated bank supplying the course, bank ID, and eligible questions.
173
+ term
174
+ Nonblank label for when the questions were administered. Stored
175
+ exactly as supplied, without trimming.
176
+ assessment
177
+ Nonblank assessment name, matched exactly against question
178
+ eligibility and stored without normalization.
179
+ ids
180
+ Unique question IDs to append in the supplied order. Every ID must
181
+ exist in the bank, be eligible, and have no recorded use in this
182
+ course and bank, regardless of term or assessment. An empty sequence
183
+ is allowed and may still create directories and a header-only file.
184
+
185
+ Returns
186
+ -------
187
+ None
188
+ Return after appending all requested usage records.
189
+
190
+ Raises
191
+ ------
192
+ ValueError
193
+ If the term or assessment is invalid, IDs are duplicate, unknown,
194
+ ineligible, or previously used, or existing history fails validation.
195
+ OSError
196
+ If reading history, creating directories, or writing records fails.
197
+ UnicodeError
198
+ If existing history cannot be decoded or output cannot be encoded.
199
+ csv.Error
200
+ If reading or writing CSV fails.
201
+
202
+ Notes
203
+ -----
204
+ Input and existing-history validation occur before appending records.
205
+ The bank is unchanged. Additional columns in existing history receive
206
+ empty values for new rows.
207
+
208
+ """ # noqa: DOC503
209
+ if not term.strip():
210
+ raise ValueError("term cannot be blank")
211
+ assessment_name(assessment)
212
+ valid = {q["id"] for q in bank["questions"] if is_eligible(q, assessment)}
213
+ if len(ids) != len(set(ids)) or any(q not in valid for q in ids):
214
+ raise ValueError(
215
+ "Question IDs must be unique and eligible for the specified "
216
+ "assessment"
217
+ )
218
+ existing = read_history(path, bank)
219
+ existing_ids = {x["id"] for x in existing}
220
+ if existing_ids.intersection(ids):
221
+ raise ValueError(
222
+ "Reusing previously recorded IDs is blocked: "
223
+ f"{sorted(existing_ids.intersection(ids))}"
224
+ )
225
+ path = Path(path)
226
+ path.parent.mkdir(parents=True, exist_ok=True)
227
+ fresh = not path.exists() or path.stat().st_size == 0
228
+ fields = HISTORY_FIELDS
229
+ if not fresh:
230
+ with path.open(newline="", encoding="utf-8-sig") as stream:
231
+ fields = _history_fields(csv.DictReader(stream).fieldnames)
232
+ assessment_field = "assessment" if "assessment" in fields else "exam"
233
+ with path.open("a", newline="", encoding="utf-8") as f:
234
+ writer = csv.DictWriter(f, fieldnames=fields)
235
+ if fresh:
236
+ writer.writeheader()
237
+ for q in ids:
238
+ writer.writerow({
239
+ "course": bank["course"],
240
+ "bank_id": bank["bank_id"],
241
+ "term": term,
242
+ assessment_field: assessment,
243
+ "id": q,
244
+ })
245
+
246
+
247
+ def multi_term_plan(
248
+ bank: Bank,
249
+ terms: Sequence[str],
250
+ assessments: Sequence[tuple[str, int]],
251
+ used: Set[str] | None = None,
252
+ seed: str | int = 0,
253
+ ) -> list[PlannedUse]:
254
+ """Propose nonrepeating selections for named assessments in each term.
255
+
256
+ Parameters
257
+ ----------
258
+ bank
259
+ Validated bank containing the candidate questions.
260
+ terms
261
+ Nonempty sequence of nonblank term labels, processed in order.
262
+ Labels are preserved exactly; repeated labels are permitted.
263
+ assessments
264
+ Nonempty sequence of ``(name, count)`` pairs. Names must be nonblank
265
+ and unique; counts must be nonnegative integers, excluding booleans.
266
+ Every pair applies to every term, in the supplied order.
267
+ used
268
+ Previously used question IDs to exclude throughout the plan.
269
+ Defaults to None, meaning no prior use. The supplied set is copied.
270
+ seed
271
+ Base seed for reproducible selection, defaulting to 0. Each call to
272
+ choose receives the text ``seed/term/assessment`` as its seed.
273
+
274
+ Returns
275
+ -------
276
+ plan
277
+ New records ordered by term, assessment, then question selection.
278
+ Each record contains course, bank ID, term, assessment, question ID,
279
+ area, and difficulty. A question ID appears at most once in the plan.
280
+ All-zero counts produce an empty list.
281
+
282
+ Raises
283
+ ------
284
+ ValueError
285
+ If terms or assessments are empty, any label or count is invalid,
286
+ assessment names repeat, or a selection has insufficient candidates.
287
+
288
+ Notes
289
+ -----
290
+ Selections use choose's greedy balancing and exclude all earlier
291
+ selections in this proposal. Earlier choices are not reconsidered, so
292
+ failure does not prove that no alternative allocation is possible.
293
+ No history file is read or written, and caller-owned data is unchanged.
294
+ A failed plan raises an exception rather than returning a partial plan.
295
+
296
+ """
297
+ names: set[str] = set()
298
+ for name, count in assessments:
299
+ assessment_name(name)
300
+ _validate_count(count)
301
+ if name in names:
302
+ raise ValueError("Plan assessment names must be unique")
303
+ names.add(name)
304
+ if not names:
305
+ raise ValueError("Plan needs at least one assessment")
306
+ if not terms:
307
+ raise ValueError("Plan needs at least one term")
308
+ if any(not term.strip() for term in terms):
309
+ raise ValueError("term cannot be blank")
310
+ selected_ids = set(used or [])
311
+ result: list[PlannedUse] = []
312
+ for term in terms:
313
+ for assessment, count in assessments:
314
+ chosen = choose(
315
+ bank,
316
+ assessment,
317
+ count,
318
+ selected_ids,
319
+ seed=f"{seed}/{term}/{assessment}",
320
+ )
321
+ for q in chosen:
322
+ selected_ids.add(q["id"])
323
+ result.append({
324
+ "course": bank["course"],
325
+ "bank_id": bank["bank_id"],
326
+ "term": term,
327
+ "assessment": assessment,
328
+ "id": q["id"],
329
+ "area": q["area"],
330
+ "difficulty": q["difficulty"],
331
+ })
332
+ return result
333
+
334
+
335
+ def _validate_count(count: object) -> None:
336
+ """Reject negative counts and non-integers before selecting questions.
337
+
338
+ Parameters
339
+ ----------
340
+ count
341
+ Requested number of questions. Zero is allowed. Booleans, strings,
342
+ and floating-point values are rejected without coercion.
343
+
344
+ Returns
345
+ -------
346
+ None
347
+ Return normally when the count is a nonnegative integer.
348
+
349
+ Raises
350
+ ------
351
+ ValueError
352
+ If the count is negative, a boolean, or not an integer.
353
+
354
+ """
355
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
356
+ raise ValueError("count must be a nonnegative integer")
357
+
358
+
359
+ def _history_fields(fields: Sequence[str] | None) -> list[str]:
360
+ """Validate new or legacy history headers without losing column order.
361
+
362
+ Parameters
363
+ ----------
364
+ fields
365
+ CSV column names, or None when no header is available. Names must
366
+ include ``course``, ``bank_id``, ``term``, and ``id``, plus exactly
367
+ one of ``assessment`` or the legacy ``exam`` column. Additional
368
+ columns are allowed, but all names must be unique.
369
+
370
+ Returns
371
+ -------
372
+ names
373
+ A new list containing all supplied column names in their original
374
+ order. The input sequence is not modified.
375
+
376
+ Raises
377
+ ------
378
+ ValueError
379
+ If the header is missing or empty, a required column is absent,
380
+ both assessment columns are present, or any column name is repeated.
381
+
382
+ Notes
383
+ -----
384
+ Column names are case-sensitive and are not stripped or normalized.
385
+ The legacy ``exam`` name is retained so appending records preserves
386
+ the existing CSV header.
387
+
388
+ """
389
+ names = list(fields or [])
390
+ required = {"course", "bank_id", "term", "id"}
391
+ has_assessment = "assessment" in names
392
+ has_exam = "exam" in names
393
+ if (
394
+ not required.issubset(names)
395
+ or has_assessment == has_exam
396
+ or len(names) != len(set(names))
397
+ ):
398
+ raise ValueError(f"Usage history needs columns {HISTORY_FIELDS}")
399
+ return names
coursebanks/xml_io.py ADDED
@@ -0,0 +1,50 @@
1
+ """Hardened parsing of XML read from external QTI archives."""
2
+
3
+ # Standard-library elements and exceptions only; parsing uses defusedxml.
4
+ from xml.etree.ElementTree import Element, ParseError # ruff: ignore[suspicious-xml-etree-import]
5
+
6
+ from defusedxml.common import DefusedXmlException
7
+ from defusedxml.ElementTree import fromstring
8
+
9
+
10
+ def parse_xml(payload: bytes) -> Element:
11
+ """Parse XML while forbidding DTDs, entities, and external references.
12
+
13
+ Parameters
14
+ ----------
15
+ payload
16
+ Complete XML document as bytes, typically read from a QTI ZIP member.
17
+ The XML parser interprets any encoding declaration in the document.
18
+
19
+ Returns
20
+ -------
21
+ root
22
+ Root Element of the parsed document, with its child elements and
23
+ text available for subsequent QTI validation.
24
+
25
+ Raises
26
+ ------
27
+ ValueError
28
+ If the XML contains a forbidden DTD, entity declaration, or external
29
+ reference, or cannot be parsed. Diagnostics begin with "Unsafe QTI
30
+ XML" for forbidden constructs or "Invalid QTI XML" for parse errors.
31
+ The underlying parser exception is retained as the cause.
32
+
33
+ Notes
34
+ -----
35
+ Parsing uses defusedxml and does not check QTI namespaces, structure,
36
+ scoring, or question-bank constraints. The function performs no archive
37
+ operations and applies no explicit input-size limit.
38
+
39
+ """
40
+ try:
41
+ return fromstring(
42
+ payload,
43
+ forbid_dtd=True,
44
+ forbid_entities=True,
45
+ forbid_external=True,
46
+ )
47
+ except DefusedXmlException as exc:
48
+ raise ValueError(f"Unsafe QTI XML: {exc}") from exc
49
+ except ParseError as exc:
50
+ raise ValueError(f"Invalid QTI XML: {exc}") from exc