Skip to content

Commit d2fd9b4

Browse files
authored
Merge pull request #28 from techouse/chore/increase-coverage
✅ increase coverage
2 parents cb2333b + be32456 commit d2fd9b4

6 files changed

Lines changed: 384 additions & 0 deletions

File tree

tests/unit/decode_options_test.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import math
12
import typing as t
23

34
import pytest
@@ -110,6 +111,88 @@ def dec(s: t.Optional[str]) -> t.Optional[str]:
110111
with pytest.raises(TypeError):
111112
_ = opts.decoder("oops", Charset.UTF8, kind=DecodeKind.KEY)
112113

114+
def test_kwonly_charset_receives_keyword_argument(self) -> None:
115+
calls: t.List[t.Dict[str, t.Any]] = []
116+
117+
def dec(s: t.Optional[str], *, charset: t.Optional[Charset], kind: str) -> t.Optional[str]:
118+
calls.append({"charset": charset, "kind": kind})
119+
return s
120+
121+
opts = DecodeOptions(decoder=dec)
122+
assert opts.decoder("x", Charset.LATIN1, kind=DecodeKind.KEY) == "x"
123+
assert calls == [{"charset": Charset.LATIN1, "kind": "key"}]
124+
125+
def test_positional_only_kind_receives_string(self) -> None:
126+
seen: t.List[t.Any] = []
127+
128+
def dec(
129+
s: t.Optional[str],
130+
kind, # type: ignore[no-untyped-def]
131+
/,
132+
*,
133+
charset: t.Optional[Charset] = None,
134+
) -> t.Optional[str]:
135+
seen.append((kind, charset))
136+
return s
137+
138+
opts = DecodeOptions(decoder=dec)
139+
assert opts.decoder("value", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
140+
assert seen == [("value", Charset.UTF8)]
141+
142+
def test_unannotated_kind_parameter_receives_string(self) -> None:
143+
seen: t.List[t.Any] = []
144+
145+
def dec(s: t.Optional[str], charset: t.Optional[Charset], kind) -> t.Optional[str]: # type: ignore[no-untyped-def]
146+
seen.append(kind)
147+
return s
148+
149+
opts = DecodeOptions(decoder=dec)
150+
assert opts.decoder("q", Charset.UTF8, kind=DecodeKind.KEY) == "q"
151+
assert seen == ["key"]
152+
153+
def test_literal_kind_annotation_prefers_string(self) -> None:
154+
seen: t.List[t.Any] = []
155+
156+
def dec(
157+
s: t.Optional[str],
158+
charset: t.Optional[Charset],
159+
kind: t.Literal["key", "value"],
160+
) -> t.Optional[str]:
161+
seen.append(kind)
162+
return s
163+
164+
opts = DecodeOptions(decoder=dec)
165+
assert opts.decoder("ok", Charset.UTF8, kind=DecodeKind.VALUE) == "ok"
166+
assert seen == ["value"]
167+
168+
def test_builtin_without_signature_raises_original_typeerror(self) -> None:
169+
opts = DecodeOptions(decoder=math.hypot) # type: ignore[arg-type]
170+
171+
with pytest.raises(TypeError):
172+
_ = opts.decode_value("10")
173+
174+
175+
class TestDecodeOptionsFallbacks:
176+
def test_legacy_decoder_selected_when_decoder_none(self) -> None:
177+
calls: t.List[t.Tuple[t.Optional[str], t.Optional[Charset]]] = []
178+
179+
def legacy(value: t.Optional[str], charset: t.Optional[Charset]) -> t.Optional[str]:
180+
calls.append((value, charset))
181+
return "LEGACY" if value else None
182+
183+
opts = DecodeOptions(decoder=None, legacy_decoder=legacy)
184+
assert opts.decode_value("foo", Charset.LATIN1) == "LEGACY"
185+
assert calls == [("foo", Charset.LATIN1)]
186+
187+
def test_default_decoder_selected_when_all_none(self) -> None:
188+
opts = DecodeOptions(decoder=None, legacy_decoder=None)
189+
assert opts.decode_key("a%2Eb") == "a.b"
190+
191+
def test_decode_method_falls_back_when_decoder_missing(self) -> None:
192+
opts = DecodeOptions()
193+
opts.decoder = None
194+
assert opts.decode_value("a%20b") == "a b"
195+
113196

114197
class TestParserStateIsolation:
115198
def test_parse_lists_toggle_does_not_leak_across_calls(self) -> None:

tests/unit/decode_test.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88

99
from qs_codec import Charset, DecodeOptions, Duplicates, decode, load, loads
10+
from qs_codec.decode import _parse_object
1011
from qs_codec.enums.decode_kind import DecodeKind
1112
from qs_codec.utils.decode_utils import DecodeUtils
1213

@@ -1620,6 +1621,31 @@ def test_top_level_dot_to_bracket_guardrails_leading_trailing_double(self) -> No
16201621
# as a literal dot in the parent key (no [] is created)
16211622
assert decode("a..b=x", DecodeOptions(allow_dots=True, decode_dot_in_keys=False)) == {"a.": {"b": "x"}}
16221623

1624+
def test_regex_delimiter_without_limit_uses_regex_split(self) -> None:
1625+
options = DecodeOptions(parameter_limit=float("inf"), delimiter=re.compile(r"[;&]"))
1626+
assert decode("a=1;b=2", options) == {"a": "1", "b": "2"}
1627+
1628+
def test_regex_delimiter_with_limit_raises_when_exceeded(self) -> None:
1629+
options = DecodeOptions(parameter_limit=1, raise_on_limit_exceeded=True, delimiter=re.compile(r"[;&]"))
1630+
with pytest.raises(ValueError, match="Parameter limit exceeded"):
1631+
decode("a=1&b=2", options)
1632+
1633+
def test_decoder_skips_pairs_when_key_decode_returns_none(self) -> None:
1634+
def dropping_decoder(token: t.Optional[str], charset: t.Optional[Charset]) -> t.Optional[str]:
1635+
if token in {"dropNoEquals", "drop"}:
1636+
return None
1637+
return token
1638+
1639+
opts = DecodeOptions(decoder=None, legacy_decoder=dropping_decoder)
1640+
assert decode("dropNoEquals&drop=1&keep=2", opts) == {"keep": "2"}
1641+
1642+
def test_parse_object_estimates_list_length_for_numeric_parent(self) -> None:
1643+
options = DecodeOptions()
1644+
chain = ["0", "[]"]
1645+
val = [["x", "y"]]
1646+
result = _parse_object(chain, val, options, True)
1647+
assert result == {"0": [["x", "y"]]}
1648+
16231649

16241650
class TestSplitKeySegmentationRemainder:
16251651
def test_no_remainder_when_within_depth(self) -> None:

tests/unit/encode_options_test.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from qs_codec import EncodeOptions
2+
from qs_codec.utils.encode_utils import EncodeUtils
3+
4+
5+
class TestEncodeOptions:
6+
def test_post_init_restores_default_encoder(self) -> None:
7+
opts = EncodeOptions()
8+
assert opts._encoder.__func__ is EncodeUtils.encode.__func__
9+
10+
def test_post_init_recovers_when_encoder_missing(self) -> None:
11+
opts = EncodeOptions()
12+
delattr(opts, "_encoder")
13+
EncodeOptions.__post_init__(opts)
14+
assert opts._encoder.__func__ is EncodeUtils.encode.__func__
15+
16+
def test_equality_with_other_type_returns_false(self) -> None:
17+
opts = EncodeOptions()
18+
assert opts != object()
19+
20+
def test_equality_detects_field_difference(self) -> None:
21+
lhs = EncodeOptions()
22+
rhs = EncodeOptions(allow_dots=True)
23+
assert lhs != rhs

tests/unit/encode_test.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
from decimal import Decimal
66
from enum import Enum
77
from urllib.parse import quote
8+
from weakref import WeakKeyDictionary
89

910
import pytest
1011

1112
from qs_codec import Charset, EncodeOptions, Format, ListFormat, dumps, encode
13+
from qs_codec.encode import _encode, _sentinel
1214
from qs_codec.models.undefined import Undefined
15+
from qs_codec.models.weak_wrapper import WeakWrapper
1316
from qs_codec.utils.encode_utils import EncodeUtils
17+
from qs_codec.utils.utils import Utils
1418

1519

1620
class TestEncode:
@@ -1691,3 +1695,113 @@ def test_encodes_non_string_keys(self) -> None:
16911695
)
16921696
== "a=b"
16931697
)
1698+
1699+
1700+
class TestEncodeInternals:
1701+
def test_encode_cycle_detection_raises_on_same_step(self) -> None:
1702+
value: t.Dict[str, t.Any] = {"loop": {}}
1703+
side_channel: WeakKeyDictionary = WeakKeyDictionary()
1704+
parent_channel: WeakKeyDictionary = WeakKeyDictionary()
1705+
wrapper = WeakWrapper(value)
1706+
parent_channel[wrapper] = 1
1707+
side_channel[_sentinel] = parent_channel
1708+
1709+
with pytest.raises(ValueError, match="Circular reference detected"):
1710+
_encode(
1711+
value=value,
1712+
is_undefined=False,
1713+
side_channel=side_channel,
1714+
prefix="root",
1715+
comma_round_trip=False,
1716+
encoder=EncodeUtils.encode,
1717+
serialize_date=EncodeUtils.serialize_date,
1718+
sort=None,
1719+
filter=None,
1720+
formatter=Format.RFC3986.formatter,
1721+
format=Format.RFC3986,
1722+
generate_array_prefix=ListFormat.INDICES.generator,
1723+
allow_empty_lists=False,
1724+
strict_null_handling=False,
1725+
skip_nulls=False,
1726+
encode_dot_in_keys=False,
1727+
allow_dots=False,
1728+
encode_values_only=False,
1729+
charset=Charset.UTF8,
1730+
)
1731+
1732+
def test_encode_cycle_detection_marks_prior_visit_without_raising(self) -> None:
1733+
value: t.Dict[str, t.Any] = {"child": "value"}
1734+
side_channel: WeakKeyDictionary = WeakKeyDictionary()
1735+
parent_channel: WeakKeyDictionary = WeakKeyDictionary()
1736+
wrapper = WeakWrapper(value)
1737+
parent_channel[wrapper] = 99
1738+
side_channel[_sentinel] = parent_channel
1739+
1740+
tokens = _encode(
1741+
value=value,
1742+
is_undefined=False,
1743+
side_channel=side_channel,
1744+
prefix="root",
1745+
comma_round_trip=False,
1746+
encoder=EncodeUtils.encode,
1747+
serialize_date=EncodeUtils.serialize_date,
1748+
sort=None,
1749+
filter=None,
1750+
formatter=Format.RFC3986.formatter,
1751+
format=Format.RFC3986,
1752+
generate_array_prefix=ListFormat.INDICES.generator,
1753+
allow_empty_lists=False,
1754+
strict_null_handling=False,
1755+
skip_nulls=False,
1756+
encode_dot_in_keys=False,
1757+
allow_dots=False,
1758+
encode_values_only=False,
1759+
charset=Charset.UTF8,
1760+
)
1761+
1762+
assert tokens == ["root%5Bchild%5D=value"]
1763+
1764+
def test_encode_handles_iterable_filter_for_indexable_object(self, monkeypatch: pytest.MonkeyPatch) -> None:
1765+
class Indexable:
1766+
def __getitem__(self, key: str) -> str:
1767+
if key != "foo":
1768+
raise KeyError(key)
1769+
return "bar"
1770+
1771+
original = Utils.is_non_nullish_primitive
1772+
1773+
def fake_is_non_nullish_primitive(val: t.Any, skip_nulls: bool = False) -> bool:
1774+
if isinstance(val, Indexable):
1775+
return False
1776+
return original(val, skip_nulls)
1777+
1778+
monkeypatch.setattr(Utils, "is_non_nullish_primitive", staticmethod(fake_is_non_nullish_primitive))
1779+
1780+
tokens = _encode(
1781+
value=Indexable(),
1782+
is_undefined=False,
1783+
side_channel=WeakKeyDictionary(),
1784+
prefix="root",
1785+
comma_round_trip=False,
1786+
encoder=EncodeUtils.encode,
1787+
serialize_date=EncodeUtils.serialize_date,
1788+
sort=None,
1789+
filter=["foo"],
1790+
formatter=Format.RFC3986.formatter,
1791+
format=Format.RFC3986,
1792+
generate_array_prefix=ListFormat.INDICES.generator,
1793+
allow_empty_lists=False,
1794+
strict_null_handling=False,
1795+
skip_nulls=False,
1796+
encode_dot_in_keys=False,
1797+
allow_dots=False,
1798+
encode_values_only=False,
1799+
charset=Charset.UTF8,
1800+
)
1801+
1802+
assert tokens == ["root%5Bfoo%5D=bar"]
1803+
1804+
def test_encode_comma_format_serializes_datetime_without_custom_callable(self) -> None:
1805+
dt = datetime(2024, 1, 1)
1806+
options = EncodeOptions(list_format=ListFormat.COMMA, serialize_date="iso")
1807+
assert encode({"a": [dt]}, options) == f"a={dt.isoformat().replace(':', '%3A')}"

0 commit comments

Comments
 (0)