Skip to content

Commit 06a2a92

Browse files
authored
Merge pull request #31 from techouse/feat/comma_compact_nulls
✨ add `EncodeOptions.comma_compact_nulls`
2 parents 92304f6 + 228aabd commit 06a2a92

5 files changed

Lines changed: 82 additions & 9 deletions

File tree

README.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,9 @@ format of the output ``list``:
582582
`COMMA <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.enums.list_format.ListFormat.COMMA>`_, you can also pass the
583583
`comma_round_trip <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.encode_options.EncodeOptions.comma_round_trip>`__ option set to ``True`` or
584584
``False``, to append ``[]`` on single-item ``list``\ s, so that they can round trip through a decoding.
585+
Set the `comma_compact_nulls <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.encode_options.EncodeOptions.comma_compact_nulls>`__ option to ``True`` with the same
586+
format when you'd like to drop ``None`` entries instead of keeping empty slots (e.g. ``[True, False, None, True]`` becomes
587+
``true,false,true``).
585588

586589
`BRACKETS <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.enums.list_format.ListFormat.BRACKETS>`__ notation is used for encoding ``dict``\s by default:
587590

docs/README.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,9 @@ format of the output ``list``:
538538
:py:attr:`COMMA <qs_codec.enums.list_format.ListFormat.COMMA>`, you can also pass the
539539
:py:attr:`comma_round_trip <qs_codec.models.encode_options.EncodeOptions.comma_round_trip>` option set to ``True`` or
540540
``False``, to append ``[]`` on single-item ``list``\ s so they can round-trip through a decoding.
541+
Set :py:attr:`comma_compact_nulls <qs_codec.models.encode_options.EncodeOptions.comma_compact_nulls>` to ``True`` with the same
542+
format when you'd like to drop ``None`` entries instead of keeping empty slots (e.g.
543+
``[True, False, None, True]`` becomes ``true,false,true``).
541544

542545
:py:attr:`BRACKETS <qs_codec.enums.list_format.ListFormat.BRACKETS>` notation is used for encoding ``dict``\s by default:
543546

src/qs_codec/encode.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def encode(value: t.Any, options: EncodeOptions = EncodeOptions()) -> str:
109109
prefix=_key,
110110
generate_array_prefix=options.list_format.generator,
111111
comma_round_trip=comma_round_trip,
112+
comma_compact_nulls=options.list_format == ListFormat.COMMA and options.comma_compact_nulls,
112113
encoder=options.encoder if options.encode else None,
113114
serialize_date=options.serialize_date,
114115
sort=options.sort,
@@ -162,6 +163,7 @@ def _encode(
162163
side_channel: WeakKeyDictionary,
163164
prefix: t.Optional[str],
164165
comma_round_trip: t.Optional[bool],
166+
comma_compact_nulls: bool,
165167
encoder: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Optional[Format]], str]],
166168
serialize_date: t.Callable[[datetime], t.Optional[str]],
167169
sort: t.Optional[t.Callable[[t.Any, t.Any], int]],
@@ -193,6 +195,7 @@ def _encode(
193195
side_channel: Cycle-detection chain; child frames point to their parent via `_sentinel`.
194196
prefix: The key path accumulated so far (unencoded except for dot-encoding when requested).
195197
comma_round_trip: Whether a single-element list should emit `[]` to ensure round-trip with comma format.
198+
comma_compact_nulls: When True (and using comma list format), drop `None` entries before joining.
196199
encoder: Custom per-scalar encoder; if None, falls back to `str(value)` for primitives.
197200
serialize_date: Optional `datetime` serializer hook.
198201
sort: Optional comparator for object/array key ordering.
@@ -293,15 +296,22 @@ def _encode(
293296
return values
294297

295298
# --- Determine which keys/indices to traverse ----------------------------------------
299+
comma_effective_length: t.Optional[int] = None
296300
obj_keys: t.List[t.Any]
297301
if generate_array_prefix == ListFormat.COMMA.generator and isinstance(obj, (list, tuple)):
298302
# In COMMA mode we join the elements into a single token at this level.
303+
comma_items: t.List[t.Any] = list(obj)
304+
if comma_compact_nulls:
305+
comma_items = [item for item in comma_items if item is not None]
306+
comma_effective_length = len(comma_items)
307+
299308
if encode_values_only and callable(encoder):
300-
obj = Utils.apply(obj, encoder)
301-
obj_keys_value = ",".join(("" if e is None else str(e)) for e in obj)
309+
encoded_items = Utils.apply(comma_items, encoder)
310+
obj_keys_value = ",".join(("" if e is None else str(e)) for e in encoded_items)
302311
else:
303-
obj_keys_value = ",".join(Utils.normalize_comma_elem(e) for e in obj)
304-
if obj:
312+
obj_keys_value = ",".join(Utils.normalize_comma_elem(e) for e in comma_items)
313+
314+
if comma_items:
305315
obj_keys = [{"value": obj_keys_value if obj_keys_value else None}]
306316
else:
307317
obj_keys = [{"value": UNDEFINED}]
@@ -322,11 +332,13 @@ def _encode(
322332
encoded_prefix: str = prefix.replace(".", "%2E") if encode_dot_in_keys else prefix
323333

324334
# In comma round-trip mode, ensure a single-element list appends `[]` to preserve type on decode.
325-
adjusted_prefix: str = (
326-
f"{encoded_prefix}[]"
327-
if comma_round_trip and isinstance(obj, (list, tuple)) and len(obj) == 1
328-
else encoded_prefix
329-
)
335+
single_item_for_round_trip: bool = False
336+
if comma_round_trip and isinstance(obj, (list, tuple)):
337+
if generate_array_prefix == ListFormat.COMMA.generator and comma_effective_length is not None:
338+
single_item_for_round_trip = comma_effective_length == 1
339+
else:
340+
single_item_for_round_trip = len(obj) == 1
341+
adjusted_prefix: str = f"{encoded_prefix}[]" if single_item_for_round_trip else encoded_prefix
330342

331343
# Optionally emit empty lists as `key[]=`.
332344
if allow_empty_lists and isinstance(obj, (list, tuple)) and not obj:
@@ -381,6 +393,7 @@ def _encode(
381393
side_channel=value_side_channel,
382394
prefix=key_prefix,
383395
comma_round_trip=comma_round_trip,
396+
comma_compact_nulls=comma_compact_nulls,
384397
encoder=(
385398
None
386399
if generate_array_prefix is ListFormat.COMMA.generator

src/qs_codec/models/encode_options.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ def encoder(self, value: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Op
110110
comma_round_trip: t.Optional[bool] = None
111111
"""Only used with `ListFormat.COMMA`. When `True`, single‑item lists append `[]` so they round‑trip back to a list on decode."""
112112

113+
comma_compact_nulls: bool = False
114+
"""Only with `ListFormat.COMMA`. When `True`, omit `None` entries inside lists instead of emitting empty positions
115+
(e.g. `[True, False, None, True]` -> `true,false,true`)."""
116+
113117
sort: t.Optional[t.Callable[[t.Any, t.Any], int]] = field(default=None)
114118
"""Optional comparator for deterministic key ordering. Must return -1, 0, or +1."""
115119

tests/unit/encode_test.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,7 @@ def test_default_parameter_assignments(self) -> None:
782782
side_channel=WeakKeyDictionary(),
783783
prefix=None, # This will trigger line 133
784784
comma_round_trip=None, # This will trigger line 136
785+
comma_compact_nulls=False,
785786
encoder=None,
786787
serialize_date=lambda dt: dt.isoformat(),
787788
sort=None,
@@ -1719,6 +1720,7 @@ def test_encode_cycle_detection_raises_on_same_step(self) -> None:
17191720
side_channel=side_channel,
17201721
prefix="root",
17211722
comma_round_trip=False,
1723+
comma_compact_nulls=False,
17221724
encoder=EncodeUtils.encode,
17231725
serialize_date=EncodeUtils.serialize_date,
17241726
sort=None,
@@ -1749,6 +1751,7 @@ def test_encode_cycle_detection_marks_prior_visit_without_raising(self) -> None:
17491751
side_channel=side_channel,
17501752
prefix="root",
17511753
comma_round_trip=False,
1754+
comma_compact_nulls=False,
17521755
encoder=EncodeUtils.encode,
17531756
serialize_date=EncodeUtils.serialize_date,
17541757
sort=None,
@@ -1789,6 +1792,7 @@ def fake_is_non_nullish_primitive(val: t.Any, skip_nulls: bool = False) -> bool:
17891792
side_channel=WeakKeyDictionary(),
17901793
prefix="root",
17911794
comma_round_trip=False,
1795+
comma_compact_nulls=False,
17921796
encoder=EncodeUtils.encode,
17931797
serialize_date=EncodeUtils.serialize_date,
17941798
sort=None,
@@ -1881,3 +1885,49 @@ def test_encode_serializes_booleans(
18811885
self, data: t.Mapping[str, t.Any], list_format: ListFormat, expected: str
18821886
) -> None:
18831887
assert encode(data, EncodeOptions(list_format=list_format, encode=False)) == expected
1888+
1889+
def test_comma_compact_nulls_skips_none_entries(self) -> None:
1890+
options = EncodeOptions(
1891+
list_format=ListFormat.COMMA,
1892+
encode=False,
1893+
comma_compact_nulls=True,
1894+
)
1895+
assert encode({"a": {"b": [True, False, None, True]}}, options) == "a[b]=true,false,true"
1896+
1897+
def test_comma_compact_nulls_empty_after_filtering_omits_key(self) -> None:
1898+
options = EncodeOptions(list_format=ListFormat.COMMA, encode=False, comma_compact_nulls=True)
1899+
assert encode({"a": [None, None]}, options) == ""
1900+
1901+
def test_comma_compact_nulls_preserves_round_trip_marker(self) -> None:
1902+
options = EncodeOptions(
1903+
list_format=ListFormat.COMMA,
1904+
encode=False,
1905+
comma_round_trip=True,
1906+
comma_compact_nulls=True,
1907+
)
1908+
assert encode({"a": [None, "foo"]}, options) == "a[]=foo"
1909+
1910+
def test_comma_round_trip_branch_for_non_comma_generator(self) -> None:
1911+
tokens = _encode(
1912+
value=[1],
1913+
is_undefined=False,
1914+
side_channel=WeakKeyDictionary(),
1915+
prefix="root",
1916+
comma_round_trip=True,
1917+
comma_compact_nulls=False,
1918+
encoder=EncodeUtils.encode,
1919+
serialize_date=EncodeUtils.serialize_date,
1920+
sort=None,
1921+
filter=None,
1922+
formatter=Format.RFC3986.formatter,
1923+
format=Format.RFC3986,
1924+
generate_array_prefix=ListFormat.INDICES.generator,
1925+
allow_empty_lists=False,
1926+
strict_null_handling=False,
1927+
skip_nulls=False,
1928+
encode_dot_in_keys=False,
1929+
allow_dots=False,
1930+
encode_values_only=False,
1931+
charset=Charset.UTF8,
1932+
)
1933+
assert tokens == ["root%5B%5D%5B0%5D=1"]

0 commit comments

Comments
 (0)