just-bash 0.1.5__py3-none-any.whl
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.
- just_bash/__init__.py +55 -0
- just_bash/ast/__init__.py +213 -0
- just_bash/ast/factory.py +320 -0
- just_bash/ast/types.py +953 -0
- just_bash/bash.py +220 -0
- just_bash/commands/__init__.py +23 -0
- just_bash/commands/argv/__init__.py +5 -0
- just_bash/commands/argv/argv.py +21 -0
- just_bash/commands/awk/__init__.py +5 -0
- just_bash/commands/awk/awk.py +1168 -0
- just_bash/commands/base64/__init__.py +5 -0
- just_bash/commands/base64/base64.py +138 -0
- just_bash/commands/basename/__init__.py +5 -0
- just_bash/commands/basename/basename.py +72 -0
- just_bash/commands/bash/__init__.py +5 -0
- just_bash/commands/bash/bash.py +188 -0
- just_bash/commands/cat/__init__.py +5 -0
- just_bash/commands/cat/cat.py +173 -0
- just_bash/commands/checksum/__init__.py +5 -0
- just_bash/commands/checksum/checksum.py +179 -0
- just_bash/commands/chmod/__init__.py +5 -0
- just_bash/commands/chmod/chmod.py +216 -0
- just_bash/commands/column/__init__.py +5 -0
- just_bash/commands/column/column.py +180 -0
- just_bash/commands/comm/__init__.py +5 -0
- just_bash/commands/comm/comm.py +150 -0
- just_bash/commands/compression/__init__.py +5 -0
- just_bash/commands/compression/compression.py +298 -0
- just_bash/commands/cp/__init__.py +5 -0
- just_bash/commands/cp/cp.py +149 -0
- just_bash/commands/curl/__init__.py +5 -0
- just_bash/commands/curl/curl.py +801 -0
- just_bash/commands/cut/__init__.py +5 -0
- just_bash/commands/cut/cut.py +327 -0
- just_bash/commands/date/__init__.py +5 -0
- just_bash/commands/date/date.py +258 -0
- just_bash/commands/diff/__init__.py +5 -0
- just_bash/commands/diff/diff.py +118 -0
- just_bash/commands/dirname/__init__.py +5 -0
- just_bash/commands/dirname/dirname.py +56 -0
- just_bash/commands/du/__init__.py +5 -0
- just_bash/commands/du/du.py +150 -0
- just_bash/commands/echo/__init__.py +5 -0
- just_bash/commands/echo/echo.py +125 -0
- just_bash/commands/env/__init__.py +5 -0
- just_bash/commands/env/env.py +163 -0
- just_bash/commands/expand/__init__.py +5 -0
- just_bash/commands/expand/expand.py +299 -0
- just_bash/commands/expr/__init__.py +5 -0
- just_bash/commands/expr/expr.py +273 -0
- just_bash/commands/file/__init__.py +5 -0
- just_bash/commands/file/file.py +274 -0
- just_bash/commands/find/__init__.py +5 -0
- just_bash/commands/find/find.py +623 -0
- just_bash/commands/fold/__init__.py +5 -0
- just_bash/commands/fold/fold.py +160 -0
- just_bash/commands/grep/__init__.py +5 -0
- just_bash/commands/grep/grep.py +418 -0
- just_bash/commands/head/__init__.py +5 -0
- just_bash/commands/head/head.py +167 -0
- just_bash/commands/help/__init__.py +5 -0
- just_bash/commands/help/help.py +67 -0
- just_bash/commands/hostname/__init__.py +5 -0
- just_bash/commands/hostname/hostname.py +21 -0
- just_bash/commands/html_to_markdown/__init__.py +5 -0
- just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
- just_bash/commands/join/__init__.py +5 -0
- just_bash/commands/join/join.py +252 -0
- just_bash/commands/jq/__init__.py +5 -0
- just_bash/commands/jq/jq.py +280 -0
- just_bash/commands/ln/__init__.py +5 -0
- just_bash/commands/ln/ln.py +127 -0
- just_bash/commands/ls/__init__.py +5 -0
- just_bash/commands/ls/ls.py +280 -0
- just_bash/commands/mkdir/__init__.py +5 -0
- just_bash/commands/mkdir/mkdir.py +92 -0
- just_bash/commands/mv/__init__.py +5 -0
- just_bash/commands/mv/mv.py +142 -0
- just_bash/commands/nl/__init__.py +5 -0
- just_bash/commands/nl/nl.py +180 -0
- just_bash/commands/od/__init__.py +5 -0
- just_bash/commands/od/od.py +157 -0
- just_bash/commands/paste/__init__.py +5 -0
- just_bash/commands/paste/paste.py +100 -0
- just_bash/commands/printf/__init__.py +5 -0
- just_bash/commands/printf/printf.py +157 -0
- just_bash/commands/pwd/__init__.py +5 -0
- just_bash/commands/pwd/pwd.py +23 -0
- just_bash/commands/read/__init__.py +5 -0
- just_bash/commands/read/read.py +185 -0
- just_bash/commands/readlink/__init__.py +5 -0
- just_bash/commands/readlink/readlink.py +86 -0
- just_bash/commands/registry.py +844 -0
- just_bash/commands/rev/__init__.py +5 -0
- just_bash/commands/rev/rev.py +74 -0
- just_bash/commands/rg/__init__.py +5 -0
- just_bash/commands/rg/rg.py +1048 -0
- just_bash/commands/rm/__init__.py +5 -0
- just_bash/commands/rm/rm.py +106 -0
- just_bash/commands/search_engine/__init__.py +13 -0
- just_bash/commands/search_engine/matcher.py +170 -0
- just_bash/commands/search_engine/regex.py +159 -0
- just_bash/commands/sed/__init__.py +5 -0
- just_bash/commands/sed/sed.py +863 -0
- just_bash/commands/seq/__init__.py +5 -0
- just_bash/commands/seq/seq.py +190 -0
- just_bash/commands/shell/__init__.py +5 -0
- just_bash/commands/shell/shell.py +206 -0
- just_bash/commands/sleep/__init__.py +5 -0
- just_bash/commands/sleep/sleep.py +62 -0
- just_bash/commands/sort/__init__.py +5 -0
- just_bash/commands/sort/sort.py +411 -0
- just_bash/commands/split/__init__.py +5 -0
- just_bash/commands/split/split.py +237 -0
- just_bash/commands/sqlite3/__init__.py +5 -0
- just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
- just_bash/commands/stat/__init__.py +5 -0
- just_bash/commands/stat/stat.py +150 -0
- just_bash/commands/strings/__init__.py +5 -0
- just_bash/commands/strings/strings.py +150 -0
- just_bash/commands/tac/__init__.py +5 -0
- just_bash/commands/tac/tac.py +158 -0
- just_bash/commands/tail/__init__.py +5 -0
- just_bash/commands/tail/tail.py +180 -0
- just_bash/commands/tar/__init__.py +5 -0
- just_bash/commands/tar/tar.py +1067 -0
- just_bash/commands/tee/__init__.py +5 -0
- just_bash/commands/tee/tee.py +63 -0
- just_bash/commands/timeout/__init__.py +5 -0
- just_bash/commands/timeout/timeout.py +188 -0
- just_bash/commands/touch/__init__.py +5 -0
- just_bash/commands/touch/touch.py +91 -0
- just_bash/commands/tr/__init__.py +5 -0
- just_bash/commands/tr/tr.py +297 -0
- just_bash/commands/tree/__init__.py +5 -0
- just_bash/commands/tree/tree.py +139 -0
- just_bash/commands/true/__init__.py +5 -0
- just_bash/commands/true/true.py +32 -0
- just_bash/commands/uniq/__init__.py +5 -0
- just_bash/commands/uniq/uniq.py +323 -0
- just_bash/commands/wc/__init__.py +5 -0
- just_bash/commands/wc/wc.py +169 -0
- just_bash/commands/which/__init__.py +5 -0
- just_bash/commands/which/which.py +52 -0
- just_bash/commands/xan/__init__.py +5 -0
- just_bash/commands/xan/xan.py +1663 -0
- just_bash/commands/xargs/__init__.py +5 -0
- just_bash/commands/xargs/xargs.py +136 -0
- just_bash/commands/yq/__init__.py +5 -0
- just_bash/commands/yq/yq.py +848 -0
- just_bash/fs/__init__.py +29 -0
- just_bash/fs/in_memory_fs.py +621 -0
- just_bash/fs/mountable_fs.py +504 -0
- just_bash/fs/overlay_fs.py +894 -0
- just_bash/fs/read_write_fs.py +455 -0
- just_bash/interpreter/__init__.py +37 -0
- just_bash/interpreter/builtins/__init__.py +92 -0
- just_bash/interpreter/builtins/alias.py +154 -0
- just_bash/interpreter/builtins/cd.py +76 -0
- just_bash/interpreter/builtins/control.py +127 -0
- just_bash/interpreter/builtins/declare.py +336 -0
- just_bash/interpreter/builtins/export.py +56 -0
- just_bash/interpreter/builtins/let.py +44 -0
- just_bash/interpreter/builtins/local.py +57 -0
- just_bash/interpreter/builtins/mapfile.py +152 -0
- just_bash/interpreter/builtins/misc.py +378 -0
- just_bash/interpreter/builtins/readonly.py +80 -0
- just_bash/interpreter/builtins/set.py +234 -0
- just_bash/interpreter/builtins/shopt.py +201 -0
- just_bash/interpreter/builtins/source.py +136 -0
- just_bash/interpreter/builtins/test.py +290 -0
- just_bash/interpreter/builtins/unset.py +53 -0
- just_bash/interpreter/conditionals.py +387 -0
- just_bash/interpreter/control_flow.py +381 -0
- just_bash/interpreter/errors.py +116 -0
- just_bash/interpreter/expansion.py +1156 -0
- just_bash/interpreter/interpreter.py +813 -0
- just_bash/interpreter/types.py +134 -0
- just_bash/network/__init__.py +1 -0
- just_bash/parser/__init__.py +39 -0
- just_bash/parser/lexer.py +948 -0
- just_bash/parser/parser.py +2162 -0
- just_bash/py.typed +0 -0
- just_bash/query_engine/__init__.py +83 -0
- just_bash/query_engine/builtins/__init__.py +1283 -0
- just_bash/query_engine/evaluator.py +578 -0
- just_bash/query_engine/parser.py +525 -0
- just_bash/query_engine/tokenizer.py +329 -0
- just_bash/query_engine/types.py +373 -0
- just_bash/types.py +180 -0
- just_bash-0.1.5.dist-info/METADATA +410 -0
- just_bash-0.1.5.dist-info/RECORD +193 -0
- just_bash-0.1.5.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"""Curl command implementation."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import gzip
|
|
5
|
+
import time
|
|
6
|
+
import zlib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from urllib.parse import urlencode, urlparse, quote
|
|
10
|
+
|
|
11
|
+
from ...types import CommandContext, ExecResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class FormField:
|
|
16
|
+
"""A form field for multipart data."""
|
|
17
|
+
name: str
|
|
18
|
+
value: str
|
|
19
|
+
filename: Optional[str] = None
|
|
20
|
+
content_type: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CurlOptions:
|
|
25
|
+
"""Parsed curl options."""
|
|
26
|
+
method: str = "GET"
|
|
27
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
28
|
+
data: Optional[str] = None
|
|
29
|
+
data_binary: bool = False
|
|
30
|
+
form_fields: list[FormField] = field(default_factory=list)
|
|
31
|
+
user: Optional[str] = None
|
|
32
|
+
upload_file: Optional[str] = None
|
|
33
|
+
cookie_jar: Optional[str] = None
|
|
34
|
+
cookie_file: Optional[str] = None # -b @file
|
|
35
|
+
output_file: Optional[str] = None
|
|
36
|
+
use_remote_name: bool = False
|
|
37
|
+
head_only: bool = False
|
|
38
|
+
include_headers: bool = False
|
|
39
|
+
silent: bool = False
|
|
40
|
+
show_error: bool = False
|
|
41
|
+
fail_silently: bool = False
|
|
42
|
+
follow_redirects: bool = True
|
|
43
|
+
max_redirects: Optional[int] = None # --max-redirs
|
|
44
|
+
compressed: bool = False # --compressed
|
|
45
|
+
write_out: Optional[str] = None
|
|
46
|
+
verbose: bool = False
|
|
47
|
+
timeout_ms: Optional[int] = None
|
|
48
|
+
url: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def encode_form_data(input_str: str) -> str:
|
|
52
|
+
"""URL-encode form data in curl's --data-urlencode format."""
|
|
53
|
+
eq_index = input_str.find("=")
|
|
54
|
+
if eq_index >= 0:
|
|
55
|
+
name = input_str[:eq_index]
|
|
56
|
+
value = input_str[eq_index + 1:]
|
|
57
|
+
if name:
|
|
58
|
+
return f"{quote(name, safe='')}={quote(value, safe='')}"
|
|
59
|
+
return quote(value, safe='')
|
|
60
|
+
return quote(input_str, safe='')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_form_field(spec: str) -> Optional[FormField]:
|
|
64
|
+
"""Parse -F/--form field specification."""
|
|
65
|
+
eq_index = spec.find("=")
|
|
66
|
+
if eq_index < 0:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
name = spec[:eq_index]
|
|
70
|
+
value = spec[eq_index + 1:]
|
|
71
|
+
filename = None
|
|
72
|
+
content_type = None
|
|
73
|
+
|
|
74
|
+
# Check for ;type= suffix
|
|
75
|
+
if ";type=" in value:
|
|
76
|
+
type_idx = value.rfind(";type=")
|
|
77
|
+
content_type = value[type_idx + 6:]
|
|
78
|
+
value = value[:type_idx]
|
|
79
|
+
|
|
80
|
+
# Check for ;filename= suffix
|
|
81
|
+
if ";filename=" in value:
|
|
82
|
+
fn_idx = value.find(";filename=")
|
|
83
|
+
fn_end = value.find(";", fn_idx + 1)
|
|
84
|
+
if fn_end < 0:
|
|
85
|
+
fn_end = len(value)
|
|
86
|
+
filename = value[fn_idx + 10:fn_end]
|
|
87
|
+
value = value[:fn_idx] + (value[fn_end:] if fn_end < len(value) else "")
|
|
88
|
+
|
|
89
|
+
# @ means file upload, < means file content
|
|
90
|
+
if value.startswith("@") or value.startswith("<"):
|
|
91
|
+
if not filename:
|
|
92
|
+
filename = value[1:].split("/")[-1]
|
|
93
|
+
|
|
94
|
+
return FormField(name=name, value=value, filename=filename, content_type=content_type)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def generate_multipart_body(
|
|
98
|
+
fields: list[FormField],
|
|
99
|
+
file_contents: dict[str, str],
|
|
100
|
+
) -> tuple[str, str]:
|
|
101
|
+
"""Generate multipart form data body and boundary."""
|
|
102
|
+
boundary = f"----CurlFormBoundary{int(time.time() * 1000):x}"
|
|
103
|
+
parts = []
|
|
104
|
+
|
|
105
|
+
for field in fields:
|
|
106
|
+
value = field.value
|
|
107
|
+
|
|
108
|
+
# Replace file references with content
|
|
109
|
+
if value.startswith("@") or value.startswith("<"):
|
|
110
|
+
file_path = value[1:]
|
|
111
|
+
value = file_contents.get(file_path, "")
|
|
112
|
+
|
|
113
|
+
part = f"--{boundary}\r\n"
|
|
114
|
+
if field.filename:
|
|
115
|
+
part += f'Content-Disposition: form-data; name="{field.name}"; filename="{field.filename}"\r\n'
|
|
116
|
+
if field.content_type:
|
|
117
|
+
part += f"Content-Type: {field.content_type}\r\n"
|
|
118
|
+
else:
|
|
119
|
+
part += f'Content-Disposition: form-data; name="{field.name}"\r\n'
|
|
120
|
+
part += f"\r\n{value}\r\n"
|
|
121
|
+
parts.append(part)
|
|
122
|
+
|
|
123
|
+
parts.append(f"--{boundary}--\r\n")
|
|
124
|
+
return "".join(parts), boundary
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def format_headers(headers: dict[str, str]) -> str:
|
|
128
|
+
"""Format response headers for output."""
|
|
129
|
+
return "\r\n".join(f"{name}: {value}" for name, value in headers.items())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def extract_filename(url: str) -> str:
|
|
133
|
+
"""Extract filename from URL for -O option."""
|
|
134
|
+
try:
|
|
135
|
+
parsed = urlparse(url)
|
|
136
|
+
pathname = parsed.path
|
|
137
|
+
filename = pathname.split("/")[-1] if pathname else ""
|
|
138
|
+
return filename or "index.html"
|
|
139
|
+
except Exception:
|
|
140
|
+
return "index.html"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def apply_write_out(format_str: str, result: dict) -> str:
|
|
144
|
+
"""Apply write-out format string replacements."""
|
|
145
|
+
output = format_str
|
|
146
|
+
status = str(result.get("status", 0))
|
|
147
|
+
headers = result.get("headers", {})
|
|
148
|
+
|
|
149
|
+
# Basic variables
|
|
150
|
+
output = output.replace("%{http_code}", status)
|
|
151
|
+
output = output.replace("%{response_code}", status) # Alias for http_code
|
|
152
|
+
output = output.replace("%{content_type}", headers.get("content-type", ""))
|
|
153
|
+
output = output.replace("%{url_effective}", result.get("url", ""))
|
|
154
|
+
output = output.replace("%{size_download}", str(result.get("body_length", 0)))
|
|
155
|
+
|
|
156
|
+
# Redirect variables
|
|
157
|
+
output = output.replace("%{num_redirects}", str(result.get("redirect_count", 0)))
|
|
158
|
+
output = output.replace("%{redirect_url}", result.get("url", ""))
|
|
159
|
+
|
|
160
|
+
# Header size (calculated from formatted headers)
|
|
161
|
+
header_size = result.get("header_size", 0)
|
|
162
|
+
output = output.replace("%{header_size}", str(header_size))
|
|
163
|
+
|
|
164
|
+
# Timing variables
|
|
165
|
+
time_total = result.get("time_total", 0.0)
|
|
166
|
+
output = output.replace("%{time_total}", f"{time_total:.6f}")
|
|
167
|
+
|
|
168
|
+
# Speed (bytes/sec)
|
|
169
|
+
speed_download = result.get("speed_download", 0.0)
|
|
170
|
+
output = output.replace("%{speed_download}", f"{speed_download:.3f}")
|
|
171
|
+
|
|
172
|
+
output = output.replace("\\n", "\n")
|
|
173
|
+
return output
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_options(args: list[str]) -> CurlOptions | ExecResult:
|
|
177
|
+
"""Parse curl command line arguments."""
|
|
178
|
+
options = CurlOptions()
|
|
179
|
+
i = 0
|
|
180
|
+
|
|
181
|
+
while i < len(args):
|
|
182
|
+
arg = args[i]
|
|
183
|
+
|
|
184
|
+
if arg == "-X" or arg == "--request":
|
|
185
|
+
i += 1
|
|
186
|
+
options.method = args[i] if i < len(args) else "GET"
|
|
187
|
+
elif arg.startswith("-X"):
|
|
188
|
+
options.method = arg[2:]
|
|
189
|
+
elif arg.startswith("--request="):
|
|
190
|
+
options.method = arg[10:]
|
|
191
|
+
|
|
192
|
+
elif arg == "-H" or arg == "--header":
|
|
193
|
+
i += 1
|
|
194
|
+
if i < len(args):
|
|
195
|
+
header = args[i]
|
|
196
|
+
colon_idx = header.find(":")
|
|
197
|
+
if colon_idx > 0:
|
|
198
|
+
name = header[:colon_idx].strip()
|
|
199
|
+
value = header[colon_idx + 1:].strip()
|
|
200
|
+
options.headers[name] = value
|
|
201
|
+
elif arg.startswith("--header="):
|
|
202
|
+
header = arg[9:]
|
|
203
|
+
colon_idx = header.find(":")
|
|
204
|
+
if colon_idx > 0:
|
|
205
|
+
name = header[:colon_idx].strip()
|
|
206
|
+
value = header[colon_idx + 1:].strip()
|
|
207
|
+
options.headers[name] = value
|
|
208
|
+
|
|
209
|
+
elif arg == "-d" or arg == "--data" or arg == "--data-raw":
|
|
210
|
+
i += 1
|
|
211
|
+
options.data = args[i] if i < len(args) else ""
|
|
212
|
+
if options.method == "GET":
|
|
213
|
+
options.method = "POST"
|
|
214
|
+
elif arg.startswith("-d"):
|
|
215
|
+
options.data = arg[2:]
|
|
216
|
+
if options.method == "GET":
|
|
217
|
+
options.method = "POST"
|
|
218
|
+
elif arg.startswith("--data="):
|
|
219
|
+
options.data = arg[7:]
|
|
220
|
+
if options.method == "GET":
|
|
221
|
+
options.method = "POST"
|
|
222
|
+
elif arg.startswith("--data-raw="):
|
|
223
|
+
options.data = arg[11:]
|
|
224
|
+
if options.method == "GET":
|
|
225
|
+
options.method = "POST"
|
|
226
|
+
|
|
227
|
+
elif arg == "--data-binary":
|
|
228
|
+
i += 1
|
|
229
|
+
options.data = args[i] if i < len(args) else ""
|
|
230
|
+
options.data_binary = True
|
|
231
|
+
if options.method == "GET":
|
|
232
|
+
options.method = "POST"
|
|
233
|
+
elif arg.startswith("--data-binary="):
|
|
234
|
+
options.data = arg[14:]
|
|
235
|
+
options.data_binary = True
|
|
236
|
+
if options.method == "GET":
|
|
237
|
+
options.method = "POST"
|
|
238
|
+
|
|
239
|
+
elif arg == "--data-urlencode":
|
|
240
|
+
i += 1
|
|
241
|
+
value = args[i] if i < len(args) else ""
|
|
242
|
+
encoded = encode_form_data(value)
|
|
243
|
+
if options.data:
|
|
244
|
+
options.data = f"{options.data}&{encoded}"
|
|
245
|
+
else:
|
|
246
|
+
options.data = encoded
|
|
247
|
+
if options.method == "GET":
|
|
248
|
+
options.method = "POST"
|
|
249
|
+
elif arg.startswith("--data-urlencode="):
|
|
250
|
+
value = arg[17:]
|
|
251
|
+
encoded = encode_form_data(value)
|
|
252
|
+
if options.data:
|
|
253
|
+
options.data = f"{options.data}&{encoded}"
|
|
254
|
+
else:
|
|
255
|
+
options.data = encoded
|
|
256
|
+
if options.method == "GET":
|
|
257
|
+
options.method = "POST"
|
|
258
|
+
|
|
259
|
+
elif arg == "-F" or arg == "--form":
|
|
260
|
+
i += 1
|
|
261
|
+
if i < len(args):
|
|
262
|
+
form_field = parse_form_field(args[i])
|
|
263
|
+
if form_field:
|
|
264
|
+
options.form_fields.append(form_field)
|
|
265
|
+
if options.method == "GET":
|
|
266
|
+
options.method = "POST"
|
|
267
|
+
elif arg.startswith("--form="):
|
|
268
|
+
form_field = parse_form_field(arg[7:])
|
|
269
|
+
if form_field:
|
|
270
|
+
options.form_fields.append(form_field)
|
|
271
|
+
if options.method == "GET":
|
|
272
|
+
options.method = "POST"
|
|
273
|
+
|
|
274
|
+
elif arg == "-u" or arg == "--user":
|
|
275
|
+
i += 1
|
|
276
|
+
options.user = args[i] if i < len(args) else None
|
|
277
|
+
elif arg.startswith("-u"):
|
|
278
|
+
options.user = arg[2:]
|
|
279
|
+
elif arg.startswith("--user="):
|
|
280
|
+
options.user = arg[7:]
|
|
281
|
+
|
|
282
|
+
elif arg == "-A" or arg == "--user-agent":
|
|
283
|
+
i += 1
|
|
284
|
+
options.headers["User-Agent"] = args[i] if i < len(args) else ""
|
|
285
|
+
elif arg.startswith("-A"):
|
|
286
|
+
options.headers["User-Agent"] = arg[2:]
|
|
287
|
+
elif arg.startswith("--user-agent="):
|
|
288
|
+
options.headers["User-Agent"] = arg[13:]
|
|
289
|
+
|
|
290
|
+
elif arg == "-e" or arg == "--referer":
|
|
291
|
+
i += 1
|
|
292
|
+
options.headers["Referer"] = args[i] if i < len(args) else ""
|
|
293
|
+
elif arg.startswith("-e"):
|
|
294
|
+
options.headers["Referer"] = arg[2:]
|
|
295
|
+
elif arg.startswith("--referer="):
|
|
296
|
+
options.headers["Referer"] = arg[10:]
|
|
297
|
+
|
|
298
|
+
elif arg == "-b" or arg == "--cookie":
|
|
299
|
+
i += 1
|
|
300
|
+
cookie_value = args[i] if i < len(args) else ""
|
|
301
|
+
if cookie_value.startswith("@"):
|
|
302
|
+
options.cookie_file = cookie_value[1:] # Store file path without @
|
|
303
|
+
else:
|
|
304
|
+
options.headers["Cookie"] = cookie_value
|
|
305
|
+
elif arg.startswith("-b"):
|
|
306
|
+
cookie_value = arg[2:]
|
|
307
|
+
if cookie_value.startswith("@"):
|
|
308
|
+
options.cookie_file = cookie_value[1:]
|
|
309
|
+
else:
|
|
310
|
+
options.headers["Cookie"] = cookie_value
|
|
311
|
+
elif arg.startswith("--cookie="):
|
|
312
|
+
cookie_value = arg[9:]
|
|
313
|
+
if cookie_value.startswith("@"):
|
|
314
|
+
options.cookie_file = cookie_value[1:]
|
|
315
|
+
else:
|
|
316
|
+
options.headers["Cookie"] = cookie_value
|
|
317
|
+
|
|
318
|
+
elif arg == "-c" or arg == "--cookie-jar":
|
|
319
|
+
i += 1
|
|
320
|
+
options.cookie_jar = args[i] if i < len(args) else None
|
|
321
|
+
elif arg.startswith("--cookie-jar="):
|
|
322
|
+
options.cookie_jar = arg[13:]
|
|
323
|
+
|
|
324
|
+
elif arg == "-T" or arg == "--upload-file":
|
|
325
|
+
i += 1
|
|
326
|
+
options.upload_file = args[i] if i < len(args) else None
|
|
327
|
+
if options.method == "GET":
|
|
328
|
+
options.method = "PUT"
|
|
329
|
+
elif arg.startswith("--upload-file="):
|
|
330
|
+
options.upload_file = arg[14:]
|
|
331
|
+
if options.method == "GET":
|
|
332
|
+
options.method = "PUT"
|
|
333
|
+
|
|
334
|
+
elif arg == "-m" or arg == "--max-time":
|
|
335
|
+
i += 1
|
|
336
|
+
try:
|
|
337
|
+
secs = float(args[i] if i < len(args) else "0")
|
|
338
|
+
if secs > 0:
|
|
339
|
+
options.timeout_ms = int(secs * 1000)
|
|
340
|
+
except ValueError:
|
|
341
|
+
pass
|
|
342
|
+
elif arg.startswith("--max-time="):
|
|
343
|
+
try:
|
|
344
|
+
secs = float(arg[11:])
|
|
345
|
+
if secs > 0:
|
|
346
|
+
options.timeout_ms = int(secs * 1000)
|
|
347
|
+
except ValueError:
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
elif arg == "--connect-timeout":
|
|
351
|
+
i += 1
|
|
352
|
+
try:
|
|
353
|
+
secs = float(args[i] if i < len(args) else "0")
|
|
354
|
+
if secs > 0 and options.timeout_ms is None:
|
|
355
|
+
options.timeout_ms = int(secs * 1000)
|
|
356
|
+
except ValueError:
|
|
357
|
+
pass
|
|
358
|
+
elif arg.startswith("--connect-timeout="):
|
|
359
|
+
try:
|
|
360
|
+
secs = float(arg[18:])
|
|
361
|
+
if secs > 0 and options.timeout_ms is None:
|
|
362
|
+
options.timeout_ms = int(secs * 1000)
|
|
363
|
+
except ValueError:
|
|
364
|
+
pass
|
|
365
|
+
|
|
366
|
+
elif arg == "-o" or arg == "--output":
|
|
367
|
+
i += 1
|
|
368
|
+
options.output_file = args[i] if i < len(args) else None
|
|
369
|
+
elif arg.startswith("--output="):
|
|
370
|
+
options.output_file = arg[9:]
|
|
371
|
+
|
|
372
|
+
elif arg == "-O" or arg == "--remote-name":
|
|
373
|
+
options.use_remote_name = True
|
|
374
|
+
|
|
375
|
+
elif arg == "-I" or arg == "--head":
|
|
376
|
+
options.head_only = True
|
|
377
|
+
options.method = "HEAD"
|
|
378
|
+
|
|
379
|
+
elif arg == "-i" or arg == "--include":
|
|
380
|
+
options.include_headers = True
|
|
381
|
+
|
|
382
|
+
elif arg == "-s" or arg == "--silent":
|
|
383
|
+
options.silent = True
|
|
384
|
+
|
|
385
|
+
elif arg == "-S" or arg == "--show-error":
|
|
386
|
+
options.show_error = True
|
|
387
|
+
|
|
388
|
+
elif arg == "-f" or arg == "--fail":
|
|
389
|
+
options.fail_silently = True
|
|
390
|
+
|
|
391
|
+
elif arg == "-L" or arg == "--location":
|
|
392
|
+
options.follow_redirects = True
|
|
393
|
+
|
|
394
|
+
elif arg == "--max-redirs":
|
|
395
|
+
i += 1
|
|
396
|
+
try:
|
|
397
|
+
options.max_redirects = int(args[i]) if i < len(args) else None
|
|
398
|
+
except ValueError:
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
elif arg.startswith("--max-redirs="):
|
|
402
|
+
try:
|
|
403
|
+
options.max_redirects = int(arg[13:])
|
|
404
|
+
except ValueError:
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
elif arg == "-w" or arg == "--write-out":
|
|
408
|
+
i += 1
|
|
409
|
+
options.write_out = args[i] if i < len(args) else None
|
|
410
|
+
elif arg.startswith("--write-out="):
|
|
411
|
+
options.write_out = arg[12:]
|
|
412
|
+
|
|
413
|
+
elif arg == "-v" or arg == "--verbose":
|
|
414
|
+
options.verbose = True
|
|
415
|
+
|
|
416
|
+
elif arg == "--compressed":
|
|
417
|
+
options.compressed = True
|
|
418
|
+
|
|
419
|
+
elif arg.startswith("--") and arg != "--":
|
|
420
|
+
return ExecResult(
|
|
421
|
+
stdout="",
|
|
422
|
+
stderr=f"curl: option {arg}: is unknown\n",
|
|
423
|
+
exit_code=2,
|
|
424
|
+
)
|
|
425
|
+
elif arg.startswith("-") and arg != "-":
|
|
426
|
+
# Handle combined short options like -sS
|
|
427
|
+
for c in arg[1:]:
|
|
428
|
+
if c == "s":
|
|
429
|
+
options.silent = True
|
|
430
|
+
elif c == "S":
|
|
431
|
+
options.show_error = True
|
|
432
|
+
elif c == "f":
|
|
433
|
+
options.fail_silently = True
|
|
434
|
+
elif c == "L":
|
|
435
|
+
options.follow_redirects = True
|
|
436
|
+
elif c == "I":
|
|
437
|
+
options.head_only = True
|
|
438
|
+
options.method = "HEAD"
|
|
439
|
+
elif c == "i":
|
|
440
|
+
options.include_headers = True
|
|
441
|
+
elif c == "O":
|
|
442
|
+
options.use_remote_name = True
|
|
443
|
+
elif c == "v":
|
|
444
|
+
options.verbose = True
|
|
445
|
+
else:
|
|
446
|
+
return ExecResult(
|
|
447
|
+
stdout="",
|
|
448
|
+
stderr=f"curl: option -{c}: is unknown\n",
|
|
449
|
+
exit_code=2,
|
|
450
|
+
)
|
|
451
|
+
elif not arg.startswith("-"):
|
|
452
|
+
options.url = arg
|
|
453
|
+
|
|
454
|
+
i += 1
|
|
455
|
+
|
|
456
|
+
return options
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class CurlCommand:
|
|
460
|
+
"""The curl command - transfer a URL."""
|
|
461
|
+
|
|
462
|
+
name = "curl"
|
|
463
|
+
|
|
464
|
+
async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
|
|
465
|
+
"""Execute the curl command."""
|
|
466
|
+
if "--help" in args:
|
|
467
|
+
return ExecResult(
|
|
468
|
+
stdout=(
|
|
469
|
+
"Usage: curl [OPTIONS] URL\n"
|
|
470
|
+
"Transfer a URL.\n\n"
|
|
471
|
+
"Options:\n"
|
|
472
|
+
" -X, --request METHOD HTTP method (GET, POST, PUT, DELETE, etc.)\n"
|
|
473
|
+
" -H, --header HEADER Add header (can be used multiple times)\n"
|
|
474
|
+
" -d, --data DATA HTTP POST data\n"
|
|
475
|
+
" --data-raw DATA HTTP POST data (no @ interpretation)\n"
|
|
476
|
+
" --data-binary DATA HTTP POST binary data\n"
|
|
477
|
+
" --data-urlencode DATA URL-encode and POST data\n"
|
|
478
|
+
" -F, --form NAME=VALUE Multipart form data\n"
|
|
479
|
+
" -u, --user USER:PASS HTTP authentication\n"
|
|
480
|
+
" -A, --user-agent STR Set User-Agent header\n"
|
|
481
|
+
" -e, --referer URL Set Referer header\n"
|
|
482
|
+
" -b, --cookie DATA Send cookies (name=value or @file)\n"
|
|
483
|
+
" -c, --cookie-jar FILE Save cookies to file\n"
|
|
484
|
+
" -T, --upload-file FILE Upload file (PUT)\n"
|
|
485
|
+
" -o, --output FILE Write output to file\n"
|
|
486
|
+
" -O, --remote-name Write to file named from URL\n"
|
|
487
|
+
" -I, --head Show headers only (HEAD request)\n"
|
|
488
|
+
" -i, --include Include response headers in output\n"
|
|
489
|
+
" -s, --silent Silent mode (no progress)\n"
|
|
490
|
+
" -S, --show-error Show errors even when silent\n"
|
|
491
|
+
" -f, --fail Fail silently on HTTP errors (no output)\n"
|
|
492
|
+
" -L, --location Follow redirects (default)\n"
|
|
493
|
+
" --max-redirs NUM Maximum redirects (default: 20)\n"
|
|
494
|
+
" -m, --max-time SECS Maximum time for request\n"
|
|
495
|
+
" --connect-timeout SECS Connection timeout\n"
|
|
496
|
+
" -w, --write-out FMT Output format after completion\n"
|
|
497
|
+
" -v, --verbose Verbose output\n"
|
|
498
|
+
" --help Display this help and exit\n"
|
|
499
|
+
),
|
|
500
|
+
stderr="",
|
|
501
|
+
exit_code=0,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# Parse options
|
|
505
|
+
result = parse_options(args)
|
|
506
|
+
if isinstance(result, ExecResult):
|
|
507
|
+
return result
|
|
508
|
+
|
|
509
|
+
options = result
|
|
510
|
+
|
|
511
|
+
# Check for URL
|
|
512
|
+
if not options.url:
|
|
513
|
+
return ExecResult(
|
|
514
|
+
stdout="",
|
|
515
|
+
stderr="curl: no URL specified\n",
|
|
516
|
+
exit_code=2,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Check for fetch function
|
|
520
|
+
if not ctx.fetch:
|
|
521
|
+
return ExecResult(
|
|
522
|
+
stdout="",
|
|
523
|
+
stderr="curl: internal error: fetch not available\n",
|
|
524
|
+
exit_code=1,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Normalize URL
|
|
528
|
+
url = options.url
|
|
529
|
+
if not url.startswith("http://") and not url.startswith("https://"):
|
|
530
|
+
url = f"https://{url}"
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
# Load cookies from file if specified
|
|
534
|
+
if options.cookie_file:
|
|
535
|
+
cookie_path = ctx.fs.resolve_path(ctx.cwd, options.cookie_file)
|
|
536
|
+
try:
|
|
537
|
+
cookie_content = await ctx.fs.read_file(cookie_path)
|
|
538
|
+
options.headers["Cookie"] = cookie_content.strip()
|
|
539
|
+
except Exception:
|
|
540
|
+
return ExecResult(
|
|
541
|
+
stdout="",
|
|
542
|
+
stderr=f"curl: (26) Failed to open/read cookie file: {options.cookie_file}: No such file or directory\n",
|
|
543
|
+
exit_code=26,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Prepare body and headers
|
|
547
|
+
body, content_type = await self._prepare_request_body(options, ctx)
|
|
548
|
+
headers = self._prepare_headers(options, content_type)
|
|
549
|
+
|
|
550
|
+
# Add Accept-Encoding header if --compressed
|
|
551
|
+
if options.compressed and "Accept-Encoding" not in headers:
|
|
552
|
+
headers["Accept-Encoding"] = "gzip, deflate"
|
|
553
|
+
|
|
554
|
+
# Track timing for write-out variables
|
|
555
|
+
start_time = time.time()
|
|
556
|
+
|
|
557
|
+
# Make the request
|
|
558
|
+
fetch_options = {
|
|
559
|
+
"method": options.method,
|
|
560
|
+
"headers": headers if headers else None,
|
|
561
|
+
"body": body,
|
|
562
|
+
"followRedirects": options.follow_redirects,
|
|
563
|
+
"timeoutMs": options.timeout_ms,
|
|
564
|
+
}
|
|
565
|
+
if options.max_redirects is not None:
|
|
566
|
+
fetch_options["maxRedirects"] = options.max_redirects
|
|
567
|
+
|
|
568
|
+
result = await ctx.fetch(url, fetch_options)
|
|
569
|
+
|
|
570
|
+
# Calculate timing
|
|
571
|
+
elapsed_time = time.time() - start_time
|
|
572
|
+
|
|
573
|
+
# Decompress response if --compressed and Content-Encoding is set
|
|
574
|
+
response_headers = result.get("headers", {})
|
|
575
|
+
content_encoding = response_headers.get("content-encoding", "").lower()
|
|
576
|
+
body = result.get("body", "")
|
|
577
|
+
|
|
578
|
+
if options.compressed and content_encoding:
|
|
579
|
+
body = self._decompress_body(body, content_encoding)
|
|
580
|
+
result["body"] = body
|
|
581
|
+
|
|
582
|
+
# Save cookies if requested
|
|
583
|
+
await self._save_cookies(options, response_headers, ctx)
|
|
584
|
+
|
|
585
|
+
# Check for HTTP errors with -f/--fail
|
|
586
|
+
status = result.get("status", 0)
|
|
587
|
+
if options.fail_silently and status >= 400:
|
|
588
|
+
stderr = ""
|
|
589
|
+
if options.show_error or not options.silent:
|
|
590
|
+
stderr = f"curl: (22) The requested URL returned error: {status}\n"
|
|
591
|
+
return ExecResult(stdout="", stderr=stderr, exit_code=22)
|
|
592
|
+
|
|
593
|
+
# Calculate header size (approximate)
|
|
594
|
+
header_size = len(format_headers(response_headers)) + 20 # +20 for status line
|
|
595
|
+
|
|
596
|
+
# Calculate download speed (bytes/sec)
|
|
597
|
+
body_length = len(body) if isinstance(body, str) else len(body)
|
|
598
|
+
speed_download = body_length / elapsed_time if elapsed_time > 0 else 0.0
|
|
599
|
+
|
|
600
|
+
# Prepare write-out data
|
|
601
|
+
write_out_data = {
|
|
602
|
+
"status": status,
|
|
603
|
+
"headers": response_headers,
|
|
604
|
+
"url": result.get("url", url),
|
|
605
|
+
"body_length": body_length,
|
|
606
|
+
"redirect_count": result.get("redirectCount", 0),
|
|
607
|
+
"header_size": header_size,
|
|
608
|
+
"time_total": elapsed_time,
|
|
609
|
+
"speed_download": speed_download,
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
output = self._build_output(options, result, url, write_out_data)
|
|
613
|
+
|
|
614
|
+
# Write to file
|
|
615
|
+
if options.output_file or options.use_remote_name:
|
|
616
|
+
filename = options.output_file or extract_filename(url)
|
|
617
|
+
file_path = ctx.fs.resolve_path(ctx.cwd, filename)
|
|
618
|
+
body_content = "" if options.head_only else result.get("body", "")
|
|
619
|
+
await ctx.fs.write_file(file_path, body_content)
|
|
620
|
+
|
|
621
|
+
# When writing to file, don't output body unless verbose
|
|
622
|
+
if not options.verbose:
|
|
623
|
+
output = ""
|
|
624
|
+
|
|
625
|
+
# Add write-out after file write
|
|
626
|
+
if options.write_out:
|
|
627
|
+
output = apply_write_out(options.write_out, write_out_data)
|
|
628
|
+
|
|
629
|
+
return ExecResult(stdout=output, stderr="", exit_code=0)
|
|
630
|
+
|
|
631
|
+
except Exception as e:
|
|
632
|
+
message = str(e)
|
|
633
|
+
|
|
634
|
+
# Determine exit code based on error type
|
|
635
|
+
exit_code = 1
|
|
636
|
+
if "Network access denied" in message:
|
|
637
|
+
exit_code = 7 # CURLE_COULDNT_CONNECT
|
|
638
|
+
elif "HTTP method" in message and "not allowed" in message:
|
|
639
|
+
exit_code = 3 # CURLE_URL_MALFORMAT-like
|
|
640
|
+
elif "Redirect target not in allow-list" in message:
|
|
641
|
+
exit_code = 47 # CURLE_TOO_MANY_REDIRECTS-like
|
|
642
|
+
elif "Too many redirects" in message:
|
|
643
|
+
exit_code = 47
|
|
644
|
+
elif "aborted" in message or "timeout" in message.lower():
|
|
645
|
+
exit_code = 28 # CURLE_OPERATION_TIMEDOUT
|
|
646
|
+
|
|
647
|
+
# Silent mode suppresses error output unless -S is used
|
|
648
|
+
show_err = not options.silent or options.show_error
|
|
649
|
+
stderr = f"curl: ({exit_code}) {message}\n" if show_err else ""
|
|
650
|
+
|
|
651
|
+
return ExecResult(stdout="", stderr=stderr, exit_code=exit_code)
|
|
652
|
+
|
|
653
|
+
async def _prepare_request_body(
|
|
654
|
+
self,
|
|
655
|
+
options: CurlOptions,
|
|
656
|
+
ctx: CommandContext,
|
|
657
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
658
|
+
"""Prepare request body from options."""
|
|
659
|
+
# Handle -T/--upload-file
|
|
660
|
+
if options.upload_file:
|
|
661
|
+
file_path = ctx.fs.resolve_path(ctx.cwd, options.upload_file)
|
|
662
|
+
content = await ctx.fs.read_file(file_path)
|
|
663
|
+
return content, None
|
|
664
|
+
|
|
665
|
+
# Handle -F/--form multipart data
|
|
666
|
+
if options.form_fields:
|
|
667
|
+
file_contents = {}
|
|
668
|
+
|
|
669
|
+
# Read any file references
|
|
670
|
+
for field in options.form_fields:
|
|
671
|
+
if field.value.startswith("@") or field.value.startswith("<"):
|
|
672
|
+
file_path = ctx.fs.resolve_path(ctx.cwd, field.value[1:])
|
|
673
|
+
try:
|
|
674
|
+
content = await ctx.fs.read_file(file_path)
|
|
675
|
+
file_contents[field.value[1:]] = content
|
|
676
|
+
except Exception:
|
|
677
|
+
file_contents[field.value[1:]] = ""
|
|
678
|
+
|
|
679
|
+
body, boundary = generate_multipart_body(options.form_fields, file_contents)
|
|
680
|
+
return body, f"multipart/form-data; boundary={boundary}"
|
|
681
|
+
|
|
682
|
+
# Handle -d/--data variants
|
|
683
|
+
if options.data is not None:
|
|
684
|
+
return options.data, None
|
|
685
|
+
|
|
686
|
+
return None, None
|
|
687
|
+
|
|
688
|
+
def _prepare_headers(
|
|
689
|
+
self,
|
|
690
|
+
options: CurlOptions,
|
|
691
|
+
content_type: Optional[str],
|
|
692
|
+
) -> dict[str, str]:
|
|
693
|
+
"""Prepare request headers from options."""
|
|
694
|
+
headers = dict(options.headers)
|
|
695
|
+
|
|
696
|
+
# Add authentication header
|
|
697
|
+
if options.user:
|
|
698
|
+
encoded = base64.b64encode(options.user.encode()).decode()
|
|
699
|
+
headers["Authorization"] = f"Basic {encoded}"
|
|
700
|
+
|
|
701
|
+
# Set content type if needed and not already set
|
|
702
|
+
if content_type and "Content-Type" not in headers:
|
|
703
|
+
headers["Content-Type"] = content_type
|
|
704
|
+
|
|
705
|
+
return headers
|
|
706
|
+
|
|
707
|
+
async def _save_cookies(
|
|
708
|
+
self,
|
|
709
|
+
options: CurlOptions,
|
|
710
|
+
headers: dict[str, str],
|
|
711
|
+
ctx: CommandContext,
|
|
712
|
+
) -> None:
|
|
713
|
+
"""Save cookies from response to cookie jar file."""
|
|
714
|
+
if not options.cookie_jar:
|
|
715
|
+
return
|
|
716
|
+
|
|
717
|
+
set_cookie = headers.get("set-cookie")
|
|
718
|
+
if not set_cookie:
|
|
719
|
+
return
|
|
720
|
+
|
|
721
|
+
file_path = ctx.fs.resolve_path(ctx.cwd, options.cookie_jar)
|
|
722
|
+
await ctx.fs.write_file(file_path, set_cookie)
|
|
723
|
+
|
|
724
|
+
def _decompress_body(self, body: str | bytes, encoding: str) -> str:
|
|
725
|
+
"""Decompress response body based on Content-Encoding."""
|
|
726
|
+
try:
|
|
727
|
+
# Handle bytes or string input
|
|
728
|
+
if isinstance(body, str):
|
|
729
|
+
body_bytes = body.encode("latin-1")
|
|
730
|
+
else:
|
|
731
|
+
body_bytes = body
|
|
732
|
+
|
|
733
|
+
if encoding == "gzip":
|
|
734
|
+
decompressed = gzip.decompress(body_bytes)
|
|
735
|
+
elif encoding == "deflate":
|
|
736
|
+
decompressed = zlib.decompress(body_bytes)
|
|
737
|
+
else:
|
|
738
|
+
return body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
|
739
|
+
|
|
740
|
+
return decompressed.decode("utf-8", errors="replace")
|
|
741
|
+
except Exception:
|
|
742
|
+
# If decompression fails, return original body
|
|
743
|
+
return body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
|
744
|
+
|
|
745
|
+
def _build_output(
|
|
746
|
+
self,
|
|
747
|
+
options: CurlOptions,
|
|
748
|
+
result: dict,
|
|
749
|
+
request_url: str,
|
|
750
|
+
write_out_data: dict | None = None,
|
|
751
|
+
) -> str:
|
|
752
|
+
"""Build output string from response."""
|
|
753
|
+
output = ""
|
|
754
|
+
status = result.get("status", 0)
|
|
755
|
+
status_text = result.get("statusText", "")
|
|
756
|
+
headers = result.get("headers", {})
|
|
757
|
+
body = result.get("body", "")
|
|
758
|
+
url = result.get("url", request_url)
|
|
759
|
+
|
|
760
|
+
# Verbose output
|
|
761
|
+
if options.verbose:
|
|
762
|
+
output += f"> {options.method} {request_url}\n"
|
|
763
|
+
for name, value in options.headers.items():
|
|
764
|
+
output += f"> {name}: {value}\n"
|
|
765
|
+
output += ">\n"
|
|
766
|
+
output += f"< HTTP/1.1 {status} {status_text}\n"
|
|
767
|
+
for name, value in headers.items():
|
|
768
|
+
output += f"< {name}: {value}\n"
|
|
769
|
+
output += "<\n"
|
|
770
|
+
|
|
771
|
+
# Include headers with -i/--include
|
|
772
|
+
if options.include_headers and not options.verbose:
|
|
773
|
+
output += f"HTTP/1.1 {status} {status_text}\r\n"
|
|
774
|
+
output += format_headers(headers)
|
|
775
|
+
output += "\r\n\r\n"
|
|
776
|
+
|
|
777
|
+
# Add body (unless head-only mode)
|
|
778
|
+
if not options.head_only:
|
|
779
|
+
output += body
|
|
780
|
+
elif options.include_headers or options.verbose:
|
|
781
|
+
# For HEAD, we already showed headers
|
|
782
|
+
pass
|
|
783
|
+
else:
|
|
784
|
+
# HEAD without -i shows headers
|
|
785
|
+
output += f"HTTP/1.1 {status} {status_text}\r\n"
|
|
786
|
+
output += format_headers(headers)
|
|
787
|
+
output += "\r\n"
|
|
788
|
+
|
|
789
|
+
# Write-out format
|
|
790
|
+
if options.write_out:
|
|
791
|
+
# Use provided write_out_data if available, otherwise build basic data
|
|
792
|
+
if write_out_data is None:
|
|
793
|
+
write_out_data = {
|
|
794
|
+
"status": status,
|
|
795
|
+
"headers": headers,
|
|
796
|
+
"url": url,
|
|
797
|
+
"body_length": len(body),
|
|
798
|
+
}
|
|
799
|
+
output += apply_write_out(options.write_out, write_out_data)
|
|
800
|
+
|
|
801
|
+
return output
|