From f5bdff0af83c4d6e631015264c7bd57e468e7db4 Mon Sep 17 00:00:00 2001 From: cherith <315844584+Cherith1222@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:23:34 +0800 Subject: [PATCH] Keep wildcard keys after a null or empty list A wildcard stopped scanning when a mapped value was null, or when a list value was empty, so later keys were dropped. Remember whether another key remains instead of treating those values as the end. Fixes #409 Fixes #283 AI-assisted (Cursor) --- CHANGELOG.rst | 1 + flask_restx/fields.py | 10 +++++++++ flask_restx/marshalling.py | 6 ++--- tests/test_marshalling.py | 45 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ea0152da..b89343b4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -46,6 +46,7 @@ Bug Fixes :: * Adjust field tests for Python 3.14 (``staticmethod`` around ``functools.partial`` used as a class attribute). [python-restx] + * Keep later wildcard keys when a mapped value is null or an empty list (#409, #283) [Cherith1222] .. _section-1.3.1: 1.3.1 diff --git a/flask_restx/fields.py b/flask_restx/fields.py index 9bbb9c79..fbae6525 100644 --- a/flask_restx/fields.py +++ b/flask_restx/fields.py @@ -819,9 +819,13 @@ class Wildcard(Raw): _obj = None _cache = set() _last = None + # True once output() has no further matching key. A mapped value of + # None or [] is still a real value and must not set this. + _exhausted = False def __init__(self, cls_or_instance, **kwargs): super(Wildcard, self).__init__(**kwargs) + self._exhausted = False error_msg = "The type of the wildcard elements must be a subclass of fields.Raw" if isinstance(cls_or_instance, type): if not issubclass(cls_or_instance, Raw): @@ -866,9 +870,11 @@ def reset(self): self._obj = None self._cache = set() self._last = None + self._exhausted = False def output(self, key, obj, ordered=False): value = None + found = False reg = fnmatch.translate(key) if self._flatten(obj): @@ -889,12 +895,16 @@ def output(self, key, obj, ordered=False): and re.match(reg, objkey, re.IGNORECASE) ): value = val + found = True self._cache.add(objkey) self._last = objkey break except IndexError: break + # None and [] are valid mapped values. Only "no remaining key" + # ends the wildcard scan (see marshalling.marshal). + self._exhausted = not found if value is None: if self.default is not None: return self.container.format(self.default) diff --git a/flask_restx/marshalling.py b/flask_restx/marshalling.py index df4bbecf..c0833a99 100644 --- a/flask_restx/marshalling.py +++ b/flask_restx/marshalling.py @@ -82,11 +82,9 @@ def _append(k, v): key = field.key or dkey _append(key, value) - while True: + while not field._exhausted: value = field.output(dkey, data, ordered=ordered) - if value is None or value == field.container.format( - field.default - ): + if field._exhausted: break key = field.key _append(key, value) diff --git a/tests/test_marshalling.py b/tests/test_marshalling.py index d6846ea9..702b0fe5 100644 --- a/tests/test_marshalling.py +++ b/tests/test_marshalling.py @@ -93,6 +93,51 @@ def test_marshal_wildcard_with_skip_none(self): output = marshal(marshal_dict, model, skip_none=True) assert output == {"baz": "biz"} + def test_marshal_wildcard_keeps_null_and_later_keys(self): + # A null value used to end the scan, dropping every key not yet visited. + wild = fields.Wildcard(fields.String) + model = {"*": wild} + data = {"a": 1, "b": "2", "c": None, "d": 20, "e": "e"} + assert marshal(data, model) == { + "a": "1", + "b": "2", + "c": None, + "d": "20", + "e": "e", + } + assert marshal(data, model, skip_none=True) == { + "a": "1", + "b": "2", + "d": "20", + "e": "e", + } + ordered = marshal(data, model, ordered=True) + assert list(ordered) == ["a", "b", "c", "d", "e"] + + def test_marshal_wildcard_keeps_empty_lists(self): + # List.format(None) is [], so an empty list compared equal to the + # "no more keys" sentinel and stopped the scan. + wild = fields.Wildcard(fields.List(fields.String)) + model = {"*": wild} + data = {"a": ["1"], "b": [], "c": ["3"]} + assert marshal(data, model) == {"a": ["1"], "b": [], "c": ["3"]} + + nested = {"id": fields.String(), "name": fields.String()} + wild_list = fields.Wildcard(fields.List(fields.Nested(nested))) + payload = { + "listMap": { + "key7": [{"id": "1", "name": "Guido"}], + "key8": [], + "key9": [{"id": "3", "name": "Linus"}], + } + } + output = marshal(payload, {"listMap": fields.Nested({"*": wild_list})}) + assert output["listMap"] == { + "key7": [{"id": "1", "name": "Guido"}], + "key8": [], + "key9": [{"id": "3", "name": "Linus"}], + } + def test_marshal_decorator(self): model = OrderedDict([("foo", fields.Raw)])