QuestionBankToolkit 0.1.0__tar.gz → 0.1.1__tar.gz
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.
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/CHANGELOG.md +11 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/MANIFEST.in +1 -1
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/PKG-INFO +2 -2
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/README.md +1 -1
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/QTI_specification.md +8 -8
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/cleanup-plan.md +17 -20
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/importing-qti.md +6 -5
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/releasing.md +3 -2
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/pyproject.toml +2 -2
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/check_distribution.sh +3 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/PKG-INFO +2 -2
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/SOURCES.txt +4 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/__init__.py +1 -1
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/core.py +172 -22
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/migrate.py +54 -15
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/models.py +3 -1
- questionbanktoolkit-0.1.1/tests/fixtures/canvas_item_bank/assessment.xml +14 -0
- questionbanktoolkit-0.1.1/tests/fixtures/canvas_item_bank/bank.xml.qti +51 -0
- questionbanktoolkit-0.1.1/tests/fixtures/canvas_item_bank/imsmanifest.xml +15 -0
- questionbanktoolkit-0.1.1/tests/test_canvas_qti_import.py +220 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/.woodpecker/checks.yaml +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/LICENSE +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/api.rst +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/canvas-import.md +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/commands.md +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/conf.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/development.md +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/development_roadmap.md +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/getting-started.md +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/index.md +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/schema.md +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/examples/sample_course/questions.yaml +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/newsfragments/README.md +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/build_docs.sh +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/check_release.sh +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/prepare_release.sh +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/publish_docs.sh +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/run_checks.sh +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/setup.cfg +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/dependency_links.txt +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/entry_points.txt +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/requires.txt +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/top_level.txt +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/__main__.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/archive_io.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/assessments.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/cli.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/py.typed +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/rotation.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/xml_io.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/yaml_data.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/export_bank.yaml +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/import_qti/assessment.xml +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/import_qti/imsmanifest.xml +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/sample_bank.yaml +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_archive_limits.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_assessments.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_boundary_failures.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_build_artifacts.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_cli_contracts.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_optimized_validation.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_qti_export.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_qti_failures.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_qti_import_fidelity.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_questionsbanks.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_typed_boundaries.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_validation.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_xml_security.py +0 -0
- {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_yaml_loading.py +0 -0
|
@@ -5,6 +5,17 @@ Changes prepared for the next release are stored as small files in [`newsfragmen
|
|
|
5
5
|
|
|
6
6
|
<!-- towncrier release notes start -->
|
|
7
7
|
|
|
8
|
+
# QuestionBankToolkit 0.1.1 (2026-10-21)
|
|
9
|
+
|
|
10
|
+
## Bug fixes
|
|
11
|
+
|
|
12
|
+
- Import supported Canvas Common Cartridge item-bank exports whose questions are stored in a referenced object bank, including correct-only scoring, digit-leading identifiers, and absent feedback.
|
|
13
|
+
|
|
14
|
+
## Documentation
|
|
15
|
+
|
|
16
|
+
- Document the live Codeberg Pages site, published PyPI package, and named Twine repository used for future releases.
|
|
17
|
+
|
|
18
|
+
|
|
8
19
|
# QuestionBankToolkit 0.1.0 (2026-10-21)
|
|
9
20
|
|
|
10
21
|
## Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: QuestionBankToolkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Canvas QTI 1.2 question banks from YAML
|
|
5
5
|
Author-email: "David D. Marshall" <ddmarsha@calpoly.edu>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -70,7 +70,7 @@ Import a supported QTI 1.2 ZIP package into a new YAML bank with:
|
|
|
70
70
|
coursebanks import-qti questions.zip --course COURSE123 --bank-id ImportedBank --title "Imported bank" --out imported.yaml
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
The importer supports a strict single-answer multiple-choice subset
|
|
73
|
+
The importer supports a strict single-answer multiple-choice subset, including Canvas Common Cartridge item-bank exports, and rejects unsupported or ambiguous structures.
|
|
74
74
|
See [the QTI import guide](docs/importing-qti.md) for required package structure, defaults, choice-label normalization, metadata CSV overrides, and the field-by-field compatibility table.
|
|
75
75
|
|
|
76
76
|
## Optional: selecting and recording questions
|
|
@@ -39,7 +39,7 @@ Import a supported QTI 1.2 ZIP package into a new YAML bank with:
|
|
|
39
39
|
coursebanks import-qti questions.zip --course COURSE123 --bank-id ImportedBank --title "Imported bank" --out imported.yaml
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
The importer supports a strict single-answer multiple-choice subset
|
|
42
|
+
The importer supports a strict single-answer multiple-choice subset, including Canvas Common Cartridge item-bank exports, and rejects unsupported or ambiguous structures.
|
|
43
43
|
See [the QTI import guide](docs/importing-qti.md) for required package structure, defaults, choice-label normalization, metadata CSV overrides, and the field-by-field compatibility table.
|
|
44
44
|
|
|
45
45
|
## Optional: selecting and recording questions
|
|
@@ -32,12 +32,12 @@ The XML namespace and resource labels below are compatibility details of the cur
|
|
|
32
32
|
| Question types | Single-answer multiple choice | No multiple-answer, matching, essay, numeric, or fill-in-the-blank questions |
|
|
33
33
|
| Choices | At least two in YAML/export | Import currently accepts 2-26 choices; labels are reassigned A-Z |
|
|
34
34
|
| Correct answer | Exactly one selected choice | No partial credit or negative marking |
|
|
35
|
-
| Scoring | `SCORE` = 100 for correct
|
|
35
|
+
| Scoring | Export writes `SCORE` = 100 for correct and 0 for incorrect | Import also accepts Canvas object-bank items containing only the full-credit rule |
|
|
36
36
|
| Text | Plain text or authored HTML | Does not convert LaTeX to Canvas equation content or package external media |
|
|
37
|
-
| Feedback |
|
|
37
|
+
| Feedback | Export writes one general explanation per question | Import explicitly defaults an explanation when Canvas supplies no feedback |
|
|
38
38
|
| Ordering | Questions and choices retain YAML order in generated XML | Actual display ordering is controlled by the receiving LMS |
|
|
39
39
|
| Metadata | IDs, title, points, topic, area, difficulty, assessment eligibility | Some fields are extensions understood by this toolkit, not portable QTI semantics |
|
|
40
|
-
| Import |
|
|
40
|
+
| Import | Direct QTI resources and Canvas Common Cartridge item-bank wrappers | Unsupported or ambiguous structures are rejected |
|
|
41
41
|
| Question rotation | Managed by local YAML/CSV tooling | Not encoded as a QTI assessment-selection rule |
|
|
42
42
|
| Choice shuffling | Export writes `render_choice/@shuffle` as `Yes` or `No` | A question override takes precedence over the bank default; import stores the effective value per question |
|
|
43
43
|
|
|
@@ -131,11 +131,11 @@ For exam delivery, inspect representative imported questions and confirm the rel
|
|
|
131
131
|
|
|
132
132
|
## 7. Import and round-trip boundaries
|
|
133
133
|
|
|
134
|
-
`coursebanks import-qti` is intended for compatible **single-answer** items. It reads QTI item IDs, titles, text, ordered choices, one correct score condition, and
|
|
134
|
+
`coursebanks import-qti` is intended for compatible **single-answer** items. It reads QTI item IDs, titles, text, ordered choices, one correct score condition, and available general explanations, then builds a validated YAML bank.
|
|
135
135
|
|
|
136
|
-
The current importer requires one single-cardinality `response_lid`, `text/plain` or `text/html` stems and choices, 2-26 choices, a valid optional `render_choice/@shuffle` value, one identifiable correct answer,
|
|
136
|
+
The current importer requires one single-cardinality `response_lid`, `text/plain` or `text/html` stems and choices, 2-26 choices, a valid optional `render_choice/@shuffle` value, and one identifiable correct answer. It accepts a direct QTI assessment resource or resolves one Canvas Common Cartridge wrapper to its referenced object bank. When feedback is absent, the YAML explanation states that the package provided none rather than inventing instructional content. It may use an instructor CSV to recover authoring metadata not present in the QTI package. If the format cannot otherwise be represented faithfully, import should fail rather than silently discard meaningful information.
|
|
137
137
|
|
|
138
|
-
Imported options are relabeled `A`, `B`, ... in source order.
|
|
138
|
+
Imported options are relabeled `A`, `B`, ... in source order. A question ID is preserved when it satisfies the YAML grammar; Canvas hexadecimal IDs beginning with a digit receive a `q_` prefix. Original option identifiers are not guaranteed to be preserved on import. Assessment-eligibility metadata is project-specific and cannot be expected from arbitrary third-party exports.
|
|
139
139
|
|
|
140
140
|
See the [QTI import guide](importing-qti.md) for the field-by-field compatibility table, metadata defaults, and CSV override precedence.
|
|
141
141
|
|
|
@@ -149,10 +149,10 @@ See the [QTI import guide](importing-qti.md) for the field-by-field compatibilit
|
|
|
149
149
|
|
|
150
150
|
`coursebanks check-qti <archive.zip>` currently checks, among other things:
|
|
151
151
|
|
|
152
|
-
- A root-level `imsmanifest.xml`,
|
|
152
|
+
- A root-level `imsmanifest.xml`, exactly one QTI resource of type `imsqti_xmlv1p2`, and either a resolvable direct assessment filename or one resolvable Canvas source-bank reference.
|
|
153
153
|
- No more than 100 uniquely named members, 16 MiB per uncompressed member, 32 MiB total declared uncompressed data, and a maximum 100:1 compression ratio per nonempty member.
|
|
154
154
|
- A QTI-namespace `questestinterop` document containing at least one item.
|
|
155
|
-
- Unique, nonempty item IDs, at least two response labels per item, one key matching a response-label identifier, and
|
|
155
|
+
- Unique, nonempty item IDs, at least two response labels per item, one key matching a response-label identifier, and one or two simple score assignments.
|
|
156
156
|
- XML is parsed using `defusedxml`, rejecting DTD/entity-based constructs.
|
|
157
157
|
|
|
158
158
|
Verification applies archive limits before XML parsing, and import reapplies them when reopening the package.
|
|
@@ -17,13 +17,14 @@ The check script passes when run with the project environment:
|
|
|
17
17
|
QBT_PYTHON=.venv/bin/python ./scripts/run_checks.sh
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
The current result is
|
|
21
|
-
Coverage is enabled with branch measurement and reports
|
|
22
|
-
These numbers are a snapshot from October
|
|
23
|
-
|
|
24
|
-
The repository now contains a Codeberg Woodpecker workflow, a local wheel and source-distribution installation check, and a reviewed
|
|
25
|
-
[Codeberg Woodpecker pipeline
|
|
26
|
-
|
|
20
|
+
The current result is 410 passing pytest cases with 98% reported coverage and branch measurement enabled, no Ruff lint, format, mypy, BasedPyright, or pydoclint findings.
|
|
21
|
+
Coverage is enabled with branch measurement and reports fourteen uncovered source statements and fifteen partially covered branches.
|
|
22
|
+
These numbers are a snapshot from October 21, 2026, rather than release criteria by themselves.
|
|
23
|
+
|
|
24
|
+
The repository now contains a Codeberg Woodpecker workflow, a local wheel and source-distribution installation check, and a reviewed release workflow.
|
|
25
|
+
[Codeberg Woodpecker pipeline 8](https://ci.codeberg.org/repos/18200/pipeline/8) passed the Canvas item-bank import changes on October 21, 2026, using the hosted Python 3.14 environment.
|
|
26
|
+
Version 0.1.1 is published on [PyPI](https://pypi.org/project/QuestionBankToolkit/0.1.1/), and the user documentation is published through [Codeberg Pages](https://ddmarshall.codeberg.page/QuestionBankToolkit/).
|
|
27
|
+
The importer has been exercised against a direct 20-question Canvas item-bank export, but the project has no recorded Canvas re-import result yet.
|
|
27
28
|
Structural QTI verification therefore does not constitute a claim that every supported package has been accepted by Canvas.
|
|
28
29
|
|
|
29
30
|
## Completed cleanup
|
|
@@ -59,7 +60,7 @@ Structural QTI verification therefore does not constitute a claim that every sup
|
|
|
59
60
|
- Added shared pre-parse ZIP limits of 100 members, 16 MiB per member, 32 MiB total uncompressed data, and a 100:1 per-member compression ratio.
|
|
60
61
|
- Rejected duplicate archive member names, bounded observed member reads, and reapplied the policy when import reopens an archive.
|
|
61
62
|
- Covered every archive limit at its exact boundary and immediately beyond it through verification, import, and both CLI commands without large fixtures.
|
|
62
|
-
- Rejected unsupported response cardinality, incomplete choices, malformed scoring, ambiguous answers,
|
|
63
|
+
- Rejected unsupported response cardinality, incomplete choices, malformed scoring, ambiguous answers, unsupported text structures, and more than 26 choices.
|
|
63
64
|
- Required imported questions to pass the YAML schema before they become validated banks.
|
|
64
65
|
- Added explicit import overwrite protection through `--force` and verified that rejected imports do not create output.
|
|
65
66
|
- Preserved deterministic ZIP bytes and checked the IMS manifest and QTI root namespaces.
|
|
@@ -67,6 +68,8 @@ Structural QTI verification therefore does not constitute a claim that every sup
|
|
|
67
68
|
- Added an independently authored multi-question export fixture and direct assertions for the manifest, metadata, item and choice order, identifiers, plain and HTML text, Unicode, XML escaping, fractional points, shuffling, scoring, and feedback.
|
|
68
69
|
- Verified the exact build artifact set, ZIP members, instructor Markdown, catalog columns and values, validation report, recomputed archive digest, and CLI output for a destination containing spaces.
|
|
69
70
|
- Added a hand-authored third-party-style QTI package and exact import assertions for preserved content, A-through-Z choice normalization, translated answers, current and legacy metadata, importer defaults, shuffling, CSV overrides, and precedence.
|
|
71
|
+
- Added Canvas Common Cartridge item-bank support by resolving wrapper source-bank references, accepting the correct-only scoring form, normalizing digit-leading item IDs, and explicitly marking absent feedback.
|
|
72
|
+
- Imported and validated all 20 questions from a direct Canvas item-bank export on October 21, 2026, including HTML text, four-choice ordering, answer keys, and Canvas-generated identifiers.
|
|
70
73
|
- Published and linked a field-by-field import compatibility table covering preserved, normalized, defaulted, and unsupported data.
|
|
71
74
|
|
|
72
75
|
### Code quality and developer documentation
|
|
@@ -107,30 +110,24 @@ Structural QTI verification therefore does not constitute a claim that every sup
|
|
|
107
110
|
- Replaced the release placeholders with active Towncrier and Bump My Version configuration for QuestionBankToolkit.
|
|
108
111
|
- Added news fragments, changelog generation, synchronized version checks, and a release-preparation script that runs all checks without committing, tagging, or publishing.
|
|
109
112
|
- Added [releasing.md](releasing.md) with the fragment, semantic-version, review, commit, and annotated-tag workflow.
|
|
113
|
+
- Published the Sphinx user guide and API reference through the repository's `pages` branch and Codeberg Pages webhook.
|
|
114
|
+
- Released version 0.1.1 with Canvas Common Cartridge item-bank import support, uploaded its checked wheel and source distribution to PyPI, and verified the supported workflows in a clean Python 3.14 environment.
|
|
110
115
|
|
|
111
116
|
## Remaining gaps
|
|
112
117
|
|
|
113
118
|
| Area | Current evidence | Remaining outcome |
|
|
114
119
|
| --- | --- | --- |
|
|
115
|
-
|
|
|
116
|
-
| Canvas acceptance | XML structure and toolkit round trips are automated. | Record manual import and export results from the intended Canvas quiz and question-bank workflows. |
|
|
120
|
+
| Canvas acceptance | XML structure and toolkit round trips are automated, and a direct 20-question Canvas item-bank export imports and validates locally. | Import a toolkit-generated package into Canvas, review its behavior, and re-export representative content for a complete bidirectional result. |
|
|
117
121
|
|
|
118
122
|
Standalone QTI XML is not currently supported input.
|
|
119
|
-
The importer requires a ZIP package containing `imsmanifest.xml` and one
|
|
123
|
+
The importer requires a ZIP package containing `imsmanifest.xml` and either one direct QTI assessment resource or one Canvas wrapper that resolves to an object-bank resource.
|
|
120
124
|
Any future support for standalone XML should be an explicit compatibility decision rather than an incidental parser behavior.
|
|
121
125
|
|
|
122
|
-
## Next step:
|
|
123
|
-
|
|
124
|
-
Build and review the Sphinx user guide and generated API reference locally.
|
|
125
|
-
Publish the reviewed site to the repository's `pages` branch, configure the Codeberg Pages webhook, and verify the public documentation URL.
|
|
126
|
-
Prepare version 0.1.0 from the current news fragments, review and tag the release, upload the checked wheel and source distribution to PyPI, and verify installation in a clean environment.
|
|
127
|
-
Do not treat an available package name as reserved until the first upload succeeds.
|
|
128
|
-
|
|
129
|
-
## Canvas acceptance
|
|
126
|
+
## Next step: Canvas acceptance
|
|
130
127
|
|
|
131
128
|
Perform a manual import of a small synthetic package into the intended Canvas quiz and question-bank destinations.
|
|
132
129
|
Record the Canvas environment and date, question count, text and HTML rendering, choice order, correct answers, grading points, and feedback behavior.
|
|
133
|
-
|
|
130
|
+
Import a toolkit-generated package into Canvas and re-export representative content to complete the bidirectional workflow verification.
|
|
134
131
|
Keep automated structural verification and manual Canvas acceptance clearly separate.
|
|
135
132
|
|
|
136
133
|
Completion means the primary conversion workflows and retained rotation commands pass independent tests, configured checks and packaging tests pass in CI, user documentation matches actual behavior, and the supported Canvas workflow has a dated acceptance record.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Importing QTI into YAML
|
|
2
2
|
|
|
3
3
|
QuestionBankToolkit imports a strict subset of QTI 1.2 single-answer multiple-choice packages.
|
|
4
|
-
The input must be a ZIP package containing a root `imsmanifest.xml` whose QTI resource
|
|
4
|
+
The input must be a ZIP package containing a root `imsmanifest.xml` in the supported IMS or Canvas Common Cartridge namespace whose QTI resource either contains the items directly or identifies one Canvas object bank through `sourcebank_ref`.
|
|
5
5
|
Standalone XML, QTI 2.x, and other question types are not supported.
|
|
6
6
|
|
|
7
7
|
## Archive safety limits
|
|
@@ -51,7 +51,7 @@ Preserved values keep their supported QTI content, normalized values change repr
|
|
|
51
51
|
| `course` | Defaulted | Taken from the required `--course` argument, not from QTI. |
|
|
52
52
|
| `bank_id` | Defaulted | Taken from the required `--bank-id` argument and validated as a YAML identifier. |
|
|
53
53
|
| Bank `title` | Defaulted | Taken from the required `--title` argument, not from the QTI assessment title. |
|
|
54
|
-
| `id` | Preserved | Taken from `item/@ident`; missing, duplicate, or schema-invalid IDs are rejected. |
|
|
54
|
+
| `id` | Preserved or normalized | Taken from `item/@ident`; a digit-leading identifier is prefixed with `q_`, while missing, duplicate, or otherwise schema-invalid IDs are rejected. |
|
|
55
55
|
| Question `title` | Normalized | Uses `item/@title`, removes an exact leading `<id> ` prefix, and defaults to the item ID. |
|
|
56
56
|
| `area` | Defaulted | Uses CSV `Area`, QTI `area`, QTI `topic`, then `Unclassified`. |
|
|
57
57
|
| `topic` | Defaulted | Uses CSV `Topic`, QTI `topic`, then `Unclassified`. |
|
|
@@ -64,7 +64,7 @@ Preserved values keep their supported QTI content, normalized values change repr
|
|
|
64
64
|
| `choices` | Normalized | Preserves source order and text but replaces source identifiers with `A` through `Z`; 2–26 choices are supported. |
|
|
65
65
|
| `shuffle_choices` | Normalized | QTI `render_choice/@shuffle="Yes"` becomes `true`; `No` or an omitted attribute becomes `false`. |
|
|
66
66
|
| `answer` | Normalized | Translates the source choice identifier from one full-credit score condition to its generated A–Z label. |
|
|
67
|
-
| `explanation` | Preserved | Uses
|
|
67
|
+
| `explanation` | Preserved or defaulted | Uses general feedback `mattext`; when the package contains no feedback container, records `No explanation was provided in the imported QTI package.` explicitly, while malformed feedback is rejected. |
|
|
68
68
|
| Additional YAML metadata | Unsupported | QTI fields without a mapping above are not copied into YAML. |
|
|
69
69
|
|
|
70
70
|
Imported choices deliberately receive new labels.
|
|
@@ -93,8 +93,9 @@ The metadata CSV cannot override question text, choices, answers, explanations,
|
|
|
93
93
|
## Supported item structure
|
|
94
94
|
|
|
95
95
|
Each item must contain one single-cardinality `response_lid`, one `render_choice`, 2–26 choices, and consistent `text/plain` or `text/html` content.
|
|
96
|
-
The importer requires one uniquely identifiable choice whose score condition sets `SCORE` to `100
|
|
97
|
-
|
|
96
|
+
The importer requires one uniquely identifiable choice whose score condition sets `SCORE` to `100`; it accepts either that single rule or an additional fallback score rule.
|
|
97
|
+
Canvas Common Cartridge exports may place the actual items in a manifest resource referenced by a wrapper assessment's `sourcebank_ref`, and that one level of indirection is resolved explicitly.
|
|
98
|
+
Unsupported response types, partial or ambiguous scoring, missing identifiers, inconsistent text formats, ambiguous source-bank references, media dependencies, and unrepresentable content are rejected rather than silently discarded.
|
|
98
99
|
|
|
99
100
|
The importer validates the completed bank against [the YAML schema](schema.md).
|
|
100
101
|
Passing import and validation proves compatibility with this toolkit's supported subset, not rendering or acceptance by Canvas.
|
|
@@ -79,6 +79,7 @@ Do not commit the token or place it in a command argument.
|
|
|
79
79
|
|
|
80
80
|
The first upload creates the PyPI project and therefore requires an account-scoped token.
|
|
81
81
|
After the project exists, replace that token with a token scoped only to `QuestionBankToolkit` for later releases.
|
|
82
|
+
Store the project-scoped token under a `QuestionBankToolkit` repository section in `~/.pypirc`, and restrict that file to the current user with `chmod 600 ~/.pypirc`.
|
|
82
83
|
|
|
83
84
|
From the clean, tagged release commit, build and validate the final files:
|
|
84
85
|
|
|
@@ -89,10 +90,10 @@ python -m twine check dist/*
|
|
|
89
90
|
```
|
|
90
91
|
|
|
91
92
|
Inspect the wheel and source archive names and confirm their version before uploading.
|
|
92
|
-
|
|
93
|
+
Upload through the named repository configuration so the token is not included in the command:
|
|
93
94
|
|
|
94
95
|
```bash
|
|
95
|
-
python -m twine upload dist/*
|
|
96
|
+
.venv/bin/python -m twine upload --repository QuestionBankToolkit dist/*
|
|
96
97
|
```
|
|
97
98
|
|
|
98
99
|
PyPI does not permit replacing a file or reusing a published version after deletion.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "QuestionBankToolkit"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
authors = [{ name = "David D. Marshall", email = "ddmarsha@calpoly.edu" }]
|
|
9
9
|
description = "Canvas QTI 1.2 question banks from YAML"
|
|
10
10
|
readme = "README.md"
|
|
@@ -255,7 +255,7 @@ name = "Other changes"
|
|
|
255
255
|
showcontent = true
|
|
256
256
|
|
|
257
257
|
[tool.bumpversion]
|
|
258
|
-
current_version = "0.1.
|
|
258
|
+
current_version = "0.1.1"
|
|
259
259
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
260
260
|
serialize = ["{major}.{minor}.{patch}"]
|
|
261
261
|
commit = false
|
|
@@ -64,6 +64,9 @@ required_source_suffixes = {
|
|
|
64
64
|
"/scripts/publish_docs.sh",
|
|
65
65
|
"/scripts/prepare_release.sh",
|
|
66
66
|
"/scripts/run_checks.sh",
|
|
67
|
+
"/tests/fixtures/canvas_item_bank/assessment.xml",
|
|
68
|
+
"/tests/fixtures/canvas_item_bank/bank.xml.qti",
|
|
69
|
+
"/tests/fixtures/canvas_item_bank/imsmanifest.xml",
|
|
67
70
|
"/tests/fixtures/import_qti/assessment.xml",
|
|
68
71
|
}
|
|
69
72
|
with tarfile.open(source, "r:gz") as archive:
|
{questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: QuestionBankToolkit
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Canvas QTI 1.2 question banks from YAML
|
|
5
5
|
Author-email: "David D. Marshall" <ddmarsha@calpoly.edu>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -70,7 +70,7 @@ Import a supported QTI 1.2 ZIP package into a new YAML bank with:
|
|
|
70
70
|
coursebanks import-qti questions.zip --course COURSE123 --bank-id ImportedBank --title "Imported bank" --out imported.yaml
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
The importer supports a strict single-answer multiple-choice subset
|
|
73
|
+
The importer supports a strict single-answer multiple-choice subset, including Canvas Common Cartridge item-bank exports, and rejects unsupported or ambiguous structures.
|
|
74
74
|
See [the QTI import guide](docs/importing-qti.md) for required package structure, defaults, choice-label normalization, metadata CSV overrides, and the field-by-field compatibility table.
|
|
75
75
|
|
|
76
76
|
## Optional: selecting and recording questions
|
{questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/SOURCES.txt
RENAMED
|
@@ -47,6 +47,7 @@ tests/test_archive_limits.py
|
|
|
47
47
|
tests/test_assessments.py
|
|
48
48
|
tests/test_boundary_failures.py
|
|
49
49
|
tests/test_build_artifacts.py
|
|
50
|
+
tests/test_canvas_qti_import.py
|
|
50
51
|
tests/test_cli_contracts.py
|
|
51
52
|
tests/test_optimized_validation.py
|
|
52
53
|
tests/test_qti_export.py
|
|
@@ -59,5 +60,8 @@ tests/test_xml_security.py
|
|
|
59
60
|
tests/test_yaml_loading.py
|
|
60
61
|
tests/fixtures/export_bank.yaml
|
|
61
62
|
tests/fixtures/sample_bank.yaml
|
|
63
|
+
tests/fixtures/canvas_item_bank/assessment.xml
|
|
64
|
+
tests/fixtures/canvas_item_bank/bank.xml.qti
|
|
65
|
+
tests/fixtures/canvas_item_bank/imsmanifest.xml
|
|
62
66
|
tests/fixtures/import_qti/assessment.xml
|
|
63
67
|
tests/fixtures/import_qti/imsmanifest.xml
|
|
@@ -32,6 +32,7 @@ from .yaml_data import YamlMapping, YamlValue, yaml_value
|
|
|
32
32
|
|
|
33
33
|
QTI = "http://www.imsglobal.org/xsd/ims_qtiasiv1p2"
|
|
34
34
|
IMS = "http://www.imsglobal.org/xsd/imscp_v1p1"
|
|
35
|
+
IMS_CC = "http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1"
|
|
35
36
|
ET.register_namespace("", QTI)
|
|
36
37
|
SAFE_ID = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]*$")
|
|
37
38
|
DIFFICULTIES = {"Basic", "Moderate", "Challenging"}
|
|
@@ -1139,6 +1140,167 @@ def create_qti(
|
|
|
1139
1140
|
return dest
|
|
1140
1141
|
|
|
1141
1142
|
|
|
1143
|
+
def _manifest_resource_href(
|
|
1144
|
+
resource: ET.Element, *, allow_file_href: bool
|
|
1145
|
+
) -> str | None:
|
|
1146
|
+
"""Return a resource's direct href or its first file href.
|
|
1147
|
+
|
|
1148
|
+
Parameters
|
|
1149
|
+
----------
|
|
1150
|
+
resource
|
|
1151
|
+
Manifest resource element from an IMS or Common Cartridge manifest.
|
|
1152
|
+
allow_file_href
|
|
1153
|
+
Whether a child ``file/@href`` may replace an absent direct href.
|
|
1154
|
+
|
|
1155
|
+
Returns
|
|
1156
|
+
-------
|
|
1157
|
+
href
|
|
1158
|
+
Resource href, or ``None`` when neither supported location supplies
|
|
1159
|
+
one.
|
|
1160
|
+
|
|
1161
|
+
Notes
|
|
1162
|
+
-----
|
|
1163
|
+
Canvas Common Cartridge exports place the wrapper assessment path on a
|
|
1164
|
+
child ``file`` element, while simple QTI packages use ``resource/@href``.
|
|
1165
|
+
|
|
1166
|
+
"""
|
|
1167
|
+
href = resource.get("href")
|
|
1168
|
+
if href or not allow_file_href:
|
|
1169
|
+
return href
|
|
1170
|
+
file_node = resource.find("./{*}file")
|
|
1171
|
+
return None if file_node is None else file_node.get("href")
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _validated_manifest_href(
|
|
1175
|
+
resource: ET.Element,
|
|
1176
|
+
members: dict[str, ZipInfo],
|
|
1177
|
+
description: str,
|
|
1178
|
+
*,
|
|
1179
|
+
allow_file_href: bool = False,
|
|
1180
|
+
) -> str:
|
|
1181
|
+
"""Return a safe archive member path declared by a resource.
|
|
1182
|
+
|
|
1183
|
+
Parameters
|
|
1184
|
+
----------
|
|
1185
|
+
resource
|
|
1186
|
+
Manifest resource containing a direct or child-file href.
|
|
1187
|
+
members
|
|
1188
|
+
Validated archive members keyed by their exact ZIP names.
|
|
1189
|
+
description
|
|
1190
|
+
Resource description included in a rejected-path diagnostic.
|
|
1191
|
+
allow_file_href
|
|
1192
|
+
Whether a child ``file/@href`` may replace an absent direct href.
|
|
1193
|
+
|
|
1194
|
+
Returns
|
|
1195
|
+
-------
|
|
1196
|
+
href
|
|
1197
|
+
Relative path that names an existing unique archive member.
|
|
1198
|
+
|
|
1199
|
+
Raises
|
|
1200
|
+
------
|
|
1201
|
+
ValueError
|
|
1202
|
+
If the href is absent, absolute, parent-relative, or missing from the
|
|
1203
|
+
validated archive.
|
|
1204
|
+
|
|
1205
|
+
"""
|
|
1206
|
+
href = _manifest_resource_href(resource, allow_file_href=allow_file_href)
|
|
1207
|
+
if (
|
|
1208
|
+
not href
|
|
1209
|
+
or href not in members
|
|
1210
|
+
or href.startswith("/")
|
|
1211
|
+
or ".." in Path(href).parts
|
|
1212
|
+
):
|
|
1213
|
+
raise ValueError(f"{description} href missing or invalid")
|
|
1214
|
+
return href
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _resolve_qti_member(
|
|
1218
|
+
archive: ZipFile,
|
|
1219
|
+
members: dict[str, ZipInfo],
|
|
1220
|
+
manifest_root: ET.Element,
|
|
1221
|
+
) -> tuple[str, ET.Element]:
|
|
1222
|
+
"""Resolve a direct QTI assessment or Canvas-referenced object bank.
|
|
1223
|
+
|
|
1224
|
+
Parameters
|
|
1225
|
+
----------
|
|
1226
|
+
archive
|
|
1227
|
+
Open archive containing the already parsed manifest.
|
|
1228
|
+
members
|
|
1229
|
+
Validated archive members keyed by exact ZIP names.
|
|
1230
|
+
manifest_root
|
|
1231
|
+
Parsed IMS or Common Cartridge manifest document.
|
|
1232
|
+
|
|
1233
|
+
Returns
|
|
1234
|
+
-------
|
|
1235
|
+
assessment_file
|
|
1236
|
+
Archive member containing the question items.
|
|
1237
|
+
assessment_root
|
|
1238
|
+
Parsed QTI root from that member.
|
|
1239
|
+
|
|
1240
|
+
Raises
|
|
1241
|
+
------
|
|
1242
|
+
ValueError
|
|
1243
|
+
If the manifest does not contain exactly one QTI resource, its href
|
|
1244
|
+
is invalid, or a Canvas source-bank reference is absent or ambiguous.
|
|
1245
|
+
|
|
1246
|
+
Notes
|
|
1247
|
+
-----
|
|
1248
|
+
Simple packages store items in the QTI resource itself. Canvas item-bank
|
|
1249
|
+
exports instead store a wrapper assessment there; its sourcebank_ref
|
|
1250
|
+
identifies an associated manifest resource containing the actual items.
|
|
1251
|
+
|
|
1252
|
+
"""
|
|
1253
|
+
supported_manifest_roots = {
|
|
1254
|
+
f"{{{IMS}}}manifest",
|
|
1255
|
+
f"{{{IMS_CC}}}manifest",
|
|
1256
|
+
}
|
|
1257
|
+
if manifest_root.tag not in supported_manifest_roots:
|
|
1258
|
+
raise ValueError("Incorrect IMS manifest namespace or root")
|
|
1259
|
+
resources = manifest_root.findall(".//{*}resource")
|
|
1260
|
+
qti_resources = [
|
|
1261
|
+
resource
|
|
1262
|
+
for resource in resources
|
|
1263
|
+
if resource.get("type") == "imsqti_xmlv1p2"
|
|
1264
|
+
]
|
|
1265
|
+
if len(qti_resources) != 1:
|
|
1266
|
+
raise ValueError("Missing/incorrect QTI 1.2 assessment resource")
|
|
1267
|
+
|
|
1268
|
+
common_cartridge = manifest_root.tag == f"{{{IMS_CC}}}manifest"
|
|
1269
|
+
qti_href = _validated_manifest_href(
|
|
1270
|
+
qti_resources[0],
|
|
1271
|
+
members,
|
|
1272
|
+
"Manifest QTI",
|
|
1273
|
+
allow_file_href=common_cartridge,
|
|
1274
|
+
)
|
|
1275
|
+
qti_root = parse_xml(read_member(archive, members[qti_href]))
|
|
1276
|
+
source_refs = {
|
|
1277
|
+
(node.text or "").strip()
|
|
1278
|
+
for node in qti_root.findall(f".//{{{QTI}}}sourcebank_ref")
|
|
1279
|
+
if (node.text or "").strip()
|
|
1280
|
+
}
|
|
1281
|
+
if not source_refs:
|
|
1282
|
+
return qti_href, qti_root
|
|
1283
|
+
if len(source_refs) != 1:
|
|
1284
|
+
raise ValueError("Canvas QTI source bank reference is ambiguous")
|
|
1285
|
+
|
|
1286
|
+
source_ref = next(iter(source_refs))
|
|
1287
|
+
matches = [
|
|
1288
|
+
resource
|
|
1289
|
+
for resource in resources
|
|
1290
|
+
if resource.get("identifier") == source_ref
|
|
1291
|
+
]
|
|
1292
|
+
if len(matches) != 1:
|
|
1293
|
+
raise ValueError("Canvas QTI source bank reference is missing")
|
|
1294
|
+
bank_href = _validated_manifest_href(
|
|
1295
|
+
matches[0],
|
|
1296
|
+
members,
|
|
1297
|
+
"Manifest source bank",
|
|
1298
|
+
allow_file_href=common_cartridge,
|
|
1299
|
+
)
|
|
1300
|
+
bank_root = parse_xml(read_member(archive, members[bank_href]))
|
|
1301
|
+
return bank_href, bank_root
|
|
1302
|
+
|
|
1303
|
+
|
|
1142
1304
|
def verify_qti(path: str | Path) -> QTIReport:
|
|
1143
1305
|
"""Check supported archive structure, identifiers, and scoring shape.
|
|
1144
1306
|
|
|
@@ -1168,12 +1330,13 @@ def verify_qti(path: str | Path) -> QTIReport:
|
|
|
1168
1330
|
-----
|
|
1169
1331
|
Applies the shared QTI archive limits before reading XML. Requires unique
|
|
1170
1332
|
nonempty item IDs, at least two response labels, one varequal referencing
|
|
1171
|
-
a choice ID, two setvar elements per item, and any supplied
|
|
1172
|
-
shuffle value to be Yes or No.
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
Canvas rendering require
|
|
1333
|
+
a choice ID, one or two setvar elements per item, and any supplied
|
|
1334
|
+
render_choice shuffle value to be Yes or No. Resolves either a direct QTI
|
|
1335
|
+
resource or one Canvas Common Cartridge source-bank reference and rejects
|
|
1336
|
+
absent, absolute, or parent-relative member paths. XML parsing forbids DTDs
|
|
1337
|
+
and entities. This is not complete QTI validation: score values, supported
|
|
1338
|
+
presentation content, bank schema constraints, and Canvas rendering require
|
|
1339
|
+
other checks.
|
|
1177
1340
|
|
|
1178
1341
|
""" # noqa: DOC503
|
|
1179
1342
|
with ZipFile(path) as zf:
|
|
@@ -1182,18 +1345,7 @@ def verify_qti(path: str | Path) -> QTIReport:
|
|
|
1182
1345
|
if manifest is None:
|
|
1183
1346
|
raise ValueError("QTI package missing root imsmanifest.xml")
|
|
1184
1347
|
meta = parse_xml(read_member(zf, manifest))
|
|
1185
|
-
|
|
1186
|
-
if resource is None or resource.get("type") != "imsqti_xmlv1p2":
|
|
1187
|
-
raise ValueError("Missing/incorrect QTI 1.2 assessment resource")
|
|
1188
|
-
href = resource.get("href")
|
|
1189
|
-
if (
|
|
1190
|
-
not href
|
|
1191
|
-
or href not in members
|
|
1192
|
-
or href.startswith("/")
|
|
1193
|
-
or ".." in Path(href).parts
|
|
1194
|
-
):
|
|
1195
|
-
raise ValueError("Manifest QTI href missing or invalid")
|
|
1196
|
-
root = parse_xml(read_member(zf, members[href]))
|
|
1348
|
+
href, root = _resolve_qti_member(zf, members, meta)
|
|
1197
1349
|
if root.tag != f"{{{QTI}}}questestinterop":
|
|
1198
1350
|
raise ValueError("Incorrect QTI 1.2 namespace or root")
|
|
1199
1351
|
items = root.findall(f".//{{{QTI}}}item")
|
|
@@ -1223,10 +1375,8 @@ def verify_qti(path: str | Path) -> QTIReport:
|
|
|
1223
1375
|
)
|
|
1224
1376
|
if len(val) != 1 or val[0].text not in {x.get("ident") for x in lids}:
|
|
1225
1377
|
raise ValueError(f"Missing or invalid correct-answer key for {iid}")
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
!= MIN_CHOICES
|
|
1229
|
-
):
|
|
1378
|
+
score_rules = item.findall(f".//{{{QTI}}}respcondition/{{{QTI}}}setvar")
|
|
1379
|
+
if len(score_rules) not in {1, MIN_CHOICES}:
|
|
1230
1380
|
raise ValueError(f"Unexpected scoring rules for {iid}")
|
|
1231
1381
|
return {
|
|
1232
1382
|
"count": len(items),
|
|
@@ -15,7 +15,7 @@ from zipfile import ZipFile
|
|
|
15
15
|
import yaml
|
|
16
16
|
|
|
17
17
|
from .archive_io import read_member, validate_archive
|
|
18
|
-
from .core import MIN_CHOICES, QTI, parse_bank, verify_qti
|
|
18
|
+
from .core import MIN_CHOICES, QTI, SAFE_ID, parse_bank, verify_qti
|
|
19
19
|
from .models import Bank
|
|
20
20
|
from .xml_io import parse_xml
|
|
21
21
|
from .yaml_data import YamlMapping, YamlValue, yaml_value
|
|
@@ -127,7 +127,7 @@ def import_qti(
|
|
|
127
127
|
Unclassified for area/topic, Moderate for difficulty, unrestricted
|
|
128
128
|
eligibility, 2.5 minutes, 2 points, and unshuffled choices when QTI omits
|
|
129
129
|
the shuffle attribute. CSV overrides and QTI metadata are resolved before
|
|
130
|
-
schema validation;
|
|
130
|
+
schema validation; absent feedback receives an explicit placeholder.
|
|
131
131
|
|
|
132
132
|
""" # noqa: DOC503
|
|
133
133
|
report = verify_qti(path)
|
|
@@ -403,25 +403,28 @@ def _parse_correct_answer(
|
|
|
403
403
|
|
|
404
404
|
|
|
405
405
|
def _parse_explanation(item: Element, ident: str) -> str:
|
|
406
|
-
"""Extract
|
|
406
|
+
"""Extract feedback or describe its absence explicitly.
|
|
407
407
|
|
|
408
408
|
Parameters
|
|
409
409
|
----------
|
|
410
410
|
item
|
|
411
411
|
Parsed QTI item containing itemfeedback/flow_mat/material/mattext.
|
|
412
412
|
ident
|
|
413
|
-
Question identifier used in
|
|
413
|
+
Question identifier used in malformed-feedback diagnostics.
|
|
414
414
|
|
|
415
415
|
Returns
|
|
416
416
|
-------
|
|
417
417
|
explanation
|
|
418
|
-
Text from the first matching feedback element
|
|
419
|
-
|
|
418
|
+
Text from the first matching feedback element. If QTI contains no
|
|
419
|
+
feedback container, an explicit nonempty placeholder states that the
|
|
420
|
+
imported package supplied no explanation. An empty feedback text
|
|
421
|
+
value returns an empty string for bank validation to reject.
|
|
420
422
|
|
|
421
423
|
Raises
|
|
422
424
|
------
|
|
423
425
|
ValueError
|
|
424
|
-
If
|
|
426
|
+
If an itemfeedback container exists without the supported nested
|
|
427
|
+
mattext structure.
|
|
425
428
|
|
|
426
429
|
"""
|
|
427
430
|
feedback = item.find(
|
|
@@ -429,12 +432,48 @@ def _parse_explanation(item: Element, ident: str) -> str:
|
|
|
429
432
|
f"material/{{{QTI}}}mattext"
|
|
430
433
|
)
|
|
431
434
|
if feedback is None:
|
|
432
|
-
|
|
433
|
-
f"Missing explanation in {ident}
|
|
434
|
-
|
|
435
|
+
if item.find(f"./{{{QTI}}}itemfeedback") is not None:
|
|
436
|
+
raise ValueError(f"Missing explanation in {ident}")
|
|
437
|
+
return "No explanation was provided in the imported QTI package."
|
|
435
438
|
return feedback.text or ""
|
|
436
439
|
|
|
437
440
|
|
|
441
|
+
def _normalize_question_id(ident: str) -> str:
|
|
442
|
+
"""Make a digit-leading QTI identifier valid for the YAML schema.
|
|
443
|
+
|
|
444
|
+
Parameters
|
|
445
|
+
----------
|
|
446
|
+
ident
|
|
447
|
+
Nonempty source identifier from the QTI item.
|
|
448
|
+
|
|
449
|
+
Returns
|
|
450
|
+
-------
|
|
451
|
+
question_id
|
|
452
|
+
Original identifier when already valid, or the identifier prefixed
|
|
453
|
+
with ``q_`` when its only schema conflict is a nonletter first
|
|
454
|
+
character.
|
|
455
|
+
|
|
456
|
+
Raises
|
|
457
|
+
------
|
|
458
|
+
ValueError
|
|
459
|
+
If characters after the optional prefix remain outside the YAML
|
|
460
|
+
identifier grammar.
|
|
461
|
+
|
|
462
|
+
Notes
|
|
463
|
+
-----
|
|
464
|
+
Canvas commonly emits hexadecimal item identifiers beginning with a
|
|
465
|
+
digit. Prefixing preserves every source character and is deterministic.
|
|
466
|
+
Final bank validation detects any collision with an existing identifier.
|
|
467
|
+
|
|
468
|
+
"""
|
|
469
|
+
if SAFE_ID.fullmatch(ident):
|
|
470
|
+
return ident
|
|
471
|
+
normalized = f"q_{ident}"
|
|
472
|
+
if SAFE_ID.fullmatch(normalized):
|
|
473
|
+
return normalized
|
|
474
|
+
raise ValueError(f"Unsupported QTI item identifier {ident!r}")
|
|
475
|
+
|
|
476
|
+
|
|
438
477
|
def _resolve_eligibility(
|
|
439
478
|
e: dict[str, str], metadata: dict[str, str]
|
|
440
479
|
) -> YamlMapping:
|
|
@@ -560,10 +599,10 @@ def _import_item(
|
|
|
560
599
|
Returns
|
|
561
600
|
-------
|
|
562
601
|
question
|
|
563
|
-
Unvalidated YAML mapping containing
|
|
564
|
-
choices, effective shuffling, translated answer,
|
|
565
|
-
authoring fields. QTI text/plain and text/html
|
|
566
|
-
respectively.
|
|
602
|
+
Unvalidated YAML mapping containing normalized identity, question
|
|
603
|
+
content, relabeled choices, effective shuffling, translated answer,
|
|
604
|
+
feedback, and resolved authoring fields. QTI text/plain and text/html
|
|
605
|
+
become plain and html respectively.
|
|
567
606
|
|
|
568
607
|
Raises
|
|
569
608
|
------
|
|
@@ -590,7 +629,7 @@ def _import_item(
|
|
|
590
629
|
item, ident, extras.get(ident, {}), metadata
|
|
591
630
|
)
|
|
592
631
|
return {
|
|
593
|
-
"id": ident,
|
|
632
|
+
"id": _normalize_question_id(ident),
|
|
594
633
|
**authoring,
|
|
595
634
|
"format": "html"
|
|
596
635
|
if presentation.text_format == "text/html"
|
|
@@ -117,7 +117,9 @@ class QTIReport(TypedDict):
|
|
|
117
117
|
ids
|
|
118
118
|
Question identifiers in document order.
|
|
119
119
|
assessment_file
|
|
120
|
-
Verified ZIP member
|
|
120
|
+
Verified ZIP member containing the items. For a Canvas Common
|
|
121
|
+
Cartridge export, this is the object bank resolved through the QTI
|
|
122
|
+
wrapper's source-bank reference.
|
|
121
123
|
|
|
122
124
|
Notes
|
|
123
125
|
-----
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<?xml version="1.0"?>
|
|
2
|
+
<questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2">
|
|
3
|
+
<assessment ident="assessment" title="Canvas export">
|
|
4
|
+
<section ident="root_section">
|
|
5
|
+
<section ident="bank_section" title="Canvas item bank">
|
|
6
|
+
<selection_ordering>
|
|
7
|
+
<selection>
|
|
8
|
+
<sourcebank_ref>bank_resource</sourcebank_ref>
|
|
9
|
+
</selection>
|
|
10
|
+
</selection_ordering>
|
|
11
|
+
</section>
|
|
12
|
+
</section>
|
|
13
|
+
</assessment>
|
|
14
|
+
</questestinterop>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<?xml version="1.0"?>
|
|
2
|
+
<questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2">
|
|
3
|
+
<objectbank ident="bank_resource" canvas_item_bank="true">
|
|
4
|
+
<item ident="03be6638087ef4d5e99c18e8d9c3267e"
|
|
5
|
+
title="EX18 - Successful Execution">
|
|
6
|
+
<itemmetadata>
|
|
7
|
+
<qtimetadata>
|
|
8
|
+
<qtimetadatafield>
|
|
9
|
+
<fieldlabel>question_type</fieldlabel>
|
|
10
|
+
<fieldentry>multiple_choice_question</fieldentry>
|
|
11
|
+
</qtimetadatafield>
|
|
12
|
+
<qtimetadatafield>
|
|
13
|
+
<fieldlabel>points_possible</fieldlabel>
|
|
14
|
+
<fieldentry/>
|
|
15
|
+
</qtimetadatafield>
|
|
16
|
+
</qtimetadata>
|
|
17
|
+
</itemmetadata>
|
|
18
|
+
<presentation>
|
|
19
|
+
<material>
|
|
20
|
+
<mattext texttype="text/html">What does successful execution establish?</mattext>
|
|
21
|
+
</material>
|
|
22
|
+
<response_lid ident="response1" rcardinality="Single">
|
|
23
|
+
<render_choice>
|
|
24
|
+
<response_label ident="incorrect-answer">
|
|
25
|
+
<material>
|
|
26
|
+
<mattext texttype="text/html">The engineering result is correct.</mattext>
|
|
27
|
+
</material>
|
|
28
|
+
</response_label>
|
|
29
|
+
<response_label ident="correct-answer">
|
|
30
|
+
<material>
|
|
31
|
+
<mattext texttype="text/html">The code path completed without an exception.</mattext>
|
|
32
|
+
</material>
|
|
33
|
+
</response_label>
|
|
34
|
+
</render_choice>
|
|
35
|
+
</response_lid>
|
|
36
|
+
</presentation>
|
|
37
|
+
<resprocessing>
|
|
38
|
+
<outcomes>
|
|
39
|
+
<decvar maxvalue="100" minvalue="0" varname="SCORE"
|
|
40
|
+
vartype="Decimal"/>
|
|
41
|
+
</outcomes>
|
|
42
|
+
<respcondition continue="No">
|
|
43
|
+
<conditionvar>
|
|
44
|
+
<varequal respident="response1">correct-answer</varequal>
|
|
45
|
+
</conditionvar>
|
|
46
|
+
<setvar action="Set" varname="SCORE">100</setvar>
|
|
47
|
+
</respcondition>
|
|
48
|
+
</resprocessing>
|
|
49
|
+
</item>
|
|
50
|
+
</objectbank>
|
|
51
|
+
</questestinterop>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<?xml version="1.0"?>
|
|
2
|
+
<manifest xmlns="http://www.imsglobal.org/xsd/imsccv1p1/imscp_v1p1"
|
|
3
|
+
identifier="canvas_export">
|
|
4
|
+
<resources>
|
|
5
|
+
<resource identifier="assessment" type="imsqti_xmlv1p2">
|
|
6
|
+
<file href="assessment/assessment.xml"/>
|
|
7
|
+
<dependency identifierref="bank_resource"/>
|
|
8
|
+
</resource>
|
|
9
|
+
<resource identifier="bank_resource"
|
|
10
|
+
type="associatedcontent/imscc_xmlv1p1/learning-application-resource"
|
|
11
|
+
href="non_cc_assessments/bank.xml.qti">
|
|
12
|
+
<file href="non_cc_assessments/bank.xml.qti"/>
|
|
13
|
+
</resource>
|
|
14
|
+
</resources>
|
|
15
|
+
</manifest>
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Verify import of Canvas Common Cartridge item-bank exports.
|
|
2
|
+
|
|
3
|
+
Package a minimal independently authored Canvas-style export and require its
|
|
4
|
+
manifest indirection, scoring, identifiers, and omitted fields to normalize
|
|
5
|
+
into the documented YAML schema.
|
|
6
|
+
|
|
7
|
+
Attributes
|
|
8
|
+
----------
|
|
9
|
+
FIXTURE_DIR
|
|
10
|
+
Directory containing the Canvas-style manifest and QTI members.
|
|
11
|
+
MISSING_EXPLANATION
|
|
12
|
+
Explicit placeholder used when Canvas exports no item feedback.
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from copy import deepcopy
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# ElementTree is used only to serialize locally modified test XML.
|
|
20
|
+
from xml.etree import ElementTree as ET # ruff: ignore[suspicious-xml-etree-import]
|
|
21
|
+
from zipfile import ZipFile
|
|
22
|
+
|
|
23
|
+
import pytest
|
|
24
|
+
|
|
25
|
+
from coursebanks.core import QTI, verify_qti
|
|
26
|
+
from coursebanks.migrate import import_qti
|
|
27
|
+
from coursebanks.xml_io import parse_xml
|
|
28
|
+
|
|
29
|
+
FIXTURE_DIR = Path(__file__).parent / "fixtures" / "canvas_item_bank"
|
|
30
|
+
MISSING_EXPLANATION = "No explanation was provided in the imported QTI package."
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fixture_members() -> dict[str, bytes]:
|
|
34
|
+
"""Return Canvas-style fixture bytes keyed by archive member path.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
members
|
|
39
|
+
Fresh mapping suitable for mutation by one boundary test.
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
return {
|
|
43
|
+
"imsmanifest.xml": FIXTURE_DIR.joinpath("imsmanifest.xml").read_bytes(),
|
|
44
|
+
"assessment/assessment.xml": FIXTURE_DIR.joinpath(
|
|
45
|
+
"assessment.xml"
|
|
46
|
+
).read_bytes(),
|
|
47
|
+
"non_cc_assessments/bank.xml.qti": FIXTURE_DIR.joinpath(
|
|
48
|
+
"bank.xml.qti"
|
|
49
|
+
).read_bytes(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write_archive(path: Path, members: dict[str, bytes]) -> Path:
|
|
54
|
+
"""Write a Canvas-style package with supplied member bytes.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
path
|
|
59
|
+
Destination ZIP path, overwritten if already present.
|
|
60
|
+
members
|
|
61
|
+
Archive paths mapped to payload bytes.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
path
|
|
66
|
+
Supplied destination after all members have been written.
|
|
67
|
+
|
|
68
|
+
"""
|
|
69
|
+
with ZipFile(path, "w") as archive:
|
|
70
|
+
for name, payload in members.items():
|
|
71
|
+
archive.writestr(name, payload)
|
|
72
|
+
return path
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.fixture
|
|
76
|
+
def canvas_archive(tmp_path: Path) -> Path:
|
|
77
|
+
"""Package the Canvas-style XML fixtures under their manifest paths.
|
|
78
|
+
|
|
79
|
+
Parameters
|
|
80
|
+
----------
|
|
81
|
+
tmp_path
|
|
82
|
+
Pytest directory receiving the generated Common Cartridge archive.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
archive_path
|
|
87
|
+
ZIP containing a wrapper assessment and referenced object bank.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
archive_path = tmp_path / "canvas-export.zip"
|
|
91
|
+
return _write_archive(archive_path, _fixture_members())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_canvas_item_bank_import(canvas_archive: Path) -> None:
|
|
95
|
+
"""Resolve and import a Canvas object bank through its wrapper.
|
|
96
|
+
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
canvas_archive
|
|
100
|
+
Minimal Canvas Common Cartridge export built by the fixture.
|
|
101
|
+
|
|
102
|
+
Notes
|
|
103
|
+
-----
|
|
104
|
+
The source item has a digit-leading identifier, one full-credit scoring
|
|
105
|
+
rule, empty point metadata, no shuffle attribute, and no feedback.
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
report = verify_qti(canvas_archive)
|
|
109
|
+
assert report == {
|
|
110
|
+
"count": 1,
|
|
111
|
+
"ids": ["03be6638087ef4d5e99c18e8d9c3267e"],
|
|
112
|
+
"assessment_file": "non_cc_assessments/bank.xml.qti",
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
bank = import_qti(
|
|
116
|
+
canvas_archive,
|
|
117
|
+
"AERO3300",
|
|
118
|
+
"Module01",
|
|
119
|
+
"Canvas item bank",
|
|
120
|
+
)
|
|
121
|
+
assert bank["questions"] == [
|
|
122
|
+
{
|
|
123
|
+
"id": "q_03be6638087ef4d5e99c18e8d9c3267e",
|
|
124
|
+
"title": "EX18 - Successful Execution",
|
|
125
|
+
"area": "Unclassified",
|
|
126
|
+
"topic": "Unclassified",
|
|
127
|
+
"difficulty": "Moderate",
|
|
128
|
+
"assessments": [],
|
|
129
|
+
"minutes": 2.5,
|
|
130
|
+
"points": 2,
|
|
131
|
+
"format": "html",
|
|
132
|
+
"stem": "What does successful execution establish?",
|
|
133
|
+
"choices": {
|
|
134
|
+
"A": "The engineering result is correct.",
|
|
135
|
+
"B": "The code path completed without an exception.",
|
|
136
|
+
},
|
|
137
|
+
"shuffle_choices": False,
|
|
138
|
+
"answer": "B",
|
|
139
|
+
"explanation": MISSING_EXPLANATION,
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_canvas_item_bank_rejects_ambiguous_reference(
|
|
145
|
+
tmp_path: Path,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Reject a wrapper that identifies more than one source bank.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
tmp_path
|
|
152
|
+
Pytest directory receiving the malformed package.
|
|
153
|
+
|
|
154
|
+
"""
|
|
155
|
+
members = _fixture_members()
|
|
156
|
+
root = parse_xml(members["assessment/assessment.xml"])
|
|
157
|
+
reference = root.find(f".//{{{QTI}}}sourcebank_ref")
|
|
158
|
+
assert reference is not None
|
|
159
|
+
duplicate = deepcopy(reference)
|
|
160
|
+
duplicate.text = "another_bank"
|
|
161
|
+
reference_parent = root.find(f".//{{{QTI}}}selection")
|
|
162
|
+
assert reference_parent is not None
|
|
163
|
+
reference_parent.append(duplicate)
|
|
164
|
+
members["assessment/assessment.xml"] = ET.tostring(root)
|
|
165
|
+
archive = _write_archive(tmp_path / "ambiguous.zip", members)
|
|
166
|
+
|
|
167
|
+
with pytest.raises(ValueError, match="source bank reference is ambiguous"):
|
|
168
|
+
verify_qti(archive)
|
|
169
|
+
with pytest.raises(ValueError, match="source bank reference is ambiguous"):
|
|
170
|
+
import_qti(archive, "COURSE", "Bank", "Imported")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_canvas_item_bank_rejects_unsafe_bank_href(tmp_path: Path) -> None:
|
|
174
|
+
"""Reject a parent-relative path on the referenced bank resource.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
tmp_path
|
|
179
|
+
Pytest directory receiving the malformed package.
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
members = _fixture_members()
|
|
183
|
+
root = parse_xml(members["imsmanifest.xml"])
|
|
184
|
+
bank_resource = next(
|
|
185
|
+
resource
|
|
186
|
+
for resource in root.findall(".//{*}resource")
|
|
187
|
+
if resource.get("identifier") == "bank_resource"
|
|
188
|
+
)
|
|
189
|
+
bank_resource.set("href", "../bank.xml.qti")
|
|
190
|
+
members["../bank.xml.qti"] = members["non_cc_assessments/bank.xml.qti"]
|
|
191
|
+
members["imsmanifest.xml"] = ET.tostring(root)
|
|
192
|
+
archive = _write_archive(tmp_path / "unsafe.zip", members)
|
|
193
|
+
|
|
194
|
+
with pytest.raises(ValueError, match="source bank href missing or invalid"):
|
|
195
|
+
verify_qti(archive)
|
|
196
|
+
with pytest.raises(ValueError, match="source bank href missing or invalid"):
|
|
197
|
+
import_qti(archive, "COURSE", "Bank", "Imported")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def test_canvas_item_bank_rejects_wrong_manifest_namespace(
|
|
201
|
+
tmp_path: Path,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Reject resource-like XML outside supported manifest namespaces.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
tmp_path
|
|
208
|
+
Pytest directory receiving the malformed package.
|
|
209
|
+
|
|
210
|
+
"""
|
|
211
|
+
members = _fixture_members()
|
|
212
|
+
root = parse_xml(members["imsmanifest.xml"])
|
|
213
|
+
root.tag = "{urn:unsupported}manifest"
|
|
214
|
+
members["imsmanifest.xml"] = ET.tostring(root)
|
|
215
|
+
archive = _write_archive(tmp_path / "wrong-manifest.zip", members)
|
|
216
|
+
|
|
217
|
+
with pytest.raises(ValueError, match="manifest namespace or root"):
|
|
218
|
+
verify_qti(archive)
|
|
219
|
+
with pytest.raises(ValueError, match="manifest namespace or root"):
|
|
220
|
+
import_qti(archive, "COURSE", "Bank", "Imported")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/examples/sample_course/questions.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/import_qti/assessment.xml
RENAMED
|
File without changes
|
{questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/import_qti/imsmanifest.xml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|