Repository navigation
Releases: shpaker/curlify3
Release list
0.15
Secret masking, pytest and Robot Framework integrations, documentation, and a round of fixes for commands that sent something other than the request.
Behavior change: with shell="powershell", a value holding a line break (a pretty-printed JSON body, a multi-line field) now raises ValueError instead of returning a command that would run the next line as PowerShell. Render such requests with shell="sh".
Documentation: https://shpaker.github.io/curlify3/
Added
mask_headersonto_curl()/to_curl_async(), plus aSENSITIVE_HEADERSconstant: the named headers stay in the command with***for their values. Auth headers keep the scheme (Bearer ***), cookies keep their names (#35).- A pytest plugin,
curlify3.pytest_plugin: a failing test's report gets the curl commands of the requests the test sent. Turn it on with--curl-on-failure,curl_on_failure = trueor thecurl_requestsfixture (#44). - A Robot Framework library,
curlify3.robot(pip install curlify3[robot]):To Curl,Log As Curl, andEnable/Disable Curl Loggingfor RequestsLibrary sessions. - A documentation site with logging recipes for every supported framework and client. The README is now a short introduction.
Changed
- An unsupported request object raises
UnsupportedRequestError, a subclass of bothTypeErrorandValueError. The message names the type, and says when the other entrypoint accepts it (#33). - The PyPI description, keywords and project links (#34).
Fixed
- Shell and file-read vectors (#45): multipart values
-Fwould parse go through--form-string, filenames with;or,are quoted, and a cookie without=no longer makes-bread a cookie file. - Urls and headers as the request carried them (#48): the raw, still-encoded path for starlette, werkzeug and django,
-gfor urls with[]{}, repeated headers kept, and empty header values sent. - Multipart bodies as they were sent (#50): one boundary, empty file inputs, and
multipart/related/multipart/mixedsent unchanged. - What each library actually sends (#52):
-X GETfor a GET with a body,-Ifor HEAD, a werkzeug form parsed before rendering, urllib's and tornado's default content-type, tornado client auth / user-agent headers, and djangoDisallowedHost.
The full list is in the changelog.
0.14
Header filtering and Python 3.15.
include_headers/exclude_headersonto_curl()andto_curl_async()filter the rendered headers by name, case-insensitively (#30).include_headersnarrows first,exclude_headerssubtracts from what is left;Cookiecounts as a header, so dropping it drops-btoo. Only the printed arguments change, the body is rendered as before. A barestr, or a name that is not astr, raisesTypeError.- Python 3.15 support: the classifier, and 3.15 in the CI matrix (Linux, plus the Windows end-to-end job on 3.10 and 3.15).
to_curl(req, exclude_headers=["user-agent", "accept-encoding", "accept", "connection"])
to_curl(req, include_headers=["authorization", "content-type"])0.13
Documentation release, nothing in the rendering changes.
- every adapter module now carries a usage example for its library in the module docstring, and to_curl() / to_curl_async() document the accepted types, the options and the ValueError contract
- the readme is reorganized around that: a pretty quick start, a fastapi logging-middleware example, output options and the reference tables
- the per-framework examples and their caveats live in the docstrings now, so they ship with the package
0.12
Six new packages join the supported list, and the release also carries a security-relevant rendering fix — if you render incoming requests server-side, upgrading is worth doing now.
New adapters
Runtime dependencies stay at zero: every adapter registers only when its library is importable.
| Library | Type | Call | Notes |
|---|---|---|---|
niquests |
PreparedRequest |
to_curl |
HTTP/2 and HTTP/3 live on the transport, so no --http2 |
urllib.request (stdlib) |
Request |
to_curl |
an absent method is inferred the way urllib sends it |
aiohttp client-side |
ClientRequest |
to_curl_async |
reachable in client middlewares (aiohttp 3.12+); the body is read without consuming the payload via Payload.as_bytes() (3.12.1+) |
django |
HttpRequest |
to_curl |
the framework buffers the body, so the sync call is enough — async views included |
flask / werkzeug |
werkzeug.wrappers.Request |
to_curl |
covers Flask through its Werkzeug base |
tornado |
httpclient.HTTPRequest, httputil.HTTPServerRequest |
to_curl |
client and server |
A body or form value starting with @ or < could read a local file
curl reads a leading @ in a -d value — and @ or < in a -F value — as the name of a local file to send the contents of. A request whose body or form field started with one was rendered so that the command, pasted out of a log, read that file and sent it to the url the request was rendered for. Reachable from the wire through the server-side adapters, where the value is the caller's to choose. Such values now go through --data-raw / --form-string, which take them literally; a file part keeps -F 'field=@file', where the @ is the intended meaning.
Multipart rendering rewritten
Parts are split on the boundary from Content-Type instead of matched by regex: field values with newlines are no longer truncated, single-character field names are no longer dropped, values that do not decode as UTF-8 render as ANSI-C literals instead of raising, parts come out in the order the body carries them, and a NUL byte in a field value is rejected like one in a body.
Cookies
- A
Cookieheader held in a case-sensitive container was silently dropped — tornado's client request keeps whatever plain dict it was handed, and the value reached neither-bnor-H. The extraction now scans the lowercased header names. - An empty
Cookieheader no longer renders as-H 'cookie: '; django'sRequestFactoryputs one on every request it builds, which is how it surfaced.
Full details in CHANGELOG.md.
0.11
Security-relevant release. If you render incoming requests server-side — aiohttp.web or starlette / fastapi — upgrading is worth doing now.
A single quote in any value could execute arbitrary commands
sh output wrapped values in '…' without escaping, so a quote inside a header, cookie, url or body closed the literal and the remainder was parsed by the shell as code. Through the server-side adapters this is reachable from the wire, since the request path, cookies and headers are all chosen by the caller:
# an incoming GET /x;id, rendered inside a handler
curl -b n=O'Brien -H 'host: example.com' http://example.com/x;id
# ^ unterminated quote ^ runs when pasted
A developer copying that out of a log runs id. Every value is quoted now, and the url and cookie header are quoted whenever they hold anything outside the safe character class shlex.quote() uses.
Also fixed
- A url with one query parameter did not run at all. It has no
&, so it was emitted bare, andzsh— the default shell on macOS — rejects the bare?as an unmatched glob:no matches found. - A non-UTF-8 body emitted a Python
repr.-d 'b'\xff\xfe''could never run. It renders as--data-raw $'caf\xe9'now, escaping only the bytes that must be escaped so a mis-encoded text body stays readable.--data-rawrather than-d, because both-dand--data-binaryread a leading@as a filename to load the body from, and@is an ordinary byte in a binary payload. Note$'…'is bash/zsh/ksh ANSI-C quoting and not POSIX —dashleaves it literal. - Raw bytes with
shell="powershell", and any body holding a NUL, now raiseValueErrorinstead of rendering a command that runs and sends the wrong thing. Nothing can be spelled behind the--%token, and a command-line argument is NUL-terminated so no quoting can carry a NUL. - The
Content-Typefallback for a body sent without one istext/plain, not the reversedplain/text.
Packaging
The package shipped without a license from 0.1 through 0.10. It now carries LICENSE (MIT) and trove classifiers.
Full detail in CHANGELOG.md and #13.
0.10
Added
- Type annotations across the whole package, and a
py.typedmarker so they are visible to type checkers in projects that depend oncurlify3(PEP 561).to_curl()/to_curl_async()are declared as returningstrand acceptingshell,prettyandlong_options; theValueErrorcontract for an unknownshellvalue is unchanged, soshellstays a plainstr. tyas the type checker and alintCI job that runs it together withruffon every pull request and on pushes tomain.- Dependabot configuration for
uv(pyproject.toml+uv.lock) and for GitHub Actions, weekly and grouped so tooling and test dependencies arrive as separate pull requests.
Changed
ruffreplacesblackandisort, which were declared as dev dependencies but never enforced anywhere. The formatting rules are carried over: line length 120, preserved string quotes, one blank line between plain andfromimports.- The sync and async request-data bases are siblings over a shared generic base instead of the async one inheriting the sync one. An
async def body()cannot override a syncdef body(), which made thestarletteadapter — registered as async, but inheriting the sync base — a type error. - The
httpx2adapter shares the common base again instead of duplicating it. The base grew a type parameter for the wrapped request in this release, which is what kept thehttpx2copy separate;--http2stays in the output. - The release workflow anchors its version replacement on the
versionlines instead of replacing every occurrence of the literal0.1.0, which is also a plausible dependency bound now that Dependabot writes intopyproject.toml. The step asserts the replaced value afterwards, so a substitution that matched nothing stops the release instead of publishing the placeholder.
Fixed
- The
starletteadapter resolvedstarlette.requests.Requestthrough a bareimport starlette, which worked only because an unused import of the same module happened to register the submodule. It importsRequestdirectly now. - A request whose
Content-Typeannouncesmultipartwhile carrying no body no longer raisesTypeError; it renders without-F. Reachable withrequestswhen the header is set by hand. - A
requestsbody that is an iterable or a file-like object (a streaming payload) is no longer rendered as its Pythonreprinside-d. Such a payload has no textual form a shell could replay, so the command now carries no-dat all. - Header values are decoded to text before they are rendered.
requeststypes its header values asstr | bytes, and abytesvalue used to reach"multipart" in content_typeand raiseTypeError.
Full Changelog: 0.9...0.10
0.9
Added
- Readable output (#6):
pretty=Trueonto_curl()/to_curl_async()puts the url on the first line and every option on its own,long_options=Truerenders--request/--header/--cookie/--data/--forminstead of the short names. The two are independent and both default toFalse, so the one-line output is unchanged.pretty=TrueraisesValueErrorforshell="powershell", where the--%token is effective only until the next newline.
curl 'https://httpbin.org/post?foo=911&bar=baz' \
--request POST \
--header 'content-type: application/json' \
--data '{"date":"2026-08-10"}'
Full Changelog: 0.8...0.9
0.8
Added
- PowerShell output (#7):
shell="powershell"onto_curl()/to_curl_async()renderscurl.exe --% …for Windows PowerShell 5.1, quoted by Windows command-line rules so arbitrary JSON survives. Without the stop-parsing token, 5.1's argument binder mangles the body however it is escaped; onpwsh7.2+ run$PSNativeCommandArgumentPassing = 'Legacy'first, and note that%NAME%references in a payload are expanded. Constantscurlify3.SHandcurlify3.POWERSHELLare exported; defaultshell="sh"output is unchanged. - End-to-end shell tests (
test_shell_e2e.py): the generated command is run by a real shell — bash, pluspowershell.exeandpwshon a newwindows-latestCI job — against a local server that checks the request arrives byte-for-byte.
Full Changelog: 0.7...0.8
0.7
Changed
- README restructured along Python-packaging best practices: added Features, structured Usage section with per-client examples (
requests,httpxsync/async,httpx2, server-sideaiohttpandstarlette/fastapi), API reference, supported-objects matrix, payload-handling table, and a Development section. - Added a comparison table against
curlifyandcurlify2so users can pick the right package at a glance. - Added CI / Python-versions badges to README.
Full Changelog: 0.6...0.7
0.6
Added
httpx2adapter (sync + async) — generatescurl --http2 …for Pydantic's httpx2. Adapter is self-contained so future divergence fromhttpxstays isolated.
Changed
- Minimum Python is now 3.10 (was 3.8).
- Project migrated from Poetry to uv (
uv.lock,uv sync,uv build,uv publish). Build backend switched frompoetry-coretohatchling. Justfilenow drives commands throughuv run ….- CI workflow renamed Lint → Tests; matrix
3.10 → 3.14; usesastral-sh/setup-uv@v5. - Release workflow uses
uv build+uv publish. - Dropped unused
pytest-httpxdev dep. - README refreshed: lists
httpx2, documents Python 3.10+ requirement, adds httpx2 and async usage examples. - Test expectations updated for
httpx>=0.28(compact JSON separators) and dynamicAccept-Encoding/User-Agentcapture for aiohttp.
Full Changelog: 0.5...0.6