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>
142 lines
5.8 KiB
Python
142 lines
5.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Utilities for generating, parsing and checking XML/XSD files on top of the lxml.etree module."""
|
|
|
|
import base64
|
|
from io import BytesIO
|
|
from lxml import etree
|
|
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class odoo_resolver(etree.Resolver):
|
|
"""Odoo specific file resolver that can be added to the XML Parser.
|
|
|
|
It will search filenames in the ir.attachments
|
|
"""
|
|
|
|
def __init__(self, env):
|
|
super().__init__()
|
|
self.env = env
|
|
|
|
def resolve(self, url, id, context):
|
|
"""Search url in ``ir.attachment`` and return the resolved content."""
|
|
attachment = self.env['ir.attachment'].search([('name', '=', url)])
|
|
if attachment:
|
|
return self.resolve_string(base64.b64decode(attachment.datas), context)
|
|
|
|
|
|
def _check_with_xsd(tree_or_str, stream, env=None):
|
|
"""Check an XML against an XSD schema.
|
|
|
|
This will raise a UserError if the XML file is not valid according to the
|
|
XSD file.
|
|
|
|
:param str | etree._Element tree_or_str: representation of the tree to be checked
|
|
:param io.IOBase | str stream: the byte stream used to build the XSD schema.
|
|
If env is given, it can also be the name of an attachment in the filestore
|
|
:param odoo.api.Environment env: If it is given, it enables resolving the
|
|
imports of the schema in the filestore with ir.attachments.
|
|
"""
|
|
if not isinstance(tree_or_str, etree._Element):
|
|
tree_or_str = etree.fromstring(tree_or_str)
|
|
parser = etree.XMLParser()
|
|
if env:
|
|
parser.resolvers.add(odoo_resolver(env))
|
|
if isinstance(stream, str) and stream.endswith('.xsd'):
|
|
attachment = env['ir.attachment'].search([('name', '=', stream)])
|
|
if not attachment:
|
|
raise FileNotFoundError()
|
|
stream = BytesIO(base64.b64decode(attachment.datas))
|
|
xsd_schema = etree.XMLSchema(etree.parse(stream, parser=parser))
|
|
try:
|
|
xsd_schema.assertValid(tree_or_str)
|
|
except etree.DocumentInvalid as xml_errors:
|
|
raise UserError('\n'.join(str(e) for e in xml_errors.error_log))
|
|
|
|
|
|
def create_xml_node_chain(first_parent_node, nodes_list, last_node_value=None):
|
|
"""Generate a hierarchical chain of nodes.
|
|
|
|
Each new node being the child of the previous one based on the tags contained
|
|
in `nodes_list`, under the given node `first_parent_node`.
|
|
|
|
:param etree._Element first_parent_node: parent of the created tree/chain
|
|
:param iterable[str] nodes_list: tag names to be created
|
|
:param str last_node_value: if specified, set the last node's text to this value
|
|
:returns: the list of created nodes
|
|
:rtype: list[etree._Element]
|
|
"""
|
|
res = []
|
|
current_node = first_parent_node
|
|
for tag in nodes_list:
|
|
current_node = etree.SubElement(current_node, tag)
|
|
res.append(current_node)
|
|
|
|
if last_node_value is not None:
|
|
current_node.text = last_node_value
|
|
return res
|
|
|
|
|
|
def create_xml_node(parent_node, node_name, node_value=None):
|
|
"""Create a new node.
|
|
|
|
:param etree._Element parent_node: parent of the created node
|
|
:param str node_name: name of the created node
|
|
:param str node_value: value of the created node (optional)
|
|
: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
|