httpx-qs 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of httpx-qs might be problematic. Click here for more details.

httpx_qs/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """httpx-qs: A library for smart query string handling with httpx."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .enums.merge_policy import MergePolicy
6
+ from .transporters import smart_query_strings
7
+ from .utils.merge_query import merge_query
8
+
9
+
10
+ __all__ = [
11
+ "smart_query_strings",
12
+ "MergePolicy",
13
+ "merge_query",
14
+ ]
@@ -0,0 +1,19 @@
1
+ """Policy that determines how to handle keys that already exist in the query string."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class MergePolicy(str, Enum):
7
+ """Policy that determines how to handle keys that already exist in the query string.
8
+
9
+ Values:
10
+ COMBINE: (default) Combine existing and new values into a list (preserving order: existing then new).
11
+ REPLACE: Replace existing value with the new one (last-wins).
12
+ KEEP: Keep the existing value, ignore the new one (first-wins).
13
+ ERROR: Raise a ValueError if a key collision occurs.
14
+ """
15
+
16
+ COMBINE = "combine"
17
+ REPLACE = "replace"
18
+ KEEP = "keep"
19
+ ERROR = "error"
httpx_qs/py.typed ADDED
File without changes
@@ -0,0 +1,36 @@
1
+ """A transport that merges extra query params supplied via request.extensions."""
2
+
3
+ import typing as t
4
+
5
+ import httpx
6
+ from qs_codec import EncodeOptions, ListFormat
7
+
8
+ from httpx_qs.utils.merge_query import MergePolicy, merge_query
9
+
10
+
11
+ class SmartQueryStrings(httpx.BaseTransport):
12
+ """A transport that merges extra query params supplied via request.extensions."""
13
+
14
+ def __init__(self, next_transport: httpx.BaseTransport) -> None:
15
+ """Initialize with the next transport in the chain."""
16
+ self.next_transport = next_transport
17
+
18
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
19
+ """Handle the request, merging extra query params if provided."""
20
+ extra_params: t.Dict[t.Any, t.Any] = request.extensions.get("extra_query_params", {})
21
+ extra_params_options: t.Optional[EncodeOptions] = request.extensions.get("extra_query_params_options", None)
22
+ merge_policy: t.Optional[t.Union[MergePolicy, str]] = request.extensions.get("extra_query_params_policy")
23
+ if extra_params:
24
+ request.url = httpx.URL(
25
+ merge_query(
26
+ str(request.url),
27
+ extra_params,
28
+ (
29
+ extra_params_options
30
+ if extra_params_options is not None
31
+ else EncodeOptions(list_format=ListFormat.REPEAT)
32
+ ),
33
+ policy=merge_policy if merge_policy is not None else MergePolicy.COMBINE,
34
+ )
35
+ )
36
+ return self.next_transport.handle_request(request)
@@ -0,0 +1,84 @@
1
+ """Utility to merge query parameters into a URL's query string.
2
+
3
+ Provides different merge policies controlling how conflicting keys are handled.
4
+ """
5
+
6
+ import typing as t
7
+ from urllib.parse import SplitResult, urlsplit, urlunsplit
8
+
9
+ from qs_codec import EncodeOptions, ListFormat, decode, encode
10
+
11
+ from httpx_qs.enums.merge_policy import MergePolicy
12
+
13
+
14
+ def _combine(existing_value: t.Any, new_value: t.Any) -> t.List[t.Any]:
15
+ """Return a combined list ensuring list semantics for multiple values.
16
+
17
+ Always returns a list (copy) even if both inputs are scalar values.
18
+ """
19
+ left_list: t.List[t.Any]
20
+ if isinstance(existing_value, list):
21
+ # slice copy then cast to satisfy type checker
22
+ left_list = t.cast(t.List[t.Any], existing_value[:])
23
+ else:
24
+ left_list = [existing_value]
25
+
26
+ right_list: t.List[t.Any]
27
+ if isinstance(new_value, list):
28
+ right_list = t.cast(t.List[t.Any], new_value[:])
29
+ else:
30
+ right_list = [new_value]
31
+ # Return a new list (avoid mutating originals if lists)
32
+ return list(left_list) + list(right_list)
33
+
34
+
35
+ def merge_query(
36
+ url: str,
37
+ extra: t.Mapping[str, t.Any],
38
+ options: EncodeOptions = EncodeOptions(list_format=ListFormat.REPEAT),
39
+ policy: t.Union[MergePolicy, str] = MergePolicy.COMBINE,
40
+ ) -> str:
41
+ """Merge extra query parameters into a URL's existing query string.
42
+
43
+ Args:
44
+ url: The original URL which may contain an existing query string.
45
+ extra: Mapping of additional query parameters to merge into the URL.
46
+ options: Optional :class:`qs_codec.EncodeOptions` to customize encoding behavior.
47
+ policy: Merge policy to apply when a key already exists (``combine`` | ``replace`` | ``keep`` | ``error``).
48
+ Returns:
49
+ The URL with the merged query string.
50
+ Raises:
51
+ ValueError: If ``policy == 'error'`` and a duplicate key is encountered.
52
+ """
53
+ policy_enum: MergePolicy = MergePolicy(policy) if not isinstance(policy, MergePolicy) else policy
54
+
55
+ parts: SplitResult = urlsplit(url)
56
+ existing: t.Dict[str, t.Any] = decode(parts.query) if parts.query else {}
57
+
58
+ for k, v in extra.items():
59
+ if k not in existing:
60
+ existing[k] = v
61
+ continue
62
+
63
+ # k exists already
64
+ if policy_enum is MergePolicy.COMBINE:
65
+ existing[k] = _combine(existing[k], v)
66
+ elif policy_enum is MergePolicy.REPLACE:
67
+ existing[k] = v
68
+ elif policy_enum is MergePolicy.KEEP:
69
+ # Leave existing value untouched
70
+ continue
71
+ elif policy_enum is MergePolicy.ERROR:
72
+ raise ValueError(f"Duplicate query parameter '{k}' encountered while policy=error")
73
+ else: # pragma: no cover - defensive (should not happen due to Enum validation)
74
+ existing[k] = _combine(existing[k], v)
75
+
76
+ return urlunsplit(
77
+ (
78
+ parts.scheme,
79
+ parts.netloc,
80
+ parts.path,
81
+ encode(existing, options),
82
+ parts.fragment,
83
+ )
84
+ )
@@ -0,0 +1,302 @@
1
+ Metadata-Version: 2.4
2
+ Name: httpx-qs
3
+ Version: 0.1.0
4
+ Summary: HTTPX transport leveraging qs-codec for advanced query string encoding and decoding.
5
+ Project-URL: Homepage, https://techouse.github.io/httpx_qs/
6
+ Project-URL: Repository, https://github.com/techouse/httpx_qs.git
7
+ Project-URL: Issues, https://github.com/techouse/httpx_qs/issues
8
+ Project-URL: Changelog, https://github.com/techouse/httpx_qs/blob/master/CHANGELOG.md
9
+ Project-URL: Sponsor, https://github.com/sponsors/techouse
10
+ Project-URL: PayPal, https://paypal.me/ktusar
11
+ Author-email: Klemen Tusar <techouse@gmail.com>
12
+ License-Expression: BSD-3-Clause
13
+ License-File: LICENSE
14
+ Keywords: arrays,brackets,codec,form-urlencoded,httpx,nested,percent-encoding,qs,query,query-string,querystring,rfc3986,url,urldecode,urlencode
15
+ Classifier: Development Status :: 3 - Alpha
16
+ Classifier: Environment :: Web Environment
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: License :: OSI Approved :: BSD License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python
21
+ Classifier: Programming Language :: Python :: 3
22
+ Classifier: Programming Language :: Python :: 3 :: Only
23
+ Classifier: Programming Language :: Python :: 3.9
24
+ Classifier: Programming Language :: Python :: 3.10
25
+ Classifier: Programming Language :: Python :: 3.11
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Classifier: Programming Language :: Python :: Implementation :: CPython
29
+ Classifier: Topic :: Internet :: WWW/HTTP
30
+ Classifier: Topic :: Software Development :: Libraries
31
+ Classifier: Typing :: Typed
32
+ Requires-Python: >=3.9
33
+ Requires-Dist: httpx>=0.28.1
34
+ Requires-Dist: qs-codec>=1.2.3
35
+ Provides-Extra: dev
36
+ Requires-Dist: black; extra == 'dev'
37
+ Requires-Dist: isort; extra == 'dev'
38
+ Requires-Dist: mypy>=1.15.0; extra == 'dev'
39
+ Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
40
+ Requires-Dist: pytest>=8.3.5; extra == 'dev'
41
+ Requires-Dist: toml>=0.10.2; extra == 'dev'
42
+ Requires-Dist: tox; extra == 'dev'
43
+ Description-Content-Type: text/x-rst
44
+
45
+ httpx-qs
46
+ ========
47
+
48
+ Smart, policy-driven query string merging & encoding for `httpx <https://www.python-httpx.org>`_ powered by
49
+ `qs-codec <https://techouse.github.io/qs_codec/>`_.
50
+
51
+ .. image:: https://img.shields.io/pypi/v/httpx-qs
52
+ :target: https://pypi.org/project/httpx-qs/
53
+ :alt: PyPI version
54
+
55
+ .. image:: https://img.shields.io/pypi/status/httpx-qs
56
+ :target: https://pypi.org/project/httpx-qs/
57
+ :alt: PyPI - Status
58
+
59
+ .. image:: https://img.shields.io/pypi/pyversions/httpx-qs
60
+ :target: https://pypi.org/project/httpx-qs/
61
+ :alt: Supported Python versions
62
+
63
+ .. image:: https://img.shields.io/pypi/format/httpx-qs
64
+ :target: https://pypi.org/project/httpx-qs/
65
+ :alt: PyPI - Format
66
+
67
+ .. image:: https://github.com/techouse/httpx_qs/actions/workflows/test.yml/badge.svg
68
+ :target: https://github.com/techouse/httpx_qs/actions/workflows/test.yml
69
+ :alt: Tests
70
+
71
+ .. image:: https://github.com/techouse/httpx_qs/actions/workflows/github-code-scanning/codeql/badge.svg
72
+ :target: https://github.com/techouse/httpx_qs/actions/workflows/github-code-scanning/codeql
73
+ :alt: CodeQL
74
+
75
+ .. image:: https://img.shields.io/github/license/techouse/httpx_qs
76
+ :target: https://github.com/techouse/httpx_qs/blob/master/LICENSE
77
+ :alt: License
78
+
79
+ .. image:: https://codecov.io/gh/techouse/httpx_qs/graph/badge.svg?token=JMt8akIZFh
80
+ :target: https://codecov.io/gh/techouse/httpx_qs
81
+ :alt: Codecov
82
+
83
+ .. image:: https://app.codacy.com/project/badge/Grade/420bf66ab90d4b3798573b6ff86d02af
84
+ :target: https://app.codacy.com/gh/techouse/httpx_qs/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade
85
+ :alt: Codacy Quality
86
+
87
+ .. image:: https://img.shields.io/github/sponsors/techouse
88
+ :target: https://github.com/sponsors/techouse
89
+ :alt: GitHub Sponsors
90
+
91
+ .. image:: https://img.shields.io/github/stars/techouse/qs_codec
92
+ :target: https://github.com/techouse/qs_codec/stargazers
93
+ :alt: GitHub Repo stars
94
+
95
+ .. image:: https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg
96
+ :target: CODE-OF-CONDUCT.md
97
+ :alt: Contributor Covenant
98
+
99
+ .. |flake8| image:: https://img.shields.io/badge/flake8-checked-blueviolet.svg
100
+ :target: https://flake8.pycqa.org/en/latest/
101
+
102
+ .. image:: https://img.shields.io/badge/mypy-checked-blue.svg
103
+ :target: https://mypy.readthedocs.io/en/stable/
104
+ :alt: mypy
105
+
106
+ .. image:: https://img.shields.io/badge/linting-pylint-yellowgreen.svg
107
+ :target: https://github.com/pylint-dev/pylint
108
+ :alt: pylint
109
+
110
+ .. image:: https://img.shields.io/badge/imports-isort-blue.svg
111
+ :target: https://pycqa.github.io/isort/
112
+ :alt: isort
113
+
114
+ .. image:: https://img.shields.io/badge/security-bandit-blue.svg
115
+ :target: https://github.com/PyCQA/bandit
116
+ :alt: Security Status
117
+
118
+ Overview
119
+ --------
120
+
121
+ ``httpx-qs`` provides:
122
+
123
+ * A transport wrapper ``SmartQueryStrings`` that merges *existing* URL query parameters with *additional* ones supplied via ``request.extensions``.
124
+ * A flexible ``merge_query`` utility with selectable conflict resolution policies.
125
+ * Consistent, standards-aware encoding via ``qs-codec`` (RFC3986 percent-encoding, structured arrays, nested objects, etc.).
126
+
127
+ Why?
128
+ ----
129
+
130
+ HTTPX already lets you pass ``params=`` when making requests, but sometimes you need to:
131
+
132
+ * Inject **additional** query parameters from middleware/transport layers (e.g., auth tags, tracing IDs, feature flags) *without losing* the caller's original intent.
133
+ * Combine repeated keys or treat them deterministically (replace / keep / error) rather than always flattening.
134
+ * Support nested data or list semantics consistent across clients and services.
135
+
136
+ ``qs-codec`` supplies the primitives (decoding & encoding with configurable ``ListFormat``). ``httpx-qs`` stitches that into HTTPX's transport pipeline so you can declaratively extend queries at request dispatch time.
137
+
138
+ Installation
139
+ ------------
140
+
141
+ .. code-block:: bash
142
+
143
+ pip install httpx-qs
144
+
145
+ Minimal Example
146
+ ---------------
147
+
148
+ .. code-block:: python
149
+
150
+ import httpx
151
+ from httpx_qs.transporters.smart_query_strings import SmartQueryStrings
152
+
153
+ client = httpx.Client(transport=SmartQueryStrings(httpx.HTTPTransport()))
154
+
155
+ response = client.get(
156
+ "https://www.google.com",
157
+ params={"a": "b", "c": "d"},
158
+ extensions={"extra_query_params": {"c": "D", "tags": ["x", "y"]}},
159
+ )
160
+
161
+ print(str(response.request.url))
162
+ # Example (order may vary): https://www.google.com/?a=b&c=d&c=D&tags=x&tags=y
163
+
164
+ Using Merge Policies
165
+ --------------------
166
+
167
+ Conflict resolution when a key already exists is controlled by ``MergePolicy``.
168
+
169
+ Available policies:
170
+
171
+ * ``combine`` (default): concatenate values → existing first, new afterward (``a=1&a=2``)
172
+ * ``replace``: last-wins, existing value is overwritten (``a=2``)
173
+ * ``keep``: first-wins, ignore the new value (``a=1``)
174
+ * ``error``: raise ``ValueError`` on duplicate key
175
+
176
+ Specify per request:
177
+
178
+ .. code-block:: python
179
+
180
+ from httpx_qs import MergePolicy
181
+
182
+ r = client.get(
183
+ "https://api.example.com/resources",
184
+ params={"dup": "original"},
185
+ extensions={
186
+ "extra_query_params": {"dup": "override"},
187
+ "extra_query_params_policy": MergePolicy.REPLACE,
188
+ },
189
+ )
190
+ # Query contains only dup=override
191
+
192
+ Async Usage
193
+ -----------
194
+
195
+ ``SmartQueryStrings`` works equally for ``AsyncClient``:
196
+
197
+ .. code-block:: python
198
+
199
+ import httpx
200
+ from httpx_qs.transporters.smart_query_strings import SmartQueryStrings
201
+
202
+ async def main() -> None:
203
+ async with httpx.AsyncClient(transport=SmartQueryStrings(httpx.AsyncHTTPTransport())) as client:
204
+ r = await client.get(
205
+ "https://example.com/items",
206
+ params={"filters": "active"},
207
+ extensions={"extra_query_params": {"page": 2}},
208
+ )
209
+ print(r.request.url)
210
+
211
+ # Run with: asyncio.run(main())
212
+
213
+ ``merge_query`` Utility
214
+ -----------------------
215
+
216
+ You can use the underlying function directly:
217
+
218
+ .. code-block:: python
219
+
220
+ from httpx_qs import merge_query, MergePolicy
221
+ from qs_codec import EncodeOptions, ListFormat
222
+
223
+ new_url = merge_query(
224
+ "https://example.com?a=1",
225
+ {"a": 2, "tags": ["x", "y"]},
226
+ options=EncodeOptions(list_format=ListFormat.REPEAT),
227
+ policy=MergePolicy.COMBINE,
228
+ )
229
+ # → https://example.com/?a=1&a=2&tags=x&tags=y
230
+
231
+ Why ``ListFormat.REPEAT`` by Default?
232
+ -------------------------------------
233
+
234
+ ``qs-codec`` exposes several list formatting strategies (e.g. repeat, brackets, indices). ``httpx-qs`` defaults to
235
+ ``ListFormat.REPEAT`` because:
236
+
237
+ * It matches common server expectations (``key=value&key=value``) without requiring bracket parsing logic.
238
+ * It preserves original ordering while remaining unambiguous and simple for log inspection.
239
+ * Many API gateways / proxies / caches reliably forward repeated keys whereas bracket syntaxes can be normalized away.
240
+
241
+ If your API prefers another convention (e.g. ``tags[]=x&tags[]=y`` or ``tags[0]=x``) just pass a custom ``EncodeOptions`` via
242
+ ``extensions['extra_query_params_options']`` or parameter ``options`` when calling ``merge_query`` directly.
243
+
244
+ Advanced Per-Request Customization
245
+ ----------------------------------
246
+
247
+ .. code-block:: python
248
+
249
+ from qs_codec import EncodeOptions, ListFormat
250
+
251
+ r = client.get(
252
+ "https://service.local/search",
253
+ params={"q": "test"},
254
+ extensions={
255
+ "extra_query_params": {"debug": True, "tags": ["alpha", "beta"]},
256
+ "extra_query_params_policy": "combine", # also accepts string values
257
+ "extra_query_params_options": EncodeOptions(list_format=ListFormat.BRACKETS),
258
+ },
259
+ )
260
+ # Example: ?q=test&debug=true&tags[]=alpha&tags[]=beta
261
+
262
+ Error Policy Example
263
+ --------------------
264
+
265
+ .. code-block:: python
266
+
267
+ try:
268
+ client.get(
269
+ "https://example.com",
270
+ params={"token": "abc"},
271
+ extensions={
272
+ "extra_query_params": {"token": "xyz"},
273
+ "extra_query_params_policy": "error",
274
+ },
275
+ )
276
+ except ValueError as exc:
277
+ print("Duplicate detected:", exc)
278
+
279
+ Testing Strategy
280
+ ----------------
281
+
282
+ The project includes unit tests covering policy behaviors, error handling, and transport-level integration. Run them with:
283
+
284
+ .. code-block:: bash
285
+
286
+ pytest
287
+
288
+ Further Reading
289
+ ---------------
290
+
291
+ * HTTPX documentation: https://www.python-httpx.org
292
+ * qs-codec documentation: https://techouse.github.io/qs_codec/
293
+
294
+ License
295
+ -------
296
+
297
+ BSD-3-Clause. See ``LICENSE`` for details.
298
+
299
+ Contributing
300
+ ------------
301
+
302
+ Issues & PRs welcome. Please add tests for new behavior and keep doc examples in sync.
@@ -0,0 +1,9 @@
1
+ httpx_qs/__init__.py,sha256=L_QNDEcprD6zfOnzSu2ZRRPRtKE07NzFPR83fr2LdSA,308
2
+ httpx_qs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ httpx_qs/enums/merge_policy.py,sha256=CULesKR01GjBqIuPN7WD_EasvogpIpQy4oTNlbEG9Mg,653
4
+ httpx_qs/transporters/smart_query_strings.py,sha256=d3XKTb2X1Rey3vk1uiWjk4JTKZ5bstbd4NHUw-lXuig,1584
5
+ httpx_qs/utils/merge_query.py,sha256=usOLroZzR0tTFixV2Bh2TzxUz8poqJC_4qBaS9yPhOs,3016
6
+ httpx_qs-0.1.0.dist-info/METADATA,sha256=6mzStJzL6DTnxX78GOTIN_Ewn5qMMKObFiJX9BEqXBo,10237
7
+ httpx_qs-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ httpx_qs-0.1.0.dist-info/licenses/LICENSE,sha256=BGym7TZirVdWajaAWJVsuIt57rdDM8_mmVAzXDerIlY,1498
9
+ httpx_qs-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Klemen Tusar
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.