Skip to content

Commit 6fbbb50

Browse files
authored
🏷️ improve types in tests (#39)
1 parent 7091425 commit 6fbbb50

File tree

12 files changed

+152
-100
lines changed

12 files changed

+152
-100
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ venv.bak/
108108
# PyCharm specific files
109109
.idea
110110

111+
# VS Code specific files
112+
.vscode/
113+
111114
# macOS specific
112115
.DS_Store
113116

pyproject.toml

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,7 @@ markers = []
135135
[tool.mypy]
136136
mypy_path = "src"
137137
python_version = "3.8"
138-
exclude = [
139-
"tests",
140-
"docs",
141-
"build",
142-
"dist",
143-
"venv",
144-
"env",
145-
]
138+
exclude = ["tests", "docs", "build", "dist", "venv", "env", ".tox"]
146139
show_error_codes = true
147140
warn_return_any = true
148141
warn_unused_configs = true

src/qs_codec/encode.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def _encode(
167167
comma_round_trip: t.Optional[bool],
168168
comma_compact_nulls: bool,
169169
encoder: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]],
170-
serialize_date: t.Callable[[datetime], t.Optional[str]],
170+
serialize_date: t.Union[t.Callable[[datetime], t.Optional[str]], str],
171171
sort: t.Optional[t.Callable[[t.Any, t.Any], int]],
172172
filter_: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]],
173173
formatter: t.Optional[t.Callable[[str], str]],

src/qs_codec/models/decode_options.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ class DecodeOptions:
126126
``strict_depth=True``—stops descending once ``depth`` is reached without raising.
127127
"""
128128

129-
decoder: t.Optional[t.Callable[..., t.Optional[str]]] = DecodeUtils.decode
129+
decoder: t.Optional[t.Callable[..., t.Optional[t.Any]]] = DecodeUtils.decode
130130
"""Custom scalar decoder invoked for each raw token prior to interpretation.
131131
132132
The built-in decoder supports ``kind`` and is invoked as
@@ -135,7 +135,7 @@ class DecodeOptions:
135135
from the decoder uses ``None`` as the scalar value.
136136
"""
137137

138-
legacy_decoder: t.Optional[t.Callable[..., t.Optional[str]]] = None
138+
legacy_decoder: t.Optional[t.Callable[..., t.Optional[t.Any]]] = None
139139
"""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)."""
@@ -221,7 +221,7 @@ def dispatch(
221221
s: t.Optional[str],
222222
charset: t.Optional[Charset],
223223
kind: DecodeKind,
224-
) -> t.Optional[str]:
224+
) -> t.Optional[t.Any]:
225225
kind_arg: t.Union[DecodeKind, str] = kind.value if pass_kind_as_str else kind
226226
args: t.List[t.Any] = [s]
227227
kwargs: t.Dict[str, t.Any] = {}
@@ -241,7 +241,7 @@ def dispatch(
241241
s: t.Optional[str],
242242
charset: t.Optional[Charset],
243243
kind: DecodeKind,
244-
) -> t.Optional[str]:
244+
) -> t.Optional[t.Any]:
245245
_ = kind # ignored by legacy decoders
246246
try:
247247
return user_dec(s) # type: ignore[misc]
@@ -257,7 +257,7 @@ def _adapter(
257257
charset: t.Optional[Charset] = Charset.UTF8,
258258
*,
259259
kind: DecodeKind = DecodeKind.VALUE,
260-
) -> t.Optional[str]:
260+
) -> t.Optional[t.Any]:
261261
"""Adapter that dispatches based on the user decoder's signature."""
262262
return dispatch(s, charset, kind)
263263

src/qs_codec/models/encode_options.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@ class EncodeOptions:
7777
skip_nulls: bool = False
7878
"""When `True`, omit keys whose value is `None` entirely (no trailing `=`)."""
7979

80-
serialize_date: t.Callable[[datetime], t.Optional[str]] = EncodeUtils.serialize_date
81-
"""Hook to stringify `datetime` values before encoding; returning `None` is treated as a null value
82-
(subject to null-handling options), not as a fallback to ISO-8601."""
80+
serialize_date: t.Union[t.Callable[[datetime], t.Optional[str]], str] = EncodeUtils.serialize_date
81+
"""Hook to stringify `datetime` values before encoding. Returning `None` is treated as a null value
82+
(subject to null-handling options). If a non-callable is provided, datetimes fall back to ISO-8601."""
8383

8484
encoder: t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str] = field( # type: ignore [assignment]
8585
default=EncodeUtils.encode, init=False, repr=False

tests/unit/decode_options_test.py

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ def test_default_decoder_behaves_like_decodeutils(self) -> None:
2828
# The adapter may wrap the default, so compare behavior rather than identity.
2929
opts = DecodeOptions()
3030
s = "a+b%2E"
31-
out_key = opts.decoder(s, Charset.UTF8, kind=DecodeKind.KEY)
32-
out_val = opts.decoder(s, Charset.UTF8, kind=DecodeKind.VALUE)
31+
decoder = require_decoder(opts)
32+
out_key = decoder(s, Charset.UTF8, kind=DecodeKind.KEY)
33+
out_val = decoder(s, Charset.UTF8, kind=DecodeKind.VALUE)
3334
assert out_key == DecodeUtils.decode(s, charset=Charset.UTF8, kind=DecodeKind.KEY)
3435
assert out_val == DecodeUtils.decode(s, charset=Charset.UTF8, kind=DecodeKind.VALUE)
3536

@@ -43,7 +44,8 @@ def dec(s: t.Optional[str]) -> t.Optional[str]:
4344
return None if s is None else s.upper()
4445

4546
opts = DecodeOptions(decoder=dec)
46-
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "X"
47+
decoder = require_decoder(opts)
48+
assert decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "X"
4749
assert calls == [("x",)]
4850

4951
def test_two_args_s_charset(self) -> None:
@@ -55,7 +57,8 @@ def dec(s: t.Optional[str], charset: t.Optional[Charset]) -> t.Optional[str]:
5557
return None if s is None else f"{s}|{charset.name if charset else 'NONE'}"
5658

5759
opts = DecodeOptions(decoder=dec)
58-
assert opts.decoder("hi", Charset.LATIN1, kind=DecodeKind.VALUE) == "hi|LATIN1"
60+
decoder = require_decoder(opts)
61+
assert decoder("hi", Charset.LATIN1, kind=DecodeKind.VALUE) == "hi|LATIN1"
5962
assert seen == [("hi", Charset.LATIN1)]
6063

6164
def test_three_args_kind_enum_annotation(self) -> None:
@@ -67,7 +70,8 @@ def dec(s: t.Optional[str], charset: t.Optional[Charset], kind: DecodeKind) -> t
6770
return None if s is None else f"K:{'E' if isinstance(kind, DecodeKind) else type(kind).__name__}"
6871

6972
opts = DecodeOptions(decoder=dec)
70-
assert opts.decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "K:E"
73+
decoder = require_decoder(opts)
74+
assert decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "K:E"
7175
assert seen and isinstance(seen[0], DecodeKind) and seen[0] is DecodeKind.KEY
7276

7377
def test_three_args_kind_str_annotation(self) -> None:
@@ -78,7 +82,8 @@ def dec(s: t.Optional[str], charset: t.Optional[Charset], kind: str) -> t.Option
7882
return None if s is None else kind # echo back
7983

8084
opts = DecodeOptions(decoder=dec)
81-
assert opts.decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "key"
85+
decoder = require_decoder(opts)
86+
assert decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "key"
8287
assert seen == ["key"]
8388

8489
def test_kwonly_kind_str(self) -> None:
@@ -89,7 +94,8 @@ def dec(s: t.Optional[str], charset: t.Optional[Charset], *, kind: str) -> t.Opt
8994
return None if s is None else kind
9095

9196
opts = DecodeOptions(decoder=dec)
92-
assert opts.decoder("z", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
97+
decoder = require_decoder(opts)
98+
assert decoder("z", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
9399
assert seen == ["value"]
94100

95101
def test_varargs_kwargs_receives_kind_string(self) -> None:
@@ -100,16 +106,18 @@ def dec(s: t.Optional[str], *args, **kwargs) -> t.Optional[str]: # type: ignore
100106
return s
101107

102108
opts = DecodeOptions(decoder=dec)
103-
assert opts.decoder("ok", Charset.UTF8, kind=DecodeKind.KEY) == "ok"
109+
decoder = require_decoder(opts)
110+
assert decoder("ok", Charset.UTF8, kind=DecodeKind.KEY) == "ok"
104111
assert seen == ["key"]
105112

106113
def test_user_decoder_typeerror_is_not_swallowed(self) -> None:
107114
def dec(s: t.Optional[str]) -> t.Optional[str]:
108115
raise TypeError("boom")
109116

110117
opts = DecodeOptions(decoder=dec)
118+
decoder = require_decoder(opts)
111119
with pytest.raises(TypeError):
112-
_ = opts.decoder("oops", Charset.UTF8, kind=DecodeKind.KEY)
120+
_ = decoder("oops", Charset.UTF8, kind=DecodeKind.KEY)
113121

114122
def test_kwonly_charset_receives_keyword_argument(self) -> None:
115123
calls: t.List[t.Dict[str, t.Any]] = []
@@ -119,7 +127,8 @@ def dec(s: t.Optional[str], *, charset: t.Optional[Charset], kind: str) -> t.Opt
119127
return s
120128

121129
opts = DecodeOptions(decoder=dec)
122-
assert opts.decoder("x", Charset.LATIN1, kind=DecodeKind.KEY) == "x"
130+
decoder = require_decoder(opts)
131+
assert decoder("x", Charset.LATIN1, kind=DecodeKind.KEY) == "x"
123132
assert calls == [{"charset": Charset.LATIN1, "kind": "key"}]
124133

125134
def test_positional_only_kind_receives_string(self) -> None:
@@ -136,7 +145,8 @@ def dec(
136145
return s
137146

138147
opts = DecodeOptions(decoder=dec)
139-
assert opts.decoder("value", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
148+
decoder = require_decoder(opts)
149+
assert decoder("value", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
140150
assert seen == [("value", Charset.UTF8)]
141151

142152
def test_unannotated_kind_parameter_receives_string(self) -> None:
@@ -147,7 +157,8 @@ def dec(s: t.Optional[str], charset: t.Optional[Charset], kind) -> t.Optional[st
147157
return s
148158

149159
opts = DecodeOptions(decoder=dec)
150-
assert opts.decoder("q", Charset.UTF8, kind=DecodeKind.KEY) == "q"
160+
decoder = require_decoder(opts)
161+
assert decoder("q", Charset.UTF8, kind=DecodeKind.KEY) == "q"
151162
assert seen == ["key"]
152163

153164
def test_literal_kind_annotation_prefers_string(self) -> None:
@@ -162,7 +173,8 @@ def dec(
162173
return s
163174

164175
opts = DecodeOptions(decoder=dec)
165-
assert opts.decoder("ok", Charset.UTF8, kind=DecodeKind.VALUE) == "ok"
176+
decoder = require_decoder(opts)
177+
assert decoder("ok", Charset.UTF8, kind=DecodeKind.VALUE) == "ok"
166178
assert seen == ["value"]
167179

168180
def test_builtin_signature_unavailable_single_arg_fallback(self) -> None:
@@ -173,7 +185,8 @@ def __call__(self, s: t.Optional[str]) -> t.Optional[str]:
173185
return None if s is None else f"{s}-ok"
174186

175187
opts = DecodeOptions(decoder=BadSignature())
176-
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "x-ok"
188+
decoder = require_decoder(opts)
189+
assert decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "x-ok"
177190

178191
def test_builtin_signature_unavailable_two_arg_fallback(self) -> None:
179192
class BadSignature:
@@ -183,7 +196,8 @@ def __call__(self, s: t.Optional[str], charset: t.Optional[Charset]) -> t.Option
183196
return None if s is None else f"{s}|{charset.name if charset else 'NONE'}"
184197

185198
opts = DecodeOptions(decoder=BadSignature())
186-
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.VALUE) == "x|UTF8"
199+
decoder = require_decoder(opts)
200+
assert decoder("x", Charset.UTF8, kind=DecodeKind.VALUE) == "x|UTF8"
187201

188202
def test_builtin_signature_unavailable_raises_original_typeerror(self) -> None:
189203
class BadSignature:
@@ -193,8 +207,9 @@ def __call__(self) -> t.Optional[str]:
193207
return "nope"
194208

195209
opts = DecodeOptions(decoder=BadSignature())
210+
decoder = require_decoder(opts)
196211
with pytest.raises(TypeError) as exc_info:
197-
_ = opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY)
212+
_ = decoder("x", Charset.UTF8, kind=DecodeKind.KEY)
198213
assert exc_info.value.__cause__ is not None
199214

200215
def test_builtin_without_signature_raises_original_typeerror(self) -> None:
@@ -257,43 +272,51 @@ class TestDefaultDecodeKeyEncodedDots:
257272
def test_key_maps_2e_inside_brackets_allowdots_true(self) -> None:
258273
for cs in (Charset.UTF8, Charset.LATIN1):
259274
opts = DecodeOptions(allow_dots=True, charset=cs)
260-
assert opts.decoder("a[%2E]", cs, kind=DecodeKind.KEY) == "a[.]"
261-
assert opts.decoder("a[%2e]", cs, kind=DecodeKind.KEY) == "a[.]"
275+
decoder = require_decoder(opts)
276+
assert decoder("a[%2E]", cs, kind=DecodeKind.KEY) == "a[.]"
277+
assert decoder("a[%2e]", cs, kind=DecodeKind.KEY) == "a[.]"
262278

263279
def test_key_maps_2e_outside_brackets_allowdots_true_independent_of_decodeopt(self) -> None:
264280
for cs in (Charset.UTF8, Charset.LATIN1):
265281
opts1 = DecodeOptions(allow_dots=True, decode_dot_in_keys=False, charset=cs)
266282
opts2 = DecodeOptions(allow_dots=True, decode_dot_in_keys=True, charset=cs)
267-
assert opts1.decoder("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
268-
assert opts2.decoder("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
283+
decoder1 = require_decoder(opts1)
284+
decoder2 = require_decoder(opts2)
285+
assert decoder1("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
286+
assert decoder2("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
269287

270288
def test_non_key_decodes_2e_to_dot_control(self) -> None:
271289
for cs in (Charset.UTF8, Charset.LATIN1):
272290
opts = DecodeOptions(allow_dots=True, charset=cs)
273-
assert opts.decoder("a%2Eb", cs, kind=DecodeKind.VALUE) == "a.b"
291+
decoder = require_decoder(opts)
292+
assert decoder("a%2Eb", cs, kind=DecodeKind.VALUE) == "a.b"
274293

275294
def test_key_maps_2e_inside_brackets_allowdots_false(self) -> None:
276295
for cs in (Charset.UTF8, Charset.LATIN1):
277296
opts = DecodeOptions(allow_dots=False, charset=cs)
278-
assert opts.decoder("a[%2E]", cs, kind=DecodeKind.KEY) == "a[.]"
279-
assert opts.decoder("a[%2e]", cs, kind=DecodeKind.KEY) == "a[.]"
297+
decoder = require_decoder(opts)
298+
assert decoder("a[%2E]", cs, kind=DecodeKind.KEY) == "a[.]"
299+
assert decoder("a[%2e]", cs, kind=DecodeKind.KEY) == "a[.]"
280300

281301
def test_key_outside_2e_decodes_to_dot_allowdots_false(self) -> None:
282302
for cs in (Charset.UTF8, Charset.LATIN1):
283303
opts = DecodeOptions(allow_dots=False, charset=cs)
284-
assert opts.decoder("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
285-
assert opts.decoder("a%2eb", cs, kind=DecodeKind.KEY) == "a.b"
304+
decoder = require_decoder(opts)
305+
assert decoder("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
306+
assert decoder("a%2eb", cs, kind=DecodeKind.KEY) == "a.b"
286307

287308

288309
class TestCustomDecoderBehavior:
289310
def test_decode_key_decodes_percent_sequences_like_values_when_decode_dot_in_keys_false(self) -> None:
290311
opts = DecodeOptions(allow_dots=True, decode_dot_in_keys=False)
291-
assert opts.decoder("a%2Eb", Charset.UTF8, kind=DecodeKind.KEY) == "a.b"
292-
assert opts.decoder("a%2eb", Charset.UTF8, kind=DecodeKind.KEY) == "a.b"
312+
decoder = require_decoder(opts)
313+
assert decoder("a%2Eb", Charset.UTF8, kind=DecodeKind.KEY) == "a.b"
314+
assert decoder("a%2eb", Charset.UTF8, kind=DecodeKind.KEY) == "a.b"
293315

294316
def test_decode_value_decodes_percent_sequences_normally(self) -> None:
295317
opts = DecodeOptions()
296-
assert opts.decoder("%2E", Charset.UTF8, kind=DecodeKind.VALUE) == "."
318+
decoder = require_decoder(opts)
319+
assert decoder("%2E", Charset.UTF8, kind=DecodeKind.VALUE) == "."
297320

298321
def test_decoder_is_used_for_key_and_value(self) -> None:
299322
calls: t.List[t.Tuple[t.Optional[str], DecodeKind]] = []
@@ -303,8 +326,9 @@ def dec(s: t.Optional[str], charset: t.Optional[Charset], kind: DecodeKind) -> t
303326
return s
304327

305328
opts = DecodeOptions(decoder=dec)
306-
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "x"
307-
assert opts.decoder("y", Charset.UTF8, kind=DecodeKind.VALUE) == "y"
329+
decoder = require_decoder(opts)
330+
assert decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "x"
331+
assert decoder("y", Charset.UTF8, kind=DecodeKind.VALUE) == "y"
308332

309333
assert len(calls) == 2
310334
assert calls[0][1] is DecodeKind.KEY and calls[0][0] == "x"
@@ -315,17 +339,19 @@ def dec(s: t.Optional[str], charset: t.Optional[Charset], kind: DecodeKind) -> t
315339
return None
316340

317341
opts = DecodeOptions(decoder=dec)
318-
assert opts.decoder("foo", Charset.UTF8, kind=DecodeKind.VALUE) is None
319-
assert opts.decoder("bar", Charset.UTF8, kind=DecodeKind.KEY) is None
342+
decoder = require_decoder(opts)
343+
assert decoder("foo", Charset.UTF8, kind=DecodeKind.VALUE) is None
344+
assert decoder("bar", Charset.UTF8, kind=DecodeKind.KEY) is None
320345

321346
def test_single_decoder_acts_like_legacy_when_ignoring_kind(self) -> None:
322347
def dec(s: t.Optional[str], *args, **kwargs): # type: ignore[no-untyped-def]
323348
return None if s is None else s.upper()
324349

325350
opts = DecodeOptions(decoder=dec)
326-
assert opts.decoder("abc", Charset.UTF8, kind=DecodeKind.VALUE) == "ABC"
351+
decoder = require_decoder(opts)
352+
assert decoder("abc", Charset.UTF8, kind=DecodeKind.VALUE) == "ABC"
327353
# For keys, custom decoder gets the raw token; no default percent-decoding happens first.
328-
assert opts.decoder("a%2Eb", Charset.UTF8, kind=DecodeKind.KEY) == "A%2EB"
354+
assert decoder("a%2Eb", Charset.UTF8, kind=DecodeKind.KEY) == "A%2EB"
329355

330356
def test_decoder_wins_over_legacy_decoder_when_both_provided(self) -> None:
331357
# decoder must take precedence over legacy_decoder (parity with Kotlin/C#)
@@ -356,3 +382,11 @@ def dec(
356382

357383
opts = DecodeOptions(decoder=dec)
358384
assert opts.decode_key("anything") == "42"
385+
386+
387+
DecoderCallable = t.Callable[..., t.Optional[t.Any]]
388+
389+
390+
def require_decoder(opts: DecodeOptions) -> DecoderCallable:
391+
assert opts.decoder is not None
392+
return opts.decoder

tests/unit/decode_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
class TestDecode:
1717
def test_throws_an_error_if_the_input_is_not_a_string_or_a_dict(self) -> None:
1818
with pytest.raises(ValueError):
19-
decode(123)
19+
decode(123) # type: ignore[arg-type]
2020

2121
@pytest.mark.parametrize(
2222
"encoded, decoded, options",

tests/unit/encode_options_test.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,19 @@
55
class TestEncodeOptions:
66
def test_post_init_restores_default_encoder(self) -> None:
77
opts = EncodeOptions()
8-
assert opts._encoder.__func__ is EncodeUtils.encode.__func__
8+
left = getattr(opts._encoder, "__func__", None)
9+
right = getattr(EncodeUtils.encode, "__func__", None)
10+
assert left is not None and right is not None
11+
assert left is right
912

1013
def test_post_init_recovers_when_encoder_missing(self) -> None:
1114
opts = EncodeOptions()
1215
delattr(opts, "_encoder")
1316
EncodeOptions.__post_init__(opts)
14-
assert opts._encoder.__func__ is EncodeUtils.encode.__func__
17+
left = getattr(opts._encoder, "__func__", None)
18+
right = getattr(EncodeUtils.encode, "__func__", None)
19+
assert left is not None and right is not None
20+
assert left is right
1521

1622
def test_equality_with_other_type_returns_false(self) -> None:
1723
opts = EncodeOptions()

0 commit comments

Comments
 (0)