[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:
Stanislas Gueniffey
2022-05-11 09:21:59 +02:00
parent 2490982fce
commit ac175c98e1
3 changed files with 207 additions and 0 deletions
@@ -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)
+53
View File
@@ -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