Skip to content

Commit 1eef5ba

Browse files
authored
🐛 preserve percent-encoded dots in keys during decoding (#23)
1 parent e7f9749 commit 1eef5ba

6 files changed

Lines changed: 333 additions & 67 deletions

File tree

src/qs_codec/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .decode import decode, load, loads
2020
from .encode import dumps, encode
2121
from .enums.charset import Charset
22+
from .enums.decode_kind import DecodeKind
2223
from .enums.duplicates import Duplicates
2324
from .enums.format import Format
2425
from .enums.list_format import ListFormat
@@ -36,6 +37,7 @@
3637
"loads",
3738
"load",
3839
"Charset",
40+
"DecodeKind",
3941
"Duplicates",
4042
"Format",
4143
"ListFormat",

src/qs_codec/decode.py

Lines changed: 73 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414

1515
import re
1616
import typing as t
17+
from collections.abc import Mapping
18+
from dataclasses import replace
1719
from math import isinf
1820

1921
from .enums.charset import Charset
22+
from .enums.decode_kind import DecodeKind
2023
from .enums.duplicates import Duplicates
2124
from .enums.sentinel import Sentinel
2225
from .models.decode_options import DecodeOptions
@@ -26,8 +29,8 @@
2629

2730

2831
def decode(
29-
value: t.Optional[t.Union[str, t.Mapping[str, t.Any]]],
30-
options: DecodeOptions = DecodeOptions(),
32+
value: t.Optional[t.Union[str, Mapping[str, t.Any]]],
33+
options: t.Optional[DecodeOptions] = None,
3134
) -> t.Dict[str, t.Any]:
3235
"""
3336
Decode a query string (or a pre-tokenized mapping) into a nested ``Dict[str, Any]``.
@@ -60,30 +63,40 @@ def decode(
6063
if not value:
6164
return obj
6265

63-
if not isinstance(value, (str, dict)):
64-
raise ValueError("The input must be a String or a Dict")
66+
if not isinstance(value, (str, Mapping)):
67+
raise ValueError("value must be a str or a Mapping[str, Any]")
6568

66-
temp_obj: t.Optional[t.Dict[str, t.Any]] = (
67-
_parse_query_string_values(value, options) if isinstance(value, str) else value
68-
)
69+
# Work on a local copy so any internal toggles don't leak to caller
70+
opts = replace(options) if options is not None else DecodeOptions()
6971

70-
# If a raw query string produced *more parts than list_limit*, turn list parsing off.
71-
# This prevents O(n^2) behavior when the input is a very large flat list of tokens.
72-
# We only toggle for this call (mutating the local `options` instance by design,
73-
# consistent with the other ports).
74-
if temp_obj is not None and options.parse_lists and 0 < options.list_limit < len(temp_obj):
75-
options.parse_lists = False
72+
# Temporarily toggle parse_lists for THIS call only, and only for raw strings
73+
orig_parse_lists = opts.parse_lists
74+
try:
75+
if isinstance(value, str) and orig_parse_lists:
76+
# Pre-count parameters so we can decide on toggling before tokenization/decoding
77+
_s = value.replace("?", "", 1) if opts.ignore_query_prefix else value
78+
if isinstance(opts.delimiter, re.Pattern):
79+
_parts_count = len(re.split(opts.delimiter, _s)) if _s else 0
80+
else:
81+
_parts_count = (_s.count(opts.delimiter) + 1) if _s else 0
82+
if 0 < opts.list_limit < _parts_count:
83+
opts.parse_lists = False
84+
temp_obj: t.Optional[t.Dict[str, t.Any]] = (
85+
_parse_query_string_values(value, opts) if isinstance(value, str) else dict(value)
86+
)
7687

77-
# Iterate over the keys and setup the new object
78-
if temp_obj:
79-
for key, val in temp_obj.items():
80-
new_obj: t.Any = _parse_keys(key, val, options, isinstance(value, str))
88+
# Iterate over the keys and setup the new object
89+
if temp_obj:
90+
for key, val in temp_obj.items():
91+
new_obj: t.Any = _parse_keys(key, val, opts, isinstance(value, str))
8192

82-
if not obj and isinstance(new_obj, dict):
83-
obj = new_obj
84-
continue
93+
if not obj and isinstance(new_obj, dict):
94+
obj = new_obj
95+
continue
8596

86-
obj = Utils.merge(obj, new_obj, options) # type: ignore [assignment]
97+
obj = Utils.merge(obj, new_obj, opts) # type: ignore [assignment]
98+
finally:
99+
opts.parse_lists = orig_parse_lists
87100

88101
return Utils.compact(obj)
89102

@@ -92,7 +105,7 @@ def decode(
92105
load = decode
93106

94107

95-
def loads(value: t.Optional[str], options: DecodeOptions = DecodeOptions()) -> t.Dict[str, t.Any]:
108+
def loads(value: t.Optional[str], options: t.Optional[DecodeOptions] = None) -> t.Dict[str, t.Any]:
96109
"""
97110
Alias for ``decode``. Decodes a query string into a ``Dict[str, Any]``.
98111
@@ -168,21 +181,30 @@ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str
168181
raise ValueError("Parameter limit must be a positive integer.")
169182

170183
parts: t.List[str]
171-
# Split using either a compiled regex or a literal string delimiter (do it once, then slice if needed).
172-
if isinstance(options.delimiter, re.Pattern):
173-
_all_parts = re.split(options.delimiter, clean_str)
174-
else:
175-
_all_parts = clean_str.split(options.delimiter)
176-
177-
if (limit is not None) and limit:
178-
_take = limit + 1 if options.raise_on_limit_exceeded else limit
179-
parts = _all_parts[:_take]
184+
if limit is None:
185+
# Unlimited parameters: split fully
186+
if isinstance(options.delimiter, re.Pattern):
187+
parts = re.split(options.delimiter, clean_str)
188+
else:
189+
parts = clean_str.split(options.delimiter)
180190
else:
181-
parts = _all_parts
182-
183-
# Enforce parameter count when strict mode is enabled.
184-
if options.raise_on_limit_exceeded and (limit is not None) and len(parts) > limit:
185-
raise ValueError(f"Parameter limit exceeded: Only {limit} parameter{'' if limit == 1 else 's'} allowed.")
191+
if options.raise_on_limit_exceeded:
192+
# Split to at most limit+1 parts so we can detect overflow
193+
if isinstance(options.delimiter, re.Pattern):
194+
parts = re.split(options.delimiter, clean_str, maxsplit=limit)
195+
else:
196+
parts = clean_str.split(options.delimiter, limit)
197+
if len(parts) > limit:
198+
raise ValueError(
199+
f"Parameter limit exceeded: Only {limit} parameter{'s' if limit != 1 else ''} allowed."
200+
)
201+
else:
202+
# Silent degrade: split fully, then take the first `limit` parts
203+
if isinstance(options.delimiter, re.Pattern):
204+
parts = re.split(options.delimiter, clean_str)
205+
else:
206+
parts = clean_str.split(options.delimiter)
207+
parts = parts[:limit]
186208

187209
skip_index: int = -1 # Keep track of where the utf8 sentinel was found
188210
i: int
@@ -200,6 +222,9 @@ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str
200222
skip_index = i
201223
break
202224

225+
# Local, non-optional decoder reference for type-checkers
226+
decoder_fn: t.Callable[..., t.Optional[str]] = options.decoder or DecodeUtils.decode
227+
203228
# Iterate over parts and decode each key/value pair.
204229
for i, _ in enumerate(parts):
205230
if i == skip_index:
@@ -209,20 +234,25 @@ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str
209234
bracket_equals_pos: int = part.find("]=")
210235
pos: int = part.find("=") if bracket_equals_pos == -1 else (bracket_equals_pos + 1)
211236

212-
key: str
213-
val: t.Union[t.List[t.Any], t.Tuple[t.Any], str, t.Any]
237+
# Decode key and value with a key-aware decoder; skip pairs whose key decodes to None
214238
if pos == -1:
215-
key = options.decoder(part, charset)
216-
val = None if options.strict_null_handling else ""
239+
key_decoded = decoder_fn(part, charset, kind=DecodeKind.KEY)
240+
if key_decoded is None:
241+
continue
242+
key: str = key_decoded
243+
val: t.Any = None if options.strict_null_handling else ""
217244
else:
218-
key = options.decoder(part[:pos], charset)
245+
key_decoded = decoder_fn(part[:pos], charset, kind=DecodeKind.KEY)
246+
if key_decoded is None:
247+
continue
248+
key = key_decoded
219249
val = Utils.apply(
220250
_parse_array_value(
221251
part[pos + 1 :],
222252
options,
223253
len(obj[key]) if key in obj and isinstance(obj[key], (list, tuple)) else 0,
224254
),
225-
lambda v: options.decoder(v, charset),
255+
lambda v: decoder_fn(v, charset, kind=DecodeKind.VALUE),
226256
)
227257

228258
if val and options.interpret_numeric_entities and charset == Charset.LATIN1:
@@ -344,7 +374,7 @@ def _parse_keys(given_key: t.Optional[str], val: t.Any, options: DecodeOptions,
344374

345375
keys: t.List[str] = DecodeUtils.split_key_into_segments(
346376
original_key=given_key,
347-
allow_dots=options.allow_dots,
377+
allow_dots=t.cast(bool, options.allow_dots),
348378
max_depth=options.depth,
349379
strict_depth=options.strict_depth,
350380
)

src/qs_codec/enums/decode_kind.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Decoding context used by the query string parser and utilities.
2+
3+
This enum indicates whether a given piece of text is being decoded as a *key*
4+
(or key segment) or as a *value*. The distinction matters for encoded dots
5+
(``%2E``/``%2e``) in keys: when decoding keys, the default behavior is to
6+
*preserve* these so that higher‑level options like ``allow_dots`` and
7+
``decode_dot_in_keys`` can be applied consistently later in the parse.
8+
"""
9+
10+
from enum import Enum
11+
12+
13+
class DecodeKind(str, Enum):
14+
"""Decoding context for query string tokens.
15+
16+
Attributes
17+
----------
18+
KEY
19+
Decode a *key* (or key segment). Implementations typically preserve
20+
percent‑encoded dots (``%2E``/``%2e``) so that dot‑splitting semantics can
21+
be applied later according to parser options.
22+
VALUE
23+
Decode a *value*. Implementations typically perform full percent decoding.
24+
"""
25+
26+
KEY = "key" # Key/segment decode; preserve encoded dots for later splitting logic
27+
VALUE = "value" # Value decode; fully percent-decode
28+
29+
30+
__all__ = ["DecodeKind"]

0 commit comments

Comments
 (0)