httpx-debug 0.1.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.
- httpx_debug-0.1.0/.github/workflows/ci.yml +20 -0
- httpx_debug-0.1.0/.github/workflows/release.yml +18 -0
- httpx_debug-0.1.0/.gitignore +7 -0
- httpx_debug-0.1.0/LICENSE +21 -0
- httpx_debug-0.1.0/PKG-INFO +148 -0
- httpx_debug-0.1.0/README.md +130 -0
- httpx_debug-0.1.0/docs/superpowers/specs/2026-07-16-httpx-debug-design.md +99 -0
- httpx_debug-0.1.0/pyproject.toml +34 -0
- httpx_debug-0.1.0/src/httpx_debug/__init__.py +14 -0
- httpx_debug-0.1.0/src/httpx_debug/_clipboard.py +81 -0
- httpx_debug-0.1.0/src/httpx_debug/_curl.py +78 -0
- httpx_debug-0.1.0/src/httpx_debug/_hooks.py +149 -0
- httpx_debug-0.1.0/tests/test_clipboard.py +83 -0
- httpx_debug-0.1.0/tests/test_copy_redact.py +50 -0
- httpx_debug-0.1.0/tests/test_curl.py +108 -0
- httpx_debug-0.1.0/tests/test_hooks.py +127 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: uv sync
|
|
20
|
+
- run: uv run pytest -q
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write # PyPI Trusted Publishing
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: astral-sh/setup-uv@v5
|
|
16
|
+
- run: uv sync && uv run pytest -q
|
|
17
|
+
- run: uv build
|
|
18
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mayur Rawte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: httpx-debug
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Failure-aware curl output for httpx: reproduce any (failing) request as a paste-able, secret-redacted curl command — printed or copied to your clipboard, even over SSH.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mayurrawte/httpx-debug
|
|
6
|
+
Author: Mayur Rawte
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: clipboard,curl,debug,debugging,devtools,httpx
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
14
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: httpx>=0.23
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# httpx-debug
|
|
20
|
+
|
|
21
|
+
**Reproduce any httpx request — especially the failing ones — as a paste-able curl command.**
|
|
22
|
+
|
|
23
|
+
Stop print-debugging your HTTP calls:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
# before
|
|
27
|
+
print(self._headers())
|
|
28
|
+
print(params)
|
|
29
|
+
print(self.base_url)
|
|
30
|
+
print(path)
|
|
31
|
+
|
|
32
|
+
# after
|
|
33
|
+
import httpx_debug
|
|
34
|
+
httpx_debug.attach(client) # failing requests print a ready-to-paste curl command
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
When a request fails (4xx / 5xx / timeout / connection error), `httpx-debug` prints — and optionally copies to your clipboard — the exact `curl` command that reproduces it, with your secrets redacted:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
# httpx-debug: 401 Unauthorized ← POST https://api.example.com/v1/users
|
|
41
|
+
curl -X POST -H 'Authorization: <redacted>' -H 'Content-Type: application/json' -d '{"name": "mayur"}' https://api.example.com/v1/users
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Why not curlify?
|
|
45
|
+
|
|
46
|
+
[curlify](https://pypi.org/project/curlify/) and friends convert a request object to curl — and that's it. `httpx-debug` is the debugging layer around that:
|
|
47
|
+
|
|
48
|
+
- **Failure-aware** — attach once, hear about it only when something breaks. No call-site changes.
|
|
49
|
+
- **Secret redaction by default** — `Authorization`, `Cookie`, `X-API-Key`, … become `<redacted>` so you can paste the command into an issue or Slack without leaking credentials.
|
|
50
|
+
- **Clipboard, even over SSH** — uses `pbcopy`/`xclip`/`wl-copy`/`clip` locally and falls back to the OSC 52 terminal escape, which copies to your *local* clipboard from a remote shell.
|
|
51
|
+
- **Async-first** — works identically with `httpx.Client` and `httpx.AsyncClient`.
|
|
52
|
+
- **Sees exceptions too** — the drop-in `httpx_debug.Client` / `AsyncClient` also capture timeouts and connection errors, not just error status codes.
|
|
53
|
+
|
|
54
|
+
Works anywhere httpx does — including under the **OpenAI and Anthropic SDKs**, which accept a custom `http_client`. Ever wondered what your LLM call actually sends over the wire?
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import httpx, httpx_debug, openai
|
|
58
|
+
|
|
59
|
+
http_client = httpx.Client()
|
|
60
|
+
httpx_debug.attach(http_client, on="all")
|
|
61
|
+
client = openai.OpenAI(http_client=http_client)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install httpx-debug
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Zero dependencies beyond httpx itself.
|
|
71
|
+
|
|
72
|
+
## Usage
|
|
73
|
+
|
|
74
|
+
### Convert a request to curl
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import httpx_debug
|
|
78
|
+
|
|
79
|
+
curl = httpx_debug.to_curl(request) # secrets redacted
|
|
80
|
+
curl = httpx_debug.to_curl(request, redact=False) # raw
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Copy to clipboard
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
httpx_debug.copy(request) # returns True if a clipboard was reachable
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Attach to any client (yours or an SDK's)
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
client = httpx.AsyncClient()
|
|
93
|
+
httpx_debug.attach(client) # print curl for 4xx/5xx responses
|
|
94
|
+
httpx_debug.attach(client, on="all") # ...or for every request
|
|
95
|
+
httpx_debug.attach(client, copy=True) # also copy to clipboard
|
|
96
|
+
httpx_debug.detach(client) # remove
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`attach` uses httpx event hooks, so it can't observe transport exceptions (timeouts, DNS failures). For those, use the drop-in clients:
|
|
100
|
+
|
|
101
|
+
### Drop-in clients (also capture timeouts & connection errors)
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
client = httpx_debug.AsyncClient(base_url="https://api.example.com")
|
|
105
|
+
# behaves exactly like httpx.AsyncClient, plus curl output on any failure
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Options
|
|
109
|
+
|
|
110
|
+
| Option | Default | |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| `on` | `"error"` | `"error"` (status ≥ 400 + exceptions) or `"all"` |
|
|
113
|
+
| `copy` | `False` | also copy the curl command to the clipboard |
|
|
114
|
+
| `redact` | `True` | replace sensitive header values with `<redacted>` |
|
|
115
|
+
| `copy_redact` | follows `redact` | redaction for the *clipboard* copy specifically |
|
|
116
|
+
| `redact_headers` | built-in set | which headers count as sensitive (lowercase) |
|
|
117
|
+
| `file` | `sys.stderr` | where to print |
|
|
118
|
+
|
|
119
|
+
On the drop-in clients the same options are prefixed: `debug_on`, `debug_copy`, `debug_redact`, `debug_copy_redact`, `debug_redact_headers`, `debug_file`.
|
|
120
|
+
|
|
121
|
+
### Print safe, copy replayable
|
|
122
|
+
|
|
123
|
+
The printed command ends up in logs and screenshots — keep it redacted. The clipboard's job is *replaying* the request, which needs real credentials:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
httpx_debug.attach(client, copy=True, copy_redact=False)
|
|
127
|
+
# terminal: Authorization: <redacted> ← safe to paste in an issue
|
|
128
|
+
# clipboard: Authorization: Bearer sk-… ← actually runs
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Raw copying is always opt-in — remember clipboard managers keep history.
|
|
132
|
+
|
|
133
|
+
## Notes
|
|
134
|
+
|
|
135
|
+
- Streamed request bodies are never consumed by httpx-debug; the curl output notes `# request body was streamed and is not shown`.
|
|
136
|
+
- Non-UTF-8 bodies are omitted with a byte-count comment.
|
|
137
|
+
- Debug tooling must never break your app: clipboard and hook failures are swallowed.
|
|
138
|
+
|
|
139
|
+
## Roadmap
|
|
140
|
+
|
|
141
|
+
- pytest plugin: show the curl of the last failed request under the traceback
|
|
142
|
+
- response capture alongside the request
|
|
143
|
+
- `requests` support
|
|
144
|
+
- a Node.js sibling for server-side `fetch`
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# httpx-debug
|
|
2
|
+
|
|
3
|
+
**Reproduce any httpx request — especially the failing ones — as a paste-able curl command.**
|
|
4
|
+
|
|
5
|
+
Stop print-debugging your HTTP calls:
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
# before
|
|
9
|
+
print(self._headers())
|
|
10
|
+
print(params)
|
|
11
|
+
print(self.base_url)
|
|
12
|
+
print(path)
|
|
13
|
+
|
|
14
|
+
# after
|
|
15
|
+
import httpx_debug
|
|
16
|
+
httpx_debug.attach(client) # failing requests print a ready-to-paste curl command
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
When a request fails (4xx / 5xx / timeout / connection error), `httpx-debug` prints — and optionally copies to your clipboard — the exact `curl` command that reproduces it, with your secrets redacted:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
# httpx-debug: 401 Unauthorized ← POST https://api.example.com/v1/users
|
|
23
|
+
curl -X POST -H 'Authorization: <redacted>' -H 'Content-Type: application/json' -d '{"name": "mayur"}' https://api.example.com/v1/users
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Why not curlify?
|
|
27
|
+
|
|
28
|
+
[curlify](https://pypi.org/project/curlify/) and friends convert a request object to curl — and that's it. `httpx-debug` is the debugging layer around that:
|
|
29
|
+
|
|
30
|
+
- **Failure-aware** — attach once, hear about it only when something breaks. No call-site changes.
|
|
31
|
+
- **Secret redaction by default** — `Authorization`, `Cookie`, `X-API-Key`, … become `<redacted>` so you can paste the command into an issue or Slack without leaking credentials.
|
|
32
|
+
- **Clipboard, even over SSH** — uses `pbcopy`/`xclip`/`wl-copy`/`clip` locally and falls back to the OSC 52 terminal escape, which copies to your *local* clipboard from a remote shell.
|
|
33
|
+
- **Async-first** — works identically with `httpx.Client` and `httpx.AsyncClient`.
|
|
34
|
+
- **Sees exceptions too** — the drop-in `httpx_debug.Client` / `AsyncClient` also capture timeouts and connection errors, not just error status codes.
|
|
35
|
+
|
|
36
|
+
Works anywhere httpx does — including under the **OpenAI and Anthropic SDKs**, which accept a custom `http_client`. Ever wondered what your LLM call actually sends over the wire?
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import httpx, httpx_debug, openai
|
|
40
|
+
|
|
41
|
+
http_client = httpx.Client()
|
|
42
|
+
httpx_debug.attach(http_client, on="all")
|
|
43
|
+
client = openai.OpenAI(http_client=http_client)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install httpx-debug
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Zero dependencies beyond httpx itself.
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
### Convert a request to curl
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
import httpx_debug
|
|
60
|
+
|
|
61
|
+
curl = httpx_debug.to_curl(request) # secrets redacted
|
|
62
|
+
curl = httpx_debug.to_curl(request, redact=False) # raw
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Copy to clipboard
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
httpx_debug.copy(request) # returns True if a clipboard was reachable
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Attach to any client (yours or an SDK's)
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
client = httpx.AsyncClient()
|
|
75
|
+
httpx_debug.attach(client) # print curl for 4xx/5xx responses
|
|
76
|
+
httpx_debug.attach(client, on="all") # ...or for every request
|
|
77
|
+
httpx_debug.attach(client, copy=True) # also copy to clipboard
|
|
78
|
+
httpx_debug.detach(client) # remove
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`attach` uses httpx event hooks, so it can't observe transport exceptions (timeouts, DNS failures). For those, use the drop-in clients:
|
|
82
|
+
|
|
83
|
+
### Drop-in clients (also capture timeouts & connection errors)
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
client = httpx_debug.AsyncClient(base_url="https://api.example.com")
|
|
87
|
+
# behaves exactly like httpx.AsyncClient, plus curl output on any failure
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Options
|
|
91
|
+
|
|
92
|
+
| Option | Default | |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| `on` | `"error"` | `"error"` (status ≥ 400 + exceptions) or `"all"` |
|
|
95
|
+
| `copy` | `False` | also copy the curl command to the clipboard |
|
|
96
|
+
| `redact` | `True` | replace sensitive header values with `<redacted>` |
|
|
97
|
+
| `copy_redact` | follows `redact` | redaction for the *clipboard* copy specifically |
|
|
98
|
+
| `redact_headers` | built-in set | which headers count as sensitive (lowercase) |
|
|
99
|
+
| `file` | `sys.stderr` | where to print |
|
|
100
|
+
|
|
101
|
+
On the drop-in clients the same options are prefixed: `debug_on`, `debug_copy`, `debug_redact`, `debug_copy_redact`, `debug_redact_headers`, `debug_file`.
|
|
102
|
+
|
|
103
|
+
### Print safe, copy replayable
|
|
104
|
+
|
|
105
|
+
The printed command ends up in logs and screenshots — keep it redacted. The clipboard's job is *replaying* the request, which needs real credentials:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
httpx_debug.attach(client, copy=True, copy_redact=False)
|
|
109
|
+
# terminal: Authorization: <redacted> ← safe to paste in an issue
|
|
110
|
+
# clipboard: Authorization: Bearer sk-… ← actually runs
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Raw copying is always opt-in — remember clipboard managers keep history.
|
|
114
|
+
|
|
115
|
+
## Notes
|
|
116
|
+
|
|
117
|
+
- Streamed request bodies are never consumed by httpx-debug; the curl output notes `# request body was streamed and is not shown`.
|
|
118
|
+
- Non-UTF-8 bodies are omitted with a byte-count comment.
|
|
119
|
+
- Debug tooling must never break your app: clipboard and hook failures are swallowed.
|
|
120
|
+
|
|
121
|
+
## Roadmap
|
|
122
|
+
|
|
123
|
+
- pytest plugin: show the curl of the last failed request under the traceback
|
|
124
|
+
- response capture alongside the request
|
|
125
|
+
- `requests` support
|
|
126
|
+
- a Node.js sibling for server-side `fetch`
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# httpx-debug — design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-16
|
|
4
|
+
**Status:** approved (validated demand 2026-07-15/16; see README for positioning)
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
Debugging httpx calls today means sprinkling `print(headers) / print(params) / print(url)`
|
|
9
|
+
around call sites, then hand-assembling a curl command to reproduce the failure.
|
|
10
|
+
Existing packages (curlify, curlify2, curlify3) only do request→curl *conversion* —
|
|
11
|
+
no failure triggering, no secret redaction, no clipboard, no async-first design.
|
|
12
|
+
httpx's official answer is DEBUG logging, which produces log noise, not a
|
|
13
|
+
reproducible command. The OpenAI and Anthropic SDKs are built on httpx, making
|
|
14
|
+
"what did my LLM call actually send" a growing instance of this pain.
|
|
15
|
+
|
|
16
|
+
## Goal (v1)
|
|
17
|
+
|
|
18
|
+
A tiny, dependency-light package (`httpx` only) that turns any httpx request —
|
|
19
|
+
especially a *failing* one — into a paste-able, secret-redacted curl command,
|
|
20
|
+
printed and/or copied to the clipboard (working even over SSH via OSC 52).
|
|
21
|
+
|
|
22
|
+
## Non-goals (v1)
|
|
23
|
+
|
|
24
|
+
- `requests` support (curlify covers it; may revisit)
|
|
25
|
+
- pytest plugin (roadmap v2)
|
|
26
|
+
- Other languages (roadmap; concept is portable)
|
|
27
|
+
- Response-body capture/pretty-printing (roadmap)
|
|
28
|
+
|
|
29
|
+
## Public API
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import httpx_debug
|
|
33
|
+
|
|
34
|
+
httpx_debug.to_curl(request, redact=True) # httpx.Request -> str
|
|
35
|
+
httpx_debug.copy(request_or_str, redact=True) # -> bool (clipboard success)
|
|
36
|
+
httpx_debug.attach(client, on="error", copy=False, redact=True, file=None)
|
|
37
|
+
httpx_debug.detach(client)
|
|
38
|
+
httpx_debug.Client(...) / httpx_debug.AsyncClient(...) # drop-in subclasses
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- `to_curl`: single-line curl; skips `Content-Length`/`Host` (curl recomputes);
|
|
42
|
+
redacts sensitive header values by default (`Authorization`, `Cookie`,
|
|
43
|
+
`X-API-Key`, etc.) to `<redacted>`; shell-quotes via `shlex`.
|
|
44
|
+
Body: uses `request.content` when available; streamed bodies emit a
|
|
45
|
+
`# request body was streamed and is not shown` comment; non-UTF-8 bodies emit
|
|
46
|
+
`# binary request body (N bytes) not shown`.
|
|
47
|
+
- `attach`: installs httpx event hooks on an existing `Client`/`AsyncClient`
|
|
48
|
+
(sync/async detected automatically). `on="error"` fires on status >= 400;
|
|
49
|
+
`on="all"` fires on every response. Prints to `file` (default stderr);
|
|
50
|
+
`copy=True` also copies to clipboard. Works on clients you don't construct
|
|
51
|
+
yourself (e.g. pass to OpenAI SDK via `http_client=`). Event hooks cannot see
|
|
52
|
+
transport exceptions — documented limitation.
|
|
53
|
+
- `Client`/`AsyncClient`: subclasses overriding `send()`, so they additionally
|
|
54
|
+
capture `httpx.RequestError` (timeouts, connection errors), emit the curl,
|
|
55
|
+
and re-raise.
|
|
56
|
+
- Clipboard: native tool first (`pbcopy`/`xclip`/`wl-copy`/`clip`), falling back
|
|
57
|
+
to the OSC 52 escape sequence written to `/dev/tty` so copy works over SSH.
|
|
58
|
+
Never raises; returns success bool.
|
|
59
|
+
|
|
60
|
+
## Architecture
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
src/httpx_debug/
|
|
64
|
+
__init__.py # public API surface
|
|
65
|
+
_curl.py # to_curl + redaction
|
|
66
|
+
_clipboard.py # copy_text: native tools + OSC 52 fallback
|
|
67
|
+
_hooks.py # attach/detach, Client/AsyncClient subclasses, emit logic
|
|
68
|
+
tests/ # pytest; httpx.MockTransport, no network
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Each module independently testable; `_curl` is pure, `_clipboard` isolated
|
|
72
|
+
behind `copy_text(text) -> bool`, `_hooks` composes the two.
|
|
73
|
+
|
|
74
|
+
## Error handling
|
|
75
|
+
|
|
76
|
+
Debug tooling must never break the app: hook and clipboard failures are
|
|
77
|
+
swallowed (clipboard returns False; emit falls back to print). `to_curl` never
|
|
78
|
+
raises on weird bodies — it degrades to explanatory comments.
|
|
79
|
+
|
|
80
|
+
## Testing
|
|
81
|
+
|
|
82
|
+
- `_curl`: method/-X rules, header inclusion+skips, redaction on/off + custom
|
|
83
|
+
set, query params, quoting, JSON body, binary body, streamed body.
|
|
84
|
+
- `_hooks`: MockTransport 500 → emits; 200 + on="error" → silent; on="all" →
|
|
85
|
+
emits; async variants; Client subclass emits on ConnectError and re-raises;
|
|
86
|
+
detach removes hooks.
|
|
87
|
+
- `_clipboard`: monkeypatched subprocess; OSC 52 fallback content.
|
|
88
|
+
|
|
89
|
+
## Naming / packaging
|
|
90
|
+
|
|
91
|
+
PyPI name `httpx-debug` (verified unregistered 2026-07-16), import
|
|
92
|
+
`httpx_debug`. MIT. `hatchling` build, `requires-python >= 3.9`,
|
|
93
|
+
runtime dep: `httpx>=0.23`. Publish with Classic Automation token (per npm/PyPI
|
|
94
|
+
CI conventions Mayur uses).
|
|
95
|
+
|
|
96
|
+
## Roadmap (post-v1)
|
|
97
|
+
|
|
98
|
+
pytest plugin (curl of last failed request under traceback) · response capture ·
|
|
99
|
+
`requests` support · Node sibling package.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "httpx-debug"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Failure-aware curl output for httpx: reproduce any (failing) request as a paste-able, secret-redacted curl command — printed or copied to your clipboard, even over SSH."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "Mayur Rawte" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = ["httpx>=0.23"]
|
|
14
|
+
keywords = ["httpx", "curl", "debug", "debugging", "clipboard", "devtools"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Debuggers",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/mayurrawte/httpx-debug"
|
|
25
|
+
|
|
26
|
+
[dependency-groups]
|
|
27
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
asyncio_mode = "auto"
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/httpx_debug"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from httpx_debug._clipboard import copy
|
|
2
|
+
from httpx_debug._curl import DEFAULT_REDACT_HEADERS, REDACTED, to_curl
|
|
3
|
+
from httpx_debug._hooks import AsyncClient, Client, attach, detach
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"to_curl",
|
|
7
|
+
"copy",
|
|
8
|
+
"attach",
|
|
9
|
+
"detach",
|
|
10
|
+
"Client",
|
|
11
|
+
"AsyncClient",
|
|
12
|
+
"REDACTED",
|
|
13
|
+
"DEFAULT_REDACT_HEADERS",
|
|
14
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Union
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from httpx_debug._curl import to_curl
|
|
12
|
+
|
|
13
|
+
_TOOLS = (
|
|
14
|
+
["pbcopy"],
|
|
15
|
+
["wl-copy"],
|
|
16
|
+
["xclip", "-selection", "clipboard"],
|
|
17
|
+
["xsel", "--clipboard", "--input"],
|
|
18
|
+
["clip"],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def copy(request_or_text: Union[httpx.Request, str], *, redact: bool = True) -> bool:
|
|
23
|
+
"""Copy a request (as a curl command) or plain text to the clipboard.
|
|
24
|
+
|
|
25
|
+
Returns True on success; never raises.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(request_or_text, str):
|
|
28
|
+
text = request_or_text
|
|
29
|
+
else:
|
|
30
|
+
text = to_curl(request_or_text, redact=redact)
|
|
31
|
+
return copy_text(text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def copy_text(text: str) -> bool:
|
|
35
|
+
return _native_copy(text) or _osc52_copy(text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _native_copy(text: str) -> bool:
|
|
39
|
+
for cmd in _TOOLS:
|
|
40
|
+
if not shutil.which(cmd[0]):
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
cmd,
|
|
45
|
+
input=text.encode("utf-8"),
|
|
46
|
+
stdout=subprocess.DEVNULL,
|
|
47
|
+
stderr=subprocess.DEVNULL,
|
|
48
|
+
timeout=5,
|
|
49
|
+
)
|
|
50
|
+
except Exception:
|
|
51
|
+
continue
|
|
52
|
+
if result.returncode == 0:
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _osc52_copy(text: str) -> bool:
|
|
58
|
+
# OSC 52 asks the terminal emulator to set the clipboard, so it reaches
|
|
59
|
+
# the *local* clipboard even from an SSH session
|
|
60
|
+
payload = base64.b64encode(text.encode("utf-8"))
|
|
61
|
+
try:
|
|
62
|
+
tty = _open_tty()
|
|
63
|
+
except Exception:
|
|
64
|
+
return False
|
|
65
|
+
try:
|
|
66
|
+
tty.write(b"\x1b]52;c;" + payload + b"\x07")
|
|
67
|
+
tty.flush()
|
|
68
|
+
return True
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
71
|
+
finally:
|
|
72
|
+
try:
|
|
73
|
+
tty.close()
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _open_tty():
|
|
79
|
+
if sys.platform == "win32":
|
|
80
|
+
raise OSError("OSC 52 fallback not supported on Windows")
|
|
81
|
+
return open("/dev/tty", "wb")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
from typing import Iterable, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
REDACTED = "<redacted>"
|
|
9
|
+
|
|
10
|
+
DEFAULT_REDACT_HEADERS = frozenset(
|
|
11
|
+
{
|
|
12
|
+
"authorization",
|
|
13
|
+
"proxy-authorization",
|
|
14
|
+
"cookie",
|
|
15
|
+
"set-cookie",
|
|
16
|
+
"x-api-key",
|
|
17
|
+
"api-key",
|
|
18
|
+
"x-auth-token",
|
|
19
|
+
"x-amz-security-token",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# curl derives these itself; including them makes the command wrong when edited
|
|
24
|
+
_SKIP_HEADERS = frozenset({"host", "content-length"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_curl(
|
|
28
|
+
request: httpx.Request,
|
|
29
|
+
*,
|
|
30
|
+
redact: bool = True,
|
|
31
|
+
redact_headers: Optional[Iterable[str]] = None,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""Render an httpx.Request as a single-line, shell-safe curl command.
|
|
34
|
+
|
|
35
|
+
Sensitive header values are replaced with ``<redacted>`` unless
|
|
36
|
+
``redact=False``. Streamed bodies are never consumed.
|
|
37
|
+
"""
|
|
38
|
+
sensitive = (
|
|
39
|
+
frozenset(h.lower() for h in redact_headers)
|
|
40
|
+
if redact_headers is not None
|
|
41
|
+
else DEFAULT_REDACT_HEADERS
|
|
42
|
+
)
|
|
43
|
+
tokens = ["curl"]
|
|
44
|
+
if request.method != "GET":
|
|
45
|
+
tokens += ["-X", request.method]
|
|
46
|
+
for name, value in request.headers.multi_items():
|
|
47
|
+
lower = name.lower()
|
|
48
|
+
if lower in _SKIP_HEADERS:
|
|
49
|
+
continue
|
|
50
|
+
if redact and lower in sensitive:
|
|
51
|
+
value = REDACTED
|
|
52
|
+
tokens += ["-H", f"{_display_name(name)}: {value}"]
|
|
53
|
+
body, comment = _body(request)
|
|
54
|
+
if body is not None:
|
|
55
|
+
tokens += ["-d", body]
|
|
56
|
+
tokens.append(str(request.url))
|
|
57
|
+
command = shlex.join(tokens)
|
|
58
|
+
if comment:
|
|
59
|
+
command += "\n" + comment
|
|
60
|
+
return command
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _display_name(name: str) -> str:
|
|
64
|
+
# httpx lowercases header names; Title-Case reads closer to what was sent
|
|
65
|
+
return "-".join(part.capitalize() for part in name.split("-")) if name.islower() else name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _body(request: httpx.Request) -> Tuple[Optional[str], Optional[str]]:
|
|
69
|
+
try:
|
|
70
|
+
raw = request.content
|
|
71
|
+
except httpx.RequestNotRead:
|
|
72
|
+
return None, "# request body was streamed and is not shown"
|
|
73
|
+
if not raw:
|
|
74
|
+
return None, None
|
|
75
|
+
try:
|
|
76
|
+
return raw.decode("utf-8"), None
|
|
77
|
+
except UnicodeDecodeError:
|
|
78
|
+
return None, f"# binary request body ({len(raw)} bytes) not shown"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import IO, Iterable, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from httpx_debug import _clipboard
|
|
9
|
+
from httpx_debug._curl import to_curl
|
|
10
|
+
|
|
11
|
+
_MARKER = "_httpx_debug_hook"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Config:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
on: str = "error",
|
|
18
|
+
copy: bool = False,
|
|
19
|
+
redact: bool = True,
|
|
20
|
+
redact_headers: Optional[Iterable[str]] = None,
|
|
21
|
+
file: Optional[IO[str]] = None,
|
|
22
|
+
copy_redact: Optional[bool] = None,
|
|
23
|
+
):
|
|
24
|
+
if on not in ("error", "all"):
|
|
25
|
+
raise ValueError(f'on must be "error" or "all", got {on!r}')
|
|
26
|
+
self.on = on
|
|
27
|
+
self.copy = copy
|
|
28
|
+
self.redact = redact
|
|
29
|
+
self.redact_headers = redact_headers
|
|
30
|
+
self.file = file
|
|
31
|
+
# clipboard redaction follows `redact` unless explicitly overridden;
|
|
32
|
+
# copy_redact=False keeps printed output safe while the clipboard
|
|
33
|
+
# gets a replayable command with real credentials
|
|
34
|
+
self.copy_redact = redact if copy_redact is None else copy_redact
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _emit(request: httpx.Request, headline: str, config: _Config) -> None:
|
|
38
|
+
curl = to_curl(request, redact=config.redact, redact_headers=config.redact_headers)
|
|
39
|
+
out = config.file if config.file is not None else sys.stderr
|
|
40
|
+
print(f"# httpx-debug: {headline} ← {request.method} {request.url}", file=out)
|
|
41
|
+
print(curl, file=out)
|
|
42
|
+
if config.copy:
|
|
43
|
+
if config.copy_redact == config.redact:
|
|
44
|
+
clip = curl
|
|
45
|
+
else:
|
|
46
|
+
clip = to_curl(request, redact=config.copy_redact, redact_headers=config.redact_headers)
|
|
47
|
+
_clipboard.copy_text(clip)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _handle_response(response: httpx.Response, config: _Config) -> None:
|
|
51
|
+
if config.on == "error" and response.status_code < 400:
|
|
52
|
+
return
|
|
53
|
+
_emit(response.request, f"{response.status_code} {response.reason_phrase}", config)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def attach(
|
|
57
|
+
client,
|
|
58
|
+
*,
|
|
59
|
+
on: str = "error",
|
|
60
|
+
copy: bool = False,
|
|
61
|
+
redact: bool = True,
|
|
62
|
+
redact_headers: Optional[Iterable[str]] = None,
|
|
63
|
+
file: Optional[IO[str]] = None,
|
|
64
|
+
copy_redact: Optional[bool] = None,
|
|
65
|
+
):
|
|
66
|
+
"""Install a response event hook that prints (and optionally copies) the
|
|
67
|
+
curl command for failing requests. Works on httpx.Client and AsyncClient,
|
|
68
|
+
including clients handed to third-party SDKs.
|
|
69
|
+
|
|
70
|
+
Event hooks cannot observe transport exceptions (timeouts, DNS failures);
|
|
71
|
+
use httpx_debug.Client / httpx_debug.AsyncClient for those.
|
|
72
|
+
"""
|
|
73
|
+
config = _Config(
|
|
74
|
+
on=on,
|
|
75
|
+
copy=copy,
|
|
76
|
+
redact=redact,
|
|
77
|
+
redact_headers=redact_headers,
|
|
78
|
+
file=file,
|
|
79
|
+
copy_redact=copy_redact,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if isinstance(client, httpx.AsyncClient):
|
|
83
|
+
|
|
84
|
+
async def hook(response: httpx.Response) -> None:
|
|
85
|
+
_handle_response(response, config)
|
|
86
|
+
|
|
87
|
+
else:
|
|
88
|
+
|
|
89
|
+
def hook(response: httpx.Response) -> None: # type: ignore[misc]
|
|
90
|
+
_handle_response(response, config)
|
|
91
|
+
|
|
92
|
+
setattr(hook, _MARKER, True)
|
|
93
|
+
hooks = {name: list(fns) for name, fns in client.event_hooks.items()}
|
|
94
|
+
hooks.setdefault("response", []).append(hook)
|
|
95
|
+
client.event_hooks = hooks
|
|
96
|
+
return client
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def detach(client) -> None:
|
|
100
|
+
"""Remove hooks previously installed by attach()."""
|
|
101
|
+
client.event_hooks = {
|
|
102
|
+
name: [fn for fn in fns if not getattr(fn, _MARKER, False)]
|
|
103
|
+
for name, fns in client.event_hooks.items()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _pop_debug_kwargs(kwargs: dict) -> _Config:
|
|
108
|
+
return _Config(
|
|
109
|
+
on=kwargs.pop("debug_on", "error"),
|
|
110
|
+
copy=kwargs.pop("debug_copy", False),
|
|
111
|
+
redact=kwargs.pop("debug_redact", True),
|
|
112
|
+
redact_headers=kwargs.pop("debug_redact_headers", None),
|
|
113
|
+
file=kwargs.pop("debug_file", None),
|
|
114
|
+
copy_redact=kwargs.pop("debug_copy_redact", None),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Client(httpx.Client):
|
|
119
|
+
"""Drop-in httpx.Client that also reports transport errors as curl."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, *args, **kwargs):
|
|
122
|
+
self._debug = _pop_debug_kwargs(kwargs)
|
|
123
|
+
super().__init__(*args, **kwargs)
|
|
124
|
+
|
|
125
|
+
def send(self, request: httpx.Request, **kwargs) -> httpx.Response:
|
|
126
|
+
try:
|
|
127
|
+
response = super().send(request, **kwargs)
|
|
128
|
+
except httpx.RequestError as exc:
|
|
129
|
+
_emit(request, f"{type(exc).__name__}: {exc}", self._debug)
|
|
130
|
+
raise
|
|
131
|
+
_handle_response(response, self._debug)
|
|
132
|
+
return response
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class AsyncClient(httpx.AsyncClient):
|
|
136
|
+
"""Drop-in httpx.AsyncClient that also reports transport errors as curl."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, *args, **kwargs):
|
|
139
|
+
self._debug = _pop_debug_kwargs(kwargs)
|
|
140
|
+
super().__init__(*args, **kwargs)
|
|
141
|
+
|
|
142
|
+
async def send(self, request: httpx.Request, **kwargs) -> httpx.Response:
|
|
143
|
+
try:
|
|
144
|
+
response = await super().send(request, **kwargs)
|
|
145
|
+
except httpx.RequestError as exc:
|
|
146
|
+
_emit(request, f"{type(exc).__name__}: {exc}", self._debug)
|
|
147
|
+
raise
|
|
148
|
+
_handle_response(response, self._debug)
|
|
149
|
+
return response
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
import httpx_debug
|
|
8
|
+
from httpx_debug import _clipboard
|
|
9
|
+
from httpx_debug._clipboard import copy_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeTTY:
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.data = b""
|
|
15
|
+
|
|
16
|
+
def write(self, b):
|
|
17
|
+
self.data += b
|
|
18
|
+
|
|
19
|
+
def flush(self):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def close(self):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def __enter__(self):
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __exit__(self, *exc):
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_copy_text_uses_native_tool(monkeypatch):
|
|
33
|
+
calls = {}
|
|
34
|
+
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
|
|
35
|
+
|
|
36
|
+
def fake_run(cmd, **kwargs):
|
|
37
|
+
calls["cmd"] = cmd
|
|
38
|
+
calls["input"] = kwargs.get("input")
|
|
39
|
+
return subprocess.CompletedProcess(cmd, 0)
|
|
40
|
+
|
|
41
|
+
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
42
|
+
assert copy_text("hello") is True
|
|
43
|
+
assert calls["input"] == b"hello"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_copy_text_falls_back_to_osc52_when_no_tool(monkeypatch):
|
|
47
|
+
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
48
|
+
tty = FakeTTY()
|
|
49
|
+
monkeypatch.setattr(_clipboard, "_open_tty", lambda: tty)
|
|
50
|
+
assert copy_text("hi") is True
|
|
51
|
+
assert tty.data.startswith(b"\x1b]52;c;")
|
|
52
|
+
assert base64.b64encode(b"hi") in tty.data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_copy_text_never_raises(monkeypatch):
|
|
56
|
+
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
57
|
+
|
|
58
|
+
def boom():
|
|
59
|
+
raise OSError("no tty")
|
|
60
|
+
|
|
61
|
+
monkeypatch.setattr(_clipboard, "_open_tty", boom)
|
|
62
|
+
assert copy_text("hi") is False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_public_copy_converts_request_and_redacts(monkeypatch):
|
|
66
|
+
captured = {}
|
|
67
|
+
|
|
68
|
+
def fake_copy_text(text):
|
|
69
|
+
captured["text"] = text
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
monkeypatch.setattr(_clipboard, "copy_text", fake_copy_text)
|
|
73
|
+
req = httpx.Request("GET", "https://api.example.com/", headers={"Authorization": "Bearer sk-1"})
|
|
74
|
+
assert httpx_debug.copy(req) is True
|
|
75
|
+
assert "sk-1" not in captured["text"]
|
|
76
|
+
assert "<redacted>" in captured["text"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_public_copy_accepts_plain_string(monkeypatch):
|
|
80
|
+
captured = {}
|
|
81
|
+
monkeypatch.setattr(_clipboard, "copy_text", lambda t: bool(captured.setdefault("text", t)))
|
|
82
|
+
assert httpx_debug.copy("curl https://x.example") is True
|
|
83
|
+
assert captured["text"] == "curl https://x.example"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
import httpx_debug
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def sync_client(handler):
|
|
9
|
+
return httpx.Client(transport=httpx.MockTransport(handler))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_clipboard_redacted_by_default(monkeypatch):
|
|
13
|
+
copied = {}
|
|
14
|
+
monkeypatch.setattr("httpx_debug._clipboard.copy_text", lambda t: copied.setdefault("text", t) == t)
|
|
15
|
+
buf = io.StringIO()
|
|
16
|
+
client = sync_client(lambda req: httpx.Response(500))
|
|
17
|
+
httpx_debug.attach(client, file=buf, copy=True)
|
|
18
|
+
client.get("https://api.example.com/x", headers={"Authorization": "Bearer sk-1"})
|
|
19
|
+
assert "sk-1" not in copied["text"]
|
|
20
|
+
assert "<redacted>" in copied["text"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_copy_redact_false_copies_raw_but_prints_redacted(monkeypatch):
|
|
24
|
+
copied = {}
|
|
25
|
+
monkeypatch.setattr("httpx_debug._clipboard.copy_text", lambda t: copied.setdefault("text", t) == t)
|
|
26
|
+
buf = io.StringIO()
|
|
27
|
+
client = sync_client(lambda req: httpx.Response(500))
|
|
28
|
+
httpx_debug.attach(client, file=buf, copy=True, copy_redact=False)
|
|
29
|
+
client.get("https://api.example.com/x", headers={"Authorization": "Bearer sk-1"})
|
|
30
|
+
# clipboard gets the replayable command with the real credential
|
|
31
|
+
assert "Bearer sk-1" in copied["text"]
|
|
32
|
+
# terminal output stays safe to share
|
|
33
|
+
printed = buf.getvalue()
|
|
34
|
+
assert "sk-1" not in printed
|
|
35
|
+
assert "<redacted>" in printed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_debug_client_supports_copy_redact(monkeypatch):
|
|
39
|
+
copied = {}
|
|
40
|
+
monkeypatch.setattr("httpx_debug._clipboard.copy_text", lambda t: copied.setdefault("text", t) == t)
|
|
41
|
+
buf = io.StringIO()
|
|
42
|
+
client = httpx_debug.Client(
|
|
43
|
+
transport=httpx.MockTransport(lambda req: httpx.Response(500)),
|
|
44
|
+
debug_file=buf,
|
|
45
|
+
debug_copy=True,
|
|
46
|
+
debug_copy_redact=False,
|
|
47
|
+
)
|
|
48
|
+
client.get("https://api.example.com/x", headers={"Authorization": "Bearer sk-1"})
|
|
49
|
+
assert "Bearer sk-1" in copied["text"]
|
|
50
|
+
assert "sk-1" not in buf.getvalue()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from httpx_debug import to_curl
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_simple_get_has_no_explicit_method():
|
|
8
|
+
req = httpx.Request("GET", "https://api.example.com/users")
|
|
9
|
+
curl = to_curl(req)
|
|
10
|
+
assert curl.startswith("curl ")
|
|
11
|
+
assert " -X " not in curl
|
|
12
|
+
assert curl.rstrip().endswith("https://api.example.com/users")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_non_get_method_is_explicit():
|
|
16
|
+
req = httpx.Request("DELETE", "https://api.example.com/users/1")
|
|
17
|
+
assert " -X DELETE " in to_curl(req)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_query_params_preserved_in_url():
|
|
21
|
+
req = httpx.Request("GET", "https://api.example.com/users", params={"page": "2", "q": "a b"})
|
|
22
|
+
curl = to_curl(req)
|
|
23
|
+
assert "page=2" in curl
|
|
24
|
+
assert "q=a+b" in curl or "q=a%20b" in curl
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_headers_included():
|
|
28
|
+
req = httpx.Request("GET", "https://api.example.com/", headers={"X-Custom": "yes"})
|
|
29
|
+
assert "X-Custom: yes" in to_curl(req)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_host_and_content_length_skipped():
|
|
33
|
+
req = httpx.Request("POST", "https://api.example.com/", json={"a": 1})
|
|
34
|
+
curl = to_curl(req)
|
|
35
|
+
assert "content-length" not in curl.lower()
|
|
36
|
+
assert "host:" not in curl.lower()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_authorization_redacted_by_default():
|
|
40
|
+
req = httpx.Request("GET", "https://api.example.com/", headers={"Authorization": "Bearer sk-secret"})
|
|
41
|
+
curl = to_curl(req)
|
|
42
|
+
assert "sk-secret" not in curl
|
|
43
|
+
assert "Authorization: <redacted>" in curl
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_redaction_covers_common_secret_headers():
|
|
47
|
+
req = httpx.Request(
|
|
48
|
+
"GET",
|
|
49
|
+
"https://api.example.com/",
|
|
50
|
+
headers={"X-API-Key": "k123", "Cookie": "session=abc"},
|
|
51
|
+
)
|
|
52
|
+
curl = to_curl(req)
|
|
53
|
+
assert "k123" not in curl
|
|
54
|
+
assert "session=abc" not in curl
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_redact_false_keeps_secrets():
|
|
58
|
+
req = httpx.Request("GET", "https://api.example.com/", headers={"Authorization": "Bearer sk-secret"})
|
|
59
|
+
assert "Bearer sk-secret" in to_curl(req, redact=False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_custom_redact_headers():
|
|
63
|
+
req = httpx.Request("GET", "https://api.example.com/", headers={"X-Tenant": "acme"})
|
|
64
|
+
curl = to_curl(req, redact_headers={"x-tenant"})
|
|
65
|
+
assert "acme" not in curl
|
|
66
|
+
assert "X-Tenant: <redacted>" in curl
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_json_body_included_as_data():
|
|
70
|
+
req = httpx.Request("POST", "https://api.example.com/users", json={"name": "mayur"})
|
|
71
|
+
curl = to_curl(req)
|
|
72
|
+
assert '-d \'{"name": "mayur"}\'' in curl or '-d \'{"name":"mayur"}\'' in curl
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_body_with_single_quotes_is_shell_safe():
|
|
76
|
+
req = httpx.Request("POST", "https://api.example.com/", content=b"it's fine")
|
|
77
|
+
curl = to_curl(req)
|
|
78
|
+
# shlex-style quoting: the result must be a single shell token containing the text
|
|
79
|
+
assert "it" in curl and "fine" in curl
|
|
80
|
+
import shlex
|
|
81
|
+
|
|
82
|
+
# the command must round-trip through a shell tokenizer without error
|
|
83
|
+
tokens = shlex.split(curl)
|
|
84
|
+
assert "it's fine" in tokens
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_binary_body_omitted_with_comment():
|
|
88
|
+
req = httpx.Request("POST", "https://api.example.com/", content=b"\xff\xfe\x00\x01")
|
|
89
|
+
curl = to_curl(req)
|
|
90
|
+
assert "-d" not in curl
|
|
91
|
+
assert "# binary request body (4 bytes) not shown" in curl
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_streamed_body_noted_not_consumed():
|
|
95
|
+
def gen():
|
|
96
|
+
yield b"chunk"
|
|
97
|
+
|
|
98
|
+
req = httpx.Request("POST", "https://api.example.com/", content=gen())
|
|
99
|
+
curl = to_curl(req)
|
|
100
|
+
assert "streamed" in curl
|
|
101
|
+
assert "-d" not in curl
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_output_is_single_command_line_plus_optional_comment():
|
|
105
|
+
req = httpx.Request("GET", "https://api.example.com/")
|
|
106
|
+
curl = to_curl(req)
|
|
107
|
+
command_lines = [l for l in curl.splitlines() if not l.startswith("#")]
|
|
108
|
+
assert len(command_lines) == 1
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import httpx_debug
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def sync_client(handler):
|
|
10
|
+
return httpx.Client(transport=httpx.MockTransport(handler))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_attach_prints_redacted_curl_on_error_response():
|
|
14
|
+
buf = io.StringIO()
|
|
15
|
+
client = sync_client(lambda req: httpx.Response(500))
|
|
16
|
+
httpx_debug.attach(client, file=buf)
|
|
17
|
+
client.get("https://api.example.com/x", headers={"Authorization": "Bearer sk-1"})
|
|
18
|
+
out = buf.getvalue()
|
|
19
|
+
assert "httpx-debug: 500" in out
|
|
20
|
+
assert "curl" in out
|
|
21
|
+
assert "<redacted>" in out
|
|
22
|
+
assert "sk-1" not in out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_attach_silent_on_success_by_default():
|
|
26
|
+
buf = io.StringIO()
|
|
27
|
+
client = sync_client(lambda req: httpx.Response(200))
|
|
28
|
+
httpx_debug.attach(client, file=buf)
|
|
29
|
+
client.get("https://api.example.com/x")
|
|
30
|
+
assert buf.getvalue() == ""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_attach_on_all_prints_for_success():
|
|
34
|
+
buf = io.StringIO()
|
|
35
|
+
client = sync_client(lambda req: httpx.Response(200))
|
|
36
|
+
httpx_debug.attach(client, on="all", file=buf)
|
|
37
|
+
client.get("https://api.example.com/x")
|
|
38
|
+
assert "curl" in buf.getvalue()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_attach_preserves_existing_hooks():
|
|
42
|
+
seen = []
|
|
43
|
+
buf = io.StringIO()
|
|
44
|
+
client = sync_client(lambda req: httpx.Response(500))
|
|
45
|
+
client.event_hooks = {"response": [lambda r: seen.append(r.status_code)]}
|
|
46
|
+
httpx_debug.attach(client, file=buf)
|
|
47
|
+
client.get("https://api.example.com/x")
|
|
48
|
+
assert seen == [500]
|
|
49
|
+
assert "curl" in buf.getvalue()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_detach_removes_only_our_hook():
|
|
53
|
+
seen = []
|
|
54
|
+
buf = io.StringIO()
|
|
55
|
+
client = sync_client(lambda req: httpx.Response(500))
|
|
56
|
+
client.event_hooks = {"response": [lambda r: seen.append(r.status_code)]}
|
|
57
|
+
httpx_debug.attach(client, file=buf)
|
|
58
|
+
httpx_debug.detach(client)
|
|
59
|
+
client.get("https://api.example.com/x")
|
|
60
|
+
assert buf.getvalue() == ""
|
|
61
|
+
assert seen == [500]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_attach_copy_flag_copies_curl(monkeypatch):
|
|
65
|
+
copied = {}
|
|
66
|
+
monkeypatch.setattr("httpx_debug._clipboard.copy_text", lambda t: copied.setdefault("text", t) == t)
|
|
67
|
+
buf = io.StringIO()
|
|
68
|
+
client = sync_client(lambda req: httpx.Response(500))
|
|
69
|
+
httpx_debug.attach(client, file=buf, copy=True)
|
|
70
|
+
client.get("https://api.example.com/x")
|
|
71
|
+
assert copied["text"].startswith("curl")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def test_attach_async_client():
|
|
75
|
+
buf = io.StringIO()
|
|
76
|
+
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda req: httpx.Response(503)))
|
|
77
|
+
httpx_debug.attach(client, file=buf)
|
|
78
|
+
async with client:
|
|
79
|
+
await client.get("https://api.example.com/x")
|
|
80
|
+
assert "httpx-debug: 503" in buf.getvalue()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_debug_client_emits_on_error_status():
|
|
84
|
+
buf = io.StringIO()
|
|
85
|
+
client = httpx_debug.Client(
|
|
86
|
+
transport=httpx.MockTransport(lambda req: httpx.Response(404)), debug_file=buf
|
|
87
|
+
)
|
|
88
|
+
client.get("https://api.example.com/missing")
|
|
89
|
+
assert "httpx-debug: 404" in buf.getvalue()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_debug_client_emits_curl_on_connect_error_and_reraises():
|
|
93
|
+
buf = io.StringIO()
|
|
94
|
+
|
|
95
|
+
def handler(req):
|
|
96
|
+
raise httpx.ConnectError("connection refused")
|
|
97
|
+
|
|
98
|
+
client = httpx_debug.Client(transport=httpx.MockTransport(handler), debug_file=buf)
|
|
99
|
+
with pytest.raises(httpx.ConnectError):
|
|
100
|
+
client.get("https://api.example.com/x")
|
|
101
|
+
out = buf.getvalue()
|
|
102
|
+
assert "ConnectError" in out
|
|
103
|
+
assert "curl" in out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_debug_client_silent_on_success():
|
|
107
|
+
buf = io.StringIO()
|
|
108
|
+
client = httpx_debug.Client(
|
|
109
|
+
transport=httpx.MockTransport(lambda req: httpx.Response(200)), debug_file=buf
|
|
110
|
+
)
|
|
111
|
+
client.get("https://api.example.com/x")
|
|
112
|
+
assert buf.getvalue() == ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def test_debug_async_client_emits_on_timeout_and_reraises():
|
|
116
|
+
buf = io.StringIO()
|
|
117
|
+
|
|
118
|
+
def handler(req):
|
|
119
|
+
raise httpx.ReadTimeout("timed out")
|
|
120
|
+
|
|
121
|
+
client = httpx_debug.AsyncClient(transport=httpx.MockTransport(handler), debug_file=buf)
|
|
122
|
+
async with client:
|
|
123
|
+
with pytest.raises(httpx.ReadTimeout):
|
|
124
|
+
await client.get("https://api.example.com/slow")
|
|
125
|
+
out = buf.getvalue()
|
|
126
|
+
assert "ReadTimeout" in out
|
|
127
|
+
assert "curl" in out
|