Skip to content

Commit b1b7f4b

Browse files
authored
⚡ various optimizations (#22)
* ⚡ refactor merge logic for improved readability and performance in utils * ⚡ optimize encoding logic for improved performance and clarity in encode_utils * ⚡ optimize decode logic for improved performance and clarity in decode_utils * ⚡ optimize key handling and object normalization for improved performance and clarity in encode.py * ⚡ optimize delimiter splitting and list parsing logic for improved performance and clarity in decode.py * ⚡ optimize proxy caching and dict hashing logic for improved performance and determinism in weak_wrapper * ⚡ optimize Undefined singleton logic for thread safety and clarity; prevent subclassing and ensure identity preservation * ⚡ optimize EncodeOptions initialization and equality logic for improved clarity and determinism * ⚡ optimize DecodeOptions post-init logic for improved determinism and enforce consistency between decode_dot_in_keys and allow_dots * ⚡ optimize list merging logic in Utils.merge for improved determinism and handling of Undefined values * ⚡ optimize type checking in list merging logic for improved clarity and consistency in Utils.merge * ⚡ optimize encode logic for improved determinism and clarity; use UNDEFINED singleton and refine ListFormat.COMMA comparison * ⚡ optimize decode logic to use UNDEFINED singleton for list initialization
1 parent 496328d commit b1b7f4b

9 files changed

Lines changed: 172 additions & 114 deletions

File tree

src/qs_codec/decode.py

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from .enums.duplicates import Duplicates
2121
from .enums.sentinel import Sentinel
2222
from .models.decode_options import DecodeOptions
23-
from .models.undefined import Undefined
23+
from .models.undefined import UNDEFINED
2424
from .utils.decode_utils import DecodeUtils
2525
from .utils.utils import Utils
2626

@@ -168,19 +168,17 @@ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str
168168
raise ValueError("Parameter limit must be a positive integer.")
169169

170170
parts: t.List[str]
171-
# Split using either a compiled regex or a literal string delimiter.
171+
# Split using either a compiled regex or a literal string delimiter (do it once, then slice if needed).
172172
if isinstance(options.delimiter, re.Pattern):
173-
parts = (
174-
re.split(options.delimiter, clean_str)
175-
if (limit is None) or not limit
176-
else re.split(options.delimiter, clean_str)[: (limit + 1 if options.raise_on_limit_exceeded else limit)]
177-
)
173+
_all_parts = re.split(options.delimiter, clean_str)
178174
else:
179-
parts = (
180-
clean_str.split(options.delimiter)
181-
if (limit is None) or not limit
182-
else clean_str.split(options.delimiter)[: (limit + 1 if options.raise_on_limit_exceeded else limit)]
183-
)
175+
_all_parts = clean_str.split(options.delimiter)
176+
177+
if (limit is not None) and limit:
178+
_take = limit + 1 if options.raise_on_limit_exceeded else limit
179+
parts = _all_parts[:_take]
180+
else:
181+
parts = _all_parts
184182

185183
# Enforce parameter count when strict mode is enabled.
186184
if options.raise_on_limit_exceeded and (limit is not None) and len(parts) > limit:
@@ -232,9 +230,10 @@ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str
232230
val if isinstance(val, str) else ",".join(val) if isinstance(val, (list, tuple)) else str(val)
233231
)
234232

235-
# If the pair used empty brackets syntax, note that as a list marker.
236-
if "[]=" in part:
237-
val = [val] if isinstance(val, (list, tuple)) else val
233+
# If the pair used empty brackets syntax and list parsing is enabled, force an array container.
234+
# Always wrap exactly once to preserve list-of-lists semantics when comma splitting applies.
235+
if options.parse_lists and "[]=" in part:
236+
val = [val]
238237

239238
existing: bool = key in obj
240239

@@ -303,7 +302,10 @@ def _parse_object(
303302
# Optionally treat `%2E` as a literal dot (when `decode_dot_in_keys` is enabled).
304303
clean_root: str = root[1:-1] if root.startswith("[") and root.endswith("]") else root
305304

306-
decoded_root: str = clean_root.replace(r"%2E", ".") if options.decode_dot_in_keys else clean_root
305+
if options.decode_dot_in_keys:
306+
decoded_root: str = clean_root.replace("%2E", ".").replace("%2e", ".")
307+
else:
308+
decoded_root = clean_root
307309

308310
# Parse numeric segment to decide between dict key vs. list index.
309311
index: t.Optional[int]
@@ -322,7 +324,7 @@ def _parse_object(
322324
and options.parse_lists
323325
and index <= options.list_limit
324326
):
325-
obj = [Undefined() for _ in range(index + 1)]
327+
obj = [UNDEFINED for _ in range(index + 1)]
326328
obj[index] = leaf
327329
else:
328330
obj[str(index) if index is not None else decoded_root] = leaf

src/qs_codec/encode.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from .enums.list_format import ListFormat
2525
from .enums.sentinel import Sentinel
2626
from .models.encode_options import EncodeOptions
27-
from .models.undefined import Undefined
27+
from .models.undefined import UNDEFINED, Undefined
2828
from .models.weak_wrapper import WeakWrapper
2929
from .utils.utils import Utils
3030

@@ -60,15 +60,15 @@ def encode(value: t.Any, options: EncodeOptions = EncodeOptions()) -> str:
6060
if isinstance(value, t.Mapping):
6161
obj = deepcopy(value)
6262
elif isinstance(value, (list, tuple)):
63-
obj = {str(key): value for key, value in enumerate(value)}
63+
obj = {str(i): item for i, item in enumerate(value)}
6464
else:
6565
obj = {}
6666

6767
# Early exit if there's nothing to emit.
6868
if not obj:
6969
return ""
7070

71-
keys: t.List[t.Any] = []
71+
keys: t.List[str] = []
7272

7373
# If an iterable filter is provided for the root, restrict emission to those keys.
7474
obj_keys: t.Optional[t.List[t.Any]] = None
@@ -218,14 +218,14 @@ def _encode(
218218

219219
# Infer comma round-trip when using the COMMA generator and the flag was not explicitly provided.
220220
if comma_round_trip is None:
221-
comma_round_trip = generate_array_prefix == ListFormat.COMMA.generator
221+
comma_round_trip = generate_array_prefix is ListFormat.COMMA.generator
222222

223223
# Choose a formatter if one wasn't provided (based on the selected format).
224224
if formatter is None:
225225
formatter = format.formatter
226226

227-
# Work on a copy to avoid mutating caller state during normalization.
228-
obj: t.Any = deepcopy(value)
227+
# Work with the original; we never mutate in place (we build new lists/maps when normalizing).
228+
obj: t.Any = value
229229

230230
# --- Cycle detection via chained side-channel -----------------------------------------
231231
obj_wrapper: WeakWrapper = WeakWrapper(value)
@@ -255,7 +255,7 @@ def _encode(
255255
# Normalize datetimes both for scalars and (in COMMA mode) list elements.
256256
if isinstance(obj, datetime):
257257
obj = serialize_date(obj) if callable(serialize_date) else obj.isoformat()
258-
elif generate_array_prefix == ListFormat.COMMA.generator and isinstance(obj, (list, tuple)):
258+
elif generate_array_prefix is ListFormat.COMMA.generator and isinstance(obj, (list, tuple)):
259259
if callable(serialize_date):
260260
obj = [serialize_date(x) if isinstance(x, datetime) else x for x in obj]
261261
else:
@@ -289,23 +289,22 @@ def _encode(
289289
if encode_values_only and callable(encoder):
290290
obj = Utils.apply(obj, encoder)
291291
if obj:
292-
obj_keys_value = ",".join([str(e) if e is not None else "" for e in obj])
292+
obj_keys_value = ",".join(("" if e is None else str(e)) for e in obj)
293293
obj_keys = [{"value": obj_keys_value if obj_keys_value else None}]
294294
else:
295-
obj_keys = [{"value": Undefined()}]
295+
obj_keys = [{"value": UNDEFINED}]
296296
elif isinstance(filter, (list, tuple)):
297297
# Iterable filter restricts traversal to a fixed key/index set.
298298
obj_keys = list(filter)
299299
else:
300300
# Default: enumerate keys/indices from mappings or sequences.
301-
keys: t.List[t.Any]
302301
if isinstance(obj, t.Mapping):
303302
keys = list(obj.keys())
304303
elif isinstance(obj, (list, tuple)):
305-
keys = [index for index in range(len(obj))]
304+
keys = list(range(len(obj)))
306305
else:
307306
keys = []
308-
obj_keys = sorted(keys, key=cmp_to_key(sort)) if sort is not None else list(keys)
307+
obj_keys = sorted(keys, key=cmp_to_key(sort)) if sort is not None else keys
309308

310309
# Percent-encode literal dots in key names when requested.
311310
encoded_prefix: str = prefix.replace(".", "%2E") if encode_dot_in_keys else prefix
@@ -372,7 +371,7 @@ def _encode(
372371
comma_round_trip=comma_round_trip,
373372
encoder=(
374373
None
375-
if generate_array_prefix == ListFormat.COMMA.generator
374+
if generate_array_prefix is ListFormat.COMMA.generator
376375
and encode_values_only
377376
and isinstance(obj, (list, tuple))
378377
else encoder

src/qs_codec/models/decode_options.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,12 @@ class DecodeOptions:
126126

127127
def __post_init__(self) -> None:
128128
"""Post-initialization."""
129-
if self.allow_dots is None:
130-
self.allow_dots = self.decode_dot_in_keys is True or False
129+
# Default `decode_dot_in_keys` first, then mirror into `allow_dots` when unspecified.
131130
if self.decode_dot_in_keys is None:
132131
self.decode_dot_in_keys = False
132+
if self.allow_dots is None:
133+
self.allow_dots = bool(self.decode_dot_in_keys)
134+
135+
# Enforce consistency with the docs: `decode_dot_in_keys=True` implies `allow_dots=True`.
136+
if self.decode_dot_in_keys and not self.allow_dots:
137+
raise ValueError("decode_dot_in_keys=True implies allow_dots=True")

src/qs_codec/models/encode_options.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"""
1313

1414
import typing as t
15-
from dataclasses import asdict, dataclass, field
15+
from dataclasses import dataclass, field, fields
1616
from datetime import datetime
1717

1818
from ..enums.charset import Charset
@@ -122,10 +122,12 @@ def __post_init__(self) -> None:
122122
"""
123123
if not hasattr(self, "_encoder") or self._encoder is None:
124124
self._encoder = EncodeUtils.encode
125-
if self.allow_dots is None:
126-
self.allow_dots = self.encode_dot_in_keys is True or False
125+
# Default `encode_dot_in_keys` first, then mirror into `allow_dots` when unspecified.
127126
if self.encode_dot_in_keys is None:
128127
self.encode_dot_in_keys = False
128+
if self.allow_dots is None:
129+
self.allow_dots = bool(self.encode_dot_in_keys)
130+
# Map deprecated `indices` to `list_format` for backward compatibility.
129131
if self.indices is not None:
130132
self.list_format = ListFormat.INDICES if self.indices else ListFormat.REPEAT
131133

@@ -139,10 +141,14 @@ def __eq__(self, other: object) -> bool:
139141
if not isinstance(other, EncodeOptions):
140142
return False
141143

142-
self_dict = asdict(self)
143-
other_dict = asdict(other)
144-
145-
self_dict["encoder"] = self._encoder
146-
other_dict["encoder"] = other._encoder
147-
148-
return self_dict == other_dict
144+
for f in fields(EncodeOptions):
145+
name = f.name
146+
if name == "encoder":
147+
v1 = self._encoder
148+
v2 = other._encoder
149+
else:
150+
v1 = getattr(self, name)
151+
v2 = getattr(other, name)
152+
if v1 != v2:
153+
return False
154+
return True

src/qs_codec/models/undefined.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
(e.g., `value is Undefined()`).
1111
"""
1212

13+
import threading
1314
import typing as t
1415

1516

@@ -32,6 +33,8 @@ class Undefined:
3233
... pass # skip emitting the key
3334
"""
3435

36+
__slots__ = ()
37+
_lock: t.ClassVar[threading.Lock] = threading.Lock()
3538
_instance: t.ClassVar[t.Optional["Undefined"]] = None
3639

3740
def __new__(cls: t.Type["Undefined"]) -> "Undefined":
@@ -40,5 +43,31 @@ def __new__(cls: t.Type["Undefined"]) -> "Undefined":
4043
Creating `Undefined()` multiple times always returns the same object reference. This ensures identity checks (``is``) are stable.
4144
"""
4245
if cls._instance is None:
43-
cls._instance = super().__new__(cls)
46+
with cls._lock:
47+
if cls._instance is None:
48+
cls._instance = super().__new__(cls)
4449
return cls._instance
50+
51+
def __repr__(self) -> str: # pragma: no cover - trivial
52+
"""Return a string representation of the singleton."""
53+
return "Undefined()"
54+
55+
def __copy__(self): # pragma: no cover - trivial
56+
"""Ensure copies/pickles preserve the singleton identity."""
57+
return self
58+
59+
def __deepcopy__(self, memo): # pragma: no cover - trivial
60+
"""Ensure deep copies preserve the singleton identity."""
61+
return self
62+
63+
def __reduce__(self): # pragma: no cover - trivial
64+
"""Recreate via calling the constructor, which returns the singleton."""
65+
return Undefined, ()
66+
67+
def __init_subclass__(cls, **kwargs): # pragma: no cover - defensive
68+
"""Prevent subclassing of Undefined."""
69+
raise TypeError("Undefined cannot be subclassed")
70+
71+
72+
UNDEFINED: t.Final["Undefined"] = Undefined()
73+
__all__ = ["Undefined", "UNDEFINED"]

src/qs_codec/models/weak_wrapper.py

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,6 @@ def __init__(self, value: t.Any) -> None:
3030
def _get_proxy(value: t.Any) -> "_Proxy":
3131
"""Return a per-object proxy, cached by id(value)."""
3232
key = id(value)
33-
proxy = _proxy_cache.get(key)
34-
if proxy is not None:
35-
return proxy
3633
with _proxy_cache_lock:
3734
proxy = _proxy_cache.get(key)
3835
if proxy is None:
@@ -72,14 +69,10 @@ def _leave(oid: int) -> None:
7269
if isinstance(obj, dict):
7370
oid = _enter(obj)
7471
try:
75-
# Sort by repr(key) to be stable across runs
76-
kv_hashes = tuple(
77-
(
78-
_deep_hash(k, _seen, _depth + 1),
79-
_deep_hash(v, _seen, _depth + 1),
80-
)
81-
for k, v in sorted(obj.items(), key=lambda kv: repr(kv[0]))
82-
)
72+
# Compute key/value deep hashes once and sort pairs for determinism
73+
pairs = [(_deep_hash(k, _seen, _depth + 1), _deep_hash(v, _seen, _depth + 1)) for k, v in obj.items()]
74+
pairs.sort()
75+
kv_hashes = tuple(pairs)
8376
return hash(("dict", kv_hashes))
8477
finally:
8578
_leave(oid)
@@ -146,8 +139,8 @@ def __eq__(self, other: object) -> bool:
146139
"""Check equality by comparing the proxy identity."""
147140
if not isinstance(other, WeakWrapper):
148141
return NotImplemented
149-
# Same underlying object => same wrapper identity
150-
return self._proxy.value is other._proxy.value
142+
# Same underlying object => same cached proxy instance
143+
return self._proxy is other._proxy
151144

152145
def __hash__(self) -> int:
153146
"""Return a deep hash of the wrapped value."""

src/qs_codec/utils/decode_utils.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ class DecodeUtils:
3131
# "a.b[c]" becomes "a[b][c]" before bracket parsing.
3232
DOT_TO_BRACKET: t.Pattern[str] = re.compile(r"\.([^.\[]+)")
3333

34+
# Precompiled pattern for %XX hex bytes (Latin-1 path fast path)
35+
HEX2_PATTERN: t.Pattern[str] = re.compile(r"%([0-9A-Fa-f]{2})")
36+
3437
@classmethod
3538
def unescape(cls, string: str) -> str:
36-
"""
37-
Emulate legacy JavaScript unescape behavior.
39+
"""Emulate legacy JavaScript unescape behavior.
3840
3941
Replaces both ``%XX`` and ``%uXXXX`` escape sequences with the
4042
corresponding code points. This function is intentionally permissive
@@ -48,6 +50,9 @@ def unescape(cls, string: str) -> str:
4850
>>> DecodeUtils.unescape("%7E")
4951
'~'
5052
"""
53+
# Fast path: nothing to unescape
54+
if "%" not in string:
55+
return string
5156

5257
def replacer(match: t.Match[str]) -> str:
5358
if (unicode_val := match.group("unicode")) is not None:
@@ -81,18 +86,18 @@ def decode(
8186
if string is None:
8287
return None
8388

84-
# Replace '+' with ' ' before processing.
85-
string_without_plus: str = string.replace("+", " ")
89+
# Replace '+' with ' ' only if present to avoid allocation.
90+
string_without_plus: str = string.replace("+", " ") if "+" in string else string
8691

8792
if charset == Charset.LATIN1:
88-
# Only process hex escape sequences for Latin1.
89-
return re.sub(
90-
r"%[0-9A-Fa-f]{2}",
91-
lambda match: cls.unescape(match.group(0)),
92-
string_without_plus,
93-
)
93+
# Only process %XX hex escape sequences for Latin-1 (no %uXXXX expansion here).
94+
s = string_without_plus
95+
if "%" not in s:
96+
return s
97+
return cls.HEX2_PATTERN.sub(lambda m: chr(int(m.group(1), 16)), s)
9498

95-
return unquote(string_without_plus)
99+
s = string_without_plus
100+
return s if "%" not in s else unquote(s)
96101

97102
@classmethod
98103
def split_key_into_segments(
@@ -102,8 +107,7 @@ def split_key_into_segments(
102107
max_depth: int,
103108
strict_depth: bool,
104109
) -> t.List[str]:
105-
"""
106-
Split a composite key into *balanced* bracket segments.
110+
"""Split a composite key into *balanced* bracket segments.
107111
108112
- If ``allow_dots`` is True, convert dots to bracket groups first (``a.b[c]`` → ``a[b][c]``) while leaving existing brackets intact.
109113
- The *parent* (non‑bracket) prefix becomes the first segment, e.g. ``"a[b][c]"`` → ``["a", "[b]", "[c]"]``.
@@ -113,7 +117,10 @@ def split_key_into_segments(
113117
114118
This runs in O(n) time over the key string.
115119
"""
116-
key: str = cls.DOT_TO_BRACKET.sub(r"[\1]", original_key) if allow_dots else original_key
120+
if allow_dots and "." in original_key:
121+
key: str = cls.DOT_TO_BRACKET.sub(r"[\1]", original_key)
122+
else:
123+
key = original_key
117124

118125
if max_depth <= 0:
119126
return [key]

0 commit comments

Comments
 (0)