Source code for inxs.lib

"""
This module contains common functions that can be used for either :class:`~inxs.Rule`
s' tests, as handler functions or simple transformation steps.

Community contributions are highly appreciated, but it's hard to layout hard criteria
for what belongs here and what not. In doubt open a pull request with your proposal
as far as it proved functional to you, it doesn't need to be polished at that point.
"""

# TODO indicate use area in function's docstrings; and whether they return something
# TODO delete unneeded symbols in setup functions' locals


import logging
import re
from typing import Any, Callable, Dict, List, Mapping, Sequence, Tuple

from delb import (
    TagNode,
    TextNode,
    is_tag_node,
    altered_default_filters,
    is_text_node,
    tag,
)

from inxs import dot_lookup, Ref, singleton_handler, Transformation
from inxs.utils import is_Ref, resolve_Ref_values_in_mapping

# helpers


logger = logging.getLogger(__name__)
dbg = logger.debug
nfo = logger.info

__all__ = []


def export(func):
    __all__.append(func.__name__)
    return func


# the actual lib


[docs]@export @singleton_handler def add_html_classes(*classes, target=Ref("node")): """ Adds the string tokens passed as positional arguments to the ``classes`` attribute of a node specified by ``target``. An argument can also be a sequence of strings or a :func:`~inxs.Ref` that yields on of the two. Per default that is a :func:`~inxs.Ref` to the matching node of a rule. """ def add_items(_set, value): if isinstance(value, str): _set.add(value) elif isinstance(value, Sequence): _set.update(value) else: raise RuntimeError def processor(transformation): if not classes: return # TODO input type specialized handlers _classes = set() for cls in classes: if not cls: continue if is_Ref(cls): add_items(_classes, cls(transformation)) else: add_items(_classes, cls) node = target(transformation) value = node.attributes.get("class", "").strip() _classes.update(x.strip() for x in value.split() if x) node.attributes["class"] = " ".join(sorted(_classes)) return processor
[docs]@export @singleton_handler def append(name, symbol=Ref("previous_result"), copy_node=False): """ Appends the object referenced by ``symbol`` (default: the result of the previous :term:`handler function`) to the object available as ``name`` in the :attr:`Transformation._available_symbols`. If the object is a :class:`delb.TagNode` instance and ``copy_node`` is ``True``, a copy that includes all descendant nodes is appended to the target. """ def handler(previous_result, transformation): obj = symbol(transformation) if isinstance(obj, TagNode) and copy_node: obj = obj.clone(deep=True) if "." in name: namespace, path = name.split(".", maxsplit=1) target = transformation._available_symbols[namespace] target = dot_lookup(target, path) else: target = transformation._available_symbols[name] if isinstance(target, TagNode): target.append_child(obj) else: target.append(obj) return previous_result return handler
[docs]@export def cleanup_namespaces(root: TagNode, previous_result: Any) -> Any: """ Cleanup the namespaces of the tree. This should always be used at the end of a transformation when nodes' namespaces have been changed. """ root.document.cleanup_namespaces() return previous_result
[docs]@export def clear_attributes(node: TagNode, previous_result: Any) -> Any: """ Deletes all attributes of an node. """ node.attributes.clear() return previous_result
[docs]@export @singleton_handler def concatenate(*parts): """ Concatenate the given parts which may be lists or strings as well as callables returning such. """ def handler(transformation) -> str: result = "" for part in parts: if callable(part): _part = part(transformation) elif isinstance(part, (str, List)): _part = part else: raise RuntimeError(f"Unhandled type: {type(part)}") result += _part return result return handler
[docs]@export @singleton_handler def debug_dump_document(name="tree"): """ Dumps all contents of the node referenced by ``name`` from the :attr:`inxs.Transformation._available_symbols` to the log at info level. """ def handler(transformation): node = transformation._available_symbols.get(name) if node is None: nfo(f"No symbol named '{name}' found.") elif not isinstance(node, TagNode): nfo(f"Symbol '{name}' is not a TagNode.") else: nfo(str(node)) return transformation.states.previous_result return handler
[docs]@export @singleton_handler def debug_message(msg): """ Logs the provided message at info level. """ def handler(previous_result): nfo(msg) return previous_result return handler
[docs]@export @singleton_handler def debug_symbols(*names): """ Logs the representation strings of the objects referenced by ``names`` in :attr:`inxs.Transformation._available_symbols` at info level. """ def handler(transformation): for name in names: nfo(f"symbol {name}: {transformation._available_symbols[name]!r}") return transformation.states.previous_result return handler
[docs]@export def f(func, *args, **kwargs): """ Wraps the callable ``func`` which will be called as ``func(*args, **kwargs)``, the function and any argument can be given as :func:`inxs.Ref`. """ def wrapper(transformation): if is_Ref(func): _func = func(transformation) else: _func = func _args = () for arg in args: if is_Ref(arg): _args += (arg(transformation),) else: _args += (arg,) _kwargs = resolve_Ref_values_in_mapping(kwargs, transformation) return _func(*_args, **_kwargs) return wrapper
[docs]@export @singleton_handler def get_attribute(name): """ Gets the value of the node's attribute named ``name``. """ def evaluator(node: TagNode): return node.attributes.get(name) return evaluator
[docs]@export def get_localname(node): """ Gets the node's local tag name. """ return node.local_name
[docs]@export def get_text(node: TagNode): """ Returns the content of the matched node's descendants of :class:`delb.TextNode` type. """ return node.full_text
[docs]@export @singleton_handler def get_variable(name): """ Gets the object referenced as ``name`` from the :term:`context`. It is then available as symbol ``previous_result``. """ def handler(context): return dot_lookup(context, name) return handler
[docs]@export def has_attributes(node: TagNode, _): """ Returns ``True`` if the node has attributes. """ return bool(node.attributes)
[docs]@export def has_children(node: TagNode, _): """ Returns ``True`` if the node has descendants. """ return node.first_child is not None
[docs]@export @singleton_handler def has_matching_text(pattern: str): """ Returns ``True`` if the text contained by the node and its descendants has a matches the provided ``pattern``. """ pattern = re.compile(pattern) def evaluator(node: TagNode, _): return pattern.match(node.full_text) return evaluator
[docs]@export def has_text(node: TagNode, _): """ Returns ``True`` if the node has any :class:`delb.TextNode`. """ with altered_default_filters(is_text_node): for _ in node.child_nodes(recurse=True): return True return False
[docs]@export @singleton_handler def insert_fontawesome_icon(name: str, position: str, spin: bool = False): """ Inserts the html markup for an icon from the fontawesome set with the given ``name`` at ``position`` of which only ``after`` is implemented atm. It employs semantics for Font Awesome 5. """ def after_handler(node: TagNode): classes = f"fas fa-{name}" if spin: classes += " fa-spin" node.add_next(tag("i", {"class": classes})) return {"after": after_handler}[position]
[docs]@export @singleton_handler def join_to_string(separator: str = " ", symbol="previous_result"): """ Joins the object referenced by ``symbol`` around the given ``separator`` and returns it. """ def handler(transformation): return separator.join(transformation._available_symbols[symbol]) return handler
[docs]@export def lowercase(previous_result): """ Processes ``previous_result`` to be all lower case. """ return previous_result.lower()
[docs]@export def make_node(**node_args): """ Creates a new tag node in the root node's context, takes the arguments of :meth:`delb.TagNode.new_tag_node` that must be provided as keyword arguments. The node is then available as symbol ``previous_result``. """ def handler(root, transformation): _node_args = resolve_Ref_values_in_mapping(node_args, transformation) return root.new_tag_node(**_node_args) return handler
[docs]@export @singleton_handler def pop_attribute(name: str): """ Pops the node's attribute named ``name``. """ def handler(node: TagNode) -> str: return node.attributes.pop(name) return handler
[docs]@export @singleton_handler def pop_attributes(*names: str, ignore_missing=False): """ Pops all attributes with name from ``names`` and returns a mapping with names and values. When ``ignore_missing`` is ``True`` ``KeyError`` exceptions pass silently. """ handlers = {x: pop_attribute(x) for x in names} del names def handler(node: TagNode) -> Dict[str, str]: result = {} for name, _handler in handlers.items(): try: result[name] = _handler(node) except KeyError: if not ignore_missing: raise return result return handler
[docs]@export def prefix_attributes(prefix: str, *attributes: str): """ Prefixes the ``attributes`` with ``prefix``. """ return rename_attributes({x: prefix + x for x in attributes})
[docs]@export @singleton_handler def put_variable(name, value=Ref("previous_result")): """ Puts ``value``as ``name`` to the :term:`context` namespace, by default the value is determined by a :func:`inxs.Ref` to ``previous_result``. """ def ref_handler(transformation): setattr(transformation.context, name, value(transformation)) return transformation.states.previous_result def ref_handler_dot_lookup(transformation): setattr(dot_lookup(transformation.context, name), value(transformation)) return transformation.states.previous_result def simple_handler(transformation): setattr(transformation.context, name, value) return transformation.states.previous_result def simple_handler_dot_lookup(transformation): setattr(dot_lookup(transformation.context, name), value) return transformation.states.previous_result if is_Ref(value): if "." in name: return ref_handler_dot_lookup return ref_handler elif "." in name: return simple_handler_dot_lookup else: return simple_handler
[docs]@export @singleton_handler def remove_attributes(*names): """ Removes all attributes with the keys provided as ``names`` from the node. """ def handler(node: TagNode, previous_result: Any) -> Any: for name in names: node.attributes.pop(name, None) return previous_result return handler
[docs]@export def remove_namespace(node: TagNode, previous_result): """ Removes the namespace from the node. When used, :func:`cleanup_namespaces` should be applied at the end of the transformation. """ node.namespace = None return previous_result
[docs]@export def remove_node(node: TagNode): """ A very simple handler that just removes a node and its descendants from a tree. """ node.detach()
[docs]@export @singleton_handler def remove_nodes(references, keep_children=False, preserve_text=False, clear_ref=True): """ Removes all nodes from their tree that are referenced in a list that is available as ``references``. The nodes' children are retained when ``keep_children`` is passed as ``True``, or only the contained text when ``preserve_text`` is passed as ``True``. The reference list is cleared afterwards if ``clear_ref`` is ``True``. """ def handler(transformation): nodes = transformation._available_symbols[references] for node in nodes: if not keep_children: # retain descendants' text for child in tuple(node.child_nodes(is_tag_node)): if preserve_text: child.replace_with(child.full_text) else: # or just be quick at removal child.detach() node.merge_text_nodes() if preserve_text: filters = () else: filters = (is_tag_node,) # move remaining child nodes after the target children = tuple(node.child_nodes(*filters)) if children: for child in children: child.detach() node.add_next(*children) # remove the target node.detach() if clear_ref: nodes.clear() return transformation.states.previous_result return handler
@singleton_handler def _rename_attributes(translation_map: Tuple[Tuple[str, str], ...]) -> Callable: def handler(node: TagNode) -> None: for _from, to in translation_map: node.attributes[to] = node.attributes.pop(_from) return handler
[docs]@export def rename_attributes(translation_map: Mapping[str, str]) -> Callable: """ Renames the attributes of a node according to the provided ``translation_map`` that consists of old name keys and new name values. """ return _rename_attributes(tuple((k, v) for k, v in translation_map.items()))
# FIXME test this
[docs]@export @singleton_handler def resolve_xpath_to_node(*names): """ Resolves the objects from the context namespace (which are supposed to be XPath expressions) referenced by ``names`` with the *one* node that the expression matches or ``None``. This is useful when a copied tree is processed and 'XPath pointers' are passed to the :term:`context` when a :class:`inxs.Transformation` is called. """ def resolver(context, transformation): for name in names: expression = getattr(context, name) if not expression: setattr(context, name, None) continue resolved_nodes = transformation.root.xpath(expression) if not resolved_nodes: setattr(context, name, None) elif len(resolved_nodes) == 1: setattr(context, name, resolved_nodes[0]) else: raise RuntimeError(f"More than one node matched {expression}") return transformation.states.previous_result return resolver
[docs]@export @singleton_handler def set_attribute(name, value=Ref("previous_result")): """ Sets an attribute ``name`` with ``value``. """ def simple_handler(node: TagNode, previous_result: Any) -> Any: node.attributes[name] = value return previous_result def resolving_handler( node: TagNode, previous_result: Any, transformation: Transformation ) -> Any: node.attributes[name] = value(transformation) return previous_result if isinstance(value, str): return simple_handler elif is_Ref(value): return resolving_handler
[docs]@export @singleton_handler def set_localname(name): """ Sets the node's localname to ``name``. """ def handler(node: TagNode, previous_result: Any): node.local_name = name return previous_result return handler
[docs]@export @singleton_handler def set_text(text=Ref("previous_result")): """ Sets the nodes's first child node that is of :class:`delb.TextNode` type to the one provided as ``text``, it can also be a :func:`inxs.Ref`. If the first node isn't a text node, one will be inserted. """ def ref_handler(node: TagNode, transformation: Transformation): _text = text(transformation) target = node.first_child if target is None or not isinstance(node, TextNode): node.insert_child(0, _text) else: target.content = _text return transformation.states.previous_result def static_handler(node: TagNode, previous_result): target = node.first_child if target is None or not isinstance(node, TextNode): node.insert_child(0, text) else: target.content = text return previous_result return ref_handler if is_Ref(text) else static_handler
[docs]@export @singleton_handler def sort(name: str = "previous_result", key: Callable = lambda x: x): """ Sorts the object referenced by ``name`` in the :term:`context` using ``key`` as :term:`key function`. """ def handler(context): return sorted(getattr(context, name), key=key) return handler
[docs]@export @singleton_handler def text_equals(text): """ Tests whether the evaluated node's text contained by its descendants is equal to ``text``. """ def evaluator(node: TagNode, _): return node.full_text == text return evaluator