diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fd901c3..e7da55a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -85,7 +85,11 @@ jobs: - name: Install dependencies run: | pip install -U codecov tox-gh-actions - pip install -r requirements_dev.txt + if [[ "${{ matrix.py }}" == "3.13t" ]]; then + pip install -r requirements_dev.txt 'mypy<2' + else + pip install -r requirements_dev.txt + fi - name: Test with tox run: tox - name: Check Code Coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index b944697..45c2497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +* [FIX] match Node `qs` 6.16.0 for dotted root keys and dates returned by callable encoding filters +* [FIX] allow `max_depth=0` for root scalar encoding while rejecting nested children +* [FIX] enforce raising comma-group limits inside bracket assignments and spread overflow duplicate values one level + ## 1.6.1 * [FIX] match Node `qs` 6.15.3 cumulative list-limit enforcement across duplicate-key combinations and mixed list merges diff --git a/README.rst b/README.rst index 4d96cf8..abf967f 100644 --- a/README.rst +++ b/README.rst @@ -536,8 +536,13 @@ default, or raises ``ValueError`` when ``raise_on_limit_exceeded=True``. ) == {'a': {'0': 'x', '1': 'y'}} With ``comma=True``, a flat comma value is subject to the same limit. A value -assigned through ``[]=`` counts as one outer list element, so its inner -comma-separated group may contain more values than ``list_limit``. +assigned through ``[]=`` counts as one outer list element. Without raising, +its inner comma-separated group may exceed ``list_limit`` and stays nested; +with ``raise_on_limit_exceeded=True``, each oversized inner group raises too. +When duplicate comma values extend an already-overflowed numeric-keyed mapping, +an incoming list or tuple spreads into successive numeric keys. If a later comma +group also exceeds ``list_limit``, it becomes an overflow mapping stored under +one key instead. Bracketed comma groups remain one nested value apiece. To disable ``list`` parsing entirely, set `parse_lists `__ to ``False``. @@ -642,6 +647,13 @@ option. If unset, traversal is unbounded by this option. When set, the provided except ValueError as e: assert str(e) == 'Maximum encoding depth exceeded' +``max_depth=0`` permits root scalar values but rejects any nested child: + +.. code:: python + + assert qs.encode({'a': 'b'}, qs.EncodeOptions(max_depth=0)) == 'a=b' + + This encoding can also be replaced by a custom ``Callable`` in the `encoder `__ option: @@ -814,6 +826,11 @@ You may encode dots in keys of ``dict``\s by setting ), ) == 'name%252Eobj.first=John&name%252Eobj.last=Doe' + assert qs.encode( + {'a.b': 'x'}, + qs.EncodeOptions(allow_dots=True, encode_dot_in_keys=True), + ) == 'a%252Eb=x' + **Caveat:** When both `encode_values_only `__ and `encode_dot_in_keys `__ are set to ``True``, only dots in keys and nothing else will be encoded! @@ -922,6 +939,10 @@ objects, you can provide a ``Callable`` in the == "a=7" ) +The callable ``filter`` runs before date serialization. A date retained or +returned by the filter still passes through ``serialize_date``. + + To affect the order of parameter keys, you can set a ``Callable`` in the `sort `__ option: diff --git a/docs/README.rst b/docs/README.rst index 1dcae6c..58c9529 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -443,8 +443,13 @@ default, or raises ``ValueError`` when ) == {'a': {'0': 'x', '1': 'y'}} With ``comma=True``, a flat comma value is subject to the same limit. A value -assigned through ``[]=`` counts as one outer list element, so its inner -comma-separated group may contain more values than ``list_limit``. +assigned through ``[]=`` counts as one outer list element. Without raising, +its inner comma-separated group may exceed ``list_limit`` and stays nested; +with ``raise_on_limit_exceeded=True``, each oversized inner group raises too. +When duplicate comma values extend an already-overflowed numeric-keyed mapping, +an incoming list or tuple spreads into successive numeric keys. If a later comma +group also exceeds ``list_limit``, it becomes an overflow mapping stored under +one key instead. Bracketed comma groups remain one nested value apiece. To disable ``list`` parsing entirely, set :py:attr:`parse_lists ` to ``False``. @@ -549,6 +554,13 @@ If unset, traversal is unbounded by this option. When set, the provided limit is except ValueError as e: assert str(e) == 'Maximum encoding depth exceeded' +``max_depth=0`` permits root scalar values but rejects any nested child: + +.. code:: python + + assert qs.encode({'a': 'b'}, qs.EncodeOptions(max_depth=0)) == 'a=b' + + This encoding can also be replaced by a custom ``Callable`` in the :py:attr:`encoder ` option: @@ -720,6 +732,11 @@ You may encode dots in keys of ``dict``\s by setting ), ) == 'name%252Eobj.first=John&name%252Eobj.last=Doe' + assert qs.encode( + {'a.b': 'x'}, + qs.EncodeOptions(allow_dots=True, encode_dot_in_keys=True), + ) == 'a%252Eb=x' + **Caveat:** When both :py:attr:`encode_values_only ` and :py:attr:`encode_dot_in_keys ` are set to ``True``, only dots in keys and nothing else will be encoded! @@ -828,6 +845,10 @@ objects, you can provide a ``Callable`` in the == "a=7" ) +The callable ``filter`` runs before date serialization. A date retained or +returned by the filter still passes through ``serialize_date``. + + To affect the order of parameter keys, you can set a ``Callable`` in the :py:attr:`sort ` option: diff --git a/src/qs_codec/decode.py b/src/qs_codec/decode.py index df73d23..7dee88f 100644 --- a/src/qs_codec/decode.py +++ b/src/qs_codec/decode.py @@ -227,15 +227,14 @@ def _parse_array_value( Behavior -------- - If ``comma=True`` and ``value`` is a string that contains commas, split into a list. - When ``enforce_comma_limit`` is ``True``, over-limit comma values raise or degrade to an ``OverflowDict`` here. - Raw query-string parsing and mapping key paths ending in ``[]`` pass ``False`` so the caller can account for - bracket-array key context first. + With ``raise_on_limit_exceeded=True``, an over-limit comma group raises before splitting or decoding, + including values assigned to ``[]``. Otherwise ``enforce_comma_limit`` controls conversion of an + over-limit group to ``CommaOverflowDict``; bracket assignments defer conversion until after wrapping. - Otherwise, enforce the per-list length limit by comparing ``current_list_length`` to ``options.list_limit``. When ``raise_on_limit_exceeded=True``, violations raise ``ValueError``. - When ``list_limit`` is negative, any non-empty comma split exceeds the limit: raising mode raises, - while non-raising mode degrades to an ``OverflowDict``/``CommaOverflowDict``. Raw query-string - parsing temporarily returns the split list when ``enforce_comma_limit=False`` so the caller can - apply bracket-array wrapping before the final limit check. + while non-raising mode degrades to an ``OverflowDict``/``CommaOverflowDict``. Bracket assignments + temporarily return the split list so the caller can apply wrapping before the final limit check. Returns ------- @@ -243,7 +242,7 @@ def _parse_array_value( Either the original value or a list of values, without decoding (that happens later). """ if isinstance(value, str) and value and options.comma and "," in value: - if enforce_comma_limit and options.raise_on_limit_exceeded: + if options.raise_on_limit_exceeded: comma_count = 0 comma_index = value.find(",") while comma_index >= 0: @@ -401,7 +400,7 @@ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str list_limit_exceeded = len(val) > options.list_limit if list_limit_exceeded and isinstance(val, (list, tuple)): if options.raise_on_limit_exceeded: - raise ValueError(_list_limit_exceeded_message(options.list_limit)) + raise ValueError(_list_limit_exceeded_message(options.list_limit)) # pragma: no cover - pre-split guard val = CommaOverflowDict({str(i): item for i, item in enumerate(val)}) existing: bool = key in obj @@ -502,7 +501,7 @@ def _parse_object( leaf = [leaf] if len(leaf) > options.list_limit: if options.raise_on_limit_exceeded: - raise ValueError(_list_limit_exceeded_message(options.list_limit)) + raise ValueError(_list_limit_exceeded_message(options.list_limit)) # pragma: no cover - pre-split guard leaf = CommaOverflowDict({str(i): item for i, item in enumerate(leaf)}) # Walk the chain from the leaf to the root, building nested containers on the way out. diff --git a/src/qs_codec/encode.py b/src/qs_codec/encode.py index 6d9c0b2..496e6e2 100644 --- a/src/qs_codec/encode.py +++ b/src/qs_codec/encode.py @@ -143,7 +143,7 @@ def encode(value: t.Any, options: t.Optional[EncodeOptions] = None) -> str: value=None if key_is_undefined else obj_value, is_undefined=key_is_undefined, side_channel=side_channel, - prefix=_key, + prefix=_key.replace(".", "%2E") if opts.encode_dot_in_keys else _key, generate_array_prefix=list_format.generator, comma_round_trip=comma_round_trip, comma_compact_nulls=list_format == ListFormat.COMMA and opts.comma_compact_nulls, @@ -809,14 +809,13 @@ def _append_child_result(frame: EncodeFrame, encoded: t.Any) -> None: if callable(filter_opt): obj = filter_opt(current_path.materialize(), obj) - else: - if isinstance(obj, datetime): - obj = frame.serialize_date(obj) if callable(frame.serialize_date) else obj.isoformat() - elif frame.generate_array_prefix is _COMMA_GENERATOR and isinstance(obj, (list, tuple)): - if callable(frame.serialize_date): - obj = [frame.serialize_date(x) if isinstance(x, datetime) else x for x in obj] - else: - obj = [x.isoformat() if isinstance(x, datetime) else x for x in obj] + if isinstance(obj, datetime): + obj = frame.serialize_date(obj) if callable(frame.serialize_date) else obj.isoformat() + elif frame.generate_array_prefix is _COMMA_GENERATOR and isinstance(obj, (list, tuple)): + if callable(frame.serialize_date): + obj = [frame.serialize_date(x) if isinstance(x, datetime) else x for x in obj] + else: + obj = [x.isoformat() if isinstance(x, datetime) else x for x in obj] if not frame.is_undefined and obj is None: if frame.strict_null_handling: diff --git a/src/qs_codec/models/decode_options.py b/src/qs_codec/models/decode_options.py index 0a13bc2..7358834 100644 --- a/src/qs_codec/models/decode_options.py +++ b/src/qs_codec/models/decode_options.py @@ -52,9 +52,10 @@ class DecodeOptions: a list. Above the limit, decoding either uses a numeric-keyed mapping or raises ``ValueError`` when ``raise_on_limit_exceeded=True``. - For bracket-array assignments such as ``foo[]=1,2,3``, the comma-split payload is wrapped - as a single outer list element, so the inner payload may contain more values than - ``list_limit`` while still respecting the outer container limit. + For bracket-array assignments such as ``foo[]=1,2,3``, each comma-split payload is wrapped as one + outer list element. With ``raise_on_limit_exceeded=True``, an oversized inner comma group raises + independently of the outer list limit. Otherwise it remains a nested group, while exceeding the + outer list limit changes the result to a numeric-keyed mapping. """ charset: Charset = Charset.UTF8 diff --git a/src/qs_codec/models/encode_options.py b/src/qs_codec/models/encode_options.py index c1175b8..3747e88 100644 --- a/src/qs_codec/models/encode_options.py +++ b/src/qs_codec/models/encode_options.py @@ -146,8 +146,8 @@ def __post_init__(self) -> None: if not hasattr(self, "_encoder") or self._encoder is None: self._encoder = EncodeUtils.encode if self.max_depth is not None: - if not isinstance(self.max_depth, int) or isinstance(self.max_depth, bool) or self.max_depth <= 0: - raise ValueError("max_depth must be a positive integer or None") + if not isinstance(self.max_depth, int) or isinstance(self.max_depth, bool) or self.max_depth < 0: + raise ValueError("max_depth must be a non-negative integer or None") # Default `encode_dot_in_keys` first, then mirror into `allow_dots` when unspecified. if self.encode_dot_in_keys is None: self.encode_dot_in_keys = False diff --git a/src/qs_codec/utils/utils.py b/src/qs_codec/utils/utils.py index 972934a..50015c5 100644 --- a/src/qs_codec/utils/utils.py +++ b/src/qs_codec/utils/utils.py @@ -536,12 +536,9 @@ def combine( Concatenate two values, treating non-sequences as singletons. Normal list/tuple inputs are flattened into the combined result. When - ``a`` is already an :class:`OverflowDict`, however, ``b`` is appended as - one value at the next numeric key, even if ``b`` is a list, tuple, or - another :class:`OverflowDict`. This preserves qs parity for duplicate - values after list-limit overflow: a later comma-split or bracket-array - payload remains a nested value instead of being flattened into the - overflowed container. + ``a`` is already an :class:`OverflowDict`, top-level list/tuple elements + from ``b`` are appended at successive numeric keys; a nested list remains + one group, and an incoming overflow mapping remains one copied value. If `list_limit` is exceeded, converts the list to an `OverflowDict` (a dict with numeric keys) to prevent memory exhaustion. @@ -560,15 +557,17 @@ def combine( raise ValueError( f"List limit exceeded: Only {limit} element{'' if limit == 1 else 's'} allowed in a list." ) - # a is already an OverflowDict. Append b as one value at the next numeric index. + # Copy on write; append top-level values after the highest numeric index. orig_a: OverflowDict = t.cast(OverflowDict, a) a_copy: OverflowDict = orig_a.__class__({k: v for k, v in orig_a.items() if not isinstance(v, Undefined)}) # Use max key + 1 to handle sparse dicts safely, rather than len(a) key_pairs: t.List[t.Tuple[int, str]] = _numeric_key_pairs(a_copy) idx: int = (max(key for key, _ in key_pairs) + 1) if key_pairs else 0 - if not isinstance(b, Undefined): - a_copy[str(idx)] = _copy_overflow_append_value(b) + for value in b if isinstance(b, (list, tuple)) else (b,): + if not isinstance(value, Undefined): + a_copy[str(idx)] = _copy_overflow_append_value(value) + idx += 1 return a_copy # Normal combination: flatten lists/tuples diff --git a/tests/comparison/package.json b/tests/comparison/package.json index f6d9eba..5695c43 100644 --- a/tests/comparison/package.json +++ b/tests/comparison/package.json @@ -4,8 +4,8 @@ "description": "A comparison of query string parsing libraries", "author": "Klemen Tusar", "license": "BSD-3-Clause", - "packageManager": "pnpm@11.9.0", + "packageManager": "pnpm@12.6.0", "dependencies": { - "qs": "^6.15.3" + "qs": "^6.16.0" } } diff --git a/tests/comparison/pnpm-lock.yaml b/tests/comparison/pnpm-lock.yaml index e58bff7..0013987 100644 --- a/tests/comparison/pnpm-lock.yaml +++ b/tests/comparison/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.6.0 + version: 12.6.0 + +packages: + + '@pnpm/exe.android-arm64@12.6.0': + resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.6.0': + resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.6.0': + resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.6.0': + resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.6.0': + resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.6.0': + resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.6.0': + resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.6.0': + resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.6.0': + resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.6.0': + resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.6.0': + resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.6.0': + resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.6.0': + resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.6.0': + resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + cpu: [x64] + os: [win32] + + pnpm@12.6.0: + resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.6.0': + optional: true + + '@pnpm/exe.android-x64@12.6.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.6.0': + optional: true + + '@pnpm/exe.darwin-x64@12.6.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.6.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.6.0': + optional: true + + '@pnpm/exe.linux-arm64@12.6.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.6.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.6.0': + optional: true + + '@pnpm/exe.linux-s390x@12.6.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.6.0': + optional: true + + '@pnpm/exe.linux-x64@12.6.0': + optional: true + + '@pnpm/exe.win32-arm64@12.6.0': + optional: true + + '@pnpm/exe.win32-x64@12.6.0': + optional: true + + pnpm@12.6.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.6.0 + '@pnpm/exe.android-x64': 12.6.0 + '@pnpm/exe.darwin-arm64': 12.6.0 + '@pnpm/exe.darwin-x64': 12.6.0 + '@pnpm/exe.freebsd-x64': 12.6.0 + '@pnpm/exe.linux-arm64': 12.6.0 + '@pnpm/exe.linux-arm64-musl': 12.6.0 + '@pnpm/exe.linux-ppc64': 12.6.0 + '@pnpm/exe.linux-riscv64': 12.6.0 + '@pnpm/exe.linux-s390x': 12.6.0 + '@pnpm/exe.linux-x64': 12.6.0 + '@pnpm/exe.linux-x64-musl': 12.6.0 + '@pnpm/exe.win32-arm64': 12.6.0 + '@pnpm/exe.win32-x64': 12.6.0 + +--- lockfileVersion: '9.0' settings: @@ -9,8 +167,8 @@ importers: .: dependencies: qs: - specifier: ^6.15.3 - version: 6.15.3 + specifier: ^6.16.0 + version: 6.16.0 packages: @@ -69,8 +227,8 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, tarball: https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz} engines: {node: '>= 0.4'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==, tarball: https://registry.npmjs.org/qs/-/qs-6.15.3.tgz} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==, tarball: https://registry.npmjs.org/qs/-/qs-6.16.0.tgz} engines: {node: '>=0.6'} side-channel-list@1.0.1: @@ -147,7 +305,7 @@ snapshots: object-inspect@1.13.4: {} - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 diff --git a/tests/unit/decode_test.py b/tests/unit/decode_test.py index 05ddca2..8601c61 100644 --- a/tests/unit/decode_test.py +++ b/tests/unit/decode_test.py @@ -877,12 +877,6 @@ def _decoder(s: t.Optional[str], charset: t.Optional[Charset]) -> t.Any: {"foo": [["1", "2", "3", "4"]]}, id="bracket-list-comma-value-can-exceed-list-limit", ), - pytest.param( - "foo[]=1,2,3,4", - DecodeOptions(comma=True, list_limit=3, raise_on_limit_exceeded=True), - {"foo": [["1", "2", "3", "4"]]}, - id="bracket-list-comma-value-does-not-raise-when-over-limit", - ), pytest.param( "foo[]=1,2,3,4", DecodeOptions(comma=True, list_limit=0), @@ -1607,11 +1601,50 @@ def test_keeps_flat_comma_values_at_or_below_limit(self) -> None: assert decode("a=1,2,3&a=4", options) == {"a": ["1", "2", "3", "4"]} assert decode("a=1,2,3,4,5", options) == {"a": ["1", "2", "3", "4", "5"]} + @pytest.mark.parametrize( + "query", + ["a[]=1,2,3,4", {"a[]": "1,2,3,4"}, "a[b][]=1,2,3,4", {"a[b][]": "1,2,3,4"}], + ) + def test_bracket_comma_group_raises_at_inner_limit(self, query: t.Union[str, t.Mapping[str, str]]) -> None: + with pytest.raises(ValueError, match="List limit exceeded"): + decode(query, DecodeOptions(comma=True, list_limit=3, raise_on_limit_exceeded=True)) + + @pytest.mark.parametrize("query", ["a[]=1,2,3", {"a[]": "1,2,3"}]) + def test_bracket_comma_group_at_limit_remains_nested(self, query: t.Union[str, t.Mapping[str, str]]) -> None: + assert decode(query, DecodeOptions(comma=True, list_limit=3, raise_on_limit_exceeded=True)) == { + "a": [["1", "2", "3"]] + } + + def test_non_raising_oversized_bracket_comma_group_remains_nested(self) -> None: + assert decode("a[]=1,2,3,4", DecodeOptions(comma=True, list_limit=3)) == {"a": [["1", "2", "3", "4"]]} + + def test_bracket_comma_limit_precedes_value_decoding(self) -> None: + decoded_values: t.List[str] = [] + + def decoder(value: t.Optional[str], charset: t.Optional[Charset], kind: DecodeKind) -> t.Optional[str]: + if kind == DecodeKind.VALUE and value is not None: + decoded_values.append(value) + return DecodeUtils.decode(value, charset) + + with pytest.raises(ValueError, match="List limit exceeded"): + decode( + "a[]=1,2,3,4", + DecodeOptions(comma=True, list_limit=3, raise_on_limit_exceeded=True, decoder=decoder), + ) + assert decoded_values == [] + + def test_repeated_bracket_comma_groups_enforce_outer_limit(self) -> None: + options = DecodeOptions(comma=True, list_limit=2, raise_on_limit_exceeded=True) + assert decode("a[]=1,2&a[]=3,4", options) == {"a": [["1", "2"], ["3", "4"]]} + with pytest.raises(ValueError, match="List limit exceeded"): + decode("a[]=1,2&a[]=3,4&a[]=5,6", options) + def test_counts_bracketed_comma_groups_as_outer_elements(self) -> None: options = DecodeOptions(comma=True, list_limit=5, raise_on_limit_exceeded=True) assert decode("a[]=1,2,3&a[]=4,5,6", options) == {"a": [["1", "2", "3"], ["4", "5", "6"]]} - assert decode("a[]=1,2,3,4,5,6", options) == {"a": [["1", "2", "3", "4", "5", "6"]]} + with pytest.raises(ValueError, match="List limit exceeded"): + decode("a[]=1,2,3,4,5,6", options) with pytest.raises(ValueError, match="List limit exceeded"): decode( @@ -1761,11 +1794,6 @@ def test_bracket_comma_list_negative_limit_converts_wrapped_value_to_overflow_di {"foo": [["1", "2", "3", "4"]]}, id="over-inner-list-limit", ), - pytest.param( - DecodeOptions(comma=True, list_limit=3, raise_on_limit_exceeded=True), - {"foo": [["1", "2", "3", "4"]]}, - id="over-inner-list-limit-raise-enabled", - ), pytest.param( DecodeOptions(comma=True, list_limit=0), {"foo": {"0": ["1", "2", "3", "4"]}}, @@ -1815,7 +1843,7 @@ def test_mapping_bracket_comma_list_over_zero_limit_raises(self) -> None: pytest.param( "a=1,2,3,4&a=5,6", DecodeOptions(comma=True, list_limit=3), - {"a": {"0": "1", "1": "2", "2": "3", "3": "4", "4": ["5", "6"]}}, + {"a": {"0": "1", "1": "2", "2": "3", "3": "4", "4": "5", "5": "6"}}, id="overflow-comma-list-then-in-limit-comma-list", ), pytest.param( @@ -1835,12 +1863,18 @@ def test_mapping_bracket_comma_list_over_zero_limit_raises(self) -> None: pytest.param( "a[]=1&a[]=2&a[]=3,4", DecodeOptions(comma=True, list_limit=1), - {"a": {"0": "1", "1": "2", "2": [["3", "4"]]}}, + {"a": {"0": "1", "1": "2", "2": ["3", "4"]}}, id="bracket-overflow-then-comma-list", ), + pytest.param( + "a[]=1&a[]=2&a[]=3,4&a[]=5,6", + DecodeOptions(comma=True, list_limit=1), + {"a": {"0": "1", "1": "2", "2": ["3", "4"], "3": ["5", "6"]}}, + id="bracket-overflow-then-two-comma-groups", + ), ], ) - def test_comma_overflow_duplicates_keep_overflow_values_nested( + def test_comma_overflow_duplicates_preserve_group_boundaries( self, query: str, options: DecodeOptions, expected: t.Mapping[str, t.Any] ) -> None: assert decode(query, options) == expected diff --git a/tests/unit/encode_options_test.py b/tests/unit/encode_options_test.py index cbb466d..14db760 100644 --- a/tests/unit/encode_options_test.py +++ b/tests/unit/encode_options_test.py @@ -104,9 +104,10 @@ def test_equality_detects_field_difference(self) -> None: def test_indices_normalizes_to_list_format(self, indices: bool, expected: ListFormat) -> None: assert EncodeOptions(indices=indices).list_format is expected - def test_max_depth_must_be_positive(self) -> None: - for value in (0, -1, True, 1.5): - with pytest.raises(ValueError, match="max_depth must be a positive integer or None"): + def test_max_depth_must_be_non_negative(self) -> None: + for value in (-1, True, 1.5): + with pytest.raises(ValueError, match="max_depth must be a non-negative integer or None"): EncodeOptions(max_depth=value) # type: ignore[arg-type] + assert EncodeOptions(max_depth=0).max_depth == 0 assert EncodeOptions(max_depth=5).max_depth == 5 diff --git a/tests/unit/encode_test.py b/tests/unit/encode_test.py index 0a56caf..23c331c 100644 --- a/tests/unit/encode_test.py +++ b/tests/unit/encode_test.py @@ -200,6 +200,47 @@ def test_encodes_dot_in_key_of_dict_when_encode_dot_in_keys_and_allow_dots_is_pr ) -> None: assert encode(data, options) == expected + @pytest.mark.parametrize( + "data, options, expected", + [ + pytest.param({"a.b": "x"}, EncodeOptions(allow_dots=True, encode_dot_in_keys=True), "a%252Eb=x", id="dots"), + pytest.param( + {"a.b": "x"}, EncodeOptions(allow_dots=False, encode_dot_in_keys=True), "a%252Eb=x", id="brackets" + ), + pytest.param( + {"a.b": "x"}, + EncodeOptions(encode_dot_in_keys=True, encode_values_only=True), + "a%2Eb=x", + id="values-only", + ), + pytest.param( + {"a.b": "x"}, EncodeOptions(encode_dot_in_keys=True, encode=False), "a%2Eb=x", id="no-encoding" + ), + pytest.param( + {"a.b": None}, + EncodeOptions(encode_dot_in_keys=True, strict_null_handling=True), + "a%252Eb", + id="strict-null", + ), + pytest.param( + {"a.b": "x", "c.d": "y"}, + EncodeOptions(encode_dot_in_keys=True), + "a%252Eb=x&c%252Ed=y", + id="multiple-root-keys", + ), + pytest.param( + {"a.b": {"c": "d"}}, + EncodeOptions(allow_dots=True, encode_dot_in_keys=True), + "a%252Eb.c=d", + id="structural-dot", + ), + ], + ) + def test_encodes_literal_dots_in_root_keys( + self, data: t.Mapping[str, t.Any], options: EncodeOptions, expected: str + ) -> None: + assert encode(data, options) == expected + def test_encodes_dot_in_key_of_dict_and_automatically_set_allow_dots_to_true_when_encode_dot_in_keys_is_true_and_allow_dots_in_undefined( self, ): @@ -880,6 +921,13 @@ def test_encode_depth_guard_prevents_recursion_errors(self) -> None: with pytest.raises(ValueError, match="Maximum encoding depth exceeded"): encode(data, options=EncodeOptions(max_depth=3)) + @pytest.mark.parametrize("encode_keys", [True, False]) + def test_encode_zero_depth_allows_root_scalar_but_rejects_children(self, encode_keys: bool) -> None: + options = EncodeOptions(max_depth=0, encode=encode_keys) + assert encode({"a": "b"}, options) == "a=b" + with pytest.raises(ValueError, match="Maximum encoding depth exceeded"): + encode({"a": {"b": "c"}}, options) + def test_encode_depth_guard_does_not_cap_to_recursion_limit(self) -> None: # `_get_max_encode_depth` now uses `sys.maxsize` for None and explicit values directly, # so monkeypatching `sys.getrecursionlimit` is intentionally unnecessary here. @@ -1200,6 +1248,46 @@ def test_serialize_date_option( result = encode(data) if options is None else encode(data, options) assert result == expected + @pytest.mark.parametrize( + "data, options, expected", + [ + pytest.param({"a": datetime(2024, 1, 2)}, {}, "a=2024-01-02", id="root-date"), + pytest.param({"a": {"b": datetime(2024, 1, 2)}}, {}, "a%5Bb%5D=2024-01-02", id="nested-date"), + pytest.param( + {"a": "replace"}, + {"filter": lambda prefix, value: datetime(2024, 1, 2) if prefix == "a" else value}, + "a=2024-01-02", + id="filter-creates-date", + ), + pytest.param( + {"a": datetime(2024, 1, 2)}, + {"filter": lambda prefix, value: "replaced" if prefix == "a" else value}, + "a=replaced", + id="filter-replaces-date", + ), + pytest.param( + {"a": [datetime(2024, 1, 2), "x"]}, + {"list_format": ListFormat.COMMA}, + "a=2024-01-02%2Cx", + id="comma-date", + ), + ], + ) + def test_callable_filter_serializes_resulting_dates( + self, data: t.Mapping[str, t.Any], options: t.Dict[str, t.Any], expected: str + ) -> None: + assert ( + encode( + data, + EncodeOptions( + filter=options.get("filter", lambda prefix, value: value), + serialize_date=lambda dt: dt.strftime("%Y-%m-%d"), + list_format=options.get("list_format", ListFormat.INDICES), + ), + ) + == expected + ) + @pytest.mark.parametrize( "data, expected", [ diff --git a/tests/unit/utils_test.py b/tests/unit/utils_test.py index 09c94a4..729f5ee 100644 --- a/tests/unit/utils_test.py +++ b/tests/unit/utils_test.py @@ -1258,13 +1258,18 @@ def test_combine_list_with_overflow_dict(self) -> None: result = Utils.combine(a, b) assert result == ["start", "x", "y"] - def test_combine_overflow_dict_appends_list_as_single_value(self) -> None: + def test_combine_overflow_dict_appends_list_elements_without_mutating_source(self) -> None: a = OverflowDict({"0": "x"}) - b = ["y", Undefined(), "z"] - result = Utils.combine(a, b) + result = Utils.combine(a, ["y", Undefined(), "z"]) assert isinstance(result, OverflowDict) - assert result == {"0": "x", "1": ["y", Undefined(), "z"]} - assert result["1"] is not b + assert result == {"0": "x", "1": "y", "2": "z"} + assert a == {"0": "x"} + + def test_combine_overflow_dict_keeps_nested_group_as_one_value(self) -> None: + group = ["y", "z"] + result = Utils.combine(OverflowDict({"0": "x"}), [group]) + assert result == {"0": "x", "1": ["y", "z"]} + assert result["1"] is not group def test_combine_skips_undefined_in_list_flattening(self) -> None: a = ["x", Undefined()] diff --git a/tox.ini b/tox.ini index 7dcb010..7434d67 100644 --- a/tox.ini +++ b/tox.ini @@ -42,6 +42,11 @@ deps = commands = pytest -v --cov=src/qs_codec --cov-report=xml +[testenv:python3.13t] +deps = + -rrequirements_dev.txt + mypy<2 + [testenv:black] basepython = python3 skip_install = true