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/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Validate YAML question banks and exchange Canvas QTI 1.2 archives.
|
|
2
|
+
|
|
3
|
+
The package supports single-answer multiple-choice questions, instructor
|
|
4
|
+
review artifacts, and assessment selection with usage-history tracking.
|
|
5
|
+
Import workflow functions from the core, migrate, and rotation modules;
|
|
6
|
+
they are not re-exported at package level.
|
|
7
|
+
|
|
8
|
+
Attributes
|
|
9
|
+
----------
|
|
10
|
+
__version__
|
|
11
|
+
Package version string.
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
coursebanks/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Run the CLI when invoked as ``python -m coursebanks``.
|
|
2
|
+
|
|
3
|
+
Delegate process arguments to cli.main, the same handler used by the
|
|
4
|
+
installed coursebanks command, and exit with its returned status.
|
|
5
|
+
Successful commands return 0; handled input and file errors return 2.
|
|
6
|
+
Argument parsing handles help and invalid arguments through SystemExit.
|
|
7
|
+
|
|
8
|
+
Notes
|
|
9
|
+
-----
|
|
10
|
+
Execution occurs at module scope, including when this module is imported.
|
|
11
|
+
Python callers should use cli.main directly to receive its return status.
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .cli import main
|
|
16
|
+
|
|
17
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Apply resource limits before reading external QTI ZIP members.
|
|
2
|
+
|
|
3
|
+
Attributes
|
|
4
|
+
----------
|
|
5
|
+
MAX_QTI_ARCHIVE_MEMBERS
|
|
6
|
+
Maximum number of central-directory entries in a supported archive.
|
|
7
|
+
MAX_QTI_MEMBER_SIZE
|
|
8
|
+
Maximum declared and observed uncompressed size of one member, in bytes.
|
|
9
|
+
MAX_QTI_TOTAL_SIZE
|
|
10
|
+
Maximum total declared uncompressed size of all members, in bytes.
|
|
11
|
+
MAX_QTI_COMPRESSION_RATIO
|
|
12
|
+
Maximum uncompressed-to-compressed size ratio for a nonempty member.
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from zipfile import ZipFile, ZipInfo
|
|
19
|
+
|
|
20
|
+
MAX_QTI_ARCHIVE_MEMBERS = 100
|
|
21
|
+
MAX_QTI_MEMBER_SIZE = 16 * 1024 * 1024
|
|
22
|
+
MAX_QTI_TOTAL_SIZE = 32 * 1024 * 1024
|
|
23
|
+
MAX_QTI_COMPRESSION_RATIO = 100.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_archive(archive: ZipFile) -> dict[str, ZipInfo]:
|
|
27
|
+
"""Validate ZIP metadata before any QTI member is decompressed.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
archive
|
|
32
|
+
Open ZIP archive whose central-directory entries will be inspected.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
members
|
|
37
|
+
Unique member names mapped to their ``ZipInfo`` records.
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
ValueError
|
|
42
|
+
If the archive exceeds the member-count, individual-size,
|
|
43
|
+
total-size, or compression-ratio limit, or contains a duplicate
|
|
44
|
+
member name.
|
|
45
|
+
|
|
46
|
+
Notes
|
|
47
|
+
-----
|
|
48
|
+
Directory entries count toward the member limit. Their zero sizes do not
|
|
49
|
+
affect the size or compression-ratio checks. This function trusts no
|
|
50
|
+
member name for lookup until uniqueness has been established and does
|
|
51
|
+
not decompress member data.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
entries = archive.infolist()
|
|
55
|
+
if len(entries) > MAX_QTI_ARCHIVE_MEMBERS:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"QTI archive contains {len(entries)} members; limit is "
|
|
58
|
+
f"{MAX_QTI_ARCHIVE_MEMBERS}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
members: dict[str, ZipInfo] = {}
|
|
62
|
+
total_size = 0
|
|
63
|
+
for entry in entries:
|
|
64
|
+
if entry.filename in members:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"QTI archive contains duplicate member name: {entry.filename}"
|
|
67
|
+
)
|
|
68
|
+
members[entry.filename] = entry
|
|
69
|
+
if entry.file_size > MAX_QTI_MEMBER_SIZE:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"QTI archive member {entry.filename!r} expands to "
|
|
72
|
+
f"{entry.file_size} bytes; per-member limit is "
|
|
73
|
+
f"{MAX_QTI_MEMBER_SIZE} bytes"
|
|
74
|
+
)
|
|
75
|
+
total_size += entry.file_size
|
|
76
|
+
if total_size > MAX_QTI_TOTAL_SIZE:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"QTI archive expands to {total_size} bytes; total limit "
|
|
79
|
+
f"is {MAX_QTI_TOTAL_SIZE} bytes"
|
|
80
|
+
)
|
|
81
|
+
if _exceeds_compression_ratio(entry):
|
|
82
|
+
ratio = (
|
|
83
|
+
float("inf")
|
|
84
|
+
if entry.compress_size == 0
|
|
85
|
+
else entry.file_size / entry.compress_size
|
|
86
|
+
)
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"QTI archive member {entry.filename!r} has compression "
|
|
89
|
+
f"ratio {ratio:.1f}:1; limit is "
|
|
90
|
+
f"{MAX_QTI_COMPRESSION_RATIO:g}:1"
|
|
91
|
+
)
|
|
92
|
+
return members
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def read_member(archive: ZipFile, member: ZipInfo) -> bytes:
|
|
96
|
+
"""Read one previously validated member with an observed-size bound.
|
|
97
|
+
|
|
98
|
+
Parameters
|
|
99
|
+
----------
|
|
100
|
+
archive
|
|
101
|
+
Open ZIP archive containing ``member``.
|
|
102
|
+
member
|
|
103
|
+
Unique member record returned by :func:`validate_archive`.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
payload
|
|
108
|
+
Complete uncompressed member bytes.
|
|
109
|
+
|
|
110
|
+
Raises
|
|
111
|
+
------
|
|
112
|
+
ValueError
|
|
113
|
+
If decompression produces more than the per-member size limit.
|
|
114
|
+
OSError
|
|
115
|
+
If the underlying archive cannot be read.
|
|
116
|
+
zipfile.BadZipFile
|
|
117
|
+
If member data or its checksum is invalid.
|
|
118
|
+
RuntimeError
|
|
119
|
+
If the member is encrypted and no password is available.
|
|
120
|
+
NotImplementedError
|
|
121
|
+
If the member uses an unsupported compression method.
|
|
122
|
+
|
|
123
|
+
Notes
|
|
124
|
+
-----
|
|
125
|
+
The explicit read bound supplements the central-directory metadata check
|
|
126
|
+
and prevents an unexpectedly larger stream from being accumulated.
|
|
127
|
+
|
|
128
|
+
""" # noqa: DOC503
|
|
129
|
+
with archive.open(member) as stream:
|
|
130
|
+
payload = stream.read(MAX_QTI_MEMBER_SIZE + 1)
|
|
131
|
+
if len(payload) > MAX_QTI_MEMBER_SIZE:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"QTI archive member {member.filename!r} expands beyond the "
|
|
134
|
+
f"per-member limit of {MAX_QTI_MEMBER_SIZE} bytes"
|
|
135
|
+
)
|
|
136
|
+
return payload
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _exceeds_compression_ratio(member: ZipInfo) -> bool:
|
|
140
|
+
"""Return whether one nonempty member exceeds the configured ratio.
|
|
141
|
+
|
|
142
|
+
Parameters
|
|
143
|
+
----------
|
|
144
|
+
member
|
|
145
|
+
ZIP metadata containing compressed and uncompressed sizes.
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
exceeds
|
|
150
|
+
True for a nonempty member with no compressed bytes or with an
|
|
151
|
+
uncompressed-to-compressed ratio above the configured maximum.
|
|
152
|
+
|
|
153
|
+
"""
|
|
154
|
+
if member.file_size == 0:
|
|
155
|
+
return False
|
|
156
|
+
if member.compress_size == 0:
|
|
157
|
+
return True
|
|
158
|
+
return member.file_size > MAX_QTI_COMPRESSION_RATIO * member.compress_size
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Shared assessment eligibility rules and legacy YAML migration."""
|
|
2
|
+
|
|
3
|
+
from .models import Question
|
|
4
|
+
from .yaml_data import YamlMapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def assessment_name(value: object) -> str:
|
|
8
|
+
"""Validate an assessment name without changing its text.
|
|
9
|
+
|
|
10
|
+
Parameters
|
|
11
|
+
----------
|
|
12
|
+
value
|
|
13
|
+
User-defined assessment name. It must be a string containing at
|
|
14
|
+
least one non-whitespace character.
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
name
|
|
19
|
+
Original string with case and leading or trailing whitespace intact.
|
|
20
|
+
|
|
21
|
+
Raises
|
|
22
|
+
------
|
|
23
|
+
ValueError
|
|
24
|
+
If the value is not a string or is empty or whitespace-only.
|
|
25
|
+
|
|
26
|
+
Notes
|
|
27
|
+
-----
|
|
28
|
+
Names are not limited to a predefined set. Eligibility comparisons use
|
|
29
|
+
exact string equality; validation does not trim or normalize names.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
if not isinstance(value, str) or not value.strip():
|
|
33
|
+
raise ValueError("assessment names must be nonempty strings")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def question_assessments(data: YamlMapping) -> list[str]:
|
|
38
|
+
"""Validate assessment eligibility or convert a legacy exam field.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
data
|
|
43
|
+
Question mapping containing an optional assessments list or legacy
|
|
44
|
+
exam string. Other fields are ignored by this helper.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
names
|
|
49
|
+
New list of unique, nonblank assessment names. An omitted field or
|
|
50
|
+
empty list gives unrestricted eligibility. Legacy exam Either also
|
|
51
|
+
becomes an empty list; any other valid legacy name becomes a
|
|
52
|
+
single-element list.
|
|
53
|
+
|
|
54
|
+
Raises
|
|
55
|
+
------
|
|
56
|
+
ValueError
|
|
57
|
+
If both eligibility fields are present, assessments is not a list,
|
|
58
|
+
a name is invalid, or the list contains duplicate names.
|
|
59
|
+
|
|
60
|
+
Notes
|
|
61
|
+
-----
|
|
62
|
+
Name order, case, and whitespace are preserved. Duplicate checks use
|
|
63
|
+
exact equality. Either has special meaning only in the legacy exam
|
|
64
|
+
field; an assessments entry named Either is an ordinary restriction.
|
|
65
|
+
The input mapping and any contained list are not modified.
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
if "exam" in data:
|
|
69
|
+
if "assessments" in data:
|
|
70
|
+
raise ValueError("use assessments or legacy exam, not both")
|
|
71
|
+
name = assessment_name(data["exam"])
|
|
72
|
+
return [] if name == "Either" else [name]
|
|
73
|
+
values = data.get("assessments", [])
|
|
74
|
+
if not isinstance(values, list):
|
|
75
|
+
# Schema diagnostics use ValueError, including malformed YAML types.
|
|
76
|
+
raise ValueError("assessments must be a list of nonempty strings") # ruff: ignore[type-check-without-type-error]
|
|
77
|
+
names = [assessment_name(value) for value in values]
|
|
78
|
+
if len(names) != len(set(names)):
|
|
79
|
+
raise ValueError("assessments must contain unique names")
|
|
80
|
+
return names
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_eligible(question: Question, assessment: str) -> bool:
|
|
84
|
+
"""Check whether a validated question allows a named assessment.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
question
|
|
89
|
+
Validated question whose assessments list restricts eligibility.
|
|
90
|
+
An empty list, or an absent key, permits any valid assessment name.
|
|
91
|
+
assessment
|
|
92
|
+
Nonblank assessment name to compare using exact string equality.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
eligible
|
|
97
|
+
True if eligibility is unrestricted or the name occurs in the
|
|
98
|
+
assessments list; otherwise False.
|
|
99
|
+
|
|
100
|
+
Raises
|
|
101
|
+
------
|
|
102
|
+
ValueError
|
|
103
|
+
If the requested assessment name is not a nonblank string.
|
|
104
|
+
|
|
105
|
+
Notes
|
|
106
|
+
-----
|
|
107
|
+
This helper validates the requested name, not the question's contents.
|
|
108
|
+
It does not consult usage history or check whether the question has
|
|
109
|
+
already been selected. Neither input is modified.
|
|
110
|
+
|
|
111
|
+
""" # noqa: DOC502
|
|
112
|
+
name = assessment_name(assessment)
|
|
113
|
+
names = question.get("assessments", [])
|
|
114
|
+
return not names or name in names
|