Skip to content

Commit bca7bcc

Browse files
authored
🐛 fix decode dot in keys (#25)
* ♻️ refactor DecodeOptions to support legacy decoders and add unified decode methods * 🐛 fix top-level dot splitting in keys to preserve encoded dots and handle degenerate cases * 🐛 normalize percent-encoded dots in bracketed keys when decode_dot_in_keys is enabled * ✅ add tests for DecodeOptions dot-in-keys and custom decoder behaviors * ✅ add CSharp parity tests for encoded dot behavior in DecodeOptions * ✅ add tests for decoder precedence over legacy_decoder and non-string decoder results in DecodeOptions * 🐛 handle leading dot in keys by converting to bracket segment in dot_to_bracket_top_level * ✅ add tests for dot encoding and decoding parity across DecodeOptions configurations * 💡 update docstring to clarify handling of degenerate cases and unterminated brackets in top-level dot splitting * 💡 clarify docstrings for list_limit, comma-splitting, and percent-encoded dot handling in decode logic * ♻️ update type annotations in decode_options_test for decoder and legacy_decoder signatures * ✅ revise decode test to avoid duplicate dict key assertion and ensure decoder invocation for dot-encoded and bracketed keys * 💡 update comment to clarify top-level dot splitting behavior in decode logic * 💡 add comment to clarify handling of typing.Literal annotations for kind parameter in decode logic * 💡 clarify comment on comma-split list logic and list_limit enforcement in decode function * 💡 add comment to clarify normalization of percent-encoded brackets in query string prior to splitting * 💡 add comment to clarify conservative heuristic for list length enforcement in decode logic * 💡 update comment to clarify percent-decoding behavior and handling of encoded dots in dot_to_bracket_top_level * 🐛 fix strict_depth enforcement to avoid raising on unterminated bracket groups in decode logic * 💡 clarify docstring on decode_key to note coercion of non-string decoder outputs via str() * 🐛 fix dot-to-bracket decoding to preserve leading dots in consecutive dot sequences * 💡 add examples to docstring for decode_key to illustrate max_depth and unterminated bracket handling * 💡 document that 'kind' parameter in decode_scalar is ignored and may be removed in future * 💡 clarify KEY docstring to note default decoder behavior for percent-encoded dots * ✅ add tests for split_key_into_segments remainder handling and strict depth enforcement * 🐛 fix percent-decoding to handle dot in keys and clarify top-level percent sequence handling * 🐛 handle ambiguous '.]' in key decoding and prevent bracket segment overrun on closing brackets * 💡 update decode_kind docstring to clarify scalar decoder behavior for percent-decoded dots in keys * 💡 clarify docstring to specify dot splitting only occurs on actual '.' at depth 0, not percent-encoded sequences
1 parent 2c3e103 commit bca7bcc

6 files changed

Lines changed: 666 additions & 151 deletions

File tree

src/qs_codec/decode.py

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -124,14 +124,24 @@ def _interpret_numeric_entities(value: str) -> str:
124124

125125

126126
def _parse_array_value(value: t.Any, options: DecodeOptions, current_list_length: int) -> t.Any:
127-
"""Post-process a raw scalar for list semantics and enforce `list_limit`.
127+
"""Post-process a raw scalar for list semantics and enforce ``list_limit``.
128128
129129
Behavior
130130
--------
131-
- If `comma=True` and `value` is a string that contains commas, split into a list.
132-
- Otherwise, enforce the per-list length limit by comparing `current_list_length` to `options.list_limit`. When `raise_on_limit_exceeded=True`, violations raise `ValueError`.
131+
- If ``comma=True`` and ``value`` is a string that contains commas, split into a list.
132+
- Otherwise, enforce the per-list length limit by comparing ``current_list_length`` to ``options.list_limit``.
133+
When ``raise_on_limit_exceeded=True``, violations raise ``ValueError``.
134+
- When ``list_limit`` is negative:
135+
* if ``raise_on_limit_exceeded=True``, **any** list-growth operation here (e.g., comma-splitting)
136+
raises immediately;
137+
* if ``raise_on_limit_exceeded=False`` (default), comma-splitting still returns a list; numeric
138+
bracket indices are handled later by ``_parse_object`` (where negative ``list_limit`` disables
139+
numeric-index parsing only).
133140
134-
Returns either the original value or a list of values, without decoding (that happens later).
141+
Returns
142+
-------
143+
Any
144+
Either the original value or a list of values, without decoding (that happens later).
135145
"""
136146
if isinstance(value, str) and value and options.comma and "," in value:
137147
split_val: t.List[str] = value.split(",")
@@ -155,22 +165,29 @@ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str
155165
Responsibilities
156166
----------------
157167
- Strip a leading '?' if ``ignore_query_prefix`` is True.
158-
- Normalize percent-encoded square brackets (``%5B/%5D``) so the key splitter can operate.
168+
- Normalize percent-encoded square brackets (``%5B/%5D``) (case-insensitive) so the key splitter can operate.
159169
- Split into parts using either a string delimiter or a regex delimiter.
160170
- Enforce ``parameter_limit`` (optionally raising).
161-
- Detect the UTF-8/Latin-1 charset via the `utf8=…` sentinel when enabled.
171+
- Detect the UTF-8/Latin-1 charset via the ``utf8=…`` sentinel when enabled.
162172
- For each ``key=value`` pair:
163-
* Percent-decode key/value using the selected charset.
164-
* Apply list/comma logic to values.
173+
* Decode key/value via ``options.decoder`` (default: percent-decoding using the selected ``charset``).
174+
Keys are passed with ``kind=DecodeKind.KEY`` and values with ``kind=DecodeKind.VALUE``; a custom decoder
175+
may return the raw token or ``None``.
176+
* 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.
165177
* Interpret numeric entities for Latin-1 when requested.
166-
* Handle empty brackets ``[]`` as list markers.
178+
* Handle empty brackets ``[]`` as list markers (wrapping exactly once).
167179
* Merge duplicate keys according to ``duplicates`` policy.
168180
169-
The output is a *flat* dict (keys are full key-path strings). Higher-level structure is constructed later by ``_parse_keys`` / ``_parse_object``.
181+
The output is a *flat* dict (keys are full key-path strings). Higher-level structure is constructed later by
182+
``_parse_keys`` / ``_parse_object``.
170183
"""
171184
obj: t.Dict[str, t.Any] = {}
172185

173186
clean_str: str = value.replace("?", "", 1) if options.ignore_query_prefix else value
187+
# Normalize %5B/%5D to literal brackets before splitting (case-insensitive).
188+
# Note: this operates on the entire query string (keys *and* values). That’s
189+
# intentional: it keeps the splitter simple, and value tokens are subsequently
190+
# passed through the scalar decoder, so this replacement is safe.
174191
clean_str = clean_str.replace("%5B", "[").replace("%5b", "[").replace("%5D", "]").replace("%5d", "]")
175192

176193
# Compute an effective parameter limit (None means "no limit").
@@ -296,12 +313,32 @@ def _parse_object(
296313
Notes
297314
-----
298315
- Builds lists when encountering ``[]`` (respecting ``allow_empty_lists`` and null handling).
299-
- Converts bracketed numeric segments into list indices when allowed and within ``list_limit``.
316+
- Converts bracketed **numeric** segments into list indices when allowed and within ``list_limit``.
317+
- When ``list_limit`` is negative, **numeric-indexed bracket segments** are treated as map keys
318+
(i.e., index-based list growth is disabled). Empty brackets (``[]``) still create lists unless
319+
``raise_on_limit_exceeded`` is True; with ``raise_on_limit_exceeded=True``, any list-growth operation
320+
(empty brackets, comma-split, nested pushes) raises immediately.
321+
- Inside bracket segments, a custom key decoder may leave percent-encoded dots (``%2E/%2e``). When
322+
``decode_dot_in_keys`` is True, these are normalized to ``.`` here. Top‑level dot splitting is already
323+
handled by the splitter.
300324
- When list parsing is disabled and an empty segment is encountered, coerces to ``{"0": leaf}`` to preserve round-trippability with other ports.
301325
"""
302326
current_list_length: int = 0
303327

304328
# If the chain ends with an empty list marker, compute current list length for limit checks.
329+
# Best-effort note:
330+
# This is a conservative heuristic intended to help when we see patterns like `a[0][]=`,
331+
# so `_parse_array_value` can enforce the list limit for the final `[]` push. The segments
332+
# we receive in `chain` include bracket markers (e.g., `["a", "[0]", "[]"]`), so
333+
# `"".join(chain[:-1])` is rarely a pure integer (e.g., `"a[0]"` raises `ValueError`),
334+
# and we typically fall back to `0`. That’s fine: it remains safe and conservative.
335+
# We still:
336+
# • enforce per-list length for already-allocated containers during tokenization in
337+
# `_parse_query_string_values` (where we know the current length), and
338+
# • enforce index-based growth limits inside this function when converting bracketed
339+
# numeric segments into list indices.
340+
# Keeping this lightweight probe matches the other ports and avoids costly look-ahead into
341+
# parent structures while maintaining correct limit behavior.
305342
if bool(chain) and chain[-1] == "[]":
306343
parent_key: t.Optional[int]
307344

@@ -329,7 +366,12 @@ def _parse_object(
329366
else:
330367
obj = dict()
331368

332-
# Optionally treat `%2E` as a literal dot (when `decode_dot_in_keys` is enabled).
369+
# Map `%2E`/`%2e` to a literal dot *inside bracket segments* when
370+
# `decode_dot_in_keys` is enabled. Even though `_parse_query_string_values`
371+
# typically percent‑decodes the key (default decoder), a custom
372+
# `DecodeOptions.decoder` may return the raw token. In that case, `%2E` can
373+
# still appear here and must be normalized for parity with the Kotlin/C# ports.
374+
# (Top‑level dot splitting is performed earlier by the key splitter.)
333375
clean_root: str = root[1:-1] if root.startswith("[") and root.endswith("]") else root
334376

335377
if options.decode_dot_in_keys:

src/qs_codec/enums/decode_kind.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Decoding context used by the query string parser and utilities.
22
33
This enum indicates whether a given piece of text is being decoded as a *key*
4-
(or key segment) or as a *value*. The distinction matters for encoded dots
5-
(``%2E``/``%2e``) in keys: when decoding keys, the default behavior is to
6-
*preserve* these so that higher‑level options like ``allow_dots`` and
7-
``decode_dot_in_keys`` can be applied consistently later in the parse.
4+
(or key segment) or as a *value*. Note that the built-in scalar decoder
5+
(`qs_codec.utils.decode_utils.decode`) ignores `kind` and fully percent-decodes
6+
dots; preservation of encoded dots for splitting is applied later by parser
7+
options (`allow_dots`, `decode_dot_in_keys`).
88
"""
99

1010
from enum import Enum
@@ -16,9 +16,10 @@ class DecodeKind(str, Enum):
1616
Attributes
1717
----------
1818
KEY
19-
Decode a *key* (or key segment). Implementations typically preserve
20-
percent‑encoded dots (``%2E``/``%2e``) so that dot‑splitting semantics can
21-
be applied later according to parser options.
19+
Decode a *key* (or key segment). Note that the default scalar decoder
20+
(``qs_codec.utils.decode_utils.decode``) ignores `kind` and fully
21+
decodes percent-encoded dots (``%2E``/``%2e``). Dot-splitting behavior is
22+
applied later by higher-level parser options.
2223
VALUE
2324
Decode a *value*. Implementations typically perform full percent decoding.
2425
"""

0 commit comments

Comments
 (0)