diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/uilib.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cubicweb/uilib.py Sat Jan 16 13:48:51 2016 +0100 @@ -0,0 +1,589 @@ +# -*- coding: utf-8 -*- +# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr +# +# This file is part of CubicWeb. +# +# CubicWeb is free software: you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 2.1 of the License, or (at your option) +# any later version. +# +# CubicWeb 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 Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with CubicWeb. If not, see . +"""user interface libraries + +contains some functions designed to help implementation of cubicweb user +interface. +""" + +__docformat__ = "restructuredtext en" + +import csv +import re +from io import StringIO + +from six import PY2, PY3, text_type, binary_type, string_types, integer_types + +from logilab.mtconverter import xml_escape, html_unescape +from logilab.common.date import ustrftime +from logilab.common.deprecation import deprecated + +from cubicweb import _ +from cubicweb.utils import js_dumps + + +def rql_for_eid(eid): + """return the rql query necessary to fetch entity with the given eid. This + function should only be used to generate link with rql inside, not to give + to cursor.execute (in which case you won't benefit from rql cache). + + :Parameters: + - `eid`: the eid of the entity we should search + :rtype: str + :return: the rql query + """ + return 'Any X WHERE X eid %s' % eid + +def eid_param(name, eid): + assert name is not None + assert eid is not None + return '%s:%s' % (name, eid) + +def print_bytes(value, req, props, displaytime=True): + return u'' + +def print_string(value, req, props, displaytime=True): + # don't translate empty value if you don't want strange results + if props is not None and value and props.get('internationalizable'): + return req._(value) + return value + +def print_int(value, req, props, displaytime=True): + return text_type(value) + +def print_date(value, req, props, displaytime=True): + return ustrftime(value, req.property_value('ui.date-format')) + +def print_time(value, req, props, displaytime=True): + return ustrftime(value, req.property_value('ui.time-format')) + +def print_tztime(value, req, props, displaytime=True): + return ustrftime(value, req.property_value('ui.time-format')) + u' UTC' + +def print_datetime(value, req, props, displaytime=True): + if displaytime: + return ustrftime(value, req.property_value('ui.datetime-format')) + return ustrftime(value, req.property_value('ui.date-format')) + +def print_tzdatetime(value, req, props, displaytime=True): + if displaytime: + return ustrftime(value, req.property_value('ui.datetime-format')) + u' UTC' + return ustrftime(value, req.property_value('ui.date-format')) + +_('%d years') +_('%d months') +_('%d weeks') +_('%d days') +_('%d hours') +_('%d minutes') +_('%d seconds') + +def print_timedelta(value, req, props, displaytime=True): + if isinstance(value, integer_types): + # `date - date`, unlike `datetime - datetime` gives an int + # (number of days), not a timedelta + # XXX should rql be fixed to return Int instead of Interval in + # that case? that would be probably the proper fix but we + # loose information on the way... + value = timedelta(days=value) + if value.days > 730 or value.days < -730: # 2 years + return req._('%d years') % (value.days // 365) + elif value.days > 60 or value.days < -60: # 2 months + return req._('%d months') % (value.days // 30) + elif value.days > 14 or value.days < -14: # 2 weeks + return req._('%d weeks') % (value.days // 7) + elif value.days > 2 or value.days < -2: + return req._('%d days') % int(value.days) + else: + minus = 1 if value.days >= 0 else -1 + if value.seconds > 3600: + return req._('%d hours') % (int(value.seconds // 3600) * minus) + elif value.seconds >= 120: + return req._('%d minutes') % (int(value.seconds // 60) * minus) + else: + return req._('%d seconds') % (int(value.seconds) * minus) + +def print_boolean(value, req, props, displaytime=True): + if value: + return req._('yes') + return req._('no') + +def print_float(value, req, props, displaytime=True): + return text_type(req.property_value('ui.float-format') % value) # XXX cast needed ? + +PRINTERS = { + 'Bytes': print_bytes, + 'String': print_string, + 'Int': print_int, + 'BigInt': print_int, + 'Date': print_date, + 'Time': print_time, + 'TZTime': print_tztime, + 'Datetime': print_datetime, + 'TZDatetime': print_tzdatetime, + 'Boolean': print_boolean, + 'Float': print_float, + 'Decimal': print_float, + 'Interval': print_timedelta, + } + +@deprecated('[3.14] use req.printable_value(attrtype, value, ...)') +def printable_value(req, attrtype, value, props=None, displaytime=True): + return req.printable_value(attrtype, value, props, displaytime) + +def css_em_num_value(vreg, propname, default): + """ we try to read an 'em' css property + if we get another unit we're out of luck and resort to the given default + (hence, it is strongly advised not to specify but ems for this css prop) + """ + propvalue = vreg.config.uiprops[propname].lower().strip() + if propvalue.endswith('em'): + try: + return float(propvalue[:-2]) + except Exception: + vreg.warning('css property %s looks malformed (%r)', + propname, propvalue) + else: + vreg.warning('css property %s should use em (currently is %r)', + propname, propvalue) + return default + +# text publishing ############################################################# + +from cubicweb.ext.markdown import markdown_publish # pylint: disable=W0611 + +try: + from cubicweb.ext.rest import rest_publish # pylint: disable=W0611 +except ImportError: + def rest_publish(entity, data): + """default behaviour if docutils was not found""" + return xml_escape(data) + + +TAG_PROG = re.compile(r'', re.U) +def remove_html_tags(text): + """Removes HTML tags from text + + >>> remove_html_tags('hi world') + 'hi world' + >>> + """ + return TAG_PROG.sub('', text) + + +REF_PROG = re.compile(r"([^<]*)", re.U) +def _subst_rql(view, obj): + delim, rql, descr = obj.groups() + return u'%s' % (view._cw.build_url(rql=rql), descr) + +def html_publish(view, text): + """replace links by """ + if not text: + return u'' + return REF_PROG.sub(lambda obj, view=view:_subst_rql(view, obj), text) + +# fallback implementation, nicer one defined below if lxml> 2.0 is available +def safe_cut(text, length): + """returns a string of length based on , removing any html + tags from given text if cut is necessary.""" + if text is None: + return u'' + noenttext = html_unescape(text) + text_nohtml = remove_html_tags(noenttext) + # try to keep html tags if text is short enough + if len(text_nohtml) <= length: + return text + # else if un-tagged text is too long, cut it + return xml_escape(text_nohtml[:length] + u'...') + +fallback_safe_cut = safe_cut + +REM_ROOT_HTML_TAGS = re.compile('', re.U) + +from lxml import etree, html +from lxml.html import clean, defs + +ALLOWED_TAGS = (defs.general_block_tags | defs.list_tags | defs.table_tags | + defs.phrase_tags | defs.font_style_tags | + set(('span', 'a', 'br', 'img', 'map', 'area', 'sub', 'sup', 'canvas')) + ) + +CLEANER = clean.Cleaner(allow_tags=ALLOWED_TAGS, remove_unknown_tags=False, + style=True, safe_attrs_only=True, + add_nofollow=False, + ) + +def soup2xhtml(data, encoding): + """tidy html soup by allowing some element tags and return the result + """ + # remove spurious and tags, then normalize line break + # (see http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7.1) + data = REM_ROOT_HTML_TAGS.sub('', u'\n'.join(data.splitlines())) + xmltree = etree.HTML(CLEANER.clean_html('
%s
' % data)) + # NOTE: lxml 2.0 does support encoding='unicode', but last time I (syt) + # tried I got weird results (lxml 2.2.8) + body = etree.tostring(xmltree[0], encoding=encoding) + # remove and and decode to unicode + snippet = body[6:-7].decode(encoding) + # take care to bad xhtml (for instance starting with ) which + # may mess with the
we added below. Only remove it if it's + # still there... + if snippet.startswith('
') and snippet.endswith('
'): + snippet = snippet[5:-6] + return snippet + + # lxml.Cleaner envelops text elements by internal logic (not accessible) + # see http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7.1 + # TODO drop attributes in elements + # TODO add policy configuration (content only, embedded content, ...) + # XXX this is buggy for "

text1

text2

"... + # XXX drop these two snippets action and follow the lxml behaviour + # XXX (tests need to be updated) + # if snippet.startswith('
') and snippet.endswith('
'): + # snippet = snippet[5:-6] + # if snippet.startswith('

') and snippet.endswith('

'): + # snippet = snippet[3:-4] + return snippet.decode(encoding) + +if hasattr(etree.HTML('
test
'), 'iter'): # XXX still necessary? + # pylint: disable=E0102 + def safe_cut(text, length): + """returns an html document of length based on , + and cut is necessary. + """ + if text is None: + return u'' + dom = etree.HTML(text) + curlength = 0 + add_ellipsis = False + for element in dom.iter(): + if curlength >= length: + parent = element.getparent() + parent.remove(element) + if curlength == length and (element.text or element.tail): + add_ellipsis = True + else: + if element.text is not None: + element.text = cut(element.text, length - curlength) + curlength += len(element.text) + if element.tail is not None: + if curlength < length: + element.tail = cut(element.tail, length - curlength) + curlength += len(element.tail) + elif curlength == length: + element.tail = '...' + else: + element.tail = '' + text = etree.tounicode(dom[0])[6:-7] # remove wrapping + if add_ellipsis: + return text + u'...' + return text + +def text_cut(text, nbwords=30, gotoperiod=True): + """from the given plain text, return a text with at least words, + trying to go to the end of the current sentence. + + :param nbwords: the minimum number of words required + :param gotoperiod: specifies if the function should try to go to + the first period after the cut (i.e. finish + the sentence if possible) + + Note that spaces are normalized. + """ + if text is None: + return u'' + words = text.split() + text = u' '.join(words) # normalize spaces + textlength = minlength = len(' '.join(words[:nbwords])) + if gotoperiod: + textlength = text.find('.', minlength) + 1 + if textlength == 0: # no period found + textlength = minlength + return text[:textlength] + +def cut(text, length): + """returns a string of a maximum length based on + (approximatively, since if text has been cut, '...' is added to the end of the string, + resulting in a string of len + 3) + """ + if text is None: + return u'' + if len(text) <= length: + return text + # else if un-tagged text is too long, cut it + return text[:length] + u'...' + + + +# HTML generation helper functions ############################################ + +class _JSId(object): + def __init__(self, id, parent=None): + self.id = id + self.parent = parent + def __unicode__(self): + if self.parent: + return u'%s.%s' % (self.parent, self.id) + return text_type(self.id) + __str__ = __unicode__ if PY3 else lambda self: self.__unicode__().encode('utf-8') + def __getattr__(self, attr): + return _JSId(attr, self) + def __call__(self, *args): + return _JSCallArgs(args, self) + +class _JSCallArgs(_JSId): + def __init__(self, args, parent=None): + assert isinstance(args, tuple) + self.args = args + self.parent = parent + def __unicode__(self): + args = [] + for arg in self.args: + args.append(js_dumps(arg)) + if self.parent: + return u'%s(%s)' % (self.parent, ','.join(args)) + return ','.join(args) + __str__ = __unicode__ if PY3 else lambda self: self.__unicode__().encode('utf-8') + +class _JS(object): + def __getattr__(self, attr): + return _JSId(attr) + +js = _JS() +js.__doc__ = """\ +magic object to return strings suitable to call some javascript function with +the given arguments (which should be correctly typed). + +>>> str(js.pouet(1, "2")) +'pouet(1,"2")' +>>> str(js.cw.pouet(1, "2")) +'cw.pouet(1,"2")' +>>> str(js.cw.pouet(1, "2").pouet(None)) +'cw.pouet(1,"2").pouet(null)' +>>> str(js.cw.pouet(1, JSString("$")).pouet(None)) +'cw.pouet(1,$).pouet(null)' +>>> str(js.cw.pouet(1, {'callback': JSString("cw.cb")}).pouet(None)) +'cw.pouet(1,{callback: cw.cb}).pouet(null)' +""" + +def domid(string): + """return a valid DOM id from a string (should also be usable in jQuery + search expression...) + """ + return string.replace('.', '_').replace('-', '_') + +HTML4_EMPTY_TAGS = frozenset(('base', 'meta', 'link', 'hr', 'br', 'param', + 'img', 'area', 'input', 'col')) + +def sgml_attributes(attrs): + return u' '.join(u'%s="%s"' % (attr, xml_escape(text_type(value))) + for attr, value in sorted(attrs.items()) + if value is not None) + +def simple_sgml_tag(tag, content=None, escapecontent=True, **attrs): + """generation of a simple sgml tag (eg without children tags) easier + + content and attri butes will be escaped + """ + value = u'<%s' % tag + if attrs: + try: + attrs['class'] = attrs.pop('klass') + except KeyError: + pass + value += u' ' + sgml_attributes(attrs) + if content: + if escapecontent: + content = xml_escape(text_type(content)) + value += u'>%s' % (content, tag) + else: + if tag in HTML4_EMPTY_TAGS: + value += u' />' + else: + value += u'>' % tag + return value + +def tooltipize(text, tooltip, url=None): + """make an HTML tooltip""" + url = url or '#' + return u'
%s' % (url, tooltip, text) + +def toggle_action(nodeid): + """builds a HTML link that uses the js toggleVisibility function""" + return u"javascript: toggleVisibility('%s')" % nodeid + +def toggle_link(nodeid, label): + """builds a HTML link that uses the js toggleVisibility function""" + return u'%s' % (toggle_action(nodeid), label) + + +def ureport_as_html(layout): + from logilab.common.ureports import HTMLWriter + formater = HTMLWriter(True) + stream = StringIO() #UStringIO() don't want unicode assertion + formater.format(layout, stream) + res = stream.getvalue() + if isinstance(res, binary_type): + res = res.decode('UTF8') + return res + +# traceback formatting ######################################################## + +import traceback + +def exc_message(ex, encoding): + if PY3: + excmsg = str(ex) + else: + try: + excmsg = unicode(ex) + except Exception: + try: + excmsg = unicode(str(ex), encoding, 'replace') + except Exception: + excmsg = unicode(repr(ex), encoding, 'replace') + exctype = ex.__class__.__name__ + return u'%s: %s' % (exctype, excmsg) + + +def rest_traceback(info, exception): + """return a unicode ReST formated traceback""" + res = [u'Traceback\n---------\n::\n'] + for stackentry in traceback.extract_tb(info[2]): + res.append(u'\tFile %s, line %s, function %s' % tuple(stackentry[:3])) + if stackentry[3]: + data = xml_escape(stackentry[3]) + if PY2: + data = data.decode('utf-8', 'replace') + res.append(u'\t %s' % data) + res.append(u'\n') + try: + res.append(u'\t Error: %s\n' % exception) + except Exception: + pass + return u'\n'.join(res) + + +def html_traceback(info, exception, title='', + encoding='ISO-8859-1', body=''): + """ return an html formatted traceback from python exception infos. + """ + tcbk = info[2] + stacktb = traceback.extract_tb(tcbk) + strings = [] + if body: + strings.append(u'
') + # FIXME + strings.append(body) + strings.append(u'
') + if title: + strings.append(u'

%s

'% xml_escape(title)) + try: + strings.append(u'

%s

' % xml_escape(str(exception)).replace("\n","
")) + except UnicodeError: + pass + strings.append(u'
') + for index, stackentry in enumerate(stacktb): + strings.append(u'File %s, line ' + u'%s, function ' + u'%s:
'%( + xml_escape(stackentry[0]), stackentry[1], xml_escape(stackentry[2]))) + if stackentry[3]: + string = xml_escape(stackentry[3]) + if PY2: + string = string.decode('utf-8', 'replace') + strings.append(u'  %s
\n' % (string)) + # add locals info for each entry + try: + local_context = tcbk.tb_frame.f_locals + html_info = [] + chars = 0 + for name, value in local_context.items(): + value = xml_escape(repr(value)) + info = u'%s=%s, ' % (name, value) + line_length = len(name) + len(value) + chars += line_length + # 150 is the result of *years* of research ;-) (CSS might be helpful here) + if chars > 150: + info = u'
' + info + chars = line_length + html_info.append(info) + boxid = 'ctxlevel%d' % index + strings.append(u'[%s]' % toggle_link(boxid, '+')) + strings.append(u'' % + (boxid, ''.join(html_info))) + tcbk = tcbk.tb_next + except Exception: + pass # doesn't really matter if we have no context info + strings.append(u'
') + return '\n'.join(strings) + +# csv files / unicode support ################################################# + +class UnicodeCSVWriter: + """proxies calls to csv.writer.writerow to be able to deal with unicode + + Under Python 3, this code no longer encodes anything.""" + + def __init__(self, wfunc, encoding, **kwargs): + self.writer = csv.writer(self, **kwargs) + self.wfunc = wfunc + self.encoding = encoding + + def write(self, data): + self.wfunc(data) + + def writerow(self, row): + if PY3: + self.writer.writerow(row) + return + csvrow = [] + for elt in row: + if isinstance(elt, text_type): + csvrow.append(elt.encode(self.encoding)) + else: + csvrow.append(str(elt)) + self.writer.writerow(csvrow) + + def writerows(self, rows): + for row in rows: + self.writerow(row) + + +# some decorators ############################################################# + +class limitsize(object): + def __init__(self, maxsize): + self.maxsize = maxsize + + def __call__(self, function): + def newfunc(*args, **kwargs): + ret = function(*args, **kwargs) + if isinstance(ret, string_types): + return ret[:self.maxsize] + return ret + return newfunc + + +def htmlescape(function): + def newfunc(*args, **kwargs): + ret = function(*args, **kwargs) + assert isinstance(ret, string_types) + return xml_escape(ret) + return newfunc