qs-codec 1.5.0__tar.gz → 1.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {qs_codec-1.5.0 → qs_codec-1.5.2}/CHANGELOG.md +14 -0
  2. {qs_codec-1.5.0 → qs_codec-1.5.2}/PKG-INFO +6 -1
  3. {qs_codec-1.5.0 → qs_codec-1.5.2}/README.rst +5 -0
  4. {qs_codec-1.5.0 → qs_codec-1.5.2}/docs/README.rst +37 -5
  5. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/__init__.py +1 -1
  6. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/decode.py +91 -48
  7. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/encode.py +73 -71
  8. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/decode_options.py +19 -7
  9. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/encode_options.py +2 -3
  10. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/overflow_dict.py +7 -3
  11. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/weak_wrapper.py +3 -3
  12. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/utils/decode_utils.py +34 -31
  13. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/utils/encode_utils.py +33 -35
  14. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/utils/utils.py +81 -60
  15. qs_codec-1.5.2/tests/comparison/.gitignore +1 -0
  16. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/package.json +2 -1
  17. qs_codec-1.5.2/tests/comparison/pnpm-lock.yaml +180 -0
  18. qs_codec-1.5.2/tests/comparison/pnpm-workspace.yaml +5 -0
  19. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/test_cases.json +20 -1
  20. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/decode_options_test.py +21 -1
  21. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/decode_test.py +264 -6
  22. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/encode_test.py +66 -0
  23. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/example_test.py +12 -4
  24. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/thread_safety_test.py +4 -4
  25. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/utils_test.py +31 -9
  26. qs_codec-1.5.2/tests/unit/wpt_urlencoded_parser_test.py +90 -0
  27. qs_codec-1.5.0/tests/comparison/.gitignore +0 -2
  28. {qs_codec-1.5.0 → qs_codec-1.5.2}/.gitignore +0 -0
  29. {qs_codec-1.5.0 → qs_codec-1.5.2}/CODE-OF-CONDUCT.md +0 -0
  30. {qs_codec-1.5.0 → qs_codec-1.5.2}/LICENSE +0 -0
  31. {qs_codec-1.5.0 → qs_codec-1.5.2}/pyproject.toml +0 -0
  32. {qs_codec-1.5.0 → qs_codec-1.5.2}/requirements_dev.txt +0 -0
  33. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/constants/__init__.py +0 -0
  34. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/constants/encode_constants.py +0 -0
  35. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/__init__.py +0 -0
  36. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/charset.py +0 -0
  37. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/decode_kind.py +0 -0
  38. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/duplicates.py +0 -0
  39. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/format.py +0 -0
  40. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/list_format.py +0 -0
  41. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/sentinel.py +0 -0
  42. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/__init__.py +0 -0
  43. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/cycle_state.py +0 -0
  44. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/encode_frame.py +0 -0
  45. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/key_path_node.py +0 -0
  46. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/structured_key_scan.py +0 -0
  47. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/undefined.py +0 -0
  48. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/py.typed +0 -0
  49. {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/utils/__init__.py +0 -0
  50. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/__init__.py +0 -0
  51. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/compare_outputs.sh +0 -0
  52. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/qs.js +0 -0
  53. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/qs.py +0 -0
  54. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/e2e/__init__.py +0 -0
  55. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/e2e/e2e_test.py +0 -0
  56. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/__init__.py +0 -0
  57. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/encode_internal_helpers_test.py +0 -0
  58. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/encode_options_test.py +0 -0
  59. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/fixed_qs_issues_test.py +0 -0
  60. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/key_path_node_test.py +0 -0
  61. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/list_format_test.py +0 -0
  62. {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/weakref_test.py +0 -0
@@ -1,3 +1,17 @@
1
+ ## 1.5.2
2
+
3
+ * [CHORE] simplify mapping and key-iteration checks in encode/decode internals
4
+ * [CHORE] improve readability in `EncodeOptions`, `Utils`, `DecodeUtils`, and `EncodeUtils` helper code
5
+ * [CHORE] expand internal type annotations across encode, decode, utils, and weak-wrapper helpers
6
+
7
+ ## 1.5.1
8
+
9
+ * [FEAT] add `DecodeOptions.strict_merge` for Node `qs` 6.15 `strictMerge` parity
10
+ * [FIX] align `decode` `list_limit` semantics with Node `qs` `arrayLimit` as a maximum element count
11
+ * [FIX] combine bracket-array duplicate assignments regardless of `DecodeOptions.duplicates`
12
+ * [FIX] align `decode` with Node `qs` 6.15.2 by normalizing dotted keys before preserving `depth=0` input
13
+ * [FIX] align `encode` with Node `qs` 6.15.2 by using the configured delimiter after `charset_sentinel`
14
+
1
15
  ## 1.5.0
2
16
 
3
17
  * [FEAT] add the `Programming Language :: Python :: Free Threading :: 3 - Stable` classifier
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qs-codec
3
- Version: 1.5.0
3
+ Version: 1.5.2
4
4
  Summary: A query string encoding and decoding library for Python. Ported from qs for JavaScript.
5
5
  Project-URL: Homepage, https://techouse.github.io/qs_codec/
6
6
  Project-URL: Documentation, https://techouse.github.io/qs_codec/
@@ -1097,6 +1097,8 @@ Other ports
1097
1097
  +----------------------------+---------------------------------------------------------------+-----------------+
1098
1098
  | .NET / C# | `techouse/qs-net <https://github.com/techouse/qs-net>`__ | |nuget| |
1099
1099
  +----------------------------+---------------------------------------------------------------+-----------------+
1100
+ | Rust | `techouse/qs_rust <https://github.com/techouse/qs_rust>`__ | |crates.io| |
1101
+ +----------------------------+---------------------------------------------------------------+-----------------+
1100
1102
  | Node.js (original) | `ljharb/qs <https://github.com/ljharb/qs>`__ | |npm| |
1101
1103
  +----------------------------+---------------------------------------------------------------+-----------------+
1102
1104
 
@@ -1188,6 +1190,9 @@ Holowaychuk <https://github.com/visionmedia/node-querystring>`__
1188
1190
  .. |nuget| image:: https://img.shields.io/nuget/v/QsNet?logo=dotnet&label=NuGet
1189
1191
  :target: https://www.nuget.org/packages/QsNet
1190
1192
  :alt: NuGet version
1193
+ .. |crates.io| image:: https://img.shields.io/crates/v/qs_rust?logo=rust&label=crates.io
1194
+ :target: https://crates.io/crates/qs_rust
1195
+ :alt: crates.io version
1191
1196
  .. |npm| image:: https://img.shields.io/npm/v/qs?logo=javascript&label=npm
1192
1197
  :target: https://www.npmjs.com/package/qs
1193
1198
  :alt: npm version
@@ -1034,6 +1034,8 @@ Other ports
1034
1034
  +----------------------------+---------------------------------------------------------------+-----------------+
1035
1035
  | .NET / C# | `techouse/qs-net <https://github.com/techouse/qs-net>`__ | |nuget| |
1036
1036
  +----------------------------+---------------------------------------------------------------+-----------------+
1037
+ | Rust | `techouse/qs_rust <https://github.com/techouse/qs_rust>`__ | |crates.io| |
1038
+ +----------------------------+---------------------------------------------------------------+-----------------+
1037
1039
  | Node.js (original) | `ljharb/qs <https://github.com/ljharb/qs>`__ | |npm| |
1038
1040
  +----------------------------+---------------------------------------------------------------+-----------------+
1039
1041
 
@@ -1125,6 +1127,9 @@ Holowaychuk <https://github.com/visionmedia/node-querystring>`__
1125
1127
  .. |nuget| image:: https://img.shields.io/nuget/v/QsNet?logo=dotnet&label=NuGet
1126
1128
  :target: https://www.nuget.org/packages/QsNet
1127
1129
  :alt: NuGet version
1130
+ .. |crates.io| image:: https://img.shields.io/crates/v/qs_rust?logo=rust&label=crates.io
1131
+ :target: https://crates.io/crates/qs_rust
1132
+ :alt: crates.io version
1128
1133
  .. |npm| image:: https://img.shields.io/npm/v/qs?logo=javascript&label=npm
1129
1134
  :target: https://www.npmjs.com/package/qs
1130
1135
  :alt: npm version
@@ -196,6 +196,38 @@ change the behavior when duplicate keys are encountered
196
196
  qs.DecodeOptions(duplicates=qs.Duplicates.LAST),
197
197
  ) == {'foo': 'baz'}
198
198
 
199
+ Bracket-array keys always combine, regardless of the duplicate strategy:
200
+
201
+ .. code:: python
202
+
203
+ import qs_codec as qs
204
+
205
+ assert qs.decode(
206
+ 'a=1&a=2&b[]=1&b[]=2',
207
+ qs.DecodeOptions(duplicates=qs.Duplicates.LAST),
208
+ ) == {'a': '2', 'b': ['1', '2']}
209
+
210
+ When a key appears as both an object and a scalar,
211
+ :py:attr:`strict_merge <qs_codec.models.decode_options.DecodeOptions.strict_merge>` wraps the conflicting values in a
212
+ ``list`` by default:
213
+
214
+ .. code:: python
215
+
216
+ import qs_codec as qs
217
+
218
+ assert qs.decode('a[b]=c&a=d') == {'a': [{'b': 'c'}, 'd']}
219
+
220
+ Set ``strict_merge`` to ``False`` to restore the legacy behavior, where non-empty string scalars become object keys:
221
+
222
+ .. code:: python
223
+
224
+ import qs_codec as qs
225
+
226
+ assert qs.decode(
227
+ 'a[b]=c&a=d',
228
+ qs.DecodeOptions(strict_merge=False),
229
+ ) == {'a': {'b': 'c', 'd': True}}
230
+
199
231
  If you have to deal with legacy browsers or services, there’s also
200
232
  support for decoding percent-encoded octets as :py:attr:`LATIN1 <qs_codec.enums.charset.Charset.LATIN1>`:
201
233
 
@@ -310,11 +342,11 @@ Note that an empty ``str``\ing is also a value and will be preserved:
310
342
  assert qs.decode('a[0]=b&a[1]=&a[2]=c') == {'a': ['b', '', 'c']}
311
343
 
312
344
  :py:attr:`decode <qs_codec.decode>` will also limit specifying indices
313
- in a ``list`` to a maximum index of ``20``. Any ``list`` members with an
314
- index of greater than ``20`` will instead be converted to a ``dict`` with
315
- the index as the key. This is needed to handle cases when someone sent,
316
- for example, ``a[999999999]`` and it will take significant time to iterate
317
- over this huge ``list``.
345
+ in a ``list`` to a maximum element count of ``20``. Index ``19`` is the
346
+ last index that can create a default ``list``; index ``20`` and higher
347
+ are converted to a ``dict`` with the index as the key. This is needed to
348
+ handle cases when someone sent, for example, ``a[999999999]`` and it
349
+ would take significant time to iterate over this huge ``list``.
318
350
 
319
351
  .. code:: python
320
352
 
@@ -14,7 +14,7 @@ The package root re-exports the most commonly used functions and enums so you ca
14
14
  """
15
15
 
16
16
  # Package version (PEP 440). Bump in lockstep with distribution metadata.
17
- __version__ = "1.5.0"
17
+ __version__ = "1.5.2"
18
18
 
19
19
  # Public API surface re-exported at the package root.
20
20
  __all__ = [
@@ -24,13 +24,17 @@ from .enums.decode_kind import DecodeKind
24
24
  from .enums.duplicates import Duplicates
25
25
  from .enums.sentinel import Sentinel
26
26
  from .models.decode_options import DecodeOptions
27
- from .models.overflow_dict import OverflowDict
27
+ from .models.overflow_dict import CommaOverflowDict, OverflowDict
28
28
  from .models.structured_key_scan import StructuredKeyScan
29
29
  from .models.undefined import UNDEFINED
30
30
  from .utils.decode_utils import DecodeUtils
31
31
  from .utils.utils import Utils
32
32
 
33
33
 
34
+ def _list_limit_exceeded_message(limit: int) -> str:
35
+ return f"List limit exceeded: Only {limit} element{'' if limit == 1 else 's'} allowed in a list."
36
+
37
+
34
38
  def decode(
35
39
  value: t.Optional[t.Union[str, Mapping[str, t.Any]]],
36
40
  options: t.Optional[DecodeOptions] = None,
@@ -69,12 +73,12 @@ def decode(
69
73
  if not isinstance(value, (str, Mapping)):
70
74
  raise ValueError("value must be a str or a Mapping[str, Any]")
71
75
 
72
- opts = options if options is not None else DecodeOptions()
73
- decode_from_string = isinstance(value, str)
76
+ opts: DecodeOptions = options if options is not None else DecodeOptions()
77
+ decode_from_string: bool = isinstance(value, str)
74
78
  str_value: str = t.cast(str, value) if decode_from_string else ""
75
79
  mapping_value: t.Mapping[str, t.Any] = t.cast(t.Mapping[str, t.Any], value) if not decode_from_string else {}
76
80
 
77
- parse_lists_effective = opts.parse_lists
81
+ parse_lists_effective: bool = opts.parse_lists
78
82
  if decode_from_string and parse_lists_effective:
79
83
  # Keep caller options immutable: compute a local parse_lists switch only for this invocation.
80
84
  query = str_value.replace("?", "", 1) if opts.ignore_query_prefix else str_value
@@ -86,15 +90,15 @@ def decode(
86
90
  parse_lists_effective = False
87
91
 
88
92
  if decode_from_string:
89
- temp_obj: t.Optional[t.Dict[str, t.Any]] = _parse_query_string_values(
90
- str_value, opts, parse_lists=parse_lists_effective
91
- )
93
+ temp_obj: t.Optional[t.Dict[str, t.Any]] = _parse_query_string_values(str_value, opts)
92
94
  else:
93
95
  temp_obj = dict(mapping_value)
94
96
  if not temp_obj:
95
97
  return obj
96
98
 
97
- structured_scan = _scan_structured_keys(temp_obj, opts) if decode_from_string else StructuredKeyScan.empty()
99
+ structured_scan: StructuredKeyScan = (
100
+ _scan_structured_keys(temp_obj, opts) if decode_from_string else StructuredKeyScan.empty()
101
+ )
98
102
  if decode_from_string and not structured_scan.has_any_structured_syntax:
99
103
  return Utils.compact(temp_obj)
100
104
 
@@ -140,18 +144,18 @@ def loads(value: t.Optional[str], options: t.Optional[DecodeOptions] = None) ->
140
144
 
141
145
  def _first_structured_split_index(key: str, allow_dots: bool) -> int:
142
146
  """Return the earliest index that indicates structured syntax in ``key``."""
143
- split_at = key.find("[")
147
+ split_at: int = key.find("[")
144
148
  if not allow_dots:
145
149
  return split_at
146
150
 
147
- dot_index = key.find(".")
151
+ dot_index: int = key.find(".")
148
152
  if dot_index >= 0 and (split_at < 0 or dot_index < split_at):
149
153
  split_at = dot_index
150
154
 
151
- encoded_dot_index = -1
155
+ encoded_dot_index: int = -1
152
156
  if "%" in key:
153
- upper = key.find("%2E")
154
- lower = key.find("%2e")
157
+ upper: int = key.find("%2E")
158
+ lower: int = key.find("%2e")
155
159
  if upper >= 0 and lower >= 0:
156
160
  encoded_dot_index = upper if upper < lower else lower
157
161
  else:
@@ -165,7 +169,7 @@ def _first_structured_split_index(key: str, allow_dots: bool) -> int:
165
169
 
166
170
  def _leading_structured_root(key: str, options: DecodeOptions) -> str:
167
171
  """Extract root key for leading-bracket structured keys (``[]`` normalizes to ``"0"``)."""
168
- segments = DecodeUtils.split_key_into_segments(
172
+ segments: t.List[str] = DecodeUtils.split_key_into_segments(
169
173
  original_key=key,
170
174
  allow_dots=t.cast(bool, options.allow_dots),
171
175
  max_depth=options.depth,
@@ -188,12 +192,12 @@ def _scan_structured_keys(temp_obj: Mapping[str, t.Any], options: DecodeOptions)
188
192
  if not temp_obj:
189
193
  return StructuredKeyScan.empty()
190
194
 
191
- allow_dots = t.cast(bool, options.allow_dots)
195
+ allow_dots: bool = t.cast(bool, options.allow_dots)
192
196
  structured_roots: t.Set[str] = set()
193
197
  structured_keys: t.Set[str] = set()
194
198
 
195
- for key in temp_obj.keys():
196
- split_at = _first_structured_split_index(key, allow_dots)
199
+ for key in temp_obj:
200
+ split_at: int = _first_structured_split_index(key, allow_dots)
197
201
  if split_at < 0:
198
202
  continue
199
203
  structured_keys.add(key)
@@ -221,20 +225,27 @@ def _interpret_numeric_entities(value: str) -> str:
221
225
  return re.sub(r"&#(\d+);", lambda match: chr(int(match.group(1))), value)
222
226
 
223
227
 
224
- def _parse_array_value(value: t.Any, options: DecodeOptions, current_list_length: int) -> t.Any:
228
+ def _parse_array_value(
229
+ value: t.Any,
230
+ options: DecodeOptions,
231
+ current_list_length: int,
232
+ *,
233
+ enforce_comma_limit: bool = True,
234
+ ) -> t.Any:
225
235
  """Post-process a raw scalar for list semantics and enforce ``list_limit``.
226
236
 
227
237
  Behavior
228
238
  --------
229
239
  - If ``comma=True`` and ``value`` is a string that contains commas, split into a list.
240
+ When ``enforce_comma_limit`` is ``True``, over-limit comma values raise or degrade to an ``OverflowDict`` here.
241
+ Raw query-string parsing and mapping key paths ending in ``[]`` pass ``False`` so the caller can account for
242
+ bracket-array key context first.
230
243
  - Otherwise, enforce the per-list length limit by comparing ``current_list_length`` to ``options.list_limit``.
231
244
  When ``raise_on_limit_exceeded=True``, violations raise ``ValueError``.
232
- - When ``list_limit`` is negative:
233
- * if ``raise_on_limit_exceeded=True``, **any** list-growth operation here (e.g., comma-splitting)
234
- raises immediately;
235
- * if ``raise_on_limit_exceeded=False`` (default), comma-splitting still returns a list; numeric
236
- bracket indices are handled later by ``_parse_object`` (where negative ``list_limit`` disables
237
- numeric-index parsing only).
245
+ - When ``list_limit`` is negative, any non-empty comma split exceeds the limit: raising mode raises,
246
+ while non-raising mode degrades to an ``OverflowDict``/``CommaOverflowDict``. Raw query-string
247
+ parsing temporarily returns the split list when ``enforce_comma_limit=False`` so the caller can
248
+ apply bracket-array wrapping before the final limit check.
238
249
 
239
250
  Returns
240
251
  -------
@@ -243,23 +254,19 @@ def _parse_array_value(value: t.Any, options: DecodeOptions, current_list_length
243
254
  """
244
255
  if isinstance(value, str) and value and options.comma and "," in value:
245
256
  split_val: t.List[str] = value.split(",")
246
- if options.raise_on_limit_exceeded and len(split_val) > options.list_limit:
247
- raise ValueError(
248
- f"List limit exceeded: Only {options.list_limit} element{'' if options.list_limit == 1 else 's'} allowed in a list."
249
- )
257
+ if enforce_comma_limit and len(split_val) > options.list_limit:
258
+ if options.raise_on_limit_exceeded:
259
+ raise ValueError(_list_limit_exceeded_message(options.list_limit))
260
+ return CommaOverflowDict({str(i): item for i, item in enumerate(split_val)})
250
261
  return split_val
251
262
 
252
263
  if options.raise_on_limit_exceeded and current_list_length >= options.list_limit:
253
- raise ValueError(
254
- f"List limit exceeded: Only {options.list_limit} element{'' if options.list_limit == 1 else 's'} allowed in a list."
255
- )
264
+ raise ValueError(_list_limit_exceeded_message(options.list_limit))
256
265
 
257
266
  return value
258
267
 
259
268
 
260
- def _parse_query_string_values(
261
- value: str, options: DecodeOptions, *, parse_lists: t.Optional[bool] = None
262
- ) -> t.Dict[str, t.Any]:
269
+ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str, t.Any]:
263
270
  """Tokenize a raw query string into a flat ``Dict[str, Any]``.
264
271
 
265
272
  Responsibilities
@@ -273,7 +280,7 @@ def _parse_query_string_values(
273
280
  * Decode key/value via ``options.decoder`` (default: percent-decoding using the selected ``charset``).
274
281
  Keys are passed with ``kind=DecodeKind.KEY`` and values with ``kind=DecodeKind.VALUE``; a custom decoder
275
282
  may return the raw token or ``None``.
276
- * Apply comma-split list logic to values (handled here). Index-based list growth from bracket segments is applied later in ``_parse_object``. When ``list_limit < 0`` and ``raise_on_limit_exceeded=True``, any comma-split that would increase the list length raises immediately; otherwise the split proceeds.
283
+ * Apply comma-split list logic to values (handled here). Index-based list growth from bracket segments is applied later in ``_parse_object``. When ``list_limit < 0``, comma-split values always exceed the limit: they raise under ``raise_on_limit_exceeded=True`` and degrade to overflow dictionaries otherwise.
277
284
  * Interpret numeric entities for Latin-1 when requested.
278
285
  * Handle empty brackets ``[]`` as list markers (wrapping exactly once).
279
286
  * Merge duplicate keys according to ``duplicates`` policy.
@@ -282,7 +289,6 @@ def _parse_query_string_values(
282
289
  ``_parse_keys`` / ``_parse_object``.
283
290
  """
284
291
  obj: t.Dict[str, t.Any] = {}
285
- parse_lists_enabled = options.parse_lists if parse_lists is None else parse_lists
286
292
 
287
293
  clean_str: str = value.replace("?", "", 1) if options.ignore_query_prefix else value
288
294
  # Normalize %5B/%5D to literal brackets before splitting (case-insensitive).
@@ -342,21 +348,22 @@ def _parse_query_string_values(
342
348
 
343
349
  # Local, non-optional decoder reference for type-checkers
344
350
  decoder_fn: t.Callable[..., t.Optional[str]] = options.decoder or DecodeUtils.decode
345
- duplicates = options.duplicates
351
+ duplicates: Duplicates = options.duplicates
346
352
 
347
353
  # Iterate over parts and decode each key/value pair.
348
- for i, _ in enumerate(parts):
354
+ for i, part in enumerate(parts):
349
355
  if i == skip_index:
350
356
  continue
351
357
 
352
- part: str = parts[i]
353
358
  if not part:
354
359
  continue
355
360
  bracket_equals_pos: int = part.find("]=")
356
361
  pos: int = part.find("=") if bracket_equals_pos == -1 else (bracket_equals_pos + 1)
362
+ bracket_array_assignment: bool = pos != -1 and "[]=" in part
357
363
 
358
364
  # Decode key and value with a key-aware decoder; skip pairs whose key decodes to None
359
- raw_key = ""
365
+ raw_key: str = ""
366
+ list_limit_exceeded: bool = False
360
367
  if pos == -1:
361
368
  key_decoded = decoder_fn(part, charset, kind=DecodeKind.KEY)
362
369
  if key_decoded is None:
@@ -377,7 +384,9 @@ def _parse_query_string_values(
377
384
  part[pos + 1 :],
378
385
  options,
379
386
  len(obj[key]) if key in obj and isinstance(obj[key], (list, tuple)) else 0,
387
+ enforce_comma_limit=False,
380
388
  )
389
+ list_limit_exceeded = isinstance(parsed_value, (list, tuple)) and len(parsed_value) > options.list_limit
381
390
  if isinstance(parsed_value, (list, tuple)):
382
391
  val = [decoder_fn(v, charset, kind=DecodeKind.VALUE) for v in parsed_value]
383
392
  else:
@@ -390,15 +399,21 @@ def _parse_query_string_values(
390
399
 
391
400
  # Upstream parity: if token contains "[]=", only wrap values that are already arrays
392
401
  # (typically produced by comma splitting), preserving list-of-lists semantics.
393
- if parse_lists_enabled and pos != -1 and "[]=" in part and isinstance(val, (list, tuple)):
402
+ if bracket_array_assignment and isinstance(val, (list, tuple)):
394
403
  val = [val]
404
+ list_limit_exceeded = len(val) > options.list_limit
405
+ if list_limit_exceeded and isinstance(val, (list, tuple)):
406
+ if options.raise_on_limit_exceeded:
407
+ raise ValueError(_list_limit_exceeded_message(options.list_limit))
408
+ val = CommaOverflowDict({str(i): item for i, item in enumerate(val)})
395
409
 
396
410
  existing: bool = key in obj
411
+ part_duplicates = Duplicates.COMBINE if bracket_array_assignment else duplicates
397
412
 
398
413
  # Combine/overwrite according to the configured duplicates policy.
399
- if existing and duplicates == Duplicates.COMBINE:
414
+ if existing and part_duplicates == Duplicates.COMBINE:
400
415
  obj[key] = Utils.combine(obj[key], val, options)
401
- elif not existing or duplicates == Duplicates.LAST:
416
+ elif not existing or part_duplicates == Duplicates.LAST:
402
417
  obj[key] = val
403
418
 
404
419
  return obj
@@ -439,7 +454,7 @@ def _parse_object(
439
454
  handled by the splitter.
440
455
  - When list parsing is disabled and an empty segment is encountered, coerces to ``{"0": leaf}`` to preserve round-trippability with other ports.
441
456
  """
442
- parse_lists_enabled = options.parse_lists if parse_lists is None else parse_lists
457
+ parse_lists_enabled: bool = options.parse_lists if parse_lists is None else parse_lists
443
458
  current_list_length: int = 0
444
459
 
445
460
  # If the chain ends with an empty list marker, compute current list length for limit checks.
@@ -467,7 +482,31 @@ def _parse_object(
467
482
  if parent_key is not None and isinstance(val, (list, tuple)) and parent_key in dict(enumerate(val)):
468
483
  current_list_length = len(val[parent_key])
469
484
 
470
- leaf: t.Any = val if values_parsed else _parse_array_value(val, options, current_list_length)
485
+ bracket_array_comma_value: bool = (
486
+ not values_parsed
487
+ and bool(chain)
488
+ and chain[-1] == "[]"
489
+ and isinstance(val, str)
490
+ and bool(val)
491
+ and options.comma
492
+ and "," in val
493
+ )
494
+ leaf: t.Any = (
495
+ val
496
+ if values_parsed
497
+ else _parse_array_value(
498
+ val,
499
+ options,
500
+ current_list_length,
501
+ enforce_comma_limit=not bracket_array_comma_value,
502
+ )
503
+ )
504
+ if bracket_array_comma_value and isinstance(leaf, (list, tuple)):
505
+ leaf = [leaf]
506
+ if len(leaf) > options.list_limit:
507
+ if options.raise_on_limit_exceeded:
508
+ raise ValueError(_list_limit_exceeded_message(options.list_limit))
509
+ leaf = CommaOverflowDict({str(i): item for i, item in enumerate(leaf)})
471
510
 
472
511
  # Walk the chain from the leaf to the root, building nested containers on the way out.
473
512
  i: int
@@ -518,10 +557,14 @@ def _parse_object(
518
557
  and root != decoded_root
519
558
  and str(index) == decoded_root
520
559
  and parse_lists_enabled
521
- and index <= options.list_limit
522
560
  ):
523
- obj = [UNDEFINED for _ in range(index + 1)]
524
- obj[index] = leaf
561
+ if index < options.list_limit:
562
+ obj = [UNDEFINED for _ in range(index + 1)]
563
+ obj[index] = leaf
564
+ elif options.raise_on_limit_exceeded:
565
+ raise ValueError(_list_limit_exceeded_message(options.list_limit))
566
+ else:
567
+ obj[decoded_root] = leaf
525
568
  else:
526
569
  # Preserve the literal decoded key for non-array roots (e.g. "[01]" -> "01"),
527
570
  # matching Node `qs` behavior for leading-zero numeric-like segments.