From 1270db4ae14481ecb57a7e64bcc3214c23d3d247 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Tue, 5 May 2026 13:23:46 -0700 Subject: [PATCH 1/5] Document 'pretty' parameter --- curlify.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/curlify.py b/curlify.py index 7a5fc5f..1f7ec29 100644 --- a/curlify.py +++ b/curlify.py @@ -13,6 +13,9 @@ def to_curl(request, compressed=False, verify=True, pretty=False): verify : bool If `False` then a `--insecure` argument will be added to the result, disabling TLS certificate verification + pretty : bool + If `True`, then the resulting command will be pretty-printed to include + one option per line. """ command = [] From 4a351978984c7a759a23a72e7ba46e86859604d2 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Tue, 5 May 2026 13:14:28 -0700 Subject: [PATCH 2/5] Use abbreviated options for 'User-Agent' and 'Cookie' headers The '-A' and '-b' options for these specific headers have been available in curl for a very long time. --- curlify.py | 9 ++++++++- curlify_test.py | 14 +++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/curlify.py b/curlify.py index 1f7ec29..44adab5 100644 --- a/curlify.py +++ b/curlify.py @@ -26,8 +26,15 @@ def to_curl(request, compressed=False, verify=True, pretty=False): command.append('-X ' + quote(request.method)) for k, v in request.headers.items(): + lk = k.lower() if v: - command.append('-H ' + quote('{0}: {1}'.format(k, v))) + lk = k.lower() + if lk == 'user-agent': + command.append('-A ' + quote(v)) + elif lk == 'cookie': + command.append('-b ' + quote(v)) + else: + command.append('-H ' + quote('{0}: {1}'.format(k, v))) else: # -H 'Accept:' disables sending the Accept header, use semicolon to send # empty header diff --git a/curlify_test.py b/curlify_test.py index 757c51d..d37d941 100644 --- a/curlify_test.py +++ b/curlify_test.py @@ -10,7 +10,7 @@ def test_empty_data(): ) assert curlify.to_curl(r.request) == ( "curl -X POST " - "-H 'user-agent: mytest' " + "-A mytest " "-H 'Accept-Encoding: gzip, deflate' " "-H 'Accept: */*' " "-H 'Connection: keep-alive' " @@ -28,11 +28,11 @@ def test_ok(): ) assert curlify.to_curl(r.request) == ( "curl -X GET " - "-H 'user-agent: mytest' " + "-A mytest " "-H 'Accept-Encoding: gzip, deflate' " "-H 'Accept: */*' " "-H 'Connection: keep-alive' " - "-H 'Cookie: foo=bar' " + "-b foo=bar " "-H 'Content-Length: 3' " "-H 'Content-Type: application/x-www-form-urlencoded' " "-d a=b http://google.ru/" @@ -47,7 +47,7 @@ def test_prepare_request(): assert curlify.to_curl(request.prepare()) == ( "curl " - "-H 'user-agent: UA' " + "-A UA " "http://google.ru/" ) @@ -58,7 +58,7 @@ def test_compressed(): headers={"user-agent": "UA"}, ) assert curlify.to_curl(request.prepare(), compressed=True) == ( - "curl -H 'user-agent: UA' --compressed http://google.ru/" + "curl -A UA --compressed http://google.ru/" ) @@ -68,7 +68,7 @@ def test_verify(): headers={"user-agent": "UA"}, ) assert curlify.to_curl(request.prepare(), verify=False) == ( - "curl -H 'user-agent: UA' --insecure http://google.ru/" + "curl -A UA --insecure http://google.ru/" ) @@ -99,7 +99,7 @@ def test_post_csv_file(): expected = ( 'curl' - ' -H \'User-agent: UA\'' + ' -A UA' ' -H \'Content-Length: 519\'' f' -H \'Content-Type: multipart/form-data; boundary={boundary}\'' f' -d \'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="data.csv"\r\n\r\n' From a00b9227aa841c7a03aa42bd33cff6d7bb2dae21 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Tue, 5 May 2026 13:18:37 -0700 Subject: [PATCH 3/5] Use '--json', and omit 'Content-Length' when set automatically The '--json' option was added in curl 7.82, and it implies *both* 'Content-Type: application/json' and 'Accept: application/json'. https://daniel.haxx.se/blog/2022/02/02/curl-dash-dash-json/ --- curlify.py | 36 ++++++++++++++++++++++++++---------- curlify_test.py | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/curlify.py b/curlify.py index 44adab5..4f74035 100644 --- a/curlify.py +++ b/curlify.py @@ -16,19 +16,41 @@ def to_curl(request, compressed=False, verify=True, pretty=False): pretty : bool If `True`, then the resulting command will be pretty-printed to include one option per line. + modern : bool + If `False`, then the resulting command will not use the --json option + added in curl 7.82. """ command = [] inferred_method = 'GET' - if request.body is not None: + inferred_length = None + data_type = None + body = request.body + + if body is not None: inferred_method = 'POST' + body = request.body + if isinstance(body, bytes): + body = body.decode('utf-8') + inferred_length = len(body) + + if modern and not body.startswith('@') and request.headers.get('content-type') == request.headers.get('accept') == 'application/json': + data_type = '--json' + elif body.startswith('@'): # -d @filename causes curl to read from file + data_type = '--data-raw' + else: + data_type = '-d' + if request.method != inferred_method: command.append('-X ' + quote(request.method)) for k, v in request.headers.items(): lk = k.lower() - if v: - lk = k.lower() + if data_type == '--json' and lk in ('content-type', 'accept'): + pass # --json will set this automatically + elif lk == 'content-length' and str(inferred_length) == v: + pass # this will be set automatically with the body + elif v: if lk == 'user-agent': command.append('-A ' + quote(v)) elif lk == 'cookie': @@ -40,13 +62,7 @@ def to_curl(request, compressed=False, verify=True, pretty=False): # empty header command.append('-H ' + quote('{0};'.format(k))) - if request.body: - body = request.body - if isinstance(body, bytes): - body = body.decode('utf-8') - data_type = '-d' - if body.startswith('@'): # -d @filename causes curl to read from file - data_type = '--data-raw' + if body is not None: command.append(data_type + ' ' + quote(body)) if compressed: diff --git a/curlify_test.py b/curlify_test.py index d37d941..8cc4ad7 100644 --- a/curlify_test.py +++ b/curlify_test.py @@ -33,7 +33,6 @@ def test_ok(): "-H 'Accept: */*' " "-H 'Connection: keep-alive' " "-b foo=bar " - "-H 'Content-Length: 3' " "-H 'Content-Type: application/x-www-form-urlencoded' " "-d a=b http://google.ru/" ) @@ -80,12 +79,40 @@ def test_post_json(): curlified = curlify.to_curl(r.prepare()) assert curlified == ( - "curl -H 'Content-Length: 14' " + "curl " "-H 'Content-Type: application/json' " "-d '{\"foo\": \"bar\"}' https://httpbin.org/post" ) +def test_post_and_accept_json(): + data = {'foo': 'bar'} + url = 'https://httpbin.org/post' + headers = {'accept': 'application/json'} + + r = requests.Request('POST', url, json=data, headers=headers) + curlified = curlify.to_curl(r.prepare()) + + assert curlified == ( + "curl " + "--json '{\"foo\": \"bar\"}' https://httpbin.org/post" + ) + + +def test_post_and_accept_json_with_older_curl(): + data = {'foo': 'bar'} + url = 'https://httpbin.org/post' + headers = {'accept': 'application/json'} + + r = requests.Request('POST', url, json=data, headers=headers) + curlified = curlify.to_curl(r.prepare(), modern=False) + + assert curlified == ( + "curl -H 'accept: application/json' -H 'Content-Type: application/json' " + "-d '{\"foo\": \"bar\"}' https://httpbin.org/post" + ) + + def test_post_csv_file(): r = requests.Request( method='POST', @@ -100,7 +127,7 @@ def test_post_csv_file(): expected = ( 'curl' ' -A UA' - ' -H \'Content-Length: 519\'' + f' -H \'Content-Length: 519\'' f' -H \'Content-Type: multipart/form-data; boundary={boundary}\'' f' -d \'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="data.csv"\r\n\r\n' '"Id";"Title";"Content"\n' @@ -119,7 +146,7 @@ def test_data_with_at(): data='@example.com' ) assert curlify.to_curl(request.prepare()) == ( - "curl -X GET -H 'Content-Length: 12' --data-raw @example.com http://google.ru/" + "curl -X GET --data-raw @example.com http://google.ru/" ) @@ -139,7 +166,6 @@ def test_pretty(): data={'foo': 'bar'} ) assert curlify.to_curl(request.prepare(), pretty=True) == '''curl -X GET \\ - -H 'Content-Length: 7' \\ -H 'Content-Type: application/x-www-form-urlencoded' \\ -d foo=bar \\ http://google.ru/''' From b384de93c698b6a47040e95d053876adb5e2d749 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Tue, 5 May 2026 14:50:32 -0700 Subject: [PATCH 4/5] Omit 'Content-Type' when set automatically Curl defaults to 'Content-Type: application/x-www-form-urlencoded' when -d/--data-raw are specified, so there's no need to repeat this. --- curlify.py | 2 ++ curlify_test.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/curlify.py b/curlify.py index 4f74035..216f886 100644 --- a/curlify.py +++ b/curlify.py @@ -48,6 +48,8 @@ def to_curl(request, compressed=False, verify=True, pretty=False): lk = k.lower() if data_type == '--json' and lk in ('content-type', 'accept'): pass # --json will set this automatically + elif lk == 'content-type' and v == 'application/x-www-form-urlencoded': + pass # -d/--data-raw will set this automatically elif lk == 'content-length' and str(inferred_length) == v: pass # this will be set automatically with the body elif v: diff --git a/curlify_test.py b/curlify_test.py index 8cc4ad7..3f230b5 100644 --- a/curlify_test.py +++ b/curlify_test.py @@ -33,7 +33,6 @@ def test_ok(): "-H 'Accept: */*' " "-H 'Connection: keep-alive' " "-b foo=bar " - "-H 'Content-Type: application/x-www-form-urlencoded' " "-d a=b http://google.ru/" ) @@ -163,10 +162,11 @@ def test_empty_header(): def test_pretty(): request = requests.Request( 'GET', "http://google.ru", - data={'foo': 'bar'} + data={'foo': 'bar'}, + cookies={'baz': 'quux'}, ) assert curlify.to_curl(request.prepare(), pretty=True) == '''curl -X GET \\ - -H 'Content-Type: application/x-www-form-urlencoded' \\ + -b baz=quux \\ -d foo=bar \\ http://google.ru/''' From ba4b146da6008c46751625224448bb32c076b477 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Tue, 5 May 2026 14:56:49 -0700 Subject: [PATCH 5/5] Omit 'Connection: keep-alive' This is curl's default behavior with HTTP/1.1, and does nothing with HTTP/2 and newer protocol versions. The other plausible value of this header is 'Connection: close', and should still be provided to curl. --- curlify.py | 2 ++ curlify_test.py | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/curlify.py b/curlify.py index 216f886..b3ec740 100644 --- a/curlify.py +++ b/curlify.py @@ -52,6 +52,8 @@ def to_curl(request, compressed=False, verify=True, pretty=False): pass # -d/--data-raw will set this automatically elif lk == 'content-length' and str(inferred_length) == v: pass # this will be set automatically with the body + elif lk == 'connection' and v == 'keep-alive': + pass # this is curl's default behavior elif v: if lk == 'user-agent': command.append('-A ' + quote(v)) diff --git a/curlify_test.py b/curlify_test.py index 3f230b5..eb1039e 100644 --- a/curlify_test.py +++ b/curlify_test.py @@ -13,7 +13,6 @@ def test_empty_data(): "-A mytest " "-H 'Accept-Encoding: gzip, deflate' " "-H 'Accept: */*' " - "-H 'Connection: keep-alive' " "-H 'Content-Length: 0' " "http://google.ru/" ) @@ -31,7 +30,6 @@ def test_ok(): "-A mytest " "-H 'Accept-Encoding: gzip, deflate' " "-H 'Accept: */*' " - "-H 'Connection: keep-alive' " "-b foo=bar " "-d a=b http://google.ru/" )