Various cleanups and added custom cycler.

--HG--
branch : trunk
diff --git a/CHANGES b/CHANGES
index 7a5d0c2..c0c7139 100644
--- a/CHANGES
+++ b/CHANGES
@@ -37,6 +37,8 @@
 - inclusions and imports "with context" forward all variables now, not only
   the initial context.
 
+- added a cycle helper called `cycle`.
+
 Version 2.0
 -----------
 (codename jinjavitus, released on July 17th 2008)
diff --git a/docs/templates.rst b/docs/templates.rst
index 0c01eee..d797fec 100644
--- a/docs/templates.rst
+++ b/docs/templates.rst
@@ -482,6 +482,9 @@
         <li class="{{ loop.cycle('odd', 'even') }}">{{ row }}</li>
     {% endfor %}
 
+With Jinja 2.1 an extra `cycle` helper exists that allows loop-unbound
+cycling.  For more information have a look at the :ref:`builtin-globals`.
+
 .. _loop-filtering:
 
 Unlike in Python it's not possible to `break` or `continue` in a loop.  You
@@ -999,6 +1002,7 @@
 
 .. jinjatests::
 
+.. _builtin-globals:
 
 List of Global Functions
 ------------------------
@@ -1038,6 +1042,44 @@
     A convenient alternative to dict literals.  ``{'foo': 'bar'}`` is the same
     as ``dict(foo='bar')``.
 
+.. class:: cycler(\*items)
+
+    The cycler allows you to cycle among values similar to how `loop.cycle`
+    works.  Unlike `loop.cycle` however you can use this cycler outside of
+    loops or over multiple loops.
+
+    This is for example very useful if you want to show a list of folders and
+    files, with the folders on top, but both in the same list with alteranting
+    row colors.
+
+    The following example shows how `cycler` can be used::
+
+        {% set row_class = cycler('odd', 'even') %}
+        <ul class="browser">
+        {% for folder in folders %}
+          <li class="folder {{ row_class.next() }}">{{ folder|e }}</li>
+        {% endfor %}
+        {% for filename in files %}
+          <li class="file {{ row_class.next() }}">{{ filename|e }}</li>
+        {% endfor %}
+        </ul>
+
+    A cycler has the following attributes and methods:
+
+    .. method:: reset()
+
+        Resets the cycle to the first item.
+
+    .. method:: next()
+
+        Goes one item a head and returns the then current item.
+
+    .. attribute:: current
+
+        Returns the current item.
+    
+    **new in Jinja 2.1**
+
 
 Extensions
 ----------
diff --git a/jinja2/__init__.py b/jinja2/__init__.py
index 0a3ff6f..95b2d5b 100644
--- a/jinja2/__init__.py
+++ b/jinja2/__init__.py
@@ -59,8 +59,9 @@
 __all__ = [
     'Environment', 'Template', 'BaseLoader', 'FileSystemLoader',
     'PackageLoader', 'DictLoader', 'FunctionLoader', 'PrefixLoader',
-    'ChoiceLoader', 'Undefined', 'DebugUndefined', 'StrictUndefined',
-    'TemplateError', 'UndefinedError', 'TemplateNotFound',
+    'ChoiceLoader', 'BytecodeCache', 'FileSystemBytecodeCache',
+    'MemcachedBytecodeCache', 'Undefined', 'DebugUndefined',
+    'StrictUndefined', 'TemplateError', 'UndefinedError', 'TemplateNotFound',
     'TemplateSyntaxError', 'TemplateAssertionError', 'environmentfilter',
     'contextfilter', 'Markup', 'escape', 'environmentfunction',
     'contextfunction', 'clear_caches', 'is_undefined'
diff --git a/jinja2/bccache.py b/jinja2/bccache.py
index e11bad5..2c57616 100644
--- a/jinja2/bccache.py
+++ b/jinja2/bccache.py
@@ -24,6 +24,7 @@
     from hashlib import sha1
 except ImportError:
     from sha import new as sha1
+from jinja2.utils import open_if_exists
 
 
 bc_version = 1
@@ -193,17 +194,15 @@
         return path.join(self.directory, self.pattern % bucket.key)
 
     def load_bytecode(self, bucket):
-        filename = self._get_cache_filename(bucket)
-        if path.exists(filename):
-            f = file(filename, 'rb')
+        f = open_if_exists(self._get_cache_filename(bucket), 'rb')
+        if f is not None:
             try:
                 bucket.load_bytecode(f)
             finally:
                 f.close()
 
     def dump_bytecode(self, bucket):
-        filename = self._get_cache_filename(bucket)
-        f = file(filename, 'wb')
+        f = file(self._get_cache_filename(bucket), 'wb')
         try:
             bucket.write_bytecode(f)
         finally:
diff --git a/jinja2/debug.py b/jinja2/debug.py
index f503c21..53dac4d 100644
--- a/jinja2/debug.py
+++ b/jinja2/debug.py
@@ -35,24 +35,6 @@
     return exc_info[:2] + (result_tb or initial_tb,)
 
 
-def translate_syntax_error(error):
-    """When passed a syntax error it will generate a new traceback with
-    more debugging information.
-    """
-    filename = error.filename
-    if filename is None:
-        filename = '<template>'
-    elif isinstance(filename, unicode):
-        filename = filename.encode('utf-8')
-    code = compile('\n' * (error.lineno - 1) + 'raise __jinja_exception__',
-                   filename, 'exec')
-    try:
-        exec code in {'__jinja_exception__': error}
-    except:
-        exc_info = sys.exc_info()
-        return exc_info[:2] + (exc_info[2].tb_next,)
-
-
 def fake_exc_info(exc_info, filename, lineno, tb_back=None):
     """Helper for `translate_exception`."""
     exc_type, exc_value, tb = exc_info
diff --git a/jinja2/defaults.py b/jinja2/defaults.py
index 1d3be69..61706ae 100644
--- a/jinja2/defaults.py
+++ b/jinja2/defaults.py
@@ -8,7 +8,7 @@
     :copyright: 2007-2008 by Armin Ronacher.
     :license: BSD, see LICENSE for more details.
 """
-from jinja2.utils import generate_lorem_ipsum
+from jinja2.utils import generate_lorem_ipsum, Cycler
 
 
 # defaults for the parser / lexer
@@ -29,7 +29,8 @@
 DEFAULT_NAMESPACE = {
     'range':        xrange,
     'dict':         lambda **kw: kw,
-    'lipsum':       generate_lorem_ipsum
+    'lipsum':       generate_lorem_ipsum,
+    'cycler':       Cycler
 }
 
 
diff --git a/jinja2/environment.py b/jinja2/environment.py
index 862a247..519e9ec 100644
--- a/jinja2/environment.py
+++ b/jinja2/environment.py
@@ -48,6 +48,15 @@
     return LRUCache(size)
 
 
+def copy_cache(cache):
+    """Create an empty copy of the given cache."""
+    if cache is None:
+        return Noe
+    elif type(cache) is dict:
+        return {}
+    return LRUCache(cache.capacity)
+
+
 def load_extensions(environment, extensions):
     """Load the extensions from the list and bind it to the environment.
     Returns a dict of instanciated environments.
@@ -285,6 +294,8 @@
 
         if cache_size is not missing:
             rv.cache = create_cache(cache_size)
+        else:
+            rv.cache = copy_cache(self.cache)
 
         rv.extensions = {}
         for key, value in self.extensions.iteritems():
@@ -340,9 +351,8 @@
         try:
             return Parser(self, source, name, filename).parse()
         except TemplateSyntaxError, e:
-            from jinja2.debug import translate_syntax_error
-            exc_type, exc_value, tb = translate_syntax_error(e)
-            raise exc_type, exc_value, tb
+            e.source = source
+            raise e
 
     def lex(self, source, name=None, filename=None):
         """Lex the given sourcecode and return a generator that yields
@@ -354,7 +364,12 @@
         of the extensions to be applied you have to filter source through
         the :meth:`preprocess` method.
         """
-        return self.lexer.tokeniter(unicode(source), name, filename)
+        source = unicode(source)
+        try:
+            return self.lexer.tokeniter(source, name, filename)
+        except TemplateSyntaxError, e:
+            e.source = source
+            raise e
 
     def preprocess(self, source, name=None, filename=None):
         """Preprocesses the source with all extensions.  This is automatically
@@ -594,6 +609,8 @@
         else:
             parent = dict(self.globals, **vars)
         if locals:
+            # if the parent is shared a copy should be created because
+            # we don't want to modify the dict passed
             if shared:
                 parent = dict(parent)
             for key, value in locals.iteritems():
diff --git a/jinja2/exceptions.py b/jinja2/exceptions.py
index 154cf44..5bfca66 100644
--- a/jinja2/exceptions.py
+++ b/jinja2/exceptions.py
@@ -26,22 +26,35 @@
     """Raised to tell the user that there is a problem with the template."""
 
     def __init__(self, message, lineno, name=None, filename=None):
-        if name is not None:
-            extra = '%s, line %d' % (name.encode('utf-8'), lineno)
-        else:
-            extra = 'line %d' % lineno
-        # if the message was provided as unicode we have to encode it
-        # to utf-8 explicitly
-        if isinstance(message, unicode):
-            message = message.encode('utf-8')
-        # otherwise make sure it's a in fact valid utf-8
-        else:
-            message = message.decode('utf-8', 'ignore').encode('utf-8')
-        TemplateError.__init__(self, '%s (%s)' % (message, extra))
-        self.message = message
+        if not isinstance(message, unicode):
+            message = message.decode('utf-8', 'replace')
+        TemplateError.__init__(self, message.encode('utf-8'))
         self.lineno = lineno
         self.name = name
         self.filename = filename
+        self.source = None
+        self.message = message
+
+    def __unicode__(self):
+        location = 'line %d' % self.lineno
+        name = self.filename or self.name
+        if name:
+            location = 'File "%s", %s' % (name, location)
+        lines = [self.message, '  ' + location]
+
+        # if the source is set, add the line to the output
+        if self.source is not None:
+            try:
+                line = self.source.splitlines()[self.lineno - 1]
+            except IndexError:
+                line = None
+            if line:
+                lines.append('    ' + line.strip())
+
+        return u'\n'.join(lines)
+
+    def __str__(self):
+        return unicode(self).encode('utf-8')
 
 
 class TemplateAssertionError(TemplateSyntaxError):
diff --git a/jinja2/filters.py b/jinja2/filters.py
index 78d4cb6..bd9a500 100644
--- a/jinja2/filters.py
+++ b/jinja2/filters.py
@@ -339,10 +339,11 @@
         {{ mytext|indent(2, true) }}
             indent by two spaces and indent the first line too.
     """
-    indention = ' ' * width
+    indention = u' ' * width
+    rv = (u'\n' + indention).join(s.splitlines())
     if indentfirst:
-        return u'\n'.join(indention + line for line in s.splitlines())
-    return s.replace('\n', '\n' + indention)
+        rv = indention + rv
+    return rv
 
 
 def do_truncate(s, length=255, killwords=False, end='...'):
@@ -648,13 +649,20 @@
     See :ref:`Notes on subscriptions <notes-on-subscriptions>` for more details.
     """
     try:
-        value = getattr(obj, name)
-    except AttributeError:
-        return environment.undefined(obj=obj, name=name)
-    if environment.sandboxed and not \
-       environment.is_safe_attribute(obj, name, value):
-        return environment.unsafe_undefined(obj, name)
-    return value
+        name = str(name)
+    except UnicodeError:
+        pass
+    else:
+        try:
+            value = getattr(obj, name)
+        except AttributeError:
+            pass
+        else:
+            if environment.sandboxed and not \
+               environment.is_safe_attribute(obj, name, value):
+                return environment.unsafe_undefined(obj, name)
+            return value
+    return environment.undefined(obj=obj, name=name)
 
 
 FILTERS = {
diff --git a/jinja2/loaders.py b/jinja2/loaders.py
index b0522cd..8b2221f 100644
--- a/jinja2/loaders.py
+++ b/jinja2/loaders.py
@@ -14,7 +14,7 @@
 except ImportError:
     from sha import new as sha1
 from jinja2.exceptions import TemplateNotFound
-from jinja2.utils import LRUCache
+from jinja2.utils import LRUCache, open_if_exists
 
 
 def split_template_path(template):
@@ -142,9 +142,9 @@
         pieces = split_template_path(template)
         for searchpath in self.searchpath:
             filename = path.join(searchpath, *pieces)
-            if not path.isfile(filename):
+            f = open_if_exists(filename)
+            if f is None:
                 continue
-            f = file(filename)
             try:
                 contents = f.read().decode(self.encoding)
             finally:
@@ -171,7 +171,8 @@
 
     def __init__(self, package_name, package_path='templates',
                  encoding='utf-8'):
-        from pkg_resources import DefaultProvider, ResourceManager, get_provider
+        from pkg_resources import DefaultProvider, ResourceManager, \
+                                  get_provider
         provider = get_provider(package_name)
         self.encoding = encoding
         self.manager = ResourceManager()
diff --git a/jinja2/runtime.py b/jinja2/runtime.py
index bd7e305..815b589 100644
--- a/jinja2/runtime.py
+++ b/jinja2/runtime.py
@@ -165,10 +165,10 @@
         )
 
 
-# register the context as mutable mapping if possible
+# register the context as mapping if possible
 try:
-    from collections import MutableMapping
-    MutableMapping.register(Context)
+    from collections import Mapping
+    Mapping.register(Context)
 except ImportError:
     pass
 
@@ -409,7 +409,7 @@
     __int__ = __float__ = __complex__ = _fail_with_undefined_error
 
     def __str__(self):
-        return self.__unicode__().encode('utf-8')
+        return unicode(self).encode('utf-8')
 
     def __unicode__(self):
         return u''
diff --git a/jinja2/utils.py b/jinja2/utils.py
index f0ae6a9..338db4a 100644
--- a/jinja2/utils.py
+++ b/jinja2/utils.py
@@ -10,6 +10,7 @@
 """
 import re
 import sys
+import errno
 try:
     from thread import allocate_lock
 except ImportError:
@@ -173,6 +174,17 @@
             raise
 
 
+def open_if_exists(filename, mode='r'):
+    """Returns a file descriptor for the filename if that file exists,
+    otherwise `None`.
+    """
+    try:
+        return file(filename, mode)
+    except IOError, e:
+        if e.errno not in (errno.ENOENT, errno.EISDIR):
+            raise
+
+
 def pformat(obj, verbose=False):
     """Prettyprint an object.  Either use the `pretty` library or the
     builtin `pprint`.
@@ -648,6 +660,31 @@
     pass
 
 
+class Cycler(object):
+    """A cycle helper for templates."""
+
+    def __init__(self, *items):
+        if not items:
+            raise RuntimeError('at least one item has to be provided')
+        self.items = items
+        self.reset()
+
+    def reset(self):
+        """Resets the cycle."""
+        self.pos = 0
+
+    @property
+    def current(self):
+        """Returns the current item."""
+        return self.items[self.pos]
+
+    def next(self):
+        """Goes one item ahead and returns it."""
+        rv = self.current
+        self.pos = (self.pos + 1) % len(self.items)
+        return rv
+
+
 # we have to import it down here as the speedups module imports the
 # markup type which is define above.
 try:
diff --git a/tests/test_debug.py b/tests/test_debug.py
index 2363fe2..7f59445 100644
--- a/tests/test_debug.py
+++ b/tests/test_debug.py
@@ -31,7 +31,7 @@
 >>> tmpl = MODULE.env.get_template('syntaxerror.html')
 Traceback (most recent call last):
   ...
-  File "loaderres/templates/syntaxerror.html", line 4, in <module>
+TemplateSyntaxError: unknown tag 'endif'
+  File "loaderres/templates/syntaxerror.html", line 4
     {% endif %}
-TemplateSyntaxError: unknown tag 'endif' (syntaxerror.html, line 4)
 '''
diff --git a/tests/test_filters.py b/tests/test_filters.py
index f70bb4c..a02d2ae 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -152,8 +152,8 @@
     tmpl = env.from_string(INDENT)
     text = '\n'.join([' '.join(['foo', 'bar'] * 2)] * 2)
     out = tmpl.render(foo=text)
-    assert out == 'foo bar foo bar\n  foo bar foo bar|  ' \
-                  'foo bar foo bar\n  foo bar foo bar'
+    assert out == ('foo bar foo bar\n  foo bar foo bar|  '
+                   'foo bar foo bar\n  foo bar foo bar')
 
 
 def test_int(env):
diff --git a/tests/test_security.py b/tests/test_security.py
index 7c812c0..8ef3edf 100644
--- a/tests/test_security.py
+++ b/tests/test_security.py
@@ -11,7 +11,7 @@
 from jinja2.sandbox import SandboxedEnvironment, \
      ImmutableSandboxedEnvironment, unsafe
 from jinja2 import Markup, escape
-from jinja2.exceptions import SecurityError
+from jinja2.exceptions import SecurityError, TemplateSyntaxError
 
 
 class PrivateStuff(object):
@@ -62,17 +62,12 @@
 '''
 
 
-test_restricted = '''
->>> env = MODULE.SandboxedEnvironment()
->>> env.from_string("{% for item.attribute in seq %}...{% endfor %}")
-Traceback (most recent call last):
-    ...
-TemplateSyntaxError: expected token 'in', got '.' (line 1)
->>> env.from_string("{% for foo, bar.baz in seq %}...{% endfor %}")
-Traceback (most recent call last):
-    ...
-TemplateSyntaxError: expected token 'in', got '.' (line 1)
-'''
+def test_restricted():
+    env = SandboxedEnvironment()
+    raises(TemplateSyntaxError, env.from_string,
+           "{% for item.attribute in seq %}...{% endfor %}")
+    raises(TemplateSyntaxError, env.from_string,
+           "{% for foo, bar.baz in seq %}...{% endfor %}")
 
 
 test_immutable_environment = '''
@@ -87,6 +82,7 @@
 SecurityError: access to attribute 'clear' of 'dict' object is unsafe.
 '''
 
+
 def test_markup_operations():
     # adding two strings should escape the unsafe one
     unsafe = '<script type="application/x-some-script">alert("foo");</script>'
diff --git a/tests/test_various.py b/tests/test_various.py
index 2124473..aab5e76 100644
--- a/tests/test_various.py
+++ b/tests/test_various.py
@@ -9,6 +9,7 @@
 import gc
 from py.test import raises
 from jinja2 import escape
+from jinja2.utils import Cycler
 from jinja2.exceptions import TemplateSyntaxError
 
 
@@ -84,3 +85,15 @@
     assert tmpl.render(seq=(None, 1, "foo")) == '||1|foo'
     tmpl = env.from_string('<{{ none }}>')
     assert tmpl.render() == '<>'
+
+
+def test_cycler():
+    items = 1, 2, 3
+    c = Cycler(*items)
+    for item in items + items:
+        assert c.current == item
+        assert c.next() == item
+    c.next()
+    assert c.current == 2
+    c.reset()
+    assert c.current == 1