diff --git a/.gitignore b/.gitignore index d66b496..2ef20ba 100644 --- a/.gitignore +++ b/.gitignore @@ -108,6 +108,9 @@ venv.bak/ # PyCharm specific files .idea +# VS Code specific files +.vscode/ + # macOS specific .DS_Store diff --git a/pyproject.toml b/pyproject.toml index ec420c5..4719e27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/src/qs_codec/encode.py b/src/qs_codec/encode.py index 220e81c..0fc53e6 100644 --- a/src/qs_codec/encode.py +++ b/src/qs_codec/encode.py @@ -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]], diff --git a/src/qs_codec/models/decode_options.py b/src/qs_codec/models/decode_options.py index 7fec5a6..eb7479d 100644 --- a/src/qs_codec/models/decode_options.py +++ b/src/qs_codec/models/decode_options.py @@ -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 @@ -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).""" @@ -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] = {} @@ -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] @@ -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) diff --git a/src/qs_codec/models/encode_options.py b/src/qs_codec/models/encode_options.py index 0424e70..7c2b25a 100644 --- a/src/qs_codec/models/encode_options.py +++ b/src/qs_codec/models/encode_options.py @@ -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 diff --git a/tests/unit/decode_options_test.py b/tests/unit/decode_options_test.py index 19f4a97..dac1bb1 100644 --- a/tests/unit/decode_options_test.py +++ b/tests/unit/decode_options_test.py @@ -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) @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -100,7 +106,8 @@ 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: @@ -108,8 +115,9 @@ 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]] = [] @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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]] = [] @@ -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" @@ -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#) @@ -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 diff --git a/tests/unit/decode_test.py b/tests/unit/decode_test.py index 6fbeba4..c2175a1 100644 --- a/tests/unit/decode_test.py +++ b/tests/unit/decode_test.py @@ -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", diff --git a/tests/unit/encode_options_test.py b/tests/unit/encode_options_test.py index 4545942..b3ef86d 100644 --- a/tests/unit/encode_options_test.py +++ b/tests/unit/encode_options_test.py @@ -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() diff --git a/tests/unit/encode_test.py b/tests/unit/encode_test.py index e6796d2..6e2a1a8 100644 --- a/tests/unit/encode_test.py +++ b/tests/unit/encode_test.py @@ -18,6 +18,25 @@ from qs_codec.utils.utils import Utils +def options_with_encoder( + encoder: t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str], + **kwargs: t.Any, +) -> EncodeOptions: + options = EncodeOptions(**kwargs) + options.encoder = encoder + return options + + +def decimal_encoder( + value: t.Any, + charset: t.Optional[Charset] = None, + format: t.Optional[Format] = None, +) -> str: + _ = charset + _ = format + return f"{EncodeUtils.encode(value)}n" if isinstance(value, Decimal) else EncodeUtils.encode(value) + + class TestEncode: @pytest.mark.parametrize( "decoded, encoded", @@ -84,22 +103,14 @@ def test_encodes_falsy_values(self, data: t.Any, options: t.Optional[EncodeOptio pytest.param([_PI], None, f"0=3.141592653589793115997963468544185161590576171875", id="list-decimal"), pytest.param( [_PI], - EncodeOptions( - encoder=lambda v, charset=None, format=None: ( - f"{EncodeUtils.encode(v)}n" if isinstance(v, Decimal) else EncodeUtils.encode(v) - ) - ), + options_with_encoder(decimal_encoder), f"0=3.141592653589793115997963468544185161590576171875n", id="list-decimal-with-n", ), pytest.param({"a": _PI}, None, f"a=3.141592653589793115997963468544185161590576171875", id="dict-decimal"), pytest.param( {"a": _PI}, - EncodeOptions( - encoder=lambda v, charset=None, format=None: ( - f"{EncodeUtils.encode(v)}n" if isinstance(v, Decimal) else EncodeUtils.encode(v) - ) - ), + options_with_encoder(decimal_encoder), f"a=3.141592653589793115997963468544185161590576171875n", id="dict-decimal-with-n", ), @@ -111,12 +122,10 @@ def test_encodes_falsy_values(self, data: t.Any, options: t.Optional[EncodeOptio ), pytest.param( {"a": [_PI]}, - EncodeOptions( + options_with_encoder( + decimal_encoder, encode_values_only=True, list_format=ListFormat.BRACKETS, - encoder=lambda v, charset=None, format=None: ( - f"{EncodeUtils.encode(v)}n" if isinstance(v, Decimal) else EncodeUtils.encode(v) - ), ), f"a[]=3.141592653589793115997963468544185161590576171875n", id="brackets-list-decimal-with-n", @@ -1002,7 +1011,7 @@ def test_can_encode_with_custom_encoding(self) -> None: def _encode(string: str, charset: t.Optional[Charset] = None, format: t.Optional[Format] = None) -> str: return "".join([f"%{i:02x}" for i in bytes(string, "shift-jis")]) - assert encode({"県": "大阪府", "": ""}, options=EncodeOptions(encoder=_encode)) == "%8c%a7=%91%e5%8d%e3%95%7b&=" + assert encode({"県": "大阪府", "": ""}, options=options_with_encoder(_encode)) == "%8c%a7=%91%e5%8d%e3%95%7b&=" def test_can_encode_with_custom_encoding_for_a_buffer_map(self) -> None: buf: bytes = bytes([1]) @@ -1012,7 +1021,7 @@ def _encode1(buffer: t.AnyStr, charset: t.Optional[Charset] = None, format: t.Op return buffer return chr(buffer[0] + 97) - assert encode({"a": buf}, options=EncodeOptions(encoder=_encode1)) == "a=b" + assert encode({"a": buf}, options=options_with_encoder(_encode1)) == "a=b" buf2: bytes = "a b".encode("utf-8") @@ -1021,7 +1030,7 @@ def _encode2(buffer: t.AnyStr, charset: t.Optional[Charset] = None, format: t.Op return buffer.decode("utf-8") return buffer - assert encode({"a": buf2}, options=EncodeOptions(encoder=_encode2)) == "a=a b" + assert encode({"a": buf2}, options=options_with_encoder(_encode2)) == "a=a b" _DATE_NOW = datetime.now() _SPECIFIC_DATE = datetime.fromtimestamp(6) @@ -1739,7 +1748,11 @@ def test_encodes_non_string_keys(self) -> None: assert ( encode( {"a": "b", False: {}}, - options=EncodeOptions(filter=["a", False, None], allow_dots=True, encode_dot_in_keys=True), + options=EncodeOptions( + filter=t.cast(t.Sequence[t.Union[str, int]], ["a", False, None]), + allow_dots=True, + encode_dot_in_keys=True, + ), ) == "a=b" ) diff --git a/tests/unit/example_test.py b/tests/unit/example_test.py index 9fa1d66..1077b0f 100644 --- a/tests/unit/example_test.py +++ b/tests/unit/example_test.py @@ -174,7 +174,9 @@ def custom_encoder(value: str, charset: t.Optional[qs_codec.Charset], format: t. return "c" return value - assert qs_codec.encode({"a": {"b": "č"}}, qs_codec.EncodeOptions(encoder=custom_encoder)) == "a[b]=c" + encode_options = qs_codec.EncodeOptions() + encode_options.encoder = custom_encoder + assert qs_codec.encode({"a": {"b": "č"}}, encode_options) == "a[b]=c" # Similar to `EncodeOptions.encoder` there is a `DecodeOptions.decoder` option for `decode` to override decoding # of properties and values: @@ -276,7 +278,7 @@ def custom_decoder(value: t.Any, charset: t.Optional[qs_codec.Charset]): qs_codec.encode( { "a": ( - datetime.datetime.fromtimestamp(7, datetime.UTC) + datetime.datetime.fromtimestamp(7, datetime.timezone.utc) if version_info.major == 3 and version_info.minor >= 11 else datetime.datetime.utcfromtimestamp(7) ) @@ -291,7 +293,7 @@ def custom_decoder(value: t.Any, charset: t.Optional[qs_codec.Charset]): qs_codec.encode( { "a": ( - datetime.datetime.fromtimestamp(7, datetime.UTC) + datetime.datetime.fromtimestamp(7, datetime.timezone.utc) if version_info.major == 3 and version_info.minor >= 11 else datetime.datetime.utcfromtimestamp(7) ) @@ -332,7 +334,7 @@ def custom_decoder(value: t.Any, charset: t.Optional[qs_codec.Charset]): "c": "d", "e": { "f": ( - datetime.datetime.fromtimestamp(123, datetime.UTC) + datetime.datetime.fromtimestamp(123, datetime.timezone.utc) if version_info.major == 3 and version_info.minor >= 11 else datetime.datetime.utcfromtimestamp(123) ), @@ -414,10 +416,10 @@ def custom_encoder( return "%" + "%".join(result) return "" - assert ( - qs_codec.encode({"a": "こんにちは!"}, qs_codec.EncodeOptions(encoder=custom_encoder)) - == "%61=%82%b1%82%f1%82%c9%82%bf%82%cd%81%49" - ) + shift_jis_options = qs_codec.EncodeOptions() + shift_jis_options.encoder = custom_encoder + + assert qs_codec.encode({"a": "こんにちは!"}, shift_jis_options) == "%61=%82%b1%82%f1%82%c9%82%bf%82%cd%81%49" # This also works for decoding of query strings: def custom_decoder(string: str, charset: t.Optional[qs_codec.Charset]) -> t.Optional[str]: diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 36242ba..db3b118 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -591,7 +591,7 @@ def test_merges_array_into_object(self) -> None: def test_combine_both_arrays(self) -> None: a: t.List[int] = [1] b: t.List[int] = [2] - combined: t.List[int] = Utils.combine(a, b) + combined = Utils.combine(a, b) assert a == [1] assert b == [2] @@ -605,7 +605,7 @@ def test_combine_one_array_one_non_array(self) -> None: b_n: int = 2 b: t.List[int] = [b_n] - combined_an_b: t.List[int] = Utils.combine(a_n, b) + combined_an_b = Utils.combine(a_n, b) assert b == [b_n] assert a_n is not combined_an_b assert a is not combined_an_b @@ -624,7 +624,7 @@ def test_combine_one_array_one_non_array(self) -> None: def test_combine_neither_is_an_array(self) -> None: a: int = 1 b: int = 2 - combined: t.List[int] = Utils.combine(a, b) + combined = Utils.combine(a, b) assert a is not combined assert b is not combined @@ -812,13 +812,13 @@ def test_remove_undefined_from_map_with_tuple(self) -> None: def test_dicts_are_equal_with_non_dicts(self) -> None: # Test for lines 189 and 192 in utils.py # Test comparing a dict with a non-dict (should return False) - assert not Utils._dicts_are_equal({"a": 1}, "not a dict") + assert not Utils._dicts_are_equal({"a": 1}, "not a dict") # type: ignore[arg-type] # Test comparing two non-dicts that are equal - assert Utils._dicts_are_equal("same", "same") + assert Utils._dicts_are_equal("same", "same") # type: ignore[arg-type] # Test comparing two non-dicts that are not equal - assert not Utils._dicts_are_equal("one", "two") + assert not Utils._dicts_are_equal("one", "two") # type: ignore[arg-type] def test_is_non_nullish_primitive_catch_all(self) -> None: # Test for line 226-228 in utils.py @@ -905,14 +905,14 @@ def test_merge_target_is_overflow_dict(self) -> None: target = OverflowDict({"0": "a"}) source = "b" # Should delegate to combine, which appends 'b' at index 1 - result = Utils.merge(target, source) + result = Utils.merge(target, source) # type: ignore[arg-type] assert isinstance(result, OverflowDict) assert result == {"0": "a", "1": "b"} def test_merge_source_is_overflow_dict_into_dict(self) -> None: target = {"a": 1} source = OverflowDict({"b": 2}) - result = Utils.merge(target, source) + result = Utils.merge(target, source) # type: ignore[arg-type] assert isinstance(result, dict) assert result == {"a": 1, "b": 2} @@ -920,7 +920,7 @@ def test_merge_source_is_overflow_dict_into_list(self) -> None: target = ["a"] # source has key '0', which collides with target's index 0 source = OverflowDict({"0": "b"}) - result = Utils.merge(target, source) + result = Utils.merge(target, source) # type: ignore[arg-type] assert isinstance(result, dict) # Source overwrites target at key '0' assert result == {"0": "b"} @@ -928,19 +928,19 @@ def test_merge_source_is_overflow_dict_into_list(self) -> None: def test_merge_overflow_dict_with_mapping_preserves_overflow(self) -> None: target = OverflowDict({"0": "a"}) source = {"foo": "bar"} - result = Utils.merge(target, source) + result = Utils.merge(target, source) # type: ignore[arg-type] assert isinstance(result, OverflowDict) assert result == {"0": "a", "foo": "bar"} def test_merge_prefers_exact_key_match_before_string_normalization(self) -> None: target = {1: {"a": "x"}} source = {1: {"b": "y"}} - result = Utils.merge(target, source) + result = Utils.merge(target, source) # type: ignore[arg-type] assert result == {1: {"a": "x", "b": "y"}} - target = {"1": {"a": "x"}} - source = {1: {"b": "y"}} - result = Utils.merge(target, source) + target_str = {"1": {"a": "x"}} + source_str = {1: {"b": "y"}} + result = Utils.merge(target_str, source_str) # type: ignore[arg-type] assert result == {"1": {"a": "x", "b": "y"}} assert 1 not in result @@ -973,7 +973,7 @@ def test_merge_target_is_sparse_overflow_dict(self) -> None: # Merge delegates to combine, so this should also use max key + 1 target = OverflowDict({"999": "a"}) source = "b" - result = Utils.merge(target, source) + result = Utils.merge(target, source) # type: ignore[arg-type] assert isinstance(result, OverflowDict) assert result == {"999": "a", "1000": "b"} @@ -987,7 +987,7 @@ def test_merge_scalar_target_with_sparse_overflow_dict_source(self) -> None: source["2"] = "b" # Utils.merge should produce [target, *source_values_sorted] - result = Utils.merge(target, source) + result = Utils.merge(target, source) # type: ignore[arg-type] assert isinstance(result, OverflowDict) assert result == {"0": "a", "3": "b", "11": "c"} @@ -1051,7 +1051,7 @@ def test_combine_overflow_dict_source_skips_non_numeric_keys(self) -> None: def test_merge_overflow_dict_source_preserves_non_numeric_keys(self) -> None: target = "a" source = OverflowDict({"foo": "skip", "1": "b"}) - result = Utils.merge(target, source) + result = Utils.merge(target, source) # type: ignore[arg-type] assert isinstance(result, OverflowDict) assert result == {"0": "a", "2": "b", "foo": "skip"} diff --git a/tests/unit/weakref_test.py b/tests/unit/weakref_test.py index 08e6f0e..84bc93d 100644 --- a/tests/unit/weakref_test.py +++ b/tests/unit/weakref_test.py @@ -68,7 +68,7 @@ def test_hash_handles_sets(self) -> None: def test_hash_fallback_uses_repr_for_unhashable_object(self) -> None: class Unhashable: - __hash__ = None + __hash__ = None # type: ignore[assignment] def __repr__(self) -> str: # pragma: no cover - trivial repr return "" @@ -94,9 +94,10 @@ def test_hash_detects_circular_references(self) -> None: def test_hash_detects_excessive_depth(self) -> None: # artificially create a super deep nested list - deep = current = [] + deep: t.List[t.Any] = [] + current: t.List[t.Any] = deep for _ in range(401): # 400 is the limit - new_list = [] + new_list: t.List[t.Any] = [] current.append(new_list) current = new_list wrapper = WeakWrapper(deep)