blob: f0826aff6a784e3534c235d86500b3657ee47da5 [file] [log] [blame]
Chris Craikb2cbf152015-07-28 16:26:29 -07001"""
2A small templating language
3
4This implements a small templating language for use internally in
5Paste and Paste Script. This language implements if/elif/else,
6for/continue/break, expressions, and blocks of Python code. The
7syntax is::
8
9 {{any expression (function calls etc)}}
10 {{any expression | filter}}
11 {{for x in y}}...{{endfor}}
12 {{if x}}x{{elif y}}y{{else}}z{{endif}}
13 {{py:x=1}}
14 {{py:
15 def foo(bar):
16 return 'baz'
17 }}
18 {{default var = default_value}}
19 {{# comment}}
20
21You use this with the ``Template`` class or the ``sub`` shortcut.
22The ``Template`` class takes the template string and the name of
23the template (for errors) and a default namespace. Then (like
24``string.Template``) you can call the ``tmpl.substitute(**kw)``
25method to make a substitution (or ``tmpl.substitute(a_dict)``).
26
27``sub(content, **kw)`` substitutes the template immediately. You
28can use ``__name='tmpl.html'`` to set the name of the template.
29
30If there are syntax errors ``TemplateError`` will be raised.
31"""
32
33import re
34import six
35import sys
36import cgi
37from six.moves.urllib.parse import quote
38from paste.util.looper import looper
39
40__all__ = ['TemplateError', 'Template', 'sub', 'HTMLTemplate',
41 'sub_html', 'html', 'bunch']
42
43token_re = re.compile(r'\{\{|\}\}')
44in_re = re.compile(r'\s+in\s+')
45var_re = re.compile(r'^[a-z_][a-z0-9_]*$', re.I)
46
47class TemplateError(Exception):
48 """Exception raised while parsing a template
49 """
50
51 def __init__(self, message, position, name=None):
52 self.message = message
53 self.position = position
54 self.name = name
55
56 def __str__(self):
57 msg = '%s at line %s column %s' % (
58 self.message, self.position[0], self.position[1])
59 if self.name:
60 msg += ' in %s' % self.name
61 return msg
62
63class _TemplateContinue(Exception):
64 pass
65
66class _TemplateBreak(Exception):
67 pass
68
69class Template(object):
70
71 default_namespace = {
72 'start_braces': '{{',
73 'end_braces': '}}',
74 'looper': looper,
75 }
76
77 default_encoding = 'utf8'
78
79 def __init__(self, content, name=None, namespace=None):
80 self.content = content
81 self._unicode = isinstance(content, six.text_type)
82 self.name = name
83 self._parsed = parse(content, name=name)
84 if namespace is None:
85 namespace = {}
86 self.namespace = namespace
87
88 def from_filename(cls, filename, namespace=None, encoding=None):
89 f = open(filename, 'rb')
90 c = f.read()
91 f.close()
92 if encoding:
93 c = c.decode(encoding)
94 return cls(content=c, name=filename, namespace=namespace)
95
96 from_filename = classmethod(from_filename)
97
98 def __repr__(self):
99 return '<%s %s name=%r>' % (
100 self.__class__.__name__,
101 hex(id(self))[2:], self.name)
102
103 def substitute(self, *args, **kw):
104 if args:
105 if kw:
106 raise TypeError(
107 "You can only give positional *or* keyword arguments")
108 if len(args) > 1:
109 raise TypeError(
110 "You can only give on positional argument")
111 kw = args[0]
112 ns = self.default_namespace.copy()
113 ns.update(self.namespace)
114 ns.update(kw)
115 result = self._interpret(ns)
116 return result
117
118 def _interpret(self, ns):
119 __traceback_hide__ = True
120 parts = []
121 self._interpret_codes(self._parsed, ns, out=parts)
122 return ''.join(parts)
123
124 def _interpret_codes(self, codes, ns, out):
125 __traceback_hide__ = True
126 for item in codes:
127 if isinstance(item, six.string_types):
128 out.append(item)
129 else:
130 self._interpret_code(item, ns, out)
131
132 def _interpret_code(self, code, ns, out):
133 __traceback_hide__ = True
134 name, pos = code[0], code[1]
135 if name == 'py':
136 self._exec(code[2], ns, pos)
137 elif name == 'continue':
138 raise _TemplateContinue()
139 elif name == 'break':
140 raise _TemplateBreak()
141 elif name == 'for':
142 vars, expr, content = code[2], code[3], code[4]
143 expr = self._eval(expr, ns, pos)
144 self._interpret_for(vars, expr, content, ns, out)
145 elif name == 'cond':
146 parts = code[2:]
147 self._interpret_if(parts, ns, out)
148 elif name == 'expr':
149 parts = code[2].split('|')
150 base = self._eval(parts[0], ns, pos)
151 for part in parts[1:]:
152 func = self._eval(part, ns, pos)
153 base = func(base)
154 out.append(self._repr(base, pos))
155 elif name == 'default':
156 var, expr = code[2], code[3]
157 if var not in ns:
158 result = self._eval(expr, ns, pos)
159 ns[var] = result
160 elif name == 'comment':
161 return
162 else:
163 assert 0, "Unknown code: %r" % name
164
165 def _interpret_for(self, vars, expr, content, ns, out):
166 __traceback_hide__ = True
167 for item in expr:
168 if len(vars) == 1:
169 ns[vars[0]] = item
170 else:
171 if len(vars) != len(item):
172 raise ValueError(
173 'Need %i items to unpack (got %i items)'
174 % (len(vars), len(item)))
175 for name, value in zip(vars, item):
176 ns[name] = value
177 try:
178 self._interpret_codes(content, ns, out)
179 except _TemplateContinue:
180 continue
181 except _TemplateBreak:
182 break
183
184 def _interpret_if(self, parts, ns, out):
185 __traceback_hide__ = True
186 # @@: if/else/else gets through
187 for part in parts:
188 assert not isinstance(part, six.string_types)
189 name, pos = part[0], part[1]
190 if name == 'else':
191 result = True
192 else:
193 result = self._eval(part[2], ns, pos)
194 if result:
195 self._interpret_codes(part[3], ns, out)
196 break
197
198 def _eval(self, code, ns, pos):
199 __traceback_hide__ = True
200 try:
201 value = eval(code, ns)
202 return value
203 except:
204 exc_info = sys.exc_info()
205 e = exc_info[1]
206 if getattr(e, 'args'):
207 arg0 = e.args[0]
208 else:
209 arg0 = str(e)
210 e.args = (self._add_line_info(arg0, pos),)
211 six.reraise(exc_info[0], e, exc_info[2])
212
213 def _exec(self, code, ns, pos):
214 __traceback_hide__ = True
215 try:
216 six.exec_(code, ns)
217 except:
218 exc_info = sys.exc_info()
219 e = exc_info[1]
220 e.args = (self._add_line_info(e.args[0], pos),)
221 six.reraise(exc_info[0], e, exc_info[2])
222
223 def _repr(self, value, pos):
224 __traceback_hide__ = True
225 try:
226 if value is None:
227 return ''
228 if self._unicode:
229 try:
230 value = six.text_type(value)
231 except UnicodeDecodeError:
232 value = str(value)
233 else:
234 value = str(value)
235 except:
236 exc_info = sys.exc_info()
237 e = exc_info[1]
238 e.args = (self._add_line_info(e.args[0], pos),)
239 six.reraise(exc_info[0], e, exc_info[2])
240 else:
241 if self._unicode and isinstance(value, six.binary_type):
242 if not self.decode_encoding:
243 raise UnicodeDecodeError(
244 'Cannot decode str value %r into unicode '
245 '(no default_encoding provided)' % value)
246 value = value.decode(self.default_encoding)
247 elif not self._unicode and isinstance(value, six.text_type):
248 if not self.decode_encoding:
249 raise UnicodeEncodeError(
250 'Cannot encode unicode value %r into str '
251 '(no default_encoding provided)' % value)
252 value = value.encode(self.default_encoding)
253 return value
254
255
256 def _add_line_info(self, msg, pos):
257 msg = "%s at line %s column %s" % (
258 msg, pos[0], pos[1])
259 if self.name:
260 msg += " in file %s" % self.name
261 return msg
262
263def sub(content, **kw):
264 name = kw.get('__name')
265 tmpl = Template(content, name=name)
266 return tmpl.substitute(kw)
267
268def paste_script_template_renderer(content, vars, filename=None):
269 tmpl = Template(content, name=filename)
270 return tmpl.substitute(vars)
271
272class bunch(dict):
273
274 def __init__(self, **kw):
275 for name, value in kw.items():
276 setattr(self, name, value)
277
278 def __setattr__(self, name, value):
279 self[name] = value
280
281 def __getattr__(self, name):
282 try:
283 return self[name]
284 except KeyError:
285 raise AttributeError(name)
286
287 def __getitem__(self, key):
288 if 'default' in self:
289 try:
290 return dict.__getitem__(self, key)
291 except KeyError:
292 return dict.__getitem__(self, 'default')
293 else:
294 return dict.__getitem__(self, key)
295
296 def __repr__(self):
297 items = [
298 (k, v) for k, v in self.items()]
299 items.sort()
300 return '<%s %s>' % (
301 self.__class__.__name__,
302 ' '.join(['%s=%r' % (k, v) for k, v in items]))
303
304############################################################
305## HTML Templating
306############################################################
307
308class html(object):
309 def __init__(self, value):
310 self.value = value
311 def __str__(self):
312 return self.value
313 def __repr__(self):
314 return '<%s %r>' % (
315 self.__class__.__name__, self.value)
316
317def html_quote(value):
318 if value is None:
319 return ''
320 if not isinstance(value, six.string_types):
321 if hasattr(value, '__unicode__'):
322 value = unicode(value)
323 else:
324 value = str(value)
325 value = cgi.escape(value, 1)
326 if isinstance(value, unicode):
327 value = value.encode('ascii', 'xmlcharrefreplace')
328 return value
329
330def url(v):
331 if not isinstance(v, six.string_types):
332 if hasattr(v, '__unicode__'):
333 v = unicode(v)
334 else:
335 v = str(v)
336 if isinstance(v, unicode):
337 v = v.encode('utf8')
338 return quote(v)
339
340def attr(**kw):
341 kw = kw.items()
342 kw.sort()
343 parts = []
344 for name, value in kw:
345 if value is None:
346 continue
347 if name.endswith('_'):
348 name = name[:-1]
349 parts.append('%s="%s"' % (html_quote(name), html_quote(value)))
350 return html(' '.join(parts))
351
352class HTMLTemplate(Template):
353
354 default_namespace = Template.default_namespace.copy()
355 default_namespace.update(dict(
356 html=html,
357 attr=attr,
358 url=url,
359 ))
360
361 def _repr(self, value, pos):
362 plain = Template._repr(self, value, pos)
363 if isinstance(value, html):
364 return plain
365 else:
366 return html_quote(plain)
367
368def sub_html(content, **kw):
369 name = kw.get('__name')
370 tmpl = HTMLTemplate(content, name=name)
371 return tmpl.substitute(kw)
372
373
374############################################################
375## Lexing and Parsing
376############################################################
377
378def lex(s, name=None, trim_whitespace=True):
379 """
380 Lex a string into chunks:
381
382 >>> lex('hey')
383 ['hey']
384 >>> lex('hey {{you}}')
385 ['hey ', ('you', (1, 7))]
386 >>> lex('hey {{')
387 Traceback (most recent call last):
388 ...
389 TemplateError: No }} to finish last expression at line 1 column 7
390 >>> lex('hey }}')
391 Traceback (most recent call last):
392 ...
393 TemplateError: }} outside expression at line 1 column 7
394 >>> lex('hey {{ {{')
395 Traceback (most recent call last):
396 ...
397 TemplateError: {{ inside expression at line 1 column 10
398
399 """
400 in_expr = False
401 chunks = []
402 last = 0
403 last_pos = (1, 1)
404 for match in token_re.finditer(s):
405 expr = match.group(0)
406 pos = find_position(s, match.end())
407 if expr == '{{' and in_expr:
408 raise TemplateError('{{ inside expression', position=pos,
409 name=name)
410 elif expr == '}}' and not in_expr:
411 raise TemplateError('}} outside expression', position=pos,
412 name=name)
413 if expr == '{{':
414 part = s[last:match.start()]
415 if part:
416 chunks.append(part)
417 in_expr = True
418 else:
419 chunks.append((s[last:match.start()], last_pos))
420 in_expr = False
421 last = match.end()
422 last_pos = pos
423 if in_expr:
424 raise TemplateError('No }} to finish last expression',
425 name=name, position=last_pos)
426 part = s[last:]
427 if part:
428 chunks.append(part)
429 if trim_whitespace:
430 chunks = trim_lex(chunks)
431 return chunks
432
433statement_re = re.compile(r'^(?:if |elif |else |for |py:)')
434single_statements = ['endif', 'endfor', 'continue', 'break']
435trail_whitespace_re = re.compile(r'\n[\t ]*$')
436lead_whitespace_re = re.compile(r'^[\t ]*\n')
437
438def trim_lex(tokens):
439 r"""
440 Takes a lexed set of tokens, and removes whitespace when there is
441 a directive on a line by itself:
442
443 >>> tokens = lex('{{if x}}\nx\n{{endif}}\ny', trim_whitespace=False)
444 >>> tokens
445 [('if x', (1, 3)), '\nx\n', ('endif', (3, 3)), '\ny']
446 >>> trim_lex(tokens)
447 [('if x', (1, 3)), 'x\n', ('endif', (3, 3)), 'y']
448 """
449 for i in range(len(tokens)):
450 current = tokens[i]
451 if isinstance(tokens[i], six.string_types):
452 # we don't trim this
453 continue
454 item = current[0]
455 if not statement_re.search(item) and item not in single_statements:
456 continue
457 if not i:
458 prev = ''
459 else:
460 prev = tokens[i-1]
461 if i+1 >= len(tokens):
462 next = ''
463 else:
464 next = tokens[i+1]
465 if (not isinstance(next, six.string_types)
466 or not isinstance(prev, six.string_types)):
467 continue
468 if ((not prev or trail_whitespace_re.search(prev))
469 and (not next or lead_whitespace_re.search(next))):
470 if prev:
471 m = trail_whitespace_re.search(prev)
472 # +1 to leave the leading \n on:
473 prev = prev[:m.start()+1]
474 tokens[i-1] = prev
475 if next:
476 m = lead_whitespace_re.search(next)
477 next = next[m.end():]
478 tokens[i+1] = next
479 return tokens
480
481
482def find_position(string, index):
483 """Given a string and index, return (line, column)"""
484 leading = string[:index].splitlines()
485 return (len(leading), len(leading[-1])+1)
486
487def parse(s, name=None):
488 r"""
489 Parses a string into a kind of AST
490
491 >>> parse('{{x}}')
492 [('expr', (1, 3), 'x')]
493 >>> parse('foo')
494 ['foo']
495 >>> parse('{{if x}}test{{endif}}')
496 [('cond', (1, 3), ('if', (1, 3), 'x', ['test']))]
497 >>> parse('series->{{for x in y}}x={{x}}{{endfor}}')
498 ['series->', ('for', (1, 11), ('x',), 'y', ['x=', ('expr', (1, 27), 'x')])]
499 >>> parse('{{for x, y in z:}}{{continue}}{{endfor}}')
500 [('for', (1, 3), ('x', 'y'), 'z', [('continue', (1, 21))])]
501 >>> parse('{{py:x=1}}')
502 [('py', (1, 3), 'x=1')]
503 >>> parse('{{if x}}a{{elif y}}b{{else}}c{{endif}}')
504 [('cond', (1, 3), ('if', (1, 3), 'x', ['a']), ('elif', (1, 12), 'y', ['b']), ('else', (1, 23), None, ['c']))]
505
506 Some exceptions::
507
508 >>> parse('{{continue}}')
509 Traceback (most recent call last):
510 ...
511 TemplateError: continue outside of for loop at line 1 column 3
512 >>> parse('{{if x}}foo')
513 Traceback (most recent call last):
514 ...
515 TemplateError: No {{endif}} at line 1 column 3
516 >>> parse('{{else}}')
517 Traceback (most recent call last):
518 ...
519 TemplateError: else outside of an if block at line 1 column 3
520 >>> parse('{{if x}}{{for x in y}}{{endif}}{{endfor}}')
521 Traceback (most recent call last):
522 ...
523 TemplateError: Unexpected endif at line 1 column 25
524 >>> parse('{{if}}{{endif}}')
525 Traceback (most recent call last):
526 ...
527 TemplateError: if with no expression at line 1 column 3
528 >>> parse('{{for x y}}{{endfor}}')
529 Traceback (most recent call last):
530 ...
531 TemplateError: Bad for (no "in") in 'x y' at line 1 column 3
532 >>> parse('{{py:x=1\ny=2}}')
533 Traceback (most recent call last):
534 ...
535 TemplateError: Multi-line py blocks must start with a newline at line 1 column 3
536 """
537 tokens = lex(s, name=name)
538 result = []
539 while tokens:
540 next, tokens = parse_expr(tokens, name)
541 result.append(next)
542 return result
543
544def parse_expr(tokens, name, context=()):
545 if isinstance(tokens[0], six.string_types):
546 return tokens[0], tokens[1:]
547 expr, pos = tokens[0]
548 expr = expr.strip()
549 if expr.startswith('py:'):
550 expr = expr[3:].lstrip(' \t')
551 if expr.startswith('\n'):
552 expr = expr[1:]
553 else:
554 if '\n' in expr:
555 raise TemplateError(
556 'Multi-line py blocks must start with a newline',
557 position=pos, name=name)
558 return ('py', pos, expr), tokens[1:]
559 elif expr in ('continue', 'break'):
560 if 'for' not in context:
561 raise TemplateError(
562 'continue outside of for loop',
563 position=pos, name=name)
564 return (expr, pos), tokens[1:]
565 elif expr.startswith('if '):
566 return parse_cond(tokens, name, context)
567 elif (expr.startswith('elif ')
568 or expr == 'else'):
569 raise TemplateError(
570 '%s outside of an if block' % expr.split()[0],
571 position=pos, name=name)
572 elif expr in ('if', 'elif', 'for'):
573 raise TemplateError(
574 '%s with no expression' % expr,
575 position=pos, name=name)
576 elif expr in ('endif', 'endfor'):
577 raise TemplateError(
578 'Unexpected %s' % expr,
579 position=pos, name=name)
580 elif expr.startswith('for '):
581 return parse_for(tokens, name, context)
582 elif expr.startswith('default '):
583 return parse_default(tokens, name, context)
584 elif expr.startswith('#'):
585 return ('comment', pos, tokens[0][0]), tokens[1:]
586 return ('expr', pos, tokens[0][0]), tokens[1:]
587
588def parse_cond(tokens, name, context):
589 start = tokens[0][1]
590 pieces = []
591 context = context + ('if',)
592 while 1:
593 if not tokens:
594 raise TemplateError(
595 'Missing {{endif}}',
596 position=start, name=name)
597 if (isinstance(tokens[0], tuple)
598 and tokens[0][0] == 'endif'):
599 return ('cond', start) + tuple(pieces), tokens[1:]
600 next, tokens = parse_one_cond(tokens, name, context)
601 pieces.append(next)
602
603def parse_one_cond(tokens, name, context):
604 (first, pos), tokens = tokens[0], tokens[1:]
605 content = []
606 if first.endswith(':'):
607 first = first[:-1]
608 if first.startswith('if '):
609 part = ('if', pos, first[3:].lstrip(), content)
610 elif first.startswith('elif '):
611 part = ('elif', pos, first[5:].lstrip(), content)
612 elif first == 'else':
613 part = ('else', pos, None, content)
614 else:
615 assert 0, "Unexpected token %r at %s" % (first, pos)
616 while 1:
617 if not tokens:
618 raise TemplateError(
619 'No {{endif}}',
620 position=pos, name=name)
621 if (isinstance(tokens[0], tuple)
622 and (tokens[0][0] == 'endif'
623 or tokens[0][0].startswith('elif ')
624 or tokens[0][0] == 'else')):
625 return part, tokens
626 next, tokens = parse_expr(tokens, name, context)
627 content.append(next)
628
629def parse_for(tokens, name, context):
630 first, pos = tokens[0]
631 tokens = tokens[1:]
632 context = ('for',) + context
633 content = []
634 assert first.startswith('for ')
635 if first.endswith(':'):
636 first = first[:-1]
637 first = first[3:].strip()
638 match = in_re.search(first)
639 if not match:
640 raise TemplateError(
641 'Bad for (no "in") in %r' % first,
642 position=pos, name=name)
643 vars = first[:match.start()]
644 if '(' in vars:
645 raise TemplateError(
646 'You cannot have () in the variable section of a for loop (%r)'
647 % vars, position=pos, name=name)
648 vars = tuple([
649 v.strip() for v in first[:match.start()].split(',')
650 if v.strip()])
651 expr = first[match.end():]
652 while 1:
653 if not tokens:
654 raise TemplateError(
655 'No {{endfor}}',
656 position=pos, name=name)
657 if (isinstance(tokens[0], tuple)
658 and tokens[0][0] == 'endfor'):
659 return ('for', pos, vars, expr, content), tokens[1:]
660 next, tokens = parse_expr(tokens, name, context)
661 content.append(next)
662
663def parse_default(tokens, name, context):
664 first, pos = tokens[0]
665 assert first.startswith('default ')
666 first = first.split(None, 1)[1]
667 parts = first.split('=', 1)
668 if len(parts) == 1:
669 raise TemplateError(
670 "Expression must be {{default var=value}}; no = found in %r" % first,
671 position=pos, name=name)
672 var = parts[0].strip()
673 if ',' in var:
674 raise TemplateError(
675 "{{default x, y = ...}} is not supported",
676 position=pos, name=name)
677 if not var_re.search(var):
678 raise TemplateError(
679 "Not a valid variable name for {{default}}: %r"
680 % var, position=pos, name=name)
681 expr = parts[1].strip()
682 return ('default', pos, var, expr), tokens[1:]
683
684_fill_command_usage = """\
685%prog [OPTIONS] TEMPLATE arg=value
686
687Use py:arg=value to set a Python value; otherwise all values are
688strings.
689"""
690
691def fill_command(args=None):
692 import sys, optparse, pkg_resources, os
693 if args is None:
694 args = sys.argv[1:]
695 dist = pkg_resources.get_distribution('Paste')
696 parser = optparse.OptionParser(
697 version=str(dist),
698 usage=_fill_command_usage)
699 parser.add_option(
700 '-o', '--output',
701 dest='output',
702 metavar="FILENAME",
703 help="File to write output to (default stdout)")
704 parser.add_option(
705 '--html',
706 dest='use_html',
707 action='store_true',
708 help="Use HTML style filling (including automatic HTML quoting)")
709 parser.add_option(
710 '--env',
711 dest='use_env',
712 action='store_true',
713 help="Put the environment in as top-level variables")
714 options, args = parser.parse_args(args)
715 if len(args) < 1:
716 print('You must give a template filename')
717 print(dir(parser))
718 assert 0
719 template_name = args[0]
720 args = args[1:]
721 vars = {}
722 if options.use_env:
723 vars.update(os.environ)
724 for value in args:
725 if '=' not in value:
726 print('Bad argument: %r' % value)
727 sys.exit(2)
728 name, value = value.split('=', 1)
729 if name.startswith('py:'):
730 name = name[:3]
731 value = eval(value)
732 vars[name] = value
733 if template_name == '-':
734 template_content = sys.stdin.read()
735 template_name = '<stdin>'
736 else:
737 f = open(template_name, 'rb')
738 template_content = f.read()
739 f.close()
740 if options.use_html:
741 TemplateClass = HTMLTemplate
742 else:
743 TemplateClass = Template
744 template = TemplateClass(template_content, name=template_name)
745 result = template.substitute(vars)
746 if options.output:
747 f = open(options.output, 'wb')
748 f.write(result)
749 f.close()
750 else:
751 sys.stdout.write(result)
752
753if __name__ == '__main__':
754 from paste.util.template import fill_command
755 fill_command()
756
757