bas-http 1.0.0__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.
- bas/README.md +244 -0
- bas/__init__.py +97 -0
- bas/_curl.py +393 -0
- bas/async_session.py +352 -0
- bas/cookies.py +643 -0
- bas/curl_parser.py +267 -0
- bas/impersonate.py +424 -0
- bas/models.py +339 -0
- bas/session.py +648 -0
- bas/simple_session.py +299 -0
- bas/tests/__init__.py +0 -0
- bas/tests/test_cookies.py +778 -0
- bas/tests/test_curl_parser.py +172 -0
- bas/tests/test_real_request.py +142 -0
- bas/tests/test_spaceshooter.py +213 -0
- bas_http-1.0.0.dist-info/METADATA +25 -0
- bas_http-1.0.0.dist-info/RECORD +19 -0
- bas_http-1.0.0.dist-info/WHEEL +5 -0
- bas_http-1.0.0.dist-info/top_level.txt +1 -0
bas/_curl.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Low-level ctypes bindings for libcurl with curl-impersonate support.
|
|
3
|
+
|
|
4
|
+
This module provides Python bindings to libcurl (or curl-impersonate if available)
|
|
5
|
+
using ctypes. It avoids cffi compilation issues that plague curlcffi.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ctypes
|
|
9
|
+
import ctypes.util
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from ctypes import (
|
|
13
|
+
CFUNCTYPE,
|
|
14
|
+
POINTER,
|
|
15
|
+
c_char,
|
|
16
|
+
c_char_p,
|
|
17
|
+
c_int,
|
|
18
|
+
c_long,
|
|
19
|
+
c_off_t,
|
|
20
|
+
c_void_p,
|
|
21
|
+
c_slist,
|
|
22
|
+
)
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
# ── libcurl loading ──────────────────────────────────────────────────────────
|
|
26
|
+
# Priority: curl-impersonate first, then system libcurl.
|
|
27
|
+
|
|
28
|
+
_CURL_LIB_NAMES = [
|
|
29
|
+
# curl-impersonate shared libraries
|
|
30
|
+
"curl-impersonate-chrome",
|
|
31
|
+
"curl-impersonate-firefox",
|
|
32
|
+
"libcurl-impersonate-chrome",
|
|
33
|
+
"libcurl-impersonate-firefox",
|
|
34
|
+
# Standard libcurl
|
|
35
|
+
"curl",
|
|
36
|
+
"libcurl",
|
|
37
|
+
"libcurl.so.4",
|
|
38
|
+
"libcurl.4.dylib",
|
|
39
|
+
"curl.dll",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _find_curl_library() -> ctypes.CDLL:
|
|
44
|
+
"""Locate and load the best available curl library."""
|
|
45
|
+
# 1. Check FREEBUFF_CURL_PATH env var
|
|
46
|
+
env_path = os.environ.get("FREEBUFF_CURL_PATH")
|
|
47
|
+
if env_path and os.path.isfile(env_path):
|
|
48
|
+
return ctypes.CDLL(env_path)
|
|
49
|
+
|
|
50
|
+
# 2. Check system paths
|
|
51
|
+
for name in _CURL_LIB_NAMES:
|
|
52
|
+
found = ctypes.util.find_library(name)
|
|
53
|
+
if found:
|
|
54
|
+
try:
|
|
55
|
+
return ctypes.CDLL(found)
|
|
56
|
+
except OSError:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
# 3. Try direct load on common paths
|
|
60
|
+
if sys.platform == "linux":
|
|
61
|
+
for path in [
|
|
62
|
+
"/usr/lib/x86_64-linux-gnu/libcurl.so.4",
|
|
63
|
+
"/usr/lib/libcurl.so.4",
|
|
64
|
+
"/usr/local/lib/libcurl.so.4",
|
|
65
|
+
]:
|
|
66
|
+
if os.path.isfile(path):
|
|
67
|
+
return ctypes.CDLL(path)
|
|
68
|
+
elif sys.platform == "darwin":
|
|
69
|
+
for path in [
|
|
70
|
+
"/usr/local/lib/libcurl.4.dylib",
|
|
71
|
+
"/opt/homebrew/lib/libcurl.4.dylib",
|
|
72
|
+
]:
|
|
73
|
+
if os.path.isfile(path):
|
|
74
|
+
return ctypes.CDLL(path)
|
|
75
|
+
|
|
76
|
+
raise OSError(
|
|
77
|
+
"Could not find libcurl. Install libcurl-dev (Linux) or "
|
|
78
|
+
"curl-impersonate (for browser impersonation). "
|
|
79
|
+
"Set FREEBUFF_CURL_PATH to a custom library path."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_lib = _find_curl_library()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Constants ────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
# CURLoptions
|
|
89
|
+
CURL_GLOBAL_ALL = 3
|
|
90
|
+
CURLOPT_URL = 10002
|
|
91
|
+
CURLOPT_WRITEFUNCTION = 20011
|
|
92
|
+
CURLOPT_WRITEDATA = 10001
|
|
93
|
+
CURLOPT_HEADERFUNCTION = 20079
|
|
94
|
+
CURLOPT_HEADERDATA = 10029
|
|
95
|
+
CURLOPT_POSTFIELDS = 10015
|
|
96
|
+
CURLOPT_POST = 47
|
|
97
|
+
CURLOPT_CUSTOMREQUEST = 10036
|
|
98
|
+
CURLOPT_FOLLOWLOCATION = 52
|
|
99
|
+
CURLOPT_MAXREDIRS = 68
|
|
100
|
+
CURLOPT_COOKIEFILE = 10031
|
|
101
|
+
CURLOPT_COOKIEJAR = 10082
|
|
102
|
+
CURLOPT_COOKIELIST = 10135
|
|
103
|
+
CURLOPT_COOKIE = 10022
|
|
104
|
+
CURLOPT_HTTPHEADER = 10023
|
|
105
|
+
CURLOPT_HTTPGET = 80
|
|
106
|
+
CURLOPT_SSL_VERIFYPEER = 64
|
|
107
|
+
CURLOPT_SSL_VERIFYHOST = 81
|
|
108
|
+
CURLOPT_CAINFO = 10065
|
|
109
|
+
CURLOPT_TIMEOUT = 13
|
|
110
|
+
CURLOPT_CONNECTTIMEOUT = 78
|
|
111
|
+
CURLOPT_USERAGENT = 10018
|
|
112
|
+
CURLOPT_REFERER = 10016
|
|
113
|
+
CURLOPT_ENCODING = 10106
|
|
114
|
+
CURLOPT_PROXY = 10004
|
|
115
|
+
CURLOPT_PROXYPORT = 59
|
|
116
|
+
CURLOPT_PROXYTYPE = 101
|
|
117
|
+
CURLOPT_NOPROXY = 10177
|
|
118
|
+
CURLOPT_RESOLVE = 10203
|
|
119
|
+
CURLOPT_INTERFACE = 10062
|
|
120
|
+
CURLOPT_LOCALPORT = 139
|
|
121
|
+
CURLOPT_DNS_CACHE_TIMEOUT = 92
|
|
122
|
+
CURLOPT_TCP_KEEPALIVE = 213
|
|
123
|
+
CURLOPT_TCP_KEEPIDLE = 214
|
|
124
|
+
CURLOPT_TCP_KEEPINTVL = 215
|
|
125
|
+
CURLOPT_SSL_FALSESTART = 224
|
|
126
|
+
CURLOPT_TCP_FASTOPEN = 244
|
|
127
|
+
CURLOPT_HTTP_VERSION = 84
|
|
128
|
+
CURLOPT_CERTINFO = 172
|
|
129
|
+
CURLOPT_PINNEDPUBLICKEY = 10230
|
|
130
|
+
CURLOPT_NO_SIGNAL = 99
|
|
131
|
+
CURLOPT_TRANSFER_ENCODING = 20206
|
|
132
|
+
CURLOPT_HTTP200ALIASES = 10260
|
|
133
|
+
CURLOPT_PREREQFUNCTION = 20328
|
|
134
|
+
CURLOPT_PREREQDATA = 10313
|
|
135
|
+
CURLOPT_MAIL_FROM = 10186
|
|
136
|
+
CURLOPT_MAIL_RCPT = 10187
|
|
137
|
+
CURLOPT_UPLOAD = 46
|
|
138
|
+
CURLOPT_INFILESIZE = 14
|
|
139
|
+
CURLOPT_INFILESIZE_LARGE = 10311
|
|
140
|
+
CURLOPT_UPLOAD = 46
|
|
141
|
+
CURLOPT_PORT = 3
|
|
142
|
+
|
|
143
|
+
# CURLinfo
|
|
144
|
+
CURLINFO_RESPONSE_CODE = 0x200002
|
|
145
|
+
CURLINFO_EFFECTIVE_URL = 0x100002
|
|
146
|
+
CURLINFO_CONTENT_TYPE = 0x100004
|
|
147
|
+
CURLINFO_REDIRECT_URL = 0x100007
|
|
148
|
+
CURLINFO_HEADER_SIZE = 0x20000B
|
|
149
|
+
CURLINFO_TOTAL_TIME = 0x300003
|
|
150
|
+
CURLINFO_NAMELOOKUP_TIME = 0x300004
|
|
151
|
+
CURLINFO_CONNECT_TIME = 0x300005
|
|
152
|
+
CURLINFO_PRETRANSFER_TIME = 0x300006
|
|
153
|
+
CURLINFO_STARTTRANSFER_TIME = 0x300009
|
|
154
|
+
CURLINFO_PRIMARY_IP = 0x40000C
|
|
155
|
+
CURLINFO_PRIMARY_PORT = 0x40000F
|
|
156
|
+
CURLINFO_SSL_VERIFYRESULT = 0x400008
|
|
157
|
+
CURLINFO_HTTP_VERSION = 0x400012
|
|
158
|
+
|
|
159
|
+
# CURLcode
|
|
160
|
+
CURL_OK = 0
|
|
161
|
+
|
|
162
|
+
# CURLMcode
|
|
163
|
+
CURLM_OK = 0
|
|
164
|
+
|
|
165
|
+
# CURLHTTPVersion
|
|
166
|
+
CURL_HTTP_VERSION_NONE = 0
|
|
167
|
+
CURL_HTTP_VERSION_1_0 = 1
|
|
168
|
+
CURL_HTTP_VERSION_1_1 = 2
|
|
169
|
+
CURL_HTTP_VERSION_2_0 = 3
|
|
170
|
+
CURL_HTTP_VERSION_2TLS = 4
|
|
171
|
+
CURL_HTTP_VERSION_3 = 30
|
|
172
|
+
|
|
173
|
+
# CURLProxyType
|
|
174
|
+
CURLPROXY_HTTP = 0
|
|
175
|
+
CURLPROXY_SOCKS4 = 4
|
|
176
|
+
CURLPROXY_SOCKS5 = 5
|
|
177
|
+
CURLPROXY_SOCKS4A = 6
|
|
178
|
+
CURLPROXY_SOCKS5_HOSTNAME = 7
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── CURLINFO enum for curl_slist operations ──────────────────────────────────
|
|
182
|
+
|
|
183
|
+
# CURLOPT_COOKIELIST action
|
|
184
|
+
CURLCOOKIE_SET = 1
|
|
185
|
+
CURLCOOKIE_UNSET = 3
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ── Function signatures ──────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
_lib.curl_global_init.argtypes = [c_long]
|
|
191
|
+
_lib.curl_global_init.restype = c_int
|
|
192
|
+
|
|
193
|
+
_lib.curl_easy_init.argtypes = []
|
|
194
|
+
_lib.curl_easy_init.restype = c_void_p
|
|
195
|
+
|
|
196
|
+
_lib.curl_easy_cleanup.argtypes = [c_void_p]
|
|
197
|
+
_lib.curl_easy_cleanup.restype = None
|
|
198
|
+
|
|
199
|
+
_lib.curl_easy_setopt.argtypes = [c_void_p, c_int, ...]
|
|
200
|
+
_lib.curl_easy_setopt.restype = c_int
|
|
201
|
+
|
|
202
|
+
_lib.curl_easy_perform.argtypes = [c_void_p]
|
|
203
|
+
_lib.curl_easy_perform.restype = c_int
|
|
204
|
+
|
|
205
|
+
_lib.curl_easy_getinfo.argtypes = [c_void_p, c_int, c_void_p]
|
|
206
|
+
_lib.curl_easy_getinfo.restype = c_int
|
|
207
|
+
|
|
208
|
+
_lib.curl_easy_reset.argtypes = [c_void_p]
|
|
209
|
+
_lib.curl_easy_reset.restype = None
|
|
210
|
+
|
|
211
|
+
_lib.curl_easy_duphandle.argtypes = [c_void_p]
|
|
212
|
+
_lib.curl_easy_duphandle.restype = c_void_p
|
|
213
|
+
|
|
214
|
+
_lib.curl_slist_append.argtypes = [POINTER(c_slist), c_char_p]
|
|
215
|
+
_lib.curl_slist_append.restype = POINTER(c_slist)
|
|
216
|
+
|
|
217
|
+
_lib.curl_slist_free_all.argtypes = [POINTER(c_slist)]
|
|
218
|
+
_lib.curl_slist_free_all.restype = None
|
|
219
|
+
|
|
220
|
+
_lib.curl_free.argtypes = [c_void_p]
|
|
221
|
+
_lib.curl_free.restype = None
|
|
222
|
+
|
|
223
|
+
_lib.curl_easy_strerror.argtypes = [c_int]
|
|
224
|
+
_lib.curl_easy_strerror.restype = c_char_p
|
|
225
|
+
|
|
226
|
+
_lib.curl_version.argtypes = []
|
|
227
|
+
_lib.curl_version.restype = c_char_p
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ── Callback types ───────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
WRITEFUNC = CFUNCTYPE(c_int, c_char_p, c_int, c_int, c_void_p)
|
|
233
|
+
HEADERFUNC = CFUNCTYPE(c_int, c_char_p, c_int, c_int, c_void_p)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ── Global init ──────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
_lib.curl_global_init(CURL_GLOBAL_ALL)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── Helper classes ───────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
class CurlSList:
|
|
244
|
+
"""Wrapper around curl_slist* for safe memory management."""
|
|
245
|
+
|
|
246
|
+
def __init__(self):
|
|
247
|
+
self._list = POINTER(c_slist)()
|
|
248
|
+
self._items: list[bytes] = []
|
|
249
|
+
|
|
250
|
+
def append(self, item: str) -> None:
|
|
251
|
+
encoded = item.encode("utf-8") if isinstance(item, str) else item
|
|
252
|
+
self._list = _lib.curl_slist_append(self._list, encoded)
|
|
253
|
+
self._items.append(encoded)
|
|
254
|
+
|
|
255
|
+
def __del__(self):
|
|
256
|
+
if self._list:
|
|
257
|
+
_lib.curl_slist_free_all(self._list)
|
|
258
|
+
|
|
259
|
+
def as_cpointer(self) -> POINTER(c_slist):
|
|
260
|
+
return self._list
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ── Easy handle wrapper ─────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
class CurlEasy:
|
|
266
|
+
"""
|
|
267
|
+
Wrapper around a CURL* easy handle.
|
|
268
|
+
|
|
269
|
+
Provides a Pythonic interface while keeping all the power of libcurl.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
def __init__(self):
|
|
273
|
+
self._handle = _lib.curl_easy_init()
|
|
274
|
+
if not self._handle:
|
|
275
|
+
raise RuntimeError("Failed to initialize curl easy handle")
|
|
276
|
+
|
|
277
|
+
self._headers_list: Optional[CurlSList] = None
|
|
278
|
+
self._write_callback: Optional[WRITEFUNC] = None
|
|
279
|
+
self._header_callback: Optional[HEADERFUNC] = None
|
|
280
|
+
self._write_buffer = bytearray()
|
|
281
|
+
self._header_buffer = bytearray()
|
|
282
|
+
|
|
283
|
+
# Default settings
|
|
284
|
+
self.setopt(CURLOPT_FOLLOWLOCATION, 1)
|
|
285
|
+
self.setopt(CURLOPT_MAXREDIRS, 20)
|
|
286
|
+
self.setopt(CURLOPT_SSL_VERIFYPEER, 1)
|
|
287
|
+
self.setopt(CURLOPT_SSL_VERIFYHOST, 2)
|
|
288
|
+
self.setopt(CURLOPT_TIMEOUT, 30)
|
|
289
|
+
self.setopt(CURLOPT_CONNECTTIMEOUT, 10)
|
|
290
|
+
self.setopt(CURLOPT_DNS_CACHE_TIMEOUT, 300)
|
|
291
|
+
self.setopt(CURLOPT_TCP_KEEPALIVE, 1)
|
|
292
|
+
self.setopt(CURLOPT_NO_SIGNAL, 1)
|
|
293
|
+
self.setopt(CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS)
|
|
294
|
+
|
|
295
|
+
def setopt(self, option: int, value) -> None:
|
|
296
|
+
"""Set a curl option."""
|
|
297
|
+
if isinstance(value, int):
|
|
298
|
+
_lib.curl_easy_setopt(self._handle, option, c_long(value))
|
|
299
|
+
elif isinstance(value, str):
|
|
300
|
+
_lib.curl_easy_setopt(self._handle, option, value.encode("utf-8"))
|
|
301
|
+
elif isinstance(value, bytes):
|
|
302
|
+
_lib.curl_easy_setopt(self._handle, option, value)
|
|
303
|
+
elif isinstance(value, CurlSList):
|
|
304
|
+
_lib.curl_easy_setopt(self._handle, option, value.as_cpointer())
|
|
305
|
+
else:
|
|
306
|
+
raise TypeError(f"Unsupported option type: {type(value)}")
|
|
307
|
+
|
|
308
|
+
def perform(self) -> int:
|
|
309
|
+
"""Execute the curl request."""
|
|
310
|
+
return _lib.curl_easy_perform(self._handle)
|
|
311
|
+
|
|
312
|
+
def getinfo(self, info: int):
|
|
313
|
+
"""Get information about the last performed request."""
|
|
314
|
+
if info in (CURLINFO_RESPONSE_CODE, CURLINFO_HEADER_SIZE, CURLINFO_HTTP_VERSION):
|
|
315
|
+
val = c_long()
|
|
316
|
+
_lib.curl_easy_getinfo(self._handle, info, ctypes.byref(val))
|
|
317
|
+
return val.value
|
|
318
|
+
elif info in (
|
|
319
|
+
CURLINFO_EFFECTIVE_URL,
|
|
320
|
+
CURLINFO_CONTENT_TYPE,
|
|
321
|
+
CURLINFO_REDIRECT_URL,
|
|
322
|
+
CURLINFO_PRIMARY_IP,
|
|
323
|
+
):
|
|
324
|
+
val = c_char_p()
|
|
325
|
+
_lib.curl_easy_getinfo(self._handle, info, ctypes.byref(val))
|
|
326
|
+
return val.value.decode("utf-8") if val.value else None
|
|
327
|
+
elif info == CURLINFO_TOTAL_TIME:
|
|
328
|
+
val = c_double()
|
|
329
|
+
_lib.curl_easy_getinfo(self._handle, info, ctypes.byref(val))
|
|
330
|
+
return val.value
|
|
331
|
+
elif info == CURLINFO_SSL_VERIFYRESULT:
|
|
332
|
+
val = c_int()
|
|
333
|
+
_lib.curl_easy_getinfo(self._handle, info, ctypes.byref(val))
|
|
334
|
+
return val.value
|
|
335
|
+
raise ValueError(f"Unsupported info: {info}")
|
|
336
|
+
|
|
337
|
+
def set_headers(self, headers: Optional[dict[str, str]]) -> None:
|
|
338
|
+
"""Set request headers."""
|
|
339
|
+
if self._headers_list:
|
|
340
|
+
_lib.curl_slist_free_all(self._headers_list)
|
|
341
|
+
self._headers_list = None
|
|
342
|
+
|
|
343
|
+
if headers:
|
|
344
|
+
self._headers_list = CurlSList()
|
|
345
|
+
for key, value in headers.items():
|
|
346
|
+
self._headers_list.append(f"{key}: {value}")
|
|
347
|
+
self.setopt(CURLOPT_HTTPHEADER, self._headers_list)
|
|
348
|
+
|
|
349
|
+
def reset(self) -> None:
|
|
350
|
+
"""Reset the handle to default state."""
|
|
351
|
+
_lib.curl_easy_reset(self._handle)
|
|
352
|
+
# Re-apply defaults
|
|
353
|
+
self.setopt(CURLOPT_FOLLOWLOCATION, 1)
|
|
354
|
+
self.setopt(CURLOPT_MAXREDIRS, 20)
|
|
355
|
+
self.setopt(CURLOPT_SSL_VERIFYPEER, 1)
|
|
356
|
+
self.setopt(CURLOPT_SSL_VERIFYHOST, 2)
|
|
357
|
+
self.setopt(CURLOPT_TIMEOUT, 30)
|
|
358
|
+
self.setopt(CURLOPT_CONNECTTIMEOUT, 10)
|
|
359
|
+
self.setopt(CURLOPT_DNS_CACHE_TIMEOUT, 300)
|
|
360
|
+
self.setopt(CURLOPT_TCP_KEEPALIVE, 1)
|
|
361
|
+
self.setopt(CURLOPT_NO_SIGNAL, 1)
|
|
362
|
+
self.setopt(CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS)
|
|
363
|
+
|
|
364
|
+
def duphandle(self) -> "CurlEasy":
|
|
365
|
+
"""Duplicate this handle (for session forking)."""
|
|
366
|
+
new = CurlEasy.__new__(CurlEasy)
|
|
367
|
+
new._handle = _lib.curl_easy_duphandle(self._handle)
|
|
368
|
+
if not new._handle:
|
|
369
|
+
raise RuntimeError("Failed to duplicate curl handle")
|
|
370
|
+
new._headers_list = None
|
|
371
|
+
new._write_callback = None
|
|
372
|
+
new._header_callback = None
|
|
373
|
+
new._write_buffer = bytearray()
|
|
374
|
+
new._header_buffer = bytearray()
|
|
375
|
+
return new
|
|
376
|
+
|
|
377
|
+
def strerror(self, code: int) -> str:
|
|
378
|
+
"""Get human-readable error string."""
|
|
379
|
+
msg = _lib.curl_easy_strerror(code)
|
|
380
|
+
return msg.decode("utf-8") if msg else f"Unknown error {code}"
|
|
381
|
+
|
|
382
|
+
def __del__(self):
|
|
383
|
+
if hasattr(self, "_handle") and self._handle:
|
|
384
|
+
if self._headers_list:
|
|
385
|
+
_lib.curl_slist_free_all(self._headers_list)
|
|
386
|
+
_lib.curl_easy_cleanup(self._handle)
|
|
387
|
+
self._handle = None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def curl_version() -> str:
|
|
391
|
+
"""Return the libcurl version string."""
|
|
392
|
+
v = _lib.curl_version()
|
|
393
|
+
return v.decode("utf-8") if v else "unknown"
|