From b190d37448b176071adfbde4e83b05939d292632 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 16 Oct 2025 21:02:32 +0100 Subject: [PATCH 1/6] :sparkles: add `comma_compact_nulls` option to encode_options.py for `ListFormat.COMMA` --- src/qs_codec/models/encode_options.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/qs_codec/models/encode_options.py b/src/qs_codec/models/encode_options.py index 434683d..962b633 100644 --- a/src/qs_codec/models/encode_options.py +++ b/src/qs_codec/models/encode_options.py @@ -110,6 +110,9 @@ def encoder(self, value: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Op comma_round_trip: t.Optional[bool] = None """Only used with `ListFormat.COMMA`. When `True`, single‑item lists append `[]` so they round‑trip back to a list on decode.""" + comma_compact_nulls: bool = False + """Only with `ListFormat.COMMA`. When `True`, omit `None` entries inside lists instead of emitting empty positions (e.g. `[True, False, None, True]` -> `true,false,true`).""" + sort: t.Optional[t.Callable[[t.Any, t.Any], int]] = field(default=None) """Optional comparator for deterministic key ordering. Must return -1, 0, or +1.""" From 05e5f3da6ce16f53635d1757691c2693240207fe Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 16 Oct 2025 21:02:42 +0100 Subject: [PATCH 2/6] :sparkles: implement `comma_compact_nulls` handling in encode.py for improved list serialization --- src/qs_codec/encode.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/qs_codec/encode.py b/src/qs_codec/encode.py index 711018b..9bd1148 100644 --- a/src/qs_codec/encode.py +++ b/src/qs_codec/encode.py @@ -109,6 +109,7 @@ def encode(value: t.Any, options: EncodeOptions = EncodeOptions()) -> str: prefix=_key, generate_array_prefix=options.list_format.generator, comma_round_trip=comma_round_trip, + comma_compact_nulls=options.list_format == ListFormat.COMMA and options.comma_compact_nulls, encoder=options.encoder if options.encode else None, serialize_date=options.serialize_date, sort=options.sort, @@ -162,6 +163,7 @@ def _encode( side_channel: WeakKeyDictionary, prefix: t.Optional[str], comma_round_trip: t.Optional[bool], + comma_compact_nulls: bool, 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]], @@ -193,6 +195,7 @@ def _encode( side_channel: Cycle-detection chain; child frames point to their parent via `_sentinel`. prefix: The key path accumulated so far (unencoded except for dot-encoding when requested). comma_round_trip: Whether a single-element list should emit `[]` to ensure round-trip with comma format. + comma_compact_nulls: When True (and using comma list format), drop `None` entries before joining. 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. @@ -293,15 +296,22 @@ def _encode( return values # --- Determine which keys/indices to traverse ---------------------------------------- + comma_effective_length: t.Optional[int] = None obj_keys: t.List[t.Any] if generate_array_prefix == ListFormat.COMMA.generator and isinstance(obj, (list, tuple)): # In COMMA mode we join the elements into a single token at this level. + comma_items: t.List[t.Any] = list(obj) + if comma_compact_nulls: + comma_items = [item for item in comma_items if item is not None] + comma_effective_length = len(comma_items) + if encode_values_only and callable(encoder): - obj = Utils.apply(obj, encoder) - obj_keys_value = ",".join(("" if e is None else str(e)) for e in obj) + encoded_items = Utils.apply(comma_items, encoder) + obj_keys_value = ",".join(("" if e is None else str(e)) for e in encoded_items) else: - obj_keys_value = ",".join(Utils.normalize_comma_elem(e) for e in obj) - if obj: + obj_keys_value = ",".join(Utils.normalize_comma_elem(e) for e in comma_items) + + if comma_items: obj_keys = [{"value": obj_keys_value if obj_keys_value else None}] else: obj_keys = [{"value": UNDEFINED}] @@ -322,11 +332,13 @@ def _encode( encoded_prefix: str = prefix.replace(".", "%2E") if encode_dot_in_keys else prefix # In comma round-trip mode, ensure a single-element list appends `[]` to preserve type on decode. - adjusted_prefix: str = ( - f"{encoded_prefix}[]" - if comma_round_trip and isinstance(obj, (list, tuple)) and len(obj) == 1 - else encoded_prefix - ) + single_item_for_round_trip: bool = False + if comma_round_trip and isinstance(obj, (list, tuple)): + if generate_array_prefix == ListFormat.COMMA.generator and comma_effective_length is not None: + single_item_for_round_trip = comma_effective_length == 1 + else: + single_item_for_round_trip = len(obj) == 1 + adjusted_prefix: str = f"{encoded_prefix}[]" if single_item_for_round_trip else encoded_prefix # Optionally emit empty lists as `key[]=`. if allow_empty_lists and isinstance(obj, (list, tuple)) and not obj: @@ -381,6 +393,7 @@ def _encode( side_channel=value_side_channel, prefix=key_prefix, comma_round_trip=comma_round_trip, + comma_compact_nulls=comma_compact_nulls, encoder=( None if generate_array_prefix is ListFormat.COMMA.generator From 7bf9aa5a35f0bc35852aff8484cd078432edfae3 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 16 Oct 2025 21:02:54 +0100 Subject: [PATCH 3/6] :white_check_mark: add tests for `comma_compact_nulls` functionality in encode_test.py --- tests/unit/encode_test.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/encode_test.py b/tests/unit/encode_test.py index 8d5fde2..5a98a83 100644 --- a/tests/unit/encode_test.py +++ b/tests/unit/encode_test.py @@ -782,6 +782,7 @@ def test_default_parameter_assignments(self) -> None: side_channel=WeakKeyDictionary(), prefix=None, # This will trigger line 133 comma_round_trip=None, # This will trigger line 136 + comma_compact_nulls=False, encoder=None, serialize_date=lambda dt: dt.isoformat(), sort=None, @@ -1719,6 +1720,7 @@ def test_encode_cycle_detection_raises_on_same_step(self) -> None: side_channel=side_channel, prefix="root", comma_round_trip=False, + comma_compact_nulls=False, encoder=EncodeUtils.encode, serialize_date=EncodeUtils.serialize_date, sort=None, @@ -1749,6 +1751,7 @@ def test_encode_cycle_detection_marks_prior_visit_without_raising(self) -> None: side_channel=side_channel, prefix="root", comma_round_trip=False, + comma_compact_nulls=False, encoder=EncodeUtils.encode, serialize_date=EncodeUtils.serialize_date, sort=None, @@ -1789,6 +1792,7 @@ def fake_is_non_nullish_primitive(val: t.Any, skip_nulls: bool = False) -> bool: side_channel=WeakKeyDictionary(), prefix="root", comma_round_trip=False, + comma_compact_nulls=False, encoder=EncodeUtils.encode, serialize_date=EncodeUtils.serialize_date, sort=None, @@ -1881,3 +1885,24 @@ def test_encode_serializes_booleans( self, data: t.Mapping[str, t.Any], list_format: ListFormat, expected: str ) -> None: assert encode(data, EncodeOptions(list_format=list_format, encode=False)) == expected + + def test_comma_compact_nulls_skips_none_entries(self) -> None: + options = EncodeOptions( + list_format=ListFormat.COMMA, + encode=False, + comma_compact_nulls=True, + ) + assert encode({"a": {"b": [True, False, None, True]}}, options) == "a[b]=true,false,true" + + def test_comma_compact_nulls_empty_after_filtering_omits_key(self) -> None: + options = EncodeOptions(list_format=ListFormat.COMMA, encode=False, comma_compact_nulls=True) + assert encode({"a": [None, None]}, options) == "" + + def test_comma_compact_nulls_preserves_round_trip_marker(self) -> None: + options = EncodeOptions( + list_format=ListFormat.COMMA, + encode=False, + comma_round_trip=True, + comma_compact_nulls=True, + ) + assert encode({"a": [None, "foo"]}, options) == "a[]=foo" From 99f2e88edc88a2752dd7eeccbf8618e42179db31 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 16 Oct 2025 21:03:06 +0100 Subject: [PATCH 4/6] :bulb: update README to include `comma_compact_nulls` option details --- README.rst | 3 +++ docs/README.rst | 3 +++ 2 files changed, 6 insertions(+) diff --git a/README.rst b/README.rst index 98ab5f4..fb0b2ee 100644 --- a/README.rst +++ b/README.rst @@ -582,6 +582,9 @@ format of the output ``list``: `COMMA `_, you can also pass the `comma_round_trip `__ option set to ``True`` or ``False``, to append ``[]`` on single-item ``list``\ s, so that they can round trip through a decoding. +Set the `comma_compact_nulls `__ option to ``True`` with the same +format when you'd like to drop ``None`` entries instead of keeping empty slots (e.g. ``[True, False, None, True]`` becomes +``true,false,true``). `BRACKETS `__ notation is used for encoding ``dict``\s by default: diff --git a/docs/README.rst b/docs/README.rst index 76999f6..256f092 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -538,6 +538,9 @@ format of the output ``list``: :py:attr:`COMMA `, you can also pass the :py:attr:`comma_round_trip ` option set to ``True`` or ``False``, to append ``[]`` on single-item ``list``\ s so they can round-trip through a decoding. +Set :py:attr:`comma_compact_nulls ` to ``True`` with the same +format when you'd like to drop ``None`` entries instead of keeping empty slots (e.g. +``[True, False, None, True]`` becomes ``true,false,true``). :py:attr:`BRACKETS ` notation is used for encoding ``dict``\s by default: From bf0b94ef8f16531ce82c20560235cd59d2d03255 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 16 Oct 2025 21:08:25 +0100 Subject: [PATCH 5/6] :white_check_mark: add test for non-comma generator handling with `comma_compact_nulls` option --- tests/unit/encode_test.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/unit/encode_test.py b/tests/unit/encode_test.py index 5a98a83..37369b4 100644 --- a/tests/unit/encode_test.py +++ b/tests/unit/encode_test.py @@ -1906,3 +1906,28 @@ def test_comma_compact_nulls_preserves_round_trip_marker(self) -> None: comma_compact_nulls=True, ) assert encode({"a": [None, "foo"]}, options) == "a[]=foo" + + def test_comma_round_trip_branch_for_non_comma_generator(self) -> None: + tokens = _encode( + value=[1], + is_undefined=False, + side_channel=WeakKeyDictionary(), + prefix="root", + comma_round_trip=True, + comma_compact_nulls=False, + encoder=EncodeUtils.encode, + serialize_date=EncodeUtils.serialize_date, + sort=None, + filter=None, + formatter=Format.RFC3986.formatter, + format=Format.RFC3986, + generate_array_prefix=ListFormat.INDICES.generator, + allow_empty_lists=False, + strict_null_handling=False, + skip_nulls=False, + encode_dot_in_keys=False, + allow_dots=False, + encode_values_only=False, + charset=Charset.UTF8, + ) + assert tokens == ["root%5B%5D%5B0%5D=1"] From 228aabd9b95e642bbc3a36ce27b6bded954eb2c8 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 16 Oct 2025 21:13:52 +0100 Subject: [PATCH 6/6] :rotating_light: improve docstring for `comma_compact_nulls` option in encode_options.py --- src/qs_codec/models/encode_options.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/qs_codec/models/encode_options.py b/src/qs_codec/models/encode_options.py index 962b633..3ca1b8c 100644 --- a/src/qs_codec/models/encode_options.py +++ b/src/qs_codec/models/encode_options.py @@ -111,7 +111,8 @@ def encoder(self, value: t.Optional[t.Callable[[t.Any, t.Optional[Charset], t.Op """Only used with `ListFormat.COMMA`. When `True`, single‑item lists append `[]` so they round‑trip back to a list on decode.""" comma_compact_nulls: bool = False - """Only with `ListFormat.COMMA`. When `True`, omit `None` entries inside lists instead of emitting empty positions (e.g. `[True, False, None, True]` -> `true,false,true`).""" + """Only with `ListFormat.COMMA`. When `True`, omit `None` entries inside lists instead of emitting empty positions + (e.g. `[True, False, None, True]` -> `true,false,true`).""" sort: t.Optional[t.Callable[[t.Any, t.Any], int]] = field(default=None) """Optional comparator for deterministic key ordering. Must return -1, 0, or +1."""