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/cli.py
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
"""Command-line interface for reusable Canvas question-bank workflows."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from zipfile import BadZipFile
|
|
9
|
+
|
|
10
|
+
from .core import build, load_bank, verify_qti
|
|
11
|
+
from .migrate import import_qti, save_bank
|
|
12
|
+
from .models import Bank
|
|
13
|
+
from .rotation import (
|
|
14
|
+
append_record,
|
|
15
|
+
choose,
|
|
16
|
+
multi_term_plan,
|
|
17
|
+
read_history,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Arguments(argparse.Namespace):
|
|
22
|
+
"""Describe attributes populated by the CLI argument parser.
|
|
23
|
+
|
|
24
|
+
Annotations cover all subcommands, but parsing creates only the attributes
|
|
25
|
+
registered for the selected command. Handlers must read only those fields.
|
|
26
|
+
This class supplies typing information, not runtime validation or defaults.
|
|
27
|
+
Only attributes registered for the selected command are populated.
|
|
28
|
+
|
|
29
|
+
Attributes
|
|
30
|
+
----------
|
|
31
|
+
command
|
|
32
|
+
Selected CLI subcommand.
|
|
33
|
+
source
|
|
34
|
+
Input YAML path for commands operating on a question bank.
|
|
35
|
+
archive
|
|
36
|
+
Input QTI ZIP path for archive verification or import.
|
|
37
|
+
out
|
|
38
|
+
Output directory for build, YAML path for import, or CSV path
|
|
39
|
+
for plan.
|
|
40
|
+
course
|
|
41
|
+
bank_id
|
|
42
|
+
title
|
|
43
|
+
metadata_csv
|
|
44
|
+
force
|
|
45
|
+
Whether to force the changes even if data is overwritten.
|
|
46
|
+
history
|
|
47
|
+
Location of the CSV history file.
|
|
48
|
+
count
|
|
49
|
+
Number of questions desired.
|
|
50
|
+
seed
|
|
51
|
+
Seed number for random number generator.
|
|
52
|
+
term
|
|
53
|
+
Term identifier for question use.
|
|
54
|
+
ids
|
|
55
|
+
IDs of questions.
|
|
56
|
+
terms
|
|
57
|
+
Terms for questions to be used.
|
|
58
|
+
assessment
|
|
59
|
+
Assessment name used by selection and recording.
|
|
60
|
+
assessments
|
|
61
|
+
Ordered name/count-text pairs supplied to the plan command.
|
|
62
|
+
Counts remain strings until the plan handler converts them.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
command: str
|
|
67
|
+
source: Path
|
|
68
|
+
archive: Path
|
|
69
|
+
out: Path
|
|
70
|
+
course: str
|
|
71
|
+
bank_id: str
|
|
72
|
+
title: str
|
|
73
|
+
metadata_csv: Path | None
|
|
74
|
+
force: bool
|
|
75
|
+
history: Path
|
|
76
|
+
count: int
|
|
77
|
+
seed: str
|
|
78
|
+
term: str
|
|
79
|
+
ids: list[str]
|
|
80
|
+
terms: list[str]
|
|
81
|
+
assessment: str
|
|
82
|
+
assessments: list[list[str]]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv: Iterable[str] | None = None) -> int:
|
|
86
|
+
"""Run the CLI using supplied arguments or the process command line.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
argv
|
|
91
|
+
Iterable of argument strings, excluding the executable name.
|
|
92
|
+
None uses the process command line.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
status
|
|
97
|
+
0 after successful execution, or 2 for a handled input, archive,
|
|
98
|
+
filesystem, or CSV error. Handled errors are printed to standard
|
|
99
|
+
error with an ``ERROR:`` prefix.
|
|
100
|
+
|
|
101
|
+
Raises
|
|
102
|
+
------
|
|
103
|
+
SystemExit
|
|
104
|
+
When argparse handles help (status 0) or invalid arguments
|
|
105
|
+
(status 2), before command dispatch.
|
|
106
|
+
|
|
107
|
+
Notes
|
|
108
|
+
-----
|
|
109
|
+
Command summaries and proposals are printed to standard output.
|
|
110
|
+
Build, import, record, and plan write their requested outputs.
|
|
111
|
+
Unexpected programming errors propagate. Expected command failures do
|
|
112
|
+
not emit a traceback.
|
|
113
|
+
|
|
114
|
+
""" # noqa: DOC502
|
|
115
|
+
args = _build_parser().parse_args(argv, namespace=_Arguments())
|
|
116
|
+
try:
|
|
117
|
+
_dispatch(args)
|
|
118
|
+
except (ValueError, OSError, csv.Error, BadZipFile) as exc:
|
|
119
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
120
|
+
return 2
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
125
|
+
"""Build the parser for bank, archive, and rotation commands.
|
|
126
|
+
|
|
127
|
+
Returns
|
|
128
|
+
-------
|
|
129
|
+
parser
|
|
130
|
+
New argument parser requiring one subcommand. Path arguments are
|
|
131
|
+
converted to Path objects; command-specific options and defaults
|
|
132
|
+
are registered without parsing arguments or accessing files.
|
|
133
|
+
|
|
134
|
+
"""
|
|
135
|
+
p = argparse.ArgumentParser(
|
|
136
|
+
prog="coursebanks",
|
|
137
|
+
description="Validate YAML, export Canvas QTI 1.2 and plan question "
|
|
138
|
+
"reuse.",
|
|
139
|
+
)
|
|
140
|
+
sp = p.add_subparsers(dest="command", required=True)
|
|
141
|
+
for cmd in ["validate", "build"]:
|
|
142
|
+
x = sp.add_parser(cmd)
|
|
143
|
+
x.add_argument("source", type=Path)
|
|
144
|
+
if cmd == "build":
|
|
145
|
+
x.add_argument("--out", default="build", type=Path)
|
|
146
|
+
x = sp.add_parser(
|
|
147
|
+
"check-qti",
|
|
148
|
+
help="Check the structure and scoring of a generated QTI ZIP",
|
|
149
|
+
)
|
|
150
|
+
x.add_argument("archive", type=Path)
|
|
151
|
+
x = sp.add_parser(
|
|
152
|
+
"import-qti",
|
|
153
|
+
help="Migrate simple single-answer QTI 1.2 packages to YAML",
|
|
154
|
+
)
|
|
155
|
+
x.add_argument("archive", type=Path)
|
|
156
|
+
x.add_argument("--course", required=True)
|
|
157
|
+
x.add_argument("--bank-id", required=True)
|
|
158
|
+
x.add_argument("--title", required=True)
|
|
159
|
+
x.add_argument("--metadata-csv", type=Path)
|
|
160
|
+
x.add_argument("--out", type=Path, required=True)
|
|
161
|
+
x.add_argument("--force", action="store_true")
|
|
162
|
+
x = sp.add_parser(
|
|
163
|
+
"select", help="Propose questions but do not record actual usage"
|
|
164
|
+
)
|
|
165
|
+
_configure_select(x)
|
|
166
|
+
x = sp.add_parser(
|
|
167
|
+
"record",
|
|
168
|
+
help="Record question IDs actually administered (not proposed)",
|
|
169
|
+
)
|
|
170
|
+
_configure_record(x)
|
|
171
|
+
x = sp.add_parser(
|
|
172
|
+
"plan",
|
|
173
|
+
help="Propose a nonrepeating multi-term rotation without "
|
|
174
|
+
"modifying history",
|
|
175
|
+
)
|
|
176
|
+
_configure_plan(x)
|
|
177
|
+
return p
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _configure_select(parser: argparse.ArgumentParser) -> None:
|
|
181
|
+
"""Define arguments for the select command.
|
|
182
|
+
|
|
183
|
+
Parameters
|
|
184
|
+
----------
|
|
185
|
+
parser
|
|
186
|
+
Subcommand parser to configure in place.
|
|
187
|
+
Register the source, history (default usage.csv), assessment (with
|
|
188
|
+
the legacy --exam alias), integer count, and seed (default "0").
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
None
|
|
193
|
+
Return after registering arguments. No input is parsed and no files
|
|
194
|
+
are accessed.
|
|
195
|
+
|
|
196
|
+
"""
|
|
197
|
+
parser.add_argument("source", type=Path)
|
|
198
|
+
parser.add_argument("--history", type=Path, default=Path("usage.csv"))
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"--assessment", "--exam", dest="assessment", required=True
|
|
201
|
+
)
|
|
202
|
+
parser.add_argument("--count", type=int, required=True)
|
|
203
|
+
parser.add_argument("--seed", default="0")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _configure_record(parser: argparse.ArgumentParser) -> None:
|
|
207
|
+
"""Define arguments for the record command.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
parser
|
|
212
|
+
Subcommand parser to configure in place.
|
|
213
|
+
Register the source, history (default usage.csv), term, assessment
|
|
214
|
+
(with the legacy --exam alias), and one or more question IDs.
|
|
215
|
+
|
|
216
|
+
Returns
|
|
217
|
+
-------
|
|
218
|
+
None
|
|
219
|
+
Return after registering arguments. No input is parsed and no files
|
|
220
|
+
are accessed.
|
|
221
|
+
|
|
222
|
+
"""
|
|
223
|
+
parser.add_argument("source", type=Path)
|
|
224
|
+
parser.add_argument("--history", type=Path, default=Path("usage.csv"))
|
|
225
|
+
parser.add_argument("--term", required=True)
|
|
226
|
+
parser.add_argument(
|
|
227
|
+
"--assessment", "--exam", dest="assessment", required=True
|
|
228
|
+
)
|
|
229
|
+
parser.add_argument("--ids", nargs="+", required=True)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _configure_plan(parser: argparse.ArgumentParser) -> None:
|
|
233
|
+
"""Define arguments for the plan command.
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
parser
|
|
238
|
+
Subcommand parser to configure in place.
|
|
239
|
+
Register the source, history (default usage.csv), one or more terms,
|
|
240
|
+
repeated --assessment NAME COUNT pairs, seed (default "0"), and
|
|
241
|
+
required output path. Count text is converted by the plan handler.
|
|
242
|
+
|
|
243
|
+
Returns
|
|
244
|
+
-------
|
|
245
|
+
None
|
|
246
|
+
Return after registering arguments. No input is parsed and no files
|
|
247
|
+
are accessed.
|
|
248
|
+
|
|
249
|
+
"""
|
|
250
|
+
parser.add_argument("source", type=Path)
|
|
251
|
+
parser.add_argument("--history", type=Path, default=Path("usage.csv"))
|
|
252
|
+
parser.add_argument("--terms", nargs="+", required=True)
|
|
253
|
+
parser.add_argument(
|
|
254
|
+
"--assessment",
|
|
255
|
+
dest="assessments",
|
|
256
|
+
nargs=2,
|
|
257
|
+
action="append",
|
|
258
|
+
metavar=("NAME", "COUNT"),
|
|
259
|
+
required=True,
|
|
260
|
+
help="Assessment name and number of questions; may be repeated",
|
|
261
|
+
)
|
|
262
|
+
parser.add_argument("--seed", default="0")
|
|
263
|
+
parser.add_argument("--out", type=Path, required=True)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _dispatch(args: _Arguments) -> None:
|
|
267
|
+
"""Route a parsed command, loading a validated bank when needed.
|
|
268
|
+
|
|
269
|
+
Parameters
|
|
270
|
+
----------
|
|
271
|
+
args
|
|
272
|
+
Namespace produced by the command parser. The command determines
|
|
273
|
+
which attributes are present and which handler is called.
|
|
274
|
+
|
|
275
|
+
Returns
|
|
276
|
+
-------
|
|
277
|
+
None
|
|
278
|
+
Return after the selected handler completes.
|
|
279
|
+
|
|
280
|
+
Raises
|
|
281
|
+
------
|
|
282
|
+
ValueError
|
|
283
|
+
If source validation or command-specific input validation fails.
|
|
284
|
+
OSError
|
|
285
|
+
If command file operations fail.
|
|
286
|
+
csv.Error
|
|
287
|
+
If command CSV operations fail.
|
|
288
|
+
|
|
289
|
+
Notes
|
|
290
|
+
-----
|
|
291
|
+
Archive commands use args.archive directly. All other supported commands
|
|
292
|
+
first load and validate args.source. Handler output and file side effects
|
|
293
|
+
occur here; exceptions propagate to main for reporting when supported.
|
|
294
|
+
|
|
295
|
+
""" # noqa: DOC502
|
|
296
|
+
if args.command == "check-qti":
|
|
297
|
+
_run_check_qti(args)
|
|
298
|
+
return
|
|
299
|
+
if args.command == "import-qti":
|
|
300
|
+
_run_import_qti(args)
|
|
301
|
+
return
|
|
302
|
+
bank = load_bank(args.source)
|
|
303
|
+
if args.command == "validate":
|
|
304
|
+
_run_validate(bank)
|
|
305
|
+
elif args.command == "build":
|
|
306
|
+
_run_build(args, bank)
|
|
307
|
+
elif args.command == "select":
|
|
308
|
+
_run_select(args, bank)
|
|
309
|
+
elif args.command == "record":
|
|
310
|
+
_run_record(args, bank)
|
|
311
|
+
elif args.command == "plan":
|
|
312
|
+
_run_plan(args, bank)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _run_check_qti(args: _Arguments) -> None:
|
|
316
|
+
"""Check an archive and print its verified question count.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
args
|
|
321
|
+
Parsed check-qti arguments containing the archive path.
|
|
322
|
+
|
|
323
|
+
Returns
|
|
324
|
+
-------
|
|
325
|
+
None
|
|
326
|
+
Return after printing the structural validation summary.
|
|
327
|
+
|
|
328
|
+
Raises
|
|
329
|
+
------
|
|
330
|
+
ValueError
|
|
331
|
+
If the archive or one of its members is unreadable, XML is unsafe or
|
|
332
|
+
malformed, or archive structure or scoring fails verification.
|
|
333
|
+
OSError
|
|
334
|
+
If the archive cannot be read.
|
|
335
|
+
|
|
336
|
+
Notes
|
|
337
|
+
-----
|
|
338
|
+
No files are written. Structural verification does not certify Canvas
|
|
339
|
+
acceptance or rendered appearance.
|
|
340
|
+
|
|
341
|
+
""" # noqa: DOC503
|
|
342
|
+
try:
|
|
343
|
+
report = verify_qti(args.archive)
|
|
344
|
+
except (BadZipFile, RuntimeError, NotImplementedError) as exc:
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"Cannot read QTI archive {args.archive}: {exc}"
|
|
347
|
+
) from exc
|
|
348
|
+
print(f"Valid QTI 1.2 structure: {report['count']} questions")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _run_import_qti(args: _Arguments) -> None:
|
|
352
|
+
"""Import an archive to YAML, protecting existing output unless forced.
|
|
353
|
+
|
|
354
|
+
Parameters
|
|
355
|
+
----------
|
|
356
|
+
args
|
|
357
|
+
Parsed import-qti arguments: archive, course, bank_id, title,
|
|
358
|
+
optional metadata_csv, out, and force.
|
|
359
|
+
|
|
360
|
+
Returns
|
|
361
|
+
-------
|
|
362
|
+
None
|
|
363
|
+
Return after saving the bank and printing its question count and path.
|
|
364
|
+
|
|
365
|
+
Raises
|
|
366
|
+
------
|
|
367
|
+
ValueError
|
|
368
|
+
If output exists without force, the archive or one of its members is
|
|
369
|
+
unreadable, QTI content is unsupported or invalid, metadata cannot be
|
|
370
|
+
interpreted, or bank validation fails.
|
|
371
|
+
OSError
|
|
372
|
+
If input reads, directory creation, or output writes fail.
|
|
373
|
+
csv.Error
|
|
374
|
+
If parsing the optional metadata CSV fails.
|
|
375
|
+
|
|
376
|
+
Notes
|
|
377
|
+
-----
|
|
378
|
+
Import and validation complete before saving. Saving creates parent
|
|
379
|
+
directories and overwrites existing output only when force permits it.
|
|
380
|
+
Other import and serialization exceptions propagate to the caller.
|
|
381
|
+
|
|
382
|
+
""" # noqa: DOC503
|
|
383
|
+
_check_import_output(args.out, force=args.force)
|
|
384
|
+
try:
|
|
385
|
+
bank = import_qti(
|
|
386
|
+
args.archive,
|
|
387
|
+
args.course,
|
|
388
|
+
args.bank_id,
|
|
389
|
+
args.title,
|
|
390
|
+
args.metadata_csv,
|
|
391
|
+
)
|
|
392
|
+
except (BadZipFile, RuntimeError, NotImplementedError) as exc:
|
|
393
|
+
raise ValueError(
|
|
394
|
+
f"Cannot read QTI archive {args.archive}: {exc}"
|
|
395
|
+
) from exc
|
|
396
|
+
save_bank(bank, args.out)
|
|
397
|
+
print(f"Imported {len(bank['questions'])} questions into {args.out}")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _run_validate(bank: Bank) -> None:
|
|
401
|
+
"""Print the identity and question count of an already validated bank.
|
|
402
|
+
|
|
403
|
+
Parameters
|
|
404
|
+
----------
|
|
405
|
+
bank
|
|
406
|
+
Bank validated by the dispatcher before this handler is called.
|
|
407
|
+
|
|
408
|
+
Returns
|
|
409
|
+
-------
|
|
410
|
+
None
|
|
411
|
+
Return after printing the course, bank ID, and question count.
|
|
412
|
+
|
|
413
|
+
Notes
|
|
414
|
+
-----
|
|
415
|
+
This handler only reports success; it does not repeat validation or
|
|
416
|
+
modify the bank.
|
|
417
|
+
|
|
418
|
+
"""
|
|
419
|
+
print(
|
|
420
|
+
f"Valid: {bank['course']} / {bank['bank_id']}: "
|
|
421
|
+
f"{len(bank['questions'])} questions"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _run_build(args: _Arguments, bank: Bank) -> None:
|
|
426
|
+
"""Build QTI and review artifacts and print archive provenance.
|
|
427
|
+
|
|
428
|
+
Parameters
|
|
429
|
+
----------
|
|
430
|
+
args
|
|
431
|
+
Parsed build arguments containing the output-directory path.
|
|
432
|
+
bank
|
|
433
|
+
Bank to build. The build function revalidates it before writing.
|
|
434
|
+
|
|
435
|
+
Returns
|
|
436
|
+
-------
|
|
437
|
+
None
|
|
438
|
+
Return after printing the archive path, question count, and SHA-256.
|
|
439
|
+
|
|
440
|
+
Raises
|
|
441
|
+
------
|
|
442
|
+
ValueError
|
|
443
|
+
If bank validation or generated-archive verification fails.
|
|
444
|
+
OSError
|
|
445
|
+
If creating directories or writing or reading artifacts fails.
|
|
446
|
+
|
|
447
|
+
Notes
|
|
448
|
+
-----
|
|
449
|
+
Build creates the output directory and overwrites matching artifact
|
|
450
|
+
filenames. The input bank is unchanged.
|
|
451
|
+
|
|
452
|
+
""" # noqa: DOC502
|
|
453
|
+
artifact, build_report = build(bank, args.out)
|
|
454
|
+
print(
|
|
455
|
+
f"Created {artifact}: {build_report['count']} questions; "
|
|
456
|
+
f"SHA-256 {build_report['sha256']}"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _run_select(args: _Arguments, bank: Bank) -> None:
|
|
461
|
+
"""Print eligible questions without recording proposed use.
|
|
462
|
+
|
|
463
|
+
Parameters
|
|
464
|
+
----------
|
|
465
|
+
args
|
|
466
|
+
Parsed select arguments containing history, assessment, count,
|
|
467
|
+
and seed.
|
|
468
|
+
bank
|
|
469
|
+
Validated bank from which questions will be selected.
|
|
470
|
+
|
|
471
|
+
Returns
|
|
472
|
+
-------
|
|
473
|
+
None
|
|
474
|
+
Return after printing the proposal notice and each selected
|
|
475
|
+
question's ID, title, area, difficulty, and estimated minutes.
|
|
476
|
+
|
|
477
|
+
Raises
|
|
478
|
+
------
|
|
479
|
+
ValueError
|
|
480
|
+
If history, assessment, or count is invalid, or insufficient eligible
|
|
481
|
+
unused questions remain.
|
|
482
|
+
OSError
|
|
483
|
+
If history cannot be read.
|
|
484
|
+
csv.Error
|
|
485
|
+
If history CSV parsing fails.
|
|
486
|
+
|
|
487
|
+
Notes
|
|
488
|
+
-----
|
|
489
|
+
Prior usage is scoped to the bank's course and bank ID. A missing history
|
|
490
|
+
file means no prior use. No files are created or changed.
|
|
491
|
+
|
|
492
|
+
""" # noqa: DOC502
|
|
493
|
+
used = {x["id"] for x in read_history(args.history, bank)}
|
|
494
|
+
selected = choose(bank, args.assessment, args.count, used, args.seed)
|
|
495
|
+
print(
|
|
496
|
+
"PROPOSED ONLY — no history written. Record IDs after actually "
|
|
497
|
+
"administering the assessment.\n"
|
|
498
|
+
)
|
|
499
|
+
for q in selected:
|
|
500
|
+
print(
|
|
501
|
+
f"{q['id']}: {q['title']} | {q['area']} | "
|
|
502
|
+
f"{q['difficulty']} | {q['minutes']} min"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _run_record(args: _Arguments, bank: Bank) -> None:
|
|
507
|
+
"""Record administered question IDs and print the history destination.
|
|
508
|
+
|
|
509
|
+
Parameters
|
|
510
|
+
----------
|
|
511
|
+
args
|
|
512
|
+
Parsed record arguments containing history, term, assessment,
|
|
513
|
+
and the ordered question IDs.
|
|
514
|
+
bank
|
|
515
|
+
Validated bank identifying the course, bank ID, and eligible questions.
|
|
516
|
+
|
|
517
|
+
Returns
|
|
518
|
+
-------
|
|
519
|
+
None
|
|
520
|
+
Return after appending records and printing their count and path.
|
|
521
|
+
|
|
522
|
+
Raises
|
|
523
|
+
------
|
|
524
|
+
ValueError
|
|
525
|
+
If history or administration labels are invalid, or IDs are
|
|
526
|
+
duplicate, unknown, ineligible, or previously recorded.
|
|
527
|
+
OSError
|
|
528
|
+
If reading history, creating directories, or writing records fails.
|
|
529
|
+
csv.Error
|
|
530
|
+
If reading or writing CSV fails.
|
|
531
|
+
|
|
532
|
+
Notes
|
|
533
|
+
-----
|
|
534
|
+
Existing history is preserved and new records are appended. A new file
|
|
535
|
+
receives a header; legacy headers retain their column names and order.
|
|
536
|
+
|
|
537
|
+
""" # noqa: DOC502
|
|
538
|
+
append_record(args.history, bank, args.term, args.assessment, args.ids)
|
|
539
|
+
print(f"Recorded {len(args.ids)} actual questions in {args.history}")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _run_plan(args: _Arguments, bank: Bank) -> None:
|
|
543
|
+
"""Write a proposed rotation CSV without changing usage history.
|
|
544
|
+
|
|
545
|
+
Parameters
|
|
546
|
+
----------
|
|
547
|
+
args
|
|
548
|
+
Parsed plan arguments containing terms, assessment name/count-text
|
|
549
|
+
pairs, history, seed, and the output CSV path.
|
|
550
|
+
bank
|
|
551
|
+
Validated bank supplying questions for each assessment in each term.
|
|
552
|
+
|
|
553
|
+
Returns
|
|
554
|
+
-------
|
|
555
|
+
None
|
|
556
|
+
Return after writing the proposal and printing its row count and path.
|
|
557
|
+
|
|
558
|
+
Raises
|
|
559
|
+
------
|
|
560
|
+
ValueError
|
|
561
|
+
If counts, labels, or history are invalid, or a selection cannot be
|
|
562
|
+
completed without reusing questions.
|
|
563
|
+
OSError
|
|
564
|
+
If reading history, creating directories, or writing the plan fails.
|
|
565
|
+
csv.Error
|
|
566
|
+
If reading or writing CSV fails.
|
|
567
|
+
|
|
568
|
+
Notes
|
|
569
|
+
-----
|
|
570
|
+
Planning completes before the output is opened. Parent directories are
|
|
571
|
+
created as needed, and an existing output file is overwritten.
|
|
572
|
+
Rows include course, bank ID, term, assessment, question ID, area, and
|
|
573
|
+
difficulty. Proposed selections are not appended to usage history.
|
|
574
|
+
|
|
575
|
+
""" # noqa: DOC502
|
|
576
|
+
assessments = [
|
|
577
|
+
(name, _parse_assessment_count(name, count_text))
|
|
578
|
+
for name, count_text in args.assessments
|
|
579
|
+
]
|
|
580
|
+
|
|
581
|
+
used = {x["id"] for x in read_history(args.history, bank)}
|
|
582
|
+
|
|
583
|
+
rows = multi_term_plan(bank, args.terms, assessments, used, args.seed)
|
|
584
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
585
|
+
with args.out.open("w", newline="", encoding="utf-8") as f:
|
|
586
|
+
writer = csv.DictWriter(
|
|
587
|
+
f,
|
|
588
|
+
fieldnames=[
|
|
589
|
+
"course",
|
|
590
|
+
"bank_id",
|
|
591
|
+
"term",
|
|
592
|
+
"assessment",
|
|
593
|
+
"id",
|
|
594
|
+
"area",
|
|
595
|
+
"difficulty",
|
|
596
|
+
],
|
|
597
|
+
)
|
|
598
|
+
writer.writeheader()
|
|
599
|
+
writer.writerows(rows)
|
|
600
|
+
print(
|
|
601
|
+
f"Wrote proposed, nonbinding rotation of {len(rows)} "
|
|
602
|
+
f"question uses to {args.out}"
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _check_import_output(path: Path, *, force: bool) -> None:
|
|
607
|
+
"""Reject existing import output unless overwriting is allowed.
|
|
608
|
+
|
|
609
|
+
Parameters
|
|
610
|
+
----------
|
|
611
|
+
path
|
|
612
|
+
Requested destination for the imported YAML bank.
|
|
613
|
+
force
|
|
614
|
+
Whether an existing destination may be overwritten.
|
|
615
|
+
|
|
616
|
+
Returns
|
|
617
|
+
-------
|
|
618
|
+
None
|
|
619
|
+
Return when the destination does not exist or force is enabled.
|
|
620
|
+
|
|
621
|
+
Raises
|
|
622
|
+
------
|
|
623
|
+
ValueError
|
|
624
|
+
If the destination exists and force is disabled.
|
|
625
|
+
OSError
|
|
626
|
+
If checking the destination fails.
|
|
627
|
+
|
|
628
|
+
Notes
|
|
629
|
+
-----
|
|
630
|
+
This check does not create or reserve the destination, check write
|
|
631
|
+
permissions, or guarantee that a later write will succeed.
|
|
632
|
+
|
|
633
|
+
""" # noqa: DOC503
|
|
634
|
+
if path.exists() and not force:
|
|
635
|
+
raise ValueError(
|
|
636
|
+
f"Refusing to overwrite {path}; use --force if intended"
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _parse_assessment_count(name: str, count_text: str) -> int:
|
|
641
|
+
"""Parse a nonnegative count, identifying the assessment in errors.
|
|
642
|
+
|
|
643
|
+
Parameters
|
|
644
|
+
----------
|
|
645
|
+
name
|
|
646
|
+
Assessment label used in error messages; not validated here.
|
|
647
|
+
count_text
|
|
648
|
+
Integer text accepted by int, including surrounding whitespace.
|
|
649
|
+
Zero is allowed; negative integers and non-integer text are rejected.
|
|
650
|
+
|
|
651
|
+
Returns
|
|
652
|
+
-------
|
|
653
|
+
count
|
|
654
|
+
Parsed nonnegative question count.
|
|
655
|
+
|
|
656
|
+
Raises
|
|
657
|
+
------
|
|
658
|
+
ValueError
|
|
659
|
+
If the text cannot be parsed as an integer or the result is negative.
|
|
660
|
+
Conversion failures retain the original exception as their cause.
|
|
661
|
+
|
|
662
|
+
"""
|
|
663
|
+
try:
|
|
664
|
+
count = int(count_text)
|
|
665
|
+
except ValueError as exc:
|
|
666
|
+
raise ValueError(
|
|
667
|
+
f"Assessment {name!r} count must be a nonnegative "
|
|
668
|
+
f"integer; got {count_text!r}"
|
|
669
|
+
) from exc
|
|
670
|
+
if count < 0:
|
|
671
|
+
raise ValueError(
|
|
672
|
+
f"Assessment {name!r} count must be nonnegative; got {count}"
|
|
673
|
+
)
|
|
674
|
+
return count
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
if __name__ == "__main__":
|
|
678
|
+
raise SystemExit(main())
|