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/models.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Request and Response data models.
|
|
3
|
+
|
|
4
|
+
Clean, typed data structures for HTTP communication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json as _json
|
|
8
|
+
from typing import Any, Optional, Union
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Headers(dict):
|
|
13
|
+
"""
|
|
14
|
+
Case-insensitive HTTP headers container.
|
|
15
|
+
|
|
16
|
+
Mirrors the behavior of requests.structures.CaseInsensitiveDict
|
|
17
|
+
but without the dependency.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, data=None, **kwargs):
|
|
21
|
+
if data:
|
|
22
|
+
if isinstance(data, dict):
|
|
23
|
+
for key, value in data.items():
|
|
24
|
+
self.__setitem__(key, value)
|
|
25
|
+
elif isinstance(data, (list, tuple)):
|
|
26
|
+
for key, value in data:
|
|
27
|
+
self.__setitem__(key, value)
|
|
28
|
+
else:
|
|
29
|
+
for key, value in data.items():
|
|
30
|
+
self.__setitem__(key, value)
|
|
31
|
+
for key, value in kwargs.items():
|
|
32
|
+
self.__setitem__(key, value)
|
|
33
|
+
|
|
34
|
+
def __setitem__(self, key, value):
|
|
35
|
+
super().__setitem__(key.lower(), value)
|
|
36
|
+
# Store the original case as an attribute
|
|
37
|
+
if not hasattr(self, "_original_case"):
|
|
38
|
+
self._original_case = {}
|
|
39
|
+
self._original_case[key.lower()] = key
|
|
40
|
+
|
|
41
|
+
def __getitem__(self, key):
|
|
42
|
+
return super().__getitem__(key.lower())
|
|
43
|
+
|
|
44
|
+
def __contains__(self, key):
|
|
45
|
+
return super().__contains__(key.lower())
|
|
46
|
+
|
|
47
|
+
def get(self, key, default=None):
|
|
48
|
+
return super().get(key.lower(), default)
|
|
49
|
+
|
|
50
|
+
def get_original_key(self, key):
|
|
51
|
+
"""Get the original-case version of a key."""
|
|
52
|
+
if hasattr(self, "_original_case"):
|
|
53
|
+
return self._original_case.get(key.lower(), key)
|
|
54
|
+
return key
|
|
55
|
+
|
|
56
|
+
def to_original_case(self) -> dict:
|
|
57
|
+
"""Return a dict with original-case keys."""
|
|
58
|
+
result = {}
|
|
59
|
+
for key, value in self.items():
|
|
60
|
+
orig_key = self.get_original_key(key)
|
|
61
|
+
result[orig_key] = value
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
def title(self) -> "Headers":
|
|
65
|
+
"""Return headers with keys in Title-Case (for curl)."""
|
|
66
|
+
result = Headers()
|
|
67
|
+
for key, value in self.items():
|
|
68
|
+
title_key = "-".join(w.capitalize() for w in key.split("-"))
|
|
69
|
+
result[title_key] = value
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PreparedRequest:
|
|
74
|
+
"""
|
|
75
|
+
A fully prepared HTTP request, ready to be sent.
|
|
76
|
+
|
|
77
|
+
Contains everything needed to make the request: method, URL,
|
|
78
|
+
headers, body, and metadata.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
__slots__ = (
|
|
82
|
+
"method", "url", "headers", "body", "files",
|
|
83
|
+
"json_data", "params", "auth", "cookies",
|
|
84
|
+
"_parsed_url",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
method: str = "GET",
|
|
90
|
+
url: str = "",
|
|
91
|
+
headers: Optional[Union[dict, Headers]] = None,
|
|
92
|
+
body: Optional[Union[str, bytes]] = None,
|
|
93
|
+
json_data: Optional[Any] = None,
|
|
94
|
+
params: Optional[dict] = None,
|
|
95
|
+
auth: Optional[tuple] = None,
|
|
96
|
+
cookies: Optional[dict] = None,
|
|
97
|
+
):
|
|
98
|
+
self.method = method.upper()
|
|
99
|
+
self.url = url
|
|
100
|
+
self.headers = Headers(headers) if headers and not isinstance(headers, Headers) else (headers or Headers())
|
|
101
|
+
self.body = body
|
|
102
|
+
self.files = None
|
|
103
|
+
self.json_data = json_data
|
|
104
|
+
self.params = params
|
|
105
|
+
self.auth = auth
|
|
106
|
+
self.cookies = cookies or {}
|
|
107
|
+
self._parsed_url = None
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def parsed_url(self):
|
|
111
|
+
if self._parsed_url is None:
|
|
112
|
+
self._parsed_url = urlparse(self.url)
|
|
113
|
+
return self._parsed_url
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def scheme(self) -> str:
|
|
117
|
+
return self.parsed_url.scheme
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def host(self) -> str:
|
|
121
|
+
return self.parsed_url.hostname or ""
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def port(self) -> Optional[int]:
|
|
125
|
+
return self.parsed_url.port
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def path(self) -> str:
|
|
129
|
+
return self.parsed_url.path or "/"
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def full_path(self) -> str:
|
|
133
|
+
"""Path with query string."""
|
|
134
|
+
path = self.parsed_url.path or "/"
|
|
135
|
+
if self.parsed_url.query:
|
|
136
|
+
path += "?" + self.parsed_url.query
|
|
137
|
+
return path
|
|
138
|
+
|
|
139
|
+
def prepare_body(self) -> Optional[bytes]:
|
|
140
|
+
"""Prepare the request body for sending."""
|
|
141
|
+
if self.json_data is not None:
|
|
142
|
+
self.headers["Content-Type"] = "application/json"
|
|
143
|
+
return _json.dumps(self.json_data).encode("utf-8")
|
|
144
|
+
|
|
145
|
+
if self.body is not None:
|
|
146
|
+
if isinstance(self.body, str):
|
|
147
|
+
return self.body.encode("utf-8")
|
|
148
|
+
return self.body
|
|
149
|
+
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def prepare_url(self, base_url: str) -> str:
|
|
153
|
+
"""
|
|
154
|
+
Prepare the full URL, resolving relative paths against base_url.
|
|
155
|
+
"""
|
|
156
|
+
if self.url.startswith(("http://", "https://")):
|
|
157
|
+
return self.url
|
|
158
|
+
|
|
159
|
+
from urllib.parse import urljoin
|
|
160
|
+
return urljoin(base_url, self.url)
|
|
161
|
+
|
|
162
|
+
def clone(self) -> "PreparedRequest":
|
|
163
|
+
"""Create a deep copy of this request."""
|
|
164
|
+
return PreparedRequest(
|
|
165
|
+
method=self.method,
|
|
166
|
+
url=self.url,
|
|
167
|
+
headers=Headers(dict(self.headers)),
|
|
168
|
+
body=self.body,
|
|
169
|
+
json_data=self.json_data,
|
|
170
|
+
params=dict(self.params) if self.params else None,
|
|
171
|
+
auth=self.auth,
|
|
172
|
+
cookies=dict(self.cookies) if self.cookies else None,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def __repr__(self):
|
|
176
|
+
return f"PreparedRequest(method={self.method!r}, url={self.url!r})"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Response:
|
|
180
|
+
"""
|
|
181
|
+
An HTTP response object.
|
|
182
|
+
|
|
183
|
+
Provides access to status code, headers, body, cookies,
|
|
184
|
+
and convenience methods for content parsing.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
__slots__ = (
|
|
188
|
+
"status_code", "headers", "body", "url", "request",
|
|
189
|
+
"elapsed", "encoding", "cookies", "history",
|
|
190
|
+
"http_version", "ip", "port",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
status_code: int = 0,
|
|
196
|
+
headers: Optional[Union[dict, Headers]] = None,
|
|
197
|
+
body: bytes = b"",
|
|
198
|
+
url: str = "",
|
|
199
|
+
request: Optional[PreparedRequest] = None,
|
|
200
|
+
elapsed: float = 0.0,
|
|
201
|
+
cookies=None,
|
|
202
|
+
history: Optional[list] = None,
|
|
203
|
+
http_version: str = "1.1",
|
|
204
|
+
ip: str = "",
|
|
205
|
+
port: int = 0,
|
|
206
|
+
):
|
|
207
|
+
self.status_code = status_code
|
|
208
|
+
self.headers = Headers(headers) if headers and not isinstance(headers, Headers) else (headers or Headers())
|
|
209
|
+
self.body = body
|
|
210
|
+
self.url = url
|
|
211
|
+
self.request = request
|
|
212
|
+
self.elapsed = elapsed
|
|
213
|
+
self.encoding = self._guess_encoding()
|
|
214
|
+
self.cookies = cookies # Will be set by session
|
|
215
|
+
self.history = history or []
|
|
216
|
+
self.http_version = http_version
|
|
217
|
+
self.ip = ip
|
|
218
|
+
self.port = port
|
|
219
|
+
|
|
220
|
+
def _guess_encoding(self) -> str:
|
|
221
|
+
"""Guess encoding from Content-Type header."""
|
|
222
|
+
content_type = self.headers.get("content-type", "")
|
|
223
|
+
if "charset=" in content_type:
|
|
224
|
+
charset = content_type.split("charset=")[-1].split(";")[0].strip()
|
|
225
|
+
if charset:
|
|
226
|
+
return charset
|
|
227
|
+
|
|
228
|
+
# Check for BOM
|
|
229
|
+
if self.body.startswith(b'\xef\xbb\xbf'):
|
|
230
|
+
return 'utf-8-sig'
|
|
231
|
+
if self.body.startswith(b'\xff\xfe'):
|
|
232
|
+
return 'utf-16-le'
|
|
233
|
+
if self.body.startswith(b'\xfe\xff'):
|
|
234
|
+
return 'utf-16-be'
|
|
235
|
+
|
|
236
|
+
return 'utf-8'
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def ok(self) -> bool:
|
|
240
|
+
"""True if status_code < 400."""
|
|
241
|
+
return self.status_code < 400
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def is_redirect(self) -> bool:
|
|
245
|
+
"""True if this is a redirect response."""
|
|
246
|
+
return self.status_code in (301, 302, 303, 307, 308)
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def is_permanent_redirect(self) -> bool:
|
|
250
|
+
"""True if this is a permanent redirect."""
|
|
251
|
+
return self.status_code in (301, 308)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def location(self) -> Optional[str]:
|
|
255
|
+
"""Get the Location header for redirects."""
|
|
256
|
+
return self.headers.get("location")
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def content_type(self) -> Optional[str]:
|
|
260
|
+
"""Get the Content-Type header."""
|
|
261
|
+
return self.headers.get("content-type")
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def text(self) -> str:
|
|
265
|
+
"""Decoded text content."""
|
|
266
|
+
try:
|
|
267
|
+
return self.body.decode(self.encoding)
|
|
268
|
+
except (UnicodeDecodeError, LookupError):
|
|
269
|
+
return self.body.decode("utf-8", errors="replace")
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def json(self) -> Any:
|
|
273
|
+
"""Parse JSON response body."""
|
|
274
|
+
return _json.loads(self.body)
|
|
275
|
+
|
|
276
|
+
def iter_content(self, chunk_size: int = 8192):
|
|
277
|
+
"""Iterate over response body in chunks."""
|
|
278
|
+
for i in range(0, len(self.body), chunk_size):
|
|
279
|
+
yield self.body[i:i + chunk_size]
|
|
280
|
+
|
|
281
|
+
def iter_lines(self, chunk_size: int = 8192, encoding: Optional[str] = None):
|
|
282
|
+
"""Iterate over response body lines."""
|
|
283
|
+
enc = encoding or self.encoding
|
|
284
|
+
for chunk in self.iter_content(chunk_size):
|
|
285
|
+
for line in chunk.decode(enc, errors="replace").splitlines():
|
|
286
|
+
yield line
|
|
287
|
+
|
|
288
|
+
def raise_for_status(self) -> None:
|
|
289
|
+
"""Raise an exception if the status code indicates an error."""
|
|
290
|
+
if self.status_code >= 400:
|
|
291
|
+
raise HTTPError(
|
|
292
|
+
f"{self.status_code} {self.reason_phrase} for url: {self.url}",
|
|
293
|
+
response=self,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def reason_phrase(self) -> str:
|
|
298
|
+
"""Get a human-readable reason phrase for the status code."""
|
|
299
|
+
phrases = {
|
|
300
|
+
200: "OK", 201: "Created", 204: "No Content",
|
|
301
|
+
301: "Moved Permanently", 302: "Found", 303: "See Other",
|
|
302
|
+
304: "Not Modified", 307: "Temporary Redirect",
|
|
303
|
+
308: "Permanent Redirect",
|
|
304
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
305
|
+
404: "Not Found", 405: "Method Not Allowed",
|
|
306
|
+
429: "Too Many Requests",
|
|
307
|
+
500: "Internal Server Error", 502: "Bad Gateway",
|
|
308
|
+
503: "Service Unavailable",
|
|
309
|
+
}
|
|
310
|
+
return phrases.get(self.status_code, "Unknown")
|
|
311
|
+
|
|
312
|
+
def __repr__(self):
|
|
313
|
+
return f"<Response [{self.status_code}]>"
|
|
314
|
+
|
|
315
|
+
def __bool__(self):
|
|
316
|
+
return self.ok
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class HTTPError(Exception):
|
|
320
|
+
"""Exception raised for HTTP error responses."""
|
|
321
|
+
|
|
322
|
+
def __init__(self, message: str, response: Optional[Response] = None):
|
|
323
|
+
super().__init__(message)
|
|
324
|
+
self.response = response
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ConnectionError(Exception):
|
|
328
|
+
"""Exception raised for connection failures."""
|
|
329
|
+
pass
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class Timeout(Exception):
|
|
333
|
+
"""Exception raised when a request times out."""
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class TooManyRedirects(Exception):
|
|
338
|
+
"""Exception raised when redirect limit is exceeded."""
|
|
339
|
+
pass
|