diff --git a/odoo/addons/test_testing_utilities/tests/__init__.py b/odoo/addons/test_testing_utilities/tests/__init__.py index 7193c4e5f94..78398a30bce 100644 --- a/odoo/addons/test_testing_utilities/tests/__init__.py +++ b/odoo/addons/test_testing_utilities/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_methods from . import test_lxml from . import test_form_impl +from . import test_xml_tools diff --git a/odoo/addons/test_testing_utilities/tests/test_xml_tools.py b/odoo/addons/test_testing_utilities/tests/test_xml_tools.py new file mode 100644 index 00000000000..8f7d59171e5 --- /dev/null +++ b/odoo/addons/test_testing_utilities/tests/test_xml_tools.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +from lxml import etree +from odoo.tests import common +from odoo.tools.xml_utils import cleanup_xml_node + + +class TestXMLTools(common.TransactionCase): + + def setUp(self): + super(TestXMLTools, self).setUp() + self.qweb_poor = self.env()['ir.ui.view'].create({ + 'type': 'qweb', + 'arch_db': """ +

+

+

text

+ \t

+

+ +"""}) + + def test_cleanup_xml_node_no_modif(self): + # Qweb removes comments and any whitespace before first tag, no other content affected + expected = """

+

+

text

+ \t

+

+ +""" + qweb = self.env['ir.qweb']._render(self.qweb_poor.id) + self.check_xml_cleanup_result_is_as_expected(qweb, expected, remove_blank_text=False, remove_blank_nodes=False, indent_level=-1) + + def test_cleanup_xml_node_indent_level(self): + # Indentation level and spacing works as expected, nothing else affected + # (quirk: first tag not indented because indent is actually previous tag's tail) + expected = """

+__

+__

text

+__

+___

+__

+__

+_ +""" + qweb = self.env['ir.qweb']._render(self.qweb_poor.id) + self.check_xml_cleanup_result_is_as_expected(qweb, expected, remove_blank_text=False, remove_blank_nodes=False, indent_level=1, indent_space="_") + + def test_cleanup_xml_node_keep_blank_text(self): + # Blank nodes are removed but not nodes containing blank text + expected = """

+

text

+

+ +""" + qweb = self.env['ir.qweb']._render(self.qweb_poor.id) + self.check_xml_cleanup_result_is_as_expected(qweb, expected, remove_blank_text=False) + + def test_cleanup_xml_node_keep_blank_nodes(self): + # Blank text is removed but blank (empty) nodes remain + expected = """

+

+

text

+

+

+

+

+ +""" + qweb = self.env['ir.qweb']._render(self.qweb_poor.id) + self.check_xml_cleanup_result_is_as_expected(qweb, expected, remove_blank_nodes=False) + + def test_cleanup_xml_t_call_indent(self): + # Indentation is fixed after t-call (which keeps indentation of called template) + template_1 = self.env['ir.ui.view'].create({ + 'type': 'qweb', + 'arch_db': '''

+ This is content! +

+'''}) + template_2 = self.env['ir.ui.view'].create({ + 'name': 'test', + 'type': 'qweb', + 'arch_db': f''' + + + + +'''}) + expected = """ + +

+ This is content! +

+
+
+""" + qweb = self.env['ir.qweb']._render(template_2.id) + self.check_xml_cleanup_result_is_as_expected(qweb, expected) + + def test_qweb_render_values_empty_nodes(self): + # Indentation is fixed and empty nodes are removed after conditional rendering + template_addresses = self.env['ir.ui.view'].create({ + 'type': 'qweb', + 'arch_db': ''' + + + + +'''}) + template_main = self.env['ir.ui.view'].create({ + 'type': 'qweb', + 'arch_db': f''' + + + + + + + + +'''}) + expected = """ + 1 + 2 + Three + + Baker street + 221B + London + + +""" + qweb = self.env['ir.qweb']._render(template_main.id, { + 'items': [1, 2, "Three", False], + 'addressRecipient': { + 'number': '221B', + 'street': 'Baker street', + 'city': 'London', + }, + 'addressSender': { + 'street': ' ' + } + }) + self.check_xml_cleanup_result_is_as_expected(qweb, expected) + + def check_xml_cleanup_result_is_as_expected(self, original_string, expected_string, **kwargs): + result_string = etree.tostring(cleanup_xml_node(original_string, **kwargs)).decode() + self.assertEqual(expected_string, result_string) + self.assertNotEqual(expected_string, original_string) diff --git a/odoo/tools/xml_utils.py b/odoo/tools/xml_utils.py index 31d2707b6d0..d7d89d7e380 100644 --- a/odoo/tools/xml_utils.py +++ b/odoo/tools/xml_utils.py @@ -86,3 +86,56 @@ def create_xml_node(parent_node, node_name, node_value=None): :rtype: etree._Element """ return create_xml_node_chain(parent_node, [node_name], node_value)[0] + + +def cleanup_xml_node(xml_node_or_string, remove_blank_text=True, remove_blank_nodes=True, indent_level=0, indent_space=" "): + """Clean up the sub-tree of the provided XML node. + + If the provided XML node is of type: + - etree._Element, it is modified in-place. + - string/bytes, it is first parsed into an etree._Element + :param xml_node_or_string (etree._Element, str): XML node (or its string/bytes representation) + :param remove_blank_text (bool): if True, removes whitespace-only text from nodes + :param remove_blank_nodes (bool): if True, removes leaf nodes with no text (iterative, depth-first, done after remove_blank_text) + :param indent_level (int): depth or level of node within root tree (use -1 to leave indentation as-is) + :param indent_space (str): string to use for indentation (use '' to remove all indentation) + :returns (etree._Element): clean node, same instance that was received (if applicable) + """ + xml_node = xml_node_or_string + + # Convert str/bytes to etree._Element + if isinstance(xml_node, str): + xml_node = xml_node.encode() # misnomer: fromstring actually reads bytes + if isinstance(xml_node, bytes): + xml_node = etree.fromstring(xml_node) + + # Process leaf nodes iteratively + # Depth-first, so any inner node may become a leaf too (if children are removed) + def leaf_iter(parent_node, node, level): + for child_node in node: + leaf_iter(node, child_node, level if level < 0 else level + 1) + + # Indentation + if level >= 0: + indent = '\n' + indent_space * level + if not node.tail or not node.tail.strip(): + node.tail = '\n' if parent_node is None else indent + if len(node) > 0: + if not node.text or not node.text.strip(): + # First child's indentation is parent's text + node.text = indent + indent_space + last_child = node[-1] + if last_child.tail == indent + indent_space: + # Last child's tail is parent's closing tag indentation + last_child.tail = indent + + # Removal condition: node is leaf (not root nor inner node) + if parent_node is not None and len(node) == 0: + if remove_blank_text and node.text is not None and not node.text.strip(): + # node.text is None iff node.tag is self-closing (text='' creates closing tag) + node.text = '' + if remove_blank_nodes and not (node.text or ''): + parent_node.remove(node) + + leaf_iter(None, xml_node, indent_level) + return xml_node