Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ venv.bak/
# PyCharm specific files
.idea

# VS Code specific files
.vscode/

# macOS specific
.DS_Store

Expand Down
9 changes: 1 addition & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -135,14 +135,7 @@ markers = []
[tool.mypy]
mypy_path = "src"
python_version = "3.8"
exclude = [
"tests",
"docs",
"build",
"dist",
"venv",
"env",
]
exclude = ["tests", "docs", "build", "dist", "venv", "env", ".tox"]
show_error_codes = true
warn_return_any = true
warn_unused_configs = true
Expand Down
2 changes: 1 addition & 1 deletion src/qs_codec/encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def _encode(
comma_round_trip: t.Optional[bool],
comma_compact_nulls: bool,
encoder: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]],
serialize_date: t.Callable[[datetime], t.Optional[str]],
serialize_date: t.Union[t.Callable[[datetime], t.Optional[str]], str],
sort: t.Optional[t.Callable[[t.Any, t.Any], int]],
filter_: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]],
formatter: t.Optional[t.Callable[[str], str]],
Expand Down
10 changes: 5 additions & 5 deletions src/qs_codec/models/decode_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ class DecodeOptions:
``strict_depth=True``—stops descending once ``depth`` is reached without raising.
"""

decoder: t.Optional[t.Callable[..., t.Optional[str]]] = DecodeUtils.decode
decoder: t.Optional[t.Callable[..., t.Optional[t.Any]]] = DecodeUtils.decode
"""Custom scalar decoder invoked for each raw token prior to interpretation.

The built-in decoder supports ``kind`` and is invoked as
Expand All @@ -135,7 +135,7 @@ class DecodeOptions:
from the decoder uses ``None`` as the scalar value.
"""

legacy_decoder: t.Optional[t.Callable[..., t.Optional[str]]] = None
legacy_decoder: t.Optional[t.Callable[..., t.Optional[t.Any]]] = None
"""Back‑compat adapter for legacy decoders of the form ``decoder(value, charset)``.
Prefer ``decoder`` which may optionally accept a ``kind`` argument. When both are supplied,
``decoder`` takes precedence (mirroring Kotlin/C#/Swift/Dart behavior)."""
Expand Down Expand Up @@ -221,7 +221,7 @@ def dispatch(
s: t.Optional[str],
charset: t.Optional[Charset],
kind: DecodeKind,
) -> t.Optional[str]:
) -> t.Optional[t.Any]:
kind_arg: t.Union[DecodeKind, str] = kind.value if pass_kind_as_str else kind
args: t.List[t.Any] = [s]
kwargs: t.Dict[str, t.Any] = {}
Expand All @@ -241,7 +241,7 @@ def dispatch(
s: t.Optional[str],
charset: t.Optional[Charset],
kind: DecodeKind,
) -> t.Optional[str]:
) -> t.Optional[t.Any]:
_ = kind # ignored by legacy decoders
try:
return user_dec(s) # type: ignore[misc]
Expand All @@ -257,7 +257,7 @@ def _adapter(
charset: t.Optional[Charset] = Charset.UTF8,
*,
kind: DecodeKind = DecodeKind.VALUE,
) -> t.Optional[str]:
) -> t.Optional[t.Any]:
"""Adapter that dispatches based on the user decoder's signature."""
return dispatch(s, charset, kind)

Expand Down
6 changes: 3 additions & 3 deletions src/qs_codec/models/encode_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ class EncodeOptions:
skip_nulls: bool = False
"""When `True`, omit keys whose value is `None` entirely (no trailing `=`)."""

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

encoder: t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str] = field( # type: ignore [assignment]
default=EncodeUtils.encode, init=False, repr=False
Expand Down
102 changes: 68 additions & 34 deletions tests/unit/decode_options_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ def test_default_decoder_behaves_like_decodeutils(self) -> None:
# The adapter may wrap the default, so compare behavior rather than identity.
opts = DecodeOptions()
s = "a+b%2E"
out_key = opts.decoder(s, Charset.UTF8, kind=DecodeKind.KEY)
out_val = opts.decoder(s, Charset.UTF8, kind=DecodeKind.VALUE)
decoder = require_decoder(opts)
out_key = decoder(s, Charset.UTF8, kind=DecodeKind.KEY)
out_val = decoder(s, Charset.UTF8, kind=DecodeKind.VALUE)
assert out_key == DecodeUtils.decode(s, charset=Charset.UTF8, kind=DecodeKind.KEY)
assert out_val == DecodeUtils.decode(s, charset=Charset.UTF8, kind=DecodeKind.VALUE)

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "X"
decoder = require_decoder(opts)
assert decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "X"
assert calls == [("x",)]

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("hi", Charset.LATIN1, kind=DecodeKind.VALUE) == "hi|LATIN1"
decoder = require_decoder(opts)
assert decoder("hi", Charset.LATIN1, kind=DecodeKind.VALUE) == "hi|LATIN1"
assert seen == [("hi", Charset.LATIN1)]

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "K:E"
decoder = require_decoder(opts)
assert decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "K:E"
assert seen and isinstance(seen[0], DecodeKind) and seen[0] is DecodeKind.KEY

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "key"
decoder = require_decoder(opts)
assert decoder("z", Charset.UTF8, kind=DecodeKind.KEY) == "key"
assert seen == ["key"]

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("z", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
decoder = require_decoder(opts)
assert decoder("z", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
assert seen == ["value"]

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("ok", Charset.UTF8, kind=DecodeKind.KEY) == "ok"
decoder = require_decoder(opts)
assert decoder("ok", Charset.UTF8, kind=DecodeKind.KEY) == "ok"
assert seen == ["key"]

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

opts = DecodeOptions(decoder=dec)
decoder = require_decoder(opts)
with pytest.raises(TypeError):
_ = opts.decoder("oops", Charset.UTF8, kind=DecodeKind.KEY)
_ = decoder("oops", Charset.UTF8, kind=DecodeKind.KEY)

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("x", Charset.LATIN1, kind=DecodeKind.KEY) == "x"
decoder = require_decoder(opts)
assert decoder("x", Charset.LATIN1, kind=DecodeKind.KEY) == "x"
assert calls == [{"charset": Charset.LATIN1, "kind": "key"}]

def test_positional_only_kind_receives_string(self) -> None:
Expand All @@ -136,7 +145,8 @@ def dec(
return s

opts = DecodeOptions(decoder=dec)
assert opts.decoder("value", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
decoder = require_decoder(opts)
assert decoder("value", Charset.UTF8, kind=DecodeKind.VALUE) == "value"
assert seen == [("value", Charset.UTF8)]

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("q", Charset.UTF8, kind=DecodeKind.KEY) == "q"
decoder = require_decoder(opts)
assert decoder("q", Charset.UTF8, kind=DecodeKind.KEY) == "q"
assert seen == ["key"]

def test_literal_kind_annotation_prefers_string(self) -> None:
Expand All @@ -162,7 +173,8 @@ def dec(
return s

opts = DecodeOptions(decoder=dec)
assert opts.decoder("ok", Charset.UTF8, kind=DecodeKind.VALUE) == "ok"
decoder = require_decoder(opts)
assert decoder("ok", Charset.UTF8, kind=DecodeKind.VALUE) == "ok"
assert seen == ["value"]

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

opts = DecodeOptions(decoder=BadSignature())
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "x-ok"
decoder = require_decoder(opts)
assert decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "x-ok"

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

opts = DecodeOptions(decoder=BadSignature())
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.VALUE) == "x|UTF8"
decoder = require_decoder(opts)
assert decoder("x", Charset.UTF8, kind=DecodeKind.VALUE) == "x|UTF8"

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

opts = DecodeOptions(decoder=BadSignature())
decoder = require_decoder(opts)
with pytest.raises(TypeError) as exc_info:
_ = opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY)
_ = decoder("x", Charset.UTF8, kind=DecodeKind.KEY)
assert exc_info.value.__cause__ is not None

def test_builtin_without_signature_raises_original_typeerror(self) -> None:
Expand Down Expand Up @@ -257,43 +272,51 @@ class TestDefaultDecodeKeyEncodedDots:
def test_key_maps_2e_inside_brackets_allowdots_true(self) -> None:
for cs in (Charset.UTF8, Charset.LATIN1):
opts = DecodeOptions(allow_dots=True, charset=cs)
assert opts.decoder("a[%2E]", cs, kind=DecodeKind.KEY) == "a[.]"
assert opts.decoder("a[%2e]", cs, kind=DecodeKind.KEY) == "a[.]"
decoder = require_decoder(opts)
assert decoder("a[%2E]", cs, kind=DecodeKind.KEY) == "a[.]"
assert decoder("a[%2e]", cs, kind=DecodeKind.KEY) == "a[.]"

def test_key_maps_2e_outside_brackets_allowdots_true_independent_of_decodeopt(self) -> None:
for cs in (Charset.UTF8, Charset.LATIN1):
opts1 = DecodeOptions(allow_dots=True, decode_dot_in_keys=False, charset=cs)
opts2 = DecodeOptions(allow_dots=True, decode_dot_in_keys=True, charset=cs)
assert opts1.decoder("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
assert opts2.decoder("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
decoder1 = require_decoder(opts1)
decoder2 = require_decoder(opts2)
assert decoder1("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
assert decoder2("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"

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

def test_key_maps_2e_inside_brackets_allowdots_false(self) -> None:
for cs in (Charset.UTF8, Charset.LATIN1):
opts = DecodeOptions(allow_dots=False, charset=cs)
assert opts.decoder("a[%2E]", cs, kind=DecodeKind.KEY) == "a[.]"
assert opts.decoder("a[%2e]", cs, kind=DecodeKind.KEY) == "a[.]"
decoder = require_decoder(opts)
assert decoder("a[%2E]", cs, kind=DecodeKind.KEY) == "a[.]"
assert decoder("a[%2e]", cs, kind=DecodeKind.KEY) == "a[.]"

def test_key_outside_2e_decodes_to_dot_allowdots_false(self) -> None:
for cs in (Charset.UTF8, Charset.LATIN1):
opts = DecodeOptions(allow_dots=False, charset=cs)
assert opts.decoder("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
assert opts.decoder("a%2eb", cs, kind=DecodeKind.KEY) == "a.b"
decoder = require_decoder(opts)
assert decoder("a%2Eb", cs, kind=DecodeKind.KEY) == "a.b"
assert decoder("a%2eb", cs, kind=DecodeKind.KEY) == "a.b"


class TestCustomDecoderBehavior:
def test_decode_key_decodes_percent_sequences_like_values_when_decode_dot_in_keys_false(self) -> None:
opts = DecodeOptions(allow_dots=True, decode_dot_in_keys=False)
assert opts.decoder("a%2Eb", Charset.UTF8, kind=DecodeKind.KEY) == "a.b"
assert opts.decoder("a%2eb", Charset.UTF8, kind=DecodeKind.KEY) == "a.b"
decoder = require_decoder(opts)
assert decoder("a%2Eb", Charset.UTF8, kind=DecodeKind.KEY) == "a.b"
assert decoder("a%2eb", Charset.UTF8, kind=DecodeKind.KEY) == "a.b"

def test_decode_value_decodes_percent_sequences_normally(self) -> None:
opts = DecodeOptions()
assert opts.decoder("%2E", Charset.UTF8, kind=DecodeKind.VALUE) == "."
decoder = require_decoder(opts)
assert decoder("%2E", Charset.UTF8, kind=DecodeKind.VALUE) == "."

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "x"
assert opts.decoder("y", Charset.UTF8, kind=DecodeKind.VALUE) == "y"
decoder = require_decoder(opts)
assert decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "x"
assert decoder("y", Charset.UTF8, kind=DecodeKind.VALUE) == "y"

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

opts = DecodeOptions(decoder=dec)
assert opts.decoder("foo", Charset.UTF8, kind=DecodeKind.VALUE) is None
assert opts.decoder("bar", Charset.UTF8, kind=DecodeKind.KEY) is None
decoder = require_decoder(opts)
assert decoder("foo", Charset.UTF8, kind=DecodeKind.VALUE) is None
assert decoder("bar", Charset.UTF8, kind=DecodeKind.KEY) is None

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

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

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

opts = DecodeOptions(decoder=dec)
assert opts.decode_key("anything") == "42"


DecoderCallable = t.Callable[..., t.Optional[t.Any]]


def require_decoder(opts: DecodeOptions) -> DecoderCallable:
assert opts.decoder is not None
return opts.decoder
2 changes: 1 addition & 1 deletion tests/unit/decode_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
class TestDecode:
def test_throws_an_error_if_the_input_is_not_a_string_or_a_dict(self) -> None:
with pytest.raises(ValueError):
decode(123)
decode(123) # type: ignore[arg-type]

@pytest.mark.parametrize(
"encoded, decoded, options",
Expand Down
10 changes: 8 additions & 2 deletions tests/unit/encode_options_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@
class TestEncodeOptions:
def test_post_init_restores_default_encoder(self) -> None:
opts = EncodeOptions()
assert opts._encoder.__func__ is EncodeUtils.encode.__func__
left = getattr(opts._encoder, "__func__", None)
right = getattr(EncodeUtils.encode, "__func__", None)
assert left is not None and right is not None
assert left is right

def test_post_init_recovers_when_encoder_missing(self) -> None:
opts = EncodeOptions()
delattr(opts, "_encoder")
EncodeOptions.__post_init__(opts)
assert opts._encoder.__func__ is EncodeUtils.encode.__func__
left = getattr(opts._encoder, "__func__", None)
right = getattr(EncodeUtils.encode, "__func__", None)
assert left is not None and right is not None
assert left is right

def test_equality_with_other_type_returns_false(self) -> None:
opts = EncodeOptions()
Expand Down
Loading