changeset 81:d60486018004 trunk

Template expressions are now compiled to Python bytecode.
author cmlenz
date Sat, 15 Jul 2006 11:29:25 +0000
parents e0957965553f
children 5ca4be55ad0b
files examples/bench/kid/template.kid examples/bench/markup/base.html examples/bench/markup/template.html examples/bench/run.py markup/eval.py markup/template.py markup/tests/eval.py markup/tests/template.py
diffstat 8 files changed, 293 insertions(+), 337 deletions(-) [+]
line wrap: on
line diff
--- a/examples/bench/kid/template.kid
+++ b/examples/bench/kid/template.kid
@@ -16,7 +16,7 @@
     <h2>Loop</h2>
     <ul py:if="items">
       <li py:for="idx, item in enumerate(items)" py:content="item"
-          class="${idx == len(items) and 'last' or None}" />
+          class="${idx + 1 == len(items) and 'last' or None}" />
     </ul>
   </body>
 </html>
--- a/examples/bench/markup/base.html
+++ b/examples/bench/markup/base.html
@@ -6,12 +6,12 @@
     Hello, ${name}!
   </p>
 
-  <py:match path="body">
+  <body py:match="body">
     <div id="header">
       <h1>${title}</h1>
     </div>
     ${select('*')}
     <div id="footer" />
-  </py:match>
+  </body>
 
 </html>
--- a/examples/bench/markup/template.html
+++ b/examples/bench/markup/template.html
@@ -17,7 +17,7 @@
     <h2>Loop</h2>
     <ul py:if="items">
       <li py:for="idx, item in enumerate(items)" py:content="item"
-          class="${idx == len(items) and 'last' or None}" />
+          class="${idx + 1 == len(items) and 'last' or None}" />
     </ul>
 
   </body>
--- a/examples/bench/run.py
+++ b/examples/bench/run.py
@@ -6,7 +6,7 @@
 
 __all__ = ['clearsilver', 'django', 'kid', 'markup', 'simpletal']
 
-def markup(dirname):
+def markup(dirname, verbose=False):
     from markup.template import Context, TemplateLoader
     loader = TemplateLoader([dirname], auto_reload=False)
     template = loader.load('template.html')
@@ -14,9 +14,12 @@
         ctxt = Context(title='Just a test', user='joe',
                        items=['Number %d' % num for num in range(1, 15)])
         return template.generate(ctxt).render('html')
+
+    if verbose:
+        print render()
     return render
 
-def cheetah(dirname):
+def cheetah(dirname, verbose=False):
     # FIXME: infinite recursion somewhere... WTF?
     from Cheetah.Template import Template
     class MyTemplate(Template):
@@ -29,9 +32,12 @@
                               searchList=[{'title': 'Just a test', 'user': 'joe',
                                            'items': [u'Number %d' % num for num in range(1, 15)]}])
         return template.respond()
+
+    if verbose:
+        print render()
     return render
 
-def clearsilver(dirname):
+def clearsilver(dirname, verbose=False):
     import neo_cgi
     neo_cgi.update()
     import neo_util
@@ -46,9 +52,12 @@
         cs = neo_cs.CS(hdf)
         cs.parseFile('template.cs')
         return cs.render()
+
+    if verbose:
+        print render()
     return render
 
-def django(dirname):
+def django(dirname, verbose=False):
     from django.conf import settings
     settings.configure(TEMPLATE_DIRS=[os.path.join(dirname, 'templates')])
     from django import template, templatetags
@@ -60,9 +69,12 @@
         data = {'title': 'Just a test', 'user': 'joe',
                 'items': ['Number %d' % num for num in range(1, 15)]}
         return tmpl.render(template.Context(data))
+
+    if verbose:
+        print render()
     return render
 
-def kid(dirname):
+def kid(dirname, verbose=False):
     import kid
     kid.path = kid.TemplatePath([dirname])
     template = kid.Template(file='template.kid')
@@ -71,17 +83,23 @@
                                 title='Just a test', user='joe',
                                 items=['Number %d' % num for num in range(1, 15)])
         return template.serialize(output='xhtml')
+
+    if verbose:
+        print render()
     return render
 
-def nevow(dirname):
+def nevow(dirname, verbose=False):
     # FIXME: can't figure out the API
     from nevow.loaders import xmlfile
     template = xmlfile('template.xml', templateDir=dirname).load()
     def render():
         print template
+
+    if verbose:
+        print render()
     return render
 
-def simpletal(dirname):
+def simpletal(dirname, verbose=False):
     from simpletal import simpleTAL, simpleTALES
     fileobj = open(os.path.join(dirname, 'base.html'))
     base = simpleTAL.compileHTMLTemplate(fileobj)
@@ -98,17 +116,29 @@
         buf = StringIO()
         template.expand(ctxt, buf)
         return buf.getvalue()
+
+    if verbose:
+        print render()
     return render
 
-def run(engines):
+def run(engines, verbose=False):
     basepath = os.path.abspath(os.path.dirname(__file__))
     for engine in engines:
         dirname = os.path.join(basepath, engine)
-        print '%s:' % engine.capitalize(),
-        t = timeit.Timer(setup='from __main__ import %s; render = %s("%s")'
-                               % (engine, engine, dirname),
+        if verbose:
+            print '%s:' % engine.capitalize()
+            print '--------------------------------------------------------'
+        else:
+            print '%s:' % engine.capitalize(),
+        t = timeit.Timer(setup='from __main__ import %s; render = %s("%s", %s)'
+                               % (engine, engine, dirname, verbose),
                          stmt='render()')
-        print '%.2f ms' % (1000 * t.timeit(number=2000) / 2000)
+        time = t.timeit(number=2000) / 2000
+        if verbose:
+            print '--------------------------------------------------------'
+        print '%.2f ms' % (1000 * time)
+        if verbose:
+            print '--------------------------------------------------------'
 
 
 if __name__ == '__main__':
@@ -116,13 +146,15 @@
     if not engines:
         engines = __all__
 
+    verbose = '-v' in sys.argv
+
     if '-p' in sys.argv:
         import hotshot, hotshot.stats
         prof = hotshot.Profile("template.prof")
-        benchtime = prof.runcall(run, engines)
+        benchtime = prof.runcall(run, engines, verbose=verbose)
         stats = hotshot.stats.load("template.prof")
         stats.strip_dirs()
         stats.sort_stats('time', 'calls')
         stats.print_stats()
     else:
-        run(engines)
+        run(engines, verbose=verbose)
--- a/markup/eval.py
+++ b/markup/eval.py
@@ -16,12 +16,7 @@
 from __future__ import division
 
 import __builtin__
-try:
-    import _ast # Python 2.5
-except ImportError:
-    _ast = None
-    import compiler
-import operator
+from compiler import parse, pycodegen
 
 from markup.core import Stream
 
@@ -34,6 +29,7 @@
     >>> data = dict(test='Foo', items=[1, 2, 3], dict={'some': 'thing'})
     >>> Expression('test').evaluate(data)
     'Foo'
+
     >>> Expression('items[0]').evaluate(data)
     1
     >>> Expression('items[-1]').evaluate(data)
@@ -46,7 +42,8 @@
     
     >>> Expression('dict.some').evaluate(data)
     'thing'
-    
+    """
+    """
     This also works the other way around: item access can be used to access
     any object attribute (meaning there's no use for `getattr()` in templates):
     
@@ -68,292 +65,210 @@
     >>> Expression('len(items)').evaluate(data)
     3
     """
-    __slots__ = ['source', 'ast']
+    __slots__ = ['source', 'code']
     _visitors = {}
 
-    def __init__(self, source):
+    def __init__(self, source, filename=None, lineno=-1):
         """Create the expression.
         
         @param source: the expression as string
         """
         self.source = source
-        self.ast = None
+
+        tree = parse(self.source, 'eval')
+        if isinstance(filename, unicode):
+            # pycodegen doesn't like unicode in the filename
+            filename = filename.encode('utf-8', 'replace')
+        tree.filename = filename or '<string>'
+        gen = TemplateExpressionCodeGenerator(tree)
+        if lineno >= 0:
+            gen.emit('SET_LINENO', lineno)
+        self.code = gen.getCode()
 
     def __repr__(self):
         return '<Expression "%s">' % self.source
 
-    if _ast is None:
-
-        def evaluate(self, data):
-            """Evaluate the expression against the given data dictionary.
-            
-            @param data: a mapping containing the data to evaluate against
-            @return: the result of the evaluation
-            """
-            if not self.ast:
-                self.ast = compiler.parse(self.source, 'eval')
-            return self._visit(self.ast.node, data)
-
-        # AST traversal
-
-        def _visit(self, node, data):
-            v = self._visitors.get(node.__class__)
-            if not v:
-                v = getattr(self, '_visit_%s' % node.__class__.__name__.lower())
-                self._visitors[node.__class__] = v
-            return v(node, data)
-
-        def _visit_expression(self, node, data):
-            for child in node.getChildNodes():
-                return self._visit(child, data)
-
-        # Functions & Accessors
-
-        def _visit_callfunc(self, node, data):
-            func = self._visit(node.node, data)
-            if func is None:
-                return None
-            args = [self._visit(arg, data) for arg in node.args
-                    if not isinstance(arg, compiler.ast.Keyword)]
-            kwargs = dict([(arg.name, self._visit(arg.expr, data)) for arg
-                           in node.args if isinstance(arg, compiler.ast.Keyword)])
-            return func(*args, **kwargs)
-
-        def _visit_getattr(self, node, data):
-            obj = self._visit(node.expr, data)
-            if hasattr(obj, node.attrname):
-                return getattr(obj, node.attrname)
-            try:
-                return obj[node.attrname]
-            except (KeyError, TypeError):
-                return None
-
-        def _visit_slice(self, node, data):
-            obj = self._visit(node.expr, data)
-            lower = node.lower and self._visit(node.lower, data) or None
-            upper = node.upper and self._visit(node.upper, data) or None
-            return obj[lower:upper]
-
-        def _visit_subscript(self, node, data):
-            obj = self._visit(node.expr, data)
-            subs = map(lambda sub: self._visit(sub, data), node.subs)
-            if len(subs) == 1:
-                subs = subs[0]
-            try:
-                return obj[subs]
-            except (KeyError, IndexError, TypeError):
-                try:
-                    return getattr(obj, subs)
-                except (AttributeError, TypeError):
-                    return None
-
-        # Operators
-
-        def _visit_and(self, node, data):
-            return reduce(lambda x, y: x and y,
-                          [self._visit(n, data) for n in node.nodes])
-
-        def _visit_or(self, node, data):
-            return reduce(lambda x, y: x or y,
-                          [self._visit(n, data) for n in node.nodes])
-
-        def _visit_bitand(self, node, data):
-            return reduce(operator.and_,
-                          [self._visit(n, data) for n in node.nodes])
-
-        def _visit_bitor(self, node, data):
-            return reduce(operator.or_,
-                          [self._visit(n, data) for n in node.nodes])
-
-        _OP_MAP = {'==': operator.eq, '!=': operator.ne,
-                   '<':  operator.lt, '<=': operator.le,
-                   '>':  operator.gt, '>=': operator.ge,
-                   'is': operator.is_, 'is not': operator.is_not,
-                   'in': lambda x, y: operator.contains(y, x),
-                   'not in': lambda x, y: not operator.contains(y, x)}
-        def _visit_compare(self, node, data):
-            result = self._visit(node.expr, data)
-            ops = node.ops[:]
-            ops.reverse()
-            for op, rval in ops:
-                result = self._OP_MAP[op](result, self._visit(rval, data))
-            return result
-
-        def _visit_add(self, node, data):
-            return self._visit(node.left, data) + self._visit(node.right, data)
-
-        def _visit_div(self, node, data):
-            return self._visit(node.left, data) / self._visit(node.right, data)
-
-        def _visit_floordiv(self, node, data):
-            return self._visit(node.left, data) // self._visit(node.right, data)
-
-        def _visit_mod(self, node, data):
-            return self._visit(node.left, data) % self._visit(node.right, data)
-
-        def _visit_mul(self, node, data):
-            return self._visit(node.left, data) * self._visit(node.right, data)
-
-        def _visit_power(self, node, data):
-            return self._visit(node.left, data) ** self._visit(node.right, data)
-
-        def _visit_sub(self, node, data):
-            return self._visit(node.left, data) - self._visit(node.right, data)
-
-        def _visit_unaryadd(self, node, data):
-            return +self._visit(node.expr, data)
-
-        def _visit_unarysub(self, node, data):
-            return -self._visit(node.expr, data)
-
-        def _visit_not(self, node, data):
-            return not self._visit(node.expr, data)
-
-        def _visit_invert(self, node, data):
-            return ~self._visit(node.expr, data)
-
-        # Identifiers & Literals
+    def evaluate(self, data):
+        """Evaluate the expression against the given data dictionary.
+        
+        @param data: a mapping containing the data to evaluate against
+        @return: the result of the evaluation
+        """
+        return eval(self.code)
 
-        def _visit_name(self, node, data):
-            val = data.get(node.name)
-            if val is None:
-                val = getattr(__builtin__, node.name, None)
-            return val
-
-        def _visit_const(self, node, data):
-            return node.value
-
-        def _visit_dict(self, node, data):
-            return dict([(self._visit(k, data), self._visit(v, data))
-                         for k, v in node.items])
-
-        def _visit_tuple(self, node, data):
-            return tuple([self._visit(n, data) for n in node.nodes])
-
-        def _visit_list(self, node, data):
-            return [self._visit(n, data) for n in node.nodes]
-
-    else:
-
-        def evaluate(self, data):
-            """Evaluate the expression against the given data dictionary.
-            
-            @param data: a mapping containing the data to evaluate against
-            @return: the result of the evaluation
-            """
-            if not self.ast:
-                self.ast = compile(self.source, '?', 'eval', 0x400)
-            return self._visit(self.ast, data)
-
-        # AST traversal
-
-        def _visit(self, node, data):
-            v = self._visitors.get(node.__class__)
-            if not v:
-                v = getattr(self, '_visit_%s' % node.__class__.__name__.lower())
-                self._visitors[node.__class__] = v
-            return v(node, data)
-
-        def _visit_expression(self, node, data):
-            return self._visit(node.body, data)
-
-        # Functions & Accessors
-
-        def _visit_attribute(self, node, data):
-            obj = self._visit(node.value, data)
-            if hasattr(obj, node.attr):
-                return getattr(obj, node.attr)
-            try:
-                return obj[node.attr]
-            except (KeyError, TypeError):
-                return None
-
-        def _visit_call(self, node, data):
-            func = self._visit(node.func, data)
-            if func is None:
-                return None
-            args = [self._visit(arg, data) for arg in node.args]
-            kwargs = dict([(kwarg.arg, self._visit(kwarg.value, data))
-                           for kwarg in node.keywords])
-            return func(*args, **kwargs)
 
-        def _visit_subscript(self, node, data):
-            obj = self._visit(node.value, data)
-            if isinstance(node.slice, _ast.Slice):
-                try:
-                    return obj[self._visit(lower, data):
-                               self._visit(upper, data):
-                               self._visit(step, data)]
-                except (KeyError, IndexError, TypeError):
-                    pass
-            else:
-                index = self._visit(node.slice.value, data)
-                try:
-                    return obj[index]
-                except (KeyError, IndexError, TypeError):
-                    try:
-                        return getattr(obj, index)
-                    except (AttributeError, TypeError):
-                        pass
-            return None
-
-        # Operators
-
-        _OP_MAP = {_ast.Add: operator.add, _ast.And: lambda l, r: l and r,
-                   _ast.BitAnd: operator.and_, _ast.BitOr: operator.or_,
-                   _ast.Div: operator.truediv, _ast.Eq: operator.eq,
-                   _ast.FloorDiv: operator.floordiv, _ast.Gt: operator.gt,
-                   _ast.GtE: operator.ge, _ast.Invert: operator.inv,
-                   _ast.In: lambda l, r: operator.contains(r, l),
-                   _ast.Is: operator.is_, _ast.IsNot: operator.is_not,
-                   _ast.Lt: operator.lt, _ast.LtE: operator.le,
-                   _ast.Mod: operator.mod, _ast.Mult: operator.mul,
-                   _ast.Not: operator.not_, _ast.NotEq: operator.ne,
-                   _ast.NotIn: lambda l, r: not operator.contains(r, l),
-                   _ast.Or: lambda l, r: l or r, _ast.Pow: operator.pow,
-                   _ast.Sub: operator.sub, _ast.UAdd: operator.pos,
-                   _ast.USub: operator.neg}
-
-        def _visit_unaryop(self, node, data):
-            return self._OP_MAP[node.op.__class__](self._visit(node.operand, data))
+class TemplateExpressionCodeGenerator(pycodegen.ExpressionCodeGenerator):
 
-        def _visit_binop(self, node, data):
-            return self._OP_MAP[node.op.__class__](self._visit(node.left, data),
-                                                   self._visit(node.right, data))
-
-        def _visit_boolop(self, node, data):
-            return reduce(self._OP_MAP[node.op.__class__],
-                          [self._visit(n, data) for n in node.values])
-
-        def _visit_compare(self, node, data):
-            result = self._visit(node.left, data)
-            ops = node.ops[:]
-            ops.reverse()
-            for op, rval in zip(ops, node.comparators):
-                result = self._OP_MAP[op.__class__](result,
-                                                     self._visit(rval, data))
-            return result
-
-        # Identifiers & Literals
+    def visitGetattr(self, node):
+        """Overridden to fallback to item access if the object doesn't have an
+        attribute.
+        
+        Also, if either method fails, this returns `None` instead of raising an
+        `AttributeError`.
+        """
+        # check whether the object has the request attribute
+        self.visit(node.expr)
+        self.emit('STORE_NAME', 'obj')
+        self.emit('LOAD_GLOBAL', 'hasattr')
+        self.emit('LOAD_NAME', 'obj')
+        self.emit('LOAD_CONST', node.attrname)
+        self.emit('CALL_FUNCTION', 2)
+        else_ = self.newBlock()
+        self.emit('JUMP_IF_FALSE', else_)
+        self.emit('POP_TOP')
 
-        def _visit_dict(self, node, data):
-            return dict([(self._visit(k, data), self._visit(v, data))
-                         for k, v in zip(node.keys, node.values)])
-
-        def _visit_list(self, node, data):
-            return [self._visit(n, data) for n in node.elts]
+        # hasattr returned True, so return the attribute value
+        self.emit('LOAD_NAME', 'obj')
+        self.emit('LOAD_ATTR', node.attrname)
+        self.emit('STORE_NAME', 'val')
+        return_ = self.newBlock()
+        self.emit('JUMP_FORWARD', return_)
 
-        def _visit_name(self, node, data):
-            val = data.get(node.id)
-            if val is None:
-                val = getattr(__builtin__, node.id, None)
-            return val
+        # hasattr returned False, so try item access
+        self.startBlock(else_)
+        try_ = self.newBlock()
+        except_ = self.newBlock()
+        self.emit('SETUP_EXCEPT', except_)
+        self.nextBlock(try_)
+        self.setups.push((pycodegen.EXCEPT, try_))
+        self.emit('LOAD_NAME', 'obj')
+        self.emit('LOAD_CONST', node.attrname)
+        self.emit('BINARY_SUBSCR')
+        self.emit('STORE_NAME', 'val')
+        self.emit('POP_BLOCK')
+        self.setups.pop()
+        self.emit('JUMP_FORWARD', return_)
 
-        def _visit_num(self, node, data):
-            return node.n
+        # exception handler: just return `None`
+        self.startBlock(except_)
+        self.emit('DUP_TOP')
+        self.emit('LOAD_GLOBAL', 'KeyError')
+        self.emit('LOAD_GLOBAL', 'TypeError')
+        self.emit('BUILD_TUPLE', 2)
+        self.emit('COMPARE_OP', 'exception match')
+        next = self.newBlock()
+        self.emit('JUMP_IF_FALSE', next)
+        self.nextBlock()
+        self.emit('POP_TOP')
+        self.emit('POP_TOP')
+        self.emit('POP_TOP')
+        self.emit('POP_TOP')
+        self.emit('LOAD_CONST', None) # exception handler body
+        self.emit('STORE_NAME', 'val')
+        self.emit('JUMP_FORWARD', return_)
+        self.nextBlock(next)
+        self.emit('POP_TOP')
+        self.emit('END_FINALLY')
+        
+        # return
+        self.nextBlock(return_)
+        self.emit('LOAD_NAME', 'val')
 
-        def _visit_str(self, node, data):
-            return node.s
+    def visitName(self, node):
+        """Overridden to lookup names in the context data instead of in
+        locals/globals.
+        
+        If a name is not found in the context data, we fall back to Python
+        builtins.
+        """
+        next = self.newBlock()
+        end = self.newBlock()
 
-        def _visit_tuple(self, node, data):
-            return tuple([self._visit(n, data) for n in node.elts])
+        # default: lookup in context data
+        self.loadName('data')
+        self.emit('LOAD_ATTR', 'get')
+        self.emit('LOAD_CONST', node.name)
+        self.emit('CALL_FUNCTION', 1)
+        self.emit('STORE_NAME', 'val')
+
+        # test whether the value "is None"
+        self.emit('LOAD_NAME', 'val')
+        self.emit('LOAD_CONST', None)
+        self.emit('COMPARE_OP', 'is')
+        self.emit('JUMP_IF_FALSE', next)
+        self.emit('POP_TOP')
+
+        # if it is, fallback to builtins
+        self.emit('LOAD_GLOBAL', 'getattr')
+        self.emit('LOAD_GLOBAL', '__builtin__')
+        self.emit('LOAD_CONST', node.name)
+        self.emit('LOAD_CONST', None)
+        self.emit('CALL_FUNCTION', 3)
+        self.emit('STORE_NAME', 'val')
+        self.emit('JUMP_FORWARD', end)
+
+        self.nextBlock(next)
+        self.emit('POP_TOP')
+
+        self.nextBlock(end)
+        self.emit('LOAD_NAME', 'val')
+
+    def visitSubscript(self, node, aug_flag=None):
+        """Overridden to fallback to attribute access if the object doesn't
+        have an item (or doesn't even support item access).
+        
+        If either method fails, this returns `None` instead of raising an
+        `IndexError`, `KeyError`, or `TypeError`.
+        """
+        self.visit(node.expr)
+        self.emit('STORE_NAME', 'obj')
+
+        if len(node.subs) > 1:
+            # For non-scalar subscripts, use the default method
+            # FIXME: this should catch exceptions
+            self.emit('LOAD_NAME', 'obj')
+            for sub in node.subs:
+                self.visit(sub)
+            self.emit('BUILD_TUPLE', len(node.subs))
+            self.emit('BINARY_SUBSCR')
+
+        else:
+            # For a scalar subscript, fallback to attribute access
+            # FIXME: Would be nice if we could limit this to string subscripts
+            try_ = self.newBlock()
+            except_ = self.newBlock()
+            return_ = self.newBlock()
+            self.emit('SETUP_EXCEPT', except_)
+            self.nextBlock(try_)
+            self.setups.push((pycodegen.EXCEPT, try_))
+            self.emit('LOAD_NAME', 'obj')
+            self.visit(node.subs[0])
+            self.emit('BINARY_SUBSCR')
+            self.emit('STORE_NAME', 'val')
+            self.emit('POP_BLOCK')
+            self.setups.pop()
+            self.emit('JUMP_FORWARD', return_)
+
+            self.startBlock(except_)
+            self.emit('DUP_TOP')
+            self.emit('LOAD_GLOBAL', 'KeyError')
+            self.emit('LOAD_GLOBAL', 'IndexError')
+            self.emit('LOAD_GLOBAL', 'TypeError')
+            self.emit('BUILD_TUPLE', 3)
+            self.emit('COMPARE_OP', 'exception match')
+            next = self.newBlock()
+            self.emit('JUMP_IF_FALSE', next)
+            self.nextBlock()
+            self.emit('POP_TOP')
+            self.emit('POP_TOP')
+            self.emit('POP_TOP')
+            self.emit('POP_TOP')
+            self.emit('LOAD_GLOBAL', 'getattr') # exception handler body
+            self.emit('LOAD_NAME', 'obj')
+            self.visit(node.subs[0])
+            self.emit('LOAD_CONST', None)
+            self.emit('CALL_FUNCTION', 3)
+            self.emit('STORE_NAME', 'val')
+            self.emit('JUMP_FORWARD', return_)
+            self.nextBlock(next)
+            self.emit('POP_TOP')
+            self.emit('END_FINALLY')
+        
+            # return
+            self.nextBlock(return_)
+            self.emit('LOAD_NAME', 'val')
+
+
+if __name__ == '__main__':
+    import doctest
+    doctest.testmod()
--- a/markup/template.py
+++ b/markup/template.py
@@ -32,9 +32,6 @@
 Todo items:
  * Improved error reporting
  * Support for list comprehensions and generator expressions in expressions
-
-Random thoughts:
- * Could we generate byte code from expressions?
 """
 
 try:
@@ -175,8 +172,12 @@
     """
     __slots__ = ['expr']
 
-    def __init__(self, value):
-        self.expr = value and Expression(value) or None
+    def __init__(self, value, filename=None, lineno=-1, offset=-1):
+        try:
+            self.expr = value and Expression(value, filename, lineno) or None
+        except SyntaxError, err:
+            raise TemplateSyntaxError(err, filename, lineno,
+                                      offset + (err.offset or 0))
 
     def __call__(self, stream, ctxt, directives):
         raise NotImplementedError
@@ -319,8 +320,8 @@
 
     ATTRIBUTE = 'function'
 
-    def __init__(self, args):
-        Directive.__init__(self, None)
+    def __init__(self, args, filename=None, lineno=-1, offset=-1):
+        Directive.__init__(self, None, filename, lineno, offset)
         ast = compiler.parse(args, 'eval').node
         self.args = []
         self.defaults = {}
@@ -374,10 +375,10 @@
 
     ATTRIBUTE = 'each'
 
-    def __init__(self, value):
+    def __init__(self, value, filename=None, lineno=-1, offset=-1):
         targets, value = value.split(' in ', 1)
         self.targets = [str(name.strip()) for name in targets.split(',')]
-        Directive.__init__(self, value)
+        Directive.__init__(self, value, filename, lineno, offset)
 
     def __call__(self, stream, ctxt, directives):
         iterable = self.expr.evaluate(ctxt)
@@ -442,8 +443,8 @@
 
     ATTRIBUTE = 'path'
 
-    def __init__(self, value):
-        Directive.__init__(self, None)
+    def __init__(self, value, filename=None, lineno=-1, offset=-1):
+        Directive.__init__(self, None, filename, lineno, offset)
         self.path = Path(value)
         self.stream = []
 
@@ -716,7 +717,7 @@
                     if cls is None:
                         raise BadDirectiveError(tag, pos[0], pos[1])
                     value = attrib.get(getattr(cls, 'ATTRIBUTE', None), '')
-                    directives.append(cls(value))
+                    directives.append(cls(value, *pos))
                     strip = True
 
                 new_attrib = []
@@ -725,10 +726,12 @@
                         cls = self._dir_by_name.get(name.localname)
                         if cls is None:
                             raise BadDirectiveError(name, pos[0], pos[1])
-                        directives.append(cls(value))
+                        directives.append(cls(value, *pos))
                     else:
                         if value:
                             value = list(self._interpolate(value, *pos))
+                            if len(value) == 1 and value[0][0] is TEXT:
+                                value = value[0][1]
                         else:
                             value = [(TEXT, u'', pos)]
                         new_attrib.append((name, value))
@@ -781,7 +784,12 @@
         def _interpolate(text, patterns):
             for idx, group in enumerate(patterns.pop(0).split(text)):
                 if idx % 2:
-                    yield EXPR, Expression(group), (lineno, offset)
+                    try:
+                        yield EXPR, Expression(group, filename, lineno), \
+                              (lineno, offset)
+                    except SyntaxError, err:
+                        raise TemplateSyntaxError(err, filename, lineno,
+                                                  offset + (err.offset or 0))
                 elif group:
                     if patterns:
                         for result in _interpolate(group, patterns[:]):
@@ -822,9 +830,11 @@
         """Internal stream filter that evaluates any expressions in `START` and
         `TEXT` events.
         """
+        filters = (self._ensure, self._eval, self._match)
+
         for kind, data, pos in stream:
 
-            if kind is START:
+            if kind is START and data[1]:
                 # Attributes may still contain expressions in start tags at
                 # this point, so do some evaluation
                 tag, attrib = data
@@ -860,7 +870,7 @@
                     # case we yield the individual items
                     try:
                         substream = iter(result)
-                        for filter_ in [self._ensure, self._eval, self._match]:
+                        for filter_ in filters:
                             substream = filter_(substream, ctxt)
                         for event in substream:
                             yield event
@@ -874,23 +884,19 @@
 
     def _flatten(self, stream, ctxt=None):
         """Internal stream filter that expands `SUB` events in the stream."""
-        try:
-            for kind, data, pos in stream:
-                if kind is SUB:
-                    # This event is a list of directives and a list of nested
-                    # events to which those directives should be applied
-                    directives, substream = data
-                    substream = _apply_directives(substream, ctxt, directives)
-                    for filter_ in (self._eval, self._match, self._flatten):
-                        substream = filter_(substream, ctxt)
-                    for event in substream:
-                        yield event
-                        continue
-                else:
-                    yield kind, data, pos
-        except SyntaxError, err:
-            raise TemplateSyntaxError(err, pos[0], pos[1],
-                                      pos[2] + (err.offset or 0))
+        for kind, data, pos in stream:
+            if kind is SUB:
+                # This event is a list of directives and a list of nested
+                # events to which those directives should be applied
+                directives, substream = data
+                substream = _apply_directives(substream, ctxt, directives)
+                for filter_ in (self._eval, self._match, self._flatten):
+                    substream = filter_(substream, ctxt)
+                for event in substream:
+                    yield event
+                    continue
+            else:
+                yield kind, data, pos
 
     def _match(self, stream, ctxt=None, match_templates=None):
         """Internal stream filter that applies any defined match templates
--- a/markup/tests/eval.py
+++ b/markup/tests/eval.py
@@ -170,9 +170,14 @@
 
     def test_compare_multi(self):
         self.assertEqual(True, Expression("1 != 3 == 3").evaluate({}))
-        self.assertEqual(True, Expression("x != y == y").evaluate({'x': 3,
+        self.assertEqual(True, Expression("x != y == y").evaluate({'x': 1,
                                                                    'y': 3}))
 
+    # FIXME: need support for local names in comprehensions
+    #def test_list_comprehension(self):
+    #    expr = Expression("[n for n in numbers if n < 2]")
+    #    self.assertEqual([0, 1], expr.evaluate({'numbers': range(5)}))
+
 
 def suite():
     suite = unittest.TestSuite()
--- a/markup/tests/template.py
+++ b/markup/tests/template.py
@@ -473,10 +473,9 @@
                 self.assertEqual(1, e.lineno)
 
     def test_directive_value_syntax_error(self):
-        xml = '<p xmlns:py="http://markup.edgewall.org/" py:if="bar\'" />'
-        tmpl = Template(xml, filename='test.html')
+        xml = """<p xmlns:py="http://markup.edgewall.org/" py:if="bar'" />"""
         try:
-            list(tmpl.generate())
+            tmpl = Template(xml, filename='test.html')
             self.fail('Expected SyntaxError')
         except TemplateSyntaxError, e:
             self.assertEqual('test.html', e.filename)
@@ -485,17 +484,16 @@
                 # We don't really care about the offset here, do we?
 
     def test_expression_syntax_error(self):
-        xml = '<p>\n  Foo <em>${bar"}</em>\n</p>'
-        tmpl = Template(xml, filename='test.html')
-        ctxt = Context(bar='baz')
+        xml = """<p>
+          Foo <em>${bar"}</em>
+        </p>"""
         try:
-            list(tmpl.generate(ctxt))
+            tmpl = Template(xml, filename='test.html')
             self.fail('Expected SyntaxError')
         except TemplateSyntaxError, e:
             self.assertEqual('test.html', e.filename)
             if sys.version_info[:2] >= (2, 4):
                 self.assertEqual(2, e.lineno)
-                self.assertEqual(10, e.offset)
 
     def test_markup_noescape(self):
         """
Copyright (C) 2012-2017 Edgewall Software