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. diff --git a/pyproject.toml b/pyproject.toml index 3df158c..ec420c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,14 +56,24 @@ PayPal = "https://paypal.me/ktusar" [project.optional-dependencies] dev = [ - "pytest>=8.1.2", + "bandit", + "black", + "flake8", + "flake8-colors", + "flake8-docstrings", + "flake8-import-order", + "flake8-typing-imports", + "isort", + "mypy<1.11; python_version < \"3.9\"", + "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", - "mypy>=1.10.0; platform_python_implementation != \"PyPy\"", - "mypy<1.19; platform_python_implementation == \"PyPy\"", + "pytest>=8.1.2", "toml>=0.10.2", "tox", - "black", - "isort" ] [tool.hatch.version] @@ -136,3 +146,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..b308741 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,8 +1,18 @@ -pytest>=8.1.2 +bandit +black +flake8 +flake8-colors +flake8-docstrings +flake8-import-order +flake8-typing-imports +isort +mypy<1.11; python_version < "3.9" +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 -mypy>=1.10.0; platform_python_implementation != "PyPy" -mypy<1.19; platform_python_implementation == "PyPy" +pytest>=8.1.2 toml>=0.10.2 tox -black -isort diff --git a/src/qs_codec/encode.py b/src/qs_codec/encode.py index 9bd1148..220e81c 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 as ABCSequence from copy import deepcopy from datetime import datetime from functools import cmp_to_key @@ -72,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, (list, tuple)): - 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 @@ -113,7 +115,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, @@ -167,7 +169,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, @@ -199,7 +201,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). @@ -251,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): @@ -315,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, (list, tuple)): + 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): @@ -358,8 +365,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 @@ -403,7 +414,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/src/qs_codec/models/encode_options.py b/src/qs_codec/models/encode_options.py index 3ca1b8c..0424e70 100644 --- a/src/qs_codec/models/encode_options.py +++ b/src/qs_codec/models/encode_options.py @@ -67,11 +67,11 @@ 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). - - 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 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. 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 37369b4..e6796d2 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 @@ -786,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 ) @@ -876,6 +877,25 @@ 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_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 @@ -899,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", [ @@ -1724,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, @@ -1755,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, @@ -1796,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, @@ -1918,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, diff --git a/tox.ini b/tox.ini index 74d63b6..96848f0 100644 --- a/tox.ini +++ b/tox.ini @@ -14,6 +14,7 @@ envlist = pypy3.11, black, flake8, + pyright, linters, skip_missing_interpreters = true @@ -41,7 +42,7 @@ commands = basepython = python3 skip_install = true deps = - black + -rrequirements_dev.txt commands = black src/qs_codec tests/ @@ -50,7 +51,7 @@ basepython = python3 skip_install = true profile = black deps = - isort + -rrequirements_dev.txt commands = isort --check-only --diff . @@ -58,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 @@ -91,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 @@ -99,11 +94,18 @@ commands = basepython = python3 skip_install = true deps = - mypy>=1.15.0 -rrequirements_dev.txt commands = mypy src/qs_codec +[testenv:pyright] +basepython = python3 +skip_install = true +deps = + -rrequirements_dev.txt +commands = + pyright src/qs_codec + [testenv:linters] basepython = python3 skip_install = true @@ -114,6 +116,7 @@ deps = {[testenv:pylint]deps} {[testenv:bandit]deps} {[testenv:mypy]deps} + {[testenv:pyright]deps} commands = {[testenv:black]commands} {[testenv:isort]commands} @@ -121,6 +124,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