From a2f03a8b6802797985734ec66a8a4e10e3f92fc3 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 10:45:48 +0000 Subject: [PATCH 01/12] chore: add pyright to development dependencies and configure tox for type checking --- pyproject.toml | 8 +++++++- requirements_dev.txt | 1 + tox.ini | 11 +++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3df158c..6d62f06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,8 @@ dev = [ "toml>=0.10.2", "tox", "black", - "isort" + "isort", + "pyright" ] [tool.hatch.version] @@ -136,3 +137,8 @@ exclude = [ show_error_codes = true warn_return_any = true warn_unused_configs = true + +[tool.pyright] +pythonVersion = "3.8" +include = ["src/qs_codec"] +exclude = ["tests", "docs", "build", "dist", "venv", "env", ".tox"] diff --git a/requirements_dev.txt b/requirements_dev.txt index ec67e9c..3948775 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -6,3 +6,4 @@ toml>=0.10.2 tox black isort +pyright diff --git a/tox.ini b/tox.ini index 74d63b6..505c661 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ envlist = pypy3.11, black, flake8, + pyright, linters, skip_missing_interpreters = true @@ -104,6 +105,14 @@ deps = commands = mypy src/qs_codec +[testenv:pyright] +basepython = python3 +skip_install = true +deps = + pyright +commands = + pyright src/qs_codec + [testenv:linters] basepython = python3 skip_install = true @@ -114,6 +123,7 @@ deps = {[testenv:pylint]deps} {[testenv:bandit]deps} {[testenv:mypy]deps} + {[testenv:pyright]deps} commands = {[testenv:black]commands} {[testenv:isort]commands} @@ -121,6 +131,7 @@ commands = {[testenv:pylint]commands} {[testenv:bandit]commands} {[testenv:mypy]commands} + {[testenv:pyright]commands} [flake8] ignore = I100,I201,I202,D203,D401,W503,E203,F401,F403,C901,E501 From 367b9c7d30b7ca7bc76bec49d20ac858a653a343 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 10:48:40 +0000 Subject: [PATCH 02/12] chore: add pyright to linting workflow for enhanced type checking --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e5cf832..c6ca020 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,7 +29,7 @@ Concise, project-specific guidance for AI coding agents working on this repo. Fo ## 4. Developer Workflow - Install (dev): `python -m pip install -e .[dev]`. - Run full test suite: `pytest -v --cov=src/qs_codec` (coverage enforced in CI). -- Lint/type check: `tox -e linters` (chains Black, isort, flake8, pylint, mypy, bandit). +- Lint/type check: `tox -e linters` (chains Black, isort, flake8, pylint, mypy, pyright, bandit). - Multi-version tests: `tox -e python3.13` (swap env name for other versions). - Docs build: `make -C docs html` (update Sphinx when public behavior or options change). - Cross-language parity verification: run `tests/comparison/compare_outputs.sh` (invokes Node reference `qs.js` with shared `test_cases.json`). Update cases when adding features—maintain symmetry. From b165d15542231a1e2d2e5e32d35fbd41559582b8 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 10:56:17 +0000 Subject: [PATCH 03/12] refactor: update type hints for filter parameter and improve Undefined singleton method --- src/qs_codec/encode.py | 10 +++++++--- src/qs_codec/models/encode_options.py | 2 +- src/qs_codec/models/undefined.py | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/qs_codec/encode.py b/src/qs_codec/encode.py index 9bd1148..ab987e6 100644 --- a/src/qs_codec/encode.py +++ b/src/qs_codec/encode.py @@ -167,7 +167,7 @@ def _encode( encoder: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]], serialize_date: t.Callable[[datetime], t.Optional[str]], sort: t.Optional[t.Callable[[t.Any, t.Any], int]], - filter: t.Optional[t.Union[t.Callable, t.List[t.Union[str, int]]]], + filter: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]], formatter: t.Optional[t.Callable[[str], str]], format: Format = Format.RFC3986, generate_array_prefix: t.Callable[[str, t.Optional[str]], str] = ListFormat.INDICES.generator, @@ -358,8 +358,12 @@ def _encode( _value = obj.get(_key) _value_undefined = _key not in obj elif isinstance(obj, (list, tuple)): - _value = obj[_key] - _value_undefined = False + if isinstance(_key, int): + _value = obj[_key] + _value_undefined = False + else: + _value = None + _value_undefined = True else: _value = obj[_key] _value_undefined = False diff --git a/src/qs_codec/models/encode_options.py b/src/qs_codec/models/encode_options.py index 3ca1b8c..ff3c5da 100644 --- a/src/qs_codec/models/encode_options.py +++ b/src/qs_codec/models/encode_options.py @@ -67,7 +67,7 @@ class EncodeOptions: """Space handling and percent‑encoding style. `RFC3986` encodes spaces as `%20`, while `RFC1738` uses `+`.""" - filter: t.Optional[t.Union[t.Callable, t.List[t.Union[str, int]]]] = field(default=None) + filter: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]] = field(default=None) """Restrict which keys get included. - If a callable is provided, it is invoked for each key and should return the replacement value (or `None` to drop when `skip_nulls` applies). diff --git a/src/qs_codec/models/undefined.py b/src/qs_codec/models/undefined.py index 1c8869f..c1dcaa2 100644 --- a/src/qs_codec/models/undefined.py +++ b/src/qs_codec/models/undefined.py @@ -37,7 +37,7 @@ class Undefined: _lock: t.ClassVar[threading.Lock] = threading.Lock() _instance: t.ClassVar[t.Optional["Undefined"]] = None - def __new__(cls: t.Type["Undefined"]) -> "Undefined": + def __new__(cls): """Return the singleton instance. Creating `Undefined()` multiple times always returns the same object reference. This ensures identity checks (``is``) are stable. From 47062c2e309908a74988eafa10c6906ef1535514 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 11:05:44 +0000 Subject: [PATCH 04/12] chore: update mypy version constraints in dev requirements and tox configuration --- pyproject.toml | 5 +++-- requirements_dev.txt | 5 +++-- tox.ini | 1 - 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6d62f06..50a1ecc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,8 +58,9 @@ PayPal = "https://paypal.me/ktusar" dev = [ "pytest>=8.1.2", "pytest-cov>=5.0.0", - "mypy>=1.10.0; platform_python_implementation != \"PyPy\"", - "mypy<1.19; platform_python_implementation == \"PyPy\"", + "mypy<1.11; python_version < \"3.9\"", + "mypy>=1.11; python_version >= \"3.9\" and platform_python_implementation != \"PyPy\"", + "mypy<1.19; python_version >= \"3.9\" and platform_python_implementation == \"PyPy\"", "toml>=0.10.2", "tox", "black", diff --git a/requirements_dev.txt b/requirements_dev.txt index 3948775..7ba02d4 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,7 +1,8 @@ pytest>=8.1.2 pytest-cov>=5.0.0 -mypy>=1.10.0; platform_python_implementation != "PyPy" -mypy<1.19; platform_python_implementation == "PyPy" +mypy<1.11; python_version < "3.9" +mypy>=1.11; python_version >= "3.9" and platform_python_implementation != "PyPy" +mypy<1.19; python_version >= "3.9" and platform_python_implementation == "PyPy" toml>=0.10.2 tox black diff --git a/tox.ini b/tox.ini index 505c661..081a47c 100644 --- a/tox.ini +++ b/tox.ini @@ -100,7 +100,6 @@ commands = basepython = python3 skip_install = true deps = - mypy>=1.15.0 -rrequirements_dev.txt commands = mypy src/qs_codec From f3a69db46be763a882d00c58c921c52e021ef9e7 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 11:22:51 +0000 Subject: [PATCH 05/12] chore: update development dependencies and streamline tox configuration --- pyproject.toml | 20 ++++++++++++++------ requirements_dev.txt | 20 ++++++++++++++------ tox.ini | 16 +++++----------- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 50a1ecc..ec420c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,16 +56,24 @@ PayPal = "https://paypal.me/ktusar" [project.optional-dependencies] dev = [ - "pytest>=8.1.2", - "pytest-cov>=5.0.0", + "bandit", + "black", + "flake8", + "flake8-colors", + "flake8-docstrings", + "flake8-import-order", + "flake8-typing-imports", + "isort", "mypy<1.11; python_version < \"3.9\"", - "mypy>=1.11; python_version >= \"3.9\" and platform_python_implementation != \"PyPy\"", "mypy<1.19; python_version >= \"3.9\" and platform_python_implementation == \"PyPy\"", + "mypy>=1.11; python_version >= \"3.9\" and platform_python_implementation != \"PyPy\"", + "pep8-naming", + "pylint", + "pyright", + "pytest-cov>=5.0.0", + "pytest>=8.1.2", "toml>=0.10.2", "tox", - "black", - "isort", - "pyright" ] [tool.hatch.version] diff --git a/requirements_dev.txt b/requirements_dev.txt index 7ba02d4..b308741 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,10 +1,18 @@ -pytest>=8.1.2 -pytest-cov>=5.0.0 +bandit +black +flake8 +flake8-colors +flake8-docstrings +flake8-import-order +flake8-typing-imports +isort mypy<1.11; python_version < "3.9" -mypy>=1.11; python_version >= "3.9" and platform_python_implementation != "PyPy" mypy<1.19; python_version >= "3.9" and platform_python_implementation == "PyPy" +mypy>=1.11; python_version >= "3.9" and platform_python_implementation != "PyPy" +pep8-naming +pylint +pyright +pytest-cov>=5.0.0 +pytest>=8.1.2 toml>=0.10.2 tox -black -isort -pyright diff --git a/tox.ini b/tox.ini index 081a47c..96848f0 100644 --- a/tox.ini +++ b/tox.ini @@ -42,7 +42,7 @@ commands = basepython = python3 skip_install = true deps = - black + -rrequirements_dev.txt commands = black src/qs_codec tests/ @@ -51,7 +51,7 @@ basepython = python3 skip_install = true profile = black deps = - isort + -rrequirements_dev.txt commands = isort --check-only --diff . @@ -59,19 +59,13 @@ commands = basepython = python3 skip_install = true deps = - flake8 - flake8-colors - flake8-docstrings - flake8-import-order - flake8-typing-imports - pep8-naming + -rrequirements_dev.txt commands = flake8 src/qs_codec [testenv:pylint] basepython = python3 skip_install = true deps = - pylint -rrequirements_dev.txt commands = pylint --rcfile=tox.ini src/qs_codec @@ -92,7 +86,7 @@ basepython = pypy3.11 basepython = python3 skip_install = true deps = - bandit + -rrequirements_dev.txt commands = bandit -r src/qs_codec -c .bandit.yml @@ -108,7 +102,7 @@ commands = basepython = python3 skip_install = true deps = - pyright + -rrequirements_dev.txt commands = pyright src/qs_codec From 711d7c293a459fac79c82e19266098e3a20aebf9 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 11:26:30 +0000 Subject: [PATCH 06/12] test: add parameterized test for filtering non-int keys in sequences --- tests/unit/encode_test.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/unit/encode_test.py b/tests/unit/encode_test.py index 37369b4..d645e91 100644 --- a/tests/unit/encode_test.py +++ b/tests/unit/encode_test.py @@ -876,6 +876,19 @@ def test_selects_properties_when_filter_is_list( ) -> None: assert encode(data, options) == expected + @pytest.mark.parametrize( + "sequence, expected", + [ + pytest.param([1, 2], "a%5B0%5D=1", id="list"), + pytest.param((1, 2), "a%5B0%5D=1", id="tuple"), + ], + ) + def test_filter_list_ignores_non_int_keys_for_sequences(self, sequence: t.Sequence[int], expected: str) -> None: + data = {"a": sequence} + options = EncodeOptions(filter=["a", 0, "x"]) + + assert encode(data, options) == expected + def test_supports_custom_representations_when_filter_is_function(self) -> None: calls = 0 From 7f4f506556ef1df555a048c93dd916f341f9e104 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 11:32:39 +0000 Subject: [PATCH 07/12] refactor: update filter type hint to use Sequence and add test for UserList support --- src/qs_codec/encode.py | 7 ++++--- tests/unit/encode_test.py | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/qs_codec/encode.py b/src/qs_codec/encode.py index ab987e6..2ef1ee7 100644 --- a/src/qs_codec/encode.py +++ b/src/qs_codec/encode.py @@ -14,6 +14,7 @@ """ import typing as t +from collections.abc import Sequence from copy import deepcopy from datetime import datetime from functools import cmp_to_key @@ -76,7 +77,7 @@ def encode(value: t.Any, options: EncodeOptions = EncodeOptions()) -> str: if callable(options.filter): # Callable filter may transform the root object. obj = options.filter("", obj) - elif isinstance(options.filter, (list, tuple)): + elif isinstance(options.filter, Sequence) and not isinstance(options.filter, (str, bytes, bytearray)): obj_keys = list(options.filter) # Single-item list round-trip marker when using comma format. @@ -167,7 +168,7 @@ def _encode( encoder: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]], serialize_date: t.Callable[[datetime], t.Optional[str]], sort: t.Optional[t.Callable[[t.Any, t.Any], int]], - filter: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]], + filter: t.Optional[t.Union[t.Callable, Sequence[t.Union[str, int]]]], formatter: t.Optional[t.Callable[[str], str]], format: Format = Format.RFC3986, generate_array_prefix: t.Callable[[str, t.Optional[str]], str] = ListFormat.INDICES.generator, @@ -315,7 +316,7 @@ def _encode( obj_keys = [{"value": obj_keys_value if obj_keys_value else None}] else: obj_keys = [{"value": UNDEFINED}] - elif isinstance(filter, (list, tuple)): + elif isinstance(filter, Sequence) and not isinstance(filter, (str, bytes, bytearray)): # Iterable filter restricts traversal to a fixed key/index set. obj_keys = list(filter) else: diff --git a/tests/unit/encode_test.py b/tests/unit/encode_test.py index d645e91..aa0a54a 100644 --- a/tests/unit/encode_test.py +++ b/tests/unit/encode_test.py @@ -1,5 +1,6 @@ import math import typing as t +from collections import UserList from contextlib import nullcontext as does_not_raise from datetime import datetime from decimal import Decimal @@ -889,6 +890,12 @@ def test_filter_list_ignores_non_int_keys_for_sequences(self, sequence: t.Sequen assert encode(data, options) == expected + def test_filter_sequence_accepts_non_list_sequence(self) -> None: + data = {"a": [1, 2]} + options = EncodeOptions(filter=UserList(["a", 0, "x"])) + + assert encode(data, options) == "a%5B0%5D=1" + def test_supports_custom_representations_when_filter_is_function(self) -> None: calls = 0 From 77efe7fb4b6f85d0d5f71239374bc76a48110897 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 11:34:10 +0000 Subject: [PATCH 08/12] refactor: update documentation to specify 'sequence' instead of 'list' for key inclusion in EncodeOptions --- src/qs_codec/models/encode_options.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qs_codec/models/encode_options.py b/src/qs_codec/models/encode_options.py index ff3c5da..0424e70 100644 --- a/src/qs_codec/models/encode_options.py +++ b/src/qs_codec/models/encode_options.py @@ -71,7 +71,7 @@ class EncodeOptions: """Restrict which keys get included. - If a callable is provided, it is invoked for each key and should return the replacement value (or `None` to drop when `skip_nulls` applies). - - If a list is provided, only those keys/indices are retained. + - If a sequence is provided, only those keys/indices are retained. """ skip_nulls: bool = False From 2856ff1a25eedc2587ad1fcf02420bc32d1ab05a Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 11:40:23 +0000 Subject: [PATCH 09/12] refactor: update import alias for Sequence and adjust type hints to use ABCSequence --- src/qs_codec/encode.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/qs_codec/encode.py b/src/qs_codec/encode.py index 2ef1ee7..f53baab 100644 --- a/src/qs_codec/encode.py +++ b/src/qs_codec/encode.py @@ -14,7 +14,7 @@ """ import typing as t -from collections.abc import Sequence +from collections.abc import Sequence as ABCSequence from copy import deepcopy from datetime import datetime from functools import cmp_to_key @@ -77,7 +77,7 @@ def encode(value: t.Any, options: EncodeOptions = EncodeOptions()) -> str: if callable(options.filter): # Callable filter may transform the root object. obj = options.filter("", obj) - elif isinstance(options.filter, Sequence) and not isinstance(options.filter, (str, bytes, bytearray)): + elif isinstance(options.filter, ABCSequence) and not isinstance(options.filter, (str, bytes, bytearray)): obj_keys = list(options.filter) # Single-item list round-trip marker when using comma format. @@ -168,7 +168,7 @@ def _encode( encoder: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]], serialize_date: t.Callable[[datetime], t.Optional[str]], sort: t.Optional[t.Callable[[t.Any, t.Any], int]], - filter: t.Optional[t.Union[t.Callable, Sequence[t.Union[str, int]]]], + filter: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]], formatter: t.Optional[t.Callable[[str], str]], format: Format = Format.RFC3986, generate_array_prefix: t.Callable[[str, t.Optional[str]], str] = ListFormat.INDICES.generator, @@ -316,7 +316,7 @@ def _encode( obj_keys = [{"value": obj_keys_value if obj_keys_value else None}] else: obj_keys = [{"value": UNDEFINED}] - elif isinstance(filter, Sequence) and not isinstance(filter, (str, bytes, bytearray)): + elif isinstance(filter, ABCSequence) and not isinstance(filter, (str, bytes, bytearray)): # Iterable filter restricts traversal to a fixed key/index set. obj_keys = list(filter) else: From 976da1e8e9cf98251b4c9c221e9c7e1e8bfecd19 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 11:47:19 +0000 Subject: [PATCH 10/12] test: add fallback tests for decoder with unavailable signatures and handle mapping get exceptions --- tests/unit/decode_options_test.py | 32 +++++++++++++++++++++++++++++++ tests/unit/encode_test.py | 21 ++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/unit/decode_options_test.py b/tests/unit/decode_options_test.py index 7cdbf4d..19f4a97 100644 --- a/tests/unit/decode_options_test.py +++ b/tests/unit/decode_options_test.py @@ -165,6 +165,38 @@ def dec( assert opts.decoder("ok", Charset.UTF8, kind=DecodeKind.VALUE) == "ok" assert seen == ["value"] + def test_builtin_signature_unavailable_single_arg_fallback(self) -> None: + class BadSignature: + __signature__ = "nope" + + 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" + + def test_builtin_signature_unavailable_two_arg_fallback(self) -> None: + class BadSignature: + __signature__ = "nope" + + def __call__(self, 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=BadSignature()) + assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.VALUE) == "x|UTF8" + + def test_builtin_signature_unavailable_raises_original_typeerror(self) -> None: + class BadSignature: + __signature__ = "nope" + + def __call__(self) -> t.Optional[str]: + return "nope" + + opts = DecodeOptions(decoder=BadSignature()) + with pytest.raises(TypeError) as exc_info: + _ = opts.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: opts = DecodeOptions(decoder=math.hypot) # type: ignore[arg-type] diff --git a/tests/unit/encode_test.py b/tests/unit/encode_test.py index aa0a54a..5e2dcde 100644 --- a/tests/unit/encode_test.py +++ b/tests/unit/encode_test.py @@ -919,6 +919,27 @@ def filter_func(prefix: str, value: t.Any) -> t.Any: assert encode(obj, options=EncodeOptions(filter=filter_func)) == "a=b&c=&e%5Bf%5D=1257894000" assert calls == 5 + def test_encode_handles_mapping_get_exception(self) -> None: + class ExplodingMapping(t.Mapping): + def __iter__(self): + return iter(["boom"]) + + def __len__(self) -> int: + return 1 + + def __getitem__(self, key): # type: ignore[no-untyped-def] + raise RuntimeError("boom") + + def get(self, key, default=None): # type: ignore[no-untyped-def] + raise RuntimeError("boom") + + def __deepcopy__(self, memo): # type: ignore[no-untyped-def] + return self + + data = {"a": ExplodingMapping()} + + assert encode(data) == "" + @pytest.mark.parametrize( "data, options, expected", [ From 0ea2f9c2b9f108e63cf72908e29662f93d987ed0 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 11:54:13 +0000 Subject: [PATCH 11/12] refactor: rename filter parameter to filter_ for consistency in encode functions --- src/qs_codec/encode.py | 16 ++++++++-------- tests/unit/encode_test.py | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/qs_codec/encode.py b/src/qs_codec/encode.py index f53baab..6825274 100644 --- a/src/qs_codec/encode.py +++ b/src/qs_codec/encode.py @@ -114,7 +114,7 @@ def encode(value: t.Any, options: EncodeOptions = EncodeOptions()) -> str: encoder=options.encoder if options.encode else None, serialize_date=options.serialize_date, sort=options.sort, - filter=options.filter, + filter_=options.filter, formatter=options.format.formatter, allow_empty_lists=options.allow_empty_lists, strict_null_handling=options.strict_null_handling, @@ -168,7 +168,7 @@ def _encode( encoder: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]], serialize_date: t.Callable[[datetime], t.Optional[str]], sort: t.Optional[t.Callable[[t.Any, t.Any], int]], - filter: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]], + filter_: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]], formatter: t.Optional[t.Callable[[str], str]], format: Format = Format.RFC3986, generate_array_prefix: t.Callable[[str, t.Optional[str]], str] = ListFormat.INDICES.generator, @@ -200,7 +200,7 @@ def _encode( encoder: Custom per-scalar encoder; if None, falls back to `str(value)` for primitives. serialize_date: Optional `datetime` serializer hook. sort: Optional comparator for object/array key ordering. - filter: Callable (transform value) or iterable of keys/indices (select). + filter_: Callable (transform value) or iterable of keys/indices (select). formatter: Percent-escape function chosen by `format` (RFC3986/1738). format: Format enum (only used to choose a default `formatter` if none provided). generate_array_prefix: Strategy used to build array key segments (indices/brackets/repeat/comma). @@ -252,9 +252,9 @@ def _encode( step = 0 # --- Pre-processing: filter & datetime handling --------------------------------------- - if callable(filter): + if callable(filter_): # Callable filter can transform the object for this prefix. - obj = filter(prefix, obj) + obj = filter_(prefix, obj) else: # Normalize datetimes both for scalars and (in COMMA mode) list elements. if isinstance(obj, datetime): @@ -316,9 +316,9 @@ def _encode( obj_keys = [{"value": obj_keys_value if obj_keys_value else None}] else: obj_keys = [{"value": UNDEFINED}] - elif isinstance(filter, ABCSequence) and not isinstance(filter, (str, bytes, bytearray)): + elif isinstance(filter_, ABCSequence) and not isinstance(filter_, (str, bytes, bytearray)): # Iterable filter restricts traversal to a fixed key/index set. - obj_keys = list(filter) + obj_keys = list(filter_) else: # Default: enumerate keys/indices from mappings or sequences. if isinstance(obj, t.Mapping): @@ -408,7 +408,7 @@ def _encode( ), serialize_date=serialize_date, sort=sort, - filter=filter, + filter_=filter_, formatter=formatter, format=format, generate_array_prefix=generate_array_prefix, diff --git a/tests/unit/encode_test.py b/tests/unit/encode_test.py index 5e2dcde..e6796d2 100644 --- a/tests/unit/encode_test.py +++ b/tests/unit/encode_test.py @@ -787,7 +787,7 @@ def test_default_parameter_assignments(self) -> None: encoder=None, serialize_date=lambda dt: dt.isoformat(), sort=None, - filter=None, + filter_=None, formatter=None, # This will trigger line 139 ) @@ -1765,7 +1765,7 @@ def test_encode_cycle_detection_raises_on_same_step(self) -> None: encoder=EncodeUtils.encode, serialize_date=EncodeUtils.serialize_date, sort=None, - filter=None, + filter_=None, formatter=Format.RFC3986.formatter, format=Format.RFC3986, generate_array_prefix=ListFormat.INDICES.generator, @@ -1796,7 +1796,7 @@ def test_encode_cycle_detection_marks_prior_visit_without_raising(self) -> None: encoder=EncodeUtils.encode, serialize_date=EncodeUtils.serialize_date, sort=None, - filter=None, + filter_=None, formatter=Format.RFC3986.formatter, format=Format.RFC3986, generate_array_prefix=ListFormat.INDICES.generator, @@ -1837,7 +1837,7 @@ def fake_is_non_nullish_primitive(val: t.Any, skip_nulls: bool = False) -> bool: encoder=EncodeUtils.encode, serialize_date=EncodeUtils.serialize_date, sort=None, - filter=["foo"], + filter_=["foo"], formatter=Format.RFC3986.formatter, format=Format.RFC3986, generate_array_prefix=ListFormat.INDICES.generator, @@ -1959,7 +1959,7 @@ def test_comma_round_trip_branch_for_non_comma_generator(self) -> None: encoder=EncodeUtils.encode, serialize_date=EncodeUtils.serialize_date, sort=None, - filter=None, + filter_=None, formatter=Format.RFC3986.formatter, format=Format.RFC3986, generate_array_prefix=ListFormat.INDICES.generator, From 5300242dcce84ddcac24b08f0842ef373d6b1c14 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Sat, 31 Jan 2026 11:58:41 +0000 Subject: [PATCH 12/12] refactor: streamline filter handling in encode function for improved readability --- src/qs_codec/encode.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/qs_codec/encode.py b/src/qs_codec/encode.py index 6825274..220e81c 100644 --- a/src/qs_codec/encode.py +++ b/src/qs_codec/encode.py @@ -73,12 +73,13 @@ def encode(value: t.Any, options: EncodeOptions = EncodeOptions()) -> str: # If an iterable filter is provided for the root, restrict emission to those keys. obj_keys: t.Optional[t.List[t.Any]] = None - if options.filter is not None: - if callable(options.filter): + filter_opt = options.filter + if filter_opt is not None: + if callable(filter_opt): # Callable filter may transform the root object. - obj = options.filter("", obj) - elif isinstance(options.filter, ABCSequence) and not isinstance(options.filter, (str, bytes, bytearray)): - obj_keys = list(options.filter) + obj = filter_opt("", obj) + elif isinstance(filter_opt, ABCSequence) and not isinstance(filter_opt, (str, bytes, bytearray)): + obj_keys = list(filter_opt) # Single-item list round-trip marker when using comma format. comma_round_trip: bool = options.list_format == ListFormat.COMMA and options.comma_round_trip is True @@ -252,9 +253,10 @@ def _encode( step = 0 # --- Pre-processing: filter & datetime handling --------------------------------------- - if callable(filter_): + filter_opt = filter_ + if callable(filter_opt): # Callable filter can transform the object for this prefix. - obj = filter_(prefix, obj) + obj = filter_opt(prefix, obj) else: # Normalize datetimes both for scalars and (in COMMA mode) list elements. if isinstance(obj, datetime): @@ -316,9 +318,13 @@ def _encode( obj_keys = [{"value": obj_keys_value if obj_keys_value else None}] else: obj_keys = [{"value": UNDEFINED}] - elif isinstance(filter_, ABCSequence) and not isinstance(filter_, (str, bytes, bytearray)): + elif ( + filter_opt is not None + and isinstance(filter_opt, ABCSequence) + and not isinstance(filter_opt, (str, bytes, bytearray)) + ): # Iterable filter restricts traversal to a fixed key/index set. - obj_keys = list(filter_) + obj_keys = list(filter_opt) else: # Default: enumerate keys/indices from mappings or sequences. if isinstance(obj, t.Mapping):