onyx2moodle
Convert ONYX QTI 2.1 question exports — as produced by the OPAL learning
platform (BPS Bildungsportal Sachsen, used at many German universities) —
into Moodle XML question banks. Maxima-graded items become
qtype_stack questions; the rest map to
core Moodle question types.
- What it does
- Install
- Usage
- CLI reference
- Output format
- Optional structural validation
- What gets converted, what doesn't
- How it works internally
- Project layout
- Development
- Releasing
- Contributing
- License
What it does
OPAL exports questions as nested zip archives:
algebra.zip
└── Algebra/Gruppentheorie/Gruppenaxiome_3.zip
├── imsmanifest.xml
├── id<uuid>.xml ← QTI 2.1 assessmentItem
└── *.png ← optional embedded media
onyx2moodle unpacks the tree, classifies each item by its QTI
interaction type plus the ONYX-Maxima extensions
(customOperator definition="MAXIMA", VARIABLESTRING), and emits one
Moodle XML bundle ready for import. The inner folder structure becomes
Moodle category headers ($course$/top/Algebra/Gruppentheorie).
| ONYX item shape | Moodle target |
|---|---|
textEntryInteraction + MAXIMA grading |
qtype_stack (1-node PRT) |
textEntryInteraction + plain string mapping |
shortanswer |
choiceInteraction / single inlineChoiceInteraction |
multichoice |
Multiple inlineChoiceInteraction in one body |
cloze (multianswer) |
extendedTextInteraction |
essay |
uploadInteraction |
essay with file response |
matchInteraction |
matching |
hottextInteraction |
manual rewrite |
MAXIMAGRAPHIC plot grading |
manual rewrite |
Items using <printedVariable> / $(N) template variants |
manual rewrite |
Items that can't be mechanically translated are listed in a
<bundle>.skipped.log beside the output bundle, with the reason —
convenient for triage and manual re-authoring.
Install
From PyPI:
pip install onyx2moodle
From a clone (for development):
git clone https://github.com/patrickmelix/onyx2moodle
cd onyx2moodle
pip install -e ".[dev]"
Requires Python 3.10+. The only runtime dependency is lxml.
Usage
# Classify items and print a coverage report (read-only).
onyx2moodle inventory algebra.zip
# Convert the whole archive to one importable Moodle XML bundle.
onyx2moodle convert algebra.zip -o algebra.moodle.xml
# Restrict to specific Moodle target(s) (repeatable).
onyx2moodle convert algebra.zip -o stack-only.xml --only stack
# Unpack only — useful for inspecting source items.
onyx2moodle unpack algebra.zip --work ./work
Import into Moodle: Question bank → Import → Moodle XML format. STACK
questions require the qtype_stack
plugin on the target Moodle.
CLI reference
onyx2moodle <command> [options]
unpack
Extract an outer ONYX zip into a flat per-item tree on disk.
onyx2moodle unpack <archive.zip> [--work <dir>]
| Flag | Default | Description |
|---|---|---|
--work |
./work |
Where to place the unpacked tree. One subdirectory per item. |
Each unpacked item directory contains item.xml (the QTI assessment
item), manifest.xml (the IMS manifest, if present), an assets/
folder for embedded media, and a _meta.json recording the source
archive path and category breadcrumb.
inventory
Run the parser + classifier on every item in the archive and print a coverage report. No XML is written.
onyx2moodle inventory <archive.zip> [--work <dir>] [--json]
| Flag | Default | Description |
|---|---|---|
--work |
./work |
Same as for unpack. |
--json |
off | Emit raw JSON instead of the text report. |
Sample output:
Inventory: algebra.zip (307 items)
Convertible automatically: 280 (91%)
Target distribution:
essay 191
stack 29
matching 26
shortanswer 18
multichoice 12
cloze 6
manual 24
error 1
Manual / deferred items (top 10 reasons):
23 uses ONYX template variants (printedVariable / $(N) reference) ...
1 hottextInteraction — needs per-item rewrite
...
convert
End-to-end pipeline: unpack → parse → classify → translate → emit.
onyx2moodle convert <archive.zip> -o <bundle.xml>
[--work <dir>]
[--course-root <prefix>]
[--only <target>]...
[--validate]
| Flag | Default | Description |
|---|---|---|
-o, --output |
(required) | Path for the Moodle XML bundle. |
--work |
./work |
Per-archive unpack location. |
--course-root |
$course$/top |
Prefix for Moodle category headers. |
--only |
(all) | Restrict to specific targets: stack, multichoice, shortanswer, essay, matching, cloze. Repeatable. |
--validate |
off | Run the external structural validator on each STACK question. |
Side-effects:
- Writes
<bundle>.xmland<bundle>.skipped.log(one line per skipped item).
Output format
The bundle is a single <quiz> document. Category headers reproduce the
ONYX folder tree:
<?xml version="1.0" encoding="UTF-8"?>
<quiz>
<question type="category">
<category><text>$course$/top/Algebra/Gruppentheorie</text></category>
<info format="html"><text></text></info>
<idnumber></idnumber>
</question>
<question type="stack">
<name><text>Gruppenaxiome 3</text></name>
...
</question>
<question type="multichoice">
...
</question>
</quiz>
For each qtype_stack question we emit a one-node PRT with AlgEquiv:
<questionvariables>
<text>tans_ans1 : {1,3,5,7};</text>
</questionvariables>
<input>
<name>ans1</name>
<type>algebraic</type>
<tans>tans_ans1</tans>
...
</input>
<prt>
<name>prt_ans1</name>
<node>
<name>0</name>
<answertest>AlgEquiv</answertest>
<sans>ans1</sans>
<tans>tans_ans1</tans>
...
</node>
</prt>
ONYX set(...) literals are rewritten to Maxima native sets ({...});
$$...$$ LaTeX delimiters become \(...\) (inline) or \[...\] (display
when a align/equation/gather/multline/eqnarray environment is
detected); embedded <img> references are inlined as base64 <file>
blocks using Moodle's @@PLUGINFILE@@ convention.
Optional structural validation
If you have a STACK structural validator (any script that takes a Moodle
XML file containing a single <question type="stack"> and exits 0 on
pass, non-zero on fail, printing one [WARN] or [FAIL] line per
finding), you can wire it in as a post-emission gate:
export ONYX2MOODLE_VALIDATOR=/path/to/validate.py
onyx2moodle convert algebra.zip -o algebra.moodle.xml --validate
Discovery order:
$ONYX2MOODLE_VALIDATORvalidate.pyonPATH
onyx2moodle does not bundle a validator — pick one that suits your
target Moodle/STACK version.
What gets converted, what doesn't
Each STACK question emits a one-node PRT with AlgEquiv(ans, tans). This
is correct grading, but it has no diagnostic-misconception branches and
no <qtest> self-tests. For pedagogically rich STACK questions
(multi-branch feedback per named misconception, deployed-variant
testing, custom answer notes), re-author the converted item by hand
after import.
Specifically out of scope:
- Diagnostic PRT branches and
<qtest>self-tests. - Randomised question variants (items that use
<printedVariable>orVARIABLESTRING$(N)references are not mechanically translated; they are flagged for manual rewrite). MAXIMAGRAPHICplot-based grading.- Mixed-interaction items (e.g. one body combining
textEntryandchoiceInteraction) — no clean Moodle equivalent.
A representative OPAL course export of ~300 items typically converts
~85–95% automatically; the remainder are flagged for manual review.
Distribution skews heavily towards essay (free-text answers), with a
smaller core of STACK and core Moodle types. Run onyx2moodle inventory
on your archive to see your own breakdown.
How it works internally
Pipeline per item:
- Unpack —
unpack.pyopens the outer zip, walks each inner zip (one per question), and writes the QTI XML + assets to a per-item directory. The directory tree above the inner zip is recorded as thecategory_path. - Parse —
parser.pybuilds a small domain model (AssessmentItem) from the QTI XML using lxml + namespace-aware XPath. Captures response declarations, template bindings, grading rules, modal feedback, and the list of interactions. - Classify —
classifier.pydecides the Moodle target. Includes a defensive rule that defers any item using<printedVariable>or$(N)template references to manual rewrite — these encode random variant logic that can't be mechanically translated to STACK's Maximaquestionvariables. - Translate —
translate/*.pymodules each produce one<question type="...">...</question>block for their target. The STACK translator uses slot-substitution templates intemplates/*.xml; the other translators emit XML directly. - Emit —
emitter.pygroups blocks by category path, writes the<question type="category">headers, and bundles everything into one<quiz>document. - Validate (optional) —
qa.pyextracts each<question type="stack">block, wraps it as a single-question document, and runs your external validator script.
Project layout
src/onyx2moodle/
├── unpack.py # Nested-zip extractor
├── parser.py # QTI 2.1 + ONYX-Maxima -> domain model
├── classifier.py # Per-item routing decisions
├── translate/
│ ├── common.py # Math delim, image embed, body extraction
│ ├── stack.py # qtype_stack with 1-node PRT
│ ├── multichoice.py
│ ├── shortanswer.py
│ ├── essay.py
│ ├── matching.py
│ └── cloze.py
├── render/templates.py # Slot-substitution helpers
├── templates/ # XML templates used during emission
├── emitter.py # Category headers + bundle envelope
├── qa.py # Optional external-validator wrapper
└── cli.py # argparse front-end
Development
git clone https://github.com/patrickmelix/onyx2moodle
cd onyx2moodle
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest # run the suite
ruff check src tests # lint
The test suite covers nested-zip unpacking edge cases, QTI parsing round-trips, the classifier's routing decisions (including the safeguard that defers template-variant items), and end-to-end translation well-formedness for each Moodle target.
CI runs the suite on Python 3.10–3.13 on Linux, with single-Python smoke
runs on macOS and Windows. See
.github/workflows/ci.yml.
Releasing
Releases are tag-driven, published to PyPI via Trusted Publishing (OIDC) — no API tokens to rotate. The two release workflows are:
.github/workflows/release-pypi.yml— fires when av*tag is pushed, builds sdist + wheel, asserts the tag matchespyproject.toml'sversion, and uploads to PyPI..github/workflows/release-testpypi.yml— manualworkflow_dispatch, same build, uploads to TestPyPI for dry runs.
One-time PyPI setup (project maintainer)
- Trusted publisher on PyPI — log in to
https://pypi.org/manage/account/publishing/ and add a pending
publisher with:
- PyPI project name:
onyx2moodle - Owner:
patrickmelix - Repository:
onyx2moodle - Workflow:
release-pypi.yml - Environment:
pypi
- PyPI project name:
- Trusted publisher on TestPyPI — same form at
https://test.pypi.org/manage/account/publishing/ with workflow
release-testpypi.ymland environmenttestpypi. - GitHub environments — under Settings → Environments, create
pypiandtestpypi. Optionally add a required reviewer topypiso each release requires a human click.
Cutting a release
# 1. Bump the version
$EDITOR pyproject.toml # change `version = "..."`
git commit -am "Release v0.2.0"
# 2. Tag and push
git tag v0.2.0
git push origin main --tags
The Release to PyPI workflow takes it from there.
To dry-run on TestPyPI first, bump the version to a pre-release suffix
(e.g. 0.2.0rc1) and run Release to TestPyPI via the Actions tab,
then install with:
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
onyx2moodle
Contributing
Issues and pull requests welcome at
https://github.com/patrickmelix/onyx2moodle/issues. Please run the
test suite (pytest) and the linter (ruff check src tests) before
submitting.
License
Metadata
Release files for onyx2moodle 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| onyx2moodle-0.1.0.tar.gz | 41.9 kB | With provenance | Details for onyx2moodle-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| onyx2moodle-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for onyx2moodle-0.1.0-py3-none-any.whl |
Total release size: 83.4 kB
Release files / onyx2moodle-0.1.0.tar.gz
| Download URL | onyx2moodle-0.1.0.tar.gz |
|---|---|
| Size | 41.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e821e74d3abbb9d8c9e803bb84c68bcf9d282b086864ff3b904c6147f16cb158
|
|
BLAKE2b-256 checksum How to use checksums |
865f7a30e111ee3ad133652342beac41e4f62efd0f7d9d53739b5135a4b81ece
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 29, 2026.
Transparency logRelease files / onyx2moodle-0.1.0-py3-none-any.whl
| Download URL | onyx2moodle-0.1.0-py3-none-any.whl |
|---|---|
| Size | 41.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9588dbc5b4f87a8daf7db1f760e64aa388be4242541773e07970cd973694c339
|
|
BLAKE2b-256 checksum How to use checksums |
63051a56404fd99fe084fbf66dd8c6808bd151890a9217b2b60632d083971265
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 29, 2026.
Transparency logSecurity for onyx2moodle 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for onyx2moodle-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on May 29, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.