changeset 407:f37d8e6acdf2 trunk

Move string interpolation code into separate module (`genshi.template.interpolation`).
author cmlenz
date Wed, 21 Feb 2007 14:17:22 +0000
parents 01be13831f5e
children 4675d5cf6c67
files genshi/template/base.py genshi/template/interpolation.py genshi/template/markup.py genshi/template/tests/__init__.py genshi/template/tests/base.py genshi/template/tests/eval.py genshi/template/tests/interpolation.py genshi/template/text.py
diffstat 8 files changed, 346 insertions(+), 292 deletions(-) [+]
line wrap: on
line diff
--- a/genshi/template/base.py
+++ b/genshi/template/base.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 #
-# Copyright (C) 2006 Edgewall Software
+# Copyright (C) 2006-2007 Edgewall Software
 # All rights reserved.
 #
 # This software is licensed as described in the file COPYING, which
@@ -17,14 +17,10 @@
     class deque(list):
         def appendleft(self, x): self.insert(0, x)
         def popleft(self): return self.pop(0)
-from itertools import chain
 import os
-import re
 from StringIO import StringIO
-from tokenize import tokenprog
 
 from genshi.core import Attrs, Stream, StreamEventKind, START, TEXT, _ensure
-from genshi.template.eval import Expression
 
 __all__ = ['Context', 'Template', 'TemplateError', 'TemplateRuntimeError',
            'TemplateSyntaxError', 'BadDirectiveError']
@@ -40,7 +36,8 @@
 
     def __init__(self, message, filename='<string>', lineno=-1, offset=-1):
         self.msg = message
-        message = '%s (%s, line %d)' % (self.msg, filename, lineno)
+        if filename != '<string>' or lineno >= 0:
+            message = '%s (%s, line %d)' % (self.msg, filename, lineno)
         TemplateError.__init__(self, message)
         self.filename = filename
         self.lineno = lineno
@@ -229,111 +226,6 @@
         """
         raise NotImplementedError
 
-    def _interpolate(cls, text, basedir=None, filename=None, lineno=-1,
-                     offset=0):
-        """Parse the given string and extract expressions.
-        
-        This method returns a list containing both literal text and `Expression`
-        objects.
-        
-        @param text: the text to parse
-        @param lineno: the line number at which the text was found (optional)
-        @param offset: the column number at which the text starts in the source
-            (optional)
-        """
-        filepath = filename
-        if filepath and basedir:
-            filepath = os.path.join(basedir, filepath)
-
-        namestart = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_'
-        namechars = namestart + '.0123456789'
-
-        def _split():
-            pos = 0
-            end = len(text)
-            escaped = False
-
-            while 1:
-                if escaped:
-                    offset = text.find('$', offset + 2)
-                    escaped = False
-                else:
-                    offset = text.find('$', pos)
-                if offset < 0 or offset == end - 1:
-                    break
-                next = text[offset + 1]
-
-                if next == '{':
-                    if offset > pos:
-                        yield False, text[pos:offset]
-                    pos = offset + 2
-                    level = 1
-                    while level:
-                        match = tokenprog.match(text, pos)
-                        if match is None:
-                            raise TemplateSyntaxError('invalid syntax',
-                                                      filename, lineno, offset)
-                        pos = match.end()
-                        tstart, tend = match.regs[3]
-                        token = text[tstart:tend]
-                        if token == '{':
-                            level += 1
-                        elif token == '}':
-                            level -= 1
-                    yield True, text[offset + 2:pos - 1]
-
-                elif next in namestart:
-                    if offset > pos:
-                        yield False, text[pos:offset]
-                        pos = offset
-                    pos += 1
-                    while pos < end:
-                        char = text[pos]
-                        if char not in namechars:
-                            break
-                        pos += 1
-                    yield True, text[offset + 1:pos].strip()
-
-                elif not escaped and next == '$':
-                    escaped = True
-                    pos = offset + 1
-
-                else:
-                    yield False, text[pos:offset + 1]
-                    pos = offset + 1
-
-            if pos < end:
-                yield False, text[pos:]
-
-        textbuf = []
-        textpos = None
-        for is_expr, chunk in chain(_split(), [(True, '')]):
-            if is_expr:
-                if textbuf:
-                    yield TEXT, u''.join(textbuf), textpos
-                    del textbuf[:]
-                    textpos = None
-                if chunk:
-                    try:
-                        expr = Expression(chunk.strip(), filename, lineno)
-                        yield EXPR, expr, (filename, lineno, offset)
-                    except SyntaxError, err:
-                        raise TemplateSyntaxError(err, filename, lineno,
-                                                  offset + (err.offset or 0))
-            else:
-                textbuf.append(chunk)
-                if textpos is None:
-                    textpos = (filename, lineno, offset)
-
-            if '\n' in chunk:
-                lines = chunk.splitlines()
-                lineno += len(lines) - 1
-                offset += len(lines[-1])
-            else:
-                offset += len(chunk)
-
-    _interpolate = classmethod(_interpolate)
-
     def _prepare(self, stream):
         """Call the `attach` method of every directive found in the template."""
         for kind, data, pos in stream:
new file mode 100644
--- /dev/null
+++ b/genshi/template/interpolation.py
@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2007 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://genshi.edgewall.org/wiki/License.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://genshi.edgewall.org/log/.
+
+"""String interpolation routines, i.e. the splitting up a given text into some
+parts that are literal strings, and others that are Python expressions.
+"""
+
+from itertools import chain
+import os
+from tokenize import tokenprog
+
+from genshi.core import TEXT
+from genshi.template.base import TemplateSyntaxError, EXPR
+from genshi.template.eval import Expression
+
+__all__ = ['interpolate']
+
+NAMESTART = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_'
+NAMECHARS = NAMESTART + '.0123456789'
+PREFIX = '$'
+
+def interpolate(text, basedir=None, filename=None, lineno=-1, offset=0):
+    """Parse the given string and extract expressions.
+    
+    This method returns a list containing both literal text and `Expression`
+    objects.
+    
+    >>> for kind, data, pos in interpolate("$foo bar"):
+    ...     print kind, `data`
+    EXPR Expression('foo')
+    TEXT u' bar'
+    
+    @param text: the text to parse
+    @param basedir: base directory of the file in which the text was found
+        (optional)
+    @param filename: basename of the file in which the text was found (optional)
+    @param lineno: the line number at which the text was found (optional)
+    @param offset: the column number at which the text starts in the source
+        (optional)
+    """
+    filepath = filename
+    if filepath and basedir:
+        filepath = os.path.join(basedir, filepath)
+    pos = [filepath, lineno, offset]
+
+    textbuf = []
+    textpos = None
+    for is_expr, chunk in chain(lex(text, pos), [(True, '')]):
+        if is_expr:
+            if textbuf:
+                yield TEXT, u''.join(textbuf), textpos
+                del textbuf[:]
+                textpos = None
+            if chunk:
+                try:
+                    expr = Expression(chunk.strip(), pos[0], pos[1])
+                    yield EXPR, expr, tuple(pos)
+                except SyntaxError, err:
+                    raise TemplateSyntaxError(err, pos[0], pos[1],
+                                              pos[2] + (err.offset or 0))
+        else:
+            textbuf.append(chunk)
+            if textpos is None:
+                textpos = tuple(pos)
+
+        if '\n' in chunk:
+            lines = chunk.splitlines()
+            pos[1] += len(lines) - 1
+            pos[2] += len(lines[-1])
+        else:
+            pos[2] += len(chunk)
+
+def lex(text, textpos):
+    offset = pos = 0
+    end = len(text)
+    escaped = False
+
+    while 1:
+        if escaped:
+            offset = text.find(PREFIX, offset + 2)
+            escaped = False
+        else:
+            offset = text.find(PREFIX, pos)
+        if offset < 0 or offset == end - 1:
+            break
+        next = text[offset + 1]
+
+        if next == '{':
+            if offset > pos:
+                yield False, text[pos:offset]
+            pos = offset + 2
+            level = 1
+            while level:
+                match = tokenprog.match(text, pos)
+                if match is None:
+                    raise TemplateSyntaxError('invalid syntax', *textpos)
+                pos = match.end()
+                tstart, tend = match.regs[3]
+                token = text[tstart:tend]
+                if token == '{':
+                    level += 1
+                elif token == '}':
+                    level -= 1
+            yield True, text[offset + 2:pos - 1]
+
+        elif next in NAMESTART:
+            if offset > pos:
+                yield False, text[pos:offset]
+                pos = offset
+            pos += 1
+            while pos < end:
+                char = text[pos]
+                if char not in NAMECHARS:
+                    break
+                pos += 1
+            yield True, text[offset + 1:pos].strip()
+
+        elif not escaped and next == PREFIX:
+            escaped = True
+            pos = offset + 1
+
+        else:
+            yield False, text[pos:offset + 1]
+            pos = offset + 1
+
+    if pos < end:
+        yield False, text[pos:]
--- a/genshi/template/markup.py
+++ b/genshi/template/markup.py
@@ -23,6 +23,7 @@
 from genshi.template.base import BadDirectiveError, Template, \
                                  TemplateSyntaxError, _apply_directives, SUB
 from genshi.template.eval import Suite
+from genshi.template.interpolation import interpolate
 from genshi.template.loader import TemplateNotFound
 from genshi.template.directives import *
 
@@ -127,8 +128,7 @@
                         directives.append((cls, value, ns_prefix.copy(), pos))
                     else:
                         if value:
-                            value = list(self._interpolate(value, self.basedir,
-                                                           *pos))
+                            value = list(interpolate(value, self.basedir, *pos))
                             if len(value) == 1 and value[0][0] is TEXT:
                                 value = value[0][1]
                         else:
@@ -200,8 +200,7 @@
                 stream.append((EXEC, suite, pos))
 
             elif kind is TEXT:
-                for kind, data, pos in self._interpolate(data, self.basedir,
-                                                         *pos):
+                for kind, data, pos in interpolate(data, self.basedir, *pos):
                     stream.append((kind, data, pos))
 
             elif kind is COMMENT:
--- a/genshi/template/tests/__init__.py
+++ b/genshi/template/tests/__init__.py
@@ -16,12 +16,13 @@
 
 
 def suite():
-    from genshi.template.tests import base, directives, eval, loader, markup, \
-                                      plugin, text
+    from genshi.template.tests import base, directives, eval, interpolation, \
+                                      loader, markup, plugin, text
     suite = unittest.TestSuite()
     suite.addTest(base.suite())
     suite.addTest(directives.suite())
     suite.addTest(eval.suite())
+    suite.addTest(interpolation.suite())
     suite.addTest(loader.suite())
     suite.addTest(markup.suite())
     suite.addTest(plugin.suite())
--- a/genshi/template/tests/base.py
+++ b/genshi/template/tests/base.py
@@ -14,178 +14,11 @@
 import doctest
 import unittest
 
-from genshi.core import Stream
-from genshi.template.base import Template, TemplateSyntaxError
-
-
-class TemplateTestCase(unittest.TestCase):
-    """Tests for basic template processing, expression evaluation and error
-    reporting.
-    """
-
-    def test_interpolate_string(self):
-        parts = list(Template._interpolate('bla'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Stream.TEXT, parts[0][0])
-        self.assertEqual('bla', parts[0][1])
-
-    def test_interpolate_simple(self):
-        parts = list(Template._interpolate('${bla}'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('bla', parts[0][1].source)
-
-    def test_interpolate_escaped(self):
-        parts = list(Template._interpolate('$${bla}'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Stream.TEXT, parts[0][0])
-        self.assertEqual('${bla}', parts[0][1])
-
-    def test_interpolate_dobuleescaped(self):
-        parts = list(Template._interpolate('$$${bla}'))
-        self.assertEqual(2, len(parts))
-        self.assertEqual(Stream.TEXT, parts[0][0])
-        self.assertEqual('$', parts[0][1])
-        self.assertEqual(Template.EXPR, parts[1][0])
-        self.assertEqual('bla', parts[1][1].source)
-
-    def test_interpolate_short(self):
-        parts = list(Template._interpolate('$bla'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('bla', parts[0][1].source)
-
-    def test_interpolate_short_escaped(self):
-        parts = list(Template._interpolate('$$bla'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Stream.TEXT, parts[0][0])
-        self.assertEqual('$bla', parts[0][1])
-
-    def test_interpolate_short_doubleescaped(self):
-        parts = list(Template._interpolate('$$$bla'))
-        self.assertEqual(2, len(parts))
-        self.assertEqual(Stream.TEXT, parts[0][0])
-        self.assertEqual('$', parts[0][1])
-        self.assertEqual(Template.EXPR, parts[1][0])
-        self.assertEqual('bla', parts[1][1].source)
-
-    def test_interpolate_short_starting_with_underscore(self):
-        parts = list(Template._interpolate('$_bla'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('_bla', parts[0][1].source)
-
-    def test_interpolate_short_containing_underscore(self):
-        parts = list(Template._interpolate('$foo_bar'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('foo_bar', parts[0][1].source)
-
-    def test_interpolate_short_starting_with_dot(self):
-        parts = list(Template._interpolate('$.bla'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Stream.TEXT, parts[0][0])
-        self.assertEqual('$.bla', parts[0][1])
-
-    def test_interpolate_short_containing_dot(self):
-        parts = list(Template._interpolate('$foo.bar'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('foo.bar', parts[0][1].source)
-
-    def test_interpolate_short_starting_with_digit(self):
-        parts = list(Template._interpolate('$0bla'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Stream.TEXT, parts[0][0])
-        self.assertEqual('$0bla', parts[0][1])
-
-    def test_interpolate_short_containing_digit(self):
-        parts = list(Template._interpolate('$foo0'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('foo0', parts[0][1].source)
-
-    def test_interpolate_short_starting_with_digit(self):
-        parts = list(Template._interpolate('$0bla'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Stream.TEXT, parts[0][0])
-        self.assertEqual('$0bla', parts[0][1])
-
-    def test_interpolate_short_containing_digit(self):
-        parts = list(Template._interpolate('$foo0'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('foo0', parts[0][1].source)
-
-    def test_interpolate_full_nested_brackets(self):
-        parts = list(Template._interpolate('${{1:2}}'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('{1:2}', parts[0][1].source)
-
-    def test_interpolate_full_mismatched_brackets(self):
-        try:
-            list(Template._interpolate('${{1:2}'))
-        except TemplateSyntaxError, e:
-            pass
-        else:
-            self.fail('Expected TemplateSyntaxError')
-
-    def test_interpolate_quoted_brackets_1(self):
-        parts = list(Template._interpolate('${"}"}'))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('"}"', parts[0][1].source)
-
-    def test_interpolate_quoted_brackets_2(self):
-        parts = list(Template._interpolate("${'}'}"))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual("'}'", parts[0][1].source)
-
-    def test_interpolate_quoted_brackets_3(self):
-        parts = list(Template._interpolate("${'''}'''}"))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual("'''}'''", parts[0][1].source)
-
-    def test_interpolate_quoted_brackets_4(self):
-        parts = list(Template._interpolate("${'''}\"\"\"'''}"))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual("'''}\"\"\"'''", parts[0][1].source)
-
-    def test_interpolate_quoted_brackets_5(self):
-        parts = list(Template._interpolate(r"${'\'}'}"))
-        self.assertEqual(1, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual(r"'\'}'", parts[0][1].source)
-
-    def test_interpolate_mixed1(self):
-        parts = list(Template._interpolate('$foo bar $baz'))
-        self.assertEqual(3, len(parts))
-        self.assertEqual(Template.EXPR, parts[0][0])
-        self.assertEqual('foo', parts[0][1].source)
-        self.assertEqual(Stream.TEXT, parts[1][0])
-        self.assertEqual(' bar ', parts[1][1])
-        self.assertEqual(Template.EXPR, parts[2][0])
-        self.assertEqual('baz', parts[2][1].source)
-
-    def test_interpolate_mixed2(self):
-        parts = list(Template._interpolate('foo $bar baz'))
-        self.assertEqual(3, len(parts))
-        self.assertEqual(Stream.TEXT, parts[0][0])
-        self.assertEqual('foo ', parts[0][1])
-        self.assertEqual(Template.EXPR, parts[1][0])
-        self.assertEqual('bar', parts[1][1].source)
-        self.assertEqual(Stream.TEXT, parts[2][0])
-        self.assertEqual(' baz', parts[2][1])
-
+from genshi.template.base import Template
 
 def suite():
     suite = unittest.TestSuite()
     suite.addTest(doctest.DocTestSuite(Template.__module__))
-    suite.addTest(unittest.makeSuite(TemplateTestCase, 'test'))
     return suite
 
 if __name__ == '__main__':
--- a/genshi/template/tests/eval.py
+++ b/genshi/template/tests/eval.py
@@ -338,7 +338,7 @@
                 frame = frame.tb_next
                 frames.append(frame)
             self.assertEqual('Variable "nothing" is not defined', str(e))
-            self.assertEqual('<Expression "nothing()">',
+            self.assertEqual("<Expression 'nothing()'>",
                              frames[-3].tb_frame.f_code.co_name)
             self.assertEqual('index.html',
                              frames[-3].tb_frame.f_code.co_filename)
@@ -357,7 +357,7 @@
                 frame = frame.tb_next
                 frames.append(frame)
             self.assertEqual('Variable "nothing" is not defined', str(e))
-            self.assertEqual('<Expression "nothing.nil">',
+            self.assertEqual("<Expression 'nothing.nil'>",
                              frames[-3].tb_frame.f_code.co_name)
             self.assertEqual('index.html',
                              frames[-3].tb_frame.f_code.co_filename)
@@ -376,7 +376,7 @@
                 frame = frame.tb_next
                 frames.append(frame)
             self.assertEqual('Variable "nothing" is not defined', str(e))
-            self.assertEqual('<Expression "nothing[0]">',
+            self.assertEqual("<Expression 'nothing[0]'>",
                              frames[-3].tb_frame.f_code.co_name)
             self.assertEqual('index.html',
                              frames[-3].tb_frame.f_code.co_filename)
new file mode 100644
--- /dev/null
+++ b/genshi/template/tests/interpolation.py
@@ -0,0 +1,191 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2007 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://genshi.edgewall.org/wiki/License.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://genshi.edgewall.org/log/.
+
+import doctest
+import sys
+import unittest
+
+from genshi.core import TEXT
+from genshi.template.base import TemplateSyntaxError, EXPR
+from genshi.template.interpolation import interpolate
+
+
+class InterpolateTestCase(unittest.TestCase):
+
+    def test_interpolate_string(self):
+        parts = list(interpolate('bla'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(TEXT, parts[0][0])
+        self.assertEqual('bla', parts[0][1])
+
+    def test_interpolate_simple(self):
+        parts = list(interpolate('${bla}'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('bla', parts[0][1].source)
+
+    def test_interpolate_escaped(self):
+        parts = list(interpolate('$${bla}'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(TEXT, parts[0][0])
+        self.assertEqual('${bla}', parts[0][1])
+
+    def test_interpolate_dobuleescaped(self):
+        parts = list(interpolate('$$${bla}'))
+        self.assertEqual(2, len(parts))
+        self.assertEqual(TEXT, parts[0][0])
+        self.assertEqual('$', parts[0][1])
+        self.assertEqual(EXPR, parts[1][0])
+        self.assertEqual('bla', parts[1][1].source)
+
+    def test_interpolate_short(self):
+        parts = list(interpolate('$bla'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('bla', parts[0][1].source)
+
+    def test_interpolate_short_escaped(self):
+        parts = list(interpolate('$$bla'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(TEXT, parts[0][0])
+        self.assertEqual('$bla', parts[0][1])
+
+    def test_interpolate_short_doubleescaped(self):
+        parts = list(interpolate('$$$bla'))
+        self.assertEqual(2, len(parts))
+        self.assertEqual(TEXT, parts[0][0])
+        self.assertEqual('$', parts[0][1])
+        self.assertEqual(EXPR, parts[1][0])
+        self.assertEqual('bla', parts[1][1].source)
+
+    def test_interpolate_short_starting_with_underscore(self):
+        parts = list(interpolate('$_bla'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('_bla', parts[0][1].source)
+
+    def test_interpolate_short_containing_underscore(self):
+        parts = list(interpolate('$foo_bar'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('foo_bar', parts[0][1].source)
+
+    def test_interpolate_short_starting_with_dot(self):
+        parts = list(interpolate('$.bla'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(TEXT, parts[0][0])
+        self.assertEqual('$.bla', parts[0][1])
+
+    def test_interpolate_short_containing_dot(self):
+        parts = list(interpolate('$foo.bar'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('foo.bar', parts[0][1].source)
+
+    def test_interpolate_short_starting_with_digit(self):
+        parts = list(interpolate('$0bla'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(TEXT, parts[0][0])
+        self.assertEqual('$0bla', parts[0][1])
+
+    def test_interpolate_short_containing_digit(self):
+        parts = list(interpolate('$foo0'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('foo0', parts[0][1].source)
+
+    def test_interpolate_short_starting_with_digit(self):
+        parts = list(interpolate('$0bla'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(TEXT, parts[0][0])
+        self.assertEqual('$0bla', parts[0][1])
+
+    def test_interpolate_short_containing_digit(self):
+        parts = list(interpolate('$foo0'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('foo0', parts[0][1].source)
+
+    def test_interpolate_full_nested_brackets(self):
+        parts = list(interpolate('${{1:2}}'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('{1:2}', parts[0][1].source)
+
+    def test_interpolate_full_mismatched_brackets(self):
+        try:
+            list(interpolate('${{1:2}'))
+        except TemplateSyntaxError, e:
+            pass
+        else:
+            self.fail('Expected TemplateSyntaxError')
+
+    def test_interpolate_quoted_brackets_1(self):
+        parts = list(interpolate('${"}"}'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('"}"', parts[0][1].source)
+
+    def test_interpolate_quoted_brackets_2(self):
+        parts = list(interpolate("${'}'}"))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual("'}'", parts[0][1].source)
+
+    def test_interpolate_quoted_brackets_3(self):
+        parts = list(interpolate("${'''}'''}"))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual("'''}'''", parts[0][1].source)
+
+    def test_interpolate_quoted_brackets_4(self):
+        parts = list(interpolate("${'''}\"\"\"'''}"))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual("'''}\"\"\"'''", parts[0][1].source)
+
+    def test_interpolate_quoted_brackets_5(self):
+        parts = list(interpolate(r"${'\'}'}"))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual(r"'\'}'", parts[0][1].source)
+
+    def test_interpolate_mixed1(self):
+        parts = list(interpolate('$foo bar $baz'))
+        self.assertEqual(3, len(parts))
+        self.assertEqual(EXPR, parts[0][0])
+        self.assertEqual('foo', parts[0][1].source)
+        self.assertEqual(TEXT, parts[1][0])
+        self.assertEqual(' bar ', parts[1][1])
+        self.assertEqual(EXPR, parts[2][0])
+        self.assertEqual('baz', parts[2][1].source)
+
+    def test_interpolate_mixed2(self):
+        parts = list(interpolate('foo $bar baz'))
+        self.assertEqual(3, len(parts))
+        self.assertEqual(TEXT, parts[0][0])
+        self.assertEqual('foo ', parts[0][1])
+        self.assertEqual(EXPR, parts[1][0])
+        self.assertEqual('bar', parts[1][1].source)
+        self.assertEqual(TEXT, parts[2][0])
+        self.assertEqual(' baz', parts[2][1])
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(doctest.DocTestSuite(interpolate.__module__))
+    suite.addTest(unittest.makeSuite(InterpolateTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
--- a/genshi/template/text.py
+++ b/genshi/template/text.py
@@ -17,6 +17,7 @@
 
 from genshi.template.base import BadDirectiveError, Template, SUB
 from genshi.template.directives import *
+from genshi.template.interpolation import interpolate
 
 
 class TextTemplate(Template):
@@ -70,8 +71,8 @@
             start, end = mo.span()
             if start > offset:
                 text = source[offset:start]
-                for kind, data, pos in self._interpolate(text, self.basedir,
-                                                         self.filename, lineno):
+                for kind, data, pos in interpolate(text, self.basedir,
+                                                   self.filename, lineno):
                     stream.append((kind, data, pos))
                 lineno += len(text.splitlines())
 
@@ -102,8 +103,8 @@
 
         if offset < len(source):
             text = source[offset:].replace('\\#', '#')
-            for kind, data, pos in self._interpolate(text, self.basedir,
-                                                     self.filename, lineno):
+            for kind, data, pos in interpolate(text, self.basedir,
+                                               self.filename, lineno):
                 stream.append((kind, data, pos))
 
         return stream
Copyright (C) 2012-2017 Edgewall Software