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
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,9 @@ format of the output ``list``:
`COMMA <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.enums.list_format.ListFormat.COMMA>`_, you can also pass the
`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
``False``, to append ``[]`` on single-item ``list``\ s, so that they can round trip through a decoding.
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
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 <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:

Expand Down
3 changes: 3 additions & 0 deletions docs/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,9 @@ format of the output ``list``:
:py:attr:`COMMA <qs_codec.enums.list_format.ListFormat.COMMA>`, you can also pass the
:py:attr:`comma_round_trip <qs_codec.models.encode_options.EncodeOptions.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 <qs_codec.models.encode_options.EncodeOptions.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 <qs_codec.enums.list_format.ListFormat.BRACKETS>` notation is used for encoding ``dict``\s by default:

Expand Down
31 changes: 22 additions & 9 deletions src/qs_codec/encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]],
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}]
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/qs_codec/models/encode_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ 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."""

Expand Down
50 changes: 50 additions & 0 deletions tests/unit/encode_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1881,3 +1885,49 @@ 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"

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"]