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/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)