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.
@@ -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,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ uv.lock
@@ -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