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/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
|
coursebanks/rotation.py
ADDED
|
@@ -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
|