[IMP] define xml_utils.cleanup_xml_node
Intended to cleanup qweb xmls: - remove blank (empty) nodes and/or nodes with whitespace text - fix indentation - remove indentation (needed for some xml signatures) closes odoo/odoo#91006 X-original-commit: b7d7adb4f5a9873350511c227d5fbf54c8f38ce8 Signed-off-by: William André (wan) <wan@odoo.com>
This commit is contained in:
@@ -2,3 +2,4 @@
|
||||
from . import test_methods
|
||||
from . import test_lxml
|
||||
from . import test_form_impl
|
||||
from . import test_xml_tools
|
||||
|
||||
@@ -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': """
|
||||
<h1>
|
||||
<h2/>
|
||||
<h2>text</h2>
|
||||
\t<h2><h3/></h2>
|
||||
<h2> </h2>
|
||||
<!-- comment removed by qweb -->
|
||||
</h1>"""})
|
||||
|
||||
def test_cleanup_xml_node_no_modif(self):
|
||||
# Qweb removes comments and any whitespace before first tag, no other content affected
|
||||
expected = """<h1>
|
||||
<h2/>
|
||||
<h2>text</h2>
|
||||
\t<h2><h3/></h2>
|
||||
<h2> </h2>
|
||||
|
||||
</h1>"""
|
||||
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 = """<h1>
|
||||
__<h2/>
|
||||
__<h2>text</h2>
|
||||
__<h2>
|
||||
___<h3/>
|
||||
__</h2>
|
||||
__<h2> </h2>
|
||||
_</h1>
|
||||
"""
|
||||
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 = """<h1>
|
||||
<h2>text</h2>
|
||||
<h2> </h2>
|
||||
</h1>
|
||||
"""
|
||||
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 = """<h1>
|
||||
<h2/>
|
||||
<h2>text</h2>
|
||||
<h2>
|
||||
<h3/>
|
||||
</h2>
|
||||
<h2></h2>
|
||||
</h1>
|
||||
"""
|
||||
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': '''<h1>
|
||||
<content>This is content!</content>
|
||||
</h1>
|
||||
'''})
|
||||
template_2 = self.env['ir.ui.view'].create({
|
||||
'name': 'test',
|
||||
'type': 'qweb',
|
||||
'arch_db': f'''<odoo>
|
||||
<data>
|
||||
<t t-call="{template_1.id}"/>
|
||||
</data>
|
||||
</odoo>
|
||||
'''})
|
||||
expected = """<odoo>
|
||||
<data>
|
||||
<h1>
|
||||
<content>This is content!</content>
|
||||
</h1>
|
||||
</data>
|
||||
</odoo>
|
||||
"""
|
||||
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': '''<t>
|
||||
<street t-esc="address.get('street')"/>
|
||||
<number t-esc="address.get('number')"/>
|
||||
<city t-esc="address.get('city')"/>
|
||||
</t>
|
||||
'''})
|
||||
template_main = self.env['ir.ui.view'].create({
|
||||
'type': 'qweb',
|
||||
'arch_db': f'''<data>
|
||||
<item t-foreach="items" t-as="item" t-esc="item"/>
|
||||
<addressSender t-call='{template_addresses.id}'>
|
||||
<t t-set="address" t-value="addressSender"/>
|
||||
</addressSender>
|
||||
<addressRecipient t-call='{template_addresses.id}'>
|
||||
<t t-set="address" t-value="addressRecipient"/>
|
||||
</addressRecipient>
|
||||
</data>
|
||||
'''})
|
||||
expected = """<data>
|
||||
<item>1</item>
|
||||
<item>2</item>
|
||||
<item>Three</item>
|
||||
<addressRecipient>
|
||||
<street>Baker street</street>
|
||||
<number>221B</number>
|
||||
<city>London</city>
|
||||
</addressRecipient>
|
||||
</data>
|
||||
"""
|
||||
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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user