diff -r 058bb3dc685f -r 0b59724cb3f2 ext/rest.py --- a/ext/rest.py Mon Jan 04 18:40:30 2016 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,469 +0,0 @@ -# copyright 2003-2013 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 . -"""rest publishing functions - -contains some functions and setup of docutils for cubicweb. Provides the -following ReST directives: - -* `eid`, create link to entity in the repository by their eid - -* `card`, create link to card entity in the repository by their wikiid - (proposing to create it when the refered card doesn't exist yet) - -* `winclude`, reference to a web documentation file (in wdoc/ directories) - -* `sourcecode` (if pygments is installed), source code colorization - -* `rql-table`, create a table from a RQL query - -""" -__docformat__ = "restructuredtext en" - -import sys -from itertools import chain -from logging import getLogger -from os.path import join - -from six import text_type -from six.moves.urllib.parse import urlsplit - -from docutils import statemachine, nodes, utils, io -from docutils.core import Publisher -from docutils.parsers.rst import Parser, states, directives, Directive -from docutils.parsers.rst.roles import register_canonical_role, set_classes - -from logilab.mtconverter import ESC_UCAR_TABLE, ESC_CAR_TABLE, xml_escape - -from cubicweb import UnknownEid -from cubicweb.ext.html4zope import Writer - -from cubicweb.web.views import vid_from_rset # XXX better not to import c.w.views here... - -# We provide our own parser as an attempt to get rid of -# state machine reinstanciation - -import re -# compile states.Body patterns -for k, v in states.Body.patterns.items(): - if isinstance(v, str): - states.Body.patterns[k] = re.compile(v) - -# register ReStructured Text mimetype / extensions -import mimetypes -mimetypes.add_type('text/rest', '.rest') -mimetypes.add_type('text/rest', '.rst') - - -LOGGER = getLogger('cubicweb.rest') - - -def eid_reference_role(role, rawtext, text, lineno, inliner, - options={}, content=[]): - try: - try: - eid_num, rest = text.split(u':', 1) - except ValueError: - eid_num, rest = text, '#'+text - eid_num = int(eid_num) - if eid_num < 0: - raise ValueError - except ValueError: - msg = inliner.reporter.error( - 'EID number must be a positive number; "%s" is invalid.' - % text, line=lineno) - prb = inliner.problematic(rawtext, rawtext, msg) - return [prb], [msg] - # Base URL mainly used by inliner.pep_reference; so this is correct: - context = inliner.document.settings.context - try: - refedentity = context._cw.entity_from_eid(eid_num) - except UnknownEid: - ref = '#' - rest += u' ' + context._cw._('(UNEXISTANT EID)') - else: - ref = refedentity.absolute_url() - set_classes(options) - return [nodes.reference(rawtext, utils.unescape(rest), refuri=ref, - **options)], [] - - -def rql_role(role, rawtext, text, lineno, inliner, options={}, content=[]): - """``:rql:```` or ``:rql:`:``` - - Example: ``:rql:`Any X,Y WHERE X is CWUser, X login Y:table``` - - Replace the directive with the output of applying the view to the resultset - returned by the query. - - "X eid %(userid)s" can be used in the RQL query for this query will be - executed with the argument {'userid': _cw.user.eid}. - """ - _cw = inliner.document.settings.context._cw - text = text.strip() - if ':' in text: - rql, vid = text.rsplit(u':', 1) - rql = rql.strip() - else: - rql, vid = text, None - _cw.ensure_ro_rql(rql) - try: - rset = _cw.execute(rql, {'userid': _cw.user.eid}) - if rset: - if vid is None: - vid = vid_from_rset(_cw, rset, _cw.vreg.schema) - else: - vid = 'noresult' - view = _cw.vreg['views'].select(vid, _cw, rset=rset) - content = view.render() - except Exception as exc: - content = 'an error occurred while interpreting this rql directive: %r' % exc - set_classes(options) - return [nodes.raw('', content, format='html')], [] - - -def bookmark_role(role, rawtext, text, lineno, inliner, options={}, content=[]): - """``:bookmark:```` or ``:bookmark:`:``` - - Example: ``:bookmark:`1234:table``` - - Replace the directive with the output of applying the view to the resultset - returned by the query stored in the bookmark. By default, the view is the one - stored in the bookmark, but it can be overridden by the directive as in the - example above. - - "X eid %(userid)s" can be used in the RQL query stored in the Bookmark, for - this query will be executed with the argument {'userid': _cw.user.eid}. - """ - _cw = inliner.document.settings.context._cw - text = text.strip() - try: - if ':' in text: - eid, vid = text.rsplit(u':', 1) - eid = int(eid) - else: - eid, vid = int(text), None - except ValueError: - msg = inliner.reporter.error( - 'EID number must be a positive number; "%s" is invalid.' - % text, line=lineno) - prb = inliner.problematic(rawtext, rawtext, msg) - return [prb], [msg] - try: - bookmark = _cw.entity_from_eid(eid) - except UnknownEid: - msg = inliner.reporter.error('Unknown EID %s.' % text, line=lineno) - prb = inliner.problematic(rawtext, rawtext, msg) - return [prb], [msg] - try: - params = dict(_cw.url_parse_qsl(urlsplit(bookmark.path).query)) - rql = params['rql'] - if vid is None: - vid = params.get('vid') - except (ValueError, KeyError) as exc: - msg = inliner.reporter.error('Could not parse bookmark path %s [%s].' - % (bookmark.path, exc), line=lineno) - prb = inliner.problematic(rawtext, rawtext, msg) - return [prb], [msg] - try: - rset = _cw.execute(rql, {'userid': _cw.user.eid}) - if rset: - if vid is None: - vid = vid_from_rset(_cw, rset, _cw.vreg.schema) - else: - vid = 'noresult' - view = _cw.vreg['views'].select(vid, _cw, rset=rset) - content = view.render() - except Exception as exc: - content = 'An error occurred while interpreting directive bookmark: %r' % exc - set_classes(options) - return [nodes.raw('', content, format='html')], [] - - -def winclude_directive(name, arguments, options, content, lineno, - content_offset, block_text, state, state_machine): - """Include a reST file as part of the content of this reST file. - - same as standard include directive but using config.locate_doc_resource to - get actual file to include. - - Most part of this implementation is copied from `include` directive defined - in `docutils.parsers.rst.directives.misc` - """ - context = state.document.settings.context - cw = context._cw - source = state_machine.input_lines.source( - lineno - state_machine.input_offset - 1) - #source_dir = os.path.dirname(os.path.abspath(source)) - fid = arguments[0] - for lang in chain((cw.lang, cw.vreg.property_value('ui.language')), - cw.vreg.config.available_languages()): - rid = '%s_%s.rst' % (fid, lang) - resourcedir = cw.vreg.config.locate_doc_file(rid) - if resourcedir: - break - else: - severe = state_machine.reporter.severe( - 'Problems with "%s" directive path:\nno resource matching %s.' - % (name, fid), - nodes.literal_block(block_text, block_text), line=lineno) - return [severe] - path = join(resourcedir, rid) - encoding = options.get('encoding', state.document.settings.input_encoding) - try: - state.document.settings.record_dependencies.add(path) - include_file = io.FileInput( - source_path=path, encoding=encoding, - error_handler=state.document.settings.input_encoding_error_handler, - handle_io_errors=None) - except IOError as error: - severe = state_machine.reporter.severe( - 'Problems with "%s" directive path:\n%s: %s.' - % (name, error.__class__.__name__, error), - nodes.literal_block(block_text, block_text), line=lineno) - return [severe] - try: - include_text = include_file.read() - except UnicodeError as error: - severe = state_machine.reporter.severe( - 'Problem with "%s" directive:\n%s: %s' - % (name, error.__class__.__name__, error), - nodes.literal_block(block_text, block_text), line=lineno) - return [severe] - if 'literal' in options: - literal_block = nodes.literal_block(include_text, include_text, - source=path) - literal_block.line = 1 - return literal_block - else: - include_lines = statemachine.string2lines(include_text, - convert_whitespace=1) - state_machine.insert_input(include_lines, path) - return [] - -winclude_directive.arguments = (1, 0, 1) -winclude_directive.options = {'literal': directives.flag, - 'encoding': directives.encoding} - - -class RQLTableDirective(Directive): - """rql-table directive - - Example: - - .. rql-table:: - :vid: mytable - :headers: , , progress - :colvids: 2=progress - - Any X,U,X WHERE X is Project, X url U - - All fields but the RQL string are optionnal. The ``:headers:`` option can - contain empty column names. - """ - - required_arguments = 0 - optional_arguments = 0 - has_content= True - final_argument_whitespace = True - option_spec = {'vid': directives.unchanged, - 'headers': directives.unchanged, - 'colvids': directives.unchanged} - - def run(self): - errid = "rql-table directive" - self.assert_has_content() - if self.arguments: - raise self.warning('%s does not accept arguments' % errid) - rql = ' '.join([l.strip() for l in self.content]) - _cw = self.state.document.settings.context._cw - _cw.ensure_ro_rql(rql) - try: - rset = _cw.execute(rql) - except Exception as exc: - raise self.error("fail to execute RQL query in %s: %r" % - (errid, exc)) - if not rset: - raise self.warning("empty result set") - vid = self.options.get('vid', 'table') - try: - view = _cw.vreg['views'].select(vid, _cw, rset=rset) - except Exception as exc: - raise self.error("fail to select '%s' view in %s: %r" % - (vid, errid, exc)) - headers = None - if 'headers' in self.options: - headers = [h.strip() for h in self.options['headers'].split(',')] - while headers.count(''): - headers[headers.index('')] = None - if len(headers) != len(rset[0]): - raise self.error("the number of 'headers' does not match the " - "number of columns in %s" % errid) - cellvids = None - if 'colvids' in self.options: - cellvids = {} - for f in self.options['colvids'].split(','): - try: - idx, vid = f.strip().split('=') - except ValueError: - raise self.error("malformatted 'colvids' option in %s" % - errid) - cellvids[int(idx.strip())] = vid.strip() - try: - content = view.render(headers=headers, cellvids=cellvids) - except Exception as exc: - raise self.error("Error rendering %s (%s)" % (errid, exc)) - return [nodes.raw('', content, format='html')] - - -try: - from pygments import highlight - from pygments.lexers import get_lexer_by_name - from pygments.formatters.html import HtmlFormatter -except ImportError: - pygments_directive = None -else: - _PYGMENTS_FORMATTER = HtmlFormatter() - - def pygments_directive(name, arguments, options, content, lineno, - content_offset, block_text, state, state_machine): - try: - lexer = get_lexer_by_name(arguments[0]) - except ValueError: - # no lexer found - lexer = get_lexer_by_name('text') - parsed = highlight(u'\n'.join(content), lexer, _PYGMENTS_FORMATTER) - # don't fail if no context set on the sourcecode directive - try: - context = state.document.settings.context - context._cw.add_css('pygments.css') - except AttributeError: - # used outside cubicweb XXX use hasattr instead - pass - return [nodes.raw('', parsed, format='html')] - - pygments_directive.arguments = (1, 0, 1) - pygments_directive.content = 1 - - -class CubicWebReSTParser(Parser): - """The (customized) reStructuredText parser.""" - - def __init__(self): - self.initial_state = 'Body' - self.state_classes = states.state_classes - self.inliner = states.Inliner() - self.statemachine = states.RSTStateMachine( - state_classes=self.state_classes, - initial_state=self.initial_state, - debug=0) - - def parse(self, inputstring, document): - """Parse `inputstring` and populate `document`, a document tree.""" - self.setup_parse(inputstring, document) - inputlines = statemachine.string2lines(inputstring, - convert_whitespace=1) - self.statemachine.run(inputlines, document, inliner=self.inliner) - self.finish_parse() - - -# XXX docutils keep a ref on context, can't find a correct way to remove it -class CWReSTPublisher(Publisher): - def __init__(self, context, settings, **kwargs): - Publisher.__init__(self, **kwargs) - self.set_components('standalone', 'restructuredtext', 'pseudoxml') - self.process_programmatic_settings(None, settings, None) - self.settings.context = context - - -def rest_publish(context, data): - """publish a string formatted as ReStructured Text to HTML - - :type context: a cubicweb application object - - :type data: str - :param data: some ReST text - - :rtype: unicode - :return: - the data formatted as HTML or the original data if an error occurred - """ - req = context._cw - if isinstance(data, text_type): - encoding = 'unicode' - # remove unprintable characters unauthorized in xml - data = data.translate(ESC_UCAR_TABLE) - else: - encoding = req.encoding - # remove unprintable characters unauthorized in xml - data = data.translate(ESC_CAR_TABLE) - settings = {'input_encoding': encoding, 'output_encoding': 'unicode', - 'warning_stream': False, - 'traceback': True, # don't sys.exit - 'stylesheet': None, # don't try to embed stylesheet (may cause - # obscure bug due to docutils computing - # relative path according to the directory - # used *at import time* - # dunno what's the max, severe is 4, and we never want a crash - # (though try/except may be a better option...). May be the - # above traceback option will avoid this? - 'halt_level': 10, - # disable stupid switch to colspan=2 if field name is above a size limit - 'field_name_limit': sys.maxsize, - } - if context: - if hasattr(req, 'url'): - base_url = req.url() - elif hasattr(context, 'absolute_url'): - base_url = context.absolute_url() - else: - base_url = req.base_url() - else: - base_url = None - try: - pub = CWReSTPublisher(context, settings, - parser=CubicWebReSTParser(), - writer=Writer(base_url=base_url), - source_class=io.StringInput, - destination_class=io.StringOutput) - pub.set_source(data) - pub.set_destination() - res = pub.publish(enable_exit_status=None) - # necessary for proper garbage collection, else a ref is kept somewhere in docutils... - del pub.settings.context - return res - except BaseException: - LOGGER.exception('error while publishing ReST text') - if not isinstance(data, text_type): - data = text_type(data, encoding, 'replace') - return xml_escape(req._('error while publishing ReST text') - + '\n\n' + data) - - -_INITIALIZED = False -def cw_rest_init(): - global _INITIALIZED - if _INITIALIZED: - return - _INITIALIZED = True - register_canonical_role('eid', eid_reference_role) - register_canonical_role('rql', rql_role) - register_canonical_role('bookmark', bookmark_role) - directives.register_directive('winclude', winclude_directive) - if pygments_directive is not None: - directives.register_directive('sourcecode', pygments_directive) - directives.register_directive('rql-table', RQLTableDirective)