diff --git a/xblock/core.py b/xblock/core.py
index 7ff362475..34306e1e1 100644
--- a/xblock/core.py
+++ b/xblock/core.py
@@ -24,6 +24,7 @@
from xblock.internal import class_lazy
from xblock.plugin import Plugin
from xblock.validation import Validation
+from xblock.xml import is_pointer_tag
# OrderedDict is used so that namespace attributes are put in predictable order
# This allows for simple string equality assertions in tests and have no other effects
@@ -32,6 +33,7 @@
("block", "http://code.edx.org/xblock/block"),
])
+
# __all__ controls what classes end up in the docs.
__all__ = ['XBlock', 'XBlockAside']
@@ -746,6 +748,9 @@ def parse_xml(cls, node, runtime, keys):
keys (:class:`.ScopeIds`): The keys identifying where this block
will store its data.
"""
+ if is_pointer_tag(node):
+ node, _ = runtime.load_definition_xml(node, keys.def_id)
+
block = runtime.construct_xblock_from_class(cls, keys)
# The base implementation: child nodes become child blocks.
diff --git a/xblock/runtime.py b/xblock/runtime.py
index 8aa822dda..b3c0726a4 100644
--- a/xblock/runtime.py
+++ b/xblock/runtime.py
@@ -31,6 +31,7 @@
FieldDataDeprecationWarning,
UserIdDeprecationWarning,
)
+from xblock.xml import format_filepath, load_file, name_to_pathname
log = logging.getLogger(__name__)
@@ -792,6 +793,24 @@ def add_block_as_child_node(self, block, node):
child = etree.SubElement(node, "unknown")
block.add_xml_to_node(child)
+ def load_definition_xml(self, node, def_id):
+ """
+ Load an XML definition for a block.
+ This method can be overridden in different runtime environments.
+
+ Args:
+ node: XML element containing attributes for definition loading
+ def_id: Unique identifier for the definition being loaded
+
+ Returns:
+ tuple: A tuple containing the loaded XML definition and the
+ corresponding file path or identifier
+ """
+ url_name = node.get('url_name')
+ filepath = format_filepath(node.tag, name_to_pathname(url_name))
+ definition_xml = load_file(filepath, self.resources_fs, def_id) # pylint: disable=no-member
+ return definition_xml, filepath
+
# Rendering
def render(self, block, view_name, context=None):
diff --git a/xblock/test/test_xml.py b/xblock/test/test_xml.py
new file mode 100644
index 000000000..763246d24
--- /dev/null
+++ b/xblock/test/test_xml.py
@@ -0,0 +1,63 @@
+"""
+Tests for xblock/xml.py
+"""
+
+import unittest
+from io import BytesIO
+from lxml import etree
+
+from xblock.xml import (
+ name_to_pathname,
+ is_pointer_tag,
+ format_filepath,
+ file_to_xml,
+)
+
+
+class TestPointerTagParsing(unittest.TestCase):
+ """
+ Tests for core functions in XBlock.
+ """
+ def test_name_to_pathname(self):
+ self.assertEqual(name_to_pathname("course:subcourse"), "course/subcourse")
+ self.assertEqual(name_to_pathname("module:lesson:part"), "module/lesson/part")
+ self.assertEqual(name_to_pathname("no_colon"), "no_colon")
+
+ def test_is_pointer_tag(self):
+ # Case 1: Valid pointer tag
+ xml_obj = etree.Element("some_tag", url_name="test_url")
+ self.assertTrue(is_pointer_tag(xml_obj))
+
+ # Case 2: Valid course pointer tag
+ xml_obj = etree.Element("course", url_name="test_url", course="test_course", org="test_org")
+ self.assertTrue(is_pointer_tag(xml_obj))
+
+ # Case 3: Invalid case - extra attribute
+ xml_obj = etree.Element("some_tag", url_name="test_url", extra_attr="invalid")
+ self.assertFalse(is_pointer_tag(xml_obj))
+
+ # Case 4: Invalid case - has text
+ xml_obj = etree.Element("some_tag", url_name="test_url")
+ xml_obj.text = "invalid_text"
+ self.assertFalse(is_pointer_tag(xml_obj))
+
+ # Case 5: Invalid case - has children
+ xml_obj = etree.Element("some_tag", url_name="test_url")
+ _ = etree.SubElement(xml_obj, "child")
+ self.assertFalse(is_pointer_tag(xml_obj))
+
+ def test_format_filepath(self):
+ self.assertEqual(format_filepath("course", "test_url"), "course/test_url.xml")
+
+ def test_file_to_xml(self):
+ """Test that `file_to_xml` correctly parses XML from a file object."""
+ # Create a BytesIO object
+ file_obj = BytesIO(b"Value")
+
+ # Parse the XML
+ result = file_to_xml(file_obj)
+
+ # Verify the result
+ self.assertEqual(result.tag, 'root')
+ self.assertEqual(result[0].tag, 'child')
+ self.assertEqual(result[0].text, 'Value')
diff --git a/xblock/xml.py b/xblock/xml.py
new file mode 100644
index 000000000..8466a1d84
--- /dev/null
+++ b/xblock/xml.py
@@ -0,0 +1,71 @@
+"""
+XML utilities for XBlock, including parsing and loading XML definitions.
+"""
+from lxml import etree
+
+
+XML_PARSER = etree.XMLParser(dtd_validation=False, load_dtd=False, remove_blank_text=True, encoding='utf-8')
+
+
+def name_to_pathname(name):
+ """
+ Convert a location name for use in a path: replace ':' with '/'.
+ This allows users of the xml format to organize content into directories
+ """
+ return name.replace(':', '/')
+
+
+def is_pointer_tag(xml_obj):
+ """
+ Check if xml_obj is a pointer tag: .
+ No children, one attribute named url_name, no text.
+
+ Special case for course roots: the pointer is
+
+
+ xml_obj: an etree Element
+
+ Returns a bool.
+ """
+ if xml_obj.tag != "course":
+ expected_attr = {'url_name'}
+ else:
+ expected_attr = {'url_name', 'course', 'org'}
+
+ actual_attr = set(xml_obj.attrib.keys())
+
+ has_text = xml_obj.text is not None and len(xml_obj.text.strip()) > 0
+
+ return len(xml_obj) == 0 and actual_attr == expected_attr and not has_text
+
+
+def format_filepath(category, name):
+ """
+ Construct a formatted filepath string based on the given category and name.
+ """
+ return f'{category}/{name}.xml'
+
+
+def load_file(filepath, fs, def_id): # pylint: disable=invalid-name
+ """
+ Open the specified file in fs, and call cls.file_to_xml on it,
+ returning the lxml object.
+
+ Add details and reraise on error.
+ """
+ try:
+ with fs.open(filepath) as xml_file:
+ return file_to_xml(xml_file)
+ except Exception as err:
+ # Add info about where we are, but keep the traceback
+ raise Exception(f'Unable to load file contents at path {filepath} for item {def_id}: {err}') from err
+
+
+def file_to_xml(file_object):
+ """
+ Used when this module wants to parse a file object to xml
+ that will be converted to the definition.
+
+ Returns an lxml Element
+ """
+ return etree.parse(file_object, parser=XML_PARSER).getroot()