Skip to content

Commit 2e4f3bc

Browse files
committed
test: add deep regressions and internal compatibility coverage
Adds deep stack-safety regressions (depth 12_000), direct merge/decoder deep-path checks, and internal cycle-state compatibility tests for _encode/Utils.merge behavior.
1 parent 7a7477f commit 2e4f3bc

3 files changed

Lines changed: 196 additions & 11 deletions

File tree

tests/unit/decode_test.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,30 @@ def test_does_not_crash_when_parsing_deep_dicts(self) -> None:
929929

930930
assert actual_depth == depth
931931

932+
def test_does_not_crash_when_merging_very_deep_keys(self) -> None:
933+
# Single high-depth canary for the deep conflicting merge path.
934+
depth = 12_000
935+
936+
path = "a" + ("[p]" * depth)
937+
query = f"{path}[left]=1&{path}[right]=2"
938+
939+
parsed: t.Optional[t.Mapping[str, t.Any]]
940+
941+
with does_not_raise():
942+
parsed = decode(query, DecodeOptions(depth=depth + 2, parameter_limit=10_000_000))
943+
944+
assert parsed is not None
945+
assert "a" in parsed
946+
947+
current = parsed["a"]
948+
for _ in range(depth):
949+
assert isinstance(current, dict)
950+
current = current["p"]
951+
952+
assert isinstance(current, dict)
953+
assert current["left"] == "1"
954+
assert current["right"] == "2"
955+
932956
def test_parses_null_dicts_correctly(self) -> None:
933957
a: t.Dict[str, t.Any] = {"b": "c"}
934958
assert decode(a) == {"b": "c"}

tests/unit/encode_test.py

Lines changed: 108 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import pytest
1212

1313
from qs_codec import Charset, EncodeOptions, Format, ListFormat, dumps, encode
14-
from qs_codec.encode import _encode, _sentinel
14+
from qs_codec.encode import _CycleState, _encode, _pop_current_node, _sentinel
1515
from qs_codec.models.undefined import Undefined
1616
from qs_codec.models.weak_wrapper import WeakWrapper
1717
from qs_codec.utils.encode_utils import EncodeUtils
@@ -867,23 +867,33 @@ def test_encode_depth_guard_prevents_recursion_errors(self) -> None:
867867
with pytest.raises(ValueError, match="Maximum encoding depth exceeded"):
868868
encode(data, options=EncodeOptions(max_depth=3))
869869

870-
def test_encode_depth_guard_caps_to_recursion_limit(self, monkeypatch: pytest.MonkeyPatch) -> None:
871-
import importlib
872-
873-
encode_module = importlib.import_module("qs_codec.encode")
874-
875-
limit = encode_module._DEPTH_MARGIN + 3
876-
monkeypatch.setattr(encode_module.sys, "getrecursionlimit", lambda: limit)
877-
870+
def test_encode_depth_guard_does_not_cap_to_recursion_limit(self) -> None:
871+
# `_get_max_encode_depth` now uses `sys.maxsize` for None and explicit values directly,
872+
# so monkeypatching `sys.getrecursionlimit` is intentionally unnecessary here.
878873
data: t.Dict[str, t.Any] = {}
879874
current = data
880875
for _ in range(5):
881876
nxt: t.Dict[str, t.Any] = {}
882877
current["a"] = nxt
883878
current = nxt
879+
current["leaf"] = "x"
884880

885-
with pytest.raises(ValueError, match="Maximum encoding depth exceeded"):
886-
encode(data, options=EncodeOptions(max_depth=10_000))
881+
with does_not_raise():
882+
result = encode(data, options=EncodeOptions(max_depth=10_000, encode=False))
883+
884+
assert result.endswith("=x")
885+
886+
def test_encode_deep_nesting_iterative_stack_safety(self) -> None:
887+
# Keep this above common recursion limits so recursion regressions still fail quickly.
888+
depth = 12_000
889+
data: t.Dict[str, t.Any] = {"leaf": "x"}
890+
for _ in range(depth):
891+
data = {"a": data}
892+
893+
with does_not_raise():
894+
result = encode(data, options=EncodeOptions(encode=False))
895+
896+
assert result.endswith("=x")
887897

888898
@pytest.mark.parametrize(
889899
"data, options, expected",
@@ -1853,6 +1863,93 @@ def test_encode_cycle_detection_marks_prior_visit_without_raising(self) -> None:
18531863

18541864
assert tokens == ["root%5Bchild%5D=value"]
18551865

1866+
def test_encode_cycle_state_prefers_nearest_ancestor_mapping(self) -> None:
1867+
value: t.Dict[str, t.Any] = {"child": "value"}
1868+
wrapper = WeakWrapper(value)
1869+
1870+
top_channel: WeakKeyDictionary = WeakKeyDictionary()
1871+
top_channel[wrapper] = 2
1872+
1873+
mid_channel: WeakKeyDictionary = WeakKeyDictionary()
1874+
mid_channel[_sentinel] = top_channel
1875+
mid_channel[wrapper] = 99
1876+
1877+
side_channel: WeakKeyDictionary = WeakKeyDictionary()
1878+
side_channel[_sentinel] = mid_channel
1879+
1880+
tokens = _encode(
1881+
value=value,
1882+
is_undefined=False,
1883+
side_channel=side_channel,
1884+
prefix="root",
1885+
comma_round_trip=False,
1886+
comma_compact_nulls=False,
1887+
encoder=EncodeUtils.encode,
1888+
serialize_date=EncodeUtils.serialize_date,
1889+
sort=None,
1890+
filter_=None,
1891+
formatter=Format.RFC3986.formatter,
1892+
format=Format.RFC3986,
1893+
generate_array_prefix=ListFormat.INDICES.generator,
1894+
allow_empty_lists=False,
1895+
strict_null_handling=False,
1896+
skip_nulls=False,
1897+
encode_dot_in_keys=False,
1898+
allow_dots=False,
1899+
encode_values_only=False,
1900+
charset=Charset.UTF8,
1901+
)
1902+
1903+
assert tokens == ["root%5Bchild%5D=value"]
1904+
1905+
def test_encode_cycle_state_bootstrap_matches_legacy_side_channel_behavior(self) -> None:
1906+
value: t.Dict[str, t.Any] = {"child": "value"}
1907+
wrapper = WeakWrapper(value)
1908+
1909+
top_channel: WeakKeyDictionary = WeakKeyDictionary()
1910+
top_channel[wrapper] = 99
1911+
1912+
parent_channel: WeakKeyDictionary = WeakKeyDictionary()
1913+
parent_channel[_sentinel] = top_channel
1914+
1915+
side_channel: WeakKeyDictionary = WeakKeyDictionary()
1916+
side_channel[_sentinel] = parent_channel
1917+
1918+
tokens = _encode(
1919+
value=value,
1920+
is_undefined=False,
1921+
side_channel=side_channel,
1922+
prefix="root",
1923+
comma_round_trip=False,
1924+
comma_compact_nulls=False,
1925+
encoder=EncodeUtils.encode,
1926+
serialize_date=EncodeUtils.serialize_date,
1927+
sort=None,
1928+
filter_=None,
1929+
formatter=Format.RFC3986.formatter,
1930+
format=Format.RFC3986,
1931+
generate_array_prefix=ListFormat.INDICES.generator,
1932+
allow_empty_lists=False,
1933+
strict_null_handling=False,
1934+
skip_nulls=False,
1935+
encode_dot_in_keys=False,
1936+
allow_dots=False,
1937+
encode_values_only=False,
1938+
charset=Charset.UTF8,
1939+
)
1940+
1941+
assert tokens == ["root%5Bchild%5D=value"]
1942+
1943+
def test_pop_current_node_noop_when_wrapper_not_present(self) -> None:
1944+
value: t.Dict[str, t.Any] = {"child": "value"}
1945+
wrapper = WeakWrapper(value)
1946+
state = _CycleState()
1947+
1948+
with does_not_raise():
1949+
_pop_current_node(state, wrapper)
1950+
1951+
assert state.entries == {}
1952+
18561953
def test_encode_handles_iterable_filter_for_indexable_object(self, monkeypatch: pytest.MonkeyPatch) -> None:
18571954
class Indexable:
18581955
def __getitem__(self, key: str) -> str:

tests/unit/utils_test.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,52 @@ def test_merges_array_into_object(self) -> None:
594594
{"foo": ["xyzzy"]},
595595
) == {"foo": {"bar": "baz", "0": "xyzzy"}}
596596

597+
def test_merge_mapping_target_with_scalar_source_returns_target_unchanged(self) -> None:
598+
target = {"a": "b"}
599+
source = "scalar"
600+
601+
result = Utils.merge(target, source) # type: ignore[arg-type]
602+
603+
assert result == {"a": "b"}
604+
assert result is target
605+
606+
def test_merge_structured_lists_prefers_source_when_target_slot_is_undefined(self) -> None:
607+
options = DecodeOptions()
608+
target = [Undefined()]
609+
source = [{"from_source": 1}]
610+
611+
result = Utils.merge(target, source, options)
612+
613+
assert result == [{"from_source": 1}]
614+
615+
def test_merge_deep_maps_without_stack_overflow(self) -> None:
616+
# Keep this above common recursion limits so recursion regressions still fail quickly.
617+
depth = 12_000
618+
619+
left: t.Dict[str, t.Any] = {}
620+
cursor = left
621+
for _ in range(depth):
622+
nxt: t.Dict[str, t.Any] = {}
623+
cursor["a"] = nxt
624+
cursor = nxt
625+
626+
right: t.Dict[str, t.Any] = {}
627+
right_cursor = right
628+
for _ in range(depth):
629+
nxt = {}
630+
right_cursor["a"] = nxt
631+
right_cursor = nxt
632+
right_cursor["leaf"] = "x"
633+
634+
merged = Utils.merge(left, right)
635+
walk = merged
636+
for _ in range(depth):
637+
assert isinstance(walk, dict)
638+
walk = walk["a"]
639+
640+
assert isinstance(walk, dict)
641+
assert walk["leaf"] == "x"
642+
597643
def test_combine_both_arrays(self) -> None:
598644
a: t.List[int] = [1]
599645
b: t.List[int] = [2]
@@ -950,6 +996,24 @@ def test_merge_prefers_exact_key_match_before_string_normalization(self) -> None
950996
assert result == {"1": {"a": "x", "b": "y"}}
951997
assert 1 not in result
952998

999+
def test_merge_does_not_match_source_keys_inserted_earlier_in_same_map_pass(self) -> None:
1000+
target = {"3": ""}
1001+
source = {"1": {"a": 1}, 1: ("x",)}
1002+
1003+
result = Utils.merge(target, source) # type: ignore[arg-type]
1004+
1005+
assert result == {"3": "", "1": {"a": 1}, 1: ("x",)}
1006+
assert "1" in result
1007+
assert 1 in result
1008+
1009+
def test_merge_normalized_collision_with_existing_key_uses_existing_target_slot(self) -> None:
1010+
target = {"1": {"z": 0}}
1011+
source = {"1": {"a": 1}, 1: ("x",)}
1012+
1013+
result = Utils.merge(target, source) # type: ignore[arg-type]
1014+
1015+
assert result == {"1": {"z": 0, "a": 1, "0": "x"}}
1016+
9531017
def test_overflow_dict_copy_preserves_type(self) -> None:
9541018
target = OverflowDict({"0": "a"})
9551019
result = target.copy()

0 commit comments

Comments
 (0)