From ee4fcf5196ec6ff10ddc6c9b683743600d184d88 Mon Sep 17 00:00:00 2001 From: Ben Falk Date: Mon, 18 May 2026 11:47:40 -0700 Subject: [PATCH] map and groupby filters accept explicit `none` as default value --- CHANGES.rst | 5 +++++ src/jinja2/filters.py | 18 +++++++++++------- tests/test_filters.py | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 338dc9966..087d808d4 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,11 @@ Unreleased - Use modern packaging metadata with ``pyproject.toml`` instead of ``setup.cfg``. :pr:`1793` - Use ``flit_core`` instead of ``setuptools`` as build backend. +- The ``map`` filter (with ``attribute=...``) and ``groupby`` filter + accept an explicit ``none`` value for ``default``. Previously, + ``default=none`` was indistinguishable from omitting ``default``, so + missing attributes and keys were not replaced with ``None``. + :issue:`2165` Version 3.1.6 diff --git a/src/jinja2/filters.py b/src/jinja2/filters.py index c46e20c10..779e2e8cc 100644 --- a/src/jinja2/filters.py +++ b/src/jinja2/filters.py @@ -21,6 +21,7 @@ from .exceptions import FilterArgumentError from .runtime import Undefined from .utils import htmlsafe_json_dumps +from .utils import missing from .utils import pass_context from .utils import pass_environment from .utils import pass_eval_context @@ -59,7 +60,7 @@ def make_attrgetter( environment: "Environment", attribute: str | int | None, postprocess: t.Callable[[t.Any], t.Any] | None = None, - default: t.Any | None = None, + default: t.Any | None = missing, ) -> t.Callable[[t.Any], t.Any]: """Returns a callable that looks up the given attribute from a passed object with the rules of the environment. Dots are allowed @@ -72,7 +73,7 @@ def attrgetter(item: t.Any) -> t.Any: for part in parts: item = environment.getitem(item, part) - if default is not None and isinstance(item, Undefined): + if default is not missing and isinstance(item, Undefined): item = default if postprocess is not None: @@ -1199,7 +1200,7 @@ def sync_do_groupby( environment: "Environment", value: "t.Iterable[V]", attribute: str | int, - default: t.Any | None = None, + default: t.Any | None = missing, case_sensitive: bool = False, ) -> "list[_GroupTuple]": """Group a sequence of objects by an attribute using Python's @@ -1283,7 +1284,7 @@ async def do_groupby( environment: "Environment", value: "t.AsyncIterable[V] | t.Iterable[V]", attribute: str | int, - default: t.Any | None = None, + default: t.Any | None = missing, case_sensitive: bool = False, ) -> "list[_GroupTuple]": expr = make_attrgetter( @@ -1443,7 +1444,7 @@ def sync_do_map( value: t.Iterable[t.Any], *, attribute: str = ..., - default: t.Any | None = None, + default: t.Any | None = missing, ) -> t.Iterable[t.Any]: ... @@ -1513,7 +1514,7 @@ def do_map( value: t.AsyncIterable[t.Any] | t.Iterable[t.Any], *, attribute: str = ..., - default: t.Any | None = None, + default: t.Any | None = missing, ) -> t.Iterable[t.Any]: ... @@ -1720,7 +1721,10 @@ def prepare_map( ) -> t.Callable[[t.Any], t.Any]: if not args and "attribute" in kwargs: attribute = kwargs.pop("attribute") - default = kwargs.pop("default", None) + if "default" in kwargs: + default = kwargs.pop("default") + else: + default = missing if kwargs: raise FilterArgumentError( diff --git a/tests/test_filters.py b/tests/test_filters.py index 4601469a6..bcb912061 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -9,6 +9,8 @@ from jinja2 import TemplateRuntimeError from jinja2 import UndefinedError from jinja2.exceptions import TemplateAssertionError +from jinja2.nativetypes import NativeEnvironment +from jinja2.runtime import Undefined class Magic: @@ -741,6 +743,25 @@ def test_map_default(self, env): assert test_list.render(users=users) == "lennon, edwards, None, ['smith', 'x']" assert test_str.render(users=users) == "lennon, edwards, None, " + def test_map_explicit_none_default(self): + env = NativeEnvironment() + tmpl = env.from_string('{{ [{}]|map(attribute="foo", default=none)|list }}') + assert tmpl.render() == [None] + + def test_map_omitted_default_remains_undefined(self): + env = NativeEnvironment() + tmpl = env.from_string('{{ [{}]|map(attribute="foo")|list }}') + items = tmpl.render() + assert len(items) == 1 + assert isinstance(items[0], Undefined) + + def test_groupby_explicit_none_default(self): + env = NativeEnvironment() + tmpl = env.from_string( + '{{ [{}]|groupby("k", default=none)|map(attribute="grouper")|first }}' + ) + assert tmpl.render() is None + def test_simple_select(self, env): env = Environment() tmpl = env.from_string('{{ [1, 2, 3, 4, 5]|select("odd")|join("|") }}')