httpx-debug 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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")
httpx_debug/_curl.py ADDED
@@ -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"
httpx_debug/_hooks.py ADDED
@@ -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,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,8 @@
1
+ httpx_debug/__init__.py,sha256=jodJv2IpcOYTgOr0OCxnpWmTQ-gBlsVnbWn498LqWkg,328
2
+ httpx_debug/_clipboard.py,sha256=h9gQ3zu5mrMqmg0gRFovithx5YEfzlUKBqTqTOntJ2M,1951
3
+ httpx_debug/_curl.py,sha256=FvmD7Eam2SAhGV-iome03izJCopftzxy9XFqv0Onyfo,2228
4
+ httpx_debug/_hooks.py,sha256=Flgi5OfjJUf08Fe7EWGTpM_SZs9T927zla4WsATEek4,4925
5
+ httpx_debug-0.1.0.dist-info/METADATA,sha256=K2X4RIF9YbzNzIjEqBB1vw9dkrCUCZkboVprD3QOUzM,5483
6
+ httpx_debug-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ httpx_debug-0.1.0.dist-info/licenses/LICENSE,sha256=RFWd3lWPIDAthIAm9tYEbLpsSJVKWFCjXPhrblnH4Bk,1068
8
+ httpx_debug-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.