import re from bisect import bisect_left from copy import deepcopy from itertools import chain from collections.abc import Iterable from collections import deque from dataclasses import dataclass, field from typing import Optional, Callable, Union, Literal, TypeAlias from lxml import etree Element = etree.Element ## SOME XML UTILS INDENT_RE = re.compile(r"(^\n[\s\t]*)|(\n[\s\t]*$)") def append_text(node, text): if len(node) > 0: tail = (node[-1].tail or "") + (text or "") node[-1].tail = tail or None else: text = (node.text or "") + (text or "") node.text = text or None def dedent_tree(elem): for el in elem.iter(tag=(etree.Element, etree.Comment)): if el.text is not None and not isinstance(el, etree._Comment): el.text = INDENT_RE.sub("", el.text) or None if el.tail is not None: el.tail = INDENT_RE.sub("", el.tail) or None def indent_tree(elem, level=1, spaces=2): """ The lxml library doesn't pretty_print xml tails, this method aims to solve this. Returns the elem with properly indented text and tail """ # See: http://lxml.de/FAQ.html#why-doesn-t-the-pretty-print-option-reformat-my-xml-output # Below code is inspired by http://effbot.org/zone/element-lib.htm#prettyprint indent_texts = elem.tag == "xpath" len_elem = len(elem) if len_elem: i = "\n" + level * spaces * " " prev_i = "\n" + (level - 1) * spaces * " " if indent_texts or (not elem.text or not elem.text.strip()): text = elem.text elem.text = (text.strip() + i) if text else i index = 0 while (index < len_elem): subelem = elem[index] tail = (subelem.tail or "").strip() if indent_texts or not tail: if index == len_elem - 1: subelem.tail = (i + tail + prev_i) if tail else prev_i else: subelem.tail = (i + tail + i) if tail else i indent_tree(subelem, level + 1, spaces) index += 1 return elem def visit(node, do_children=lambda n: True): yield node if do_children(node) is False: return for child in node.iterchildren(etree.Element): yield from visit(child, do_children) def _visit_new_node(new_node): yield from visit(new_node, lambda n: not n.get(DIFF_ATTRIBUTE)) ## TOOLS TO MAKE THE DIFF def diff_dicts(old: etree._Attrib, new: etree._Attrib, ignored_keys: Optional[set] = frozenset()) -> dict: return { k: new.get(k) for k in sorted(set().union(old, new)) if k not in ignored_keys and old.get(k) != new.get(k) } def longest_increasing_subsequence(arr): """Returns the longest increasing subsequence of a list of unordered values Largely inspired from https://en.wikipedia.org/wiki/Longest_increasing_subsequence and by https://cp-algorithms.com/sequences/longest_increasing_subsequence.html It compares items on their value: in the case items are string: "11" < "2" == True. So, transforming an item to its value if necessary should be done before hand As it returns the biggest list of stable elements in the list, it is useful to compute the least amount of moving items into that list eg: [3,1,2] : 1 and 2 did not move, 3 is just placed before 1 longest_increasing_subsequence = [2,1] (the output is the reversed of the subsequence) """ if not arr: return [] previous = {} first, *list_arr = arr smallest_endings = [first] for el in list_arr: if el < smallest_endings[-1]: target_index = bisect_left(smallest_endings, el) previous[el] = smallest_endings[target_index - 1] smallest_endings[target_index] = el else: previous[el] = smallest_endings[-1] smallest_endings.append(el) sequence = [] el = smallest_endings[-1] for _el in smallest_endings: sequence.append(el) el = previous.get(el) return sequence Position = Literal["replace", "attributes", "move", "before", "after", "inside"] # Leaves are representation of the elements in the Tree # to ease their management @dataclass class TextLeaf: element: Optional[str] = None ignores: set[Position] = field(default_factory=set) def __post_init__(self): self.element = INDENT_RE.sub("", self.element or "") or None @dataclass class NodeLeaf: element: etree._Element = None id: Optional[str] = None is_owned: bool = False @dataclass class CommentLeaf: element: Optional[str] = None Leaf: TypeAlias = Union[TextLeaf, NodeLeaf, CommentLeaf] XMLInput: TypeAlias = Union[etree._ElementTree, etree._Element, str] def _group_by_pivot(iterable: Iterable, is_pivot: Callable): """Group the iterable's elements according to an unmoving pivor (or target). Similar to itertools.groupy, except it needed to be "read ahead" as the target of a leaf can be before any pivot node. returns a Generator, of which iterations are composed by a tuple (target, position), elements if target and position are None, no pivot has been found. if position is None, the pivot is the last one. :param: iterable :param: is_pivot :return: Generator: Tuple: (target, position), list[elements]. """ current_pivot = None current_group = [] left_overs = [] for elem in iterable: new_pivot = is_pivot(elem) if new_pivot in (None, False): if current_pivot in (None, False): left_overs.append(elem) else: current_group.append(elem) elif current_pivot not in (None, False): if current_group: yield (current_pivot, "after"), current_group current_group = [] current_pivot = new_pivot else: if left_overs: yield (new_pivot, "before"), left_overs left_overs = [] current_pivot = new_pivot if left_overs or current_group: yield (current_pivot, None), left_overs or current_group def _get_subtree_and_ancestors(node: etree._Element, is_subtree=lambda n: False) -> tuple[etree._Element | None, list[etree._Element]]: """For a node, returns its subtree parent (a relevant parent node that indicates a tree that could be separate) eg:
(this is a subtree)
(this is a subtree) and its ancestors (excluding the subtree) in opposite order from the document's (ie bottom-up) """ ancestors = [] for ancestor in node.iterancestors(etree.Element): if is_subtree(ancestor): return ancestor, ancestors ancestors.append(ancestor) return None, ancestors def _standardize_target_position(target, position, parent): """The _group_by_pivot function returns an abstract target/position tuple This function converts that into the behavior we want. """ if target is not None and position is None: return target, "after" else: return target if target is not None else parent, position or "inside" def is_node_empty(node): return len(node) == 0 and node.text is None def _texts_concat(texts, null_value=None): return "".join(t or "" for t in texts) or null_value def _leaves_repr(leaves: list[Leaf]): texts = [] for leaf in leaves: match leaf: case NodeLeaf(id=id): if texts: yield (None, _texts_concat(texts)) texts = [] yield (id or "new", None) case TextLeaf(element=element): if element: texts.append(element) if texts: yield (None, _texts_concat(texts)) def append_leaf(leaves, leaf: Leaf): if isinstance(leaf, TextLeaf): prev = leaves[-1] if len(leaves) > 0 else None if isinstance(prev, TextLeaf): prev.element = _texts_concat(lf.element for lf in [prev, leaf]) return leaves.append(leaf) DIFF_ATTRIBUTE = "o-diff-key" class DiffAnalyzer: """Class that parses both trees and determines what has changed between the two. For each pair, it computes the differences in the attributes and in content. Changes are understood as a mapping from node_id to the things (added, removed, moving nodes) at that spot precisely. It uses a NodeTracker to globally register what happens in terms of nodes moving around. The two sets of data are necessary to ensure we do things with a global perspective, but also be able to put the relevant changes around a node grouped together. """ def __init__( self, ignore_attributes=None, on_new_node=lambda n: True, is_subtree=lambda n: False, get_moving_candidate_key=lambda n: None, ): self.ignore_attributes = set( [] if ignore_attributes is None else ignore_attributes ) self.on_new_node = on_new_node self.is_subtree = is_subtree self.changes = {} self.tracker = NodeTracker(get_moving_candidate_key) def diff(self, old: XMLInput, new: XMLInput) -> dict: old_tree = self._build_tree_from_input(old) new_tree = self._build_tree_from_input(new) self.map_id_to_node_old = {node.get(DIFF_ATTRIBUTE): node for node in old_tree.iter(etree.Element)} self.tracker.keep(new_tree.get(DIFF_ATTRIBUTE)) self._diff_nodes(self.map_id_to_node_old[new_tree.get(DIFF_ATTRIBUTE)], new_tree) return { "changes": self.changes, "original_node_map": self.map_id_to_node_old, "node_tracker": self.tracker, } def _build_tree_from_input(self, diff_input: XMLInput) -> etree._ElementTree: if isinstance(diff_input, (etree._ElementTree, etree._Element)): return deepcopy(diff_input) else: parser = etree.XMLParser(remove_blank_text=True, resolve_entities=False) return etree.fromstring(diff_input, parser=parser) def _diff_nodes(self, old, new): old_set_node_ids = set() old_leaves: list[Leaf] = [TextLeaf(old.text)] comments = set() for child in old.iterchildren(tag=(etree.Element, etree.Comment)): if isinstance(child, etree._Comment): comments.add(repr(child)) append_leaf(old_leaves, TextLeaf(child.tail)) continue nid = child.get(DIFF_ATTRIBUTE) old_set_node_ids.add(nid) old_leaves.extend([ NodeLeaf(element=child, id=nid), TextLeaf(element=child.tail), ]) leaves = [] # represents children and texts. They will be grouped according to their nearest unmoving sibling kept_nodes = [] # nodes' id that are still present children_to_diff = {} # a map id to new node to continue the iteration of the tree removed_nodes = set(old_set_node_ids) # children of the main node that will be removed candidates_move = [] # gather new nodes that potentially have a removed counterpart leaves.append(TextLeaf(new.text)) for child in new.iterchildren(tag=(etree.Element, etree.Comment)): if isinstance(child, etree._Comment): if repr(child) not in comments: leaves.append(CommentLeaf(child)) append_leaf(leaves, TextLeaf(child.tail)) child.tail = None continue nid = child.get(DIFF_ATTRIBUTE) is_owned = nid in old_set_node_ids candidate_key = None if nid: children_to_diff[nid] = child if is_owned: self.tracker.keep(nid) kept_nodes.append(int(nid)) removed_nodes.remove(nid) else: self.tracker.move(nid) else: candidate_key = self.tracker.moving_candidate(child) if candidate_key is not None: kept_nodes.append(candidate_key) candidates_move.append((candidate_key, False)) else: for new_child in _visit_new_node(child): if _nid := new_child.get(DIFF_ATTRIBUTE): self.tracker.move(_nid) children_to_diff[_nid] = new_child elif candidate_key := self.tracker.moving_candidate(new_child): candidates_move.append((candidate_key, True)) leaves.extend([ NodeLeaf(element=child, id=nid, is_owned=is_owned), TextLeaf(element=child.tail), ]) attributes_changes = diff_dicts(old.attrib, new.attrib, self.ignore_attributes) has_body_changes = tuple(_leaves_repr(old_leaves)) != tuple(_leaves_repr(leaves)) if not has_body_changes and not attributes_changes: for nid, new_child in children_to_diff.items(): self._diff_nodes(self.map_id_to_node_old[nid], new_child) return command = {"attributes": attributes_changes, "new_node": new, "new_leaves": leaves} if has_body_changes: command.update( candidates_move=candidates_move, kept_nodes=kept_nodes, removed_nodes=removed_nodes, old_leaves=old_leaves, ) self.changes[old.get(DIFF_ATTRIBUTE)] = command for nid, new_child in children_to_diff.items(): self._diff_nodes(self.map_id_to_node_old[nid], new_child) def _get_moving_candidate_key(self, node): """From a node, determines whether it is electable to be a moving node. Valid candidates should: - be empty (no children, no text) - have a not None key - be in a subtree that was here in the old tree (has a DIFF ID)""" if len(node) > 0 or (node.text or "").strip(): return key = self.__get_moving_candidate_key(node) if key is None: return subtree, _ = _get_subtree_and_ancestors(node, self.is_subtree) if subtree is None or not subtree.get(DIFF_ATTRIBUTE): return return tuple(chain([subtree.get(DIFF_ATTRIBUTE)], key)) class KeyedXmlDiffer: """A class that allows to compute the difference between two trees, of which we know one is a modification of the other. Namely, both trees have nodes that have a unique ID, each node in the new tree is compared to its counterpart in the old one. Hence the recommended flow: - assign ids on the original tree - ids must be convertible to int, and increasing with the tree's order (depth first) - do some operation on that modified tree - compare the original tree with the modified one It supports moving, removing nodes, altering texts, altering the order of a node's children, modifying attributes of a node It doesn't support changing the tag name of a node It doesn't support anything else than Elements, in particular, comments and their tail will be ignored The `diff` method returns an abstraction describing what happened for a node with a given id The `diff_xpath` method computes the Odoo's xpath notation to be used as an inherited view The expected complexity is O(n log n), because of the use of bisect. It could be higher when we compute the xpath for each touched nodes. We still have to browse several times the trees. """ @classmethod def assign_node_ids_for_diff(cls, tree): for index, desc in enumerate(tree.iter(etree.Element)): desc.set(DIFF_ATTRIBUTE, str(index)) def __init__( self, ignore_attributes=None, on_new_node=lambda n: True, is_subtree=lambda n: False, xpath_with_meta=False, get_moving_candidate_key=lambda n: None, ): # User-defined parameters self.ignore_attributes = set([] if ignore_attributes is None else ignore_attributes) self.on_new_node = on_new_node self.attributes_identifiers = { "id": True, "name": True, "for": True, "t-name": True, "t-call": True, "t-field": True, "t-set": True, } self.is_subtree = is_subtree self.xpath_with_meta = xpath_with_meta # Internal State self.new_id = 1 self.analyzer = DiffAnalyzer( ignore_attributes=ignore_attributes, on_new_node=on_new_node, is_subtree=is_subtree, get_moving_candidate_key=get_moving_candidate_key, ) # Basic diffing abstraction def diff(self, old: XMLInput, new: XMLInput): return self.analyzer.diff(old, new) # Methods that concern the building of the Odoo's xpath semantic tree def diff_xpath(self, old: XMLInput, new: XMLInput, flat: bool = False) -> str: diff = self.diff(old, new) changes = diff["changes"] self.tracker = diff["node_tracker"] self.map_id_to_node_old = diff["original_node_map"] self.new_nodes_map: dict[str, etree._Element] = {} return self._build_xpath_operations(changes, flat) def _build_xpath_operations(self, changes: dict, flat: bool) -> str: # gather nodes that are moving around for change in changes.values(): for rm_id in change.get("removed_nodes", []): if self.tracker.is_removed(rm_id): el = self.map_id_to_node_old[rm_id] candidate = self.tracker.moving_reference(el) if candidate is not None: self.tracker.keep(rm_id) candidate.set(DIFF_ATTRIBUTE, el.get(DIFF_ATTRIBUTE)) traversed = set() for nid in self.tracker.move_ids: node = self.map_id_to_node_old[nid] for ancestor in node.iterancestors(etree.Element): ancestor_id = ancestor.get(DIFF_ATTRIBUTE) if ancestor_id in traversed: break else: traversed.add(ancestor_id) if self.tracker.is_removed(ancestor_id): self.tracker.delay_remove(ancestor_id) break # Gather virtual xpaths operations xpath_operations_bundles = [] for main_id, change in changes.items(): if self.tracker.is_removed(main_id): continue main_xpaths_list = deque() sub_xpath_list = [] replace_targets = set() ## O. Heart of the system: inside a node, determines unmoving children ## Determine what operations to do around those pivots (replace/after/before/inside) ## Determine whether we *can* do those operation given the surrounding texts. if "kept_nodes" in change: kept_nodes_ids = [] for key in change["kept_nodes"]: if isinstance(key, int): kept_nodes_ids.append(key) else: ref, _ = self.tracker.get_from_key(key) if ref is not None: kept_nodes_ids.append(int(ref.get(DIFF_ATTRIBUTE))) pivot_nodes = {str(k) for k in longest_increasing_subsequence(kept_nodes_ids)} should_full_replace = False def is_pivot(leaf): return nid if (nid := isinstance(leaf, NodeLeaf) and leaf.element.get(DIFF_ATTRIBUTE)) in pivot_nodes else None old_leaves = change["old_leaves"] new_leaves = change["new_leaves"] old_pivoting = {_standardize_target_position(target, position, main_id): leaves for (target, position), leaves in _group_by_pivot(old_leaves, is_pivot)} for (target, position), new_leaves in _group_by_pivot(new_leaves, is_pivot): target, position = _standardize_target_position(target, position, main_id) replace_target, old_leaves_with_removals = self._parse_old_leaves(old_pivoting.get((target, position), [])) if replace_target: texts_compatible = self._leaves_text_compatibility(old_leaves_with_removals, new_leaves, "replace") if not texts_compatible: replace_target = None else: position = "replace" target = replace_target replace_targets.add(target) if position != "replace": texts_compatible = self._leaves_text_compatibility(old_leaves_with_removals, new_leaves, position) if not texts_compatible: should_full_replace = True break xpath_operation = Element("xpath", expr=target, position=position) main_xpaths_list.append(xpath_operation) self.fill_xpath_node(new_leaves, xpath_operation, sub_xpath_list, position) if should_full_replace: main_xpaths_list, sub_xpath_list = self._handle_node_full_replace(main_id, change) xpath_operations_bundles.append([main_xpaths_list, sub_xpath_list]) continue ## 1. Remove nodes first, that way browsing that subtree will be easier for cnid in change.get("removed_nodes", []): if self.tracker.is_removed(cnid, include_delayed=False) and cnid not in replace_targets: main_xpaths_list.appendleft(Element("xpath", expr=cnid, position="replace")) ## 2. Make the changes onto the attributes. It is last because the node's identifiers ## and consequently the node's xpath may be affected main_xpaths_list.extend(self._build_xpaths_attributes(change["attributes"], main_id)) xpath_operations_bundles.append([main_xpaths_list, sub_xpath_list]) ## 3. Build xpath for new nodes which have a fully removed counterpart ## that is close enough (same key) to be reconciled with it. for candidate_key, in_new_node in change.get("candidates_move", []): ref, candid = self.tracker.get_from_key(candidate_key) if ref is not None: xpath_ops = self._build_xpaths_attributes(diff_dicts(ref.attrib, candid.attrib, self.ignore_attributes), ref.get(DIFF_ATTRIBUTE)) if in_new_node: sub_xpath_list.extend(xpath_ops) else: main_xpaths_list.extend(xpath_ops) ## Remove nodes for which at least one children was moved around and still present if self.tracker.delayed_remove: delayed_xpaths = [] xpath_operations_bundles.append([delayed_xpaths]) for rm_id in self.tracker.delayed_remove: xpath_element = Element("xpath", expr=rm_id, position="replace") delayed_xpaths.append(xpath_element) ## Build the actual xpath tree diff_as_arch = Element("data") for operation_bundle in xpath_operations_bundles: bundle_sub_element = Element("data") if not flat else diff_as_arch for operation_list in operation_bundle: for xpath_node in operation_list: self._apply_xpath_operation(xpath_node) if xpath_node.get("position") != "replace" and is_node_empty(xpath_node): continue bundle_sub_element.append(xpath_node) if not is_node_empty(bundle_sub_element) and bundle_sub_element != diff_as_arch: diff_as_arch.append(bundle_sub_element) ## Clean up the result from some metadata for node in diff_as_arch.iter(etree.Element): node.attrib.pop("o-diff-new-id", None) if len(diff_as_arch) == 0: return "" indent_tree(diff_as_arch) return etree.tostring(diff_as_arch, encoding="unicode") def _build_xpaths_attributes(self, attributes_changes, expr_id): if attributes_changes: xpath_attrs = Element("xpath", expr=expr_id, position="attributes") for key, value in attributes_changes.items(): attr_node = etree.Element("attribute", name=key) attr_node.text = value xpath_attrs.append(attr_node) return [xpath_attrs] return [] def fill_xpath_node( self, leaves: list[Leaf], target_xpath: etree._Element, sub_xpath_list, ignored_position: Optional[Position] = None, ): for leaf in leaves: match leaf: case TextLeaf(element=element): if not ignored_position or ignored_position not in leaf.ignores: append_text(target_xpath, element) case CommentLeaf(element=element): target_xpath.append(element) case NodeLeaf(element=element): if diff_id := element.get(DIFF_ATTRIBUTE): target_xpath.append(Element("xpath", expr=diff_id, position="move")) else: new_element, sub_operations = self._handle_new_node(element) target_xpath.append(deepcopy(new_element)) sub_xpath_list.extend(sub_operations) def _apply_xpath_operation(self, xpath_node): """Apply the relevant changes of xpath_node onto the old tree. We are incrementally changing the old tree to be able to locate each node and accurately compute their xpath as they move around. This function reproduces partially what is done in template_inheritance. Except that here we don't care about tails or texts or attributes, as only the relative position of nodes is crucial for us. """ expr, position, new_target = [xpath_node.get(attr) for attr in ["expr", "position", "new_target"]] if not new_target: target_node = self.map_id_to_node_old[expr] else: target_node = self.new_nodes_map[expr] xpath_node.set("expr", self._get_xpath(target_node)) if position == "move": self._redo_xpath_node(xpath_node, target_node) return target_node last_target = target_node for content in xpath_node: if new_id := content.get("o-diff-new-id"): content = self.new_nodes_map[new_id] elif content.tag == "xpath": content = self._apply_xpath_operation(content) else: content = deepcopy(content) if position == "before": target_node.addprevious(content) elif position in ("after", "replace"): last_target.addnext(content) last_target = content elif position == "inside": target_node.append(content) self._redo_xpath_node(xpath_node, target_node) if position == "replace": target_node.getparent().remove(target_node) def _handle_new_node(self, new_node): """When a node was not present in the old tree (it doesn't have an DIFF-ID) we should browse it recursively to detect old nodes that have been moved inside. We build xpaths to place those move correctly. The new node may be altered when a old node has been moved inside, surrounded by texts. """ new_node = deepcopy(new_node) new_node.tail = None parents = {} for node in _visit_new_node(new_node): nid = node.get(DIFF_ATTRIBUTE) if nid: parents[node.getparent()] = True node.clear(keep_tail=True) node.set(DIFF_ATTRIBUTE, nid) else: node.set("o-diff-new-id", str(self.new_id)) self.new_nodes_map[str(self.new_id)] = node self.new_id += 1 self.on_new_node(node) def is_pivot(n): return None if n.get(DIFF_ATTRIBUTE) else n xpath_list = [] for parent in parents: for (target, position), children in _group_by_pivot(parent.iterchildren(etree.Element), is_pivot): target, position = _standardize_target_position(target, position, parent) xpath_element = Element("xpath", expr=target.get("o-diff-new-id"), position=position, new_target="1") xpath_list.append(xpath_element) if position == "after": tail = INDENT_RE.sub("", target.tail or "") or None target.tail = INDENT_RE.sub("", children[-1].tail or "") or None children[-1].tail = None xpath_element.text = tail after_text = None if position == "before": if (prev := target.getprevious()) is not None: after_text = prev.tail prev.tail = None else: parent = target.getparent() after_text = parent.text parent.text = None after_text = INDENT_RE.sub("", after_text or "") or None parent = children[0].getparent() if len(children) else None for child in children: xpath_element.append(Element("xpath", expr=child.get(DIFF_ATTRIBUTE), position="move")) append_text(xpath_element, INDENT_RE.sub("", child.tail or "") or None) parent.remove(child) append_text(xpath_element, after_text) return new_node, xpath_list def _get_identifiers(self, node): node_attrib = node.attrib return {attr: node.get(attr) for attr in self.attributes_identifiers if attr in node_attrib} def _get_descendants_axis_xpath(self, node: etree._Element, subtree: etree._Element | None = None) -> str: """Computes the xpath for `node` in terms of the descendants axis eg: [subtree]//[node's identification] If more than one node is found, the function returns an empty string subtree is a reference node for which we can compute the xpath separately """ xpath_template = "//%s[@%s='%s']" if subtree is None: subtree = node.getroottree() is_subtree_element = False else: is_subtree_element = True tag = node.tag identifiers = self._get_identifiers(node) for name, value in identifiers.items(): xpath_from_subtree = xpath_template % (tag, name, value) found = subtree.xpath("." + xpath_from_subtree) if found is not None and len(found) == 1: if is_subtree_element: return self._get_xpath(subtree) + xpath_from_subtree return "." + xpath_from_subtree return "" def _get_children_axis_xpath(self, node: etree._Element, ancestors: list[etree._Element], subtree: etree._Element | None = None) -> str: """Computes the xpath of `node` in terms of direct children hierarchy eg: /form/div/notebook ancestors is in opposite order (bottom-up) subtree is a reference node for which we can compute the xpath separately """ if subtree is not None: xpath = self._get_xpath(subtree) else: xpath = "" for ancestor in chain(reversed(ancestors), [node]): xpath += self._get_node_xpath(ancestor) return xpath def _get_node_xpath(self, node): """Computes the relative xpath of node in the context of its parent. Only the part concerning the location of the node inside the parent is returned eg: /field[@name='display_name'][1] /div[4] """ identifiers = list(self._get_identifiers(node).items()) main_identifier = identifiers and identifiers[0] iter_siblings = node.itersiblings(node.tag, preceding=True) if main_identifier: count = len([s for s in iter_siblings if s.get(main_identifier[0]) == main_identifier[1]]) else: count = len(list(iter_siblings)) count += 1 # As usual, xpath index starts at 1 if main_identifier: local_xpath = f"/{node.tag}[@{main_identifier[0]}='{main_identifier[1]}']" else: local_xpath = f"/{node.tag}" if count > 1: local_xpath += f"[{count}]" return local_xpath def _get_xpath(self, node): subtree, ancestors = _get_subtree_and_ancestors(node, self.is_subtree) absolute = self._get_descendants_axis_xpath(node, subtree) if absolute: return absolute return self._get_children_axis_xpath(node, ancestors, subtree) def _redo_xpath_node(self, xpath_node, target_node): """Until now, we prepared "virtual" xpath nodes that have the correct position but only a target's DIFF ID as its expression. This method converts that virtual expression into its real XPATH representation, and applies some clean up to have deterministic output""" attribs = {} for attr in ("expr", "position"): attribs[attr] = xpath_node.attrib.pop(attr) for attr in xpath_node.attrib: xpath_node.attrib.pop(attr) for k, v in attribs.items(): xpath_node.set(k, v) if target_node is not None and self.xpath_with_meta: for attr in target_node.attrib: if attr == DIFF_ATTRIBUTE: continue xpath_node.set(f"meta-{attr}", target_node.get(attr)) def _handle_node_full_replace(self, node_id, change): """When texts in an old node are incompatible with texts in a new node we replace the whole node by its new tag, attributes and contents. We don't want to loose references of its children though. So, we build a temporary node in which we push and move everything to replace the old node with that new one in a second operation """ main_node = self.map_id_to_node_old[node_id] new_node = change["new_node"] attribs = dict(new_node.attrib) main_xpaths_list = [] sub_xpath_list = [] if len(new_node) == 0: replace_with = Element(main_node.tag, attribs) replace_with.text = new_node.text replace_xpath = Element("xpath", expr=node_id, position="replace", replace_all="1") replace_xpath.append(deepcopy(replace_with)) main_xpaths_list.append(replace_xpath) return main_xpaths_list, sub_xpath_list # Append a temporary node inside the main one to store elements. # [*children] xpath_temp = Element("xpath", expr=node_id, position="inside") new_id = self.new_id self.new_id += 1 new_node = Element(main_node.tag, attribs) new_node.set("o-diff-new-id", str(new_id)) self.new_nodes_map[str(new_id)] = new_node xpath_temp.append(deepcopy(new_node)) main_xpaths_list.append(xpath_temp) inside_temp_xpath = Element("xpath", expr=str(new_id), position="inside", new_target="1") main_xpaths_list.append(inside_temp_xpath) self.fill_xpath_node(change['new_leaves'], inside_temp_xpath, sub_xpath_list) # Replace the original node with the temporary one replace_with_temp = Element("xpath", expr=node_id, position="replace", replace_all="1") replace_with_temp.append(Element("xpath", expr=str(new_id), position="move", new_target="1")) main_xpaths_list.append(replace_with_temp) return main_xpaths_list, sub_xpath_list def _parse_old_leaves(self, leaves): """leaves represents the texts and nodes that were present in the old tree before or after a given pivot node. This function applies the changes on the old tree to determine what would be this old section of the tree once we remove or replace nodes. In Odoo's inheritance mechanism, moved nodes leave their tail behind, but removed nodes take their tail with them. This process allows, in a section of an old tree (before or after a pivot node), to select a removed node (replace_target) to build a replace xpath instead of a before/after one. """ iter_leaves = iter(leaves) replace_target = None old_leaf = next(iter_leaves, None) old_leaves_with_removals = [] while old_leaf: nid = isinstance(old_leaf, NodeLeaf) and old_leaf.element.get(DIFF_ATTRIBUTE) if nid and self.tracker.is_removed(nid, include_delayed=False): if replace_target is None: replace_target = nid # The next leaf is necessarily a text-like one. # since the removal of a node implies the removal of its tail # we just ignore the removed node's tail. next(iter_leaves, None) old_leaves_with_removals.append(TextLeaf()) else: append_leaf(old_leaves_with_removals, old_leaf) old_leaf = next(iter_leaves, None) return replace_target, old_leaves_with_removals def _leaves_text_compatibility(self, old_leaves, new_leaves, position): """Compares texts in old_leaves to new leaves according to position. The Odoo's inheritance mechanism doesn't really allow replacing texts This algorithm is used to determine beforehand if we can apply an xpath containing texts and whether to include a specific text or not in the xpath """ left_old = left_new = right_old = right_new = None if old_leaves: left_old = old_leaves[0] if isinstance(old_leaves[-1], TextLeaf): right_old = old_leaves[-1] if new_leaves: left_new = new_leaves[0] right_new = new_leaves[-1] left = (left_old, left_new) right = (right_old, right_new) if position in ("before", "inside"): compare = [left] elif position == "after": compare = [right] elif position == "replace": compare = [left, right] for old, new in compare: old_text = old and old.element or None new_text = new and new.element or None compatible = old_text is None or old_text == new_text include_new = new_text is not None and old_text != new_text if not compatible: return False if not include_new and new_text: new.ignores.add(position) if position == "replace": # The replace instruction doesn't support the xpath node to have a text # and doesn't push tails if the element is a move xpath has_text = False has_moves = False for index, new_leaf in enumerate(new_leaves): if ( isinstance(new_leaf, TextLeaf) and (index == 0 or has_moves) and "replace" not in new_leaf.ignores and new_leaf.element ): has_text = True elif isinstance(new_leaf, NodeLeaf) and new_leaf.element.get(DIFF_ATTRIBUTE): has_moves = True if has_text: return False return True class NodeTracker: def __init__(self, get_moving_candidate_key): self.get_moving_candidate_key = get_moving_candidate_key self.kept_ids = set() self.delayed_remove = set() self.move_ids = set() self.references = dict() self.candidates = dict() self.element_to_key = dict() def moving_candidate(self, node): key = self.get_moving_candidate_key(node) if key is not None: if key not in self.candidates: self.candidates[key] = node self.element_to_key[node] = key return key return key def moving_reference(self, node): key = self.get_moving_candidate_key(node) if key is not None and key not in self.references: self.references[key] = node self.element_to_key[node] = key return self.candidates.get(key) def get_from_key(self, key): ref = self.references.get(key) if ref is None or not self.element_to_key.get(ref): return (None, None) candid = self.candidates.get(key) if candid is not None: return ref, candid return (None, None) def is_removed(self, _id, include_delayed=True): rm = _id not in self.kept_ids if not include_delayed: return rm and _id not in self.delayed_remove return rm def delay_remove(self, _id): if _id: self.delayed_remove.add(_id) def keep(self, _id): if _id: self.kept_ids.add(_id) def move(self, _id): if _id: self.kept_ids.add(_id) self.move_ids.add(_id)