Supported request objects¶
An adapter registers only when its library is importable, so curlify3 itself has no runtime dependencies. The first adapter that accepts the object renders it; an object no adapter accepts raises UnsupportedRequestError.
Library |
Type |
|
|
Side |
|---|---|---|---|---|
|
|
✅ |
— |
client |
|
|
✅ |
— |
client |
|
|
✅ |
✅ |
client |
|
|
✅ |
✅ |
client |
|
|
✅ |
— |
client |
|
|
— |
✅ |
client |
|
|
— |
✅ |
server |
|
|
— |
✅ |
server |
|
|
✅ |
— |
server |
|
|
✅ |
— |
server |
|
|
✅ |
— |
client |
|
|
✅ |
— |
server |
The sections below show the call for each library and what its adapter does beyond reading the request.
Client side¶
requests¶
import requests
from curlify3 import to_curl
req = requests.Request("POST", "https://httpbin.org/post", json={"hello": "world"}).prepare()
print(to_curl(req))
Pass a PreparedRequest: Request(...).prepare(), Session().prepare_request(...), or response.request for one that was sent. A plain requests.Request has not been encoded yet and is not accepted.
niquests¶
import niquests
from curlify3 import to_curl
req = niquests.Request("POST", "https://httpbin.org/post", json={"hello": "world"}).prepare()
print(to_curl(req))
# curl -X POST -H 'content-type: application/json;charset=utf-8' -d '{"hello": "world"}' https://httpbin.org/post
The same shape as requests. HTTP/2 and HTTP/3 are negotiated on the transport, so the command carries no --http2. The async session sends the same PreparedRequest, which goes through the sync to_curl().
httpx¶
import httpx
from curlify3 import to_curl
req = httpx.Request("POST", "https://httpbin.org/post", json={"hello": "world"})
print(to_curl(req))
# curl -X POST -H 'host: httpbin.org' -H 'content-type: application/json' -d '{"hello":"world"}' https://httpbin.org/post
Both calls accept it; to_curl_async() reads a body that is an async stream. httpx adds the host header to the request object itself, so the command carries it.
httpx2¶
import httpx2
from curlify3 import to_curl
req = httpx2.Request("GET", "https://httpbin.org/get")
print(to_curl(req))
# curl --http2 -H 'host: httpbin.org' https://httpbin.org/get
As httpx, plus --http2.
urllib.request¶
import urllib.request
from curlify3 import to_curl
req = urllib.request.Request(
"https://httpbin.org/post",
data=b'{"hello": "world"}',
headers={"Content-Type": "application/json"},
)
print(to_curl(req))
# curl -X POST -H 'content-type: application/json' -d '{"hello": "world"}' https://httpbin.org/post
What urllib decides at send time is rendered as it will be sent: a request without an explicit method is POST when it carries data and GET otherwise, and data sent without a content-type gets application/x-www-form-urlencoded. Headers are merged from the regular and the unredirected ones. An iterable body has no text to render and the command carries no -d.
aiohttp client¶
import aiohttp
from curlify3 import to_curl_async
async def log_as_curl(request: aiohttp.ClientRequest, handler):
print(await to_curl_async(request))
return await handler(request)
async with aiohttp.ClientSession(middlewares=(log_as_curl,)) as session:
await session.post("https://httpbin.org/post", json={"hello": "world"})
The outgoing ClientRequest is reachable in a client middleware (aiohttp 3.12+). Rendering does not consume the payload: in-memory bodies hand back their value, files seek back, and async iterables are cached and replayed when the request is sent. That read needs aiohttp 3.12.1+; on older versions only in-memory payloads are read, and anything else renders without -d, because reading it would corrupt the outgoing request.
tornado client¶
import tornado.httpclient
from curlify3 import to_curl
req = tornado.httpclient.HTTPRequest(
"https://httpbin.org/post",
method="POST",
body='{"hello": "world"}',
headers={"Content-Type": "application/json"},
)
print(to_curl(req))
# curl -X POST -H 'content-type: application/json' -d '{"hello": "world"}' https://httpbin.org/post
The command includes the headers tornado’s client sets from the request’s attributes when it sends it: a basic authorization from auth_username / auth_password, user-agent from user_agent, accept-encoding: gzip from decompress_response, and a form-encoded content-type for a POST without one. Attributes left as None fall back to client-wide defaults the request object cannot see, and are not rendered.
Server side¶
On the server, every value in the command — path, query, headers, cookies, body — was chosen by the client. Quoting and untrusted values describes what keeps such a command safe to paste. The url keeps the path as it arrived, percent-encoding included, whenever the server makes it available: the decoded path would turn %2F into another route and %20 into a url curl rejects.
starlette / FastAPI¶
from fastapi import FastAPI, Request
from curlify3 import to_curl_async
app = FastAPI()
@app.post("/echo")
async def echo(request: Request):
return {"curl": await to_curl_async(request)}
Async: the body is read from the stream. Safe in a middleware — starlette caches what the render consumed and replays it to the route handler (starlette 0.28+); see the logging recipe. The path comes from the scope’s raw_path.
aiohttp server¶
from aiohttp import web
from curlify3 import to_curl_async
async def handler(request: web.Request) -> web.Response:
print(await to_curl_async(request))
return web.json_response({"ok": True})
Async: the body is read with request.read(), which caches it for the handler.
django¶
from django.http import JsonResponse
from curlify3 import to_curl
def echo(request):
return JsonResponse({"curl": to_curl(request)})
Django buffers the body before the view runs, so the sync to_curl() is the right call, async views included. WSGIRequest and ASGIRequest are both covered.
The path comes from
RAW_URI/REQUEST_URIwhen the server sets them (gunicorn and uwsgi do); otherwise from the decodedPATH_INFO.A host outside
ALLOWED_HOSTSis rendered rather than raisingDisallowedHost, so a logger that runs before django answers 400 still gets the command.A stream consumed without buffering —
request.read(), or multipart parsing — leaves nothing to render: the command carries the headers but no-d.
flask / werkzeug¶
import flask
from curlify3 import to_curl
app = flask.Flask(__name__)
@app.post("/echo")
def echo():
return {"curl": to_curl(flask.request)}
flask.Request subclasses werkzeug.wrappers.Request, so Flask and plain werkzeug apps are covered alike.
The body is read with
get_data(cache=True, parse_form_data=False): form parsing after the render still works.A form parsed before the render has drained the stream. An urlencoded one is encoded again from the parsed fields — the same fields, though not necessarily the same bytes; a multipart one also held files and renders without
-d.The path comes from
RAW_URI/REQUEST_URIwhen the server sets them (gunicorn, uwsgi and werkzeug’s own server do).
tornado server¶
import tornado.web
from curlify3 import to_curl
class EchoHandler(tornado.web.RequestHandler):
def post(self):
self.write({"curl": to_curl(self.request)})
tornado reads the body before the handler runs, so the incoming request renders synchronously. The url comes from full_url().