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