qs-codec 1.5.1__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.1 → qs_codec-1.5.2}/CHANGELOG.md +6 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/PKG-INFO +1 -1
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/__init__.py +1 -1
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/decode.py +23 -22
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/encode.py +71 -69
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/encode_options.py +2 -3
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/weak_wrapper.py +3 -3
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/utils/decode_utils.py +31 -28
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/utils/encode_utils.py +33 -35
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/utils/utils.py +40 -39
- {qs_codec-1.5.1 → qs_codec-1.5.2}/.gitignore +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/CODE-OF-CONDUCT.md +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/LICENSE +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/README.rst +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/docs/README.rst +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/pyproject.toml +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/requirements_dev.txt +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/constants/__init__.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/constants/encode_constants.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/enums/__init__.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/enums/charset.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/enums/decode_kind.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/enums/duplicates.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/enums/format.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/enums/list_format.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/enums/sentinel.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/__init__.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/cycle_state.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/decode_options.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/encode_frame.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/key_path_node.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/overflow_dict.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/structured_key_scan.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/models/undefined.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/py.typed +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/src/qs_codec/utils/__init__.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/comparison/.gitignore +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/comparison/__init__.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/comparison/compare_outputs.sh +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/comparison/package.json +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/comparison/pnpm-lock.yaml +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/comparison/pnpm-workspace.yaml +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/comparison/qs.js +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/comparison/qs.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/comparison/test_cases.json +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/e2e/__init__.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/e2e/e2e_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/__init__.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/decode_options_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/decode_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/encode_internal_helpers_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/encode_options_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/encode_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/example_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/fixed_qs_issues_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/key_path_node_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/list_format_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/thread_safety_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/utils_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/weakref_test.py +0 -0
- {qs_codec-1.5.1 → qs_codec-1.5.2}/tests/unit/wpt_urlencoded_parser_test.py +0 -0
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
## 1.5.1
|
|
2
8
|
|
|
3
9
|
* [FEAT] add `DecodeOptions.strict_merge` for Node `qs` 6.15 `strictMerge` parity
|
|
@@ -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/
|
|
@@ -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__ = [
|
|
@@ -73,12 +73,12 @@ def decode(
|
|
|
73
73
|
if not isinstance(value, (str, Mapping)):
|
|
74
74
|
raise ValueError("value must be a str or a Mapping[str, Any]")
|
|
75
75
|
|
|
76
|
-
opts = options if options is not None else DecodeOptions()
|
|
77
|
-
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)
|
|
78
78
|
str_value: str = t.cast(str, value) if decode_from_string else ""
|
|
79
79
|
mapping_value: t.Mapping[str, t.Any] = t.cast(t.Mapping[str, t.Any], value) if not decode_from_string else {}
|
|
80
80
|
|
|
81
|
-
parse_lists_effective = opts.parse_lists
|
|
81
|
+
parse_lists_effective: bool = opts.parse_lists
|
|
82
82
|
if decode_from_string and parse_lists_effective:
|
|
83
83
|
# Keep caller options immutable: compute a local parse_lists switch only for this invocation.
|
|
84
84
|
query = str_value.replace("?", "", 1) if opts.ignore_query_prefix else str_value
|
|
@@ -96,7 +96,9 @@ def decode(
|
|
|
96
96
|
if not temp_obj:
|
|
97
97
|
return obj
|
|
98
98
|
|
|
99
|
-
structured_scan =
|
|
99
|
+
structured_scan: StructuredKeyScan = (
|
|
100
|
+
_scan_structured_keys(temp_obj, opts) if decode_from_string else StructuredKeyScan.empty()
|
|
101
|
+
)
|
|
100
102
|
if decode_from_string and not structured_scan.has_any_structured_syntax:
|
|
101
103
|
return Utils.compact(temp_obj)
|
|
102
104
|
|
|
@@ -142,18 +144,18 @@ def loads(value: t.Optional[str], options: t.Optional[DecodeOptions] = None) ->
|
|
|
142
144
|
|
|
143
145
|
def _first_structured_split_index(key: str, allow_dots: bool) -> int:
|
|
144
146
|
"""Return the earliest index that indicates structured syntax in ``key``."""
|
|
145
|
-
split_at = key.find("[")
|
|
147
|
+
split_at: int = key.find("[")
|
|
146
148
|
if not allow_dots:
|
|
147
149
|
return split_at
|
|
148
150
|
|
|
149
|
-
dot_index = key.find(".")
|
|
151
|
+
dot_index: int = key.find(".")
|
|
150
152
|
if dot_index >= 0 and (split_at < 0 or dot_index < split_at):
|
|
151
153
|
split_at = dot_index
|
|
152
154
|
|
|
153
|
-
encoded_dot_index = -1
|
|
155
|
+
encoded_dot_index: int = -1
|
|
154
156
|
if "%" in key:
|
|
155
|
-
upper = key.find("%2E")
|
|
156
|
-
lower = key.find("%2e")
|
|
157
|
+
upper: int = key.find("%2E")
|
|
158
|
+
lower: int = key.find("%2e")
|
|
157
159
|
if upper >= 0 and lower >= 0:
|
|
158
160
|
encoded_dot_index = upper if upper < lower else lower
|
|
159
161
|
else:
|
|
@@ -167,7 +169,7 @@ def _first_structured_split_index(key: str, allow_dots: bool) -> int:
|
|
|
167
169
|
|
|
168
170
|
def _leading_structured_root(key: str, options: DecodeOptions) -> str:
|
|
169
171
|
"""Extract root key for leading-bracket structured keys (``[]`` normalizes to ``"0"``)."""
|
|
170
|
-
segments = DecodeUtils.split_key_into_segments(
|
|
172
|
+
segments: t.List[str] = DecodeUtils.split_key_into_segments(
|
|
171
173
|
original_key=key,
|
|
172
174
|
allow_dots=t.cast(bool, options.allow_dots),
|
|
173
175
|
max_depth=options.depth,
|
|
@@ -190,12 +192,12 @@ def _scan_structured_keys(temp_obj: Mapping[str, t.Any], options: DecodeOptions)
|
|
|
190
192
|
if not temp_obj:
|
|
191
193
|
return StructuredKeyScan.empty()
|
|
192
194
|
|
|
193
|
-
allow_dots = t.cast(bool, options.allow_dots)
|
|
195
|
+
allow_dots: bool = t.cast(bool, options.allow_dots)
|
|
194
196
|
structured_roots: t.Set[str] = set()
|
|
195
197
|
structured_keys: t.Set[str] = set()
|
|
196
198
|
|
|
197
|
-
for key in temp_obj
|
|
198
|
-
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)
|
|
199
201
|
if split_at < 0:
|
|
200
202
|
continue
|
|
201
203
|
structured_keys.add(key)
|
|
@@ -346,23 +348,22 @@ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str
|
|
|
346
348
|
|
|
347
349
|
# Local, non-optional decoder reference for type-checkers
|
|
348
350
|
decoder_fn: t.Callable[..., t.Optional[str]] = options.decoder or DecodeUtils.decode
|
|
349
|
-
duplicates = options.duplicates
|
|
351
|
+
duplicates: Duplicates = options.duplicates
|
|
350
352
|
|
|
351
353
|
# Iterate over parts and decode each key/value pair.
|
|
352
|
-
for i,
|
|
354
|
+
for i, part in enumerate(parts):
|
|
353
355
|
if i == skip_index:
|
|
354
356
|
continue
|
|
355
357
|
|
|
356
|
-
part: str = parts[i]
|
|
357
358
|
if not part:
|
|
358
359
|
continue
|
|
359
360
|
bracket_equals_pos: int = part.find("]=")
|
|
360
361
|
pos: int = part.find("=") if bracket_equals_pos == -1 else (bracket_equals_pos + 1)
|
|
361
|
-
bracket_array_assignment = pos != -1 and "[]=" in part
|
|
362
|
+
bracket_array_assignment: bool = pos != -1 and "[]=" in part
|
|
362
363
|
|
|
363
364
|
# Decode key and value with a key-aware decoder; skip pairs whose key decodes to None
|
|
364
|
-
raw_key = ""
|
|
365
|
-
list_limit_exceeded = False
|
|
365
|
+
raw_key: str = ""
|
|
366
|
+
list_limit_exceeded: bool = False
|
|
366
367
|
if pos == -1:
|
|
367
368
|
key_decoded = decoder_fn(part, charset, kind=DecodeKind.KEY)
|
|
368
369
|
if key_decoded is None:
|
|
@@ -453,7 +454,7 @@ def _parse_object(
|
|
|
453
454
|
handled by the splitter.
|
|
454
455
|
- When list parsing is disabled and an empty segment is encountered, coerces to ``{"0": leaf}`` to preserve round-trippability with other ports.
|
|
455
456
|
"""
|
|
456
|
-
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
|
|
457
458
|
current_list_length: int = 0
|
|
458
459
|
|
|
459
460
|
# If the chain ends with an empty list marker, compute current list length for limit checks.
|
|
@@ -481,12 +482,12 @@ def _parse_object(
|
|
|
481
482
|
if parent_key is not None and isinstance(val, (list, tuple)) and parent_key in dict(enumerate(val)):
|
|
482
483
|
current_list_length = len(val[parent_key])
|
|
483
484
|
|
|
484
|
-
bracket_array_comma_value = (
|
|
485
|
+
bracket_array_comma_value: bool = (
|
|
485
486
|
not values_parsed
|
|
486
487
|
and bool(chain)
|
|
487
488
|
and chain[-1] == "[]"
|
|
488
489
|
and isinstance(val, str)
|
|
489
|
-
and val
|
|
490
|
+
and bool(val)
|
|
490
491
|
and options.comma
|
|
491
492
|
and "," in val
|
|
492
493
|
)
|
|
@@ -64,28 +64,28 @@ def encode(value: t.Any, options: t.Optional[EncodeOptions] = None) -> str:
|
|
|
64
64
|
if value is None:
|
|
65
65
|
return ""
|
|
66
66
|
|
|
67
|
-
opts = options if options is not None else EncodeOptions()
|
|
67
|
+
opts: EncodeOptions = options if options is not None else EncodeOptions()
|
|
68
68
|
|
|
69
|
-
filter_opt = opts.filter
|
|
70
|
-
filter_is_callable = callable(filter_opt)
|
|
71
|
-
filter_is_sequence = (
|
|
69
|
+
filter_opt: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]] = opts.filter
|
|
70
|
+
filter_is_callable: bool = callable(filter_opt)
|
|
71
|
+
filter_is_sequence: bool = (
|
|
72
72
|
filter_opt is not None
|
|
73
73
|
and isinstance(filter_opt, ABCSequence)
|
|
74
74
|
and not isinstance(filter_opt, (str, bytes, bytearray))
|
|
75
75
|
)
|
|
76
|
-
list_format = opts.list_format
|
|
77
|
-
sort_opt = opts.sort if callable(opts.sort) else None
|
|
78
|
-
encoder
|
|
79
|
-
|
|
76
|
+
list_format: ListFormat = opts.list_format
|
|
77
|
+
sort_opt: t.Optional[t.Callable[[t.Any, t.Any], int]] = opts.sort if callable(opts.sort) else None
|
|
78
|
+
encoder: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]] = (
|
|
79
|
+
opts.encoder if opts.encode else None
|
|
80
|
+
)
|
|
81
|
+
formatter: t.Optional[t.Callable[[str], str]] = opts.format.formatter
|
|
80
82
|
|
|
81
83
|
# Normalize the root into a mapping we can traverse deterministically:
|
|
82
84
|
# - Mapping -> shallow copy (deep-copy only when a callable filter may mutate)
|
|
83
85
|
# - Sequence -> optionally deep-copy for callable filters, then promote to {"0": v0, "1": v1, ...}
|
|
84
86
|
# - Other -> empty (encodes to "")
|
|
85
87
|
obj: t.Mapping[str, t.Any]
|
|
86
|
-
if isinstance(value,
|
|
87
|
-
obj = deepcopy(value) if filter_is_callable else dict(value)
|
|
88
|
-
elif isinstance(value, ABCMapping):
|
|
88
|
+
if isinstance(value, ABCMapping):
|
|
89
89
|
obj = deepcopy(value) if filter_is_callable else dict(value)
|
|
90
90
|
elif isinstance(value, (list, tuple)):
|
|
91
91
|
sequence = deepcopy(value) if filter_is_callable else value
|
|
@@ -123,17 +123,17 @@ def encode(value: t.Any, options: t.Optional[EncodeOptions] = None) -> str:
|
|
|
123
123
|
|
|
124
124
|
# Side channel seed for legacy `_encode` compatibility (and cycle-state bootstrap when provided).
|
|
125
125
|
side_channel: WeakKeyDictionary = WeakKeyDictionary()
|
|
126
|
-
max_depth = _get_max_encode_depth(opts.max_depth)
|
|
126
|
+
max_depth: int = _get_max_encode_depth(opts.max_depth)
|
|
127
127
|
|
|
128
128
|
# Encode each selected root key.
|
|
129
|
-
missing = _MISSING
|
|
129
|
+
missing: t.Any = _MISSING
|
|
130
130
|
for _key in obj_keys:
|
|
131
131
|
if not isinstance(_key, str):
|
|
132
132
|
# Skip non-string keys; parity with ports that stringify key paths.
|
|
133
133
|
continue
|
|
134
134
|
|
|
135
|
-
obj_value = obj.get(_key, missing)
|
|
136
|
-
key_is_undefined = obj_value is missing
|
|
135
|
+
obj_value: t.Any = obj.get(_key, missing)
|
|
136
|
+
key_is_undefined: bool = obj_value is missing
|
|
137
137
|
|
|
138
138
|
# Optionally drop explicit nulls at the root.
|
|
139
139
|
if opts.skip_nulls and obj_value is None:
|
|
@@ -232,12 +232,12 @@ def _bootstrap_cycle_state_from_side_channel(side_channel: WeakKeyDictionary) ->
|
|
|
232
232
|
from the current frame to the top-most side-channel mapping.
|
|
233
233
|
"""
|
|
234
234
|
chain: t.List[WeakKeyDictionary] = []
|
|
235
|
-
tmp_sc = side_channel.get(_sentinel)
|
|
235
|
+
tmp_sc: t.Any = side_channel.get(_sentinel)
|
|
236
236
|
while isinstance(tmp_sc, WeakKeyDictionary):
|
|
237
237
|
chain.append(tmp_sc)
|
|
238
238
|
tmp_sc = tmp_sc.get(_sentinel) # type: ignore[assignment]
|
|
239
239
|
|
|
240
|
-
state = CycleState()
|
|
240
|
+
state: CycleState = CycleState()
|
|
241
241
|
for level, ancestor in enumerate(reversed(chain)):
|
|
242
242
|
is_top = ancestor.get(_sentinel) is None
|
|
243
243
|
for key, pos in ancestor.items():
|
|
@@ -257,8 +257,8 @@ def _compute_step_and_check_cycle(state: CycleState, node_key: t.Any, current_le
|
|
|
257
257
|
* raise when ancestor_pos == distance
|
|
258
258
|
* return 0 when no match or when nearest match is the top-most side-channel
|
|
259
259
|
"""
|
|
260
|
-
key_id = node_key if isinstance(node_key, int) else _identity_key(node_key)
|
|
261
|
-
entries = state.entries.get(key_id)
|
|
260
|
+
key_id: int = node_key if isinstance(node_key, int) else _identity_key(node_key)
|
|
261
|
+
entries: t.Optional[t.List[t.Tuple[int, int, bool]]] = state.entries.get(key_id)
|
|
262
262
|
if not entries:
|
|
263
263
|
return 0
|
|
264
264
|
|
|
@@ -271,13 +271,13 @@ def _compute_step_and_check_cycle(state: CycleState, node_key: t.Any, current_le
|
|
|
271
271
|
|
|
272
272
|
|
|
273
273
|
def _push_current_node(state: CycleState, node_key: t.Any, current_level: int, pos: int, is_top: bool) -> None:
|
|
274
|
-
key_id = node_key if isinstance(node_key, int) else _identity_key(node_key)
|
|
274
|
+
key_id: int = node_key if isinstance(node_key, int) else _identity_key(node_key)
|
|
275
275
|
state.entries.setdefault(key_id, []).append((current_level, pos, is_top))
|
|
276
276
|
|
|
277
277
|
|
|
278
278
|
def _pop_current_node(state: CycleState, node_key: t.Any) -> None:
|
|
279
|
-
key_id = node_key if isinstance(node_key, int) else _identity_key(node_key)
|
|
280
|
-
entries = state.entries.get(key_id)
|
|
279
|
+
key_id: int = node_key if isinstance(node_key, int) else _identity_key(node_key)
|
|
280
|
+
entries: t.Optional[t.List[t.Tuple[int, int, bool]]] = state.entries.get(key_id)
|
|
281
281
|
if not entries:
|
|
282
282
|
return
|
|
283
283
|
|
|
@@ -286,10 +286,10 @@ def _pop_current_node(state: CycleState, node_key: t.Any) -> None:
|
|
|
286
286
|
del state.entries[key_id]
|
|
287
287
|
|
|
288
288
|
|
|
289
|
-
_INDICES_GENERATOR = ListFormat.INDICES.generator
|
|
290
|
-
_BRACKETS_GENERATOR = ListFormat.BRACKETS.generator
|
|
291
|
-
_REPEAT_GENERATOR = ListFormat.REPEAT.generator
|
|
292
|
-
_COMMA_GENERATOR = ListFormat.COMMA.generator
|
|
289
|
+
_INDICES_GENERATOR: t.Callable[[str, t.Optional[str]], str] = ListFormat.INDICES.generator
|
|
290
|
+
_BRACKETS_GENERATOR: t.Callable[[str, t.Optional[str]], str] = ListFormat.BRACKETS.generator
|
|
291
|
+
_REPEAT_GENERATOR: t.Callable[[str, t.Optional[str]], str] = ListFormat.REPEAT.generator
|
|
292
|
+
_COMMA_GENERATOR: t.Callable[[str, t.Optional[str]], str] = ListFormat.COMMA.generator
|
|
293
293
|
|
|
294
294
|
|
|
295
295
|
def _uses_legacy_cycle_state(side_channel: WeakKeyDictionary) -> bool:
|
|
@@ -380,19 +380,19 @@ def _try_encode_linear_chain_plain_dict(
|
|
|
380
380
|
):
|
|
381
381
|
return None
|
|
382
382
|
|
|
383
|
-
current = value
|
|
383
|
+
current: t.Any = value
|
|
384
384
|
if type(current) is not dict: # pylint: disable=unidiomatic-typecheck
|
|
385
385
|
return None
|
|
386
386
|
|
|
387
|
-
current_depth = _depth
|
|
388
|
-
max_depth = _get_max_encode_depth(_max_depth)
|
|
389
|
-
formatter_fn = formatter if formatter is not None else format.formatter
|
|
390
|
-
prefix_value = prefix if prefix is not None else ("?" if add_query_prefix else "")
|
|
387
|
+
current_depth: int = _depth
|
|
388
|
+
max_depth: int = _get_max_encode_depth(_max_depth)
|
|
389
|
+
formatter_fn: t.Callable[[str], str] = formatter if formatter is not None else format.formatter
|
|
390
|
+
prefix_value: str = prefix if prefix is not None else ("?" if add_query_prefix else "")
|
|
391
391
|
path_parts: t.List[str] = [prefix_value]
|
|
392
392
|
# Match the generic walker so bounded cyclic payloads still let `max_depth`
|
|
393
393
|
# win before the eventual cycle error on the same shapes.
|
|
394
|
-
cycle_state = CycleState()
|
|
395
|
-
root_level = _depth
|
|
394
|
+
cycle_state: CycleState = CycleState()
|
|
395
|
+
root_level: int = _depth
|
|
396
396
|
|
|
397
397
|
while True:
|
|
398
398
|
if current_depth > max_depth:
|
|
@@ -416,11 +416,11 @@ def _try_encode_linear_chain_plain_dict(
|
|
|
416
416
|
continue
|
|
417
417
|
|
|
418
418
|
if current is None:
|
|
419
|
-
path = formatter_fn("".join(path_parts))
|
|
419
|
+
path: str = formatter_fn("".join(path_parts))
|
|
420
420
|
return [f"{path}="]
|
|
421
421
|
|
|
422
422
|
if current_type is bool:
|
|
423
|
-
value_str = "true" if current else "false"
|
|
423
|
+
value_str: str = "true" if current else "false"
|
|
424
424
|
path = formatter_fn("".join(path_parts))
|
|
425
425
|
return [f"{path}={formatter_fn(value_str)}"]
|
|
426
426
|
|
|
@@ -429,7 +429,7 @@ def _try_encode_linear_chain_plain_dict(
|
|
|
429
429
|
return [f"{path}={formatter_fn(str(current))}"]
|
|
430
430
|
|
|
431
431
|
if current_type is datetime:
|
|
432
|
-
date_value = t.cast(datetime, current)
|
|
432
|
+
date_value: datetime = t.cast(datetime, current)
|
|
433
433
|
current = serialize_date(date_value) if callable(serialize_date) else date_value.isoformat()
|
|
434
434
|
continue
|
|
435
435
|
|
|
@@ -489,19 +489,19 @@ def _try_encode_linear_chain(
|
|
|
489
489
|
):
|
|
490
490
|
return None
|
|
491
491
|
|
|
492
|
-
current = value
|
|
492
|
+
current: t.Any = value
|
|
493
493
|
if type(current) is not dict and not isinstance(current, ABCMapping): # pylint: disable=unidiomatic-typecheck
|
|
494
494
|
return None
|
|
495
495
|
|
|
496
|
-
current_depth = _depth
|
|
497
|
-
max_depth = _get_max_encode_depth(_max_depth)
|
|
498
|
-
formatter_fn = formatter if formatter is not None else format.formatter
|
|
499
|
-
prefix_value = prefix if prefix is not None else ("?" if add_query_prefix else "")
|
|
496
|
+
current_depth: int = _depth
|
|
497
|
+
max_depth: int = _get_max_encode_depth(_max_depth)
|
|
498
|
+
formatter_fn: t.Callable[[str], str] = formatter if formatter is not None else format.formatter
|
|
499
|
+
prefix_value: str = prefix if prefix is not None else ("?" if add_query_prefix else "")
|
|
500
500
|
path_segments: t.List[str] = []
|
|
501
501
|
# Match the generic walker so bounded cyclic payloads still let `max_depth`
|
|
502
502
|
# win before the eventual cycle error on the same shapes.
|
|
503
|
-
cycle_state = CycleState()
|
|
504
|
-
root_level = _depth
|
|
503
|
+
cycle_state: CycleState = CycleState()
|
|
504
|
+
root_level: int = _depth
|
|
505
505
|
|
|
506
506
|
while True:
|
|
507
507
|
if current_depth > max_depth:
|
|
@@ -510,11 +510,11 @@ def _try_encode_linear_chain(
|
|
|
510
510
|
current_type = type(current)
|
|
511
511
|
|
|
512
512
|
if current is None:
|
|
513
|
-
path = prefix_value + "".join(path_segments)
|
|
513
|
+
path: str = prefix_value + "".join(path_segments)
|
|
514
514
|
return [f"{formatter_fn(path)}="]
|
|
515
515
|
|
|
516
516
|
if current_type is bool:
|
|
517
|
-
value_str = "true" if current else "false"
|
|
517
|
+
value_str: str = "true" if current else "false"
|
|
518
518
|
path = prefix_value + "".join(path_segments)
|
|
519
519
|
return [f"{formatter_fn(path)}={formatter_fn(value_str)}"]
|
|
520
520
|
|
|
@@ -524,7 +524,7 @@ def _try_encode_linear_chain(
|
|
|
524
524
|
return [f"{formatter_fn(path)}={formatter_fn(value_str)}"]
|
|
525
525
|
|
|
526
526
|
if current_type is datetime:
|
|
527
|
-
date_value = t.cast(datetime, current)
|
|
527
|
+
date_value: datetime = t.cast(datetime, current)
|
|
528
528
|
current = serialize_date(date_value) if callable(serialize_date) else date_value.isoformat()
|
|
529
529
|
continue
|
|
530
530
|
|
|
@@ -546,8 +546,8 @@ def _try_encode_linear_chain(
|
|
|
546
546
|
else:
|
|
547
547
|
return None
|
|
548
548
|
|
|
549
|
-
current_id = id(mapping)
|
|
550
|
-
step = _compute_step_and_check_cycle(cycle_state, current_id, current_depth)
|
|
549
|
+
current_id: int = id(mapping)
|
|
550
|
+
step: int = _compute_step_and_check_cycle(cycle_state, current_id, current_depth)
|
|
551
551
|
|
|
552
552
|
if len(mapping) != 1:
|
|
553
553
|
return None
|
|
@@ -576,8 +576,8 @@ def _next_path_for_sequence(
|
|
|
576
576
|
if generator is _REPEAT_GENERATOR or generator is _COMMA_GENERATOR:
|
|
577
577
|
return path
|
|
578
578
|
|
|
579
|
-
parent = path.materialize()
|
|
580
|
-
child = generator(parent, encoded_key)
|
|
579
|
+
parent: str = path.materialize()
|
|
580
|
+
child: str = generator(parent, encoded_key)
|
|
581
581
|
if child.startswith(parent):
|
|
582
582
|
return path.append(child[len(parent) :])
|
|
583
583
|
|
|
@@ -649,7 +649,7 @@ def _encode(
|
|
|
649
649
|
Returns:
|
|
650
650
|
Either a list/tuple of tokens or a single token string.
|
|
651
651
|
"""
|
|
652
|
-
fast_path_result = _try_encode_linear_chain_plain_dict(
|
|
652
|
+
fast_path_result: t.Optional[t.List[t.Any]] = _try_encode_linear_chain_plain_dict(
|
|
653
653
|
value=value,
|
|
654
654
|
is_undefined=is_undefined,
|
|
655
655
|
side_channel=side_channel,
|
|
@@ -716,7 +716,7 @@ def _encode(
|
|
|
716
716
|
if last_bracket_key == encoded_key and last_bracket_segment is not None:
|
|
717
717
|
return last_bracket_segment
|
|
718
718
|
|
|
719
|
-
segment = f"[{encoded_key}]"
|
|
719
|
+
segment: str = f"[{encoded_key}]"
|
|
720
720
|
last_bracket_key = encoded_key
|
|
721
721
|
last_bracket_segment = segment
|
|
722
722
|
return segment
|
|
@@ -779,7 +779,7 @@ def _encode(
|
|
|
779
779
|
]
|
|
780
780
|
|
|
781
781
|
while stack:
|
|
782
|
-
frame = stack[-1]
|
|
782
|
+
frame: EncodeFrame = stack[-1]
|
|
783
783
|
|
|
784
784
|
if frame.phase == PHASE_START:
|
|
785
785
|
if frame.max_depth is None:
|
|
@@ -791,7 +791,7 @@ def _encode(
|
|
|
791
791
|
if frame.prefix is None:
|
|
792
792
|
frame.prefix = "?" if frame.add_query_prefix else ""
|
|
793
793
|
frame.path = KeyPathNode.from_materialized(frame.prefix)
|
|
794
|
-
current_path = frame.path
|
|
794
|
+
current_path: KeyPathNode = frame.path
|
|
795
795
|
if current_path is None: # pragma: no cover - internal invariant
|
|
796
796
|
raise RuntimeError("path is not initialized") # noqa: TRY003
|
|
797
797
|
if frame.comma_round_trip is None:
|
|
@@ -800,8 +800,8 @@ def _encode(
|
|
|
800
800
|
frame.formatter = frame.format.formatter
|
|
801
801
|
|
|
802
802
|
obj: t.Any = frame.value
|
|
803
|
-
filter_opt = frame.filter_
|
|
804
|
-
filter_is_sequence = (
|
|
803
|
+
filter_opt: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]] = frame.filter_
|
|
804
|
+
filter_is_sequence: bool = (
|
|
805
805
|
filter_opt is not None
|
|
806
806
|
and isinstance(filter_opt, ABCSequence)
|
|
807
807
|
and not isinstance(filter_opt, (str, bytes, bytearray))
|
|
@@ -820,13 +820,13 @@ def _encode(
|
|
|
820
820
|
|
|
821
821
|
if not frame.is_undefined and obj is None:
|
|
822
822
|
if frame.strict_null_handling:
|
|
823
|
-
key_text = current_path.materialize()
|
|
824
|
-
key_value = (
|
|
823
|
+
key_text: str = current_path.materialize()
|
|
824
|
+
key_value: str = (
|
|
825
825
|
frame.encoder(key_text, frame.charset, frame.format)
|
|
826
826
|
if callable(frame.encoder) and not frame.encode_values_only
|
|
827
827
|
else key_text
|
|
828
828
|
)
|
|
829
|
-
result_token = frame.formatter(key_value) if frame.formatter is not None else key_value
|
|
829
|
+
result_token: str = frame.formatter(key_value) if frame.formatter is not None else key_value
|
|
830
830
|
stack.pop()
|
|
831
831
|
last_result = result_token
|
|
832
832
|
continue
|
|
@@ -863,7 +863,7 @@ def _encode(
|
|
|
863
863
|
last_result = []
|
|
864
864
|
continue
|
|
865
865
|
|
|
866
|
-
obj_id = id(obj)
|
|
866
|
+
obj_id: int = id(frame.obj)
|
|
867
867
|
frame.obj_id = obj_id
|
|
868
868
|
if use_legacy_cycle_state:
|
|
869
869
|
if frame.cycle_state is None or frame.cycle_level is None:
|
|
@@ -884,7 +884,7 @@ def _encode(
|
|
|
884
884
|
|
|
885
885
|
if frame.encode_values_only and callable(frame.encoder):
|
|
886
886
|
encoded_items = [frame.encoder(item, frame.charset, frame.format) for item in comma_items]
|
|
887
|
-
obj_keys_value = ",".join("" if e is None else str(e) for e in encoded_items)
|
|
887
|
+
obj_keys_value: str = ",".join("" if e is None else str(e) for e in encoded_items)
|
|
888
888
|
else:
|
|
889
889
|
obj_keys_value = ",".join(Utils.normalize_comma_elem(e) for e in comma_items)
|
|
890
890
|
|
|
@@ -893,20 +893,20 @@ def _encode(
|
|
|
893
893
|
else:
|
|
894
894
|
frame.obj_keys = [{"value": UNDEFINED}]
|
|
895
895
|
elif filter_is_sequence:
|
|
896
|
-
filter_keys = t.cast(t.Sequence[t.Union[str, int]], filter_opt)
|
|
896
|
+
filter_keys: t.Sequence[t.Union[str, int]] = t.cast(t.Sequence[t.Union[str, int]], filter_opt)
|
|
897
897
|
frame.obj_keys = list(filter_keys)
|
|
898
898
|
else:
|
|
899
899
|
if frame.is_mapping:
|
|
900
|
-
keys = list(obj) if isinstance(obj, dict) else list(obj.keys())
|
|
900
|
+
keys: t.List[t.Any] = list(obj) if isinstance(obj, dict) else list(obj.keys())
|
|
901
901
|
elif frame.is_sequence:
|
|
902
902
|
keys = list(range(len(obj)))
|
|
903
903
|
else:
|
|
904
904
|
keys = []
|
|
905
905
|
frame.obj_keys = sorted(keys, key=cmp_to_key(frame.sort)) if frame.sort is not None else keys
|
|
906
906
|
|
|
907
|
-
path_for_children = current_path.as_dot_encoded() if frame.encode_dot_in_keys else current_path
|
|
907
|
+
path_for_children: KeyPathNode = current_path.as_dot_encoded() if frame.encode_dot_in_keys else current_path
|
|
908
908
|
|
|
909
|
-
single_item_for_round_trip = False
|
|
909
|
+
single_item_for_round_trip: bool = False
|
|
910
910
|
if frame.comma_round_trip and frame.is_sequence:
|
|
911
911
|
if frame.generate_array_prefix is _COMMA_GENERATOR and comma_effective_length is not None:
|
|
912
912
|
single_item_for_round_trip = comma_effective_length == 1
|
|
@@ -952,7 +952,7 @@ def _encode(
|
|
|
952
952
|
_push_current_node(public_cycle_state, frame.obj_id, frame.depth, frame.step, frame.depth == 0)
|
|
953
953
|
frame.cycle_pushed = True
|
|
954
954
|
|
|
955
|
-
_key = frame.obj_keys[frame.index]
|
|
955
|
+
_key: t.Any = frame.obj_keys[frame.index]
|
|
956
956
|
frame.index += 1
|
|
957
957
|
|
|
958
958
|
_value: t.Any
|
|
@@ -988,13 +988,15 @@ def _encode(
|
|
|
988
988
|
continue
|
|
989
989
|
|
|
990
990
|
key_text = str(_key)
|
|
991
|
-
encoded_key =
|
|
991
|
+
encoded_key: str = (
|
|
992
|
+
key_text.replace(".", "%2E") if frame.allow_dots and frame.encode_dot_in_keys else key_text
|
|
993
|
+
)
|
|
992
994
|
if frame.path is None: # pragma: no cover - internal invariant
|
|
993
995
|
raise RuntimeError("path is not initialized") # noqa: TRY003
|
|
994
|
-
adjusted_path = frame.adjusted_path if frame.adjusted_path is not None else frame.path
|
|
996
|
+
adjusted_path: KeyPathNode = frame.adjusted_path if frame.adjusted_path is not None else frame.path
|
|
995
997
|
|
|
996
998
|
if frame.is_sequence:
|
|
997
|
-
child_path = _next_path_for_sequence(
|
|
999
|
+
child_path: KeyPathNode = _next_path_for_sequence(
|
|
998
1000
|
adjusted_path,
|
|
999
1001
|
frame.generate_array_prefix,
|
|
1000
1002
|
encoded_key,
|
|
@@ -166,9 +166,8 @@ class EncodeOptions:
|
|
|
166
166
|
name = f.name
|
|
167
167
|
if name == "encoder":
|
|
168
168
|
continue
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
v2 = getattr(other, name)
|
|
169
|
+
v1 = getattr(self, name)
|
|
170
|
+
v2 = getattr(other, name)
|
|
172
171
|
if v1 != v2:
|
|
173
172
|
return False
|
|
174
173
|
return True
|
|
@@ -31,9 +31,9 @@ _proxy_cache_lock = RLock()
|
|
|
31
31
|
|
|
32
32
|
def _get_proxy(value: t.Any) -> "_Proxy":
|
|
33
33
|
"""Return a per-object proxy, cached by id(value)."""
|
|
34
|
-
key = id(value)
|
|
34
|
+
key: int = id(value)
|
|
35
35
|
with _proxy_cache_lock:
|
|
36
|
-
proxy = _proxy_cache.get(key)
|
|
36
|
+
proxy: t.Optional[_Proxy] = _proxy_cache.get(key)
|
|
37
37
|
if proxy is None:
|
|
38
38
|
proxy = _Proxy(value)
|
|
39
39
|
_proxy_cache[key] = proxy
|
|
@@ -54,7 +54,7 @@ class WeakWrapper:
|
|
|
54
54
|
|
|
55
55
|
def __init__(self, value: t.Any) -> None:
|
|
56
56
|
"""Initialize the wrapper with a value."""
|
|
57
|
-
proxy = _get_proxy(value)
|
|
57
|
+
proxy: _Proxy = _get_proxy(value)
|
|
58
58
|
# Strong edge: wrapper -> proxy
|
|
59
59
|
object.__setattr__(self, "_proxy", proxy)
|
|
60
60
|
# Weak edge so tests can observe GC of the proxy
|
|
@@ -4,7 +4,11 @@ This mirrors the semantics of the Node `qs` library:
|
|
|
4
4
|
|
|
5
5
|
- Decoding handles both UTF-8 and Latin-1 code paths.
|
|
6
6
|
- Key splitting keeps bracket groups *balanced* and optionally treats dots as path separators when ``allow_dots=True``.
|
|
7
|
-
- Top-level dot splitting uses a character-scanner that handles degenerate cases
|
|
7
|
+
- Top-level dot splitting uses a character-scanner that handles degenerate cases
|
|
8
|
+
(leading '.' starts a bracket segment; '.[' is skipped; double dots preserve
|
|
9
|
+
the first; trailing '.' is preserved) and never treats literal percent-encoded
|
|
10
|
+
sequences (e.g., '%2E') as split points; only actual '.' characters at depth
|
|
11
|
+
0 are split.
|
|
8
12
|
"""
|
|
9
13
|
|
|
10
14
|
import re
|
|
@@ -62,70 +66,70 @@ class DecodeUtils:
|
|
|
62
66
|
"""
|
|
63
67
|
if "." not in s:
|
|
64
68
|
return s
|
|
65
|
-
|
|
66
|
-
depth = 0
|
|
67
|
-
i = 0
|
|
68
|
-
n = len(s)
|
|
69
|
+
buffer: t.List[str] = []
|
|
70
|
+
depth: int = 0
|
|
71
|
+
i: int = 0
|
|
72
|
+
n: int = len(s)
|
|
69
73
|
while i < n:
|
|
70
|
-
ch = s[i]
|
|
74
|
+
ch: str = s[i]
|
|
71
75
|
if ch == "[":
|
|
72
76
|
depth += 1
|
|
73
|
-
|
|
77
|
+
buffer.append(ch)
|
|
74
78
|
i += 1
|
|
75
79
|
elif ch == "]":
|
|
76
80
|
if depth > 0:
|
|
77
81
|
depth -= 1
|
|
78
|
-
|
|
82
|
+
buffer.append(ch)
|
|
79
83
|
i += 1
|
|
80
84
|
elif ch == ".":
|
|
81
85
|
if depth == 0:
|
|
82
|
-
has_next = i + 1 < n
|
|
83
|
-
next_ch = s[i + 1] if has_next else "\0"
|
|
86
|
+
has_next: bool = i + 1 < n
|
|
87
|
+
next_ch: str = s[i + 1] if has_next else "\0"
|
|
84
88
|
if next_ch == "[":
|
|
85
89
|
# skip the dot so 'a.[b]' acts like 'a[b]'
|
|
86
90
|
i += 1
|
|
87
91
|
elif next_ch == "]":
|
|
88
92
|
# preserve ambiguous '.]' as a literal to avoid constructing '[]]'
|
|
89
|
-
|
|
93
|
+
buffer.append(".")
|
|
90
94
|
i += 1
|
|
91
95
|
elif i == 0:
|
|
92
96
|
# If input starts with '..', preserve the first dot like the 'a..b' case.
|
|
93
97
|
if has_next and next_ch == ".":
|
|
94
|
-
|
|
98
|
+
buffer.append(".")
|
|
95
99
|
i += 1
|
|
96
100
|
continue
|
|
97
101
|
# leading '.' starts a bracket segment: ".a" -> "[a]"
|
|
98
102
|
start = i + 1
|
|
99
|
-
j = start
|
|
100
|
-
while j < n and s[j]
|
|
103
|
+
j: int = start
|
|
104
|
+
while j < n and s[j] not in ".[]":
|
|
101
105
|
j += 1
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
buffer.append("[")
|
|
107
|
+
buffer.append(s[start:j])
|
|
108
|
+
buffer.append("]")
|
|
105
109
|
i = j
|
|
106
110
|
elif (not has_next) or next_ch == ".":
|
|
107
111
|
# trailing dot, or first of a double dot
|
|
108
|
-
|
|
112
|
+
buffer.append(".")
|
|
109
113
|
i += 1
|
|
110
114
|
else:
|
|
111
115
|
# normal split at top level: convert a.b → a[b]
|
|
112
116
|
start = i + 1
|
|
113
117
|
j = start
|
|
114
|
-
while j < n and s[j]
|
|
118
|
+
while j < n and s[j] not in ".[]":
|
|
115
119
|
j += 1
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
buffer.append("[")
|
|
121
|
+
buffer.append(s[start:j])
|
|
122
|
+
buffer.append("]")
|
|
119
123
|
i = j
|
|
120
124
|
else:
|
|
121
|
-
|
|
125
|
+
buffer.append(".")
|
|
122
126
|
i += 1
|
|
123
127
|
else:
|
|
124
128
|
# No special handling for percent sequences here; characters are appended as-is.
|
|
125
129
|
# We never split on '%2E' at this stage.
|
|
126
|
-
|
|
130
|
+
buffer.append(ch)
|
|
127
131
|
i += 1
|
|
128
|
-
return "".join(
|
|
132
|
+
return "".join(buffer)
|
|
129
133
|
|
|
130
134
|
# Precompiled pattern for %XX hex bytes (Latin-1 path fast path)
|
|
131
135
|
HEX2_PATTERN: t.Pattern[str] = re.compile(r"%([0-9A-Fa-f]{2})")
|
|
@@ -153,7 +157,7 @@ class DecodeUtils:
|
|
|
153
157
|
def replacer(match: t.Match[str]) -> str:
|
|
154
158
|
if (unicode_val := match.group("unicode")) is not None:
|
|
155
159
|
return chr(int(unicode_val, 16))
|
|
156
|
-
|
|
160
|
+
if (hex_val := match.group("hex")) is not None:
|
|
157
161
|
return chr(int(hex_val, 16))
|
|
158
162
|
return match.group(0)
|
|
159
163
|
|
|
@@ -199,8 +203,7 @@ class DecodeUtils:
|
|
|
199
203
|
_int, _chr = int, chr
|
|
200
204
|
return cls.HEX2_PATTERN.sub(lambda m: _chr(_int(m.group(1), 16)), s)
|
|
201
205
|
|
|
202
|
-
|
|
203
|
-
return s if "%" not in s else unquote(s)
|
|
206
|
+
return string_without_plus if "%" not in string_without_plus else unquote(string_without_plus)
|
|
204
207
|
|
|
205
208
|
@classmethod
|
|
206
209
|
def split_key_into_segments(
|
|
@@ -46,24 +46,28 @@ class EncodeUtils:
|
|
|
46
46
|
) -> str:
|
|
47
47
|
"""Emulate the legacy JavaScript escaping behavior.
|
|
48
48
|
|
|
49
|
-
This function operates on UTF-16 *code units* to emulate JavaScript's legacy `%uXXXX` behavior.
|
|
50
|
-
points are first expanded into surrogate pairs via `_to_surrogates`, then each code unit is
|
|
49
|
+
This function operates on UTF-16 *code units* to emulate JavaScript's legacy `%uXXXX` behavior.
|
|
50
|
+
Non-BMP code points are first expanded into surrogate pairs via `_to_surrogates`, then each code unit is
|
|
51
|
+
processed.
|
|
51
52
|
|
|
52
|
-
- Safe set: when `format == Format.RFC1738`, the characters `(` and `)` are additionally treated as safe.
|
|
53
|
+
- Safe set: when `format == Format.RFC1738`, the characters `(` and `)` are additionally treated as safe.
|
|
54
|
+
Otherwise, the RFC3986 safe set is used.
|
|
53
55
|
- ASCII characters in the safe set are emitted unchanged.
|
|
54
56
|
- Code units < 256 are emitted as `%XX`.
|
|
55
57
|
- Other code units are emitted as `%uXXXX`.
|
|
56
58
|
|
|
57
59
|
Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/escape
|
|
58
60
|
"""
|
|
59
|
-
safe_points_ascii
|
|
61
|
+
safe_points_ascii: t.Tuple[bool, ...] = (
|
|
62
|
+
cls.RFC1738_SAFE_POINTS_ASCII if format == Format.RFC1738 else cls.SAFE_POINTS_ASCII
|
|
63
|
+
)
|
|
60
64
|
if string.isascii() and cls._all_ascii_safe(string, safe_points_ascii):
|
|
61
65
|
return string
|
|
62
66
|
|
|
63
67
|
if not string.isascii():
|
|
64
68
|
string = cls._to_surrogates(string)
|
|
65
69
|
|
|
66
|
-
hex_table = cls.HEX_TABLE
|
|
70
|
+
hex_table: t.Tuple[str, ...] = cls.HEX_TABLE
|
|
67
71
|
|
|
68
72
|
buffer: t.List[str] = []
|
|
69
73
|
|
|
@@ -112,9 +116,9 @@ class EncodeUtils:
|
|
|
112
116
|
return ""
|
|
113
117
|
|
|
114
118
|
if charset == Charset.LATIN1:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return
|
|
119
|
+
u_escape_pattern: t.Pattern[str] = cls._RE_UXXXX
|
|
120
|
+
escaped: str = cls.escape(string, format)
|
|
121
|
+
return u_escape_pattern.sub(lambda m: f"%26%23{int(m.group(1), 16)}%3B", escaped)
|
|
118
122
|
|
|
119
123
|
return cls._encode_string(string, format)
|
|
120
124
|
|
|
@@ -129,20 +133,19 @@ class EncodeUtils:
|
|
|
129
133
|
"""
|
|
130
134
|
if isinstance(value, bytes):
|
|
131
135
|
return value.decode("utf-8")
|
|
132
|
-
|
|
136
|
+
if isinstance(value, bool):
|
|
133
137
|
return str(value).lower()
|
|
134
|
-
|
|
138
|
+
if isinstance(value, str):
|
|
135
139
|
return value
|
|
136
|
-
|
|
137
|
-
return str(value)
|
|
140
|
+
return str(value)
|
|
138
141
|
|
|
139
142
|
@classmethod
|
|
140
143
|
def _encode_string(cls, string: str, format: t.Optional[Format]) -> str:
|
|
141
144
|
"""Percent-encode `string` per RFC3986 or RFC1738, operating on UTF-16 code units.
|
|
142
145
|
|
|
143
|
-
We first expand non-BMP code points into surrogate pairs so that indexing and length checks are done in
|
|
144
|
-
matching JavaScript semantics. We then walk the string with a manual index, skipping the low
|
|
145
|
-
surrogate pair.
|
|
146
|
+
We first expand non-BMP code points into surrogate pairs so that indexing and length checks are done in
|
|
147
|
+
*code units*, matching JavaScript semantics. We then walk the string with a manual index, skipping the low
|
|
148
|
+
surrogate when we emit a surrogate pair.
|
|
146
149
|
"""
|
|
147
150
|
safe_chars_ascii = cls.RFC1738_SAFE_CHARS_ASCII if format == Format.RFC1738 else cls.SAFE_CHARS_ASCII
|
|
148
151
|
if string.isascii():
|
|
@@ -152,7 +155,7 @@ class EncodeUtils:
|
|
|
152
155
|
else:
|
|
153
156
|
s = cls._to_surrogates(string)
|
|
154
157
|
|
|
155
|
-
hex_table = cls.HEX_TABLE
|
|
158
|
+
hex_table: t.Tuple[str, ...] = cls.HEX_TABLE
|
|
156
159
|
buffer: t.List[str] = []
|
|
157
160
|
|
|
158
161
|
i = 0
|
|
@@ -214,48 +217,43 @@ class EncodeUtils:
|
|
|
214
217
|
"""
|
|
215
218
|
if c < 0x80: # ASCII
|
|
216
219
|
return [cls.HEX_TABLE[c]]
|
|
217
|
-
|
|
220
|
+
if c < 0x800: # 2 bytes
|
|
218
221
|
return [
|
|
219
222
|
cls.HEX_TABLE[0xC0 | (c >> 6)],
|
|
220
223
|
cls.HEX_TABLE[0x80 | (c & 0x3F)],
|
|
221
224
|
]
|
|
222
|
-
|
|
225
|
+
if c < 0xD800 or c >= 0xE000: # 3 bytes
|
|
223
226
|
return [
|
|
224
227
|
cls.HEX_TABLE[0xE0 | (c >> 12)],
|
|
225
228
|
cls.HEX_TABLE[0x80 | ((c >> 6) & 0x3F)],
|
|
226
229
|
cls.HEX_TABLE[0x80 | (c & 0x3F)],
|
|
227
230
|
]
|
|
228
|
-
|
|
229
|
-
return cls._encode_surrogate_pair(string, i, c)
|
|
231
|
+
return cls._encode_surrogate_pair(string, i, c)
|
|
230
232
|
|
|
231
233
|
@classmethod
|
|
232
234
|
def _encode_surrogate_pair(cls, string: str, i: int, c: int) -> t.List[str]:
|
|
233
235
|
"""Encode a surrogate pair starting at `i` as a 4-byte UTF-8 sequence."""
|
|
234
|
-
buffer: t.List[str] = []
|
|
235
236
|
low = ord(string[i + 1])
|
|
236
237
|
c = 0x10000 + (((c & 0x3FF) << 10) | (low & 0x3FF))
|
|
237
|
-
|
|
238
|
-
[
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
],
|
|
244
|
-
)
|
|
245
|
-
return buffer
|
|
238
|
+
return [
|
|
239
|
+
cls.HEX_TABLE[0xF0 | (c >> 18)],
|
|
240
|
+
cls.HEX_TABLE[0x80 | ((c >> 12) & 0x3F)],
|
|
241
|
+
cls.HEX_TABLE[0x80 | ((c >> 6) & 0x3F)],
|
|
242
|
+
cls.HEX_TABLE[0x80 | (c & 0x3F)],
|
|
243
|
+
]
|
|
246
244
|
|
|
247
245
|
@staticmethod
|
|
248
246
|
def _to_surrogates(string: str) -> str:
|
|
249
247
|
"""Expand non-BMP code points (code point > 0xFFFF) into UTF-16 surrogate pairs.
|
|
250
248
|
|
|
251
249
|
This mirrors how JavaScript strings store characters, allowing compatibility with legacy `%uXXXX` encoding paths
|
|
252
|
-
and consistent behavior with the Node `qs` implementation. If no non-BMP
|
|
253
|
-
|
|
250
|
+
and consistent behavior with the Node `qs` implementation. If no non-BMP code point is present, the original
|
|
251
|
+
string is returned unchanged.
|
|
254
252
|
"""
|
|
255
253
|
buffer: t.Optional[t.List[str]] = None
|
|
256
254
|
|
|
257
255
|
for index, ch in enumerate(string):
|
|
258
|
-
cp = ord(ch)
|
|
256
|
+
cp: int = ord(ch)
|
|
259
257
|
if cp <= 0xFFFF:
|
|
260
258
|
if buffer is not None:
|
|
261
259
|
buffer.append(ch)
|
|
@@ -266,8 +264,8 @@ class EncodeUtils:
|
|
|
266
264
|
|
|
267
265
|
# Convert to surrogate pair.
|
|
268
266
|
cp -= 0x10000
|
|
269
|
-
high = 0xD800 + (cp >> 10)
|
|
270
|
-
low = 0xDC00 + (cp & 0x3FF)
|
|
267
|
+
high: int = 0xD800 + (cp >> 10)
|
|
268
|
+
low: int = 0xDC00 + (cp & 0x3FF)
|
|
271
269
|
buffer.append(chr(high))
|
|
272
270
|
buffer.append(chr(low))
|
|
273
271
|
|
|
@@ -38,7 +38,7 @@ def _numeric_key_pairs(mapping: t.Mapping[t.Any, t.Any]) -> t.List[t.Tuple[int,
|
|
|
38
38
|
may overwrite earlier values when materializing numeric-keyed dicts.
|
|
39
39
|
"""
|
|
40
40
|
pairs: t.List[t.Tuple[int, t.Any]] = []
|
|
41
|
-
for key in mapping
|
|
41
|
+
for key in mapping:
|
|
42
42
|
try:
|
|
43
43
|
numeric_key = int(key)
|
|
44
44
|
except (TypeError, ValueError):
|
|
@@ -125,17 +125,17 @@ class Utils:
|
|
|
125
125
|
The merged structure. May be the original `target` object when
|
|
126
126
|
`source` is ``None``.
|
|
127
127
|
"""
|
|
128
|
-
opts = options if options is not None else DecodeOptions()
|
|
128
|
+
opts: DecodeOptions = options if options is not None else DecodeOptions()
|
|
129
129
|
last_result: t.Any = None
|
|
130
130
|
|
|
131
131
|
stack: t.List[_MergeFrame] = [_MergeFrame(target=target, source=source, options=opts)]
|
|
132
132
|
|
|
133
133
|
while stack:
|
|
134
|
-
frame = stack[-1]
|
|
134
|
+
frame: _MergeFrame = stack[-1]
|
|
135
135
|
|
|
136
136
|
if frame.phase == "start":
|
|
137
|
-
current_target = frame.target
|
|
138
|
-
current_source = frame.source
|
|
137
|
+
current_target: t.Any = frame.target
|
|
138
|
+
current_source: t.Any = frame.source
|
|
139
139
|
|
|
140
140
|
if current_source is None:
|
|
141
141
|
stack.pop()
|
|
@@ -148,24 +148,26 @@ class Utils:
|
|
|
148
148
|
# If the target sequence contains `Undefined`, we may need to promote it
|
|
149
149
|
# to a dict keyed by indices for stable writes.
|
|
150
150
|
if any(isinstance(el, Undefined) for el in current_target):
|
|
151
|
-
|
|
151
|
+
target_by_index: t.Dict[int, t.Any] = dict(enumerate(current_target))
|
|
152
152
|
|
|
153
153
|
if isinstance(current_source, (list, tuple)):
|
|
154
154
|
for i, item in enumerate(current_source):
|
|
155
155
|
if not isinstance(item, Undefined):
|
|
156
|
-
|
|
156
|
+
target_by_index[i] = item
|
|
157
157
|
else:
|
|
158
|
-
|
|
158
|
+
target_by_index[len(target_by_index)] = current_source
|
|
159
159
|
|
|
160
160
|
# When list parsing is disabled, collapse to a string-keyed dict and drop sentinels.
|
|
161
161
|
if not frame.options.parse_lists and any(
|
|
162
|
-
isinstance(value, Undefined) for value in
|
|
162
|
+
isinstance(value, Undefined) for value in target_by_index.values()
|
|
163
163
|
):
|
|
164
164
|
result: t.Any = {
|
|
165
|
-
str(i):
|
|
165
|
+
str(i): target_by_index[i]
|
|
166
|
+
for i in target_by_index
|
|
167
|
+
if not isinstance(target_by_index[i], Undefined)
|
|
166
168
|
}
|
|
167
169
|
else:
|
|
168
|
-
result = [el for el in
|
|
170
|
+
result = [el for el in target_by_index.values() if not isinstance(el, Undefined)]
|
|
169
171
|
stack.pop()
|
|
170
172
|
last_result = result
|
|
171
173
|
continue
|
|
@@ -182,7 +184,7 @@ class Utils:
|
|
|
182
184
|
frame.phase = "list_iter"
|
|
183
185
|
continue
|
|
184
186
|
|
|
185
|
-
mutable_target = (
|
|
187
|
+
mutable_target: t.List[t.Any] = (
|
|
186
188
|
list(current_target) if isinstance(current_target, tuple) else current_target
|
|
187
189
|
)
|
|
188
190
|
# Mutates in-place by design for list targets to preserve merge performance.
|
|
@@ -260,16 +262,18 @@ class Utils:
|
|
|
260
262
|
continue
|
|
261
263
|
|
|
262
264
|
if Utils.is_overflow(current_source):
|
|
263
|
-
source_of = t.cast(OverflowDict, current_source)
|
|
264
|
-
sorted_pairs = sorted(
|
|
265
|
-
|
|
265
|
+
source_of: OverflowDict = t.cast(OverflowDict, current_source)
|
|
266
|
+
sorted_pairs: t.List[t.Tuple[int, t.Any]] = sorted(
|
|
267
|
+
_numeric_key_pairs(source_of), key=lambda item: item[0]
|
|
268
|
+
)
|
|
269
|
+
numeric_keys: t.Set[str] = {key for _, key in sorted_pairs}
|
|
266
270
|
result = OverflowDict()
|
|
267
|
-
offset = 0
|
|
271
|
+
offset: int = 0
|
|
268
272
|
if not isinstance(current_target, Undefined):
|
|
269
273
|
result["0"] = current_target
|
|
270
274
|
offset = 1
|
|
271
275
|
for numeric_key, key in sorted_pairs:
|
|
272
|
-
val = source_of[key]
|
|
276
|
+
val: t.Any = source_of[key]
|
|
273
277
|
if not isinstance(val, Undefined):
|
|
274
278
|
# Offset ensures target occupies index "0"; source indices shift up by 1.
|
|
275
279
|
result[str(numeric_key + offset)] = val
|
|
@@ -283,19 +287,17 @@ class Utils:
|
|
|
283
287
|
continue
|
|
284
288
|
|
|
285
289
|
result_list: t.List[t.Any] = []
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if not isinstance(element, Undefined):
|
|
291
|
-
result_list.append(element)
|
|
290
|
+
if not isinstance(current_target, Undefined):
|
|
291
|
+
result_list.append(current_target)
|
|
292
|
+
if not isinstance(current_source, Undefined):
|
|
293
|
+
result_list.append(current_source)
|
|
292
294
|
stack.pop()
|
|
293
295
|
last_result = result_list
|
|
294
296
|
continue
|
|
295
297
|
|
|
296
298
|
# Prepare a mutable target we can merge into; reuse dict targets for performance.
|
|
297
299
|
frame.merge_target = current_target if isinstance(current_target, dict) else dict(current_target)
|
|
298
|
-
frame.merge_existing_keys = set(frame.merge_target
|
|
300
|
+
frame.merge_existing_keys = set(frame.merge_target)
|
|
299
301
|
frame.pending_updates = {}
|
|
300
302
|
frame.source_items = list(current_source.items())
|
|
301
303
|
frame.entry_index = 0
|
|
@@ -304,7 +306,7 @@ class Utils:
|
|
|
304
306
|
continue
|
|
305
307
|
|
|
306
308
|
if frame.phase == "map_iter":
|
|
307
|
-
merge_target = frame.merge_target
|
|
309
|
+
merge_target: t.Optional[t.MutableMapping[t.Any, t.Any]] = frame.merge_target
|
|
308
310
|
if merge_target is None: # pragma: no cover - internal invariant
|
|
309
311
|
raise RuntimeError("merge target is not initialized") # noqa: TRY003
|
|
310
312
|
|
|
@@ -441,7 +443,7 @@ class Utils:
|
|
|
441
443
|
"""
|
|
442
444
|
i: int = len(value) - 1
|
|
443
445
|
while i >= 0:
|
|
444
|
-
item = value[i]
|
|
446
|
+
item: t.Any = value[i]
|
|
445
447
|
if isinstance(item, Undefined):
|
|
446
448
|
value.pop(i)
|
|
447
449
|
elif isinstance(item, dict):
|
|
@@ -462,9 +464,9 @@ class Utils:
|
|
|
462
464
|
`_dicts_are_equal` to avoid descending into the same mapping from itself.
|
|
463
465
|
"""
|
|
464
466
|
# Snapshot keys so we can delete while iterating.
|
|
465
|
-
keys: t.List[t.Any] = list(obj
|
|
467
|
+
keys: t.List[t.Any] = list(obj)
|
|
466
468
|
for key in keys:
|
|
467
|
-
val = obj[key]
|
|
469
|
+
val: t.Any = obj[key]
|
|
468
470
|
if isinstance(val, Undefined):
|
|
469
471
|
obj.pop(key)
|
|
470
472
|
elif isinstance(val, dict) and not Utils._dicts_are_equal(val, obj):
|
|
@@ -514,8 +516,7 @@ class Utils:
|
|
|
514
516
|
if not Utils._dicts_are_equal(v, d2[k], path):
|
|
515
517
|
return False
|
|
516
518
|
return True
|
|
517
|
-
|
|
518
|
-
return d1 == d2
|
|
519
|
+
return d1 == d2
|
|
519
520
|
|
|
520
521
|
@staticmethod
|
|
521
522
|
def is_overflow(obj: t.Any) -> bool:
|
|
@@ -554,11 +555,11 @@ class Utils:
|
|
|
554
555
|
"""
|
|
555
556
|
if Utils.is_overflow(a):
|
|
556
557
|
# a is already an OverflowDict. Append b as one value at the next numeric index.
|
|
557
|
-
orig_a = t.cast(OverflowDict, a)
|
|
558
|
-
a_copy = orig_a.__class__({k: v for k, v in orig_a.items() if not isinstance(v, Undefined)})
|
|
558
|
+
orig_a: OverflowDict = t.cast(OverflowDict, a)
|
|
559
|
+
a_copy: OverflowDict = orig_a.__class__({k: v for k, v in orig_a.items() if not isinstance(v, Undefined)})
|
|
559
560
|
# Use max key + 1 to handle sparse dicts safely, rather than len(a)
|
|
560
|
-
key_pairs = _numeric_key_pairs(a_copy)
|
|
561
|
-
idx = (max(key for key, _ in key_pairs) + 1) if key_pairs else 0
|
|
561
|
+
key_pairs: t.List[t.Tuple[int, str]] = _numeric_key_pairs(a_copy)
|
|
562
|
+
idx: int = (max(key for key, _ in key_pairs) + 1) if key_pairs else 0
|
|
562
563
|
|
|
563
564
|
if not isinstance(b, Undefined):
|
|
564
565
|
a_copy[str(idx)] = _copy_overflow_append_value(b)
|
|
@@ -567,17 +568,17 @@ class Utils:
|
|
|
567
568
|
# Normal combination: flatten lists/tuples
|
|
568
569
|
# Flatten a
|
|
569
570
|
if isinstance(a, (list, tuple)):
|
|
570
|
-
list_a = [x for x in a if not isinstance(x, Undefined)]
|
|
571
|
+
list_a: t.List[t.Any] = [x for x in a if not isinstance(x, Undefined)]
|
|
571
572
|
else:
|
|
572
573
|
list_a = [a] if not isinstance(a, Undefined) else []
|
|
573
574
|
|
|
574
575
|
# Flatten b, handling OverflowDict as a list source
|
|
575
576
|
if isinstance(b, (list, tuple)):
|
|
576
|
-
list_b = [x for x in b if not isinstance(x, Undefined)]
|
|
577
|
+
list_b: t.List[t.Any] = [x for x in b if not isinstance(x, Undefined)]
|
|
577
578
|
elif isinstance(b, CommaOverflowDict):
|
|
578
579
|
list_b = [b]
|
|
579
580
|
elif Utils.is_overflow(b):
|
|
580
|
-
b_of = t.cast(OverflowDict, b)
|
|
581
|
+
b_of: OverflowDict = t.cast(OverflowDict, b)
|
|
581
582
|
list_b = [
|
|
582
583
|
b_of[k]
|
|
583
584
|
for _, k in sorted(_numeric_key_pairs(b_of), key=lambda item: item[0])
|
|
@@ -586,9 +587,9 @@ class Utils:
|
|
|
586
587
|
else:
|
|
587
588
|
list_b = [b] if not isinstance(b, Undefined) else []
|
|
588
589
|
|
|
589
|
-
res = [*list_a, *list_b]
|
|
590
|
+
res: t.List[t.Any] = [*list_a, *list_b]
|
|
590
591
|
|
|
591
|
-
list_limit = options.list_limit if options else DecodeOptions().list_limit
|
|
592
|
+
list_limit: int = options.list_limit if options else DecodeOptions().list_limit
|
|
592
593
|
if list_limit < 0:
|
|
593
594
|
return OverflowDict({str(i): x for i, x in enumerate(res)}) if res else res
|
|
594
595
|
if len(res) > list_limit:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|