Mercurial > libervia-backend
view libervia/backend/tools/xml_tools.py @ 4314:6a70fcd93a7a
plugin XEP-0131: Stanza Headers and Internet Metadata implementation:
- SHIM is now supported and put in `msg_data["extra"]["headers"]`.
- `Keywords` are converted from and to list of string in `msg_data["extra"]["keywords"]`
field (if present in headers on message sending, values are merged).
- Python minimal version upgraded to 3.11 due to use of `StrEnum`.
rel 451
author | Goffi <goffi@goffi.org> |
---|---|
date | Sat, 28 Sep 2024 15:56:04 +0200 |
parents | d2deddd6df44 |
children |
line wrap: on
line source
#!/usr/bin/env python3 # SAT: a jabber client # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from collections import OrderedDict import html.entities import re from typing import Dict, Optional, Tuple, Union, Literal, overload, Iterable from xml.dom import NotFoundErr, minidom import xml.etree.ElementTree as ET from lxml import etree from twisted.internet import defer from twisted.words.protocols.jabber import jid from twisted.words.xish import domish from wokkel import data_form from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C from libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger log = getLogger(__name__) """This library help manage XML used in SàT (parameters, registration, etc)""" SAT_FORM_PREFIX = "SAT_FORM_" SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_" # used to have unique elements names html_entity_re = re.compile(r"&([a-zA-Z]+?);") XML_ENTITIES = ("quot", "amp", "apos", "lt", "gt") # method to clean XHTML, receive raw unsecure XML or HTML, must return cleaned raw XHTML # this method must be set during runtime clean_xhtml = None # TODO: move XMLUI stuff in a separate module # TODO: rewrite this with lxml or ElementTree or domish.Element: it's complicated and difficult to maintain with current minidom implementation # Helper functions def _data_form_field_2_xmlui_data(field, read_only=False): """Get data needed to create an XMLUI's Widget from Wokkel's data_form's Field. The attribute field can be modified (if it's fixed and it has no value). @param field (data_form.Field): a field with attributes "value", "fieldType", "label" and "var" @param read_only (bool): if True and it makes sense, create a read only input widget @return: a tuple (widget_type, widget_args, widget_kwargs) """ widget_args = field.values or [None] widget_kwargs = {} if field.fieldType is None and field.ext_type is not None: # we have an extended field if field.ext_type == "xml": element = field.value if element.uri == C.NS_XHTML: widget_type = "xhtmlbox" widget_args[0] = element.toXml() widget_kwargs["read_only"] = read_only else: log.warning("unknown XML element, falling back to textbox") widget_type = "textbox" widget_args[0] = element.toXml() widget_kwargs["read_only"] = read_only else: raise exceptions.DataError( "unknown extended type {ext_type}".format(ext_type=field.ext_type) ) elif field.fieldType == "fixed" or field.fieldType is None: widget_type = "text" if field.value is None: if field.label is None: log.warning(_("Fixed field has neither value nor label, ignoring it")) field.value = "" else: field.value = field.label field.label = None widget_args = [field.value] elif field.fieldType == "text-single": widget_type = "string" widget_kwargs["read_only"] = read_only elif field.fieldType == "jid-single": widget_type = "jid_input" widget_kwargs["read_only"] = read_only elif field.fieldType == "text-multi": widget_type = "textbox" widget_args = ["\n".join(field.values)] widget_kwargs["read_only"] = read_only elif field.fieldType == "hidden": widget_type = "hidden" elif field.fieldType == "text-private": widget_type = "password" widget_kwargs["read_only"] = read_only elif field.fieldType == "boolean": widget_type = "bool" if widget_args[0] is None: widget_args = ["false"] widget_kwargs["read_only"] = read_only elif field.fieldType == "integer": widget_type = "integer" widget_kwargs["read_only"] = read_only elif field.fieldType == "list-single": widget_type = "list" widget_kwargs["options"] = [ (option.value, option.label or option.value) for option in field.options ] widget_kwargs["selected"] = widget_args widget_args = [] elif field.fieldType == "list-multi": widget_type = "list" widget_kwargs["options"] = [ (option.value, option.label or option.value) for option in field.options ] widget_kwargs["selected"] = widget_args widget_kwargs["styles"] = ["multi"] widget_args = [] else: log.error( "FIXME FIXME FIXME: Type [%s] is not managed yet by SàT" % field.fieldType ) widget_type = "string" widget_kwargs["read_only"] = read_only if field.var: widget_kwargs["name"] = field.var return widget_type, widget_args, widget_kwargs def data_form_2_widgets(form_ui, form, read_only=False, prepend=None, filters=None): """Complete an existing XMLUI with widget converted from XEP-0004 data forms. @param form_ui (XMLUI): XMLUI instance @param form (data_form.Form): Wokkel's implementation of data form @param read_only (bool): if True and it makes sense, create a read only input widget @param prepend(iterable, None): widgets to prepend to main LabelContainer if not None, must be an iterable of *args for add_widget. Those widgets will be added first to the container. @param filters(dict, None): if not None, a dictionary of callable: key is the name of the widget to filter the value is a callable, it will get form's XMLUI, widget's type, args and kwargs and must return widget's type, args and kwargs (which can be modified) This is especially useful to modify well known fields @return: the completed XMLUI instance """ if filters is None: filters = {} if form.instructions: form_ui.addText("\n".join(form.instructions), "instructions") form_ui.change_container("label") if prepend is not None: for widget_args in prepend: form_ui.add_widget(*widget_args) for field in form.fieldList: widget_type, widget_args, widget_kwargs = _data_form_field_2_xmlui_data( field, read_only ) try: widget_filter = filters[widget_kwargs["name"]] except KeyError: pass else: widget_type, widget_args, widget_kwargs = widget_filter( form_ui, widget_type, widget_args, widget_kwargs ) if widget_type != "hidden": label = field.label or field.var if label: form_ui.addLabel(label) else: form_ui.addEmpty() form_ui.add_widget(widget_type, *widget_args, **widget_kwargs) return form_ui def data_form_2_xmlui(form, submit_id, session_id=None, read_only=False): """Take a data form (Wokkel's XEP-0004 implementation) and convert it to a SàT XMLUI. @param form (data_form.Form): a Form instance @param submit_id (unicode): callback id to call when submitting form @param session_id (unicode): session id to return with the data @param read_only (bool): if True and it makes sense, create a read only input widget @return: XMLUI instance """ form_ui = XMLUI("form", "vertical", submit_id=submit_id, session_id=session_id) return data_form_2_widgets(form_ui, form, read_only=read_only) def data_form_2_data_dict(form: data_form.Form) -> dict: """Convert data form to a simple dict, easily serialisable see data_dict_2_data_form for a description of the format """ fields = [] data_dict = {"fields": fields} if form.formNamespace: data_dict["namespace"] = form.formNamespace for form_field in form.fieldList: field = {"type": form_field.fieldType} fields.append(field) for src_name, dest_name in ( ("var", "name"), ("label", "label"), ("value", "value"), # FIXME: we probably should have only "values" ("values", "values"), ): value = getattr(form_field, src_name, None) if value: field[dest_name] = value if form_field.options: options = field["options"] = [] for form_opt in form_field.options: opt = {"value": form_opt.value} if form_opt.label: opt["label"] = form_opt.label options.append(opt) if form_field.fieldType is None and form_field.ext_type == "xml": if isinstance(form_field.value, domish.Element): if form_field.value.uri == C.NS_XHTML and form_field.value.name == "div": field["type"] = "xhtml" if form_field.value.children: log.warning( "children are not managed for XHTML fields: " f"{form_field.value.toXml()}" ) return data_dict def data_dict_2_data_form(data_dict): """Convert serialisable dict of data to a data form The format of the dict is as follow: - an optional "namespace" key with form namespace - a mandatory "fields" key with list of fields as follow: - "type" is mostly the same as data_form.Field.fieldType - "name" is used to set the "var" attribute of data_form.Field - "label", and "value" follow same attribude in data_form.Field - "xhtml" is used for "xml" fields with child in the C.NS_XHTML namespace - "options" are list of dict with optional "label" and mandatory "value" following suitable attributes from data_form.Option - "required" is the same as data_form.Field.required """ # TODO: describe format fields = [] for field_data in data_dict["fields"]: field_type = field_data.get("type", "text-single") kwargs = { "fieldType": field_type, "var": field_data["name"], "label": field_data.get("label"), "value": field_data.get("value"), "required": field_data.get("required"), } if field_type == "xhtml": kwargs.update( { "fieldType": None, "ext_type": "xml", } ) if kwargs["value"] is None: kwargs["value"] = domish.Element((C.NS_XHTML, "div")) elif "options" in field_data: kwargs["options"] = [ data_form.Option(o["value"], o.get("label")) for o in field_data["options"] ] field = data_form.Field(**kwargs) fields.append(field) return data_form.Form("form", formNamespace=data_dict.get("namespace"), fields=fields) def data_form_elt_result_2_xmlui_data(form_xml): """Parse a data form result (not parsed by Wokkel's XEP-0004 implementation). The raw data form is used because Wokkel doesn't manage result items parsing yet. @param form_xml (domish.Element): element of the data form @return: a couple (headers, result_list): - headers (dict{unicode: unicode}): form headers (field labels and types) - xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs) """ headers = OrderedDict() try: reported_elt = next(form_xml.elements("jabber:x:data", "reported")) except StopIteration: raise exceptions.DataError( "Couldn't find expected <reported> tag in %s" % form_xml.toXml() ) for elt in reported_elt.elements(): if elt.name != "field": raise exceptions.DataError("Unexpected tag") name = elt["var"] label = elt.attributes.get("label", "") type_ = elt.attributes.get("type") headers[name] = (label, type_) if not headers: raise exceptions.DataError("No reported fields (see XEP-0004 §3.4)") xmlui_data = [] item_elts = form_xml.elements("jabber:x:data", "item") for item_elt in item_elts: for elt in item_elt.elements(): if elt.name != "field": log.warning("Unexpected tag (%s)" % elt.name) continue field = data_form.Field.fromElement(elt) xmlui_data.append(_data_form_field_2_xmlui_data(field)) return headers, xmlui_data def xmlui_data_2_advanced_list(xmlui, headers, xmlui_data): """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list. The raw data form is used because Wokkel doesn't manage result items parsing yet. @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added @param headers (dict{unicode: unicode}): form headers (field labels and types) @param xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs) @return: the completed XMLUI instance """ adv_list = AdvancedListContainer( xmlui, headers=headers, columns=len(headers), parent=xmlui.current_container ) xmlui.change_container(adv_list) for widget_type, widget_args, widget_kwargs in xmlui_data: xmlui.add_widget(widget_type, *widget_args, **widget_kwargs) return xmlui def data_form_result_2_advanced_list(xmlui, form_xml): """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list. The raw data form is used because Wokkel doesn't manage result items parsing yet. @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added @param form_xml (domish.Element): element of the data form @return: the completed XMLUI instance """ headers, xmlui_data = data_form_elt_result_2_xmlui_data(form_xml) xmlui_data_2_advanced_list(xmlui, headers, xmlui_data) def data_form_elt_result_2_xmlui(form_elt, session_id=None): """Take a raw data form (not parsed by XEP-0004) and convert it to a SàT XMLUI. The raw data form is used because Wokkel doesn't manage result items parsing yet. @param form_elt (domish.Element): element of the data form @param session_id (unicode): session id to return with the data @return: XMLUI instance """ xml_ui = XMLUI("window", "vertical", session_id=session_id) try: data_form_result_2_advanced_list(xml_ui, form_elt) except exceptions.DataError: parsed_form = data_form.Form.fromElement(form_elt) data_form_2_widgets(xml_ui, parsed_form, read_only=True) return xml_ui def data_form_result_2_xmlui( result_form, base_form, session_id=None, prepend=None, filters=None, read_only=True ): """Convert data form result to SàT XMLUI. @param result_form (data_form.Form): result form to convert @param base_form (data_form.Form): initial form (i.e. of form type "form") this one is necessary to reconstruct options when needed (e.g. list elements) @param session_id (unicode): session id to return with the data @param prepend: same as for [data_form_2_widgets] @param filters: same as for [data_form_2_widgets] @param read_only: same as for [data_form_2_widgets] @return: XMLUI instance """ # we deepcopy the form because _data_form_field_2_xmlui_data can modify the value # FIXME: check if it's really important, the only modified value seems to be # the replacement of None by "" on fixed fields # form = deepcopy(result_form) form = result_form for name, field in form.fields.items(): try: base_field = base_form.fields[name] except KeyError: continue field.options = base_field.options[:] xml_ui = XMLUI("window", "vertical", session_id=session_id) data_form_2_widgets( xml_ui, form, read_only=read_only, prepend=prepend, filters=filters ) return xml_ui def _clean_value(value): """Workaround method to avoid DBus types with D-Bus bridge. @param value: value to clean @return: value in a non DBus type (only clean string yet) """ # XXX: must be removed when DBus types will no cause problems anymore # FIXME: should be cleaned inside D-Bus bridge itself if isinstance(value, str): return str(value) return value def xmlui_result_2_data_form_result(xmlui_data): """Extract form data from a XMLUI return. @param xmlui_data (dict): data returned by frontends for XMLUI form @return: dict of data usable by Wokkel's data form """ ret = {} for key, value in xmlui_data.items(): if not key.startswith(SAT_FORM_PREFIX): continue if isinstance(value, str): if "\n" in value: # data form expects multi-lines text to be in separated values value = value.split("\n") elif "\t" in value: # FIXME: workaround to handle multiple values. Proper serialisation must # be done in XMLUI value = value.split("\t") ret[key[len(SAT_FORM_PREFIX) :]] = _clean_value(value) return ret def form_escape(name): """Return escaped name for forms. @param name (unicode): form name @return: unicode """ return "%s%s" % (SAT_FORM_PREFIX, name) def is_xmlui_cancelled(raw_xmlui): """Tell if an XMLUI has been cancelled by checking raw XML""" return C.bool(raw_xmlui.get("cancelled", C.BOOL_FALSE)) def xmlui_result_to_elt(xmlui_data: dict, namespace: str | None = None): """Construct result domish.Element from XMLUI result. @param xmlui_data: data returned by frontends for XMLUI form @param namespace: namespace to use for the form. @return: domish.Element """ form = data_form.Form("submit", formNamespace=namespace) form.makeFields(xmlui_result_2_data_form_result(xmlui_data)) return form.toElement() def tuple_list_2_data_form(values): """Convert a list of tuples (name, value) to a wokkel submit data form. @param values (list): list of tuples @return: data_form.Form """ form = data_form.Form("submit") for value in values: field = data_form.Field(var=value[0], value=value[1]) form.addField(field) return form def params_xml_2_xmlui(xml): """Convert the XML for parameter to a SàT XML User Interface. @param xml (unicode) @return: XMLUI """ # TODO: refactor params and use Twisted directly to parse XML params_doc = minidom.parseString(xml.encode("utf-8")) top = params_doc.documentElement if top.nodeName != "params": raise exceptions.DataError(_("INTERNAL ERROR: parameters xml not valid")) param_ui = XMLUI("param", "tabs") tabs_cont = param_ui.current_container for category in top.getElementsByTagName("category"): category_name = category.getAttribute("name") label = category.getAttribute("label") if not category_name: raise exceptions.DataError( _("INTERNAL ERROR: params categories must have a name") ) tabs_cont.add_tab(category_name, label=label, container=LabelContainer) for param in category.getElementsByTagName("param"): widget_kwargs = {} param_name = param.getAttribute("name") param_label = param.getAttribute("label") type_ = param.getAttribute("type") if not param_name and type_ != "text": raise exceptions.DataError(_("INTERNAL ERROR: params must have a name")) value = param.getAttribute("value") or None callback_id = param.getAttribute("callback_id") or None if type_ == "list": options, selected = _params_get_list_options(param) widget_kwargs["options"] = options widget_kwargs["selected"] = selected widget_kwargs["styles"] = ["extensible"] elif type_ == "jids_list": widget_kwargs["jids"] = _params_get_list_jids(param) if type_ in ("button", "text"): param_ui.addEmpty() value = param_label else: param_ui.addLabel(param_label or param_name) if value: widget_kwargs["value"] = value if callback_id: widget_kwargs["callback_id"] = callback_id others = [ "%s%s%s" % (category_name, SAT_PARAM_SEPARATOR, other.getAttribute("name")) for other in category.getElementsByTagName("param") if other.getAttribute("type") != "button" ] widget_kwargs["fields_back"] = others widget_kwargs["name"] = "%s%s%s" % ( category_name, SAT_PARAM_SEPARATOR, param_name, ) param_ui.add_widget(type_, **widget_kwargs) return param_ui.toXml() def _params_get_list_options(param): """Retrieve the options for list element. The <option/> tags must be direct children of <param/>. @param param (domish.Element): element @return: a tuple (options, selected_value) """ if len(param.getElementsByTagName("options")) > 0: raise exceptions.DataError( _("The 'options' tag is not allowed in parameter of type 'list'!") ) elems = param.getElementsByTagName("option") if len(elems) == 0: return [] options = [] for elem in elems: value = elem.getAttribute("value") if not value: raise exceptions.InternalError("list option must have a value") label = elem.getAttribute("label") if label: options.append((value, label)) else: options.append(value) selected = [ elem.getAttribute("value") for elem in elems if elem.getAttribute("selected") == "true" ] return (options, selected) def _params_get_list_jids(param): """Retrive jids from a jids_list element. the <jid/> tags must be direct children of <param/> @param param (domish.Element): element @return: a list of jids """ elems = param.getElementsByTagName("jid") jids = [ elem.firstChild.data for elem in elems if elem.firstChild is not None and elem.firstChild.nodeType == elem.TEXT_NODE ] return jids ### XMLUI Elements ### class Element(object): """Base XMLUI element""" type = None def __init__(self, xmlui, parent=None): """Create a container element @param xmlui: XMLUI instance @parent: parent element """ assert self.type is not None self.children = [] if not hasattr(self, "elem"): self.elem = parent.xmlui.doc.createElement(self.type) self.xmlui = xmlui if parent is not None: parent.append(self) self.parent = parent def append(self, child): """Append a child to this element. @param child (Element): child element @return: the added child Element """ self.elem.appendChild(child.elem) child.parent = self self.children.append(child) return child class TopElement(Element): """Main XML Element""" type = "top" def __init__(self, xmlui): self.elem = xmlui.doc.documentElement super(TopElement, self).__init__(xmlui) class TabElement(Element): """Used by TabsContainer to give name and label to tabs.""" type = "tab" def __init__(self, parent, name, label, selected=False): """ @param parent (TabsContainer): parent container @param name (unicode): tab name @param label (unicode): tab label @param selected (bool): set to True to select this tab """ if not isinstance(parent, TabsContainer): raise exceptions.DataError(_("TabElement must be a child of TabsContainer")) super(TabElement, self).__init__(parent.xmlui, parent) self.elem.setAttribute("name", name) self.elem.setAttribute("label", label) if selected: self.set_selected(selected) def set_selected(self, selected=False): """Set the tab selected. @param selected (bool): set to True to select this tab """ self.elem.setAttribute("selected", "true" if selected else "false") class FieldBackElement(Element): """Used by ButtonWidget to indicate which field have to be sent back""" type = "field_back" def __init__(self, parent, name): assert isinstance(parent, ButtonWidget) super(FieldBackElement, self).__init__(parent.xmlui, parent) self.elem.setAttribute("name", name) class InternalFieldElement(Element): """Used by internal callbacks to indicate which fields are manipulated""" type = "internal_field" def __init__(self, parent, name): super(InternalFieldElement, self).__init__(parent.xmlui, parent) self.elem.setAttribute("name", name) class InternalDataElement(Element): """Used by internal callbacks to retrieve extra data""" type = "internal_data" def __init__(self, parent, children): super(InternalDataElement, self).__init__(parent.xmlui, parent) assert isinstance(children, list) for child in children: self.elem.childNodes.append(child) class OptionElement(Element): """ " Used by ListWidget to specify options""" type = "option" def __init__(self, parent, option, selected=False): """ @param parent @param option (string, tuple) @param selected (boolean) """ assert isinstance(parent, ListWidget) super(OptionElement, self).__init__(parent.xmlui, parent) if isinstance(option, str): value, label = option, option elif isinstance(option, tuple): value, label = option else: raise NotImplementedError self.elem.setAttribute("value", value) self.elem.setAttribute("label", label) if selected: self.elem.setAttribute("selected", "true") class JidElement(Element): """ " Used by JidsListWidget to specify jids""" type = "jid" def __init__(self, parent, jid_): """ @param jid_(jid.JID, unicode): jid to append """ assert isinstance(parent, JidsListWidget) super(JidElement, self).__init__(parent.xmlui, parent) if isinstance(jid_, jid.JID): value = jid_.full() elif isinstance(jid_, str): value = str(jid_) else: raise NotImplementedError jid_txt = self.xmlui.doc.createTextNode(value) self.elem.appendChild(jid_txt) class RowElement(Element): """ " Used by AdvancedListContainer""" type = "row" def __init__(self, parent): assert isinstance(parent, AdvancedListContainer) super(RowElement, self).__init__(parent.xmlui, parent) if parent.next_row_idx is not None: if parent.auto_index: raise exceptions.DataError(_("Can't set row index if auto_index is True")) self.elem.setAttribute("index", parent.next_row_idx) parent.next_row_idx = None class HeaderElement(Element): """ " Used by AdvancedListContainer""" type = "header" def __init__(self, parent, name=None, label=None, description=None): """ @param parent: AdvancedListContainer instance @param name: name of the container @param label: label to be displayed in columns @param description: long descriptive text """ assert isinstance(parent, AdvancedListContainer) super(HeaderElement, self).__init__(parent.xmlui, parent) if name: self.elem.setAttribute("name", name) if label: self.elem.setAttribute("label", label) if description: self.elem.setAttribute("description", description) ## Containers ## class Container(Element): """And Element which contains other ones and has a layout""" type = None def __init__(self, xmlui, parent=None): """Create a container element @param xmlui: XMLUI instance @parent: parent element or None """ self.elem = xmlui.doc.createElement("container") super(Container, self).__init__(xmlui, parent) self.elem.setAttribute("type", self.type) def get_parent_container(self): """Return first parent container @return: parent container or None """ current = self.parent while not isinstance(current, (Container)) and current is not None: current = current.parent return current class VerticalContainer(Container): type = "vertical" class HorizontalContainer(Container): type = "horizontal" class PairsContainer(Container): """Container with series of 2 elements""" type = "pairs" class LabelContainer(Container): """Like PairsContainer, but first element can only be a label""" type = "label" class TabsContainer(Container): type = "tabs" def add_tab(self, name, label=None, selected=None, container=VerticalContainer): """Add a tab. @param name (unicode): tab name @param label (unicode): tab label @param selected (bool): set to True to select this tab @param container (class): container class, inheriting from Container @return: the container for the new tab """ if not label: label = name tab_elt = TabElement(self, name, label, selected) new_container = container(self.xmlui, tab_elt) return self.xmlui.change_container(new_container) def end(self): """Called when we have finished tabs change current container to first container parent """ parent_container = self.get_parent_container() self.xmlui.change_container(parent_container) class AdvancedListContainer(Container): """A list which can contain other widgets, headers, etc""" type = "advanced_list" def __init__( self, xmlui, callback_id=None, name=None, headers=None, items=None, columns=None, selectable="no", auto_index=False, parent=None, ): """Create an advanced list @param headers: optional headers information @param callback_id: id of the method to call when selection is done @param items: list of widgets to add (just the first row) @param columns: number of columns in this table, or None to autodetect @param selectable: one of: 'no': nothing is done 'single': one row can be selected @param auto_index: if True, indexes will be generated by frontends, starting from 0 @return: created element """ assert selectable in ("no", "single") if not items and columns is None: raise exceptions.DataError(_("either items or columns need do be filled")) if headers is None: headers = [] if items is None: items = [] super(AdvancedListContainer, self).__init__(xmlui, parent) if columns is None: columns = len(items[0]) self._columns = columns self._item_idx = 0 self.current_row = None if headers: if len(headers) != self._columns: raise exceptions.DataError( _("Headers lenght doesn't correspond to columns") ) self.add_headers(headers) if items: self.add_items(items) self.elem.setAttribute("columns", str(self._columns)) if callback_id is not None: self.elem.setAttribute("callback", callback_id) self.elem.setAttribute("selectable", selectable) self.auto_index = auto_index if auto_index: self.elem.setAttribute("auto_index", "true") self.next_row_idx = None def add_headers(self, headers): for header in headers: self.addHeader(header) def addHeader(self, header): pass # TODO def add_items(self, items): for item in items: self.append(item) def set_row_index(self, idx): """Set index for next row index are returned when a row is selected, in data's "index" key @param idx: string index to associate to the next row """ self.next_row_idx = idx def append(self, child): if isinstance(child, RowElement): return super(AdvancedListContainer, self).append(child) if self._item_idx % self._columns == 0: self.current_row = RowElement(self) self.current_row.append(child) self._item_idx += 1 def end(self): """Called when we have finished list change current container to first container parent """ if self._item_idx % self._columns != 0: raise exceptions.DataError(_("Incorrect number of items in list")) parent_container = self.get_parent_container() self.xmlui.change_container(parent_container) ## Widgets ## class Widget(Element): type = None def __init__(self, xmlui, name=None, parent=None): """Create an element @param xmlui: XMLUI instance @param name: name of the element or None @param parent: parent element or None """ self.elem = xmlui.doc.createElement("widget") super(Widget, self).__init__(xmlui, parent) if name: self.elem.setAttribute("name", name) if name in xmlui.named_widgets: raise exceptions.ConflictError( _('A widget with the name "{name}" already exists.').format(name=name) ) xmlui.named_widgets[name] = self self.elem.setAttribute("type", self.type) def set_internal_callback(self, callback, fields, data_elts=None): """Set an internal UI callback when the widget value is changed. The internal callbacks are NO callback ids, they are strings from a predefined set of actions that are running in the scope of XMLUI. @param callback (string): a value from: - 'copy': process the widgets given in 'fields' two by two, by copying the values of one widget to the other. Target widgets of type List do not accept the empty value. - 'move': same than copy but moves the values if the source widget is not a List. - 'groups_of_contact': process the widgets two by two, assume A is is a list of JID and B a list of groups, select in B the groups to which the JID selected in A belongs. - more operation to be added when necessary... @param fields (list): a list of widget names (string) @param data_elts (list[Element]): extra data elements """ self.elem.setAttribute("internal_callback", callback) if fields: for field in fields: InternalFieldElement(self, field) if data_elts: InternalDataElement(self, data_elts) class EmptyWidget(Widget): """Place holder widget""" type = "empty" class TextWidget(Widget): """Used for blob of text""" type = "text" def __init__(self, xmlui, value, name=None, parent=None): super(TextWidget, self).__init__(xmlui, name, parent) value_elt = self.xmlui.doc.createElement("value") text = self.xmlui.doc.createTextNode(value) value_elt.appendChild(text) self.elem.appendChild(value_elt) @property def value(self): return self.elem.firstChild.firstChild.wholeText class LabelWidget(Widget): """One line blob of text used most of time to display the desciption or name of the next widget """ type = "label" def __init__(self, xmlui, label, name=None, parent=None): super(LabelWidget, self).__init__(xmlui, name, parent) self.elem.setAttribute("value", label) class HiddenWidget(Widget): """Not displayed widget, frontends will just copy the value(s)""" type = "hidden" def __init__(self, xmlui, value, name, parent=None): super(HiddenWidget, self).__init__(xmlui, name, parent) value_elt = self.xmlui.doc.createElement("value") text = self.xmlui.doc.createTextNode(value) value_elt.appendChild(text) self.elem.appendChild(value_elt) @property def value(self): return self.elem.firstChild.firstChild.wholeText class JidWidget(Widget): """Used to display a Jabber ID, some specific methods can be added""" type = "jid" def __init__(self, xmlui, jid, name=None, parent=None): super(JidWidget, self).__init__(xmlui, name, parent) try: self.elem.setAttribute("value", jid.full()) except AttributeError: self.elem.setAttribute("value", str(jid)) @property def value(self): return self.elem.getAttribute("value") class DividerWidget(Widget): type = "divider" def __init__(self, xmlui, style="line", name=None, parent=None): """Create a divider @param xmlui: XMLUI instance @param style: one of: - line: a simple line - dot: a line of dots - dash: a line of dashes - plain: a full thick line - blank: a blank line/space @param name: name of the widget @param parent: parent container """ super(DividerWidget, self).__init__(xmlui, name, parent) self.elem.setAttribute("style", style) ### Inputs ### class InputWidget(Widget): """Widget which can accept user inputs used mainly in forms """ def __init__(self, xmlui, name=None, parent=None, read_only=False): super(InputWidget, self).__init__(xmlui, name, parent) if read_only: self.elem.setAttribute("read_only", "true") class StringWidget(InputWidget): type = "string" def __init__(self, xmlui, value=None, name=None, parent=None, read_only=False): super(StringWidget, self).__init__(xmlui, name, parent, read_only=read_only) if value: value_elt = self.xmlui.doc.createElement("value") text = self.xmlui.doc.createTextNode(value) value_elt.appendChild(text) self.elem.appendChild(value_elt) @property def value(self): return self.elem.firstChild.firstChild.wholeText class PasswordWidget(StringWidget): type = "password" class TextBoxWidget(StringWidget): type = "textbox" class XHTMLBoxWidget(StringWidget): """Specialized textbox to manipulate XHTML""" type = "xhtmlbox" def __init__(self, xmlui, value, name=None, parent=None, read_only=False, clean=True): """ @param clean(bool): if True, the XHTML is considered insecure and will be cleaned Only set to False if you are absolutely sure that the XHTML is safe (in other word, set to False only if you made the XHTML yourself) """ if clean: if clean_xhtml is None: raise exceptions.NotFound("No cleaning method set, can't clean the XHTML") value = clean_xhtml(value) super(XHTMLBoxWidget, self).__init__( xmlui, value=value, name=name, parent=parent, read_only=read_only ) class JidInputWidget(StringWidget): type = "jid_input" # TODO handle min and max values class IntWidget(StringWidget): type = "int" def __init__(self, xmlui, value=0, name=None, parent=None, read_only=False): try: int(value) except ValueError: raise exceptions.DataError(_("Value must be an integer")) super(IntWidget, self).__init__(xmlui, value, name, parent, read_only=read_only) class BoolWidget(InputWidget): type = "bool" def __init__(self, xmlui, value="false", name=None, parent=None, read_only=False): if isinstance(value, bool): value = "true" if value else "false" elif value == "0": value = "false" elif value == "1": value = "true" if value not in ("true", "false"): raise exceptions.DataError(_("Value must be 0, 1, false or true")) super(BoolWidget, self).__init__(xmlui, name, parent, read_only=read_only) self.elem.setAttribute("value", value) class ButtonWidget(Widget): type = "button" def __init__( self, xmlui, callback_id, value=None, fields_back=None, name=None, parent=None ): """Add a button @param callback_id: callback which will be called if button is pressed @param value: label of the button @param fields_back: list of names of field to give back when pushing the button @param name: name @param parent: parent container """ if fields_back is None: fields_back = [] super(ButtonWidget, self).__init__(xmlui, name, parent) self.elem.setAttribute("callback", callback_id) if value: self.elem.setAttribute("value", value) for field in fields_back: FieldBackElement(self, field) class ListWidget(InputWidget): type = "list" STYLES = ("multi", "noselect", "extensible", "reducible", "inline") def __init__( self, xmlui, options, selected=None, styles=None, name=None, parent=None ): """ @param xmlui @param options (list[option]): each option can be given as: - a single string if the label and the value are the same - a tuple with a couple of string (value,label) if the label and the value differ @param selected (list[string]): list of the selected values @param styles (iterable[string]): flags to set the behaviour of the list can be: - multi: multiple selection is allowed - noselect: no selection is allowed useful when only the list itself is needed - extensible: can be extended by user (i.e. new options can be added) - reducible: can be reduced by user (i.e. options can be removed) - inline: hint that this list should be displayed on a single line (e.g. list of labels) @param name (string) @param parent """ styles = set() if styles is None else set(styles) if styles is None: styles = set() else: styles = set(styles) if "noselect" in styles and ("multi" in styles or selected): raise exceptions.DataError( _( '"multi" flag and "selected" option are not compatible with ' '"noselect" flag' ) ) if not options: # we can have no options if we get a submitted data form # but we can't use submitted values directly, # because we would not have the labels log.warning(_('empty "options" list')) super(ListWidget, self).__init__(xmlui, name, parent) self.add_options(options, selected) self.set_styles(styles) def add_options(self, options, selected=None): """Add options to a multi-values element (e.g. list)""" if selected: if isinstance(selected, str): selected = [selected] else: selected = [] for option in options: assert isinstance(option, str) or isinstance(option, tuple) value = option if isinstance(option, str) else option[0] OptionElement(self, option, value in selected) def set_styles(self, styles): if not styles.issubset(self.STYLES): raise exceptions.DataError(_("invalid styles")) for style in styles: self.elem.setAttribute(style, "yes") # TODO: check flags incompatibily (noselect and multi) like in __init__ def setStyle(self, style): self.set_styles([style]) @property def value(self): """Return the value of first selected option""" for child in self.elem.childNodes: if child.tagName == "option" and child.getAttribute("selected") == "true": return child.getAttribute("value") return "" class JidsListWidget(InputWidget): """A list of text or jids where elements can be added/removed or modified""" type = "jids_list" def __init__(self, xmlui, jids, styles=None, name=None, parent=None): """ @param xmlui @param jids (list[jid.JID]): base jids @param styles (iterable[string]): flags to set the behaviour of the list @param name (string) @param parent """ super(JidsListWidget, self).__init__(xmlui, name, parent) styles = set() if styles is None else set(styles) if not styles.issubset([]): # TODO raise exceptions.DataError(_("invalid styles")) for style in styles: self.elem.setAttribute(style, "yes") if not jids: log.debug("empty jids list") else: self.add_jids(jids) def add_jids(self, jids): for jid_ in jids: JidElement(self, jid_) ## Dialog Elements ## class DialogElement(Element): """Main dialog element""" type = "dialog" def __init__(self, parent, type_, level=None): if not isinstance(parent, TopElement): raise exceptions.DataError( _("DialogElement must be a direct child of TopElement") ) super(DialogElement, self).__init__(parent.xmlui, parent) self.elem.setAttribute(C.XMLUI_DATA_TYPE, type_) self.elem.setAttribute(C.XMLUI_DATA_LVL, level or C.XMLUI_DATA_LVL_DEFAULT) class MessageElement(Element): """Element with the instruction message""" type = C.XMLUI_DATA_MESS def __init__(self, parent, message): if not isinstance(parent, DialogElement): raise exceptions.DataError( _("MessageElement must be a direct child of DialogElement") ) super(MessageElement, self).__init__(parent.xmlui, parent) message_txt = self.xmlui.doc.createTextNode(message) self.elem.appendChild(message_txt) class ButtonsElement(Element): """Buttons element which indicate which set to use""" type = "buttons" def __init__(self, parent, set_): if not isinstance(parent, DialogElement): raise exceptions.DataError( _("ButtonsElement must be a direct child of DialogElement") ) super(ButtonsElement, self).__init__(parent.xmlui, parent) self.elem.setAttribute("set", set_) class FileElement(Element): """File element used for FileDialog""" type = "file" def __init__(self, parent, type_): if not isinstance(parent, DialogElement): raise exceptions.DataError( _("FileElement must be a direct child of DialogElement") ) super(FileElement, self).__init__(parent.xmlui, parent) self.elem.setAttribute("type", type_) ## XMLUI main class class XMLUI(object): """This class is used to create a user interface (form/window/parameters/etc) using SàT XML""" def __init__( self, panel_type="window", container="vertical", dialog_opt=None, title=None, submit_id=None, session_id=None, ): """Init SàT XML Panel @param panel_type: one of - C.XMLUI_WINDOW (new window) - C.XMLUI_POPUP - C.XMLUI_FORM (form, depend of the frontend, usually a panel with cancel/submit buttons) - C.XMLUI_PARAM (parameters, presentation depend of the frontend) - C.XMLUI_DIALOG (one common dialog, presentation depend of frontend) @param container: disposition of elements, one of: - vertical: elements are disposed up to bottom - horizontal: elements are disposed left to right - pairs: elements come on two aligned columns (usually one for a label, the next for the element) - label: associations of one LabelWidget or EmptyWidget with an other widget similar to pairs but specialized in LabelWidget, and not necessarily arranged in 2 columns - tabs: elemens are in categories with tabs (notebook) @param dialog_opt: only used if panel_type == C.XMLUI_DIALOG. Dictionnary (string/string) where key can be: - C.XMLUI_DATA_TYPE: type of dialog, value can be: - C.XMLUI_DIALOG_MESSAGE (default): an information/error message. Action of user is necessary to close the dialog. Usually the frontend display a classic popup. - C.XMLUI_DIALOG_NOTE: like a C.XMLUI_DIALOG_MESSAGE, but action of user is not necessary to close, at frontend choice (it can be closed after a timeout). Usually the frontend display as a timed out notification - C.XMLUI_DIALOG_CONFIRM: dialog with 2 choices (usualy "Ok"/"Cancel"). returned data can contain: - "answer": "true" if answer is "ok", "yes" or equivalent, "false" else - C.XLMUI_DIALOG_FILE: a file selection dialog returned data can contain: - "cancelled": "true" if dialog has been cancelled, not present or "false" else - "path": path of the choosed file/dir - C.XMLUI_DATA_MESS: message shown in dialog - C.XMLUI_DATA_LVL: one of: - C.XMLUI_DATA_LVL_INFO (default): normal message - C.XMLUI_DATA_LVL_WARNING: attention of user is important - C.XMLUI_DATA_LVL_ERROR: something went wrong - C.XMLUI_DATA_BTNS_SET: one of: - C.XMLUI_DATA_BTNS_SET_OKCANCEL (default): classical "OK" and "Cancel" set - C.XMLUI_DATA_BTNS_SET_YESNO: a translated "yes" for OK, and "no" for Cancel - C.XMLUI_DATA_FILETYPE: only used for file dialogs, one of: - C.XMLUI_DATA_FILETYPE_FILE: a file path is requested - C.XMLUI_DATA_FILETYPE_DIR: a dir path is requested - C.XMLUI_DATA_FILETYPE_DEFAULT: same as C.XMLUI_DATA_FILETYPE_FILE @param title: title or default if None @param submit_id: callback id to call for panel_type we can submit (form, param, dialog) @param session_id: use to keep a session attached to the dialog, must be returned by frontends @attribute named_widgets(dict): map from name to widget """ if panel_type not in [ C.XMLUI_WINDOW, C.XMLUI_FORM, C.XMLUI_PARAM, C.XMLUI_POPUP, C.XMLUI_DIALOG, ]: raise exceptions.DataError(_("Unknown panel type [%s]") % panel_type) if panel_type == C.XMLUI_FORM and submit_id is None: raise exceptions.DataError(_("form XMLUI need a submit_id")) if not isinstance(container, str): raise exceptions.DataError(_("container argument must be a string")) if dialog_opt is not None and panel_type != C.XMLUI_DIALOG: raise exceptions.DataError( _("dialog_opt can only be used with dialog panels") ) self.type = panel_type impl = minidom.getDOMImplementation() self.doc = impl.createDocument(None, "sat_xmlui", None) top_element = self.doc.documentElement top_element.setAttribute("type", panel_type) if title: top_element.setAttribute("title", title) self.submit_id = submit_id self.session_id = session_id if panel_type == C.XMLUI_DIALOG: if dialog_opt is None: dialog_opt = {} self._create_dialog(dialog_opt) return self.main_container = self._create_container(container, TopElement(self)) self.current_container = self.main_container self.named_widgets = {} @staticmethod def creator_wrapper(widget_cls, is_input): # TODO: once moved to Python 3, use functools.partialmethod and # remove the creator_wrapper def create_widget(self, *args, **kwargs): if self.type == C.XMLUI_DIALOG: raise exceptions.InternalError( _("create_widget can't be used with dialogs") ) if "parent" not in kwargs: kwargs["parent"] = self.current_container if "name" not in kwargs and is_input: # name can be given as first argument or in keyword # arguments for InputWidgets args = list(args) kwargs["name"] = args.pop(0) return widget_cls(self, *args, **kwargs) return create_widget @classmethod def _introspect(cls): """Introspect module to find Widgets and Containers, and create addXXX methods""" # FIXME: we can't log anything because this file is used # in bin/sat script then evaluated # bin/sat should be refactored # log.debug(u'introspecting XMLUI widgets and containers') cls._containers = {} cls._widgets = {} for obj in list(globals().values()): try: if issubclass(obj, Widget): if obj.__name__ == "Widget": continue cls._widgets[obj.type] = obj creator_name = "add" + obj.__name__ if creator_name.endswith("Widget"): creator_name = creator_name[:-6] is_input = issubclass(obj, InputWidget) # FIXME: cf. above comment # log.debug(u"Adding {creator_name} creator (is_input={is_input}))" # .format(creator_name=creator_name, is_input=is_input)) assert not hasattr(cls, creator_name) # XXX: we need to use creator_wrapper because we are in a loop # and Python 2 doesn't support default values in kwargs # when using *args, **kwargs setattr(cls, creator_name, cls.creator_wrapper(obj, is_input)) elif issubclass(obj, Container): if obj.__name__ == "Container": continue cls._containers[obj.type] = obj except TypeError: pass def __del__(self): self.doc.unlink() @property def submit_id(self): top_element = self.doc.documentElement if not top_element.hasAttribute("submit"): # getAttribute never return None (it return empty string it attribute doesn't exists) # so we have to manage None here return None value = top_element.getAttribute("submit") return value @submit_id.setter def submit_id(self, value): top_element = self.doc.documentElement if value is None: try: top_element.removeAttribute("submit") except NotFoundErr: pass else: # submit_id can be the empty string to bypass form restriction top_element.setAttribute("submit", value) @property def session_id(self): top_element = self.doc.documentElement value = top_element.getAttribute("session_id") return value or None @session_id.setter def session_id(self, value): top_element = self.doc.documentElement if value is None: try: top_element.removeAttribute("session_id") except NotFoundErr: pass elif value: top_element.setAttribute("session_id", value) else: raise exceptions.DataError("session_id can't be empty") def _create_dialog(self, dialog_opt): dialog_type = dialog_opt.setdefault(C.XMLUI_DATA_TYPE, C.XMLUI_DIALOG_MESSAGE) if ( dialog_type in [C.XMLUI_DIALOG_CONFIRM, C.XMLUI_DIALOG_FILE] and self.submit_id is None ): raise exceptions.InternalError( _("Submit ID must be filled for this kind of dialog") ) top_element = TopElement(self) level = dialog_opt.get(C.XMLUI_DATA_LVL) dialog_elt = DialogElement(top_element, dialog_type, level) try: MessageElement(dialog_elt, dialog_opt[C.XMLUI_DATA_MESS]) except KeyError: pass try: ButtonsElement(dialog_elt, dialog_opt[C.XMLUI_DATA_BTNS_SET]) except KeyError: pass try: FileElement(dialog_elt, dialog_opt[C.XMLUI_DATA_FILETYPE]) except KeyError: pass def _create_container(self, container, parent=None, **kwargs): """Create a container element @param type: container type (cf init doc) @parent: parent element or None """ if container not in self._containers: raise exceptions.DataError(_("Unknown container type [%s]") % container) cls = self._containers[container] new_container = cls(self, parent=parent, **kwargs) return new_container def change_container(self, container, **kwargs): """Change the current container @param container: either container type (container it then created), or an Container instance""" if isinstance(container, str): self.current_container = self._create_container( container, self.current_container.get_parent_container() or self.main_container, **kwargs, ) else: self.current_container = ( self.main_container if container is None else container ) assert isinstance(self.current_container, Container) return self.current_container def add_widget(self, type_, *args, **kwargs): """Convenience method to add an element""" if "parent" not in kwargs: kwargs["parent"] = self.current_container try: cls = self._widgets[type_] except KeyError: raise exceptions.DataError(_("Invalid type [{type_}]").format(type_=type_)) return cls(self, *args, **kwargs) def toXml(self): """return the XML representation of the panel""" return self.doc.toxml() # we call this to have automatic discovery of containers and widgets XMLUI._introspect() # Some sugar for XMLUI dialogs def note(message, title="", level=C.XMLUI_DATA_LVL_INFO): """sugar to easily create a Note Dialog @param message(unicode): body of the note @param title(unicode): title of the note @param level(unicode): one of C.XMLUI_DATA_LVL_* @return(XMLUI): instance of XMLUI """ note_xmlui = XMLUI( C.XMLUI_DIALOG, dialog_opt={ C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE, C.XMLUI_DATA_MESS: message, C.XMLUI_DATA_LVL: level, }, title=title, ) return note_xmlui def quick_note(host, client, message, title="", level=C.XMLUI_DATA_LVL_INFO): """more sugar to do the whole note process""" note_ui = note(message, title, level) host.action_new({"xmlui": note_ui.toXml()}, profile=client.profile) def deferred_ui(host, xmlui, chained=False): """Create a deferred linked to XMLUI @param xmlui(XMLUI): instance of the XMLUI Must be an XMLUI that you can submit, with submit_id set to '' @param chained(bool): True if the Deferred result must be returned to the frontend useful when backend is in a series of dialogs with an ui @return (D(data)): a deferred which fire the data """ assert xmlui.submit_id == "" xmlui_d = defer.Deferred() def on_submit(data, profile): xmlui_d.callback(data) return xmlui_d if chained else {} xmlui.submit_id = host.register_callback(on_submit, with_data=True, one_shot=True) return xmlui_d def defer_xmlui( host, xmlui: XMLUI, action_extra: dict | None = None, security_limit: int = C.NO_SECURITY_LIMIT, chained: bool = False, profile: str = C.PROF_KEY_NONE, ) -> defer.Deferred[dict]: """Create a deferred linked to XMLUI @param xmlui: instance of the XMLUI Must be an XMLUI that you can submit, with submit_id set to '' @param profile: %(doc_profile)s @param action_extra: extra action to merge with xmlui mainly used to add meta informations (see action_new doc) @param security_limit: %(doc_security_limit)s @param chained: True if the Deferred result must be returned to the frontend useful when backend is in a series of dialogs with an ui @return: a deferred which fire the data """ xmlui_d = deferred_ui(host, xmlui, chained) action_data = {"xmlui": xmlui.toXml()} if action_extra is not None: action_data.update(action_extra) host.action_new( action_data, security_limit=security_limit, keep_id=xmlui.submit_id, profile=profile, ) return xmlui_d def defer_dialog( host, message: str, title: str = "Please confirm", type_: str = C.XMLUI_DIALOG_CONFIRM, options: Optional[dict] = None, action_extra: Optional[dict] = None, security_limit: int = C.NO_SECURITY_LIMIT, chained: bool = False, profile: str = C.PROF_KEY_NONE, ) -> defer.Deferred: """Create a submitable dialog and manage it with a deferred @param message: message to display @param title: title of the dialog @param type: dialog type (C.XMLUI_DIALOG_* or plugin specific string) @param options: if not None, will be used to update (extend) dialog_opt arguments of XMLUI @param action_extra: extra action to merge with xmlui mainly used to add meta informations (see action_new doc) @param security_limit: %(doc_security_limit)s @param chained: True if the Deferred result must be returned to the frontend useful when backend is in a series of dialogs with an ui @param profile: %(doc_profile)s @return: answer dict """ assert profile is not None dialog_opt = {"type": type_, "message": message} if options is not None: dialog_opt.update(options) dialog = XMLUI(C.XMLUI_DIALOG, title=title, dialog_opt=dialog_opt, submit_id="") return defer_xmlui(host, dialog, action_extra, security_limit, chained, profile) def defer_confirm(*args, **kwargs): """call defer_dialog and return a boolean instead of the whole data dict""" d = defer_dialog(*args, **kwargs) d.addCallback(lambda data: C.bool(data["answer"])) return d # Misc other funtions def element_copy( element: domish.Element, with_parent: bool = True, with_children: bool = True ) -> domish.Element: """Make a copy of a domish.Element The copy will have its own children list, so other elements can be added as direct children without modifying orignal one. Children are not deeply copied, so if an element is added to a child or grandchild, it will also affect original element. @param element: Element to clone """ new_elt = domish.Element( (element.uri, element.name), defaultUri=element.defaultUri, attribs=element.attributes, localPrefixes=element.localPrefixes, ) if with_parent: new_elt.parent = element.parent if with_children: new_elt.children = element.children[:] return new_elt def is_xhtml_field(field): """Check if a data_form.Field is an XHTML one""" return ( field.fieldType is None and field.ext_type == "xml" and field.value.uri == C.NS_XHTML ) class ElementParser: """Callable class to parse XML string into Element""" # XXX: Found at http://stackoverflow.com/questions/2093400/how-to-create-twisted-words-xish-domish-element-entirely-from-raw-xml/2095942#2095942 def _escape_html(self, matchobj): entity = matchobj.group(1) if entity in XML_ENTITIES: # we don't escape XML entities return matchobj.group(0) else: try: return chr(html.entities.name2codepoint[entity]) except KeyError: log.warning("removing unknown entity {}".format(entity)) return "" def __call__(self, raw_xml, force_spaces=False, namespace=None): """ @param raw_xml(unicode): the raw XML @param force_spaces (bool): if True, replace occurrences of '\n' and '\t' with ' '. @param namespace(unicode, None): if set, use this namespace for the wrapping element """ # we need to wrap element in case # there is not a unique one on the top if namespace is not None: raw_xml = "<div xmlns='{}'>{}</div>".format(namespace, raw_xml) else: raw_xml = "<div>{}</div>".format(raw_xml) # avoid ParserError on HTML escaped chars raw_xml = html_entity_re.sub(self._escape_html, raw_xml) self.result = None def on_start(elem): self.result = elem def on_end(): pass def onElement(elem): self.result.addChild(elem) parser = domish.elementStream() parser.DocumentStartEvent = on_start parser.ElementEvent = onElement parser.DocumentEndEvent = on_end tmp = domish.Element((None, "s")) if force_spaces: raw_xml = raw_xml.replace("\n", " ").replace("\t", " ") tmp.addRawXml(raw_xml) parser.parse(tmp.toXml().encode("utf-8")) top_elt = self.result.firstChildElement() # we now can check if there was a unique element on the top # and remove our wrapping <div/> is this is the case top_elt_children = list(top_elt.elements()) if len(top_elt_children) == 1: top_elt = top_elt_children[0] return top_elt parse = ElementParser() # FIXME: this method is duplicated from frontends.tools.xmlui.get_text def get_text(node): """Get child text nodes of a domish.Element. @param node (domish.Element) @return: joined unicode text of all nodes """ data = [] for child in node.childNodes: if child.nodeType == child.TEXT_NODE: data.append(child.wholeText) return "".join(data) def find_all(elt, namespaces=None, names=None): """Find child element at any depth matching criteria @param elt(domish.Element): top parent of the elements to find @param names(iterable[unicode], basestring, None): names to match None to accept every names @param namespace(iterable[unicode], basestring, None): URIs to match None to accept every namespaces @return ((G)domish.Element): found elements """ if isinstance(namespaces, str): namespaces = tuple((namespaces,)) if isinstance(names, str): names = tuple((names,)) for child in elt.elements(): if ( domish.IElement.providedBy(child) and (not names or child.name in names) and (not namespaces or child.uri in namespaces) ): yield child for found in find_all(child, namespaces, names): yield found def find_ancestor( elt, name: str, namespace: Optional[Union[str, Iterable[str]]] = None ) -> domish.Element: """Retrieve ancestor of an element @param elt: starting element, its parent will be checked recursively until the required one if found @param name: name of the element to find @param namespace: namespace of the element to find - None to find any element with that name - a simple string to find the namespace - several namespaces can be specified in an iterable, if an element with any of this namespace and given name is found, it will match """ if isinstance(namespace, str): namespace = [namespace] current = elt.parent while True: if current is None: raise exceptions.NotFound( f"Can't find any ancestor {name!r} (xmlns: {namespace!r})" ) if current.name == name and (namespace is None or current.uri in namespace): return current current = current.parent def p_fmt_elt(elt, indent=0, defaultUri=""): """Pretty format a domish.Element""" strings = [] for child in elt.children: if domish.IElement.providedBy(child): strings.append(p_fmt_elt(child, indent + 2, defaultUri=elt.defaultUri)) else: strings.append(f"{(indent+2)*' '}{child!s}") if elt.children: nochild_elt = domish.Element( (elt.uri, elt.name), elt.defaultUri, elt.attributes, elt.localPrefixes ) strings.insert(0, f"{indent*' '}{nochild_elt.toXml(defaultUri=defaultUri)[:-2]}>") strings.append(f"{indent*' '}</{nochild_elt.name}>") else: strings.append(f"{indent*' '}{elt.toXml(defaultUri=defaultUri)}") return "\n".join(strings) def pp_elt(elt): """Pretty print a domish.Element""" print(p_fmt_elt(elt)) # ElementTree def et_get_namespace_and_name(et_elt: ET.Element) -> Tuple[Optional[str], str]: """Retrieve element namespace and name from ElementTree element @param et_elt: ElementTree element @return: namespace and name of the element if not namespace if specified, None is returned """ name = et_elt.tag if not name: raise ValueError("no name set in ET element") elif name[0] != "{": return None, name end_idx = name.find("}") if end_idx == -1: raise ValueError("Invalid ET name") return name[1:end_idx], name[end_idx + 1 :] def et_elt_2_domish_elt(et_elt: Union[ET.Element, etree.Element]) -> domish.Element: """Convert ElementTree element to Twisted's domish.Element Note: this is a naive implementation, adapted to XMPP, and some content are ignored (attributes namespaces, tail) """ namespace, name = et_get_namespace_and_name(et_elt) elt = domish.Element((namespace, name), attribs=et_elt.attrib) if et_elt.text: elt.addContent(et_elt.text) for child in et_elt: elt.addChild(et_elt_2_domish_elt(child)) return elt @overload def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[False]) -> ET.Element: ... @overload def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[True]) -> etree.Element: ... @overload def domish_elt_2_et_elt( elt: domish.Element, lxml: bool ) -> Union[ET.Element, etree.Element]: ... def domish_elt_2_et_elt(elt, lxml=False): """Convert Twisted's domish.Element to ElementTree equivalent Note: this is a naive implementation, adapted to XMPP, and some text content may be missing (content put after a tag, i.e. what would go to the "tail" attribute of ET Element) """ tag = f"{{{elt.uri}}}{elt.name}" if elt.uri else elt.name if lxml: et_elt = etree.Element(tag, attr=elt.attributes) else: et_elt = ET.Element(tag, attrib=elt.attributes) content = str(elt) if content: et_elt.text = str(elt) for child in elt.elements(): et_elt.append(domish_elt_2_et_elt(child, lxml=lxml)) return et_elt def domish_elt_2_et_elt2(element: domish.Element) -> ET.Element: """ WIP, originally from the OMEMO plugin """ element_name = element.name if element.uri is not None: element_name = "{" + element.uri + "}" + element_name attrib: Dict[str, str] = {} for qname, value in element.attributes.items(): attribute_name = qname[1] if isinstance(qname, tuple) else qname attribute_namespace = qname[0] if isinstance(qname, tuple) else None if attribute_namespace is not None: attribute_name = "{" + attribute_namespace + "}" + attribute_name attrib[attribute_name] = value result = ET.Element(element_name, attrib) last_child: Optional[ET.Element] = None for child in element.children: if isinstance(child, str): if last_child is None: result.text = child else: last_child.tail = child else: last_child = domish_elt_2_et_elt2(child) result.append(last_child) return result