cloudru-qa-httpx-curlify 1.0.0__tar.gz

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.
Files changed (27) hide show
  1. cloudru_qa_httpx_curlify-1.0.0/.gitignore +26 -0
  2. cloudru_qa_httpx_curlify-1.0.0/LICENSE +202 -0
  3. cloudru_qa_httpx_curlify-1.0.0/PKG-INFO +244 -0
  4. cloudru_qa_httpx_curlify-1.0.0/README.md +222 -0
  5. cloudru_qa_httpx_curlify-1.0.0/httpx_curlify/__init__.py +3 -0
  6. cloudru_qa_httpx_curlify-1.0.0/httpx_curlify/curlify.py +89 -0
  7. cloudru_qa_httpx_curlify-1.0.0/httpx_curlify/py.typed +1 -0
  8. cloudru_qa_httpx_curlify-1.0.0/httpx_curlify/sanitaizer.py +38 -0
  9. cloudru_qa_httpx_curlify-1.0.0/pyproject.toml +61 -0
  10. cloudru_qa_httpx_curlify-1.0.0/tests/__init__.py +1 -0
  11. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_delete_request.raw +3 -0
  12. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_multiline_format.raw +7 -0
  13. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_post_request_with_form_data.raw +6 -0
  14. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_post_request_with_json.raw +6 -0
  15. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_put_request.raw +6 -0
  16. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_simple_get_request.raw +3 -0
  17. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_with_binary_data.raw +6 -0
  18. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_with_custom_headers.raw +5 -0
  19. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_with_empty_header_value.raw +4 -0
  20. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_with_json_dumps_kwargs.raw +9 -0
  21. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_with_nested_sanitize_keys.raw +6 -0
  22. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_with_query_parameters.raw +3 -0
  23. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_with_sanitize_keys_in_headers.raw +5 -0
  24. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_with_sanitize_keys_in_json.raw +6 -0
  25. cloudru_qa_httpx_curlify-1.0.0/tests/__snapshots__/test_curlify/test_to_curl_with_special_characters_in_url.raw +3 -0
  26. cloudru_qa_httpx_curlify-1.0.0/tests/conftest.py +16 -0
  27. cloudru_qa_httpx_curlify-1.0.0/tests/test_curlify.py +219 -0
@@ -0,0 +1,26 @@
1
+ /docs/plans/
2
+ .venv/
3
+ .venvs/
4
+ __pycache__/
5
+ *.py[cod]
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .hypothesis/
10
+ .coverage
11
+ coverage.xml
12
+ htmlcov/
13
+ report*.xml
14
+ allure-results/
15
+ allure-report/
16
+ .allure/
17
+ logs/
18
+ *.log
19
+ .env
20
+ .env.*
21
+ .DS_Store
22
+ dist/
23
+ build/
24
+ *.egg-info/
25
+ /packages/*/api/
26
+ .claude/settings.local.json
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,244 @@
1
+ Metadata-Version: 2.5
2
+ Name: cloudru-qa-httpx-curlify
3
+ Version: 1.0.0
4
+ Summary: Преобразование запросов httpx.Request в команды curl
5
+ Project-URL: Repository, https://github.com/cloud-ru-tech/qa-libraries
6
+ Project-URL: Issues, https://github.com/cloud-ru-tech/qa-libraries/issues
7
+ Author-email: Алексей Кирютин <ayukiryutin@cloud.ru>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.13
19
+ Requires-Dist: glom>=24.11.0
20
+ Requires-Dist: httpx<0.29,>=0.28.1
21
+ Description-Content-Type: text/markdown
22
+
23
+ # httpx-curlify
24
+
25
+ [![PyPI](https://img.shields.io/pypi/v/cloudru-qa-httpx-curlify)](https://pypi.org/project/cloudru-qa-httpx-curlify/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/cloudru-qa-httpx-curlify)](https://pypi.org/project/cloudru-qa-httpx-curlify/)
27
+ [![License](https://img.shields.io/pypi/l/cloudru-qa-httpx-curlify)](https://github.com/cloud-ru-tech/qa-libraries/blob/master/LICENSE)
28
+
29
+ English | [Русский](README.ru.md)
30
+
31
+ Convert HTTP requests from `httpx.Request` to curl commands. Useful for debugging, logging, and documenting API requests.
32
+
33
+ - [httpx-curlify](#httpx-curlify)
34
+ - [Installation](#installation)
35
+ - [Skills for AI agents](#skills-for-ai-agents)
36
+ - [Usage](#usage)
37
+ - [Basic usage](#basic-usage)
38
+ - [Masking sensitive data](#masking-sensitive-data)
39
+ - [JSON formatting](#json-formatting)
40
+ - [Development](#development)
41
+
42
+ ## Installation
43
+
44
+ Requires Python 3.13 or later.
45
+
46
+ ```shell
47
+ pip install cloudru-qa-httpx-curlify
48
+ ```
49
+
50
+ Import the library as the module `httpx_curlify`.
51
+
52
+ ## Skills for AI agents
53
+
54
+ This library does not have its own skill yet. If your project uses other
55
+ libraries from this suite with [skills for AI agents](https://github.com/cloud-ru-tech/qa-libraries#skills-for-ai-agents),
56
+ select and enable them from your project root (requires [`uv`](https://docs.astral.sh/uv/)):
57
+
58
+ ```shell
59
+ uvx library-skills
60
+ ```
61
+
62
+ For Claude Code, add `--claude`.
63
+
64
+ ## Usage
65
+
66
+ ### Basic usage
67
+
68
+ Convert a GET request:
69
+
70
+ ```python
71
+ import httpx
72
+ from httpx_curlify import to_curl
73
+
74
+ request = httpx.Request("GET", "https://example.com/api/users")
75
+ curl_command = to_curl(request)
76
+ print(curl_command)
77
+ ```
78
+
79
+ Output:
80
+ ```bash
81
+ curl -X GET \
82
+ -H 'host: example.com' \
83
+ https://example.com/api/users
84
+ ```
85
+
86
+ A POST request with JSON:
87
+
88
+ ```python
89
+ request = httpx.Request(
90
+ "POST",
91
+ "https://example.com/api/users",
92
+ json={"name": "John", "age": 30},
93
+ )
94
+ curl_command = to_curl(request)
95
+ print(curl_command)
96
+ ```
97
+
98
+ Output:
99
+ ```bash
100
+ curl -X POST \
101
+ -H 'host: example.com' \
102
+ -H 'content-length: 24' \
103
+ -H 'content-type: application/json' \
104
+ --json '{"name": "John", "age": 30}' \
105
+ https://example.com/api/users
106
+ ```
107
+
108
+ A request with headers:
109
+
110
+ ```python
111
+ request = httpx.Request(
112
+ "GET",
113
+ "https://example.com/api/data",
114
+ headers={
115
+ "Authorization": "Bearer token123",
116
+ "X-Custom-Header": "custom-value",
117
+ },
118
+ )
119
+ curl_command = to_curl(request)
120
+ print(curl_command)
121
+ ```
122
+
123
+ ### Masking sensitive data
124
+
125
+ Mask sensitive data in headers and JSON request bodies using [glom](https://glom.readthedocs.io/en/latest/) syntax:
126
+
127
+ Mask headers:
128
+
129
+ ```python
130
+ request = httpx.Request(
131
+ "GET",
132
+ "https://example.com/api/data",
133
+ headers={
134
+ "Authorization": "Bearer secret_token",
135
+ "X-Api-Key": "api_key_value",
136
+ },
137
+ )
138
+ curl_command = to_curl(request, sanitize_keys=["authorization", "x-api-key"])
139
+ print(curl_command)
140
+ ```
141
+
142
+ Output:
143
+ ```bash
144
+ curl -X GET \
145
+ -H 'host: example.com' \
146
+ -H 'authorization: ****' \
147
+ -H 'x-api-key: ****' \
148
+ https://example.com/api/data
149
+ ```
150
+
151
+ Mask JSON data:
152
+
153
+ ```python
154
+ request = httpx.Request(
155
+ "POST",
156
+ "https://example.com/api/login",
157
+ json={"username": "john", "password": "secret123"},
158
+ )
159
+ curl_command = to_curl(request, sanitize_keys=["password"])
160
+ print(curl_command)
161
+ ```
162
+
163
+ Output:
164
+ ```bash
165
+ curl -X POST \
166
+ -H 'host: example.com' \
167
+ -H 'content-type: application/json' \
168
+ --json '{"username": "john", "password": "****"}' \
169
+ https://example.com/api/login
170
+ ```
171
+
172
+ Mask nested data:
173
+
174
+ ```python
175
+ request = httpx.Request(
176
+ "POST",
177
+ "https://example.com/api/data",
178
+ json={"user": {"name": "John", "password": "secret"}},
179
+ )
180
+ curl_command = to_curl(request, sanitize_keys=["user.password"])
181
+ print(curl_command)
182
+ ```
183
+
184
+ Output:
185
+ ```bash
186
+ curl -X POST \
187
+ -H 'host: example.com' \
188
+ -H 'content-type: application/json' \
189
+ --json '{"user": {"name": "John", "password": "****"}}' \
190
+ https://example.com/api/data
191
+ ```
192
+
193
+ ### JSON formatting
194
+
195
+ Pass additional options to `json.dumps`:
196
+
197
+ ```python
198
+ request = httpx.Request(
199
+ "POST",
200
+ "https://example.com/api/data",
201
+ json={"name": "John", "age": 30},
202
+ )
203
+ curl_command = to_curl(request, json_dumps_kwargs={"indent": 2})
204
+ print(curl_command)
205
+ ```
206
+
207
+ Output:
208
+ ```bash
209
+ curl -X POST \
210
+ -H 'host: example.com' \
211
+ -H 'content-type: application/json' \
212
+ --json '{
213
+ "name": "John",
214
+ "age": 30
215
+ }' \
216
+ https://example.com/api/data
217
+ ```
218
+
219
+ ## Development
220
+
221
+ This library is developed in the [qa-libraries](https://github.com/cloud-ru-tech/qa-libraries) monorepo
222
+ alongside the other `cloudru-qa-*` libraries. You need [`uv`](https://docs.astral.sh/uv/), GNU Make
223
+ and [`checkmake`](https://github.com/checkmake/checkmake);
224
+ run the commands from the repository root:
225
+
226
+ ```shell
227
+ make sync # dependencies for all libraries from uv.lock
228
+ make test-httpx-curlify # tests for this library
229
+ make typecheck-httpx-curlify # mypy for this library
230
+ make lint-fix # safe Ruff fixes
231
+ make check # formatting, Ruff, mypy, and tests for the whole suite
232
+ ```
233
+
234
+ Update snapshots with:
235
+
236
+ ```shell
237
+ make test-httpx-curlify PYTEST_ARGS=--snapshot-update
238
+ ```
239
+
240
+ Tests run on Python 3.13 and 3.14. Each version uses a separate environment:
241
+ `make sync test-httpx-curlify PYTHON_VERSION=3.14`.
242
+
243
+ Create a release with the Git tag `httpx-curlify/v<version>`. The tag version must match the version
244
+ in `packages/httpx-curlify/pyproject.toml`.
@@ -0,0 +1,222 @@
1
+ # httpx-curlify
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/cloudru-qa-httpx-curlify)](https://pypi.org/project/cloudru-qa-httpx-curlify/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/cloudru-qa-httpx-curlify)](https://pypi.org/project/cloudru-qa-httpx-curlify/)
5
+ [![License](https://img.shields.io/pypi/l/cloudru-qa-httpx-curlify)](https://github.com/cloud-ru-tech/qa-libraries/blob/master/LICENSE)
6
+
7
+ English | [Русский](README.ru.md)
8
+
9
+ Convert HTTP requests from `httpx.Request` to curl commands. Useful for debugging, logging, and documenting API requests.
10
+
11
+ - [httpx-curlify](#httpx-curlify)
12
+ - [Installation](#installation)
13
+ - [Skills for AI agents](#skills-for-ai-agents)
14
+ - [Usage](#usage)
15
+ - [Basic usage](#basic-usage)
16
+ - [Masking sensitive data](#masking-sensitive-data)
17
+ - [JSON formatting](#json-formatting)
18
+ - [Development](#development)
19
+
20
+ ## Installation
21
+
22
+ Requires Python 3.13 or later.
23
+
24
+ ```shell
25
+ pip install cloudru-qa-httpx-curlify
26
+ ```
27
+
28
+ Import the library as the module `httpx_curlify`.
29
+
30
+ ## Skills for AI agents
31
+
32
+ This library does not have its own skill yet. If your project uses other
33
+ libraries from this suite with [skills for AI agents](https://github.com/cloud-ru-tech/qa-libraries#skills-for-ai-agents),
34
+ select and enable them from your project root (requires [`uv`](https://docs.astral.sh/uv/)):
35
+
36
+ ```shell
37
+ uvx library-skills
38
+ ```
39
+
40
+ For Claude Code, add `--claude`.
41
+
42
+ ## Usage
43
+
44
+ ### Basic usage
45
+
46
+ Convert a GET request:
47
+
48
+ ```python
49
+ import httpx
50
+ from httpx_curlify import to_curl
51
+
52
+ request = httpx.Request("GET", "https://example.com/api/users")
53
+ curl_command = to_curl(request)
54
+ print(curl_command)
55
+ ```
56
+
57
+ Output:
58
+ ```bash
59
+ curl -X GET \
60
+ -H 'host: example.com' \
61
+ https://example.com/api/users
62
+ ```
63
+
64
+ A POST request with JSON:
65
+
66
+ ```python
67
+ request = httpx.Request(
68
+ "POST",
69
+ "https://example.com/api/users",
70
+ json={"name": "John", "age": 30},
71
+ )
72
+ curl_command = to_curl(request)
73
+ print(curl_command)
74
+ ```
75
+
76
+ Output:
77
+ ```bash
78
+ curl -X POST \
79
+ -H 'host: example.com' \
80
+ -H 'content-length: 24' \
81
+ -H 'content-type: application/json' \
82
+ --json '{"name": "John", "age": 30}' \
83
+ https://example.com/api/users
84
+ ```
85
+
86
+ A request with headers:
87
+
88
+ ```python
89
+ request = httpx.Request(
90
+ "GET",
91
+ "https://example.com/api/data",
92
+ headers={
93
+ "Authorization": "Bearer token123",
94
+ "X-Custom-Header": "custom-value",
95
+ },
96
+ )
97
+ curl_command = to_curl(request)
98
+ print(curl_command)
99
+ ```
100
+
101
+ ### Masking sensitive data
102
+
103
+ Mask sensitive data in headers and JSON request bodies using [glom](https://glom.readthedocs.io/en/latest/) syntax:
104
+
105
+ Mask headers:
106
+
107
+ ```python
108
+ request = httpx.Request(
109
+ "GET",
110
+ "https://example.com/api/data",
111
+ headers={
112
+ "Authorization": "Bearer secret_token",
113
+ "X-Api-Key": "api_key_value",
114
+ },
115
+ )
116
+ curl_command = to_curl(request, sanitize_keys=["authorization", "x-api-key"])
117
+ print(curl_command)
118
+ ```
119
+
120
+ Output:
121
+ ```bash
122
+ curl -X GET \
123
+ -H 'host: example.com' \
124
+ -H 'authorization: ****' \
125
+ -H 'x-api-key: ****' \
126
+ https://example.com/api/data
127
+ ```
128
+
129
+ Mask JSON data:
130
+
131
+ ```python
132
+ request = httpx.Request(
133
+ "POST",
134
+ "https://example.com/api/login",
135
+ json={"username": "john", "password": "secret123"},
136
+ )
137
+ curl_command = to_curl(request, sanitize_keys=["password"])
138
+ print(curl_command)
139
+ ```
140
+
141
+ Output:
142
+ ```bash
143
+ curl -X POST \
144
+ -H 'host: example.com' \
145
+ -H 'content-type: application/json' \
146
+ --json '{"username": "john", "password": "****"}' \
147
+ https://example.com/api/login
148
+ ```
149
+
150
+ Mask nested data:
151
+
152
+ ```python
153
+ request = httpx.Request(
154
+ "POST",
155
+ "https://example.com/api/data",
156
+ json={"user": {"name": "John", "password": "secret"}},
157
+ )
158
+ curl_command = to_curl(request, sanitize_keys=["user.password"])
159
+ print(curl_command)
160
+ ```
161
+
162
+ Output:
163
+ ```bash
164
+ curl -X POST \
165
+ -H 'host: example.com' \
166
+ -H 'content-type: application/json' \
167
+ --json '{"user": {"name": "John", "password": "****"}}' \
168
+ https://example.com/api/data
169
+ ```
170
+
171
+ ### JSON formatting
172
+
173
+ Pass additional options to `json.dumps`:
174
+
175
+ ```python
176
+ request = httpx.Request(
177
+ "POST",
178
+ "https://example.com/api/data",
179
+ json={"name": "John", "age": 30},
180
+ )
181
+ curl_command = to_curl(request, json_dumps_kwargs={"indent": 2})
182
+ print(curl_command)
183
+ ```
184
+
185
+ Output:
186
+ ```bash
187
+ curl -X POST \
188
+ -H 'host: example.com' \
189
+ -H 'content-type: application/json' \
190
+ --json '{
191
+ "name": "John",
192
+ "age": 30
193
+ }' \
194
+ https://example.com/api/data
195
+ ```
196
+
197
+ ## Development
198
+
199
+ This library is developed in the [qa-libraries](https://github.com/cloud-ru-tech/qa-libraries) monorepo
200
+ alongside the other `cloudru-qa-*` libraries. You need [`uv`](https://docs.astral.sh/uv/), GNU Make
201
+ and [`checkmake`](https://github.com/checkmake/checkmake);
202
+ run the commands from the repository root:
203
+
204
+ ```shell
205
+ make sync # dependencies for all libraries from uv.lock
206
+ make test-httpx-curlify # tests for this library
207
+ make typecheck-httpx-curlify # mypy for this library
208
+ make lint-fix # safe Ruff fixes
209
+ make check # formatting, Ruff, mypy, and tests for the whole suite
210
+ ```
211
+
212
+ Update snapshots with:
213
+
214
+ ```shell
215
+ make test-httpx-curlify PYTEST_ARGS=--snapshot-update
216
+ ```
217
+
218
+ Tests run on Python 3.13 and 3.14. Each version uses a separate environment:
219
+ `make sync test-httpx-curlify PYTHON_VERSION=3.14`.
220
+
221
+ Create a release with the Git tag `httpx-curlify/v<version>`. The tag version must match the version
222
+ in `packages/httpx-curlify/pyproject.toml`.
@@ -0,0 +1,3 @@
1
+ from .curlify import to_curl
2
+
3
+ __all__ = ["to_curl"]
@@ -0,0 +1,89 @@
1
+ import json
2
+ from collections.abc import Callable, Sequence
3
+ from shlex import quote
4
+ from typing import TypedDict, Unpack, cast
5
+
6
+ import httpx
7
+
8
+ from .sanitaizer import sanitize
9
+
10
+
11
+ class JsonDumpsOptions(TypedDict, total=False):
12
+ """JSON serialization options passed to ``json.dumps``."""
13
+
14
+ skipkeys: bool
15
+ ensure_ascii: bool
16
+ check_circular: bool
17
+ allow_nan: bool
18
+ indent: int | str | None
19
+ separators: tuple[str, str] | None
20
+ default: Callable[[object], object] | None
21
+ cls: type[json.JSONEncoder] | None
22
+ sort_keys: bool
23
+
24
+
25
+ def to_curl(
26
+ request: httpx.Request,
27
+ sanitize_keys: Sequence[str] = (),
28
+ json_dumps_kwargs: JsonDumpsOptions | None = None,
29
+ ) -> str:
30
+ """Serialize an HTTP request to a curl command string.
31
+
32
+ :param request: HTTP request
33
+ :param sanitize_keys: Keys for masking secrets in the request
34
+ :param json_dumps_kwargs: Options for json.dumps when the request has content-type = application/json
35
+ :return: Curl command string
36
+ """
37
+ command = ["-X " + quote(request.method)]
38
+
39
+ content_type: str | None = None
40
+
41
+ headers = sanitize(dict(request.headers), sanitize_keys)
42
+ for key, value in headers.items():
43
+ if value:
44
+ command.append("-H " + quote(f"{key}: {value}"))
45
+ else:
46
+ command.append("-H " + quote(f"{key};"))
47
+
48
+ if key.lower() == "content-type":
49
+ content_type = value.lower()
50
+
51
+ if content_type:
52
+ request_body = request.read()
53
+ try:
54
+ payload = request_body.decode("utf-8")
55
+ except UnicodeDecodeError:
56
+ payload = f"<binary data {len(request_body)} bytes>"
57
+
58
+ if content_type.startswith("application/json"):
59
+ if json_dumps_kwargs is None:
60
+ json_dumps_kwargs = {}
61
+
62
+ command.append(f"--json {quote(_format_json(payload, sanitize_keys, **json_dumps_kwargs))}")
63
+ else:
64
+ command.append(f"--data {quote(payload)}")
65
+
66
+ command.append(quote(str(request.url)))
67
+
68
+ return "curl " + " \\\n ".join(command)
69
+
70
+
71
+ def _format_json(payload: str, sanitize_keys: Sequence[str], **kwargs: Unpack[JsonDumpsOptions]) -> str:
72
+ """Format a JSON string for readable output in a curl command.
73
+
74
+ :param payload: JSON string
75
+ :param sanitize_keys: Keys for masking secrets in JSON
76
+ :param kwargs: Options for json.dumps
77
+ :return: Formatted JSON string
78
+ """
79
+ json_payload = sanitize(cast("object", json.loads(payload)), sanitize_keys)
80
+
81
+ formatted_payload = json.dumps(json_payload, **kwargs)
82
+
83
+ lines = formatted_payload.split("\n")
84
+ formatted_lines = lines[:1]
85
+
86
+ for line in lines[1:]:
87
+ formatted_lines.append(f" {line}")
88
+
89
+ return "\n".join(formatted_lines)
@@ -0,0 +1 @@
1
+ This marker file indicates that the package supports type checking under PEP 561
@@ -0,0 +1,38 @@
1
+ from collections.abc import Sequence
2
+ from copy import deepcopy
3
+
4
+ import glom
5
+
6
+
7
+ def sanitize[T](
8
+ value_to_sanitize: T,
9
+ sensitive_keys: Sequence[str],
10
+ ) -> T:
11
+ """Create a new dictionary with sensitive data masked.
12
+
13
+ :param value_to_sanitize: Dictionary or list to process.
14
+ :param sensitive_keys: Keys in [glom](https://glom.readthedocs.io/en/latest/) format, to mask.
15
+ :returns: New dictionary or list with sensitive data masked.
16
+
17
+ #### Examples:
18
+ >>> result = sanitize({"d": {"password": "123123"}, "v": 123123}, ["d.password"])
19
+ >>> assert result == {'d': {'password': '****'}, "v": 123123}
20
+
21
+ >>> result = sanitize([{"d": {"passwd": "123123"}, "v": 123123}], ["*.d.passwd"])
22
+ >>> assert result == [{'d': {'passwd': '****'}, "v": 123123}]
23
+
24
+ >>> result = sanitize({"d": [{"passwd": "123123"}, {"passwd": "123123"}]}, ["d.*.passwd"])
25
+ >>> assert result == {"d": [{"passwd": "****"}, {"passwd": "****"}]}
26
+ """
27
+ if len(sensitive_keys) == 0:
28
+ return value_to_sanitize
29
+
30
+ value = deepcopy(value_to_sanitize)
31
+
32
+ for sensitive_key in sensitive_keys:
33
+ if glom.glom(value, sensitive_key, default=None) is None:
34
+ continue
35
+
36
+ glom.assign(value, sensitive_key, "****")
37
+
38
+ return value
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "cloudru-qa-httpx-curlify"
3
+ version = "1.0.0"
4
+ description = "Преобразование запросов httpx.Request в команды curl"
5
+ authors = [
6
+ { name = "Алексей Кирютин", email = "ayukiryutin@cloud.ru" },
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "glom>=24.11.0",
12
+ "httpx>=0.28.1,<0.29",
13
+ ]
14
+ license = "Apache-2.0"
15
+ license-files = [
16
+ "LICENSE",
17
+ ]
18
+ classifiers = [
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Internet :: WWW/HTTP",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Typing :: Typed",
27
+ ]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/cloud-ru-tech/qa-libraries"
31
+ Issues = "https://github.com/cloud-ru-tech/qa-libraries/issues"
32
+
33
+ [build-system]
34
+ requires = [
35
+ "hatchling>=1.33.0,<2",
36
+ ]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = [
41
+ "httpx_curlify",
42
+ ]
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ include = [
46
+ "/httpx_curlify",
47
+ "/tests",
48
+ "/README.md",
49
+ "/LICENSE",
50
+ "/pyproject.toml",
51
+ ]
52
+
53
+ [tool.pytest.ini_options]
54
+ asyncio_default_fixture_loop_scope = "function"
55
+ testpaths = [
56
+ "tests",
57
+ ]
58
+ addopts = "--strict-markers --strict-config"
59
+ filterwarnings = [
60
+ "error",
61
+ ]
@@ -0,0 +1 @@
1
+ # Module for unit tests
@@ -0,0 +1,3 @@
1
+ curl -X DELETE \
2
+ -H 'host: example.com' \
3
+ https://example.com/api/users/123
@@ -0,0 +1,7 @@
1
+ curl -X POST \
2
+ -H 'host: example.com' \
3
+ -H 'authorization: Bearer token' \
4
+ -H 'content-length: 15' \
5
+ -H 'content-type: application/json' \
6
+ --json '{"key": "value"}' \
7
+ https://example.com/api/data
@@ -0,0 +1,6 @@
1
+ curl -X POST \
2
+ -H 'host: example.com' \
3
+ -H 'content-length: 27' \
4
+ -H 'content-type: application/x-www-form-urlencoded' \
5
+ --data 'field1=value1&field2=value2' \
6
+ https://example.com/api/form
@@ -0,0 +1,6 @@
1
+ curl -X POST \
2
+ -H 'host: example.com' \
3
+ -H 'content-length: 24' \
4
+ -H 'content-type: application/json' \
5
+ --json '{"name": "John", "age": 30}' \
6
+ https://example.com/api/users
@@ -0,0 +1,6 @@
1
+ curl -X PUT \
2
+ -H 'host: example.com' \
3
+ -H 'content-length: 23' \
4
+ -H 'content-type: application/json' \
5
+ --json '{"name": "Updated Name"}' \
6
+ https://example.com/api/users/123
@@ -0,0 +1,3 @@
1
+ curl -X GET \
2
+ -H 'host: example.com' \
3
+ https://example.com/api/users
@@ -0,0 +1,6 @@
1
+ curl -X POST \
2
+ -H 'host: example.com' \
3
+ -H 'content-type: application/octet-stream' \
4
+ -H 'content-length: 4' \
5
+ --data '<binary data 4 bytes>' \
6
+ https://example.com/api/upload
@@ -0,0 +1,5 @@
1
+ curl -X GET \
2
+ -H 'host: example.com' \
3
+ -H 'authorization: Bearer token123' \
4
+ -H 'x-custom-header: custom-value' \
5
+ https://example.com/api/data
@@ -0,0 +1,4 @@
1
+ curl -X GET \
2
+ -H 'host: example.com' \
3
+ -H 'x-empty;' \
4
+ https://example.com/api/data
@@ -0,0 +1,9 @@
1
+ curl -X POST \
2
+ -H 'host: example.com' \
3
+ -H 'content-length: 24' \
4
+ -H 'content-type: application/json' \
5
+ --json '{
6
+ "name": "John",
7
+ "age": 30
8
+ }' \
9
+ https://example.com/api/data
@@ -0,0 +1,6 @@
1
+ curl -X POST \
2
+ -H 'host: example.com' \
3
+ -H 'content-length: 44' \
4
+ -H 'content-type: application/json' \
5
+ --json '{"user": {"name": "John", "password": "****"}}' \
6
+ https://example.com/api/data
@@ -0,0 +1,3 @@
1
+ curl -X GET \
2
+ -H 'host: example.com' \
3
+ 'https://example.com/api/search?q=test+query&page=1'
@@ -0,0 +1,5 @@
1
+ curl -X GET \
2
+ -H 'host: example.com' \
3
+ -H 'authorization: ****' \
4
+ -H 'x-api-key: ****' \
5
+ https://example.com/api/data
@@ -0,0 +1,6 @@
1
+ curl -X POST \
2
+ -H 'host: example.com' \
3
+ -H 'content-length: 42' \
4
+ -H 'content-type: application/json' \
5
+ --json '{"username": "john", "password": "****"}' \
6
+ https://example.com/api/login
@@ -0,0 +1,3 @@
1
+ curl -X GET \
2
+ -H 'host: example.com' \
3
+ 'https://example.com/api/search?q=test&filter=name'
@@ -0,0 +1,16 @@
1
+ import pytest
2
+ from syrupy.assertion import SnapshotAssertion
3
+ from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode
4
+
5
+
6
+ class TextSnapshotExtension(SingleFileSnapshotExtension):
7
+ """Extension for creating snapshots as separate text files."""
8
+
9
+ _file_extension = "txt"
10
+ _write_mode = WriteMode.TEXT
11
+
12
+
13
+ @pytest.fixture()
14
+ def snapshot(snapshot: SnapshotAssertion) -> SnapshotAssertion:
15
+ """Fixture for text snapshots."""
16
+ return snapshot.use_extension(TextSnapshotExtension)
@@ -0,0 +1,219 @@
1
+ import httpx
2
+ from assertpy import assert_that
3
+ from httpx_curlify import to_curl
4
+ from syrupy.assertion import SnapshotAssertion
5
+
6
+
7
+ def test_to_curl_simple_get_request(snapshot: SnapshotAssertion) -> None:
8
+ """Convert a simple GET request."""
9
+ request = httpx.Request("GET", "https://example.com/api/users")
10
+
11
+ result = to_curl(request)
12
+
13
+ assert_that(result).is_equal_to(snapshot)
14
+
15
+
16
+ def test_to_curl_post_request_with_json(snapshot: SnapshotAssertion) -> None:
17
+ """Convert a POST request with a JSON body."""
18
+ request = httpx.Request(
19
+ "POST",
20
+ "https://example.com/api/users",
21
+ json=dict[str, object]({"name": "John", "age": 30}),
22
+ )
23
+
24
+ result = to_curl(request)
25
+
26
+ assert_that(result).is_equal_to(snapshot)
27
+
28
+
29
+ def test_to_curl_with_custom_headers(snapshot: SnapshotAssertion) -> None:
30
+ """Convert a request with custom headers."""
31
+ request = httpx.Request(
32
+ "GET",
33
+ "https://example.com/api/data",
34
+ headers={
35
+ "Authorization": "Bearer token123",
36
+ "X-Custom-Header": "custom-value",
37
+ },
38
+ )
39
+
40
+ result = to_curl(request)
41
+
42
+ assert_that(result).is_equal_to(snapshot)
43
+
44
+
45
+ def test_to_curl_with_empty_header_value(snapshot: SnapshotAssertion) -> None:
46
+ """Convert a request with an empty header value."""
47
+ request = httpx.Request(
48
+ "GET",
49
+ "https://example.com/api/data",
50
+ headers={"X-Empty": ""},
51
+ )
52
+
53
+ result = to_curl(request)
54
+
55
+ assert_that(result).is_equal_to(snapshot)
56
+
57
+
58
+ def test_to_curl_post_request_with_form_data(snapshot: SnapshotAssertion) -> None:
59
+ """Convert a POST request with form data."""
60
+ request = httpx.Request(
61
+ "POST",
62
+ "https://example.com/api/form",
63
+ data=dict[str, object]({"field1": "value1", "field2": "value2"}),
64
+ )
65
+
66
+ result = to_curl(request)
67
+
68
+ assert_that(result).is_equal_to(snapshot)
69
+
70
+
71
+ def test_to_curl_with_sanitize_keys_in_headers(snapshot: SnapshotAssertion) -> None:
72
+ """Convert a request while masking sensitive header data."""
73
+ request = httpx.Request(
74
+ "GET",
75
+ "https://example.com/api/data",
76
+ headers={
77
+ "Authorization": "Bearer secret_token",
78
+ "X-Api-Key": "api_key_value",
79
+ },
80
+ )
81
+
82
+ result = to_curl(request, sanitize_keys=["authorization", "x-api-key"])
83
+
84
+ assert_that(result).is_equal_to(snapshot)
85
+
86
+
87
+ def test_to_curl_with_sanitize_keys_in_json(snapshot: SnapshotAssertion) -> None:
88
+ """Convert a request while masking sensitive JSON data."""
89
+ request = httpx.Request(
90
+ "POST",
91
+ "https://example.com/api/login",
92
+ json=dict[str, object]({"username": "john", "password": "secret123"}),
93
+ )
94
+
95
+ result = to_curl(request, sanitize_keys=["password"])
96
+
97
+ assert_that(result).is_equal_to(snapshot)
98
+
99
+
100
+ def test_to_curl_with_nested_sanitize_keys(snapshot: SnapshotAssertion) -> None:
101
+ """Convert a request while masking nested sensitive data."""
102
+ request = httpx.Request(
103
+ "POST",
104
+ "https://example.com/api/data",
105
+ json=dict[str, object]({"user": {"name": "John", "password": "secret"}}),
106
+ )
107
+
108
+ result = to_curl(request, sanitize_keys=["user.password"])
109
+
110
+ assert_that(result).is_equal_to(snapshot)
111
+
112
+
113
+ def test_to_curl_with_json_dumps_kwargs(snapshot: SnapshotAssertion) -> None:
114
+ """Convert a request with JSON formatting options."""
115
+ request = httpx.Request(
116
+ "POST",
117
+ "https://example.com/api/data",
118
+ json=dict[str, object]({"name": "John", "age": 30}),
119
+ )
120
+
121
+ result = to_curl(request, json_dumps_kwargs={"indent": 2})
122
+
123
+ assert_that(result).is_equal_to(snapshot)
124
+
125
+
126
+ def test_to_curl_put_request(snapshot: SnapshotAssertion) -> None:
127
+ """Convert a PUT request."""
128
+ request = httpx.Request(
129
+ "PUT",
130
+ "https://example.com/api/users/123",
131
+ json=dict[str, object]({"name": "Updated Name"}),
132
+ )
133
+
134
+ result = to_curl(request)
135
+
136
+ assert_that(result).is_equal_to(snapshot)
137
+
138
+
139
+ def test_to_curl_delete_request(snapshot: SnapshotAssertion) -> None:
140
+ """Convert a DELETE request."""
141
+ request = httpx.Request("DELETE", "https://example.com/api/users/123")
142
+
143
+ result = to_curl(request)
144
+
145
+ assert_that(result).is_equal_to(snapshot)
146
+
147
+
148
+ def test_to_curl_with_query_parameters(snapshot: SnapshotAssertion) -> None:
149
+ """Convert a request with query parameters."""
150
+ request = httpx.Request(
151
+ "GET",
152
+ "https://example.com/api/search",
153
+ params={"q": "test query", "page": "1"},
154
+ )
155
+
156
+ result = to_curl(request)
157
+
158
+ assert_that(result).is_equal_to(snapshot)
159
+
160
+
161
+ def test_to_curl_with_binary_data(snapshot: SnapshotAssertion) -> None:
162
+ """Convert a request with binary data."""
163
+ binary_data = b"\x80\x81\x82\x83"
164
+ request = httpx.Request(
165
+ "POST",
166
+ "https://example.com/api/upload",
167
+ content=binary_data,
168
+ headers={"Content-Type": "application/octet-stream"},
169
+ )
170
+
171
+ result = to_curl(request)
172
+
173
+ assert_that(result).is_equal_to(snapshot)
174
+
175
+
176
+ def test_to_curl_multiline_format(snapshot: SnapshotAssertion) -> None:
177
+ """Check that the result contains line breaks."""
178
+ request = httpx.Request(
179
+ "POST",
180
+ "https://example.com/api/data",
181
+ json=dict[str, object]({"key": "value"}),
182
+ headers={"Authorization": "Bearer token"},
183
+ )
184
+
185
+ result = to_curl(request)
186
+
187
+ assert_that(result).is_equal_to(snapshot)
188
+
189
+
190
+ def test_to_curl_with_special_characters_in_url(snapshot: SnapshotAssertion) -> None:
191
+ """Convert a request with special characters in its URL."""
192
+ request = httpx.Request("GET", "https://example.com/api/search?q=test&filter=name")
193
+
194
+ result = to_curl(request)
195
+
196
+ assert_that(result).is_equal_to(snapshot)
197
+
198
+
199
+ def test_sanitization_preserves_request_body() -> None:
200
+ request = httpx.Request(
201
+ "POST", "https://example.com/private", json=dict[str, object]({"password": "keep-original"})
202
+ )
203
+ original = request.read()
204
+
205
+ to_curl(request, sanitize_keys=["password"])
206
+
207
+ assert_that(request.read()).is_equal_to(original)
208
+
209
+
210
+ def test_hides_secrets_inside_json_arrays() -> None:
211
+ request = httpx.Request(
212
+ "POST",
213
+ "https://example.com/keys",
214
+ json=dict[str, object]({"users": [dict[str, object]({"token": "array-secret"})]}),
215
+ )
216
+
217
+ command = to_curl(request, sanitize_keys=["users.*.token"])
218
+
219
+ assert_that(command).contains("****").does_not_contain("array-secret")