Skip to content

Commit 3ee7509

Browse files
authored
Merge pull request #45 from techouse/chore/optimize-encoder-some-more
* [CHORE] optimize `encode` hot paths with lower-overhead iterative traversal, cheaper container checks, and deep linear mapping fast paths * [CHORE] optimize `EncodeUtils` ASCII/BMP handling and `KeyPathNode` path materialization caching for repeated encode workloads * [FIX] preserve legacy `max_depth` vs circular-reference error precedence in both generic and fast-path encoding * [FIX] keep `EncodeOptions` free of encode-time mutable cache state while preserving encoder/equality behavior * [CHORE] expand encode regression coverage for deep nesting, cycle handling, key-path caching, and `EncodeOptions` semantics
2 parents cc8e162 + 30d145c commit 3ee7509

17 files changed

Lines changed: 1250 additions & 184 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 1.4.5-wip
2+
3+
* [CHORE] optimize `encode` hot paths with lower-overhead iterative traversal, cheaper container checks, and deep linear mapping fast paths
4+
* [CHORE] optimize `EncodeUtils` ASCII/BMP handling and `KeyPathNode` path materialization caching for repeated encode workloads
5+
* [FIX] preserve legacy `max_depth` vs circular-reference error precedence in both generic and fast-path encoding
6+
* [FIX] keep `EncodeOptions` free of encode-time mutable cache state while preserving encoder/equality behavior
7+
* [CHORE] expand encode regression coverage for deep nesting, cycle handling, key-path caching, and `EncodeOptions` semantics
8+
19
## 1.4.4
210

311
* [CHORE] optimize `decode` hot paths via structured-key pre-scan/bypass logic and lower-overhead default decoder dispatch

src/qs_codec/decode.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ def _parse_object(
435435
``raise_on_limit_exceeded`` is True; with ``raise_on_limit_exceeded=True``, any list-growth operation
436436
(empty brackets, comma-split, nested pushes) raises immediately.
437437
- Inside bracket segments, a custom key decoder may leave percent-encoded dots (``%2E/%2e``). When
438-
``decode_dot_in_keys`` is True, these are normalized to ``.`` here. Toplevel dot splitting is already
438+
``decode_dot_in_keys`` is True, these are normalized to ``.`` here. Top-level dot splitting is already
439439
handled by the splitter.
440440
- When list parsing is disabled and an empty segment is encountered, coerces to ``{"0": leaf}`` to preserve round-trippability with other ports.
441441
"""
@@ -489,10 +489,10 @@ def _parse_object(
489489

490490
# Map `%2E`/`%2e` to a literal dot *inside bracket segments* when
491491
# `decode_dot_in_keys` is enabled. Even though `_parse_query_string_values`
492-
# typically percentdecodes the key (default decoder), a custom
492+
# typically percent-decodes the key (default decoder), a custom
493493
# `DecodeOptions.decoder` may return the raw token. In that case, `%2E` can
494494
# still appear here and must be normalized for parity with the Kotlin/C#/Swift/Dart ports.
495-
# (Toplevel dot splitting is performed earlier by the key splitter.)
495+
# (Top-level dot splitting is performed earlier by the key splitter.)
496496
clean_root: str = root[1:-1] if root.startswith("[") and root.endswith("]") else root
497497

498498
if options.decode_dot_in_keys and "%2" in clean_root:

src/qs_codec/encode.py

Lines changed: 474 additions & 62 deletions
Large diffs are not rendered by default.

src/qs_codec/enums/list_format.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""List formatting strategies for querystring arrays.
1+
"""List formatting strategies for query-string arrays.
22
33
This module defines small generator functions and an enum that the encoder
44
uses to format list (array) keys, e.g., ``foo[]=1``, ``foo[0]=1``, ``foo=1,2``,
@@ -36,7 +36,7 @@ def brackets(prefix: str, key: t.Optional[str] = None) -> str: # pylint: disabl
3636

3737
@staticmethod
3838
def comma(prefix: str, key: t.Optional[str] = None) -> str: # pylint: disable=W0613
39-
"""Return the key for commaseparated lists (no change).
39+
"""Return the key for comma-separated lists (no change).
4040
4141
The encoder will join values with commas instead of repeating the key.
4242
@@ -104,7 +104,7 @@ class ListFormat(_ListFormatDataMixin, Enum):
104104
- ``BRACKETS``: ``foo[]`` for each element.
105105
- ``INDICES``: ``foo[0]``, ``foo[1]``, …
106106
- ``REPEAT``: repeat the key per value (``foo=1&foo=2``).
107-
- ``COMMA``: single key with commajoined values (``foo=1,2``).
107+
- ``COMMA``: single key with comma-joined values (``foo=1,2``).
108108
109109
These options control only how keys are produced; value encoding and
110110
delimiter handling are governed by other options.

src/qs_codec/enums/sentinel.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,16 @@ class Sentinel(_SentinelDataMixin, Enum):
3535
"""
3636

3737
ISO = r"✓", r"utf8=%26%2310003%3B"
38-
"""HTMLentity sentinel used by nonUTF8 submissions.
38+
"""HTML-entity sentinel used by non-UTF-8 submissions.
3939
4040
When a check mark (✓) appears but the page/form encoding is ``iso-8859-1``
41-
(or another charset that lacks ✓), browsers first HTMLentityescape it as
42-
``"✓"`` and then URLencode it, producing ``utf8=%26%2310003%3B``.
41+
(or another charset that lacks ✓), browsers first HTML-entity-escape it as
42+
``"✓"`` and then URL-encode it, producing ``utf8=%26%2310003%3B``.
4343
"""
4444

4545
CHARSET = r"✓", r"utf8=%E2%9C%93"
46-
"""UTF8 sentinel indicating the request is UTF8 encoded.
46+
"""UTF-8 sentinel indicating the request is UTF-8 encoded.
4747
48-
This is the percentencoded UTF8 sequence for ✓, yielding the fragment
48+
This is the percent-encoded UTF-8 sequence for ✓, yielding the fragment
4949
``utf8=%E2%9C%93``.
5050
"""

src/qs_codec/models/decode_options.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,13 @@ class DecodeOptions:
2525
When ``None`` (default), it inherits the value of ``decode_dot_in_keys``."""
2626

2727
decode_dot_in_keys: t.Optional[bool] = None
28-
"""Set to ``True`` to decode percentencoded dots in keys (e.g., ``%2E`` → ``.``).
28+
"""Set to ``True`` to decode percent-encoded dots in keys (e.g., ``%2E`` → ``.``).
2929
Note: it implies ``allow_dots``, so ``decode`` will error if you set ``decode_dot_in_keys`` to ``True``, and
3030
``allow_dots`` to ``False``.
3131
When ``None`` (default), it defaults to ``False``.
3232
3333
Inside bracket segments, percent-decoding naturally yields ``.`` from ``%2E/%2e``. This option controls whether
34-
**toplevel** encoded dots are treated as additional split points; it does **not** affect the literal ``.`` produced
34+
**top-level** encoded dots are treated as additional split points; it does **not** affect the literal ``.`` produced
3535
by percent-decoding inside bracket segments."""
3636

3737
allow_empty_lists: bool = False
@@ -136,7 +136,7 @@ class DecodeOptions:
136136
"""
137137

138138
legacy_decoder: t.Optional[t.Callable[..., t.Optional[t.Any]]] = None
139-
"""Backcompat adapter for legacy decoders of the form ``decoder(value, charset)``.
139+
"""Back-compat adapter for legacy decoders of the form ``decoder(value, charset)``.
140140
Prefer ``decoder`` which may optionally accept a ``kind`` argument. When both are supplied,
141141
``decoder`` takes precedence (mirroring Kotlin/C#/Swift/Dart behavior)."""
142142

src/qs_codec/models/encode_frame.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ class EncodeFrame:
8484
is_sequence: bool
8585
step: int
8686
obj_keys: t.List[t.Any]
87-
values: t.List[t.Any]
87+
values: t.Optional[t.List[t.Any]]
8888
index: int
8989
adjusted_path: t.Optional[KeyPathNode]
9090
cycle_state: t.Optional[CycleState]
@@ -152,7 +152,7 @@ def __init__(
152152
self.is_sequence = False
153153
self.step = 0
154154
self.obj_keys = []
155-
self.values = []
155+
self.values = None
156156
self.index = 0
157157
self.adjusted_path = None
158158
self.cycle_state = cycle_state

src/qs_codec/models/encode_options.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class EncodeOptions:
4646
"""Controls how lists are encoded (indices/brackets/repeat/comma). See `ListFormat`."""
4747

4848
charset: Charset = Charset.UTF8
49-
"""Character encoding used by the encoder (defaults to UTF8)."""
49+
"""Character encoding used by the encoder (defaults to UTF-8)."""
5050

5151
charset_sentinel: bool = False
5252
"""When `True`, include a sentinel parameter announcing the charset (e.g. `utf8=✓`)."""
@@ -55,7 +55,7 @@ class EncodeOptions:
5555
"""Pair delimiter between tokens (typically `&`; `;` and others are allowed)."""
5656

5757
encode: bool = True
58-
"""Master switch. When `False`, values/keys are not percentencoded (joined as-is)."""
58+
"""Master switch. When `False`, values/keys are not percent-encoded (joined as-is)."""
5959

6060
encode_dot_in_keys: bool = field(default=None) # type: ignore [assignment]
6161
"""When `True`, encode dots in keys literally. With `encode_values_only=True`, only key dots are encoded while values remain untouched."""
@@ -64,7 +64,7 @@ class EncodeOptions:
6464
"""When `True`, the encoder is applied to values only; keys are left unencoded."""
6565

6666
format: Format = Format.RFC3986
67-
"""Space handling and percentencoding style. `RFC3986` encodes spaces as `%20`, while
67+
"""Space handling and percent-encoding style. `RFC3986` encodes spaces as `%20`, while
6868
`RFC1738` uses `+`."""
6969

7070
filter: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]] = field(default=None)
@@ -86,7 +86,7 @@ class EncodeOptions:
8686
)
8787
"""Custom scalar encoder. Signature: `(value, charset|None, format|None) -> str`.
8888
Note: when `encode=False`, this is bypassed and values are joined without
89-
percentencoding."""
89+
percent-encoding."""
9090

9191
_encoder: t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str] = field(init=False, repr=False)
9292

@@ -97,18 +97,30 @@ def encoder(self) -> t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]]
9797
The returned callable has signature `(value) -> str` and internally calls the
9898
underlying `_encoder(value, self.charset, self.format)`.
9999
"""
100-
return lambda v, c=self.charset, f=self.format: self._encoder(v, c, f) # type: ignore [misc]
100+
raw_encoder = self._encoder
101+
charset = self.charset
102+
format_ = self.format
103+
104+
def bound_encoder(
105+
value: t.Any,
106+
c: t.Optional[Charset] = charset,
107+
f: t.Optional[Format] = format_,
108+
_encoder: t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str] = raw_encoder,
109+
) -> str:
110+
return _encoder(value, c, f)
111+
112+
return bound_encoder
101113

102114
@encoder.setter
103115
def encoder(self, value: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]]) -> None:
104-
"""Set the underlying encoder, falling back to `EncodeUtils.encode` when `None` or noncallable."""
116+
"""Set the underlying encoder, falling back to `EncodeUtils.encode` when `None` or non-callable."""
105117
self._encoder = value if callable(value) else EncodeUtils.encode
106118

107119
strict_null_handling: bool = False
108120
"""When `True`, distinguish empty strings from `None`: `None` → `a` (no `=`), empty string → `a=`."""
109121

110122
comma_round_trip: t.Optional[bool] = None
111-
"""Only used with `ListFormat.COMMA`. When `True`, singleitem lists append `[]` so they roundtrip back to a list on decode."""
123+
"""Only used with `ListFormat.COMMA`. When `True`, single-item lists append `[]` so they round-trip back to a list on decode."""
112124

113125
comma_compact_nulls: bool = False
114126
"""Only with `ListFormat.COMMA`. When `True`, omit `None` entries inside lists instead of emitting empty positions
@@ -127,7 +139,7 @@ def encoder(self, value: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Op
127139
def __post_init__(self) -> None:
128140
"""Normalize interdependent options.
129141
130-
- If `allow_dots` is `None`, mirror `encode_dot_in_keys` (treating non`True` as `False`).
142+
- If `allow_dots` is `None`, mirror `encode_dot_in_keys` (treating non-`True` as `False`).
131143
- Default `encode_dot_in_keys` to `False` when unset.
132144
- Map deprecated `indices` to `list_format` for backward compatibility.
133145
"""
@@ -146,20 +158,14 @@ def __post_init__(self) -> None:
146158
self.list_format = ListFormat.INDICES if self.indices else ListFormat.REPEAT
147159

148160
def __eq__(self, other: object) -> bool:
149-
"""Structural equality that treats the bound encoder consistently.
150-
151-
`dataclasses.asdict` would serialize the `encoder` property (a lambda bound to
152-
charset/format) differently on each instance. To compare meaningfully, we swap in
153-
`_encoder` (the raw callable) on both sides before comparing dictionaries.
154-
"""
161+
"""Structural equality that ignores the property wrapper for `encoder`."""
155162
if not isinstance(other, EncodeOptions):
156163
return False
157164

158165
for f in fields(EncodeOptions):
159166
name = f.name
160167
if name == "encoder":
161-
v1 = self._encoder
162-
v2 = other._encoder
168+
continue
163169
else:
164170
v1 = getattr(self, name)
165171
v2 = getattr(other, name)

src/qs_codec/models/key_path_node.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class KeyPathNode:
1414
path semantics.
1515
"""
1616

17+
_MATERIALIZE_CACHE_NODE_LIMIT: t.ClassVar[int] = 8
18+
1719
__slots__ = ("depth", "dot_encoded", "materialized", "parent", "segment")
1820
parent: t.Optional["KeyPathNode"]
1921
segment: str
@@ -87,14 +89,49 @@ def materialize(self) -> str:
8789
self.materialized = value
8890
return value
8991

90-
parts = [""] * self.depth
91-
node: t.Optional["KeyPathNode"] = self
92-
index = self.depth - 1
93-
while node is not None:
94-
parts[index] = node.segment
95-
node = node.parent
96-
index -= 1
92+
suffix_nodes: t.List["KeyPathNode"] = []
93+
current: t.Optional["KeyPathNode"] = self
94+
base = ""
95+
while current is not None:
96+
cached_current = current.materialized
97+
if cached_current is not None:
98+
base = cached_current
99+
break
100+
suffix_nodes.append(current)
101+
current = current.parent
102+
103+
suffix_count = len(suffix_nodes)
104+
prefix_lengths: t.List[int] = [0] * suffix_count
105+
parts: t.List[str] = [base] if base else []
106+
prefix_length = len(base)
107+
forward_index = 0
108+
for index in range(suffix_count - 1, -1, -1):
109+
node = suffix_nodes[index]
110+
parts.append(node.segment)
111+
prefix_length += len(node.segment)
112+
prefix_lengths[forward_index] = prefix_length
113+
forward_index += 1
97114

98115
value = "".join(parts)
99116
self.materialized = value
100-
return value
117+
118+
def cache_forward_index(index: int) -> None:
119+
node = suffix_nodes[suffix_count - 1 - index]
120+
if node.materialized is None:
121+
node.materialized = value[: prefix_lengths[index]]
122+
123+
# `self.materialized` is already populated above, so count the leaf
124+
# toward the cache budget without performing a redundant write.
125+
cached_nodes = 1
126+
127+
if suffix_count > 1 and cached_nodes < self._MATERIALIZE_CACHE_NODE_LIMIT:
128+
cache_forward_index(0)
129+
cached_nodes += 1
130+
131+
for index in range(suffix_count - 2, 0, -1):
132+
if cached_nodes >= self._MATERIALIZE_CACHE_NODE_LIMIT:
133+
break
134+
cache_forward_index(index)
135+
cached_nodes += 1
136+
137+
return self.materialized if self.materialized is not None else value

src/qs_codec/utils/decode_utils.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
"""Utilities for decoding percentencoded query strings and splitting composite keys into bracketed path segments.
1+
"""Utilities for decoding percent-encoded query strings and splitting composite keys into bracketed path segments.
22
33
This mirrors the semantics of the Node `qs` library:
44
5-
- Decoding handles both UTF8 and Latin1 code paths.
5+
- Decoding handles both UTF-8 and Latin-1 code paths.
66
- Key splitting keeps bracket groups *balanced* and optionally treats dots as path separators when ``allow_dots=True``.
7-
- Toplevel dot splitting uses a characterscanner that handles degenerate cases (leading '.' starts a bracket segment; '.[' is skipped; double dots preserve the first; trailing '.' is preserved) and never treats literal percentencoded sequences (e.g., '%2E') as split points; only actual '.' characters at depth 0 are split.
7+
- Top-level dot splitting uses a character-scanner that handles degenerate cases (leading '.' starts a bracket segment; '.[' is skipped; double dots preserve the first; trailing '.' is preserved) and never treats literal percent-encoded sequences (e.g., '%2E') as split points; only actual '.' characters at depth 0 are split.
88
"""
99

1010
import re
@@ -22,7 +22,7 @@ class DecodeUtils:
2222
the compiled regular expressions are created once per interpreter session.
2323
"""
2424

25-
# Matches either a 16bit JavaScript-style %uXXXX sequence or a singlebyte
25+
# Matches either a 16-bit JavaScript-style %uXXXX sequence or a single-byte
2626
# %XX sequence. Used by `unescape` to emulate legacy browser behavior.
2727
UNESCAPE_PATTERN: t.Pattern[str] = re.compile(
2828
r"%u(?P<unicode>[0-9A-Fa-f]{4})|%(?P<hex>[0-9A-Fa-f]{2})",
@@ -136,8 +136,8 @@ def unescape(cls, string: str) -> str:
136136
137137
Replaces both ``%XX`` and ``%uXXXX`` escape sequences with the
138138
corresponding code points. This function is intentionally permissive
139-
and does not validate UTF8; it is used to model historical behavior
140-
in Latin1 mode.
139+
and does not validate UTF-8; it is used to model historical behavior
140+
in Latin-1 mode.
141141
142142
Examples
143143
--------
@@ -166,7 +166,7 @@ def decode(
166166
charset: t.Optional[Charset] = Charset.UTF8,
167167
kind: DecodeKind = DecodeKind.VALUE, # pylint: disable=unused-argument
168168
) -> t.Optional[str]:
169-
"""Decode a URLencoded scalar.
169+
"""Decode a URL-encoded scalar.
170170
171171
Notes
172172
-----
@@ -176,9 +176,9 @@ def decode(
176176
177177
Behavior:
178178
- Replace ``+`` with a literal space *before* decoding.
179-
- If ``charset`` is :data:`~qs_codec.enums.charset.Charset.LATIN1`, decode only ``%XX`` byte sequences (no ``%uXXXX``). ``%uXXXX`` sequences are left asis to mimic older browser/JS behavior.
180-
- Otherwise (UTF8), defer to :func:`urllib.parse.unquote`.
181-
- Keys and values are decoded identically; whether a literal ``.`` acts as a key separator is decided later by the keysplitting logic.
179+
- If ``charset`` is :data:`~qs_codec.enums.charset.Charset.LATIN1`, decode only ``%XX`` byte sequences (no ``%uXXXX``). ``%uXXXX`` sequences are left as-is to mimic older browser/JS behavior.
180+
- Otherwise (UTF-8), defer to :func:`urllib.parse.unquote`.
181+
- Keys and values are decoded identically; whether a literal ``.`` acts as a key separator is decided later by the key-splitting logic.
182182
183183
Returns
184184
-------
@@ -212,8 +212,8 @@ def split_key_into_segments(
212212
) -> t.List[str]:
213213
"""Split a composite key into *balanced* bracket segments.
214214
215-
- If ``allow_dots`` is True, convert **toplevel** dots to bracket groups using a characterscanner (``a.b[c]`` → ``a[b][c]``), preserving dots inside brackets and degenerate cases.
216-
- The *parent* (nonbracket) prefix becomes the first segment, e.g. ``"a[b][c]"`` → ``["a", "[b]", "[c]"]``.
215+
- If ``allow_dots`` is True, convert **top-level** dots to bracket groups using a character-scanner (``a.b[c]`` → ``a[b][c]``), preserving dots inside brackets and degenerate cases.
216+
- The *parent* (non-bracket) prefix becomes the first segment, e.g. ``"a[b][c]"`` → ``["a", "[b]", "[c]"]``.
217217
- Bracket groups are *balanced* using a counter so nested brackets within a single group (e.g. ``"[with[inner]]"``) are treated as one segment.
218218
- When ``max_depth <= 0``, no splitting occurs; the key is returned as a single segment (qs semantics).
219219
- If there are more groups beyond ``max_depth`` and ``strict_depth`` is True, an ``IndexError`` is raised. Otherwise, the remainder is added as one final segment (again mirroring qs).

0 commit comments

Comments
 (0)