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.
- {qs_codec-1.5.0 → qs_codec-1.5.2}/CHANGELOG.md +14 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/PKG-INFO +6 -1
- {qs_codec-1.5.0 → qs_codec-1.5.2}/README.rst +5 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/docs/README.rst +37 -5
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/__init__.py +1 -1
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/decode.py +91 -48
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/encode.py +73 -71
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/decode_options.py +19 -7
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/encode_options.py +2 -3
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/overflow_dict.py +7 -3
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/weak_wrapper.py +3 -3
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/utils/decode_utils.py +34 -31
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/utils/encode_utils.py +33 -35
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/utils/utils.py +81 -60
- qs_codec-1.5.2/tests/comparison/.gitignore +1 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/package.json +2 -1
- qs_codec-1.5.2/tests/comparison/pnpm-lock.yaml +180 -0
- qs_codec-1.5.2/tests/comparison/pnpm-workspace.yaml +5 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/test_cases.json +20 -1
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/decode_options_test.py +21 -1
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/decode_test.py +264 -6
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/encode_test.py +66 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/example_test.py +12 -4
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/thread_safety_test.py +4 -4
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/utils_test.py +31 -9
- qs_codec-1.5.2/tests/unit/wpt_urlencoded_parser_test.py +90 -0
- qs_codec-1.5.0/tests/comparison/.gitignore +0 -2
- {qs_codec-1.5.0 → qs_codec-1.5.2}/.gitignore +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/CODE-OF-CONDUCT.md +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/LICENSE +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/pyproject.toml +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/requirements_dev.txt +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/constants/__init__.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/constants/encode_constants.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/__init__.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/charset.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/decode_kind.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/duplicates.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/format.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/list_format.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/enums/sentinel.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/__init__.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/cycle_state.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/encode_frame.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/key_path_node.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/structured_key_scan.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/models/undefined.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/py.typed +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/src/qs_codec/utils/__init__.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/__init__.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/compare_outputs.sh +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/qs.js +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/comparison/qs.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/e2e/__init__.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/e2e/e2e_test.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/__init__.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/encode_internal_helpers_test.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/encode_options_test.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/fixed_qs_issues_test.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/key_path_node_test.py +0 -0
- {qs_codec-1.5.0 → qs_codec-1.5.2}/tests/unit/list_format_test.py +0 -0
- {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.
|
|
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
|
|
314
|
-
index
|
|
315
|
-
the index as the key. This is needed to
|
|
316
|
-
for example, ``a[999999999]`` and it
|
|
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.
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
414
|
+
if existing and part_duplicates == Duplicates.COMBINE:
|
|
400
415
|
obj[key] = Utils.combine(obj[key], val, options)
|
|
401
|
-
elif not existing or
|
|
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
|
-
|
|
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
|
-
|
|
524
|
-
|
|
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.
|