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/async_session.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async HTTP session using asyncio.
|
|
3
|
+
|
|
4
|
+
Provides the same cookie management guarantees as the sync Session,
|
|
5
|
+
but with async/await support using subprocess-based curl execution.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Optional, Union
|
|
12
|
+
from urllib.parse import urlencode, urljoin, urlparse
|
|
13
|
+
|
|
14
|
+
from .cookies import Cookie, CookieJar
|
|
15
|
+
from .impersonate import BrowserProfile, get_profile
|
|
16
|
+
from .models import Headers, PreparedRequest, Response
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyncSession:
|
|
20
|
+
"""
|
|
21
|
+
Async HTTP session with proper cookie management.
|
|
22
|
+
|
|
23
|
+
Same cookie fixes as sync Session:
|
|
24
|
+
- Cookies accumulate across requests
|
|
25
|
+
- Cookies survive redirects
|
|
26
|
+
- No manual header construction
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
impersonate: Optional[str] = None,
|
|
32
|
+
profile: Optional[BrowserProfile] = None,
|
|
33
|
+
proxies: Optional[dict] = None,
|
|
34
|
+
verify: bool = True,
|
|
35
|
+
timeout: Optional[float] = None,
|
|
36
|
+
cookies: Optional[dict] = None,
|
|
37
|
+
headers: Optional[dict] = None,
|
|
38
|
+
allow_redirects: bool = True,
|
|
39
|
+
max_redirects: int = 20,
|
|
40
|
+
):
|
|
41
|
+
self.profile: Optional[BrowserProfile] = None
|
|
42
|
+
if profile:
|
|
43
|
+
self.profile = profile
|
|
44
|
+
elif impersonate:
|
|
45
|
+
self.profile = get_profile(impersonate)
|
|
46
|
+
|
|
47
|
+
self._cookie_jar = CookieJar()
|
|
48
|
+
self._headers = Headers()
|
|
49
|
+
if self.profile and self.profile.http_headers:
|
|
50
|
+
self._headers.update(self.profile.http_headers)
|
|
51
|
+
if headers:
|
|
52
|
+
self._headers.update(headers)
|
|
53
|
+
|
|
54
|
+
if cookies:
|
|
55
|
+
for name, value in cookies.items():
|
|
56
|
+
self._cookie_jar.add(Cookie(
|
|
57
|
+
name=str(name), value=str(value),
|
|
58
|
+
domain="", path="/",
|
|
59
|
+
))
|
|
60
|
+
|
|
61
|
+
self.verify = verify
|
|
62
|
+
self.timeout = timeout or 30
|
|
63
|
+
self.allow_redirects = allow_redirects
|
|
64
|
+
self.max_redirects = max_redirects
|
|
65
|
+
self.proxies = proxies or {}
|
|
66
|
+
self._request_count = 0
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def cookies(self) -> CookieJar:
|
|
70
|
+
return self._cookie_jar
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def headers(self) -> Headers:
|
|
74
|
+
return self._headers
|
|
75
|
+
|
|
76
|
+
@headers.setter
|
|
77
|
+
def headers(self, value):
|
|
78
|
+
self._headers = Headers(value)
|
|
79
|
+
|
|
80
|
+
async def _exec_curl(self, args: list[str]) -> tuple[int, str, bytes, str]:
|
|
81
|
+
"""
|
|
82
|
+
Execute curl as a subprocess. This works without libcurl bindings.
|
|
83
|
+
|
|
84
|
+
Returns: (status_code, effective_url, body, raw_headers)
|
|
85
|
+
"""
|
|
86
|
+
proc = await asyncio.create_subprocess_exec(
|
|
87
|
+
*args,
|
|
88
|
+
stdout=asyncio.subprocess.PIPE,
|
|
89
|
+
stderr=asyncio.subprocess.PIPE,
|
|
90
|
+
)
|
|
91
|
+
stdout, stderr = await asyncio.wait_for(
|
|
92
|
+
proc.communicate(),
|
|
93
|
+
timeout=self.timeout + 5,
|
|
94
|
+
)
|
|
95
|
+
return proc.returncode, "", stdout, stderr.decode("utf-8", errors="replace")
|
|
96
|
+
|
|
97
|
+
async def _exec_curl_cffi(
|
|
98
|
+
self,
|
|
99
|
+
request: PreparedRequest,
|
|
100
|
+
) -> Response:
|
|
101
|
+
"""Execute request using curl_cffi if available (fallback)."""
|
|
102
|
+
try:
|
|
103
|
+
from curl_cffi.requests import AsyncSession as CFFIAsyncSession
|
|
104
|
+
except ImportError:
|
|
105
|
+
raise ConnectionError(
|
|
106
|
+
"Neither libcurl bindings nor curl_cffi are available. "
|
|
107
|
+
"Install one of: libcurl-dev, curl_cffi"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Use curl_cffi as a backend
|
|
111
|
+
async with CFFIAsyncSession() as s:
|
|
112
|
+
imp = self.profile.name if self.profile else "chrome131"
|
|
113
|
+
resp = await s.request(
|
|
114
|
+
method=request.method,
|
|
115
|
+
url=request.url,
|
|
116
|
+
headers=dict(request.headers),
|
|
117
|
+
data=request.prepare_body(),
|
|
118
|
+
impersonate=imp,
|
|
119
|
+
allow_redirects=self.allow_redirects,
|
|
120
|
+
timeout=self.timeout,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Build our Response and update cookies
|
|
124
|
+
response = Response(
|
|
125
|
+
status_code=resp.status_code,
|
|
126
|
+
headers=Headers(dict(resp.headers)),
|
|
127
|
+
body=resp.content,
|
|
128
|
+
url=str(resp.url),
|
|
129
|
+
request=request,
|
|
130
|
+
elapsed=0,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Parse cookies from curl_cffi response
|
|
134
|
+
for name, value in resp.cookies.items():
|
|
135
|
+
self._cookie_jar.add(Cookie(
|
|
136
|
+
name=name, value=value,
|
|
137
|
+
domain=urlparse(str(resp.url)).hostname or "",
|
|
138
|
+
path="/",
|
|
139
|
+
))
|
|
140
|
+
|
|
141
|
+
response.cookies = self._cookie_jar
|
|
142
|
+
self._request_count += 1
|
|
143
|
+
return response
|
|
144
|
+
|
|
145
|
+
async def _do_request(self, request: PreparedRequest) -> Response:
|
|
146
|
+
"""Execute a single request, handling cookies properly."""
|
|
147
|
+
# Inject cookies from our jar
|
|
148
|
+
cookie_header = self._cookie_jar.to_header(request.url)
|
|
149
|
+
if cookie_header:
|
|
150
|
+
request.headers["Cookie"] = cookie_header
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
return await self._exec_curl_cffi(request)
|
|
154
|
+
except ConnectionError:
|
|
155
|
+
# Fallback: use subprocess curl
|
|
156
|
+
return await self._exec_curl_subprocess(request)
|
|
157
|
+
|
|
158
|
+
async def _exec_curl_subprocess(self, request: PreparedRequest) -> Response:
|
|
159
|
+
"""Execute via subprocess curl as ultimate fallback."""
|
|
160
|
+
args = ["curl", "-s", "-S", "-D", "-"]
|
|
161
|
+
|
|
162
|
+
if not self.verify:
|
|
163
|
+
args.append("-k")
|
|
164
|
+
|
|
165
|
+
args.extend(["--max-time", str(self.timeout)])
|
|
166
|
+
args.extend(["--connect-timeout", str(min(self.timeout, 10))])
|
|
167
|
+
|
|
168
|
+
for key, value in request.headers.items():
|
|
169
|
+
args.extend(["-H", f"{key}: {value}"])
|
|
170
|
+
|
|
171
|
+
args.append("-X")
|
|
172
|
+
args.append(request.method)
|
|
173
|
+
args.append(request.url)
|
|
174
|
+
|
|
175
|
+
body = request.prepare_body()
|
|
176
|
+
if body:
|
|
177
|
+
args.extend(["-d", body.decode("utf-8", errors="replace")])
|
|
178
|
+
|
|
179
|
+
proc = await asyncio.create_subprocess_exec(
|
|
180
|
+
*args,
|
|
181
|
+
stdout=asyncio.subprocess.PIPE,
|
|
182
|
+
stderr=asyncio.subprocess.PIPE,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
stdout, stderr = await asyncio.wait_for(
|
|
187
|
+
proc.communicate(),
|
|
188
|
+
timeout=self.timeout + 5,
|
|
189
|
+
)
|
|
190
|
+
except asyncio.TimeoutError:
|
|
191
|
+
proc.kill()
|
|
192
|
+
raise Timeout(f"Request timed out: {request.url}")
|
|
193
|
+
|
|
194
|
+
output = stdout
|
|
195
|
+
# Parse headers from -D output
|
|
196
|
+
header_text = ""
|
|
197
|
+
body_data = output
|
|
198
|
+
|
|
199
|
+
# curl -D dumps headers to stdout mixed with body
|
|
200
|
+
# We need to split them
|
|
201
|
+
parts = output.split(b"\r\n\r\n", 1)
|
|
202
|
+
if len(parts) == 2:
|
|
203
|
+
header_text = parts[0].decode("utf-8", errors="replace")
|
|
204
|
+
body_data = parts[1]
|
|
205
|
+
|
|
206
|
+
headers = Headers()
|
|
207
|
+
status_code = 200
|
|
208
|
+
for line in header_text.split("\r\n"):
|
|
209
|
+
if line.startswith("HTTP/"):
|
|
210
|
+
parts2 = line.split(" ", 2)
|
|
211
|
+
if len(parts2) >= 2:
|
|
212
|
+
try:
|
|
213
|
+
status_code = int(parts2[1])
|
|
214
|
+
except ValueError:
|
|
215
|
+
pass
|
|
216
|
+
elif ":" in line:
|
|
217
|
+
key, _, value = line.partition(":")
|
|
218
|
+
headers[key.strip()] = value.strip()
|
|
219
|
+
|
|
220
|
+
response = Response(
|
|
221
|
+
status_code=status_code,
|
|
222
|
+
headers=headers,
|
|
223
|
+
body=body_data,
|
|
224
|
+
url=request.url,
|
|
225
|
+
request=request,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Update cookies
|
|
229
|
+
self._cookie_jar.parse_set_cookie_headers(headers, request.url)
|
|
230
|
+
response.cookies = self._cookie_jar
|
|
231
|
+
self._request_count += 1
|
|
232
|
+
return response
|
|
233
|
+
|
|
234
|
+
async def request(self, method: str, url: str, **kwargs) -> Response:
|
|
235
|
+
"""Send an async request with redirect handling."""
|
|
236
|
+
allow_redirects = kwargs.pop("allow_redirects", self.allow_redirects)
|
|
237
|
+
|
|
238
|
+
# Merge headers
|
|
239
|
+
merged = Headers(dict(self._headers))
|
|
240
|
+
if "headers" in kwargs:
|
|
241
|
+
merged.update(kwargs.pop("headers"))
|
|
242
|
+
|
|
243
|
+
# Build request
|
|
244
|
+
request = PreparedRequest(
|
|
245
|
+
method=method.upper(),
|
|
246
|
+
url=url,
|
|
247
|
+
headers=merged,
|
|
248
|
+
json_data=kwargs.pop("json", None),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
data = kwargs.pop("data", None)
|
|
252
|
+
if data:
|
|
253
|
+
if isinstance(data, (dict, list)):
|
|
254
|
+
request.body = urlencode(data, doseq=True).encode("utf-8")
|
|
255
|
+
elif isinstance(data, str):
|
|
256
|
+
request.body = data.encode("utf-8")
|
|
257
|
+
elif isinstance(data, bytes):
|
|
258
|
+
request.body = data
|
|
259
|
+
|
|
260
|
+
params = kwargs.pop("params", None)
|
|
261
|
+
if params:
|
|
262
|
+
sep = "&" if "?" in url else "?"
|
|
263
|
+
request.url = url + sep + urlencode(params, doseq=True)
|
|
264
|
+
|
|
265
|
+
cookies = kwargs.pop("cookies", None)
|
|
266
|
+
if cookies:
|
|
267
|
+
for name, value in cookies.items():
|
|
268
|
+
self._cookie_jar.add(Cookie(
|
|
269
|
+
name=str(name), value=str(value),
|
|
270
|
+
domain=urlparse(url).hostname or "", path="/",
|
|
271
|
+
))
|
|
272
|
+
|
|
273
|
+
response = await self._do_request(request)
|
|
274
|
+
|
|
275
|
+
# Handle redirects
|
|
276
|
+
history = []
|
|
277
|
+
redirect_count = 0
|
|
278
|
+
while (
|
|
279
|
+
allow_redirects
|
|
280
|
+
and response.is_redirect
|
|
281
|
+
and redirect_count < self.max_redirects
|
|
282
|
+
):
|
|
283
|
+
location = response.location
|
|
284
|
+
if not location:
|
|
285
|
+
break
|
|
286
|
+
|
|
287
|
+
history.append(response)
|
|
288
|
+
redirect_url = urljoin(response.url, location)
|
|
289
|
+
redirect_method = "GET" if response.status_code in (301, 302, 303) else method
|
|
290
|
+
|
|
291
|
+
redirect_request = PreparedRequest(
|
|
292
|
+
method=redirect_method,
|
|
293
|
+
url=redirect_url,
|
|
294
|
+
headers=Headers(dict(request.headers)),
|
|
295
|
+
)
|
|
296
|
+
redirect_request.headers.pop("Authorization", None)
|
|
297
|
+
redirect_request.headers.pop("Cookie", None)
|
|
298
|
+
|
|
299
|
+
response = await self._do_request(redirect_request)
|
|
300
|
+
redirect_count += 1
|
|
301
|
+
|
|
302
|
+
response.history = history
|
|
303
|
+
return response
|
|
304
|
+
|
|
305
|
+
async def get(self, url: str, **kwargs) -> Response:
|
|
306
|
+
return await self.request("GET", url, **kwargs)
|
|
307
|
+
|
|
308
|
+
async def post(self, url: str, **kwargs) -> Response:
|
|
309
|
+
return await self.request("POST", url, **kwargs)
|
|
310
|
+
|
|
311
|
+
async def put(self, url: str, **kwargs) -> Response:
|
|
312
|
+
return await self.request("PUT", url, **kwargs)
|
|
313
|
+
|
|
314
|
+
async def delete(self, url: str, **kwargs) -> Response:
|
|
315
|
+
return await self.request("DELETE", url, **kwargs)
|
|
316
|
+
|
|
317
|
+
async def patch(self, url: str, **kwargs) -> Response:
|
|
318
|
+
return await self.request("PATCH", url, **kwargs)
|
|
319
|
+
|
|
320
|
+
async def head(self, url: str, **kwargs) -> Response:
|
|
321
|
+
return await self.request("HEAD", url, **kwargs)
|
|
322
|
+
|
|
323
|
+
async def close(self):
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
async def __aenter__(self):
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
async def __aexit__(self, *args):
|
|
330
|
+
await self.close()
|
|
331
|
+
|
|
332
|
+
# Cookie convenience methods
|
|
333
|
+
def get_cookies(self, url: str) -> dict:
|
|
334
|
+
return self._cookie_jar.get_dict(url)
|
|
335
|
+
|
|
336
|
+
def set_cookie(self, name: str, value: str, domain: str = "", path: str = "/"):
|
|
337
|
+
self._cookie_jar.add(Cookie(name=name, value=value, domain=domain, path=path))
|
|
338
|
+
|
|
339
|
+
def clear_cookies(self):
|
|
340
|
+
self._cookie_jar.clear()
|
|
341
|
+
|
|
342
|
+
def save_cookies(self, filepath: str, format: str = "json"):
|
|
343
|
+
if format == "netscape":
|
|
344
|
+
self._cookie_jar.save_netscape(filepath)
|
|
345
|
+
else:
|
|
346
|
+
self._cookie_jar.save_json(filepath)
|
|
347
|
+
|
|
348
|
+
def load_cookies(self, filepath: str, format: str = "json"):
|
|
349
|
+
if format == "netscape":
|
|
350
|
+
self._cookie_jar.load_netscape(filepath)
|
|
351
|
+
else:
|
|
352
|
+
self._cookie_jar.load_json(filepath)
|