Repository: pallets/jinja (python)

Issue:
Namespaces do not allow multi-variable assignments
Replace this comment with a clear outline of what the bug is.
-->

In Jinja we can use standard python multi-variable assignments like line 3 below, which successfully evaluates but does not confer benefits of namespaces variables.

However, line 4 below throws an error, `jinja2.exceptions.TemplateSyntaxError: expected token 'end of statement block', got ','`
```
            {% set full = namespace(id="", prev_id="") -%}
            {% for id endpoints recursive %}
              {% set prev_id, id = id, id + "/" + id -%}
              {% set full.prev_id, full.id = full.id, full.id + "/" + id -%}
            {% endfor -%}
```


Include the full traceback if there was an exception.
-->

```
Traceback (most recent call last):
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/app.py", line 2328, in __call__
    return self.wsgi_app(environ, start_response)
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/app.py", line 2314, in wsgi_app
    response = self.handle_exception(e)
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/app.py", line 1760, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/_compat.py", line 36, in reraise
    raise value
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/app.py", line 2311, in wsgi_app
    response = self.full_dispatch_request()
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/app.py", line 1834, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/app.py", line 1737, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/_compat.py", line 36, in reraise
    raise value
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/app.py", line 1832, in full_dispatch_request
    rv = self.dispatch_request()
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/app.py", line 1818, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/home/ubuntu/cap/endpoints/views.py", line 12, in home_page
    return Response(render_template('home.html'))
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/templating.py", line 135, in render_template
    context, ctx.app)
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/flask/templating.py", line 117, in _render
    rv = template.render(context)
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/jinja2/environment.py", line 1090, in render
    self.environment.handle_exception()
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/jinja2/environment.py", line 832, in handle_exception
    reraise(*rewrite_traceback_stack(source=source))
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/jinja2/_compat.py", line 28, in reraise
    raise value.with_traceback(tb)
  File "/home/ubuntu/cap/templates/home.html", line 1, in top-level template code
    {% extends "index.html" %}
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/jinja2/environment.py", line 832, in handle_exception
    reraise(*rewrite_traceback_stack(source=source))
  File "/home/ubuntu/.tox/dev/lib/python3.6/site-packages/jinja2/_compat.py", line 28, in reraise
    raise value.with_traceback(tb)
  File "/home/ubuntu/cap/templates/index.html", line 89, in template
    {% set full.prev_id, full.id = full.id, full.id + "/" + id -%}
jinja2.exceptions.TemplateSyntaxError: expected token 'end of statement block', got ','
```

Environment:

- Python version: 3.6.9
- Jinja version: 2.11.3


Pull request description:
Add support for namespaces in tuple parsing
This fixes a bug that existed because namespaces within `{% set %}` were treated as a special case. This special case had the side-effect of bypassing the code which allows for tuples to be assigned to.

The solution was to make tuple handling (and by extension, primary token handling) aware of namespaces so that namespace tokens can be handled appropriately. This is handled in a backwards-compatible way which ensures that we do not try to parse namespace tokens when we otherwise would be expecting to parse out name tokens with attributes.

Fixes #1413 

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [x] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 521f5a08..2b817985 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -43,6 +43,8 @@ Unreleased
 -   ``urlize`` does not add ``mailto:`` to values like `@a@b`. :pr:`1870`
 -   Tests decorated with `@pass_context`` can be used with the ``|select``
     filter. :issue:`1624`
+-   Using ``set`` for multiple assignment (``a, b = 1, 2``) does not fail when the
+    target is a namespace attribute. :issue:`1413`
 
 
 Version 3.1.4
diff --git a/docs/templates.rst b/docs/templates.rst
index 8db8ccaf..9f376a13 100644
--- a/docs/templates.rst
+++ b/docs/templates.rst
@@ -1678,6 +1678,9 @@ The following functions are available in the global scope by default:
 
     .. versionadded:: 2.10
 
+    .. versionchanged:: 3.2
+        Namespace attributes can be assigned to in multiple assignment.
+
 
 Extensions
 ----------
diff --git a/src/jinja2/compiler.py b/src/jinja2/compiler.py
index ca079070..a4ff6a1b 100644
--- a/src/jinja2/compiler.py
+++ b/src/jinja2/compiler.py
@@ -1581,6 +1581,29 @@ class CodeGenerator(NodeVisitor):
 
     def visit_Assign(self, node: nodes.Assign, frame: Frame) -> None:
         self.push_assign_tracking()
+
+        # ``a.b`` is allowed for assignment, and is parsed as an NSRef. However,
+        # it is only valid if it references a Namespace object. Emit a check for
+        # that for each ref here, before assignment code is emitted. This can't
+        # be done in visit_NSRef as the ref could be in the middle of a tuple.
+        seen_refs: t.Set[str] = set()
+
+        for nsref in node.find_all(nodes.NSRef):
+            if nsref.name in seen_refs:
+                # Only emit the check for each reference once, in case the same
+                # ref is used multiple times in a tuple, `ns.a, ns.b = c, d`.
+                continue
+
+            seen_refs.add(nsref.name)
+            ref = frame.symbols.ref(nsref.name)
+            self.writeline(f"if not isinstance({ref}, Namespace):")
+            self.indent()
+            self.writeline(
+                "raise TemplateRuntimeError"
+                '("cannot assign attribute on non-namespace object")'
+            )
+            self.outdent()
+
         self.newline(node)
         self.visit(node.target, frame)
         self.write(" = ")
@@ -1637,17 +1660,11 @@ class CodeGenerator(NodeVisitor):
         self.write(ref)
 
     def visit_NSRef(self, node: nodes.NSRef, frame: Frame) -> None:
-        # 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
+        # NSRef is a dotted assignment target a.b=c, but uses a[b]=c internally.
+        # visit_Assign emits code to validate that each ref is to a Namespace
+        # object only. That can't be emitted here as the ref could be in the
+        # middle of a tuple assignment.
         ref = frame.symbols.ref(node.name)
-        self.writeline(f"if not isinstance({ref}, Namespace):")
-        self.indent()
-        self.writeline(
-            "raise TemplateRuntimeError"
-            '("cannot assign attribute on non-namespace object")'
-        )
-        self.outdent()
         self.writeline(f"{ref}[{node.attr!r}]")
 
     def visit_Const(self, node: nodes.Const, frame: Frame) -> None:
diff --git a/src/jinja2/parser.py b/src/jinja2/parser.py
index 22f3f81f..f4117754 100644
--- a/src/jinja2/parser.py
+++ b/src/jinja2/parser.py
@@ -487,21 +487,18 @@ class Parser:
         """
         target: nodes.Expr
 
-        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:
+        if name_only:
             token = self.stream.expect("name")
             target = nodes.Name(token.value, "store", lineno=token.lineno)
         else:
             if with_tuple:
                 target = self.parse_tuple(
-                    simplified=True, extra_end_rules=extra_end_rules
+                    simplified=True,
+                    extra_end_rules=extra_end_rules,
+                    with_namespace=with_namespace,
                 )
             else:
-                target = self.parse_primary()
+                target = self.parse_primary(with_namespace=with_namespace)
 
             target.set_ctx("store")
 
@@ -643,17 +640,25 @@ class Parser:
             node = self.parse_filter_expr(node)
         return node
 
-    def parse_primary(self) -> nodes.Expr:
+    def parse_primary(self, with_namespace: bool = False) -> nodes.Expr:
+        """Parse a name or literal value. If ``with_namespace`` is enabled, also
+        parse namespace attr refs, for use in assignments."""
         token = self.stream.current
         node: nodes.Expr
         if token.type == "name":
+            next(self.stream)
             if token.value in ("true", "false", "True", "False"):
                 node = nodes.Const(token.value in ("true", "True"), lineno=token.lineno)
             elif token.value in ("none", "None"):
                 node = nodes.Const(None, lineno=token.lineno)
+            elif with_namespace and self.stream.current.type == "dot":
+                # If namespace attributes are allowed at this point, and the next
+                # token is a dot, produce a namespace reference.
+                next(self.stream)
+                attr = self.stream.expect("name")
+                node = nodes.NSRef(token.value, attr.value, lineno=token.lineno)
             else:
                 node = nodes.Name(token.value, "load", lineno=token.lineno)
-            next(self.stream)
         elif token.type == "string":
             next(self.stream)
             buf = [token.value]
@@ -683,6 +688,7 @@ class Parser:
         with_condexpr: bool = True,
         extra_end_rules: t.Optional[t.Tuple[str, ...]] = None,
         explicit_parentheses: bool = False,
+        with_namespace: bool = False,
     ) -> t.Union[nodes.Tuple, nodes.Expr]:
         """Works like `parse_expression` but if multiple expressions are
         delimited by a comma a :class:`~jinja2.nodes.Tuple` node is created.
@@ -690,8 +696,9 @@ class Parser:
         if no commas where found.
 
         The default parsing mode is a full tuple.  If `simplified` is `True`
-        only names and literals are parsed.  The `no_condexpr` parameter is
-        forwarded to :meth:`parse_expression`.
+        only names and literals are parsed; ``with_namespace`` allows namespace
+        attr refs as well. The `no_condexpr` parameter is forwarded to
+        :meth:`parse_expression`.
 
         Because tuples do not require delimiters and may end in a bogus comma
         an extra hint is needed that marks the end of a tuple.  For example
@@ -704,13 +711,14 @@ class Parser:
         """
         lineno = self.stream.current.lineno
         if simplified:
-            parse = self.parse_primary
-        elif with_condexpr:
-            parse = self.parse_expression
+
+            def parse() -> nodes.Expr:
+                return self.parse_primary(with_namespace=with_namespace)
+
         else:
 
             def parse() -> nodes.Expr:
-                return self.parse_expression(with_condexpr=False)
+                return self.parse_expression(with_condexpr=with_condexpr)
 
         args: t.List[nodes.Expr] = []
         is_tuple = False


Test patch:
diff --git a/tests/test_core_tags.py b/tests/test_core_tags.py
index 4bb95e02..2d847a2c 100644
--- a/tests/test_core_tags.py
+++ b/tests/test_core_tags.py
@@ -538,6 +538,14 @@ class TestSet:
         )
         assert tmpl.render() == "13|37"
 
+    def test_namespace_set_tuple(self, env_trim):
+        tmpl = env_trim.from_string(
+            "{% set ns = namespace(a=12, b=36) %}"
+            "{% set ns.a, ns.b = ns.a + 1, ns.b + 1 %}"
+            "{{ ns.a }}|{{ ns.b }}"
+        )
+        assert tmpl.render() == "13|37"
+
     def test_block_escaping_filtered(self):
         env = Environment(autoescape=True)
         tmpl = env.from_string(