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()