Skip to content

Commit 2607a57

Browse files
authored
Merge pull request learningequality#6027 from rtibblesbot/issue-6005-a23526
feat: add QTI 3.0 schema validator
2 parents f89579e + 804a8e4 commit 2607a57

18 files changed

Lines changed: 38197 additions & 1 deletion

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ repos:
44
hooks:
55
- id: trailing-whitespace
66
- id: check-added-large-files
7-
exclude: '^.+?\.ttf$'
7+
exclude: '^.+?\.ttf$|^contentcuration/contentcuration/utils/assessment/qti/schema/xsd/.+?\.xsd$'
88
- id: debug-statements
99
- id: end-of-file-fixer
1010
exclude: '^.+?(\.json|\.po)$'
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import os
2+
import tempfile
3+
import unittest
4+
5+
from lxml import etree
6+
7+
from contentcuration.utils.assessment.qti.validation import _compiled_schema
8+
from contentcuration.utils.assessment.qti.validation import validate_qti_item
9+
10+
11+
class CompiledSchemaTests(unittest.TestCase):
12+
def test_returns_xml_schema_instance(self):
13+
self.assertIsInstance(_compiled_schema(), etree.XMLSchema)
14+
15+
def test_is_cached_across_calls(self):
16+
self.assertIs(_compiled_schema(), _compiled_schema())
17+
18+
19+
def _item_xml(identifier, title, response_declaration, item_body):
20+
return (
21+
'<?xml version="1.0" encoding="UTF-8"?>'
22+
'<qti-assessment-item xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0" '
23+
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
24+
'xsi:schemaLocation="http://www.imsglobal.org/xsd/imsqtiasi_v3p0 '
25+
'https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_asiv3p0p1_v1p0.xsd" '
26+
'identifier="%s" title="%s" adaptive="false" time-dependent="false" '
27+
'language="en-US" tool-name="kolibri" tool-version="0.1">'
28+
"%s"
29+
'<qti-outcome-declaration identifier="SCORE" cardinality="single" base-type="float" />'
30+
"<qti-item-body>%s</qti-item-body>"
31+
'<qti-response-processing template="https://purl.imsglobal.org/spec/qti/v3p0/rptemplates/match_correct" />'
32+
"</qti-assessment-item>"
33+
) % (identifier, title, response_declaration, item_body)
34+
35+
36+
VALID_CHOICE_ITEM = _item_xml(
37+
"item_1",
38+
"Sample Item",
39+
'<qti-response-declaration identifier="RESPONSE" cardinality="single" base-type="identifier">'
40+
"<qti-correct-response><qti-value>choice_0</qti-value></qti-correct-response>"
41+
"</qti-response-declaration>",
42+
'<qti-choice-interaction response-identifier="RESPONSE" max-choices="1" min-choices="0" '
43+
'orientation="vertical">'
44+
"<qti-prompt>Select the correct answer.</qti-prompt>"
45+
'<qti-simple-choice identifier="choice_0" show-hide="show" fixed="false">Option A</qti-simple-choice>'
46+
'<qti-simple-choice identifier="choice_1" show-hide="show" fixed="false">Option B</qti-simple-choice>'
47+
"</qti-choice-interaction>",
48+
)
49+
50+
51+
class ValidateQTIItemTests(unittest.TestCase):
52+
def test_accepts_valid_item(self):
53+
result = validate_qti_item(VALID_CHOICE_ITEM)
54+
self.assertTrue(result.is_valid)
55+
self.assertEqual(result.errors, [])
56+
57+
def test_accepts_valid_item_as_bytes(self):
58+
result = validate_qti_item(VALID_CHOICE_ITEM.encode("utf-8"))
59+
self.assertTrue(result.is_valid)
60+
61+
def test_rejects_invalid_enum_value(self):
62+
xml = VALID_CHOICE_ITEM.replace(
63+
'orientation="vertical"', 'orientation="sideways"'
64+
)
65+
result = validate_qti_item(xml)
66+
self.assertFalse(result.is_valid)
67+
self.assertTrue(
68+
any(
69+
"orientation" in e.message and "sideways" in e.message
70+
for e in result.errors
71+
)
72+
)
73+
74+
def test_rejects_missing_required_attribute(self):
75+
xml = VALID_CHOICE_ITEM.replace(' time-dependent="false"', "")
76+
result = validate_qti_item(xml)
77+
self.assertFalse(result.is_valid)
78+
self.assertTrue(any("time-dependent" in e.message for e in result.errors))
79+
80+
def test_rejects_malformed_xml_without_raising(self):
81+
result = validate_qti_item("<qti-assessment-item><unclosed>")
82+
self.assertFalse(result.is_valid)
83+
self.assertEqual(len(result.errors), 1)
84+
85+
def test_does_not_resolve_external_entities(self):
86+
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
87+
f.write("super-secret-value")
88+
secret_path = f.name
89+
self.addCleanup(os.remove, secret_path)
90+
xml = (
91+
'<?xml version="1.0"?>'
92+
'<!DOCTYPE qti-assessment-item [<!ENTITY xxe SYSTEM "file://%s">]>'
93+
'<qti-assessment-item title="&xxe;"></qti-assessment-item>' % secret_path
94+
)
95+
result = validate_qti_item(xml)
96+
serialized = " ".join(e.message for e in result.errors)
97+
self.assertNotIn("super-secret-value", serialized)
98+
99+
100+
MATCH_INTERACTION_ITEM = _item_xml(
101+
"item_match",
102+
"Match Item",
103+
'<qti-response-declaration identifier="RESPONSE" cardinality="multiple" base-type="directedPair">'
104+
"<qti-correct-response><qti-value>A X</qti-value><qti-value>B Y</qti-value></qti-correct-response>"
105+
"</qti-response-declaration>",
106+
'<qti-match-interaction response-identifier="RESPONSE" shuffle="false" max-associations="1">'
107+
"<qti-prompt>Match the fruit to its color.</qti-prompt>"
108+
"<qti-simple-match-set>"
109+
'<qti-simple-associable-choice identifier="A" match-max="1">Apple</qti-simple-associable-choice>'
110+
'<qti-simple-associable-choice identifier="B" match-max="1">Banana</qti-simple-associable-choice>'
111+
"</qti-simple-match-set>"
112+
"<qti-simple-match-set>"
113+
'<qti-simple-associable-choice identifier="X" match-max="1">Red</qti-simple-associable-choice>'
114+
'<qti-simple-associable-choice identifier="Y" match-max="1">Yellow</qti-simple-associable-choice>'
115+
"</qti-simple-match-set>"
116+
"</qti-match-interaction>",
117+
)
118+
119+
ORDER_INTERACTION_ITEM = _item_xml(
120+
"item_order",
121+
"Order Item",
122+
'<qti-response-declaration identifier="RESPONSE" cardinality="ordered" base-type="identifier">'
123+
"<qti-correct-response>"
124+
"<qti-value>step1</qti-value><qti-value>step2</qti-value><qti-value>step3</qti-value>"
125+
"</qti-correct-response>"
126+
"</qti-response-declaration>",
127+
'<qti-order-interaction response-identifier="RESPONSE" shuffle="false" orientation="vertical">'
128+
"<qti-prompt>Order the steps.</qti-prompt>"
129+
'<qti-simple-choice identifier="step1" show-hide="show" fixed="false">First</qti-simple-choice>'
130+
'<qti-simple-choice identifier="step2" show-hide="show" fixed="false">Second</qti-simple-choice>'
131+
'<qti-simple-choice identifier="step3" show-hide="show" fixed="false">Third</qti-simple-choice>'
132+
"</qti-order-interaction>",
133+
)
134+
135+
136+
class UncoveredInteractionTypeTests(unittest.TestCase):
137+
"""Interaction types the pydantic models / QTIExerciseGenerator never produce."""
138+
139+
def test_accepts_match_interaction_item(self):
140+
result = validate_qti_item(MATCH_INTERACTION_ITEM)
141+
self.assertTrue(result.is_valid)
142+
self.assertEqual(result.errors, [])
143+
144+
def test_accepts_order_interaction_item(self):
145+
result = validate_qti_item(ORDER_INTERACTION_ITEM)
146+
self.assertTrue(result.is_valid)
147+
self.assertEqual(result.errors, [])
148+
149+
def test_rejects_item_with_unknown_root_element(self):
150+
result = validate_qti_item('<not-qti identifier="x" />')
151+
self.assertFalse(result.is_valid)
152+
self.assertTrue(result.errors)
153+
154+
155+
class SchemaReuseTests(unittest.TestCase):
156+
def test_schema_compiled_once_across_multiple_validate_calls(self):
157+
_compiled_schema.cache_clear()
158+
self.addCleanup(_compiled_schema.cache_clear)
159+
validate_qti_item(VALID_CHOICE_ITEM)
160+
validate_qti_item(MATCH_INTERACTION_ITEM)
161+
validate_qti_item(ORDER_INTERACTION_ITEM)
162+
self.assertEqual(_compiled_schema.cache_info().misses, 1)
163+
self.assertEqual(_compiled_schema.cache_info().hits, 2)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
from .base import ElementTreeBase
2+
from .validation import QTIValidationError
3+
from .validation import QTIValidationResult
4+
from .validation import validate_qti_item
25

36

47
__all__ = [
58
"ElementTreeBase",
9+
"QTIValidationError",
10+
"QTIValidationResult",
11+
"validate_qti_item",
612
]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Vendored QTI 3.0 schema
2+
3+
XSD files backing `contentcuration.utils.assessment.qti.validation`. Source: QTI 3.0.1
4+
(IMS Global / 1EdTech), https://www.imsglobal.org/spec/qti/v3p0/impl.
5+
6+
- `xsd/imsqti_itemv3p0p1_v1p0.xsd` — the QTI-item-scoped schema (validates a single
7+
`qti-assessment-item` document; does not accept `qti-assessment-test`/`-section` roots).
8+
Source: https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_itemv3p0p1_v1p0.xsd
9+
- `xsd/xml.xsd`, `xsd/XInclude.xsd` — W3C schemas for the `xml:` namespace and XInclude.
10+
Source: https://purl.imsglobal.org/spec/w3/2001/schema/xsd/{xml,XInclude}.xsd
11+
- `xsd/mathml3*.xsd` — MathML 3 schema (content, presentation, common, strict-content).
12+
Source: https://purl.imsglobal.org/spec/mathml/v3p0/schema/xsd/
13+
- `xsd/ssmlv1p1-core.xsd`, `xsd/synthesis-nonamespace.xsd` — SSML 1.1 schema (QTI allows
14+
SSML markup for text-to-speech hints).
15+
Source: https://purl.imsglobal.org/spec/ssml/v1p1/schema/xsd/
16+
17+
All `schemaLocation` attributes that originally pointed at `https://purl.imsglobal.org/...`
18+
have been rewritten to local relative filenames so the schema compiles with no network
19+
access at runtime — required since bulk publish and ricecooker upload validate many items
20+
per run. Do not restore the absolute URLs.
21+
22+
To refresh to a newer QTI point release, run `refresh_schema.py` (re-downloads each
23+
file above and re-applies the `schemaLocation` rewrites), then re-run the full
24+
`test_validation.py` suite:
25+
26+
```
27+
python contentcuration/contentcuration/utils/assessment/qti/schema/refresh_schema.py
28+
pytest contentcuration/contentcuration/tests/utils/qti/test_validation.py -v
29+
```
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Re-vendor the QTI 3.0 item XSD schema tree.
2+
3+
Downloads the QTI-item XSD and its W3C/MathML/SSML dependencies, then
4+
rewrites their `schemaLocation` attributes from absolute
5+
`https://purl.imsglobal.org/...` URLs to local relative filenames so the
6+
schema tree compiles with no network access at runtime.
7+
8+
Usage: python refresh_schema.py
9+
(Run from any directory; writes into the `xsd/` subdirectory next to this
10+
script.) Afterwards, re-run the validation test suite:
11+
12+
pytest contentcuration/contentcuration/tests/utils/qti/test_validation.py -v
13+
"""
14+
import codecs
15+
import socket
16+
import subprocess
17+
from pathlib import Path
18+
19+
import requests
20+
from lxml import etree
21+
22+
23+
XSD_DIR = Path(__file__).parent / "xsd"
24+
25+
SOURCES = {
26+
"imsqti_itemv3p0p1_v1p0.xsd": "https://purl.imsglobal.org/spec/qti/v3p0/schema/xsd/imsqti_itemv3p0p1_v1p0.xsd",
27+
"xml.xsd": "https://purl.imsglobal.org/spec/w3/2001/schema/xsd/xml.xsd",
28+
"XInclude.xsd": "https://purl.imsglobal.org/spec/w3/2001/schema/xsd/XInclude.xsd",
29+
"mathml3.xsd": "https://purl.imsglobal.org/spec/mathml/v3p0/schema/xsd/mathml3.xsd",
30+
"mathml3-content.xsd": "https://purl.imsglobal.org/spec/mathml/v3p0/schema/xsd/mathml3-content.xsd",
31+
"mathml3-presentation.xsd": "https://purl.imsglobal.org/spec/mathml/v3p0/schema/xsd/mathml3-presentation.xsd",
32+
"mathml3-common.xsd": "https://purl.imsglobal.org/spec/mathml/v3p0/schema/xsd/mathml3-common.xsd",
33+
"mathml3-strict-content.xsd": "https://purl.imsglobal.org/spec/mathml/v3p0/schema/xsd/mathml3-strict-content.xsd",
34+
"ssmlv1p1-core.xsd": "https://purl.imsglobal.org/spec/ssml/v1p1/schema/xsd/ssmlv1p1-core.xsd",
35+
"synthesis-nonamespace.xsd": "https://purl.imsglobal.org/spec/ssml/v1p1/schema/xsd/synthesis-nonamespace.xsd",
36+
}
37+
38+
# schemaLocation rewrites: file -> its dependencies (keys into SOURCES),
39+
# each rewritten from the dependency's absolute URL to its local filename.
40+
REWRITES = {
41+
"imsqti_itemv3p0p1_v1p0.xsd": [
42+
"xml.xsd",
43+
"XInclude.xsd",
44+
"mathml3.xsd",
45+
"ssmlv1p1-core.xsd",
46+
],
47+
"ssmlv1p1-core.xsd": ["xml.xsd"],
48+
"synthesis-nonamespace.xsd": ["xml.xsd"],
49+
}
50+
51+
52+
def _normalize_encoding(content):
53+
# purl.imsglobal.org serves XInclude.xsd as UTF-16 with no XML declaration,
54+
# unlike every other source file here (plain UTF-8); transcode rather than
55+
# leaving it as the only non-UTF-8, non-diffable file in the vendored tree.
56+
# Decoding/re-encoding (instead of round-tripping through lxml) preserves
57+
# the original formatting instead of reflowing it.
58+
if content.startswith(codecs.BOM_UTF16_LE) or content.startswith(
59+
codecs.BOM_UTF16_BE
60+
):
61+
text = content.decode("utf-16")
62+
if not text.lstrip().startswith("<?xml"):
63+
text = '<?xml version="1.0" encoding="UTF-8"?>\n' + text
64+
return text.encode("utf-8")
65+
return content
66+
67+
68+
def download():
69+
XSD_DIR.mkdir(parents=True, exist_ok=True)
70+
for filename, url in SOURCES.items():
71+
response = requests.get(url, timeout=30)
72+
response.raise_for_status()
73+
(XSD_DIR / filename).write_bytes(_normalize_encoding(response.content))
74+
75+
76+
def rewrite_schema_locations():
77+
for filename, deps in REWRITES.items():
78+
path = XSD_DIR / filename
79+
text = path.read_text()
80+
for dep in deps:
81+
old = 'schemaLocation="%s"' % SOURCES[dep]
82+
new = 'schemaLocation="%s"' % dep
83+
assert old in text, "%r not found in %s" % (old, filename)
84+
text = text.replace(old, new)
85+
path.write_text(text)
86+
87+
88+
def run_pre_commit():
89+
# Applies the repo's trailing-whitespace/end-of-file-fixer hooks (the only
90+
# hooks whose `files:` patterns match .xsd) so the vendored tree matches
91+
# the same convention already applied to the committed copies. Exit code
92+
# 1 just means files were modified; that's expected, not a failure.
93+
subprocess.run(
94+
["pre-commit", "run", "--files"]
95+
+ [str(path) for path in sorted(XSD_DIR.glob("*.xsd"))],
96+
check=False,
97+
)
98+
99+
100+
def verify_compiles_offline():
101+
real_socket = socket.socket
102+
103+
def _blocked(*args, **kwargs):
104+
raise RuntimeError("network access attempted while compiling vendored schema")
105+
106+
socket.socket = _blocked
107+
try:
108+
etree.XMLSchema(etree.parse(str(XSD_DIR / "imsqti_itemv3p0p1_v1p0.xsd")))
109+
finally:
110+
socket.socket = real_socket
111+
112+
113+
def main():
114+
download()
115+
rewrite_schema_locations()
116+
run_pre_commit()
117+
verify_compiles_offline()
118+
print("Vendored schema refreshed in %s" % XSD_DIR) # noqa: T201
119+
120+
121+
if __name__ == "__main__":
122+
main()
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
3+
xmlns:xi="http://www.w3.org/2001/XInclude"
4+
targetNamespace="http://www.w3.org/2001/XInclude"
5+
finalDefault="extension">
6+
<xs:annotation>
7+
<xs:documentation>
8+
Not normative, but may be useful.
9+
See the REC http://www.w3.org/TR/XInclude for definitive
10+
information about this namespace.
11+
</xs:documentation>
12+
</xs:annotation>
13+
14+
<xs:element name="include" type="xi:includeType" />
15+
16+
<xs:complexType name="includeType" mixed="true">
17+
<xs:choice minOccurs='0' maxOccurs='unbounded' >
18+
<xs:element ref='xi:fallback' />
19+
<xs:any namespace='##other' processContents='lax' />
20+
<xs:any namespace='##local' processContents='lax' />
21+
</xs:choice>
22+
<xs:attribute name="href" use="optional" type="xs:anyURI"/>
23+
<xs:attribute name="parse" use="optional" default="xml"
24+
type="xi:parseType" />
25+
<xs:attribute name="xpointer" use="optional" type="xs:string"/>
26+
<xs:attribute name="encoding" use="optional" type="xs:string"/>
27+
<xs:attribute name="accept" use="optional" type="xs:string"/>
28+
<xs:attribute name="accept-language" use="optional" type="xs:string"/>
29+
<xs:anyAttribute namespace="##other" processContents="lax"/>
30+
</xs:complexType>
31+
32+
<xs:simpleType name="parseType">
33+
<xs:restriction base="xs:token">
34+
<xs:enumeration value="xml"/>
35+
<xs:enumeration value="text"/>
36+
</xs:restriction>
37+
</xs:simpleType>
38+
39+
<xs:element name="fallback" type="xi:fallbackType" />
40+
41+
<xs:complexType name="fallbackType" mixed="true">
42+
<xs:choice minOccurs="0" maxOccurs="unbounded">
43+
<xs:element ref="xi:include"/>
44+
<xs:any namespace="##other" processContents="lax"/>
45+
<xs:any namespace="##local" processContents="lax"/>
46+
</xs:choice>
47+
<xs:anyAttribute namespace="##other" processContents="lax" />
48+
</xs:complexType>
49+
50+
</xs:schema>

0 commit comments

Comments
 (0)