ext/rest.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """rest publishing functions
       
    19 
       
    20 contains some functions and setup of docutils for cubicweb. Provides the
       
    21 following ReST directives:
       
    22 
       
    23 * `eid`, create link to entity in the repository by their eid
       
    24 
       
    25 * `card`, create link to card entity in the repository by their wikiid
       
    26   (proposing to create it when the refered card doesn't exist yet)
       
    27 
       
    28 * `winclude`, reference to a web documentation file (in wdoc/ directories)
       
    29 
       
    30 * `sourcecode` (if pygments is installed), source code colorization
       
    31 
       
    32 * `rql-table`, create a table from a RQL query
       
    33 
       
    34 """
       
    35 __docformat__ = "restructuredtext en"
       
    36 
       
    37 import sys
       
    38 from itertools import chain
       
    39 from logging import getLogger
       
    40 from os.path import join
       
    41 
       
    42 from six import text_type
       
    43 from six.moves.urllib.parse import urlsplit
       
    44 
       
    45 from docutils import statemachine, nodes, utils, io
       
    46 from docutils.core import Publisher
       
    47 from docutils.parsers.rst import Parser, states, directives, Directive
       
    48 from docutils.parsers.rst.roles import register_canonical_role, set_classes
       
    49 
       
    50 from logilab.mtconverter import ESC_UCAR_TABLE, ESC_CAR_TABLE, xml_escape
       
    51 
       
    52 from cubicweb import UnknownEid
       
    53 from cubicweb.ext.html4zope import Writer
       
    54 
       
    55 from cubicweb.web.views import vid_from_rset  # XXX better not to import c.w.views here...
       
    56 
       
    57 # We provide our own parser as an attempt to get rid of
       
    58 # state machine reinstanciation
       
    59 
       
    60 import re
       
    61 # compile states.Body patterns
       
    62 for k, v in states.Body.patterns.items():
       
    63     if isinstance(v, str):
       
    64         states.Body.patterns[k] = re.compile(v)
       
    65 
       
    66 # register ReStructured Text mimetype / extensions
       
    67 import mimetypes
       
    68 mimetypes.add_type('text/rest', '.rest')
       
    69 mimetypes.add_type('text/rest', '.rst')
       
    70 
       
    71 
       
    72 LOGGER = getLogger('cubicweb.rest')
       
    73 
       
    74 
       
    75 def eid_reference_role(role, rawtext, text, lineno, inliner,
       
    76                        options={}, content=[]):
       
    77     try:
       
    78         try:
       
    79             eid_num, rest = text.split(u':', 1)
       
    80         except ValueError:
       
    81             eid_num, rest = text, '#'+text
       
    82         eid_num = int(eid_num)
       
    83         if eid_num < 0:
       
    84             raise ValueError
       
    85     except ValueError:
       
    86         msg = inliner.reporter.error(
       
    87             'EID number must be a positive number; "%s" is invalid.'
       
    88             % text, line=lineno)
       
    89         prb = inliner.problematic(rawtext, rawtext, msg)
       
    90         return [prb], [msg]
       
    91     # Base URL mainly used by inliner.pep_reference; so this is correct:
       
    92     context = inliner.document.settings.context
       
    93     try:
       
    94         refedentity = context._cw.entity_from_eid(eid_num)
       
    95     except UnknownEid:
       
    96         ref = '#'
       
    97         rest += u' ' + context._cw._('(UNEXISTANT EID)')
       
    98     else:
       
    99         ref = refedentity.absolute_url()
       
   100     set_classes(options)
       
   101     return [nodes.reference(rawtext, utils.unescape(rest), refuri=ref,
       
   102                             **options)], []
       
   103 
       
   104 
       
   105 def rql_role(role, rawtext, text, lineno, inliner, options={}, content=[]):
       
   106     """``:rql:`<rql-expr>``` or ``:rql:`<rql-expr>:<vid>```
       
   107 
       
   108     Example: ``:rql:`Any X,Y WHERE X is CWUser, X login Y:table```
       
   109 
       
   110     Replace the directive with the output of applying the view to the resultset
       
   111     returned by the query.
       
   112 
       
   113     "X eid %(userid)s" can be used in the RQL query for this query will be
       
   114     executed with the argument {'userid': _cw.user.eid}.
       
   115     """
       
   116     _cw = inliner.document.settings.context._cw
       
   117     text = text.strip()
       
   118     if ':' in text:
       
   119         rql, vid = text.rsplit(u':', 1)
       
   120         rql = rql.strip()
       
   121     else:
       
   122         rql, vid = text, None
       
   123     _cw.ensure_ro_rql(rql)
       
   124     try:
       
   125         rset = _cw.execute(rql, {'userid': _cw.user.eid})
       
   126         if rset:
       
   127             if vid is None:
       
   128                 vid = vid_from_rset(_cw, rset, _cw.vreg.schema)
       
   129         else:
       
   130             vid = 'noresult'
       
   131         view = _cw.vreg['views'].select(vid, _cw, rset=rset)
       
   132         content = view.render()
       
   133     except Exception as exc:
       
   134         content = 'an error occurred while interpreting this rql directive: %r' % exc
       
   135     set_classes(options)
       
   136     return [nodes.raw('', content, format='html')], []
       
   137 
       
   138 
       
   139 def bookmark_role(role, rawtext, text, lineno, inliner, options={}, content=[]):
       
   140     """``:bookmark:`<bookmark-eid>``` or ``:bookmark:`<eid>:<vid>```
       
   141 
       
   142     Example: ``:bookmark:`1234:table```
       
   143 
       
   144     Replace the directive with the output of applying the view to the resultset
       
   145     returned by the query stored in the bookmark. By default, the view is the one
       
   146     stored in the bookmark, but it can be overridden by the directive as in the
       
   147     example above.
       
   148 
       
   149     "X eid %(userid)s" can be used in the RQL query stored in the Bookmark, for
       
   150     this query will be executed with the argument {'userid': _cw.user.eid}.
       
   151     """
       
   152     _cw = inliner.document.settings.context._cw
       
   153     text = text.strip()
       
   154     try:
       
   155         if ':' in text:
       
   156             eid, vid = text.rsplit(u':', 1)
       
   157             eid = int(eid)
       
   158         else:
       
   159             eid, vid = int(text), None
       
   160     except ValueError:
       
   161         msg = inliner.reporter.error(
       
   162             'EID number must be a positive number; "%s" is invalid.'
       
   163             % text, line=lineno)
       
   164         prb = inliner.problematic(rawtext, rawtext, msg)
       
   165         return [prb], [msg]
       
   166     try:
       
   167         bookmark = _cw.entity_from_eid(eid)
       
   168     except UnknownEid:
       
   169         msg = inliner.reporter.error('Unknown EID %s.' % text, line=lineno)
       
   170         prb = inliner.problematic(rawtext, rawtext, msg)
       
   171         return [prb], [msg]
       
   172     try:
       
   173         params = dict(_cw.url_parse_qsl(urlsplit(bookmark.path).query))
       
   174         rql = params['rql']
       
   175         if vid is None:
       
   176             vid = params.get('vid')
       
   177     except (ValueError, KeyError) as exc:
       
   178         msg = inliner.reporter.error('Could not parse bookmark path %s [%s].'
       
   179                                      % (bookmark.path, exc), line=lineno)
       
   180         prb = inliner.problematic(rawtext, rawtext, msg)
       
   181         return [prb], [msg]
       
   182     try:
       
   183         rset = _cw.execute(rql, {'userid': _cw.user.eid})
       
   184         if rset:
       
   185             if vid is None:
       
   186                 vid = vid_from_rset(_cw, rset, _cw.vreg.schema)
       
   187         else:
       
   188             vid = 'noresult'
       
   189         view = _cw.vreg['views'].select(vid, _cw, rset=rset)
       
   190         content = view.render()
       
   191     except Exception as exc:
       
   192         content = 'An error occurred while interpreting directive bookmark: %r' % exc
       
   193     set_classes(options)
       
   194     return [nodes.raw('', content, format='html')], []
       
   195 
       
   196 
       
   197 def winclude_directive(name, arguments, options, content, lineno,
       
   198                        content_offset, block_text, state, state_machine):
       
   199     """Include a reST file as part of the content of this reST file.
       
   200 
       
   201     same as standard include directive but using config.locate_doc_resource to
       
   202     get actual file to include.
       
   203 
       
   204     Most part of this implementation is copied from `include` directive defined
       
   205     in `docutils.parsers.rst.directives.misc`
       
   206     """
       
   207     context = state.document.settings.context
       
   208     cw = context._cw
       
   209     source = state_machine.input_lines.source(
       
   210         lineno - state_machine.input_offset - 1)
       
   211     #source_dir = os.path.dirname(os.path.abspath(source))
       
   212     fid = arguments[0]
       
   213     for lang in chain((cw.lang, cw.vreg.property_value('ui.language')),
       
   214                       cw.vreg.config.available_languages()):
       
   215         rid = '%s_%s.rst' % (fid, lang)
       
   216         resourcedir = cw.vreg.config.locate_doc_file(rid)
       
   217         if resourcedir:
       
   218             break
       
   219     else:
       
   220         severe = state_machine.reporter.severe(
       
   221               'Problems with "%s" directive path:\nno resource matching %s.'
       
   222               % (name, fid),
       
   223               nodes.literal_block(block_text, block_text), line=lineno)
       
   224         return [severe]
       
   225     path = join(resourcedir, rid)
       
   226     encoding = options.get('encoding', state.document.settings.input_encoding)
       
   227     try:
       
   228         state.document.settings.record_dependencies.add(path)
       
   229         include_file = io.FileInput(
       
   230             source_path=path, encoding=encoding,
       
   231             error_handler=state.document.settings.input_encoding_error_handler,
       
   232             handle_io_errors=None)
       
   233     except IOError as error:
       
   234         severe = state_machine.reporter.severe(
       
   235               'Problems with "%s" directive path:\n%s: %s.'
       
   236               % (name, error.__class__.__name__, error),
       
   237               nodes.literal_block(block_text, block_text), line=lineno)
       
   238         return [severe]
       
   239     try:
       
   240         include_text = include_file.read()
       
   241     except UnicodeError as error:
       
   242         severe = state_machine.reporter.severe(
       
   243               'Problem with "%s" directive:\n%s: %s'
       
   244               % (name, error.__class__.__name__, error),
       
   245               nodes.literal_block(block_text, block_text), line=lineno)
       
   246         return [severe]
       
   247     if 'literal' in options:
       
   248         literal_block = nodes.literal_block(include_text, include_text,
       
   249                                             source=path)
       
   250         literal_block.line = 1
       
   251         return literal_block
       
   252     else:
       
   253         include_lines = statemachine.string2lines(include_text,
       
   254                                                   convert_whitespace=1)
       
   255         state_machine.insert_input(include_lines, path)
       
   256         return []
       
   257 
       
   258 winclude_directive.arguments = (1, 0, 1)
       
   259 winclude_directive.options = {'literal': directives.flag,
       
   260                               'encoding': directives.encoding}
       
   261 
       
   262 
       
   263 class RQLTableDirective(Directive):
       
   264     """rql-table directive
       
   265 
       
   266     Example:
       
   267 
       
   268         .. rql-table::
       
   269            :vid: mytable
       
   270            :headers: , , progress
       
   271            :colvids: 2=progress
       
   272 
       
   273             Any X,U,X WHERE X is Project, X url U
       
   274 
       
   275     All fields but the RQL string are optionnal. The ``:headers:`` option can
       
   276     contain empty column names.
       
   277     """
       
   278 
       
   279     required_arguments = 0
       
   280     optional_arguments = 0
       
   281     has_content= True
       
   282     final_argument_whitespace = True
       
   283     option_spec = {'vid': directives.unchanged,
       
   284                    'headers': directives.unchanged,
       
   285                    'colvids': directives.unchanged}
       
   286 
       
   287     def run(self):
       
   288         errid = "rql-table directive"
       
   289         self.assert_has_content()
       
   290         if self.arguments:
       
   291             raise self.warning('%s does not accept arguments' % errid)
       
   292         rql = ' '.join([l.strip() for l in self.content])
       
   293         _cw = self.state.document.settings.context._cw
       
   294         _cw.ensure_ro_rql(rql)
       
   295         try:
       
   296             rset = _cw.execute(rql)
       
   297         except Exception as exc:
       
   298             raise self.error("fail to execute RQL query in %s: %r" %
       
   299                              (errid, exc))
       
   300         if not rset:
       
   301             raise self.warning("empty result set")
       
   302         vid = self.options.get('vid', 'table')
       
   303         try:
       
   304             view = _cw.vreg['views'].select(vid, _cw, rset=rset)
       
   305         except Exception as exc:
       
   306             raise self.error("fail to select '%s' view in %s: %r" %
       
   307                              (vid, errid, exc))
       
   308         headers = None
       
   309         if 'headers' in self.options:
       
   310             headers = [h.strip() for h in self.options['headers'].split(',')]
       
   311             while headers.count(''):
       
   312                 headers[headers.index('')] = None
       
   313             if len(headers) != len(rset[0]):
       
   314                 raise self.error("the number of 'headers' does not match the "
       
   315                                  "number of columns in %s" % errid)
       
   316         cellvids = None
       
   317         if 'colvids' in self.options:
       
   318             cellvids = {}
       
   319             for f in self.options['colvids'].split(','):
       
   320                 try:
       
   321                     idx, vid = f.strip().split('=')
       
   322                 except ValueError:
       
   323                     raise self.error("malformatted 'colvids' option in %s" %
       
   324                                      errid)
       
   325                 cellvids[int(idx.strip())] = vid.strip()
       
   326         try:
       
   327             content = view.render(headers=headers, cellvids=cellvids)
       
   328         except Exception as exc:
       
   329             raise self.error("Error rendering %s (%s)" % (errid, exc))
       
   330         return [nodes.raw('', content, format='html')]
       
   331 
       
   332 
       
   333 try:
       
   334     from pygments import highlight
       
   335     from pygments.lexers import get_lexer_by_name
       
   336     from pygments.formatters.html import HtmlFormatter
       
   337 except ImportError:
       
   338     pygments_directive = None
       
   339 else:
       
   340     _PYGMENTS_FORMATTER = HtmlFormatter()
       
   341 
       
   342     def pygments_directive(name, arguments, options, content, lineno,
       
   343                            content_offset, block_text, state, state_machine):
       
   344         try:
       
   345             lexer = get_lexer_by_name(arguments[0])
       
   346         except ValueError:
       
   347             # no lexer found
       
   348             lexer = get_lexer_by_name('text')
       
   349         parsed = highlight(u'\n'.join(content), lexer, _PYGMENTS_FORMATTER)
       
   350         # don't fail if no context set on the sourcecode directive
       
   351         try:
       
   352             context = state.document.settings.context
       
   353             context._cw.add_css('pygments.css')
       
   354         except AttributeError:
       
   355             # used outside cubicweb XXX use hasattr instead
       
   356             pass
       
   357         return [nodes.raw('', parsed, format='html')]
       
   358 
       
   359     pygments_directive.arguments = (1, 0, 1)
       
   360     pygments_directive.content = 1
       
   361 
       
   362 
       
   363 class CubicWebReSTParser(Parser):
       
   364     """The (customized) reStructuredText parser."""
       
   365 
       
   366     def __init__(self):
       
   367         self.initial_state = 'Body'
       
   368         self.state_classes = states.state_classes
       
   369         self.inliner = states.Inliner()
       
   370         self.statemachine = states.RSTStateMachine(
       
   371               state_classes=self.state_classes,
       
   372               initial_state=self.initial_state,
       
   373               debug=0)
       
   374 
       
   375     def parse(self, inputstring, document):
       
   376         """Parse `inputstring` and populate `document`, a document tree."""
       
   377         self.setup_parse(inputstring, document)
       
   378         inputlines = statemachine.string2lines(inputstring,
       
   379                                                convert_whitespace=1)
       
   380         self.statemachine.run(inputlines, document, inliner=self.inliner)
       
   381         self.finish_parse()
       
   382 
       
   383 
       
   384 # XXX docutils keep a ref on context, can't find a correct way to remove it
       
   385 class CWReSTPublisher(Publisher):
       
   386     def __init__(self, context, settings, **kwargs):
       
   387         Publisher.__init__(self, **kwargs)
       
   388         self.set_components('standalone', 'restructuredtext', 'pseudoxml')
       
   389         self.process_programmatic_settings(None, settings, None)
       
   390         self.settings.context = context
       
   391 
       
   392 
       
   393 def rest_publish(context, data):
       
   394     """publish a string formatted as ReStructured Text to HTML
       
   395 
       
   396     :type context: a cubicweb application object
       
   397 
       
   398     :type data: str
       
   399     :param data: some ReST text
       
   400 
       
   401     :rtype: unicode
       
   402     :return:
       
   403       the data formatted as HTML or the original data if an error occurred
       
   404     """
       
   405     req = context._cw
       
   406     if isinstance(data, text_type):
       
   407         encoding = 'unicode'
       
   408         # remove unprintable characters unauthorized in xml
       
   409         data = data.translate(ESC_UCAR_TABLE)
       
   410     else:
       
   411         encoding = req.encoding
       
   412         # remove unprintable characters unauthorized in xml
       
   413         data = data.translate(ESC_CAR_TABLE)
       
   414     settings = {'input_encoding': encoding, 'output_encoding': 'unicode',
       
   415                 'warning_stream': False,
       
   416                 'traceback': True, # don't sys.exit
       
   417                 'stylesheet': None, # don't try to embed stylesheet (may cause
       
   418                                     # obscure bug due to docutils computing
       
   419                                     # relative path according to the directory
       
   420                                     # used *at import time*
       
   421                 # dunno what's the max, severe is 4, and we never want a crash
       
   422                 # (though try/except may be a better option...). May be the
       
   423                 # above traceback option will avoid this?
       
   424                 'halt_level': 10,
       
   425                 # disable stupid switch to colspan=2 if field name is above a size limit
       
   426                 'field_name_limit': sys.maxsize,
       
   427                 }
       
   428     if context:
       
   429         if hasattr(req, 'url'):
       
   430             base_url = req.url()
       
   431         elif hasattr(context, 'absolute_url'):
       
   432             base_url = context.absolute_url()
       
   433         else:
       
   434             base_url = req.base_url()
       
   435     else:
       
   436         base_url = None
       
   437     try:
       
   438         pub = CWReSTPublisher(context, settings,
       
   439                               parser=CubicWebReSTParser(),
       
   440                               writer=Writer(base_url=base_url),
       
   441                               source_class=io.StringInput,
       
   442                               destination_class=io.StringOutput)
       
   443         pub.set_source(data)
       
   444         pub.set_destination()
       
   445         res = pub.publish(enable_exit_status=None)
       
   446         # necessary for proper garbage collection, else a ref is kept somewhere in docutils...
       
   447         del pub.settings.context
       
   448         return res
       
   449     except BaseException:
       
   450         LOGGER.exception('error while publishing ReST text')
       
   451         if not isinstance(data, text_type):
       
   452             data = text_type(data, encoding, 'replace')
       
   453         return xml_escape(req._('error while publishing ReST text')
       
   454                            + '\n\n' + data)
       
   455 
       
   456 
       
   457 _INITIALIZED = False
       
   458 def cw_rest_init():
       
   459     global _INITIALIZED
       
   460     if _INITIALIZED:
       
   461         return
       
   462     _INITIALIZED = True
       
   463     register_canonical_role('eid', eid_reference_role)
       
   464     register_canonical_role('rql', rql_role)
       
   465     register_canonical_role('bookmark', bookmark_role)
       
   466     directives.register_directive('winclude', winclude_directive)
       
   467     if pygments_directive is not None:
       
   468         directives.register_directive('sourcecode', pygments_directive)
       
   469     directives.register_directive('rql-table', RQLTableDirective)