Changelog¶
0.15 (2026-10-17)¶
One behavior change can break existing callers: 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". The rest of this release adds options and fixes commands that sent something other than the request; existing calls keep working.
Added¶
mask_headersonto_curl()andto_curl_async()keeps the named headers in the command but prints***in place of their values, so a rendered command can go to a log without the credentials it carried (#35).authorizationandproxy-authorizationkeep the auth scheme (Bearer ***), and a maskedcookiekeeps the name of each cookie in-b(-b 'sid=***; theme=***'). Names match case-insensitively; the include/exclude filters decide what is printed and masking applies to what is left. The newSENSITIVE_HEADERSconstant holds the usual credential-carrying names and is what the FastAPI middleware example in the README now passes, together with anexclude_headerslist that drops the headers every client sends. Opt-in: the default masks nothing, so existing output does not change. Query-string and body secrets are not covered.A pytest plugin,
curlify3.pytest_plugin, that attaches the curl commands of the HTTP requests a failing test sent to its report as aCaptured curlsection (#44). The requests are numbered in the order they were sent and labelled with the response status, or with the exception the client raised. It is loaded explicitly, withpytest_plugins = ["curlify3.pytest_plugin"]or-p curlify3.pytest_plugin, not through apytest11entry point: importingcurlify3imports every installed library it has an adapter for, which would otherwise land on the startup of every pytest run in an environment that merely hascurlify3installed. Loaded, it still records nothing until--curl-on-failure/ thecurl_on_failureini option turns it on for the run, or a test requests thecurl_requestsfixture, which also returns the rendered commands. Requests are recorded at the clients’send—httpx,httpx2(and with it the starlette / FastAPITestClient),requestsandniquests, sync and async — and rendered as they are sent, before anything consumes the body.curl_exclude_headersandcurl_mask_headersfeedexclude_headersandmask_headers, andcurl_max_requests(default 20,0for all) keeps the report to the last requests. A request that cannot be rendered is listed as a comment and never fails the test.A Robot Framework keyword library, imported as
Library curlify3.robotand installed with therobotextra (pip install curlify3[robot]).To Curlreturns the command,Log As Curlwrites it to the log,Enable Curl Logging/Disable Curl Logginglog every request sent through a RequestsLibrary session. The keywords take the response RequestsLibrary returns as well as the request objectsto_curl()accepts, and theto_curl()options as arguments; given at import, they become the library-wide defaults. The core package still has no runtime dependencies: onlycurlify3.robotimports Robot Framework.Documentation at shpaker.github.io/curlify3: guides for rendering, header filtering and masking, logging recipes for every supported framework and client, PowerShell, the supported request objects, payload handling, quoting and untrusted values, the pytest plugin, the Robot Framework library and an API reference. The README is condensed to an introduction that links there.
Changed¶
An unsupported request object is reported by type, with the fix when there is one (#33). The bare
unknown request objectnow carries themodule.qualnameof what was passed, and when the other entrypoint accepts the object — a starlette, FastAPI or aiohttp request handed to the syncto_curl(), or arequests.PreparedRequesttoto_curl_async()— the message says to use that one instead. The exception is the newcurlify3.UnsupportedRequestError, a subclass of bothTypeErrorandValueError: an unsupported type is a type error, andexcept ValueErrorwritten against earlier releases keeps catching it. The message still starts withunknown request object.The PyPI summary is the README’s first sentence now instead of “yet another library to convert request object to curl command”, and the package declares keywords and links to its documentation, changelog and issue tracker (#34).
Fixed¶
shell="powershell"no longer renders a value holding a line break (#45).--%is effective only until the end of the line, so the rest of a multi-line body, header or field value ran as PowerShell commands of its own — reachable from the wire through the server-side adapters, where a body such asx"+ newline +Get-Date; #is the caller’s to choose. Such a value now raisesValueError, like raw bytes already did.A multipart field value that
-Fwould parse is rendered with--form-string(#45). A;started an attribute —;headers=@fileloaded the part headers from a local file,;type=and;filename=changed the part and cut the value short — and a leading"opened a quoted string whose tail was dropped. Only a leading@or<was handled before.A multipart filename holding
;,,,"or\is written as a quoted string,-F 'f=@"a;b.txt"'(#45).-Fcut a bare one at the;or read the,as a list of files, so the command sent another file or failed.A
Cookieheader without an=is rendered as-H 'cookie: …'(#45).-breads such a value as the name of a cookie file and sent the cookies stored in it.The url of a starlette / FastAPI request keeps its path as it arrived (#48).
request.urlis built from the percent-decoded path, so%20gave a url curl rejects and%2F,%3F,%23came back as/,?,#— another route. The path now comes from the scope’sraw_path. werkzeug / flask and django take it fromRAW_URI/REQUEST_URIwhen the server sets them (gunicorn, uwsgi, werkzeug’s own), sincePATH_INFOis decoded too.A url holding
[,],{or}is rendered with-g/--globoff(#48). curl expanded them as a glob:{x}lost its braces,[1-3]went out as three requests.A repeated header keeps every value (#48). starlette and aiohttp kept only the first one; it is joined with
,now, and a repeatedCookiewith;— httpx, werkzeug and tornado joined cookies with a comma, which a server reads as part of the first cookie’s value.A header with an empty value is rendered as
-H 'name;'(#48). curl drops-H 'name: 'without sending it.A multipart command no longer sends two boundaries (#50).
-Fmakes curl pick a boundary of its own and append it to thecontent-typegiven with-H, which still carried the request’s —boundary=X; boundary=<curl's>, and a server that reads the first one cannot parse the body. Theboundaryparameter is left out of the header now.A file part with an empty filename, which a browser sends for a file input left empty, is rendered as
-F 'f=;filename=""'(#50).-F 'f=@'names a file curl cannot open, so the command failed without sending anything.A part whose
filenamecomes before itsnamekeeps its field name (#50).name="was matched insidefilename="as well, so the filename named the field.Only
multipart/form-datais taken apart into-Fparts, matched case-insensitively (#50).multipart/relatedandmultipart/mixedcarry no field names, so their parts were skipped one by one — amultipart/relatedbody was left out of the command entirely. They go out as the body they are now, with the boundary they were written with.A werkzeug / flask request whose form was read before the command was rendered no longer loses its body (#52). Parsing reads the stream to the end, so the command carried no
-dwithout a word. An urlencoded form is encoded again from the parsed fields; a multipart one also held files and renders without-d, the way a consumed django stream already did.A GET that carries a body is rendered with
-X GET(#52).-Xwas left out for GET, and a body makes curl send POST.A HEAD request is rendered with
-I/--headinstead of-X HEAD(#52), which kept curl waiting for a response body a HEAD response does not have.A
urllib.request.Requestwith data and no content-type renderscontent-type: application/x-www-form-urlencoded(#52), which urllib adds when it sends the request, rather thantext/plain.A django request whose host is outside
ALLOWED_HOSTSis rendered instead of raisingDisallowedHost(#52), so a logger that runs before django answers 400 still gets the command.A tornado client
HTTPRequestrenders the headers the client sets from its attributes when it sends it (#52):authorizationfromauth_username/auth_password(basic),user-agentfromuser_agent,accept-encoding: gzipfromdecompress_response, and a form-encoded content-type for a POST without one.
0.14 (2026-10-16)¶
Added¶
include_headers/exclude_headersonto_curl()andto_curl_async()filter the rendered headers by name, case-insensitively (#30).include_headersnarrows first andexclude_headerssubtracts from what is left.Cookiecounts as a header, so dropping it drops-btoo. Only the printed arguments change: leaving outcontent-typestill renders a multipart body as-Fparts. They are per-call arguments rather than module-level state, so a filter set in one place cannot change what other callers in the same process render. A barestr, or a name that is not astr, raisesTypeError.Python 3.15 support: the
Programming Language :: Python :: 3.15classifier, and 3.15 in the CI test matrix (the Windows end-to-end job now runs on 3.10 and 3.15).
0.13 (2026-08-21)¶
Added¶
Docstrings across the package: every adapter module carries a usage example for its library, and
to_curl()/to_curl_async()document the accepted types, the options and theValueErrorcontract in matching terms.A FastAPI logging-middleware example in the README — reading the body in a middleware is safe there, starlette 0.28+ caches what the render consumed and replays it to the route handler.
Changed¶
The README is reorganized: the per-framework examples moved into the adapter docstrings, the quick start renders with
pretty=True, and the comparison table moved after the reference sections.
0.12 (2026-08-20)¶
Added¶
Six new packages, seven new adapters. Still zero runtime dependencies: every adapter registers only when its library is importable.
niquests—PreparedRequest, sync. A self-contained copy of therequestsadapter (the httpx2 precedent: a fork can diverge, the copy keeps that isolated). HTTP/2 and HTTP/3 are negotiated on the transport, so the command carries no--http2.urllib.request(stdlib) —Request, sync. An absent method renders the one urllib would send (POSTwithdata,GETotherwise), headers are merged from both the plain header dict and the unredirected ones, and a streaming body renders without-d.aiohttpclient-side —ClientRequest, async, reachable in client middlewares (aiohttp 3.12+). The body is read without being consumed viaPayload.as_bytes()(aiohttp 3.12.1+): in-memory payloads hand back their value, file payloads seek back, async iterables are cached and replayed at send time; on older aiohttp only in-memory payloads are read and everything else renders without-d, because reading it would corrupt the outgoing request.django—HttpRequest, sync (the framework buffers the body before the view runs), coveringWSGIRequestandASGIRequest. The absolute url comes frombuild_absolute_uri(); a stream consumed without buffering (multipart parsing,request.read()) raisesRawPostDataExceptioninside Django, and the command then carries the headers but no-d.flask/werkzeug—werkzeug.wrappers.Request, sync. Covers Flask through its Werkzeug base, the way the starlette adapter covers FastAPI; the body is read withget_data(cache=True, parse_form_data=False), so form parsing after rendering still works.tornado— clienthttpclient.HTTPRequestand serverhttputil.HTTPServerRequest, both sync. The server url comes fromfull_url().
Fixed¶
A body or a multipart field value that starts with
@— or<for a field value — no longer makes the rendered command read a local file and send it to the url the request was rendered for.curlreads those leading characters as the name of a file to load the value from, so-d '@/etc/passwd'sent the file rather than the body. Such a value is now rendered with the option that takes it literally,--data-rawor--form-string; a file part keeps-F 'field=@file', where the@is the intended meaning. Reachable from the wire through the server-side adapters, where the value is the caller’s to choose, and verified end-to-end against a real file.A multipart field carrying a value that is not valid UTF-8 no longer raises
UnicodeDecodeError. Part names, field values and filenames are rendered through the same ANSI-C quoting a body that did not decode uses (-F $'blob=caf\xe9'), andshell="powershell"refuses them the way it already refuses a raw body.A multipart field value containing a newline was truncated at it. The value was matched as a single line, so the command silently sent a prefix of what the request carried. The parts are split on the boundary from
Content-Typenow instead, which also means they are rendered in the order the body carries them — the two patterns this replaced ran one after the other, so every plain field came out before every file part whatever order the body put them in. Amultipartcontent-type with noboundaryparameter leaves nothing to take the body apart with and renders without-F.A single-character multipart field name was dropped from the command without a word: both part patterns required two characters of a name, so
-F 'a=1'never appeared and the command silently sent less than the request did.A NUL byte in a multipart field value is rejected like one in a body. The multipart branch was exempt from the check on the grounds that it renders only names and filenames, which was not true of a plain field’s value — the argument would have been truncated at the NUL and the command would have run, sending something other than the request.
A
Cookieheader held in a case-sensitive container was silently dropped: the cookie extraction looked the header up case-insensitively, which not every wrapped container supports — tornado’s client request keeps whatever plain dict it was handed — so the value reached neither-bnor-H. The extraction now scans the lowercased header names.An empty
Cookieheader no longer renders as-H 'cookie: '. It carries no cookies, and-bcorrectly stayed silent, but the header itself leaked through; django’sRequestFactoryputs one on every request it builds, which is how it surfaced.
0.11 (2026-08-17)¶
Fixed¶
A value containing a single quote could execute arbitrary commands in the reader’s shell.
shoutput wrapped every value in'…'without escaping, so a quote inside a header, cookie, url or body closed the literal and the rest of the value was parsed as shell code. This is reachable from the wire through the server-side adapters, where the request path, cookies and headers are chosen by the client: an incomingGET /x;idused to render ascurl … http://host/x;id, which runsidwhen the developer pastes it out of a log. Every value is now quoted, and the url and cookie header are quoted whenever they hold anything outside the safe character class thatshlex.quote()uses.A url with a single query parameter is now quoted. It has no
&, so it used to be emitted bare, andzsh— the default shell on macOS — refuses the bare?as an unmatched glob withno matches found, so the command did not run at all.A body that is not valid UTF-8 is rendered as
--data-raw $'\xff\xfe'instead of the Pythonreprof thebytesobject inside-d '…', which produced a command that could not run. The escape form is ANSI-C quoting:bash,zshandkshexpand it, POSIXshdoes not, and only the bytes that have to be escaped are, so a mis-encoded text body stays readable as$'caf\xe9'.--data-rawrather than-d, because both-dand--data-binaryread a leading@as a filename to load the body from. The same body withshell="powershell"now raisesValueError: raw bytes cannot be spelled behind the--%stop-parsing token.A body containing a NUL byte raises
ValueErrorinstead of rendering a command that runs and sends a truncated body. A command-line argument is NUL-terminated, so no quoting can carry one. A NUL is valid UTF-8, so this is checked on the text path as well as thebytesone; a multipart body is unaffected, since only its part names and filenames are rendered.The
Content-Typefallback for a body sent without one istext/plain, not the reversedplain/text.
Added¶
End-to-end coverage for the cases above: the single-quoted payload now runs under
bashas well as PowerShell, and two newshcases replay a body of every byte value except NUL, and a body starting with@, byte-for-byte through a real shell.LICENSE(MIT) and trove classifiers. The package was published without either; thepyversionsbadge in the README rendered aspython missingbecause the classifiers it reads were absent.
0.10 (2026-08-17)¶
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.
0.9 (2026-08-17)¶
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.
0.8 (2026-08-17)¶
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.
0.7 (2026-06-05)¶
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.
0.6 (2026-06-05)¶
Added¶
httpx2adapter (sync + async). When the input is anhttpx2.Request, the generated curl command includes--http2. The adapter is fully self-contained so future divergence fromhttpxstays isolated.
Changed¶
Minimum Python is now
3.10(was3.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 is3.10 → 3.14; runs onastral-sh/setup-uv@v5.Release workflow (
pypi.yml) usesuv build+uv publish.Dropped unused
pytest-httpxdev dependency.Test expectations updated for
httpx>=0.28(compact JSON separators) and dynamicAccept-Encoding/User-Agentcapture for aiohttp tests so they stop breaking on aiohttp version bumps.README refreshed: lists
httpx2in supported clients, documents the Python 3.10+ requirement, adds httpx2 and async usage examples.
0.5 (2024-11-08)¶
Added¶
Python 3.8 support — typing imports relaxed so the package installs on older interpreters.
0.4 (2024-11-08)¶
Fixed¶
requestsadapter no longer crashes when the prepared body is a plain text file payload (-d '…'is now produced correctly).
0.3 (2024-11-08)¶
Added¶
aiohttpserver-side adapter —to_curl_async(aiohttp.web.Request)renders the incoming request as a curl command.
0.2 (2024-11-07)¶
Fixed¶
Filled in
repositorymetadata inpyproject.tomlso PyPI links back to GitHub.
0.1 (2024-11-07)¶
Added¶
Initial release.
to_curl(request)andto_curl_async(request)forrequests.PreparedRequest,httpx.Request, andstarlette.requests.Request.Multipart, form, JSON, text, and binary file payload support; cookies passed via
-b.