Skip to content

Releases: shpaker/curlify3

0.15

Choose a tag to compare

@shpaker shpaker released this 17 Oct 13:32

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_headers on to_curl() / to_curl_async(), plus a SENSITIVE_HEADERS constant: 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 = true or the curl_requests fixture (#44).
  • A Robot Framework library, curlify3.robot (pip install curlify3[robot]): To Curl, Log As Curl, and Enable / Disable Curl Logging for 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 both TypeError and ValueError. 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 -F would parse go through --form-string, filenames with ; or , are quoted, and a cookie without = no longer makes -b read a cookie file.
  • Urls and headers as the request carried them (#48): the raw, still-encoded path for starlette, werkzeug and django, -g for 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/mixed sent unchanged.
  • What each library actually sends (#52): -X GET for a GET with a body, -I for HEAD, a werkzeug form parsed before rendering, urllib's and tornado's default content-type, tornado client auth / user-agent headers, and django DisallowedHost.

The full list is in the changelog.

0.14

Choose a tag to compare

@shpaker shpaker released this 16 Oct 19:27

Header filtering and Python 3.15.

  • include_headers / exclude_headers on to_curl() and to_curl_async() filter the rendered headers by name, case-insensitively (#30). include_headers narrows first, exclude_headers subtracts from what is left; Cookie counts as a header, so dropping it drops -b too. Only the printed arguments change, the body is rendered as before. A bare str, or a name that is not a str, raises TypeError.
  • 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

Choose a tag to compare

@shpaker shpaker released this 21 Aug 18:18

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

Choose a tag to compare

@shpaker shpaker released this 20 Aug 06:37

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 Cookie header 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 -b nor -H. The extraction now scans the lowercased header names.
  • An empty Cookie header no longer renders as -H 'cookie: '; django's RequestFactory puts one on every request it builds, which is how it surfaced.

Full details in CHANGELOG.md.

0.11

Choose a tag to compare

@shpaker shpaker released this 17 Aug 14:28
e166cb2

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, and zsh — 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-raw rather than -d, because both -d and --data-binary read 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 — dash leaves it literal.
  • Raw bytes with shell="powershell", and any body holding a NUL, now raise ValueError instead 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-Type fallback for a body sent without one is text/plain, not the reversed plain/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

Choose a tag to compare

@shpaker shpaker released this 17 Aug 12:59
51356d6

Added

  • Type annotations across the whole package, and a py.typed marker so they are visible to type checkers in projects that depend on curlify3 (PEP 561). to_curl() / to_curl_async() are declared as returning str and accepting shell, pretty and long_options; the ValueError contract for an unknown shell value is unchanged, so shell stays a plain str.
  • ty as the type checker and a lint CI job that runs it together with ruff on every pull request and on pushes to main.
  • 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

  • ruff replaces black and isort, 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 and from imports.
  • 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 sync def body(), which made the starlette adapter — registered as async, but inheriting the sync base — a type error.
  • The httpx2 adapter 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 the httpx2 copy separate; --http2 stays in the output.
  • The release workflow anchors its version replacement on the version lines instead of replacing every occurrence of the literal 0.1.0, which is also a plausible dependency bound now that Dependabot writes into pyproject.toml. The step asserts the replaced value afterwards, so a substitution that matched nothing stops the release instead of publishing the placeholder.

Fixed

  • The starlette adapter resolved starlette.requests.Request through a bare import starlette, which worked only because an unused import of the same module happened to register the submodule. It imports Request directly now.
  • A request whose Content-Type announces multipart while carrying no body no longer raises TypeError; it renders without -F. Reachable with requests when the header is set by hand.
  • A requests body that is an iterable or a file-like object (a streaming payload) is no longer rendered as its Python repr inside -d. Such a payload has no textual form a shell could replay, so the command now carries no -d at all.
  • Header values are decoded to text before they are rendered. requests types its header values as str | bytes, and a bytes value used to reach "multipart" in content_type and raise TypeError.

Full Changelog: 0.9...0.10

0.9

0.9

Choose a tag to compare

@shpaker shpaker released this 17 Aug 08:00
7f25a4c

Added

  • Readable output (#6): pretty=True on to_curl() / to_curl_async() puts the url on the first line and every option on its own, long_options=True renders --request / --header / --cookie / --data / --form instead of the short names. The two are independent and both default to False, so the one-line output is unchanged. pretty=True raises ValueError for shell="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

0.8

Choose a tag to compare

@shpaker shpaker released this 17 Aug 06:13
1eac5d2

Added

  • PowerShell output (#7): shell="powershell" on to_curl() / to_curl_async() renders curl.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; on pwsh 7.2+ run $PSNativeCommandArgumentPassing = 'Legacy' first, and note that %NAME% references in a payload are expanded. Constants curlify3.SH and curlify3.POWERSHELL are exported; default shell="sh" output is unchanged.
  • End-to-end shell tests (test_shell_e2e.py): the generated command is run by a real shell — bash, plus powershell.exe and pwsh on a new windows-latest CI job — against a local server that checks the request arrives byte-for-byte.

Full Changelog: 0.7...0.8

0.7

0.7

Choose a tag to compare

@shpaker shpaker released this 05 Jun 08:48

Changed

  • README restructured along Python-packaging best practices: added Features, structured Usage section with per-client examples (requests, httpx sync/async, httpx2, server-side aiohttp and starlette / fastapi), API reference, supported-objects matrix, payload-handling table, and a Development section.
  • Added a comparison table against curlify and curlify2 so users can pick the right package at a glance.
  • Added CI / Python-versions badges to README.

Full Changelog: 0.6...0.7

0.6

0.6

Choose a tag to compare

@shpaker shpaker released this 05 Jun 08:33
586f928

Added

  • httpx2 adapter (sync + async) — generates curl --http2 … for Pydantic's httpx2. Adapter is self-contained so future divergence from httpx stays 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 from poetry-core to hatchling.
  • Justfile now drives commands through uv run ….
  • CI workflow renamed Lint → Tests; matrix 3.10 → 3.14; uses astral-sh/setup-uv@v5.
  • Release workflow uses uv build + uv publish.
  • Dropped unused pytest-httpx dev 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 dynamic Accept-Encoding/User-Agent capture for aiohttp.

Full Changelog: 0.5...0.6