# Copyright (C) 2020 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """A library containing functions for diffing XML elements.""" import textwrap from typing import Any, Callable, Dict, Set import xml.etree.ElementTree as ET import dataclasses Element = ET.Element _INDENT = (' ' * 2) @dataclasses.dataclass class Change: value_from: str value_to: str def __repr__(self): return f'{self.value_from} -> {self.value_to}' @dataclasses.dataclass class ChangeMap: """A collection of changes broken down by added, removed and modified. Attributes: added: A dictionary of string identifiers to the added string. removed: A dictionary of string identifiers to the removed string. modified: A dictionary of string identifiers to the changed object. """ added: Dict[str, str] = dataclasses.field(default_factory=dict) removed: Dict[str, str] = dataclasses.field(default_factory=dict) modified: Dict[str, Any] = dataclasses.field(default_factory=dict) def __repr__(self): ret_str = '' if self.added: ret_str += 'Added:\n' for value in self.added.values(): ret_str += textwrap.indent(str(value) + '\n', _INDENT) if self.removed: ret_str += 'Removed:\n' for value in self.removed.values(): ret_str += textwrap.indent(str(value) + '\n', _INDENT) if self.modified: ret_str += 'Modified:\n' for name, value in self.modified.items(): ret_str += textwrap.indent(name + ':\n', _INDENT) ret_str += textwrap.indent(str(value) + '\n', _INDENT * 2) return ret_str def __bool__(self): return bool(self.added) or bool(self.removed) or bool(self.modified) def element_string(e: Element) -> str: return ET.tostring(e).decode(encoding='UTF-8').strip() def attribute_changes(e1: Element, e2: Element, ignored_attrs: Set[str]) -> ChangeMap: """Get the changes in attributes between two XML elements. Arguments: e1: the first xml element. e2: the second xml element. ignored_attrs: a set of attribute names to ignore changes. Returns: A ChangeMap of attribute changes. Keyed by attribute name. """ changes = ChangeMap() attributes = set(e1.keys()) | set(e2.keys()) for attr in attributes: if attr in ignored_attrs: continue a1 = e1.get(attr) a2 = e2.get(attr) if a1 == a2: continue elif not a1: changes.added[attr] = a2 or '' elif not a2: changes.removed[attr] = a1 else: changes.modified[attr] = Change(value_from=a1, value_to=a2) return changes def compare_subelements( tag: str, p1: Element, p2: Element, ignored_attrs: Set[str], key_fn: Callable[[Element], str], diff_fn: Callable[[Element, Element, Set[str]], Any]) -> ChangeMap: """Get the changes between subelements of two parent elements. Arguments: tag: tag name for children element. p1: the base parent xml element. p2: the parent xml element to compare ignored_attrs: a set of attribute names to ignore changes. key_fn: Function that takes a subelement and returns a key diff_fn: Function that take two subelements and a set of ignored attributes, returns the differences Returns: A ChangeMap object of the changes. """ changes = ChangeMap() group1 = {} for e1 in p1.findall(tag): group1[key_fn(e1)] = e1 for e2 in p2.findall(tag): key = key_fn(e2) e1 = group1.pop(key, None) if e1 is None: changes.added[key] = element_string(e2) else: echange = diff_fn(e1, e2, ignored_attrs) if echange: changes.modified[key] = echange for name, e1 in group1.items(): changes.removed[name] = element_string(e1) return changes