Skip to main content

qti-convert

Convert QTI content between versions, in Python:

  • upgrade: QTI 2.x (2.0, 2.1, 2.2) → QTI 3.0
  • downgrade: QTI 3.0 → QTI 2.1
  • fix-references: repair broken file references (images, stylesheets, templates, ...) in a QTI 2.x or 3 package

It converts single items, tests and stimuli as well as whole content packages (imsmanifest.xml with its items, tests, stimuli and assets; as a .zip, a folder or a dict of files). It depends only on lxml and works without XSLT.

This is the Python version of the conversions in the TypeScript @citolab/qti-convert package. It gives the same results and is tested against the same fixtures.

Installation

pip install citolab-qti-convert

Requires Python 3.9 or newer.

Command line

# QTI 2.x -> QTI 3.0
qti-convert upgrade package-qti2.zip package-qti3.zip
qti-convert upgrade item-qti2.xml item-qti3.xml
qti-convert upgrade ./qti2-folder ./qti3-folder --extract-shared-stimuli

# QTI 3.0 -> QTI 2.1 (warnings are printed to stderr)
qti-convert downgrade package-qti3.zip package-qti21.zip
qti-convert downgrade item-qti3.xml item-qti21.xml

# repair broken file references (what was fixed and what wasn't found is printed to stderr)
qti-convert fix-references package.zip package-fixed.zip

INPUT and OUTPUT can each be an XML file, a .zip, or a folder (fix-references takes a .zip or a folder). Run qti-convert <command> --help for all options.

Python API

Single documents

from qti_convert import upgrade_qti2_to_qti3, convert_qti3_to_qti21

qti3_xml = upgrade_qti2_to_qti3(qti2_xml)        # str or bytes in, str out

result = convert_qti3_to_qti21(qti3_xml)
result.xml                                         # QTI 2.1 XML
for warning in result.warnings:                    # what could not be converted one-to-one
    print(warning.code, warning.message)

convert_qti3_to_qti21 takes these options:

  • resolve_stimulus: a function that returns the XML of a stimulus for the href of a qti-assessment-stimulus-ref. That stimulus is then inlined into the item body; without it, stimulus refs are removed with a warning.
  • shared_vocabulary_stylesheet_href: adds a stylesheet link, so QTI 2.1 players can style the QTI 3 qti-* classes.
  • file_path: tags the warnings with this path.

Packages

from qti_convert import upgrade_package, downgrade_package

# source: path to a .zip or folder, zip bytes, a binary file object, or a {path: content} dict
# target (optional): a .zip path or a folder
result = upgrade_package("package-qti2.zip", "package-qti3.zip")
result.files                                       # {path: content} of the converted package

result = downgrade_package("package-qti3.zip", "package-qti21.zip")
result.warnings

upgrade_package_files and downgrade_package_files do the same with a {path: str | bytes} dict in and out, without any file I/O.

The package upgrade:

  • converts items, tests, stimuli and the manifest (namespaces, schema version and resource types)
  • runs post-processing transforms on the upgraded items (qti_convert.transforms.DEFAULT_ITEM_TRANSFORMS). They convert <object> media to <img>/<video>/<audio> and SSML to data-ssml-* spans, remove companion materials, set min-choices="1" on choice interactions, mark items without response processing as external-scored, and handle Dutch Extension Profile dialog triggers. Pass item_transforms=[...] to choose your own, or [] to skip them.
  • gives item refs in tests the identifier of the item they point to, and updates the manifest to match (sync_identifiers=True)
  • can optionally move content that is repeated across items (typically a reading passage) into shared qti-assessment-stimulus files (extract_shared_stimuli=True, or a dict with min_text_length, similarity_threshold and stimulus_folder). The result then includes a report of what was extracted and of near-duplicate content.

The package downgrade:

  • converts every QTI 3 file, and passes non-QTI files and QTI 2.x files through unchanged
  • inlines shared stimuli into the items that reference them, and removes them from the package and manifest
  • adds qti3-shared-vocabulary.css next to the manifest and links it from the items that use qti-* classes (switch this off with inject_shared_vocabulary_stylesheet=False)
  • accepts convert_item, convert_test and convert_manifest callbacks to override the default conversions

Fixing file references

Some exports write references that don't resolve: src="mediafiles/a.png" in questions/q1.xml, which is relative to the package root instead of to the item, templateLocation="/templates/rp.xml", or a path on the author's computer. fix_package_references repairs them. It is a separate step: run it before or after a conversion, on QTI 2.x or QTI 3 packages.

from qti_convert import fix_package_references

result = fix_package_references("package.zip", "package-fixed.zip")   # or fix_package_references_files(files)
for fixed in result.fixed:
    print(fixed.file, fixed.value, "->", fixed.new_value, f"({fixed.method})")
for unresolved in result.unresolved:
    print(unresolved.file, unresolved.value, unresolved.candidates)

Every reference in the items, tests and stimuli (src, href, data, poster, template-location, primary-path, ... see REFERENCE_ATTRIBUTES) is resolved in this order:

  1. relative to its own file, as the specs require. A reference that only differs in case is corrected (case).
  2. relative to the package root, the folder of imsmanifest.xml. This also covers paths that start with / (package-root).
  3. by file name anywhere in the package (file-name). When several files have that name, the one whose folders match the reference best wins; if that's still a tie, the reference is reported with the candidates. Switch this step off with search_by_file_name=False.

A reference found in step 2 or 3 is rewritten relative to its own file (../mediafiles/a.png); query strings, fragments and URL encoding are kept. Only the attribute values change, so the rest of each file stays byte-for-byte the same, and running it again changes nothing. External URLs are left alone. References that aren't found are left as they are and reported in result.unresolved.

To resolve references while reading a package file by file, without loading it whole, use PackageReferenceResolver. It needs only the paths of the files in the package, and gives the same results:

import zipfile
from qti_convert import PackageReferenceResolver

with zipfile.ZipFile("package.zip") as archive:
    resolver = PackageReferenceResolver(archive.namelist())   # root_dir defaults to the folder of imsmanifest.xml
    resolution = resolver.resolve("questions/q1.xml", "mediafiles/a.png", "src")
    resolution.target      # "mediafiles/a.png": the package path to read, or None when nothing was found
    resolution.new_value   # "../mediafiles/a.png": the value to write, or None when it was already right
    resolution.method      # "", "case", "package-root" or "file-name"

resolve returns None for values that aren't a file in the package (URLs, data: URIs, fragments). A target is always one of the given paths, so a reference can never point outside the package.

What the downgrade changes

QTI 3.0 has features that QTI 2.1 lacks. The downgrade converts what it can and reports the rest as warnings (Qti21Warning.code):

Code What happened
stimulus-inlined / stimulus-unresolved a shared stimulus was inlined, or removed because it could not be found
stimulus-standalone an assessmentStimulus exists only in QTI 2.2; QTI 2.1 players will not recognise it
removed-element / removed-attribute a QTI 3-only element (e.g. qti-catalog-info) or attribute (e.g. external-scored) was removed
data-attributes-removed / accessibility-attributes-removed data-*, aria-*, role and dir attributes were removed
media-to-object / img-to-object / gap-text-to-gap-img HTML5 media and images in graphic interactions became <object>; an image-only gap text became a gapImg
html5-element HTML5 elements such as <section> or <figure> became <div>/<span>
ssml-removed SSML markup was removed; its text was kept
pci a portable custom interaction was wrapped in a customInteraction
shared-vocabulary-stylesheet / shared-vocabulary-classes qti-* classes are styled by the added stylesheet, or were kept without styling
already-qti2 / not-qti the input was left unchanged

Upgrade compared to the ETS XSLT

The upgrade follows qti2xTo30.xsl (ETS, Apache-2.0), with these fixes:

  • stimulusBody, durationLT/durationGTE and a few other QTI 2.x elements that the XSLT does not list get their qti- name
  • testFeedback content is wrapped in qti-content-body, like the other feedback elements
  • qti-rubric-block gets the use attribute that QTI 3 requires
  • elements are matched by local name, so prefixed QTI 2 elements convert correctly
  • inline SVG stays in the SVG namespace, and a video <object> keeps its children once

HTML named entities such as &nbsp; are accepted in the input, even though XML does not define them.

Development

python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
pytest                 # the XSD validation tests download the IMS schemas once; set QTI_CONVERT_OFFLINE=1 to skip them
ruff check src tests && ruff format --check src tests
python -m build && twine check dist/*

Releasing

  1. Update __version__ in src/qti_convert/__init__.py and CHANGELOG.md.
  2. Commit, then tag and push: git tag v0.1.0 && git push origin v0.1.0.
  3. The publish workflow builds the package and publishes it to PyPI through trusted publishing. Configure it once on PyPI for this repository, with workflow publish.yml and environment pypi.

License

GPL-3.0-only, like the TypeScript package. The upgrade is derived from qti2xTo30.xsl by ETS (Apache-2.0).

Release files for citolab-qti-convert 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for citolab-qti-convert 0.2.0
File Size Uploaded
citolab_qti_convert-0.2.0.tar.gz 381.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for citolab-qti-convert 0.2.0
File Interpreter ABI Platform
citolab_qti_convert-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 443.8 kB

Release files / citolab_qti_convert-0.2.0.tar.gz

Download URL citolab_qti_convert-0.2.0.tar.gz
Size 381.7 kB
Tags Source
SHA-256 checksum
How to use checksums
2fffde8b95c923881c549517d900611d2c9caa4d405958fea16bae68eb568b4b
BLAKE2b-256 checksum
How to use checksums
44ba5783d94926270bc42b95a76d0d47bf7f01c9dacdfe9c3150326fa7c16553
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 Sep 25, 2026.

Transparency log

Release files / citolab_qti_convert-0.2.0-py3-none-any.whl

Download URL citolab_qti_convert-0.2.0-py3-none-any.whl
Size 62.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
568047cf11dae8e261595f75d734e8b7c3629f8b2bf0fb2c931edd88c040e0ef
BLAKE2b-256 checksum
How to use checksums
b48baed45259e514120b8b8b641d4096d46012b9115b47a1e8a4de4c23c42c80
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page