Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

⚡ decode returns dict[str, Any] instead of dict #4

Merged
merged 3 commits into from
May 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ dictionaries
def decode(
value: t.Optional[t.Union[str, t.Mapping]],
options: qs_codec.DecodeOptions = qs_codec.DecodeOptions(),
) -> dict:
) -> t.Dict[str, t.Any]:
"""Decodes a str or Mapping into a Dict.

Providing custom DecodeOptions will override the default behavior."""
Expand Down Expand Up @@ -328,7 +328,7 @@ over this huge ``list``.

import qs_codec

assert qs_codec.decode('a[100]=b') == {'a': {100: 'b'}}
assert qs_codec.decode('a[100]=b') == {'a': {'100': 'b'}}

This limit can be overridden by passing an `list_limit <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.decode_options.DecodeOptions.list_limit>`__
option:
Expand All @@ -340,7 +340,7 @@ option:
assert qs_codec.decode(
'a[1]=b',
qs_codec.DecodeOptions(list_limit=0),
) == {'a': {1: 'b'}}
) == {'a': {'1': 'b'}}

To disable ``list`` parsing entirely, set `parse_lists <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.decode_options.DecodeOptions.parse_lists>`__
to ``False``.
Expand All @@ -352,15 +352,15 @@ to ``False``.
assert qs_codec.decode(
'a[]=b',
qs_codec.DecodeOptions(parse_lists=False),
) == {'a': {0: 'b'}}
) == {'a': {'0': 'b'}}

If you mix notations, `decode <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.decode>`__ will merge the two items into a ``dict``:

.. code:: python

import qs_codec

assert qs_codec.decode('a[0]=b&a[b]=c') == {'a': {0: 'b', 'b': 'c'}}
assert qs_codec.decode('a[0]=b&a[b]=c') == {'a': {'0': 'b', 'b': 'c'}}

You can also create ``list``\ s of ``dict``\ s:

Expand Down
10 changes: 5 additions & 5 deletions docs/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ dictionaries
def decode(
value: t.Optional[t.Union[str, t.Mapping]],
options: qs_codec.DecodeOptions = qs_codec.DecodeOptions(),
) -> dict:
) -> t.Dict[str, t.Any]:
"""Decodes a str or Mapping into a Dict.

Providing custom DecodeOptions will override the default behavior."""
Expand Down Expand Up @@ -303,7 +303,7 @@ over this huge ``list``.

import qs_codec

assert qs_codec.decode('a[100]=b') == {'a': {100: 'b'}}
assert qs_codec.decode('a[100]=b') == {'a': {'100': 'b'}}

This limit can be overridden by passing an :py:attr:`list_limit <qs_codec.models.decode_options.DecodeOptions.list_limit>`
option:
Expand All @@ -315,7 +315,7 @@ option:
assert qs_codec.decode(
'a[1]=b',
qs_codec.DecodeOptions(list_limit=0),
) == {'a': {1: 'b'}}
) == {'a': {'1': 'b'}}

To disable ``list`` parsing entirely, set :py:attr:`parse_lists <qs_codec.models.decode_options.DecodeOptions.parse_lists>`
to ``False``.
Expand All @@ -327,15 +327,15 @@ to ``False``.
assert qs_codec.decode(
'a[]=b',
qs_codec.DecodeOptions(parse_lists=False),
) == {'a': {0: 'b'}}
) == {'a': {'0': 'b'}}

If you mix notations, :py:attr:`decode <qs_codec.decode>` will merge the two items into a ``dict``:

.. code:: python

import qs_codec

assert qs_codec.decode('a[0]=b&a[b]=c') == {'a': {0: 'b', 'b': 'c'}}
assert qs_codec.decode('a[0]=b&a[b]=c') == {'a': {'0': 'b', 'b': 'c'}}

You can also create ``list``\ s of ``dict``\ s:

Expand Down
25 changes: 15 additions & 10 deletions src/qs_codec/decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,25 @@
from .utils.utils import Utils


def decode(value: t.Optional[t.Union[str, t.Mapping]], options: DecodeOptions = DecodeOptions()) -> dict:
def decode(
value: t.Optional[t.Union[str, t.Dict[str, t.Any]]], options: DecodeOptions = DecodeOptions()
) -> t.Dict[str, t.Any]:
"""
Decodes a ``str`` or ``Mapping`` into a ``dict``.

Providing custom ``DecodeOptions`` will override the default behavior.
"""
obj: t.Dict[str, t.Any] = {}

if not value:
return dict()
return obj

if not isinstance(value, (str, t.Mapping)):
if not isinstance(value, (str, dict)):
raise ValueError("The input must be a String or a Dict")

temp_obj: t.Optional[t.Mapping] = _parse_query_string_values(value, options) if isinstance(value, str) else value
obj: t.Dict = dict()
temp_obj: t.Optional[t.Dict[str, t.Any]] = (
_parse_query_string_values(value, options) if isinstance(value, str) else value
)

# Iterate over the keys and setup the new object
if temp_obj:
Expand All @@ -49,8 +54,8 @@ def _parse_array_value(value: t.Any, options: DecodeOptions) -> t.Any:
return value


def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict:
obj: t.Dict = dict()
def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str, t.Any]:
obj: t.Dict[str, t.Any] = {}

clean_str: str = value.replace("?", "", 1) if options.ignore_query_prefix else value
clean_str = clean_str.replace("%5B", "[").replace("%5b", "[").replace("%5D", "]").replace("%5d", "]")
Expand Down Expand Up @@ -119,7 +124,7 @@ def _parse_object(

i: int
for i in reversed(range(len(chain))):
obj: t.Optional[t.Any]
obj: t.Optional[t.Union[t.Dict[str, t.Any], t.List[t.Any]]]
root: str = chain[i]

if root == "[]" and options.parse_lists:
Expand All @@ -141,7 +146,7 @@ def _parse_object(
index = None

if not options.parse_lists and decoded_root == "":
obj = {0: leaf}
obj = {"0": leaf}
elif (
index is not None
and index >= 0
Expand All @@ -153,7 +158,7 @@ def _parse_object(
obj = [Undefined() for _ in range(index + 1)]
obj[index] = leaf
else:
obj[index if index is not None else decoded_root] = leaf
obj[str(index) if index is not None else decoded_root] = leaf

leaf = obj

Expand Down
20 changes: 10 additions & 10 deletions src/qs_codec/utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ class Utils:

@staticmethod
def merge(
target: t.Optional[t.Union[t.Mapping, t.List, t.Tuple]],
source: t.Optional[t.Union[t.Mapping, t.List, t.Tuple, t.Any]],
target: t.Optional[t.Union[t.Mapping[str, t.Any], t.List[t.Any], t.Tuple]],
source: t.Optional[t.Union[t.Mapping[str, t.Any], t.List[t.Any], t.Tuple, t.Any]],
options: DecodeOptions = DecodeOptions(),
) -> t.Union[t.Dict, t.List, t.Tuple, t.Any]:
) -> t.Union[t.Dict[str, t.Any], t.List, t.Tuple, t.Any]:
"""Merge two objects together."""
if source is None:
return target
Expand Down Expand Up @@ -60,7 +60,7 @@ def merge(
if isinstance(source, (list, tuple)):
target = {
**target,
**{i: item for i, item in enumerate(source) if not isinstance(item, Undefined)},
**{str(i): item for i, item in enumerate(source) if not isinstance(item, Undefined)},
}
elif source is not None:
if not isinstance(target, (list, tuple)) and isinstance(source, (list, tuple)):
Expand All @@ -72,7 +72,7 @@ def merge(
if target is None or not isinstance(target, t.Mapping):
if isinstance(target, (list, tuple)):
return {
**{i: item for i, item in enumerate(target) if not isinstance(item, Undefined)},
**{str(i): item for i, item in enumerate(target) if not isinstance(item, Undefined)},
**source,
}

Expand All @@ -86,24 +86,24 @@ def merge(
if not isinstance(el, Undefined)
]

merge_target: t.Dict = (
dict(enumerate(el for el in source if not isinstance(el, Undefined)))
merge_target: t.Dict[str, t.Any] = (
{str(i): el for i, el in enumerate(source) if not isinstance(el, Undefined)}
if isinstance(target, (list, tuple)) and not isinstance(source, (list, tuple))
else copy.deepcopy(dict(target) if not isinstance(target, dict) else target)
)

return {
**merge_target,
**{
key: Utils.merge(merge_target[key], value, options) if key in merge_target else value
str(key): Utils.merge(merge_target[key], value, options) if key in merge_target else value
for key, value in source.items()
},
}

@staticmethod
def compact(value: t.Dict) -> t.Dict:
def compact(value: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]:
"""Remove all `Undefined` values from a dictionary."""
queue: t.List[t.Dict] = [{"obj": {"o": value}, "prop": "o"}]
queue: t.List[t.Dict[str, t.Any]] = [{"obj": {"o": value}, "prop": "o"}]
refs: t.List = []

for i in range(len(queue)): # pylint: disable=C0200
Expand Down
54 changes: 27 additions & 27 deletions tests/unit/decode_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def test_throws_an_error_if_the_input_is_not_a_string_or_a_dict(self) -> None:
@pytest.mark.parametrize(
"encoded, decoded, options",
[
("0=foo", {0: "foo"}, None),
("0=foo", {"0": "foo"}, None),
("foo=c++", {"foo": "c "}, None),
("a[>=]=23", {"a": {">=": "23"}}, None),
("a[<=>]==23", {"a": {"<=>": "=23"}}, None),
Expand Down Expand Up @@ -189,14 +189,14 @@ def test_allows_to_specify_list_indices(self) -> None:
assert decode("a[1]=c&a[0]=b&a[2]=d") == {"a": ["b", "c", "d"]}
assert decode("a[1]=c&a[0]=b") == {"a": ["b", "c"]}
assert decode("a[1]=c", DecodeOptions(list_limit=20)) == {"a": ["c"]}
assert decode("a[1]=c", DecodeOptions(list_limit=0)) == {"a": {1: "c"}}
assert decode("a[1]=c", DecodeOptions(list_limit=0)) == {"a": {"1": "c"}}
assert decode("a[1]=c") == {"a": ["c"]}

def test_limits_specific_list_indices_to_list_limit(self) -> None:
assert decode("a[20]=a", DecodeOptions(list_limit=20)) == {"a": ["a"]}
assert decode("a[21]=a", DecodeOptions(list_limit=20)) == {"a": {21: "a"}}
assert decode("a[21]=a", DecodeOptions(list_limit=20)) == {"a": {"21": "a"}}
assert decode("a[20]=a") == {"a": ["a"]}
assert decode("a[21]=a") == {"a": {21: "a"}}
assert decode("a[21]=a") == {"a": {"21": "a"}}

def test_supports_keys_that_begin_with_a_number(self) -> None:
assert decode("a[12b]=c") == {"a": {"12b": "c"}}
Expand All @@ -217,11 +217,11 @@ def test_allows_empty_values(self) -> None:
assert decode(None) == {}

def test_transforms_lists_to_dicts(self) -> None:
assert decode("foo[0]=bar&foo[bad]=baz") == {"foo": {0: "bar", "bad": "baz"}}
assert decode("foo[bad]=baz&foo[0]=bar") == {"foo": {"bad": "baz", 0: "bar"}}
assert decode("foo[bad]=baz&foo[]=bar") == {"foo": {"bad": "baz", 0: "bar"}}
assert decode("foo[]=bar&foo[bad]=baz") == {"foo": {0: "bar", "bad": "baz"}}
assert decode("foo[bad]=baz&foo[]=bar&foo[]=foo") == {"foo": {"bad": "baz", 0: "bar", 1: "foo"}}
assert decode("foo[0]=bar&foo[bad]=baz") == {"foo": {"0": "bar", "bad": "baz"}}
assert decode("foo[bad]=baz&foo[0]=bar") == {"foo": {"bad": "baz", "0": "bar"}}
assert decode("foo[bad]=baz&foo[]=bar") == {"foo": {"bad": "baz", "0": "bar"}}
assert decode("foo[]=bar&foo[bad]=baz") == {"foo": {"0": "bar", "bad": "baz"}}
assert decode("foo[bad]=baz&foo[]=bar&foo[]=foo") == {"foo": {"bad": "baz", "0": "bar", "1": "foo"}}
assert decode("foo[0][a]=a&foo[0][b]=b&foo[1][a]=aa&foo[1][b]=bb") == {
"foo": [{"a": "a", "b": "b"}, {"a": "aa", "b": "bb"}]
}
Expand All @@ -245,18 +245,18 @@ def test_transforms_lists_to_dicts_dot_notation(self) -> None:
assert decode("foo[0].baz[0]=15&foo[0].baz[1]=16&foo[0].bar=2", DecodeOptions(allow_dots=True)) == {
"foo": [{"baz": ["15", "16"], "bar": "2"}]
}
assert decode("foo.bad=baz&foo[0]=bar", DecodeOptions(allow_dots=True)) == {"foo": {"bad": "baz", 0: "bar"}}
assert decode("foo.bad=baz&foo[]=bar", DecodeOptions(allow_dots=True)) == {"foo": {"bad": "baz", 0: "bar"}}
assert decode("foo[]=bar&foo.bad=baz", DecodeOptions(allow_dots=True)) == {"foo": {0: "bar", "bad": "baz"}}
assert decode("foo.bad=baz&foo[0]=bar", DecodeOptions(allow_dots=True)) == {"foo": {"bad": "baz", "0": "bar"}}
assert decode("foo.bad=baz&foo[]=bar", DecodeOptions(allow_dots=True)) == {"foo": {"bad": "baz", "0": "bar"}}
assert decode("foo[]=bar&foo.bad=baz", DecodeOptions(allow_dots=True)) == {"foo": {"0": "bar", "bad": "baz"}}
assert decode("foo.bad=baz&foo[]=bar&foo[]=foo", DecodeOptions(allow_dots=True)) == {
"foo": {"bad": "baz", 0: "bar", 1: "foo"}
"foo": {"bad": "baz", "0": "bar", "1": "foo"}
}
assert decode("foo[0].a=a&foo[0].b=b&foo[1].a=aa&foo[1].b=bb", DecodeOptions(allow_dots=True)) == {
"foo": [{"a": "a", "b": "b"}, {"a": "aa", "b": "bb"}]
}

def test_correctly_prunes_undefined_values_when_converting_a_list_to_a_dict(self) -> None:
assert decode("a[2]=b&a[99999999]=c") == {"a": {2: "b", 99999999: "c"}}
assert decode("a[2]=b&a[99999999]=c") == {"a": {"2": "b", "99999999": "c"}}

def test_supports_malformed_uri_characters(self) -> None:
assert decode("{%:%}", DecodeOptions(strict_null_handling=True)) == {"{%:%}": None}
Expand Down Expand Up @@ -307,8 +307,8 @@ def test_parses_jquery_param_strings(self) -> None:
) == {"filter": [["int1", "=", "77"], "and", ["int2", "=", "8"]]}

def test_continues_parsing_when_no_parent_is_found(self) -> None:
assert decode("[]=&a=b") == {0: "", "a": "b"}
assert decode("[]&a=b", DecodeOptions(strict_null_handling=True)) == {0: None, "a": "b"}
assert decode("[]=&a=b") == {"0": "", "a": "b"}
assert decode("[]&a=b", DecodeOptions(strict_null_handling=True)) == {"0": None, "a": "b"}
assert decode("[foo]=bar") == {"foo": "bar"}

def test_does_not_error_when_parsing_a_very_long_list(self) -> None:
Expand All @@ -333,16 +333,16 @@ def test_allows_setting_the_parameter_limit_to_infinity(self) -> None:
assert decode("a=b&c=d", DecodeOptions(parameter_limit=float("inf"))) == {"a": "b", "c": "d"}

def test_allows_overriding_list_limit(self) -> None:
assert decode("a[0]=b", DecodeOptions(list_limit=-1)) == {"a": {0: "b"}}
assert decode("a[0]=b", DecodeOptions(list_limit=-1)) == {"a": {"0": "b"}}
assert decode("a[0]=b", DecodeOptions(list_limit=0)) == {"a": ["b"]}
assert decode("a[-1]=b", DecodeOptions(list_limit=-1)) == {"a": {-1: "b"}}
assert decode("a[-1]=b", DecodeOptions(list_limit=0)) == {"a": {-1: "b"}}
assert decode("a[0]=b&a[1]=c", DecodeOptions(list_limit=-1)) == {"a": {0: "b", 1: "c"}}
assert decode("a[0]=b&a[1]=c", DecodeOptions(list_limit=0)) == {"a": {0: "b", 1: "c"}}
assert decode("a[-1]=b", DecodeOptions(list_limit=-1)) == {"a": {"-1": "b"}}
assert decode("a[-1]=b", DecodeOptions(list_limit=0)) == {"a": {"-1": "b"}}
assert decode("a[0]=b&a[1]=c", DecodeOptions(list_limit=-1)) == {"a": {"0": "b", "1": "c"}}
assert decode("a[0]=b&a[1]=c", DecodeOptions(list_limit=0)) == {"a": {"0": "b", "1": "c"}}

def test_allows_disabling_list_parsing(self) -> None:
assert decode("a[0]=b&a[1]=c", DecodeOptions(parse_lists=False)) == {"a": {0: "b", 1: "c"}}
assert decode("a[]=b", DecodeOptions(parse_lists=False)) == {"a": {0: "b"}}
assert decode("a[0]=b&a[1]=c", DecodeOptions(parse_lists=False)) == {"a": {"0": "b", "1": "c"}}
assert decode("a[]=b", DecodeOptions(parse_lists=False)) == {"a": {"0": "b"}}

def test_allows_for_query_string_prefix(self) -> None:
assert decode("?foo=bar", DecodeOptions(ignore_query_prefix=True)) == {"foo": "bar"}
Expand Down Expand Up @@ -486,7 +486,7 @@ def test_can_return_null_dicts(self) -> None:

expected_list: t.Dict[str, t.Any] = {}
expected_list["a"] = {}
expected_list["a"][0] = "b"
expected_list["a"]["0"] = "b"
expected_list["a"]["c"] = "d"
assert decode("a[]=b&a[c]=d") == expected_list

Expand Down Expand Up @@ -533,10 +533,10 @@ def test_parses_a_latin_1_string_if_asked_to(self) -> None:
("a[0]=b&a=c&=", {"a": ["b", "c"]}),
("a=b&a[]=c&=", {"a": ["b", "c"]}),
("a=b&a[0]=c&=", {"a": ["b", "c"]}),
("[]=a&[]=b& []=1", {0: "a", 1: "b", " ": ["1"]}),
("[0]=a&[1]=b&a[0]=1&a[1]=2", {0: "a", 1: "b", "a": ["1", "2"]}),
("[]=a&[]=b& []=1", {"0": "a", "1": "b", " ": ["1"]}),
("[0]=a&[1]=b&a[0]=1&a[1]=2", {"0": "a", "1": "b", "a": ["1", "2"]}),
("[deep]=a&[deep]=2", {"deep": ["a", "2"]}),
("%5B0%5D=a&%5B1%5D=b", {0: "a", 1: "b"}),
("%5B0%5D=a&%5B1%5D=b", {"0": "a", "1": "b"}),
],
)
def test_parses_empty_keys(self, encoded: str, decoded: t.Mapping) -> None:
Expand Down
8 changes: 4 additions & 4 deletions tests/unit/example_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,16 +130,16 @@ def test_lists(self):
# Any `list` members with an index of greater than `20` will instead be converted to a `dict` with the index as
# the key. This is needed to handle cases when someone sent, for example, `a[999999999]` and it will take
# significant time to iterate over this huge `list`.
assert qs_codec.decode("a[100]=b") == {"a": {100: "b"}}
assert qs_codec.decode("a[100]=b") == {"a": {"100": "b"}}

# This limit can be overridden by passing an `DecodeOptions.list_limit` option:
assert qs_codec.decode("a[1]=b", qs_codec.DecodeOptions(list_limit=0)) == {"a": {1: "b"}}
assert qs_codec.decode("a[1]=b", qs_codec.DecodeOptions(list_limit=0)) == {"a": {"1": "b"}}

# To disable List parsing entirely, set `DecodeOptions.parse_lists` to `False`.
assert qs_codec.decode("a[]=b", qs_codec.DecodeOptions(parse_lists=False)) == {"a": {0: "b"}}
assert qs_codec.decode("a[]=b", qs_codec.DecodeOptions(parse_lists=False)) == {"a": {"0": "b"}}

# If you mix notations, **qs_codec** will merge the two items into a `dict`:
assert qs_codec.decode("a[0]=b&a[b]=c") == {"a": {0: "b", "b": "c"}}
assert qs_codec.decode("a[0]=b&a[b]=c") == {"a": {"0": "b", "b": "c"}}

# You can also create `list`s of `dict`s:
# (**qs_codec** cannot convert nested `dict`s, such as `'a={b:1},{c:d}'`)
Expand Down
Loading
Loading