Repository: pallets/jinja (python)

Issue:
non-deterministic output from compile templates when using tuple unpacking
<!--
This issue tracker is a tool to address bugs in Jinja itself. Please use
GitHub Discussions or the Pallets Discord for questions about your own code.

Replace this comment with a clear outline of what the bug is.
-->
Ideally compile_templates will have deterministic output so we can use git diff etc

See also https://github.com/pallets/jinja/issues/1453 and https://github.com/pallets/jinja/issues/1452

Found another case where this occurs, e.g. I'm randomly getting these changes or their inverse:

```diff
-    context.vars.update({'triage_reports': l_0_triage_reports, 'open_group_index': l_0_open_group_index})
-    context.exported_vars.update(('triage_reports', 'open_group_index'))
+    context.vars.update({'open_group_index': l_0_open_group_index, 'triage_reports': l_0_triage_reports})
+    context.exported_vars.update(('open_group_index', 'triage_reports'))
```
or:
```diff
-                _loop_vars.update({'row_spans': l_2_row_spans, 'row': l_2_row})
+                _loop_vars.update({'row': l_2_row, 'row_spans': l_2_row_spans})
```
<!--
Describe how to replicate the bug.

Include a minimal reproducible example that demonstrates the bug.
Include the full traceback if there was an exception.
-->
I can see that both of the cases above are a result of using tuple unpacking to set multiple new context vars in a single `set` call
e.g.
```jinja
{% set triage_reports, open_group_index = get_nav_list_items(TRIAGE_REPORTS_NAV, url_name, url_func=url) %}
```
```jinja
{% set row, row_spans = row %}
```

<!--
Describe the expected behavior that should have happened but didn't.
-->
The output from compiling the templates should be stable if the underlying template hasn't changed, rather than varying randomly each time you run it.

Environment:

- Python version: 3.11.5
- Jinja version: 3.1.4


Pull request description:
Make compiled output deterministic for tuple unpacking in set tag
Makes compiled output deterministic, when doing tuple unpacking to set multiple vars in a single `{% set ... %}` call.

fixes #2021

<!--
Ensure each step in CONTRIBUTING.rst is complete, especially the following:

- Add tests that demonstrate the correct behavior of the change. Tests
  should fail without the change.
- Add or update relevant docs, in the docs folder and in code.
- Add an entry in CHANGES.rst summarizing the change and linking to the issue.
- Add `.. versionchanged::` entries in any relevant code docs.
-->


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 6c998e62..aebb38b5 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -20,6 +20,8 @@ Unreleased
     async-aware filter. :issue:`1781`
 -   ``|int`` filter handles ``OverflowError`` from scientific notation.
     :issue:`1921`
+-   Make compiling deterministic for tuple unpacking in a ``{% set ... %}``
+    call. :issue:`2021`
 
 
 Version 3.1.4
diff --git a/src/jinja2/compiler.py b/src/jinja2/compiler.py
index 91720c5f..074e9b18 100644
--- a/src/jinja2/compiler.py
+++ b/src/jinja2/compiler.py
@@ -811,7 +811,7 @@ class CodeGenerator(NodeVisitor):
                 self.writeline("_block_vars.update({")
             else:
                 self.writeline("context.vars.update({")
-            for idx, name in enumerate(vars):
+            for idx, name in enumerate(sorted(vars)):
                 if idx:
                     self.write(", ")
                 ref = frame.symbols.ref(name)
@@ -821,7 +821,7 @@ class CodeGenerator(NodeVisitor):
             if len(public_names) == 1:
                 self.writeline(f"context.exported_vars.add({public_names[0]!r})")
             else:
-                names_str = ", ".join(map(repr, public_names))
+                names_str = ", ".join(map(repr, sorted(public_names)))
                 self.writeline(f"context.exported_vars.update(({names_str}))")
 
     # -- Statement Visitors


Test patch:
diff --git a/tests/test_compile.py b/tests/test_compile.py
index 42a773f2..42efa59c 100644
--- a/tests/test_compile.py
+++ b/tests/test_compile.py
@@ -26,3 +26,64 @@ def test_import_as_with_context_deterministic(tmp_path):
     expect = [f"'bar{i}': " for i in range(10)]
     found = re.findall(r"'bar\d': ", content)[:10]
     assert found == expect
+
+
+def test_top_level_set_vars_unpacking_deterministic(tmp_path):
+    src = "\n".join(f"{{% set a{i}, b{i}, c{i} = tuple_var{i} %}}" for i in range(10))
+    env = Environment(loader=DictLoader({"foo": src}))
+    env.compile_templates(tmp_path, zip=None)
+    name = os.listdir(tmp_path)[0]
+    content = (tmp_path / name).read_text("utf8")
+    expect = [
+        f"context.vars.update({{'a{i}': l_0_a{i}, 'b{i}': l_0_b{i}, 'c{i}': l_0_c{i}}})"
+        for i in range(10)
+    ]
+    found = re.findall(
+        r"context\.vars\.update\(\{'a\d': l_0_a\d, 'b\d': l_0_b\d, 'c\d': l_0_c\d\}\)",
+        content,
+    )[:10]
+    assert found == expect
+    expect = [
+        f"context.exported_vars.update(('a{i}', 'b{i}', 'c{i}'))" for i in range(10)
+    ]
+    found = re.findall(
+        r"context\.exported_vars\.update\(\('a\d', 'b\d', 'c\d'\)\)",
+        content,
+    )[:10]
+    assert found == expect
+
+
+def test_loop_set_vars_unpacking_deterministic(tmp_path):
+    src = "\n".join(f"  {{% set a{i}, b{i}, c{i} = tuple_var{i} %}}" for i in range(10))
+    src = f"{{% for i in seq %}}\n{src}\n{{% endfor %}}"
+    env = Environment(loader=DictLoader({"foo": src}))
+    env.compile_templates(tmp_path, zip=None)
+    name = os.listdir(tmp_path)[0]
+    content = (tmp_path / name).read_text("utf8")
+    expect = [
+        f"_loop_vars.update({{'a{i}': l_1_a{i}, 'b{i}': l_1_b{i}, 'c{i}': l_1_c{i}}})"
+        for i in range(10)
+    ]
+    found = re.findall(
+        r"_loop_vars\.update\(\{'a\d': l_1_a\d, 'b\d': l_1_b\d, 'c\d': l_1_c\d\}\)",
+        content,
+    )[:10]
+    assert found == expect
+
+
+def test_block_set_vars_unpacking_deterministic(tmp_path):
+    src = "\n".join(f"  {{% set a{i}, b{i}, c{i} = tuple_var{i} %}}" for i in range(10))
+    src = f"{{% block test %}}\n{src}\n{{% endblock test %}}"
+    env = Environment(loader=DictLoader({"foo": src}))
+    env.compile_templates(tmp_path, zip=None)
+    name = os.listdir(tmp_path)[0]
+    content = (tmp_path / name).read_text("utf8")
+    expect = [
+        f"_block_vars.update({{'a{i}': l_0_a{i}, 'b{i}': l_0_b{i}, 'c{i}': l_0_c{i}}})"
+        for i in range(10)
+    ]
+    found = re.findall(
+        r"_block_vars\.update\(\{'a\d': l_0_a\d, 'b\d': l_0_b\d, 'c\d': l_0_c\d\}\)",
+        content,
+    )[:10]
+    assert found == expect