Skip to content

Commit 7091425

Browse files
authored
🚨 add Pyright static type checking (#38)
1 parent a718ae2 commit 7091425

File tree

9 files changed

+158
-45
lines changed

9 files changed

+158
-45
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Concise, project-specific guidance for AI coding agents working on this repo. Fo
2929
## 4. Developer Workflow
3030
- Install (dev): `python -m pip install -e .[dev]`.
3131
- Run full test suite: `pytest -v --cov=src/qs_codec` (coverage enforced in CI).
32-
- Lint/type check: `tox -e linters` (chains Black, isort, flake8, pylint, mypy, bandit).
32+
- Lint/type check: `tox -e linters` (chains Black, isort, flake8, pylint, mypy, pyright, bandit).
3333
- Multi-version tests: `tox -e python3.13` (swap env name for other versions).
3434
- Docs build: `make -C docs html` (update Sphinx when public behavior or options change).
3535
- 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.

pyproject.toml

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,24 @@ PayPal = "https://paypal.me/ktusar"
5656

5757
[project.optional-dependencies]
5858
dev = [
59-
"pytest>=8.1.2",
59+
"bandit",
60+
"black",
61+
"flake8",
62+
"flake8-colors",
63+
"flake8-docstrings",
64+
"flake8-import-order",
65+
"flake8-typing-imports",
66+
"isort",
67+
"mypy<1.11; python_version < \"3.9\"",
68+
"mypy<1.19; python_version >= \"3.9\" and platform_python_implementation == \"PyPy\"",
69+
"mypy>=1.11; python_version >= \"3.9\" and platform_python_implementation != \"PyPy\"",
70+
"pep8-naming",
71+
"pylint",
72+
"pyright",
6073
"pytest-cov>=5.0.0",
61-
"mypy>=1.10.0; platform_python_implementation != \"PyPy\"",
62-
"mypy<1.19; platform_python_implementation == \"PyPy\"",
74+
"pytest>=8.1.2",
6375
"toml>=0.10.2",
6476
"tox",
65-
"black",
66-
"isort"
6777
]
6878

6979
[tool.hatch.version]
@@ -136,3 +146,8 @@ exclude = [
136146
show_error_codes = true
137147
warn_return_any = true
138148
warn_unused_configs = true
149+
150+
[tool.pyright]
151+
pythonVersion = "3.8"
152+
include = ["src/qs_codec"]
153+
exclude = ["tests", "docs", "build", "dist", "venv", "env", ".tox"]

requirements_dev.txt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1-
pytest>=8.1.2
1+
bandit
2+
black
3+
flake8
4+
flake8-colors
5+
flake8-docstrings
6+
flake8-import-order
7+
flake8-typing-imports
8+
isort
9+
mypy<1.11; python_version < "3.9"
10+
mypy<1.19; python_version >= "3.9" and platform_python_implementation == "PyPy"
11+
mypy>=1.11; python_version >= "3.9" and platform_python_implementation != "PyPy"
12+
pep8-naming
13+
pylint
14+
pyright
215
pytest-cov>=5.0.0
3-
mypy>=1.10.0; platform_python_implementation != "PyPy"
4-
mypy<1.19; platform_python_implementation == "PyPy"
16+
pytest>=8.1.2
517
toml>=0.10.2
618
tox
7-
black
8-
isort

src/qs_codec/encode.py

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""
1515

1616
import typing as t
17+
from collections.abc import Sequence as ABCSequence
1718
from copy import deepcopy
1819
from datetime import datetime
1920
from functools import cmp_to_key
@@ -72,12 +73,13 @@ def encode(value: t.Any, options: EncodeOptions = EncodeOptions()) -> str:
7273

7374
# If an iterable filter is provided for the root, restrict emission to those keys.
7475
obj_keys: t.Optional[t.List[t.Any]] = None
75-
if options.filter is not None:
76-
if callable(options.filter):
76+
filter_opt = options.filter
77+
if filter_opt is not None:
78+
if callable(filter_opt):
7779
# Callable filter may transform the root object.
78-
obj = options.filter("", obj)
79-
elif isinstance(options.filter, (list, tuple)):
80-
obj_keys = list(options.filter)
80+
obj = filter_opt("", obj)
81+
elif isinstance(filter_opt, ABCSequence) and not isinstance(filter_opt, (str, bytes, bytearray)):
82+
obj_keys = list(filter_opt)
8183

8284
# Single-item list round-trip marker when using comma format.
8385
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:
113115
encoder=options.encoder if options.encode else None,
114116
serialize_date=options.serialize_date,
115117
sort=options.sort,
116-
filter=options.filter,
118+
filter_=options.filter,
117119
formatter=options.format.formatter,
118120
allow_empty_lists=options.allow_empty_lists,
119121
strict_null_handling=options.strict_null_handling,
@@ -167,7 +169,7 @@ def _encode(
167169
encoder: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]],
168170
serialize_date: t.Callable[[datetime], t.Optional[str]],
169171
sort: t.Optional[t.Callable[[t.Any, t.Any], int]],
170-
filter: t.Optional[t.Union[t.Callable, t.List[t.Union[str, int]]]],
172+
filter_: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]],
171173
formatter: t.Optional[t.Callable[[str], str]],
172174
format: Format = Format.RFC3986,
173175
generate_array_prefix: t.Callable[[str, t.Optional[str]], str] = ListFormat.INDICES.generator,
@@ -199,7 +201,7 @@ def _encode(
199201
encoder: Custom per-scalar encoder; if None, falls back to `str(value)` for primitives.
200202
serialize_date: Optional `datetime` serializer hook.
201203
sort: Optional comparator for object/array key ordering.
202-
filter: Callable (transform value) or iterable of keys/indices (select).
204+
filter_: Callable (transform value) or iterable of keys/indices (select).
203205
formatter: Percent-escape function chosen by `format` (RFC3986/1738).
204206
format: Format enum (only used to choose a default `formatter` if none provided).
205207
generate_array_prefix: Strategy used to build array key segments (indices/brackets/repeat/comma).
@@ -251,9 +253,10 @@ def _encode(
251253
step = 0
252254

253255
# --- Pre-processing: filter & datetime handling ---------------------------------------
254-
if callable(filter):
256+
filter_opt = filter_
257+
if callable(filter_opt):
255258
# Callable filter can transform the object for this prefix.
256-
obj = filter(prefix, obj)
259+
obj = filter_opt(prefix, obj)
257260
else:
258261
# Normalize datetimes both for scalars and (in COMMA mode) list elements.
259262
if isinstance(obj, datetime):
@@ -315,9 +318,13 @@ def _encode(
315318
obj_keys = [{"value": obj_keys_value if obj_keys_value else None}]
316319
else:
317320
obj_keys = [{"value": UNDEFINED}]
318-
elif isinstance(filter, (list, tuple)):
321+
elif (
322+
filter_opt is not None
323+
and isinstance(filter_opt, ABCSequence)
324+
and not isinstance(filter_opt, (str, bytes, bytearray))
325+
):
319326
# Iterable filter restricts traversal to a fixed key/index set.
320-
obj_keys = list(filter)
327+
obj_keys = list(filter_opt)
321328
else:
322329
# Default: enumerate keys/indices from mappings or sequences.
323330
if isinstance(obj, t.Mapping):
@@ -358,8 +365,12 @@ def _encode(
358365
_value = obj.get(_key)
359366
_value_undefined = _key not in obj
360367
elif isinstance(obj, (list, tuple)):
361-
_value = obj[_key]
362-
_value_undefined = False
368+
if isinstance(_key, int):
369+
_value = obj[_key]
370+
_value_undefined = False
371+
else:
372+
_value = None
373+
_value_undefined = True
363374
else:
364375
_value = obj[_key]
365376
_value_undefined = False
@@ -403,7 +414,7 @@ def _encode(
403414
),
404415
serialize_date=serialize_date,
405416
sort=sort,
406-
filter=filter,
417+
filter_=filter_,
407418
formatter=formatter,
408419
format=format,
409420
generate_array_prefix=generate_array_prefix,

src/qs_codec/models/encode_options.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,11 @@ class EncodeOptions:
6767
"""Space handling and percent‑encoding style. `RFC3986` encodes spaces as `%20`, while
6868
`RFC1738` uses `+`."""
6969

70-
filter: t.Optional[t.Union[t.Callable, t.List[t.Union[str, int]]]] = field(default=None)
70+
filter: t.Optional[t.Union[t.Callable, t.Sequence[t.Union[str, int]]]] = field(default=None)
7171
"""Restrict which keys get included.
7272
- If a callable is provided, it is invoked for each key and should return the
7373
replacement value (or `None` to drop when `skip_nulls` applies).
74-
- If a list is provided, only those keys/indices are retained.
74+
- If a sequence is provided, only those keys/indices are retained.
7575
"""
7676

7777
skip_nulls: bool = False

src/qs_codec/models/undefined.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class Undefined:
3737
_lock: t.ClassVar[threading.Lock] = threading.Lock()
3838
_instance: t.ClassVar[t.Optional["Undefined"]] = None
3939

40-
def __new__(cls: t.Type["Undefined"]) -> "Undefined":
40+
def __new__(cls):
4141
"""Return the singleton instance.
4242
4343
Creating `Undefined()` multiple times always returns the same object reference. This ensures identity checks (``is``) are stable.

tests/unit/decode_options_test.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,38 @@ def dec(
165165
assert opts.decoder("ok", Charset.UTF8, kind=DecodeKind.VALUE) == "ok"
166166
assert seen == ["value"]
167167

168+
def test_builtin_signature_unavailable_single_arg_fallback(self) -> None:
169+
class BadSignature:
170+
__signature__ = "nope"
171+
172+
def __call__(self, s: t.Optional[str]) -> t.Optional[str]:
173+
return None if s is None else f"{s}-ok"
174+
175+
opts = DecodeOptions(decoder=BadSignature())
176+
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY) == "x-ok"
177+
178+
def test_builtin_signature_unavailable_two_arg_fallback(self) -> None:
179+
class BadSignature:
180+
__signature__ = "nope"
181+
182+
def __call__(self, s: t.Optional[str], charset: t.Optional[Charset]) -> t.Optional[str]:
183+
return None if s is None else f"{s}|{charset.name if charset else 'NONE'}"
184+
185+
opts = DecodeOptions(decoder=BadSignature())
186+
assert opts.decoder("x", Charset.UTF8, kind=DecodeKind.VALUE) == "x|UTF8"
187+
188+
def test_builtin_signature_unavailable_raises_original_typeerror(self) -> None:
189+
class BadSignature:
190+
__signature__ = "nope"
191+
192+
def __call__(self) -> t.Optional[str]:
193+
return "nope"
194+
195+
opts = DecodeOptions(decoder=BadSignature())
196+
with pytest.raises(TypeError) as exc_info:
197+
_ = opts.decoder("x", Charset.UTF8, kind=DecodeKind.KEY)
198+
assert exc_info.value.__cause__ is not None
199+
168200
def test_builtin_without_signature_raises_original_typeerror(self) -> None:
169201
opts = DecodeOptions(decoder=math.hypot) # type: ignore[arg-type]
170202

tests/unit/encode_test.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import math
22
import typing as t
3+
from collections import UserList
34
from contextlib import nullcontext as does_not_raise
45
from datetime import datetime
56
from decimal import Decimal
@@ -786,7 +787,7 @@ def test_default_parameter_assignments(self) -> None:
786787
encoder=None,
787788
serialize_date=lambda dt: dt.isoformat(),
788789
sort=None,
789-
filter=None,
790+
filter_=None,
790791
formatter=None, # This will trigger line 139
791792
)
792793

@@ -876,6 +877,25 @@ def test_selects_properties_when_filter_is_list(
876877
) -> None:
877878
assert encode(data, options) == expected
878879

880+
@pytest.mark.parametrize(
881+
"sequence, expected",
882+
[
883+
pytest.param([1, 2], "a%5B0%5D=1", id="list"),
884+
pytest.param((1, 2), "a%5B0%5D=1", id="tuple"),
885+
],
886+
)
887+
def test_filter_list_ignores_non_int_keys_for_sequences(self, sequence: t.Sequence[int], expected: str) -> None:
888+
data = {"a": sequence}
889+
options = EncodeOptions(filter=["a", 0, "x"])
890+
891+
assert encode(data, options) == expected
892+
893+
def test_filter_sequence_accepts_non_list_sequence(self) -> None:
894+
data = {"a": [1, 2]}
895+
options = EncodeOptions(filter=UserList(["a", 0, "x"]))
896+
897+
assert encode(data, options) == "a%5B0%5D=1"
898+
879899
def test_supports_custom_representations_when_filter_is_function(self) -> None:
880900
calls = 0
881901

@@ -899,6 +919,27 @@ def filter_func(prefix: str, value: t.Any) -> t.Any:
899919
assert encode(obj, options=EncodeOptions(filter=filter_func)) == "a=b&c=&e%5Bf%5D=1257894000"
900920
assert calls == 5
901921

922+
def test_encode_handles_mapping_get_exception(self) -> None:
923+
class ExplodingMapping(t.Mapping):
924+
def __iter__(self):
925+
return iter(["boom"])
926+
927+
def __len__(self) -> int:
928+
return 1
929+
930+
def __getitem__(self, key): # type: ignore[no-untyped-def]
931+
raise RuntimeError("boom")
932+
933+
def get(self, key, default=None): # type: ignore[no-untyped-def]
934+
raise RuntimeError("boom")
935+
936+
def __deepcopy__(self, memo): # type: ignore[no-untyped-def]
937+
return self
938+
939+
data = {"a": ExplodingMapping()}
940+
941+
assert encode(data) == ""
942+
902943
@pytest.mark.parametrize(
903944
"data, options, expected",
904945
[
@@ -1724,7 +1765,7 @@ def test_encode_cycle_detection_raises_on_same_step(self) -> None:
17241765
encoder=EncodeUtils.encode,
17251766
serialize_date=EncodeUtils.serialize_date,
17261767
sort=None,
1727-
filter=None,
1768+
filter_=None,
17281769
formatter=Format.RFC3986.formatter,
17291770
format=Format.RFC3986,
17301771
generate_array_prefix=ListFormat.INDICES.generator,
@@ -1755,7 +1796,7 @@ def test_encode_cycle_detection_marks_prior_visit_without_raising(self) -> None:
17551796
encoder=EncodeUtils.encode,
17561797
serialize_date=EncodeUtils.serialize_date,
17571798
sort=None,
1758-
filter=None,
1799+
filter_=None,
17591800
formatter=Format.RFC3986.formatter,
17601801
format=Format.RFC3986,
17611802
generate_array_prefix=ListFormat.INDICES.generator,
@@ -1796,7 +1837,7 @@ def fake_is_non_nullish_primitive(val: t.Any, skip_nulls: bool = False) -> bool:
17961837
encoder=EncodeUtils.encode,
17971838
serialize_date=EncodeUtils.serialize_date,
17981839
sort=None,
1799-
filter=["foo"],
1840+
filter_=["foo"],
18001841
formatter=Format.RFC3986.formatter,
18011842
format=Format.RFC3986,
18021843
generate_array_prefix=ListFormat.INDICES.generator,
@@ -1918,7 +1959,7 @@ def test_comma_round_trip_branch_for_non_comma_generator(self) -> None:
19181959
encoder=EncodeUtils.encode,
19191960
serialize_date=EncodeUtils.serialize_date,
19201961
sort=None,
1921-
filter=None,
1962+
filter_=None,
19221963
formatter=Format.RFC3986.formatter,
19231964
format=Format.RFC3986,
19241965
generate_array_prefix=ListFormat.INDICES.generator,

0 commit comments

Comments
 (0)