Skip to content

Commit

Permalink
Merge pull request #684 from ThiefMaster/set-namespaces
Browse files Browse the repository at this point in the history
Add namespace objects that support attribute assignment
  • Loading branch information
ThiefMaster committed Jun 24, 2017
2 parents b73cb48 + e0475f9 commit c7dfa21
Show file tree
Hide file tree
Showing 12 changed files with 168 additions and 11 deletions.
3 changes: 3 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ Version 2.10
- Added `changed(*values)` to loop contexts, providing an easy way of checking
whether a value has changed since the last iteration (or rather since the
last call of the method)
- Added a `namespace` function that creates a special object which allows
attribute assignment using the `set` tag. This can be used to carry data
across scopes, e.g. from a loop body to code that comes after the loop.

Version 2.9.6
-------------
Expand Down
2 changes: 2 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,8 @@ Exceptions
unicode strings is that Python 2.x is not using unicode for exceptions
and tracebacks as well as the compiler. This will change with Python 3.

.. autoexception:: jinja2.TemplateRuntimeError

.. autoexception:: jinja2.TemplateAssertionError


Expand Down
41 changes: 41 additions & 0 deletions docs/templates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,24 @@ Assignments use the `set` tag and can have multiple targets::
did not iterate
{% endfor %}

As of version 2.10 more complex use cases can be handled using namespace
objects which allow propagating of changes across scopes::

{% set ns = namespace(found=false) %}
{% for item in items %}
{% if item.check_something() %}
{% set ns.found = true %}
{% endif %}
* {{ item.title }}
{% endfor %}
Found item having something: {{ ns.found }}

Note hat the ``obj.attr`` notation in the `set` tag is only allowed for
namespace objects; attempting to assign an attribute on any other object
will raise an exception.

.. versionadded:: 2.10 Added support for namespace objects


Block Assignments
~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -1400,6 +1418,29 @@ The following functions are available in the global scope by default:

.. versionadded:: 2.1

.. class:: namespace(...)

Creates a new container that allows attribute assignment using the
``{% set %}`` tag::

{% set ns = namespace() %}
{% set ns.foo = 'bar' %}

The main purpose of this is to allow carrying a value from within a loop
body to an outer scope. Initial values can be provided as a dict, as
keyword arguments, or both (same behavior as Python's `dict` constructor)::

{% set ns = namespace(found=false) %}
{% for item in items %}
{% if item.check_something() %}
{% set ns.found = true %}
{% endif %}
* {{ item.title }}
{% endfor %}
Found item having something: {{ ns.found }}

.. versionadded:: 2.10


Extensions
----------
Expand Down
3 changes: 2 additions & 1 deletion jinja2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
# exceptions
from jinja2.exceptions import TemplateError, UndefinedError, \
TemplateNotFound, TemplatesNotFound, TemplateSyntaxError, \
TemplateAssertionError
TemplateAssertionError, TemplateRuntimeError

# decorators and public utilities
from jinja2.filters import environmentfilter, contextfilter, \
Expand All @@ -64,6 +64,7 @@
'MemcachedBytecodeCache', 'Undefined', 'DebugUndefined',
'StrictUndefined', 'TemplateError', 'UndefinedError', 'TemplateNotFound',
'TemplatesNotFound', 'TemplateSyntaxError', 'TemplateAssertionError',
'TemplateRuntimeError',
'ModuleLoader', 'environmentfilter', 'contextfilter', 'Markup', 'escape',
'environmentfunction', 'contextfunction', 'clear_caches', 'is_undefined',
'evalcontextfilter', 'evalcontextfunction', 'make_logging_undefined',
Expand Down
12 changes: 12 additions & 0 deletions jinja2/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1403,6 +1403,18 @@ def visit_Name(self, node, frame):

self.write(ref)

def visit_NSRef(self, node, frame):
# NSRefs can only be used to store values; since they use the normal
# `foo.bar` notation they will be parsed as a normal attribute access
# when used anywhere but in a `set` context
ref = frame.symbols.ref(node.name)
self.writeline('if not isinstance(%s, Namespace):' % ref)
self.indent()
self.writeline('raise TemplateRuntimeError(%r)' %
'cannot assign attribute on non-namespace object')
self.outdent()
self.writeline('%s[%r]' % (ref, node.attr))

def visit_Const(self, node, frame):
val = node.as_const(frame.eval_ctx)
if isinstance(val, float):
Expand Down
5 changes: 3 additions & 2 deletions jinja2/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
:license: BSD, see LICENSE for more details.
"""
from jinja2._compat import range_type
from jinja2.utils import generate_lorem_ipsum, Cycler, Joiner
from jinja2.utils import generate_lorem_ipsum, Cycler, Joiner, Namespace


# defaults for the parser / lexer
Expand All @@ -35,7 +35,8 @@
'dict': dict,
'lipsum': generate_lorem_ipsum,
'cycler': Cycler,
'joiner': Joiner
'joiner': Joiner,
'namespace': Namespace
}


Expand Down
3 changes: 3 additions & 0 deletions jinja2/idtracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ def visit_Name(self, node, store_as_param=False, **kwargs):
elif node.ctx == 'load':
self.symbols.load(node.name)

def visit_NSRef(self, node, **kwargs):
self.symbols.load(node.name)

def visit_If(self, node, **kwargs):
self.visit(node.test, **kwargs)

Expand Down
12 changes: 12 additions & 0 deletions jinja2/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,18 @@ def can_assign(self):
'True', 'False', 'None')


class NSRef(Expr):
"""Reference to a namespace value assignment"""
fields = ('name', 'attr')

def can_assign(self):
# We don't need any special checks here; NSRef assignments have a
# runtime check to ensure the target is a namespace object which will
# have been checked already as it is created using a normal assignment
# which goes through a `Name` node.
return True


class Literal(Expr):
"""Baseclass for literals."""
abstract = True
Expand Down
14 changes: 10 additions & 4 deletions jinja2/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def parse_statements(self, end_tokens, drop_needle=False):
def parse_set(self):
"""Parse an assign statement."""
lineno = next(self.stream).lineno
target = self.parse_assign_target()
target = self.parse_assign_target(with_namespace=True)
if self.stream.skip_if('assign'):
expr = self.parse_tuple()
return nodes.Assign(target, expr, lineno=lineno)
Expand Down Expand Up @@ -395,15 +395,21 @@ def parse_print(self):
return node

def parse_assign_target(self, with_tuple=True, name_only=False,
extra_end_rules=None):
extra_end_rules=None, with_namespace=False):
"""Parse an assignment target. As Jinja2 allows assignments to
tuples, this function can parse all allowed assignment targets. Per
default assignments to tuples are parsed, that can be disable however
by setting `with_tuple` to `False`. If only assignments to names are
wanted `name_only` can be set to `True`. The `extra_end_rules`
parameter is forwarded to the tuple parsing function.
parameter is forwarded to the tuple parsing function. If
`with_namespace` is enabled, a namespace assignment may be parsed.
"""
if name_only:
if with_namespace and self.stream.look().type == 'dot':
token = self.stream.expect('name')
next(self.stream) # dot
attr = self.stream.expect('name')
target = nodes.NSRef(token.value, attr.value, lineno=token.lineno)
elif name_only:
token = self.stream.expect('name')
target = nodes.Name(token.value, 'store', lineno=token.lineno)
else:
Expand Down
4 changes: 2 additions & 2 deletions jinja2/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from jinja2.nodes import EvalContext, _context_function_types
from jinja2.utils import Markup, soft_unicode, escape, missing, concat, \
internalcode, object_type_repr, evalcontextfunction
internalcode, object_type_repr, evalcontextfunction, Namespace
from jinja2.exceptions import UndefinedError, TemplateRuntimeError, \
TemplateNotFound
from jinja2._compat import imap, text_type, iteritems, \
Expand All @@ -27,7 +27,7 @@
__all__ = ['LoopContext', 'TemplateReference', 'Macro', 'Markup',
'TemplateRuntimeError', 'missing', 'concat', 'escape',
'markup_join', 'unicode_join', 'to_string', 'identity',
'TemplateNotFound']
'TemplateNotFound', 'Namespace']

#: the name of the function that is used to convert something into
#: a string. We can just use the text type here.
Expand Down
23 changes: 23 additions & 0 deletions jinja2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,29 @@ def __call__(self):
return self.sep


class Namespace(object):
"""A namespace object that can hold arbitrary attributes. It may be
initialized from a dictionary or with keyword argments."""

def __init__(*args, **kwargs):
self, args = args[0], args[1:]
self.__attrs = dict(*args, **kwargs)

def __getattribute__(self, name):
if name == '_Namespace__attrs':
return object.__getattribute__(self, name)
try:
return self.__attrs[name]
except KeyError:
raise AttributeError(name)

def __setitem__(self, name, value):
self.__attrs[name] = value

def __repr__(self):
return '<Namespace %r>' % self.__attrs


# does this python version support async for in and async generators?
try:
exec('async def _():\n async for _ in ():\n yield _')
Expand Down
57 changes: 55 additions & 2 deletions tests/test_core_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
:license: BSD, see LICENSE for more details.
"""
import pytest
from jinja2 import Environment, TemplateSyntaxError, UndefinedError, \
DictLoader
from jinja2 import Environment, TemplateSyntaxError, TemplateRuntimeError, \
UndefinedError, DictLoader


@pytest.fixture
Expand Down Expand Up @@ -391,6 +391,59 @@ def test_block_escaping(self):
'{% endset %}foo: {{ foo }}')
assert tmpl.render(test='<unsafe>') == 'foo: <em>&lt;unsafe&gt;</em>'

def test_set_invalid(self, env_trim):
pytest.raises(TemplateSyntaxError, env_trim.from_string,
"{% set foo['bar'] = 1 %}")
tmpl = env_trim.from_string('{% set foo.bar = 1 %}')
exc_info = pytest.raises(TemplateRuntimeError, tmpl.render, foo={})
assert 'non-namespace object' in exc_info.value.message

def test_namespace_redefined(self, env_trim):
tmpl = env_trim.from_string('{% set ns = namespace() %}'
'{% set ns.bar = "hi" %}')
exc_info = pytest.raises(TemplateRuntimeError, tmpl.render,
namespace=dict)
assert 'non-namespace object' in exc_info.value.message

def test_namespace(self, env_trim):
tmpl = env_trim.from_string('{% set ns = namespace() %}'
'{% set ns.bar = "42" %}'
'{{ ns.bar }}')
assert tmpl.render() == '42'

def test_namespace_block(self, env_trim):
tmpl = env_trim.from_string('{% set ns = namespace() %}'
'{% set ns.bar %}42{% endset %}'
'{{ ns.bar }}')
assert tmpl.render() == '42'

def test_init_namespace(self, env_trim):
tmpl = env_trim.from_string('{% set ns = namespace(d, self=37) %}'
'{% set ns.b = 42 %}'
'{{ ns.a }}|{{ ns.self }}|{{ ns.b }}')
assert tmpl.render(d={'a': 13}) == '13|37|42'

def test_namespace_loop(self, env_trim):
tmpl = env_trim.from_string('{% set ns = namespace(found=false) %}'
'{% for x in range(4) %}'
'{% if x == v %}'
'{% set ns.found = true %}'
'{% endif %}'
'{% endfor %}'
'{{ ns.found }}')
assert tmpl.render(v=3) == 'True'
assert tmpl.render(v=4) == 'False'

def test_namespace_macro(self, env_trim):
tmpl = env_trim.from_string('{% set ns = namespace() %}'
'{% set ns.a = 13 %}'
'{% macro magic(x) %}'
'{% set x.b = 37 %}'
'{% endmacro %}'
'{{ magic(ns) }}'
'{{ ns.a }}|{{ ns.b }}')
assert tmpl.render() == '13|37'


@pytest.mark.core_tags
@pytest.mark.with_
Expand Down

0 comments on commit c7dfa21

Please sign in to comment.