curlify3 0.7__tar.gz → 0.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: curlify3
3
- Version: 0.7
3
+ Version: 0.9
4
4
  Summary: yet another library to convert request object to curl command
5
5
  Project-URL: Repository, https://github.com/shpaker/curlify3
6
6
  Author-email: "A.Shpak" <shpaker@gmail.com>
@@ -24,6 +24,8 @@ Convert request objects from popular Python HTTP libraries into ready-to-run `cu
24
24
  - Works with **client-side** requests (`requests`, `httpx`, `httpx2`) and **server-side** incoming requests (`aiohttp.web`, `starlette` / `fastapi`)
25
25
  - Faithful rendering of headers, query parameters, cookies (`-b`), and bodies
26
26
  - Body payloads: text, JSON, form-encoded, multipart, binary
27
+ - POSIX shell output by default, Windows PowerShell output with `shell="powershell"`
28
+ - One-line output by default, multi-line with `pretty=True` and long option names with `long_options=True`
27
29
  - Zero runtime dependencies
28
30
 
29
31
  ## Installation
@@ -116,6 +118,56 @@ print(to_curl(req))
116
118
 
117
119
  `to_curl_async()` works with `httpx2.Request` too.
118
120
 
121
+ ### Readable output
122
+
123
+ `pretty=True` puts every option on its own line, and `long_options=True` spells the options out (`--header` instead of `-H`). They are independent, so either can be used alone.
124
+
125
+ ```python
126
+ import requests
127
+ from curlify3 import to_curl
128
+
129
+ req = requests.Request(
130
+ "POST",
131
+ "https://httpbin.org/post",
132
+ json={"date": "2026-08-10"},
133
+ ).prepare()
134
+
135
+ print(to_curl(req, pretty=True, long_options=True))
136
+ # curl https://httpbin.org/post \
137
+ # --request POST \
138
+ # --header 'content-type: application/json' \
139
+ # --data '{"date": "2026-08-10"}'
140
+ ```
141
+
142
+ The url moves to the first line, where `curl` reads it just as well as in the trailing position — the same layout Chrome DevTools' "Copy as cURL" produces. A request with no options stays on one line.
143
+
144
+ `pretty=True` is rejected with a `ValueError` for `shell="powershell"`: the `--%` token that dialect relies on is effective only until the next newline, and a backtick cannot extend it, so a multi-line command would be passed to `curl.exe` in pieces.
145
+
146
+ ### Windows PowerShell
147
+
148
+ By default the command is formatted for POSIX shells. Pass `shell="powershell"` to get one that pastes into Windows PowerShell 5.1.
149
+
150
+ ```python
151
+ import requests
152
+ from curlify3 import to_curl
153
+
154
+ req = requests.Request(
155
+ "POST",
156
+ "https://httpbin.org/post",
157
+ json={"date": "2026-08-10"},
158
+ ).prepare()
159
+
160
+ print(to_curl(req, shell="powershell"))
161
+ # curl.exe --% -X POST -H "content-type: application/json" -d "{\"date\": \"2026-08-10\"}" "https://httpbin.org/post"
162
+ ```
163
+
164
+ `curl.exe` avoids the `Invoke-WebRequest` alias, and `--%` — PowerShell's stop-parsing token — hands the rest to `curl.exe` verbatim. The token is what makes arbitrary JSON survive: without it, 5.1's argument binder re-quotes values by counting every double quote, escaped or not, and mangles the body. Two consequences worth knowing:
165
+
166
+ - `%NAME%` environment-variable references in a payload are still expanded.
167
+ - The command is for PowerShell only — in `cmd`, git-bash, or WSL, `curl.exe` chokes on `--%`; use the default `shell="sh"` output there. On `pwsh` 7.2+, run `$PSNativeCommandArgumentPassing = 'Legacy'` in the session first.
168
+
169
+ The constants `curlify3.SH` and `curlify3.POWERSHELL` are exported for use instead of the raw strings.
170
+
119
171
  ### `aiohttp` — server-side
120
172
 
121
173
  Render an incoming request inside a handler. The async variant is required because the body is read from the stream.
@@ -146,15 +198,17 @@ async def echo(request: Request):
146
198
 
147
199
  ## API
148
200
 
149
- ### `to_curl(request) -> str`
201
+ ### `to_curl(request, shell="sh", pretty=False, long_options=False) -> str`
150
202
 
151
203
  Render a request object as a `curl` command. Use for synchronous request types (`requests.PreparedRequest`, `httpx.Request`, `httpx2.Request`).
152
204
 
153
- ### `to_curl_async(request) -> str`
205
+ ### `to_curl_async(request, shell="sh", pretty=False, long_options=False) -> str`
154
206
 
155
207
  Async variant. Use for server-side request objects whose body must be `await`-ed (`aiohttp.web.Request`, `starlette.requests.Request`) or when you prefer the async pathway for `httpx` / `httpx2`.
156
208
 
157
- Both functions raise `ValueError("unknown request object")` if the request type is not recognized.
209
+ `shell` selects the output dialect: `"sh"` (default, POSIX shells) or `"powershell"` (Windows PowerShell 5.1; for `pwsh` 7.2+ see the PowerShell section). `pretty` breaks the command across lines, `long_options` spells the options out; both default to `False`, which keeps the output on a single line with short options.
210
+
211
+ Both functions raise `ValueError` if the request type or the `shell` value is not recognized, or if `pretty=True` is combined with `shell="powershell"`.
158
212
 
159
213
  ## Supported request objects
160
214
 
@@ -15,6 +15,8 @@ Convert request objects from popular Python HTTP libraries into ready-to-run `cu
15
15
  - Works with **client-side** requests (`requests`, `httpx`, `httpx2`) and **server-side** incoming requests (`aiohttp.web`, `starlette` / `fastapi`)
16
16
  - Faithful rendering of headers, query parameters, cookies (`-b`), and bodies
17
17
  - Body payloads: text, JSON, form-encoded, multipart, binary
18
+ - POSIX shell output by default, Windows PowerShell output with `shell="powershell"`
19
+ - One-line output by default, multi-line with `pretty=True` and long option names with `long_options=True`
18
20
  - Zero runtime dependencies
19
21
 
20
22
  ## Installation
@@ -107,6 +109,56 @@ print(to_curl(req))
107
109
 
108
110
  `to_curl_async()` works with `httpx2.Request` too.
109
111
 
112
+ ### Readable output
113
+
114
+ `pretty=True` puts every option on its own line, and `long_options=True` spells the options out (`--header` instead of `-H`). They are independent, so either can be used alone.
115
+
116
+ ```python
117
+ import requests
118
+ from curlify3 import to_curl
119
+
120
+ req = requests.Request(
121
+ "POST",
122
+ "https://httpbin.org/post",
123
+ json={"date": "2026-08-10"},
124
+ ).prepare()
125
+
126
+ print(to_curl(req, pretty=True, long_options=True))
127
+ # curl https://httpbin.org/post \
128
+ # --request POST \
129
+ # --header 'content-type: application/json' \
130
+ # --data '{"date": "2026-08-10"}'
131
+ ```
132
+
133
+ The url moves to the first line, where `curl` reads it just as well as in the trailing position — the same layout Chrome DevTools' "Copy as cURL" produces. A request with no options stays on one line.
134
+
135
+ `pretty=True` is rejected with a `ValueError` for `shell="powershell"`: the `--%` token that dialect relies on is effective only until the next newline, and a backtick cannot extend it, so a multi-line command would be passed to `curl.exe` in pieces.
136
+
137
+ ### Windows PowerShell
138
+
139
+ By default the command is formatted for POSIX shells. Pass `shell="powershell"` to get one that pastes into Windows PowerShell 5.1.
140
+
141
+ ```python
142
+ import requests
143
+ from curlify3 import to_curl
144
+
145
+ req = requests.Request(
146
+ "POST",
147
+ "https://httpbin.org/post",
148
+ json={"date": "2026-08-10"},
149
+ ).prepare()
150
+
151
+ print(to_curl(req, shell="powershell"))
152
+ # curl.exe --% -X POST -H "content-type: application/json" -d "{\"date\": \"2026-08-10\"}" "https://httpbin.org/post"
153
+ ```
154
+
155
+ `curl.exe` avoids the `Invoke-WebRequest` alias, and `--%` — PowerShell's stop-parsing token — hands the rest to `curl.exe` verbatim. The token is what makes arbitrary JSON survive: without it, 5.1's argument binder re-quotes values by counting every double quote, escaped or not, and mangles the body. Two consequences worth knowing:
156
+
157
+ - `%NAME%` environment-variable references in a payload are still expanded.
158
+ - The command is for PowerShell only — in `cmd`, git-bash, or WSL, `curl.exe` chokes on `--%`; use the default `shell="sh"` output there. On `pwsh` 7.2+, run `$PSNativeCommandArgumentPassing = 'Legacy'` in the session first.
159
+
160
+ The constants `curlify3.SH` and `curlify3.POWERSHELL` are exported for use instead of the raw strings.
161
+
110
162
  ### `aiohttp` — server-side
111
163
 
112
164
  Render an incoming request inside a handler. The async variant is required because the body is read from the stream.
@@ -137,15 +189,17 @@ async def echo(request: Request):
137
189
 
138
190
  ## API
139
191
 
140
- ### `to_curl(request) -> str`
192
+ ### `to_curl(request, shell="sh", pretty=False, long_options=False) -> str`
141
193
 
142
194
  Render a request object as a `curl` command. Use for synchronous request types (`requests.PreparedRequest`, `httpx.Request`, `httpx2.Request`).
143
195
 
144
- ### `to_curl_async(request) -> str`
196
+ ### `to_curl_async(request, shell="sh", pretty=False, long_options=False) -> str`
145
197
 
146
198
  Async variant. Use for server-side request objects whose body must be `await`-ed (`aiohttp.web.Request`, `starlette.requests.Request`) or when you prefer the async pathway for `httpx` / `httpx2`.
147
199
 
148
- Both functions raise `ValueError("unknown request object")` if the request type is not recognized.
200
+ `shell` selects the output dialect: `"sh"` (default, POSIX shells) or `"powershell"` (Windows PowerShell 5.1; for `pwsh` 7.2+ see the PowerShell section). `pretty` breaks the command across lines, `long_options` spells the options out; both default to `False`, which keeps the output on a single line with short options.
201
+
202
+ Both functions raise `ValueError` if the request type or the `shell` value is not recognized, or if `pretty=True` is combined with `shell="powershell"`.
149
203
 
150
204
  ## Supported request objects
151
205
 
@@ -0,0 +1,9 @@
1
+ from curlify3._curl import POWERSHELL, SH, to_curl, to_curl_async
2
+
3
+ __version__ = "0.9"
4
+ __all__ = [
5
+ "POWERSHELL",
6
+ "SH",
7
+ "to_curl",
8
+ "to_curl_async",
9
+ ]
@@ -0,0 +1,168 @@
1
+ import re
2
+
3
+ from asyncio import iscoroutine
4
+ from typing import Callable, NamedTuple, Optional
5
+
6
+ from curlify3._utils import make_request_obj, make_request_obj_async
7
+
8
+ MULTIPART_FORM_DATA = re.compile(rb'form-data; name="(.[^"]+)"\r\n\r\n(.+)\r\n')
9
+ MULTIPART_FILE_DATA = re.compile(rb'form-data; name="(.[^"]+)"; filename="(.[^"]+)"')
10
+
11
+ PS_QUOTE = re.compile(r'(\\*)"')
12
+ PS_TRAILING_BACKSLASHES = re.compile(r"\\+$")
13
+
14
+ SH = "sh"
15
+ POWERSHELL = "powershell"
16
+
17
+ SHORT_OPTIONS = {
18
+ "request": "-X",
19
+ "header": "-H",
20
+ "cookie": "-b",
21
+ "data": "-d",
22
+ "form": "-F",
23
+ }
24
+ LONG_OPTIONS = {
25
+ "request": "--request",
26
+ "header": "--header",
27
+ "cookie": "--cookie",
28
+ "data": "--data",
29
+ "form": "--form",
30
+ }
31
+
32
+
33
+ def quote_sh(value):
34
+ return f"'{value}'"
35
+
36
+
37
+ def quote_sh_url(url):
38
+ return url if not "&" in url else quote_sh(url)
39
+
40
+
41
+ def quote_sh_cookies(cookies):
42
+ return quote_sh(cookies) if " " in cookies else cookies
43
+
44
+
45
+ def quote_powershell(value):
46
+ # the command is emitted behind the --% stop-parsing token, so the only parser left is the
47
+ # C runtime of curl.exe: wrap in "...", escape " as \" doubling any run of backslashes
48
+ # directly before it, and double a trailing run so it cannot swallow the closing quote
49
+ value = PS_QUOTE.sub(lambda matched: matched.group(1) * 2 + '\\"', str(value))
50
+ value = PS_TRAILING_BACKSLASHES.sub(lambda matched: matched.group() * 2, value)
51
+ return f'"{value}"'
52
+
53
+
54
+ class ShellConfig(NamedTuple):
55
+ binary: str
56
+ args_prefix: str
57
+ quote: Callable
58
+ quote_url: Callable
59
+ quote_cookies: Callable
60
+ # what separates arguments in pretty mode, None when the shell cannot span lines
61
+ pretty_separator: Optional[str]
62
+
63
+
64
+ SHELLS = {
65
+ SH: ShellConfig("curl", "", quote_sh, quote_sh_url, quote_sh_cookies, " \\\n "),
66
+ # --% is the stop-parsing token: Windows PowerShell 5.1 (the dialect's target) hands
67
+ # everything after it to curl.exe verbatim (only %VAR% references expand), leaving the
68
+ # C runtime as the single parser — 5.1's own argument binder re-quotes by counting every
69
+ # double quote, escaped or not, and corrupts JSON payloads no matter how they are written;
70
+ # pwsh 7.2+ binds even these arguments the new way and needs
71
+ # $PSNativeCommandArgumentPassing = 'Legacy' in the session first (documented in README);
72
+ # the url and cookies are always quoted to keep whitespace safe for the C runtime parser
73
+ # pretty is impossible here: --% is effective only until the next newline and the line
74
+ # continuation character (`) cannot extend it (about_Parsing), so a multi-line command
75
+ # would pass the backtick to curl.exe and run the next line on its own
76
+ POWERSHELL: ShellConfig("curl.exe", "--%", quote_powershell, quote_powershell, quote_powershell, None),
77
+ }
78
+
79
+
80
+ def make_curl_headers(headers, quote, options):
81
+ option = options["header"]
82
+ return [f"{option} {quote(f'{header}: {value}')}" for header, value in headers.items()]
83
+
84
+
85
+ def make_curl_cookies(cookies, quote_cookies, options):
86
+ if not cookies:
87
+ return []
88
+ return [f"{options['cookie']} {quote_cookies(cookies)}"]
89
+
90
+
91
+ def make_multipart_curl_args(body, quote, options):
92
+ option = options["form"]
93
+ body_parts = []
94
+ body = body.encode() if isinstance(body, str) else body
95
+ for matched in MULTIPART_FORM_DATA.finditer(body):
96
+ groups = matched.groups()
97
+ body_parts.append(f"{option} {quote(f'{groups[0].decode()}={groups[1].decode()}')}")
98
+ for matched in MULTIPART_FILE_DATA.finditer(body):
99
+ groups = matched.groups()
100
+ body_parts.append(f"{option} {quote(f'{groups[0].decode()}=@{groups[1].decode()}')}")
101
+ return body_parts
102
+
103
+
104
+ def make_curl_body(body, headers, quote, options):
105
+ if "multipart" in headers.get("content-type", ""):
106
+ return make_multipart_curl_args(body, quote, options)
107
+ if not body:
108
+ return []
109
+ return [f"{options['data']} {quote(body)}"]
110
+
111
+
112
+ def make_curl_string(method, url, headers, body, cookies, http2=False, shell=SH, pretty=False, long_options=False):
113
+ if shell not in SHELLS:
114
+ raise ValueError(f"unknown shell: {shell!r}, expected one of {sorted(SHELLS)}")
115
+ shell_conf = SHELLS[shell]
116
+ if pretty and shell_conf.pretty_separator is None:
117
+ raise ValueError(f"pretty output is not supported for shell: {shell!r}")
118
+ options = LONG_OPTIONS if long_options else SHORT_OPTIONS
119
+ if "content-length" in headers:
120
+ del headers["content-length"]
121
+ if body and isinstance(body, (str, bytes)) and not headers.get("content-type"):
122
+ headers["content-type"] = "plain/text"
123
+ args = [
124
+ "--http2" if http2 else None,
125
+ f"{options['request']} {method}" if method != "GET" else None,
126
+ *make_curl_cookies(cookies, shell_conf.quote_cookies, options),
127
+ *make_curl_headers(headers, shell_conf.quote, options),
128
+ *make_curl_body(body, headers, shell_conf.quote, options),
129
+ ]
130
+ args = [str(entity) for entity in args if entity]
131
+ command = " ".join([part for part in (shell_conf.binary, shell_conf.args_prefix) if part])
132
+ url = shell_conf.quote_url(url)
133
+ if pretty:
134
+ return shell_conf.pretty_separator.join([f"{command} {url}", *args])
135
+ return " ".join([command, *args, url])
136
+
137
+
138
+ def to_curl(request, shell=SH, pretty=False, long_options=False):
139
+ data = make_request_obj(request)
140
+ return make_curl_string(
141
+ method=data.method,
142
+ url=data.url,
143
+ headers=data.headers,
144
+ body=data.body(),
145
+ cookies=data.cookies,
146
+ http2=getattr(data, "http2", False),
147
+ shell=shell,
148
+ pretty=pretty,
149
+ long_options=long_options,
150
+ )
151
+
152
+
153
+ async def to_curl_async(request, shell=SH, pretty=False, long_options=False):
154
+ data = make_request_obj_async(request)
155
+ body = data.body()
156
+ if iscoroutine(body):
157
+ body = await body
158
+ return make_curl_string(
159
+ method=data.method,
160
+ url=data.url,
161
+ headers=data.headers,
162
+ body=body,
163
+ cookies=data.cookies,
164
+ http2=getattr(data, "http2", False),
165
+ shell=shell,
166
+ pretty=pretty,
167
+ long_options=long_options,
168
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "curlify3"
3
- version = "0.7"
3
+ version = "0.9"
4
4
  description = "yet another library to convert request object to curl command"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,7 +0,0 @@
1
- from curlify3._curl import to_curl, to_curl_async
2
-
3
- __version__ = "0.7"
4
- __all__ = [
5
- "to_curl",
6
- "to_curl_async",
7
- ]
@@ -1,91 +0,0 @@
1
- import re
2
-
3
- from asyncio import iscoroutine
4
-
5
- from curlify3._utils import make_request_obj, make_request_obj_async
6
-
7
- MULTIPART_FORM_DATA = re.compile(rb'form-data; name="(.[^"]+)"\r\n\r\n(.+)\r\n')
8
- MULTIPART_FILE_DATA = re.compile(rb'form-data; name="(.[^"]+)"; filename="(.[^"]+)"')
9
-
10
-
11
- def make_full_url(url) -> str:
12
- return url if not "&" in url else f"'{url}'"
13
-
14
-
15
- def make_curl_headers(headers):
16
- results = []
17
- for header, value in headers.items():
18
- results.append(f"-H '{header}: {value}'")
19
- return " ".join(results)
20
-
21
-
22
- def make_curl_cookies(cookies):
23
- if not cookies:
24
- return None
25
- if " " in cookies:
26
- cookies = f"'{cookies}'"
27
- return f"-b {cookies}"
28
-
29
-
30
- def make_multipart_curl_args(body):
31
- body_parts = []
32
- body = body.encode() if isinstance(body, str) else body
33
- for matched in MULTIPART_FORM_DATA.finditer(body):
34
- groups = matched.groups()
35
- body_parts.append(f"-F '{groups[0].decode()}={groups[1].decode()}'")
36
- for matched in MULTIPART_FILE_DATA.finditer(body):
37
- groups = matched.groups()
38
- body_parts.append(f"-F '{groups[0].decode()}=@{groups[1].decode()}'")
39
- return " ".join(body_parts)
40
-
41
-
42
- def make_curl_body(body, headers):
43
- if "multipart" in headers.get("content-type", ""):
44
- return make_multipart_curl_args(body)
45
- if not body:
46
- return ""
47
- return f"-d '{body}'"
48
-
49
-
50
- def make_curl_string(method, url, headers, body, cookies, http2=False):
51
- if "content-length" in headers:
52
- del headers["content-length"]
53
- if body and isinstance(body, (str, bytes)) and not headers.get("content-type"):
54
- headers["content-type"] = "plain/text"
55
- cli_parts = [
56
- f"curl",
57
- "--http2" if http2 else None,
58
- f"-X {method}" if method != "GET" else None,
59
- make_curl_cookies(cookies),
60
- make_curl_headers(headers),
61
- make_curl_body(body, headers),
62
- make_full_url(url),
63
- ]
64
- return " ".join([str(entity) for entity in cli_parts if entity])
65
-
66
-
67
- def to_curl(request):
68
- data = make_request_obj(request)
69
- return make_curl_string(
70
- method=data.method,
71
- url=data.url,
72
- headers=data.headers,
73
- body=data.body(),
74
- cookies=data.cookies,
75
- http2=getattr(data, "http2", False),
76
- )
77
-
78
-
79
- async def to_curl_async(request):
80
- data = make_request_obj_async(request)
81
- body = data.body()
82
- if iscoroutine(body):
83
- body = await body
84
- return make_curl_string(
85
- method=data.method,
86
- url=data.url,
87
- headers=data.headers,
88
- body=body,
89
- cookies=data.cookies,
90
- http2=getattr(data, "http2", False),
91
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes