From 51850951dc050d0c999aa65e789ef7eae5f376d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klemen=20Tu=C5=A1ar?= Date: Sat, 24 May 2025 09:16:38 +0100 Subject: [PATCH 1/2] :bug: fix #18 list parsing behavior and improve test cases for DecodeOptions --- src/qs_codec/decode.py | 3 +++ src/qs_codec/utils/utils.py | 2 +- tests/unit/decode_test.py | 44 ++++++++++++++++++++++++++----------- tests/unit/example_test.py | 2 +- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/qs_codec/decode.py b/src/qs_codec/decode.py index 4449b21..e753a16 100644 --- a/src/qs_codec/decode.py +++ b/src/qs_codec/decode.py @@ -35,6 +35,9 @@ def decode( _parse_query_string_values(value, options) if isinstance(value, str) else value ) + if options.parse_lists and 0 < options.list_limit < len(temp_obj): + options.parse_lists = False + # Iterate over the keys and setup the new object if temp_obj: for key, val in temp_obj.items(): diff --git a/src/qs_codec/utils/utils.py b/src/qs_codec/utils/utils.py index 7023332..c16015c 100644 --- a/src/qs_codec/utils/utils.py +++ b/src/qs_codec/utils/utils.py @@ -38,7 +38,7 @@ def merge( else: target_[len(target_)] = source - if any(isinstance(value, Undefined) for value in target_.values()): + if not options.parse_lists and any(isinstance(value, Undefined) for value in target_.values()): target = {str(i): target_[i] for i in target_ if not isinstance(target_[i], Undefined)} else: target = list(filter(lambda el: not isinstance(el, Undefined), target_.values())) diff --git a/tests/unit/decode_test.py b/tests/unit/decode_test.py index 379be71..0dd7ca3 100644 --- a/tests/unit/decode_test.py +++ b/tests/unit/decode_test.py @@ -252,9 +252,7 @@ def test_parses_an_explicit_list(self, query: str, expected: t.Dict) -> None: pytest.param("a[]=b&a=c", None, {"a": ["b", "c"]}, id="explicit-first-mix-simple-second"), pytest.param("a[0]=b&a=c", None, {"a": ["b", "c"]}, id="indexed-list-first"), pytest.param("a=b&a[0]=c", None, {"a": ["b", "c"]}, id="simple-first-indexed-list-second"), - pytest.param( - "a[1]=b&a=c", DecodeOptions(list_limit=20), {"a": {"1": "b", "2": "c"}}, id="indexed-list-with-limit" - ), + pytest.param("a[1]=b&a=c", DecodeOptions(list_limit=20), {"a": ["b", "c"]}, id="indexed-list-with-limit"), pytest.param( "a[]=b&a=c", DecodeOptions(list_limit=0), {"a": ["b", "c"]}, id="explicit-list-with-zero-limit" ), @@ -292,6 +290,30 @@ def test_parses_a_nested_list(self, query: str, expected: t.Mapping[str, t.Any]) pytest.param("a[1]=c", DecodeOptions(list_limit=20), {"a": ["c"]}, id="list-limit-20"), pytest.param("a[1]=c", DecodeOptions(list_limit=0), {"a": {"1": "c"}}, id="list-limit-0"), pytest.param("a[1]=c", None, {"a": ["c"]}, id="default-behavior"), + pytest.param( + "a[0]=b&a[2]=c", + DecodeOptions(parse_lists=True), + {"a": ["b", "c"]}, + id="list-starting-with-0-with-missing-index-parse-lists-true", + ), + pytest.param( + "a[0]=b&a[2]=c", + DecodeOptions(parse_lists=False), + {"a": {"0": "b", "2": "c"}}, + id="list-starting-with-0-with-missing-index-parse-lists-false", + ), + pytest.param( + "a[1]=b&a[15]=c", + DecodeOptions(parse_lists=False), + {"a": {"1": "b", "15": "c"}}, + id="list-starting-with-non-0-with-missing-index-parse-lists-false", + ), + pytest.param( + "a[1]=b&a[15]=c", + DecodeOptions(parse_lists=True), + {"a": ["b", "c"]}, + id="list-starting-with-non-0-with-missing-index-parse-lists-false", + ), ], ) def test_allows_to_specify_list_indices( @@ -513,22 +535,18 @@ def test_allows_for_empty_strings_in_lists( assert result == expected @pytest.mark.parametrize( - "query, expected, not_expected", + "query, expected", [ - pytest.param("a[10]=1&a[2]=2", {"a": {"10": "1", "2": "2"}}, {"a": ["2", "1"]}, id="sparse-list"), - pytest.param("a[1][b][2][c]=1", {"a": [{"b": [{"c": "1"}]}]}, None, id="nested-list-of-dicts"), - pytest.param("a[1][2][3][c]=1", {"a": [[[{"c": "1"}]]]}, None, id="deeper-nested-list"), - pytest.param("a[1][2][3][c][1]=1", {"a": [[[{"c": ["1"]}]]]}, None, id="deepest-nested-list"), + pytest.param("a[10]=1&a[2]=2", {"a": ["2", "1"]}, id="sparse-list"), + pytest.param("a[1][b][2][c]=1", {"a": [{"b": [{"c": "1"}]}]}, id="nested-list-of-dicts"), + pytest.param("a[1][2][3][c]=1", {"a": [[[{"c": "1"}]]]}, id="deeper-nested-list"), + pytest.param("a[1][2][3][c][1]=1", {"a": [[[{"c": ["1"]}]]]}, id="deepest-nested-list"), ], ) - def test_compacts_sparse_lists( - self, query: str, expected: t.Mapping[str, t.Any], not_expected: t.Optional[t.Mapping[str, t.Any]] - ) -> None: + def test_compacts_sparse_lists(self, query: str, expected: t.Mapping[str, t.Any]) -> None: opts = DecodeOptions(list_limit=20) result = decode(query, opts) assert result == expected - if not_expected is not None: - assert result != not_expected @pytest.mark.parametrize( "query, expected", diff --git a/tests/unit/example_test.py b/tests/unit/example_test.py index 6de9475..38ab51c 100644 --- a/tests/unit/example_test.py +++ b/tests/unit/example_test.py @@ -120,7 +120,7 @@ def test_lists(self): # Note that the only difference between an index in a `list` and a key in a `dict` is that the value between the # brackets must be a number to create a `list`. When creating `list`s with specific indices, **qs_codec** will compact # a sparse `list` to only the existing values preserving their order: - assert qs_codec.decode("a[1]=b&a[15]=c") == {"a": {"1": "b", "15": "c"}} + assert qs_codec.decode("a[1]=b&a[15]=c") == {"a": ["b", "c"]} # Note that an empty string is also a value, and will be preserved: assert qs_codec.decode("a[]=&a[]=b") == {"a": ["", "b"]} From f2edaed826b7c7c13dba8c984e3a43db476e98d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Klemen=20Tu=C5=A1ar?= Date: Sat, 24 May 2025 09:21:45 +0100 Subject: [PATCH 2/2] :bug: fix list parsing condition to handle None values in DecodeOptions --- src/qs_codec/decode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qs_codec/decode.py b/src/qs_codec/decode.py index e753a16..a3edd26 100644 --- a/src/qs_codec/decode.py +++ b/src/qs_codec/decode.py @@ -35,7 +35,7 @@ def decode( _parse_query_string_values(value, options) if isinstance(value, str) else value ) - if options.parse_lists and 0 < options.list_limit < len(temp_obj): + if temp_obj is not None and options.parse_lists and 0 < options.list_limit < len(temp_obj): options.parse_lists = False # Iterate over the keys and setup the new object