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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 20 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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"]
20 changes: 15 additions & 5 deletions requirements_dev.txt
Original file line number Diff line number Diff line change
@@ -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
41 changes: 26 additions & 15 deletions src/qs_codec/encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Comment thread
techouse marked this conversation as resolved.
Comment thread
techouse marked this conversation as resolved.
else:
_value = obj[_key]
_value_undefined = False
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/qs_codec/models/encode_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/qs_codec/models/undefined.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/decode_options_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
51 changes: 46 additions & 5 deletions tests/unit/encode_test.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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:
Comment thread
techouse marked this conversation as resolved.
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

Expand All @@ -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):
Comment thread
techouse marked this conversation as resolved.
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",
[
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading