|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +import re |
| 4 | +import textwrap |
| 5 | +from typing import TYPE_CHECKING |
| 6 | +from xml.etree.ElementTree import tostring |
| 7 | + |
| 8 | +if TYPE_CHECKING: |
| 9 | + import os |
| 10 | + from collections.abc import Callable, Iterable, Sequence |
| 11 | + from xml.etree.ElementTree import Element, ElementTree |
| 12 | + |
| 13 | + |
| 14 | +def _get_text(node: Element) -> str: |
| 15 | + if node.text is not None: |
| 16 | + # the node has only one text |
| 17 | + return node.text |
| 18 | + |
| 19 | + # the node has tags and text; gather texts just under the node |
| 20 | + return ''.join(n.tail or '' for n in node) |
| 21 | + |
| 22 | + |
| 23 | +def _prettify(nodes: Iterable[Element]) -> str: |
| 24 | + def pformat(node: Element) -> str: |
| 25 | + return tostring(node, encoding='unicode', method='html') |
| 26 | + |
| 27 | + return ''.join(f'(i={index}) {pformat(node)}\n' for index, node in enumerate(nodes)) |
| 28 | + |
| 29 | + |
| 30 | +def check_xpath( |
| 31 | + etree: ElementTree, |
| 32 | + filename: str | os.PathLike[str], |
| 33 | + xpath: str, |
| 34 | + check: str | re.Pattern[str] | Callable[[Sequence[Element]], None] | None, |
| 35 | + be_found: bool = True, |
| 36 | + *, |
| 37 | + min_count: int = 1, |
| 38 | +) -> None: |
| 39 | + """Check that one or more nodes satisfy a predicate. |
| 40 | +
|
| 41 | + :param etree: The element tree. |
| 42 | + :param filename: The element tree source name (for errors only). |
| 43 | + :param xpath: An XPath expression to use. |
| 44 | + :param check: Optional regular expression or a predicate the nodes must validate. |
| 45 | + :param be_found: If false, negate the predicate. |
| 46 | + :param min_count: Minimum number of nodes expected to satisfy the predicate. |
| 47 | +
|
| 48 | + * If *check* is empty (``''``), only the minimum count is checked. |
| 49 | + * If *check* is ``None``, no node should satisfy the XPath expression. |
| 50 | + """ |
| 51 | + nodes = etree.findall(xpath) |
| 52 | + assert isinstance(nodes, list) |
| 53 | + |
| 54 | + if check is None: |
| 55 | + # use == to have a nice pytest diff |
| 56 | + assert nodes == [], f'found nodes matching xpath {xpath!r} in file {filename}' |
| 57 | + return |
| 58 | + |
| 59 | + assert len(nodes) >= min_count, (f'expecting at least {min_count} node(s) ' |
| 60 | + f'to satisfy {xpath!r} in file {filename}') |
| 61 | + |
| 62 | + if check == '': |
| 63 | + return |
| 64 | + |
| 65 | + if callable(check): |
| 66 | + check(nodes) |
| 67 | + return |
| 68 | + |
| 69 | + rex = re.compile(check) |
| 70 | + if be_found: |
| 71 | + if any(rex.search(_get_text(node)) for node in nodes): |
| 72 | + return |
| 73 | + else: |
| 74 | + if all(not rex.search(_get_text(node)) for node in nodes): |
| 75 | + return |
| 76 | + |
| 77 | + ctx = textwrap.indent(_prettify(nodes), ' ' * 2) |
| 78 | + msg = f'{check!r} not found in any node matching {xpath!r} in file {filename}:\n{ctx}' |
| 79 | + raise AssertionError(msg) |
0 commit comments