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: