1414
1515import re
1616import typing as t
17+ from collections .abc import Mapping
18+ from dataclasses import replace
1719from math import isinf
1820
1921from .enums .charset import Charset
22+ from .enums .decode_kind import DecodeKind
2023from .enums .duplicates import Duplicates
2124from .enums .sentinel import Sentinel
2225from .models .decode_options import DecodeOptions
2629
2730
2831def 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(
92105load = 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 )
0 commit comments