# # Copyright (c), 2018-2026, SISSA (International School for Advanced Studies). # All rights reserved. # This file is distributed under the terms of the MIT License. # See the file 'LICENSE' in the root directory of the present # distribution, or http://opensource.org/licenses/MIT. # # @author Davide Brunato # from collections.abc import Callable, Iterator from copy import copy from typing import Optional, cast, Union, Any import elementpath.aliases as ta from elementpath.protocols import ElementProtocol, DocumentProtocol from elementpath.datatypes import QName, AnyAtomicType from elementpath.tdop import MultiLabel from elementpath.namespaces import XSD_NAMESPACE, XPATH_FUNCTIONS_NAMESPACE, \ XPATH_MATH_FUNCTIONS_NAMESPACE from elementpath.helpers import split_function_test, ordinal from elementpath.sequences import xlist from elementpath.etree import is_etree_document, is_etree_element from elementpath.sequence_types import match_sequence_type, is_sequence_type_restriction from elementpath.xpath_nodes import XPathNode from elementpath.tree_builders import get_node_tree from .base import XPathToken from .tokens import ValueToken class XPathFunction(XPathToken): """ A token for processing XPath functions. """ __name__: str _qname: Optional[QName] = None pattern = r'(? None: super().__init__(parser) if isinstance(nargs, int) and nargs != self.nargs: if nargs < 0: raise self.error('XPST0017', 'number of arguments must be non negative') elif self.nargs is None: self.nargs = nargs elif isinstance(self.nargs, int): raise self.error('XPST0017', 'incongruent number of arguments') elif self.nargs[0] > nargs or self.nargs[1] is not None and self.nargs[1] < nargs: raise self.error('XPST0017', 'incongruent number of arguments') else: self.nargs = nargs def __repr__(self) -> str: qname = self.qname if qname is None: return '<%s object at %#x>' % (self.__class__.__name__, id(self)) elif not isinstance(self.nargs, int): return '' % (qname.qname, id(self)) return '' % (qname.qname, self.nargs, id(self)) def __str__(self) -> str: if self.namespace is None: return f'{self.symbol!r} {self.label}' elif self.namespace == XPATH_FUNCTIONS_NAMESPACE: return f"'fn:{self.symbol}' {self.label}" else: for prefix, uri in self.parser.namespaces.items(): if uri == self.namespace: return f"'{prefix}:{self.symbol}' {self.label}" else: return f"'Q{{{self.namespace}}}{self.symbol}' {self.label}" def __call__(self, *args: ta.FunctionArgType, context: ta.ContextType = None) -> ta.ValueType: self.check_arguments_number(len(args)) context = copy(self.context or context) if self.label == 'partial function': for arg, tk in zip(args, filter(lambda x: x.symbol == '?', self)): if isinstance(arg, XPathFunction) or not isinstance(arg, XPathToken): tk.value = self.validated_argument(arg, context) else: tk.value = arg.evaluate(context) else: self.clear() for arg in args: if isinstance(arg, XPathToken): self._items.append(arg) else: value = self.validated_argument(arg, context) # Accepts and wraps etree elements/documents, useful for external calls. self._items.append(ValueToken(self.parser, value=value)) if any(tk.symbol == '?' and not tk for tk in self._items): self.to_partial_function() return self if isinstance(self.label, MultiLabel): # Disambiguate multi-label tokens if self.namespace == XSD_NAMESPACE and \ 'constructor function' in self.label.values: self.label = 'constructor function' else: for label in self.label.values: if label.endswith('function'): self.label = label break if self.label == 'partial function': result = self._partial_evaluate(context) else: result = self.evaluate(context) return self.validated_result(result) def check_arguments_number(self, nargs: int) -> None: """Check the number of arguments against function arity.""" if self.nargs is None or self.nargs == nargs: pass elif isinstance(self.nargs, tuple): if nargs < self.nargs[0]: raise self.error('XPTY0004', "missing required arguments") elif self.nargs[1] is not None and nargs > self.nargs[1]: raise self.error('XPTY0004', "too many arguments") elif self.nargs > nargs: raise self.error('XPTY0004', "missing required arguments") else: raise self.error('XPTY0004', "too many arguments") def validated_argument(self, arg: ta.FunctionArgType, context: ta.ContextType = None) -> ta.ValueType: def get_arg_item(item: ta.FunctionArgType) -> ta.ItemType: if isinstance(item, (XPathNode, XPathFunction, AnyAtomicType)): return item elif not is_etree_document(item) and not is_etree_element(item): raise self.error('XPTY0004', f"unexpected argument type {type(item)}") else: return get_node_tree( cast(Union[ElementProtocol, DocumentProtocol], item), namespaces=self.parser.namespaces, uri=self.parser.base_uri, fragment=None ) if context is not None: return context.get_value(arg, context.namespaces, self.parser.base_uri, None) elif arg is None: return [] elif not isinstance(arg, (list, tuple)): return get_arg_item(arg) return [get_arg_item(x) for x in arg] def validated_result(self, result: ta.ValueType) -> ta.ValueType: if isinstance(result, XPathToken) and result.symbol == '?': return result elif match_sequence_type(result, self.sequence_types[-1], self.parser): return result result = self.cast_to_primitive_type(result, self.sequence_types[-1]) if not match_sequence_type(result, self.sequence_types[-1], self.parser): msg = "{!r} does not match sequence type {}" raise self.error('XPTY0004', msg.format(result, self.sequence_types[-1])) return result @property def source(self) -> str: return f"{self.symbol}({', '.join(t.source for t in self)}){self.occurrence}" @property def qname(self) -> Optional[QName]: if self._qname is not None: return self._qname elif self.symbol == 'function': return None elif self.label == 'partial function': return None elif not self.namespace: self._qname = QName(None, self.symbol) elif self.namespace == XPATH_FUNCTIONS_NAMESPACE: self._qname = QName(XPATH_FUNCTIONS_NAMESPACE, 'fn:%s' % self.symbol) elif self.namespace == XSD_NAMESPACE: self._qname = QName(XSD_NAMESPACE, 'xs:%s' % self.symbol) elif self.namespace == XPATH_MATH_FUNCTIONS_NAMESPACE: self._qname = QName(XPATH_MATH_FUNCTIONS_NAMESPACE, 'math:%s' % self.symbol) else: for pfx, uri in self.parser.namespaces.items(): if uri == self.namespace: self._qname = QName(uri, f'{pfx}:{self.symbol}') break else: self._qname = QName(self.namespace, self.symbol) return self._qname @property def arity(self) -> int: if isinstance(self.nargs, int): return self.nargs return len(self._items) @property def min_args(self) -> int: if isinstance(self.nargs, int): return self.nargs elif isinstance(self.nargs, (tuple, list)): return self.nargs[0] else: return 0 @property def max_args(self) -> Optional[int]: if isinstance(self.nargs, int): return self.nargs elif isinstance(self.nargs, (tuple, list)): return self.nargs[1] else: return None def is_reference(self) -> bool: return isinstance(self.nargs, int) and self.nargs != 0 and not self._items def nud(self) -> 'XPathFunction': if not self.parser.parse_arguments: return self code = 'XPST0017' if 'function' in self.label else 'XPST0003' self.parser.advance('(') if self.nargs is None: del self._items[:] if self.parser.next_token.symbol in (')', '(end)'): raise self.error(code, 'at least an argument is required') while True: self.append(self.parser.expression(5)) if self.parser.next_token.symbol != ',': break self.parser.advance() elif self.nargs == 0: if self.parser.next_token.symbol != ')': if self.parser.next_token.symbol != '(end)': raise self.error(code, '%s has no arguments' % str(self)) raise self.parser.next_token.wrong_syntax() self.parser.advance() return self else: if isinstance(self.nargs, (tuple, list)): min_args, max_args = self.nargs else: min_args = max_args = self.nargs k = 0 while k < min_args: if self.parser.next_token.symbol in (')', '(end)'): msg = 'Too few arguments: expected at least %s arguments' % min_args raise self.error('XPST0017', msg if min_args > 1 else msg[:-1]) self._items[k:] = self.parser.expression(5), k += 1 if k < min_args: if self.parser.next_token.symbol == ')': msg = f'{str(self)}: Too few arguments, expected ' \ f'at least {min_args} arguments' raise self.error(code, msg if min_args > 1 else msg[:-1]) self.parser.advance(',') while max_args is None or k < max_args: if self.parser.next_token.symbol == ',': self.parser.advance(',') self._items[k:] = self.parser.expression(5), elif k == 0 and self.parser.next_token.symbol != ')': self._items[k:] = self.parser.expression(5), else: break # pragma: no cover k += 1 if self.parser.next_token.symbol == ',': msg = 'Too many arguments: expected at most %s arguments' % max_args raise self.error(code, msg if max_args != 1 else msg[:-1]) self.parser.advance(')') if any(tk.symbol == '?' and not tk for tk in self._items): self.to_partial_function() return self def match_function_test(self, function_test: ta.SequenceTypesType, as_argument: bool = False) -> bool: """ Match if function signature satisfies the provided *function_test*. For default return type is covariant and arguments are contravariant. If *as_argument* is `True` the match is inverted. References: https://www.w3.org/TR/xpath-31/#id-function-test https://www.w3.org/TR/xpath-31/#id-sequencetype-subtype """ if isinstance(function_test, (list, tuple)): sequence_types = function_test else: sequence_types = split_function_test(function_test) if not sequence_types or not sequence_types[-1]: return False elif sequence_types[0] == '*': return True signature = [x for x in self.sequence_types[:self.arity]] signature.append(self.sequence_types[-1]) if len(sequence_types) != len(signature): return False if as_argument: iterator = zip(sequence_types[:-1], signature[:-1]) else: iterator = zip(signature[:-1], sequence_types[:-1]) # compare sequence types for st1, st2 in iterator: if not is_sequence_type_restriction(st1, st2): return False else: st1, st2 = sequence_types[-1], signature[-1] return is_sequence_type_restriction(st1, st2) def to_partial_function(self) -> None: """Convert an XPath function to a partial function.""" nargs = len([tk and not tk for tk in self._items if tk.symbol == '?']) assert nargs, "a partial function requires at least a placeholder token" if self.label != 'partial function': def evaluate(context: ta.ContextType = None) -> 'XPathFunction': return self def select(context: ta.ContextType = None) -> Iterator['XPathFunction']: yield self if self.__class__.evaluate is not XPathToken.evaluate: setattr(self, '_partial_evaluate', self.evaluate) if self.__class__.select is not XPathToken.select: setattr(self, '_partial_select', self.select) setattr(self, 'evaluate', evaluate) setattr(self, 'select', select) self._qname = None self.label = 'partial function' self.nargs = nargs def as_function(self) -> Callable[..., Any]: """ Wraps the XPath function instance into a standard function. """ def wrapper(*args: ta.FunctionArgType, context: ta.ContextType = None) -> ta.ValueType: return self.__call__(*args, context=context) qname = self.qname if self.is_reference(): ref_part = f'#{self.nargs}' else: ref_part = '' if qname is None: name = f'' else: name = f'<{qname.qname}{ref_part}>' wrapper.__name__ = name wrapper.__qualname__ = wrapper.__qualname__[:-7] + name return wrapper def _partial_evaluate(self, context: ta.ContextType = None) -> ta.ValueType: return xlist(self._partial_select(context)) def _partial_select(self, context: ta.ContextType = None) -> Iterator[ta.ItemType]: item = self._partial_evaluate(context) if item is not None: if isinstance(item, list): yield from item else: if context is not None: context.item = item yield item class ExternalFunction(XPathFunction): callback: Callable[..., Any] def evaluate(self, context: ta.ContextType = None) -> Any: args = [] for k in range(len(self)): try: if self.sequence_types[k][-1] in '+*': arg = self[k].evaluate(context) else: arg = self.get_argument(context, index=k) except IndexError: arg = self.get_argument(context, index=k) args.append(arg) if self.sequence_types: for k, (arg, st) in enumerate(zip(args, self.sequence_types), start=1): if not match_sequence_type(arg, st, self.parser): msg = f"{ordinal(k)} argument does not match sequence type {st!r}" raise self.error('XPTY0004', msg) result = self.callback(*args) if not match_sequence_type(result, self.sequence_types[-1], self.parser): msg = f"Result does not match sequence type {self.sequence_types[-1]!r}" raise self.error('XPTY0004', msg) return result return self.callback(*args)