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.
@@ -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"
@@ -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