diff options
author | ianb <devnull@localhost> | 2007-01-31 17:43:01 +0000 |
---|---|---|
committer | ianb <devnull@localhost> | 2007-01-31 17:43:01 +0000 |
commit | 7dd6025cafb48902ffe2cdb0b7493bd0bf27f07a (patch) | |
tree | 882b19075a75bc28d49bdd8f631d1e28e3ae6906 | |
parent | 40e7e0a2fd3cb9a0bf4914b0dd6aa3340b02ba3d (diff) | |
download | paste-7dd6025cafb48902ffe2cdb0b7493bd0bf27f07a.tar.gz |
Added a templating language
-rw-r--r-- | docs/news.txt | 2 | ||||
-rw-r--r-- | paste/util/template.py | 473 | ||||
-rw-r--r-- | tests/conftest.py | 3 | ||||
-rw-r--r-- | tests/test_doctests.py | 36 | ||||
-rw-r--r-- | tests/test_template.txt | 66 |
5 files changed, 579 insertions, 1 deletions
diff --git a/docs/news.txt b/docs/news.txt index d2ec8d8..e085ed9 100644 --- a/docs/news.txt +++ b/docs/news.txt @@ -24,6 +24,8 @@ svn trunk * ``paste.fixture.TestApp.get(status=X)`` takes a list of allowed status codes for ``X``. +* Added a small templating system for internal use. + In paste.wsgiwrappers ~~~~~~~~~~~~~~~~~~~~~ diff --git a/paste/util/template.py b/paste/util/template.py new file mode 100644 index 0000000..a4f8e22 --- /dev/null +++ b/paste/util/template.py @@ -0,0 +1,473 @@ +""" +A small templating language + +This implements a small templating language for use internally in +Paste and Paste Script. This language implements if/elif/else, +for/continue/break, expressions, and blocks of Python code. The +syntax is:: + + {{any expression (function calls etc)}} + {{any expression | filter}} + {{for x in y}}...{{endfor}} + {{if x}}x{{elif y}}y{{else}}z{{endif}} + {{py:x=1}} + {{py: + def foo(bar): + return 'baz' + }} + +You use this with the ``Template`` class or the ``sub`` shortcut. +The ``Template`` class takes the template string and the name of +the template (for errors) and a default namespace. Then (like +``string.Template``) you can call the ``tmpl.substitute(**kw)`` +method to make a substitution (or ``tmpl.substitute(a_dict)``). + +``sub(content, **kw)`` substitutes the template immediately. You +can use ``__name='tmpl.html'`` to set the name of the template. + +If there are syntax errors ``TemplateError`` will be raised. +""" + +import re +import sys + +__all__ = ['TemplateError', 'Template', 'sub'] + +token_re = re.compile(r'\{\{|\}\}') +in_re = re.compile(r'\s+in\s+') + +class TemplateError(Exception): + """Exception raised while parsing a template + """ + + def __init__(self, message, position, name=None): + self.message = message + self.position = position + self.name = name + + def __str__(self): + msg = '%s at line %s column %s' % ( + self.message, self.position[0], self.position[1]) + if self.name: + msg += ' in %s' % self.name + return msg + +class _TemplateContinue(Exception): + pass + +class _TemplateBreak(Exception): + pass + +class Template(object): + + default_namespace = { + 'start_braces': '{{', + 'end_braces': '}}', + } + + default_encoding = 'utf8' + + def __init__(self, content, name=None, namespace=None): + self.content = content + self._unicode = isinstance(content, unicode) + self.name = name + self._parsed = parse(content, name=name) + if namespace is None: + namespace = {} + self.namespace = namespace + + def from_filename(cls, filename, namespace=None, encoding=None): + f = open(filename, 'rb') + c = f.read() + f.close() + if encoding: + c = c.decode(encoding) + return cls(content=c, name=filename, namespace=namespace) + + from_filename = classmethod(from_filename) + + def __repr__(self): + return '<%s %s name=%r>' % ( + self.__class__.__name__, + hex(id(self))[2:], name) + + def substitute(self, *args, **kw): + if args: + if kw: + raise TypeError( + "You can only give positional *or* keyword arguments") + if len(args) > 1: + raise TypeError( + "You can only give on positional argument") + kw = args[0] + ns = self.default_namespace.copy() + ns.update(self.namespace) + ns.update(kw) + result = self._interpret(ns) + return result + + def _interpret(self, ns): + parts = [] + self._interpret_codes(self._parsed, ns, out=parts) + return ''.join(parts) + + def _interpret_codes(self, codes, ns, out): + for item in codes: + if isinstance(item, basestring): + out.append(item) + else: + self._interpret_code(item, ns, out) + + def _interpret_code(self, code, ns, out): + name, pos = code[0], code[1] + if name == 'py': + self._exec(code[2], ns, pos) + elif name == 'continue': + raise _TemplateContinue() + elif name == 'break': + raise _TemplateBreak() + elif name == 'for': + vars, expr, content = code[2], code[3], code[4] + expr = self._eval(expr, ns, pos) + self._interpret_for(vars, expr, content, ns, out) + elif name == 'cond': + parts = code[2:] + self._interpret_if(parts, ns, out) + elif name == 'expr': + parts = code[2].split('|') + base = self._eval(parts[0], ns, pos) + for part in parts[1:]: + func = self._eval(part, ns, pos) + base = func(base) + out.append(self._repr(base, pos)) + else: + assert 0, "Unknown code: %r" % name + + def _interpret_for(self, vars, expr, content, ns, out): + for item in expr: + if len(vars) == 1: + ns[vars[0]] = item + else: + if len(vars) != len(item): + raise ValueError( + 'Need %i items to unpack (got %i items)' + % (len(vars), len(item))) + for name, value in zip(vars, item): + ns[name] = value + try: + self._interpret_codes(content, ns, out) + except _TemplateContinue: + continue + except _TemplateBreak: + break + + def _interpret_if(self, parts, ns, out): + # @@: if/else/else gets through + for part in parts: + assert not isinstance(part, basestring) + name, pos = part[0], part[1] + if name == 'else': + result = True + else: + result = self._eval(part[2], ns, pos) + if result: + self._interpret_codes(part[3], ns, out) + break + + def _eval(self, code, ns, pos): + try: + value = eval(code, ns) + return value + except: + exc_info = sys.exc_info() + e = exc_info[1] + e.args = (self._add_line_info(e.args[0], pos),) + raise exc_info[0], e, exc_info[2] + + def _exec(self, code, ns, pos): + try: + exec code in ns + except: + exc_info = sys.exc_info() + e = exc_info[1] + e.args = (self._add_line_info(e.args[0], pos),) + raise exc_info[0], e, exc_info[2] + + def _repr(self, value, pos): + try: + if value is None: + return '' + if self._unicode: + try: + value = unicode(value) + except UnicodeDecodeError: + value = str(value) + else: + value = str(value) + except: + exc_info = sys.exc_info() + e = exc_info[1] + e.args = (self._add_line_info(e.args[0], pos),) + raise exc_info[0], e, exc_info[2] + else: + if self._unicode and isinstance(value, str): + if not self.decode_encoding: + raise UnicodeDecodeError( + 'Cannot decode str value %r into unicode ' + '(no default_encoding provided)' % value) + value = value.decode(self.default_encoding) + elif not self._unicode and isinstance(value, unicode): + if not self.decode_encoding: + raise UnicodeEncodeError( + 'Cannot encode unicode value %r into str ' + '(no default_encoding provided)' % value) + value = value.encode(self.default_encoding) + return value + + + def _add_line_info(self, msg, pos): + msg = "%s at line %s column %s" % ( + msg, pos[0], pos[1]) + if self.name: + msg += " in file %s" % self.name + return msg + +def sub(content, **kw): + name = kw.get('__name') + tmpl = Template(content, name=name) + result = tmpl.substitute(kw) + return result + +############################################################ +## Lexing and Parsing +############################################################ + +def lex(s, name=None): + """ + Lex a string into chunks: + + >>> lex('hey') + ['hey'] + >>> lex('hey {{you}}') + ['hey ', ('you', (1, 7))] + >>> lex('hey {{') + Traceback (most recent call last): + ... + TemplateError: No }} to finish last expression at line 1 column 7 + >>> lex('hey }}') + Traceback (most recent call last): + ... + TemplateError: }} outside expression at line 1 column 7 + >>> lex('hey {{ {{') + Traceback (most recent call last): + ... + TemplateError: {{ inside expression at line 1 column 10 + + """ + in_expr = False + chunks = [] + last = 0 + last_pos = (1, 1) + for match in token_re.finditer(s): + expr = match.group(0) + pos = find_position(s, match.end()) + if expr == '{{' and in_expr: + raise TemplateError('{{ inside expression', position=pos, + name=name) + elif expr == '}}' and not in_expr: + raise TemplateError('}} outside expression', position=pos, + name=name) + if expr == '{{': + part = s[last:match.start()] + if part: + chunks.append(part) + in_expr = True + else: + chunks.append((s[last:match.start()], last_pos)) + in_expr = False + last = match.end() + last_pos = pos + if in_expr: + raise TemplateError('No }} to finish last expression', + name=name, position=last_pos) + part = s[last:] + if part: + chunks.append(part) + return chunks + +def find_position(string, index): + """Given a string and index, return (line, column)""" + leading = string[:index].splitlines() + return (len(leading), len(leading[-1])+1) + +def parse(s, name=None): + r""" + Parses a string into a kind of AST + + >>> parse('{{x}}') + [('expr', (1, 3), 'x')] + >>> parse('foo') + ['foo'] + >>> parse('{{if x}}test{{endif}}') + [('cond', (1, 3), ('if', (1, 3), 'x', ['test']))] + >>> parse('series->{{for x in y}}x={{x}}{{endfor}}') + ['series->', ('for', (1, 11), ('x',), 'y', ['x=', ('expr', (1, 27), 'x')])] + >>> parse('{{for x, y in z:}}{{continue}}{{endfor}}') + [('for', (1, 3), ('x', 'y'), 'z', [('continue', (1, 21))])] + >>> parse('{{py:x=1}}') + [('py', (1, 3), 'x=1')] + >>> parse('{{if x}}a{{elif y}}b{{else}}c{{endif}}') + [('cond', (1, 3), ('if', (1, 3), 'x', ['a']), ('elif', (1, 12), 'y', ['b']), ('else', (1, 23), None, ['c']))] + + Some exceptions:: + + >>> parse('{{continue}}') + Traceback (most recent call last): + ... + TemplateError: continue outside of for loop at line 1 column 3 + >>> parse('{{if x}}foo') + Traceback (most recent call last): + ... + TemplateError: No {{endif}} at line 1 column 3 + >>> parse('{{else}}') + Traceback (most recent call last): + ... + TemplateError: else outside of an if block at line 1 column 3 + >>> parse('{{if x}}{{for x in y}}{{endif}}{{endfor}}') + Traceback (most recent call last): + ... + TemplateError: Unexpected endif at line 1 column 25 + >>> parse('{{if}}{{endif}}') + Traceback (most recent call last): + ... + TemplateError: if with no expression at line 1 column 3 + >>> parse('{{for x y}}{{endfor}}') + Traceback (most recent call last): + ... + TemplateError: Bad for (no "in") in 'x y' at line 1 column 3 + >>> parse('{{py:x=1\ny=2}}') + Traceback (most recent call last): + ... + TemplateError: Multi-line py blocks must start with a newline at line 1 column 3 + """ + tokens = lex(s, name=name) + result = [] + while tokens: + next, tokens = parse_expr(tokens, name) + result.append(next) + return result + +def parse_expr(tokens, name, context=()): + if isinstance(tokens[0], basestring): + return tokens[0], tokens[1:] + expr, pos = tokens[0] + expr = expr.strip() + if expr.startswith('py:'): + expr = expr[3:].lstrip(' \t') + if expr.startswith('\n'): + expr = expr[1:] + else: + if '\n' in expr: + raise TemplateError( + 'Multi-line py blocks must start with a newline', + position=pos, name=name) + return ('py', pos, expr), tokens[1:] + elif expr in ('continue', 'break'): + if 'for' not in context: + raise TemplateError( + 'continue outside of for loop', + position=pos, name=name) + return (expr, pos), tokens[1:] + elif expr.startswith('if '): + return parse_cond(tokens, name, context) + elif (expr.startswith('elif ') + or expr == 'else'): + raise TemplateError( + '%s outside of an if block' % expr.split()[0], + position=pos, name=name) + elif expr in ('if', 'elif', 'for'): + raise TemplateError( + '%s with no expression' % expr, + position=pos, name=name) + elif expr in ('endif', 'endfor'): + raise TemplateError( + 'Unexpected %s' % expr, + position=pos, name=name) + elif expr.startswith('for '): + return parse_for(tokens, name, context) + return ('expr', pos, tokens[0][0]), tokens[1:] + +def parse_cond(tokens, name, context): + start = tokens[0][1] + pieces = [] + context = context + ('if',) + while 1: + if not tokens: + raise TemplateError( + 'Missing {{endif}}', + position=start, name=name) + if (isinstance(tokens[0], tuple) + and tokens[0][0] == 'endif'): + return ('cond', start) + tuple(pieces), tokens[1:] + next, tokens = parse_one_cond(tokens, name, context) + pieces.append(next) + +def parse_one_cond(tokens, name, context): + (first, pos), tokens = tokens[0], tokens[1:] + content = [] + if first.endswith(':'): + first = first[:-1] + if first.startswith('if '): + part = ('if', pos, first[3:].lstrip(), content) + elif first.startswith('elif '): + part = ('elif', pos, first[5:].lstrip(), content) + elif first == 'else': + part = ('else', pos, None, content) + else: + assert 0, "Unexpected token %r at %s" % (first, pos) + while 1: + if not tokens: + raise TemplateError( + 'No {{endif}}', + position=pos, name=name) + if (isinstance(tokens[0], tuple) + and tokens[0][0] == 'endif' + or tokens[0][0].startswith('elif ') + or tokens[0][0] == 'else'): + return part, tokens + next, tokens = parse_expr(tokens, name, context) + content.append(next) + +def parse_for(tokens, name, context): + first, pos = tokens[0] + tokens = tokens[1:] + context = ('for',) + context + content = [] + assert first.startswith('for ') + if first.endswith(':'): + first = first[:-1] + first = first[3:].strip() + match = in_re.search(first) + if not match: + raise TemplateError( + 'Bad for (no "in") in %r' % first, + position=pos, name=name) + vars = first[:match.start()] + if '(' in vars: + raise TemplateError( + 'You cannot have () in the variable section of a for loop (%r)' + % vars, position=pos, name=name) + vars = tuple([ + v.strip() for v in first[:match.start()].split(',') + if v.strip()]) + expr = first[match.end():] + while 1: + if not tokens: + raise TemplateError( + 'No {{endfor}}', + position=pos, name=name) + if (isinstance(tokens[0], tuple) + and tokens[0][0] == 'endfor'): + return ('for', pos, vars, expr, content), tokens[1:] + next, tokens = parse_expr(tokens, name, context) + content.append(next) diff --git a/tests/conftest.py b/tests/conftest.py index d9113df..e639ec1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ import pkg_resources pkg_resources.require('Paste') import py - +""" Option = py.test.Config.Option option = py.test.Config.addoptions( "Paste options", @@ -25,3 +25,4 @@ class SetupDirectory(py.test.collect.Directory): warnings.filterwarnings('error') Directory = SetupDirectory +""" diff --git a/tests/test_doctests.py b/tests/test_doctests.py new file mode 100644 index 0000000..46470d2 --- /dev/null +++ b/tests/test_doctests.py @@ -0,0 +1,36 @@ +import doctest +from paste.util.import_string import simple_import +import os + +filenames = [ + 'tests/test_template.txt', + ] + +modules = [ + 'paste.util.template', + ] + +options = doctest.ELLIPSIS|doctest.REPORT_ONLY_FIRST_FAILURE + +def test_doctests(): + for filename in filenames: + filename = os.path.join( + os.path.dirname(os.path.dirname(__file__)), + filename) + yield do_doctest, filename + +def do_doctest(filename): + failure, total = doctest.testfile( + filename, module_relative=False, + optionflags=options) + assert not failure, "Failure in %r" % filename + +def test_doctest_mods(): + for module in modules: + yield do_doctest_mod, module + +def do_doctest_mod(module): + module = simple_import(module) + failure, total = doctest.testmod( + module, optionflags=options) + assert not failure, "Failure in %r" % module diff --git a/tests/test_template.txt b/tests/test_template.txt new file mode 100644 index 0000000..a5dad58 --- /dev/null +++ b/tests/test_template.txt @@ -0,0 +1,66 @@ +The templating language is fairly simple, just {{stuff}}. For +example:: + + >>> from paste.util.template import Template, sub + >>> sub('Hi {{name}}', name='Ian') + 'Hi Ian' + >>> Template('Hi {{repr(name)}}').substitute(name='Ian') + "Hi 'Ian'" + >>> Template('Hi {{name+1}}').substitute(name='Ian') + Traceback (most recent call last): + ... + TypeError: cannot concatenate 'str' and 'int' objects at line 1 column 6 + +It also has Django-style piping:: + + >>> sub('Hi {{name|repr}}', name='Ian') + "Hi 'Ian'" + +Note that None shows up as an empty string:: + + >>> sub('Hi {{name}}', name=None) + 'Hi ' + +And if/elif/else:: + + >>> t = Template('{{if x}}{{y}}{{else}}{{z}}{{endif}}') + >>> t.substitute(x=1, y=2, z=3) + '2' + >>> t.substitute(x=0, y=2, z=3) + '3' + >>> t = Template('{{if x > 0}}positive{{elif x < 0}}negative{{else}}zero{{endif}}') + >>> t.substitute(x=1), t.substitute(x=-10), t.substitute(x=0) + ('positive', 'negative', 'zero') + +Plus a for loop:: + + >>> t = Template('{{for i in x}}i={{i}}\n{{endfor}}') + >>> t.substitute(x=range(3)) + 'i=0\ni=1\ni=2\n' + >>> t = Template('{{for a, b in sorted(z.items()):}}{{a}}={{b}},{{endfor}}') + >>> t.substitute(z={1: 2, 3: 4}) + '1=2,3=4,' + >>> t = Template('{{for i in x}}{{if not i}}{{break}}' + ... '{{endif}}{{i}} {{endfor}}') + >>> t.substitute(x=[1, 2, 0, 3, 4]) + '1 2 ' + >>> t = Template('{{for i in x}}{{if not i}}{{continue}}' + ... '{{endif}}{{i}} {{endfor}}') + >>> t.substitute(x=[1, 2, 0, 3, 0, 4]) + '1 2 3 4 ' + +Also Python blocks:: + + >>> sub('{{py:\nx=1\n}}{{x}}') + '1' + +And some syntax errors:: + + >>> t = Template('{{if x}}', name='foo.html') + Traceback (most recent call last): + ... + TemplateError: No {{endif}} at line 1 column 3 in foo.html + >>> t = Template('{{for x}}', name='foo2.html') + Traceback (most recent call last): + ... + TemplateError: Bad for (no "in") in 'x' at line 1 column 3 in foo2.html |