Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 40 additions & 10 deletions curlify.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,60 @@ 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.
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)
Comment on lines +33 to +35

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the reasonable behavior here is for a non-ASCII string.

On systems with UTF-8 shells, curl will use the UTF-8 string provided by the shell. For example, this will return 7, because the length of the string Hééé encoded as UTF-8 is 7 bytes, and so curl fills in Content-Length: 7 for this payload:

curl -s https://httpbin.org/post -d 'Hééé' | jq '.headers["Content-Length"] | tonumber'

But on other systems, the encoding will be different.

If body is bytes or a pure-ASCII string, inferred_length = len(body) is reasonable.

But if body is any other string, the behavior of Python requests (defaults to UTF-8) and curl may differ.


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():
if v:
command.append('-H ' + quote('{0}: {1}'.format(k, v)))
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 lk == 'connection' and v == 'keep-alive':
pass # this is curl's default behavior
elif v:
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
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:
Expand Down
58 changes: 41 additions & 17 deletions curlify_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ 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' "
"-H 'Content-Length: 0' "
"http://google.ru/"
)
Expand All @@ -28,13 +27,10 @@ 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' "
"-H 'Content-Length: 3' "
"-H 'Content-Type: application/x-www-form-urlencoded' "
"-b foo=bar "
"-d a=b http://google.ru/"
)

Expand All @@ -47,7 +43,7 @@ def test_prepare_request():

assert curlify.to_curl(request.prepare()) == (
"curl "
"-H 'user-agent: UA' "
"-A UA "
"http://google.ru/"
)

Expand All @@ -58,7 +54,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/"
)


Expand All @@ -68,7 +64,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/"
)


Expand All @@ -80,12 +76,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',
Expand All @@ -99,8 +123,8 @@ def test_post_csv_file():

expected = (
'curl'
' -H \'User-agent: UA\''
' -H \'Content-Length: 519\''
' -A UA'
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'
Expand All @@ -119,7 +143,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/"
)


Expand All @@ -136,11 +160,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-Length: 7' \\
-H 'Content-Type: application/x-www-form-urlencoded' \\
-b baz=quux \\
-d foo=bar \\
http://google.ru/'''

Expand Down