Repository: pallets/jinja (python)

Issue:
Add case_sensitive parameter for groupby() filter
`groupby()` filter performs auto sorting under the hood, and it should support specifying case sensitivity for the sort order, the same way `sort()` filter does.


Current `groupby()` implementation always performs case-sensitive sorting under the hood, which makes it inflexible, and behave inconsistently with how the `sort()` filter works (case-insensitive by default). When used in the same template for the same dataset, `groupby()` and `sort()` filters produce different sort order on the same fields.

I think, I would personally prefer to not have any implicit sorting in `groupby()`, and chain it with `sort()` explicitly, but I do understand that auto sorting was, probably, added to `groupby()` for convenience.

Implementing an option to control case sensitivity seems to be pretty straightforward:

- A `case_sensitive` parameter can be added to `do_groupby()` function, similar to `do_sort()`.
- `make_attrgetter()` inside `do_groupby()` should be called with `postprocess=ignore_case` if `case_sensitive==False`.

If this feature request looks reasonable, I should be able to submit a pull request.


Pull request description:
Add case_sensitive parameter to groupby() filter
This is a quick implementation test to add support for case-insensitive sorting inside the `groupby()` filter, intended to verify the approach.

Changes:

- Add `case_sensitive` parameter to `sync_do_groupby()`.
- Ignore case when sorting in `sync_do_groupby()` if `case_sensitive=False`

Open questions:

1. Is this approach correct?
2. Should async groupby (`async def do_groupby`) also be changed?
3. Should the default value for `case_sensitive` parameter be `False` for backwards compatibility, or `True` to align with the `sort()` filter behavior?

- fixes #1463 

Checklist:

- [ ] 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.
- [ ] Run `pre-commit` hooks and fix any issues.
- [ ] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index d67381b1..88e167d2 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -33,6 +33,9 @@ Unreleased
 -   Add ``items`` filter. :issue:`1561`
 -   Subscriptions (``[0]``, etc.) can be used after filters, tests, and
     calls when the environment is in async mode. :issue:`1573`
+-   The ``groupby`` filter is case-insensitive by default, matching
+    other comparison filters. Added the ``case_sensitive`` parameter to
+    control this. :issue:`1463`
 
 
 Version 3.0.3
diff --git a/src/jinja2/filters.py b/src/jinja2/filters.py
index 80ea6504..7e097098 100644
--- a/src/jinja2/filters.py
+++ b/src/jinja2/filters.py
@@ -1163,7 +1163,8 @@ def sync_do_groupby(
     value: "t.Iterable[V]",
     attribute: t.Union[str, int],
     default: t.Optional[t.Any] = None,
-) -> "t.List[t.Tuple[t.Any, t.List[V]]]":
+    case_sensitive: bool = False,
+) -> "t.List[_GroupTuple]":
     """Group a sequence of objects by an attribute using Python's
     :func:`itertools.groupby`. The attribute can use dot notation for
     nested access, like ``"address.city"``. Unlike Python's ``groupby``,
@@ -1203,18 +1204,42 @@ def sync_do_groupby(
           <li>{{ city }}: {{ items|map(attribute="name")|join(", ") }}</li>
         {% endfor %}</ul>
 
+    Like the :func:`~jinja-filters.sort` filter, sorting and grouping is
+    case-insensitive by default. The ``key`` for each group will have
+    the case of the first item in that group of values. For example, if
+    a list of users has cities ``["CA", "NY", "ca"]``, the "CA" group
+    will have two values. This can be disabled by passing
+    ``case_sensitive=True``.
+
+    .. versionchanged:: 3.1
+        Added the ``case_sensitive`` parameter. Sorting and grouping is
+        case-insensitive by default, matching other filters that do
+        comparisons.
+
     .. versionchanged:: 3.0
         Added the ``default`` parameter.
 
     .. versionchanged:: 2.6
         The attribute supports dot notation for nested access.
     """
-    expr = make_attrgetter(environment, attribute, default=default)
-    return [
+    expr = make_attrgetter(
+        environment,
+        attribute,
+        postprocess=ignore_case if not case_sensitive else None,
+        default=default,
+    )
+    out = [
         _GroupTuple(key, list(values))
         for key, values in groupby(sorted(value, key=expr), expr)
     ]
 
+    if not case_sensitive:
+        # Return the real key from the first value instead of the lowercase key.
+        output_expr = make_attrgetter(environment, attribute, default=default)
+        out = [_GroupTuple(output_expr(values[0]), values) for _, values in out]
+
+    return out
+
 
 @async_variant(sync_do_groupby)  # type: ignore
 async def do_groupby(
@@ -1222,13 +1247,26 @@ async def do_groupby(
     value: "t.Union[t.AsyncIterable[V], t.Iterable[V]]",
     attribute: t.Union[str, int],
     default: t.Optional[t.Any] = None,
-) -> "t.List[t.Tuple[t.Any, t.List[V]]]":
-    expr = make_attrgetter(environment, attribute, default=default)
-    return [
+    case_sensitive: bool = False,
+) -> "t.List[_GroupTuple]":
+    expr = make_attrgetter(
+        environment,
+        attribute,
+        postprocess=ignore_case if not case_sensitive else None,
+        default=default,
+    )
+    out = [
         _GroupTuple(key, await auto_to_list(values))
         for key, values in groupby(sorted(await auto_to_list(value), key=expr), expr)
     ]
 
+    if not case_sensitive:
+        # Return the real key from the first value instead of the lowercase key.
+        output_expr = make_attrgetter(environment, attribute, default=default)
+        out = [_GroupTuple(output_expr(values[0]), values) for _, values in out]
+
+    return out
+
 
 @pass_environment
 def sync_do_sum(


Test patch:
diff --git a/tests/test_async_filters.py b/tests/test_async_filters.py
index 5d4f332e..f5b2627a 100644
--- a/tests/test_async_filters.py
+++ b/tests/test_async_filters.py
@@ -57,6 +57,26 @@ def test_groupby(env_async, items):
     ]
 
 
+@pytest.mark.parametrize(
+    ("case_sensitive", "expect"),
+    [
+        (False, "a: 1, 3\nb: 2\n"),
+        (True, "A: 3\na: 1\nb: 2\n"),
+    ],
+)
+def test_groupby_case(env_async, case_sensitive, expect):
+    tmpl = env_async.from_string(
+        "{% for k, vs in data|groupby('k', case_sensitive=cs) %}"
+        "{{ k }}: {{ vs|join(', ', attribute='v') }}\n"
+        "{% endfor %}"
+    )
+    out = tmpl.render(
+        data=[{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "A", "v": 3}],
+        cs=case_sensitive,
+    )
+    assert out == expect
+
+
 @mark_dualiter("items", lambda: [("a", 1), ("a", 2), ("b", 1)])
 def test_groupby_tuple_index(env_async, items):
     tmpl = env_async.from_string(
diff --git a/tests/test_filters.py b/tests/test_filters.py
index 43ddf59c..73f0f0be 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -619,6 +619,25 @@ class TestFilter:
         )
         assert out == "NY: emma, john\nWA: smith\n"
 
+    @pytest.mark.parametrize(
+        ("case_sensitive", "expect"),
+        [
+            (False, "a: 1, 3\nb: 2\n"),
+            (True, "A: 3\na: 1\nb: 2\n"),
+        ],
+    )
+    def test_groupby_case(self, env, case_sensitive, expect):
+        tmpl = env.from_string(
+            "{% for k, vs in data|groupby('k', case_sensitive=cs) %}"
+            "{{ k }}: {{ vs|join(', ', attribute='v') }}\n"
+            "{% endfor %}"
+        )
+        out = tmpl.render(
+            data=[{"k": "a", "v": 1}, {"k": "b", "v": 2}, {"k": "A", "v": 3}],
+            cs=case_sensitive,
+        )
+        assert out == expect
+
     def test_filtertag(self, env):
         tmpl = env.from_string(
             "{% filter upper|replace('FOO', 'foo') %}foobar{% endfilter %}"