source: tracformsplugin/trunk/tracforms/macros.py

Last change on this file was 16958, checked in by Ryan J Ollos, 6 years ago

TracForms 0.5dev: Log exception from macro

Refs #13319.

File size: 24.2 KB
Line 
1# -*- coding: utf-8 -*-
2
3import StringIO
4import fnmatch
5import re
6import time
7import traceback
8from math import modf
9
10from api import FormDBUser, PasswordStoreUser, _
11from compat import json
12from errors import FormError, FormTooManyValuesError
13from formdb import format_author
14from trac.util.datefmt import format_datetime
15from trac.util.text import exception_to_unicode, to_unicode
16from trac.wiki.formatter import Formatter
17from trac.wiki.macros import WikiMacroBase
18from util import resource_from_page, xml_escape
19
20argRE = re.compile('\s*(".*?"|\'.*?\'|\S+)\s*')
21argstrRE = re.compile('%(.*?)%')
22tfRE = re.compile('\['
23                  'tf(?:\.([a-zA-Z_]+?))?'
24                  '(?::([^\[\]]*?))?'
25                  '\]')
26chartrans = {
27    u'"': u'"',
28    u'\t': u'	',
29    u'\n': u'
',
30    u'\r': u'
',
31}
32kwtrans = {
33    'class': '_class',
34    'id': '_id',
35    'mode': '_mode',
36    'title': '_title',
37}
38
39
40class TracFormMacro(WikiMacroBase, FormDBUser, PasswordStoreUser):
41    """Docs for TracForms macro..."""
42
43    def expand_macro(self, formatter, name, content, args=None):
44        processor = FormProcessor(self, formatter, name, content)
45        return processor.execute()
46
47
48class FormProcessor(object):
49    """Core parser and processor logic for TracForms markup."""
50
51    # Default state (beyond what is set in expand_macro).
52    showErrors = True
53    page = None
54    subcontext = None
55    default_op = 'checkbox'
56    allow_submit = False
57    keep_history = False
58    track_fields = True
59    submit_label = None
60    submit_name = None
61    form_class = None
62    form_cssid = None
63    form_name = None
64    sorted_env = None
65
66    def __init__(self, macro, formatter, name, args):
67        self.macro = macro
68        self.formatter = formatter
69        self.args = args
70        self.name = name
71        self.env = None
72        self.context = None
73        self.subform = None
74        self.updated = None
75        self.form_realm = None
76        self.form_resource_id = None
77        self.form_subcontext = None
78        self.form_updater = None
79        self.form_updated_on = None
80        self.form_keep_history = None
81        self.form_track_fields = None
82        self.form_id = None
83
84    def execute(self):
85        formatter = self.formatter
86        args = self.args
87
88        # Look in the formatter req object for evidence we are executing.
89        self.subform = getattr(formatter.req, type(self).__name__, False)
90        if not self.subform:
91            setattr(formatter.req, type(self).__name__, True)
92        self.env = dict(getattr(formatter.req, 'tracform_env', ()))
93
94        # Setup preliminary context
95        self.page = formatter.req.path_info
96        if self.page == '/wiki' or self.page == '/wiki/':
97            self.page = '/wiki/WikiStart'
98        realm, resource_id = resource_from_page(formatter.env, self.page)
99
100        # Remove leading comments and process commands.
101        textlines = []
102        errors = []
103        srciter = iter(args.split('\n'))
104        for line in srciter:
105            if line[:1] == '#':
106                # Comment or operation.
107                line = line.strip()[1:]
108                if line[:1] == '!':
109                    # It's a command, parse the arguments...
110                    kw = {}
111                    args = list(self.getargs(line[1:], kw))
112                    if len(args):
113                        cmd = args.pop(0)
114                        fn = getattr(self, 'cmd_' + cmd.lower(), None)
115                        if fn is None:
116                            errors.append(
117                                _("ERROR: No TracForms command '%s'" % cmd))
118                        else:
119                            try:
120                                fn(*args, **kw)
121                            except FormError, e:
122                                self.env.log.warning(e)
123                                errors.append(to_unicode(e))
124                            except Exception, e:
125                                message = \
126                                    exception_to_unicode(e, traceback=True) \
127                                    .encode('utf-8')
128                                self.env.log.error(message)
129                                errors.append(message)
130            else:
131                if self.showErrors:
132                    textlines.extend(errors)
133                textlines.append(line)
134                textlines.extend(srciter)
135
136        # Determine our destination context and load the current state.
137        self.context = tuple([realm, resource_id,
138                              self.subcontext is not None and
139                              self.subcontext or ''])
140        state = self.macro.get_tracform_state(self.context)
141        self.formatter.env.log.debug(
142            'TracForms state = ' + (state is not None and state or ''))
143        for name, value in json.loads(state or '{}').iteritems():
144            self.env[name] = _xml_escape(value)
145            self.formatter.env.log.debug(
146                name + ' = ' + to_unicode(value))
147            if self.subcontext is not None:
148                self.env[self.subcontext + ':' + name] = value
149        self.sorted_env = None
150        (self.form_id, self.form_realm, self.form_resource_id,
151         self.form_subcontext, self.form_updater, self.form_updated_on,
152         self.form_keep_history, self.form_track_fields) = \
153            self.macro.get_tracform_meta(self.context)
154        self.form_id = self.form_id is not None and int(self.form_id) or None
155
156        # Wiki-ize the text, this will allow other macros to execute after
157        # which we can do our own replacements within whatever formatted
158        # junk is left over.
159        text = self.wiki('\n'.join(textlines))
160
161        # Keep replacing tf: sections until there are no more
162        # replacements.  On each substitution, wiki() is called on the
163        # result.
164        self.updated = True
165        while self.updated:
166            self.updated = False
167            text = tfRE.sub(self.process, text)
168            self.formatter.env.log.debug('TRACFORM_PARSER_OUT: ' + text)
169        setattr(formatter.req, type(self).__name__, None)
170
171        # Handle any backslash-escaped content.
172        text = text.encode('utf-8').decode('string_escape')
173        self.formatter.env.log.debug('TracForms parsing finished')
174        if 'FORM_EDIT_VAL' in formatter.perm:
175            self.allow_submit = True
176        return ''.join(self.build_form(text))
177
178    def build_form(self, text):
179        if not self.subform:
180            form_class = self.form_class
181            form_cssid = self.form_cssid or self.subcontext
182            form_name = self.form_name or self.subcontext
183            dest = self.formatter.req.href('/form/update')
184            yield ('<FORM class="printableform" ' +
185                   'method="POST" action=%r' % str(dest) +
186                   (form_cssid is not None
187                    and ' id="%s"' % form_cssid
188                    or '') +
189                   (form_name is not None
190                    and ' name="%s"' % form_name
191                    or '') +
192                   (form_class is not None
193                    and ' class="%s"' % form_class
194                    or '') +
195                   '>')
196            yield text
197            if self.allow_submit:
198                # TRANSLATOR: Default submit button label
199                submit_label = self.submit_label or _("Update Form")
200                yield '<INPUT class="buttons" type="submit"'
201                if not self.macro.save_tracform_allowed(self.context[0],
202                                                        self.context[1]):
203                    yield ' disabled="disabled"'
204                if self.submit_name:
205                    yield ' name=%r' % str(self.submit_name)
206                yield ' value=%r' % xml_escape(submit_label)
207                yield '>'
208            if self.keep_history:
209                yield '<INPUT type="hidden"'
210                yield ' name="__keep_history__" value="yes">'
211            if self.track_fields:
212                yield '<INPUT type="hidden"'
213                yield ' name="__track_fields__" value="yes">'
214            if self.form_updated_on is not None:
215                yield '<INPUT type="hidden" name="__basever__"'
216                yield ' value="' + str(self.form_updated_on) + '">'
217            context = json.dumps(
218                self.context, separators=(',', ':'))
219            yield '<INPUT type="hidden" ' + \
220                  'name="__context__" value=%r>' % context
221            backpath = self.formatter.req.href(self.formatter.req.path_info)
222            yield '<INPUT type="hidden" ' \
223                  'name="__backpath__" value=%s>' % str(backpath)
224            form_token = self.formatter.req.form_token
225            yield '<INPUT type="hidden" ' \
226                  'name="__FORM_TOKEN" value=%r>' % str(form_token)
227            yield '</FORM>'
228        else:
229            yield text
230
231    def getargs(self, argstr, kw=None):
232        if kw is None:
233            kw = {}
234        for arg in argRE.findall(argstrRE.sub(self.argsub, argstr) or ''):
235            if arg[:1] in '"\'':
236                arg = arg[1:-1]
237            if arg[:1] == '-':
238                try:
239                    arg = (str(float(arg)))
240                    yield arg
241                except ValueError:
242                    name, value = (arg[1:].split('=', 1) + [True])[:2]
243                    kw[str(kwtrans.get(name, name))] = value
244                    pass
245            else:
246                yield arg
247
248    def argsub(self, match, NOT_FOUND=KeyError, aslist=False):
249        if isinstance(match, basestring):
250            name = match
251        else:
252            name = match.group(1)
253        if name[:1] in '"\'':
254            quote = True
255            name = name[1:-1]
256        else:
257            quote = False
258        if '*' in name or '?' in name or '[' in name:
259            value = []
260            keys = self.get_sorted_env()
261            for key in fnmatch.filter(keys, name):
262                obj = self.env[key]
263                if isinstance(obj, (tuple, list)):
264                    value.extend(obj)
265                else:
266                    value.append(obj)
267            if not value and self.page:
268                for key in fnmatch.filter(keys, self.page + ':' + name):
269                    obj = self.env[key]
270                    if isinstance(obj, (tuple, list)):
271                        value.extend(obj)
272                    else:
273                        value.append(obj)
274            if not value and self.subcontext:
275                for key in fnmatch.filter(keys, self.subcontext + ':' + name):
276                    obj = self.env[key]
277                    if isinstance(obj, (tuple, list)):
278                        value.extend(obj)
279                    else:
280                        value.append(obj)
281            if not value:
282                for key in fnmatch.filter(keys, name):
283                    obj = self.env[key]
284                    if isinstance(obj, (tuple, list)):
285                        value.extend(obj)
286                    else:
287                        value.append(obj)
288        else:
289            value = self.env.get(name, NOT_FOUND)
290            if self.page is not None and value is NOT_FOUND:
291                value = self.env.get(self.page + ':' + name, NOT_FOUND)
292            if self.subcontext is not None and value is NOT_FOUND:
293                value = self.env.get(self.subcontext + ':' + name, NOT_FOUND)
294            if value is NOT_FOUND:
295                value = self.env.get(name, NOT_FOUND)
296            if value is NOT_FOUND:
297                fn = getattr(self, 'env:' + name.lower(), None)
298                if fn is not None:
299                    value = fn()
300                else:
301                    value = ''
302        if aslist:
303            if isinstance(value, (list, tuple)):
304                return tuple(value)
305            else:
306                return value,
307        else:
308            if isinstance(value, (list, tuple)):
309                return ' '.join(
310                    quote and repr(str(item)) or str(item) for item in value)
311            else:
312                value = xml_escape(value)
313                if quote:
314                    value = repr(value)
315                return value
316
317    def get_sorted_env(self):
318        if self.sorted_env is None:
319            self.sorted_env = sorted(self.env)
320        return self.sorted_env
321
322    def env_user(self):
323        return self.req.authname
324
325    def env_now(self):
326        return time.strftime(time.localtime(time.time()))
327
328    def cmd_errors(self, show):
329        self.showErrors = show.upper() in ('SHOW', 'TRUE', 'YES')
330
331    def cmd_page(self, page):
332        if page.upper() in ('NONE', 'DEFAULT', 'CURRENT'):
333            self.page = None
334        else:
335            self.page = page
336
337    def cmd_subcontext(self, context):
338        if context.lower() == 'none':
339            self.subcontext = None
340        else:
341            self.subcontext = str(context)
342
343    def cmd_load(self, subcontext, page=None):
344        if page is None:
345            page = self.page
346        context = page + ':' + subcontext
347        state = self.macro.get_tracform_state(context)
348        for name, value in json.loads(state or '{}').iteritems():
349            self.env[context + ':' + name] = value
350            if self.subcontext is not None:
351                self.env[self.subcontext + ':' + name] = value
352
353    def cmd_class(self, value):
354        self.form_class = value
355
356    def cmd_id(self, value):
357        self.form_cssid = value
358
359    def cmd_name(self, value):
360        self.form_name = value
361
362    def cmd_default(self, default):
363        self.default_op = default
364
365    def cmd_track_fields(self, track='yes'):
366        self.track_fields = track.lower() == 'yes'
367
368    def cmd_keep_history(self, track='yes'):
369        self.keep_history = track.lower() == 'yes'
370
371    def cmd_submit_label(self, label):
372        self.submit_label = label
373
374    def cmd_submit_name(self, name):
375        self.submit_name = name
376
377    def cmd_setenv(self, name, value):
378        self.env[name] = value
379        self.sorted_env = None
380
381    def cmd_setlist(self, name, *values):
382        self.env[name] = tuple(values)
383        self.sorted_env = None
384
385    def cmd_operation(self, _name, _op, *_args, **_kw):
386        if _op in ('is', 'as'):
387            _op, _args = _args[0], _args[1:]
388        op = getattr(self, 'op_' + _op, None)
389        if op is None:
390            raise FormTooManyValuesError(str(_name))
391
392        def partial(*_newargs, **_newkw):
393            if _kw or _newkw:
394                kw = dict(_kw)
395                kw.update(_newkw)
396            else:
397                kw = {}
398            return op(*(_newargs + _args), **kw)
399
400        self.env['op:' + _name] = partial
401
402    def wiki(self, text):
403        out = StringIO.StringIO()
404        Formatter(self.formatter.env, self.formatter.context).format(text,
405                                                                     out)
406        return out.getvalue()
407
408    def process(self, m):
409        self.updated = True
410        op, argstr = m.groups()
411        op = op or self.default_op
412        self.formatter.env.log.debug('Converting TracForms op: ' + str(op))
413        kw = {}
414        args = tuple(self.getargs(argstr, kw))
415        fn = self.env.get('op:' + op.lower())
416        if fn is None:
417            fn = getattr(self, 'op_' + op.lower(), None)
418        if fn is None:
419            raise FormTooManyValuesError(str(op))
420        else:
421            try:
422                if op[:5] == 'wikiop_':
423                    self.formatter.env.log.debug(
424                        'TracForms wiki value: ' + self.wiki(str(fn(*args))))
425                    return self.wiki(str(fn(*args)))
426                else:
427                    self.formatter.env.log.debug(
428                        'TracForms value: ' + to_unicode(fn(*args, **kw)))
429                    return to_unicode(fn(*args, **kw))
430            except FormError, e:
431                return '<PRE>' + str(e) + '</PRE>'
432            except Exception:
433                return '<PRE>' + traceback.format_exc() + '</PRE>'
434
435    def op_test(self, *args):
436        return repr(args)
437
438    def wikiop_value(self, field):
439        return 'VALUE=' + field
440
441    def get_field(self, name, default=None, make_single=True):
442        current = self.env.get(name, default)
443        if make_single and isinstance(current, (tuple, list)):
444            if len(current) == 0:
445                current = default
446            elif len(current) == 1:
447                current = current[0]
448            else:
449                raise FormTooManyValuesError(str(name))
450        return current
451
452    def op_input(self, field, content=None, size=None, maxlen=None,
453                 _title=None, _id=None, _class=None, _mode='rw'):
454        current = self.get_field(field)
455        if current is not None:
456            content = current
457        readonly = False
458        if _mode == 'ro' or (_mode == 'once' and current is not None):
459            readonly = True
460        return ((_mode != 'rd' and "<INPUT name='%s'" % field or "<INPUT ") +
461                (size is not None and ' size="%s"' % size or '') +
462                (maxlen is not None and ' maxlength="%s"' % maxlen or '') +
463                (_id is not None and ' id="%s"' % _id or '') +
464                (_class is not None and ' class="%s"' % _class or '') +
465                (_title is not None and ' title="%s"' % _title or '') +
466                (readonly is True and ' readonly="readonly"' or '') +
467                (content is not None and (' value="%s"'
468                                          % xml_escape(content)) or '') +
469                '>')
470
471    def op_checkbox(self, field, value=None, _title=None, _id=None,
472                    _class=None):
473        current = self.get_field(field)
474        if value is not None:
475            checked = value == current
476        else:
477            checked = bool(current)
478        return ("<INPUT type='checkbox' name='%s'" % field +
479                (_id is not None and ' id="%s"' % _id or '') +
480                (_class is not None and ' class="%s"' % _class or '') +
481                (_title is not None and ' title="%s"' % _title or '') +
482                (value and (' value="' + value + '"') or '') +
483                (checked and ' checked' or '') +
484                '>')
485
486    def op_radio(self, field, value, _title=None, _id=None, _class=None):
487        current = self.get_field(field)
488        return ("<INPUT type='radio' name='%s'" % field +
489                (_id is not None and ' id="%s"' % _id or '') +
490                (_class is not None and ' class="%s"' % _class or '') +
491                (_title is not None and ' title="%s"' % _title or '') +
492                " value='%s'" % value +
493                (current == value and ' checked' or '') +
494                '>')
495
496    def op_select(self, field, *values, **kw):
497        _id = kw.pop('_id', None)
498        _class = kw.pop('_class', None)
499        _title = kw.pop('_title', None)
500        current = self.get_field(field)
501        result = ["<SELECT name='%s'" % field +
502                  (_id is not None and ' id="%s"' % _id or '') +
503                  (_class is not None and ' class="%s"' % _class or '') +
504                  (_title is not None and ' title="%s"' % _title or '') +
505                  '>']
506        for value in values:
507            value, label = (value.split('//', 1) + [value])[:2]
508            result += ("<OPTION value='%s'" % value.strip() +
509                       (current == value and ' selected' or '') +
510                       '>' + label.strip() + '</OPTION>')
511        result.append("</SELECT>")
512        return ''.join(result)
513
514    def op_textarea(self, field, content='', cols=None, rows=None,
515                    _title=None, _id=None, _class=None, _mode='rw'):
516        current = self.get_field(field)
517        if current is not None:
518            content = current
519        readonly = False
520        if _mode == 'ro' or (_mode == 'once' and current is not None):
521            readonly = True
522        return ((_mode != 'rd' and "<TEXTAREA name='%s'" % field or
523                 "<TEXTAREA ") +
524                (cols is not None and ' cols="%s"' % cols or '') +
525                (rows is not None and ' rows="%s"' % rows or '') +
526                (_id is not None and ' id="%s"' % _id or '') +
527                (_class is not None and ' class="%s"' % _class or '') +
528                (_title is not None and ' title="%s"' % _title or '') +
529                (_mode == 'rd' and ' disabled="disabled="' or '') +
530                (readonly is True and ' readonly="readonly"' or '') +
531                '>' + xml_escape(content) + '</TEXTAREA>')
532
533    def op_context(self):
534        return str(self.context)
535
536    def op_who(self, field):
537        # TRANSLATOR: Default updater name
538        who = self.macro.get_tracform_fieldinfo(
539            self.form_id is not None and self.form_id or self.context,
540            field)[0] or _("unknown")
541        return format_author(self.formatter.env, self.formatter.req, who)
542
543    def op_when(self, field, format='%m/%d/%Y %H:%M:%S'):
544        when = self.macro.get_tracform_fieldinfo(
545            self.form_id is not None and self.form_id or
546            self.context, field)[1]
547        return (when is not None and format_datetime(
548            when, format=str(format)) or _("unknown"))
549
550    def op_id(self):
551        return id(self)
552
553    def op_subform(self):
554        return self.subform
555
556    def op_form_id(self):
557        return self.form_id
558
559    def op_form_context(self):
560        return self.form_context
561
562    def op_form_updater(self):
563        return format_author(self.formatter.env, self.formatter.req,
564                             self.form_updater)
565
566    def op_form_updated_on(self, format='%m/%d/%Y %H:%M:%S'):
567        return time.strftime(format, time.localtime(self.form_updated_on))
568
569    def op_sum(self, *values):
570        """Full precision summation using multiple floats for intermediate
571           values
572        """
573        # msum() from http://code.activestate.com/recipes/393090/ (r5)
574        # Depends on IEEE-754 arithmetic guarantees.
575        partials = []  # sorted, non-overlapping partial sums
576        for x in values:
577            x = float(x)
578            i = 0
579            for y in partials:
580                if abs(x) < abs(y):
581                    x, y = y, x
582                hi = x + y
583                lo = y - (hi - x)
584                if lo:
585                    partials[i] = lo
586                    i += 1
587                x = hi
588            partials[i:] = [x]
589        result = sum(partials, 0.0)
590        # finally reduce display precision to integer, if possible
591        if modf(result)[0] == 0:
592            return str(int(result))
593        return str(result)
594
595    def op_sumif(self, check, *values):
596        return self.op_sum(*self.filter(check, values))
597
598    def op_count(self, *values):
599        return str(len(values))
600
601    def op_countif(self, check, *values):
602        return self.op_count(*self.filter(check, values))
603
604    def op_filter(self, check, *values):
605        return ' '.join(str(item) for item in self.filter(check, values))
606
607    def filter(self, check, values):
608        if check[:1] == '/' and check[-1:] == '/':
609            return re.findall(check[1: -1], values)
610        elif '*' in check or '?' in check or '[' in check:
611            return fnmatch.filter(values, check)
612        else:
613            return [i for i in values if check == i]
614
615    def op_sumprod(self, *values, **kw):
616        stride = int(kw.pop('stride', 2))
617        total = 0
618        irange = range(stride)
619        for index in xrange(0, len(values), stride):
620            row = 1.0
621            for inner in irange:
622                row *= float(values[inner + index])
623            total += row
624        return str(total)
625
626    def op_int(self, *values):
627        return ' '.join(str(int(float(value))) for value in values)
628
629    def op_value(self, *names):
630        return ' '.join(self.argsub(name) for name in names)
631
632    def op_quote(self, *names):
633        return ' '.join(repr(self.argsub(name)) for name in names)
634
635    def op_zip(self, *names):
636        zipped = zip(*(self.argsub(name, aslist=True) for name in names))
637        return ' '.join(' '.join(str(item) for item in level)
638                        for level in zipped)
639
640    def op_env_debug(self, pattern='*'):
641        result = []
642        for key in fnmatch.filter(self.get_sorted_env(), pattern):
643            result.append('%s = %s<BR>' % (key, self.env[key]))
644        return ''.join(result)
645
646
647def _xml_escape(text):
648    """Escape literal '&' first to prevent evaluation of valid HTML escape
649    sequences from user input.
650    """
651    # DEVEL: Research how to rework more elegant using a custom codec.
652    text = text.replace(u'&', u'&amp;')
653    for k in chartrans.keys():
654        text = text.replace(k, chartrans[k])
655    return text
Note: See TracBrowser for help on using the repository browser.