|
11 | 11 | import pytest |
12 | 12 |
|
13 | 13 | 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 |
15 | 15 | from qs_codec.models.undefined import Undefined |
16 | 16 | from qs_codec.models.weak_wrapper import WeakWrapper |
17 | 17 | from qs_codec.utils.encode_utils import EncodeUtils |
@@ -867,23 +867,33 @@ def test_encode_depth_guard_prevents_recursion_errors(self) -> None: |
867 | 867 | with pytest.raises(ValueError, match="Maximum encoding depth exceeded"): |
868 | 868 | encode(data, options=EncodeOptions(max_depth=3)) |
869 | 869 |
|
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. |
878 | 873 | data: t.Dict[str, t.Any] = {} |
879 | 874 | current = data |
880 | 875 | for _ in range(5): |
881 | 876 | nxt: t.Dict[str, t.Any] = {} |
882 | 877 | current["a"] = nxt |
883 | 878 | current = nxt |
| 879 | + current["leaf"] = "x" |
884 | 880 |
|
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") |
887 | 897 |
|
888 | 898 | @pytest.mark.parametrize( |
889 | 899 | "data, options, expected", |
@@ -1853,6 +1863,93 @@ def test_encode_cycle_detection_marks_prior_visit_without_raising(self) -> None: |
1853 | 1863 |
|
1854 | 1864 | assert tokens == ["root%5Bchild%5D=value"] |
1855 | 1865 |
|
| 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 | + |
1856 | 1953 | def test_encode_handles_iterable_filter_for_indexable_object(self, monkeypatch: pytest.MonkeyPatch) -> None: |
1857 | 1954 | class Indexable: |
1858 | 1955 | def __getitem__(self, key: str) -> str: |
|
0 commit comments