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