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.
Files changed (69) hide show
  1. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/CHANGELOG.md +11 -0
  2. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/MANIFEST.in +1 -1
  3. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/PKG-INFO +2 -2
  4. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/README.md +1 -1
  5. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/QTI_specification.md +8 -8
  6. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/cleanup-plan.md +17 -20
  7. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/importing-qti.md +6 -5
  8. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/releasing.md +3 -2
  9. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/pyproject.toml +2 -2
  10. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/check_distribution.sh +3 -0
  11. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/PKG-INFO +2 -2
  12. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/SOURCES.txt +4 -0
  13. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/__init__.py +1 -1
  14. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/core.py +172 -22
  15. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/migrate.py +54 -15
  16. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/models.py +3 -1
  17. questionbanktoolkit-0.1.1/tests/fixtures/canvas_item_bank/assessment.xml +14 -0
  18. questionbanktoolkit-0.1.1/tests/fixtures/canvas_item_bank/bank.xml.qti +51 -0
  19. questionbanktoolkit-0.1.1/tests/fixtures/canvas_item_bank/imsmanifest.xml +15 -0
  20. questionbanktoolkit-0.1.1/tests/test_canvas_qti_import.py +220 -0
  21. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/.woodpecker/checks.yaml +0 -0
  22. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/LICENSE +0 -0
  23. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/api.rst +0 -0
  24. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/canvas-import.md +0 -0
  25. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/commands.md +0 -0
  26. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/conf.py +0 -0
  27. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/development.md +0 -0
  28. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/development_roadmap.md +0 -0
  29. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/getting-started.md +0 -0
  30. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/index.md +0 -0
  31. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/docs/schema.md +0 -0
  32. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/examples/sample_course/questions.yaml +0 -0
  33. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/newsfragments/README.md +0 -0
  34. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/build_docs.sh +0 -0
  35. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/check_release.sh +0 -0
  36. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/prepare_release.sh +0 -0
  37. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/publish_docs.sh +0 -0
  38. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/scripts/run_checks.sh +0 -0
  39. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/setup.cfg +0 -0
  40. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/dependency_links.txt +0 -0
  41. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/entry_points.txt +0 -0
  42. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/requires.txt +0 -0
  43. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/QuestionBankToolkit.egg-info/top_level.txt +0 -0
  44. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/__main__.py +0 -0
  45. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/archive_io.py +0 -0
  46. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/assessments.py +0 -0
  47. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/cli.py +0 -0
  48. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/py.typed +0 -0
  49. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/rotation.py +0 -0
  50. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/xml_io.py +0 -0
  51. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/src/coursebanks/yaml_data.py +0 -0
  52. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/export_bank.yaml +0 -0
  53. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/import_qti/assessment.xml +0 -0
  54. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/import_qti/imsmanifest.xml +0 -0
  55. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/fixtures/sample_bank.yaml +0 -0
  56. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_archive_limits.py +0 -0
  57. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_assessments.py +0 -0
  58. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_boundary_failures.py +0 -0
  59. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_build_artifacts.py +0 -0
  60. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_cli_contracts.py +0 -0
  61. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_optimized_validation.py +0 -0
  62. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_qti_export.py +0 -0
  63. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_qti_failures.py +0 -0
  64. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_qti_import_fidelity.py +0 -0
  65. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_questionsbanks.py +0 -0
  66. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_typed_boundaries.py +0 -0
  67. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_validation.py +0 -0
  68. {questionbanktoolkit-0.1.0 → questionbanktoolkit-0.1.1}/tests/test_xml_security.py +0 -0
  69. {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
@@ -6,4 +6,4 @@ recursive-include docs *.md *.py *.rst
6
6
  recursive-include examples *.yaml
7
7
  recursive-include newsfragments *.md
8
8
  recursive-include scripts *.sh
9
- recursive-include tests *.py *.xml *.yaml
9
+ recursive-include tests *.py *.qti *.xml *.yaml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: QuestionBankToolkit
3
- Version: 0.1.0
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 and rejects structures that cannot be represented without loss.
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 and rejects structures that cannot be represented without loss.
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, 0 for incorrect | Question points are separately stored in metadata |
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 | One general explanation per question | Not distinct feedback for each wrong choice |
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 | Supported subset of QTI 1.2 packages | Unsupported or lossy structures should not be silently converted |
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 one general explanation, then builds a validated YAML bank.
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, and general feedback. It may use an instructor CSV to recover authoring metadata not present in the QTI package. If the format cannot be represented faithfully, import should fail rather than silently discard meaningful information.
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. Thus, the stable question ID is preserved, but 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.
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`, a QTI resource of type `imsqti_xmlv1p2`, and a resolvable relative assessment filename.
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 the expected simple scoring-condition count.
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 406 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 twelve uncovered source statements and thirteen partially covered branches.
22
- These numbers are a snapshot from October 20, 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 local release-preparation workflow.
25
- [Codeberg Woodpecker pipeline 2](https://ci.codeberg.org/repos/18200/pipeline/2) passed on October 20, 2026, using the hosted Python 3.14 environment.
26
- The project has no recorded Canvas acceptance result yet.
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, missing feedback, unsupported text structures, and more than 26 choices.
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
- | Release publication | Changelog assembly, version synchronization, release checks, distribution validation, and local commit and tag instructions are tested. PyPI is the selected package index, and the `QuestionBankToolkit` name appeared unregistered when checked on October 21, 2026. | Build and review the Sphinx site, publish it through Codeberg Pages, prepare version 0.1.0, and perform the first PyPI upload with an account token before replacing it with a project-scoped token. |
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 referenced QTI assessment resource.
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: Public documentation and PyPI release
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
- Export and reimport representative Canvas-produced packages to verify the documented importer scope.
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 references the assessment XML member.
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 required general feedback `mattext`; a missing explanation is rejected to prevent a lossy import. |
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` and requires general feedback text.
97
- Unsupported response types, partial or ambiguous scoring, missing identifiers, inconsistent text formats, missing feedback, media dependencies, and unrepresentable content are rejected rather than silently discarded.
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
- Then let Twine prompt for the username `__token__` and the API token:
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.0"
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.0"
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: QuestionBankToolkit
3
- Version: 0.1.0
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 and rejects structures that cannot be represented without loss.
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
@@ -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
@@ -12,4 +12,4 @@ __version__
12
12
 
13
13
  """
14
14
 
15
- __version__ = "0.1.0"
15
+ __version__ = "0.1.1"
@@ -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 render_choice
1172
- shuffle value to be Yes or No. Checks the first manifest resource and
1173
- rejects absent, absolute, or parent-relative member paths. XML parsing
1174
- forbids DTDs and entities. This is not complete QTI validation: score
1175
- values, supported presentation content, bank schema constraints, and
1176
- Canvas rendering require other checks.
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
- resource = meta.find(f".//{{{IMS}}}resource")
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
- if (
1227
- len(item.findall(f".//{{{QTI}}}respcondition/{{{QTI}}}setvar"))
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; missing explanations are rejected.
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 required feedback without silently losing an explanation.
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 error messages.
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, or an empty string
419
- if the element has no text. Bank validation checks for nonempty text.
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 no matching feedback mattext element exists.
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
- raise ValueError(
433
- f"Missing explanation in {ident}; refusing lossy import"
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 question content, relabeled
564
- choices, effective shuffling, translated answer, feedback, and resolved
565
- authoring fields. QTI text/plain and text/html become plain and 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 path referenced by the manifest's QTI resource.
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")