Skip to content

Commit 714b349

Browse files
committed
Add MathML pydantic objects.
Generalize tree generation code from HTML for reuse in MathML.
1 parent 1fdb112 commit 714b349

11 files changed

Lines changed: 2113 additions & 104 deletions

File tree

contentcuration/contentcuration/tests/utils/qti/test_mathml.py

Lines changed: 1554 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .base import ElementTreeBase
2+
3+
4+
__all__ = [
5+
"ElementTreeBase",
6+
]

contentcuration/contentcuration/utils/assessment/qti/base.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import List
88
from typing import Optional
99
from typing import Set
10+
from typing import Type
1011
from typing import Union
1112

1213
from pydantic import BaseModel
@@ -175,3 +176,108 @@ class BaseSequence(XMLElement):
175176
label: Optional[str] = None
176177
# We explicitly do not set the base value.
177178
dir_: Optional[Dir] = None
179+
180+
181+
# Pydantic's BaseModel Metaclass is only importable from an internal module,
182+
# so we inspect the BaseSequence class to get its metaclass.
183+
BaseSequenceMetaclass = type(BaseSequence)
184+
185+
186+
class RegistryMeta(BaseSequenceMetaclass):
187+
"""Generic metaclass that creates separate registries for each subclass"""
188+
189+
def __new__(mcs, name, bases, attrs):
190+
cls = super().__new__(mcs, name, bases, attrs)
191+
192+
# Each metaclass gets its own registry
193+
if not hasattr(mcs, "_registry"):
194+
mcs._registry = {}
195+
196+
element_name = cls.element_name()
197+
if element_name in mcs._registry and mcs._registry[element_name] is not cls:
198+
raise ValueError(
199+
f"Element name '{element_name}' already registered in {mcs.__name__}"
200+
)
201+
mcs._registry[element_name] = cls
202+
203+
return cls
204+
205+
@classmethod
206+
def _ensure_registry_complete(cls):
207+
"""Ensure all HTML and MathML classes are registered"""
208+
if not hasattr(cls, "_registry_initialized"):
209+
# Import modules to trigger registration
210+
from contentcuration.utils.assessment.qti import html, mathml # noqa: F401
211+
212+
cls._registry_initialized = True
213+
214+
@classmethod
215+
def get_class_for_tag(cls, tag_name: str) -> Optional[Type]:
216+
"""Get the registered class for a given tag name"""
217+
cls._ensure_registry_complete()
218+
return getattr(cls, "_registry", {}).get(tag_name)
219+
220+
221+
class ElementTreeBase(BaseSequence, metaclass=RegistryMeta):
222+
@classmethod
223+
def from_element(cls, element: ET.Element) -> "ElementTreeBase":
224+
# Get the appropriate class for this tag
225+
target_class = type(cls).get_class_for_tag(element.tag)
226+
if target_class is None:
227+
raise ValueError(f"No registered class found for tag: {element.tag}")
228+
229+
# Convert attributes to field data - Pydantic will handle type coercion
230+
field_data = {}
231+
for attr_name, attr_value in element.attrib.items():
232+
field_name = cls._attr_name_to_field_name(attr_name)
233+
field_data[field_name] = attr_value
234+
235+
# Convert children and text
236+
children = cls._extract_children(element)
237+
if children:
238+
field_data["children"] = children
239+
240+
return target_class(**field_data)
241+
242+
@classmethod
243+
def _attr_name_to_field_name(cls, attr_name: str) -> str:
244+
"""Convert attribute name to Python field name"""
245+
# kebab-case -> snake_case, : -> __
246+
field_name = attr_name.replace(":", "__").replace("-", "_")
247+
248+
# Add trailing underscore for Python keywords
249+
if field_name in {"class", "for", "type", "id", "dir"}:
250+
field_name += "_"
251+
252+
return field_name
253+
254+
@classmethod
255+
def _extract_children(
256+
cls, element: ET.Element
257+
) -> List[Union["ElementTreeBase", TextNode]]:
258+
"""Extract child elements and text nodes from XML element"""
259+
children = []
260+
261+
# Add initial text if present
262+
if element.text and element.text.strip():
263+
children.append(TextNode(text=element.text))
264+
265+
# Process child elements
266+
for child_elem in element:
267+
children.append(cls.from_element(child_elem))
268+
# Add tail text after child element
269+
if child_elem.tail and child_elem.tail.strip():
270+
children.append(TextNode(text=child_elem.tail))
271+
272+
return children
273+
274+
@classmethod
275+
def from_string(cls, string: str) -> List["ElementTreeBase"]:
276+
"""Parse markup string and return list of ElementTreeBase instances"""
277+
try:
278+
# Wrap in a root element to handle multiple top-level elements
279+
wrapped_markup = f"<root>{string}</root>"
280+
root = ET.fromstring(wrapped_markup)
281+
return [cls.from_element(child) for child in root]
282+
except ET.ParseError as e:
283+
raise ValueError(f"Invalid Markup: {e}") from e

contentcuration/contentcuration/utils/assessment/qti/html/base.py

Lines changed: 3 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,20 @@
1-
import xml.etree.ElementTree as ET
2-
from typing import Dict
31
from typing import List
42
from typing import Optional
5-
from typing import Type
6-
from typing import Union
73

8-
from contentcuration.utils.assessment.qti.base import BaseSequence
9-
from contentcuration.utils.assessment.qti.base import TextNode
4+
from contentcuration.utils.assessment.qti.base import ElementTreeBase
105
from contentcuration.utils.assessment.qti.fields import LocalSrcPath
116
from contentcuration.utils.assessment.qti.fields import LocalSrcSet
127

138

14-
# Pydantic's BaseModel Metaclass is only importable from an internal module,
15-
# so we inspect the BaseSequence class to get its metaclass.
16-
BaseSequenceMetaclass = type(BaseSequence)
17-
18-
19-
class HTMLElementMeta(BaseSequenceMetaclass):
20-
"""Metaclass that auto-registers HTML element classes by their tag name"""
21-
22-
# Class registry mapping tag names to classes
23-
_registry: Dict[str, Type["HTMLElement"]] = {}
24-
25-
def __new__(mcs, name, bases, attrs):
26-
cls = super().__new__(mcs, name, bases, attrs)
27-
element_name = cls.element_name()
28-
mcs._registry[element_name] = cls
29-
return cls
30-
31-
@classmethod
32-
def get_class_for_tag(mcs, tag_name: str) -> Optional[Type["HTMLElement"]]:
33-
"""Get the registered class for a given tag name"""
34-
return mcs._registry.get(tag_name)
35-
36-
@classmethod
37-
def register_class(mcs, tag_name: str, cls: Type["HTMLElement"]):
38-
"""Manually register a class for a tag name"""
39-
mcs._registry[tag_name] = cls
40-
41-
42-
class HTMLElement(BaseSequence, metaclass=HTMLElementMeta):
9+
class HTMLElement(ElementTreeBase):
4310
"""
4411
Represents an HTML element within QTI.
4512
"""
4613

47-
@classmethod
48-
def element_name(cls):
49-
return cls.__name__.lower()
50-
51-
@classmethod
52-
def from_element(cls, element: ET.Element) -> "HTMLElement":
53-
"""Create HTMLElement instance from ET.Element"""
54-
# Get the appropriate class for this tag
55-
target_class = HTMLElementMeta.get_class_for_tag(element.tag)
56-
if target_class is None:
57-
raise ValueError(f"No registered class found for tag: {element.tag}")
58-
59-
# Convert attributes to field data - Pydantic will handle type coercion
60-
field_data = {}
61-
for attr_name, attr_value in element.attrib.items():
62-
field_name = cls._attr_name_to_field_name(attr_name)
63-
field_data[field_name] = attr_value
64-
65-
# Convert children and text
66-
children = cls._extract_children(element)
67-
if children:
68-
field_data["children"] = children
69-
70-
return target_class(**field_data)
71-
72-
@classmethod
73-
def _attr_name_to_field_name(cls, attr_name: str) -> str:
74-
"""Convert HTML attribute name to Python field name"""
75-
# kebab-case -> snake_case, : -> __
76-
field_name = attr_name.replace(":", "__").replace("-", "_")
77-
78-
# Add trailing underscore for Python keywords
79-
if field_name in {"class", "for", "type", "id", "dir"}:
80-
field_name += "_"
81-
82-
return field_name
83-
84-
@classmethod
85-
def _extract_children(
86-
cls, element: ET.Element
87-
) -> List[Union["HTMLElement", TextNode]]:
88-
"""Extract child elements and text nodes from XML element"""
89-
children = []
90-
91-
# Add initial text if present
92-
if element.text and element.text.strip():
93-
children.append(TextNode(text=element.text))
94-
95-
# Process child elements
96-
for child_elem in element:
97-
children.append(cls.from_element(child_elem))
98-
# Add tail text after child element
99-
if child_elem.tail and child_elem.tail.strip():
100-
children.append(TextNode(text=child_elem.tail))
101-
102-
return children
103-
10414
@classmethod
10515
def from_html_string(cls, html_string: str) -> List["HTMLElement"]:
10616
"""Parse HTML string and return list of HTMLElement instances"""
107-
try:
108-
# Wrap in a root element to handle multiple top-level elements
109-
wrapped_html = f"<root>{html_string}</root>"
110-
root = ET.fromstring(wrapped_html)
111-
return [cls.from_element(child) for child in root]
112-
except ET.ParseError as e:
113-
raise ValueError(f"Invalid HTML: {e}") from e
17+
return cls.from_string(html_string)
11418

11519

11620
class FlowContentElement(HTMLElement):

contentcuration/contentcuration/utils/assessment/qti/html/content_types.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from contentcuration.utils.assessment.qti.base import TextType
55
from contentcuration.utils.assessment.qti.html.base import FlowContentElement
66
from contentcuration.utils.assessment.qti.html.base import InlineContentElement
7+
from contentcuration.utils.assessment.qti.interaction_types.base import BlockInteraction
78
from contentcuration.utils.assessment.qti.interaction_types.base import (
89
InlineInteraction,
910
)
11+
from contentcuration.utils.assessment.qti.mathml import Math
1012

1113

1214
FlowContent = Union[FlowContentElement, TextType]
@@ -26,8 +28,17 @@
2628
# InlineChoiceInteraction,
2729
# EndAttemptInteraction,
2830
# CustomInteraction,
29-
# Math,
31+
Math,
3032
# Include,
3133
]
3234

3335
InlineGroupList = List[Union[InlineGroup, TextType]]
36+
37+
FlowGroup = Union[
38+
FlowContentElement,
39+
BlockInteraction,
40+
InlineInteraction,
41+
Math,
42+
]
43+
44+
FlowGroupList = List[Union[FlowGroup, TextType]]

contentcuration/contentcuration/utils/assessment/qti/html/flow.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from pydantic import Field
44

55
from contentcuration.utils.assessment.qti.html.base import BlockContentElement
6-
from contentcuration.utils.assessment.qti.html.content_types import FlowContentList
6+
from contentcuration.utils.assessment.qti.html.content_types import FlowGroupList
77

88

99
class HTMLFlowContainer(BlockContentElement):
@@ -13,7 +13,7 @@ class HTMLFlowContainer(BlockContentElement):
1313
Corresponds to HTML "Flow Content" category.
1414
"""
1515

16-
children: FlowContentList = Field(default_factory=list)
16+
children: FlowGroupList = Field(default_factory=list)
1717

1818

1919
class Blockquote(HTMLFlowContainer):
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from .base import MathMLElement
2+
from .core import Annotation
3+
from .core import AnnotationXml
4+
from .core import Maction
5+
from .core import Math
6+
from .core import Merror
7+
from .core import Mfrac
8+
from .core import Mi
9+
from .core import Mmultiscripts
10+
from .core import Mn
11+
from .core import Mo
12+
from .core import Mover
13+
from .core import Mpadded
14+
from .core import Mphantom
15+
from .core import Mprescripts
16+
from .core import Mroot
17+
from .core import Mrow
18+
from .core import Ms
19+
from .core import Mspace
20+
from .core import Msqrt
21+
from .core import Mstyle
22+
from .core import Msub
23+
from .core import Msubsup
24+
from .core import Msup
25+
from .core import Mtable
26+
from .core import Mtd
27+
from .core import Mtext
28+
from .core import Mtr
29+
from .core import Munder
30+
from .core import Munderover
31+
from .core import Semantics
32+
from .fields import MathMLDisplay
33+
from .fields import MathMLForm
34+
35+
__all__ = [
36+
"MathMLElement",
37+
# Root element
38+
"Math",
39+
# Token elements
40+
"Mi",
41+
"Mn",
42+
"Mo",
43+
"Mtext",
44+
"Ms",
45+
"Mspace",
46+
# Layout elements
47+
"Mrow",
48+
"Mfrac",
49+
"Msqrt",
50+
"Mroot",
51+
"Mpadded",
52+
# Script elements
53+
"Msub",
54+
"Msup",
55+
"Msubsup",
56+
"Munder",
57+
"Mover",
58+
"Munderover",
59+
"Mmultiscripts",
60+
"Mprescripts",
61+
# Table elements
62+
"Mtd",
63+
"Mtr",
64+
"Mtable",
65+
# Grouping elements
66+
"Mstyle",
67+
"Merror",
68+
"Mphantom",
69+
"Maction",
70+
# Semantic elements
71+
"Annotation",
72+
"AnnotationXml",
73+
"Semantics",
74+
# enums
75+
"MathMLForm",
76+
"MathMLDisplay",
77+
]

0 commit comments

Comments
 (0)