Skip to content

Commit 12e8032

Browse files
committed
chore: move xml utility functions to xblock/xml.py
1 parent fa76371 commit 12e8032

File tree

5 files changed

+170
-161
lines changed

5 files changed

+170
-161
lines changed

xblock/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
from xblock.fields import Field, List, Reference, ReferenceList, Scope, String
2424
from xblock.internal import class_lazy
2525
from xblock.plugin import Plugin
26-
from xblock.utils.helpers import is_pointer_tag, load_definition_xml
2726
from xblock.validation import Validation
27+
from xblock.xml import is_pointer_tag, load_definition_xml
2828

2929
# OrderedDict is used so that namespace attributes are put in predictable order
3030
# This allows for simple string equality assertions in tests and have no other effects

xblock/test/test_xml.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""
2+
Tests for xblock/xml.py
3+
"""
4+
5+
import unittest
6+
from io import BytesIO
7+
from unittest.mock import patch, Mock
8+
from lxml import etree
9+
10+
from xblock.xml import (
11+
name_to_pathname,
12+
is_pointer_tag,
13+
load_definition_xml,
14+
format_filepath,
15+
file_to_xml,
16+
)
17+
18+
19+
class TestPointerTagParsing(unittest.TestCase):
20+
"""
21+
Tests for core functions in XBlock.
22+
"""
23+
def test_name_to_pathname(self):
24+
self.assertEqual(name_to_pathname("course:subcourse"), "course/subcourse")
25+
self.assertEqual(name_to_pathname("module:lesson:part"), "module/lesson/part")
26+
self.assertEqual(name_to_pathname("no_colon"), "no_colon")
27+
28+
def test_is_pointer_tag(self):
29+
# Case 1: Valid pointer tag
30+
xml_obj = etree.Element("some_tag", url_name="test_url")
31+
self.assertTrue(is_pointer_tag(xml_obj))
32+
33+
# Case 2: Valid course pointer tag
34+
xml_obj = etree.Element("course", url_name="test_url", course="test_course", org="test_org")
35+
self.assertTrue(is_pointer_tag(xml_obj))
36+
37+
# Case 3: Invalid case - extra attribute
38+
xml_obj = etree.Element("some_tag", url_name="test_url", extra_attr="invalid")
39+
self.assertFalse(is_pointer_tag(xml_obj))
40+
41+
# Case 4: Invalid case - has text
42+
xml_obj = etree.Element("some_tag", url_name="test_url")
43+
xml_obj.text = "invalid_text"
44+
self.assertFalse(is_pointer_tag(xml_obj))
45+
46+
# Case 5: Invalid case - has children
47+
xml_obj = etree.Element("some_tag", url_name="test_url")
48+
_ = etree.SubElement(xml_obj, "child")
49+
self.assertFalse(is_pointer_tag(xml_obj))
50+
51+
@patch("xblock.xml.load_file")
52+
def test_load_definition_xml(self, mock_load_file):
53+
mock_load_file.return_value = "<mock_xml />"
54+
node = etree.Element("course", url_name="test_url")
55+
runtime = Mock()
56+
def_id = "mock_id"
57+
58+
definition_xml, filepath = load_definition_xml(node, runtime, def_id)
59+
self.assertEqual(filepath, "course/test_url.xml")
60+
self.assertEqual(definition_xml, "<mock_xml />")
61+
mock_load_file.assert_called_once()
62+
63+
def test_format_filepath(self):
64+
self.assertEqual(format_filepath("course", "test_url"), "course/test_url.xml")
65+
66+
def test_file_to_xml(self):
67+
"""Test that `file_to_xml` correctly parses XML from a file object."""
68+
# Create a BytesIO object
69+
file_obj = BytesIO(b"<root><child>Value</child></root>")
70+
71+
# Parse the XML
72+
result = file_to_xml(file_obj)
73+
74+
# Verify the result
75+
self.assertEqual(result.tag, 'root')
76+
self.assertEqual(result[0].tag, 'child')
77+
self.assertEqual(result[0].text, 'Value')

xblock/test/utils/test_helpers.py

Lines changed: 1 addition & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,10 @@
33
"""
44

55
import unittest
6-
from io import BytesIO
7-
from unittest.mock import patch, Mock
8-
from lxml import etree
96

107
from xblock.core import XBlock
118
from xblock.test.toy_runtime import ToyRuntime
12-
from xblock.utils.helpers import (
13-
child_isinstance,
14-
name_to_pathname,
15-
is_pointer_tag,
16-
load_definition_xml,
17-
format_filepath,
18-
file_to_xml,
19-
)
9+
from xblock.utils.helpers import child_isinstance
2010

2111

2212
# pylint: disable=unnecessary-pass
@@ -88,64 +78,3 @@ def test_child_isinstance_descendants(self):
8878
self.assertTrue(child_isinstance(root, block.children[1], DogXBlock))
8979
self.assertTrue(child_isinstance(root, block.children[1], GoldenRetrieverXBlock))
9080
self.assertFalse(child_isinstance(root, block.children[1], CatXBlock))
91-
92-
93-
class TestPointerTagParsing(unittest.TestCase):
94-
"""
95-
Tests for core functions in XBlock.
96-
"""
97-
def test_name_to_pathname(self):
98-
self.assertEqual(name_to_pathname("course:subcourse"), "course/subcourse")
99-
self.assertEqual(name_to_pathname("module:lesson:part"), "module/lesson/part")
100-
self.assertEqual(name_to_pathname("no_colon"), "no_colon")
101-
102-
def test_is_pointer_tag(self):
103-
# Case 1: Valid pointer tag
104-
xml_obj = etree.Element("some_tag", url_name="test_url")
105-
self.assertTrue(is_pointer_tag(xml_obj))
106-
107-
# Case 2: Valid course pointer tag
108-
xml_obj = etree.Element("course", url_name="test_url", course="test_course", org="test_org")
109-
self.assertTrue(is_pointer_tag(xml_obj))
110-
111-
# Case 3: Invalid case - extra attribute
112-
xml_obj = etree.Element("some_tag", url_name="test_url", extra_attr="invalid")
113-
self.assertFalse(is_pointer_tag(xml_obj))
114-
115-
# Case 4: Invalid case - has text
116-
xml_obj = etree.Element("some_tag", url_name="test_url")
117-
xml_obj.text = "invalid_text"
118-
self.assertFalse(is_pointer_tag(xml_obj))
119-
120-
# Case 5: Invalid case - has children
121-
xml_obj = etree.Element("some_tag", url_name="test_url")
122-
_ = etree.SubElement(xml_obj, "child")
123-
self.assertFalse(is_pointer_tag(xml_obj))
124-
125-
@patch("xblock.utils.helpers.load_file")
126-
def test_load_definition_xml(self, mock_load_file):
127-
mock_load_file.return_value = "<mock_xml />"
128-
node = etree.Element("course", url_name="test_url")
129-
runtime = Mock()
130-
def_id = "mock_id"
131-
132-
definition_xml, filepath = load_definition_xml(node, runtime, def_id)
133-
self.assertEqual(filepath, "course/test_url.xml")
134-
self.assertEqual(definition_xml, "<mock_xml />")
135-
mock_load_file.assert_called_once()
136-
137-
def test_format_filepath(self):
138-
self.assertEqual(format_filepath("course", "test_url"), "course/test_url.xml")
139-
140-
def test_file_to_xml(self):
141-
"""Test that `file_to_xml` correctly parses XML from a file object."""
142-
# Create a BytesIO object
143-
file_obj = BytesIO(b"<root><child>Value</child></root>")
144-
145-
# Parse the XML
146-
result = file_to_xml(file_obj)
147-
148-
# Verify the result
149-
self.assertEqual(result.tag, 'root')
150-
self.assertEqual(result[0].tag, 'child')
151-
self.assertEqual(result[0].text, 'Value')

xblock/utils/helpers.py

Lines changed: 0 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
"""
22
Useful helper methods
33
"""
4-
from lxml import etree
5-
6-
7-
XML_PARSER = etree.XMLParser(dtd_validation=False, load_dtd=False, remove_blank_text=True, encoding='utf-8')
84

95

106
def child_isinstance(block, child_id, block_class_or_mixin):
@@ -27,87 +23,3 @@ def child_isinstance(block, child_id, block_class_or_mixin):
2723
type_name = block.runtime.id_reader.get_block_type(def_id)
2824
child_class = block.runtime.load_block_type(type_name)
2925
return issubclass(child_class, block_class_or_mixin)
30-
31-
32-
def name_to_pathname(name):
33-
"""
34-
Convert a location name for use in a path: replace ':' with '/'.
35-
This allows users of the xml format to organize content into directories
36-
"""
37-
return name.replace(':', '/')
38-
39-
40-
def is_pointer_tag(xml_obj):
41-
"""
42-
Check if xml_obj is a pointer tag: <blah url_name="something" />.
43-
No children, one attribute named url_name, no text.
44-
45-
Special case for course roots: the pointer is
46-
<course url_name="something" org="myorg" course="course">
47-
48-
xml_obj: an etree Element
49-
50-
Returns a bool.
51-
"""
52-
if xml_obj.tag != "course":
53-
expected_attr = {'url_name'}
54-
else:
55-
expected_attr = {'url_name', 'course', 'org'}
56-
57-
actual_attr = set(xml_obj.attrib.keys())
58-
59-
has_text = xml_obj.text is not None and len(xml_obj.text.strip()) > 0
60-
61-
return len(xml_obj) == 0 and actual_attr == expected_attr and not has_text
62-
63-
64-
def load_definition_xml(node, runtime, def_id):
65-
"""
66-
Parses and loads an XML definition file based on a given node, runtime
67-
environment, and definition ID.
68-
69-
Arguments:
70-
node: XML element containing attributes for definition loading.
71-
runtime: The runtime environment that provides resource access.
72-
def_id: Unique identifier for the definition being loaded.
73-
74-
Returns:
75-
tuple: A tuple containing the loaded XML definition and the
76-
corresponding file path.
77-
"""
78-
url_name = node.get('url_name')
79-
filepath = format_filepath(node.tag, name_to_pathname(url_name))
80-
definition_xml = load_file(filepath, runtime.resources_fs, def_id)
81-
return definition_xml, filepath
82-
83-
84-
def format_filepath(category, name):
85-
"""
86-
Construct a formatted filepath string based on the given category and name.
87-
"""
88-
return f'{category}/{name}.xml'
89-
90-
91-
def load_file(filepath, fs, def_id): # pylint: disable=invalid-name
92-
"""
93-
Open the specified file in fs, and call cls.file_to_xml on it,
94-
returning the lxml object.
95-
96-
Add details and reraise on error.
97-
"""
98-
try:
99-
with fs.open(filepath) as xml_file:
100-
return file_to_xml(xml_file)
101-
except Exception as err:
102-
# Add info about where we are, but keep the traceback
103-
raise Exception(f'Unable to load file contents at path {filepath} for item {def_id}: {err}') from err
104-
105-
106-
def file_to_xml(file_object):
107-
"""
108-
Used when this module wants to parse a file object to xml
109-
that will be converted to the definition.
110-
111-
Returns an lxml Element
112-
"""
113-
return etree.parse(file_object, parser=XML_PARSER).getroot()

xblock/xml.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""
2+
XML utilities for XBlock, including parsing and loading XML definitions.
3+
"""
4+
from lxml import etree
5+
6+
7+
XML_PARSER = etree.XMLParser(dtd_validation=False, load_dtd=False, remove_blank_text=True, encoding='utf-8')
8+
9+
10+
def name_to_pathname(name):
11+
"""
12+
Convert a location name for use in a path: replace ':' with '/'.
13+
This allows users of the xml format to organize content into directories
14+
"""
15+
return name.replace(':', '/')
16+
17+
18+
def is_pointer_tag(xml_obj):
19+
"""
20+
Check if xml_obj is a pointer tag: <blah url_name="something" />.
21+
No children, one attribute named url_name, no text.
22+
23+
Special case for course roots: the pointer is
24+
<course url_name="something" org="myorg" course="course">
25+
26+
xml_obj: an etree Element
27+
28+
Returns a bool.
29+
"""
30+
if xml_obj.tag != "course":
31+
expected_attr = {'url_name'}
32+
else:
33+
expected_attr = {'url_name', 'course', 'org'}
34+
35+
actual_attr = set(xml_obj.attrib.keys())
36+
37+
has_text = xml_obj.text is not None and len(xml_obj.text.strip()) > 0
38+
39+
return len(xml_obj) == 0 and actual_attr == expected_attr and not has_text
40+
41+
42+
def load_definition_xml(node, runtime, def_id):
43+
"""
44+
Parses and loads an XML definition file based on a given node, runtime
45+
environment, and definition ID.
46+
47+
Arguments:
48+
node: XML element containing attributes for definition loading.
49+
runtime: The runtime environment that provides resource access.
50+
def_id: Unique identifier for the definition being loaded.
51+
52+
Returns:
53+
tuple: A tuple containing the loaded XML definition and the
54+
corresponding file path.
55+
"""
56+
url_name = node.get('url_name')
57+
filepath = format_filepath(node.tag, name_to_pathname(url_name))
58+
definition_xml = load_file(filepath, runtime.resources_fs, def_id)
59+
return definition_xml, filepath
60+
61+
62+
def format_filepath(category, name):
63+
"""
64+
Construct a formatted filepath string based on the given category and name.
65+
"""
66+
return f'{category}/{name}.xml'
67+
68+
69+
def load_file(filepath, fs, def_id): # pylint: disable=invalid-name
70+
"""
71+
Open the specified file in fs, and call cls.file_to_xml on it,
72+
returning the lxml object.
73+
74+
Add details and reraise on error.
75+
"""
76+
try:
77+
with fs.open(filepath) as xml_file:
78+
return file_to_xml(xml_file)
79+
except Exception as err:
80+
# Add info about where we are, but keep the traceback
81+
raise Exception(f'Unable to load file contents at path {filepath} for item {def_id}: {err}') from err
82+
83+
84+
def file_to_xml(file_object):
85+
"""
86+
Used when this module wants to parse a file object to xml
87+
that will be converted to the definition.
88+
89+
Returns an lxml Element
90+
"""
91+
return etree.parse(file_object, parser=XML_PARSER).getroot()

0 commit comments

Comments
 (0)